Skip to main content

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