150 lines
3.9 KiB
TypeScript
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);
|
|
});
|