- bitECS world with time tracking (delta, elapsed, tick) - 5 components: Position, Velocity, SpriteRef, Health, ChemicalComposition - Movement system (velocity * delta) + bounce system (boundary reflection) - Health system with damage, healing, death detection - Entity factory (createGameEntity/removeGameEntity) - Phaser bridge: polling sync creates/destroys/updates circle sprites - GameScene: 20 colored circles bouncing at 60fps - BootScene: click-to-start transition, version bump to v0.2.0 - 39 ECS unit tests passing (74 total) Co-authored-by: Cursor <cursoragent@cursor.com>
632 lines
17 KiB
TypeScript
632 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
|
import {
|
|
Position,
|
|
Velocity,
|
|
Health,
|
|
SpriteRef,
|
|
ChemicalComposition,
|
|
} from '../src/ecs/components';
|
|
import {
|
|
createGameWorld,
|
|
updateTime,
|
|
type World,
|
|
} from '../src/ecs/world';
|
|
import {
|
|
movementSystem,
|
|
bounceSystem,
|
|
} from '../src/ecs/systems/movement';
|
|
import {
|
|
healthSystem,
|
|
applyDamage,
|
|
applyHealing,
|
|
} from '../src/ecs/systems/health';
|
|
import {
|
|
createGameEntity,
|
|
removeGameEntity,
|
|
} from '../src/ecs/factory';
|
|
|
|
// ─── World ──────────────────────────────────────────────────────
|
|
|
|
describe('World', () => {
|
|
it('creates a game world with zeroed time', () => {
|
|
const gw = createGameWorld();
|
|
expect(gw.world).toBeDefined();
|
|
expect(gw.time.delta).toBe(0);
|
|
expect(gw.time.elapsed).toBe(0);
|
|
expect(gw.time.tick).toBe(0);
|
|
});
|
|
|
|
it('updates time tracking correctly', () => {
|
|
const gw = createGameWorld();
|
|
|
|
updateTime(gw, 16.67);
|
|
expect(gw.time.delta).toBeCloseTo(16.67);
|
|
expect(gw.time.elapsed).toBeCloseTo(16.67);
|
|
expect(gw.time.tick).toBe(1);
|
|
|
|
updateTime(gw, 16.67);
|
|
expect(gw.time.delta).toBeCloseTo(16.67);
|
|
expect(gw.time.elapsed).toBeCloseTo(33.34);
|
|
expect(gw.time.tick).toBe(2);
|
|
});
|
|
|
|
it('tracks varying delta times', () => {
|
|
const gw = createGameWorld();
|
|
|
|
updateTime(gw, 10);
|
|
updateTime(gw, 20);
|
|
updateTime(gw, 30);
|
|
|
|
expect(gw.time.delta).toBe(30);
|
|
expect(gw.time.elapsed).toBe(60);
|
|
expect(gw.time.tick).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ─── Components ─────────────────────────────────────────────────
|
|
|
|
describe('Components', () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('stores Position data for entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = 200;
|
|
|
|
expect(Position.x[eid]).toBe(100);
|
|
expect(Position.y[eid]).toBe(200);
|
|
});
|
|
|
|
it('stores Velocity data for entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Velocity);
|
|
Velocity.vx[eid] = 50;
|
|
Velocity.vy[eid] = -30;
|
|
|
|
expect(Velocity.vx[eid]).toBe(50);
|
|
expect(Velocity.vy[eid]).toBe(-30);
|
|
});
|
|
|
|
it('stores Health data for entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 80;
|
|
Health.max[eid] = 100;
|
|
|
|
expect(Health.current[eid]).toBe(80);
|
|
expect(Health.max[eid]).toBe(100);
|
|
});
|
|
|
|
it('stores SpriteRef data for entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, SpriteRef);
|
|
SpriteRef.color[eid] = 0x00ff88;
|
|
SpriteRef.radius[eid] = 12;
|
|
|
|
expect(SpriteRef.color[eid]).toBe(0x00ff88);
|
|
expect(SpriteRef.radius[eid]).toBe(12);
|
|
});
|
|
|
|
it('stores ChemicalComposition for entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, ChemicalComposition);
|
|
ChemicalComposition.primaryElement[eid] = 11; // Na
|
|
|
|
expect(ChemicalComposition.primaryElement[eid]).toBe(11);
|
|
});
|
|
|
|
it('queries entities by single component', () => {
|
|
const e1 = addEntity(world);
|
|
const e2 = addEntity(world);
|
|
addComponent(world, e1, Position);
|
|
addComponent(world, e2, Position);
|
|
|
|
const entities = [...query(world, [Position])];
|
|
expect(entities).toContain(e1);
|
|
expect(entities).toContain(e2);
|
|
});
|
|
|
|
it('queries entities by multiple components', () => {
|
|
const moving = addEntity(world);
|
|
const stationary = addEntity(world);
|
|
|
|
addComponent(world, moving, Position);
|
|
addComponent(world, moving, Velocity);
|
|
addComponent(world, stationary, Position);
|
|
|
|
const movingEntities = [...query(world, [Position, Velocity])];
|
|
expect(movingEntities).toContain(moving);
|
|
expect(movingEntities).not.toContain(stationary);
|
|
});
|
|
});
|
|
|
|
// ─── Movement System ────────────────────────────────────────────
|
|
|
|
describe('Movement System', () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('updates position by velocity * delta', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = 200;
|
|
Velocity.vx[eid] = 60;
|
|
Velocity.vy[eid] = -30;
|
|
|
|
movementSystem(world, 1000); // 1 second
|
|
|
|
expect(Position.x[eid]).toBeCloseTo(160);
|
|
expect(Position.y[eid]).toBeCloseTo(170);
|
|
});
|
|
|
|
it('handles fractional delta (16.67ms ≈ 60fps)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 0;
|
|
Position.y[eid] = 0;
|
|
Velocity.vx[eid] = 100;
|
|
Velocity.vy[eid] = 100;
|
|
|
|
movementSystem(world, 16.67);
|
|
|
|
expect(Position.x[eid]).toBeCloseTo(1.667, 1);
|
|
expect(Position.y[eid]).toBeCloseTo(1.667, 1);
|
|
});
|
|
|
|
it('does not move entities without Velocity', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = 200;
|
|
|
|
movementSystem(world, 1000);
|
|
|
|
expect(Position.x[eid]).toBe(100);
|
|
expect(Position.y[eid]).toBe(200);
|
|
});
|
|
|
|
it('handles negative velocities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 500;
|
|
Position.y[eid] = 500;
|
|
Velocity.vx[eid] = -100;
|
|
Velocity.vy[eid] = -200;
|
|
|
|
movementSystem(world, 1000);
|
|
|
|
expect(Position.x[eid]).toBeCloseTo(400);
|
|
expect(Position.y[eid]).toBeCloseTo(300);
|
|
});
|
|
|
|
it('handles zero delta', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = 200;
|
|
Velocity.vx[eid] = 999;
|
|
Velocity.vy[eid] = 999;
|
|
|
|
movementSystem(world, 0);
|
|
|
|
expect(Position.x[eid]).toBe(100);
|
|
expect(Position.y[eid]).toBe(200);
|
|
});
|
|
|
|
it('moves multiple entities independently', () => {
|
|
const e1 = addEntity(world);
|
|
const e2 = addEntity(world);
|
|
|
|
addComponent(world, e1, Position);
|
|
addComponent(world, e1, Velocity);
|
|
addComponent(world, e2, Position);
|
|
addComponent(world, e2, Velocity);
|
|
|
|
Position.x[e1] = 0;
|
|
Position.y[e1] = 0;
|
|
Velocity.vx[e1] = 100;
|
|
Velocity.vy[e1] = 0;
|
|
|
|
Position.x[e2] = 100;
|
|
Position.y[e2] = 100;
|
|
Velocity.vx[e2] = 0;
|
|
Velocity.vy[e2] = -50;
|
|
|
|
movementSystem(world, 1000);
|
|
|
|
expect(Position.x[e1]).toBeCloseTo(100);
|
|
expect(Position.y[e1]).toBeCloseTo(0);
|
|
expect(Position.x[e2]).toBeCloseTo(100);
|
|
expect(Position.y[e2]).toBeCloseTo(50);
|
|
});
|
|
});
|
|
|
|
// ─── Bounce System ──────────────────────────────────────────────
|
|
|
|
describe('Bounce System', () => {
|
|
let world: World;
|
|
const WIDTH = 1280;
|
|
const HEIGHT = 720;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('bounces at left boundary (x < 0)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = -5;
|
|
Position.y[eid] = 100;
|
|
Velocity.vx[eid] = -100;
|
|
Velocity.vy[eid] = 0;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.x[eid]).toBe(0);
|
|
expect(Velocity.vx[eid]).toBe(100);
|
|
});
|
|
|
|
it('bounces at right boundary (x > width)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = WIDTH + 5;
|
|
Position.y[eid] = 100;
|
|
Velocity.vx[eid] = 100;
|
|
Velocity.vy[eid] = 0;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.x[eid]).toBe(WIDTH);
|
|
expect(Velocity.vx[eid]).toBe(-100);
|
|
});
|
|
|
|
it('bounces at top boundary (y < 0)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = -10;
|
|
Velocity.vx[eid] = 0;
|
|
Velocity.vy[eid] = -50;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.y[eid]).toBe(0);
|
|
expect(Velocity.vy[eid]).toBe(50);
|
|
});
|
|
|
|
it('bounces at bottom boundary (y > height)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 100;
|
|
Position.y[eid] = HEIGHT + 10;
|
|
Velocity.vx[eid] = 0;
|
|
Velocity.vy[eid] = 200;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.y[eid]).toBe(HEIGHT);
|
|
expect(Velocity.vy[eid]).toBe(-200);
|
|
});
|
|
|
|
it('does not affect entities within bounds', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = 640;
|
|
Position.y[eid] = 360;
|
|
Velocity.vx[eid] = 100;
|
|
Velocity.vy[eid] = -50;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.x[eid]).toBe(640);
|
|
expect(Position.y[eid]).toBe(360);
|
|
expect(Velocity.vx[eid]).toBe(100);
|
|
expect(Velocity.vy[eid]).toBe(-50);
|
|
});
|
|
|
|
it('handles corner bounce (both axes out of bounds)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
Position.x[eid] = -5;
|
|
Position.y[eid] = -10;
|
|
Velocity.vx[eid] = -100;
|
|
Velocity.vy[eid] = -200;
|
|
|
|
bounceSystem(world, WIDTH, HEIGHT);
|
|
|
|
expect(Position.x[eid]).toBe(0);
|
|
expect(Position.y[eid]).toBe(0);
|
|
expect(Velocity.vx[eid]).toBe(100);
|
|
expect(Velocity.vy[eid]).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ─── Health System ──────────────────────────────────────────────
|
|
|
|
describe('Health System', () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('detects entities with health = 0', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 0;
|
|
Health.max[eid] = 100;
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).toContain(eid);
|
|
});
|
|
|
|
it('detects entities with negative health', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = -50;
|
|
Health.max[eid] = 100;
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).toContain(eid);
|
|
});
|
|
|
|
it('does not flag healthy entities', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 50;
|
|
Health.max[eid] = 100;
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).not.toContain(eid);
|
|
});
|
|
|
|
it('returns empty array when all entities are healthy', () => {
|
|
const e1 = addEntity(world);
|
|
const e2 = addEntity(world);
|
|
addComponent(world, e1, Health);
|
|
addComponent(world, e2, Health);
|
|
Health.current[e1] = 100;
|
|
Health.max[e1] = 100;
|
|
Health.current[e2] = 1;
|
|
Health.max[e2] = 100;
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).toHaveLength(0);
|
|
});
|
|
|
|
it('returns multiple dead entities', () => {
|
|
const e1 = addEntity(world);
|
|
const e2 = addEntity(world);
|
|
const e3 = addEntity(world);
|
|
addComponent(world, e1, Health);
|
|
addComponent(world, e2, Health);
|
|
addComponent(world, e3, Health);
|
|
Health.current[e1] = 0;
|
|
Health.max[e1] = 100;
|
|
Health.current[e2] = -10;
|
|
Health.max[e2] = 50;
|
|
Health.current[e3] = 50;
|
|
Health.max[e3] = 100;
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).toContain(e1);
|
|
expect(dead).toContain(e2);
|
|
expect(dead).not.toContain(e3);
|
|
expect(dead).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ─── Damage & Healing ───────────────────────────────────────────
|
|
|
|
describe('Damage and Healing', () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('applies damage correctly', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 100;
|
|
Health.max[eid] = 100;
|
|
|
|
applyDamage(eid, 30);
|
|
expect(Health.current[eid]).toBe(70);
|
|
});
|
|
|
|
it('allows overkill (negative health)', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 10;
|
|
Health.max[eid] = 100;
|
|
|
|
applyDamage(eid, 50);
|
|
expect(Health.current[eid]).toBe(-40);
|
|
});
|
|
|
|
it('applies healing correctly', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 50;
|
|
Health.max[eid] = 100;
|
|
|
|
applyHealing(eid, 30);
|
|
expect(Health.current[eid]).toBe(80);
|
|
});
|
|
|
|
it('caps healing at max health', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 90;
|
|
Health.max[eid] = 100;
|
|
|
|
applyHealing(eid, 50);
|
|
expect(Health.current[eid]).toBe(100);
|
|
});
|
|
|
|
it('full damage → heal → kill lifecycle', () => {
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Health);
|
|
Health.current[eid] = 100;
|
|
Health.max[eid] = 100;
|
|
|
|
applyDamage(eid, 60);
|
|
expect(Health.current[eid]).toBe(40);
|
|
|
|
applyHealing(eid, 20);
|
|
expect(Health.current[eid]).toBe(60);
|
|
|
|
applyDamage(eid, 70);
|
|
expect(Health.current[eid]).toBe(-10);
|
|
|
|
const dead = healthSystem(world);
|
|
expect(dead).toContain(eid);
|
|
});
|
|
});
|
|
|
|
// ─── Entity Factory ─────────────────────────────────────────────
|
|
|
|
describe('Entity Factory', () => {
|
|
let world: World;
|
|
|
|
beforeEach(() => {
|
|
world = createWorld();
|
|
});
|
|
|
|
it('creates entity with position only', () => {
|
|
const eid = createGameEntity(world, {
|
|
position: { x: 100, y: 200 },
|
|
});
|
|
|
|
expect(typeof eid).toBe('number');
|
|
const entities = [...query(world, [Position])];
|
|
expect(entities).toContain(eid);
|
|
expect(Position.x[eid]).toBe(100);
|
|
expect(Position.y[eid]).toBe(200);
|
|
});
|
|
|
|
it('creates entity with all components', () => {
|
|
const eid = createGameEntity(world, {
|
|
position: { x: 10, y: 20 },
|
|
velocity: { vx: 30, vy: 40 },
|
|
health: { current: 80, max: 100 },
|
|
sprite: { color: 0xff0000, radius: 8 },
|
|
chemicalElement: 26, // Fe
|
|
});
|
|
|
|
expect(Position.x[eid]).toBe(10);
|
|
expect(Position.y[eid]).toBe(20);
|
|
expect(Velocity.vx[eid]).toBe(30);
|
|
expect(Velocity.vy[eid]).toBe(40);
|
|
expect(Health.current[eid]).toBe(80);
|
|
expect(Health.max[eid]).toBe(100);
|
|
expect(SpriteRef.color[eid]).toBe(0xff0000);
|
|
expect(SpriteRef.radius[eid]).toBe(8);
|
|
expect(ChemicalComposition.primaryElement[eid]).toBe(26);
|
|
});
|
|
|
|
it('creates multiple independent entities', () => {
|
|
const e1 = createGameEntity(world, {
|
|
position: { x: 0, y: 0 },
|
|
});
|
|
const e2 = createGameEntity(world, {
|
|
position: { x: 100, y: 100 },
|
|
});
|
|
|
|
expect(e1).not.toBe(e2);
|
|
expect(Position.x[e1]).toBe(0);
|
|
expect(Position.x[e2]).toBe(100);
|
|
});
|
|
|
|
it('removes entity from world', () => {
|
|
const eid = createGameEntity(world, {
|
|
position: { x: 100, y: 200 },
|
|
health: { current: 50, max: 100 },
|
|
});
|
|
|
|
expect([...query(world, [Position])]).toContain(eid);
|
|
|
|
removeGameEntity(world, eid);
|
|
|
|
expect([...query(world, [Position])]).not.toContain(eid);
|
|
});
|
|
|
|
it('creates entity without any components', () => {
|
|
const eid = createGameEntity(world, {});
|
|
expect(typeof eid).toBe('number');
|
|
});
|
|
});
|
|
|
|
// ─── Integration ────────────────────────────────────────────────
|
|
|
|
describe('Integration', () => {
|
|
it('full lifecycle: create → move → damage → die → remove', () => {
|
|
const { world } = createGameWorld();
|
|
|
|
const eid = createGameEntity(world, {
|
|
position: { x: 100, y: 100 },
|
|
velocity: { vx: 200, vy: 0 },
|
|
health: { current: 50, max: 100 },
|
|
});
|
|
|
|
// Move for 1 second
|
|
movementSystem(world, 1000);
|
|
expect(Position.x[eid]).toBeCloseTo(300);
|
|
|
|
// Lethal damage
|
|
applyDamage(eid, 60);
|
|
expect(Health.current[eid]).toBe(-10);
|
|
|
|
// Health system detects death
|
|
const dead = healthSystem(world);
|
|
expect(dead).toContain(eid);
|
|
|
|
// Remove dead entity
|
|
removeGameEntity(world, eid);
|
|
expect([...query(world, [Health])]).not.toContain(eid);
|
|
});
|
|
|
|
it('movement + bounce in sequence', () => {
|
|
const { world } = createGameWorld();
|
|
|
|
const eid = createGameEntity(world, {
|
|
position: { x: 1270, y: 360 },
|
|
velocity: { vx: 100, vy: 0 },
|
|
});
|
|
|
|
// Move past right boundary (1270 + 100*0.5 = 1320 > 1280)
|
|
movementSystem(world, 500);
|
|
expect(Position.x[eid]).toBeCloseTo(1320);
|
|
|
|
// Bounce should clamp and reverse
|
|
bounceSystem(world, 1280, 720);
|
|
expect(Position.x[eid]).toBe(1280);
|
|
expect(Velocity.vx[eid]).toBe(-100);
|
|
|
|
// Next frame: move away from boundary
|
|
movementSystem(world, 1000);
|
|
expect(Position.x[eid]).toBeCloseTo(1180);
|
|
});
|
|
});
|