From 5899ea0b896f9aac4878e3bf4f605710acb286ae Mon Sep 17 00:00:00 2001 From: Qian Rui Date: Thu, 21 May 2026 09:22:04 +0800 Subject: [PATCH] Revert "feat: add replay server and web client" This reverts commit 3c027eae0bd98747e36d2cf59e47fb03b1514396. --- 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 deletions(-) delete mode 100644 docs/replay_view_design.md delete mode 100644 tests/test_replay_server.py delete mode 100644 texas_holdem_replay/__init__.py delete mode 100644 texas_holdem_replay/server.py delete mode 100644 texas_holdem_replay/static/app.js delete mode 100644 texas_holdem_replay/static/index.html delete mode 100644 texas_holdem_replay/static/styles.css diff --git a/docs/replay_view_design.md b/docs/replay_view_design.md deleted file mode 100644 index ff30873..0000000 --- a/docs/replay_view_design.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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 b91300b..73d3f87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,6 @@ 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 deleted file mode 100644 index fbdf9e9..0000000 --- a/tests/test_replay_server.py +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 5632b00..0000000 --- a/texas_holdem_replay/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Standalone web replay viewer for Texas Hold X game snapshots.""" - diff --git a/texas_holdem_replay/server.py b/texas_holdem_replay/server.py deleted file mode 100644 index 0d7501b..0000000 --- a/texas_holdem_replay/server.py +++ /dev/null @@ -1,153 +0,0 @@ -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 deleted file mode 100644 index a9c18d2..0000000 --- a/texas_holdem_replay/static/app.js +++ /dev/null @@ -1,832 +0,0 @@ -// ============================================================================= -// 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 deleted file mode 100644 index 4af31d1..0000000 --- a/texas_holdem_replay/static/index.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - 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 deleted file mode 100644 index 3a9b5fc..0000000 --- a/texas_holdem_replay/static/styles.css +++ /dev/null @@ -1,735 +0,0 @@ -/* ========================================================================= - 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; } -}