refactor(examples): reorganized code

Move code to avoid big main.ts and changed the bt to match the pattern
This commit is contained in:
hypercross 2026-06-02 10:39:27 +08:00
parent 5d125167cc
commit a97488e8d6
6 changed files with 574 additions and 514 deletions

View File

@ -1,4 +1,26 @@
import { defineComponent } from "../../src/component";
import type { World, Entity } from "../../src/index";
import { query } from "../../src/query";
import type { CommandQueue } from "../../src/commands/index";
import {
Card,
InDeck,
InPlayerHand,
InDealerHand,
HoleHidden,
Score,
Bet,
GamePhase,
} from "./components";
import {
handValue,
isBust,
isBlackjack,
determineOutcome,
payout,
} from "./game";
// ── Command definitions ──────────────────────────────
/** Player takes another card. */
export const Hit = defineComponent("hit", {});
@ -14,3 +36,112 @@ export const BetMore = defineComponent("betMore", {});
/** Decrease the bet. */
export const BetLess = defineComponent("betLess", {});
// ── Command handlers ─────────────────────────────────
export function registerCommands(
world: World,
commands: CommandQueue,
helpers: ReturnType<typeof import("./components").createCardHelpers>,
): void {
commands.handle(Hit, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
const cardEntity = helpers.drawCard();
if (cardEntity) {
helpers.dealTo(cardEntity, InPlayerHand);
}
if (isBust(helpers.getHand(InPlayerHand))) {
world.removeSingleton(HoleHidden);
resolveRound(world, helpers);
}
});
commands.handle(Stand, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
world.removeSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "dealerTurn",
message: "Dealer's turn...",
});
});
commands.handle(NewRound, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "roundOver" && phase.phase !== "betting") return;
const score = world.getSingleton(Score);
if (score.chips <= 0) {
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "You're out of chips! Restart the program to play again.",
});
return;
}
helpers.startRound();
});
commands.handle(BetMore, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
bet.amount = Math.min(bet.amount + 10, score.chips);
});
commands.handle(BetLess, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
bet.amount = Math.max(bet.amount - 10, 10);
});
}
// ── Internal ─────────────────────────────────────────
export function resolveRound(
world: World,
helpers: ReturnType<typeof import("./components").createCardHelpers>,
): void {
const playerCards = helpers.getHand(InPlayerHand);
const dealerCards = helpers.getHand(InDealerHand);
const score = world.getSingleton(Score);
const bet = world.getSingleton(Bet);
const outcome = determineOutcome(playerCards, dealerCards);
const winnings = payout(outcome, bet.amount);
score.chips += bet.amount + winnings;
switch (outcome) {
case "blackjack":
case "win":
score.wins++;
break;
case "lose":
score.losses++;
break;
case "push":
score.pushes++;
break;
}
const messages: Record<string, string> = {
win: "You win!",
lose: "Dealer wins.",
push: "Push — tie!",
blackjack: "Blackjack! You win 3:2!",
};
world.setSingleton(GamePhase, {
phase: "roundOver",
message: `${messages[outcome]} Press N for new round.`,
});
}

View File

@ -1,6 +1,10 @@
import { defineComponent } from "../../src/component";
import type { World, Entity } from "../../src/index";
import { query } from "../../src/query";
import { SUITS, RANKS, isBlackjack, type CardData } from "./game";
// ── Component definitions ────────────────────────────
// ── Card ─────────────────────────────────────────────
/** Each card is its own entity. `order` tracks position within its collection. */
export const Card = defineComponent("card", {
rank: "A",
@ -36,3 +40,164 @@ export const GamePhase = defineComponent("gamePhase", {
phase: "betting" as Phase,
message: "",
});
// ── Card helpers ─────────────────────────────────────
export function createCardHelpers(world: World) {
return {
/** Create all 52 card entities with the InDeck tag. */
buildDeck(): void {
let order = 0;
for (const suit of SUITS) {
for (const rank of RANKS) {
const e = world.spawn();
world.add(e, Card, { rank, suit, order });
world.add(e, InDeck);
order++;
}
}
},
/** Shuffle the deck: collect all InDeck entities, shuffle, reassign order. */
shuffleDeck(): void {
const entities = [...world.query(query(Card, InDeck))];
for (let i = entities.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[entities[i], entities[j]] = [entities[j], entities[i]];
}
for (let i = 0; i < entities.length; i++) {
world.get(entities[i], Card).order = i;
}
},
/** Draw the top card from the deck (highest order). Returns entity or null. */
drawCard(): Entity | null {
const cards = [...world.query(query(Card, InDeck))];
if (cards.length === 0) return null;
let top = cards[0];
let topOrder = world.get(top, Card).order;
for (let i = 1; i < cards.length; i++) {
const o = world.get(cards[i], Card).order;
if (o > topOrder) {
top = cards[i];
topOrder = o;
}
}
world.remove(top, InDeck);
return top;
},
/** Move a card entity to a hand tag, assigning the next order. */
dealTo(cardEntity: Entity, tag: typeof InPlayerHand): void {
const existing = [...world.query(query(Card, tag))];
const nextOrder =
existing.length === 0
? 0
: Math.max(...existing.map((e) => world.get(e, Card).order)) + 1;
world.add(cardEntity, tag);
world.get(cardEntity, Card).order = nextOrder;
},
/** Collect CardData from a hand tag, sorted by order. */
getHand(tag: typeof InPlayerHand): CardData[] {
return [...world.query(query(Card, tag))]
.map((e) => {
const c = world.get(e, Card);
return { rank: c.rank, suit: c.suit, order: c.order };
})
.sort((a, b) => a.order - b.order)
.map(({ rank, suit }) => ({ rank, suit }));
},
/** Remove all cards from a hand tag (destroy the card entities). */
clearHand(tag: typeof InPlayerHand): void {
for (const e of world.query(query(Card, tag))) {
world.destroy(e);
}
},
/** Count cards remaining in the deck. */
deckCount(): number {
return [...world.query(query(Card, InDeck))].length;
},
/** Deal initial 4 cards and start a round. */
startRound(): void {
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
score.chips -= bet.amount;
if (this.deckCount() < 15) {
const remaining = [...world.query(query(Card, InDeck))];
for (const e of remaining) {
world.remove(e, InDeck);
}
for (const e of remaining) {
world.add(e, InDeck);
}
this.shuffleDeck();
}
this.clearHand(InPlayerHand);
this.clearHand(InDealerHand);
world.removeSingleton(HoleHidden);
const c1 = this.drawCard();
const c2 = this.drawCard();
const c3 = this.drawCard();
const c4 = this.drawCard();
if (c1) this.dealTo(c1, InPlayerHand);
if (c2) this.dealTo(c2, InDealerHand);
if (c3) this.dealTo(c3, InPlayerHand);
if (c4) this.dealTo(c4, InDealerHand);
const playerCards = this.getHand(InPlayerHand);
const dealerCards = this.getHand(InDealerHand);
if (isBlackjack(playerCards)) {
world.removeSingleton(HoleHidden);
if (isBlackjack(dealerCards)) {
score.chips += bet.amount;
score.pushes++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Both have Blackjack — Push! Press N for new round.",
});
} else {
const winnings = payout(0, "blackjack", bet.amount);
score.chips += bet.amount + winnings;
score.wins++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Blackjack! You win 3:2! Press N for new round.",
});
}
} else {
world.addSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "playerTurn",
message: "Your turn — H to hit, S to stand.",
});
}
},
};
}
// ── Internal ─────────────────────────────────────────
function payout(outcome: string, bet: number): number {
switch (outcome) {
case "blackjack":
return Math.floor(bet * 1.5);
case "win":
return bet;
case "push":
return 0;
case "lose":
return -bet;
default:
return 0;
}
}

View File

@ -2,10 +2,11 @@
//
// Architecture:
// Behaviour Tree (buildTree) — controls game flow:
// root (repeat)
// parallel
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
// └── repeat
// └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands, mutates state
// ├── dealerPlay (leaf) — auto-plays dealer hand on dealerTurn
// ├── handleInput (leaf) — reads queued commands
// └── render (leaf) — draws via blessed
//
// CommandQueue — processes input:
@ -22,34 +23,28 @@
// npx tsx examples/blackjack/main.ts
import { World } from "../../src/index";
import { query } from "../../src/query";
import { buildTree } from "../../src/bt/index";
import { CommandQueue } from "../../src/commands/index";
import {
Card,
InDeck,
InPlayerHand,
InDealerHand,
HoleHidden,
Score,
Bet,
GamePhase,
InDealerHand,
createCardHelpers,
} from "./components";
import { Hit, Stand, NewRound, BetMore, BetLess } from "./commands";
import {
SUITS,
RANKS,
handValue,
isBust,
isBlackjack,
dealerShouldHit,
determineOutcome,
payout,
type CardData,
} from "./game";
registerCommands,
resolveRound,
Hit,
Stand,
NewRound,
BetMore,
BetLess,
} from "./commands";
import { dealerShouldHit } from "./game";
import { createUI, render } from "./render";
import { startInput, type Key } from "./input";
@ -57,268 +52,40 @@ import { startInput, type Key } from "./input";
// ── Setup ────────────────────────────────────────────
const world = new World();
// Singleton game state
world.addSingleton(Score);
world.addSingleton(Bet);
world.addSingleton(GamePhase);
// Create blessed UI
const ui = createUI();
// ── Card helpers (tag-based) ─────────────────────────
/** Create all 52 card entities with the InDeck tag. */
function buildDeck(): void {
let order = 0;
for (const suit of SUITS) {
for (const rank of RANKS) {
const e = world.spawn();
world.add(e, Card, { rank, suit, order });
world.add(e, InDeck);
order++;
}
}
}
/** Shuffle the deck: collect all InDeck entities, shuffle, reassign order. */
function shuffleDeck(): void {
const entities = [...world.query(query(Card, InDeck))];
// Fisher-Yates shuffle
for (let i = entities.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[entities[i], entities[j]] = [entities[j], entities[i]];
}
// Reassign order to match shuffled position
for (let i = 0; i < entities.length; i++) {
world.get(entities[i], Card).order = i;
}
}
/** Draw the top card from the deck (highest order). Returns entity or null. */
function drawCard(): number | null {
const cards = [...world.query(query(Card, InDeck))];
if (cards.length === 0) return null;
// Find the one with the highest order
let top = cards[0];
let topOrder = world.get(top, Card).order;
for (let i = 1; i < cards.length; i++) {
const o = world.get(cards[i], Card).order;
if (o > topOrder) {
top = cards[i];
topOrder = o;
}
}
world.remove(top, InDeck);
return top;
}
/** Move a card entity to a hand tag, assigning the next order. */
function dealTo(cardEntity: number, tag: typeof InPlayerHand): void {
const existing = [...world.query(query(Card, tag))];
const nextOrder =
existing.length === 0
? 0
: Math.max(...existing.map((e) => world.get(e, Card).order)) + 1;
world.add(cardEntity, tag);
world.get(cardEntity, Card).order = nextOrder;
}
/** Collect CardData from a hand tag, sorted by order. */
function getHand(tag: typeof InPlayerHand): CardData[] {
return [...world.query(query(Card, tag))]
.map((e) => {
const c = world.get(e, Card);
return { rank: c.rank, suit: c.suit, order: c.order };
})
.sort((a, b) => a.order - b.order)
.map(({ rank, suit }) => ({ rank, suit }));
}
/** Remove all cards from a hand tag (destroy the card entities). */
function clearHand(tag: typeof InPlayerHand): void {
for (const e of world.query(query(Card, tag))) {
world.destroy(e);
}
}
/** Count cards remaining in the deck. */
function deckCount(): number {
return [...world.query(query(Card, InDeck))].length;
}
// ── Game flow ────────────────────────────────────────
function dealInitial(): void {
const c1 = drawCard();
const c2 = drawCard();
const c3 = drawCard();
const c4 = drawCard();
if (c1) dealTo(c1, InPlayerHand);
if (c2) dealTo(c2, InDealerHand);
if (c3) dealTo(c3, InPlayerHand);
if (c4) dealTo(c4, InDealerHand);
}
function startRound(): void {
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
// Deduct bet
score.chips -= bet.amount;
// Re-shuffle if deck is low
if (deckCount() < 15) {
// Move all remaining InDeck cards back, then shuffle
const remaining = [...world.query(query(Card, InDeck))];
for (const e of remaining) {
world.remove(e, InDeck);
}
for (const e of remaining) {
world.add(e, InDeck);
}
shuffleDeck();
}
// Clear old hands
clearHand(InPlayerHand);
clearHand(InDealerHand);
world.removeSingleton(HoleHidden);
dealInitial();
// Check for natural blackjack
const playerCards = getHand(InPlayerHand);
const dealerCards = getHand(InDealerHand);
if (isBlackjack(playerCards)) {
world.removeSingleton(HoleHidden);
if (isBlackjack(dealerCards)) {
score.chips += bet.amount;
score.pushes++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Both have Blackjack — Push! Press N for new round.",
});
} else {
const winnings = payout("blackjack", bet.amount);
score.chips += bet.amount + winnings;
score.wins++;
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "Blackjack! You win 3:2! Press N for new round.",
});
}
} else {
world.addSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "playerTurn",
message: "Your turn — H to hit, S to stand.",
});
}
}
function resolveRound(): void {
const playerCards = getHand(InPlayerHand);
const dealerCards = getHand(InDealerHand);
const score = world.getSingleton(Score);
const bet = world.getSingleton(Bet);
const outcome = determineOutcome(playerCards, dealerCards);
const winnings = payout(outcome, bet.amount);
score.chips += bet.amount + winnings;
switch (outcome) {
case "blackjack":
case "win":
score.wins++;
break;
case "lose":
score.losses++;
break;
case "push":
score.pushes++;
break;
}
const messages: Record<string, string> = {
win: "You win!",
lose: "Dealer wins.",
push: "Push — tie!",
blackjack: "Blackjack! You win 3:2!",
};
world.setSingleton(GamePhase, {
phase: "roundOver",
message: `${messages[outcome]} Press N for new round.`,
});
}
// ── Command handlers ─────────────────────────────────
const cards = createCardHelpers(world);
const commands = new CommandQueue(world);
commands.handle(Hit, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
const cardEntity = drawCard();
if (cardEntity) {
dealTo(cardEntity, InPlayerHand);
}
if (isBust(getHand(InPlayerHand))) {
world.removeSingleton(HoleHidden);
resolveRound();
}
});
commands.handle(Stand, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "playerTurn") return;
world.removeSingleton(HoleHidden);
world.setSingleton(GamePhase, {
phase: "dealerTurn",
message: "Dealer's turn...",
});
});
commands.handle(NewRound, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "roundOver" && phase.phase !== "betting") return;
const score = world.getSingleton(Score);
if (score.chips <= 0) {
world.setSingleton(GamePhase, {
phase: "roundOver",
message: "You're out of chips! Restart the program to play again.",
});
return;
}
startRound();
});
commands.handle(BetMore, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
const score = world.getSingleton(Score);
bet.amount = Math.min(bet.amount + 10, score.chips);
});
commands.handle(BetLess, () => {
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
const bet = world.getSingleton(Bet);
bet.amount = Math.max(bet.amount - 10, 10);
});
registerCommands(world, commands, cards);
// ── Behaviour Tree ───────────────────────────────────
const runner = buildTree(world, {
kind: "parallel",
children: [
{
kind: "leaf",
*run() {
while (true) {
const dt: number = yield;
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") continue;
if (dealerShouldHit(cards.getHand(InDealerHand))) {
const cardEntity = cards.drawCard();
if (cardEntity) {
cards.dealTo(cardEntity, InDealerHand);
}
} else {
resolveRound(world, cards);
}
}
},
},
{
kind: "repeat",
child: {
kind: "sequential",
@ -329,25 +96,6 @@ const runner = buildTree(world, {
commands.execute();
},
},
{
kind: "leaf",
*run() {
while (true) {
const dt: number = yield;
const phase = world.getSingleton(GamePhase);
if (phase.phase !== "dealerTurn") continue;
if (dealerShouldHit(getHand(InDealerHand))) {
const cardEntity = drawCard();
if (cardEntity) {
dealTo(cardEntity, InDealerHand);
}
} else {
resolveRound();
}
}
},
},
{
kind: "leaf",
run: () => {
@ -356,6 +104,8 @@ const runner = buildTree(world, {
},
],
},
},
],
});
// ── Input → Command mapping ──────────────────────────
@ -381,8 +131,8 @@ world.setSingleton(GamePhase, {
message: "Welcome to Blackjack! Press N to start.",
});
buildDeck();
shuffleDeck();
cards.buildDeck();
cards.shuffleDeck();
runner.schedule((runner as any).root);
const TICK_MS = 16;

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();
});
}

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);
}
},
};
}

View File

@ -2,10 +2,11 @@
//
// Architecture:
// Behaviour Tree (buildTree) — controls game flow:
// root (repeat)
// parallel
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
// └── repeat
// └── seq (sequential)
// ├── handleInput (leaf) — reads queued commands, mutates state
// ├── gravityTick (leaf) — auto-drop piece on timer
// ├── handleInput (leaf) — reads queued commands
// └── render (leaf) — draws via blessed
//
// CommandQueue — processes input:
@ -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,175 +52,21 @@ 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",
kind: "parallel",
children: [
{
kind: "leaf",
run: () => {
commands.execute();
},
},
{
kind: "leaf",
*run() {
@ -231,19 +79,30 @@ const runner = buildTree(world, {
timer.accumulator += dt;
if (timer.accumulator >= timer.interval) {
timer.accumulator -= timer.interval;
if (hasActivePiece()) {
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 {
lockAndSpawn();
pieces.lockAndSpawn();
}
}
}
}
},
},
{
kind: "repeat",
child: {
kind: "sequential",
children: [
{
kind: "leaf",
run: () => {
commands.execute();
},
},
{
kind: "leaf",
run: () => {
@ -252,6 +111,8 @@ const runner = buildTree(world, {
},
],
},
},
],
});
// ── 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();