ecs-observable/examples/blackjack/render.ts

174 lines
4.3 KiB
TypeScript

// ── 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();
}