From 04518bad93110a78000de4296177352577eb541d Mon Sep 17 00:00:00 2001 From: Francois Date: Wed, 8 Oct 2025 08:59:21 +0900 Subject: [PATCH] feat: add particle system module --- PROJECT_DESCRIPTION.md | 4 +- README.md | 4 +- ROADMAP.md | 2 +- docs/index.d.ts | 71 +++++++ examples/particleSystem.ts | 22 +++ src/gameplay/particleSystem.ts | 331 +++++++++++++++++++++++++++++++++ src/index.ts | 19 ++ tests/index.test.ts | 2 + tests/particleSystem.test.ts | 88 +++++++++ 9 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 examples/particleSystem.ts create mode 100644 src/gameplay/particleSystem.ts create mode 100644 tests/particleSystem.test.ts diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index 415d522..a012704 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -39,7 +39,7 @@ npm run build | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -94,7 +94,7 @@ Consistency between runtime code, documentation, and TypeScript declarations kee - **Procedural:** 2D/3D Perlin, Worley noise, Wave Function Collapse tile synthesis. - **Spatial:** Quadtree, AABB helpers, SAT convex polygon collision. - **Performance utilities:** Debounce, throttle, LRU cache, memoize, request deduplication, virtual scrolling, weighted alias sampling, object pooling, Fisher–Yates shuffle. -- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake. +- **Gameplay systems:** Delta-time manager, fixed timestep loop, 2D camera with smoothing and shake, particle system with configurable emitters. - **Search:** Fuzzy search + scoring, Trie-based autocomplete, binary search, Levenshtein distance. - **Data tools:** Diff operations (LCS), deep clone, groupBy, JSON diff/patch helpers. - **Graph:** BFS distance map, DFS traversal, topological sort. diff --git a/README.md b/README.md index 0af1c04..d9fac81 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ CDN usage: | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | | Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` | -| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts` | +| Gameplay systems | `createDeltaTimeManager`, `createFixedTimestepLoop`, `createCamera2D`, `createParticleSystem` | `util/deltaTime.ts`, `util/fixedTimestep.ts`, `gameplay/camera2D.ts`, `gameplay/particleSystem.ts` | `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | @@ -53,7 +53,7 @@ npm run size # Enforce bundle size budget - Milestone 0.2 next targets crowd-flow integrations (RVO + flow fields) and behaviour-tree decorators for richer AI control. - Milestone 0.4 plans a procedural + gameplay systems toolkit (Wave Function Collapse, dungeon suite, L-systems, game loop, camera, particles, inventory, combat, save/load, and more). -Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. +Examples live under `examples/` and can be executed with `tsx`/`ts-node` or compiled for the browser. See `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts`, `examples/steering.ts`, `examples/boids.ts`, `examples/requestDedup.ts`, `examples/deltaTime.ts`, `examples/fixedTimestep.ts`, `examples/camera2D.ts`, `examples/particleSystem.ts`, `examples/search.ts`, `examples/graph.ts`, `examples/geometry.ts`, `examples/bresenham.ts`, `examples/visual.ts`, `examples/sat.ts`, `examples/simplex.ts`, and `examples/worley.ts` for quick starts. The `examples` registry exported from `src/index.ts` provides a typed index you can traverse programmatically. ## Contributing 1. Fork the repository. diff --git a/ROADMAP.md b/ROADMAP.md index 72b8263..e1205b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,7 +52,7 @@ - [x] Bresenham line / raster traversal helpers - Real-time systems: - [x] 2D camera system (smooth follow, dead zones, screen shake) - - [ ] Particle system with configurable emitters + - [x] Particle system with configurable emitters - [ ] Sprite animation controller (frame timing, events) - [ ] Tween/lerp utility for smooth interpolation - [ ] Platformer physics helper (gravity, coyote time, jump buffering) diff --git a/docs/index.d.ts b/docs/index.d.ts index 04406fc..01f7c31 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -1007,6 +1007,77 @@ export interface Camera2D { */ export function createCamera2D(options: Camera2DOptions): Camera2D; +/** + * Particle range descriptor. + * Use for: defining min/max values for particle properties. + * Import: gameplay/particleSystem.ts + */ +export interface ParticleRangeOptions { + min: number; + max: number; +} + +/** + * Particle emitter configuration. + * Use for: controlling emission rate, lifetime, speed, angles, and size. + * Import: gameplay/particleSystem.ts + */ +export interface ParticleEmitterOptions { + rate?: number; + position?: Point; + life: ParticleRangeOptions; + speed?: ParticleRangeOptions; + angle?: ParticleRangeOptions; + size?: ParticleRangeOptions; + acceleration?: Vector2D; +} + +/** + * Particle system creation options. + * Use for: configuring emitter and pooling limits. + * Import: gameplay/particleSystem.ts + */ +export interface ParticleSystemOptions { + emitter: ParticleEmitterOptions; + maxParticles?: number; + random?: () => number; +} + +/** + * Particle state information. + * Use for: rendering particle positions, velocities, and lifetimes. + * Import: gameplay/particleSystem.ts + */ +export interface Particle { + position: Point; + velocity: Vector2D; + age: number; + life: number; + size: number; +} + +/** + * Particle system runtime API. + * Use for: stepping simulation, bursts, emitter updates, pooling. + * Import: gameplay/particleSystem.ts + */ +export interface ParticleSystem { + update(delta: number): void; + burst(count: number): void; + getParticles(): readonly Particle[]; + setEmitter(options: Partial): void; + setPosition(position: Point): void; + reset(): void; +} + +/** + * Creates a configurable particle system with emitter controls and pooling. + * Use for: explosions, weather, ambient effects. + * Performance: O(particles) per update. + * Import: gameplay/particleSystem.ts + */ +export function createParticleSystem(options: ParticleSystemOptions): ParticleSystem; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/particleSystem.ts b/examples/particleSystem.ts new file mode 100644 index 0000000..1476d0e --- /dev/null +++ b/examples/particleSystem.ts @@ -0,0 +1,22 @@ +import { createParticleSystem } from '../src/index.js'; + +const system = createParticleSystem({ + emitter: { + position: { x: 0, y: 0 }, + rate: 20, + life: { min: 0.6, max: 1.2 }, + speed: { min: 2, max: 4 }, + angle: { min: 0, max: Math.PI * 2 }, + size: { min: 0.5, max: 1.5 }, + acceleration: { x: 0, y: -3 }, + }, + maxParticles: 100, +}); + +for (let i = 0; i < 5; i += 1) { + system.update(1 / 30); + console.log(`frame ${i}:`, system.getParticles().length, 'particles'); +} + +system.burst(10); +console.log('after burst:', system.getParticles().length, 'particles'); diff --git a/src/gameplay/particleSystem.ts b/src/gameplay/particleSystem.ts new file mode 100644 index 0000000..b857faf --- /dev/null +++ b/src/gameplay/particleSystem.ts @@ -0,0 +1,331 @@ +import type { Point, Vector2D } from '../types.js'; + +export interface RangeOptions { + min: number; + max: number; +} + +export interface ParticleEmitterOptions { + /** Particles spawned per second. */ + rate?: number; + /** Emitter origin for new particles. */ + position?: Point; + /** Lifetime range in seconds for each particle. */ + life: RangeOptions; + /** Initial speed magnitude range. */ + speed?: RangeOptions; + /** Emission angle range in radians. */ + angle?: RangeOptions; + /** Initial particle size range. */ + size?: RangeOptions; + /** Constant acceleration applied each update (e.g., gravity). */ + acceleration?: Vector2D; +} + +export interface ParticleSystemOptions { + emitter: ParticleEmitterOptions; + /** Maximum particles retained in the system at once. Defaults to 500. */ + maxParticles?: number; + /** Deterministic random function override (0-1 range). */ + random?: () => number; +} + +export interface Particle { + position: Point; + velocity: Vector2D; + age: number; + life: number; + size: number; +} + +export interface ParticleSystem { + update(delta: number): void; + burst(count: number): void; + getParticles(): readonly Particle[]; + setEmitter(options: Partial): void; + setPosition(position: Point): void; + reset(): void; +} + +interface NormalizedRange { + min: number; + max: number; +} + +interface InternalParticle extends Particle {} + +interface EmitterState { + position: Point; + rate: number; + life: NormalizedRange; + speed: NormalizedRange; + angle: NormalizedRange; + size: NormalizedRange; + acceleration: Vector2D; +} + +function assertFinite(value: number, label: string): void { + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error(`${label} must be a finite number.`); + } +} + +function assertPoint(point: Point, label: string): void { + assertFinite(point.x, `${label}.x`); + assertFinite(point.y, `${label}.y`); +} + +function normalizeRange( + range: RangeOptions, + label: string, + constraints: { minValue?: number; inclusive?: boolean } = {} +): NormalizedRange { + assertFinite(range.min, `${label}.min`); + assertFinite(range.max, `${label}.max`); + if (range.min > range.max) { + throw new Error(`${label}.min must be <= ${label}.max.`); + } + + if (Object.prototype.hasOwnProperty.call(constraints, 'minValue')) { + const { minValue = 0, inclusive = false } = constraints; + if (inclusive) { + if (range.min < minValue || range.max < minValue) { + throw new Error(`${label} values must be >= ${minValue}.`); + } + } else if (range.min <= minValue || range.max <= minValue) { + throw new Error(`${label} values must be > ${minValue}.`); + } + } + + return { min: range.min, max: range.max }; +} + +function sampleRange(range: NormalizedRange, random: () => number): number { + const span = range.max - range.min; + if (span <= 0) { + return range.min; + } + const value = random(); + if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { + throw new Error('random() must return a finite number.'); + } + return range.min + value * span; +} + +function normalizeEmitter(options: ParticleEmitterOptions): EmitterState { + const position = options.position ?? { x: 0, y: 0 }; + assertPoint(position, 'emitter.position'); + + const rate = options.rate ?? 0; + assertFinite(rate, 'emitter.rate'); + if (rate < 0) { + throw new Error('emitter.rate must be >= 0.'); + } + + const life = normalizeRange(options.life, 'emitter.life', { minValue: 0, inclusive: false }); + const speed = options.speed + ? normalizeRange(options.speed, 'emitter.speed', { minValue: 0, inclusive: true }) + : { min: 0, max: 0 }; + const angle = options.angle ? normalizeRange(options.angle, 'emitter.angle') : { min: 0, max: Math.PI * 2 }; + const size = options.size + ? normalizeRange(options.size, 'emitter.size', { minValue: 0, inclusive: true }) + : { min: 1, max: 1 }; + const acceleration = options.acceleration ?? { x: 0, y: 0 }; + assertFinite(acceleration.x, 'emitter.acceleration.x'); + assertFinite(acceleration.y, 'emitter.acceleration.y'); + + return { + position: { x: position.x, y: position.y }, + rate, + life, + speed, + angle, + size, + acceleration: { x: acceleration.x, y: acceleration.y }, + }; +} + +function updateEmitter(state: EmitterState, options: Partial): void { + if (options.position) { + assertPoint(options.position, 'emitter.position'); + state.position = { x: options.position.x, y: options.position.y }; + } + if (options.rate !== undefined) { + assertFinite(options.rate, 'emitter.rate'); + if (options.rate < 0) { + throw new Error('emitter.rate must be >= 0.'); + } + state.rate = options.rate; + } + if (options.life) { + state.life = normalizeRange(options.life, 'emitter.life', { minValue: 0, inclusive: false }); + } + if (options.speed) { + state.speed = normalizeRange(options.speed, 'emitter.speed', { minValue: 0, inclusive: true }); + } + if (options.angle) { + state.angle = normalizeRange(options.angle, 'emitter.angle'); + } + if (options.size) { + state.size = normalizeRange(options.size, 'emitter.size', { minValue: 0, inclusive: true }); + } + if (options.acceleration) { + assertFinite(options.acceleration.x, 'emitter.acceleration.x'); + assertFinite(options.acceleration.y, 'emitter.acceleration.y'); + state.acceleration = { x: options.acceleration.x, y: options.acceleration.y }; + } +} + +/** + * Creates a particle system with configurable emitters and pooling. + * Useful for: explosions, weather effects, and ambient VFX. + */ +export function createParticleSystem({ + emitter, + maxParticles = 500, + random = Math.random, +}: ParticleSystemOptions): ParticleSystem { + if (!emitter) { + throw new Error('emitter configuration is required.'); + } + assertFinite(maxParticles, 'maxParticles'); + if (!Number.isInteger(maxParticles) || maxParticles <= 0) { + throw new Error('maxParticles must be a positive integer.'); + } + if (typeof random !== 'function') { + throw new Error('random must be a function.'); + } + + const emitterState = normalizeEmitter(emitter); + const particles: InternalParticle[] = []; + const pool: InternalParticle[] = []; + let spawnAccumulator = 0; + + function acquireParticle(): InternalParticle { + const recycled = pool.pop(); + if (recycled) { + return recycled; + } + return { + position: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + age: 0, + life: 0, + size: 1, + }; + } + + function releaseParticle(particle: InternalParticle): void { + pool.push(particle); + } + + function spawnParticle(): void { + if (particles.length >= maxParticles) { + return; + } + const particle = acquireParticle(); + const angle = sampleRange(emitterState.angle, random); + const speed = sampleRange(emitterState.speed, random); + const life = sampleRange(emitterState.life, random); + const size = sampleRange(emitterState.size, random); + + particle.position.x = emitterState.position.x; + particle.position.y = emitterState.position.y; + particle.velocity.x = Math.cos(angle) * speed; + particle.velocity.y = Math.sin(angle) * speed; + particle.age = 0; + particle.life = life; + particle.size = size; + + particles.push(particle); + } + + function spawnMultiple(count: number): void { + for (let i = 0; i < count; i += 1) { + spawnParticle(); + } + } + + function update(delta: number): void { + assertFinite(delta, 'delta'); + if (delta < 0) { + throw new Error('delta must be >= 0.'); + } + + spawnAccumulator += emitterState.rate * delta; + const spawnCount = Math.floor(spawnAccumulator); + if (spawnCount > 0) { + spawnAccumulator -= spawnCount; + spawnMultiple(spawnCount); + } + + for (let i = particles.length - 1; i >= 0; i -= 1) { + const particle = particles[i]; + particle.age += delta; + if (particle.age >= particle.life) { + const removed = particles.pop(); + if (removed && removed !== particle) { + particles[i] = removed; + } + releaseParticle(particle); + continue; + } + + particle.velocity.x += emitterState.acceleration.x * delta; + particle.velocity.y += emitterState.acceleration.y * delta; + particle.position.x += particle.velocity.x * delta; + particle.position.y += particle.velocity.y * delta; + } + } + + function burst(count: number): void { + assertFinite(count, 'count'); + if (!Number.isInteger(count) || count < 0) { + throw new Error('count must be a non-negative integer.'); + } + spawnMultiple(count); + } + + function getParticles(): readonly Particle[] { + return particles; + } + + function setEmitter(options: Partial): void { + if (!options || Object.keys(options).length === 0) { + return; + } + updateEmitter(emitterState, options); + } + + function setPosition(position: Point): void { + assertPoint(position, 'position'); + emitterState.position = { x: position.x, y: position.y }; + } + + function reset(): void { + while (particles.length > 0) { + const particle = particles.pop(); + if (particle) { + releaseParticle(particle); + } + } + spawnAccumulator = 0; + } + + return { + update, + burst, + getParticles, + setEmitter, + setPosition, + reset, + }; +} + +/** @internal */ +export const __internals = { + normalizeEmitter, + updateEmitter, + sampleRange, + normalizeRange, +}; diff --git a/src/index.ts b/src/index.ts index a1c23b9..99b5d98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,7 @@ export const examples = { createDeltaTimeManager: 'examples/deltaTime.ts', createFixedTimestepLoop: 'examples/fixedTimestep.ts', createCamera2D: 'examples/camera2D.ts', + createParticleSystem: 'examples/particleSystem.ts', }, ai: { seek: 'examples/steering.ts', @@ -494,6 +495,24 @@ export type { CameraShakeOptions, } from './gameplay/camera2D.js'; +/** + * Particle system helper with emitter configuration and pooling. + * + * Example file: examples/particleSystem.ts + */ +export { createParticleSystem } from './gameplay/particleSystem.js'; + +/** + * Particle system configuration and runtime types. + */ +export type { + RangeOptions as ParticleRangeOptions, + ParticleEmitterOptions, + ParticleSystemOptions, + Particle, + ParticleSystem, +} from './gameplay/particleSystem.js'; + // ============================================================================ // 🔍 SEARCH & STRING UTILITIES diff --git a/tests/index.test.ts b/tests/index.test.ts index 8b18a1c..b495ef8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -27,6 +27,7 @@ describe('package entry point', () => { expect(examples.gameplay.createDeltaTimeManager).toBe('examples/deltaTime.ts'); expect(examples.gameplay.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts'); expect(examples.gameplay.createCamera2D).toBe('examples/camera2D.ts'); + expect(examples.gameplay.createParticleSystem).toBe('examples/particleSystem.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -97,6 +98,7 @@ describe('package entry point', () => { | 'createDeltaTimeManager' | 'createFixedTimestepLoop' | 'createCamera2D' + | 'createParticleSystem' >(); }); }); diff --git a/tests/particleSystem.test.ts b/tests/particleSystem.test.ts new file mode 100644 index 0000000..df02633 --- /dev/null +++ b/tests/particleSystem.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { createParticleSystem } from '../src/index.js'; + +describe('createParticleSystem', () => { + it('spawns particles based on rate and updates movement', () => { + const system = createParticleSystem({ + emitter: { + position: { x: 0, y: 0 }, + rate: 10, + life: { min: 1, max: 1 }, + speed: { min: 5, max: 5 }, + angle: { min: 0, max: 0 }, + size: { min: 1, max: 1 }, + acceleration: { x: 0, y: -9.81 }, + }, + maxParticles: 20, + }); + + system.update(0.5); + let particles = system.getParticles(); + expect(particles.length).toBe(5); + particles.forEach((particle) => { + expect(particle.age).toBeCloseTo(0.5, 5); + expect(particle.position.x).toBeCloseTo(2.5, 5); + expect(particle.position.y).toBeCloseTo(-2.4525, 4); + }); + + system.update(0.5); + particles = system.getParticles(); + expect(particles.length).toBe(5); + particles.forEach((particle) => { + expect(particle.age).toBeLessThanOrEqual(0.5); + }); + }); + + it('supports bursts and respects maxParticles', () => { + const system = createParticleSystem({ + emitter: { + life: { min: 1, max: 1 }, + speed: { min: 0, max: 0 }, + angle: { min: 0, max: 0 }, + }, + maxParticles: 3, + }); + + system.burst(5); + expect(system.getParticles().length).toBe(3); + }); + + it('updates emitter configuration and resets state', () => { + const system = createParticleSystem({ + emitter: { + position: { x: 0, y: 0 }, + rate: 0, + life: { min: 1, max: 1 }, + }, + }); + + system.setEmitter({ rate: 2, life: { min: 0.5, max: 0.5 }, acceleration: { x: 1, y: 0 } }); + system.setPosition({ x: 10, y: 5 }); + system.burst(1); + let particles = system.getParticles(); + expect(particles.length).toBe(1); + expect(particles[0].position).toEqual({ x: 10, y: 5 }); + + system.reset(); + particles = system.getParticles(); + expect(particles.length).toBe(0); + }); + + it('validates inputs', () => { + expect(() => createParticleSystem({ + emitter: { + life: { min: -1, max: 1 }, + }, + })).toThrow(/life/); + + const system = createParticleSystem({ + emitter: { + life: { min: 1, max: 1 }, + }, + }); + + expect(() => system.update(-0.1)).toThrow(/delta/); + expect(() => system.burst(-1)).toThrow(/count/); + }); +});