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}
61
62impl RenderBridgeState {
63    pub fn new(base_dir: PathBuf) -> Self {
64        Self {
65            sprite_commands: Vec::new(),
66            camera_x: 0.0,
67            camera_y: 0.0,
68            camera_zoom: 1.0,
69            delta_time: 0.0,
70            keys_down: std::collections::HashSet::new(),
71            keys_pressed: std::collections::HashSet::new(),
72            mouse_x: 0.0,
73            mouse_y: 0.0,
74            texture_load_queue: Vec::new(),
75            base_dir,
76            next_texture_id: 1,
77            texture_path_to_id: std::collections::HashMap::new(),
78            tilemaps: TilemapStore::new(),
79            ambient_light: [1.0, 1.0, 1.0],
80            point_lights: Vec::new(),
81            audio_commands: Vec::new(),
82            next_sound_id: 1,
83            sound_path_to_id: std::collections::HashMap::new(),
84            font_texture_queue: Vec::new(),
85            viewport_width: 800.0,
86            viewport_height: 600.0,
87        }
88    }
89}
90
91/// Queue a sprite draw command for this frame.
92/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
93#[deno_core::op2(fast)]
94pub fn op_draw_sprite(
95    state: &mut OpState,
96    texture_id: u32,
97    x: f64,
98    y: f64,
99    w: f64,
100    h: f64,
101    layer: i32,
102    uv_x: f64,
103    uv_y: f64,
104    uv_w: f64,
105    uv_h: f64,
106    tint_r: f64,
107    tint_g: f64,
108    tint_b: f64,
109    tint_a: f64,
110) {
111    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
112    bridge.borrow_mut().sprite_commands.push(SpriteCommand {
113        texture_id,
114        x: x as f32,
115        y: y as f32,
116        w: w as f32,
117        h: h as f32,
118        layer,
119        uv_x: uv_x as f32,
120        uv_y: uv_y as f32,
121        uv_w: uv_w as f32,
122        uv_h: uv_h as f32,
123        tint_r: tint_r as f32,
124        tint_g: tint_g as f32,
125        tint_b: tint_b as f32,
126        tint_a: tint_a as f32,
127    });
128}
129
130/// Clear all queued sprite commands for this frame.
131#[deno_core::op2(fast)]
132pub fn op_clear_sprites(state: &mut OpState) {
133    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
134    bridge.borrow_mut().sprite_commands.clear();
135}
136
137/// Update the camera position and zoom.
138/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
139#[deno_core::op2(fast)]
140pub fn op_set_camera(state: &mut OpState, x: f64, y: f64, zoom: f64) {
141    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
142    let mut b = bridge.borrow_mut();
143    b.camera_x = x as f32;
144    b.camera_y = y as f32;
145    b.camera_zoom = zoom as f32;
146}
147
148/// Get camera state as [x, y, zoom].
149#[deno_core::op2]
150#[serde]
151pub fn op_get_camera(state: &mut OpState) -> Vec<f64> {
152    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
153    let b = bridge.borrow();
154    vec![b.camera_x as f64, b.camera_y as f64, b.camera_zoom as f64]
155}
156
157/// Register a texture to be loaded. Returns a texture ID immediately.
158/// The actual GPU upload happens on the main thread before the next render.
159#[deno_core::op2(fast)]
160pub fn op_load_texture(state: &mut OpState, #[string] path: &str) -> u32 {
161    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
162    let mut b = bridge.borrow_mut();
163
164    // Resolve relative paths against base_dir
165    let resolved = if std::path::Path::new(path).is_absolute() {
166        path.to_string()
167    } else {
168        b.base_dir.join(path).to_string_lossy().to_string()
169    };
170
171    // Check cache
172    if let Some(&id) = b.texture_path_to_id.get(&resolved) {
173        return id;
174    }
175
176    let id = b.next_texture_id;
177    b.next_texture_id += 1;
178    b.texture_path_to_id.insert(resolved.clone(), id);
179    b.texture_load_queue.push((resolved, id));
180    id
181}
182
183/// Check if a key is currently held down.
184#[deno_core::op2(fast)]
185pub fn op_is_key_down(state: &mut OpState, #[string] key: &str) -> bool {
186    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
187    bridge.borrow().keys_down.contains(key)
188}
189
190/// Check if a key was pressed this frame.
191#[deno_core::op2(fast)]
192pub fn op_is_key_pressed(state: &mut OpState, #[string] key: &str) -> bool {
193    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
194    bridge.borrow().keys_pressed.contains(key)
195}
196
197/// Get mouse position as [x, y].
198#[deno_core::op2]
199#[serde]
200pub fn op_get_mouse_position(state: &mut OpState) -> Vec<f64> {
201    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
202    let b = bridge.borrow();
203    vec![b.mouse_x as f64, b.mouse_y as f64]
204}
205
206/// Get the delta time (seconds since last frame).
207#[deno_core::op2(fast)]
208pub fn op_get_delta_time(state: &mut OpState) -> f64 {
209    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
210    bridge.borrow().delta_time
211}
212
213/// Create a solid-color texture from TS. Returns texture ID.
214/// The actual GPU upload happens on the main thread.
215#[deno_core::op2(fast)]
216pub fn op_create_solid_texture(
217    state: &mut OpState,
218    #[string] name: &str,
219    r: u32,
220    g: u32,
221    b: u32,
222    a: u32,
223) -> u32 {
224    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
225    let mut br = bridge.borrow_mut();
226
227    let key = format!("__solid__{name}");
228    if let Some(&id) = br.texture_path_to_id.get(&key) {
229        return id;
230    }
231
232    let id = br.next_texture_id;
233    br.next_texture_id += 1;
234    br.texture_path_to_id.insert(key.clone(), id);
235    // Encode color in the path as a signal to the loader
236    br.texture_load_queue
237        .push((format!("__solid__:{name}:{r}:{g}:{b}:{a}"), id));
238    id
239}
240
241/// Create a tilemap. Returns tilemap ID.
242#[deno_core::op2(fast)]
243pub fn op_create_tilemap(
244    state: &mut OpState,
245    texture_id: u32,
246    width: u32,
247    height: u32,
248    tile_size: f64,
249    atlas_columns: u32,
250    atlas_rows: u32,
251) -> u32 {
252    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
253    bridge
254        .borrow_mut()
255        .tilemaps
256        .create(texture_id, width, height, tile_size as f32, atlas_columns, atlas_rows)
257}
258
259/// Set a tile in a tilemap.
260#[deno_core::op2(fast)]
261pub fn op_set_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32, tile_id: u32) {
262    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
263    if let Some(tm) = bridge.borrow_mut().tilemaps.get_mut(tilemap_id) {
264        tm.set_tile(gx, gy, tile_id as u16);
265    }
266}
267
268/// Get a tile from a tilemap.
269#[deno_core::op2(fast)]
270pub fn op_get_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32) -> u32 {
271    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
272    bridge
273        .borrow()
274        .tilemaps
275        .get(tilemap_id)
276        .map(|tm| tm.get_tile(gx, gy) as u32)
277        .unwrap_or(0)
278}
279
280/// Draw a tilemap's visible tiles as sprite commands (camera-culled).
281/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
282#[deno_core::op2(fast)]
283pub fn op_draw_tilemap(state: &mut OpState, tilemap_id: u32, world_x: f64, world_y: f64, layer: i32) {
284    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
285    let mut b = bridge.borrow_mut();
286    let cam_x = b.camera_x;
287    let cam_y = b.camera_y;
288    let cam_zoom = b.camera_zoom;
289    // Default viewport for culling; actual viewport is synced by renderer
290    let vp_w = 800.0;
291    let vp_h = 600.0;
292
293    if let Some(tm) = b.tilemaps.get(tilemap_id) {
294        let cmds = tm.bake_visible(world_x as f32, world_y as f32, layer, cam_x, cam_y, cam_zoom, vp_w, vp_h);
295        b.sprite_commands.extend(cmds);
296    }
297}
298
299// --- Lighting ops ---
300
301/// Set the ambient light color (0-1 per channel).
302/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
303#[deno_core::op2(fast)]
304pub fn op_set_ambient_light(state: &mut OpState, r: f64, g: f64, b: f64) {
305    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
306    bridge.borrow_mut().ambient_light = [r as f32, g as f32, b as f32];
307}
308
309/// Add a point light at world position (x,y) with radius, color, and intensity.
310/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
311#[deno_core::op2(fast)]
312pub fn op_add_point_light(
313    state: &mut OpState,
314    x: f64,
315    y: f64,
316    radius: f64,
317    r: f64,
318    g: f64,
319    b: f64,
320    intensity: f64,
321) {
322    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
323    bridge.borrow_mut().point_lights.push(PointLight {
324        x: x as f32,
325        y: y as f32,
326        radius: radius as f32,
327        r: r as f32,
328        g: g as f32,
329        b: b as f32,
330        intensity: intensity as f32,
331    });
332}
333
334/// Clear all point lights for this frame.
335#[deno_core::op2(fast)]
336pub fn op_clear_lights(state: &mut OpState) {
337    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
338    bridge.borrow_mut().point_lights.clear();
339}
340
341// --- Audio ops ---
342
343/// Load a sound file. Returns a sound ID.
344#[deno_core::op2(fast)]
345pub fn op_load_sound(state: &mut OpState, #[string] path: &str) -> u32 {
346    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
347    let mut b = bridge.borrow_mut();
348
349    let resolved = if std::path::Path::new(path).is_absolute() {
350        path.to_string()
351    } else {
352        b.base_dir.join(path).to_string_lossy().to_string()
353    };
354
355    if let Some(&id) = b.sound_path_to_id.get(&resolved) {
356        return id;
357    }
358
359    let id = b.next_sound_id;
360    b.next_sound_id += 1;
361    b.sound_path_to_id.insert(resolved.clone(), id);
362    b.audio_commands.push(BridgeAudioCommand::LoadSound { id, path: resolved });
363    id
364}
365
366/// Play a loaded sound.
367/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
368#[deno_core::op2(fast)]
369pub fn op_play_sound(state: &mut OpState, id: u32, volume: f64, looping: bool) {
370    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
371    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySound { id, volume: volume as f32, looping });
372}
373
374/// Stop a specific sound.
375#[deno_core::op2(fast)]
376pub fn op_stop_sound(state: &mut OpState, id: u32) {
377    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
378    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopSound { id });
379}
380
381/// Stop all sounds.
382#[deno_core::op2(fast)]
383pub fn op_stop_all_sounds(state: &mut OpState) {
384    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
385    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopAll);
386}
387
388/// Set the master volume.
389/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
390#[deno_core::op2(fast)]
391pub fn op_set_master_volume(state: &mut OpState, volume: f64) {
392    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
393    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetMasterVolume { volume: volume as f32 });
394}
395
396// --- Font ops ---
397
398/// Create the built-in font texture. Returns a texture ID.
399#[deno_core::op2(fast)]
400pub fn op_create_font_texture(state: &mut OpState) -> u32 {
401    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
402    let mut b = bridge.borrow_mut();
403
404    let key = "__builtin_font__".to_string();
405    if let Some(&id) = b.texture_path_to_id.get(&key) {
406        return id;
407    }
408
409    let id = b.next_texture_id;
410    b.next_texture_id += 1;
411    b.texture_path_to_id.insert(key, id);
412    b.font_texture_queue.push(id);
413    id
414}
415
416// --- Viewport ops ---
417
418/// Get the current viewport size as [width, height].
419#[deno_core::op2]
420#[serde]
421pub fn op_get_viewport_size(state: &mut OpState) -> Vec<f64> {
422    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
423    let b = bridge.borrow();
424    vec![b.viewport_width as f64, b.viewport_height as f64]
425}
426
427deno_core::extension!(
428    render_ext,
429    ops = [
430        op_draw_sprite,
431        op_clear_sprites,
432        op_set_camera,
433        op_get_camera,
434        op_load_texture,
435        op_is_key_down,
436        op_is_key_pressed,
437        op_get_mouse_position,
438        op_get_delta_time,
439        op_create_solid_texture,
440        op_create_tilemap,
441        op_set_tile,
442        op_get_tile,
443        op_draw_tilemap,
444        op_set_ambient_light,
445        op_add_point_light,
446        op_clear_lights,
447        op_load_sound,
448        op_play_sound,
449        op_stop_sound,
450        op_stop_all_sounds,
451        op_set_master_volume,
452        op_create_font_texture,
453        op_get_viewport_size,
454    ],
455);