Files
Poker/public/assets/table.js
T
agomizi1cmandClaude Opus 5.5 b5d84e5f65 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 <noreply@anthropic.com>
2026-10-15 17:46:10 +09:00

509 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カードは実物で配るので、ここで扱うのはチップのやり取りだけ
(function () {
'use strict';
const { Store, esc, chips, toast } = App;
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
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>';
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 wasMyTurn = false;
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
$('name-modal').hidden = false;
$('name-input').focus();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
if (!name) return $('name-input').classList.add('invalid');
Store.name = name;
$('name-modal').hidden = true;
connect();
});
// ---------- 通信 ----------
function connect() {
socket = App.connectSocket();
socket.on('connect', () => {
$('conn-lost').hidden = true;
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 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) {
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();
}
});
}
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;
$('not-found').hidden = false;
if (socket) socket.disconnect();
}
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 || (s.me.canAward && !s.paused)));
$('main').hidden = false;
document.title = `${myTurn ? '▶ ' : ''}${s.name} - Hold'em Night`;
$('table-name').textContent = s.name;
$('level').textContent = `Lv.${s.level}`;
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
$('next-blinds').textContent = s.timer.enabled
? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
: 'ブラインドタイマーなし';
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
// 中央
$('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}` : '';
renderResult(s);
// ホスト操作
$('host-controls').hidden = !s.isHost;
$('host-spacer').hidden = s.isHost;
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 ? '再開' : '一時停止';
// 自分
$('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 ? '退席する' : 'ロビーへ戻る';
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;
}
function renderResult(s) {
const el = $('result');
const h = s.hand;
if (!h || !h.finished || !h.results) {
el.hidden = true;
return;
}
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('・');
return `<div>${label}<b>${who}</b> +${chips(r.amount)}</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 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'];
if (p.seat === s.mySeat) classes.push('me');
if (!p.connected) classes.push('offline');
if (p.inHand && p.folded) classes.push('folded');
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
? [
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('')
: '';
// ベット(中央寄り)・獲得額
const below = Math.sin(angles[idx]) <= 0.35;
let bet = '';
if (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 && !(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="nameplate">
${tag}
<div class="pname">${esc(p.name)}</div>
<div class="pstack">${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}</div>
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
</div>
${bet}
</div>`;
})
.join('');
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
[...container.children].forEach((el, i) => {
el.style.left = `${(50 + 38 * Math.cos(angles[i])).toFixed(2)}%`;
el.style.top = `${(50 + 41 * Math.sin(angles[i])).toFixed(2)}%`;
});
}
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-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 (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) status = '次のハンドを準備中…';
else if (!h) status = '参加者を待っています(2人以上で再開)';
$('status-line').textContent = status;
$('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) && !awarding;
if (!o) raising = false;
$('alt-actions').hidden = !alt;
$('sit-in').hidden = !m.sittingOut;
$('rebuy').hidden = !m.canRebuy;
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
$('actions').hidden = alt || raising || awarding;
$('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;
}
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 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) =>
`<button type="button" class="btn btn-sm pick${awardSel[i].has(seat) ? ' on' : ''}" data-pot="${i}" data-seat="${seat}">${esc(nameOf(seat))}</button>`
)
.join('');
return `<div class="award-pot"><div class="award-title">${title} <b>${chips(p.amount)}</b></div><div class="picks">${names}</div></div>`;
})
.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;
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;
// 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;
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;
el.classList.toggle('paused', state.paused);
if (!t.enabled) {
el.textContent = state.paused ? '⏸' : '';
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);
// ---------- その他の操作 ----------
$('start').addEventListener('click', () => emit('table:start'));
$('pause').addEventListener('click', () => emit('table:pause'));
$('sit-out').addEventListener('click', () => emit('table:sitOut'));
$('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;
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 = '/'; });
});
$('share').addEventListener('click', async () => {
const url = location.origin + '/';
try {
await navigator.clipboard.writeText(url);
} catch {
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
toast('サイトのリンクをコピーしました');
});
ensureName();
})();