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    // Phase 20: New instance-based commands
23    PlaySoundEx {
24        sound_id: u32,
25        instance_id: u64,
26        volume: f32,
27        looping: bool,
28        bus: u32,
29        pan: f32,
30        pitch: f32,
31        low_pass_freq: u32,
32        reverb_mix: f32,
33        reverb_delay_ms: u32,
34    },
35    PlaySoundSpatial {
36        sound_id: u32,
37        instance_id: u64,
38        volume: f32,
39        looping: bool,
40        bus: u32,
41        pitch: f32,
42        source_x: f32,
43        source_y: f32,
44        listener_x: f32,
45        listener_y: f32,
46    },
47    StopInstance { instance_id: u64 },
48    SetInstanceVolume { instance_id: u64, volume: f32 },
49    SetInstancePitch { instance_id: u64, pitch: f32 },
50    UpdateSpatialPositions {
51        updates: Vec<(u64, f32, f32)>, // (instance_id, source_x, source_y)
52        listener_x: f32,
53        listener_y: f32,
54    },
55    SetBusVolume { bus: u32, volume: f32 },
56}
57
58/// Shared state between render ops and the main loop.
59/// This is placed into `OpState` when running in renderer mode.
60#[derive(Clone)]
61pub struct RenderBridgeState {
62    pub sprite_commands: Vec<SpriteCommand>,
63    pub camera_x: f32,
64    pub camera_y: f32,
65    pub camera_zoom: f32,
66    /// True when TS called setCamera() this frame (prevents sync-back from overwriting it).
67    pub camera_dirty: bool,
68    pub delta_time: f64,
69    /// Input state snapshot (updated each frame by the event loop).
70    pub keys_down: std::collections::HashSet<String>,
71    pub keys_pressed: std::collections::HashSet<String>,
72    pub mouse_x: f32,
73    pub mouse_y: f32,
74    pub mouse_buttons_down: std::collections::HashSet<u8>,
75    pub mouse_buttons_pressed: std::collections::HashSet<u8>,
76    /// Gamepad state: buttons down per button name string.
77    pub gamepad_buttons_down: std::collections::HashSet<String>,
78    /// Gamepad buttons pressed this frame.
79    pub gamepad_buttons_pressed: std::collections::HashSet<String>,
80    /// Gamepad axis values: axis name -> value.
81    pub gamepad_axes: std::collections::HashMap<String, f32>,
82    /// Number of connected gamepads.
83    pub gamepad_count: u32,
84    /// Name of the primary gamepad.
85    pub gamepad_name: String,
86    /// Touch state: active touch points as (id, x, y).
87    pub touch_points: Vec<(u64, f32, f32)>,
88    /// Number of active touches.
89    pub touch_count: u32,
90    /// Pending texture load requests (path → result channel).
91    pub texture_load_queue: Vec<(String, u32)>,
92    /// Base directory for resolving relative texture paths.
93    pub base_dir: PathBuf,
94    /// Next texture ID to assign (for pre-registration before GPU load).
95    pub next_texture_id: u32,
96    /// Map of path → already-assigned texture ID.
97    pub texture_path_to_id: std::collections::HashMap<String, u32>,
98    /// Tilemap storage (managed by tilemap ops).
99    pub tilemaps: TilemapStore,
100    /// Lighting: ambient color (0-1 per channel). Default white = no darkening.
101    pub ambient_light: [f32; 3],
102    /// Lighting: point lights for this frame.
103    pub point_lights: Vec<PointLight>,
104    /// Audio commands queued by TS, drained each frame.
105    pub audio_commands: Vec<BridgeAudioCommand>,
106    /// Next sound ID to assign.
107    pub next_sound_id: u32,
108    /// Map of sound path → assigned sound ID.
109    pub sound_path_to_id: std::collections::HashMap<String, u32>,
110    /// Font texture creation queue (texture IDs to create as built-in font).
111    pub font_texture_queue: Vec<u32>,
112    /// Current viewport dimensions in logical pixels (synced from renderer each frame).
113    pub viewport_width: f32,
114    pub viewport_height: f32,
115    /// Display scale factor (e.g. 2.0 on Retina).
116    pub scale_factor: f32,
117    /// Clear/background color [r, g, b, a] in 0.0-1.0 range.
118    pub clear_color: [f32; 4],
119    /// Directory for save files (.arcane/saves/ relative to game entry file).
120    pub save_dir: PathBuf,
121    /// Custom shader creation queue: (id, name, wgsl_source).
122    pub shader_create_queue: Vec<(u32, String, String)>,
123    /// Custom shader param updates: (shader_id, index, [x, y, z, w]).
124    pub shader_param_queue: Vec<(u32, u32, [f32; 4])>,
125    /// Next shader ID to assign.
126    pub next_shader_id: u32,
127    /// Post-process effect creation queue: (id, effect_type_name).
128    pub effect_create_queue: Vec<(u32, String)>,
129    /// Post-process effect param updates: (effect_id, index, [x, y, z, w]).
130    pub effect_param_queue: Vec<(u32, u32, [f32; 4])>,
131    /// Post-process effect removal queue.
132    pub effect_remove_queue: Vec<u32>,
133    /// Flag to clear all post-process effects.
134    pub effect_clear: bool,
135    /// Next effect ID to assign.
136    pub next_effect_id: u32,
137    /// Camera bounds (world-space limits).
138    pub camera_bounds: Option<CameraBounds>,
139    /// Whether global illumination (radiance cascades) is enabled.
140    pub gi_enabled: bool,
141    /// GI intensity multiplier.
142    pub gi_intensity: f32,
143    /// GI probe spacing override (None = default 8).
144    pub gi_probe_spacing: Option<f32>,
145    /// GI interval override (None = default 4).
146    pub gi_interval: Option<f32>,
147    /// GI cascade count override (None = default 4).
148    pub gi_cascade_count: Option<u32>,
149    /// Emissive surfaces for GI: (x, y, w, h, r, g, b, intensity).
150    pub emissives: Vec<[f32; 8]>,
151    /// Occluders for GI: (x, y, w, h).
152    pub occluders: Vec<[f32; 4]>,
153    /// Directional lights: (angle, r, g, b, intensity).
154    pub directional_lights: Vec<[f32; 5]>,
155    /// Spot lights: (x, y, angle, spread, range, r, g, b, intensity).
156    pub spot_lights: Vec<[f32; 9]>,
157    /// MSDF font storage.
158    pub msdf_fonts: MsdfFontStore,
159    /// Queue for creating built-in MSDF font: (font_id, texture_id).
160    pub msdf_builtin_queue: Vec<(u32, u32)>,
161    /// Queue for creating MSDF shader: (shader_id, wgsl_source).
162    pub msdf_shader_queue: Vec<(u32, String)>,
163    /// Pool of MSDF shader IDs (same WGSL, separate uniform buffers for per-draw-call params).
164    pub msdf_shader_pool: Vec<u32>,
165    /// Pending MSDF texture loads (needs linear sampling, not sRGB).
166    pub msdf_texture_load_queue: Vec<(String, u32)>,
167}
168
169impl RenderBridgeState {
170    pub fn new(base_dir: PathBuf) -> Self {
171        let save_dir = base_dir.join(".arcane").join("saves");
172        Self {
173            sprite_commands: Vec::new(),
174            camera_x: 0.0,
175            camera_y: 0.0,
176            camera_zoom: 1.0,
177            camera_dirty: false,
178            delta_time: 0.0,
179            keys_down: std::collections::HashSet::new(),
180            keys_pressed: std::collections::HashSet::new(),
181            mouse_x: 0.0,
182            mouse_y: 0.0,
183            mouse_buttons_down: std::collections::HashSet::new(),
184            mouse_buttons_pressed: std::collections::HashSet::new(),
185            gamepad_buttons_down: std::collections::HashSet::new(),
186            gamepad_buttons_pressed: std::collections::HashSet::new(),
187            gamepad_axes: std::collections::HashMap::new(),
188            gamepad_count: 0,
189            gamepad_name: String::new(),
190            touch_points: Vec::new(),
191            touch_count: 0,
192            texture_load_queue: Vec::new(),
193            base_dir,
194            next_texture_id: 1,
195            texture_path_to_id: std::collections::HashMap::new(),
196            tilemaps: TilemapStore::new(),
197            ambient_light: [1.0, 1.0, 1.0],
198            point_lights: Vec::new(),
199            audio_commands: Vec::new(),
200            next_sound_id: 1,
201            sound_path_to_id: std::collections::HashMap::new(),
202            font_texture_queue: Vec::new(),
203            viewport_width: 800.0,
204            viewport_height: 600.0,
205            scale_factor: 1.0,
206            clear_color: [0.1, 0.1, 0.15, 1.0],
207            save_dir,
208            shader_create_queue: Vec::new(),
209            shader_param_queue: Vec::new(),
210            next_shader_id: 1,
211            effect_create_queue: Vec::new(),
212            effect_param_queue: Vec::new(),
213            effect_remove_queue: Vec::new(),
214            effect_clear: false,
215            next_effect_id: 1,
216            camera_bounds: None,
217            gi_enabled: false,
218            gi_intensity: 1.0,
219            gi_probe_spacing: None,
220            gi_interval: None,
221            gi_cascade_count: None,
222            emissives: Vec::new(),
223            occluders: Vec::new(),
224            directional_lights: Vec::new(),
225            spot_lights: Vec::new(),
226            msdf_fonts: MsdfFontStore::new(),
227            msdf_builtin_queue: Vec::new(),
228            msdf_shader_queue: Vec::new(),
229            msdf_shader_pool: Vec::new(),
230            msdf_texture_load_queue: Vec::new(),
231        }
232    }
233}
234
235/// Queue a sprite draw command for this frame.
236/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
237#[deno_core::op2(fast)]
238pub fn op_draw_sprite(
239    state: &mut OpState,
240    texture_id: u32,
241    x: f64,
242    y: f64,
243    w: f64,
244    h: f64,
245    layer: i32,
246    uv_x: f64,
247    uv_y: f64,
248    uv_w: f64,
249    uv_h: f64,
250    tint_r: f64,
251    tint_g: f64,
252    tint_b: f64,
253    tint_a: f64,
254    rotation: f64,
255    origin_x: f64,
256    origin_y: f64,
257    flip_x: f64,
258    flip_y: f64,
259    opacity: f64,
260    blend_mode: f64,
261    shader_id: f64,
262) {
263    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
264    bridge.borrow_mut().sprite_commands.push(SpriteCommand {
265        texture_id,
266        x: x as f32,
267        y: y as f32,
268        w: w as f32,
269        h: h as f32,
270        layer,
271        uv_x: uv_x as f32,
272        uv_y: uv_y as f32,
273        uv_w: uv_w as f32,
274        uv_h: uv_h as f32,
275        tint_r: tint_r as f32,
276        tint_g: tint_g as f32,
277        tint_b: tint_b as f32,
278        tint_a: tint_a as f32,
279        rotation: rotation as f32,
280        origin_x: origin_x as f32,
281        origin_y: origin_y as f32,
282        flip_x: flip_x != 0.0,
283        flip_y: flip_y != 0.0,
284        opacity: opacity as f32,
285        blend_mode: (blend_mode as u8).min(3),
286        shader_id: shader_id as u32,
287    });
288}
289
290/// Clear all queued sprite commands for this frame.
291#[deno_core::op2(fast)]
292pub fn op_clear_sprites(state: &mut OpState) {
293    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
294    bridge.borrow_mut().sprite_commands.clear();
295}
296
297/// Update the camera position and zoom.
298/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
299#[deno_core::op2(fast)]
300pub fn op_set_camera(state: &mut OpState, x: f64, y: f64, zoom: f64) {
301    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
302    let mut b = bridge.borrow_mut();
303    b.camera_x = x as f32;
304    b.camera_y = y as f32;
305    b.camera_zoom = zoom as f32;
306    b.camera_dirty = true;
307}
308
309/// Get camera state as [x, y, zoom].
310#[deno_core::op2]
311#[serde]
312pub fn op_get_camera(state: &mut OpState) -> Vec<f64> {
313    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
314    let b = bridge.borrow();
315    vec![b.camera_x as f64, b.camera_y as f64, b.camera_zoom as f64]
316}
317
318/// Register a texture to be loaded. Returns a texture ID immediately.
319/// The actual GPU upload happens on the main thread before the next render.
320#[deno_core::op2(fast)]
321pub fn op_load_texture(state: &mut OpState, #[string] path: &str) -> u32 {
322    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
323    let mut b = bridge.borrow_mut();
324
325    // Resolve relative paths against base_dir
326    let resolved = if std::path::Path::new(path).is_absolute() {
327        path.to_string()
328    } else {
329        b.base_dir.join(path).to_string_lossy().to_string()
330    };
331
332    // Check cache
333    if let Some(&id) = b.texture_path_to_id.get(&resolved) {
334        return id;
335    }
336
337    let id = b.next_texture_id;
338    b.next_texture_id += 1;
339    b.texture_path_to_id.insert(resolved.clone(), id);
340    b.texture_load_queue.push((resolved, id));
341    id
342}
343
344/// Check if a key is currently held down.
345#[deno_core::op2(fast)]
346pub fn op_is_key_down(state: &mut OpState, #[string] key: &str) -> bool {
347    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
348    bridge.borrow().keys_down.contains(key)
349}
350
351/// Check if a key was pressed this frame.
352#[deno_core::op2(fast)]
353pub fn op_is_key_pressed(state: &mut OpState, #[string] key: &str) -> bool {
354    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
355    bridge.borrow().keys_pressed.contains(key)
356}
357
358/// Get mouse position as [x, y].
359#[deno_core::op2]
360#[serde]
361pub fn op_get_mouse_position(state: &mut OpState) -> Vec<f64> {
362    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
363    let b = bridge.borrow();
364    vec![b.mouse_x as f64, b.mouse_y as f64]
365}
366
367/// Check if a mouse button is currently held down.
368/// Button 0 = left, 1 = right, 2 = middle.
369#[deno_core::op2(fast)]
370pub fn op_is_mouse_button_down(state: &mut OpState, button: u8) -> bool {
371    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
372    bridge.borrow().mouse_buttons_down.contains(&button)
373}
374
375/// Check if a mouse button was pressed this frame.
376/// Button 0 = left, 1 = right, 2 = middle.
377#[deno_core::op2(fast)]
378pub fn op_is_mouse_button_pressed(state: &mut OpState, button: u8) -> bool {
379    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
380    bridge.borrow().mouse_buttons_pressed.contains(&button)
381}
382
383/// Get the delta time (seconds since last frame).
384#[deno_core::op2(fast)]
385pub fn op_get_delta_time(state: &mut OpState) -> f64 {
386    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
387    bridge.borrow().delta_time
388}
389
390/// Create a solid-color texture from TS. Returns texture ID.
391/// The actual GPU upload happens on the main thread.
392#[deno_core::op2(fast)]
393pub fn op_create_solid_texture(
394    state: &mut OpState,
395    #[string] name: &str,
396    r: u32,
397    g: u32,
398    b: u32,
399    a: u32,
400) -> u32 {
401    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
402    let mut br = bridge.borrow_mut();
403
404    let key = format!("__solid__{name}");
405    if let Some(&id) = br.texture_path_to_id.get(&key) {
406        return id;
407    }
408
409    let id = br.next_texture_id;
410    br.next_texture_id += 1;
411    br.texture_path_to_id.insert(key.clone(), id);
412    // Encode color in the path as a signal to the loader
413    br.texture_load_queue
414        .push((format!("__solid__:{name}:{r}:{g}:{b}:{a}"), id));
415    id
416}
417
418/// Create a tilemap. Returns tilemap ID.
419#[deno_core::op2(fast)]
420pub fn op_create_tilemap(
421    state: &mut OpState,
422    texture_id: u32,
423    width: u32,
424    height: u32,
425    tile_size: f64,
426    atlas_columns: u32,
427    atlas_rows: u32,
428) -> u32 {
429    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
430    bridge
431        .borrow_mut()
432        .tilemaps
433        .create(texture_id, width, height, tile_size as f32, atlas_columns, atlas_rows)
434}
435
436/// Set a tile in a tilemap.
437#[deno_core::op2(fast)]
438pub fn op_set_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32, tile_id: u32) {
439    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
440    if let Some(tm) = bridge.borrow_mut().tilemaps.get_mut(tilemap_id) {
441        tm.set_tile(gx, gy, tile_id as u16);
442    }
443}
444
445/// Get a tile from a tilemap.
446#[deno_core::op2(fast)]
447pub fn op_get_tile(state: &mut OpState, tilemap_id: u32, gx: u32, gy: u32) -> u32 {
448    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
449    bridge
450        .borrow()
451        .tilemaps
452        .get(tilemap_id)
453        .map(|tm| tm.get_tile(gx, gy) as u32)
454        .unwrap_or(0)
455}
456
457/// Draw a tilemap's visible tiles as sprite commands (camera-culled).
458/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
459#[deno_core::op2(fast)]
460pub fn op_draw_tilemap(state: &mut OpState, tilemap_id: u32, world_x: f64, world_y: f64, layer: i32) {
461    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
462    let mut b = bridge.borrow_mut();
463    let cam_x = b.camera_x;
464    let cam_y = b.camera_y;
465    let cam_zoom = b.camera_zoom;
466    // Default viewport for culling; actual viewport is synced by renderer
467    let vp_w = 800.0;
468    let vp_h = 600.0;
469
470    if let Some(tm) = b.tilemaps.get(tilemap_id) {
471        let cmds = tm.bake_visible(world_x as f32, world_y as f32, layer, cam_x, cam_y, cam_zoom, vp_w, vp_h);
472        b.sprite_commands.extend(cmds);
473    }
474}
475
476// --- Lighting ops ---
477
478/// Set the ambient light color (0-1 per channel).
479/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
480#[deno_core::op2(fast)]
481pub fn op_set_ambient_light(state: &mut OpState, r: f64, g: f64, b: f64) {
482    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
483    bridge.borrow_mut().ambient_light = [r as f32, g as f32, b as f32];
484}
485
486/// Add a point light at world position (x,y) with radius, color, and intensity.
487/// Accepts f64 (JavaScript's native number type), converts to f32 for GPU.
488#[deno_core::op2(fast)]
489pub fn op_add_point_light(
490    state: &mut OpState,
491    x: f64,
492    y: f64,
493    radius: f64,
494    r: f64,
495    g: f64,
496    b: f64,
497    intensity: f64,
498) {
499    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
500    bridge.borrow_mut().point_lights.push(PointLight {
501        x: x as f32,
502        y: y as f32,
503        radius: radius as f32,
504        r: r as f32,
505        g: g as f32,
506        b: b as f32,
507        intensity: intensity as f32,
508    });
509}
510
511/// Clear all point lights for this frame.
512#[deno_core::op2(fast)]
513pub fn op_clear_lights(state: &mut OpState) {
514    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
515    bridge.borrow_mut().point_lights.clear();
516}
517
518// --- Audio ops ---
519
520/// Load a sound file. Returns a sound ID.
521#[deno_core::op2(fast)]
522pub fn op_load_sound(state: &mut OpState, #[string] path: &str) -> u32 {
523    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
524    let mut b = bridge.borrow_mut();
525
526    let resolved = if std::path::Path::new(path).is_absolute() {
527        path.to_string()
528    } else {
529        b.base_dir.join(path).to_string_lossy().to_string()
530    };
531
532    if let Some(&id) = b.sound_path_to_id.get(&resolved) {
533        return id;
534    }
535
536    let id = b.next_sound_id;
537    b.next_sound_id += 1;
538    b.sound_path_to_id.insert(resolved.clone(), id);
539    b.audio_commands.push(BridgeAudioCommand::LoadSound { id, path: resolved });
540    id
541}
542
543/// Play a loaded sound.
544/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
545#[deno_core::op2(fast)]
546pub fn op_play_sound(state: &mut OpState, id: u32, volume: f64, looping: bool) {
547    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
548    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySound { id, volume: volume as f32, looping });
549}
550
551/// Stop a specific sound.
552#[deno_core::op2(fast)]
553pub fn op_stop_sound(state: &mut OpState, id: u32) {
554    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
555    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopSound { id });
556}
557
558/// Stop all sounds.
559#[deno_core::op2(fast)]
560pub fn op_stop_all_sounds(state: &mut OpState) {
561    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
562    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopAll);
563}
564
565/// Set the master volume.
566/// Accepts f64 (JavaScript's native number type), converts to f32 for audio.
567#[deno_core::op2(fast)]
568pub fn op_set_master_volume(state: &mut OpState, volume: f64) {
569    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
570    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetMasterVolume { volume: volume as f32 });
571}
572
573// --- Font ops ---
574
575/// Create the built-in font texture. Returns a texture ID.
576#[deno_core::op2(fast)]
577pub fn op_create_font_texture(state: &mut OpState) -> u32 {
578    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
579    let mut b = bridge.borrow_mut();
580
581    let key = "__builtin_font__".to_string();
582    if let Some(&id) = b.texture_path_to_id.get(&key) {
583        return id;
584    }
585
586    let id = b.next_texture_id;
587    b.next_texture_id += 1;
588    b.texture_path_to_id.insert(key, id);
589    b.font_texture_queue.push(id);
590    id
591}
592
593// --- Viewport ops ---
594
595/// Get the current viewport size as [width, height].
596#[deno_core::op2]
597#[serde]
598pub fn op_get_viewport_size(state: &mut OpState) -> Vec<f64> {
599    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
600    let b = bridge.borrow();
601    vec![b.viewport_width as f64, b.viewport_height as f64]
602}
603
604/// Get the display scale factor (e.g. 2.0 on Retina).
605#[deno_core::op2(fast)]
606pub fn op_get_scale_factor(state: &mut OpState) -> f64 {
607    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
608    bridge.borrow().scale_factor as f64
609}
610
611/// Set the background/clear color (r, g, b in 0.0-1.0 range).
612#[deno_core::op2(fast)]
613pub fn op_set_background_color(state: &mut OpState, r: f64, g: f64, b: f64) {
614    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
615    let mut br = bridge.borrow_mut();
616    br.clear_color = [r as f32, g as f32, b as f32, 1.0];
617}
618
619// --- File I/O ops (save/load) ---
620
621/// Write a save file. Returns true on success.
622#[deno_core::op2(fast)]
623pub fn op_save_file(state: &mut OpState, #[string] key: &str, #[string] value: &str) -> bool {
624    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
625    let save_dir = bridge.borrow().save_dir.clone();
626
627    // Sanitize key: only allow alphanumeric, underscore, dash
628    if !key.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
629        return false;
630    }
631
632    // Ensure save directory exists
633    if std::fs::create_dir_all(&save_dir).is_err() {
634        return false;
635    }
636
637    let path = save_dir.join(format!("{key}.json"));
638    std::fs::write(path, value).is_ok()
639}
640
641/// Load a save file. Returns the contents or empty string if not found.
642#[deno_core::op2]
643#[string]
644pub fn op_load_file(state: &mut OpState, #[string] key: &str) -> String {
645    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
646    let save_dir = bridge.borrow().save_dir.clone();
647
648    let path = save_dir.join(format!("{key}.json"));
649    std::fs::read_to_string(path).unwrap_or_default()
650}
651
652/// Delete a save file. Returns true on success.
653#[deno_core::op2(fast)]
654pub fn op_delete_file(state: &mut OpState, #[string] key: &str) -> bool {
655    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
656    let save_dir = bridge.borrow().save_dir.clone();
657
658    let path = save_dir.join(format!("{key}.json"));
659    std::fs::remove_file(path).is_ok()
660}
661
662/// List all save file keys (filenames without .json extension).
663#[deno_core::op2]
664#[serde]
665pub fn op_list_save_files(state: &mut OpState) -> Vec<String> {
666    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
667    let save_dir = bridge.borrow().save_dir.clone();
668
669    let mut keys = Vec::new();
670    if let Ok(entries) = std::fs::read_dir(&save_dir) {
671        for entry in entries.flatten() {
672            let path = entry.path();
673            if path.extension().map_or(false, |ext| ext == "json") {
674                if let Some(stem) = path.file_stem() {
675                    keys.push(stem.to_string_lossy().to_string());
676                }
677            }
678        }
679    }
680    keys.sort();
681    keys
682}
683
684// --- Shader ops ---
685
686/// Create a custom fragment shader from WGSL source. Returns a shader ID.
687#[deno_core::op2(fast)]
688pub fn op_create_shader(state: &mut OpState, #[string] name: &str, #[string] source: &str) -> u32 {
689    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
690    let mut b = bridge.borrow_mut();
691    let id = b.next_shader_id;
692    b.next_shader_id += 1;
693    b.shader_create_queue
694        .push((id, name.to_string(), source.to_string()));
695    id
696}
697
698/// Set a vec4 parameter slot on a custom shader. Index 0-15.
699#[deno_core::op2(fast)]
700pub fn op_set_shader_param(
701    state: &mut OpState,
702    shader_id: u32,
703    index: u32,
704    x: f64,
705    y: f64,
706    z: f64,
707    w: f64,
708) {
709    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
710    bridge.borrow_mut().shader_param_queue.push((
711        shader_id,
712        index,
713        [x as f32, y as f32, z as f32, w as f32],
714    ));
715}
716
717// --- Post-process effect ops ---
718
719/// Add a post-process effect. Returns an effect ID.
720#[deno_core::op2(fast)]
721pub fn op_add_effect(state: &mut OpState, #[string] effect_type: &str) -> u32 {
722    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
723    let mut b = bridge.borrow_mut();
724    let id = b.next_effect_id;
725    b.next_effect_id += 1;
726    b.effect_create_queue
727        .push((id, effect_type.to_string()));
728    id
729}
730
731/// Set a vec4 parameter slot on a post-process effect. Index 0-3.
732#[deno_core::op2(fast)]
733pub fn op_set_effect_param(
734    state: &mut OpState,
735    effect_id: u32,
736    index: u32,
737    x: f64,
738    y: f64,
739    z: f64,
740    w: f64,
741) {
742    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
743    bridge.borrow_mut().effect_param_queue.push((
744        effect_id,
745        index,
746        [x as f32, y as f32, z as f32, w as f32],
747    ));
748}
749
750/// Remove a single post-process effect by ID.
751#[deno_core::op2(fast)]
752pub fn op_remove_effect(state: &mut OpState, effect_id: u32) {
753    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
754    bridge.borrow_mut().effect_remove_queue.push(effect_id);
755}
756
757/// Remove all post-process effects.
758#[deno_core::op2(fast)]
759pub fn op_clear_effects(state: &mut OpState) {
760    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
761    bridge.borrow_mut().effect_clear = true;
762}
763
764// --- Camera bounds ops ---
765
766/// Set camera bounds (world-space limits).
767#[deno_core::op2(fast)]
768pub fn op_set_camera_bounds(state: &mut OpState, min_x: f64, min_y: f64, max_x: f64, max_y: f64) {
769    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
770    bridge.borrow_mut().camera_bounds = Some(CameraBounds {
771        min_x: min_x as f32,
772        min_y: min_y as f32,
773        max_x: max_x as f32,
774        max_y: max_y as f32,
775    });
776}
777
778/// Clear camera bounds (no limits).
779#[deno_core::op2(fast)]
780pub fn op_clear_camera_bounds(state: &mut OpState) {
781    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
782    bridge.borrow_mut().camera_bounds = None;
783}
784
785/// Get camera bounds as [minX, minY, maxX, maxY] or empty if none.
786#[deno_core::op2]
787#[serde]
788pub fn op_get_camera_bounds(state: &mut OpState) -> Vec<f64> {
789    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
790    let b = bridge.borrow();
791    match b.camera_bounds {
792        Some(bounds) => vec![
793            bounds.min_x as f64,
794            bounds.min_y as f64,
795            bounds.max_x as f64,
796            bounds.max_y as f64,
797        ],
798        None => vec![],
799    }
800}
801
802// --- Global Illumination ops ---
803
804/// Enable radiance cascades global illumination.
805#[deno_core::op2(fast)]
806pub fn op_enable_gi(state: &mut OpState) {
807    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
808    bridge.borrow_mut().gi_enabled = true;
809}
810
811/// Disable radiance cascades global illumination.
812#[deno_core::op2(fast)]
813pub fn op_disable_gi(state: &mut OpState) {
814    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
815    bridge.borrow_mut().gi_enabled = false;
816}
817
818/// Set the GI intensity multiplier.
819#[deno_core::op2(fast)]
820pub fn op_set_gi_intensity(state: &mut OpState, intensity: f64) {
821    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
822    bridge.borrow_mut().gi_intensity = intensity as f32;
823}
824
825/// Set GI quality parameters (probe spacing, interval, cascade count).
826/// Pass 0 for any parameter to keep the current/default value.
827#[deno_core::op2(fast)]
828pub fn op_set_gi_quality(state: &mut OpState, probe_spacing: f64, interval: f64, cascade_count: f64) {
829    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
830    let mut b = bridge.borrow_mut();
831    if probe_spacing > 0.0 {
832        b.gi_probe_spacing = Some(probe_spacing as f32);
833    }
834    if interval > 0.0 {
835        b.gi_interval = Some(interval as f32);
836    }
837    if cascade_count > 0.0 {
838        b.gi_cascade_count = Some(cascade_count as u32);
839    }
840}
841
842/// Add an emissive surface (light source) for GI.
843#[deno_core::op2(fast)]
844pub fn op_add_emissive(
845    state: &mut OpState,
846    x: f64,
847    y: f64,
848    w: f64,
849    h: f64,
850    r: f64,
851    g: f64,
852    b: f64,
853    intensity: f64,
854) {
855    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
856    bridge.borrow_mut().emissives.push([
857        x as f32,
858        y as f32,
859        w as f32,
860        h as f32,
861        r as f32,
862        g as f32,
863        b as f32,
864        intensity as f32,
865    ]);
866}
867
868/// Clear all emissive surfaces.
869#[deno_core::op2(fast)]
870pub fn op_clear_emissives(state: &mut OpState) {
871    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
872    bridge.borrow_mut().emissives.clear();
873}
874
875/// Add a rectangular occluder that blocks light.
876#[deno_core::op2(fast)]
877pub fn op_add_occluder(state: &mut OpState, x: f64, y: f64, w: f64, h: f64) {
878    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
879    bridge.borrow_mut().occluders.push([x as f32, y as f32, w as f32, h as f32]);
880}
881
882/// Clear all occluders.
883#[deno_core::op2(fast)]
884pub fn op_clear_occluders(state: &mut OpState) {
885    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
886    bridge.borrow_mut().occluders.clear();
887}
888
889/// Add a directional light (infinite distance, parallel rays).
890#[deno_core::op2(fast)]
891pub fn op_add_directional_light(
892    state: &mut OpState,
893    angle: f64,
894    r: f64,
895    g: f64,
896    b: f64,
897    intensity: f64,
898) {
899    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
900    bridge.borrow_mut().directional_lights.push([
901        angle as f32,
902        r as f32,
903        g as f32,
904        b as f32,
905        intensity as f32,
906    ]);
907}
908
909/// Add a spot light with position, direction, and spread.
910#[deno_core::op2(fast)]
911pub fn op_add_spot_light(
912    state: &mut OpState,
913    x: f64,
914    y: f64,
915    angle: f64,
916    spread: f64,
917    range: f64,
918    r: f64,
919    g: f64,
920    b: f64,
921    intensity: f64,
922) {
923    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
924    bridge.borrow_mut().spot_lights.push([
925        x as f32,
926        y as f32,
927        angle as f32,
928        spread as f32,
929        range as f32,
930        r as f32,
931        g as f32,
932        b as f32,
933        intensity as f32,
934    ]);
935}
936
937// --- Phase 20: New audio ops ---
938
939/// Play a sound with extended parameters (pan, pitch, effects, bus).
940/// Accepts f64 for all numeric params (deno_core convention), converts to f32/u32/u64 internally.
941#[deno_core::op2(fast)]
942pub fn op_play_sound_ex(
943    state: &mut OpState,
944    sound_id: u32,
945    instance_id: f64,
946    volume: f64,
947    looping: bool,
948    bus: u32,
949    pan: f64,
950    pitch: f64,
951    low_pass_freq: u32,
952    reverb_mix: f64,
953    reverb_delay_ms: u32,
954) {
955    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
956    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySoundEx {
957        sound_id,
958        instance_id: instance_id as u64,
959        volume: volume as f32,
960        looping,
961        bus,
962        pan: pan as f32,
963        pitch: pitch as f32,
964        low_pass_freq,
965        reverb_mix: reverb_mix as f32,
966        reverb_delay_ms,
967    });
968}
969
970/// Play a sound with spatial audio (3D positioning).
971/// Accepts f64 for all numeric params (deno_core convention), converts to f32/u64 internally.
972#[deno_core::op2(fast)]
973pub fn op_play_sound_spatial(
974    state: &mut OpState,
975    sound_id: u32,
976    instance_id: f64,
977    volume: f64,
978    looping: bool,
979    bus: u32,
980    pitch: f64,
981    source_x: f64,
982    source_y: f64,
983    listener_x: f64,
984    listener_y: f64,
985) {
986    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
987    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::PlaySoundSpatial {
988        sound_id,
989        instance_id: instance_id as u64,
990        volume: volume as f32,
991        looping,
992        bus,
993        pitch: pitch as f32,
994        source_x: source_x as f32,
995        source_y: source_y as f32,
996        listener_x: listener_x as f32,
997        listener_y: listener_y as f32,
998    });
999}
1000
1001/// Stop a specific audio instance.
1002/// Accepts f64 (deno_core convention), converts to u64 internally.
1003#[deno_core::op2(fast)]
1004pub fn op_stop_instance(state: &mut OpState, instance_id: f64) {
1005    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1006    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::StopInstance {
1007        instance_id: instance_id as u64,
1008    });
1009}
1010
1011/// Set the volume of a specific audio instance.
1012/// Accepts f64 (deno_core convention), converts to u64/f32 internally.
1013#[deno_core::op2(fast)]
1014pub fn op_set_instance_volume(state: &mut OpState, instance_id: f64, volume: f64) {
1015    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1016    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetInstanceVolume {
1017        instance_id: instance_id as u64,
1018        volume: volume as f32,
1019    });
1020}
1021
1022/// Set the pitch of a specific audio instance.
1023/// Accepts f64 (deno_core convention), converts to u64/f32 internally.
1024#[deno_core::op2(fast)]
1025pub fn op_set_instance_pitch(state: &mut OpState, instance_id: f64, pitch: f64) {
1026    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1027    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetInstancePitch {
1028        instance_id: instance_id as u64,
1029        pitch: pitch as f32,
1030    });
1031}
1032
1033/// Update positions for multiple spatial audio instances in a batch.
1034/// Uses JSON string for variable-length data (simplest approach with deno_core 0.385.0).
1035/// Format: {"instanceIds": [id1, id2, ...], "sourceXs": [x1, x2, ...], "sourceYs": [y1, y2, ...], "listenerX": x, "listenerY": y}
1036#[deno_core::op2(fast)]
1037pub fn op_update_spatial_positions(state: &mut OpState, #[string] data_json: &str, listener_x: f64, listener_y: f64) {
1038    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1039
1040    // Parse JSON (simple ad-hoc parsing for array data)
1041    // In production, we'd use serde_json, but for minimal dependencies, parse manually
1042    let mut updates = Vec::new();
1043
1044    // Extract arrays from JSON manually (hacky but works for simple structure)
1045    if let Some(ids_start) = data_json.find("\"instanceIds\":[") {
1046        if let Some(xs_start) = data_json.find("\"sourceXs\":[") {
1047            if let Some(ys_start) = data_json.find("\"sourceYs\":[") {
1048                let ids_str = &data_json[ids_start + 15..];
1049                let xs_str = &data_json[xs_start + 12..];
1050                let ys_str = &data_json[ys_start + 12..];
1051
1052                let ids_end = ids_str.find(']').unwrap_or(0);
1053                let xs_end = xs_str.find(']').unwrap_or(0);
1054                let ys_end = ys_str.find(']').unwrap_or(0);
1055
1056                let ids: Vec<u64> = ids_str[..ids_end]
1057                    .split(',')
1058                    .filter_map(|s| s.trim().parse().ok())
1059                    .collect();
1060                let xs: Vec<f32> = xs_str[..xs_end]
1061                    .split(',')
1062                    .filter_map(|s| s.trim().parse().ok())
1063                    .collect();
1064                let ys: Vec<f32> = ys_str[..ys_end]
1065                    .split(',')
1066                    .filter_map(|s| s.trim().parse().ok())
1067                    .collect();
1068
1069                for i in 0..ids.len().min(xs.len()).min(ys.len()) {
1070                    updates.push((ids[i], xs[i], ys[i]));
1071                }
1072            }
1073        }
1074    }
1075
1076    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::UpdateSpatialPositions {
1077        updates,
1078        listener_x: listener_x as f32,
1079        listener_y: listener_y as f32,
1080    });
1081}
1082
1083/// Set the volume for an audio bus (affects all sounds on that bus).
1084/// Accepts f64 (deno_core convention), converts to f32 internally.
1085#[deno_core::op2(fast)]
1086pub fn op_set_bus_volume(state: &mut OpState, bus: u32, volume: f64) {
1087    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1088    bridge.borrow_mut().audio_commands.push(BridgeAudioCommand::SetBusVolume {
1089        bus,
1090        volume: volume as f32,
1091    });
1092}
1093
1094// --- MSDF text ops ---
1095
1096/// Create the built-in MSDF font (from CP437 bitmap data converted to SDF).
1097/// Returns a JSON string: { "fontId": N, "textureId": M, "shaderId": S }
1098#[deno_core::op2]
1099#[string]
1100pub fn op_create_msdf_builtin_font(state: &mut OpState) -> String {
1101    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1102    let mut b = bridge.borrow_mut();
1103
1104    // Check if already created
1105    let key = "__msdf_builtin__".to_string();
1106    if let Some(&tex_id) = b.texture_path_to_id.get(&key) {
1107        // Already created — find the font ID
1108        // The font was registered with the texture_id as the lookup key
1109        let font_id_key = format!("__msdf_font_{tex_id}__");
1110        if let Some(&font_id) = b.texture_path_to_id.get(&font_id_key) {
1111            let pool = &b.msdf_shader_pool;
1112            let shader_id = pool.first().copied().unwrap_or(0);
1113            let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
1114            return format!(
1115                "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
1116                font_id, tex_id, shader_id, pool_json.join(",")
1117            );
1118        }
1119    }
1120
1121    // Assign texture ID
1122    let tex_id = b.next_texture_id;
1123    b.next_texture_id += 1;
1124    b.texture_path_to_id.insert(key, tex_id);
1125
1126    // Generate MSDF atlas data and register font
1127    let (_pixels, _width, _height, mut font) =
1128        crate::renderer::msdf::generate_builtin_msdf_font();
1129    font.texture_id = tex_id;
1130
1131    // Register font in the store
1132    let font_id = b.msdf_fonts.register(font);
1133    b.texture_path_to_id
1134        .insert(format!("__msdf_font_{tex_id}__"), font_id);
1135
1136    // Queue the texture for GPU upload.
1137    // dev.rs will call generate_builtin_msdf_font() again and upload pixels.
1138    b.msdf_builtin_queue.push((font_id, tex_id));
1139
1140    // Ensure MSDF shader pool exists
1141    let pool = ensure_msdf_shader_pool(&mut b);
1142    let shader_id = pool.first().copied().unwrap_or(0);
1143    let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
1144
1145    format!(
1146        "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
1147        font_id, tex_id, shader_id, pool_json.join(",")
1148    )
1149}
1150
1151/// Get MSDF glyph metrics for a text string. Returns JSON array of glyph info.
1152/// Each glyph: { "uv": [x, y, w, h], "advance": N, "width": N, "height": N, "offsetX": N, "offsetY": N }
1153#[deno_core::op2]
1154#[string]
1155pub fn op_get_msdf_glyphs(
1156    state: &mut OpState,
1157    font_id: u32,
1158    #[string] text: &str,
1159) -> String {
1160    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1161    let b = bridge.borrow();
1162
1163    let font = match b.msdf_fonts.get(font_id) {
1164        Some(f) => f,
1165        None => return "[]".to_string(),
1166    };
1167
1168    let mut entries = Vec::new();
1169    for ch in text.chars() {
1170        if let Some(glyph) = font.get_glyph(ch) {
1171            entries.push(format!(
1172                "{{\"char\":{},\"uv\":[{},{},{},{}],\"advance\":{},\"width\":{},\"height\":{},\"offsetX\":{},\"offsetY\":{}}}",
1173                ch as u32,
1174                glyph.uv_x, glyph.uv_y, glyph.uv_w, glyph.uv_h,
1175                glyph.advance, glyph.width, glyph.height,
1176                glyph.offset_x, glyph.offset_y,
1177            ));
1178        }
1179    }
1180
1181    format!("[{}]", entries.join(","))
1182}
1183
1184/// Get MSDF font info. Returns JSON: { "fontSize": N, "lineHeight": N, "distanceRange": N, "textureId": N }
1185#[deno_core::op2]
1186#[string]
1187pub fn op_get_msdf_font_info(state: &mut OpState, font_id: u32) -> String {
1188    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1189    let b = bridge.borrow();
1190
1191    match b.msdf_fonts.get(font_id) {
1192        Some(font) => format!(
1193            "{{\"fontSize\":{},\"lineHeight\":{},\"distanceRange\":{},\"textureId\":{}}}",
1194            font.font_size, font.line_height, font.distance_range, font.texture_id,
1195        ),
1196        None => "null".to_string(),
1197    }
1198}
1199
1200/// Load an MSDF font from an atlas image path + metrics JSON string.
1201/// Returns a JSON string: { "fontId": N, "textureId": M, "shaderId": S }
1202#[deno_core::op2]
1203#[string]
1204pub fn op_load_msdf_font(
1205    state: &mut OpState,
1206    #[string] atlas_path: &str,
1207    #[string] metrics_json: &str,
1208) -> String {
1209    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1210    let mut b = bridge.borrow_mut();
1211
1212    // Resolve atlas path
1213    let resolved = if std::path::Path::new(atlas_path).is_absolute() {
1214        atlas_path.to_string()
1215    } else {
1216        b.base_dir.join(atlas_path).to_string_lossy().to_string()
1217    };
1218
1219    // Load atlas texture (reuse existing if already loaded)
1220    let tex_id = if let Some(&id) = b.texture_path_to_id.get(&resolved) {
1221        id
1222    } else {
1223        let id = b.next_texture_id;
1224        b.next_texture_id += 1;
1225        b.texture_path_to_id.insert(resolved.clone(), id);
1226        b.msdf_texture_load_queue.push((resolved, id));
1227        id
1228    };
1229
1230    // Parse metrics
1231    let font = match crate::renderer::msdf::parse_msdf_metrics(metrics_json, tex_id) {
1232        Ok(f) => f,
1233        Err(e) => {
1234            return format!("{{\"error\":\"{}\"}}", e);
1235        }
1236    };
1237
1238    let font_id = b.msdf_fonts.register(font);
1239    let pool = ensure_msdf_shader_pool(&mut b);
1240    let shader_id = pool.first().copied().unwrap_or(0);
1241    let pool_json: Vec<String> = pool.iter().map(|id| id.to_string()).collect();
1242
1243    format!(
1244        "{{\"fontId\":{},\"textureId\":{},\"shaderId\":{},\"shaderPool\":[{}]}}",
1245        font_id, tex_id, shader_id, pool_json.join(",")
1246    )
1247}
1248
1249/// Pool size for MSDF shaders (same WGSL, different uniform buffers).
1250const MSDF_SHADER_POOL_SIZE: usize = 8;
1251
1252/// Ensure the MSDF shader pool exists in the bridge state. Returns the pool of shader IDs.
1253fn ensure_msdf_shader_pool(b: &mut RenderBridgeState) -> Vec<u32> {
1254    if !b.msdf_shader_pool.is_empty() {
1255        return b.msdf_shader_pool.clone();
1256    }
1257
1258    let source = crate::renderer::msdf::MSDF_FRAGMENT_SOURCE.to_string();
1259    let mut pool = Vec::with_capacity(MSDF_SHADER_POOL_SIZE);
1260
1261    for _ in 0..MSDF_SHADER_POOL_SIZE {
1262        let id = b.next_shader_id;
1263        b.next_shader_id += 1;
1264        b.msdf_shader_queue.push((id, source.clone()));
1265        pool.push(id);
1266    }
1267
1268    b.msdf_shader_pool = pool.clone();
1269    pool
1270}
1271
1272// --- Gamepad ops ---
1273
1274/// Get the number of connected gamepads.
1275#[deno_core::op2(fast)]
1276pub fn op_get_gamepad_count(state: &mut OpState) -> u32 {
1277    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1278    bridge.borrow().gamepad_count
1279}
1280
1281/// Get the name of the primary gamepad.
1282#[deno_core::op2]
1283#[string]
1284pub fn op_get_gamepad_name(state: &mut OpState) -> String {
1285    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1286    bridge.borrow().gamepad_name.clone()
1287}
1288
1289/// Check if a gamepad button is currently held down.
1290/// Button name is the canonical string (e.g. "A", "B", "LeftBumper", "DPadUp").
1291#[deno_core::op2(fast)]
1292pub fn op_is_gamepad_button_down(state: &mut OpState, #[string] button: &str) -> bool {
1293    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1294    bridge.borrow().gamepad_buttons_down.contains(button)
1295}
1296
1297/// Check if a gamepad button was pressed this frame.
1298#[deno_core::op2(fast)]
1299pub fn op_is_gamepad_button_pressed(state: &mut OpState, #[string] button: &str) -> bool {
1300    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1301    bridge.borrow().gamepad_buttons_pressed.contains(button)
1302}
1303
1304/// Get a gamepad axis value (-1.0 to 1.0 for sticks, 0.0 to 1.0 for triggers).
1305/// Axis name: "LeftStickX", "LeftStickY", "RightStickX", "RightStickY", "LeftTrigger", "RightTrigger".
1306#[deno_core::op2(fast)]
1307pub fn op_get_gamepad_axis(state: &mut OpState, #[string] axis: &str) -> f64 {
1308    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1309    bridge.borrow().gamepad_axes.get(axis).copied().unwrap_or(0.0) as f64
1310}
1311
1312// --- Touch ops ---
1313
1314/// Get the number of active touch points.
1315#[deno_core::op2(fast)]
1316pub fn op_get_touch_count(state: &mut OpState) -> u32 {
1317    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1318    bridge.borrow().touch_count
1319}
1320
1321/// Get a touch point position by index. Returns [x, y] or empty array if not found.
1322#[deno_core::op2]
1323#[serde]
1324pub fn op_get_touch_position(state: &mut OpState, index: u32) -> Vec<f64> {
1325    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1326    let b = bridge.borrow();
1327    if let Some(&(_, x, y)) = b.touch_points.get(index as usize) {
1328        vec![x as f64, y as f64]
1329    } else {
1330        vec![]
1331    }
1332}
1333
1334/// Check if any touch is currently active.
1335#[deno_core::op2(fast)]
1336pub fn op_is_touch_active(state: &mut OpState) -> bool {
1337    let bridge = state.borrow_mut::<Rc<RefCell<RenderBridgeState>>>();
1338    bridge.borrow().touch_count > 0
1339}
1340
1341deno_core::extension!(
1342    render_ext,
1343    ops = [
1344        op_draw_sprite,
1345        op_clear_sprites,
1346        op_set_camera,
1347        op_get_camera,
1348        op_load_texture,
1349        op_is_key_down,
1350        op_is_key_pressed,
1351        op_get_mouse_position,
1352        op_is_mouse_button_down,
1353        op_is_mouse_button_pressed,
1354        op_get_delta_time,
1355        op_create_solid_texture,
1356        op_create_tilemap,
1357        op_set_tile,
1358        op_get_tile,
1359        op_draw_tilemap,
1360        op_set_ambient_light,
1361        op_add_point_light,
1362        op_clear_lights,
1363        op_load_sound,
1364        op_play_sound,
1365        op_stop_sound,
1366        op_stop_all_sounds,
1367        op_set_master_volume,
1368        op_play_sound_ex,
1369        op_play_sound_spatial,
1370        op_stop_instance,
1371        op_set_instance_volume,
1372        op_set_instance_pitch,
1373        op_update_spatial_positions,
1374        op_set_bus_volume,
1375        op_create_font_texture,
1376        op_get_viewport_size,
1377        op_get_scale_factor,
1378        op_set_background_color,
1379        op_save_file,
1380        op_load_file,
1381        op_delete_file,
1382        op_list_save_files,
1383        op_create_shader,
1384        op_set_shader_param,
1385        op_add_effect,
1386        op_set_effect_param,
1387        op_remove_effect,
1388        op_clear_effects,
1389        op_set_camera_bounds,
1390        op_clear_camera_bounds,
1391        op_get_camera_bounds,
1392        op_enable_gi,
1393        op_disable_gi,
1394        op_set_gi_intensity,
1395        op_set_gi_quality,
1396        op_add_emissive,
1397        op_clear_emissives,
1398        op_add_occluder,
1399        op_clear_occluders,
1400        op_add_directional_light,
1401        op_add_spot_light,
1402        op_create_msdf_builtin_font,
1403        op_get_msdf_glyphs,
1404        op_get_msdf_font_info,
1405        op_load_msdf_font,
1406        op_get_gamepad_count,
1407        op_get_gamepad_name,
1408        op_is_gamepad_button_down,
1409        op_is_gamepad_button_pressed,
1410        op_get_gamepad_axis,
1411        op_get_touch_count,
1412        op_get_touch_position,
1413        op_is_touch_active,
1414    ],
1415);