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:
1 parent
6d33440048
commit
66907345ff
13 files changed
+653
-42
No files matched your search
+75
-3
@@ -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人以上で再開)';
|
||||
|
||||
Reference in new issue
Block a user