onedrop_engine/
sprites.rs

1//! Sprite (`MILK_IMG.INI`) state and lifecycle.
2//!
3//! Holds the parsed sprite definitions ([`SpriteDef`]), an active
4//! list of running sprite instances ([`ActiveSprite`]), and per-frame
5//! state evaluation. Each active sprite owns its own
6//! [`MilkEvaluator`] so per-frame equations can write to local
7//! `x, y, sx, sy, rot, blendmode, a, r, g, b, done` variables without
8//! leaking into the preset evaluator or sibling sprites.
9//!
10//! The engine ticks the manager each frame after the preset's
11//! `per_frame` block (so sprites can read fresh `q1..q32` /
12//! `bass / mid / treb` if needed via shared context state, copied in
13//! by the engine) and produces a list of [`SpriteRenderInstance`]s the
14//! renderer turns into textured quads.
15
16use onedrop_eval::MilkEvaluator;
17use onedrop_parser::SpriteDef;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20
21/// Blend modes the GPU pipeline understands. MD2 specced more
22/// (decal, overlay, multiply, …) but corpus sprites overwhelmingly
23/// use Alpha or Additive — the renderer's two pipelines cover both.
24/// Unknown / unsupported `blendmode` values from the per-frame eval
25/// snap to `Alpha`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SpriteBlendMode {
28    /// Standard alpha blend (`src.a, 1 - src.a`).
29    Alpha,
30    /// Additive (`one, one`) — useful for glow / particle effects.
31    Additive,
32}
33
34/// Per-frame snapshot of one active sprite, fed to the renderer.
35/// The renderer turns this into a quad draw via `sprite_pipeline`.
36#[derive(Debug, Clone, Copy)]
37pub struct SpriteRenderInstance {
38    /// Index into the engine's sprite texture pool. The renderer
39    /// owns the actual `wgpu::Texture` for each slot.
40    pub texture_index: u32,
41    /// Centre in normalised screen coords `[0, 1]`, top-left origin.
42    pub x: f32,
43    pub y: f32,
44    /// Per-axis scale relative to the sprite's native pixel size.
45    /// `1.0` = native, `2.0` = twice as wide / tall, etc.
46    pub sx: f32,
47    pub sy: f32,
48    /// Rotation in radians, clockwise.
49    pub rot: f32,
50    /// Tint colour (premultiplied by `a` at draw time).
51    pub rgba: [f32; 4],
52    /// Blend pipeline to pick. See [`SpriteBlendMode`].
53    pub blend: SpriteBlendMode,
54    /// `true` on the *one* frame a `burn=1` sprite is finishing: the
55    /// renderer is expected to draw it into `render_texture` so it
56    /// feeds back through the warp loop. The manager drops the
57    /// sprite immediately after.
58    pub burn: bool,
59}
60
61/// One running sprite instance.
62struct ActiveSprite {
63    /// Index into [`SpriteManager::defs`] — used to recover the
64    /// texture index without re-walking the def list each frame.
65    def_index: usize,
66    /// Per-instance variable state. The init block runs once on
67    /// spawn; per_frame runs every tick.
68    eval: MilkEvaluator,
69    /// `true` after the first per_frame tick — used to gate the
70    /// init block, which mustn't re-run.
71    initialised: bool,
72    /// Frames since spawn. Exposed to the equations as `frame`.
73    frame: u32,
74    /// Burn-pending: set on the tick the sprite's `done` variable
75    /// goes truthy AND the def had `burn=1`. The renderer reads
76    /// this on the *next* render to draw the sprite into
77    /// `render_texture` once, then the manager prunes it.
78    burn_pending: bool,
79    /// `true` once `done` is set and we've already emitted the burn
80    /// (if any). Pruned at end-of-frame.
81    finished: bool,
82}
83
84/// Sprite system: definitions + active instances.
85pub struct SpriteManager {
86    /// Parsed `MILK_IMG.INI` entries indexed by load order. The
87    /// public API targets entries by slot (1-based, matching the
88    /// section headers); [`slot_to_index`] converts.
89    defs: Vec<SpriteDef>,
90    /// Slot → defs-vector position map. Sparse, since users can
91    /// author `[img01]` then jump to `[img50]`.
92    slot_to_index: HashMap<u32, usize>,
93    /// Active sprites — drawn in spawn order so later spawns paint
94    /// over earlier ones.
95    active: Vec<ActiveSprite>,
96    /// Cursor for "cycle to next" (the `K` keybind). Walks the
97    /// slot list mod `defs.len()`.
98    cycle_cursor: usize,
99}
100
101impl Default for SpriteManager {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107impl SpriteManager {
108    pub fn new() -> Self {
109        Self {
110            defs: Vec::new(),
111            slot_to_index: HashMap::new(),
112            active: Vec::new(),
113            cycle_cursor: 0,
114        }
115    }
116
117    /// Load a freshly-parsed set of sprite definitions, replacing
118    /// any previous load. Doesn't touch the active list — running
119    /// sprites keep their `def_index`-back-reference until they
120    /// finish naturally, even though the def is gone (we re-resolve
121    /// the texture each frame in case the renderer reloaded).
122    pub fn load_defs(&mut self, defs: Vec<SpriteDef>) {
123        self.slot_to_index.clear();
124        for (i, d) in defs.iter().enumerate() {
125            self.slot_to_index.insert(d.slot, i);
126        }
127        self.defs = defs;
128        self.cycle_cursor = 0;
129    }
130
131    /// Number of loaded sprite definitions (NOT the active count).
132    pub fn def_count(&self) -> usize {
133        self.defs.len()
134    }
135
136    /// Number of sprites currently being drawn each frame.
137    pub fn active_count(&self) -> usize {
138        self.active.len()
139    }
140
141    /// Spawn a sprite by INI slot. Returns `false` if the slot
142    /// isn't loaded. Multiple spawns of the same slot stack — that
143    /// matches MD2 behaviour and lets users build up effects by
144    /// hammering the key.
145    pub fn spawn_slot(&mut self, slot: u32) -> bool {
146        let Some(&idx) = self.slot_to_index.get(&slot) else {
147            return false;
148        };
149        self.spawn_index(idx);
150        true
151    }
152
153    fn spawn_index(&mut self, idx: usize) {
154        let mut eval = MilkEvaluator::new();
155        // Seed the per-sprite variables with MD2's defaults.
156        let ctx = eval.context_mut();
157        ctx.set("x", 0.5);
158        ctx.set("y", 0.5);
159        ctx.set("sx", 1.0);
160        ctx.set("sy", 1.0);
161        ctx.set("rot", 0.0);
162        ctx.set("flipx", 0.0);
163        ctx.set("flipy", 0.0);
164        ctx.set("repeatx", 1.0);
165        ctx.set("repeaty", 1.0);
166        ctx.set("blendmode", 0.0);
167        ctx.set("a", 1.0);
168        ctx.set("r", 1.0);
169        ctx.set("g", 1.0);
170        ctx.set("b", 1.0);
171        ctx.set("done", 0.0);
172        self.active.push(ActiveSprite {
173            def_index: idx,
174            eval,
175            initialised: false,
176            frame: 0,
177            burn_pending: false,
178            finished: false,
179        });
180    }
181
182    /// `K` keybind: load the next sprite slot in cyclic order.
183    pub fn cycle_next(&mut self) -> Option<u32> {
184        if self.defs.is_empty() {
185            return None;
186        }
187        let idx = self.cycle_cursor % self.defs.len();
188        let slot = self.defs[idx].slot;
189        self.cycle_cursor = (idx + 1) % self.defs.len();
190        self.spawn_index(idx);
191        Some(slot)
192    }
193
194    /// `Shift+K`: pick a slot at random. The caller passes the RNG
195    /// seed so we stay deterministic in tests.
196    pub fn spawn_random(&mut self, seed: u64) -> Option<u32> {
197        if self.defs.is_empty() {
198            return None;
199        }
200        // Tiny LCG — keeps us off the `rand` dep for one call site.
201        let mut x = seed
202            .wrapping_mul(6364136223846793005)
203            .wrapping_add(1442695040888963407);
204        x ^= x >> 33;
205        let idx = (x as usize) % self.defs.len();
206        let slot = self.defs[idx].slot;
207        self.spawn_index(idx);
208        Some(slot)
209    }
210
211    /// `Ctrl+T` and `Delete`: drop every active sprite immediately.
212    pub fn clear(&mut self) {
213        self.active.clear();
214    }
215
216    /// `Delete` variant that only removes the most recent sprite
217    /// (last-in-first-out). Returns `true` when something was
218    /// removed so the GUI can suppress redundant Delete events on
219    /// an already-empty list.
220    pub fn pop_most_recent(&mut self) -> bool {
221        self.active.pop().is_some()
222    }
223
224    /// Advance every active sprite by one frame. Returns the list
225    /// of [`SpriteRenderInstance`]s for the renderer.
226    ///
227    /// `time` is the engine's global clock (seconds, monotonic).
228    /// `q_snapshot` is the preset's `q1..q32` after the preset's
229    /// `per_frame` block runs, exposed so sprite equations can
230    /// piggyback on the preset's audio-reactive computations.
231    pub fn tick(&mut self, time: f32, q_snapshot: &[f32; 32]) -> Vec<SpriteRenderInstance> {
232        let mut out = Vec::with_capacity(self.active.len());
233        for s in self.active.iter_mut() {
234            // Push the shared context: `time`, `frame`, `q1..q32`.
235            // Per-sprite vars carry across frames inside the
236            // evaluator's local scope.
237            {
238                let ctx = s.eval.context_mut();
239                ctx.set_time(time as f64);
240                ctx.set_frame(s.frame as f64);
241                for (i, q) in q_snapshot.iter().enumerate() {
242                    ctx.set(&format!("q{}", i + 1), *q as f64);
243                }
244            }
245            // Init runs once.
246            if !s.initialised {
247                if let Some(def) = self.defs.get(s.def_index)
248                    && !def.init.is_empty()
249                {
250                    let _ = s.eval.eval(&def.init);
251                }
252                s.initialised = true;
253            }
254            // Per-frame.
255            if let Some(def) = self.defs.get(s.def_index)
256                && !def.per_frame.is_empty()
257            {
258                let _ = s.eval.eval(&def.per_frame);
259            }
260            // Read back the snapshot.
261            let ctx = s.eval.context();
262            let x = ctx.get("x").unwrap_or(0.5) as f32;
263            let y = ctx.get("y").unwrap_or(0.5) as f32;
264            let sx = ctx.get("sx").unwrap_or(1.0) as f32;
265            let sy = ctx.get("sy").unwrap_or(1.0) as f32;
266            let rot = ctx.get("rot").unwrap_or(0.0) as f32;
267            let blend_int = ctx.get("blendmode").unwrap_or(0.0) as i32;
268            let r = ctx.get("r").unwrap_or(1.0) as f32;
269            let g = ctx.get("g").unwrap_or(1.0) as f32;
270            let b = ctx.get("b").unwrap_or(1.0) as f32;
271            let a = ctx.get("a").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
272            let done = ctx.get("done").unwrap_or(0.0) != 0.0;
273
274            let blend = match blend_int {
275                2 => SpriteBlendMode::Additive,
276                _ => SpriteBlendMode::Alpha,
277            };
278
279            let def = self.defs.get(s.def_index);
280            // `def_index` survives a `load_defs` swap by index but
281            // there's a chance the new INI shrinks the vector below
282            // the old index. Pretend the sprite is finished in
283            // that case so we don't try to draw with a stale
284            // pointer.
285            let texture_index = def.map(|_| s.def_index as u32).unwrap_or(u32::MAX);
286            let burn_flag = def.map(|d| d.burn).unwrap_or(false);
287
288            // Lifecycle decision.
289            if done && burn_flag && !s.burn_pending {
290                // First "I'm done" tick on a burn sprite — let the
291                // renderer paint it into `render_texture` this
292                // frame; finish next frame.
293                s.burn_pending = true;
294                s.finished = true;
295            } else if done {
296                s.finished = true;
297            }
298
299            // Emit the render instance — clamp colour now so the
300            // GPU sees premultiplied alpha-safe values.
301            let rgba = [
302                (r * a).clamp(0.0, 1.0),
303                (g * a).clamp(0.0, 1.0),
304                (b * a).clamp(0.0, 1.0),
305                a,
306            ];
307            // Always emit while alive — even the burn frame goes out
308            // as a render so the texture lands somewhere visible.
309            if texture_index != u32::MAX {
310                out.push(SpriteRenderInstance {
311                    texture_index,
312                    x,
313                    y,
314                    sx,
315                    sy,
316                    rot,
317                    rgba,
318                    blend,
319                    burn: s.burn_pending,
320                });
321            }
322
323            s.frame = s.frame.saturating_add(1);
324        }
325        // Prune finished sprites (burn ones get one frame to render).
326        self.active.retain(|s| !s.finished);
327        out
328    }
329
330    /// Borrow the parsed defs — useful for the renderer's texture
331    /// loader (resolve `img=` strings against an XDG sprite dir).
332    pub fn defs(&self) -> &[SpriteDef] {
333        &self.defs
334    }
335
336    /// Path the engine expects sprite assets to live under, relative
337    /// to a sprite-bank root. Pure helper, no I/O.
338    pub fn resolve_img_path(root: &Path, def: &SpriteDef) -> PathBuf {
339        root.join(&def.img)
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn def(slot: u32, img: &str, per_frame: &str, burn: bool) -> SpriteDef {
348        SpriteDef {
349            slot,
350            img: img.into(),
351            init: String::new(),
352            per_frame: per_frame.into(),
353            burn,
354        }
355    }
356
357    #[test]
358    fn spawn_and_cycle_walks_defs_in_order() {
359        let mut mgr = SpriteManager::new();
360        mgr.load_defs(vec![
361            def(1, "a.png", "x=0.25;", false),
362            def(2, "b.png", "x=0.75;", false),
363        ]);
364        assert_eq!(mgr.cycle_next(), Some(1));
365        assert_eq!(mgr.cycle_next(), Some(2));
366        assert_eq!(mgr.cycle_next(), Some(1));
367        assert_eq!(mgr.active_count(), 3);
368    }
369
370    #[test]
371    fn tick_evaluates_per_frame_and_emits_render() {
372        let mut mgr = SpriteManager::new();
373        mgr.load_defs(vec![def(1, "a.png", "x=0.25; y=0.75;", false)]);
374        mgr.spawn_slot(1);
375        let instances = mgr.tick(0.0, &[0.0; 32]);
376        assert_eq!(instances.len(), 1);
377        let i = &instances[0];
378        assert!((i.x - 0.25).abs() < 1e-6);
379        assert!((i.y - 0.75).abs() < 1e-6);
380    }
381
382    #[test]
383    fn done_drops_sprite_next_frame() {
384        let mut mgr = SpriteManager::new();
385        mgr.load_defs(vec![def(1, "a.png", "done=1;", false)]);
386        mgr.spawn_slot(1);
387        let _ = mgr.tick(0.0, &[0.0; 32]);
388        // After tick, sprite is marked finished → pruned.
389        assert_eq!(mgr.active_count(), 0);
390    }
391
392    #[test]
393    fn burn_sprite_emits_one_render_with_burn_flag() {
394        let mut mgr = SpriteManager::new();
395        mgr.load_defs(vec![def(1, "a.png", "done=1;", true)]);
396        mgr.spawn_slot(1);
397        let instances = mgr.tick(0.0, &[0.0; 32]);
398        assert_eq!(instances.len(), 1);
399        assert!(instances[0].burn, "burn=1 + done=1 should set burn flag");
400        // Pruned at end of tick.
401        assert_eq!(mgr.active_count(), 0);
402    }
403
404    #[test]
405    fn blendmode_two_maps_to_additive() {
406        let mut mgr = SpriteManager::new();
407        mgr.load_defs(vec![def(1, "a.png", "blendmode=2;", false)]);
408        mgr.spawn_slot(1);
409        let i = mgr.tick(0.0, &[0.0; 32]);
410        assert_eq!(i[0].blend, SpriteBlendMode::Additive);
411    }
412
413    #[test]
414    fn unknown_blendmode_falls_back_to_alpha() {
415        let mut mgr = SpriteManager::new();
416        mgr.load_defs(vec![def(1, "a.png", "blendmode=42;", false)]);
417        mgr.spawn_slot(1);
418        let i = mgr.tick(0.0, &[0.0; 32]);
419        assert_eq!(i[0].blend, SpriteBlendMode::Alpha);
420    }
421
422    #[test]
423    fn clear_drops_everything() {
424        let mut mgr = SpriteManager::new();
425        mgr.load_defs(vec![def(1, "a.png", "", false), def(2, "b.png", "", false)]);
426        mgr.spawn_slot(1);
427        mgr.spawn_slot(2);
428        mgr.spawn_slot(1);
429        assert_eq!(mgr.active_count(), 3);
430        mgr.clear();
431        assert_eq!(mgr.active_count(), 0);
432    }
433
434    #[test]
435    fn pop_most_recent_lifo() {
436        let mut mgr = SpriteManager::new();
437        mgr.load_defs(vec![
438            def(1, "a.png", "x=0.1;", false),
439            def(2, "b.png", "x=0.9;", false),
440        ]);
441        mgr.spawn_slot(1);
442        mgr.spawn_slot(2);
443        assert!(mgr.pop_most_recent());
444        assert_eq!(mgr.active_count(), 1);
445        assert!(mgr.pop_most_recent());
446        assert!(!mgr.pop_most_recent());
447    }
448
449    #[test]
450    fn spawn_unknown_slot_returns_false() {
451        let mut mgr = SpriteManager::new();
452        mgr.load_defs(vec![def(1, "a.png", "", false)]);
453        assert!(!mgr.spawn_slot(99));
454        assert_eq!(mgr.active_count(), 0);
455    }
456}