refactor(replay): remove replay server and web client

This commit is contained in:
2026-05-17 11:27:39 +08:00
parent 1ee963ce2e
commit 01c7176b1c
7 changed files with 2 additions and 1861 deletions
+2 -6
View File
@@ -4,16 +4,12 @@
Core poker service code lives in `texas_holdem/`. Important modules include `engine.py` for Texas Hold'em rules, `service.py` for game management, `server.py` for the HTTP API, `agents.py` for local/HTTP agents, and `ai_client.py` / `human_client.py` for standalone agents. Prompt templates live in `texas_holdem/prompts/`.
Replay UI code lives in `texas_holdem_replay/`, with static browser assets under `texas_holdem_replay/static/`. Tests are in `tests/`, named by feature area such as `test_engine.py`, `test_service.py`, and `test_replay_server.py`. Design notes belong in `docs/`.
## Build, Test, and Development Commands
- `python -m unittest discover -v` runs the full test suite.
- `python -m compileall texas_holdem texas_holdem_replay tests` checks import and syntax validity.
- `python -m texas_holdem.server --host 127.0.0.1 --port 8000` starts the game service.
- `python -m texas_holdem.human_client --port 9001 --keep-history` starts an interactive human HTTP agent.
- `python -m texas_holdem.ai_client --port 9101 --api-key "$OPENAI_API_KEY" --model gpt-4o-mini` starts an OpenAI-compatible AI agent.
- `python -m texas_holdem_replay.server --port 8088` starts the replay viewer.
## Coding Style & Naming Conventions
@@ -21,11 +17,11 @@ Use Python 3.11+ standard-library APIs unless a dependency is intentionally adde
## Testing Guidelines
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`, and replay UI server helpers in `tests/test_replay_server.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
## Commit & Pull Request Guidelines
History uses short Conventional Commit-style subjects, for example `feat: add replay server and web client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for replay UI or visible terminal-output changes.
History uses short Conventional Commit-style subjects, for example `feat: add server and agent client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for visible terminal-output changes.
## Security & Configuration Tips
-1
View File
@@ -9,7 +9,6 @@ dependencies = []
texas-holdem-server = "texas_holdem.server:main"
texas-holdem-human = "texas_holdem.human_client:main"
texas-holdem-ai = "texas_holdem.ai_client:main"
texas-holdem-replay = "texas_holdem_replay.server:main"
[tool.setuptools.package-data]
texas_holdem_replay = ["static/*"]
-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; }
}