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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2c91062f1
commit
8de55f7ca5
12 files changed
+1648
-167
No files matched your search
@@ -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 };
|
||||
|
||||
+201
-18
@@ -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 属性を使わないため) ---------- */
|
||||
|
||||
+385
-52
@@ -1,4 +1,4 @@
|
||||
// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ
|
||||
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -8,10 +8,19 @@
|
||||
|
||||
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;
|
||||
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 `<span class="card back ${extra}"></span>`;
|
||||
const rank = c[0] === 'T' ? '10' : c[0];
|
||||
return `<span class="card suit-${c[1]} ${extra}" data-card="${c}"><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] ? 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 += '<span class="card slot"></span>';
|
||||
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 ? `<span class="pot-tag">${i === 0 ? 'メイン' : 'サイド'}</span>` : '';
|
||||
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
|
||||
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;
|
||||
}
|
||||
|
||||
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
|
||||
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 ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
|
||||
p.seat === s.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
|
||||
p.seat === s.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
|
||||
].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 ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
|
||||
p.seat === h.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
|
||||
p.seat === h.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
|
||||
].join('')
|
||||
: '';
|
||||
|
||||
// 手札(自分と公開された人は表、それ以外は裏)
|
||||
let cards = '';
|
||||
if (p.inHand && (!p.folded || p.cards)) {
|
||||
const deal = h.number !== seenHand ? 'deal' : '';
|
||||
const list = p.cards || [null, null];
|
||||
cards = `<div class="hole">${list
|
||||
.map((c) => cardHtml(c, [deal, best && c ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ')))
|
||||
.join('')}</div>`;
|
||||
}
|
||||
|
||||
// ベット(中央寄り)・獲得額
|
||||
const below = Math.sin(angles[idx]) <= 0.35;
|
||||
let bet = '';
|
||||
if (h && h.finished && p.won > 0) bet = `<div class="bet win ${below ? 'below' : 'above'}">+${chips(p.won)}</div>`;
|
||||
else if (p.bet > 0) bet = `<div class="bet ${below ? 'below' : 'above'}">${chips(p.bet)}</div>`;
|
||||
|
||||
let tag = '';
|
||||
if (p.last && !(h && h.finished && p.won > 0)) {
|
||||
tag = `<div class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</div>`;
|
||||
} else if (p.sittingOut && !p.inHand) {
|
||||
tag = '<div class="act-tag tag-away">離席中</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${classes.join(' ')}">
|
||||
<div class="avatar">${AVATAR_SVG}${markers}</div>
|
||||
<div class="avatar">${AVATAR_SVG}${markers}${cards}</div>
|
||||
<div class="nameplate">
|
||||
<div class="pname">${p.seat === s.hostSeat ? '<span title="ホスト">👑</span>' : ''}${esc(p.name)}</div>
|
||||
<div class="pstack">${chips(p.stack)}</div>
|
||||
${tag}
|
||||
<div class="pname">${esc(p.name)}</div>
|
||||
<div class="pstack">${p.allIn && !(h && h.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>'}
|
||||
${isTurn ? '<div class="seat-timer"><i></i></div>' : ''}
|
||||
</div>
|
||||
${bet}
|
||||
</div>`;
|
||||
})
|
||||
.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('')
|
||||
: '<span class="card slot"></span><span class="card slot"></span>';
|
||||
|
||||
$('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 = '/'; });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user