import type { ParticleEmitter2D, ParticleEmitterData } from '@flighthq/types/contract'; import type { ParticleObject } from '@flighthq/types/contract'; import { applyParticleCollisions, applyParticleObjectCollisions } from './applyParticleCollisions'; import { createParticleEmitterState, ensureParticleEmitterStateCapacity } from './particleEmitterState'; import { createParticleObjectsState } from './particleObjectsState'; // Node-free SoA emitter fixture: applyParticleCollisions reads only `emitter.data`, so the display // node from @flighthq/particleemitter is not needed to unit-test the collision pass. function createEmitterFixture(capacity: number): ParticleEmitter2D { const data: ParticleEmitterData = { alphas: new Float32Array(capacity), atlas: null, colors: new Float32Array(capacity * 3), ids: new Uint16Array(capacity), particleCount: 0, positionsZ: new Float32Array(capacity), transforms: new Float32Array(capacity * 4), velocities: new Float32Array(capacity * 2), worldSpace: false, }; return { data, x: 0, y: 0 } as unknown as ParticleEmitter2D; } // One particle at a known position with a known velocity. function place(px: number, py: number, vx: number, vy: number) { const emitter = createEmitterFixture(1); const state = createParticleEmitterState(); ensureParticleEmitterStateCapacity(state, 1, false); emitter.data.particleCount = 1; emitter.data.transforms[0] = px; emitter.data.transforms[1] = py; state.velocities[0] = vx; state.velocities[1] = vy; return { emitter, state }; } describe('applyParticleCollisions', () => { describe('circle', () => { it('exclude mode keeps a particle outside the disc', () => { const { emitter, state } = place(5, 0, -10, 0); // inside radius-10 disc at origin, moving inward applyParticleCollisions(emitter, state, [ { kind: 'CircleCollider', x: 0, y: 0, radius: 10, mode: 'exclude', restitution: 1 }, ]); const d = Math.hypot(emitter.data.transforms[0], emitter.data.transforms[1]); expect(d).toBeCloseTo(10); // pushed to the surface expect(state.velocities[0]).toBeCloseTo(10); // bounced outward }); it('contain mode keeps a particle inside the boundary', () => { const { emitter, state } = place(20, 0, 10, 0); // outside radius-10 boundary, moving further out applyParticleCollisions(emitter, state, [{ kind: 'CircleCollider', x: 0, y: 0, radius: 10, mode: 'contain' }]); const d = Math.hypot(emitter.data.transforms[0], emitter.data.transforms[1]); expect(d).toBeCloseTo(10); }); }); describe('integration', () => { it('is a no-op with no colliders', () => { const { emitter, state } = place(0, 520, 0, 100); applyParticleCollisions(emitter, state, []); expect(emitter.data.transforms[1]).toBe(520); }); }); describe('plane (floor)', () => { it('pushes a penetrating particle back to the surface and stops it (restitution 0)', () => { // Floor at y = 500, valid region is y <= 500 (normal points up = -y). const { emitter, state } = place(0, 520, 0, 100); // 20px below floor, moving down applyParticleCollisions(emitter, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]); expect(emitter.data.transforms[1]).toBeCloseTo(500); // snapped to surface expect(state.velocities[1]).toBeCloseTo(0); // normal velocity killed }); it('bounces with restitution', () => { const { emitter, state } = place(0, 520, 0, 100); applyParticleCollisions(emitter, state, [ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, restitution: 0.5 }, ]); expect(state.velocities[1]).toBeCloseTo(-50); // 100 down → 50 up }); it('applies friction to the tangential component', () => { const { emitter, state } = place(0, 520, 80, 100); applyParticleCollisions(emitter, state, [ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, friction: 0.25 }, ]); expect(state.velocities[0]).toBeCloseTo(60); // 80 * (1 - 0.25) }); it('leaves particles above the floor untouched', () => { const { emitter, state } = place(0, 100, 0, 100); applyParticleCollisions(emitter, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]); expect(emitter.data.transforms[1]).toBe(100); expect(state.velocities[1]).toBe(100); }); }); describe('plane (3D normal)', () => { it('plane with nz pushes a penetrating particle back along the 3D normal', () => { // Plane with normal pointing in -z, distance -100 → valid region z <= 100 const { emitter, state } = place(0, 0, 0, 0); emitter.data.positionsZ[0] = 120; // 20 units past the plane, moving +z state.velocities[2] = 50; applyParticleCollisions(emitter, state, [{ kind: 'PlaneCollider', nx: 0, ny: 0, nz: -1, distance: -100 }]); expect(emitter.data.positionsZ[0]).toBeCloseTo(100); // snapped to surface expect(state.velocities[2]).toBeCloseTo(0); // normal velocity killed (restitution 0) }); }); describe('sphere', () => { it('exclude mode keeps a particle outside the sphere', () => { const { emitter, state } = place(3, 4, -10, 0); // inside radius-10 sphere at origin emitter.data.positionsZ[0] = 0; state.velocities[2] = 0; applyParticleCollisions(emitter, state, [ { kind: 'SphereCollider', x: 0, y: 0, z: 0, radius: 10, mode: 'exclude', restitution: 1 }, ]); const d = Math.sqrt( emitter.data.transforms[0] ** 2 + emitter.data.transforms[1] ** 2 + emitter.data.positionsZ[0] ** 2, ); expect(d).toBeCloseTo(10); // pushed to the surface }); it('contain mode keeps a particle inside the sphere', () => { const { emitter, state } = place(20, 0, 10, 0); // outside radius-10 sphere emitter.data.positionsZ[0] = 0; state.velocities[2] = 0; applyParticleCollisions(emitter, state, [ { kind: 'SphereCollider', x: 0, y: 0, z: 0, radius: 10, mode: 'contain' }, ]); const d = Math.sqrt( emitter.data.transforms[0] ** 2 + emitter.data.transforms[1] ** 2 + emitter.data.positionsZ[0] ** 2, ); expect(d).toBeCloseTo(10); }); it('sphere contain with 3D offset', () => { const { emitter, state } = place(0, 0, 0, 0); emitter.data.positionsZ[0] = 25; // 25 units from center at (0,0,0), outside radius 10 state.velocities[2] = 10; applyParticleCollisions(emitter, state, [ { kind: 'SphereCollider', x: 0, y: 0, z: 0, radius: 10, mode: 'contain' }, ]); expect(emitter.data.positionsZ[0]).toBeCloseTo(10); // clamped to surface }); }); describe('rect', () => { it('contain mode clamps particles to the bounds and reflects velocity', () => { // Box centered (0,0), 100×100 → bounds ±50. const { emitter, state } = place(80, 0, 30, 0); applyParticleCollisions(emitter, state, [ { kind: 'RectangleCollider', x: 0, y: 0, width: 100, height: 100, mode: 'contain', restitution: 1 }, ]); expect(emitter.data.transforms[0]).toBeCloseTo(50); expect(state.velocities[0]).toBeCloseTo(-30); }); it('exclude mode pushes a particle out of a solid box along the shallowest axis', () => { // Particle just inside the right edge of a box centered (0,0), 100×100. const { emitter, state } = place(40, 0, 0, 0); applyParticleCollisions(emitter, state, [ { kind: 'RectangleCollider', x: 0, y: 0, width: 100, height: 100, mode: 'exclude' }, ]); expect(emitter.data.transforms[0]).toBeCloseTo(50); // pushed out the near (right) edge }); }); }); function makeObject(): ParticleObject { return { alpha: 1, blendMode: null, colorScaleBias: null, rotation: 0, scaleX: 1, scaleY: 1, visible: true, x: 0, y: 0, } as unknown as ParticleObject; } // Build an object-pool collision scenario: a single live object at (px,py) with // velocity (vx,vy) in slot 0; slot 1 is left dead. function placeObject(px: number, py: number, vx: number, vy: number) { const objects = [makeObject(), makeObject()]; const state = createParticleObjectsState(2); state.lifetimes[1] = 0; // slot 0 age state.lifetimes[1 * 2 + 1] = 0; // slot 1 maxAge = 0 → dead state.lifetimes[0 * 2 + 1] = 100; // slot 0 maxAge > 0 → alive objects[0].x = px; objects[0].y = py; state.velocities[0] = vx; state.velocities[1] = vy; return { objects, state }; } describe('applyParticleObjectCollisions', () => { it('pushes a live object back onto a floor plane and kills its normal velocity', () => { const { objects, state } = placeObject(0, 520, 0, 100); // below floor y=500, moving down applyParticleObjectCollisions(objects, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]); expect(objects[0].y).toBeCloseTo(500); // snapped to surface expect(state.velocities[1]).toBeCloseTo(0); // normal velocity killed }); it('bounces a live object with restitution', () => { const { objects, state } = placeObject(0, 520, 0, 100); applyParticleObjectCollisions(objects, state, [ { kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500, restitution: 0.5 }, ]); expect(state.velocities[1]).toBeCloseTo(-50); // 100 down → 50 up }); it('skips dead slots', () => { const { objects, state } = placeObject(0, 100, 0, 0); // slot 0 above the floor → untouched objects[1].x = 0; objects[1].y = 520; // dead slot 1 is below the floor but must be ignored applyParticleObjectCollisions(objects, state, [{ kind: 'PlaneCollider', nx: 0, ny: -1, distance: -500 }]); expect(objects[1].y).toBe(520); // dead object left where it was }); it('is a no-op with no colliders', () => { const { objects, state } = placeObject(0, 520, 0, 100); applyParticleObjectCollisions(objects, state, []); expect(objects[0].y).toBe(520); expect(state.velocities[1]).toBe(100); }); });