数据源
- - - -回放
- - -牌局摘要
--
-
- 状态
- -
- 玩家
- -
- 总手数
- -
- 盲注
- -
diff --git a/.gitignore b/.gitignore index 64b9066..af2de71 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ htmlcov/ .vscode/ *.swp *.swo +.codex/ # debug resources debug/ diff --git a/texas_holdem_replay/static/app.js b/texas_holdem_replay/static/app.js index a9c18d2..6ac672b 100644 --- a/texas_holdem_replay/static/app.js +++ b/texas_holdem_replay/static/app.js @@ -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 +//
, 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();
diff --git a/texas_holdem_replay/static/index.html b/texas_holdem_replay/static/index.html
index 4af31d1..f724f69 100644
--- a/texas_holdem_replay/static/index.html
+++ b/texas_holdem_replay/static/index.html
@@ -1,5 +1,5 @@
-
+
@@ -8,121 +8,111 @@
-
-
-
-
+
+
-
- No Data
- Auto Off
+
+
+ Texas Hold X Replay
+ awaiting game snapshot
+
+
+ NO DATA
+ AUTO OFF
+
+
-
-
-
-
-
- Hand -
- 未加载
-
-
- Pot 0
-
+
+
+ $ /source
+
+
+
-
-
-
-
-
-
-
-
+
+
+
-
-
-
- 数据源
-
-
-
-
-
-
-
+
+ $ /replay
+
+
+
+
+
+
+
-
-
- 回放
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 牌局摘要
-
- - 状态
- -
- - 玩家
- -
- - 总手数
- -
- - 盲注
- -
-
+
-