1use psx_gpu::frame::{OtFrame, PrimitiveArena};
22use psx_gpu::prim::RectFlat;
23
24use crate::rng::LcgRng;
25
26#[derive(Copy, Clone, Debug)]
33pub struct Particle {
34 pub x: i16,
36 pub y: i16,
38 pub x_frac: u8,
42 pub y_frac: u8,
44 pub vx: i16,
46 pub vy: i16,
48 pub r: u8,
51 pub g: u8,
53 pub b: u8,
55 pub ttl: u8,
57 pub spawn_ttl: u8,
60}
61
62impl Particle {
63 pub const fn empty() -> Self {
65 Self {
66 x: 0,
67 y: 0,
68 x_frac: 0,
69 y_frac: 0,
70 vx: 0,
71 vy: 0,
72 r: 0,
73 g: 0,
74 b: 0,
75 ttl: 0,
76 spawn_ttl: 0,
77 }
78 }
79
80 pub const fn is_alive(&self) -> bool {
82 self.ttl != 0
83 }
84
85 #[inline]
88 fn rect(&self, shake: (i16, i16)) -> RectFlat {
89 let denom = self.spawn_ttl.max(1) as u16;
90 let scale = self.ttl as u16;
91 let r = ((self.r as u16 * scale) / denom) as u8;
92 let g = ((self.g as u16 * scale) / denom) as u8;
93 let b = ((self.b as u16 * scale) / denom) as u8;
94 let size = if (self.ttl as u16) * 2 > denom { 3 } else { 2 };
95 RectFlat::new(self.x + shake.0, self.y + shake.1, size, size, r, g, b)
96 }
97}
98
99pub struct ParticlePool<const N: usize> {
106 particles: [Particle; N],
107}
108
109impl<const N: usize> ParticlePool<N> {
110 pub const fn new() -> Self {
112 Self {
113 particles: [Particle::empty(); N],
114 }
115 }
116
117 pub fn spawn_burst(
130 &mut self,
131 rng: &mut LcgRng,
132 centre: (i16, i16),
133 color: (u8, u8, u8),
134 count: usize,
135 velocity_range: i16,
136 ttl: u8,
137 ) {
138 let mut spawned = 0;
139 for slot in self.particles.iter_mut() {
140 if slot.ttl != 0 {
141 continue;
142 }
143 let vx = rng.signed(velocity_range);
144 let vy = rng.signed(velocity_range);
145 *slot = Particle {
146 x: centre.0,
147 y: centre.1,
148 x_frac: 0,
149 y_frac: 0,
150 vx,
151 vy,
152 r: color.0,
153 g: color.1,
154 b: color.2,
155 ttl,
156 spawn_ttl: ttl,
157 };
158 spawned += 1;
159 if spawned >= count {
160 return;
161 }
162 }
163 }
164
165 pub fn update(&mut self, gravity: i16) {
171 for p in self.particles.iter_mut() {
172 if p.ttl == 0 {
173 continue;
174 }
175 (p.x, p.x_frac) = advance(p.x, p.x_frac, p.vx);
176 (p.y, p.y_frac) = advance(p.y, p.y_frac, p.vy);
177 p.vy = p.vy.saturating_add(gravity);
178 p.ttl -= 1;
179 }
180 }
181
182 pub fn clear(&mut self) {
185 for p in self.particles.iter_mut() {
186 *p = Particle::empty();
187 }
188 }
189
190 pub fn render_into_frame<'f, const OT_N: usize>(
197 &self,
198 frame: &mut OtFrame<'f, OT_N>,
199 rects: &mut PrimitiveArena<'f, RectFlat>,
200 z: u8,
201 shake: (i16, i16),
202 ) -> usize {
203 let mut written = 0;
204 for p in self.particles.iter() {
205 if p.ttl == 0 {
206 continue;
207 }
208 let Some(rect) = rects.push(p.rect(shake)) else {
209 break;
210 };
211 frame.add(z as usize, rect);
212 written += 1;
213 }
214 written
215 }
216
217 pub fn live_count(&self) -> usize {
220 self.particles.iter().filter(|p| p.ttl != 0).count()
221 }
222
223 pub fn particles(&self) -> &[Particle; N] {
226 &self.particles
227 }
228}
229
230impl<const N: usize> Default for ParticlePool<N> {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236#[inline]
243const fn advance(pos: i16, frac: u8, velocity: i16) -> (i16, u8) {
244 let total = (pos as i32) * 16 + frac as i32 + velocity as i32;
245 ((total >> 4) as i16, (total & 15) as u8)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use psx_gpu::ot::OrderingTable;
252
253 #[test]
254 fn render_into_frame_stops_when_the_arena_is_full() {
255 let mut pool: ParticlePool<8> = ParticlePool::new();
256 let mut rng = LcgRng::new(7);
257 pool.spawn_burst(&mut rng, (50, 50), (200, 100, 50), 5, 16, 20);
258 let mut storage: [RectFlat; 3] =
259 core::array::from_fn(|_| RectFlat::new(0, 0, 0, 0, 0, 0, 0));
260 let mut ot: OrderingTable<4> = OrderingTable::new();
261 let mut frame = ot.frame();
262 let mut arena = PrimitiveArena::new(&mut storage);
263 assert_eq!(pool.render_into_frame(&mut frame, &mut arena, 2, (0, 0)), 3);
264 assert_eq!(arena.remaining(), 0);
265 drop(frame);
266 assert_eq!(unsafe { ot.packets() }.count(), 3);
268 }
269
270 #[test]
271 fn empty_pool_is_empty() {
272 let pool: ParticlePool<16> = ParticlePool::new();
273 assert_eq!(pool.live_count(), 0);
274 }
275
276 #[test]
277 fn spawn_burst_fills_slots() {
278 let mut pool: ParticlePool<16> = ParticlePool::new();
279 let mut rng = LcgRng::new(42);
280 pool.spawn_burst(&mut rng, (100, 100), (255, 0, 0), 6, 32, 30);
281 assert_eq!(pool.live_count(), 6);
282 }
283
284 #[test]
285 fn spawn_burst_truncates_at_capacity() {
286 let mut pool: ParticlePool<4> = ParticlePool::new();
287 let mut rng = LcgRng::new(1);
288 pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 16, 32, 30);
289 assert_eq!(pool.live_count(), 4);
290 }
291
292 #[test]
293 fn update_decrements_ttl() {
294 let mut pool: ParticlePool<4> = ParticlePool::new();
295 let mut rng = LcgRng::new(1);
296 pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 2, 0, 3);
297 assert_eq!(pool.live_count(), 2);
298 pool.update(0);
299 pool.update(0);
300 pool.update(0);
301 assert_eq!(pool.live_count(), 0, "all slots should expire after TTL");
302 }
303
304 #[test]
305 fn clear_empties_pool() {
306 let mut pool: ParticlePool<8> = ParticlePool::new();
307 let mut rng = LcgRng::new(1);
308 pool.spawn_burst(&mut rng, (0, 0), (255, 255, 255), 4, 0, 30);
309 pool.clear();
310 assert_eq!(pool.live_count(), 0);
311 }
312
313 #[test]
314 fn update_integrates_velocity() {
315 let mut pool: ParticlePool<2> = ParticlePool::new();
316 pool.particles[0] = Particle {
319 x: 10,
320 y: 20,
321 x_frac: 0,
322 y_frac: 0,
323 vx: 32, vy: 16, r: 0,
326 g: 0,
327 b: 0,
328 ttl: 5,
329 spawn_ttl: 5,
330 };
331 pool.update(0);
332 assert_eq!(pool.particles[0].x, 12);
333 assert_eq!(pool.particles[0].y, 21);
334 assert_eq!(pool.particles[0].ttl, 4);
335 }
336 fn drift(vx: i16, vy: i16, frames: u32) -> (i16, i16) {
338 let mut pool: ParticlePool<1> = ParticlePool::new();
339 pool.particles[0] = Particle {
340 ttl: 200,
341 spawn_ttl: 200,
342 vx,
343 vy,
344 ..Particle::empty()
345 };
346 for _ in 0..frames {
347 pool.update(0);
348 }
349 (pool.particles[0].x, pool.particles[0].y)
350 }
351
352 #[test]
353 fn a_slow_particle_drifts_instead_of_freezing() {
354 assert_eq!(drift(8, 0, 2), (1, 0));
356 assert_eq!(drift(8, 0, 32), (16, 0));
357 assert_eq!(drift(1, 1, 15), (0, 0));
359 assert_eq!(drift(1, 1, 16), (1, 1));
360 }
361
362 #[test]
363 fn total_distance_is_velocity_times_frames_in_sixteenths() {
364 for v in [-31i16, -17, -16, -9, -1, 0, 1, 7, 16, 17, 24, 31, 32] {
366 for frames in [1u32, 2, 3, 10, 33] {
367 let expected = (frames as i32 * v as i32).div_euclid(16);
368 assert_eq!(
369 drift(v, 0, frames).0 as i32,
370 expected,
371 "v={v} frames={frames}"
372 );
373 }
374 }
375 }
376
377 #[test]
378 fn velocities_between_16_and_31_no_longer_all_move_one_pixel() {
379 assert_eq!(drift(24, 0, 2), (3, 0));
381 assert_eq!(drift(16, 0, 2), (2, 0));
382 }
383}