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:
+72
-49
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user