3 Commits

Author SHA1 Message Date
qianrui.mmmy 3c027eae0b feat: add replay server and web client 2026-05-13 17:35:46 +08:00
qianrui.mmmy 09c42e9fa3 feat: set blind bet by run hands 2026-05-13 14:30:51 +08:00
qianrui.mmmy e22586aa2f feat: add --hide-reasoning for ai agent to hide reasoning info 2026-05-12 20:42:38 +08:00
14 changed files with 2324 additions and 10 deletions
+3
View File
@@ -34,3 +34,6 @@ htmlcov/
.vscode/
*.swp
*.swo
# debug resources
debug/
+81
View File
@@ -0,0 +1,81 @@
# Texas Hold X 回放视图设计方案
## 目标
构建一个与核心游戏服务和 Agent 解耦的独立 Web 服务,用于读取游戏详情 JSON 并以动画方式回放 Texas Hold'em 对局。它可以部署在任意能运行 Python 标准库 HTTP 服务的环境中,不要求核心服务增加前端路由,也不改变 Human HTTP Agent / AI HTTP Agent 协议。
## 架构
新增 `texas_holdem_replay` 包:
- `texas_holdem_replay.server`:标准库 HTTP 服务,负责托管静态前端文件,并提供 `/api/fetch-game` 抓取代理。
- `texas_holdem_replay/static/index.html`:独立页面入口。
- `texas_holdem_replay/static/styles.css`:像素风牌桌、卡牌、座位和响应式布局。
- `texas_holdem_replay/static/app.js`:数据归一化、手牌时间轴生成、动画播放、上传 JSON、手动抓取和自动轮询。
运行方式:
```bash
python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088
```
也可以通过安装后的脚本启动:
```bash
texas-holdem-replay --host 127.0.0.1 --port 8088
```
## 数据输入
视图支持三种输入方式:
1. 填写核心游戏服务地址和 `game_id`,点击“获取”。
前端请求自身的 `/api/fetch-game?base_url=...&game_id=...`,由回放服务去访问核心服务的 `/games/{game_id}`,避免浏览器跨域限制。
2. 上传静态 JSON 文件。
文件在浏览器本地解析,不依赖核心服务。
3. 开启自动获取。
按指定秒数轮询同一个核心服务和 `game_id`,用于观察正在运行的游戏快照。新快照会先尝试和当前回放位置合并;如果当前手牌追加了 action、showdown 或 award,回放会接续到新增 frame,而不是重头播放。
`/api/fetch-game` 也支持传入完整 `url`,便于未来接入网关或静态 JSON 服务。
## 数据模型与归一化
当前游戏详情返回结构包含:
- `players`:玩家最终状态。
- `hands`:历史手牌列表。
- 每手 `actions`:行动记录,包含 `street``player_id``action``amount`、行动后 `street_bet``stack`
- `awards`:底池分配。
- `showdown_hands`:摊牌玩家手牌。
前端不会依赖核心服务内部对象,只读取 JSON 字段并做归一化:
- 根据 `players``actions` 得到座位顺序。
-`starting_stack` 开始,按历史 `actions.stack``awards` 推演每手牌开始时的筹码。
- 每手牌生成一组离散 frame:开局、跨街发公共牌、玩家行动、摊牌、结算。
- 每个 frame 都有稳定 key。上传 JSON、手动获取和自动获取都会复用同一套合并逻辑:同一 `game_id` 且能找到当前手牌时,保留当前 frame;如果用户停在最新进度末尾,且当前手牌或后续手牌出现新增 frame,则从当前位置接续播放增量。用户正在查看历史手牌时,不会被新轮询强制跳走。
- 非 showdown 玩家手牌显示卡背。
- 已预留 `hand.hole_cards[player_id]``hand.private_hands[player_id]` 兼容点,后续核心服务返回非 showdown 手牌时可直接展示。
## 动画与交互
牌桌采用卡通像素风格:
- 椭圆绿色牌桌、木质像素边框、像素筹码状态条。
- 玩家围绕牌桌分布,当前行动玩家高亮并显示冒泡文字。
- 公共牌按 flop / turn / river 分阶段发出。
- 动作之间默认保留约 1.1-1.5 秒间隔,用户可用“节奏”滑杆调慢或调快。
- 支持上一帧、下一帧、播放/暂停、重置、选择指定手牌。
## 响应式设计
桌面端为三栏布局:数据控制、牌桌、事件日志。中等屏幕下事件日志下移,手机和平板窄屏下改为单列,牌桌高度固定到可观看的移动端比例,座位尺寸通过 CSS clamp 控制,避免文字和控件溢出。
## 解耦边界
回放服务只消费核心服务的公开 HTTP JSON,不导入 `texas_holdem.engine``service` 或 Agent 代码,也不要求游戏服务开放 CORS。未来可以单独部署在 CDN + 轻量代理、容器或任意 Python 运行环境中。
## 后续适配
- 核心服务返回非 showdown 玩家手牌后,只需要让每手 JSON 包含 `hole_cards``private_hands` 映射,前端现有归一化会优先读取。
- 如果服务端未来提供增量事件流,可以在 `app.js` 增加一个 source adapter,把事件流转成同样的 frame 列表,动画层无需重写。
+4
View File
@@ -9,6 +9,10 @@ dependencies = []
texas-holdem-server = "texas_holdem.server:main"
texas-holdem-human = "texas_holdem.human_client:main"
texas-holdem-ai = "texas_holdem.ai_client:main"
texas-holdem-replay = "texas_holdem_replay.server:main"
[tool.setuptools.package-data]
texas_holdem_replay = ["static/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import unittest
from texas_holdem_replay.server import build_game_url
class ReplayServerTests(unittest.TestCase):
def test_build_game_url_from_base_and_game_id(self) -> None:
self.assertEqual(
build_game_url({"base_url": ["http://127.0.0.1:8000/"], "game_id": ["game 1"]}),
"http://127.0.0.1:8000/games/game%201",
)
def test_build_game_url_accepts_full_url(self) -> None:
self.assertEqual(
build_game_url({"url": ["https://example.test/games/demo"]}),
"https://example.test/games/demo",
)
def test_build_game_url_rejects_non_http_url(self) -> None:
with self.assertRaises(ValueError):
build_game_url({"url": ["file:///tmp/game.json"]})
if __name__ == "__main__":
unittest.main()
+169 -3
View File
@@ -69,6 +69,109 @@ ANSI_RESET = "\x1b[0m"
# ---------------------------------------------------------------------------
class _ThinkingIndicator:
"""Animated "thinking..." marquee for the AI agent console.
Design rationale:
- Encapsulated as its own class so the animation lifecycle (timer
thread, frame state, screen erase sequence) does not pollute the
surrounding console class.
- Runs in a daemon background thread driven by ``threading.Event`` so
``stop`` returns promptly even if the current frame is mid-sleep.
- Uses ANSI ``\\r`` plus a clearing escape sequence to overwrite the
previous frame in place, avoiding scrollback noise. The frames
cycle through 0/1/2/3 dots every 0.5s as requested.
- ``start``/``stop`` are idempotent so the higher-level console can
call ``stop`` defensively (e.g. on the fallback path) without
tracking whether a marquee is actually running.
"""
# Frame interval in seconds; matches the user-visible cadence.
_FRAME_INTERVAL = 0.5
# 0..3 dots, looping.
_FRAMES = ("thinking", "thinking.", "thinking..", "thinking...")
# ANSI escape that clears from the cursor to the end of the line; we
# combine it with a leading carriage return to redraw the frame in
# place.
_ERASE_LINE = "\r\x1b[K"
def __init__(
self,
write_fn: Callable[[str], None],
gray_fn: Callable[[str], str],
) -> None:
self._write = write_fn
self._gray = gray_fn
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
# ``_active`` reflects whether a frame is currently visible on
# screen; ``stop`` uses it to decide whether to emit the final
# erase sequence.
self._active = False
# Guard against concurrent start/stop calls from different
# threads (e.g. content-delta handler vs. end_llm_stream).
self._lifecycle_lock = threading.Lock()
def start(self) -> None:
"""Begin the marquee in a background thread.
Calling ``start`` while already running is a no-op.
"""
with self._lifecycle_lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._active = True
thread = threading.Thread(
target=self._run,
name="ai-thinking-indicator",
daemon=True,
)
self._thread = thread
thread.start()
def stop(self) -> None:
"""Stop the marquee and erase the current frame from the screen.
Safe to call when not running.
"""
with self._lifecycle_lock:
thread = self._thread
if thread is None:
return
self._stop_event.set()
self._thread = None
# Wait for the worker outside the lifecycle lock so an in-flight
# ``_render_frame`` cannot deadlock against ``start`` from
# another thread.
thread.join()
if self._active:
# Wipe the last frame so the model's actual content begins on
# a clean line.
self._write(self._ERASE_LINE)
self._active = False
def _run(self) -> None:
"""Background loop: redraw the next frame every ``_FRAME_INTERVAL``."""
index = 0
while not self._stop_event.is_set():
self._render_frame(self._FRAMES[index % len(self._FRAMES)])
index += 1
# ``Event.wait`` returns immediately when ``set`` is called,
# so ``stop`` is responsive even mid-frame.
if self._stop_event.wait(self._FRAME_INTERVAL):
return
def _render_frame(self, label: str) -> None:
"""Emit one frame in place using carriage-return + erase-EOL."""
self._write(f"{self._ERASE_LINE}{self._gray(label)}")
# ---------------------------------------------------------------------------
# AI agent console
# ---------------------------------------------------------------------------
class AIAgentConsole:
"""Serialised terminal output for the standalone AI agent.
@@ -84,11 +187,30 @@ class AIAgentConsole:
output_stream: IO[str] | None = None,
keep_history: bool = False,
use_color: bool = True,
show_reasoning: bool = True,
) -> None:
self._output = output_stream if output_stream is not None else sys.stdout
self._keep_history = keep_history
self._use_color = use_color
# ``show_reasoning`` controls whether the LLM's chain-of-thought
# ("reasoning") deltas are printed to the terminal. The final
# answer ("content") is always printed so operators can still see
# the action being chosen.
self._show_reasoning = show_reasoning
# ``_lock`` serialises whole act/game render blocks (coarse grain).
# ``_io_lock`` is a finer-grained mutex protecting just the
# ``self._output.write`` calls so the thinking-indicator background
# thread can interleave safely with the main rendering thread
# without being blocked by the coarse lock.
self._lock = threading.Lock()
self._io_lock = threading.Lock()
# Animated "thinking..." marquee shown while reasoning output is
# suppressed. Created up-front so callers can ``start``/``stop``
# idempotently regardless of the show_reasoning flag.
self._thinking = _ThinkingIndicator(
write_fn=self._write,
gray_fn=self._gray,
)
@contextmanager
def act_log(self, observation: dict[str, Any]) -> Iterator[None]:
@@ -106,13 +228,32 @@ class AIAgentConsole:
def begin_llm_stream(self) -> None:
self._write(self._gray("AI MODEL STREAM\n"))
# When reasoning output is hidden, immediately start the marquee
# so the user sees liveness while the model is "thinking" before
# any content delta arrives.
if not self._show_reasoning:
self._thinking.start()
def write_llm_delta(self, kind: str, text: str) -> None:
if not text:
return
# Skip "reasoning" deltas entirely when reasoning output is hidden;
# this keeps the terminal focused on the final answer for users
# who do not care about chain-of-thought traces.
if kind == "reasoning" and not self._show_reasoning:
return
# First non-reasoning delta means the model has started speaking
# the actual answer; tear down the marquee before printing so the
# animation does not collide with the content stream.
if kind == "content" and not self._show_reasoning:
self._thinking.stop()
self._write(self._gray(text))
def end_llm_stream(self) -> None:
# Defensive stop in case the request finished without ever
# producing a content delta (e.g. fallback path / error).
if not self._show_reasoning:
self._thinking.stop()
self._write(self._gray("\n"))
def announce_action(
@@ -120,11 +261,17 @@ class AIAgentConsole:
action: dict[str, Any],
source: str = "model",
) -> None:
# Defensive stop: error / fallback paths bypass end_llm_stream, so
# we ensure the marquee never leaks into action / warning output.
self._thinking.stop()
body = json.dumps(action, ensure_ascii=False)
self._write(f"\nAI ACTION ({source}) -> {body}\n")
self._write("~" * 60 + "\n\n")
def announce_warning(self, message: str) -> None:
# Same defensive stop as ``announce_action`` - warnings can fire
# before the LLM stream closes (HTTP error, JSON parse error...).
self._thinking.stop()
self._write(f"\nAI WARNING -> {message}\n")
def _gray(self, text: str) -> str:
@@ -133,8 +280,12 @@ class AIAgentConsole:
return f"{ANSI_GRAY}{text}{ANSI_RESET}"
def _write(self, text: str) -> None:
self._output.write(text)
self._output.flush()
# The thinking-indicator background thread writes from a different
# thread than the main /act handler; the fine-grained ``_io_lock``
# avoids tearing of escape sequences and keeps stdout consistent.
with self._io_lock:
self._output.write(text)
self._output.flush()
# ---------------------------------------------------------------------------
@@ -296,8 +447,11 @@ def _format_action_history(history: list[dict[str, Any]]) -> str:
return "(no actions yet)"
# The engine never produces unbounded history within a single hand, but
# we cap defensively so a malformed payload cannot blow up token usage.
# The cap is sized to comfortably cover the worst realistic case (a
# 12-handed table running ~10 betting rounds within one hand) so the
# LLM never sees a silently truncated history at full ring tables.
rows = []
for record in history[-32:]:
for record in history[-128:]:
rows.append(
f"- [{record.get('street')}] {record.get('player_id')} -> "
f"{record.get('action')} amount={record.get('amount', 0)}"
@@ -927,6 +1081,16 @@ def main() -> None:
action="store_true",
help="Disable ANSI gray coloring for streamed LLM output.",
)
parser.add_argument(
"--hide-reasoning",
action="store_true",
help=(
"Hide the LLM's reasoning/chain-of-thought stream from the "
"terminal. The model still performs reasoning; only its "
"terminal output is suppressed. The final answer (content) "
"is still printed so operators can see the chosen action."
),
)
args = parser.parse_args()
if not args.api_key:
@@ -944,6 +1108,7 @@ def main() -> None:
console = AIAgentConsole(
keep_history=args.keep_history,
use_color=not args.no_color,
show_reasoning=not args.hide_reasoning,
)
service = AIAgentService(LLMClient(config), prompts, console=console)
server = create_server(args.host, args.port, service, default_player_id=args.player_id)
@@ -956,6 +1121,7 @@ def main() -> None:
f" base_url : {config.base_url}\n"
f" player_id : {args.player_id}\n"
f" stream : {'on' if config.stream else 'off'}\n"
f" reasoning : {'hidden (output suppressed)' if args.hide_reasoning else 'visible'}\n"
f" clear-screen: {'off (keep history)' if args.keep_history else 'on'}",
file=sys.stderr,
flush=True,
+102 -3
View File
@@ -8,6 +8,7 @@ from texas_holdem.cards import Deck
from texas_holdem.evaluator import evaluate
from texas_holdem.models import (
ActionRecord,
BlindLevel,
HandSummary,
Observation,
PlayerAction,
@@ -58,16 +59,44 @@ class TableGame:
self.board = []
self.action_history: list[ActionRecord] = []
self.hand_summaries: list[HandSummary] = []
# ``blind_history`` is an append-only log of every blind level change
# (including the initial one). Each entry's ``hand_number`` is the
# first hand that played under those stakes, which makes it trivial
# to reconstruct the schedule from the outside.
self.blind_history: list[BlindLevel] = []
@property
def is_complete(self) -> bool:
return len([player for player in self.players if player.stack > 0]) < 2
def run_hand(self) -> HandSummary:
def run_hand(
self,
small_blind: int | None = None,
big_blind: int | None = None,
) -> HandSummary:
"""Play a single hand.
``small_blind`` / ``big_blind`` allow callers to bump the stakes
between hands without rebuilding the table. Either both must be
provided or both omitted (in which case the previously configured
blinds carry over). The resolved blind level is appended to
:attr:`blind_history` whenever it changes (including the very first
hand) so external observers can replay the schedule.
"""
if self.is_complete:
raise GameComplete("game is complete")
self._apply_blinds_for_hand(small_blind, big_blind)
self.hand_number += 1
# Stamp the active blind level onto the upcoming summary so a hand
# remains self-describing even after the blinds change later on.
active_blinds = BlindLevel(
hand_number=self.hand_number,
small_blind=self.small_blind,
big_blind=self.big_blind,
)
self._record_blind_level_if_new(active_blinds)
started_at = time()
self.board = []
self.action_history = []
@@ -116,6 +145,7 @@ class TableGame:
board=list(self.board),
actions=list(self.action_history),
awards=awards,
blinds=active_blinds,
showdown_hands=self._collect_showdown_hands(),
started_at=started_at,
finished_at=time(),
@@ -123,14 +153,32 @@ class TableGame:
self.hand_summaries.append(summary)
return summary
def run_hands(self, max_hands: int, until_one_left: bool = False) -> list[HandSummary]:
def run_hands(
self,
max_hands: int,
until_one_left: bool = False,
small_blind: int | None = None,
big_blind: int | None = None,
) -> list[HandSummary]:
"""Play up to ``max_hands`` hands using a single blind configuration.
Passing ``small_blind`` / ``big_blind`` bumps the stakes starting
with the first hand of this call; subsequent calls can raise them
again. Leaving them ``None`` keeps the current level unchanged.
"""
if max_hands <= 0:
raise ValueError("max_hands must be positive")
summaries = []
for _ in range(max_hands):
if self.is_complete:
break
summaries.append(self.run_hand())
# Only the first hand of the batch needs to apply the blind
# override; after that the engine reuses the stored values.
summaries.append(
self.run_hand(small_blind=small_blind, big_blind=big_blind)
)
small_blind = None
big_blind = None
if until_one_left and self.is_complete:
break
return summaries
@@ -143,8 +191,18 @@ class TableGame:
"button_seat": None
if self.button_index is None
else self.players[self.button_index].seat,
# ``small_blind`` / ``big_blind`` mirror the *current* level so
# legacy callers keep working. New consumers should prefer the
# structured ``blinds`` block which carries the full schedule.
"small_blind": self.small_blind,
"big_blind": self.big_blind,
"blinds": {
"current": {
"small_blind": self.small_blind,
"big_blind": self.big_blind,
},
"history": [level.to_dict() for level in self.blind_history],
},
"starting_stack": self.starting_stack,
"players": [player.public_dict() for player in self.players],
# ``hands`` exposes every finished hand (each entry is the same
@@ -153,6 +211,47 @@ class TableGame:
"hands": [summary.to_dict() for summary in self.hand_summaries],
}
def _apply_blinds_for_hand(
self,
small_blind: int | None,
big_blind: int | None,
) -> None:
"""Validate and apply optional per-hand blind overrides.
Splitting this out keeps :meth:`run_hand` focused on the table flow
while letting us reuse the validation rules originally enforced by
``__init__``. We require both values to be supplied together so the
configuration cannot drift into an inconsistent half-update.
"""
if small_blind is None and big_blind is None:
return
if small_blind is None or big_blind is None:
raise ValueError(
"small_blind and big_blind must be provided together"
)
if small_blind <= 0 or big_blind <= 0 or small_blind > big_blind:
raise ValueError("blinds must satisfy 0 < small_blind <= big_blind")
self.small_blind = int(small_blind)
self.big_blind = int(big_blind)
def _record_blind_level_if_new(self, level: BlindLevel) -> None:
"""Append ``level`` to :attr:`blind_history` when it differs.
Comparing against the latest entry (rather than blindly appending)
keeps the log compact: stretches of unchanged stakes only contribute
a single record. The very first hand always seeds an entry because
the history starts empty.
"""
if not self.blind_history:
self.blind_history.append(level)
return
latest = self.blind_history[-1]
if (
latest.small_blind != level.small_blind
or latest.big_blind != level.big_blind
):
self.blind_history.append(level)
def _advance_button(self) -> None:
if self.button_index is None:
self.button_index = self._next_index(0, lambda index: self.players[index].stack > 0)
+27
View File
@@ -130,6 +130,28 @@ class Observation:
}
@dataclass(slots=True)
class BlindLevel:
"""A snapshot of the blind configuration that took effect at a given hand.
The structure is intentionally append-only: every time the blinds change
(or the very first hand seeds the initial values) we push a new
``BlindLevel`` so callers can reconstruct how the stakes evolved over the
course of the game without losing any prior state.
"""
hand_number: int
small_blind: int
big_blind: int
def to_dict(self) -> dict[str, object]:
return {
"hand_number": self.hand_number,
"small_blind": self.small_blind,
"big_blind": self.big_blind,
}
@dataclass(slots=True)
class PotAward:
amount: int
@@ -152,6 +174,10 @@ class HandSummary:
board: list[Card]
actions: list[ActionRecord]
awards: list[PotAward]
# ``blinds`` records the exact blind level used by this hand. Storing it
# on the summary (rather than only on the game) guarantees historical
# hands remain self-describing even after the blinds are raised later.
blinds: BlindLevel | None = None
showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
started_at: float = field(default_factory=time)
finished_at: float = field(default_factory=time)
@@ -161,6 +187,7 @@ class HandSummary:
"game_id": self.game_id,
"hand_number": self.hand_number,
"button_seat": self.button_seat,
"blinds": self.blinds.to_dict() if self.blinds else None,
"board": [str(card) for card in self.board],
"actions": [record.to_dict() for record in self.actions],
"awards": [award.to_dict() for award in self.awards],
+35 -2
View File
@@ -43,14 +43,28 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
summaries = MANAGER.run_hands(path[1], count, until_one_left)
small_blind, big_blind = self._extract_blinds(body)
summaries = MANAGER.run_hands(
path[1],
count,
until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
return
if len(path) == 4 and path[0] == "games" and path[2] == "hands" and path[3] == "run":
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
summaries = MANAGER.run_hands(path[1], count, until_one_left)
small_blind, big_blind = self._extract_blinds(body)
summaries = MANAGER.run_hands(
path[1],
count,
until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
@@ -78,6 +92,25 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
raise ValueError("request body must be a JSON object")
return payload
@staticmethod
def _extract_blinds(body: dict[str, Any]) -> tuple[int | None, int | None]:
"""Parse optional blind overrides from a /hands POST body.
Callers may omit both keys (keep current level), or supply both to
raise the blinds for the upcoming batch. Providing only one is
treated as a client error and surfaced via ``ValueError`` so the
handler can reply with 400.
"""
raw_small = body.get("small_blind")
raw_big = body.get("big_blind")
if raw_small is None and raw_big is None:
return None, None
if raw_small is None or raw_big is None:
raise ValueError(
"small_blind and big_blind must be provided together"
)
return int(raw_small), int(raw_big)
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
self.send_response(status)
+21 -2
View File
@@ -62,10 +62,29 @@ class GameManager:
with self._lock:
return [game.to_dict() for game in self._games.values()]
def run_hands(self, game_id: str, count: int = 1, until_one_left: bool = False) -> list[dict[str, object]]:
def run_hands(
self,
game_id: str,
count: int = 1,
until_one_left: bool = False,
small_blind: int | None = None,
big_blind: int | None = None,
) -> list[dict[str, object]]:
"""Run ``count`` hands, optionally raising the blinds first.
``small_blind`` / ``big_blind`` are forwarded to the engine so the
blinds can change between batches. Leaving them as ``None`` keeps
the previously configured level, which preserves the original
no-argument behaviour.
"""
game = self.get_game(game_id)
with self._lock:
return [
summary.to_dict()
for summary in game.run_hands(count, until_one_left=until_one_left)
for summary in game.run_hands(
count,
until_one_left=until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
]
+2
View File
@@ -0,0 +1,2 @@
"""Standalone web replay viewer for Texas Hold X game snapshots."""
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import argparse
import json
import mimetypes
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, unquote, urlparse, parse_qs
from urllib.request import Request, urlopen
STATIC_DIR = Path(__file__).with_name("static")
def build_game_url(query: dict[str, list[str]]) -> str:
raw_url = _first(query, "url")
if raw_url:
return _validate_http_url(raw_url)
base_url = _first(query, "base_url")
game_id = _first(query, "game_id")
if not base_url or not game_id:
raise ValueError("provide either url or both base_url and game_id")
base = _validate_http_url(base_url).rstrip("/")
safe_game_id = quote(game_id.strip("/"), safe="")
return f"{base}/games/{safe_game_id}"
class ReplayRequestHandler(BaseHTTPRequestHandler):
server_version = "TexasHoldemReplay/0.1"
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/health":
self._json({"ok": True, "service": "texas-holdem-replay"})
return
if parsed.path == "/api/fetch-game":
self._handle_fetch_game(parsed.query)
return
self._serve_static(parsed.path)
def do_OPTIONS(self) -> None:
self.send_response(HTTPStatus.NO_CONTENT)
self._cors_headers()
self.end_headers()
def log_message(self, format: str, *args: Any) -> None:
return
def _handle_fetch_game(self, raw_query: str) -> None:
query = parse_qs(raw_query)
try:
target = build_game_url(query)
timeout = float(_first(query, "timeout") or 8)
request = Request(target, headers={"Accept": "application/json"})
with urlopen(request, timeout=max(1, min(timeout, 30))) as response:
payload = response.read()
content_type = response.headers.get("Content-Type", "")
try:
data = json.loads(payload.decode("utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("target did not return valid JSON") from exc
if not isinstance(data, dict):
raise ValueError("target JSON must be an object")
self._json({"source": target, "content_type": content_type, "game": data})
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:600]
self._json(
{"error": f"upstream returned HTTP {exc.code}", "detail": body},
HTTPStatus.BAD_GATEWAY,
)
except (URLError, TimeoutError) as exc:
self._json({"error": "failed to reach upstream", "detail": str(exc)}, HTTPStatus.BAD_GATEWAY)
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def _serve_static(self, raw_path: str) -> None:
relative = unquote(raw_path.lstrip("/")) or "index.html"
if relative.endswith("/"):
relative += "index.html"
candidate = (STATIC_DIR / relative).resolve()
root = STATIC_DIR.resolve()
if root not in candidate.parents and candidate != root:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
body = candidate.read_bytes()
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self._cors_headers()
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store" if candidate.name == "index.html" else "public, max-age=60")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self._cors_headers()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _cors_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def create_server(host: str, port: int) -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the standalone Texas Hold X replay web viewer.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=8088, type=int)
args = parser.parse_args()
server = create_server(args.host, args.port)
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def _first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
if not values:
return None
value = values[0].strip()
return value or None
def _validate_http_url(value: str) -> str:
parsed = urlparse(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must be an absolute http(s) URL")
return value.strip()
if __name__ == "__main__":
main()
+832
View File
@@ -0,0 +1,832 @@
// =============================================================================
// Texas Hold X Replay — viewer logic
// -----------------------------------------------------------------------------
// Architecture overview:
// * `state` — single mutable runtime store; never read directly by render
// helpers, instead passed via the active frame snapshot.
// * `el` — cached DOM references (set once on load, not per render).
// * Frames — pre-computed, immutable per-hand snapshots produced by
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
// Per-player palette is derived deterministically from
// player_id so the same player always looks identical.
// * Animation — seat sprites get a transient `act-${kind}` class that maps
// to an avatar @keyframes animation in styles.css.
// =============================================================================
const state = {
game: null,
hands: [],
currentHandIndex: 0,
frames: [],
frameIndex: 0,
playing: false,
timer: null,
pollTimer: null,
source: "",
};
const el = {
subtitle: document.querySelector("#subtitle"),
sourceBadge: document.querySelector("#sourceBadge"),
pollBadge: document.querySelector("#pollBadge"),
serverUrl: document.querySelector("#serverUrl"),
gameId: document.querySelector("#gameId"),
fetchBtn: document.querySelector("#fetchBtn"),
fileInput: document.querySelector("#fileInput"),
autoPoll: document.querySelector("#autoPoll"),
pollSeconds: document.querySelector("#pollSeconds"),
handSelect: document.querySelector("#handSelect"),
pace: document.querySelector("#pace"),
prevBtn: document.querySelector("#prevBtn"),
playBtn: document.querySelector("#playBtn"),
nextBtn: document.querySelector("#nextBtn"),
resetBtn: document.querySelector("#resetBtn"),
progressBar: document.querySelector("#progressBar"),
gameStatus: document.querySelector("#gameStatus"),
playerCount: document.querySelector("#playerCount"),
handCount: document.querySelector("#handCount"),
blindLevel: document.querySelector("#blindLevel"),
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
eventLog: document.querySelector("#eventLog"),
};
// ---------------------------------------------------------------------------
// Static label tables. Kept module-scope so render functions are fully pure.
// ---------------------------------------------------------------------------
const STREET_LABELS = {
setup: "准备",
preflop: "翻牌前",
flop: "翻牌",
turn: "转牌",
river: "河牌",
showdown: "摊牌",
awards: "结算",
};
const ACTION_LABELS = {
small_blind: "小盲",
big_blind: "大盲",
fold: "弃牌",
check: "过牌",
call: "跟注",
bet: "下注",
raise: "加注",
all_in: "全下",
award: "赢得",
showdown: "亮牌",
deal: "发牌",
};
// Bubble color category — bucket many actions into a few visual classes.
// (The .speech.kind-* CSS classes paint distinct backgrounds for fold/
// call/check/bet/raise/all_in/award. Blinds are rendered as bet-style.)
// ---------------------------------------------------------------------------
// Pixel avatar palette — picked from a small 8-bit-friendly set so every
// player is visually distinct. Derivation is deterministic via FNV-1a over
// player_id, so a given player always renders with the same colors across
// frames and hands.
// ---------------------------------------------------------------------------
const AVATAR_PALETTE = {
skin: ["#f7c98a", "#e2a96b", "#c98c5a", "#8b5a3c", "#f2d4ad"],
hair: ["#2a1e16", "#5b3a23", "#a85d2a", "#d8a13a", "#7a3b8e", "#3a4d8a", "#b9362f"],
shirt: ["#c44c4c", "#3a7fbf", "#3a9b62", "#b87a1f", "#7a3b8e", "#2a3a5a", "#d8a13a"],
};
/**
* Hash a string into a non-negative 32-bit integer using FNV-1a.
* Used to deterministically pick avatar palette entries from player_id.
*/
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/**
* Pick a deterministic avatar palette for the given player_id.
* Returns an object with { skin, hair, shirt } CSS colors.
*/
function avatarPalette(playerId) {
const hash = fnv1a(playerId);
const pickFrom = (list, salt) => list[(hash >>> salt) % list.length];
return {
skin: pickFrom(AVATAR_PALETTE.skin, 0),
hair: pickFrom(AVATAR_PALETTE.hair, 5),
shirt: pickFrom(AVATAR_PALETTE.shirt, 11),
};
}
// 8x8 sprite map for the player portrait.
// . = transparent / dark frame backdrop
// H = hair S = skin E = eye M = mouth (red) T = shirt body
// Two side hair pixels on rows 2-3 give a cohesive helmet shape; row 5 mouth
// adds personality. Row 7 is full shirt body so the avatar reads as a bust.
const AVATAR_SPRITE = [
".HHHHHH.",
"HHHHHHHH",
"HSSSSSSH",
".SESSESS",
".SSSSSS.",
".SSMMSS.",
".TTTTTT.",
"TTTTTTTT",
];
/**
* Build an inline SVG string for a pixel-art avatar using the player's
* deterministic palette. Each filled cell becomes a 1x1 <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
@@ -0,0 +1,132 @@
<!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
@@ -0,0 +1,735 @@
/* =========================================================================
Texas Hold X Replay — Pixel-art skin & responsive layout
-------------------------------------------------------------------------
Design goals:
1. Stage zone (table + seats + animations) is visually isolated from the
interaction zone (controls + event log). Each zone has independent
overflow rules so speech bubbles never get clipped.
2. Pixel-art aesthetic: hard edges, stepped shadows (no blur), 8-bit
palette, monospace typography.
3. Three responsive breakpoints (desktop 3-col → tablet 2-col → mobile
stacked) — see media queries at the bottom of this file.
========================================================================= */
:root {
color-scheme: dark;
/* Palette — keep limited and high-contrast for that 8-bit look. */
--ink: #f7efd2;
--ink-dim: #c8b98a;
--muted: #8e8466;
--panel: #221a17;
--panel-2: #2d231f;
--panel-3: #3a2c25;
--line: #5d4638;
--line-soft: #3b2c25;
/* Felt greens. */
--felt: #1d8a5f;
--felt-dark: #0c4a37;
--felt-light: #4ec089;
--felt-rail: #6c3a20;
--felt-rail-dark: #3a1d10;
/* Accents. */
--gold: #f0b64d;
--gold-dark: #b87a1f;
--red: #e24b4b;
--blue: #53a6de;
--green: #63cb73;
--purple: #b773d3;
--shadow: rgba(8, 6, 5, 0.55);
--pixel: 3px;
/* Seat sizing scales with the stage width via container query fallback
(clamp on viewport). */
--seat-size: clamp(96px, 13vw, 138px);
--avatar-size: 44px;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
/* Pixel-art monospace stack — keeps numerals crisp & blocky. */
font-family: "Courier New", "Lucida Console", "Press Start 2P", monospace;
font-size: 13px;
letter-spacing: 0.2px;
background:
/* Tiny noise made from offset diagonals for that CRT pixel-grid feel. */
linear-gradient(45deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(135deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px,
radial-gradient(ellipse at 50% 0%, #2a1f1a 0%, #14100e 60%, #0d0a08 100%);
image-rendering: pixelated;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: grayscale;
}
button, input, select { font: inherit; }
/* ---------- Buttons (chunky pixel "press" feel) ---------- */
button, .file-btn {
min-height: 42px;
border: var(--pixel) solid #17100d;
color: var(--ink);
background: #3a2b24;
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 4px 0 #120d0b;
cursor: pointer;
transition: transform 90ms steps(2), filter 90ms steps(2);
}
button:hover, .file-btn:hover { filter: brightness(1.12); }
button:active, .file-btn:active {
transform: translateY(3px);
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 1px 0 #120d0b;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
input, select {
width: 100%;
min-height: 40px;
border: 2px solid var(--line);
border-radius: 0;
color: var(--ink);
background: #15110f;
padding: 9px 10px;
outline: none;
}
input:focus, select:focus { border-color: var(--gold); }
/* ---------- Shell / Topbar ---------- */
.app-shell {
width: min(1640px, 100%);
margin: 0 auto;
padding: 18px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 16px;
border: var(--pixel) solid #49382e;
background:
linear-gradient(180deg, #322620 0%, #1f1715 100%);
box-shadow: 0 8px 0 #0d0908, 0 18px 34px var(--shadow);
}
.brand-lockup { display: flex; gap: 14px; align-items: center; min-width: 0; }
.brand-meta { min-width: 0; }
.chip-mark {
display: grid;
place-items: center;
width: 58px;
height: 58px;
flex: 0 0 auto;
border: 4px dashed #fff4bc;
border-radius: 50%;
color: #20130b;
background: radial-gradient(circle, #ffe28a 0 42%, #c73f3d 43% 100%);
font-weight: 900;
text-shadow: 1px 1px 0 rgba(255,255,255,0.45);
/* Slow 8-bit chip rotation when idle for a touch of life. */
animation: chipSpin 6s linear infinite;
}
h1, h2, p { margin: 0; }
h1 { font-size: clamp(18px, 2.3vw, 28px); }
h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; }
#subtitle { margin-top: 5px; color: var(--ink-dim); font-size: 12px; }
.status-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* Generic pixel "tag" badge — shared by status, hand, pot, etc. */
.badge {
display: inline-block;
border: 2px solid #16100d;
padding: 7px 10px;
color: #1b120c;
font-weight: 700;
white-space: nowrap;
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.18);
}
.badge-gold { background: var(--gold); }
.badge-blue { background: var(--blue); color: #0f1c2e; }
/* ---------- Layout Grid ---------- */
/* Desktop: stage in middle, controls left, events right. The stage column
is the dominant area (1.4fr) so the table breathes. */
.layout-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1.4fr) minmax(280px, 340px);
grid-template-areas: "controls stage events";
gap: 16px;
margin-top: 18px;
align-items: start;
}
.control-panel { grid-area: controls; display: grid; gap: 14px; }
.stage-zone { grid-area: stage; min-width: 0; }
.event-panel { grid-area: events; display: grid; gap: 14px; }
/* ---------- Panel sections (the chunky bordered cards) ---------- */
.panel-section {
border: var(--pixel) solid #49382e;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
padding: 14px;
box-shadow: 0 7px 0 #0e0a08, 0 14px 26px var(--shadow);
}
.panel-section label {
display: grid;
gap: 6px;
margin-bottom: 10px;
color: var(--ink-dim);
font-size: 12px;
}
.button-row, .transport-row, .auto-grid { display: grid; gap: 9px; }
.button-row { grid-template-columns: 1fr 1fr; }
.transport-row { grid-template-columns: repeat(4, minmax(42px, 1fr)); }
.auto-grid { grid-template-columns: 1fr 86px; align-items: end; }
.toggle-line { display: flex !important; flex-direction: row; align-items: center; min-height: 40px; }
.toggle-line input { width: auto; min-height: auto; accent-color: var(--gold); }
.primary-btn { color: #1b120c; background: var(--gold); }
.file-btn {
display: grid; place-items: center; text-align: center;
position: relative; overflow: hidden;
}
.file-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.progress-wrap {
height: 12px; margin-top: 14px;
border: 2px solid #120d0b; background: #14100e;
}
#progressBar {
width: 0; height: 100%;
background: linear-gradient(90deg, var(--red), var(--gold), var(--green));
transition: width 220ms linear;
}
.stat-list { display: grid; gap: 8px; margin: 0; }
.stat-list div {
display: flex; justify-content: space-between; gap: 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
padding-bottom: 7px;
}
.stat-list dt { color: var(--ink-dim); }
.stat-list dd { margin: 0; text-align: right; }
/* ---------- Stage zone ---------- */
.stage-head {
display: flex; justify-content: space-between; align-items: center;
gap: 10px; margin-bottom: 12px;
}
.stage-head-left { display: flex; gap: 10px; align-items: center; min-width: 0; }
#streetLabel { font-size: clamp(16px, 2vw, 22px); color: var(--ink); }
/* The poker-table is the positioning context for seats. Its overflow MUST
stay visible so seats placed near the table edges (and their speech
bubbles) can extend slightly outside the green felt without being clipped.
The visual clipping is handled by .felt-shell instead. */
.poker-table {
position: relative;
/* Reserve vertical breathing room above/below the felt for seats that
visually sit on the rail and for speech bubbles. */
padding: 70px 12px 80px;
/* Explicit responsive height — required because seats and felt are
absolutely positioned and the inner content (community area) is
center-aligned. clamp() keeps it usable from 640px to ~820px. */
height: clamp(620px, 64vw, 820px);
overflow: visible;
}
/* Felt shell — actual visible green table. Absolutely positioned and
inset within poker-table so seats/bubbles can spill outside. */
.felt-shell {
position: absolute;
inset: 60px 0 70px;
border: 6px solid var(--felt-rail);
border-radius: 46% / 36%;
background: var(--felt-rail-dark);
box-shadow:
inset 0 0 0 10px var(--felt-rail),
inset 0 0 0 16px #2c1b12,
0 10px 0 #130c09,
0 24px 44px var(--shadow);
overflow: hidden;
pointer-events: none;
}
.felt-rail {
/* Decorative pixel "studs" running along the rail. */
position: absolute; inset: -2px;
background:
repeating-linear-gradient(90deg,
transparent 0 22px, rgba(255,220,160,0.18) 22px 24px),
repeating-linear-gradient(0deg,
transparent 0 22px, rgba(255,220,160,0.18) 22px 24px);
mix-blend-mode: screen;
opacity: 0.4;
}
.felt-surface {
position: absolute;
inset: 12px;
border-radius: 44% / 32%;
background:
radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 38%, var(--felt-dark) 100%);
overflow: hidden;
}
.felt-grid {
position: absolute; inset: 0;
opacity: 0.18;
/* 1px-wide pixel grid for the felt — gives a chess-board-like 8-bit feel. */
background:
linear-gradient(90deg, transparent calc(100% - 2px), rgba(255,255,255,0.4) 0) 0 0 / 28px 28px,
linear-gradient(180deg, transparent calc(100% - 2px), rgba(255,255,255,0.28) 0) 0 0 / 28px 28px;
image-rendering: pixelated;
}
.felt-glow {
position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 45%, rgba(255,255,255,0.18), transparent 55%);
pointer-events: none;
}
.felt-mark {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
font-size: clamp(48px, 7vw, 96px);
font-weight: 900;
color: rgba(0,0,0,0.18);
letter-spacing: 6px;
text-shadow: 2px 2px 0 rgba(255,255,255,0.06);
user-select: none;
}
/* ---------- Community area (board + message) ---------- */
.community-area {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(60%, 560px);
display: grid;
justify-items: center;
gap: 14px;
z-index: 3;
pointer-events: none;
}
.card-row {
display: flex;
justify-content: center;
gap: clamp(6px, 1vw, 12px);
min-height: 76px;
}
.card-row.board { min-height: 92px; }
/* ---------- Cards ---------- */
.card {
display: grid;
grid-template-rows: auto 1fr auto;
width: clamp(48px, 5.4vw, 70px);
height: clamp(66px, 7.4vw, 96px);
border: 3px solid #1c1411;
background: #fff7df;
color: #17100d;
padding: 4px 6px;
box-shadow:
inset -2px -2px 0 rgba(0,0,0,0.12),
inset 2px 2px 0 rgba(255,255,255,0.6),
0 5px 0 rgba(0,0,0,0.42);
transform-origin: center;
animation: cardDeal 520ms cubic-bezier(.2,.9,.2,1);
font-family: "Courier New", monospace;
}
.card.red { color: #b92732; }
.card.back {
background:
/* 8-bit checker pattern for card back. */
linear-gradient(45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(-45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px,
linear-gradient(-45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px,
#173a72;
}
.card .rank { font-size: clamp(12px, 1.2vw, 16px); font-weight: 900; line-height: 1; }
.card .suit { display: grid; place-items: center; font-size: clamp(18px, 2vw, 28px); }
.card .rank.bottom { transform: rotate(180deg); justify-self: end; }
.table-message {
min-height: 32px;
max-width: min(540px, 82%);
border: 3px solid #14100d;
padding: 8px 12px;
color: #1b120c;
background: #ffe28a;
text-align: center;
box-shadow: 0 4px 0 rgba(0,0,0,0.38);
}
/* ---------- Seats ---------- */
.seat-layer {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.seat {
--x: 50%;
--y: 50%;
position: absolute;
left: var(--x);
top: var(--y);
width: var(--seat-size);
transform: translate(-50%, -50%);
transition: transform 220ms steps(4), filter 220ms ease;
pointer-events: auto;
}
/* Active seat — slight lift + glow + sprite "hop" animation. */
.seat.active {
filter: drop-shadow(0 0 14px rgba(240,182,77,0.85));
z-index: 6;
}
.seat.active .avatar { animation: avatarHop 520ms ease; }
.seat.folded { opacity: 0.55; filter: grayscale(0.6); }
.seat.folded .avatar { transform: rotate(-8deg); }
/* Action-driven sprite reactions (added by JS as transient classes). */
.seat.act-fold .avatar { animation: avatarFold 600ms ease forwards; }
.seat.act-bet .avatar,
.seat.act-raise .avatar,
.seat.act-all_in .avatar { animation: avatarShake 480ms ease; }
.seat.act-call .avatar,
.seat.act-check .avatar { animation: avatarNod 480ms ease; }
.seat.act-award .avatar { animation: avatarCheer 900ms ease; }
/* Speech bubble — placed above the seat by default; below for top seats so
it does not punch out of the table viewport. */
.speech {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
min-width: 72px;
max-width: 160px;
transform: translateX(-50%);
border: 3px solid #160f0c;
padding: 6px 10px;
color: #1a110b;
background: #fff2b7;
text-align: center;
font-weight: 700;
font-size: 12px;
box-shadow: 0 4px 0 rgba(0,0,0,0.38);
animation: bubblePop 380ms ease;
z-index: 8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Bubble tail — pixel-art triangle made from a rotated solid square. */
.speech::after {
content: "";
position: absolute;
left: 50%; top: 100%;
transform: translate(-50%, -3px) rotate(45deg);
width: 12px; height: 12px;
background: #fff2b7;
border-right: 3px solid #160f0c;
border-bottom: 3px solid #160f0c;
}
/* For top-row seats, flip the bubble below so it does not get clipped. */
.seat.seat-top .speech {
bottom: auto;
top: calc(100% + 8px);
}
.seat.seat-top .speech::after {
top: auto; bottom: 100%;
transform: translate(-50%, 3px) rotate(225deg);
}
/* Color the bubble by action category. */
.speech.kind-fold { background: #d8d2bc; }
.speech.kind-call,
.speech.kind-check { background: #c2e6c8; }
.speech.kind-bet,
.speech.kind-raise,
.speech.kind-all_in { background: #ffc7a8; color: #5a1f0a; }
.speech.kind-award { background: #ffe28a; }
/* Player name+avatar+stack box. */
.player-box {
border: 3px solid #15100d;
background: linear-gradient(180deg, #423128, #261c18);
padding: 8px;
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 6px 0 #120d0b;
position: relative;
}
.player-head {
display: grid;
grid-template-columns: var(--avatar-size) 1fr auto;
gap: 8px;
align-items: center;
min-width: 0;
}
/* Avatar — wraps an inline SVG pixel-art portrait generated by JS. The SVG
is an 8x8 grid of <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; }
}