feat(examples): add blackjack game example

This commit is contained in:
hypercross 2026-06-02 00:57:22 +08:00
parent ef9abf03c6
commit 2469cdc7cb
6 changed files with 755 additions and 0 deletions

View File

@ -0,0 +1,16 @@
import { defineComponent } from "../../src/component";
/** Player takes another card. */
export const Hit = defineComponent("hit", {});
/** Player stands (ends their turn). */
export const Stand = defineComponent("stand", {});
/** Start a new round. */
export const NewRound = defineComponent("newRound", {});
/** Increase the bet. */
export const BetMore = defineComponent("betMore", {});
/** Decrease the bet. */
export const BetLess = defineComponent("betLess", {});

View File

@ -0,0 +1,38 @@
import { defineComponent } from "../../src/component";
// ── Card ─────────────────────────────────────────────
/** Each card is its own entity. `order` tracks position within its collection. */
export const Card = defineComponent("card", {
rank: "A",
suit: "♠",
order: 0,
});
// ── Tag components — mark which collection a card belongs to ─
export const InDeck = defineComponent("inDeck", {});
export const InPlayerHand = defineComponent("inPlayerHand", {});
export const InDealerHand = defineComponent("inDealerHand", {});
// ── Dealer state ─────────────────────────────────────
/** When present (as singleton), the dealer's hole card is hidden. */
export const HoleHidden = defineComponent("holeHidden", {});
// ── Score / state ────────────────────────────────────
export const Score = defineComponent("score", {
wins: 0,
losses: 0,
pushes: 0,
chips: 100,
});
export const Bet = defineComponent("bet", {
amount: 10,
});
/** Current phase of the game. */
export type Phase = "betting" | "playerTurn" | "dealerTurn" | "roundOver";
export const GamePhase = defineComponent("gamePhase", {
phase: "betting" as Phase,
message: "",
});

104
examples/blackjack/game.ts Normal file
View File

@ -0,0 +1,104 @@
// ── Blackjack game logic (pure functions, no ECS dependency) ──
export const SUITS = ["♠", "♥", "♦", "♣"] as const;
export const RANKS = [
"A",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
] as const;
export interface CardData {
rank: string;
suit: string;
}
// ── Hand evaluation ──────────────────────────────────
/** Numeric value of a single card rank. */
export function rankValue(rank: string): number {
if (rank === "A") return 11;
if (rank === "K" || rank === "Q" || rank === "J") return 10;
return parseInt(rank, 10);
}
/** Best total for a hand (aces count as 1 or 11). */
export function handValue(cards: CardData[]): number {
let total = 0;
let aces = 0;
for (const c of cards) {
total += rankValue(c.rank);
if (c.rank === "A") aces++;
}
while (total > 21 && aces > 0) {
total -= 10;
aces--;
}
return total;
}
export function isBust(cards: CardData[]): boolean {
return handValue(cards) > 21;
}
export function isBlackjack(cards: CardData[]): boolean {
return cards.length === 2 && handValue(cards) === 21;
}
export function isSoft(cards: CardData[]): boolean {
let total = 0;
let aces = 0;
for (const c of cards) {
total += rankValue(c.rank);
if (c.rank === "A") aces++;
}
return aces > 0 && total <= 21;
}
// ── Dealer logic ─────────────────────────────────────
/** Dealer must hit on soft 17 in this variant. */
export function dealerShouldHit(cards: CardData[]): boolean {
const val = handValue(cards);
if (val < 17) return true;
if (val === 17 && isSoft(cards)) return true;
return false;
}
// ── Outcome ──────────────────────────────────────────
export type Outcome = "win" | "lose" | "push" | "blackjack";
export function determineOutcome(
playerCards: CardData[],
dealerCards: CardData[],
): Outcome {
if (isBust(playerCards)) return "lose";
if (isBust(dealerCards)) return "win";
if (isBlackjack(playerCards) && !isBlackjack(dealerCards)) return "blackjack";
const pv = handValue(playerCards);
const dv = handValue(dealerCards);
if (pv > dv) return "win";
if (pv < dv) return "lose";
return "push";
}
/** Payout multiplier. Blackjack pays 3:2, win pays 1:1. */
export function payout(outcome: Outcome, 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;
}
}

View File

@ -0,0 +1,27 @@
// ── Keyboard input via blessed ────────────────────────
import type blessed from "blessed";
export type Key =
| "h"
| "s"
| "n"
| "up"
| "down"
| "q";
/** Wire blessed screen key events to a callback. */
export function startInput(
screen: blessed.Widgets.Screen,
onKey: (key: Key) => void,
): void {
screen.key(
["h", "s", "n", "up", "down", "q", "C-c"],
(_ch, key) => {
if (key.name === "q" || key.name === "C-c") {
screen.destroy();
process.exit(0);
}
onKey(key.name as Key);
},
);
}

397
examples/blackjack/main.ts Normal file
View File

@ -0,0 +1,397 @@
// ── 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);
});

View File

@ -0,0 +1,173 @@
// ── Terminal rendering via blessed ────────────────────
import blessed from "blessed";
import type { World } from "../../src/index";
import { query } from "../../src/query";
import {
Card,
InPlayerHand,
InDealerHand,
HoleHidden,
Score,
Bet,
GamePhase,
} from "./components";
import { handValue, isBust, isBlackjack } from "./game";
const SUIT_SYMBOLS: Record<string, string> = {
"♠": "\x1b[37m♠\x1b[0m",
"♥": "\x1b[31m♥\x1b[0m",
"♦": "\x1b[31m♦\x1b[0m",
"♣": "\x1b[37m♣\x1b[0m",
};
export function createUI(): {
screen: blessed.Widgets.Screen;
dealerBox: blessed.Widgets.BoxElement;
playerBox: blessed.Widgets.BoxElement;
infoText: blessed.Widgets.TextElement;
controlsText: blessed.Widgets.TextElement;
} {
const screen = blessed.screen({
smartCSR: true,
title: "Blackjack",
});
const dealerBox = blessed.box({
parent: screen,
top: 2,
left: "center",
width: 50,
height: 8,
border: { type: "line" },
label: " Dealer ",
style: { border: { fg: "white" } },
});
const playerBox = blessed.box({
parent: screen,
top: 12,
left: "center",
width: 50,
height: 8,
border: { type: "line" },
label: " Player ",
style: { border: { fg: "white" } },
});
const infoText = blessed.text({
parent: screen,
top: 22,
left: "center",
width: 50,
height: 5,
style: { fg: "white" },
});
const controlsText = blessed.text({
parent: screen,
bottom: 0,
left: "center",
width: 60,
height: 1,
style: { fg: "gray" },
});
return { screen, dealerBox, playerBox, infoText, controlsText };
}
function formatCard(rank: string, suit: string): string {
return `${rank}${SUIT_SYMBOLS[suit] ?? suit}`;
}
/** Collect cards from a hand tag, sorted by order. */
function collectHand(
world: World,
tag: typeof InPlayerHand,
): { rank: string; suit: string }[] {
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 }));
}
function formatHand(
cards: { rank: string; suit: string }[],
hideHole: boolean,
): string {
if (cards.length === 0) return " (empty)";
const lines: string[] = [];
if (hideHole && cards.length >= 2) {
lines.push(` ${formatCard(cards[0].rank, cards[0].suit)} ██`);
lines.push(` Value: ${handValue([cards[0]])}`);
} else {
const cardStr = cards.map((c) => formatCard(c.rank, c.suit)).join(" ");
lines.push(` ${cardStr}`);
const val = handValue(cards);
const extra = isBlackjack(cards)
? " — BLACKJACK!"
: isBust(cards)
? " — BUST!"
: "";
lines.push(` Value: ${val}${extra}`);
}
return lines.join("\n");
}
/** Render the full game state into the blessed UI. */
export function render(world: World, ui: ReturnType<typeof createUI>): void {
const score = world.tryGetSingleton(Score);
const bet = world.tryGetSingleton(Bet);
const phase = world.tryGetSingleton(GamePhase);
const holeHidden = world.hasSingleton(HoleHidden);
// Dealer
const dealerCards = collectHand(world, InDealerHand);
ui.dealerBox.setContent(formatHand(dealerCards, holeHidden));
// Player
const playerCards = collectHand(world, InPlayerHand);
ui.playerBox.setContent(formatHand(playerCards, false));
// Info
let info = "";
if (score) {
info += `Chips: ${score.chips} Wins: ${score.wins} Losses: ${score.losses} Pushes: ${score.pushes}\n`;
}
if (bet) {
info += `Bet: ${bet.amount}\n`;
}
if (phase) {
info += `\n${phase.message}`;
}
ui.infoText.setContent(info);
// Dynamic controls based on phase
if (phase) {
switch (phase.phase) {
case "betting":
ui.controlsText.setContent(
"N : new round ↑↓ : adjust bet Q : quit",
);
break;
case "playerTurn":
ui.controlsText.setContent("H : hit S : stand Q : quit");
break;
case "dealerTurn":
ui.controlsText.setContent("Dealer is playing...");
break;
case "roundOver":
ui.controlsText.setContent(
"N : new round ↑↓ : adjust bet Q : quit",
);
break;
}
}
ui.screen.render();
}