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:
31
PROGRESS.md
31
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 |
|
||||
|
||||
@@ -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
80
src/ecs/bridge.ts
Normal 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
36
src/ecs/components.ts
Normal 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
62
src/ecs/factory.ts
Normal 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
30
src/ecs/systems/health.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
39
src/ecs/systems/movement.ts
Normal file
39
src/ecs/systems/movement.ts
Normal 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
39
src/ecs/world.ts
Normal 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;
|
||||
}
|
||||
@@ -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
91
src/scenes/GameScene.ts
Normal 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
631
tests/ecs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user