174 lines
4.3 KiB
TypeScript
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();
|
|
}
|