// ── 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 = { "♠": "\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): 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(); }