398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
|
|
// ── Blackjack: BT-driven game loop with command-based input ──
|
||
|
|
//
|
||
|
|
// 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
|
||
|
|
//
|
||
|
|
// 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 { 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,
|
||
|
|
} from "./components";
|
||
|
|
|
||
|
|
import { Hit, Stand, NewRound, BetMore, BetLess } from "./commands";
|
||
|
|
|
||
|
|
import {
|
||
|
|
SUITS,
|
||
|
|
RANKS,
|
||
|
|
handValue,
|
||
|
|
isBust,
|
||
|
|
isBlackjack,
|
||
|
|
dealerShouldHit,
|
||
|
|
determineOutcome,
|
||
|
|
payout,
|
||
|
|
type CardData,
|
||
|
|
} from "./game";
|
||
|
|
|
||
|
|
import { createUI, render } from "./render";
|
||
|
|
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 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);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── 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;
|
||
|
|
|
||
|
|
if (dealerShouldHit(getHand(InDealerHand))) {
|
||
|
|
const cardEntity = drawCard();
|
||
|
|
if (cardEntity) {
|
||
|
|
dealTo(cardEntity, InDealerHand);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
resolveRound();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
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.",
|
||
|
|
});
|
||
|
|
|
||
|
|
buildDeck();
|
||
|
|
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);
|
||
|
|
});
|