| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- 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> = {}): 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);
- });
- });
|