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/// Get the number of live particles in an emitter.
274#[deno_core::op2(fast)]
275fn op_get_emitter_particle_count(state: &mut OpState, id: u32) -> u32 {
276    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
277    let ps = ps.borrow();
278    match ps.find(id) {
279        Some(idx) => ps.emitters[idx].particles.len() as u32,
280        None => 0,
281    }
282}
283
284/// Get packed sprite data for all live particles in an emitter.
285/// Returns a Vec<u8> (backed by f32s) with 6 f32 values per particle:
286/// [x, y, angle, scale, alpha, texture_id_as_f32]
287#[deno_core::op2]
288#[buffer]
289fn op_get_emitter_sprite_data(state: &mut OpState, id: u32) -> Vec<u8> {
290    let ps = state.borrow_mut::<Rc<RefCell<ParticleState>>>();
291    let ps = ps.borrow();
292    let idx = match ps.find(id) {
293        Some(i) => i,
294        None => return Vec::new(),
295    };
296
297    let emitter = &ps.emitters[idx];
298    let count = emitter.particles.len();
299    let mut floats = Vec::with_capacity(count * 6);
300
301    for p in &emitter.particles {
302        floats.push(p.x);
303        floats.push(p.y);
304        floats.push(p.angle);
305        floats.push(p.scale);
306        floats.push(p.alpha);
307        floats.push(f32::from_bits(p.texture_id));
308    }
309
310    bytemuck::cast_slice(&floats).to_vec()
311}
312
313deno_core::extension!(
314    particle_ext,
315    ops = [
316        op_create_emitter,
317        op_update_emitter,
318        op_destroy_emitter,
319        op_get_emitter_particle_count,
320        op_get_emitter_sprite_data,
321    ],
322);