From 8de55f7ca5feaeab6dea468d03bec5a2fca2f547 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Thu, 15 Oct 2026 17:34:25 +0900 Subject: [PATCH] Implement Texas Hold'em play: dealing, betting, side pots, showdown - Hand engine (server/hand.js): crypto-shuffled deck kept server-side, blinds (heads-up button = SB), min-raise rules incl. incomplete all-in, uncalled bet refunds, side pots, split pots, all-in runout - Hand evaluator (server/cards.js) and node:test suite (npm test) - Table flow: hands auto-continue after host START, 30s action clock (timeout = check/fold + sit out), sit out / sit in, rebuy, mid-hand leave folds and keeps the seat reserved until the hand ends, pause freezes all clocks - Only each player's own hole cards are sent until a showdown/runout reveal; all actions validated server-side - Play UI: board, hole cards, bets, action tags, turn timer, raise panel with presets, showdown results with winning cards highlighted, compact 7+ layout - Remove the host crown and the temporary "next phase" control Co-Authored-By: Claude Opus 5.5 --- README.md | 33 ++- package.json | 3 +- public/assets/common.js | 2 +- public/assets/style.css | 219 ++++++++++++++++++-- public/assets/table.js | 437 +++++++++++++++++++++++++++++++++++----- public/table.html | 54 ++++- server/cards.js | 96 +++++++++ server/config.js | 10 + server/hand.js | 360 +++++++++++++++++++++++++++++++++ server/index.js | 21 +- server/tables.js | 277 ++++++++++++++++++------- test/engine.test.js | 303 ++++++++++++++++++++++++++++ 12 files changed, 1648 insertions(+), 167 deletions(-) create mode 100644 server/cards.js create mode 100644 server/hand.js create mode 100644 test/engine.test.js diff --git a/README.md b/README.md index fd029cf..d8bd0e9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,19 @@ 友達専用のテキサスホールデム(リングゲーム)サイト。Node.js + Express + Socket.IO。 -現状は「鍵入力 → ロビー → テーブル作成 → プレイ画面(スタブ)」まで。カード配布・ベッティングなどのゲーム処理は未実装で、プレイ画面ではホストが「次へ(仮)」でフェーズを手動で進められる。 +「鍵入力 → ロビー → テーブル作成 → プレイ」まで動く。 + +## ルール・仕様 + +- ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。 +- ブラインドはハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 +- 最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 +- サイドポット・山分け(端数はボタンの左から)・誰もコールしなかった分の返金に対応。 +- 全員オールインになったら手札を公開して残りのカードを順にめくる。ショーダウンでは残った全員の手札を公開。 +- 持ち時間は1手30秒(`server/config.js` の `ACTION_TIME_MS`)。過ぎるとチェック(できなければフォールド)して「離席中」になり、「復帰する」を押すまで配られない。 +- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 +- ハンド中に退席するとフォールド扱い(出したチップはポットに残る)。 +- ホストの一時停止中は、持ち時間・ブラインドタイマー・次のハンドの開始がすべて止まる。 ## ローカルで動かす @@ -35,6 +47,7 @@ http://localhost:4671 を開く。 - **XSS**: 厳格な CSP(インラインスクリプト・インラインスタイル禁止)、表示時のエスケープ、名前から制御文字・不可視文字を除去。 - **その他**: クリックジャッキング防止(`frame-ancestors 'none'`)、`nosniff`、`no-referrer`、HTML/API は `no-store`、検索エンジン除外、エラー時に内部情報を返さない、リクエストサイズ・イベント連打・テーブル数の上限。 - 他人の席を操作できないよう、席の持ち主を示すプレイヤーIDはブラウザ内だけに保存し、他のプレイヤーには送らない。 +- **カード**: 山札は暗号論的乱数(`crypto.randomInt`)でシャッフルしてサーバ内だけに保持。各プレイヤーには自分の手札だけを送り、他人の手札は公開時(オールイン・ショーダウン)まで送らない。アクションはサーバ側で手番・額をすべて検証する。 6桁のキーはそれ自体が強い秘密ではないので、上記の回数制限が守りの要になっている。キーは友達にだけ直接伝えること。 @@ -44,14 +57,17 @@ http://localhost:4671 を開く。 | --- | --- | | `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO | | `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック | -| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・フェーズ・タイマー) | +| `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) | +| `server/hand.js` | 1ハンドの進行(配札・ベッティング・サイドポット・ショーダウン) | +| `server/cards.js` | 山札と役判定 | +| `test/` | ゲーム処理のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/*.html`, `public/assets/*.js` | 各画面 | - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 -- 作成者がホスト(👑)。ホストが退席すると次の着席者に移る。 +- 作成者がホスト(START・一時停止ができる)。ホストが退席すると次の着席者に移る。 ## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS) @@ -138,3 +154,14 @@ sudo systemctl daemon-reload && sudo systemctl enable --now holdem # ファイアウォール: 4671 は開けない(nginx だけが 127.0.0.1 経由で使う) sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable ``` + +## 更新(デプロイ済みのサーバに反映) + +テーブルはメモリ上にしかないので、再起動すると進行中のテーブルは消える。誰も遊んでいないときに行う。 + +```bash +cd /opt/holdem +sudo git pull +sudo npm ci --omit=dev +sudo systemctl restart holdem +``` diff --git a/package.json b/package.json index 1a6ecc4..ceafadf 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "test": "node --test" }, "engines": { "node": ">=18" diff --git a/public/assets/common.js b/public/assets/common.js index 03b498c..d36abae 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -92,7 +92,7 @@ FLOP: 'FLOP', TURN: 'TURN', RIVER: 'RIVER', - SHOWDOWN: 'SHOWDOWN', + FINISHING: 'FINISHING', }; window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS }; diff --git a/public/assets/style.css b/public/assets/style.css index 138fbbb..e440eb9 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -320,7 +320,7 @@ input.invalid { border-color: var(--red); } position: relative; flex: 1; min-height: 400px; - margin: 8px 0; + margin: 30px 0 26px; } .felt { position: absolute; @@ -356,7 +356,7 @@ input.invalid { border-color: var(--red); } padding: 5px 14px; border-radius: 999px; } -.hand-no { font-size: 11px; color: rgba(0, 0, 0, 0.45); font-weight: 700; margin-top: 2px; } +.phase .hand-no { margin-left: 8px; font-size: 11px; opacity: 0.6; letter-spacing: 0; } .seat { position: absolute; @@ -386,8 +386,8 @@ input.invalid { border-color: var(--red); } background: rgba(10, 30, 20, 0.88); border-radius: 10px; padding: 4px 8px; - min-width: 84px; - max-width: 92px; + min-width: 80px; + max-width: 88px; text-align: center; position: relative; } @@ -411,11 +411,12 @@ input.invalid { border-color: var(--red); } justify-content: center; border: 2px solid #222; } -.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; } +.chip-marker.dealer { background: #fff; color: #111; top: 22px; right: -16px; z-index: 2; } .blind-marker { position: absolute; - top: -4px; - left: -10px; + top: 26px; + left: -20px; + z-index: 2; font-size: 10px; font-weight: 900; padding: 2px 5px; @@ -424,34 +425,43 @@ input.invalid { border-color: var(--red); } } .blind-marker.sb { background: #7fd3ff; } .blind-marker.bb { background: var(--gold); } -.host-mark { position: absolute; bottom: -2px; right: -6px; font-size: 14px; } .offline-tag { font-size: 10px; color: var(--muted); } .spectator-note { - text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; font-size: 13px; color: var(--muted); - margin-bottom: 8px; + margin-bottom: 12px; } .action-bar { + position: sticky; + bottom: 0; + z-index: 5; background: #161c19; padding: 12px 16px calc(14px + env(safe-area-inset-bottom)); } .action-bar .inner { max-width: 480px; margin: 0 auto; } .my-stack { - text-align: center; - font-size: 28px; + font-size: 24px; font-weight: 900; - margin-bottom: 10px; display: flex; align-items: center; - justify-content: center; - gap: 10px; + gap: 8px; + line-height: 1.1; } -.my-stack .wallet { color: var(--gold); font-size: 22px; } +.my-stack .wallet { color: var(--gold); font-size: 18px; } .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } -.actions .btn { background: #555; color: #bbb; font-size: 18px; } +.actions.raising { grid-template-columns: 1fr 2fr; } +.actions.single { grid-template-columns: 1fr; } +.actions .btn { font-size: 16px; padding: 14px 4px; white-space: normal; line-height: 1.15; min-height: 52px; } +.act-fold, .act-call, .act-raise { background: #555; color: #bbb; } +.act-fold:not(:disabled) { background: #5d646b; color: #fff; } +.act-call:not(:disabled) { background: var(--blue); color: #fff; } +.act-raise:not(:disabled) { background: var(--orange); color: #fff; } .modal-backdrop { position: fixed; @@ -469,7 +479,7 @@ input.invalid { border-color: var(--red); } .toast { position: fixed; left: 50%; - bottom: 160px; + bottom: 210px; transform: translateX(-50%); background: rgba(0, 0, 0, 0.85); color: #fff; @@ -499,6 +509,179 @@ input.invalid { border-color: var(--red); } z-index: 30; } +/* ---------- カード ---------- */ +.card { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: none; + width: 28px; + height: 40px; + background: #fff; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + font-weight: 900; + line-height: 1; + user-select: none; + transition: filter 0.2s; +} +.card b { font-size: 15px; letter-spacing: -0.05em; } +.card i { font-style: normal; font-size: 13px; margin-top: 1px; } +.suit-s { color: #1a1a1a; } +.suit-h { color: #d62839; } +.suit-d { color: #1f6fd1; } +.suit-c { color: #16884a; } +.card.back { + background: repeating-linear-gradient(45deg, #c0392b 0 3px, #a93226 3px 6px); + border: 2px solid #fff; +} +.card.slot { background: rgba(0, 0, 0, 0.12); box-shadow: none; border: 1px dashed rgba(255, 255, 255, 0.3); } +.card.deal { animation: deal 0.35s ease-out both; } +@keyframes deal { + from { opacity: 0; transform: translateY(-10px) scale(0.8); } +} +.card.dim { filter: brightness(0.5); } +.card.hl { box-shadow: 0 0 0 2px var(--gold), 0 0 10px rgba(246, 199, 68, 0.8); } + +.board { display: flex; gap: 3px; margin-top: 8px; min-height: 40px; } + +.hole { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 2px; + z-index: 1; +} +.hole .card { width: 25px; height: 35px; border-radius: 4px; } +.hole .card b { font-size: 13px; } +.hole .card i { font-size: 11px; } +.hole .card.back { border-width: 1.5px; } + +/* ---------- 席の状態 ---------- */ +.seat.folded .avatar, .seat.folded .nameplate { opacity: 0.45; } +.seat.away .avatar { opacity: 0.4; } +.seat.turn .avatar { + border-color: var(--gold); + box-shadow: 0 0 0 3px rgba(246, 199, 68, 0.35), 0 0 18px rgba(246, 199, 68, 0.7); +} +.seat.winner .nameplate { box-shadow: 0 0 0 2px var(--gold), 0 0 12px rgba(246, 199, 68, 0.6); } +.nameplate .phand { + font-size: 10px; + font-weight: 700; + color: var(--gold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.seat-timer { height: 3px; background: rgba(255, 255, 255, 0.15); border-radius: 2px; margin-top: 3px; overflow: hidden; } +.seat-timer i, .turn-progress i { display: block; height: 100%; width: 100%; background: var(--green); transition: width 0.2s linear; } +.seat-timer i.low, .turn-progress i.low { background: var(--red); } + +.act-tag { + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-weight: 900; + padding: 1px 7px; + border-radius: 6px; + color: #fff; + white-space: nowrap; + z-index: 2; +} +.tag-fold { background: #555; color: #ccc; } +.tag-check { background: #3b7dd8; } +.tag-call { background: #2e9e5b; } +.tag-bet, .tag-raise { background: #e67e22; } +.tag-allin { background: var(--red); } +.tag-away { background: #444; color: #bbb; } + +.bet { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 4px; + background: rgba(0, 0, 0, 0.55); + color: var(--gold); + font-weight: 800; + font-size: 12px; + padding: 2px 8px 2px 4px; + border-radius: 999px; + white-space: nowrap; + z-index: 3; +} +.bet::before { + content: ""; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--gold); + border: 2px dashed #fff; + box-sizing: border-box; +} +.bet.above { bottom: calc(100% + 20px); } +.bet.below { top: calc(100% + 4px); } +.bet.win { background: var(--gold); color: #111; padding: 2px 8px; } +.bet.win::before { display: none; } + +.phase.paused { background: var(--red); } +.result { + margin-top: 6px; + background: rgba(0, 0, 0, 0.4); + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; + line-height: 1.45; + text-align: center; + max-width: 94%; +} +.result b { color: var(--gold); } +.result .rhand { color: #fff; opacity: 0.85; } +.pot-tag { font-size: 9px; background: rgba(255, 255, 255, 0.2); padding: 0 4px; border-radius: 4px; margin-right: 4px; } + +/* 7人以上 */ +.felt-area.crowded { min-height: 470px; } +.crowded .seat { width: 80px; } +.crowded .avatar { width: 46px; height: 46px; } +.crowded .avatar svg { width: 22px; height: 22px; } +.crowded .nameplate { min-width: 72px; max-width: 80px; padding: 3px 6px; } +.crowded .nameplate .pname { font-size: 11px; } +.crowded .nameplate .pstack { font-size: 12px; } +.crowded .hole { top: -12px; } +.crowded .hole .card { width: 20px; height: 28px; } +.crowded .hole .card b { font-size: 11px; } +.crowded .hole .card i { font-size: 9px; } +.crowded .chip-marker.dealer { top: 14px; } +.crowded .blind-marker { top: 18px; } + +/* ---------- 自分の操作エリア ---------- */ +.turn-progress { position: absolute; top: 0; left: 0; right: 0; height: 4px; background: rgba(255, 255, 255, 0.08); } +.me-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; } +.my-cards { display: flex; gap: 4px; flex: none; } +.my-cards .card { width: 42px; height: 58px; border-radius: 6px; } +.my-cards .card b { font-size: 22px; } +.my-cards .card i { font-size: 19px; } +.me-info { flex: 1; min-width: 0; } +.me-sub { font-size: 12px; color: var(--muted); display: flex; gap: 8px; margin-top: 3px; white-space: nowrap; } +.me-sub .my-hand { color: var(--gold); font-weight: 800; } +.me-sub .plus { color: var(--green); } +.me-sub .minus { color: var(--red); } +.status-line { text-align: center; font-size: 12px; color: var(--muted); margin-bottom: 8px; min-height: 16px; } +.status-line.my-turn { color: var(--gold); font-weight: 800; } +.raise-panel { margin-bottom: 8px; } +.presets { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } +.presets .btn { padding: 8px 0; font-size: 13px; } +.presets .btn.active { background: var(--orange); color: #111; } +.raise-row { display: flex; gap: 10px; align-items: center; margin-top: 8px; } +.raise-row input[type="range"] { flex: 1; min-width: 0; accent-color: var(--orange); height: 28px; } +.raise-row input[type="number"] { width: 100px; flex: none; font-size: 16px; padding: 8px 10px; } + [hidden] { display: none !important; } /* ---------- ユーティリティ(CSP で style 属性を使わないため) ---------- */ diff --git a/public/assets/table.js b/public/assets/table.js index d967930..9610311 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -1,4 +1,4 @@ -// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ +// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る (function () { 'use strict'; @@ -8,10 +8,19 @@ const AVATAR_SVG = ''; + // U+FE0E: iOS で絵文字として描画されないように + const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' }; + const ACTION_LABEL = { FOLD: 'Fold', CHECK: 'Check', CALL: 'Call', BET: 'Bet', RAISE: 'Raise', ALLIN: 'All-in' }; let socket = null; let state = null; let clockOffset = 0; // serverNow - clientNow + let pending = false; // アクション送信中(連打防止) + let raising = false; + let raiseValue = 0; + let seenBoard = ''; // 新しくめくられたカードだけアニメーションする + let seenHand = 0; + let wasMyTurn = false; // ---------- 名前 ---------- function ensureName() { @@ -33,28 +42,49 @@ socket = App.connectSocket(); socket.on('connect', () => { $('conn-lost').hidden = true; - socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => { - if (res && res.ok) return; - if (res && res.error === 'NOT_FOUND') return showNotFound(); - toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー')); - }); + join(); }); socket.on('disconnect', () => { $('conn-lost').hidden = false; }); socket.on('connect_error', () => { $('conn-lost').hidden = false; }); socket.on('table:state', (s) => { state = s; + pending = false; clockOffset = s.serverNow - Date.now(); render(); }); } - function emit(event) { - if (!socket) return; - socket.emit(event, null, (res) => { - if (res && !res.ok) toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error); + function join(manual) { + socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => { + if (res && res.ok) { + if (res.seat < 0 && manual) toast('空いている席がありません'); + return; + } + if (res && res.error === 'NOT_FOUND') return showNotFound(); + toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー')); }); } + function emit(event, payload, onDone) { + if (!socket) return; + socket.emit(event, payload || null, (res) => { + if (res && !res.ok) { + pending = false; + toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error); + if (state) render(); + } + if (onDone) onDone(res); + }); + } + + function sendAction(type, amount) { + if (pending) return; + pending = true; + raising = false; + emit('table:action', { type, amount }); + render(); + } + function showNotFound() { $('main').hidden = true; $('action-bar').hidden = true; @@ -62,11 +92,27 @@ if (socket) socket.disconnect(); } + // ---------- カード ---------- + function cardHtml(c, extra = '') { + if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``; + const rank = c[0] === 'T' ? '10' : c[0]; + return `${rank}${SUIT_SYMBOL[c[1]]}`; + } + + // ショーダウンで勝った5枚 + function winningCards(h) { + return h && h.finished && h.showdown && h.results && h.results[0] ? new Set(h.results[0].bestCards) : null; + } + // ---------- 描画 ---------- function render() { const s = state; + const h = s.hand; + const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null; + const myTurn = Boolean(s.me && s.me.options); + $('main').hidden = false; - document.title = `${s.name} - Hold'em Night`; + document.title = `${myTurn ? '▶ ' : ''}${s.name} - Hold'em Night`; $('table-name').textContent = s.name; $('level').textContent = `Lv.${s.level}`; @@ -76,95 +122,382 @@ : 'ブラインドタイマーなし'; $('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`; - $('pot').textContent = chips(s.pot); - $('phase').textContent = App.PHASE_LABELS[s.phase] || s.phase; - $('hand-no').textContent = s.handNumber ? `Hand #${s.handNumber}` : ''; + // 中央 + $('pot').textContent = chips(h ? h.potTotal : 0); + $('phase').textContent = s.paused ? 'PAUSED' : App.PHASE_LABELS[s.phase] || s.phase; + $('phase').parentElement.classList.toggle('paused', s.paused); + $('hand-no').textContent = s.handNumber ? `#${s.handNumber}` : ''; + renderBoard(h); + renderResult(s); // ホスト操作 - const players = s.seats.filter(Boolean).length; $('host-controls').hidden = !s.isHost; $('host-spacer').hidden = s.isHost; - $('start').disabled = s.phase !== 'WAITING' || players < 2; - $('start').title = players < 2 ? '2人以上で開始できます' : ''; - $('next-phase').disabled = s.phase === 'WAITING'; - $('pause').disabled = !s.started; + const eligible = s.seats.filter((p) => p && !p.sittingOut && p.stack > 0).length; + $('start').hidden = s.started; + $('start').disabled = eligible < 2; + $('start').title = eligible < 2 ? '2人以上で開始できます' : ''; + $('pause').hidden = !s.started; $('pause').textContent = s.paused ? '再開' : '一時停止'; // 自分 - const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null; $('action-bar').hidden = !me; $('spectator-note').hidden = Boolean(me); + const hasFreeSeat = s.seats.some((p) => !p); + $('take-seat').hidden = Boolean(me) || !hasFreeSeat; + $('spectator-text').textContent = hasFreeSeat ? '観戦中です' : '満席のため観戦中です'; $('leave').title = me ? '退席する' : 'ロビーへ戻る'; - if (me) $('my-stack').textContent = chips(me.stack); renderSeats(s); + if (me) renderActionBar(s, me); renderTimer(); + + // 自分の番になったら知らせる + const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive; + if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80); + wasMyTurn = myTurn; + if (h) { + seenBoard = `${h.number}:${h.board.length}`; + seenHand = h.number; + } + } + + function renderBoard(h) { + const el = $('board'); + if (!h) { + el.innerHTML = ''; + return; + } + const [seenNo, seenLen] = seenBoard.split(':').map(Number); + const oldLen = seenNo === h.number ? seenLen : 0; + const best = winningCards(h); + let html = ''; + for (let i = 0; i < 5; i++) { + const c = h.board[i]; + if (!c) { + html += ''; + continue; + } + const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' '); + html += cardHtml(c, cls); + } + el.innerHTML = html; + } + + function renderResult(s) { + const el = $('result'); + const h = s.hand; + if (!h || !h.finished || !h.results) { + el.hidden = true; + return; + } + const nameOf = (seat) => (s.seats[seat] ? s.seats[seat].name : '退席者'); + el.innerHTML = h.results + .map((r, i) => { + const label = h.results.length > 1 ? `${i === 0 ? 'メイン' : 'サイド'}` : ''; + const who = r.winners.map((w) => esc(nameOf(w))).join('・'); + const hand = r.handName ? `${esc(r.handName)}` : ''; + return `
${label}${who} +${chips(r.amount)} ${hand}
`; + }) + .join(''); + el.hidden = false; } // 着席者を楕円上に配置。自分が一番下、そこから時計回り。 function renderSeats(s) { + const h = s.hand; const occupied = s.seats.filter(Boolean); const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat)); const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx)); const n = ordered.length; + const best = winningCards(h); + const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1)); const container = $('seats'); + $('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示 container.innerHTML = ordered - .map((p) => { + .map((p, idx) => { const classes = ['seat']; + const isTurn = s.turn && s.turn.seat === p.seat; if (p.seat === s.mySeat) classes.push('me'); if (!p.connected) classes.push('offline'); - const markers = [ - p.seat === s.buttonSeat ? 'D' : '', - p.seat === s.sbSeat ? 'SB' : '', - p.seat === s.bbSeat ? 'BB' : '', - ].join(''); + if (p.inHand && p.folded) classes.push('folded'); + if (isTurn) classes.push('turn'); + if (h && h.finished && p.won > 0) classes.push('winner'); + if (p.sittingOut && !p.inHand) classes.push('away'); + + const markers = h + ? [ + p.seat === h.button ? 'D' : '', + p.seat === h.sbSeat ? 'SB' : '', + p.seat === h.bbSeat ? 'BB' : '', + ].join('') + : ''; + + // 手札(自分と公開された人は表、それ以外は裏) + let cards = ''; + if (p.inHand && (!p.folded || p.cards)) { + const deal = h.number !== seenHand ? 'deal' : ''; + const list = p.cards || [null, null]; + cards = `
${list + .map((c) => cardHtml(c, [deal, best && c ? (best.has(c) ? 'hl' : 'dim') : ''].join(' '))) + .join('')}
`; + } + + // ベット(中央寄り)・獲得額 + const below = Math.sin(angles[idx]) <= 0.35; + let bet = ''; + if (h && h.finished && p.won > 0) bet = `
+${chips(p.won)}
`; + else if (p.bet > 0) bet = `
${chips(p.bet)}
`; + + let tag = ''; + if (p.last && !(h && h.finished && p.won > 0)) { + tag = `
${ACTION_LABEL[p.last.type] || ''}
`; + } else if (p.sittingOut && !p.inHand) { + tag = '
離席中
'; + } + return `
-
${AVATAR_SVG}${markers}
+
${AVATAR_SVG}${markers}${cards}
-
${p.seat === s.hostSeat ? '👑' : ''}${esc(p.name)}
-
${chips(p.stack)}
+ ${tag} +
${esc(p.name)}
+
${p.allIn && !(h && h.finished) ? 'ALL-IN' : chips(p.stack)}
+ ${p.handName && p.seat !== s.mySeat ? `
${esc(p.handName)}
` : ''} ${p.connected ? '' : '
オフライン
'} + ${isTurn ? '
' : ''}
+ ${bet}
`; }) .join(''); // 位置は CSSOM で設定(CSP で style 属性を禁止しているため) [...container.children].forEach((el, i) => { - const angle = Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1); - el.style.left = `${(50 + 38 * Math.cos(angle)).toFixed(2)}%`; - el.style.top = `${(50 + 41 * Math.sin(angle)).toFixed(2)}%`; + el.style.left = `${(50 + 38 * Math.cos(angles[i])).toFixed(2)}%`; + el.style.top = `${(50 + 41 * Math.sin(angles[i])).toFixed(2)}%`; }); } - function renderTimer() { - const el = $('timer'); - if (!state) return; - const t = state.timer; - el.classList.toggle('paused', state.paused); - if (!t.enabled) { - el.textContent = state.paused ? '⏸ PAUSED' : ''; + function renderActionBar(s, seat) { + const m = s.me; + const h = s.hand; + const o = m.options; + + $('my-stack').textContent = chips(m.stack); + const net = m.net; + $('my-net').textContent = `収支 ${net > 0 ? '+' : net < 0 ? '−' : '±'}${chips(Math.abs(net))}`; + $('my-net').className = net > 0 ? 'plus' : net < 0 ? 'minus' : ''; + $('my-hand').textContent = seat.handName || ''; + + const myCards = seat.inHand && seat.cards ? seat.cards : null; + const best = winningCards(h); + $('my-cards').innerHTML = myCards + ? myCards.map((c) => cardHtml(c, [seat.folded ? 'dim' : '', best && best.has(c) ? 'hl' : ''].join(' '))).join('') + : ''; + + $('sit-out').hidden = m.sittingOut; + + // 状況 + let status = ''; + if (!s.started) status = s.isHost ? '2人以上揃ったら START を押してください' : 'ホストの開始を待っています'; + else if (s.paused) status = '一時停止中'; + else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します'; + else if (m.canRebuy) status = 'チップがなくなりました'; + else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`; + else if (h && !h.finished && s.turn) status = `${(s.seats[s.turn.seat] || {}).name || ''} の番です`; + else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します'; + else if (h && !h.finished && !s.turn) status = 'オールイン — 残りのカードをめくります'; + else if (h && h.finished) status = '次のハンドを準備中…'; + else if (!h) status = '参加者を待っています(2人以上で再開)'; + $('status-line').textContent = status; + $('status-line').classList.toggle('my-turn', Boolean(o)); + + // ボタン + const alt = m.sittingOut || m.canRebuy; + $('alt-actions').hidden = !alt; + $('sit-in').hidden = !m.sittingOut; + $('rebuy').hidden = !m.canRebuy; + $('rebuy').textContent = `リバイ(${chips(s.startingStack)})`; + + if (!o) raising = false; + $('actions').hidden = alt || raising; + $('raise-actions').hidden = alt || !raising; + $('raise-panel').hidden = alt || !raising; + $('status-line').hidden = raising; + + const fold = $('act-fold'); + const call = $('act-call'); + const raise = $('act-raise'); + fold.disabled = call.disabled = raise.disabled = !o || pending; + if (!o) { + call.textContent = 'Check'; + raise.textContent = 'Raise'; return; } - const remaining = t.running ? t.endsAt - (Date.now() + clockOffset) : t.remainingMs; - const sec = Math.max(0, Math.ceil(remaining / 1000)); - const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; - el.textContent = state.paused ? `⏸ ${time}` : time; - el.classList.toggle('warn', t.running && sec <= 60); - } - setInterval(renderTimer, 250); + call.textContent = o.canCheck ? 'Check' : o.toCall >= m.stack ? `All-in ${chips(o.toCall)}` : `Call ${chips(o.toCall)}`; + raise.disabled = pending || !o.canRaise; + raise.textContent = !o.canRaise + ? o.isBet ? 'Bet' : 'Raise' + : o.minRaiseTo >= o.maxRaiseTo + ? `All-in ${chips(o.maxRaiseTo)}` + : o.isBet ? 'Bet' : 'Raise'; - // ---------- 操作 ---------- + if (raising) renderRaisePanel(o); + } + + // ---------- レイズ ---------- + function presetValue(o, kind) { + const h = state.hand; + const potAfterCall = h.potTotal + o.toCall; + const base = h.currentBet; + const v = { + min: o.minRaiseTo, + half: base + Math.round(potAfterCall / 2), + threeq: base + Math.round((potAfterCall * 3) / 4), + pot: base + potAfterCall, + max: o.maxRaiseTo, + }[kind]; + return clampRaise(o, v); + } + + function clampRaise(o, v) { + if (!Number.isFinite(v)) return o.minRaiseTo; + return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v))); + } + + function renderRaisePanel(o) { + raiseValue = clampRaise(o, raiseValue); + const range = $('raise-range'); + range.min = o.minRaiseTo; + range.max = o.maxRaiseTo; + range.step = 1; + range.value = raiseValue; + const amount = $('raise-amount'); + if (document.activeElement !== amount) amount.value = raiseValue; + amount.min = o.minRaiseTo; + amount.max = o.maxRaiseTo; + for (const b of $('presets').children) { + b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue); + } + const allIn = raiseValue >= o.maxRaiseTo; + $('raise-confirm').textContent = allIn + ? `All-in ${chips(raiseValue)}` + : `${o.isBet ? 'Bet' : 'Raise to'} ${chips(raiseValue)}`; + $('raise-confirm').disabled = pending; + } + + const options = () => (state && state.me && state.me.options) || null; + + $('act-fold').addEventListener('click', () => { + const o = options(); + if (!o) return; + if (o.canCheck && !confirm('チェックできます。フォールドしますか?')) return; + sendAction('fold'); + }); + $('act-call').addEventListener('click', () => { + const o = options(); + if (o) sendAction(o.canCheck ? 'check' : 'call'); + }); + $('act-raise').addEventListener('click', () => { + const o = options(); + if (!o || !o.canRaise) return; + if (o.minRaiseTo >= o.maxRaiseTo) return sendAction('raise', o.maxRaiseTo); // オールインしかない + raising = true; + raiseValue = o.minRaiseTo; + render(); + }); + $('raise-cancel').addEventListener('click', () => { + raising = false; + render(); + }); + $('raise-confirm').addEventListener('click', () => { + const o = options(); + if (o) sendAction('raise', clampRaise(o, raiseValue)); + }); + $('presets').addEventListener('click', (e) => { + const b = e.target.closest('[data-preset]'); + const o = options(); + if (!b || !o) return; + raiseValue = presetValue(o, b.dataset.preset); + $('raise-amount').value = raiseValue; + renderRaisePanel(o); + }); + $('raise-range').addEventListener('input', (e) => { + const o = options(); + if (!o) return; + // BB 単位に丸める(最大はオールイン額そのまま) + const unit = Math.max(1, state.smallBlind); + let v = Number(e.target.value); + if (v < o.maxRaiseTo) v = o.minRaiseTo + Math.round((v - o.minRaiseTo) / unit) * unit; + raiseValue = clampRaise(o, v); + $('raise-amount').value = raiseValue; + renderRaisePanel(o); + }); + $('raise-amount').addEventListener('input', (e) => { + const o = options(); + const v = Number(e.target.value); + if (!o || !Number.isFinite(v) || v <= 0) return; + raiseValue = clampRaise(o, v); + renderRaisePanel(o); + }); + $('raise-amount').addEventListener('blur', () => { + const o = options(); + if (o) $('raise-amount').value = clampRaise(o, raiseValue); + }); + + // ---------- タイマー ---------- + function renderTimer() { + if (!state) return; + const el = $('timer'); + const t = state.timer; + const now = Date.now() + clockOffset; + el.classList.toggle('paused', state.paused); + if (!t.enabled) { + el.textContent = state.paused ? '⏸' : ''; + } else { + const remaining = t.running ? t.endsAt - now : t.remainingMs; + const sec = Math.max(0, Math.ceil(remaining / 1000)); + const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; + el.textContent = state.paused ? `⏸ ${time}` : time; + el.classList.toggle('warn', t.running && sec <= 60); + } + + // 手番の残り時間 + const turn = state.turn; + const ratio = turn ? Math.max(0, Math.min(1, (turn.endsAt != null ? turn.endsAt - now : turn.remainingMs) / turn.totalMs)) : 0; + const width = `${(ratio * 100).toFixed(1)}%`; + const bar = document.querySelector('.seat.turn .seat-timer i'); + if (bar) { + bar.style.width = width; + bar.classList.toggle('low', ratio < 0.3); + } + const mine = Boolean(turn && turn.seat === state.mySeat); + $('turn-progress').hidden = !mine; + if (mine) { + $('turn-bar').style.width = width; + $('turn-bar').classList.toggle('low', ratio < 0.3); + } + } + setInterval(renderTimer, 200); + + // ---------- その他の操作 ---------- $('start').addEventListener('click', () => emit('table:start')); - $('next-phase').addEventListener('click', () => emit('table:nextPhase')); $('pause').addEventListener('click', () => emit('table:pause')); + $('sit-out').addEventListener('click', () => emit('table:sitOut')); + $('take-seat').addEventListener('click', () => join(true)); + $('sit-in').addEventListener('click', () => emit('table:sitIn')); + $('rebuy').addEventListener('click', () => emit('table:rebuy')); $('leave').addEventListener('click', () => { - const seated = state && state.mySeat >= 0; - if (!seated) return (location.href = '/'); - if (!confirm('テーブルから退席しますか?(チップは失われます)')) return; + const me = state && state.mySeat >= 0 ? state.seats[state.mySeat] : null; + if (!me) return (location.href = '/'); + const inHand = state.hand && !state.hand.finished && me.inHand && !me.folded; + const msg = inHand + ? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。退席しますか?' + : 'テーブルから退席しますか?(チップは失われます)'; + if (!confirm(msg)) return; socket.emit('table:leave', null, () => { location.href = '/'; }); }); diff --git a/public/table.html b/public/table.html index 8c20dc2..973f9e6 100644 --- a/public/table.html +++ b/public/table.html @@ -20,7 +20,6 @@
@@ -39,22 +38,59 @@
POT
$0
-
WAITING
-
+
+
WAITING
+
- +