refactor: replay ui to terminal style

This commit is contained in:
2026-05-16 22:35:35 +08:00
parent 351cac7734
commit 9697c24276
4 changed files with 848 additions and 142 deletions
+143 -39
View File
@@ -9,11 +9,8 @@
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
// Per-player palette is derived deterministically from
// player_id so the same player always looks identical.
// * Animation — seat sprites get a transient `act-${kind}` class that maps
// to an avatar @keyframes animation in styles.css.
// * Terminal — renderFrame() writes a fixed-width command-line view into
// <pre>, keeping all fetch/upload/replay processing unchanged.
// =============================================================================
const state = {
@@ -28,10 +25,13 @@ const state = {
source: "",
};
const THEME_STORAGE_KEY = "txh-replay-theme";
const el = {
subtitle: document.querySelector("#subtitle"),
sourceBadge: document.querySelector("#sourceBadge"),
pollBadge: document.querySelector("#pollBadge"),
themeMode: document.querySelector("#themeMode"),
serverUrl: document.querySelector("#serverUrl"),
gameId: document.querySelector("#gameId"),
fetchBtn: document.querySelector("#fetchBtn"),
@@ -52,9 +52,8 @@ const el = {
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
frameLabel: document.querySelector("#frameLabel"),
tableOutput: document.querySelector("#tableOutput"),
eventLog: document.querySelector("#eventLog"),
};
@@ -528,28 +527,103 @@ function renderFrame() {
const game = state.game;
const hasData = Boolean(game && hand && frame);
el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
el.tableMessage.style.display = hasData ? "" : "block";
el.handBadge.textContent = hasData ? `hand ${hand.hand_number}` : "hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "idle";
el.potLabel.textContent = hasData ? `pot ${money(frame.pot)}` : "pot 0";
el.frameLabel.textContent = hasData ? `frame ${state.frameIndex + 1}/${state.frames.length}` : "frame 0/0";
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
el.boardCards.replaceChildren();
for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
el.seatLayer.replaceChildren();
if (hasData) {
frame.players.forEach((player, index) => {
const position = seatPosition(index, frame.players.length);
el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
});
}
el.tableOutput.textContent = hasData
? renderTerminalFrame(game, hand, frame)
: [
"$ txh-replay --status idle",
"",
"source: none",
"event : waiting for game data",
"",
"load: fetch from a game service or upload a replay json snapshot",
].join("\n");
renderLog();
syncControls();
}
function renderTerminalFrame(game, hand, frame) {
const active = frame.activePlayerId || "-";
const command = [
"$ txh-replay",
`--game ${game.game_id || "-"}`,
`--hand ${hand.hand_number}`,
`--frame ${state.frameIndex + 1}/${state.frames.length}`,
].join(" ");
const lines = [
command,
"",
`source: ${state.source || "-"}`,
`status: ${game.status || "-"} | street: ${STREET_LABELS[frame.street] || frame.street} | pot: ${money(frame.pot)}`,
`board : ${formatCards(frame.board, 5)}`,
`event : ${frame.message}`,
`actor : ${active}`,
"",
"players",
`${pad("seat", 4)} ${pad("flag", 4)} ${pad("player", 22)} ${pad("stack", 10, "right")} ${pad("street", 10, "right")} ${pad("total", 10, "right")} cards`,
`${"-".repeat(4)} ${"-".repeat(4)} ${"-".repeat(22)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(13)}`,
];
for (const player of frame.players) {
const playerName = player.name && player.name !== player.player_id
? `${player.name}/${player.player_id}`
: player.player_id;
lines.push([
pad(player.seat, 4),
pad(playerFlags(player, hand, frame), 4),
pad(playerName, 22),
pad(money(player.stack), 10, "right"),
pad(money(player.street_bet), 10, "right"),
pad(money(player.total_bet), 10, "right"),
formatCards(knownCardsForPlayer(player, hand, frame), 2),
].join(" "));
}
lines.push(
"",
"flags: > active | D dealer | F folded | A all-in",
);
return lines.join("\n");
}
function playerFlags(player, hand, frame) {
const flags = [];
if (player.player_id === frame.activePlayerId) flags.push(">");
if (player.seat === hand.button_seat) flags.push("D");
if (player.folded) flags.push("F");
if (player.all_in) flags.push("A");
return flags.join("") || ".";
}
function formatCards(cards, minimum = 0) {
const output = (cards || []).map(cardText);
while (output.length < minimum) output.push("[--]");
return output.join(" ");
}
function cardText(raw) {
if (!raw || raw === "back") return "[??]";
const parts = cardParts(raw);
return `[${parts.rank}${parts.suit}]`;
}
function pad(value, width, align = "left") {
const text = truncate(value, width);
const gap = Math.max(0, width - text.length);
return align === "right" ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`;
}
function truncate(value, width) {
const text = String(value ?? "");
if (text.length <= width) return text;
return `${text.slice(0, Math.max(0, width - 1))}~`;
}
function knownCardsForPlayer(player, hand, frame) {
const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
@@ -566,18 +640,12 @@ function knownCardsForPlayer(player, hand, frame) {
// the `.past` class.
// ---------------------------------------------------------------------------
function renderLog() {
const hand = state.hands[state.currentHandIndex];
el.eventLog.replaceChildren();
if (!hand) return;
const events = [
{ text: `${hand.hand_number} 手牌开始`, kind: "setup" },
...(hand.actions || []).map((action) => ({ text: actionText(action), kind: "action" })),
...(hand.awards || []).map((award) => ({ text: awardText(award), kind: "award" })),
];
if (!state.frames.length) return;
let currentLi = null;
events.forEach((event, index) => {
state.frames.forEach((frame, index) => {
const li = document.createElement("li");
li.textContent = `${index + 1}. ${event.text}`;
li.textContent = `${String(index + 1).padStart(2, "0")} ${frame.type.padEnd(8, " ")} ${frame.message}`;
if (index < state.frameIndex) li.classList.add("past");
if (index === state.frameIndex) {
li.classList.add("current");
@@ -588,7 +656,8 @@ function renderLog() {
// Keep the focused event visible without yanking the page when an event
// is already in view.
if (currentLi) {
currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
currentLi.scrollIntoView({ block: "nearest", behavior });
}
}
@@ -600,7 +669,7 @@ function syncControls() {
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
el.nextBtn.disabled = !loaded || !hasNextFrame;
el.resetBtn.disabled = !loaded;
el.playBtn.textContent = state.playing ? "" : "";
el.playBtn.textContent = state.playing ? "pause" : "play";
}
// ---------------------------------------------------------------------------
@@ -675,11 +744,13 @@ function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterH
function renderSummary() {
const game = state.game;
const hand = state.hands[state.currentHandIndex];
const blinds = hand?.blinds || game;
const blinds = hand?.blinds || game?.blinds?.current || game?.blinds || game || {};
const smallBlind = blinds.small_blind ?? blinds.small ?? blinds.sb;
const bigBlind = blinds.big_blind ?? blinds.big ?? blinds.bb;
el.gameStatus.textContent = game?.status || "-";
el.playerCount.textContent = game?.players?.length ?? "-";
el.handCount.textContent = game?.hands?.length ?? "-";
el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
el.blindLevel.textContent = game ? `${money(smallBlind)} / ${money(bigBlind)}` : "-";
}
function populateHands() {
@@ -787,8 +858,13 @@ function setAutoPoll(enabled) {
}
function showMessage(message) {
el.tableMessage.textContent = message;
el.sourceBadge.textContent = "Error";
el.tableOutput.textContent = [
"$ txh-replay --status error",
"",
`error: ${message}`,
].join("\n");
el.sourceBadge.textContent = "ERROR";
el.frameLabel.textContent = "frame 0/0";
}
function escapeHtml(value) {
@@ -800,9 +876,35 @@ function escapeHtml(value) {
.replaceAll("'", "&#039;");
}
function applyTheme(mode) {
const theme = ["auto", "dark", "light"].includes(mode) ? mode : "auto";
document.documentElement.dataset.theme = theme;
if (el.themeMode) el.themeMode.value = theme;
}
function initTheme() {
let stored = "auto";
try {
stored = window.localStorage.getItem(THEME_STORAGE_KEY) || "auto";
} catch {
stored = "auto";
}
applyTheme(stored);
}
// ---------------------------------------------------------------------------
// Wiring.
// ---------------------------------------------------------------------------
el.themeMode.addEventListener("change", () => {
const mode = el.themeMode.value;
applyTheme(mode);
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// Ignore storage failures; the current session still applies the theme.
}
});
el.fetchBtn.addEventListener("click", () => {
fetchFromServer().catch((error) => showMessage(error.message));
});
@@ -829,4 +931,6 @@ el.prevBtn.addEventListener("click", () => prevFrame());
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
window.addEventListener("resize", () => renderFrame());
initTheme();
syncControls();
renderFrame();