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;
11use crate::renderer::msdf::MsdfFontStore;
12
13/// Audio command queued from TS ops, drained by the frame callback.
14#[derive(Clone, Debug)]
15pub enum BridgeAudioCommand {
16    LoadSound { id: u32, path: String },
17    PlaySound { id: u32, volume: f32, looping: bool },
18    StopSound { id: u32 },
19    StopAll,
20    SetMasterVolume { volume: f32 },
21}
22
23/// Shared state between render ops and the main loop.
24/// This is placed into `OpState` when running in renderer mode.
25#[derive(Clone)]
26pub struct RenderBridgeState {
27    pub sprite_commands: Vec<SpriteCommand>,
28    pub camera_x: f32,
29    pub camera_y: f32,
30    pub camera_zoom: f32,
31    pub delta_time: f64,
32    /// Input state snapshot (updated each frame by the event loop).
33    pub keys_down: std::collections::HashSet<String>,
34    pub keys_pressed: std::collections::HashSet<String>,
35    pub mouse_x: f32,
36    pub mouse_y: f32,
37    /// Pending texture load requests (path → result channel).
38    pub texture_load_queue: Vec<(String, u32)>,
39    /// Base directory for resolving relative texture paths.
40    pub base_dir: PathBuf,
41    /// Next texture ID to assign (for pre-registration before GPU load).
42    pub next_texture_id: u32,
43    /// Map of path → already-assigned texture ID.
44    pub texture_path_to_id: std::collections::HashMap<String, u32>,
45    /// Tilemap storage (managed by tilemap ops).
46    pub tilemaps: TilemapStore,
47    /// Lighting: ambient color (0-1 per channel). Default white = no darkening.
48    pub ambient_light: [f32; 3],
49    /// Lighting: point lights for this frame.
50    pub point_lights: Vec<PointLight>,
51    /// Audio commands queued by TS, drained each frame.
52    pub audio_commands: Vec<BridgeAudioCommand>,
53    /// Next sound ID to assign.
54    pub next_sound_id: u32,
55    /// Map of sound path → assigned sound ID.
56    pub sound_path_to_id: std::collections::HashMap<String, u32>,
57    /// Font texture creation queue (texture IDs to create as built-in font).
58    pub font_texture_queue: Vec<u32>,
59    /// Current viewport dimensions in logical pixels (synced from renderer each frame).
60    pub viewport_width: f32,
61    pub viewport_height: f32,
62    /// Display scale factor (e.g. 2.0 on Retina).
63    pub scale_factor: f32,
64    /// Clear/background color [r, g, b, a] in 0.0-1.0 range.
65    pub clear_color: [f32; 4],
66    /// Directory for save files (.arcane/saves/ relative to game entry file).
67    pub save_dir: PathBuf,
68    /// Custom shader creation queue: (id, name, wgsl_source).
69    pub shader_create_queue: Vec<(u32, String, String)>,
70    /// Custom shader param updates: (shader_id, index, [x, y, z, w]).
71    pub shader_param_queue: Vec<(u32, u32, [f32; 4])>,
72    /// Next shader ID to assign.
73    pub next_shader_id: u32,
74    /// Post-process effect creation queue: (id, effect_type_name).
75    pub effect_create_queue: Vec<(u32, String)>,
76    /// Post-process effect param updates: (effect_id, index, [x, y, z, w]).
77    pub effect_param_queue: Vec<(u32, u32, [f32; 4])>,
78    /// Post-process effect removal queue.
79    pub effect_remove_queue: Vec<u32>,
80    /// Flag to clear all post-process effects.
81    pub effect_clear: bool,
82    /// Next effect ID to assign.
83    pub next_effect_id: u32,
84    /// Camera bounds (world-space limits).
85    pub camera_bounds: Option<CameraBounds>,
86    /// Whether global illumination (radiance cascades) is enabled.
87    pub gi_enabled: bool,
88    /// GI intensity multiplier.
89    pub gi_intensity: f32,
90    /// GI probe spacing override (None = default 8).
91    pub gi_probe_spacing: Option<f32>,
92    /// GI interval override (None = default 4).
93    pub gi_interval: Option<f32>,
94    /// GI cascade count override (None = default 4).
95    pub gi_cascade_count: Option<u32>,
96    /// Emissive surfaces for GI: (x, y, w, h, r, g, b, intensity).
97    pub emissives: Vec<[f32; 8]>,
98    /// Occluders for GI: (x, y, w, h).
99    pub occluders: Vec<[f32; 4]>,
100    /// Directional lights: (angle, r, g, b, intensity).
101    pub directional_lights: Vec<[f32; 5]>,
102    /// Spot lights: (x, y, angle, spread, range, r, g, b, intensity).
103    pub spot_lights: Vec<[f32; 9]>,
104    /// MSDF font storage.
105    pub msdf_fonts: MsdfFontStore,
106    /// Queue for creating built-in MSDF font: (font_id, texture_id).
107    pub msdf_builtin_queue: Vec<(u32, u32)>,
108    /// Queue for creating MSDF shader: (shader_id, wgsl_source).
109    pub msdf_shader_queue: Vec<(u32, String)>,
110    /// Pool of MSDF shader IDs (same WGSL, separate uniform buffers for per-draw-call params).
111    pub msdf_shader_pool: Vec<u32>,
112    /// Pending MSDF texture loads (needs linear sampling, not sRGB).
113    pub msdf_texture_load_queue: Vec<(String, u32)>,
114}
115
116impl RenderBridgeState {
117    pub fn new(base_dir: PathBuf) -> Self {
118        let save_dir = base_dir.join(".arcane").join("saves");
119        Self {
120            sprite_commands: Vec::new(),
121            camera_x: 0.0,
122            camera_y: 0.0,
123            camera_zoom: 1.0,
124            delta_time: 0.0,
125            keys_down: std::collections::HashSet::new(),
126            keys_pressed: std::collections::HashSet::new(),
127            mouse_x: 0.0,
128            mouse_y: 0.0,
129            texture_load_queue: Vec::new(),
130            base_dir,
131            next_texture_id: 1,
132            texture_path_to_id: std::collections::HashMap::new(),
133            tilemaps: TilemapStore::new(),
134            ambient_light: [1.0, 1.0, 1.0],
135            point_lights: Vec::new(),
136            audio_commands: Vec::new(),
137            next_sound_id: 1,
138            sound_path_to_id: std::collections::HashMap::new(),
139            font_texture_queue: Vec::new(),
140            viewport_width: 800.0,
141            viewport_height: 600.0,
142            scale_factor: 1.0,
143            clear_color: [0.1, 0.1, 0.15, 1.0],
144            save_dir,
145            shader_create_queue: Vec::new(),
146            shader_param_queue: Vec::new(),
147            next_shader_id: 1,
148            effect_create_queue: Vec::new(),
149            effect_param_queue: Vec::new(),
150            effect_remove_queue: Vec::new(),
151            effect_clear: false,
152            next_effect_id: 1,
153            camera_bounds: None,
154            gi_enabled: false,
155            gi_intensity: 1.0,
156            gi_probe_spacing: None,
157            gi_interval: None,
158            gi_cascade_count: None,
159            emissives: Vec::new(),
160            occluders: Vec::new(),
161            directional_lights: Vec::new(),
162            spot_lights: Vec::new(),
163            msdf_fonts: MsdfFontStore::new(),
164            msdf_builtin_queue: Vec::new(),
165            msdf_shader_queue: Vec::new(),
166            msdf_shader_pool: Vec::new(),
167            msdf_texture_load_queue: Vec::new(),
168        }
169    }
170}
171
172/// Queue a sprite draw command for this frame.
173/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
174#[deno_core::op2(fast)]
175pub fn op_draw_sprite(
176    state: &mut OpState,
177    texture_id: u32,
178    x: f64,
179    y: f64,
180    w: f64,
181    h: f64,
182    layer: i32,
183    uv_x: f64,
184    uv_y: f64,
185    uv_w: f64,
186    uv_h: f64,
187    tint_r: f64,
188    tint_g: f64,
189    tint_b: f64,
190    tint_a: f64,
191    rotation: f64,
192    origin_x: f64,
193    origin_y: f64,
194    flip_x: f64,
195    flip_y: f64,
196    opacity: f64,
197    blend_mode: f64,
198    shader_id: f64,
199) {
200    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
201    bridge.borrow_mut().sprite_commands.push(SpriteCommand {
202        texture_id,
203        x: x as f32,
204        y: y as f32,
205        w: w as f32,
206        h: h as f32,
207        layer,
208        uv_x: uv_x as f32,
209        uv_y: uv_y as f32,
210        uv_w: uv_w as f32,
211        uv_h: uv_h as f32,
212        tint_r: tint_r as f32,
213        tint_g: tint_g as f32,
214        tint_b: tint_b as f32,
215        tint_a: tint_a as f32,
216        rotation: rotation as f32,
217        origin_x: origin_x as f32,
218        origin_y: origin_y as f32,
219        flip_x: flip_x != 0.0,
220        flip_y: flip_y != 0.0,
221        opacity: opacity as f32,
222        blend_mode: (blend_mode as u8).min(3),
223        shader_id: shader_id as u32,
224    });
225}
226
227/// Clear all queued sprite commands for this frame.
228#[deno_core::op2(fast)]
229pub fn op_clear_sprites(state: &mut OpState) {
230    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
231    bridge.borrow_mut().sprite_commands.clear();
232}
233
234/// Update the camera position and zoom.
235/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
236#[deno_core::op2(fast)]
237pub fn op_set_camera(state: &mut OpState, x: f64, y: f64, zoom: f64) {
238    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
239    let mut b = bridge.borrow_mut();
240    b.camera_x = x as f32;
241    b.camera_y = y as f32;
242    b.camera_zoom = zoom as f32;
243}
244
245/// Get camera state as [x, y, zoom].
246#[deno_core::op2]
247#[serde]
248pub fn op_get_camera(state: &mut OpState) -> Vec<f64> {
249    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
250    let b = bridge.borrow();
251    vec![b.camera_x as f64, b.camera_y as f64, b.camera_zoom as f64]
252}
253
254/// Register a texture to be loaded. Returns a texture ID immediately.
255/// The actual GPU upload happens on the main thread before the next render.
256#[deno_core::op2(fast)]
257pub fn op_load_texture(state: &mut OpState, #[string] path: &str) -> u32 {
258    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
259    let mut b = bridge.borrow_mut();
260
261    // Resolve relative paths against base_dir
262    let resolved = if std::path::Path::new(path).is_absolute() {
263        path.to_string()
264    } else {
265        b.base_dir.join(path).to_string_lossy().to_string()
266    };
267
268    // Check cache
269    if let Some(&id) = b.texture_path_to_id.get(&resolved) {
270        return id;
271    }
272
273    let id = b.next_texture_id;
274    b.next_texture_id += 1;
275    b.texture_path_to_id.insert(resolved.clone(), id);
276    b.texture_load_queue.push((resolved, id));
277    id
278}
279
280/// Check if a key is currently held down.
281#[deno_core::op2(fast)]
282pub fn op_is_key_down(state: &mut OpState, #[string] key: &str) -> bool {
283    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
284    bridge.borrow().keys_down.contains(key)
285}
286
287/// Check if a key was pressed this frame.
288#[deno_core::op2(fast)]
289pub fn op_is_key_pressed(state: &mut OpState, #[string] key: &str) -> bool {
290    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
291    bridge.borrow().keys_pressed.contains(key)
292}
293
294/// Get mouse position as [x, y].
295#[deno_core::op2]
296#[serde]
297pub fn op_get_mouse_position(state: &mut OpState) -> Vec<f64> {
298    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
299    let b = bridge.borrow();
300    vec![b.mouse_x as f64, b.mouse_y as f64]
301}
302
303/// Get the delta time (seconds since last frame).
304#[deno_core::op2(fast)]
305pub fn op_get_delta_time(state: &mut OpState) -> f64 {
306    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
307    bridge.borrow().delta_time
308}
309
310/// Create a solid-color texture from TS. Returns texture ID.
311/// The actual GPU upload happens on the main thread.
312#[deno_core::op2(fast)]
313pub fn op_create_solid_texture(
314    state: &mut OpState,
315    #[string] name: &str,
316    r: u32,
317    g: u32,
318    b: u32,
319    a: u32,
320) -> u32 {
321    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
322    let mut br = bridge.borrow_mut();
323
324    let key = format!("__solid__{name}");
325    if let Some(&id) = br.texture_path_to_id.get(&key) {
326        return id;
327    }
328
329    let id = br.next_texture_id;
330    br.next_texture_id += 1;
331    br.texture_path_to_id.insert(key.clone(), id);
332    // Encode color in the path as a signal to the loader
333    br.texture_load_queue
334        .push((format!("__solid__:{name}:{r}:{g}:{b}:{a}"), id));
335    id
336}
337
338/// Create a tilemap. Returns tilemap ID.
339#[deno_core::op2(fast)]
340pub fn op_create_tilemap(
341    state: &mut OpState,
342    texture_id: u32,
343    width: u32,
344    height: u32,
345    tile_size: f64,
346    atlas_columns: u32,
347    atlas_rows: u32,
348) -> u32 {
349    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
350    bridge
351        .borrow_mut()
352        .tilemaps
353        .create(texture_id, width, height, tile_size as f32, atlas_columns, atlas_rows)
354}
355
356/// Set a tile in a tilemap.
357#[deno_core::op2(fast)]
358pub fn op_set_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32, tile_id: u32) {
359    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
360    if let Some(tm) = bridge.borrow_mut().tilemaps.get_mut(tilemap_id) {
361        tm.set_tile(gx, gy, tile_id as u16);
362    }
363}
364
365/// Get a tile from a tilemap.
366#[deno_core::op2(fast)]
367pub fn op_get_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32) -> u32 {
368    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
369    bridge
370        .borrow()
371        .tilemaps
372        .get(tilemap_id)
373        .map(|tm| tm.get_tile(gx, gy) as u32)
374        .unwrap_or(0)
375}
376
377/// Draw a tilemap's visible tiles as sprite commands (camera-culled).
378/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
379#[deno_core::op2(fast)]
380pub fn op_draw_tilemap(state: &mut OpState, tilemap_id: u32, world_x: f64, world_y: f64, layer: i32) {
381    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
382    let mut b = bridge.borrow_mut();
383    let cam_x = b.camera_x;
384    let cam_y = b.camera_y;
385    let cam_zoom = b.camera_zoom;
386    // Default viewport for culling; actual viewport is synced by renderer
387    let vp_w = 800.0;
388    let vp_h = 600.0;
389
390    if let Some(tm) = b.tilemaps.get(tilemap_id) {
391        let cmds = tm.bake_visible(world_x as f32, world_y as f32, layer, cam_x, cam_y, cam_zoom, vp_w, vp_h);
392        b.sprite_commands.extend(cmds);
393    }
394}
395
396// --- Lighting ops ---
397
398/// Set the ambient light color (0-1 per channel).
399/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
400#[deno_core::op2(fast)]
401pub fn op_set_ambient_light(state: &mut OpState, r: f64, g: f64, b: f64) {
402    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
403    bridge.borrow_mut().ambient_light = [r as f32, g as f32, b as f32];
404}
405
406/// Add a point light at world position (x,y) with radius, color, and intensity.
407/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
408#[deno_core::op2(fast)]
409pub fn op_add_point_light(
410    state: &mut OpState,
411    x: f64,
412    y: f64,
413    radius: f64,
414    r: f64,
415    g: f64,
416    b: f64,
417    intensity: f64,
418) {
419    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
420    bridge.borrow_mut().point_lights.push(PointLight {
421        x: x as f32,
422        y: y as f32,
423        radius: radius as f32,
424        r: r as f32,
425        g: g as f32,
426        b: b as f32,
427        intensity: intensity as f32,
428    });
429}
430
431/// Clear all point lights for this frame.
432#[deno_core::op2(fast)]
433pub fn op_clear_lights(state: &mut OpState) {
434    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
435    bridge.borrow_mut().point_lights.clear();
436}
437
438// --- Audio ops ---
439
440/// Load a sound file. Returns a sound ID.
441#[deno_core::op2(fast)]
442pub fn op_load_sound(state: &mut OpState, #[string] path: &str) -> u32 {
443    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
444    let mut b = bridge.borrow_mut();
445
446    let resolved = if std::path::Path::new(path).is_absolute() {
447        path.to_string()
448    } else {
449        b.base_dir.join(path).to_string_lossy().to_string()
450    };
451
452    if let Some(&id) = b.sound_path_to_id.get(&resolved) {
453        return id;
454    }
455
456    let id = b.next_sound_id;
457    b.next_sound_id += 1;
458    b.sound_path_to_id.insert(resolved.clone(), id);
459    b.audio_commands.push(BridgeAudioCommand::LoadSound { id, path: resolved });
460    id
461}
462
463/// Play a loaded sound.
464/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
465#[deno_core::op2(fast)]
466pub fn op_play_sound(state: &mut OpState, id: u32, volume: f64, looping: bool) {
467    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
468    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySound { id, volume: volume as f32, looping });
469}
470
471/// Stop a specific sound.
472#[deno_core::op2(fast)]
473pub fn op_stop_sound(state: &mut OpState, id: u32) {
474    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
475    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopSound { id });
476}
477
478/// Stop all sounds.
479#[deno_core::op2(fast)]
480pub fn op_stop_all_sounds(state: &mut OpState) {
481    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
482    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopAll);
483}
484
485/// Set the master volume.
486/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
487#[deno_core::op2(fast)]
488pub fn op_set_master_volume(state: &mut OpState, volume: f64) {
489    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
490    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetMasterVolume { volume: volume as f32 });
491}
492
493// --- Font ops ---
494
495/// Create the built-in font texture. Returns a texture ID.
496#[deno_core::op2(fast)]
497pub fn op_create_font_texture(state: &mut OpState) -> u32 {
498    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
499    let mut b = bridge.borrow_mut();
500
501    let key = "__builtin_font__".to_string();
502    if let Some(&id) = b.texture_path_to_id.get(&key) {
503        return id;
504    }
505
506    let id = b.next_texture_id;
507    b.next_texture_id += 1;
508    b.texture_path_to_id.insert(key, id);
509    b.font_texture_queue.push(id);
510    id
511}
512
513// --- Viewport ops ---
514
515/// Get the current viewport size as [width, height].
516#[deno_core::op2]
517#[serde]
518pub fn op_get_viewport_size(state: &mut OpState) -> Vec<f64> {
519    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
520    let b = bridge.borrow();
521    vec![b.viewport_width as f64, b.viewport_height as f64]
522}
523
524/// Get the display scale factor (e.g. 2.0 on Retina).
525#[deno_core::op2(fast)]
526pub fn op_get_scale_factor(state: &mut OpState) -> f64 {
527    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
528    bridge.borrow().scale_factor as f64
529}
530
531/// Set the background/clear color (r, g, b in 0.0-1.0 range).
532#[deno_core::op2(fast)]
533pub fn op_set_background_color(state: &mut OpState, r: f64, g: f64, b: f64) {
534    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
535    let mut br = bridge.borrow_mut();
536    br.clear_color = [r as f32, g as f32, b as f32, 1.0];
537}
538
539// --- File I/O ops (save/load) ---
540
541/// Write a save file. Returns true on success.
542#[deno_core::op2(fast)]
543pub fn op_save_file(state: &mut OpState, #[string] key: &str, #[string] value: &str) -> bool {
544    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
545    let save_dir = bridge.borrow().save_dir.clone();
546
547    // Sanitize key: only allow alphanumeric, underscore, dash
548    if !key.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
549        return false;
550    }
551
552    // Ensure save directory exists
553    if std::fs::create_dir_all(&save_dir).is_err() {
554        return false;
555    }
556
557    let path = save_dir.join(format!("{key}.json"));
558    std::fs::write(path, value).is_ok()
559}
560
561/// Load a save file. Returns the contents or empty string if not found.
562#[deno_core::op2]
563#[string]
564pub fn op_load_file(state: &mut OpState, #[string] key: &str) -> String {
565    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
566    let save_dir = bridge.borrow().save_dir.clone();
567
568    let path = save_dir.join(format!("{key}.json"));
569    std::fs::read_to_string(path).unwrap_or_default()
570}
571
572/// Delete a save file. Returns true on success.
573#[deno_core::op2(fast)]
574pub fn op_delete_file(state: &mut OpState, #[string] key: &str) -> bool {
575    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
576    let save_dir = bridge.borrow().save_dir.clone();
577
578    let path = save_dir.join(format!("{key}.json"));
579    std::fs::remove_file(path).is_ok()
580}
581
582/// List all save file keys (filenames without .json extension).
583#[deno_core::op2]
584#[serde]
585pub fn op_list_save_files(state: &mut OpState) -> Vec<String> {
586    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
587    let save_dir = bridge.borrow().save_dir.clone();
588
589    let mut keys = Vec::new();
590    if let Ok(entries) = std::fs::read_dir(&save_dir) {
591        for entry in entries.flatten() {
592            let path = entry.path();
593            if path.extension().map_or(false, |ext| ext == "json") {
594                if let Some(stem) = path.file_stem() {
595                    keys.push(stem.to_string_lossy().to_string());
596                }
597            }
598        }
599    }
600    keys.sort();
601    keys
602}
603
604// --- Shader ops ---
605
606/// Create a custom fragment shader from WGSL source. Returns a shader ID.
607#[deno_core::op2(fast)]
608pub fn op_create_shader(state: &mut OpState, #[string] name: &str, #[string] source: &str) -> u32 {
609    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
610    let mut b = bridge.borrow_mut();
611    let id = b.next_shader_id;
612    b.next_shader_id += 1;
613    b.shader_create_queue
614        .push((id, name.to_string(), source.to_string()));
615    id
616}
617
618/// Set a vec4 parameter slot on a custom shader. Index 0-15.
619#[deno_core::op2(fast)]
620pub fn op_set_shader_param(
621    state: &mut OpState,
622    shader_id: u32,
623    index: u32,
624    x: f64,
625    y: f64,
626    z: f64,
627    w: f64,
628) {
629    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
630    bridge.borrow_mut().shader_param_queue.push((
631        shader_id,
632        index,
633        [x as f32, y as f32, z as f32, w as f32],
634    ));
635}
636
637// --- Post-process effect ops ---
638
639/// Add a post-process effect. Returns an effect ID.
640#[deno_core::op2(fast)]
641pub fn op_add_effect(state: &mut OpState, #[string] effect_type: &str) -> u32 {
642    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
643    let mut b = bridge.borrow_mut();
644    let id = b.next_effect_id;
645    b.next_effect_id += 1;
646    b.effect_create_queue
647        .push((id, effect_type.to_string()));
648    id
649}
650
651/// Set a vec4 parameter slot on a post-process effect. Index 0-3.
652#[deno_core::op2(fast)]
653pub fn op_set_effect_param(
654    state: &mut OpState,
655    effect_id: u32,
656    index: u32,
657    x: f64,
658    y: f64,
659    z: f64,
660    w: f64,
661) {
662    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
663    bridge.borrow_mut().effect_param_queue.push((
664        effect_id,
665        index,
666        [x as f32, y as f32, z as f32, w as f32],
667    ));
668}
669
670/// Remove a single post-process effect by ID.
671#[deno_core::op2(fast)]
672pub fn op_remove_effect(state: &mut OpState, effect_id: u32) {
673    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
674    bridge.borrow_mut().effect_remove_queue.push(effect_id);
675}
676
677/// Remove all post-process effects.
678#[deno_core::op2(fast)]
679pub fn op_clear_effects(state: &mut OpState) {
680    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
681    bridge.borrow_mut().effect_clear = true;
682}
683
684// --- Camera bounds ops ---
685
686/// Set camera bounds (world-space limits).
687#[deno_core::op2(fast)]
688pub fn op_set_camera_bounds(state: &mut OpState, min_x: f64, min_y: f64, max_x: f64, max_y: f64) {
689    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
690    bridge.borrow_mut().camera_bounds = Some(CameraBounds {
691        min_x: min_x as f32,
692        min_y: min_y as f32,
693        max_x: max_x as f32,
694        max_y: max_y as f32,
695    });
696}
697
698/// Clear camera bounds (no limits).
699#[deno_core::op2(fast)]
700pub fn op_clear_camera_bounds(state: &mut OpState) {
701    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
702    bridge.borrow_mut().camera_bounds = None;
703}
704
705/// Get camera bounds as [minX, minY, maxX, maxY] or empty if none.
706#[deno_core::op2]
707#[serde]
708pub fn op_get_camera_bounds(state: &mut OpState) -> Vec<f64> {
709    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
710    let b = bridge.borrow();
711    match b.camera_bounds {
712        Some(bounds) => vec![
713            bounds.min_x as f64,
714            bounds.min_y as f64,
715            bounds.max_x as f64,
716            bounds.max_y as f64,
717        ],
718        None => vec![],
719    }
720}
721
722// --- Global Illumination ops ---
723
724/// Enable radiance cascades global illumination.
725#[deno_core::op2(fast)]
726pub fn op_enable_gi(state: &mut OpState) {
727    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
728    bridge.borrow_mut().gi_enabled = true;
729}
730
731/// Disable radiance cascades global illumination.
732#[deno_core::op2(fast)]
733pub fn op_disable_gi(state: &mut OpState) {
734    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
735    bridge.borrow_mut().gi_enabled = false;
736}
737
738/// Set the GI intensity multiplier.
739#[deno_core::op2(fast)]
740pub fn op_set_gi_intensity(state: &mut OpState, intensity: f64) {
741    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
742    bridge.borrow_mut().gi_intensity = intensity as f32;
743}
744
745/// Set GI quality parameters (probe spacing, interval, cascade count).
746/// Pass 0 for any parameter to keep the current/default value.
747#[deno_core::op2(fast)]
748pub fn op_set_gi_quality(state: &mut OpState, probe_spacing: f64, interval: f64, cascade_count: f64) {
749    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
750    let mut b = bridge.borrow_mut();
751    if probe_spacing > 0.0 {
752        b.gi_probe_spacing = Some(probe_spacing as f32);
753    }
754    if interval > 0.0 {
755        b.gi_interval = Some(interval as f32);
756    }
757    if cascade_count > 0.0 {
758        b.gi_cascade_count = Some(cascade_count as u32);
759    }
760}
761
762/// Add an emissive surface (light source) for GI.
763#[deno_core::op2(fast)]
764pub fn op_add_emissive(
765    state: &mut OpState,
766    x: f64,
767    y: f64,
768    w: f64,
769    h: f64,
770    r: f64,
771    g: f64,
772    b: f64,
773    intensity: f64,
774) {
775    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
776    bridge.borrow_mut().emissives.push([
777        x as f32,
778        y as f32,
779        w as f32,
780        h as f32,
781        r as f32,
782        g as f32,
783        b as f32,
784        intensity as f32,
785    ]);
786}
787
788/// Clear all emissive surfaces.
789#[deno_core::op2(fast)]
790pub fn op_clear_emissives(state: &mut OpState) {
791    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
792    bridge.borrow_mut().emissives.clear();
793}
794
795/// Add a rectangular occluder that blocks light.
796#[deno_core::op2(fast)]
797pub fn op_add_occluder(state: &mut OpState, x: f64, y: f64, w: f64, h: f64) {
798    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
799    bridge.borrow_mut().occluders.push([x as f32, y as f32, w as f32, h as f32]);
800}
801
802/// Clear all occluders.
803#[deno_core::op2(fast)]
804pub fn op_clear_occluders(state: &mut OpState) {
805    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
806    bridge.borrow_mut().occluders.clear();
807}
808
809/// Add a directional light (infinite distance, parallel rays).
810#[deno_core::op2(fast)]
811pub fn op_add_directional_light(
812    state: &mut OpState,
813    angle: f64,
814    r: f64,
815    g: f64,
816    b: f64,
817    intensity: f64,
818) {
819    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
820    bridge.borrow_mut().directional_lights.push([
821        angle as f32,
822        r as f32,
823        g as f32,
824        b as f32,
825        intensity as f32,
826    ]);
827}
828
829/// Add a spot light with position, direction, and spread.
830#[deno_core::op2(fast)]
831pub fn op_add_spot_light(
832    state: &mut OpState,
833    x: f64,
834    y: f64,
835    angle: f64,
836    spread: f64,
837    range: f64,
838    r: f64,
839    g: f64,
840    b: f64,
841    intensity: f64,
842) {
843    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
844    bridge.borrow_mut().spot_lights.push([
845        x as f32,
846        y as f32,
847        angle as f32,
848        spread as f32,
849        range as f32,
850        r as f32,
851        g as f32,
852        b as f32,
853        intensity as f32,
854    ]);
855}
856
857// --- MSDF text ops ---
858
859/// Create the built-in MSDF font (from CP437 bitmap data converted to SDF).
860/// Returns a JSON string: { "fontId": N, "textureId": M, "shaderId": S }
861#[deno_core::op2]
862#[string]
863pub fn op_create_msdf_builtin_font(state: &mut OpState) -> String {
864    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
865    let mut b = bridge.borrow_mut();
866
867    // Check if already created
868    let key = "__msdf_builtin__".to_string();
869    if let Some(&tex_id) = b.texture_path_to_id.get(&key) {
870        // Already created — find the font ID
871        // The font was registered with the texture_id as the lookup key
872        let font_id_key = format!("__msdf_font_{tex_id}__");
873        if let Some(&font_id) = b.texture_path_to_id.get(&font_id_key) {
874            let pool = &b.msdf_shader_pool;
875            let shader_id = pool.first().copied().unwrap_or(0);
876            let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
877            return format!(
878                "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
879                font_id, tex_id, shader_id, pool_json.join(",")
880            );
881        }
882    }
883
884    // Assign texture ID
885    let tex_id = b.next_texture_id;
886    b.next_texture_id += 1;
887    b.texture_path_to_id.insert(key, tex_id);
888
889    // Generate MSDF atlas data and register font
890    let (_pixels, _width, _height, mut font) =
891        crate::renderer::msdf::generate_builtin_msdf_font();
892    font.texture_id = tex_id;
893
894    // Register font in the store
895    let font_id = b.msdf_fonts.register(font);
896    b.texture_path_to_id
897        .insert(format!("__msdf_font_{tex_id}__"), font_id);
898
899    // Queue the texture for GPU upload.
900    // dev.rs will call generate_builtin_msdf_font() again and upload pixels.
901    b.msdf_builtin_queue.push((font_id, tex_id));
902
903    // Ensure MSDF shader pool exists
904    let pool = ensure_msdf_shader_pool(&mut b);
905    let shader_id = pool.first().copied().unwrap_or(0);
906    let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
907
908    format!(
909        "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
910        font_id, tex_id, shader_id, pool_json.join(",")
911    )
912}
913
914/// Get MSDF glyph metrics for a text string. Returns JSON array of glyph info.
915/// Each glyph: { "uv": [x, y, w, h], "advance": N, "width": N, "height": N, "offsetX": N, "offsetY": N }
916#[deno_core::op2]
917#[string]
918pub fn op_get_msdf_glyphs(
919    state: &mut OpState,
920    font_id: u32,
921    #[string] text: &str,
922) -> String {
923    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
924    let b = bridge.borrow();
925
926    let font = match b.msdf_fonts.get(font_id) {
927        Some(f) => f,
928        None => return "[]".to_string(),
929    };
930
931    let mut entries = Vec::new();
932    for ch in text.chars() {
933        if let Some(glyph) = font.get_glyph(ch) {
934            entries.push(format!(
935                "{{\"char\":{},\"uv\":[{},{},{},{}],\"advance\":{},\"width\":{},\"height\":{},\"offsetX\":{},\"offsetY\":{}}}",
936                ch as u32,
937                glyph.uv_x, glyph.uv_y, glyph.uv_w, glyph.uv_h,
938                glyph.advance, glyph.width, glyph.height,
939                glyph.offset_x, glyph.offset_y,
940            ));
941        }
942    }
943
944    format!("[{}]", entries.join(","))
945}
946
947/// Get MSDF font info. Returns JSON: { "fontSize": N, "lineHeight": N, "distanceRange": N, "textureId": N }
948#[deno_core::op2]
949#[string]
950pub fn op_get_msdf_font_info(state: &mut OpState, font_id: u32) -> String {
951    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
952    let b = bridge.borrow();
953
954    match b.msdf_fonts.get(font_id) {
955        Some(font) => format!(
956            "{{\"fontSize\":{},\"lineHeight\":{},\"distanceRange\":{},\"textureId\":{}}}",
957            font.font_size, font.line_height, font.distance_range, font.texture_id,
958        ),
959        None => "null".to_string(),
960    }
961}
962
963/// Load an MSDF font from an atlas image path + metrics JSON string.
964/// Returns a JSON string: { "fontId": N, "textureId": M, "shaderId": S }
965#[deno_core::op2]
966#[string]
967pub fn op_load_msdf_font(
968    state: &mut OpState,
969    #[string] atlas_path: &str,
970    #[string] metrics_json: &str,
971) -> String {
972    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
973    let mut b = bridge.borrow_mut();
974
975    // Resolve atlas path
976    let resolved = if std::path::Path::new(atlas_path).is_absolute() {
977        atlas_path.to_string()
978    } else {
979        b.base_dir.join(atlas_path).to_string_lossy().to_string()
980    };
981
982    // Load atlas texture (reuse existing if already loaded)
983    let tex_id = if let Some(&id) = b.texture_path_to_id.get(&resolved) {
984        id
985    } else {
986        let id = b.next_texture_id;
987        b.next_texture_id += 1;
988        b.texture_path_to_id.insert(resolved.clone(), id);
989        b.msdf_texture_load_queue.push((resolved, id));
990        id
991    };
992
993    // Parse metrics
994    let font = match crate::renderer::msdf::parse_msdf_metrics(metrics_json, tex_id) {
995        Ok(f) => f,
996        Err(e) => {
997            return format!("{{\"error\":\"{}\"}}", e);
998        }
999    };
1000
1001    let font_id = b.msdf_fonts.register(font);
1002    let pool = ensure_msdf_shader_pool(&mut b);
1003    let shader_id = pool.first().copied().unwrap_or(0);
1004    let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
1005
1006    format!(
1007        "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
1008        font_id, tex_id, shader_id, pool_json.join(",")
1009    )
1010}
1011
1012/// Pool size for MSDF shaders (same WGSL, different uniform buffers).
1013const MSDF_SHADER_POOL_SIZE: usize = 8;
1014
1015/// Ensure the MSDF shader pool exists in the bridge state. Returns the pool of shader IDs.
1016fn ensure_msdf_shader_pool(b: &mut RenderBridgeState) -> Vec<u32> {
1017    if !b.msdf_shader_pool.is_empty() {
1018        return b.msdf_shader_pool.clone();
1019    }
1020
1021    let source = crate::renderer::msdf::MSDF_FRAGMENT_SOURCE.to_string();
1022    let mut pool = Vec::with_capacity(MSDF_SHADER_POOL_SIZE);
1023
1024    for _ in 0..MSDF_SHADER_POOL_SIZE {
1025        let id = b.next_shader_id;
1026        b.next_shader_id += 1;
1027        b.msdf_shader_queue.push((id, source.clone()));
1028        pool.push(id);
1029    }
1030
1031    b.msdf_shader_pool = pool.clone();
1032    pool
1033}
1034
1035deno_core::extension!(
1036    render_ext,
1037    ops = [
1038        op_draw_sprite,
1039        op_clear_sprites,
1040        op_set_camera,
1041        op_get_camera,
1042        op_load_texture,
1043        op_is_key_down,
1044        op_is_key_pressed,
1045        op_get_mouse_position,
1046        op_get_delta_time,
1047        op_create_solid_texture,
1048        op_create_tilemap,
1049        op_set_tile,
1050        op_get_tile,
1051        op_draw_tilemap,
1052        op_set_ambient_light,
1053        op_add_point_light,
1054        op_clear_lights,
1055        op_load_sound,
1056        op_play_sound,
1057        op_stop_sound,
1058        op_stop_all_sounds,
1059        op_set_master_volume,
1060        op_create_font_texture,
1061        op_get_viewport_size,
1062        op_get_scale_factor,
1063        op_set_background_color,
1064        op_save_file,
1065        op_load_file,
1066        op_delete_file,
1067        op_list_save_files,
1068        op_create_shader,
1069        op_set_shader_param,
1070        op_add_effect,
1071        op_set_effect_param,
1072        op_remove_effect,
1073        op_clear_effects,
1074        op_set_camera_bounds,
1075        op_clear_camera_bounds,
1076        op_get_camera_bounds,
1077        op_enable_gi,
1078        op_disable_gi,
1079        op_set_gi_intensity,
1080        op_set_gi_quality,
1081        op_add_emissive,
1082        op_clear_emissives,
1083        op_add_occluder,
1084        op_clear_occluders,
1085        op_add_directional_light,
1086        op_add_spot_light,
1087        op_create_msdf_builtin_font,
1088        op_get_msdf_glyphs,
1089        op_get_msdf_font_info,
1090        op_load_msdf_font,
1091    ],
1092);