From b5d84e5f650f09e7fcec4a11564c734c7fc58614 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Thu, 15 Oct 2026 17:46:10 +0900 Subject: [PATCH] Switch to chips-only play: real cards at the table, winners picked on screen - Remove the deck, hole cards, board, hand evaluator and reveal logic (cards are dealt physically; the site only tracks bets, pots and stacks) - Showdown now waits for a winner per pot (main/side), chosen by a player still in the hand or the host; multiple winners split the pot - All-in with no further betting goes straight to showdown - Remove the 30s action clock / auto sit-out (everyone is at the same table) - UI: winner picker panel (main winner auto-fills side pots), compact table Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++-- public/assets/common.js | 1 + public/assets/style.css | 98 +++----------- public/assets/table.js | 212 ++++++++++++++--------------- public/table.html | 10 +- server/cards.js | 96 -------------- server/config.js | 6 +- server/hand.js | 194 ++++++++++----------------- server/index.js | 9 ++ server/tables.js | 78 +++++------ test/engine.test.js | 286 ++++++++++++++++++++++------------------ 11 files changed, 408 insertions(+), 605 deletions(-) delete mode 100644 server/cards.js diff --git a/README.md b/README.md index d8bd0e9..181bf97 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,19 @@ ## ルール・仕様 +カードは実物で配り、このサイトはチップの代わりに使う(ベット・ポット・スタックの管理)。 + - ノーリミット・テキサスホールデムのリングゲーム(最大9人)。ホストが START を押すと、以降は2人以上いる限り自動でハンドが続く。 -- ブラインドはハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 -- 最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 -- サイドポット・山分け(端数はボタンの左から)・誰もコールしなかった分の返金に対応。 -- 全員オールインになったら手札を公開して残りのカードを順にめくる。ショーダウンでは残った全員の手札を公開。 -- 持ち時間は1手30秒(`server/config.js` の `ACTION_TIME_MS`)。過ぎるとチェック(できなければフォールド)して「離席中」になり、「復帰する」を押すまで配られない。 +- 画面にディーラーボタン(D)・SB・BB と、いまのストリート(PREFLOP / FLOP / TURN / RIVER)が出るので、それに合わせて実物のカードを配る。 +- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 +- 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 +- ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。 +- **ショーダウン**: カードを見せ合い、勝った人を画面で選んで確定する(選べるのはショーダウンに残った人とホスト)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 +- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 +- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 - ハンド中に退席するとフォールド扱い(出したチップはポットに残る)。 -- ホストの一時停止中は、持ち時間・ブラインドタイマー・次のハンドの開始がすべて止まる。 +- ホストの一時停止中は、アクション・勝者の確定・ブラインドタイマー・次のハンドの開始がすべて止まる。 ## ローカルで動かす @@ -47,7 +51,7 @@ http://localhost:4671 を開く。 - **XSS**: 厳格な CSP(インラインスクリプト・インラインスタイル禁止)、表示時のエスケープ、名前から制御文字・不可視文字を除去。 - **その他**: クリックジャッキング防止(`frame-ancestors 'none'`)、`nosniff`、`no-referrer`、HTML/API は `no-store`、検索エンジン除外、エラー時に内部情報を返さない、リクエストサイズ・イベント連打・テーブル数の上限。 - 他人の席を操作できないよう、席の持ち主を示すプレイヤーIDはブラウザ内だけに保存し、他のプレイヤーには送らない。 -- **カード**: 山札は暗号論的乱数(`crypto.randomInt`)でシャッフルしてサーバ内だけに保持。各プレイヤーには自分の手札だけを送り、他人の手札は公開時(オールイン・ショーダウン)まで送らない。アクションはサーバ側で手番・額をすべて検証する。 +- **チップ操作**: アクションはサーバ側で手番・額をすべて検証する。ショーダウンの勝者を確定できるのは残った人とホストだけで、ポットの対象外の人は選べない。 6桁のキーはそれ自体が強い秘密ではないので、上記の回数制限が守りの要になっている。キーは友達にだけ直接伝えること。 @@ -58,9 +62,8 @@ http://localhost:4671 を開く。 | `server/index.js` | HTTP ルーティング・セキュリティヘッダ・REST API・Socket.IO | | `server/auth.js` | 参加キー認証(Cookie)・回数制限・Origin チェック | | `server/tables.js` | テーブル状態(席・ボタン・ブラインド・タイマー・ハンドの開始/進行) | -| `server/hand.js` | 1ハンドの進行(配札・ベッティング・サイドポット・ショーダウン) | -| `server/cards.js` | 山札と役判定 | -| `test/` | ゲーム処理のテスト(`npm test`) | +| `server/hand.js` | 1ハンドのチップの動き(ブラインド・ベッティング・サイドポット・勝者への分配) | +| `test/` | チップ処理のテスト(`npm test`) | | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/*.html`, `public/assets/*.js` | 各画面 | diff --git a/public/assets/common.js b/public/assets/common.js index d36abae..e57c8e8 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -92,6 +92,7 @@ FLOP: 'FLOP', TURN: 'TURN', RIVER: 'RIVER', + SHOWDOWN: 'SHOWDOWN', FINISHING: 'FINISHING', }; diff --git a/public/assets/style.css b/public/assets/style.css index e440eb9..736c021 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -319,8 +319,8 @@ input.invalid { border-color: var(--red); } .felt-area { position: relative; flex: 1; - min-height: 400px; - margin: 30px 0 26px; + min-height: 340px; + margin: 20px 0 24px; } .felt { position: absolute; @@ -411,11 +411,11 @@ input.invalid { border-color: var(--red); } justify-content: center; border: 2px solid #222; } -.chip-marker.dealer { background: #fff; color: #111; top: 22px; right: -16px; z-index: 2; } +.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; z-index: 2; } .blind-marker { position: absolute; - top: 26px; - left: -20px; + top: -4px; + left: -10px; z-index: 2; font-size: 10px; font-weight: 900; @@ -509,57 +509,6 @@ 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; } @@ -568,17 +517,7 @@ input.invalid { border-color: var(--red); } 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); } +.seat.contender .avatar { border-color: var(--orange); } .act-tag { position: absolute; @@ -625,12 +564,13 @@ input.invalid { border-color: var(--red); } border: 2px dashed #fff; box-sizing: border-box; } -.bet.above { bottom: calc(100% + 20px); } +.bet.above { bottom: calc(100% + 4px); } .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); } +.phase.showdown { background: var(--orange); color: #111; } .result { margin-top: 6px; background: rgba(0, 0, 0, 0.4); @@ -646,35 +586,31 @@ input.invalid { border-color: var(--red); } .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; } +.felt-area.crowded { min-height: 420px; } .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 .my-bet { color: var(--gold); font-weight: 700; } .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; } +.award-panel { margin-bottom: 4px; } +.award-pot { background: var(--panel-2); border-radius: 10px; padding: 8px 10px; margin-bottom: 8px; } +.award-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; } +.award-title b { color: var(--gold); font-size: 14px; } +.picks { display: flex; flex-wrap: wrap; gap: 6px; } +.picks .pick { background: var(--input); color: var(--text); border: 2px solid transparent; } +.picks .pick.on { background: rgba(246, 199, 68, 0.18); border-color: var(--gold); color: var(--gold); } .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; } diff --git a/public/assets/table.js b/public/assets/table.js index 9610311..dde3e36 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -1,4 +1,5 @@ // プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る +// カードは実物で配るので、ここで扱うのはチップのやり取りだけ (function () { 'use strict'; @@ -8,19 +9,18 @@ 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 pending = false; // 送信中(連打防止) let raising = false; let raiseValue = 0; - let seenBoard = ''; // 新しくめくられたカードだけアニメーションする - let seenHand = 0; let wasMyTurn = false; + // ショーダウンの勝者選択(ポットごとの席番号の Set) + let awardKey = ''; + let awardSel = []; // ---------- 名前 ---------- function ensureName() { @@ -65,7 +65,7 @@ }); } - function emit(event, payload, onDone) { + function emit(event, payload) { if (!socket) return; socket.emit(event, payload || null, (res) => { if (res && !res.ok) { @@ -73,7 +73,6 @@ toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error); if (state) render(); } - if (onDone) onDone(res); }); } @@ -92,24 +91,14 @@ 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; - } + const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者'); // ---------- 描画 ---------- 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); + const myTurn = Boolean(s.me && (s.me.options || (s.me.canAward && !s.paused))); $('main').hidden = false; document.title = `${myTurn ? '▶ ' : ''}${s.name} - Hold'em Night`; @@ -126,8 +115,8 @@ $('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); + $('phase').parentElement.classList.toggle('showdown', s.phase === 'SHOWDOWN' && !s.paused); $('hand-no').textContent = s.handNumber ? `#${s.handNumber}` : ''; - renderBoard(h); renderResult(s); // ホスト操作 @@ -152,36 +141,10 @@ 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) { @@ -191,13 +154,11 @@ 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}
`; + return `
${label}${who} +${chips(r.amount)}
`; }) .join(''); el.hidden = false; @@ -210,20 +171,21 @@ 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 showdown = s.phase === 'SHOWDOWN'; + const finished = Boolean(h && h.finished); const container = $('seats'); $('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示 container.innerHTML = ordered .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'); if (p.inHand && p.folded) classes.push('folded'); - if (isTurn) classes.push('turn'); - if (h && h.finished && p.won > 0) classes.push('winner'); + if (s.toAct === p.seat) classes.push('turn'); + if (showdown && p.inHand && !p.folded) classes.push('contender'); + if (finished && p.won > 0) classes.push('winner'); if (p.sittingOut && !p.inHand) classes.push('away'); const markers = h @@ -234,24 +196,14 @@ ].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)}
`; + if (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)) { + if (p.last && !(finished && p.won > 0)) { tag = `
${ACTION_LABEL[p.last.type] || ''}
`; } else if (p.sittingOut && !p.inHand) { tag = '
離席中
'; @@ -259,14 +211,12 @@ return `
-
${AVATAR_SVG}${markers}${cards}
+
${AVATAR_SVG}${markers}
${tag}
${esc(p.name)}
-
${p.allIn && !(h && h.finished) ? 'ALL-IN' : chips(p.stack)}
- ${p.handName && p.seat !== s.mySeat ? `
${esc(p.handName)}
` : ''} +
${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}
${p.connected ? '' : '
オフライン
'} - ${isTurn ? '
' : ''}
${bet}
`; @@ -289,40 +239,38 @@ 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('') - : ''; - + $('my-bet').textContent = seat.inHand && seat.bet > 0 ? `ベット中 ${chips(seat.bet)}` : ''; $('sit-out').hidden = m.sittingOut; // 状況 + const awaiting = Boolean(h && h.pots && !h.finished); 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 (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : '勝者の選択を待っています'; + else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`; 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)); + $('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward)); + + // ショーダウンの勝者選択 + const awarding = awaiting && m.canAward && !s.paused; + $('award-panel').hidden = !awarding; + if (awarding) renderAwardPanel(h.pots); // ボタン - const alt = m.sittingOut || m.canRebuy; + const alt = (m.sittingOut || m.canRebuy) && !awarding; + if (!o) raising = false; $('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; + $('actions').hidden = alt || raising || awarding; $('raise-actions').hidden = alt || !raising; $('raise-panel').hidden = alt || !raising; $('status-line').hidden = raising; @@ -347,6 +295,63 @@ if (raising) renderRaisePanel(o); } + // ---------- 勝者選択 ---------- + function renderAwardPanel(pots) { + const key = `${state.handNumber}:${JSON.stringify(pots)}`; + if (key !== awardKey) { + awardKey = key; + // 参加者が1人だけのポットは最初から選んでおく + awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : [])); + } + $('award-pots').innerHTML = pots + .map((p, i) => { + const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`; + const names = p.eligible + .map( + (seat) => + `` + ) + .join(''); + return `
${title} ${chips(p.amount)}
${names}
`; + }) + .join(''); + const ready = awardSel.every((set) => set.size > 0); + const split = awardSel.some((set) => set.size > 1); + $('award-confirm').disabled = !ready || pending; + $('award-confirm').textContent = split ? '山分けで確定' : '確定'; + } + + $('award-pots').addEventListener('click', (e) => { + const b = e.target.closest('[data-pot]'); + if (!b || !state || !state.hand || !state.hand.pots) return; + const i = Number(b.dataset.pot); + const seat = Number(b.dataset.seat); + const set = awardSel[i]; + if (set.has(seat)) set.delete(seat); + else { + set.add(seat); + // メインで勝った人は、まだ決めていないサイドポットでも勝っているはず + state.hand.pots.forEach((p, j) => { + if (j > i && awardSel[j].size === 0 && p.eligible.includes(seat)) awardSel[j].add(seat); + }); + } + renderAwardPanel(state.hand.pots); + }); + + $('award-confirm').addEventListener('click', () => { + if (pending || !awardSel.every((set) => set.size > 0)) return; + const winners = awardSel.map((set) => [...set]); + const names = winners.map((w) => w.map(nameOf).join('・')); + const msg = + names.length === 1 + ? `${names[0]} の勝ちで確定しますか?` + : names.map((n, i) => `${i === 0 ? 'メイン' : 'サイド'}: ${n}`).join('\n') + '\nで確定しますか?'; + if (!confirm(msg)) return; + pending = true; + emit('table:award', { winners }); + render(); + }); + // ---------- レイズ ---------- function presetValue(o, kind) { const h = state.hand; @@ -427,7 +432,7 @@ $('raise-range').addEventListener('input', (e) => { const o = options(); if (!o) return; - // BB 単位に丸める(最大はオールイン額そのまま) + // SB 単位に丸める(最大はオールイン額そのまま) 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; @@ -447,48 +452,31 @@ 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); + 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, 200); + setInterval(renderTimer, 250); // ---------- その他の操作 ---------- $('start').addEventListener('click', () => emit('table:start')); $('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')); + $('take-seat').addEventListener('click', () => join(true)); $('leave').addEventListener('click', () => { const me = state && state.mySeat >= 0 ? state.seats[state.mySeat] : null; diff --git a/public/table.html b/public/table.html index 973f9e6..5084471 100644 --- a/public/table.html +++ b/public/table.html @@ -38,7 +38,6 @@
POT
$0
-
WAITING
@@ -52,19 +51,22 @@