feat(world): add singleton component support

Introduce a mechanism to manage components on a shared, lazily-created
singleton entity. This simplifies access to global state by providing
dedicated methods for adding, removing, getting, and checking for
singleton components.

Refactor the Tetris example to utilize this new singleton pattern for
game state components like Board, Score, and Piece.
This commit is contained in:
hypercross 2026-06-01 23:52:47 +08:00
parent efa92be5ab
commit ccd0e3afb4
3 changed files with 145 additions and 60 deletions

View File

@ -2,7 +2,8 @@
//
// Architecture:
// Behaviour Tree (TaskRunner) — controls game flow:
// root (sequential, repeat)
// root (repeat)
// └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands, mutates state
// ├── gravityTick (leaf) — auto-drop piece on timer
// └── render (leaf) — draws via blessed
@ -11,6 +12,9 @@
// 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);
}
};

View File

@ -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<typeof createUI>,
): 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<typeof createUI>): 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]);

View File

@ -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<WorldEvent> {
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<T extends Record<string, any>>(
def: ComponentDef<T>,
init?: Partial<T>,
): 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<any>): 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<T extends Record<string, any>>(def: ComponentDef<T>): T {
return this.get(this._singletonEntity!, def);
}
/** Try-get a singleton component. Returns undefined if missing. */
tryGetSingleton<T extends Record<string, any>>(
def: ComponentDef<T>,
): 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<any>): 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<T extends Record<string, any>>(
def: ComponentDef<T>,
value: T,
): void {
this.set(this._singletonEntity!, def, value);
}
/** Mark a singleton component as dirty for change tracking. */
markDirtySingleton(def: ComponentDef<any>): void {
this.markDirty(this._singletonEntity!, def);
}
// ── Relationships ─────────────────────────────────
/**