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:
parent
efa92be5ab
commit
ccd0e3afb4
|
|
@ -2,15 +2,19 @@
|
||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// Behaviour Tree (TaskRunner) — controls game flow:
|
// Behaviour Tree (TaskRunner) — controls game flow:
|
||||||
// root (sequential, repeat)
|
// root (repeat)
|
||||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
// └── seq (sequential)
|
||||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||||
// └── render (leaf) — draws via blessed
|
// ├── gravityTick (leaf) — auto-drop piece on timer
|
||||||
|
// └── render (leaf) — draws via blessed
|
||||||
//
|
//
|
||||||
// CommandQueue — processes input:
|
// CommandQueue — processes input:
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
// → handlers mutate game state
|
// → handlers mutate game state
|
||||||
//
|
//
|
||||||
|
// Singleton components — global state accessed via world.*Singleton():
|
||||||
|
// Board, Piece, Score, GameOver, Paused, TickTimer
|
||||||
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// npx tsx examples/tetris/main.ts
|
// npx tsx examples/tetris/main.ts
|
||||||
|
|
||||||
|
|
@ -46,11 +50,10 @@ import { startInput, type Key } from "./input";
|
||||||
// ── Setup ────────────────────────────────────────────
|
// ── Setup ────────────────────────────────────────────
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
||||||
// Create the singleton game entity
|
// Singleton game state — one entity holds all of these
|
||||||
const game = world.spawn();
|
world.addSingleton(Board);
|
||||||
world.add(game, Board);
|
world.addSingleton(Score);
|
||||||
world.add(game, Score);
|
world.addSingleton(TickTimer);
|
||||||
world.add(game, TickTimer);
|
|
||||||
|
|
||||||
// Create blessed UI
|
// Create blessed UI
|
||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
|
|
@ -63,7 +66,7 @@ const commands = new CommandQueue(world);
|
||||||
|
|
||||||
function spawnPiece(): void {
|
function spawnPiece(): void {
|
||||||
const p = randomPiece();
|
const p = randomPiece();
|
||||||
world.add(game, Piece, {
|
world.addSingleton(Piece, {
|
||||||
shape: p.shape,
|
shape: p.shape,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
||||||
|
|
@ -72,15 +75,19 @@ function spawnPiece(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasActivePiece(): boolean {
|
function hasActivePiece(): boolean {
|
||||||
return world.has(game, Piece);
|
return world.hasSingleton(Piece);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move left
|
// Move left
|
||||||
commands.handle(MoveLeft, () => {
|
commands.handle(MoveLeft, () => {
|
||||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
if (
|
||||||
|
!hasActivePiece() ||
|
||||||
|
world.hasSingleton(GameOver) ||
|
||||||
|
world.hasSingleton(Paused)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
||||||
piece.x--;
|
piece.x--;
|
||||||
}
|
}
|
||||||
|
|
@ -88,10 +95,14 @@ commands.handle(MoveLeft, () => {
|
||||||
|
|
||||||
// Move right
|
// Move right
|
||||||
commands.handle(MoveRight, () => {
|
commands.handle(MoveRight, () => {
|
||||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
if (
|
||||||
|
!hasActivePiece() ||
|
||||||
|
world.hasSingleton(GameOver) ||
|
||||||
|
world.hasSingleton(Paused)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
||||||
piece.x++;
|
piece.x++;
|
||||||
}
|
}
|
||||||
|
|
@ -99,10 +110,14 @@ commands.handle(MoveRight, () => {
|
||||||
|
|
||||||
// Rotate
|
// Rotate
|
||||||
commands.handle(Rotate, () => {
|
commands.handle(Rotate, () => {
|
||||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
if (
|
||||||
|
!hasActivePiece() ||
|
||||||
|
world.hasSingleton(GameOver) ||
|
||||||
|
world.hasSingleton(Paused)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
||||||
if (result) {
|
if (result) {
|
||||||
piece.shape = result.shape;
|
piece.shape = result.shape;
|
||||||
|
|
@ -112,10 +127,14 @@ commands.handle(Rotate, () => {
|
||||||
|
|
||||||
// Soft drop
|
// Soft drop
|
||||||
commands.handle(SoftDrop, () => {
|
commands.handle(SoftDrop, () => {
|
||||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
if (
|
||||||
|
!hasActivePiece() ||
|
||||||
|
world.hasSingleton(GameOver) ||
|
||||||
|
world.hasSingleton(Paused)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
}
|
}
|
||||||
|
|
@ -123,10 +142,14 @@ commands.handle(SoftDrop, () => {
|
||||||
|
|
||||||
// Hard drop
|
// Hard drop
|
||||||
commands.handle(HardDrop, () => {
|
commands.handle(HardDrop, () => {
|
||||||
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
|
if (
|
||||||
|
!hasActivePiece() ||
|
||||||
|
world.hasSingleton(GameOver) ||
|
||||||
|
world.hasSingleton(Paused)
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
}
|
}
|
||||||
|
|
@ -135,52 +158,52 @@ commands.handle(HardDrop, () => {
|
||||||
|
|
||||||
// Toggle pause
|
// Toggle pause
|
||||||
commands.handle(TogglePause, () => {
|
commands.handle(TogglePause, () => {
|
||||||
if (world.has(game, GameOver)) return;
|
if (world.hasSingleton(GameOver)) return;
|
||||||
if (world.has(game, Paused)) {
|
if (world.hasSingleton(Paused)) {
|
||||||
world.remove(game, Paused);
|
world.removeSingleton(Paused);
|
||||||
} else {
|
} else {
|
||||||
world.add(game, Paused);
|
world.addSingleton(Paused);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restart
|
// Restart
|
||||||
commands.handle(Restart, () => {
|
commands.handle(Restart, () => {
|
||||||
if (!world.has(game, GameOver)) return;
|
if (!world.hasSingleton(GameOver)) return;
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
for (let r = 0; r < board.grid.length; r++) {
|
for (let r = 0; r < board.grid.length; r++) {
|
||||||
board.grid[r].fill(0);
|
board.grid[r].fill(0);
|
||||||
}
|
}
|
||||||
world.set(game, Score, { points: 0, lines: 0, level: 1 });
|
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
|
||||||
world.set(game, TickTimer, { accumulator: 0, interval: 800 });
|
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
|
||||||
world.remove(game, GameOver);
|
world.removeSingleton(GameOver);
|
||||||
if (world.has(game, Piece)) world.remove(game, Piece);
|
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
|
||||||
spawnPiece();
|
spawnPiece();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Lock piece & spawn next ──────────────────────────
|
// ── Lock piece & spawn next ──────────────────────────
|
||||||
function lockAndSpawn(): void {
|
function lockAndSpawn(): void {
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
|
|
||||||
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
||||||
world.remove(game, Piece);
|
world.removeSingleton(Piece);
|
||||||
|
|
||||||
const cleared = clearLines(board.grid);
|
const cleared = clearLines(board.grid);
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
const score = world.get(game, Score);
|
const score = world.getSingleton(Score);
|
||||||
score.lines += cleared;
|
score.lines += cleared;
|
||||||
score.points += scoreForLines(cleared, score.level);
|
score.points += scoreForLines(cleared, score.level);
|
||||||
score.level = Math.floor(score.lines / 10) + 1;
|
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);
|
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnPiece();
|
spawnPiece();
|
||||||
|
|
||||||
const newPiece = world.get(game, Piece);
|
const newPiece = world.getSingleton(Piece);
|
||||||
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
||||||
world.remove(game, Piece);
|
world.removeSingleton(Piece);
|
||||||
world.add(game, GameOver);
|
world.addSingleton(GameOver);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,17 +242,17 @@ runner.onLeaf = (_w, entity) => {
|
||||||
commands.execute();
|
commands.execute();
|
||||||
runner.succeed(entity);
|
runner.succeed(entity);
|
||||||
} else if (entity === gravityTask) {
|
} else if (entity === gravityTask) {
|
||||||
if (world.has(game, GameOver) || world.has(game, Paused)) {
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
runner.succeed(entity);
|
runner.succeed(entity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = world.get(game, TickTimer);
|
const timer = world.getSingleton(TickTimer);
|
||||||
timer.accumulator += 16;
|
timer.accumulator += 16;
|
||||||
if (timer.accumulator >= timer.interval) {
|
if (timer.accumulator >= timer.interval) {
|
||||||
timer.accumulator -= timer.interval;
|
timer.accumulator -= timer.interval;
|
||||||
if (hasActivePiece()) {
|
if (hasActivePiece()) {
|
||||||
const piece = world.get(game, Piece);
|
const piece = world.getSingleton(Piece);
|
||||||
const board = world.get(game, Board);
|
const board = world.getSingleton(Board);
|
||||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
piece.y++;
|
piece.y++;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -239,7 +262,7 @@ runner.onLeaf = (_w, entity) => {
|
||||||
}
|
}
|
||||||
runner.succeed(entity);
|
runner.succeed(entity);
|
||||||
} else if (entity === renderTask) {
|
} else if (entity === renderTask) {
|
||||||
render(world, game, ui);
|
render(world, ui);
|
||||||
runner.succeed(entity);
|
runner.succeed(entity);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// ── Terminal rendering via blessed ────────────────────
|
// ── Terminal rendering via blessed ────────────────────
|
||||||
import blessed from "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, Piece, Score, GameOver, Paused } from "./components";
|
||||||
import { BOARD_W, BOARD_H, ghostY } from "./game";
|
import { BOARD_W, BOARD_H, ghostY } from "./game";
|
||||||
|
|
||||||
|
|
@ -91,16 +91,12 @@ export function createUI(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the full game state into the blessed UI. */
|
/** Render the full game state into the blessed UI. */
|
||||||
export function render(
|
export function render(world: World, ui: ReturnType<typeof createUI>): void {
|
||||||
world: World,
|
const board = world.getSingleton(Board);
|
||||||
gameEntity: Entity,
|
const piece = world.tryGetSingleton(Piece);
|
||||||
ui: ReturnType<typeof createUI>,
|
const score = world.tryGetSingleton(Score);
|
||||||
): void {
|
const isOver = world.hasSingleton(GameOver);
|
||||||
const board = world.get(gameEntity, Board);
|
const isPaused = world.hasSingleton(Paused);
|
||||||
const piece = world.tryGet(gameEntity, Piece);
|
|
||||||
const score = world.tryGet(gameEntity, Score);
|
|
||||||
const isOver = world.has(gameEntity, GameOver);
|
|
||||||
const isPaused = world.has(gameEntity, Paused);
|
|
||||||
|
|
||||||
// Build display grid
|
// Build display grid
|
||||||
const display = board.grid.map((row) => [...row]);
|
const display = board.grid.map((row) => [...row]);
|
||||||
|
|
|
||||||
66
src/world.ts
66
src/world.ts
|
|
@ -42,6 +42,9 @@ export class World {
|
||||||
// ── Observable layer ──────────────────────────────
|
// ── Observable layer ──────────────────────────────
|
||||||
private _observable = new ObservableLayer();
|
private _observable = new ObservableLayer();
|
||||||
|
|
||||||
|
// ── Singleton entity ──────────────────────────────
|
||||||
|
private _singletonEntity: Entity | null = null;
|
||||||
|
|
||||||
/** Global event stream. */
|
/** Global event stream. */
|
||||||
get events$(): Observable<WorldEvent> {
|
get events$(): Observable<WorldEvent> {
|
||||||
return this._observable.events$.asObservable();
|
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 ─────────────────────────────────
|
// ── Relationships ─────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue