Files
synthesis/tests/ecs.test.ts
Денис Шкабатур ddbca12740 Phase 2: ECS foundation — world, components, systems, bridge
- 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>
2026-02-12 12:34:06 +03:00

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);
});
});