ecs-observable/examples/blackjack/main.ts

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