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;
10use crate::renderer::camera::CameraBounds;
11
12/// Audio command queued from TS ops, drained by the frame callback.
13#[derive(Clone, Debug)]
14pub enum BridgeAudioCommand {
15    LoadSound { id: u32, path: String },
16    PlaySound { id: u32, volume: f32, looping: bool },
17    StopSound { id: u32 },
18    StopAll,
19    SetMasterVolume { volume: f32 },
20}
21
22/// Shared state between render ops and the main loop.
23/// This is placed into `OpState` when running in renderer mode.
24#[derive(Clone)]
25pub struct RenderBridgeState {
26    pub sprite_commands: Vec<SpriteCommand>,
27    pub camera_x: f32,
28    pub camera_y: f32,
29    pub camera_zoom: f32,
30    pub delta_time: f64,
31    /// Input state snapshot (updated each frame by the event loop).
32    pub keys_down: std::collections::HashSet<String>,
33    pub keys_pressed: std::collections::HashSet<String>,
34    pub mouse_x: f32,
35    pub mouse_y: f32,
36    /// Pending texture load requests (path → result channel).
37    pub texture_load_queue: Vec<(String, u32)>,
38    /// Base directory for resolving relative texture paths.
39    pub base_dir: PathBuf,
40    /// Next texture ID to assign (for pre-registration before GPU load).
41    pub next_texture_id: u32,
42    /// Map of path → already-assigned texture ID.
43    pub texture_path_to_id: std::collections::HashMap<String, u32>,
44    /// Tilemap storage (managed by tilemap ops).
45    pub tilemaps: TilemapStore,
46    /// Lighting: ambient color (0-1 per channel). Default white = no darkening.
47    pub ambient_light: [f32; 3],
48    /// Lighting: point lights for this frame.
49    pub point_lights: Vec<PointLight>,
50    /// Audio commands queued by TS, drained each frame.
51    pub audio_commands: Vec<BridgeAudioCommand>,
52    /// Next sound ID to assign.
53    pub next_sound_id: u32,
54    /// Map of sound path → assigned sound ID.
55    pub sound_path_to_id: std::collections::HashMap<String, u32>,
56    /// Font texture creation queue (texture IDs to create as built-in font).
57    pub font_texture_queue: Vec<u32>,
58    /// Current viewport dimensions in logical pixels (synced from renderer each frame).
59    pub viewport_width: f32,
60    pub viewport_height: f32,
61    /// Display scale factor (e.g. 2.0 on Retina).
62    pub scale_factor: f32,
63    /// Clear/background color [r, g, b, a] in 0.0-1.0 range.
64    pub clear_color: [f32; 4],
65    /// Directory for save files (.arcane/saves/ relative to game entry file).
66    pub save_dir: PathBuf,
67    /// Custom shader creation queue: (id, name, wgsl_source).
68    pub shader_create_queue: Vec<(u32, String, String)>,
69    /// Custom shader param updates: (shader_id, index, [x, y, z, w]).
70    pub shader_param_queue: Vec<(u32, u32, [f32; 4])>,
71    /// Next shader ID to assign.
72    pub next_shader_id: u32,
73    /// Post-process effect creation queue: (id, effect_type_name).
74    pub effect_create_queue: Vec<(u32, String)>,
75    /// Post-process effect param updates: (effect_id, index, [x, y, z, w]).
76    pub effect_param_queue: Vec<(u32, u32, [f32; 4])>,
77    /// Post-process effect removal queue.
78    pub effect_remove_queue: Vec<u32>,
79    /// Flag to clear all post-process effects.
80    pub effect_clear: bool,
81    /// Next effect ID to assign.
82    pub next_effect_id: u32,
83    /// Camera bounds (world-space limits).
84    pub camera_bounds: Option<CameraBounds>,
85}
86
87impl RenderBridgeState {
88    pub fn new(base_dir: PathBuf) -> Self {
89        let save_dir = base_dir.join(".arcane").join("saves");
90        Self {
91            sprite_commands: Vec::new(),
92            camera_x: 0.0,
93            camera_y: 0.0,
94            camera_zoom: 1.0,
95            delta_time: 0.0,
96            keys_down: std::collections::HashSet::new(),
97            keys_pressed: std::collections::HashSet::new(),
98            mouse_x: 0.0,
99            mouse_y: 0.0,
100            texture_load_queue: Vec::new(),
101            base_dir,
102            next_texture_id: 1,
103            texture_path_to_id: std::collections::HashMap::new(),
104            tilemaps: TilemapStore::new(),
105            ambient_light: [1.0, 1.0, 1.0],
106            point_lights: Vec::new(),
107            audio_commands: Vec::new(),
108            next_sound_id: 1,
109            sound_path_to_id: std::collections::HashMap::new(),
110            font_texture_queue: Vec::new(),
111            viewport_width: 800.0,
112            viewport_height: 600.0,
113            scale_factor: 1.0,
114            clear_color: [0.1, 0.1, 0.15, 1.0],
115            save_dir,
116            shader_create_queue: Vec::new(),
117            shader_param_queue: Vec::new(),
118            next_shader_id: 1,
119            effect_create_queue: Vec::new(),
120            effect_param_queue: Vec::new(),
121            effect_remove_queue: Vec::new(),
122            effect_clear: false,
123            next_effect_id: 1,
124            camera_bounds: None,
125        }
126    }
127}
128
129/// Queue a sprite draw command for this frame.
130/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
131#[deno_core::op2(fast)]
132pub fn op_draw_sprite(
133    state: &mut OpState,
134    texture_id: u32,
135    x: f64,
136    y: f64,
137    w: f64,
138    h: f64,
139    layer: i32,
140    uv_x: f64,
141    uv_y: f64,
142    uv_w: f64,
143    uv_h: f64,
144    tint_r: f64,
145    tint_g: f64,
146    tint_b: f64,
147    tint_a: f64,
148    rotation: f64,
149    origin_x: f64,
150    origin_y: f64,
151    flip_x: f64,
152    flip_y: f64,
153    opacity: f64,
154    blend_mode: f64,
155    shader_id: f64,
156) {
157    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
158    bridge.borrow_mut().sprite_commands.push(SpriteCommand {
159        texture_id,
160        x: x as f32,
161        y: y as f32,
162        w: w as f32,
163        h: h as f32,
164        layer,
165        uv_x: uv_x as f32,
166        uv_y: uv_y as f32,
167        uv_w: uv_w as f32,
168        uv_h: uv_h as f32,
169        tint_r: tint_r as f32,
170        tint_g: tint_g as f32,
171        tint_b: tint_b as f32,
172        tint_a: tint_a as f32,
173        rotation: rotation as f32,
174        origin_x: origin_x as f32,
175        origin_y: origin_y as f32,
176        flip_x: flip_x != 0.0,
177        flip_y: flip_y != 0.0,
178        opacity: opacity as f32,
179        blend_mode: (blend_mode as u8).min(3),
180        shader_id: shader_id as u32,
181    });
182}
183
184/// Clear all queued sprite commands for this frame.
185#[deno_core::op2(fast)]
186pub fn op_clear_sprites(state: &mut OpState) {
187    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
188    bridge.borrow_mut().sprite_commands.clear();
189}
190
191/// Update the camera position and zoom.
192/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
193#[deno_core::op2(fast)]
194pub fn op_set_camera(state: &mut OpState, x: f64, y: f64, zoom: f64) {
195    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
196    let mut b = bridge.borrow_mut();
197    b.camera_x = x as f32;
198    b.camera_y = y as f32;
199    b.camera_zoom = zoom as f32;
200}
201
202/// Get camera state as [x, y, zoom].
203#[deno_core::op2]
204#[serde]
205pub fn op_get_camera(state: &mut OpState) -> Vec<f64> {
206    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
207    let b = bridge.borrow();
208    vec![b.camera_x as f64, b.camera_y as f64, b.camera_zoom as f64]
209}
210
211/// Register a texture to be loaded. Returns a texture ID immediately.
212/// The actual GPU upload happens on the main thread before the next render.
213#[deno_core::op2(fast)]
214pub fn op_load_texture(state: &mut OpState, #[string] path: &str) -> u32 {
215    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
216    let mut b = bridge.borrow_mut();
217
218    // Resolve relative paths against base_dir
219    let resolved = if std::path::Path::new(path).is_absolute() {
220        path.to_string()
221    } else {
222        b.base_dir.join(path).to_string_lossy().to_string()
223    };
224
225    // Check cache
226    if let Some(&id) = b.texture_path_to_id.get(&resolved) {
227        return id;
228    }
229
230    let id = b.next_texture_id;
231    b.next_texture_id += 1;
232    b.texture_path_to_id.insert(resolved.clone(), id);
233    b.texture_load_queue.push((resolved, id));
234    id
235}
236
237/// Check if a key is currently held down.
238#[deno_core::op2(fast)]
239pub fn op_is_key_down(state: &mut OpState, #[string] key: &str) -> bool {
240    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
241    bridge.borrow().keys_down.contains(key)
242}
243
244/// Check if a key was pressed this frame.
245#[deno_core::op2(fast)]
246pub fn op_is_key_pressed(state: &mut OpState, #[string] key: &str) -> bool {
247    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
248    bridge.borrow().keys_pressed.contains(key)
249}
250
251/// Get mouse position as [x, y].
252#[deno_core::op2]
253#[serde]
254pub fn op_get_mouse_position(state: &mut OpState) -> Vec<f64> {
255    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
256    let b = bridge.borrow();
257    vec![b.mouse_x as f64, b.mouse_y as f64]
258}
259
260/// Get the delta time (seconds since last frame).
261#[deno_core::op2(fast)]
262pub fn op_get_delta_time(state: &mut OpState) -> f64 {
263    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
264    bridge.borrow().delta_time
265}
266
267/// Create a solid-color texture from TS. Returns texture ID.
268/// The actual GPU upload happens on the main thread.
269#[deno_core::op2(fast)]
270pub fn op_create_solid_texture(
271    state: &mut OpState,
272    #[string] name: &str,
273    r: u32,
274    g: u32,
275    b: u32,
276    a: u32,
277) -> u32 {
278    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
279    let mut br = bridge.borrow_mut();
280
281    let key = format!("__solid__{name}");
282    if let Some(&id) = br.texture_path_to_id.get(&key) {
283        return id;
284    }
285
286    let id = br.next_texture_id;
287    br.next_texture_id += 1;
288    br.texture_path_to_id.insert(key.clone(), id);
289    // Encode color in the path as a signal to the loader
290    br.texture_load_queue
291        .push((format!("__solid__:{name}:{r}:{g}:{b}:{a}"), id));
292    id
293}
294
295/// Create a tilemap. Returns tilemap ID.
296#[deno_core::op2(fast)]
297pub fn op_create_tilemap(
298    state: &mut OpState,
299    texture_id: u32,
300    width: u32,
301    height: u32,
302    tile_size: f64,
303    atlas_columns: u32,
304    atlas_rows: u32,
305) -> u32 {
306    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
307    bridge
308        .borrow_mut()
309        .tilemaps
310        .create(texture_id, width, height, tile_size as f32, atlas_columns, atlas_rows)
311}
312
313/// Set a tile in a tilemap.
314#[deno_core::op2(fast)]
315pub fn op_set_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32, tile_id: u32) {
316    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
317    if let Some(tm) = bridge.borrow_mut().tilemaps.get_mut(tilemap_id) {
318        tm.set_tile(gx, gy, tile_id as u16);
319    }
320}
321
322/// Get a tile from a tilemap.
323#[deno_core::op2(fast)]
324pub fn op_get_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32) -> u32 {
325    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
326    bridge
327        .borrow()
328        .tilemaps
329        .get(tilemap_id)
330        .map(|tm| tm.get_tile(gx, gy) as u32)
331        .unwrap_or(0)
332}
333
334/// Draw a tilemap's visible tiles as sprite commands (camera-culled).
335/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
336#[deno_core::op2(fast)]
337pub fn op_draw_tilemap(state: &mut OpState, tilemap_id: u32, world_x: f64, world_y: f64, layer: i32) {
338    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
339    let mut b = bridge.borrow_mut();
340    let cam_x = b.camera_x;
341    let cam_y = b.camera_y;
342    let cam_zoom = b.camera_zoom;
343    // Default viewport for culling; actual viewport is synced by renderer
344    let vp_w = 800.0;
345    let vp_h = 600.0;
346
347    if let Some(tm) = b.tilemaps.get(tilemap_id) {
348        let cmds = tm.bake_visible(world_x as f32, world_y as f32, layer, cam_x, cam_y, cam_zoom, vp_w, vp_h);
349        b.sprite_commands.extend(cmds);
350    }
351}
352
353// --- Lighting ops ---
354
355/// Set the ambient light color (0-1 per channel).
356/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
357#[deno_core::op2(fast)]
358pub fn op_set_ambient_light(state: &mut OpState, r: f64, g: f64, b: f64) {
359    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
360    bridge.borrow_mut().ambient_light = [r as f32, g as f32, b as f32];
361}
362
363/// Add a point light at world position (x,y) with radius, color, and intensity.
364/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
365#[deno_core::op2(fast)]
366pub fn op_add_point_light(
367    state: &mut OpState,
368    x: f64,
369    y: f64,
370    radius: f64,
371    r: f64,
372    g: f64,
373    b: f64,
374    intensity: f64,
375) {
376    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
377    bridge.borrow_mut().point_lights.push(PointLight {
378        x: x as f32,
379        y: y as f32,
380        radius: radius as f32,
381        r: r as f32,
382        g: g as f32,
383        b: b as f32,
384        intensity: intensity as f32,
385    });
386}
387
388/// Clear all point lights for this frame.
389#[deno_core::op2(fast)]
390pub fn op_clear_lights(state: &mut OpState) {
391    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
392    bridge.borrow_mut().point_lights.clear();
393}
394
395// --- Audio ops ---
396
397/// Load a sound file. Returns a sound ID.
398#[deno_core::op2(fast)]
399pub fn op_load_sound(state: &mut OpState, #[string] path: &str) -> u32 {
400    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
401    let mut b = bridge.borrow_mut();
402
403    let resolved = if std::path::Path::new(path).is_absolute() {
404        path.to_string()
405    } else {
406        b.base_dir.join(path).to_string_lossy().to_string()
407    };
408
409    if let Some(&id) = b.sound_path_to_id.get(&resolved) {
410        return id;
411    }
412
413    let id = b.next_sound_id;
414    b.next_sound_id += 1;
415    b.sound_path_to_id.insert(resolved.clone(), id);
416    b.audio_commands.push(BridgeAudioCommand::LoadSound { id, path: resolved });
417    id
418}
419
420/// Play a loaded sound.
421/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
422#[deno_core::op2(fast)]
423pub fn op_play_sound(state: &mut OpState, id: u32, volume: f64, looping: bool) {
424    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
425    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySound { id, volume: volume as f32, looping });
426}
427
428/// Stop a specific sound.
429#[deno_core::op2(fast)]
430pub fn op_stop_sound(state: &mut OpState, id: u32) {
431    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
432    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopSound { id });
433}
434
435/// Stop all sounds.
436#[deno_core::op2(fast)]
437pub fn op_stop_all_sounds(state: &mut OpState) {
438    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
439    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopAll);
440}
441
442/// Set the master volume.
443/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
444#[deno_core::op2(fast)]
445pub fn op_set_master_volume(state: &mut OpState, volume: f64) {
446    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
447    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetMasterVolume { volume: volume as f32 });
448}
449
450// --- Font ops ---
451
452/// Create the built-in font texture. Returns a texture ID.
453#[deno_core::op2(fast)]
454pub fn op_create_font_texture(state: &mut OpState) -> u32 {
455    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
456    let mut b = bridge.borrow_mut();
457
458    let key = "__builtin_font__".to_string();
459    if let Some(&id) = b.texture_path_to_id.get(&key) {
460        return id;
461    }
462
463    let id = b.next_texture_id;
464    b.next_texture_id += 1;
465    b.texture_path_to_id.insert(key, id);
466    b.font_texture_queue.push(id);
467    id
468}
469
470// --- Viewport ops ---
471
472/// Get the current viewport size as [width, height].
473#[deno_core::op2]
474#[serde]
475pub fn op_get_viewport_size(state: &mut OpState) -> Vec<f64> {
476    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
477    let b = bridge.borrow();
478    vec![b.viewport_width as f64, b.viewport_height as f64]
479}
480
481/// Get the display scale factor (e.g. 2.0 on Retina).
482#[deno_core::op2(fast)]
483pub fn op_get_scale_factor(state: &mut OpState) -> f64 {
484    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
485    bridge.borrow().scale_factor as f64
486}
487
488/// Set the background/clear color (r, g, b in 0.0-1.0 range).
489#[deno_core::op2(fast)]
490pub fn op_set_background_color(state: &mut OpState, r: f64, g: f64, b: f64) {
491    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
492    let mut br = bridge.borrow_mut();
493    br.clear_color = [r as f32, g as f32, b as f32, 1.0];
494}
495
496// --- File I/O ops (save/load) ---
497
498/// Write a save file. Returns true on success.
499#[deno_core::op2(fast)]
500pub fn op_save_file(state: &mut OpState, #[string] key: &str, #[string] value: &str) -> bool {
501    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
502    let save_dir = bridge.borrow().save_dir.clone();
503
504    // Sanitize key: only allow alphanumeric, underscore, dash
505    if !key.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
506        return false;
507    }
508
509    // Ensure save directory exists
510    if std::fs::create_dir_all(&save_dir).is_err() {
511        return false;
512    }
513
514    let path = save_dir.join(format!("{key}.json"));
515    std::fs::write(path, value).is_ok()
516}
517
518/// Load a save file. Returns the contents or empty string if not found.
519#[deno_core::op2]
520#[string]
521pub fn op_load_file(state: &mut OpState, #[string] key: &str) -> String {
522    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
523    let save_dir = bridge.borrow().save_dir.clone();
524
525    let path = save_dir.join(format!("{key}.json"));
526    std::fs::read_to_string(path).unwrap_or_default()
527}
528
529/// Delete a save file. Returns true on success.
530#[deno_core::op2(fast)]
531pub fn op_delete_file(state: &mut OpState, #[string] key: &str) -> bool {
532    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
533    let save_dir = bridge.borrow().save_dir.clone();
534
535    let path = save_dir.join(format!("{key}.json"));
536    std::fs::remove_file(path).is_ok()
537}
538
539/// List all save file keys (filenames without .json extension).
540#[deno_core::op2]
541#[serde]
542pub fn op_list_save_files(state: &mut OpState) -> Vec<String> {
543    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
544    let save_dir = bridge.borrow().save_dir.clone();
545
546    let mut keys = Vec::new();
547    if let Ok(entries) = std::fs::read_dir(&save_dir) {
548        for entry in entries.flatten() {
549            let path = entry.path();
550            if path.extension().map_or(false, |ext| ext == "json") {
551                if let Some(stem) = path.file_stem() {
552                    keys.push(stem.to_string_lossy().to_string());
553                }
554            }
555        }
556    }
557    keys.sort();
558    keys
559}
560
561// --- Shader ops ---
562
563/// Create a custom fragment shader from WGSL source. Returns a shader ID.
564#[deno_core::op2(fast)]
565pub fn op_create_shader(state: &mut OpState, #[string] name: &str, #[string] source: &str) -> u32 {
566    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
567    let mut b = bridge.borrow_mut();
568    let id = b.next_shader_id;
569    b.next_shader_id += 1;
570    b.shader_create_queue
571        .push((id, name.to_string(), source.to_string()));
572    id
573}
574
575/// Set a vec4 parameter slot on a custom shader. Index 0-15.
576#[deno_core::op2(fast)]
577pub fn op_set_shader_param(
578    state: &mut OpState,
579    shader_id: u32,
580    index: u32,
581    x: f64,
582    y: f64,
583    z: f64,
584    w: f64,
585) {
586    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
587    bridge.borrow_mut().shader_param_queue.push((
588        shader_id,
589        index,
590        [x as f32, y as f32, z as f32, w as f32],
591    ));
592}
593
594// --- Post-process effect ops ---
595
596/// Add a post-process effect. Returns an effect ID.
597#[deno_core::op2(fast)]
598pub fn op_add_effect(state: &mut OpState, #[string] effect_type: &str) -> u32 {
599    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
600    let mut b = bridge.borrow_mut();
601    let id = b.next_effect_id;
602    b.next_effect_id += 1;
603    b.effect_create_queue
604        .push((id, effect_type.to_string()));
605    id
606}
607
608/// Set a vec4 parameter slot on a post-process effect. Index 0-3.
609#[deno_core::op2(fast)]
610pub fn op_set_effect_param(
611    state: &mut OpState,
612    effect_id: u32,
613    index: u32,
614    x: f64,
615    y: f64,
616    z: f64,
617    w: f64,
618) {
619    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
620    bridge.borrow_mut().effect_param_queue.push((
621        effect_id,
622        index,
623        [x as f32, y as f32, z as f32, w as f32],
624    ));
625}
626
627/// Remove a single post-process effect by ID.
628#[deno_core::op2(fast)]
629pub fn op_remove_effect(state: &mut OpState, effect_id: u32) {
630    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
631    bridge.borrow_mut().effect_remove_queue.push(effect_id);
632}
633
634/// Remove all post-process effects.
635#[deno_core::op2(fast)]
636pub fn op_clear_effects(state: &mut OpState) {
637    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
638    bridge.borrow_mut().effect_clear = true;
639}
640
641// --- Camera bounds ops ---
642
643/// Set camera bounds (world-space limits).
644#[deno_core::op2(fast)]
645pub fn op_set_camera_bounds(state: &mut OpState, min_x: f64, min_y: f64, max_x: f64, max_y: f64) {
646    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
647    bridge.borrow_mut().camera_bounds = Some(CameraBounds {
648        min_x: min_x as f32,
649        min_y: min_y as f32,
650        max_x: max_x as f32,
651        max_y: max_y as f32,
652    });
653}
654
655/// Clear camera bounds (no limits).
656#[deno_core::op2(fast)]
657pub fn op_clear_camera_bounds(state: &mut OpState) {
658    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
659    bridge.borrow_mut().camera_bounds = None;
660}
661
662/// Get camera bounds as [minX, minY, maxX, maxY] or empty if none.
663#[deno_core::op2]
664#[serde]
665pub fn op_get_camera_bounds(state: &mut OpState) -> Vec<f64> {
666    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
667    let b = bridge.borrow();
668    match b.camera_bounds {
669        Some(bounds) => vec![
670            bounds.min_x as f64,
671            bounds.min_y as f64,
672            bounds.max_x as f64,
673            bounds.max_y as f64,
674        ],
675        None => vec![],
676    }
677}
678
679deno_core::extension!(
680    render_ext,
681    ops = [
682        op_draw_sprite,
683        op_clear_sprites,
684        op_set_camera,
685        op_get_camera,
686        op_load_texture,
687        op_is_key_down,
688        op_is_key_pressed,
689        op_get_mouse_position,
690        op_get_delta_time,
691        op_create_solid_texture,
692        op_create_tilemap,
693        op_set_tile,
694        op_get_tile,
695        op_draw_tilemap,
696        op_set_ambient_light,
697        op_add_point_light,
698        op_clear_lights,
699        op_load_sound,
700        op_play_sound,
701        op_stop_sound,
702        op_stop_all_sounds,
703        op_set_master_volume,
704        op_create_font_texture,
705        op_get_viewport_size,
706        op_get_scale_factor,
707        op_set_background_color,
708        op_save_file,
709        op_load_file,
710        op_delete_file,
711        op_list_save_files,
712        op_create_shader,
713        op_set_shader_param,
714        op_add_effect,
715        op_set_effect_param,
716        op_remove_effect,
717        op_clear_effects,
718        op_set_camera_bounds,
719        op_clear_camera_bounds,
720        op_get_camera_bounds,
721    ],
722);