diff --git a/PROGRESS.md b/PROGRESS.md index f94e4bd..8e56e87 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 2 — ECS Foundation +## Up Next: Phase 3 — World 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 | diff --git a/src/config.ts b/src/config.ts index 7bd9c07..cdceb8c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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: { diff --git a/src/ecs/bridge.ts b/src/ecs/bridge.ts new file mode 100644 index 0000000..d4318ef --- /dev/null +++ b/src/ecs/bridge.ts @@ -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(); + + 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(); + + 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(); + } +} diff --git a/src/ecs/components.ts b/src/ecs/components.ts new file mode 100644 index 0000000..f88d390 --- /dev/null +++ b/src/ecs/components.ts @@ -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) +}; diff --git a/src/ecs/factory.ts b/src/ecs/factory.ts new file mode 100644 index 0000000..60ea519 --- /dev/null +++ b/src/ecs/factory.ts @@ -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); +} diff --git a/src/ecs/systems/health.ts b/src/ecs/systems/health.ts new file mode 100644 index 0000000..6fafb66 --- /dev/null +++ b/src/ecs/systems/health.ts @@ -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], + ); +} diff --git a/src/ecs/systems/movement.ts b/src/ecs/systems/movement.ts new file mode 100644 index 0000000..56cc2ad --- /dev/null +++ b/src/ecs/systems/movement.ts @@ -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; + } + } +} diff --git a/src/ecs/world.ts b/src/ecs/world.ts new file mode 100644 index 0000000..a115139 --- /dev/null +++ b/src/ecs/world.ts @@ -0,0 +1,39 @@ +import { createWorld } from 'bitecs'; + +/** bitECS world type */ +export type World = ReturnType; + +/** 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; +} diff --git a/src/scenes/BootScene.ts b/src/scenes/BootScene.ts index d5623aa..35336d9 100644 --- a/src/scenes/BootScene.ts +++ b/src/scenes/BootScene.ts @@ -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'); + }); } } diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts new file mode 100644 index 0000000..1bd5242 --- /dev/null +++ b/src/scenes/GameScene.ts @@ -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`, + ); + } +} diff --git a/tests/ecs.test.ts b/tests/ecs.test.ts new file mode 100644 index 0000000..5390330 --- /dev/null +++ b/tests/ecs.test.ts @@ -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); + }); +});