Add optional card mode: server deals cards and settles showdowns automatically

- New "カードを配る" switch on the create-table screen (default off); the lobby
  shows "カードあり" for tables that use it
- Card mode (server/cards.js restored): crypto-shuffled deck kept server-side,
  hole cards sent only to their owner until showdown/all-in reveal, board dealt
  per street with burns, all-in runout on a timer (frozen while paused)
- Showdown is evaluated and every pot (incl. side pots, split pots) is paid out
  automatically; host winner selection is only used when card mode is off
- Play UI: board, hole cards, own cards in the action bar, hand names, winning
  cards highlighted; layout tuned for 7+ seats and short screens
- Playing-card styles use .pcard so they don't clash with the existing .card panel
- Tests for hand evaluation, card privacy, runout, side pots, ties and random play

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-17 16:44:22 +09:00
1 parent 6d33440048
commit 66907345ff
13 files changed
+653 -42

No files matched your search

+75 -3
View File
@@ -1,5 +1,5 @@
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カードは実物で配るので、ここで扱うのはチップのやり取りだけ
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
(function () {
'use strict';
@@ -9,6 +9,8 @@
const AVATAR_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></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;
@@ -18,6 +20,8 @@
let raising = false;
let raiseValue = 0;
let wasMyTurn = false;
let seenBoard = ''; // 新しくめくられたカードだけアニメーションする
let seenHand = 0;
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
@@ -93,6 +97,45 @@
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
// ---------- カード(カード機能ありのテーブルのみ) ----------
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return `<span class="pcard back ${extra}"></span>`;
const rank = c[0] === 'T' ? '10' : c[0];
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
// ショーダウンで勝った5枚(メインポット)
function winningCards(h) {
return h && h.finished && h.showdown && h.results && h.results[0] && h.results[0].bestCards
? new Set(h.results[0].bestCards)
: null;
}
function renderBoard(s) {
const el = $('board');
const h = s.hand;
el.hidden = !s.cardsEnabled;
if (!s.cardsEnabled) return;
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 += '<span class="pcard slot"></span>';
continue;
}
const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
html += cardHtml(c, cls);
}
el.innerHTML = html;
}
// ---------- 描画 ----------
function render() {
const s = state;
@@ -117,6 +160,7 @@
$('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(s);
renderResult(s);
// ホスト操作
@@ -147,6 +191,10 @@
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 renderResult(s) {
@@ -160,7 +208,8 @@
.map((r, i) => {
const label = h.results.length > 1 ? `<span class="pot-tag">${i === 0 ? 'メイン' : 'サイド'}</span>` : '';
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
return `<div>${label}<b>${who}</b> +${chips(r.amount)}</div>`;
const hand = r.handName ? ` <span class="rhand">${esc(r.handName)}</span>` : '';
return `<div>${label}<b>${who}</b> +${chips(r.amount)}${hand}</div>`;
})
.join('');
el.hidden = false;
@@ -176,9 +225,11 @@
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 best = winningCards(h);
const container = $('seats');
$('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
$('felt-area').classList.toggle('with-cards', s.cardsEnabled);
container.innerHTML = ordered
.map((p, idx) => {
const classes = ['seat'];
@@ -198,6 +249,15 @@
].join('')
: '';
// 手札(自分と公開された人は表、それ以外は裏)
let cards = '';
if (s.cardsEnabled && p.inHand && (!p.folded || p.cards)) {
const deal = h.number !== seenHand ? 'deal' : '';
const list = p.cards || [null, null];
const mark = (c) => (best && c ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : '');
cards = `<div class="hole">${list.map((c) => cardHtml(c, `${deal} ${mark(c)}`)).join('')}</div>`;
}
// ベット(中央寄り)・獲得額
const below = Math.sin(angles[idx]) <= 0.35;
let bet = '';
@@ -213,11 +273,12 @@
return `
<div class="${classes.join(' ')}">
<div class="avatar">${AVATAR_SVG}${markers}</div>
<div class="avatar">${AVATAR_SVG}${markers}${cards}</div>
<div class="nameplate">
${tag}
<div class="pname">${esc(p.name)}</div>
<div class="pstack">${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}</div>
${p.handName && p.seat !== s.mySeat ? `<div class="phand">${esc(p.handName)}</div>` : ''}
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
</div>
${bet}
@@ -242,6 +303,16 @@
$('my-net').textContent = `収支 ${net > 0 ? '+' : net < 0 ? '−' : '±'}${chips(Math.abs(net))}`;
$('my-net').className = net > 0 ? 'plus' : net < 0 ? 'minus' : '';
$('my-bet').textContent = seat.inHand && seat.bet > 0 ? `ベット中 ${chips(seat.bet)}` : '';
$('my-hand').textContent = (seat.inHand && seat.handName) || '';
$('my-cards').hidden = !s.cardsEnabled;
if (s.cardsEnabled) {
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('')
: '<span class="pcard slot"></span><span class="pcard slot"></span>';
}
$('sit-out').hidden = m.sittingOut;
// 状況
@@ -254,6 +325,7 @@
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます';
else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`;
else if (h && !h.finished && h.runout) status = 'オールイン — 残りのカードをめくります';
else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します';
else if (h && h.finished) status = '次のハンドを準備中…';
else if (!h) status = '参加者を待っています(2人以上で再開)';