From 3c027eae0bd98747e36d2cf59e47fb03b1514396 Mon Sep 17 00:00:00 2001 From: "qianrui.mmmy" Date: Wed, 13 May 2026 16:48:00 +0800 Subject: [PATCH] feat: add replay server and web client --- docs/replay_view_design.md | 81 +++ pyproject.toml | 4 + tests/test_replay_server.py | 28 + texas_holdem_replay/__init__.py | 2 + texas_holdem_replay/server.py | 153 +++++ texas_holdem_replay/static/app.js | 832 ++++++++++++++++++++++++++ texas_holdem_replay/static/index.html | 132 ++++ texas_holdem_replay/static/styles.css | 735 +++++++++++++++++++++++ 8 files changed, 1967 insertions(+) create mode 100644 docs/replay_view_design.md create mode 100644 tests/test_replay_server.py create mode 100644 texas_holdem_replay/__init__.py create mode 100644 texas_holdem_replay/server.py create mode 100644 texas_holdem_replay/static/app.js create mode 100644 texas_holdem_replay/static/index.html create mode 100644 texas_holdem_replay/static/styles.css diff --git a/docs/replay_view_design.md b/docs/replay_view_design.md new file mode 100644 index 0000000..ff30873 --- /dev/null +++ b/docs/replay_view_design.md @@ -0,0 +1,81 @@ +# Texas Hold X 回放视图设计方案 + +## 目标 + +构建一个与核心游戏服务和 Agent 解耦的独立 Web 服务,用于读取游戏详情 JSON 并以动画方式回放 Texas Hold'em 对局。它可以部署在任意能运行 Python 标准库 HTTP 服务的环境中,不要求核心服务增加前端路由,也不改变 Human HTTP Agent / AI HTTP Agent 协议。 + +## 架构 + +新增 `texas_holdem_replay` 包: + +- `texas_holdem_replay.server`:标准库 HTTP 服务,负责托管静态前端文件,并提供 `/api/fetch-game` 抓取代理。 +- `texas_holdem_replay/static/index.html`:独立页面入口。 +- `texas_holdem_replay/static/styles.css`:像素风牌桌、卡牌、座位和响应式布局。 +- `texas_holdem_replay/static/app.js`:数据归一化、手牌时间轴生成、动画播放、上传 JSON、手动抓取和自动轮询。 + +运行方式: + +```bash +python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088 +``` + +也可以通过安装后的脚本启动: + +```bash +texas-holdem-replay --host 127.0.0.1 --port 8088 +``` + +## 数据输入 + +视图支持三种输入方式: + +1. 填写核心游戏服务地址和 `game_id`,点击“获取”。 + 前端请求自身的 `/api/fetch-game?base_url=...&game_id=...`,由回放服务去访问核心服务的 `/games/{game_id}`,避免浏览器跨域限制。 +2. 上传静态 JSON 文件。 + 文件在浏览器本地解析,不依赖核心服务。 +3. 开启自动获取。 + 按指定秒数轮询同一个核心服务和 `game_id`,用于观察正在运行的游戏快照。新快照会先尝试和当前回放位置合并;如果当前手牌追加了 action、showdown 或 award,回放会接续到新增 frame,而不是重头播放。 + +`/api/fetch-game` 也支持传入完整 `url`,便于未来接入网关或静态 JSON 服务。 + +## 数据模型与归一化 + +当前游戏详情返回结构包含: + +- `players`:玩家最终状态。 +- `hands`:历史手牌列表。 +- 每手 `actions`:行动记录,包含 `street`、`player_id`、`action`、`amount`、行动后 `street_bet` 和 `stack`。 +- `awards`:底池分配。 +- `showdown_hands`:摊牌玩家手牌。 + +前端不会依赖核心服务内部对象,只读取 JSON 字段并做归一化: + +- 根据 `players` 和 `actions` 得到座位顺序。 +- 从 `starting_stack` 开始,按历史 `actions.stack` 和 `awards` 推演每手牌开始时的筹码。 +- 每手牌生成一组离散 frame:开局、跨街发公共牌、玩家行动、摊牌、结算。 +- 每个 frame 都有稳定 key。上传 JSON、手动获取和自动获取都会复用同一套合并逻辑:同一 `game_id` 且能找到当前手牌时,保留当前 frame;如果用户停在最新进度末尾,且当前手牌或后续手牌出现新增 frame,则从当前位置接续播放增量。用户正在查看历史手牌时,不会被新轮询强制跳走。 +- 非 showdown 玩家手牌显示卡背。 +- 已预留 `hand.hole_cards[player_id]` 和 `hand.private_hands[player_id]` 兼容点,后续核心服务返回非 showdown 手牌时可直接展示。 + +## 动画与交互 + +牌桌采用卡通像素风格: + +- 椭圆绿色牌桌、木质像素边框、像素筹码状态条。 +- 玩家围绕牌桌分布,当前行动玩家高亮并显示冒泡文字。 +- 公共牌按 flop / turn / river 分阶段发出。 +- 动作之间默认保留约 1.1-1.5 秒间隔,用户可用“节奏”滑杆调慢或调快。 +- 支持上一帧、下一帧、播放/暂停、重置、选择指定手牌。 + +## 响应式设计 + +桌面端为三栏布局:数据控制、牌桌、事件日志。中等屏幕下事件日志下移,手机和平板窄屏下改为单列,牌桌高度固定到可观看的移动端比例,座位尺寸通过 CSS clamp 控制,避免文字和控件溢出。 + +## 解耦边界 + +回放服务只消费核心服务的公开 HTTP JSON,不导入 `texas_holdem.engine`、`service` 或 Agent 代码,也不要求游戏服务开放 CORS。未来可以单独部署在 CDN + 轻量代理、容器或任意 Python 运行环境中。 + +## 后续适配 + +- 核心服务返回非 showdown 玩家手牌后,只需要让每手 JSON 包含 `hole_cards` 或 `private_hands` 映射,前端现有归一化会优先读取。 +- 如果服务端未来提供增量事件流,可以在 `app.js` 增加一个 source adapter,把事件流转成同样的 frame 列表,动画层无需重写。 diff --git a/pyproject.toml b/pyproject.toml index 73d3f87..b91300b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,10 @@ dependencies = [] texas-holdem-server = "texas_holdem.server:main" texas-holdem-human = "texas_holdem.human_client:main" texas-holdem-ai = "texas_holdem.ai_client:main" +texas-holdem-replay = "texas_holdem_replay.server:main" + +[tool.setuptools.package-data] +texas_holdem_replay = ["static/*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_replay_server.py b/tests/test_replay_server.py new file mode 100644 index 0000000..fbdf9e9 --- /dev/null +++ b/tests/test_replay_server.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import unittest + +from texas_holdem_replay.server import build_game_url + + +class ReplayServerTests(unittest.TestCase): + def test_build_game_url_from_base_and_game_id(self) -> None: + self.assertEqual( + build_game_url({"base_url": ["http://127.0.0.1:8000/"], "game_id": ["game 1"]}), + "http://127.0.0.1:8000/games/game%201", + ) + + def test_build_game_url_accepts_full_url(self) -> None: + self.assertEqual( + build_game_url({"url": ["https://example.test/games/demo"]}), + "https://example.test/games/demo", + ) + + def test_build_game_url_rejects_non_http_url(self) -> None: + with self.assertRaises(ValueError): + build_game_url({"url": ["file:///tmp/game.json"]}) + + +if __name__ == "__main__": + unittest.main() + diff --git a/texas_holdem_replay/__init__.py b/texas_holdem_replay/__init__.py new file mode 100644 index 0000000..5632b00 --- /dev/null +++ b/texas_holdem_replay/__init__.py @@ -0,0 +1,2 @@ +"""Standalone web replay viewer for Texas Hold X game snapshots.""" + diff --git a/texas_holdem_replay/server.py b/texas_holdem_replay/server.py new file mode 100644 index 0000000..0d7501b --- /dev/null +++ b/texas_holdem_replay/server.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import argparse +import json +import mimetypes +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, unquote, urlparse, parse_qs +from urllib.request import Request, urlopen + + +STATIC_DIR = Path(__file__).with_name("static") + + +def build_game_url(query: dict[str, list[str]]) -> str: + raw_url = _first(query, "url") + if raw_url: + return _validate_http_url(raw_url) + + base_url = _first(query, "base_url") + game_id = _first(query, "game_id") + if not base_url or not game_id: + raise ValueError("provide either url or both base_url and game_id") + + base = _validate_http_url(base_url).rstrip("/") + safe_game_id = quote(game_id.strip("/"), safe="") + return f"{base}/games/{safe_game_id}" + + +class ReplayRequestHandler(BaseHTTPRequestHandler): + server_version = "TexasHoldemReplay/0.1" + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/api/health": + self._json({"ok": True, "service": "texas-holdem-replay"}) + return + if parsed.path == "/api/fetch-game": + self._handle_fetch_game(parsed.query) + return + self._serve_static(parsed.path) + + def do_OPTIONS(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self._cors_headers() + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + return + + def _handle_fetch_game(self, raw_query: str) -> None: + query = parse_qs(raw_query) + try: + target = build_game_url(query) + timeout = float(_first(query, "timeout") or 8) + request = Request(target, headers={"Accept": "application/json"}) + with urlopen(request, timeout=max(1, min(timeout, 30))) as response: + payload = response.read() + content_type = response.headers.get("Content-Type", "") + try: + data = json.loads(payload.decode("utf-8")) + except json.JSONDecodeError as exc: + raise ValueError("target did not return valid JSON") from exc + if not isinstance(data, dict): + raise ValueError("target JSON must be an object") + self._json({"source": target, "content_type": content_type, "game": data}) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:600] + self._json( + {"error": f"upstream returned HTTP {exc.code}", "detail": body}, + HTTPStatus.BAD_GATEWAY, + ) + except (URLError, TimeoutError) as exc: + self._json({"error": "failed to reach upstream", "detail": str(exc)}, HTTPStatus.BAD_GATEWAY) + except ValueError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def _serve_static(self, raw_path: str) -> None: + relative = unquote(raw_path.lstrip("/")) or "index.html" + if relative.endswith("/"): + relative += "index.html" + candidate = (STATIC_DIR / relative).resolve() + root = STATIC_DIR.resolve() + if root not in candidate.parents and candidate != root: + self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) + return + if not candidate.is_file(): + candidate = STATIC_DIR / "index.html" + body = candidate.read_bytes() + content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream" + self.send_response(HTTPStatus.OK) + self._cors_headers() + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store" if candidate.name == "index.html" else "public, max-age=60") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self._cors_headers() + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _cors_headers(self) -> None: + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + +def create_server(host: str, port: int) -> ThreadingHTTPServer: + return ThreadingHTTPServer((host, port), ReplayRequestHandler) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the standalone Texas Hold X replay web viewer.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8088, type=int) + args = parser.parse_args() + + server = create_server(args.host, args.port) + print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +def _first(query: dict[str, list[str]], key: str) -> str | None: + values = query.get(key) + if not values: + return None + value = values[0].strip() + return value or None + + +def _validate_http_url(value: str) -> str: + parsed = urlparse(value.strip()) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("url must be an absolute http(s) URL") + return value.strip() + + +if __name__ == "__main__": + main() diff --git a/texas_holdem_replay/static/app.js b/texas_holdem_replay/static/app.js new file mode 100644 index 0000000..a9c18d2 --- /dev/null +++ b/texas_holdem_replay/static/app.js @@ -0,0 +1,832 @@ +// ============================================================================= +// Texas Hold X Replay — viewer logic +// ----------------------------------------------------------------------------- +// Architecture overview: +// * `state` — single mutable runtime store; never read directly by render +// helpers, instead passed via the active frame snapshot. +// * `el` — cached DOM references (set once on load, not per render). +// * Frames — pre-computed, immutable per-hand snapshots produced by +// 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. +// ============================================================================= + +const state = { + game: null, + hands: [], + currentHandIndex: 0, + frames: [], + frameIndex: 0, + playing: false, + timer: null, + pollTimer: null, + source: "", +}; + +const el = { + subtitle: document.querySelector("#subtitle"), + sourceBadge: document.querySelector("#sourceBadge"), + pollBadge: document.querySelector("#pollBadge"), + serverUrl: document.querySelector("#serverUrl"), + gameId: document.querySelector("#gameId"), + fetchBtn: document.querySelector("#fetchBtn"), + fileInput: document.querySelector("#fileInput"), + autoPoll: document.querySelector("#autoPoll"), + pollSeconds: document.querySelector("#pollSeconds"), + handSelect: document.querySelector("#handSelect"), + pace: document.querySelector("#pace"), + prevBtn: document.querySelector("#prevBtn"), + playBtn: document.querySelector("#playBtn"), + nextBtn: document.querySelector("#nextBtn"), + resetBtn: document.querySelector("#resetBtn"), + progressBar: document.querySelector("#progressBar"), + gameStatus: document.querySelector("#gameStatus"), + playerCount: document.querySelector("#playerCount"), + handCount: document.querySelector("#handCount"), + blindLevel: document.querySelector("#blindLevel"), + handBadge: document.querySelector("#handBadge"), + streetLabel: document.querySelector("#streetLabel"), + potLabel: document.querySelector("#potLabel"), + boardCards: document.querySelector("#boardCards"), + tableMessage: document.querySelector("#tableMessage"), + seatLayer: document.querySelector("#seatLayer"), + eventLog: document.querySelector("#eventLog"), +}; + +// --------------------------------------------------------------------------- +// Static label tables. Kept module-scope so render functions are fully pure. +// --------------------------------------------------------------------------- +const STREET_LABELS = { + setup: "准备", + preflop: "翻牌前", + flop: "翻牌", + turn: "转牌", + river: "河牌", + showdown: "摊牌", + awards: "结算", +}; + +const ACTION_LABELS = { + small_blind: "小盲", + big_blind: "大盲", + fold: "弃牌", + check: "过牌", + call: "跟注", + bet: "下注", + raise: "加注", + all_in: "全下", + award: "赢得", + showdown: "亮牌", + deal: "发牌", +}; + +// Bubble color category — bucket many actions into a few visual classes. +// (The .speech.kind-* CSS classes paint distinct backgrounds for fold/ +// call/check/bet/raise/all_in/award. Blinds are rendered as bet-style.) + +// --------------------------------------------------------------------------- +// Pixel avatar palette — picked from a small 8-bit-friendly set so every +// player is visually distinct. Derivation is deterministic via FNV-1a over +// player_id, so a given player always renders with the same colors across +// frames and hands. +// --------------------------------------------------------------------------- +const AVATAR_PALETTE = { + skin: ["#f7c98a", "#e2a96b", "#c98c5a", "#8b5a3c", "#f2d4ad"], + hair: ["#2a1e16", "#5b3a23", "#a85d2a", "#d8a13a", "#7a3b8e", "#3a4d8a", "#b9362f"], + shirt: ["#c44c4c", "#3a7fbf", "#3a9b62", "#b87a1f", "#7a3b8e", "#2a3a5a", "#d8a13a"], +}; + +/** + * Hash a string into a non-negative 32-bit integer using FNV-1a. + * Used to deterministically pick avatar palette entries from player_id. + */ +function fnv1a(value) { + let hash = 0x811c9dc5; + const text = String(value); + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +/** + * Pick a deterministic avatar palette for the given player_id. + * Returns an object with { skin, hair, shirt } CSS colors. + */ +function avatarPalette(playerId) { + const hash = fnv1a(playerId); + const pickFrom = (list, salt) => list[(hash >>> salt) % list.length]; + return { + skin: pickFrom(AVATAR_PALETTE.skin, 0), + hair: pickFrom(AVATAR_PALETTE.hair, 5), + shirt: pickFrom(AVATAR_PALETTE.shirt, 11), + }; +} + +// 8x8 sprite map for the player portrait. +// . = transparent / dark frame backdrop +// H = hair S = skin E = eye M = mouth (red) T = shirt body +// Two side hair pixels on rows 2-3 give a cohesive helmet shape; row 5 mouth +// adds personality. Row 7 is full shirt body so the avatar reads as a bust. +const AVATAR_SPRITE = [ + ".HHHHHH.", + "HHHHHHHH", + "HSSSSSSH", + ".SESSESS", + ".SSSSSS.", + ".SSMMSS.", + ".TTTTTT.", + "TTTTTTTT", +]; + +/** + * Build an inline SVG string for a pixel-art avatar using the player's + * deterministic palette. Each filled cell becomes a 1x1 in an 8x8 + * viewBox; SVG `shape-rendering="crispEdges"` keeps the squares sharp. + */ +function avatarSvg(playerId) { + const palette = avatarPalette(playerId); + const colors = { + H: palette.hair, + S: palette.skin, + E: "#1c0e08", + M: "#b9362f", + T: palette.shirt, + }; + let rects = ""; + for (let y = 0; y < AVATAR_SPRITE.length; y += 1) { + const row = AVATAR_SPRITE[y]; + for (let x = 0; x < row.length; x += 1) { + const ch = row[x]; + const fill = colors[ch]; + if (!fill) continue; + rects += ``; + } + } + return `${rects}`; +} + +// --------------------------------------------------------------------------- +// Card rendering helpers. +// --------------------------------------------------------------------------- +function cardParts(raw) { + if (!raw || raw === "back") return { rank: "", suit: "", red: false, back: true }; + const suitCode = raw.slice(-1).toLowerCase(); + const rank = raw.slice(0, -1).toUpperCase(); + const suits = { h: "♥", d: "♦", c: "♣", s: "♠" }; + return { + rank, + suit: suits[suitCode] || suitCode, + red: suitCode === "h" || suitCode === "d", + back: false, + }; +} + +function renderCard(raw) { + const parts = cardParts(raw); + const card = document.createElement("div"); + card.className = `card${parts.red ? " red" : ""}${parts.back ? " back" : ""}`; + if (!parts.back) { + card.innerHTML = `${parts.rank}${parts.suit}${parts.rank}`; + } + return card; +} + +function money(value) { + return Number(value || 0).toLocaleString("en-US"); +} + +// --------------------------------------------------------------------------- +// Game normalization: hydrate the raw JSON into a uniform shape with +// player list and per-hand starting stacks. +// --------------------------------------------------------------------------- +function uniquePlayers(game) { + const byId = new Map(); + for (const player of game.players || []) { + byId.set(player.player_id, { + player_id: player.player_id, + name: player.name || player.player_id, + seat: Number(player.seat || 0), + stack: Number(player.stack || 0), + }); + } + for (const hand of game.hands || []) { + for (const action of hand.actions || []) { + if (!byId.has(action.player_id)) { + byId.set(action.player_id, { + player_id: action.player_id, + name: action.player_id, + seat: byId.size, + stack: Number(game.starting_stack || 0), + }); + } + } + } + return Array.from(byId.values()).sort((a, b) => a.seat - b.seat); +} + +function inferHandStarts(game) { + const players = uniquePlayers(game); + const stacks = new Map(players.map((player) => [player.player_id, Number(game.starting_stack || player.stack || 0)])); + const starts = new Map(); + for (const hand of game.hands || []) { + starts.set(hand.hand_number, new Map(stacks)); + for (const action of hand.actions || []) { + if (stacks.has(action.player_id)) stacks.set(action.player_id, Number(action.stack || 0)); + } + for (const award of hand.awards || []) { + const winners = award.winners || []; + const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0; + for (const winner of winners) stacks.set(winner, Number(stacks.get(winner) || 0) + share); + } + } + return starts; +} + +function normalizeGame(raw) { + const game = raw.game && raw.game.hands ? raw.game : raw; + if (!game || !Array.isArray(game.hands)) throw new Error("JSON 中未找到 hands 数组"); + const players = uniquePlayers(game); + const starts = inferHandStarts(game); + const hands = game.hands.map((hand) => ({ ...hand, startingStacks: starts.get(hand.hand_number) || new Map() })); + return { ...game, players, hands }; +} + +function clonePlayersForHand(game, hand) { + return game.players.map((player) => ({ + ...player, + stack: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0), + folded: false, + all_in: false, + in_hand: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0) > 0, + street_bet: 0, + total_bet: 0, + hole_cards: [], + })); +} + +// --------------------------------------------------------------------------- +// Frame builder — converts a hand's chronological action list into a list of +// fully-resolved frame snapshots. Each frame is independent and renderable. +// --------------------------------------------------------------------------- +function buildFrames(game, hand) { + const players = clonePlayersForHand(game, hand); + const byId = new Map(players.map((player) => [player.player_id, player])); + const frames = []; + const pot = { value: 0 }; + let street = "preflop"; + let boardCount = 0; + + // `actionKind` is what drives the seat sprite animation classes. It maps + // the raw action verb (fold/call/raise/...) onto a CSS animation token. + const snapshot = (type, message, activePlayerId = null, key = `${type}:${frames.length}`, extras = {}) => ({ + key: `hand:${hand.hand_number}:${key}`, + type, + message, + activePlayerId, + street, + pot: pot.value, + board: (hand.board || []).slice(0, boardCount), + players: players.map((player) => ({ ...player, hole_cards: [...player.hole_cards] })), + ...extras, + }); + + frames.push(snapshot("setup", `第 ${hand.hand_number} 手牌开始`, null, "setup")); + + const revealStreet = (nextStreet) => { + const counts = { flop: 3, turn: 4, river: 5 }; + const nextCount = counts[nextStreet] || boardCount; + for (const player of players) player.street_bet = 0; + if (nextCount > boardCount) { + street = nextStreet; + boardCount = Math.min(nextCount, (hand.board || []).length); + frames.push(snapshot("deal", `${STREET_LABELS[nextStreet]}发出`, null, `deal:${nextStreet}:${boardCount}`)); + } else { + street = nextStreet; + } + }; + + for (const [actionIndex, action] of (hand.actions || []).entries()) { + if (action.street !== street && action.street !== "preflop") revealStreet(action.street); + const player = byId.get(action.player_id); + if (!player) continue; + const previousTotal = Number(player.total_bet || 0); + player.street_bet = Number(action.street_bet || 0); + player.stack = Number(action.stack || 0); + if (action.action === "fold") player.folded = true; + if (action.action === "all_in" || player.stack === 0) player.all_in = true; + player.total_bet = previousTotal + Math.max(0, Number(action.amount || 0)); + pot.value += Math.max(0, Number(action.amount || 0)); + frames.push(snapshot( + "action", + actionText(action), + action.player_id, + actionKey(action, actionIndex), + { actionKind: action.action, bubbleText: bubbleTextFor(action) }, + )); + } + + if ((hand.board || []).length > boardCount) { + for (const nextStreet of ["flop", "turn", "river"]) { + if (({ flop: 3, turn: 4, river: 5 }[nextStreet] || 0) > boardCount) revealStreet(nextStreet); + } + } + + const shown = hand.showdown_hands || {}; + for (const [playerId, cards] of Object.entries(shown)) { + const player = byId.get(playerId); + if (player) player.hole_cards = cards; + } + if (Object.keys(shown).length) { + street = "showdown"; + frames.push(snapshot("showdown", "摊牌亮牌", null, `showdown:${Object.keys(shown).sort().join(",")}`)); + } + + street = "awards"; + for (const [awardIndex, award] of (hand.awards || []).entries()) { + const winners = award.winners || []; + const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0; + const remainder = winners.length ? Number(award.amount || 0) % winners.length : 0; + winners.forEach((winnerId, index) => { + const player = byId.get(winnerId); + if (player) player.stack += share + (index < remainder ? 1 : 0); + }); + pot.value = Math.max(0, pot.value - Number(award.amount || 0)); + frames.push(snapshot( + "award", + awardText(award), + winners[0] || null, + awardKey(award, awardIndex), + { actionKind: "award", bubbleText: `赢得 ${money(award.amount)}` }, + )); + } + + if (frames.length === 1) frames.push(snapshot("empty", "这手牌没有可回放动作", null, "empty")); + return frames; +} + +function actionKey(action, index) { + return [ + "action", index, action.street, action.player_id, action.action, + Number(action.amount || 0), Number(action.street_bet || 0), Number(action.stack || 0), + ].join(":"); +} + +function awardKey(award, index) { + return [ + "award", index, Number(award.amount || 0), + (award.winners || []).join(","), + award.hand_value?.name || "", + ].join(":"); +} + +function actionText(action) { + const label = ACTION_LABELS[action.action] || action.action; + if (["check", "fold"].includes(action.action)) return `${action.player_id} ${label}`; + return `${action.player_id} ${label} ${money(action.amount)}`; +} + +/** + * Build a SHORT speech-bubble label (player_id is implicit since the bubble + * already points at the seat). Keeps text readable inside narrow bubbles. + */ +function bubbleTextFor(action) { + const label = ACTION_LABELS[action.action] || action.action; + if (["check", "fold"].includes(action.action)) return label; + return `${label} ${money(action.amount)}`; +} + +function awardText(award) { + const winners = (award.winners || []).join(", "); + const handName = award.hand_value?.name ? ` · ${award.hand_value.name}` : ""; + return `${winners} 赢得 ${money(award.amount)}${handName}`; +} + +// --------------------------------------------------------------------------- +// Seat layout — six radial presets, with mobile fallback. +// Returned (x, y) values are percentages relative to the poker-table box +// (NOT the felt-shell). The poker-table reserves vertical padding so the +// top/bottom seats and their bubbles never overlap the table headers. +// --------------------------------------------------------------------------- +function seatPosition(index, count) { + const mobile = window.matchMedia("(max-width: 760px)").matches; + const presets = mobile ? mobileSeatPreset(count) : desktopSeatPreset(count); + if (presets[index]) return presets[index]; + const radiusX = mobile ? 36 : 39; + const radiusY = mobile ? 41 : 39; + const start = -90; + const angle = (start + index * (360 / Math.max(count, 1))) * Math.PI / 180; + return { + x: 50 + Math.cos(angle) * radiusX, + y: 50 + Math.sin(angle) * radiusY, + }; +} + +// Coordinates expressed in percent of the poker-table box (which already +// includes vertical padding around the felt). `y < ~25` lands above the +// felt (top rail), `y > ~75` lands below the felt (bottom rail). +function desktopSeatPreset(count) { + const presets = { + 2: [{ x: 50, y: 18 }, { x: 50, y: 82 }], + 3: [{ x: 50, y: 18 }, { x: 80, y: 72 }, { x: 20, y: 72 }], + 4: [{ x: 50, y: 18 }, { x: 84, y: 60 }, { x: 50, y: 84 }, { x: 16, y: 60 }], + 5: [{ x: 50, y: 18 }, { x: 84, y: 44 }, { x: 72, y: 84 }, { x: 28, y: 84 }, { x: 16, y: 44 }], + 6: [{ x: 50, y: 18 }, { x: 82, y: 33 }, { x: 82, y: 70 }, { x: 50, y: 86 }, { x: 18, y: 70 }, { x: 18, y: 33 }], + }; + return presets[count] || []; +} + +function mobileSeatPreset(count) { + const presets = { + 2: [{ x: 50, y: 16 }, { x: 50, y: 84 }], + 3: [{ x: 50, y: 15 }, { x: 80, y: 78 }, { x: 20, y: 78 }], + 4: [{ x: 50, y: 14 }, { x: 83, y: 60 }, { x: 50, y: 86 }, { x: 17, y: 60 }], + }; + return presets[count] || []; +} + +/** + * Map a seat coordinate to a `seat-top|bottom|left|right|mid` zone class. + * The CSS uses this to flip the speech bubble below the seat for top-rail + * seats so it never extends outside the visible table area. + */ +function seatZone(pos) { + if (pos.y < 32) return "seat-top"; + if (pos.y > 70) return "seat-bottom"; + if (pos.x < 34) return "seat-left"; + if (pos.x > 66) return "seat-right"; + return "seat-mid"; +} + +// --------------------------------------------------------------------------- +// DOM rendering: assemble a single seat element from a frame's player snapshot. +// Extracted into its own function so renderFrame stays declarative. +// --------------------------------------------------------------------------- +function renderSeat(player, position, frame, hand) { + const zone = seatZone(position); + const isActive = player.player_id === frame.activePlayerId; + const isDealer = player.seat === hand.button_seat; + const isCurrentActor = isActive && (frame.type === "action" || frame.type === "award"); + + const seat = document.createElement("div"); + seat.className = [ + "seat", + zone, + isActive ? "active" : "", + player.folded ? "folded" : "", + isDealer ? "dealer-seat" : "", + // The transient act-* class is what triggers the avatar's reaction + // animation (shake/nod/fold/cheer). Only apply on the actor's frame. + isCurrentActor && frame.actionKind ? `act-${frame.actionKind}` : "", + ].filter(Boolean).join(" "); + seat.style.setProperty("--x", `${position.x}%`); + seat.style.setProperty("--y", `${position.y}%`); + + // Speech bubble — shown on the active player's action/award frames using + // a SHORT label (e.g. "加注 50") so it fits the small bubble width. + const bubbleHtml = (isCurrentActor && frame.bubbleText) + ? `
${escapeHtml(frame.bubbleText)}
` + : ""; + + // Avatar — inline SVG pixel-art sprite, deterministic per player_id so + // the same player keeps a stable look across hands. + const avatarMarkup = ``; + + seat.innerHTML = ` + ${bubbleHtml} +
+
+ ${avatarMarkup} + ${escapeHtml(player.name || player.player_id)} + D +
+
+ Stack ${money(player.stack)} + ${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""} +
+
+ `; + + // Hole cards live inside the player-box so they share its layout flow. + const hole = document.createElement("div"); + hole.className = "card-row hole-cards"; + for (const card of knownCardsForPlayer(player, hand, frame)) hole.appendChild(renderCard(card)); + seat.querySelector(".player-box").appendChild(hole); + + return seat; +} + +function renderFrame() { + const hand = state.hands[state.currentHandIndex]; + const frame = state.frames[state.frameIndex]; + 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.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)); + }); + } + + renderLog(); + syncControls(); +} + +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); + const showdownCards = canRevealShowdown ? hand.showdown_hands?.[player.player_id] : null; + const shown = futureHoleCards || showdownCards || player.hole_cards; + if (shown && shown.length) return shown; + if (!player.in_hand && !player.folded) return []; + return ["back", "back"]; +} + +// --------------------------------------------------------------------------- +// Event log — one line per visible event, with auto-scroll keeping the +// current item in view. Items past the current frame index are dimmed via +// 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" })), + ]; + let currentLi = null; + events.forEach((event, index) => { + const li = document.createElement("li"); + li.textContent = `${index + 1}. ${event.text}`; + if (index < state.frameIndex) li.classList.add("past"); + if (index === state.frameIndex) { + li.classList.add("current"); + currentLi = li; + } + el.eventLog.appendChild(li); + }); + // Keep the focused event visible without yanking the page when an event + // is already in view. + if (currentLi) { + currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } +} + +function syncControls() { + const loaded = Boolean(state.game); + const hasPreviousFrame = state.frameIndex > 0 || state.currentHandIndex > 0; + const hasNextFrame = state.frameIndex < state.frames.length - 1 || state.currentHandIndex < state.hands.length - 1; + el.playBtn.disabled = !loaded; + el.prevBtn.disabled = !loaded || !hasPreviousFrame; + el.nextBtn.disabled = !loaded || !hasNextFrame; + el.resetBtn.disabled = !loaded; + el.playBtn.textContent = state.playing ? "Ⅱ" : "▶"; +} + +// --------------------------------------------------------------------------- +// Game lifecycle: load / merge / select hand / step / play. +// --------------------------------------------------------------------------- +function loadGame(raw, source, options = {}) { + const nextGame = normalizeGame(raw); + const wasPlaying = state.playing; + const mergeResult = options.allowMerge !== false ? mergeGame(nextGame) : { merged: false, advanced: false }; + if (!mergeResult.merged) { + pause(); + state.game = nextGame; + state.hands = state.game.hands; + state.currentHandIndex = Math.max(0, state.hands.length - 1); + state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]); + state.frameIndex = 0; + } + state.source = source; + el.sourceBadge.textContent = source; + el.subtitle.textContent = `${state.game.game_id || "未命名游戏"} · ${state.hands.length} hands`; + renderSummary(); + populateHands(); + el.handSelect.value = String(state.currentHandIndex); + renderFrame(); + if (mergeResult.merged && (wasPlaying || mergeResult.advanced)) play(); +} + +function mergeGame(nextGame) { + if (!state.game || state.game.game_id !== nextGame.game_id) return { merged: false, advanced: false }; + const currentHand = state.hands[state.currentHandIndex]; + const nextHandIndex = nextGame.hands.findIndex((hand) => hand.hand_number === currentHand?.hand_number); + if (nextHandIndex < 0) return { merged: false, advanced: false }; + + const oldFrame = state.frames[state.frameIndex]; + const nextFrames = buildFrames(nextGame, nextGame.hands[nextHandIndex]); + const oldKeyIndex = oldFrame ? nextFrames.findIndex((frame) => frame.key === oldFrame.key) : -1; + const atKnownFrame = oldKeyIndex >= 0; + const wasAtTail = state.frameIndex >= state.frames.length - 1; + const hadNewFramesOnCurrentHand = nextFrames.length > state.frames.length; + const currentWasLatestHand = state.currentHandIndex >= state.hands.length - 1; + const hasNewLaterHandFromCurrent = currentWasLatestHand && nextGame.hands.length > state.hands.length; + let advanced = false; + + state.game = nextGame; + state.hands = nextGame.hands; + state.currentHandIndex = nextHandIndex; + state.frames = nextFrames; + + if (atKnownFrame) { + advanced = shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasNewLaterHandFromCurrent); + state.frameIndex = advanced + ? Math.min(oldKeyIndex + 1, nextFrames.length - 1) + : oldKeyIndex; + } else { + state.frameIndex = Math.min(state.frameIndex, nextFrames.length - 1); + } + + if (state.frameIndex >= state.frames.length - 1 && hasNewLaterHandFromCurrent) { + state.currentHandIndex += 1; + state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]); + state.frameIndex = 0; + advanced = true; + } + + return { merged: true, advanced }; +} + +function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterHands) { + return wasAtTail && (hadNewFramesOnCurrentHand || hasLaterHands); +} + +function renderSummary() { + const game = state.game; + const hand = state.hands[state.currentHandIndex]; + const blinds = hand?.blinds || game; + 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)}` : "-"; +} + +function populateHands() { + el.handSelect.replaceChildren(); + state.hands.forEach((hand, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = `Hand ${hand.hand_number} · ${hand.actions?.length || 0} actions`; + el.handSelect.appendChild(option); + }); +} + +function selectHand(index) { + pause(); + state.currentHandIndex = Math.max(0, Math.min(index, state.hands.length - 1)); + state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]); + state.frameIndex = 0; + el.handSelect.value = String(state.currentHandIndex); + renderSummary(); + renderFrame(); +} + +function nextFrame() { + if (!state.frames.length) return; + if (state.frameIndex < state.frames.length - 1) { + state.frameIndex += 1; + renderFrame(); + return; + } + if (state.currentHandIndex < state.hands.length - 1) { + state.currentHandIndex += 1; + state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]); + state.frameIndex = 0; + el.handSelect.value = String(state.currentHandIndex); + renderSummary(); + renderFrame(); + return; + } + pause(); +} + +function prevFrame() { + pause(); + if (state.frameIndex === 0 && state.currentHandIndex > 0) { + state.currentHandIndex -= 1; + state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]); + state.frameIndex = state.frames.length - 1; + el.handSelect.value = String(state.currentHandIndex); + renderSummary(); + renderFrame(); + return; + } + state.frameIndex = Math.max(0, state.frameIndex - 1); + renderFrame(); +} + +function play() { + if (!state.game || state.playing) return; + state.playing = true; + tick(); + renderFrame(); +} + +function pause() { + state.playing = false; + if (state.timer) window.clearTimeout(state.timer); + state.timer = null; + syncControls(); +} + +function tick() { + if (!state.playing) return; + const frame = state.frames[state.frameIndex]; + const baseDelay = frame?.type === "deal" || frame?.type === "award" ? 1500 : 1150; + const delay = baseDelay * Number(el.pace.value || 1); + state.timer = window.setTimeout(() => { + nextFrame(); + tick(); + }, delay); +} + +// --------------------------------------------------------------------------- +// Network / file I/O. +// --------------------------------------------------------------------------- +async function fetchFromServer() { + const base = el.serverUrl.value.trim(); + const gameId = el.gameId.value.trim(); + if (!base || !gameId) throw new Error("请填写游戏服务地址和 Game ID"); + const url = `/api/fetch-game?${new URLSearchParams({ base_url: base, game_id: gameId })}`; + const response = await fetch(url); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "获取失败"); + loadGame(payload.game, `Server · ${gameId}`); +} + +function setAutoPoll(enabled) { + if (state.pollTimer) window.clearInterval(state.pollTimer); + state.pollTimer = null; + el.pollBadge.textContent = enabled ? `Auto ${el.pollSeconds.value}s` : "Auto Off"; + if (!enabled) return; + const interval = Math.max(5, Number(el.pollSeconds.value || 12)) * 1000; + state.pollTimer = window.setInterval(() => { + fetchFromServer().catch((error) => showMessage(error.message)); + }, interval); +} + +function showMessage(message) { + el.tableMessage.textContent = message; + el.sourceBadge.textContent = "Error"; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +// --------------------------------------------------------------------------- +// Wiring. +// --------------------------------------------------------------------------- +el.fetchBtn.addEventListener("click", () => { + fetchFromServer().catch((error) => showMessage(error.message)); +}); + +el.fileInput.addEventListener("change", async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + const raw = JSON.parse(await file.text()); + loadGame(raw, `File · ${file.name}`); + } catch (error) { + showMessage(error.message); + } finally { + event.target.value = ""; + } +}); + +el.autoPoll.addEventListener("change", () => setAutoPoll(el.autoPoll.checked)); +el.pollSeconds.addEventListener("change", () => setAutoPoll(el.autoPoll.checked)); +el.handSelect.addEventListener("change", () => selectHand(Number(el.handSelect.value))); +el.playBtn.addEventListener("click", () => state.playing ? pause() : play()); +el.nextBtn.addEventListener("click", () => nextFrame()); +el.prevBtn.addEventListener("click", () => prevFrame()); +el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex)); +window.addEventListener("resize", () => renderFrame()); + +syncControls(); diff --git a/texas_holdem_replay/static/index.html b/texas_holdem_replay/static/index.html new file mode 100644 index 0000000..4af31d1 --- /dev/null +++ b/texas_holdem_replay/static/index.html @@ -0,0 +1,132 @@ + + + + + + Texas Hold X Replay + + + +
+
+
+ +
+

Texas Hold X Replay

+

等待加载游戏数据

+
+
+
+ No Data + Auto Off +
+
+ +
+ +
+
+
+ Hand - + 未加载 +
+
+ Pot 0 +
+
+
+ + +
+
+
上传 JSON 或从游戏服务获取快照
+
+
+
+
+ + +
+
+

数据源

+ + +
+ + +
+
+ + +
+
+ +
+

回放

+ + +
+ + + + +
+
+
+
+
+ +
+

牌局摘要

+
+
状态
-
+
玩家
-
+
总手数
-
+
盲注
-
+
+
+
+ + +
+
+ + + + diff --git a/texas_holdem_replay/static/styles.css b/texas_holdem_replay/static/styles.css new file mode 100644 index 0000000..3a9b5fc --- /dev/null +++ b/texas_holdem_replay/static/styles.css @@ -0,0 +1,735 @@ +/* ========================================================================= + Texas Hold X Replay — Pixel-art skin & responsive layout + ------------------------------------------------------------------------- + Design goals: + 1. Stage zone (table + seats + animations) is visually isolated from the + interaction zone (controls + event log). Each zone has independent + overflow rules so speech bubbles never get clipped. + 2. Pixel-art aesthetic: hard edges, stepped shadows (no blur), 8-bit + palette, monospace typography. + 3. Three responsive breakpoints (desktop 3-col → tablet 2-col → mobile + stacked) — see media queries at the bottom of this file. + ========================================================================= */ + +:root { + color-scheme: dark; + + /* Palette — keep limited and high-contrast for that 8-bit look. */ + --ink: #f7efd2; + --ink-dim: #c8b98a; + --muted: #8e8466; + --panel: #221a17; + --panel-2: #2d231f; + --panel-3: #3a2c25; + --line: #5d4638; + --line-soft: #3b2c25; + + /* Felt greens. */ + --felt: #1d8a5f; + --felt-dark: #0c4a37; + --felt-light: #4ec089; + --felt-rail: #6c3a20; + --felt-rail-dark: #3a1d10; + + /* Accents. */ + --gold: #f0b64d; + --gold-dark: #b87a1f; + --red: #e24b4b; + --blue: #53a6de; + --green: #63cb73; + --purple: #b773d3; + + --shadow: rgba(8, 6, 5, 0.55); + --pixel: 3px; + + /* Seat sizing scales with the stage width via container query fallback + (clamp on viewport). */ + --seat-size: clamp(96px, 13vw, 138px); + --avatar-size: 44px; +} + +* { box-sizing: border-box; } + +body { + min-height: 100vh; + margin: 0; + color: var(--ink); + /* Pixel-art monospace stack — keeps numerals crisp & blocky. */ + font-family: "Courier New", "Lucida Console", "Press Start 2P", monospace; + font-size: 13px; + letter-spacing: 0.2px; + background: + /* Tiny noise made from offset diagonals for that CRT pixel-grid feel. */ + linear-gradient(45deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px, + linear-gradient(135deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px, + radial-gradient(ellipse at 50% 0%, #2a1f1a 0%, #14100e 60%, #0d0a08 100%); + image-rendering: pixelated; + -webkit-font-smoothing: none; + -moz-osx-font-smoothing: grayscale; +} + +button, input, select { font: inherit; } + +/* ---------- Buttons (chunky pixel "press" feel) ---------- */ +button, .file-btn { + min-height: 42px; + border: var(--pixel) solid #17100d; + color: var(--ink); + background: #3a2b24; + box-shadow: + inset -3px -3px 0 rgba(0,0,0,0.35), + inset 3px 3px 0 rgba(255,255,255,0.08), + 0 4px 0 #120d0b; + cursor: pointer; + transition: transform 90ms steps(2), filter 90ms steps(2); +} +button:hover, .file-btn:hover { filter: brightness(1.12); } +button:active, .file-btn:active { + transform: translateY(3px); + box-shadow: + inset -3px -3px 0 rgba(0,0,0,0.35), + inset 3px 3px 0 rgba(255,255,255,0.08), + 0 1px 0 #120d0b; +} +button:disabled { opacity: 0.4; cursor: not-allowed; } + +input, select { + width: 100%; + min-height: 40px; + border: 2px solid var(--line); + border-radius: 0; + color: var(--ink); + background: #15110f; + padding: 9px 10px; + outline: none; +} +input:focus, select:focus { border-color: var(--gold); } + +/* ---------- Shell / Topbar ---------- */ +.app-shell { + width: min(1640px, 100%); + margin: 0 auto; + padding: 18px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 14px 16px; + border: var(--pixel) solid #49382e; + background: + linear-gradient(180deg, #322620 0%, #1f1715 100%); + box-shadow: 0 8px 0 #0d0908, 0 18px 34px var(--shadow); +} + +.brand-lockup { display: flex; gap: 14px; align-items: center; min-width: 0; } +.brand-meta { min-width: 0; } + +.chip-mark { + display: grid; + place-items: center; + width: 58px; + height: 58px; + flex: 0 0 auto; + border: 4px dashed #fff4bc; + border-radius: 50%; + color: #20130b; + background: radial-gradient(circle, #ffe28a 0 42%, #c73f3d 43% 100%); + font-weight: 900; + text-shadow: 1px 1px 0 rgba(255,255,255,0.45); + /* Slow 8-bit chip rotation when idle for a touch of life. */ + animation: chipSpin 6s linear infinite; +} + +h1, h2, p { margin: 0; } +h1 { font-size: clamp(18px, 2.3vw, 28px); } +h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; } + +#subtitle { margin-top: 5px; color: var(--ink-dim); font-size: 12px; } + +.status-strip { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +/* Generic pixel "tag" badge — shared by status, hand, pot, etc. */ +.badge { + display: inline-block; + border: 2px solid #16100d; + padding: 7px 10px; + color: #1b120c; + font-weight: 700; + white-space: nowrap; + box-shadow: inset -2px -2px 0 rgba(0,0,0,0.18); +} +.badge-gold { background: var(--gold); } +.badge-blue { background: var(--blue); color: #0f1c2e; } + +/* ---------- Layout Grid ---------- */ +/* Desktop: stage in middle, controls left, events right. The stage column + is the dominant area (1.4fr) so the table breathes. */ +.layout-grid { + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 1.4fr) minmax(280px, 340px); + grid-template-areas: "controls stage events"; + gap: 16px; + margin-top: 18px; + align-items: start; +} +.control-panel { grid-area: controls; display: grid; gap: 14px; } +.stage-zone { grid-area: stage; min-width: 0; } +.event-panel { grid-area: events; display: grid; gap: 14px; } + +/* ---------- Panel sections (the chunky bordered cards) ---------- */ +.panel-section { + border: var(--pixel) solid #49382e; + background: linear-gradient(180deg, var(--panel-2), var(--panel)); + padding: 14px; + box-shadow: 0 7px 0 #0e0a08, 0 14px 26px var(--shadow); +} +.panel-section label { + display: grid; + gap: 6px; + margin-bottom: 10px; + color: var(--ink-dim); + font-size: 12px; +} + +.button-row, .transport-row, .auto-grid { display: grid; gap: 9px; } +.button-row { grid-template-columns: 1fr 1fr; } +.transport-row { grid-template-columns: repeat(4, minmax(42px, 1fr)); } +.auto-grid { grid-template-columns: 1fr 86px; align-items: end; } + +.toggle-line { display: flex !important; flex-direction: row; align-items: center; min-height: 40px; } +.toggle-line input { width: auto; min-height: auto; accent-color: var(--gold); } + +.primary-btn { color: #1b120c; background: var(--gold); } + +.file-btn { + display: grid; place-items: center; text-align: center; + position: relative; overflow: hidden; +} +.file-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } + +.progress-wrap { + height: 12px; margin-top: 14px; + border: 2px solid #120d0b; background: #14100e; +} +#progressBar { + width: 0; height: 100%; + background: linear-gradient(90deg, var(--red), var(--gold), var(--green)); + transition: width 220ms linear; +} + +.stat-list { display: grid; gap: 8px; margin: 0; } +.stat-list div { + display: flex; justify-content: space-between; gap: 12px; + border-bottom: 1px solid rgba(255,255,255,0.08); + padding-bottom: 7px; +} +.stat-list dt { color: var(--ink-dim); } +.stat-list dd { margin: 0; text-align: right; } + +/* ---------- Stage zone ---------- */ +.stage-head { + display: flex; justify-content: space-between; align-items: center; + gap: 10px; margin-bottom: 12px; +} +.stage-head-left { display: flex; gap: 10px; align-items: center; min-width: 0; } +#streetLabel { font-size: clamp(16px, 2vw, 22px); color: var(--ink); } + +/* The poker-table is the positioning context for seats. Its overflow MUST + stay visible so seats placed near the table edges (and their speech + bubbles) can extend slightly outside the green felt without being clipped. + The visual clipping is handled by .felt-shell instead. */ +.poker-table { + position: relative; + /* Reserve vertical breathing room above/below the felt for seats that + visually sit on the rail and for speech bubbles. */ + padding: 70px 12px 80px; + /* Explicit responsive height — required because seats and felt are + absolutely positioned and the inner content (community area) is + center-aligned. clamp() keeps it usable from 640px to ~820px. */ + height: clamp(620px, 64vw, 820px); + overflow: visible; +} + +/* Felt shell — actual visible green table. Absolutely positioned and + inset within poker-table so seats/bubbles can spill outside. */ +.felt-shell { + position: absolute; + inset: 60px 0 70px; + border: 6px solid var(--felt-rail); + border-radius: 46% / 36%; + background: var(--felt-rail-dark); + box-shadow: + inset 0 0 0 10px var(--felt-rail), + inset 0 0 0 16px #2c1b12, + 0 10px 0 #130c09, + 0 24px 44px var(--shadow); + overflow: hidden; + pointer-events: none; +} +.felt-rail { + /* Decorative pixel "studs" running along the rail. */ + position: absolute; inset: -2px; + background: + repeating-linear-gradient(90deg, + transparent 0 22px, rgba(255,220,160,0.18) 22px 24px), + repeating-linear-gradient(0deg, + transparent 0 22px, rgba(255,220,160,0.18) 22px 24px); + mix-blend-mode: screen; + opacity: 0.4; +} +.felt-surface { + position: absolute; + inset: 12px; + border-radius: 44% / 32%; + background: + radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 38%, var(--felt-dark) 100%); + overflow: hidden; +} +.felt-grid { + position: absolute; inset: 0; + opacity: 0.18; + /* 1px-wide pixel grid for the felt — gives a chess-board-like 8-bit feel. */ + background: + linear-gradient(90deg, transparent calc(100% - 2px), rgba(255,255,255,0.4) 0) 0 0 / 28px 28px, + linear-gradient(180deg, transparent calc(100% - 2px), rgba(255,255,255,0.28) 0) 0 0 / 28px 28px; + image-rendering: pixelated; +} +.felt-glow { + position: absolute; inset: 0; + background: radial-gradient(ellipse at 50% 45%, rgba(255,255,255,0.18), transparent 55%); + pointer-events: none; +} +.felt-mark { + position: absolute; + left: 50%; top: 50%; + transform: translate(-50%, -50%); + font-size: clamp(48px, 7vw, 96px); + font-weight: 900; + color: rgba(0,0,0,0.18); + letter-spacing: 6px; + text-shadow: 2px 2px 0 rgba(255,255,255,0.06); + user-select: none; +} + +/* ---------- Community area (board + message) ---------- */ +.community-area { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(60%, 560px); + display: grid; + justify-items: center; + gap: 14px; + z-index: 3; + pointer-events: none; +} + +.card-row { + display: flex; + justify-content: center; + gap: clamp(6px, 1vw, 12px); + min-height: 76px; +} +.card-row.board { min-height: 92px; } + +/* ---------- Cards ---------- */ +.card { + display: grid; + grid-template-rows: auto 1fr auto; + width: clamp(48px, 5.4vw, 70px); + height: clamp(66px, 7.4vw, 96px); + border: 3px solid #1c1411; + background: #fff7df; + color: #17100d; + padding: 4px 6px; + box-shadow: + inset -2px -2px 0 rgba(0,0,0,0.12), + inset 2px 2px 0 rgba(255,255,255,0.6), + 0 5px 0 rgba(0,0,0,0.42); + transform-origin: center; + animation: cardDeal 520ms cubic-bezier(.2,.9,.2,1); + font-family: "Courier New", monospace; +} +.card.red { color: #b92732; } +.card.back { + background: + /* 8-bit checker pattern for card back. */ + linear-gradient(45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px, + linear-gradient(-45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px, + linear-gradient(45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px, + linear-gradient(-45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px, + #173a72; +} +.card .rank { font-size: clamp(12px, 1.2vw, 16px); font-weight: 900; line-height: 1; } +.card .suit { display: grid; place-items: center; font-size: clamp(18px, 2vw, 28px); } +.card .rank.bottom { transform: rotate(180deg); justify-self: end; } + +.table-message { + min-height: 32px; + max-width: min(540px, 82%); + border: 3px solid #14100d; + padding: 8px 12px; + color: #1b120c; + background: #ffe28a; + text-align: center; + box-shadow: 0 4px 0 rgba(0,0,0,0.38); +} + +/* ---------- Seats ---------- */ +.seat-layer { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.seat { + --x: 50%; + --y: 50%; + position: absolute; + left: var(--x); + top: var(--y); + width: var(--seat-size); + transform: translate(-50%, -50%); + transition: transform 220ms steps(4), filter 220ms ease; + pointer-events: auto; +} + +/* Active seat — slight lift + glow + sprite "hop" animation. */ +.seat.active { + filter: drop-shadow(0 0 14px rgba(240,182,77,0.85)); + z-index: 6; +} +.seat.active .avatar { animation: avatarHop 520ms ease; } + +.seat.folded { opacity: 0.55; filter: grayscale(0.6); } +.seat.folded .avatar { transform: rotate(-8deg); } + +/* Action-driven sprite reactions (added by JS as transient classes). */ +.seat.act-fold .avatar { animation: avatarFold 600ms ease forwards; } +.seat.act-bet .avatar, +.seat.act-raise .avatar, +.seat.act-all_in .avatar { animation: avatarShake 480ms ease; } +.seat.act-call .avatar, +.seat.act-check .avatar { animation: avatarNod 480ms ease; } +.seat.act-award .avatar { animation: avatarCheer 900ms ease; } + +/* Speech bubble — placed above the seat by default; below for top seats so + it does not punch out of the table viewport. */ +.speech { + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + min-width: 72px; + max-width: 160px; + transform: translateX(-50%); + border: 3px solid #160f0c; + padding: 6px 10px; + color: #1a110b; + background: #fff2b7; + text-align: center; + font-weight: 700; + font-size: 12px; + box-shadow: 0 4px 0 rgba(0,0,0,0.38); + animation: bubblePop 380ms ease; + z-index: 8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* Bubble tail — pixel-art triangle made from a rotated solid square. */ +.speech::after { + content: ""; + position: absolute; + left: 50%; top: 100%; + transform: translate(-50%, -3px) rotate(45deg); + width: 12px; height: 12px; + background: #fff2b7; + border-right: 3px solid #160f0c; + border-bottom: 3px solid #160f0c; +} +/* For top-row seats, flip the bubble below so it does not get clipped. */ +.seat.seat-top .speech { + bottom: auto; + top: calc(100% + 8px); +} +.seat.seat-top .speech::after { + top: auto; bottom: 100%; + transform: translate(-50%, 3px) rotate(225deg); +} +/* Color the bubble by action category. */ +.speech.kind-fold { background: #d8d2bc; } +.speech.kind-call, +.speech.kind-check { background: #c2e6c8; } +.speech.kind-bet, +.speech.kind-raise, +.speech.kind-all_in { background: #ffc7a8; color: #5a1f0a; } +.speech.kind-award { background: #ffe28a; } + +/* Player name+avatar+stack box. */ +.player-box { + border: 3px solid #15100d; + background: linear-gradient(180deg, #423128, #261c18); + padding: 8px; + box-shadow: + inset -3px -3px 0 rgba(0,0,0,0.35), + inset 3px 3px 0 rgba(255,255,255,0.08), + 0 6px 0 #120d0b; + position: relative; +} + +.player-head { + display: grid; + grid-template-columns: var(--avatar-size) 1fr auto; + gap: 8px; + align-items: center; + min-width: 0; +} + +/* Avatar — wraps an inline SVG pixel-art portrait generated by JS. The SVG + is an 8x8 grid of elements; the host element only provides the + square frame, border, and animation hook. */ +.avatar { + width: var(--avatar-size); + height: var(--avatar-size); + flex: 0 0 auto; + position: relative; + border: 2px solid #15100d; + background: #211814; + image-rendering: pixelated; + display: grid; + place-items: stretch; + overflow: hidden; + box-shadow: inset -2px -2px 0 rgba(0,0,0,0.35); +} +.avatar svg { + width: 100%; + height: 100%; + display: block; + shape-rendering: crispEdges; +} + +.player-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 900; + font-size: 12px; + color: var(--ink); +} + +.dealer { + display: none; + flex: 0 0 auto; + border: 2px solid #130e0b; + padding: 2px 6px; + color: #17100d; + background: var(--gold); + font-weight: 900; + font-size: 11px; +} +.seat.dealer-seat .dealer { display: inline-block; } + +.player-meta { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 6px; + font-size: 11px; +} +.stack { color: var(--green); } +.bet { min-height: 14px; color: var(--gold); } + +.hole-cards { + justify-content: flex-start; + min-height: 38px; + margin-top: 6px; + gap: 4px; +} +.hole-cards .card { + width: clamp(26px, 2.8vw, 36px); + height: clamp(36px, 4vw, 50px); + padding: 2px 3px; + border-width: 2px; + box-shadow: + inset -1px -1px 0 rgba(0,0,0,0.12), + inset 1px 1px 0 rgba(255,255,255,0.6), + 0 3px 0 rgba(0,0,0,0.42); +} +.hole-cards .card .rank { font-size: 10px; } +.hole-cards .card .suit { font-size: 14px; } + +/* Chip stack indicator drawn near the player's bet (gives action visual + weight even before the bubble). */ +.chip-pile { + position: absolute; + left: 50%; + top: -14px; + transform: translateX(-50%); + display: flex; + gap: 2px; + pointer-events: none; + opacity: 0; + transition: opacity 200ms steps(2); +} +.chip-pile.visible { opacity: 1; } +.chip-pile .chip { + width: 12px; height: 12px; + border-radius: 50%; + border: 2px solid #160f0c; + background: var(--gold); + box-shadow: 0 2px 0 rgba(0,0,0,0.5); +} +.chip-pile .chip:nth-child(2) { background: var(--red); } +.chip-pile .chip:nth-child(3) { background: var(--blue); } + +/* ---------- Event log ---------- */ +.event-panel .panel-section { display: flex; flex-direction: column; } +.event-log { + display: grid; + gap: 8px; + /* Use viewport-relative max-height for desktop, with a fallback minimum + so the log never collapses to nothing. The actual scrollable height is + plenty for 20+ events while still aligning with the table's height. */ + max-height: clamp(420px, 70vh, 760px); + overflow: auto; + margin: 0; + padding: 0 4px 0 26px; + scrollbar-width: thin; + scrollbar-color: var(--gold-dark) #1a1310; +} +.event-log::-webkit-scrollbar { width: 10px; } +.event-log::-webkit-scrollbar-track { background: #1a1310; } +.event-log::-webkit-scrollbar-thumb { + background: var(--gold-dark); + border: 2px solid #1a1310; +} +.event-log li { + border-left: 4px solid var(--line); + padding: 7px 8px; + color: var(--ink-dim); + background: rgba(0,0,0,0.18); + word-break: break-word; + white-space: normal; + line-height: 1.4; +} +.event-log li.current { + border-color: var(--gold); + color: var(--ink); + background: rgba(240,182,77,0.14); + box-shadow: inset 2px 0 0 var(--gold); +} +.event-log li.past { opacity: 0.85; } + +/* ---------- Animations ---------- */ +@keyframes cardDeal { + from { opacity: 0; transform: translateY(-18px) rotate(-6deg) scale(0.86); } + to { opacity: 1; transform: translateY(0) rotate(0) scale(1); } +} +@keyframes bubblePop { + from { opacity: 0; transform: translate(-50%, 8px) scale(0.8); } + to { opacity: 1; transform: translate(-50%, 0) scale(1); } +} +@keyframes chipSpin { + 0%, 90%, 100% { transform: rotate(0); } + 95% { transform: rotate(8deg); } +} +@keyframes avatarHop { + 0%, 100% { transform: translateY(0); } + 40% { transform: translateY(-6px); } + 70% { transform: translateY(-2px); } +} +@keyframes avatarShake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-3px) rotate(-3deg); } + 40% { transform: translateX(3px) rotate(3deg); } + 60% { transform: translateX(-2px) rotate(-2deg); } + 80% { transform: translateX(2px) rotate(2deg); } +} +@keyframes avatarNod { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(3px); } +} +@keyframes avatarFold { + 0% { transform: rotate(0) translateY(0); } + 100% { transform: rotate(-12deg) translateY(2px); } +} +@keyframes avatarCheer { + 0%, 100% { transform: translateY(0) rotate(0); } + 20% { transform: translateY(-8px) rotate(-6deg); } + 50% { transform: translateY(-4px) rotate(6deg); } + 80% { transform: translateY(-6px) rotate(-3deg); } +} + +/* ========================================================================= + Responsive breakpoints + - Tablet (<=1180px): drop to 2 columns. Stage on top spans full width; + controls + events sit side-by-side underneath. + - Mobile (<=760px): single column; stage first, then controls, then log. + ========================================================================= */ + +@media (max-width: 1180px) { + .layout-grid { + grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr); + grid-template-areas: + "stage stage" + "controls events"; + } + .event-log { max-height: 360px; } + :root { --seat-size: clamp(96px, 16vw, 130px); } +} + +@media (max-width: 760px) { + .app-shell { padding: 10px; } + .topbar { + display: grid; + grid-template-columns: 1fr; + } + .status-strip { justify-content: flex-start; } + + .layout-grid { + grid-template-columns: 1fr; + grid-template-areas: + "stage" + "controls" + "events"; + } + + .stage-head { + flex-wrap: wrap; + } + + .poker-table { + padding: 56px 4px 64px; + height: 600px; + } + .felt-shell { + inset: 48px 0 56px; + border-radius: 28px; + } + .felt-surface { border-radius: 22px; } + + .community-area { width: min(76%, 420px); } + + .event-log { max-height: 240px; } + + :root { + --seat-size: clamp(86px, 30vw, 120px); + --avatar-size: 36px; + } +} + +/* Reduce-motion users get static sprites (no shake/hop). */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +}