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>
This commit is contained in:
Денис Шкабатур
2026-02-12 12:34:06 +03:00
parent 58ebb11747
commit ddbca12740
11 changed files with 1042 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
# Synthesis — Development Progress
> **Last updated:** 2026-02-12
> **Current phase:** Phase 1 ✅ → Ready for Phase 2
> **Current phase:** Phase 2 ✅ → Ready for Phase 3
---
@@ -28,23 +28,33 @@
- [x] 1.6 Compound data — 25 compounds with game effects (`src/data/compounds.json`)
- [x] 1.7 Unit tests — 35 passing (`tests/chemistry.test.ts`)
### Phase 2: ECS Foundation ✅
- [x] 2.1 World setup — bitECS world + time tracking (`src/ecs/world.ts`)
- [x] 2.2 Core components — Position, Velocity, SpriteRef, Health, ChemicalComposition (`src/ecs/components.ts`)
- [x] 2.3 Movement system — velocity-based + bounce (`src/ecs/systems/movement.ts`)
- [x] 2.4 Phaser ↔ bitECS sync bridge — polling-based, creates/destroys/syncs sprites (`src/ecs/bridge.ts`)
- [x] 2.5 Entity factory — createGameEntity/removeGameEntity (`src/ecs/factory.ts`)
- [x] 2.6 Health/damage system — damage, healing, death detection (`src/ecs/systems/health.ts`)
- [x] 2.7 Visual test — 20 colored circles bouncing at 60fps, GameScene (`src/scenes/GameScene.ts`)
- [x] Unit tests — 39 passing (`tests/ecs.test.ts`)
---
## In Progress
_None — ready to begin Phase 2_
_None — ready to begin Phase 3_
---
## Up Next: Phase 2ECS Foundation
## Up Next: Phase 3World Generation
- [ ] 2.1 World setup (bitECS world + time tracking)
- [ ] 2.2 Core components (Position, Velocity, SpriteRef, Health)
- [ ] 2.3 Movement system
- [ ] 2.4 Phaser ↔ bitECS sync bridge
- [ ] 2.5 Entity factory
- [ ] 2.6 Health/damage system
- [ ] 2.7 Visual test (entities moving on screen)
- [ ] 3.1 Tilemap system (Phaser Tilemap from data-driven tile definitions)
- [ ] 3.2 Biome data (`biomes.json` — Catalytic Wastes)
- [ ] 3.3 Noise generation (simplex-noise, seed-based)
- [ ] 3.4 Tile types (scorched earth, acid pools, crystal formations, geysers, mineral veins)
- [ ] 3.5 Resource placement (ores/minerals based on biome params + noise)
- [ ] 3.6 Camera (follow player, zoom, clamp to map bounds)
- [ ] 3.7 Minimap
---
@@ -60,3 +70,4 @@ None
|---|------|-------|---------|
| 1 | 2026-02-12 | Phase 0 | Project setup: GDD, engine analysis, npm init, Phaser config, BootScene, cursor rules, plan |
| 2 | 2026-02-12 | Phase 1 | Chemistry engine: 20 elements, 25 compounds, 34 reactions, engine with O(1) lookup + educational failures, 35 tests passing |
| 3 | 2026-02-12 | Phase 2 | ECS foundation: world + time, 5 components, movement + bounce + health systems, Phaser bridge (polling sync), entity factory, GameScene with 20 bouncing circles at 60fps, 39 tests passing |

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene';
import { GameScene } from './scenes/GameScene';
export const GAME_WIDTH = 1280;
export const GAME_HEIGHT = 720;
@@ -10,7 +11,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
height: GAME_HEIGHT,
backgroundColor: '#0a0a0a',
parent: document.body,
scene: [BootScene],
scene: [BootScene, GameScene],
physics: {
default: 'arcade',
arcade: {

80
src/ecs/bridge.ts Normal file
View File

@@ -0,0 +1,80 @@
import Phaser from 'phaser';
import { query } from 'bitecs';
import type { World } from './world';
import { Position, SpriteRef } from './components';
/**
* Phaser ↔ bitECS sync bridge
*
* Manages Phaser GameObjects based on ECS entity state:
* - Creates circles for new entities with Position + SpriteRef
* - Destroys circles for entities that no longer exist
* - Syncs ECS Position → Phaser sprite coordinates every frame
*/
export class PhaserBridge {
private scene: Phaser.Scene;
private spriteMap = new Map<number, Phaser.GameObjects.Arc>();
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
/**
* Sync ECS state to Phaser — call once per frame
* Handles creation, destruction, and position updates
*/
sync(world: World): void {
const entities = query(world, [Position, SpriteRef]);
const activeSet = new Set<number>();
for (const eid of entities) {
activeSet.add(eid);
}
// Remove sprites for entities that no longer exist
const toRemove: number[] = [];
for (const eid of this.spriteMap.keys()) {
if (!activeSet.has(eid)) {
toRemove.push(eid);
}
}
for (const eid of toRemove) {
const sprite = this.spriteMap.get(eid);
if (sprite) {
sprite.destroy();
}
this.spriteMap.delete(eid);
}
// Create sprites for new entities + update positions for all
for (const eid of entities) {
let sprite = this.spriteMap.get(eid);
if (!sprite) {
sprite = this.scene.add.circle(
Position.x[eid],
Position.y[eid],
SpriteRef.radius[eid],
SpriteRef.color[eid],
);
this.spriteMap.set(eid, sprite);
}
sprite.x = Position.x[eid];
sprite.y = Position.y[eid];
}
}
/** Current number of rendered entities */
get entityCount(): number {
return this.spriteMap.size;
}
/** Clean up all sprites */
destroy(): void {
for (const sprite of this.spriteMap.values()) {
sprite.destroy();
}
this.spriteMap.clear();
}
}

36
src/ecs/components.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* ECS Components — plain objects with number arrays (bitECS 0.4 pattern)
*
* Components define the data schema for entities.
* Systems read/write component data.
* Bridge syncs component data to Phaser rendering.
*/
/** World position in pixels */
export const Position = {
x: [] as number[],
y: [] as number[],
};
/** Movement velocity in pixels per second */
export const Velocity = {
vx: [] as number[],
vy: [] as number[],
};
/** Visual representation — used by bridge to create/update Phaser objects */
export const SpriteRef = {
color: [] as number[], // hex color (e.g. 0x00ff88)
radius: [] as number[], // circle radius in pixels
};
/** Entity health — damage, healing, death */
export const Health = {
current: [] as number[],
max: [] as number[],
};
/** Link to chemistry system — stores atomic number of primary element */
export const ChemicalComposition = {
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
};

62
src/ecs/factory.ts Normal file
View File

@@ -0,0 +1,62 @@
import { addEntity, addComponent, removeEntity } from 'bitecs';
import type { World } from './world';
import {
Position,
Velocity,
Health,
SpriteRef,
ChemicalComposition,
} from './components';
/** Configuration for creating a game entity */
export interface EntityConfig {
position?: { x: number; y: number };
velocity?: { vx: number; vy: number };
health?: { current: number; max: number };
sprite?: { color: number; radius: number };
chemicalElement?: number;
}
/**
* Create a game entity with specified components
* @returns entity ID (eid)
*/
export function createGameEntity(world: World, config: EntityConfig): number {
const eid = addEntity(world);
if (config.position !== undefined) {
addComponent(world, eid, Position);
Position.x[eid] = config.position.x;
Position.y[eid] = config.position.y;
}
if (config.velocity !== undefined) {
addComponent(world, eid, Velocity);
Velocity.vx[eid] = config.velocity.vx;
Velocity.vy[eid] = config.velocity.vy;
}
if (config.health !== undefined) {
addComponent(world, eid, Health);
Health.current[eid] = config.health.current;
Health.max[eid] = config.health.max;
}
if (config.sprite !== undefined) {
addComponent(world, eid, SpriteRef);
SpriteRef.color[eid] = config.sprite.color;
SpriteRef.radius[eid] = config.sprite.radius;
}
if (config.chemicalElement !== undefined) {
addComponent(world, eid, ChemicalComposition);
ChemicalComposition.primaryElement[eid] = config.chemicalElement;
}
return eid;
}
/** Remove a game entity and all its components from the world */
export function removeGameEntity(world: World, eid: number): void {
removeEntity(world, eid);
}

30
src/ecs/systems/health.ts Normal file
View File

@@ -0,0 +1,30 @@
import { query } from 'bitecs';
import type { World } from '../world';
import { Health } from '../components';
/**
* Health system — detects entities with health ≤ 0
* @returns array of entity IDs that should be removed (dead)
*/
export function healthSystem(world: World): number[] {
const deadEntities: number[] = [];
for (const eid of query(world, [Health])) {
if (Health.current[eid] <= 0) {
deadEntities.push(eid);
}
}
return deadEntities;
}
/** Apply damage to entity — reduces current health */
export function applyDamage(eid: number, amount: number): void {
Health.current[eid] -= amount;
}
/** Apply healing to entity — increases current health, capped at max */
export function applyHealing(eid: number, amount: number): void {
Health.current[eid] = Math.min(
Health.current[eid] + amount,
Health.max[eid],
);
}

View File

@@ -0,0 +1,39 @@
import { query } from 'bitecs';
import type { World } from '../world';
import { Position, Velocity } from '../components';
/**
* Movement system — updates Position by Velocity * delta
* Velocities are in pixels/second, delta is in milliseconds
*/
export function movementSystem(world: World, deltaMs: number): void {
const dt = deltaMs / 1000;
for (const eid of query(world, [Position, Velocity])) {
Position.x[eid] += Velocity.vx[eid] * dt;
Position.y[eid] += Velocity.vy[eid] * dt;
}
}
/**
* Bounce system — reverses velocity when entity hits screen bounds
* Ensures velocity always points away from boundary
*/
export function bounceSystem(world: World, width: number, height: number): void {
for (const eid of query(world, [Position, Velocity])) {
if (Position.x[eid] < 0) {
Velocity.vx[eid] = Math.abs(Velocity.vx[eid]);
Position.x[eid] = 0;
} else if (Position.x[eid] > width) {
Velocity.vx[eid] = -Math.abs(Velocity.vx[eid]);
Position.x[eid] = width;
}
if (Position.y[eid] < 0) {
Velocity.vy[eid] = Math.abs(Velocity.vy[eid]);
Position.y[eid] = 0;
} else if (Position.y[eid] > height) {
Velocity.vy[eid] = -Math.abs(Velocity.vy[eid]);
Position.y[eid] = height;
}
}
}

39
src/ecs/world.ts Normal file
View File

@@ -0,0 +1,39 @@
import { createWorld } from 'bitecs';
/** bitECS world type */
export type World = ReturnType<typeof createWorld>;
/** Time tracking for game loop */
export interface GameTime {
/** Milliseconds since last frame */
delta: number;
/** Total milliseconds elapsed */
elapsed: number;
/** Frame counter */
tick: number;
}
/** Game world = bitECS world + time tracking */
export interface GameWorld {
world: World;
time: GameTime;
}
/** Create a new game world with zeroed time */
export function createGameWorld(): GameWorld {
return {
world: createWorld(),
time: {
delta: 0,
elapsed: 0,
tick: 0,
},
};
}
/** Update time tracking — call once per frame with Phaser's delta (ms) */
export function updateTime(gameWorld: GameWorld, deltaMs: number): void {
gameWorld.time.delta = deltaMs;
gameWorld.time.elapsed += deltaMs;
gameWorld.time.tick += 1;
}

View File

@@ -30,29 +30,33 @@ export class BootScene extends Phaser.Scene {
// Version
this.add
.text(cx, cy + 80, 'v0.1.0 — Phase 0: Project Setup', {
.text(cx, cy + 80, 'v0.2.0 — Phase 2: ECS Foundation', {
fontSize: '12px',
color: '#333333',
fontFamily: 'monospace',
})
.setOrigin(0.5);
// Pulsing indicator
const dot = this.add
.text(cx, cy + 120, '', {
fontSize: '24px',
// Click to start
const startText = this.add
.text(cx, cy + 120, '[ Click to start ]', {
fontSize: '16px',
color: '#00ff88',
fontFamily: 'monospace',
})
.setOrigin(0.5);
this.tweens.add({
targets: dot,
alpha: 0.2,
targets: startText,
alpha: 0.3,
duration: 1500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
this.input.once('pointerdown', () => {
this.scene.start('GameScene');
});
}
}

91
src/scenes/GameScene.ts Normal file
View File

@@ -0,0 +1,91 @@
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { movementSystem, bounceSystem } from '../ecs/systems/movement';
import { healthSystem } from '../ecs/systems/health';
import { createGameEntity, removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import { GAME_WIDTH, GAME_HEIGHT } from '../config';
const ENTITY_COUNT = 20;
const COLORS = [
0x00ff88, 0xff0044, 0x44aaff, 0xffaa00,
0xff44ff, 0x44ffaa, 0xffff44, 0xaa44ff,
];
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
private bridge!: PhaserBridge;
private statsText!: Phaser.GameObjects.Text;
constructor() {
super({ key: 'GameScene' });
}
create(): void {
// 1. Initialize ECS
this.gameWorld = createGameWorld();
this.bridge = new PhaserBridge(this);
// 2. Spawn bouncing circles with random properties
for (let i = 0; i < ENTITY_COUNT; i++) {
const speed = 50 + Math.random() * 150;
const angle = Math.random() * Math.PI * 2;
createGameEntity(this.gameWorld.world, {
position: {
x: 100 + Math.random() * (GAME_WIDTH - 200),
y: 100 + Math.random() * (GAME_HEIGHT - 200),
},
velocity: {
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
},
sprite: {
color: COLORS[i % COLORS.length],
radius: 6 + Math.random() * 14,
},
health: {
current: 100,
max: 100,
},
});
}
// 3. UI labels
this.add.text(10, 10, 'Phase 2: ECS Foundation', {
fontSize: '14px',
color: '#00ff88',
fontFamily: 'monospace',
});
this.statsText = this.add.text(10, 30, '', {
fontSize: '12px',
color: '#557755',
fontFamily: 'monospace',
});
}
update(_time: number, delta: number): void {
// 1. Update world time
updateTime(this.gameWorld, delta);
// 2. Run systems
movementSystem(this.gameWorld.world, delta);
bounceSystem(this.gameWorld.world, GAME_WIDTH, GAME_HEIGHT);
// 3. Health check + cleanup
const dead = healthSystem(this.gameWorld.world);
for (const eid of dead) {
removeGameEntity(this.gameWorld.world, eid);
}
// 4. Sync to Phaser
this.bridge.sync(this.gameWorld.world);
// 5. Update stats
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
this.statsText.setText(
`${this.bridge.entityCount} entities | tick ${this.gameWorld.time.tick} | ${fps} fps`,
);
}
}

631
tests/ecs.test.ts Normal file
View File

@@ -0,0 +1,631 @@
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);
});
});