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