diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 507ebbd..bca26bb 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -2,15 +2,19 @@ // // Architecture: // Behaviour Tree (TaskRunner) — controls game flow: -// root (sequential, repeat) -// ├── handleInput (leaf) — reads queued commands, mutates state -// ├── gravityTick (leaf) — auto-drop piece on timer -// └── render (leaf) — draws via blessed +// root (repeat) +// └── seq (sequential) +// ├── handleInput (leaf) — reads queued commands, mutates state +// ├── gravityTick (leaf) — auto-drop piece on timer +// └── render (leaf) — draws via blessed // // CommandQueue — processes input: // Keyboard → spawn command entities → CommandQueue.execute() // → handlers mutate game state // +// Singleton components — global state accessed via world.*Singleton(): +// Board, Piece, Score, GameOver, Paused, TickTimer +// // Usage: // npx tsx examples/tetris/main.ts @@ -46,11 +50,10 @@ import { startInput, type Key } from "./input"; // ── Setup ──────────────────────────────────────────── const world = new World(); -// Create the singleton game entity -const game = world.spawn(); -world.add(game, Board); -world.add(game, Score); -world.add(game, TickTimer); +// Singleton game state — one entity holds all of these +world.addSingleton(Board); +world.addSingleton(Score); +world.addSingleton(TickTimer); // Create blessed UI const ui = createUI(); @@ -63,7 +66,7 @@ const commands = new CommandQueue(world); function spawnPiece(): void { const p = randomPiece(); - world.add(game, Piece, { + world.addSingleton(Piece, { shape: p.shape, color: p.color, x: Math.floor((BOARD_W - p.shape[0].length) / 2), @@ -72,15 +75,19 @@ function spawnPiece(): void { } function hasActivePiece(): boolean { - return world.has(game, Piece); + return world.hasSingleton(Piece); } // Move left commands.handle(MoveLeft, () => { - if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) + if ( + !hasActivePiece() || + world.hasSingleton(GameOver) || + world.hasSingleton(Paused) + ) return; - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) { piece.x--; } @@ -88,10 +95,14 @@ commands.handle(MoveLeft, () => { // Move right commands.handle(MoveRight, () => { - if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) + if ( + !hasActivePiece() || + world.hasSingleton(GameOver) || + world.hasSingleton(Paused) + ) return; - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) { piece.x++; } @@ -99,10 +110,14 @@ commands.handle(MoveRight, () => { // Rotate commands.handle(Rotate, () => { - if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) + if ( + !hasActivePiece() || + world.hasSingleton(GameOver) || + world.hasSingleton(Paused) + ) return; - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); const result = tryRotate(board.grid, piece.shape, piece.x, piece.y); if (result) { piece.shape = result.shape; @@ -112,10 +127,14 @@ commands.handle(Rotate, () => { // Soft drop commands.handle(SoftDrop, () => { - if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) + if ( + !hasActivePiece() || + world.hasSingleton(GameOver) || + world.hasSingleton(Paused) + ) return; - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } @@ -123,10 +142,14 @@ commands.handle(SoftDrop, () => { // Hard drop commands.handle(HardDrop, () => { - if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused)) + if ( + !hasActivePiece() || + world.hasSingleton(GameOver) || + world.hasSingleton(Paused) + ) return; - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } @@ -135,52 +158,52 @@ commands.handle(HardDrop, () => { // Toggle pause commands.handle(TogglePause, () => { - if (world.has(game, GameOver)) return; - if (world.has(game, Paused)) { - world.remove(game, Paused); + if (world.hasSingleton(GameOver)) return; + if (world.hasSingleton(Paused)) { + world.removeSingleton(Paused); } else { - world.add(game, Paused); + world.addSingleton(Paused); } }); // Restart commands.handle(Restart, () => { - if (!world.has(game, GameOver)) return; - const board = world.get(game, Board); + if (!world.hasSingleton(GameOver)) return; + const board = world.getSingleton(Board); for (let r = 0; r < board.grid.length; r++) { board.grid[r].fill(0); } - world.set(game, Score, { points: 0, lines: 0, level: 1 }); - world.set(game, TickTimer, { accumulator: 0, interval: 800 }); - world.remove(game, GameOver); - if (world.has(game, Piece)) world.remove(game, Piece); + world.setSingleton(Score, { points: 0, lines: 0, level: 1 }); + world.setSingleton(TickTimer, { accumulator: 0, interval: 800 }); + world.removeSingleton(GameOver); + if (world.hasSingleton(Piece)) world.removeSingleton(Piece); spawnPiece(); }); // ── Lock piece & spawn next ────────────────────────── function lockAndSpawn(): void { - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y); - world.remove(game, Piece); + world.removeSingleton(Piece); const cleared = clearLines(board.grid); if (cleared > 0) { - const score = world.get(game, Score); + const score = world.getSingleton(Score); score.lines += cleared; score.points += scoreForLines(cleared, score.level); score.level = Math.floor(score.lines / 10) + 1; - const timer = world.get(game, TickTimer); + const timer = world.getSingleton(TickTimer); timer.interval = Math.max(100, 800 - (score.level - 1) * 70); } spawnPiece(); - const newPiece = world.get(game, Piece); + const newPiece = world.getSingleton(Piece); if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) { - world.remove(game, Piece); - world.add(game, GameOver); + world.removeSingleton(Piece); + world.addSingleton(GameOver); } } @@ -219,17 +242,17 @@ runner.onLeaf = (_w, entity) => { commands.execute(); runner.succeed(entity); } else if (entity === gravityTask) { - if (world.has(game, GameOver) || world.has(game, Paused)) { + if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) { runner.succeed(entity); return; } - const timer = world.get(game, TickTimer); + const timer = world.getSingleton(TickTimer); timer.accumulator += 16; if (timer.accumulator >= timer.interval) { timer.accumulator -= timer.interval; if (hasActivePiece()) { - const piece = world.get(game, Piece); - const board = world.get(game, Board); + const piece = world.getSingleton(Piece); + const board = world.getSingleton(Board); if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) { piece.y++; } else { @@ -239,7 +262,7 @@ runner.onLeaf = (_w, entity) => { } runner.succeed(entity); } else if (entity === renderTask) { - render(world, game, ui); + render(world, ui); runner.succeed(entity); } }; diff --git a/examples/tetris/render.ts b/examples/tetris/render.ts index d52c108..7c27b27 100644 --- a/examples/tetris/render.ts +++ b/examples/tetris/render.ts @@ -1,6 +1,6 @@ // ── Terminal rendering via blessed ──────────────────── import blessed from "blessed"; -import type { World, Entity } from "../../src/index"; +import type { World } from "../../src/index"; import { Board, Piece, Score, GameOver, Paused } from "./components"; import { BOARD_W, BOARD_H, ghostY } from "./game"; @@ -91,16 +91,12 @@ export function createUI(): { } /** Render the full game state into the blessed UI. */ -export function render( - world: World, - gameEntity: Entity, - ui: ReturnType, -): void { - const board = world.get(gameEntity, Board); - const piece = world.tryGet(gameEntity, Piece); - const score = world.tryGet(gameEntity, Score); - const isOver = world.has(gameEntity, GameOver); - const isPaused = world.has(gameEntity, Paused); +export function render(world: World, ui: ReturnType): void { + const board = world.getSingleton(Board); + const piece = world.tryGetSingleton(Piece); + const score = world.tryGetSingleton(Score); + const isOver = world.hasSingleton(GameOver); + const isPaused = world.hasSingleton(Paused); // Build display grid const display = board.grid.map((row) => [...row]); diff --git a/src/world.ts b/src/world.ts index 0178cc7..b04f2c8 100644 --- a/src/world.ts +++ b/src/world.ts @@ -42,6 +42,9 @@ export class World { // ── Observable layer ────────────────────────────── private _observable = new ObservableLayer(); + // ── Singleton entity ────────────────────────────── + private _singletonEntity: Entity | null = null; + /** Global event stream. */ get events$(): Observable { return this._observable.events$.asObservable(); @@ -250,6 +253,69 @@ export class World { } } + // ── Singleton component access ──────────────────── + + /** + * Add a singleton component to the world. + * + * A single shared entity is created lazily and reused for all singleton + * components. Returns a mutable reference to the component data. + */ + addSingleton>( + def: ComponentDef, + init?: Partial, + ): T { + if (this._singletonEntity === null) { + this._singletonEntity = this.spawn(); + } + return this.add(this._singletonEntity, def, init); + } + + /** Remove a singleton component. Destroys the backing entity if it becomes bare. */ + removeSingleton(def: ComponentDef): void { + const e = this._singletonEntity; + if (e === null) return; + this.remove(e, def); + if (!this.hasAnyComponent(e)) { + this.destroy(e); + this._singletonEntity = null; + } + } + + /** Get a mutable reference to a singleton component. Throws if missing. */ + getSingleton>(def: ComponentDef): T { + return this.get(this._singletonEntity!, def); + } + + /** Try-get a singleton component. Returns undefined if missing. */ + tryGetSingleton>( + def: ComponentDef, + ): T | undefined { + const e = this._singletonEntity; + if (e === null) return undefined; + return this.tryGet(e, def); + } + + /** Check whether a singleton component is present. */ + hasSingleton(def: ComponentDef): boolean { + const e = this._singletonEntity; + if (e === null) return false; + return this.has(e, def); + } + + /** Bulk-replace a singleton component's data. Marks dirty. */ + setSingleton>( + def: ComponentDef, + value: T, + ): void { + this.set(this._singletonEntity!, def, value); + } + + /** Mark a singleton component as dirty for change tracking. */ + markDirtySingleton(def: ComponentDef): void { + this.markDirty(this._singletonEntity!, def); + } + // ── Relationships ───────────────────────────────── /**