refactor(examples): reorganized code

Move code to avoid big main.ts and changed the bt to match the pattern
This commit is contained in:
2026-06-02 10:39:27 +08:00
parent 5d125167cc
commit a97488e8d6
6 changed files with 574 additions and 514 deletions
+102
View File
@@ -1,4 +1,24 @@
import { defineComponent } from "../../src/component";
import type { World } from "../../src/index";
import type { CommandQueue } from "../../src/commands/index";
import {
Board,
Piece,
Score,
GameOver,
Paused,
TickTimer,
createPieceHelpers,
} from "./components";
import {
collides,
lockPiece,
clearLines,
scoreForLines,
tryRotate,
} from "./game";
// ── Command definitions ──────────────────────────────
/** Move the active piece left. */
export const MoveLeft = defineComponent("moveLeft", {});
@@ -20,3 +40,85 @@ export const TogglePause = defineComponent("togglePause", {});
/** Restart after game over. */
export const Restart = defineComponent("restart", {});
// ── Command handlers ─────────────────────────────────
export function registerCommands(
world: World,
commands: CommandQueue,
pieces: ReturnType<typeof createPieceHelpers>,
): void {
const hasActivePiece = () => world.hasSingleton(Piece);
const isBlocked = () =>
world.hasSingleton(GameOver) || world.hasSingleton(Paused);
commands.handle(MoveLeft, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--;
}
});
commands.handle(MoveRight, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++;
}
});
commands.handle(Rotate, () => {
if (!hasActivePiece() || isBlocked()) return;
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;
piece.x = result.x;
}
});
commands.handle(SoftDrop, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
});
commands.handle(HardDrop, () => {
if (!hasActivePiece() || isBlocked()) return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
pieces.lockAndSpawn();
});
commands.handle(TogglePause, () => {
if (world.hasSingleton(GameOver)) return;
if (world.hasSingleton(Paused)) {
world.removeSingleton(Paused);
} else {
world.addSingleton(Paused);
}
});
commands.handle(Restart, () => {
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.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);
pieces.spawnPiece();
});
}
+53 -1
View File
@@ -1,6 +1,16 @@
import { defineComponent } from "../../src/component";
import type { World } from "../../src/index";
import {
randomPiece,
collides,
lockPiece,
clearLines,
scoreForLines,
BOARD_W,
} from "./game";
// ── Component definitions ────────────────────────────
// ── 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[],
@@ -29,3 +39,45 @@ export const TickTimer = defineComponent("tickTimer", {
accumulator: 0,
interval: 800, // ms between gravity ticks
});
// ── Piece helpers ────────────────────────────────────
export function createPieceHelpers(world: World) {
return {
spawnPiece(): void {
const p = randomPiece();
world.addSingleton(Piece, {
shape: p.shape,
color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
y: 0,
});
},
lockAndSpawn(): void {
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.removeSingleton(Piece);
const cleared = clearLines(board.grid);
if (cleared > 0) {
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.getSingleton(TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
}
this.spawnPiece();
const newPiece = world.getSingleton(Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.removeSingleton(Piece);
world.addSingleton(GameOver);
}
},
};
}
+63 -203
View File
@@ -2,11 +2,12 @@
//
// Architecture:
// Behaviour Tree (buildTree) — controls game flow:
// root (repeat)
// ── seq (sequential)
// ├── handleInput (leaf) — reads queued commands, mutates state
// ── gravityTick (leaf) — auto-drop piece on timer
// └── render (leaf) — draws via blessed
// 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()
@@ -22,9 +23,18 @@ import { World } from "../../src/index";
import { buildTree } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
import {
Board,
Piece,
Score,
GameOver,
Paused,
TickTimer,
createPieceHelpers,
} from "./components";
import {
registerCommands,
MoveLeft,
MoveRight,
Rotate,
@@ -34,15 +44,7 @@ import {
Restart,
} from "./commands";
import {
randomPiece,
collides,
lockPiece,
clearLines,
scoreForLines,
tryRotate,
BOARD_W,
} from "./game";
import { collides } from "./game";
import { createUI, render } from "./render";
import { startInput, type Key } from "./input";
@@ -50,208 +52,67 @@ import { startInput, type Key } from "./input";
// ── Setup ────────────────────────────────────────────
const world = new World();
// Singleton game state — one entity holds all of these
world.addSingleton(Board);
world.addSingleton(Score);
world.addSingleton(TickTimer);
// Create blessed UI
const ui = createUI();
// Spawn the first piece
spawnPiece();
// ── Command handlers ─────────────────────────────────
const pieces = createPieceHelpers(world);
const commands = new CommandQueue(world);
function spawnPiece(): void {
const p = randomPiece();
world.addSingleton(Piece, {
shape: p.shape,
color: p.color,
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
y: 0,
});
}
function hasActivePiece(): boolean {
return world.hasSingleton(Piece);
}
// Move left
commands.handle(MoveLeft, () => {
if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
piece.x--;
}
});
// Move right
commands.handle(MoveRight, () => {
if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
piece.x++;
}
});
// Rotate
commands.handle(Rotate, () => {
if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return;
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;
piece.x = result.x;
}
});
// Soft drop
commands.handle(SoftDrop, () => {
if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
});
// Hard drop
commands.handle(HardDrop, () => {
if (
!hasActivePiece() ||
world.hasSingleton(GameOver) ||
world.hasSingleton(Paused)
)
return;
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
}
lockAndSpawn();
});
// Toggle pause
commands.handle(TogglePause, () => {
if (world.hasSingleton(GameOver)) return;
if (world.hasSingleton(Paused)) {
world.removeSingleton(Paused);
} else {
world.addSingleton(Paused);
}
});
// Restart
commands.handle(Restart, () => {
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.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.getSingleton(Piece);
const board = world.getSingleton(Board);
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
world.removeSingleton(Piece);
const cleared = clearLines(board.grid);
if (cleared > 0) {
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.getSingleton(TickTimer);
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
}
spawnPiece();
const newPiece = world.getSingleton(Piece);
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
world.removeSingleton(Piece);
world.addSingleton(GameOver);
}
}
registerCommands(world, commands, pieces);
pieces.spawnPiece();
// ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, {
kind: "repeat",
child: {
kind: "sequential",
children: [
{
kind: "leaf",
run: () => {
commands.execute();
},
},
{
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 (hasActivePiece()) {
const piece = world.getSingleton(Piece);
const board = world.getSingleton(Board);
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
piece.y++;
} else {
lockAndSpawn();
}
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: "leaf",
run: () => {
render(world, ui);
},
},
{
kind: "repeat",
child: {
kind: "sequential",
children: [
{
kind: "leaf",
run: () => {
commands.execute();
},
},
{
kind: "leaf",
run: () => {
render(world, ui);
},
},
],
},
],
},
},
],
});
// ── Input → Command mapping ──────────────────────────
@@ -281,7 +142,6 @@ const interval = setInterval(() => {
runner.tick(TICK_MS);
}, TICK_MS);
// Cleanup on exit
process.on("SIGINT", () => {
clearInterval(interval);
ui.screen.destroy();