148 lines
3.8 KiB
TypeScript
148 lines
3.8 KiB
TypeScript
// ── Blackjack: BT-driven game loop with command-based input ──
|
|
//
|
|
// Architecture:
|
|
// Behaviour Tree (buildTree) — controls game flow:
|
|
// 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()
|
|
// → handlers mutate game state
|
|
//
|
|
// Cards as entities with tag components:
|
|
// Each card is an entity with a Card component ({ rank, suit, order }).
|
|
// Tag components (InDeck, InPlayerHand, InDealerHand) mark which
|
|
// collection the card belongs to. Queries find cards by tag.
|
|
// Order is tracked via the `order` field on Card.
|
|
//
|
|
// Usage:
|
|
// npx tsx examples/blackjack/main.ts
|
|
|
|
import { World } from "../../src/index";
|
|
import { buildTree } from "../../src/bt/index";
|
|
import { CommandQueue } from "../../src/commands/index";
|
|
|
|
import {
|
|
Score,
|
|
Bet,
|
|
GamePhase,
|
|
InDealerHand,
|
|
createCardHelpers,
|
|
} from "./components";
|
|
|
|
import {
|
|
registerCommands,
|
|
resolveRound,
|
|
Hit,
|
|
Stand,
|
|
NewRound,
|
|
BetMore,
|
|
BetLess,
|
|
} from "./commands";
|
|
|
|
import { dealerShouldHit } from "./game";
|
|
|
|
import { createUI, render } from "./render";
|
|
import { startInput, type Key } from "./input";
|
|
|
|
// ── Setup ────────────────────────────────────────────
|
|
const world = new World();
|
|
|
|
world.addSingleton(Score);
|
|
world.addSingleton(Bet);
|
|
world.addSingleton(GamePhase);
|
|
|
|
const ui = createUI();
|
|
const cards = createCardHelpers(world);
|
|
const commands = new CommandQueue(world);
|
|
|
|
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",
|
|
children: [
|
|
{
|
|
kind: "leaf",
|
|
run: () => {
|
|
commands.execute();
|
|
},
|
|
},
|
|
{
|
|
kind: "leaf",
|
|
run: () => {
|
|
render(world, ui);
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
// ── Input → Command mapping ──────────────────────────
|
|
const keyToCommand: Partial<Record<Key, typeof Hit>> = {
|
|
h: Hit,
|
|
s: Stand,
|
|
n: NewRound,
|
|
up: BetMore,
|
|
down: BetLess,
|
|
};
|
|
|
|
startInput(ui.screen, (key) => {
|
|
const cmd = keyToCommand[key];
|
|
if (cmd) {
|
|
const cmdEntity = world.spawn();
|
|
world.add(cmdEntity, cmd);
|
|
}
|
|
});
|
|
|
|
// ── Game loop ────────────────────────────────────────
|
|
world.setSingleton(GamePhase, {
|
|
phase: "betting",
|
|
message: "Welcome to Blackjack! Press N to start.",
|
|
});
|
|
|
|
cards.buildDeck();
|
|
cards.shuffleDeck();
|
|
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);
|
|
});
|