Skip to main content

arcane_engine/scripting/
render_ops.rs

1use std::cell::RefCell;
2use std::path::PathBuf;
3use std::rc::Rc;
4
5use deno_core::OpState;
6
7use crate::renderer::SpriteCommand;
8use crate::renderer::TilemapStore;
9use crate::renderer::PointLight;
10
11/// Audio command queued from TS ops, drained by the frame callback.
12#[derive(Clone, Debug)]
13pub enum BridgeAudioCommand {
14    LoadSound { id: u32, path: String },
15    PlaySound { id: u32, volume: f32, looping: bool },
16    StopSound { id: u32 },
17    StopAll,
18    SetMasterVolume { volume: f32 },
19}
20
21/// Shared state between render ops and the main loop.
22/// This is placed into `OpState` when running in renderer mode.
23#[derive(Clone)]
24pub struct RenderBridgeState {
25    pub sprite_commands: Vec<SpriteCommand>,
26    pub camera_x: f32,
27    pub camera_y: f32,
28    pub camera_zoom: f32,
29    pub delta_time: f64,
30    /// Input state snapshot (updated each frame by the event loop).
31    pub keys_down: std::collections::HashSet<String>,
32    pub keys_pressed: std::collections::HashSet<String>,
33    pub mouse_x: f32,
34    pub mouse_y: f32,
35    /// Pending texture load requests (path → result channel).
36    pub texture_load_queue: Vec<(String, u32)>,
37    /// Base directory for resolving relative texture paths.
38    pub base_dir: PathBuf,
39    /// Next texture ID to assign (for pre-registration before GPU load).
40    pub next_texture_id: u32,
41    /// Map of path → already-assigned texture ID.
42    pub texture_path_to_id: std::collections::HashMap<String, u32>,
43    /// Tilemap storage (managed by tilemap ops).
44    pub tilemaps: TilemapStore,
45    /// Lighting: ambient color (0-1 per channel). Default white = no darkening.
46    pub ambient_light: [f32; 3],
47    /// Lighting: point lights for this frame.
48    pub point_lights: Vec<PointLight>,
49    /// Audio commands queued by TS, drained each frame.
50    pub audio_commands: Vec<BridgeAudioCommand>,
51    /// Next sound ID to assign.
52    pub next_sound_id: u32,
53    /// Map of sound path → assigned sound ID.
54    pub sound_path_to_id: std::collections::HashMap<String, u32>,
55    /// Font texture creation queue (texture IDs to create as built-in font).
56    pub font_texture_queue: Vec<u32>,
57    /// Current viewport dimensions (synced from renderer each frame).
58    pub viewport_width: f32,
59    pub viewport_height: f32,
60    /// Directory for save files (.arcane/saves/ relative to game entry file).
61    pub save_dir: PathBuf,
62}
63
64impl RenderBridgeState {
65    pub fn new(base_dir: PathBuf) -> Self {
66        let save_dir = base_dir.join(".arcane").join("saves");
67        Self {
68            sprite_commands: Vec::new(),
69            camera_x: 0.0,
70            camera_y: 0.0,
71            camera_zoom: 1.0,
72            delta_time: 0.0,
73            keys_down: std::collections::HashSet::new(),
74            keys_pressed: std::collections::HashSet::new(),
75            mouse_x: 0.0,
76            mouse_y: 0.0,
77            texture_load_queue: Vec::new(),
78            base_dir,
79            next_texture_id: 1,
80            texture_path_to_id: std::collections::HashMap::new(),
81            tilemaps: TilemapStore::new(),
82            ambient_light: [1.0, 1.0, 1.0],
83            point_lights: Vec::new(),
84            audio_commands: Vec::new(),
85            next_sound_id: 1,
86            sound_path_to_id: std::collections::HashMap::new(),
87            font_texture_queue: Vec::new(),
88            viewport_width: 800.0,
89            viewport_height: 600.0,
90            save_dir,
91        }
92    }
93}
94
95/// Queue a sprite draw command for this frame.
96/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
97#[deno_core::op2(fast)]
98pub fn op_draw_sprite(
99    state: &mut OpState,
100    texture_id: u32,
101    x: f64,
102    y: f64,
103    w: f64,
104    h: f64,
105    layer: i32,
106    uv_x: f64,
107    uv_y: f64,
108    uv_w: f64,
109    uv_h: f64,
110    tint_r: f64,
111    tint_g: f64,
112    tint_b: f64,
113    tint_a: f64,
114) {
115    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
116    bridge.borrow_mut().sprite_commands.push(SpriteCommand {
117        texture_id,
118        x: x as f32,
119        y: y as f32,
120        w: w as f32,
121        h: h as f32,
122        layer,
123        uv_x: uv_x as f32,
124        uv_y: uv_y as f32,
125        uv_w: uv_w as f32,
126        uv_h: uv_h as f32,
127        tint_r: tint_r as f32,
128        tint_g: tint_g as f32,
129        tint_b: tint_b as f32,
130        tint_a: tint_a as f32,
131    });
132}
133
134/// Clear all queued sprite commands for this frame.
135#[deno_core::op2(fast)]
136pub fn op_clear_sprites(state: &mut OpState) {
137    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
138    bridge.borrow_mut().sprite_commands.clear();
139}
140
141/// Update the camera position and zoom.
142/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
143#[deno_core::op2(fast)]
144pub fn op_set_camera(state: &mut OpState, x: f64, y: f64, zoom: f64) {
145    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
146    let mut b = bridge.borrow_mut();
147    b.camera_x = x as f32;
148    b.camera_y = y as f32;
149    b.camera_zoom = zoom as f32;
150}
151
152/// Get camera state as [x, y, zoom].
153#[deno_core::op2]
154#[serde]
155pub fn op_get_camera(state: &mut OpState) -> Vec<f64> {
156    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
157    let b = bridge.borrow();
158    vec![b.camera_x as f64, b.camera_y as f64, b.camera_zoom as f64]
159}
160
161/// Register a texture to be loaded. Returns a texture ID immediately.
162/// The actual GPU upload happens on the main thread before the next render.
163#[deno_core::op2(fast)]
164pub fn op_load_texture(state: &mut OpState, #[string] path: &str) -> u32 {
165    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
166    let mut b = bridge.borrow_mut();
167
168    // Resolve relative paths against base_dir
169    let resolved = if std::path::Path::new(path).is_absolute() {
170        path.to_string()
171    } else {
172        b.base_dir.join(path).to_string_lossy().to_string()
173    };
174
175    // Check cache
176    if let Some(&id) = b.texture_path_to_id.get(&resolved) {
177        return id;
178    }
179
180    let id = b.next_texture_id;
181    b.next_texture_id += 1;
182    b.texture_path_to_id.insert(resolved.clone(), id);
183    b.texture_load_queue.push((resolved, id));
184    id
185}
186
187/// Check if a key is currently held down.
188#[deno_core::op2(fast)]
189pub fn op_is_key_down(state: &mut OpState, #[string] key: &str) -> bool {
190    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
191    bridge.borrow().keys_down.contains(key)
192}
193
194/// Check if a key was pressed this frame.
195#[deno_core::op2(fast)]
196pub fn op_is_key_pressed(state: &mut OpState, #[string] key: &str) -> bool {
197    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
198    bridge.borrow().keys_pressed.contains(key)
199}
200
201/// Get mouse position as [x, y].
202#[deno_core::op2]
203#[serde]
204pub fn op_get_mouse_position(state: &mut OpState) -> Vec<f64> {
205    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
206    let b = bridge.borrow();
207    vec![b.mouse_x as f64, b.mouse_y as f64]
208}
209
210/// Get the delta time (seconds since last frame).
211#[deno_core::op2(fast)]
212pub fn op_get_delta_time(state: &mut OpState) -> f64 {
213    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
214    bridge.borrow().delta_time
215}
216
217/// Create a solid-color texture from TS. Returns texture ID.
218/// The actual GPU upload happens on the main thread.
219#[deno_core::op2(fast)]
220pub fn op_create_solid_texture(
221    state: &mut OpState,
222    #[string] name: &str,
223    r: u32,
224    g: u32,
225    b: u32,
226    a: u32,
227) -> u32 {
228    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
229    let mut br = bridge.borrow_mut();
230
231    let key = format!("__solid__{name}");
232    if let Some(&id) = br.texture_path_to_id.get(&key) {
233        return id;
234    }
235
236    let id = br.next_texture_id;
237    br.next_texture_id += 1;
238    br.texture_path_to_id.insert(key.clone(), id);
239    // Encode color in the path as a signal to the loader
240    br.texture_load_queue
241        .push((format!("__solid__:{name}:{r}:{g}:{b}:{a}"), id));
242    id
243}
244
245/// Create a tilemap. Returns tilemap ID.
246#[deno_core::op2(fast)]
247pub fn op_create_tilemap(
248    state: &mut OpState,
249    texture_id: u32,
250    width: u32,
251    height: u32,
252    tile_size: f64,
253    atlas_columns: u32,
254    atlas_rows: u32,
255) -> u32 {
256    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
257    bridge
258        .borrow_mut()
259        .tilemaps
260        .create(texture_id, width, height, tile_size as f32, atlas_columns, atlas_rows)
261}
262
263/// Set a tile in a tilemap.
264#[deno_core::op2(fast)]
265pub fn op_set_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32, tile_id: u32) {
266    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
267    if let Some(tm) = bridge.borrow_mut().tilemaps.get_mut(tilemap_id) {
268        tm.set_tile(gx, gy, tile_id as u16);
269    }
270}
271
272/// Get a tile from a tilemap.
273#[deno_core::op2(fast)]
274pub fn op_get_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32) -> u32 {
275    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
276    bridge
277        .borrow()
278        .tilemaps
279        .get(tilemap_id)
280        .map(|tm| tm.get_tile(gx, gy) as u32)
281        .unwrap_or(0)
282}
283
284/// Draw a tilemap's visible tiles as sprite commands (camera-culled).
285/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
286#[deno_core::op2(fast)]
287pub fn op_draw_tilemap(state: &mut OpState, tilemap_id: u32, world_x: f64, world_y: f64, layer: i32) {
288    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
289    let mut b = bridge.borrow_mut();
290    let cam_x = b.camera_x;
291    let cam_y = b.camera_y;
292    let cam_zoom = b.camera_zoom;
293    // Default viewport for culling; actual viewport is synced by renderer
294    let vp_w = 800.0;
295    let vp_h = 600.0;
296
297    if let Some(tm) = b.tilemaps.get(tilemap_id) {
298        let cmds = tm.bake_visible(world_x as f32, world_y as f32, layer, cam_x, cam_y, cam_zoom, vp_w, vp_h);
299        b.sprite_commands.extend(cmds);
300    }
301}
302
303// --- Lighting ops ---
304
305/// Set the ambient light color (0-1 per channel).
306/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
307#[deno_core::op2(fast)]
308pub fn op_set_ambient_light(state: &mut OpState, r: f64, g: f64, b: f64) {
309    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
310    bridge.borrow_mut().ambient_light = [r as f32, g as f32, b as f32];
311}
312
313/// Add a point light at world position (x,y) with radius, color, and intensity.
314/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
315#[deno_core::op2(fast)]
316pub fn op_add_point_light(
317    state: &mut OpState,
318    x: f64,
319    y: f64,
320    radius: f64,
321    r: f64,
322    g: f64,
323    b: f64,
324    intensity: f64,
325) {
326    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
327    bridge.borrow_mut().point_lights.push(PointLight {
328        x: x as f32,
329        y: y as f32,
330        radius: radius as f32,
331        r: r as f32,
332        g: g as f32,
333        b: b as f32,
334        intensity: intensity as f32,
335    });
336}
337
338/// Clear all point lights for this frame.
339#[deno_core::op2(fast)]
340pub fn op_clear_lights(state: &mut OpState) {
341    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
342    bridge.borrow_mut().point_lights.clear();
343}
344
345// --- Audio ops ---
346
347/// Load a sound file. Returns a sound ID.
348#[deno_core::op2(fast)]
349pub fn op_load_sound(state: &mut OpState, #[string] path: &str) -> u32 {
350    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
351    let mut b = bridge.borrow_mut();
352
353    let resolved = if std::path::Path::new(path).is_absolute() {
354        path.to_string()
355    } else {
356        b.base_dir.join(path).to_string_lossy().to_string()
357    };
358
359    if let Some(&id) = b.sound_path_to_id.get(&resolved) {
360        return id;
361    }
362
363    let id = b.next_sound_id;
364    b.next_sound_id += 1;
365    b.sound_path_to_id.insert(resolved.clone(), id);
366    b.audio_commands.push(BridgeAudioCommand::LoadSound { id, path: resolved });
367    id
368}
369
370/// Play a loaded sound.
371/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
372#[deno_core::op2(fast)]
373pub fn op_play_sound(state: &mut OpState, id: u32, volume: f64, looping: bool) {
374    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
375    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySound { id, volume: volume as f32, looping });
376}
377
378/// Stop a specific sound.
379#[deno_core::op2(fast)]
380pub fn op_stop_sound(state: &mut OpState, id: u32) {
381    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
382    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopSound { id });
383}
384
385/// Stop all sounds.
386#[deno_core::op2(fast)]
387pub fn op_stop_all_sounds(state: &mut OpState) {
388    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
389    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopAll);
390}
391
392/// Set the master volume.
393/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
394#[deno_core::op2(fast)]
395pub fn op_set_master_volume(state: &mut OpState, volume: f64) {
396    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
397    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetMasterVolume { volume: volume as f32 });
398}
399
400// --- Font ops ---
401
402/// Create the built-in font texture. Returns a texture ID.
403#[deno_core::op2(fast)]
404pub fn op_create_font_texture(state: &mut OpState) -> u32 {
405    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
406    let mut b = bridge.borrow_mut();
407
408    let key = "__builtin_font__".to_string();
409    if let Some(&id) = b.texture_path_to_id.get(&key) {
410        return id;
411    }
412
413    let id = b.next_texture_id;
414    b.next_texture_id += 1;
415    b.texture_path_to_id.insert(key, id);
416    b.font_texture_queue.push(id);
417    id
418}
419
420// --- Viewport ops ---
421
422/// Get the current viewport size as [width, height].
423#[deno_core::op2]
424#[serde]
425pub fn op_get_viewport_size(state: &mut OpState) -> Vec<f64> {
426    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
427    let b = bridge.borrow();
428    vec![b.viewport_width as f64, b.viewport_height as f64]
429}
430
431// --- File I/O ops (save/load) ---
432
433/// Write a save file. Returns true on success.
434#[deno_core::op2(fast)]
435pub fn op_save_file(state: &mut OpState, #[string] key: &str, #[string] value: &str) -> bool {
436    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
437    let save_dir = bridge.borrow().save_dir.clone();
438
439    // Sanitize key: only allow alphanumeric, underscore, dash
440    if !key.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
441        return false;
442    }
443
444    // Ensure save directory exists
445    if std::fs::create_dir_all(&save_dir).is_err() {
446        return false;
447    }
448
449    let path = save_dir.join(format!("{key}.json"));
450    std::fs::write(path, value).is_ok()
451}
452
453/// Load a save file. Returns the contents or empty string if not found.
454#[deno_core::op2]
455#[string]
456pub fn op_load_file(state: &mut OpState, #[string] key: &str) -> String {
457    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
458    let save_dir = bridge.borrow().save_dir.clone();
459
460    let path = save_dir.join(format!("{key}.json"));
461    std::fs::read_to_string(path).unwrap_or_default()
462}
463
464/// Delete a save file. Returns true on success.
465#[deno_core::op2(fast)]
466pub fn op_delete_file(state: &mut OpState, #[string] key: &str) -> bool {
467    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
468    let save_dir = bridge.borrow().save_dir.clone();
469
470    let path = save_dir.join(format!("{key}.json"));
471    std::fs::remove_file(path).is_ok()
472}
473
474/// List all save file keys (filenames without .json extension).
475#[deno_core::op2]
476#[serde]
477pub fn op_list_save_files(state: &mut OpState) -> Vec<String> {
478    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
479    let save_dir = bridge.borrow().save_dir.clone();
480
481    let mut keys = Vec::new();
482    if let Ok(entries) = std::fs::read_dir(&save_dir) {
483        for entry in entries.flatten() {
484            let path = entry.path();
485            if path.extension().map_or(false, |ext| ext == "json") {
486                if let Some(stem) = path.file_stem() {
487                    keys.push(stem.to_string_lossy().to_string());
488                }
489            }
490        }
491    }
492    keys.sort();
493    keys
494}
495
496deno_core::extension!(
497    render_ext,
498    ops = [
499        op_draw_sprite,
500        op_clear_sprites,
501        op_set_camera,
502        op_get_camera,
503        op_load_texture,
504        op_is_key_down,
505        op_is_key_pressed,
506        op_get_mouse_position,
507        op_get_delta_time,
508        op_create_solid_texture,
509        op_create_tilemap,
510        op_set_tile,
511        op_get_tile,
512        op_draw_tilemap,
513        op_set_ambient_light,
514        op_add_point_light,
515        op_clear_lights,
516        op_load_sound,
517        op_play_sound,
518        op_stop_sound,
519        op_stop_all_sounds,
520        op_set_master_volume,
521        op_create_font_texture,
522        op_get_viewport_size,
523        op_save_file,
524        op_load_file,
525        op_delete_file,
526        op_list_save_files,
527    ],
528);