Phase 2: ECS foundation — world, components, systems, bridge

- bitECS world with time tracking (delta, elapsed, tick)
- 5 components: Position, Velocity, SpriteRef, Health, ChemicalComposition
- Movement system (velocity * delta) + bounce system (boundary reflection)
- Health system with damage, healing, death detection
- Entity factory (createGameEntity/removeGameEntity)
- Phaser bridge: polling sync creates/destroys/updates circle sprites
- GameScene: 20 colored circles bouncing at 60fps
- BootScene: click-to-start transition, version bump to v0.2.0
- 39 ECS unit tests passing (74 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 12:34:06 +03:00
parent 58ebb11747
commit ddbca12740
11 changed files with 1042 additions and 18 deletions

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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