Skip to main content

arcane_core/scripting/
particle_ops.rs

1/// Particle system ops: Rust-native simulation driven by TS configuration.
2///
3/// Stream B owns this file.
4///
5/// ## Design
6/// - TS calls op_create_emitter(config JSON) -> returns emitter_id
7/// - TS calls op_update_emitter(id, dt, cx, cy) each frame -> simulates particles
8/// - TS calls op_get_emitter_sprite_data(id) -> packed f32 data for rendering
9/// - TS calls op_destroy_emitter(id)
10
11use std::cell::RefCell;
12use std::rc::Rc;
13
14use deno_core::OpState;
15
16/// A single simulated particle.
17#[derive(Debug, Clone)]
18struct Particle {
19    x: f32,
20    y: f32,
21    vx: f32,
22    vy: f32,
23    angle: f32,
24    angular_vel: f32,
25    scale: f32,
26    alpha: f32,
27    lifetime: f32,
28    max_lifetime: f32,
29    texture_id: u32,
30}
31
32/// Configuration for a particle emitter, parsed from JSON.
33#[derive(Debug, Clone)]
34struct EmitterConfig {
35    spawn_rate: f32,
36    lifetime_min: f32,
37    lifetime_max: f32,
38    speed_min: f32,
39    speed_max: f32,
40    direction: f32,
41    spread: f32,
42    scale_min: f32,
43    scale_max: f32,
44    alpha_start: f32,
45    alpha_end: f32,
46    gravity_x: f32,
47    gravity_y: f32,
48    texture_id: u32,
49}
50
51impl Default for EmitterConfig {
52    fn default() -> Self {
53        Self {
54            spawn_rate: 10.0,
55            lifetime_min: 0.5,
56            lifetime_max: 1.5,
57            speed_min: 20.0,
58            speed_max: 80.0,
59            direction: -std::f32::consts::FRAC_PI_2, // upward
60            spread: std::f32::consts::PI,
61            scale_min: 1.0,
62            scale_max: 1.0,
63            alpha_start: 1.0,
64            alpha_end: 0.0,
65            gravity_x: 0.0,
66            gravity_y: 0.0,
67            texture_id: 0,
68        }
69    }
70}
71
72/// Rust-native particle emitter.
73#[derive(Debug)]
74pub struct ParticleEmitter {
75    pub id: u32,
76    config: EmitterConfig,
77    particles: Vec<Particle>,
78    time_accumulator: f32,
79    /// Simple xorshift RNG state for per-emitter determinism.
80    rng_state: u32,
81}
82
83impl ParticleEmitter {
84    fn new(id: u32, config: EmitterConfig) -> Self {
85        // Seed RNG from id to get different sequences per emitter
86        let rng_state = id.wrapping_mul(2654435761).max(1);
87        Self {
88            id,
89            config,
90            particles: Vec::new(),
91            time_accumulator: 0.0,
92            rng_state,
93        }
94    }
95
96    /// Simple xorshift32 PRNG, returns value in [0, 1).
97    fn rand(&mut self) -> f32 {
98        let mut s = self.rng_state;
99        s ^= s << 13;
100        s ^= s >> 17;
101        s ^= s << 5;
102        self.rng_state = s;
103        (s as f32) / (u32::MAX as f32)
104    }
105
106    fn rand_range(&mut self, min: f32, max: f32) -> f32 {
107        min + self.rand() * (max - min)
108    }
109
110    fn spawn_particle(&mut self, cx: f32, cy: f32) {
111        // Extract config values to avoid borrow conflict with rand_range(&mut self)
112        let lifetime_min = self.config.lifetime_min;
113        let lifetime_max = self.config.lifetime_max;
114        let speed_min = self.config.speed_min;
115        let speed_max = self.config.speed_max;
116        let direction = self.config.direction;
117        let half_spread = self.config.spread * 0.5;
118        let scale_min = self.config.scale_min;
119        let scale_max = self.config.scale_max;
120        let alpha_start = self.config.alpha_start;
121        let texture_id = self.config.texture_id;
122
123        let lifetime = self.rand_range(lifetime_min, lifetime_max);
124        let speed = self.rand_range(speed_min, speed_max);
125        let angle = direction + self.rand_range(-half_spread, half_spread);
126        let scale = self.rand_range(scale_min, scale_max);
127
128        self.particles.push(Particle {
129            x: cx,
130            y: cy,
131            vx: angle.cos() * speed,
132            vy: angle.sin() * speed,
133            angle: 0.0,
134            angular_vel: 0.0,
135            scale,
136            alpha: alpha_start,
137            lifetime: 0.0,
138            max_lifetime: lifetime,
139            texture_id,
140        });
141    }
142
143    fn update(&mut self, dt: f32, cx: f32, cy: f32) {
144        // Extract config values to locals to avoid borrow conflicts
145        let spawn_rate = self.config.spawn_rate;
146        let gx = self.config.gravity_x;
147        let gy = self.config.gravity_y;
148        let alpha_start = self.config.alpha_start;
149        let alpha_end = self.config.alpha_end;
150
151        // Spawn new particles
152        self.time_accumulator += dt * spawn_rate;
153        while self.time_accumulator >= 1.0 {
154            self.spawn_particle(cx, cy);
155            self.time_accumulator -= 1.0;
156        }
157
158        self.particles.retain_mut(|p| {
159            p.lifetime += dt;
160            if p.lifetime >= p.max_lifetime {
161                return false;
162            }
163
164            p.vx += gx * dt;
165            p.vy += gy * dt;
166            p.x += p.vx * dt;
167            p.y += p.vy * dt;
168            p.angle += p.angular_vel * dt;
169
170            // Alpha interpolation
171            let t = p.lifetime / p.max_lifetime;
172            p.alpha = alpha_start + (alpha_end - alpha_start) * t;
173
174            true
175        });
176    }
177}
178
179/// All active particle emitters, keyed by ID.
180pub struct ParticleState {
181    pub emitters: Vec<ParticleEmitter>,
182    pub next_id: u32,
183}
184
185impl ParticleState {
186    pub fn new() -> Self {
187        Self {
188            emitters: Vec::new(),
189            next_id: 1,
190        }
191    }
192
193    fn find(&self, id: u32) -> Option<usize> {
194        self.emitters.iter().position(|e| e.id == id)
195    }
196}
197
198/// Create a Rust-native particle emitter from a JSON config string.
199/// Returns the emitter ID (u32).
200///
201/// JSON fields (all optional, defaults apply):
202///   spawnRate, lifetimeMin, lifetimeMax, speedMin, speedMax,
203///   direction, spread, scaleMin, scaleMax, alphaStart, alphaEnd,
204///   gravityX, gravityY, textureId
205#[deno_core::op2(fast)]
206fn op_create_emitter(state: &mut OpState, #[string] config_json: &str) -> u32 {
207    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
208    let mut ps = ps.borrow_mut();
209
210    let mut cfg = EmitterConfig::default();
211
212    // Minimal JSON parsing without serde_json dependency.
213    // Extract numeric fields from a flat JSON object.
214    fn extract_f32(json: &str, key: &str) -> Option<f32> {
215        let needle = format!("\"{}\"", key);
216        let start = json.find(&needle)?;
217        let after_key = &json[start + needle.len()..];
218        let colon = after_key.find(':')?;
219        let val_start = &after_key[colon + 1..];
220        // Skip whitespace
221        let val_start = val_start.trim_start();
222        // Read until comma, closing brace, or end
223        let end = val_start.find(|c: char| c == ',' || c == '}').unwrap_or(val_start.len());
224        val_start[..end].trim().parse().ok()
225    }
226
227    fn extract_u32(json: &str, key: &str) -> Option<u32> {
228        extract_f32(json, key).map(|f| f as u32)
229    }
230
231    if let Some(v) = extract_f32(config_json, "spawnRate") { cfg.spawn_rate = v; }
232    if let Some(v) = extract_f32(config_json, "lifetimeMin") { cfg.lifetime_min = v; }
233    if let Some(v) = extract_f32(config_json, "lifetimeMax") { cfg.lifetime_max = v; }
234    if let Some(v) = extract_f32(config_json, "speedMin") { cfg.speed_min = v; }
235    if let Some(v) = extract_f32(config_json, "speedMax") { cfg.speed_max = v; }
236    if let Some(v) = extract_f32(config_json, "direction") { cfg.direction = v; }
237    if let Some(v) = extract_f32(config_json, "spread") { cfg.spread = v; }
238    if let Some(v) = extract_f32(config_json, "scaleMin") { cfg.scale_min = v; }
239    if let Some(v) = extract_f32(config_json, "scaleMax") { cfg.scale_max = v; }
240    if let Some(v) = extract_f32(config_json, "alphaStart") { cfg.alpha_start = v; }
241    if let Some(v) = extract_f32(config_json, "alphaEnd") { cfg.alpha_end = v; }
242    if let Some(v) = extract_f32(config_json, "gravityX") { cfg.gravity_x = v; }
243    if let Some(v) = extract_f32(config_json, "gravityY") { cfg.gravity_y = v; }
244    if let Some(v) = extract_u32(config_json, "textureId") { cfg.texture_id = v; }
245
246    let id = ps.next_id;
247    ps.next_id += 1;
248    ps.emitters.push(ParticleEmitter::new(id, cfg));
249    id
250}
251
252/// Update a Rust-native emitter: spawn new particles, integrate, remove dead.
253/// cx, cy are the emitter's current world position (for spawning).
254#[deno_core::op2(fast)]
255fn op_update_emitter(state: &mut OpState, id: u32, dt: f64, cx: f64, cy: f64) {
256    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
257    let mut ps = ps.borrow_mut();
258    if let Some(idx) = ps.find(id) {
259        ps.emitters[idx].update(dt as f32, cx as f32, cy as f32);
260    }
261}
262
263/// Destroy a Rust-native emitter.
264#[deno_core::op2(fast)]
265fn op_destroy_emitter(state: &mut OpState, id: u32) {
266    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
267    let mut ps = ps.borrow_mut();
268    if let Some(idx) = ps.find(id) {
269        ps.emitters.swap_remove(idx);
270    }
271}
272
273/// Set the spawn rate of a Rust-native emitter.
274/// Set to 0 to stop spawning (useful for burst effects).
275#[deno_core::op2(fast)]
276fn op_set_emitter_spawn_rate(state: &mut OpState, id: u32, spawn_rate: f64) {
277    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
278    let mut ps = ps.borrow_mut();
279    if let Some(idx) = ps.find(id) {
280        ps.emitters[idx].config.spawn_rate = spawn_rate as f32;
281    }
282}
283
284/// Get the number of live particles in an emitter.
285#[deno_core::op2(fast)]
286fn op_get_emitter_particle_count(state: &mut OpState, id: u32) -> u32 {
287    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
288    let ps = ps.borrow();
289    match ps.find(id) {
290        Some(idx) => ps.emitters[idx].particles.len() as u32,
291        None => 0,
292    }
293}
294
295/// Get packed sprite data for all live particles in an emitter.
296/// Returns a Vec<u8> (backed by f32s) with 6 f32 values per particle:
297/// [x, y, angle, scale, alpha, texture_id_as_f32]
298#[deno_core::op2]
299#[buffer]
300fn op_get_emitter_sprite_data(state: &mut OpState, id: u32) -> Vec<u8> {
301    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
302    let ps = ps.borrow();
303    let idx = match ps.find(id) {
304        Some(i) => i,
305        None => return Vec::new(),
306    };
307
308    let emitter = &ps.emitters[idx];
309    let count = emitter.particles.len();
310    let mut floats = Vec::with_capacity(count * 6);
311
312    for p in &emitter.particles {
313        floats.push(p.x);
314        floats.push(p.y);
315        floats.push(p.angle);
316        floats.push(p.scale);
317        floats.push(p.alpha);
318        floats.push(f32::from_bits(p.texture_id));
319    }
320
321    bytemuck::cast_slice(&floats).to_vec()
322}
323
324deno_core::extension!(
325    particle_ext,
326    ops = [
327        op_create_emitter,
328        op_update_emitter,
329        op_destroy_emitter,
330        op_set_emitter_spawn_rate,
331        op_get_emitter_particle_count,
332        op_get_emitter_sprite_data,
333    ],
334);
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_emitter_config_default() {
342        let cfg = EmitterConfig::default();
343        assert_eq!(cfg.spawn_rate, 10.0);
344        assert!(cfg.lifetime_min > 0.0);
345        assert!(cfg.lifetime_max >= cfg.lifetime_min);
346        assert_eq!(cfg.alpha_start, 1.0);
347        assert_eq!(cfg.alpha_end, 0.0);
348        assert_eq!(cfg.texture_id, 0);
349    }
350
351    #[test]
352    fn test_particle_emitter_new() {
353        let cfg = EmitterConfig::default();
354        let emitter = ParticleEmitter::new(1, cfg);
355
356        assert_eq!(emitter.id, 1);
357        assert!(emitter.particles.is_empty());
358        assert_eq!(emitter.time_accumulator, 0.0);
359    }
360
361    #[test]
362    fn test_emitter_deterministic_rng() {
363        let cfg = EmitterConfig::default();
364        let mut e1 = ParticleEmitter::new(42, cfg.clone());
365        let mut e2 = ParticleEmitter::new(42, cfg);
366
367        // Same seed should produce same sequence
368        let r1_a = e1.rand();
369        let r1_b = e1.rand();
370        let r2_a = e2.rand();
371        let r2_b = e2.rand();
372
373        assert_eq!(r1_a, r2_a);
374        assert_eq!(r1_b, r2_b);
375    }
376
377    #[test]
378    fn test_emitter_different_seeds_different_rng() {
379        let cfg = EmitterConfig::default();
380        let mut e1 = ParticleEmitter::new(1, cfg.clone());
381        let mut e2 = ParticleEmitter::new(2, cfg);
382
383        // Different seeds should produce different sequences
384        let r1 = e1.rand();
385        let r2 = e2.rand();
386
387        assert_ne!(r1, r2);
388    }
389
390    #[test]
391    fn test_emitter_rand_in_range() {
392        let cfg = EmitterConfig::default();
393        let mut emitter = ParticleEmitter::new(123, cfg);
394
395        for _ in 0..100 {
396            let v = emitter.rand();
397            assert!(v >= 0.0 && v < 1.0, "rand() should be in [0, 1), got {}", v);
398        }
399    }
400
401    #[test]
402    fn test_emitter_rand_range() {
403        let cfg = EmitterConfig::default();
404        let mut emitter = ParticleEmitter::new(456, cfg);
405
406        for _ in 0..100 {
407            let v = emitter.rand_range(5.0, 10.0);
408            assert!(v >= 5.0 && v <= 10.0, "rand_range(5, 10) should be in [5, 10], got {}", v);
409        }
410    }
411
412    #[test]
413    fn test_emitter_spawns_particles() {
414        let cfg = EmitterConfig {
415            spawn_rate: 100.0, // High rate for quick spawning
416            speed_min: 0.0,    // No velocity to keep particles at spawn position
417            speed_max: 0.0,
418            ..EmitterConfig::default()
419        };
420        let mut emitter = ParticleEmitter::new(1, cfg);
421
422        assert!(emitter.particles.is_empty());
423
424        // Update for 0.1 seconds at 100 particles/sec = ~10 particles
425        emitter.update(0.1, 100.0, 200.0);
426
427        assert!(!emitter.particles.is_empty());
428        // Each spawned particle should be at the spawn position (no velocity)
429        for p in &emitter.particles {
430            assert_eq!(p.x, 100.0);
431            assert_eq!(p.y, 200.0);
432        }
433    }
434
435    #[test]
436    fn test_particle_lifetime_removal() {
437        let cfg = EmitterConfig {
438            spawn_rate: 0.0, // No spawning during update
439            lifetime_min: 0.1,
440            lifetime_max: 0.1,
441            ..EmitterConfig::default()
442        };
443        let mut emitter = ParticleEmitter::new(1, cfg);
444
445        // Manually spawn a particle
446        emitter.spawn_particle(0.0, 0.0);
447        assert_eq!(emitter.particles.len(), 1);
448
449        // Update past its lifetime
450        emitter.update(0.2, 0.0, 0.0);
451
452        assert!(emitter.particles.is_empty(), "Particle should be removed after lifetime expires");
453    }
454
455    #[test]
456    fn test_particle_state_new() {
457        let state = ParticleState::new();
458        assert!(state.emitters.is_empty());
459        assert_eq!(state.next_id, 1);
460    }
461
462    #[test]
463    fn test_particle_state_find() {
464        let mut state = ParticleState::new();
465        state.emitters.push(ParticleEmitter::new(5, EmitterConfig::default()));
466        state.emitters.push(ParticleEmitter::new(10, EmitterConfig::default()));
467
468        assert_eq!(state.find(5), Some(0));
469        assert_eq!(state.find(10), Some(1));
470        assert_eq!(state.find(999), None);
471    }
472
473    #[test]
474    fn test_sprite_data_packing() {
475        let cfg = EmitterConfig {
476            spawn_rate: 0.0,
477            texture_id: 42,
478            ..EmitterConfig::default()
479        };
480        let mut emitter = ParticleEmitter::new(1, cfg);
481        emitter.spawn_particle(100.0, 200.0);
482
483        // Simulate data packing like op_get_emitter_sprite_data does
484        let p = &emitter.particles[0];
485        let mut floats = Vec::new();
486        floats.push(p.x);
487        floats.push(p.y);
488        floats.push(p.angle);
489        floats.push(p.scale);
490        floats.push(p.alpha);
491        floats.push(f32::from_bits(p.texture_id));
492
493        assert_eq!(floats.len(), 6);
494        assert_eq!(floats[0], 100.0); // x
495        assert_eq!(floats[1], 200.0); // y
496        assert_eq!(f32::to_bits(floats[5]), 42); // texture_id round-trips
497    }
498}