2 Commits

Author SHA1 Message Date
mamamiyear c0bc5384f4 feat: add hand detail API and enrich hand summary fields
- HandSummary: add hole_cards, starting_stacks, ending_stacks, pot_contributions
- Engine: capture all players' hole cards (not just showdown), pre/post hand stacks, per-level pot contributions
- Server: new GET /game/<game_id>/hands/<hand_number> route
- Service: add get_hand_state() method
- Tests: add ServerTests for new endpoint, update existing tests
- Existing GET /game/<game_id> auto-inherits new fields via shared to_dict()
2026-05-23 22:11:45 +08:00
mamamiyear 5899ea0b89 Revert "feat: add replay server and web client"
This reverts commit 3c027eae0b.
2026-05-21 09:22:04 +08:00
15 changed files with 233 additions and 1973 deletions
-81
View File
@@ -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 列表,动画层无需重写。
-4
View File
@@ -9,10 +9,6 @@ dependencies = []
texas-holdem-server = "texas_holdem.server:main" texas-holdem-server = "texas_holdem.server:main"
texas-holdem-human = "texas_holdem.human_client:main" texas-holdem-human = "texas_holdem.human_client:main"
texas-holdem-ai = "texas_holdem.ai_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] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
+40
View File
@@ -100,10 +100,50 @@ class EngineTests(unittest.TestCase):
awards = game._award_pots() awards = game._award_pots()
self.assertEqual([award.amount for award in awards], [150, 100]) self.assertEqual([award.amount for award in awards], [150, 100])
self.assertEqual(
[contribution["amount"] for contribution in game._last_pot_contributions],
[150, 100],
)
self.assertEqual(
game._last_pot_contributions[0]["contributors"],
{"p1": 50, "p2": 50, "p3": 50},
)
self.assertEqual(
game._last_pot_contributions[1]["contributors"],
{"p2": 50, "p3": 50},
)
self.assertEqual(game.players[0].stack, 150) self.assertEqual(game.players[0].stack, 150)
self.assertEqual(game.players[1].stack, 100) self.assertEqual(game.players[1].stack, 100)
self.assertEqual(game.players[2].stack, 0) self.assertEqual(game.players[2].stack, 0)
def test_hand_summary_includes_full_hand_snapshots(self) -> None:
players = [
("p1", "Player 1", CallingStationAgent()),
("p2", "Player 2", CallingStationAgent()),
("p3", "Player 3", CallingStationAgent()),
]
game = TableGame("g5", players, starting_stack=100, small_blind=5, big_blind=10, rng=Random(23))
summary = game.run_hand()
payload = summary.to_dict()
self.assertEqual(set(summary.hole_cards), {"p1", "p2", "p3"})
self.assertTrue(all(len(cards) == 2 for cards in summary.hole_cards.values()))
self.assertEqual(summary.starting_stacks, {"p1": 100, "p2": 100, "p3": 100})
self.assertEqual(set(summary.ending_stacks), {"p1", "p2", "p3"})
self.assertEqual(sum(summary.starting_stacks.values()), sum(summary.ending_stacks.values()))
self.assertGreaterEqual(len(summary.pot_contributions), 1)
self.assertTrue(
all(
contribution["amount"] == sum(contribution["contributors"].values())
for contribution in summary.pot_contributions
)
)
self.assertEqual(set(payload["hole_cards"]), {"p1", "p2", "p3"})
self.assertEqual(payload["starting_stacks"], {"p1": 100, "p2": 100, "p3": 100})
self.assertIn("ending_stacks", payload)
self.assertIn("pot_contributions", payload)
def test_short_all_in_does_not_reopen_raising_to_prior_actor(self) -> None: def test_short_all_in_does_not_reopen_raising_to_prior_actor(self) -> None:
seen: list[tuple[str, str, list[str]]] = [] seen: list[tuple[str, str, list[str]]] = []
players = [ players = [
-28
View File
@@ -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()
+76
View File
@@ -0,0 +1,76 @@
import json
import unittest
from threading import Thread
from urllib.request import Request, urlopen
from texas_holdem import server as poker_server
from texas_holdem.service import GameManager
class ServerTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_manager = poker_server.MANAGER
poker_server.MANAGER = GameManager()
self.server = poker_server.create_server("127.0.0.1", 0)
self.thread = Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
host, port = self.server.server_address
self.base_url = f"http://{host}:{port}"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
poker_server.MANAGER = self.previous_manager
def request_json(
self,
method: str,
path: str,
payload: dict[str, object] | None = None,
) -> dict[str, object]:
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self.base_url}{path}",
data=data,
headers=headers,
method=method,
)
with urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def test_get_hand_route_returns_expanded_hand_summary(self) -> None:
self.request_json(
"POST",
"/game",
{
"game_id": "route-demo",
"seed": 17,
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{"id": "a", "type": "calling"},
{"id": "b", "type": "calling"},
],
},
)
self.request_json("POST", "/game/route-demo/hands", {"count": 1})
hand = self.request_json("GET", "/game/route-demo/hands/1")
game = self.request_json("GET", "/game/route-demo")
self.assertEqual(hand["hand_number"], 1)
self.assertEqual(set(hand["hole_cards"]), {"a", "b"})
self.assertEqual(hand["starting_stacks"], {"a": 200, "b": 200})
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
self.assertEqual(game["hands"][0], hand)
if __name__ == "__main__":
unittest.main()
+9 -1
View File
@@ -40,8 +40,16 @@ class ServiceTests(unittest.TestCase):
hands = manager.run_hands(game.game_id, count=1) hands = manager.run_hands(game.game_id, count=1)
state = manager.get_game_state("demo")
hand = manager.get_hand_state("demo", 1)
self.assertEqual(len(hands), 1) self.assertEqual(len(hands), 1)
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1) self.assertEqual(state["hand_number"], 1)
self.assertEqual(hand, state["hands"][0])
self.assertIn("hole_cards", hand)
self.assertIn("starting_stacks", hand)
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
def test_get_game_state_does_not_block_during_run(self) -> None: def test_get_game_state_does_not_block_during_run(self) -> None:
manager = GameManager() manager = GameManager()
+56 -5
View File
@@ -62,6 +62,7 @@ class TableGame:
self.board = [] self.board = []
self.action_history: list[ActionRecord] = [] self.action_history: list[ActionRecord] = []
self.hand_summaries: list[HandSummary] = [] self.hand_summaries: list[HandSummary] = []
self._last_pot_contributions: list[dict[str, object]] = []
# ``blind_history`` is an append-only log of every blind level change # ``blind_history`` is an append-only log of every blind level change
# (including the initial one). Each entry's ``hand_number`` is the # (including the initial one). Each entry's ``hand_number`` is the
# first hand that played under those stakes, which makes it trivial # first hand that played under those stakes, which makes it trivial
@@ -116,6 +117,11 @@ class TableGame:
for player in self.players: for player in self.players:
player.reset_for_hand() player.reset_for_hand()
starting_stacks = {
player.player_id: player.stack
for player in self.players
if player.in_hand
}
self._advance_button() self._advance_button()
assert self.button_index is not None assert self.button_index is not None
@@ -127,6 +133,11 @@ class TableGame:
self._broadcast_game_update() self._broadcast_game_update()
self._deal_hole_cards(deck) self._deal_hole_cards(deck)
hole_cards = {
player.player_id: list(player.hole_cards)
for player in self.players
if player.in_hand
}
small_blind_index, big_blind_index = self._blind_indexes() small_blind_index, big_blind_index = self._blind_indexes()
self._post_blind(small_blind_index, "small_blind", self.small_blind) self._post_blind(small_blind_index, "small_blind", self.small_blind)
self._post_blind(big_blind_index, "big_blind", self.big_blind) self._post_blind(big_blind_index, "big_blind", self.big_blind)
@@ -150,6 +161,11 @@ class TableGame:
self._betting_round(street, start_index, self.big_blind) self._betting_round(street, start_index, self.big_blind)
awards = self._award_pots() awards = self._award_pots()
ending_stacks = {
player.player_id: player.stack
for player in self.players
if player.player_id in starting_stacks
}
summary = HandSummary( summary = HandSummary(
game_id=self.game_id, game_id=self.game_id,
hand_number=self.hand_number, hand_number=self.hand_number,
@@ -158,6 +174,10 @@ class TableGame:
actions=list(self.action_history), actions=list(self.action_history),
awards=awards, awards=awards,
blinds=active_blinds, blinds=active_blinds,
hole_cards=hole_cards,
starting_stacks=starting_stacks,
ending_stacks=ending_stacks,
pot_contributions=deepcopy(self._last_pot_contributions),
showdown_hands=self._collect_showdown_hands(), showdown_hands=self._collect_showdown_hands(),
started_at=started_at, started_at=started_at,
finished_at=time(), finished_at=time(),
@@ -557,21 +577,43 @@ class TableGame:
return current_bet, min_raise, full_raise return current_bet, min_raise, full_raise
def _award_pots(self) -> list[PotAward]: def _award_pots(self) -> list[PotAward]:
self._last_pot_contributions = []
total_pot = sum(player.total_bet for player in self.players) total_pot = sum(player.total_bet for player in self.players)
live_players = [player for player in self.players if self._is_live(player)] live_players = [player for player in self.players if self._is_live(player)]
if not live_players or total_pot <= 0: if not live_players or total_pot <= 0:
return [] return []
if len(live_players) == 1:
live_players[0].stack += total_pot
return [PotAward(total_pot, [live_players[0].player_id], None)]
levels = sorted({player.total_bet for player in self.players if player.total_bet > 0}) levels = sorted({player.total_bet for player in self.players if player.total_bet > 0})
if len(live_players) == 1:
winner = live_players[0]
winner.stack += total_pot
previous_level = 0
for level in levels:
contributors = [player for player in self.players if player.total_bet >= level]
pot_amount = (level - previous_level) * len(contributors)
self._last_pot_contributions.append(
{
"amount": pot_amount,
"contributors": {
player.player_id: level - previous_level
for player in contributors
},
"winners": [winner.player_id],
"hand_value": None,
}
)
previous_level = level
return [PotAward(total_pot, [winner.player_id], None)]
previous_level = 0 previous_level = 0
awards: list[PotAward] = [] awards: list[PotAward] = []
for level in levels: for level in levels:
contributors = [player for player in self.players if player.total_bet >= level] contributors = [player for player in self.players if player.total_bet >= level]
pot_amount = (level - previous_level) * len(contributors) pot_amount = (level - previous_level) * len(contributors)
level_contributions = {
player.player_id: level - previous_level
for player in contributors
}
previous_level = level previous_level = level
contenders = [player for player in contributors if self._is_live(player)] contenders = [player for player in contributors if self._is_live(player)]
if not contenders or pot_amount <= 0: if not contenders or pot_amount <= 0:
@@ -593,13 +635,22 @@ class TableGame:
winner.stack += share winner.stack += share
for winner in ordered_winners[:remainder]: for winner in ordered_winners[:remainder]:
winner.stack += 1 winner.stack += 1
winner_ids = [winner.player_id for winner in ordered_winners]
awards.append( awards.append(
PotAward( PotAward(
amount=pot_amount, amount=pot_amount,
winners=[winner.player_id for winner in ordered_winners], winners=winner_ids,
hand_value=best_value, hand_value=best_value,
) )
) )
self._last_pot_contributions.append(
{
"amount": pot_amount,
"contributors": level_contributions,
"winners": winner_ids,
"hand_value": best_value,
}
)
return awards return awards
def _collect_showdown_hands(self) -> dict[str, list]: def _collect_showdown_hands(self) -> dict[str, list]:
+37
View File
@@ -178,6 +178,10 @@ class HandSummary:
# on the summary (rather than only on the game) guarantees historical # on the summary (rather than only on the game) guarantees historical
# hands remain self-describing even after the blinds are raised later. # hands remain self-describing even after the blinds are raised later.
blinds: BlindLevel | None = None blinds: BlindLevel | None = None
hole_cards: dict[str, list[Card]] = field(default_factory=dict)
starting_stacks: dict[str, int] = field(default_factory=dict)
ending_stacks: dict[str, int] = field(default_factory=dict)
pot_contributions: list[dict[str, Any]] = field(default_factory=list)
showdown_hands: dict[str, list[Card]] = field(default_factory=dict) showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
started_at: float = field(default_factory=time) started_at: float = field(default_factory=time)
finished_at: float = field(default_factory=time) finished_at: float = field(default_factory=time)
@@ -191,6 +195,16 @@ class HandSummary:
"board": [str(card) for card in self.board], "board": [str(card) for card in self.board],
"actions": [record.to_dict() for record in self.actions], "actions": [record.to_dict() for record in self.actions],
"awards": [award.to_dict() for award in self.awards], "awards": [award.to_dict() for award in self.awards],
"hole_cards": {
player_id: [str(card) for card in cards]
for player_id, cards in self.hole_cards.items()
},
"starting_stacks": dict(self.starting_stacks),
"ending_stacks": dict(self.ending_stacks),
"pot_contributions": [
self._pot_contribution_to_dict(contribution)
for contribution in self.pot_contributions
],
# ``showdown_hands`` is only populated when more than one player # ``showdown_hands`` is only populated when more than one player
# remained eligible for a pot; empty dict means the hand ended # remained eligible for a pot; empty dict means the hand ended
# without a showdown (e.g. everyone folded but the winner). # without a showdown (e.g. everyone folded but the winner).
@@ -201,3 +215,26 @@ class HandSummary:
"started_at": self.started_at, "started_at": self.started_at,
"finished_at": self.finished_at, "finished_at": self.finished_at,
} }
@staticmethod
def _pot_contribution_to_dict(contribution: dict[str, Any]) -> dict[str, object]:
hand_value = contribution.get("hand_value")
if isinstance(hand_value, HandValue):
hand_value = hand_value.to_dict()
elif isinstance(hand_value, dict):
hand_value = dict(hand_value)
raw_contributors = contribution.get("contributors") or {}
contributors = {
str(player_id): int(amount)
for player_id, amount in dict(raw_contributors).items()
}
return {
"amount": int(contribution.get("amount") or 0),
"contributors": contributors,
"winners": [
str(player_id)
for player_id in contribution.get("winners", [])
],
"hand_value": hand_value,
}
+8
View File
@@ -28,6 +28,14 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
if len(path) == 2 and path[0] in {"game", "games"}: if len(path) == 2 and path[0] in {"game", "games"}:
self._json(MANAGER.get_game_state(path[1])) self._json(MANAGER.get_game_state(path[1]))
return return
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands":
try:
hand_number = int(path[3])
except ValueError:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
self._json(MANAGER.get_hand_state(path[1], hand_number))
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
except KeyError as exc: except KeyError as exc:
self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND) self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
+7
View File
@@ -78,6 +78,13 @@ class GameManager:
def get_game_state(self, game_id: str) -> dict[str, object]: def get_game_state(self, game_id: str) -> dict[str, object]:
return self.get_game(game_id).snapshot_completed() return self.get_game(game_id).snapshot_completed()
def get_hand_state(self, game_id: str, hand_number: int) -> dict[str, object]:
state = self.get_game_state(game_id)
for hand in state.get("hands", []):
if hand.get("hand_number") == hand_number:
return hand
raise KeyError(f"hand not found: {game_id} #{hand_number}")
def list_games(self) -> list[dict[str, object]]: def list_games(self) -> list[dict[str, object]]:
with self._lock: with self._lock:
games = list(self._games.values()) games = list(self._games.values())
-2
View File
@@ -1,2 +0,0 @@
"""Standalone web replay viewer for Texas Hold X game snapshots."""
-153
View File
@@ -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()
-832
View File
@@ -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 <rect> 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 += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`;
}
}
return `<svg viewBox="0 0 8 8" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">${rects}</svg>`;
}
// ---------------------------------------------------------------------------
// 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 = `<span class="rank">${parts.rank}</span><span class="suit">${parts.suit}</span><span class="rank bottom">${parts.rank}</span>`;
}
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)
? `<div class="speech kind-${frame.actionKind || "info"}">${escapeHtml(frame.bubbleText)}</div>`
: "";
// Avatar — inline SVG pixel-art sprite, deterministic per player_id so
// the same player keeps a stable look across hands.
const avatarMarkup = `<div class="avatar" aria-hidden="true">${avatarSvg(player.player_id)}</div>`;
seat.innerHTML = `
${bubbleHtml}
<div class="player-box">
<div class="player-head">
${avatarMarkup}
<span class="player-name">${escapeHtml(player.name || player.player_id)}</span>
<span class="dealer">D</span>
</div>
<div class="player-meta">
<span class="stack">Stack ${money(player.stack)}</span>
<span class="bet">${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""}</span>
</div>
</div>
`;
// 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// ---------------------------------------------------------------------------
// 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();
-132
View File
@@ -1,132 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Texas Hold X Replay</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand-lockup">
<div class="chip-mark" aria-hidden="true">TX</div>
<div class="brand-meta">
<h1>Texas Hold X Replay</h1>
<p id="subtitle">等待加载游戏数据</p>
</div>
</div>
<div class="status-strip" aria-live="polite">
<span id="sourceBadge" class="badge badge-gold">No Data</span>
<span id="pollBadge" class="badge badge-blue">Auto Off</span>
</div>
</header>
<main class="layout-grid">
<!-- Stage zone: pure visualization (table, seats, animations).
Placed first in DOM so mobile/tablet layouts keep it on top. -->
<section class="stage-zone" aria-label="牌桌动画回放">
<div class="stage-head">
<div class="stage-head-left">
<span id="handBadge" class="badge badge-gold">Hand -</span>
<strong id="streetLabel">未加载</strong>
</div>
<div class="stage-head-right">
<span id="potLabel" class="badge badge-gold">Pot 0</span>
</div>
</div>
<div id="table" class="poker-table">
<!-- felt-shell encapsulates the rounded green felt with overflow:hidden,
so player speech bubbles drawn in seat-layer can overflow freely
above and below the table without being clipped. -->
<div class="felt-shell" aria-hidden="true">
<div class="felt-rail"></div>
<div class="felt-surface">
<div class="felt-grid"></div>
<div class="felt-glow"></div>
<div class="felt-mark">TX</div>
</div>
</div>
<div class="community-area">
<div id="boardCards" class="card-row board"></div>
<div id="tableMessage" class="table-message">上传 JSON 或从游戏服务获取快照</div>
</div>
<div id="seatLayer" class="seat-layer"></div>
</div>
</section>
<!-- Interaction zone: data source + replay controls + summary. -->
<section class="control-panel" aria-label="数据与播放控制">
<div class="panel-section">
<h2>数据源</h2>
<label>
<span>游戏服务</span>
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
</label>
<label>
<span>Game ID</span>
<input id="gameId" type="text" value="game1" placeholder="game1" />
</label>
<div class="button-row">
<button id="fetchBtn" class="primary-btn" type="button">获取</button>
<label class="file-btn">
上传 JSON
<input id="fileInput" type="file" accept="application/json,.json" />
</label>
</div>
<div class="auto-grid">
<label class="toggle-line">
<input id="autoPoll" type="checkbox" />
<span>自动获取</span>
</label>
<label>
<span>间隔秒</span>
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
</label>
</div>
</div>
<div class="panel-section">
<h2>回放</h2>
<label>
<span>手牌</span>
<select id="handSelect"></select>
</label>
<label>
<span>节奏</span>
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
</label>
<div class="transport-row">
<button id="prevBtn" type="button" title="上一帧"></button>
<button id="playBtn" class="primary-btn" type="button" title="播放/暂停"></button>
<button id="nextBtn" type="button" title="下一帧"></button>
<button id="resetBtn" type="button" title="重置"></button>
</div>
<div class="progress-wrap">
<div id="progressBar"></div>
</div>
</div>
<div class="panel-section dense">
<h2>牌局摘要</h2>
<dl class="stat-list">
<div><dt>状态</dt><dd id="gameStatus">-</dd></div>
<div><dt>玩家</dt><dd id="playerCount">-</dd></div>
<div><dt>总手数</dt><dd id="handCount">-</dd></div>
<div><dt>盲注</dt><dd id="blindLevel">-</dd></div>
</dl>
</div>
</section>
<aside class="event-panel" aria-label="事件日志">
<div class="panel-section">
<h2>事件</h2>
<ol id="eventLog" class="event-log"></ol>
</div>
</aside>
</main>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
-735
View File
@@ -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 <rect> 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; }
}