diff --git a/examples/blackjack/commands.ts b/examples/blackjack/commands.ts index 84b257f..3090d69 100644 --- a/examples/blackjack/commands.ts +++ b/examples/blackjack/commands.ts @@ -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, +): 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, +): 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 = { + 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.`, + }); +} diff --git a/examples/blackjack/components.ts b/examples/blackjack/components.ts index c4f7008..720c446 100644 --- a/examples/blackjack/components.ts +++ b/examples/blackjack/components.ts @@ -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; + } +} diff --git a/examples/blackjack/main.ts b/examples/blackjack/main.ts index 4242d9a..8f1a2d1 100644 --- a/examples/blackjack/main.ts +++ b/examples/blackjack/main.ts @@ -2,11 +2,12 @@ // // Architecture: // Behaviour Tree (buildTree) — controls game flow: -// root (repeat) -// └── seq (sequential) -// ├── handleInput (leaf) — reads queued commands, mutates state -// ├── dealerPlay (leaf) — auto-plays dealer hand on dealerTurn -// └── render (leaf) — draws via blessed +// parallel +// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand +// └── repeat +// └── seq (sequential) +// ├── handleInput (leaf) — reads queued commands +// └── render (leaf) — draws via blessed // // CommandQueue — processes input: // Keyboard → spawn command entities → CommandQueue.execute() @@ -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,305 +52,60 @@ 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 = { - 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: "repeat", - child: { - kind: "sequential", - children: [ - { - kind: "leaf", - run: () => { - commands.execute(); - }, - }, - { - kind: "leaf", - *run() { - while (true) { - const dt: number = yield; - const phase = world.getSingleton(GamePhase); - if (phase.phase !== "dealerTurn") continue; + kind: "parallel", + children: [ + { + 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(); + if (dealerShouldHit(cards.getHand(InDealerHand))) { + const cardEntity = cards.drawCard(); + if (cardEntity) { + cards.dealTo(cardEntity, InDealerHand); } + } else { + resolveRound(world, cards); } - }, + } }, - { - 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 ────────────────────────── @@ -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; diff --git a/examples/tetris/commands.ts b/examples/tetris/commands.ts index 68e0321..5ddc9d7 100644 --- a/examples/tetris/commands.ts +++ b/examples/tetris/commands.ts @@ -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, +): 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(); + }); +} diff --git a/examples/tetris/components.ts b/examples/tetris/components.ts index c382203..fc75942 100644 --- a/examples/tetris/components.ts +++ b/examples/tetris/components.ts @@ -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); + } + }, + }; +} diff --git a/examples/tetris/main.ts b/examples/tetris/main.ts index 0c3dddf..4648147 100644 --- a/examples/tetris/main.ts +++ b/examples/tetris/main.ts @@ -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();