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