refactor: replay ui to terminal style
This commit is contained in:
@@ -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("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user