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