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}