import { describe, expect, it } from "vitest"; import type { ParticleDef } from "@nengine/core"; import { particleRadius, particleSpawn, particleTick, resolveParticleDef, PARTICLE_DEFAULT_EMIT_RATE, type ParticleState, } from "../src/particles.js"; function rand(): () => number { let n = 0.42; return () => { n = (n * 9301 + 49297) % 233280; return n / 233280; }; } const def = (overrides: Partial = {}): ParticleDef => ({ id: "dust", ...overrides }); describe("resolveParticleDef", () => { it("fills documented engine defaults for an empty def", () => { expect(resolveParticleDef(def())).toEqual({ emitRate: 10, lifetimeMin: 500, lifetimeMax: 1000, speedMin: 10, speedMax: 30, angle: 0, spread: 360, gravity: 0, sizeMin: 2, sizeMax: 4, colors: ["#ffffff"], fade: true, }); }); it("honors explicit fields over defaults", () => { const resolved = resolveParticleDef( def({ emitRate: 24, angle: 90, spread: 8, gravity: 220, fade: false, colors: ["#64b5f6"] }), ); expect(resolved).toMatchObject({ emitRate: 24, angle: 90, spread: 8, gravity: 220, fade: false, colors: ["#64b5f6"], }); }); }); describe("particleSpawn", () => { it("spawns emitRate particles at the requested pixel position", () => { const states = particleSpawn(def(), 64, 96, rand()); expect(states).toHaveLength(PARTICLE_DEFAULT_EMIT_RATE); for (const state of states) { expect(state.x).toBe(64); expect(state.y).toBe(96); } }); it("keeps speed/lifetime/size within the def ranges", () => { const rain = def({ emitRate: 64, speed: { min: 100, max: 120 }, lifetime: { min: 400, max: 600 }, size: { min: 1, max: 2 }, angle: 90, spread: 0, }); const states = particleSpawn(rain, 0, 0, rand()); expect(states).toHaveLength(64); for (const state of states) { const speed = Math.hypot(state.vx, state.vy); expect(speed).toBeGreaterThanOrEqual(100); expect(speed).toBeLessThanOrEqual(120); expect(state.lifeMs).toBeGreaterThanOrEqual(400); expect(state.lifeMs).toBeLessThanOrEqual(600); expect(state.size).toBeGreaterThanOrEqual(1); expect(state.size).toBeLessThanOrEqual(2); expect(state.vx).toBeCloseTo(0, 6); expect(state.vy).toBeGreaterThan(0); } }); it("draws color from the def palette as a 0xrrggbb number", () => { const states = particleSpawn(def({ emitRate: 1, colors: ["#ffd54f"] }), 0, 0, rand()); expect(states[0]?.color).toBe(0xffd54f); }); it("full-circle spread launches in every direction", () => { const states = particleSpawn(def({ emitRate: 64 }), 0, 0, rand()); const upward = states.some((s) => s.vy < 0); const downward = states.some((s) => s.vy > 0); const left = states.some((s) => s.vx < 0); const right = states.some((s) => s.vx > 0); expect(upward && downward && left && right).toBe(true); }); }); describe("particleTick", () => { it("applies gravity each second and moves by velocity", () => { const [state] = particleSpawn( def({ emitRate: 1, angle: 0, spread: 0, speed: { min: 10, max: 10 }, gravity: 100, lifetime: { min: 5000, max: 5000 } }), 0, 0, rand(), ); const stepped = particleTick([state], 1000); expect(stepped[0]?.x).toBeCloseTo(10, 6); expect(stepped[0]?.y).toBeCloseTo(100, 6); }); it("expires particles whose lifetime elapses", () => { const states = particleSpawn(def({ emitRate: 3, lifetime: { min: 100, max: 100 } }), 0, 0, rand()); const after = particleTick(states, 100); expect(after).toHaveLength(0); }); it("keeps particles alive before their lifetime elapses", () => { const states = particleSpawn(def({ emitRate: 3, lifetime: { min: 1000, max: 1000 } }), 0, 0, rand()); const after = particleTick(states, 500); expect(after).toHaveLength(3); expect(after[0]?.elapsedMs).toBe(500); }); it("partial expiry keeps only survivors", () => { const mixed = particleSpawn(def({ emitRate: 1, lifetime: { min: 100, max: 100 } }), 0, 0, rand()); const long = particleSpawn(def({ emitRate: 2, lifetime: { min: 900, max: 900 } }), 0, 0, rand()); const after = particleTick([...mixed, ...long], 500); expect(after).toHaveLength(2); }); }); describe("particleRadius", () => { it("returns the full size when fade is off", () => { const [state] = particleSpawn(def({ emitRate: 1, size: { min: 4, max: 4 }, fade: false }), 0, 0, rand()); expect(particleRadius(state)).toBe(4); }); it("shrinks toward the documented minimum over the lifetime when fading", () => { const spawn = (): ParticleState => { const [state] = particleSpawn( def({ emitRate: 1, size: { min: 4, max: 4 }, lifetime: { min: 2000, max: 2000 } }), 0, 0, rand(), ); return state; }; expect(particleRadius(spawn())).toBe(4); const [half] = particleTick([spawn()], 500); expect(particleRadius(half)).toBeCloseTo(3.25, 6); const [nearly] = particleTick([spawn()], 1999); expect(particleRadius(nearly)).toBeCloseTo(1.0015, 4); expect(particleTick([spawn()], 2000)).toHaveLength(0); }); });