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
|
# Synthesis — Development Progress
|
||||||
|
|
||||||
> **Last updated:** 2026-02-12
|
> **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.6 Compound data — 25 compounds with game effects (`src/data/compounds.json`)
|
||||||
- [x] 1.7 Unit tests — 35 passing (`tests/chemistry.test.ts`)
|
- [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
|
## 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)
|
- [ ] 3.1 Tilemap system (Phaser Tilemap from data-driven tile definitions)
|
||||||
- [ ] 2.2 Core components (Position, Velocity, SpriteRef, Health)
|
- [ ] 3.2 Biome data (`biomes.json` — Catalytic Wastes)
|
||||||
- [ ] 2.3 Movement system
|
- [ ] 3.3 Noise generation (simplex-noise, seed-based)
|
||||||
- [ ] 2.4 Phaser ↔ bitECS sync bridge
|
- [ ] 3.4 Tile types (scorched earth, acid pools, crystal formations, geysers, mineral veins)
|
||||||
- [ ] 2.5 Entity factory
|
- [ ] 3.5 Resource placement (ores/minerals based on biome params + noise)
|
||||||
- [ ] 2.6 Health/damage system
|
- [ ] 3.6 Camera (follow player, zoom, clamp to map bounds)
|
||||||
- [ ] 2.7 Visual test (entities moving on screen)
|
- [ ] 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 |
|
| 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 |
|
| 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 Phaser from 'phaser';
|
||||||
import { BootScene } from './scenes/BootScene';
|
import { BootScene } from './scenes/BootScene';
|
||||||
|
import { GameScene } from './scenes/GameScene';
|
||||||
|
|
||||||
export const GAME_WIDTH = 1280;
|
export const GAME_WIDTH = 1280;
|
||||||
export const GAME_HEIGHT = 720;
|
export const GAME_HEIGHT = 720;
|
||||||
@@ -10,7 +11,7 @@ export const gameConfig: Phaser.Types.Core.GameConfig = {
|
|||||||
height: GAME_HEIGHT,
|
height: GAME_HEIGHT,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
parent: document.body,
|
parent: document.body,
|
||||||
scene: [BootScene],
|
scene: [BootScene, GameScene],
|
||||||
physics: {
|
physics: {
|
||||||
default: 'arcade',
|
default: 'arcade',
|
||||||
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
|
// Version
|
||||||
this.add
|
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',
|
fontSize: '12px',
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
// Pulsing indicator
|
// Click to start
|
||||||
const dot = this.add
|
const startText = this.add
|
||||||
.text(cx, cy + 120, '◉', {
|
.text(cx, cy + 120, '[ Click to start ]', {
|
||||||
fontSize: '24px',
|
fontSize: '16px',
|
||||||
color: '#00ff88',
|
color: '#00ff88',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
})
|
})
|
||||||
.setOrigin(0.5);
|
.setOrigin(0.5);
|
||||||
|
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: dot,
|
targets: startText,
|
||||||
alpha: 0.2,
|
alpha: 0.3,
|
||||||
duration: 1500,
|
duration: 1500,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: -1,
|
repeat: -1,
|
||||||
ease: 'Sine.easeInOut',
|
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