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