ecs-observable/examples/tetris/main.ts

150 lines
3.9 KiB
TypeScript

// ── Tetris: BT-driven game loop with command-based input ──
//
// Architecture:
// Behaviour Tree (buildTree) — controls game flow:
// parallel
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
// └── repeat
// └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands
// └── 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
import { World } from "../../src/index";
import { buildTree } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import {
Board,
Piece,
Score,
GameOver,
Paused,
TickTimer,
createPieceHelpers,
} from "./components";
import {
registerCommands,
MoveLeft,
MoveRight,
Rotate,
SoftDrop,
HardDrop,
TogglePause,
Restart,
} from "./commands";
import { collides } from "./game";
import { createUI, render } from "./render";
import { startInput, type Key } from "./input";
// ── Setup ────────────────────────────────────────────
const world = new World();
world.addSingleton(Board);
world.addSingleton(Score);
world.addSingleton(TickTimer);
const ui = createUI();
const pieces = createPieceHelpers(world);
const commands = new CommandQueue(world);
registerCommands(world, commands, pieces);
pieces.spawnPiece();
// ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, {
kind: "parallel",
children: [
{
kind: "leaf",
*run() {
while (true) {
const dt: number = yield;
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
continue;
}
const timer = world.getSingleton(TickTimer);
timer.accumulator += dt;
if (timer.accumulator >= timer.interval) {
timer.accumulator -= timer.interval;
if (world.hasSingleton(Piece)) {
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
pieces.lockAndSpawn();
}
}
}
}
},
},
{
kind: "repeat",
child: {
kind: "sequential",
children: [
{
kind: "leaf",
run: () => {
commands.execute();
},
},
{
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
},
],
});
// ── Input → Command mapping ──────────────────────────
const keyToCommand: Partial<Record<Key, typeof MoveLeft>> = {
left: MoveLeft,
right: MoveRight,
up: Rotate,
down: SoftDrop,
space: HardDrop,
p: TogglePause,
r: Restart,
};
startInput(ui.screen, (key) => {
const cmd = keyToCommand[key];
if (cmd) {
const cmdEntity = world.spawn();
world.add(cmdEntity, cmd);
}
});
// ── Game loop ────────────────────────────────────────
runner.schedule((runner as any).root);
const TICK_MS = 16;
const interval = setInterval(() => {
runner.tick(TICK_MS);
}, TICK_MS);
process.on("SIGINT", () => {
clearInterval(interval);
ui.screen.destroy();
process.exit(0);
});