import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { EventBus, World, MockRendererAdapter } from '@babylonjsmarket/ecs';
import type { Entity } from '@babylonjsmarket/ecs';
import { PoolComponent } from '@babylonjsmarket/arcade';
import { MeshPrimitiveComponent } from '../MeshPrimitive/MeshPrimitive.defs';
import { MeshPrimitiveSystem } from '../MeshPrimitive/MeshPrimitive';
import * as core from './Spin.core';
import { SpinSystem, SpinComponent, DEFAULT_SPIN, hashUnit, tumbleRate, type SpinInput } from './Spin';
type Vec3 = [number, number, number];
describe('SpinComponent', () => {
it('defaults to half a radian a second about Y, no tumble', () => {
expect(new SpinComponent().serialize()).toEqual({ rate: [0, 0.5, 0], tumble: 0 });
expect(new SpinComponent().serialize()).toEqual(DEFAULT_SPIN);
});
it('serialize() round-trips every field', () => {
const authored: Required<SpinInput> = { rate: [0.55, 0.35, 0], tumble: 2.4 };
expect(new SpinComponent(new SpinComponent(authored).serialize()).serialize()).toEqual(authored);
});
it('copies its rate in and out', () => {
const shared: Vec3 = [1, 2, 3];
const a = new SpinComponent({ rate: shared });
a.rate[0] = 9;
expect(shared[0]).toBe(1);
a.serialize().rate![1] = 9;
expect(a.rate[1]).toBe(2);
new SpinComponent().rate[1] = 9;
expect(DEFAULT_SPIN.rate).toEqual([0, 0.5, 0]);
});
it('the entry file re-exports the tumble arithmetic', () => {
expect(hashUnit).toBe(core.hashUnit);
expect(tumbleRate).toBe(core.tumbleRate);
});
});
describe('SpinSystem', () => {
let eventBus: EventBus;
let renderer: MockRendererAdapter;
let world: World;
let system: SpinSystem;
beforeEach(() => {
eventBus = new EventBus();
renderer = new MockRendererAdapter();
world = new World({ eventBus, renderer, detectRaces: true });
world.addSystem(new MeshPrimitiveSystem(eventBus));
system = new SpinSystem(eventBus);
world.addSystem(system);
world.initialize();
});
afterEach(() => world.destroy());
function spinner(id: string, data: SpinInput = {}): Entity {
const e = world.createEntity(id);
e.add(new MeshPrimitiveComponent({ primitive: 'torus' }));
e.add(new SpinComponent(data));
return e;
}
function rockPool(tumble: number): void {
world.registerPool('rock', {
size: 1,
build: (e) => {
e.add(new MeshPrimitiveComponent({ primitive: 'sphere' }));
e.add(new SpinComponent({ tumble }));
},
});
}
const rot = (e: Entity) => e.get(MeshPrimitiveComponent)!.rotation;
const writes = (e: Entity) =>
renderer.calls.filter((c) => c.method === 'setMeshRotation' && c.args[0] === e.get(MeshPrimitiveComponent)!.handle);
const unit = (v: number[]) => {
const l = Math.hypot(v[0], v[1], v[2]);
return [v[0] / l, v[1] / l, v[2] / l];
};
it('runs late in the frame', () => {
expect(system.priority).toBe(15);
});
it('turns the mesh at its rate, writing the rotation every frame after the first', () => {
const ring = spinner('ring', { rate: [1, 2, 0] });
renderer.calls.length = 0;
world.update(0.25);
expect(rot(ring)).toEqual([0.25, 0.5, 0]);
expect(writes(ring)).toHaveLength(0);
world.update(0.25);
expect(rot(ring)).toEqual([0.5, 1, 0]);
expect(writes(ring).map((c) => c.args.slice(1))).toEqual([[0.5, 1, 0]]);
});
it('keeps the angle within one turn', () => {
const ring = spinner('ring', { rate: [0, 4, -4] });
world.update(2);
expect(rot(ring)[1]).toBeCloseTo(8 - 2 * Math.PI, 12);
expect(rot(ring)[2]).toBeCloseTo(-(8 - 2 * Math.PI), 12);
});
it('a live edit to rate takes effect on the next frame', () => {
const ring = spinner('ring', { rate: [0, 1, 0] });
world.update(0.25);
ring.get(SpinComponent)!.rate = [2, 0, 0];
world.update(0.25);
expect(rot(ring)).toEqual([0.5, 0.25, 0]);
});
describe('tumble', () => {
it('ignores rate and turns at `tumble` rad/s about an axis of its own', () => {
const rock = spinner('rock', { rate: [5, 5, 5], tumble: 2 });
world.update(0.1);
const turned = rot(rock);
expect(Math.hypot(turned[0], turned[1], turned[2])).toBeCloseTo(0.2, 12);
expect(turned).toEqual(tumbleRate('rock', 1, 2).map((r) => r * 0.1));
});
it('two rocks never tumble in lockstep', () => {
const a = spinner('rock-a', { tumble: 2 });
const b = spinner('rock-b', { tumble: 2 });
world.update(0.1);
expect(unit(rot(a))).not.toEqual(unit(rot(b)));
});
it('a pooled rock picks a fresh axis every time it is handed out, at the same speed', () => {
rockPool(2);
const rock = world.acquire('rock')!;
world.update(0.1);
const before = [...rot(rock)];
world.update(0.1);
const firstAxis = unit(rot(rock).map((r, k) => r - before[k]));
world.removeEntity(rock);
world.acquire('rock');
const parkedAt = [...rot(rock)];
world.update(0.1);
const step = rot(rock).map((r, k) => r - parkedAt[k]);
expect(Math.hypot(step[0], step[1], step[2])).toBeCloseTo(0.2, 9);
expect(unit(step)).not.toEqual(firstAxis);
});
});
describe('the spawn frame', () => {
it('a pooled rock is posed by MeshPrimitive’s restore on the frame it is acquired — no race', () => {
rockPool(2);
const rock = world.acquire('rock')!;
renderer.calls.length = 0;
world.update(1 / 60);
const first = writes(rock);
expect(first).toHaveLength(1);
expect(first[0].args.slice(1)).toEqual([...rot(rock)]);
expect(world.raceDetector!.warnings).toHaveLength(0);
renderer.calls.length = 0;
world.update(1 / 60);
expect(writes(rock)).toHaveLength(1);
expect(writes(rock)[0].args.slice(1)).toEqual([...rot(rock)]);
expect(world.raceDetector!.warnings).toHaveLength(0);
});
});
it('ignores a Pool blueprint', () => {
const blueprint = world.createEntity('Rocks');
blueprint.add(new PoolComponent({ name: 'rock' }));
blueprint.add(new MeshPrimitiveComponent({ primitive: 'sphere' }));
blueprint.add(new SpinComponent({ rate: [1, 1, 1] }));
world.update(0.25);
expect(system.getEntities()).not.toContain(blueprint);
expect(rot(blueprint)).toEqual([0, 0, 0]);
});
});