feat(examples): add Tetris game example

This commit is contained in:
hypercross 2026-06-01 23:18:07 +08:00
parent 3620e80807
commit c3c24d2350
8 changed files with 705 additions and 0 deletions

View File

@ -0,0 +1,22 @@
import { defineComponent } from "../../src/component";
/** Move the active piece left. */
export const MoveLeft = defineComponent("moveLeft", {});
/** Move the active piece right. */
export const MoveRight = defineComponent("moveRight", {});
/** Rotate the active piece clockwise. */
export const Rotate = defineComponent("rotate", {});
/** Soft drop — move piece down one row immediately. */
export const SoftDrop = defineComponent("softDrop", {});
/** Hard drop — slam piece to the bottom instantly. */
export const HardDrop = defineComponent("hardDrop", {});
/** Pause / unpause the game. */
export const TogglePause = defineComponent("togglePause", {});
/** Restart after game over. */
export const Restart = defineComponent("restart", {});

View File

@ -0,0 +1,31 @@
import { defineComponent } from "../../src/component";
// ── Board ────────────────────────────────────────────
/** The playfield grid (20 rows × 10 cols). 0 = empty, non-zero = color index. */
export const Board = defineComponent("board", {
grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[],
});
// ── Active piece ─────────────────────────────────────
export const Piece = defineComponent("piece", {
shape: [] as number[][],
color: 1,
x: 3,
y: 0,
});
// ── Score / state ────────────────────────────────────
export const Score = defineComponent("score", {
points: 0,
lines: 0,
level: 1,
});
export const GameOver = defineComponent("gameOver", {});
export const Paused = defineComponent("paused", {});
// ── Timing ───────────────────────────────────────────
export const TickTimer = defineComponent("tickTimer", {
accumulator: 0,
interval: 800, // ms between gravity ticks
});

131
examples/tetris/game.ts Normal file
View File

@ -0,0 +1,131 @@
// ── Tetris game logic (pure functions, no ECS dependency) ──
export const BOARD_W = 10;
export const BOARD_H = 20;
// ── Tetrominoes ──────────────────────────────────────
export const PIECES: { shape: number[][]; color: number }[] = [
{ shape: [[1, 1, 1, 1]], color: 1 }, // I
{ shape: [[1, 1], [1, 1]], color: 2 }, // O
{ shape: [[0, 1, 0], [1, 1, 1]], color: 3 }, // T
{ shape: [[1, 0, 0], [1, 1, 1]], color: 4 }, // J
{ shape: [[0, 0, 1], [1, 1, 1]], color: 5 }, // L
{ shape: [[0, 1, 1], [1, 1, 0]], color: 6 }, // S
{ shape: [[1, 1, 0], [0, 1, 1]], color: 7 }, // Z
];
export function randomPiece(): { shape: number[][]; color: number } {
const p = PIECES[Math.floor(Math.random() * PIECES.length)];
return { shape: p.shape.map((r) => [...r]), color: p.color };
}
// ── Collision ────────────────────────────────────────
export function collides(
grid: Uint8Array[],
shape: number[][],
px: number,
py: number,
): boolean {
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (!shape[r][c]) continue;
const bx = px + c;
const by = py + r;
if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true;
if (by < 0) continue; // above the board is ok
if (grid[by][bx]) return true;
}
}
return false;
}
// ── Lock piece onto the grid ─────────────────────────
export function lockPiece(
grid: Uint8Array[],
shape: number[][],
color: number,
px: number,
py: number,
): void {
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (!shape[r][c]) continue;
const bx = px + c;
const by = py + r;
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
grid[by][bx] = color;
}
}
}
}
// ── Line clearing ────────────────────────────────────
export function clearLines(grid: Uint8Array[]): number {
let cleared = 0;
for (let r = BOARD_H - 1; r >= 0; r--) {
if (grid[r].every((v) => v !== 0)) {
// Shift everything above down
for (let rr = r; rr > 0; rr--) {
grid[rr] = grid[rr - 1];
}
grid[0] = new Uint8Array(BOARD_W);
cleared++;
r++; // re-check this row
}
}
return cleared;
}
// ── Scoring ──────────────────────────────────────────
const LINE_SCORES = [0, 100, 300, 500, 800];
export function scoreForLines(lines: number, level: number): number {
return (LINE_SCORES[lines] ?? lines * 200) * level;
}
// ── Ghost piece (hard-drop preview) ──────────────────
export function ghostY(
grid: Uint8Array[],
shape: number[][],
px: number,
py: number,
): number {
let gy = py;
while (!collides(grid, shape, px, gy + 1)) {
gy++;
}
return gy;
}
// ── Rotation (clockwise) ─────────────────────────────
export function rotateCW(shape: number[][]): number[][] {
const rows = shape.length;
const cols = shape[0].length;
const rotated: number[][] = [];
for (let c = 0; c < cols; c++) {
const row: number[] = [];
for (let r = rows - 1; r >= 0; r--) {
row.push(shape[r][c]);
}
rotated.push(row);
}
return rotated;
}
// ── Wall kick ────────────────────────────────────────
/** Try to rotate with basic wall kicks. Returns the rotated shape and x offset, or null. */
export function tryRotate(
grid: Uint8Array[],
shape: number[][],
px: number,
py: number,
): { shape: number[][]; x: number } | null {
const rotated = rotateCW(shape);
// Try offsets: 0, -1, +1, -2, +2
for (const dx of [0, -1, 1, -2, 2]) {
if (!collides(grid, rotated, px + dx, py)) {
return { shape: rotated, x: px + dx };
}
}
return null;
}

25
examples/tetris/input.ts Normal file
View File

@ -0,0 +1,25 @@
// ── Keyboard input via blessed ────────────────────────
import type blessed from "blessed";
export type Key = "left" | "right" | "up" | "down" | "space" | "p" | "r" | "q";
/** Wire blessed screen key events to a callback. */
export function startInput(
screen: blessed.Widgets.Screen,
onKey: (key: Key) => void,
): void {
screen.key(
["left", "right", "up", "down", "space", "p", "r", "q", "C-c"],
(_ch, key) => {
if (key.name === "q") {
screen.destroy();
process.exit(0);
}
if (key.name === "C-c") {
screen.destroy();
process.exit(0);
}
onKey(key.name as Key);
},
);
}

271
examples/tetris/main.ts Normal file
View File

@ -0,0 +1,271 @@
// ── Tetris: BT-driven game loop with command-based input ──
//
// 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
//
// CommandQueue — processes input:
// Keyboard → spawn command entities → CommandQueue.execute()
// → handlers mutate game state
//
// Usage:
// npx tsx examples/tetris/main.ts
import { World } from "../../src/index";
import { TaskRunner, Task, ChildOf } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
import {
MoveLeft,
MoveRight,
Rotate,
SoftDrop,
HardDrop,
TogglePause,
Restart,
} from "./commands";
import {
randomPiece,
collides,
lockPiece,
clearLines,
scoreForLines,
tryRotate,
BOARD_W,
} from "./game";
import { createUI, render } from "./render";
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);
// Create blessed UI
const ui = createUI();
// ── Command handlers ─────────────────────────────────
const commands = new CommandQueue(world);
function spawnPiece(): void {
const p = randomPiece();
world.add(game, Piece, {
shape: p.shape,
color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
y: 0,
});
}
function hasActivePiece(): boolean {
return world.has(game, Piece);
}
// Move left
commands.handle(MoveLeft, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--;
}
});
// Move right
commands.handle(MoveRight, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++;
}
});
// Rotate
commands.handle(Rotate, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
if (result) {
piece.shape = result.shape;
piece.x = result.x;
}
});
// Soft drop
commands.handle(SoftDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
});
// Hard drop
commands.handle(HardDrop, () => {
if (!hasActivePiece() || world.has(game, GameOver) || world.has(game, Paused))
return;
const piece = world.get(game, Piece);
const board = world.get(game, Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
lockAndSpawn();
});
// Toggle pause
commands.handle(TogglePause, () => {
if (world.has(game, GameOver)) return;
if (world.has(game, Paused)) {
world.remove(game, Paused);
} else {
world.add(game, Paused);
}
});
// Restart
commands.handle(Restart, () => {
if (!world.has(game, GameOver)) return;
const board = world.get(game, 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);
spawnPiece();
});
// ── Lock piece & spawn next ──────────────────────────
function lockAndSpawn(): void {
const piece = world.get(game, Piece);
const board = world.get(game, Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.remove(game, Piece);
const cleared = clearLines(board.grid);
if (cleared > 0) {
const score = world.get(game, Score);
score.lines += cleared;
score.points += scoreForLines(cleared, score.level);
score.level = Math.floor(score.lines / 10) + 1;
const timer = world.get(game, TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
}
spawnPiece();
const newPiece = world.get(game, Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.remove(game, Piece);
world.add(game, GameOver);
}
}
// ── Behaviour Tree ───────────────────────────────────
const runner = new TaskRunner(world);
// Build the BT structure:
// root (repeat)
// ├── handleInput (leaf)
// ├── gravityTick (leaf)
// └── render (leaf)
const root = world.spawn();
world.add(root, Task, { kind: "repeat" });
const handleInputTask = world.spawn();
world.add(handleInputTask, Task, { kind: "leaf" });
world.relate(handleInputTask, ChildOf, root);
const gravityTask = world.spawn();
world.add(gravityTask, Task, { kind: "leaf" });
world.relate(gravityTask, ChildOf, root);
const renderTask = world.spawn();
world.add(renderTask, Task, { kind: "leaf" });
world.relate(renderTask, ChildOf, root);
// ── Leaf handlers ────────────────────────────────────
runner.onLeaf = (_w, entity) => {
if (entity === handleInputTask) {
commands.execute();
runner.succeed(entity);
} else if (entity === gravityTask) {
if (world.has(game, GameOver) || world.has(game, Paused)) {
runner.succeed(entity);
return;
}
const timer = world.get(game, 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);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
lockAndSpawn();
}
}
}
runner.succeed(entity);
} else if (entity === renderTask) {
render(world, game, ui);
runner.succeed(entity);
}
};
// ── 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(root);
const TICK_MS = 16;
const interval = setInterval(() => {
runner.tick();
}, TICK_MS);
// Cleanup on exit
process.on("SIGINT", () => {
clearInterval(interval);
ui.screen.destroy();
process.exit(0);
});

179
examples/tetris/render.ts Normal file
View File

@ -0,0 +1,179 @@
// ── Terminal rendering via blessed ────────────────────
import blessed from "blessed";
import type { World, Entity } from "../../src/index";
import { Board, Piece, Score, GameOver, Paused } from "./components";
import { BOARD_W, BOARD_H, ghostY } from "./game";
// ANSI color codes for the 7 piece colors
const COLORS: Record<number, string> = {
0: "\x1b[40m", // black (empty)
1: "\x1b[46m", // cyan (I)
2: "\x1b[43m", // yellow (O)
3: "\x1b[45m", // magenta (T)
4: "\x1b[44m", // blue (J)
5: "\x1b[47m\x1b[30m", // white on black (L)
6: "\x1b[42m", // green (S)
7: "\x1b[41m", // red (Z)
};
const RESET = "\x1b[0m";
const GHOST_CHAR = "░";
export function createUI(): {
screen: blessed.Widgets.Screen;
boardBox: blessed.Widgets.BoxElement;
scoreText: blessed.Widgets.TextElement;
statusBox: blessed.Widgets.BoxElement;
statusText: blessed.Widgets.TextElement;
controlsText: blessed.Widgets.TextElement;
} {
const screen = blessed.screen({
smartCSR: true,
title: "Tetris",
});
// Board display
const boardBox = blessed.box({
parent: screen,
top: 2,
left: "center",
width: BOARD_W * 2 + 2,
height: BOARD_H + 2,
border: { type: "line" },
style: { border: { fg: "white" } },
});
// Score
const scoreText = blessed.text({
parent: screen,
top: 2,
left: 2,
width: 30,
height: 3,
content: "Score: 0 Lines: 0 Level: 1",
style: { fg: "white" },
});
// Status overlay (game over / paused)
const statusBox = blessed.box({
parent: screen,
top: "center",
left: "center",
width: 22,
height: 5,
border: { type: "line" },
style: { border: { fg: "yellow" }, fg: "yellow" },
hidden: true,
});
const statusText = blessed.text({
parent: statusBox,
top: 1,
left: "center",
width: 20,
align: "center",
style: { fg: "yellow" },
});
// Controls hint
const controlsText = blessed.text({
parent: screen,
bottom: 0,
left: "center",
width: 60,
height: 1,
content:
"← → : move ↑ : rotate ↓ : soft drop Space : hard drop P : pause Q : quit",
style: { fg: "gray" },
});
return { screen, boardBox, scoreText, statusBox, statusText, controlsText };
}
/** 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);
// Build display grid
const display = board.grid.map((row) => [...row]);
if (piece) {
const gy = ghostY(board.grid, piece.shape, piece.x, piece.y);
// Ghost
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue;
const by = gy + r;
const bx = piece.x + c;
if (
by >= 0 &&
by < BOARD_H &&
bx >= 0 &&
bx < BOARD_W &&
!display[by][bx]
) {
display[by][bx] = -piece.color;
}
}
}
// Active piece
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue;
const by = piece.y + r;
const bx = piece.x + c;
if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) {
display[by][bx] = piece.color;
}
}
}
}
// Build board string
let boardStr = "";
for (let r = 0; r < BOARD_H; r++) {
for (let c = 0; c < BOARD_W; c++) {
const v = display[r][c];
if (v === 0) {
boardStr += " ·";
} else if (v < 0) {
boardStr += COLORS[-v] + GHOST_CHAR + GHOST_CHAR + RESET;
} else {
boardStr += COLORS[v] + " " + RESET;
}
}
if (r < BOARD_H - 1) boardStr += "\n";
}
ui.boardBox.setContent(boardStr);
// Score
if (score) {
ui.scoreText.setContent(
`Score: ${score.points} Lines: ${score.lines} Level: ${score.level}`,
);
}
// Status
if (isOver) {
ui.statusBox.show();
ui.statusText.setContent("GAME OVER\nPress R to restart");
} else if (isPaused) {
ui.statusBox.show();
ui.statusText.setContent("PAUSED");
} else {
ui.statusBox.hide();
}
ui.screen.render();
}

43
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@types/blessed": "^0.1.27",
"@types/node": "^25.9.1",
"blessed": "^0.1.81",
"tsup": "^8.3.5",
"typescript": "^5.6.0",
"vitest": "^4.1.7"
@ -1247,6 +1250,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/blessed": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz",
"integrity": "sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -1272,6 +1285,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
@ -1415,6 +1438,19 @@
"node": ">=12"
}
},
"node_modules/blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
"dev": true,
"license": "MIT",
"bin": {
"blessed": "bin/tput.js"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@ -2489,6 +2525,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",

View File

@ -33,6 +33,9 @@
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/blessed": "^0.1.27",
"@types/node": "^25.9.1",
"blessed": "^0.1.81",
"tsup": "^8.3.5",
"typescript": "^5.6.0",
"vitest": "^4.1.7"