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
@@ -16,6 +16,8 @@
|
||||
const timerInput = $('timer');
|
||||
const intervalInput = $('interval');
|
||||
const scheduleEl = $('schedule');
|
||||
const cardsInput = $('cards');
|
||||
const cardsHint = $('cards-hint');
|
||||
const blindHint = $('blind-hint');
|
||||
const errorEl = $('error');
|
||||
const submitBtn = $('submit');
|
||||
@@ -66,9 +68,16 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
function updateCardsHint() {
|
||||
cardsHint.textContent = cardsInput.checked
|
||||
? 'オン: 各自のスマホに手札が配られ、勝敗とポットの分配は自動'
|
||||
: 'オフ: 実物のカードで遊び、ショーダウンの勝者はホストが選ぶ';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
updateHint();
|
||||
updateSchedule();
|
||||
updateCardsHint();
|
||||
}
|
||||
|
||||
stackInput.addEventListener('input', () => {
|
||||
@@ -91,6 +100,7 @@
|
||||
});
|
||||
timerInput.addEventListener('change', refresh);
|
||||
intervalInput.addEventListener('input', refresh);
|
||||
cardsInput.addEventListener('change', refresh);
|
||||
$('reroll').addEventListener('click', suggestName);
|
||||
|
||||
function validate() {
|
||||
@@ -128,6 +138,7 @@
|
||||
bigBlind: int(bbInput),
|
||||
blindTimerEnabled: timerInput.checked,
|
||||
blindIntervalMin: int(intervalInput),
|
||||
cardsEnabled: cardsInput.checked,
|
||||
token: Store.token,
|
||||
});
|
||||
location.href = '/table/' + encodeURIComponent(id);
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<span>開始 <b>${chips(t.startingStack)}</b></span>
|
||||
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
|
||||
<span>${timer}</span>
|
||||
${t.cardsEnabled ? '<span>🃏 カードあり</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
|
||||
|
||||
@@ -532,6 +532,71 @@ body.table-page {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* ---------- カード(カード機能ありのテーブル) ---------- */
|
||||
.pcard {
|
||||
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;
|
||||
}
|
||||
.pcard b { font-size: 15px; letter-spacing: -0.05em; }
|
||||
.pcard 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; }
|
||||
.pcard.back {
|
||||
background: repeating-linear-gradient(45deg, #c0392b 0 3px, #a93226 3px 6px);
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
.pcard.slot { background: rgba(0, 0, 0, 0.12); box-shadow: none; border: 1px dashed rgba(255, 255, 255, 0.3); }
|
||||
.pcard.deal { animation: deal 0.35s ease-out both; }
|
||||
@keyframes deal {
|
||||
from { opacity: 0; transform: translateY(-10px) scale(0.8); }
|
||||
}
|
||||
.pcard.dim { filter: brightness(0.5); }
|
||||
.pcard.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: 6px; min-height: 40px; }
|
||||
|
||||
.hole {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
.hole .pcard { width: 25px; height: 35px; border-radius: 4px; }
|
||||
.hole .pcard b { font-size: 13px; }
|
||||
.hole .pcard i { font-size: 11px; }
|
||||
.hole .pcard.back { border-width: 1.5px; }
|
||||
/* 手札と重ならないよう D・SB/BB を横に出す */
|
||||
.with-cards .chip-marker.dealer { top: 22px; right: -16px; }
|
||||
.with-cards .blind-marker { top: 26px; left: -20px; }
|
||||
.felt-area.with-cards { margin-top: 62px; }
|
||||
.with-cards .bet.above { bottom: calc(100% + 20px); }
|
||||
.crowded.with-cards .bet.above { bottom: calc(100% + 14px); }
|
||||
.nameplate .phand {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--gold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- 席の状態 ---------- */
|
||||
.seat.folded .avatar, .seat.folded .nameplate { opacity: 0.45; }
|
||||
.seat.away .avatar { opacity: 0.4; }
|
||||
@@ -615,10 +680,21 @@ body.table-page {
|
||||
.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 .pcard { width: 20px; height: 28px; }
|
||||
.crowded .hole .pcard b { font-size: 11px; }
|
||||
.crowded .hole .pcard i { font-size: 9px; }
|
||||
.crowded.with-cards .chip-marker.dealer { top: 14px; }
|
||||
.crowded.with-cards .blind-marker { top: 18px; }
|
||||
|
||||
/* ---------- 自分の操作エリア ---------- */
|
||||
.me-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.me-info { flex: 1; min-width: 0; }
|
||||
.my-cards { display: flex; gap: 4px; flex: none; }
|
||||
.my-cards .pcard { width: 42px; height: 58px; border-radius: 6px; }
|
||||
.my-cards .pcard b { font-size: 22px; }
|
||||
.my-cards .pcard i { font-size: 19px; }
|
||||
.me-sub .my-hand { color: var(--gold); font-weight: 800; }
|
||||
.me-sub { font-size: 12px; color: var(--muted); display: flex; gap: 8px; margin-top: 3px; white-space: nowrap; }
|
||||
.me-sub .my-bet { color: var(--gold); font-weight: 700; }
|
||||
.me-sub .plus { color: var(--green); }
|
||||
@@ -664,6 +740,18 @@ body.table-page {
|
||||
.crowded .nameplate .pname { font-size: 10px; }
|
||||
.crowded .nameplate .pstack { font-size: 11px; }
|
||||
.crowded .chip-marker { width: 20px; height: 20px; font-size: 10px; }
|
||||
.felt-area.with-cards { margin-top: 52px; }
|
||||
.board .pcard { width: 24px; height: 34px; }
|
||||
.board .pcard b { font-size: 13px; }
|
||||
.board .pcard i { font-size: 11px; }
|
||||
.board { min-height: 34px; margin-top: 4px; }
|
||||
.my-cards .pcard { width: 34px; height: 48px; }
|
||||
.my-cards .pcard b { font-size: 18px; }
|
||||
.my-cards .pcard i { font-size: 15px; }
|
||||
.crowded .hole { top: -10px; }
|
||||
.crowded .hole .pcard { width: 17px; height: 24px; }
|
||||
.crowded .hole .pcard b { font-size: 10px; }
|
||||
.crowded .hole .pcard i { font-size: 8px; }
|
||||
}
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
+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