Files
野口煌陽andClaude Opus 5.5 f64c502086 Wait for the host to press "next hand" on every table
Previously only the review CPU table paused after each hand; cash-only,
friends and CPU tables moved on automatically after a short delay. Now
every table keeps the result on screen until the host presses 次のハンドへ,
and the next hand is dealt right away. If the host has left, the new host
presses it; with no human seated the table still advances on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:56:37 +09:00

1491 lines
62 KiB
JavaScript
Raw Permalink 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 BOT_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="11" y="1.5" width="2" height="3.5" rx="1"/><circle cx="12" cy="1.8" r="1.6"/><path d="M5 7.5A2.5 2.5 0 0 1 7.5 5h9A2.5 2.5 0 0 1 19 7.5v7a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 14.5zM9 9.2a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zm6 0a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zM2 9.5h2v4H2zm18 0h2v4h-2zM7 18.5h10a3 3 0 0 1 3 3V23H4v-1.5a3 3 0 0 1 3-3z"/></svg>';
const CHECK_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></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 wasMyTurn = false;
let seenHand = 0; // 新しいハンドの手札だけ配る動きを付ける
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
let reviewKey = '';
const reviewSheet = Fluid.Sheet($('review-modal'));
const rankingSheet = Fluid.Sheet($('ranking-modal'));
const seatingPopup = Fluid.Popup($('seating-modal'));
const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
// 払って閉じた・背景を押した・Esc のときも「戻る」と同じ
const betSheet = Fluid.Sheet($('bet-modal'), {
focus: '#raise-range',
onClose: () => {
if (!raising) return;
raising = false;
render();
},
});
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
nameSheet.open();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
if (!name) {
$('name-input').classList.add('invalid');
Fluid.shake($('name-input'));
return;
}
Store.name = name;
nameSheet.close();
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:closed', (p) => showNotFound(true, p && p.reason));
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();
}
// closed: プレイヤーが全員退席してテーブルが閉じられた
function showNotFound(closed, reason) {
if (closed) {
$('not-found-title').textContent = 'テーブルが終了しました';
$('not-found-text').textContent =
reason === 'deleted' ? '管理者がテーブルを削除しました。' : 'プレイヤーが全員退席したため、テーブルを閉じました。';
}
$('main').hidden = true;
$('action-bar').hidden = true;
reviewSheet.close();
rankingSheet.close();
seatingPopup.close();
raising = false;
betSheet.close();
$('not-found').hidden = false;
// 自分から切断するので「接続が切れました」は出さない
if (socket) {
socket.off('disconnect');
socket.off('connect_error');
socket.disconnect();
}
$('conn-lost').hidden = true;
}
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
// 中身が変わったときだけ書き換える(書き換えたら true)。key を変えると同じ中身でも書き換える
function patch(el, html, key = html) {
if (el.patchKey === key) return false;
el.patchKey = key;
el.innerHTML = html;
return true;
}
// ---------- カード(カード機能ありのテーブルのみ) ----------
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>`;
}
function cardNode(c) {
const t = document.createElement('template');
t.innerHTML = c ? cardHtml(c) : '<span class="pcard slot"></span>';
return t.content.firstElementChild;
}
// 入れたばかりのカードに動きを付ける。delay(i): i 枚目を何ms遅らせるか(1枚ずつ配る・めくる)
function animateCards(container, cls, delay) {
[...container.children].forEach((card, i) => {
if (card.classList.contains('slot')) return;
card.classList.add(cls);
card.style.animationDelay = `${Math.round(delay(i))}ms`;
});
}
// 勝った5枚を強調し、それ以外を暗くする(カードは作り直さず印だけ付け替える)
function markCards(container, list, markOf) {
[...container.children].forEach((node, i) => {
const m = list[i] ? markOf(list[i]) : '';
node.classList.toggle('hl', m === 'hl');
node.classList.toggle('dim', m === 'dim');
});
}
// ショーダウンで勝った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;
}
// 場札: 5つの枠を使い回し、新しく出たカードだけめくる(フロップは少しずつずらして)
function renderBoard(s) {
const el = $('board');
const h = s.hand;
el.hidden = !s.cardsEnabled;
if (!s.cardsEnabled) return;
if (!el.slots) {
el.innerHTML = '<span class="pcard slot"></span>'.repeat(5);
el.slots = ['', '', '', '', ''];
}
const best = winningCards(h);
let fresh = 0;
for (let i = 0; i < 5; i++) {
const c = (h && h.board[i]) || '';
if (el.slots[i] !== c) {
el.slots[i] = c;
const next = cardNode(c);
if (c) {
next.classList.add('flip-in');
next.style.animationDelay = `${fresh++ * 90}ms`;
}
el.children[i].replaceWith(next);
}
const node = el.children[i];
const mark = c && best ? (best.has(c) ? 'hl' : 'dim') : '';
node.classList.toggle('hl', mark === 'hl');
node.classList.toggle('dim', mark === 'dim');
}
}
// ---------- 描画 ----------
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)}`;
// 中央
Fluid.countTo($('pot'), h ? h.potTotal : 0, chips);
$('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}` : '';
renderBoard(s);
renderResult(s);
// ホスト操作
$('host-controls').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').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
const seated = s.seats.filter(Boolean).length;
$('seating-open').disabled = seated < 3; // 2人までは並びが1通り
$('seating-open').title = seated < 3 ? '3人以上で席順を変えられます' : '席順を変更';
$('seating-dot').hidden = !s.pendingSeating;
// 自分
$('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 ? '退席する' : 'ロビーへ戻る';
$('leave').setAttribute('aria-label', $('leave').title);
$('leave').classList.toggle('spectator', !me);
renderSeats(s);
if (me) renderActionBar(s, me);
else betSheet.close();
renderReview(s);
if (rankingSheet.isOpen) renderRanking(s);
if (seatingPopup.isOpen) syncSeating(s);
renderTimer();
// 自分の番になったら知らせる
if (myTurn && !wasMyTurn) Fluid.haptic(80);
wasMyTurn = myTurn;
if (h) seenHand = h.number;
}
function renderResult(s) {
const el = $('result');
const h = s.hand;
if (!h || !h.finished || !h.results) {
el.hidden = true;
return;
}
patch(
el,
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;
}
// ---------- 席 ----------
const seatEls = new Map(); // 席番号 → 要素
function seatEl(no) {
let el = seatEls.get(no);
if (el) return el;
el = document.createElement('div');
el.className = 'seat entering';
el.innerHTML =
'<div class="avatar"><span class="face"></span><span class="markers"></span><span class="hole"></span></div>' +
'<div class="nameplate"><span class="tag-slot"></span><span class="pname"></span><span class="pstack"></span><span class="pnote"></span></div>' +
'<span class="bet-slot"></span>';
const q = (sel) => el.querySelector(sel);
el.parts = {
avatar: q('.avatar'),
face: q('.face'),
markers: q('.markers'),
hole: q('.hole'),
tag: q('.tag-slot'),
name: q('.pname'),
stack: q('.pstack'),
note: q('.pnote'),
bet: q('.bet-slot'),
};
el.addEventListener('animationend', (e) => {
if (e.target === el) el.classList.remove('entering');
});
seatEls.set(no, el);
$('seats').append(el);
return el;
}
// 名前の頭文字と、名前から決まる色(同じ名前なら毎回同じ色)
const initialOf = (name) => [...String(name).trim()][0] || '?';
function hueOf(name) {
let h = 0;
for (const ch of String(name)) h = (h * 31 + ch.codePointAt(0)) % 360;
return h;
}
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
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 showdown = s.phase === 'SHOWDOWN';
const finished = Boolean(h && h.finished);
const best = winningCards(h);
const newHand = Boolean(h && h.number !== seenHand);
const area = $('felt-area');
area.classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
area.classList.toggle('with-cards', s.cardsEnabled);
const live = new Set();
ordered.forEach((p, idx) => {
live.add(p.seat);
const el = seatEl(p.seat);
const q = el.parts;
const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
const left = 50 + 38 * Math.cos(angle);
const top = 50 + 41 * Math.sin(angle);
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
el.style.left = `${left.toFixed(2)}%`;
el.style.top = `${top.toFixed(2)}%`;
// 配るカードはテーブルの中央(山札)から飛んでくる
el.style.setProperty('--deal-x', `${(((50 - left) / 100) * area.clientWidth).toFixed(1)}px`);
el.style.setProperty('--deal-y', `${(((50 - top) / 100) * area.clientHeight).toFixed(1)}px`);
const set = (cls, on) => el.classList.toggle(cls, Boolean(on));
set('me', p.seat === s.mySeat);
set('bot', p.bot);
set('offline', !p.connected);
set('folded', p.inHand && p.folded);
set('turn', s.toAct === p.seat);
set('contender', showdown && p.inHand && !p.folded);
set('winner', finished && p.won > 0);
set('away', p.sittingOut && !p.inHand);
patch(q.face, p.bot ? BOT_SVG : `<span class="mono">${esc(initialOf(p.name))}</span>`);
q.avatar.style.setProperty('--hue', hueOf(p.name));
patch(
q.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 list = s.cardsEnabled && h && p.inHand && (!p.folded || p.cards) ? p.cards || [null, null] : [];
const hadBacks = Boolean(q.hole.querySelector('.back'));
if (patch(q.hole, list.map((c) => cardHtml(c)).join(''), `${h ? h.number : 0}|${list.join()}`) && list.length) {
// 新しいハンドは中央から1人1枚ずつ2周配り、ショーダウンで公開された手札はめくる
if (newHand) animateCards(q.hole, 'deal', (i) => (i * n + idx) * 55);
else if (hadBacks && !q.hole.querySelector('.back')) animateCards(q.hole, 'flip-in', (i) => i * 90);
}
markCards(q.hole, list, (c) => (best ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : ''));
let tag = '';
if (p.last && !(finished && p.won > 0)) {
tag = `<span class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</span>`;
} else if (p.sittingOut && !p.inHand) {
tag = '<span class="act-tag tag-away">離席中</span>';
}
patch(q.tag, tag);
q.name.textContent = p.name;
q.stack.textContent = p.allIn && !finished ? 'ALL-IN' : chips(p.stack);
q.note.textContent = !p.connected ? 'オフライン' : p.handName && p.seat !== s.mySeat ? p.handName : '';
q.note.classList.toggle('offline', !p.connected);
// ベット(中央寄り)・獲得額
const side = Math.sin(angle) <= 0.35 ? 'below' : 'above';
let bet = '';
if (finished && p.won > 0) bet = `<span class="bet win ${side}">+${chips(p.won)}</span>`;
else if (p.bet > 0) bet = `<span class="bet ${side}">${chips(p.bet)}</span>`;
patch(q.bet, bet);
});
for (const [no, el] of seatEls) {
if (live.has(no)) continue;
el.remove();
seatEls.delete(no);
}
}
// ---------- 自分の操作エリア ----------
function renderActionBar(s, seat) {
const m = s.me;
const h = s.hand;
const o = m.options;
Fluid.countTo($('my-stack'), m.stack, chips);
// 収支は設定で消せる(この端末だけ)
const net = Store.showNet ? m.net : null;
$('my-net').textContent = net == null ? '' : `収支 ${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 el = $('my-cards');
const myCards = seat.inHand && seat.cards ? seat.cards : [];
const base = myCards.length
? myCards.map((c) => cardHtml(c)).join('')
: '<span class="pcard slot"></span><span class="pcard slot"></span>';
if (patch(el, base, `${h ? h.number : 0}|${myCards.join()}`) && myCards.length) {
animateCards(el, 'deal', (i) => 120 + i * 140);
}
const best = winningCards(h);
markCards(el, myCards, (c) => (best && best.has(c) ? 'hl' : seat.folded ? 'dim' : ''));
}
$('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 (s.awaitingNext && s.isHost) {
status = s.review ? 'レビューを確認したら「次のハンドへ」を押してください' : '「次のハンドへ」を押すと次のハンドを始めます';
}
else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します';
else if (m.canRebuy) status = 'チップがなくなりました';
else if (s.awaitingNext) status = 'ホストが次のハンドを始めるのを待っています';
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます';
else if (h && !h.finished && s.toAct != null) {
const actor = s.seats[s.toAct];
status = actor && actor.bot ? `${actor.name}(CPU)が考えています…` : `${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人以上で再開)';
const myMove = Boolean(o) || (awaiting && m.canAward);
$('status-line').textContent = status;
$('status-line').classList.toggle('my-turn', myMove);
$('action-bar').classList.toggle('my-turn', myMove && !s.paused);
// ショーダウンの勝者選択
const awarding = awaiting && m.canAward && !s.paused;
$('award-panel').hidden = !awarding;
if (awarding) renderAwardPanel(h.pots);
// ボタン
// ハンドが終わったら、ホストが「次のハンドへ」を押すまで待つ(レビュー付きCPU戦はレビューも開ける)
const nexting = Boolean(s.awaitingNext && s.isHost && !s.paused);
const alt = (m.sittingOut || m.canRebuy) && !awarding;
if (!o) raising = false;
$('next-actions').hidden = !nexting;
$('next-actions').classList.toggle('raising', Boolean(s.review));
$('next-actions').classList.toggle('single', !s.review);
$('review-show').hidden = !s.review;
$('alt-actions').hidden = !alt;
$('sit-in').hidden = !m.sittingOut;
$('rebuy').hidden = !m.canRebuy;
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
$('actions').hidden = alt || awarding || nexting;
if (raising && o && !alt) {
renderRaisePanel(o);
betSheet.open();
} else {
raising = false;
betSheet.close();
}
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';
}
// ---------- レビュー(レビュー付きCPU戦の作成者のみ) ----------
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
const STREET_LABEL = { PREFLOP: 'プリフロップ', FLOP: 'フロップ', TURN: 'ターン', RIVER: 'リバー' };
const STREET_SHORT = { PREFLOP: 'PF', FLOP: 'Flop', TURN: 'Turn', RIVER: 'River' };
const KIND_LABEL = { fold: 'フォールド', check: 'チェック', call: 'コール', raise: 'レイズ' };
const MARK_CLASS = { '◎': 'great', '○': 'good', '△': 'warn', '✕': 'bad' };
const optionLabel = (o) => (o.kind === 'raise' ? `ベット/レイズ ${chips(o.to)}` : KIND_LABEL[o.kind]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
const markHtml = (m) => `<span class="rv-mark ${MARK_CLASS[m] || ''}">${m}</span>`;
function reviewHtml(r) {
const l = r.last;
const h = l.hero;
const pre = l.preflop.length
? `<div class="rv-items">${l.preflop
.map(
(j) => `
<div class="rv-item">${markHtml(j.mark)}<div>
<div>${esc(j.situation)}</div>
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
${j.equity != null ? `<div class="rv-sub">推定勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div>`
)
.join('')}</div>`
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
const post = l.decisions
.map(
(d) => `
<div class="rv-item">${markHtml(d.mark)}<div>
<div><b>${STREET_LABEL[d.street]}</b> ${optionLabel(d.actual)} <span class="rv-score">${d.score}点</span></div>
<div class="rv-sub">推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${
d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択'
}</div>
</div></div>`
)
.join('');
const scores = STREETS.map((st) => {
const v = l.streetScores[st];
return `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b>${v == null ? '—' : v}</b></div>`;
}).join('');
const a = h.allIn;
const allIn = a
? `<div class="rv-section">オールイン</div>
<div class="rv-sub">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})</div>`
: '';
return `
<div class="rv-head">
<div class="rv-cards">${h.cards.map((c) => cardHtml(c)).join('')}</div>
<div>
<div class="rv-hand"><b>${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span></div>
<div class="rv-sub">${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}</div>
</div>
</div>
<div class="rv-streets">${scores}</div>
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div><div class="rv-items">${post}</div>` : ''}
${allIn}
<div class="rv-foot">このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)</div>`;
}
function renderReview(s) {
const r = s.review;
$('review-open').hidden = !(r && r.last);
if (!r || !r.last) {
reviewSheet.close();
return;
}
// 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブルIDを渡す
const back = `t=${encodeURIComponent(tableId)}`;
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}&${back}` : `/review?${back}`;
const waiting = Boolean(s.awaitingNext && s.isHost);
$('review-modal-next').hidden = !waiting;
const key = JSON.stringify(r);
if (key !== reviewKey) {
reviewKey = key;
$('review-body').innerHTML = reviewHtml(r);
}
// 新しいハンドのレビューが届いたら自動で開く
if (r.last.number !== seenReview) {
seenReview = r.last.number;
if (waiting) reviewSheet.open();
}
}
// 直前のハンドの状況を、AI に貼り付けて質問できる文章にしてコピーする
async function copyHandPrompt(r) {
if (!r || !r.sessionId || !r.last) return;
const url = `/api/reviews/${encodeURIComponent(r.sessionId)}/hands/${r.last.number}/prompt`;
try {
if (await App.copyText(() => App.api('GET', url).then((d) => d.text))) toast('コピーしました。AI のチャットに貼り付けて質問できます');
} catch (err) {
toast(err.message);
}
}
const openReview = () => reviewSheet.open();
$('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview);
$('review-close').addEventListener('click', () => reviewSheet.close());
const nextHand = () => {
reviewSheet.close();
emit('table:next');
};
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
$('next-hand').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand);
// ---------- ランキング(スタック順・収支順を切り替える) ----------
// スタックはまだ降りていない人がこのハンドで出した額を含む(ベットのたびに順位が揺れないように)。
// 収支は持っているチップ − バイインの合計(リバイを含む)。同じ値は同じ順位
const RANK_STORE = 'poker.rankBy';
let rankBy = 'stack';
try {
if (localStorage.getItem(RANK_STORE) === 'net') rankBy = 'net';
} catch { /* プライベートモード等 */ }
const rankRows = new Map(); // 席番号 → 行。並びが変わっても行は使い回し、元の位置から滑らせる
const RANK_NOTE = {
stack: 'ハンド中は、まだ降りていない人がこのハンドで出したチップも含めています。',
net: '収支 = 持っているチップ − バイインの合計(リバイを含む)',
};
// 収支を表示しない設定のときはスタック順だけ
const rankMode = () => (Store.showNet ? rankBy : 'stack');
function rankingEntries(s) {
const key = rankMode() === 'net' ? 'net' : 'held';
const list = s.seats.filter(Boolean).sort((a, b) => b[key] - a[key] || a.seat - b.seat);
let rank = 0;
return list.map((p, i) => {
if (i === 0 || p[key] !== list[i - 1][key]) rank = i + 1;
return { p, rank, value: p[key] };
});
}
// 並べ替えられる行(縦の位置をばねで動かす)
function movableRow(className) {
const el = document.createElement('li');
el.className = className;
el.y = new Fluid.Spring(0, { precision: 0.3 });
el.y.onUpdate = (v) => {
el.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
};
return el;
}
const rowTops = (rows) => new Map([...rows].map((el) => [el, el.getBoundingClientRect().top]));
// 並べ替えた行を、画面上の元の位置(before: 行 → 並べ替える前の top)からばねで新しい位置へ
function slideFrom(before) {
for (const [el, top] of before) {
if (!el.isConnected) continue;
const offset = top - (el.getBoundingClientRect().top - el.y.value);
if (Math.abs(offset - el.y.value) < 0.5) continue;
el.y.set(offset);
el.y.to(0, { damping: 0.86, response: 0.42 });
}
}
function rankRow(seat) {
let el = rankRows.get(seat);
if (el) return el;
el = movableRow('cell rank-row');
rankRows.set(seat, el);
return el;
}
function renderRanking(s) {
const listEl = $('ranking-list');
const empty = listEl.querySelector('.empty');
if (empty) empty.remove();
const before = rowTops(rankRows.values());
const live = new Set();
rankingEntries(s).forEach(({ p, rank, value }, i) => {
live.add(p.seat);
const el = rankRow(p.seat);
const sub = [p.seat === s.mySeat && 'あなた', p.bot && 'CPU', p.sittingOut && '離席中', !p.connected && 'オフライン']
.filter(Boolean)
.join(' ・ ');
const net = rankMode() === 'net';
patch(
el,
`<span class="rank-no${rank <= 3 ? ` top-${rank}` : ''}">${rank}</span>` +
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}${sub ? `<span class="cell-sub">${sub}</span>` : ''}</span>` +
`<span class="rank-value${net ? (value > 0 ? ' plus' : value < 0 ? ' minus' : '') : ''}">${net ? signed(value) : chips(value)}</span>`
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', p.seat === s.mySeat);
if (listEl.children[i] !== el) listEl.insertBefore(el, listEl.children[i] || null);
});
for (const [seat, el] of rankRows) {
if (live.has(seat)) continue;
el.remove();
rankRows.delete(seat);
}
slideFrom(before); // 順位が変わった行は元の位置から滑らせる
$('ranking-tabs').hidden = !Store.showNet;
$('ranking-note').textContent = RANK_NOTE[rankMode()];
if (!s.seats.some(Boolean)) listEl.innerHTML = '<li class="empty">まだ誰も座っていません</li>';
}
Fluid.Segmented($('ranking-tabs'));
function syncRankTabs() {
for (const b of $('ranking-tabs').querySelectorAll('[data-rank]')) {
const on = b.dataset.rank === rankBy;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', String(on));
}
}
syncRankTabs();
$('ranking-tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-rank]');
if (!b || b.dataset.rank === rankBy) return;
rankBy = b.dataset.rank;
try {
localStorage.setItem(RANK_STORE, rankBy);
} catch { /* プライベートモード等 */ }
syncRankTabs();
Fluid.haptic(6);
if (state) renderRanking(state);
});
$('ranking-open').addEventListener('click', () => {
if (!state) return;
renderRanking(state); // 中身を入れてから開く(シートの高さを測るため)
rankingSheet.open();
});
$('ranking-close').addEventListener('click', () => rankingSheet.close());
// ---------- 席順の変更(ホストのみ) ----------
// 席の並びは輪なので、自分を一番上に固定し、その下に左隣から時計回りの順で並べる(テーブルの画面と同じ向き)。
// 行は右端のつまみをつかむと指に付いてきて、ほかの行がよける。キーボードではつまみにフォーカスして ↑↓。
// ハンド中に決めた席順は次のハンドの始めにサーバが並べ替える
const HANDLE_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h14M5 12h14M5 16h14"/></svg>';
let seatMe = -1; // 自分の席(観戦中のホストは -1)
let seatOrder = []; // 自分以外の席番号(左隣から時計回り)
let seatNames = new Map(); // 席番号 → 名前(開いている間に席番号が振り直されたか確かめる)
let seatDrag = null;
const seatRows = new Map(); // 席番号 → 行
const previewDots = new Map(); // 席番号 → プレビューの点
const occupiedSeats = (s) => s.seats.filter(Boolean).map((p) => p.seat);
const seatingList = () => (seatMe >= 0 ? [seatMe] : []).concat(seatOrder);
// いまの並び(次のハンドからの席順が決めてあればそれ)を、自分から時計回りに読み込む
function loadSeating(s) {
const occupied = occupiedSeats(s);
const pending = s.pendingSeating;
const base = pending
? pending.filter((i) => occupied.includes(i)).concat(occupied.filter((i) => !pending.includes(i)))
: occupied;
const k = base.indexOf(s.mySeat);
seatMe = k >= 0 ? s.mySeat : -1;
seatOrder = k >= 0 ? base.slice(k + 1).concat(base.slice(0, k)) : base;
seatNames = new Map(occupied.map((i) => [i, s.seats[i].name]));
}
// 開いている間に座った人は最後に加え、いなくなった人は外す(並べた順は崩さない)。
// 次のハンドで席番号が振り直されたら読み込み直す
function syncSeating(s) {
if (!s.isHost) return seatingPopup.close();
if (seatDrag) return;
const occupied = occupiedSeats(s);
const renumbered =
(seatMe >= 0 && s.mySeat !== seatMe) || [...seatNames].some(([i, name]) => s.seats[i] && s.seats[i].name !== name);
if (renumbered) {
loadSeating(s);
toast('席が並べ替わったので、いまの席順を読み込み直しました');
} else {
const kept = seatOrder.filter((i) => occupied.includes(i));
const added = occupied.filter((i) => i !== seatMe && !kept.includes(i));
seatOrder = kept.concat(added);
for (const i of added) seatNames.set(i, s.seats[i].name);
}
renderSeating(rowTops(seatRows.values()));
}
function seatRow(seat) {
let el = seatRows.get(seat);
if (el) return el;
el = movableRow('cell seat-row');
el.dataset.seat = seat;
seatRows.set(seat, el);
return el;
}
function whereText(seat) {
if (seat === seatMe) return 'あなた';
const k = seatOrder.indexOf(seat);
const n = seatOrder.length;
if (seatMe < 0) return `${k + 1}番目`;
if (n === 1) return 'あなたの隣';
if (k === 0) return 'あなたの左隣';
if (k === n - 1) return 'あなたの右隣';
return `あなたから時計回りに${k + 1}人目`;
}
// before: 並べ替える前の行の位置(渡すと元の位置から滑らせる)
function renderSeating(before) {
const s = state;
const list = $('seating-list');
const seats = seatingList().filter((i) => s.seats[i]);
seats.forEach((seat, i) => {
const p = s.seats[seat];
const mine = seat === seatMe;
const sub = [whereText(seat), p.bot && 'CPU', p.sittingOut && '離席中'].filter(Boolean).join(' ・ ');
const el = seatRow(seat);
patch(
el,
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
`<span class="cell-title">${esc(p.name)}<span class="cell-sub">${sub}</span></span>` +
(mine
? '<span class="seat-pin">固定</span>'
: `<button type="button" class="seat-handle" aria-label="${esc(p.name)}の席を移動(↑↓キーで並べ替え)">${HANDLE_SVG}</button>`)
);
el.style.setProperty('--hue', hueOf(p.name));
el.classList.toggle('me', mine);
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
for (const [seat, el] of seatRows) {
if (seats.includes(seat)) continue;
el.remove();
seatRows.delete(seat);
}
if (before) slideFrom(before);
const note = $('seating-pending');
note.hidden = !s.hand && !s.pendingSeating;
note.textContent = s.hand
? 'ハンドの途中なので、次のハンドの始めに並べ替えます。'
: 'まだ反映していない席順があります。';
renderSeatingPreview(seats);
}
// 小さなテーブル: 自分が一番下で時計回り(テーブルの画面と同じ)。点は新しい位置へ滑る
function renderSeatingPreview(seats) {
const box = $('seating-preview');
const n = seats.length;
seats.forEach((seat, idx) => {
const p = state.seats[seat];
let dot = previewDots.get(seat);
if (!dot) {
dot = document.createElement('span');
dot.className = 'seating-dot';
dot.innerHTML = '<span class="rank-avatar"></span><span class="seating-name"></span>';
previewDots.set(seat, dot);
box.append(dot);
}
const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
dot.style.left = `${(50 + 40 * Math.cos(angle)).toFixed(2)}%`;
dot.style.top = `${(50 + 36 * Math.sin(angle)).toFixed(2)}%`;
dot.style.setProperty('--hue', hueOf(p.name));
dot.classList.toggle('me', seat === seatMe);
const face = dot.firstChild;
face.classList.toggle('bot', p.bot);
patch(face, p.bot ? BOT_SVG : esc(initialOf(p.name)));
dot.lastChild.textContent = seat === seatMe ? 'あなた' : p.name;
});
for (const [seat, dot] of previewDots) {
if (seats.includes(seat)) continue;
dot.remove();
previewDots.delete(seat);
}
}
function moveSeat(from, to) {
const before = rowTops(seatRows.values());
if (from !== to) {
const [seat] = seatOrder.splice(from, 1);
seatOrder.splice(to, 0, seat);
}
renderSeating(before);
}
const seatingListEl = $('seating-list');
seatingListEl.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.seat-handle');
if (!handle || seatDrag || (e.pointerType === 'mouse' && e.button !== 0)) return;
const row = handle.closest('[data-seat]');
const seat = Number(row.dataset.seat);
e.preventDefault();
handle.setPointerCapture(e.pointerId);
row.y.stop();
const from = seatOrder.indexOf(seat);
seatDrag = { id: e.pointerId, seat, row, from, to: from, y0: e.clientY - row.y.value, h: row.offsetHeight };
row.classList.add('dragging');
Fluid.haptic(8);
});
seatingListEl.addEventListener('pointermove', (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
const n = seatOrder.length;
// 端より先へはゴムのように抵抗する
const min = -d.from * d.h;
const max = (n - 1 - d.from) * d.h;
let dy = e.clientY - d.y0;
if (dy < min) dy = min - Fluid.rubberband(min - dy, d.h * 2);
else if (dy > max) dy = max + Fluid.rubberband(dy - max, d.h * 2);
d.row.y.set(dy);
const to = Fluid.clamp(Math.round(d.from + dy / d.h), 0, n - 1);
if (to !== d.to) {
d.to = to;
Fluid.haptic(6);
}
// 間の行は1行ぶんよける
seatOrder.forEach((seat, j) => {
if (seat === d.seat) return;
const shift = d.from < j && j <= d.to ? -d.h : d.to <= j && j < d.from ? d.h : 0;
const el = seatRows.get(seat);
if (el && el.y.target !== shift) el.y.to(shift, { damping: 1, response: 0.28 });
});
});
const dropSeat = (e) => {
const d = seatDrag;
if (!d || e.pointerId !== d.id) return;
seatDrag = null;
d.row.classList.remove('dragging');
moveSeat(d.from, e.type === 'pointercancel' ? d.from : d.to);
};
seatingListEl.addEventListener('pointerup', dropSeat);
seatingListEl.addEventListener('pointercancel', dropSeat);
seatingListEl.addEventListener('keydown', (e) => {
const handle = e.target.closest('.seat-handle');
const dir = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
if (!handle || !dir) return;
e.preventDefault();
const seat = Number(handle.closest('[data-seat]').dataset.seat);
const from = seatOrder.indexOf(seat);
if (from + dir < 0 || from + dir >= seatOrder.length) return;
moveSeat(from, from + dir);
const again = seatRows.get(seat).querySelector('.seat-handle');
if (again) again.focus();
});
$('seating-shuffle').addEventListener('click', () => {
const before = rowTops(seatRows.values());
const r = new Uint32Array(seatOrder.length);
crypto.getRandomValues(r);
for (let i = seatOrder.length - 1; i > 0; i--) {
const j = r[i] % (i + 1);
[seatOrder[i], seatOrder[j]] = [seatOrder[j], seatOrder[i]];
}
Fluid.haptic(10);
renderSeating(before);
});
$('seating-open').addEventListener('click', () => {
if (!state || !state.isHost) return;
loadSeating(state);
renderSeating(null);
seatingPopup.open();
});
$('seating-cancel').addEventListener('click', () => seatingPopup.close());
$('seating-save').addEventListener('click', () => {
if (!socket || !state) return;
const later = Boolean(state.hand); // ハンドがある間(結果の表示中を含む)は次のハンドから
const btn = $('seating-save');
btn.disabled = true;
socket.emit('table:seating', { order: seatingList() }, (res) => {
btn.disabled = false;
if (!res || !res.ok) {
toast((res && res.error) || '席順を変更できませんでした');
if (state) syncSeating(state);
return;
}
seatingPopup.close();
toast(later ? '次のハンドから新しい席順になります' : '席順を変更しました');
});
});
// ---------- 勝者選択 ----------
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 : []));
}
patch(
$('award-pots'),
pots
.map((p, i) => {
const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
const names = p.eligible
.map((seat) => {
const on = awardSel[i].has(seat);
return `<button type="button" class="pick${on ? ' on' : ''}" aria-pressed="${on}" data-pot="${i}" data-seat="${seat}">${CHECK_SVG}${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', async () => {
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で確定しますか?';
const ok = await App.confirm({ title: '勝者の確定', message: msg, ok: '確定する' });
// 確認中に他の人が確定した・ハンドが終わった場合は送らない
if (!ok || pending || $('award-panel').hidden) return;
pending = true;
emit('table:award', { winners });
render();
});
// ---------- レイズ ----------
// 場面ごとの額の候補。プリフロップはオープン(BB の何倍)・3bet 以降(相手の額の何倍)、
// フロップ以降はベット(ポットの何割)・レイズ(相手のベットの何倍)。
// ポットの何割は「コールしたあとのポット」に対する額を上乗せする(ポットレイズと同じ考え方)。
// def は開いたときに選んでおく候補
function betContext(o) {
const h = state.hand;
const bb = state.bigBlind;
const cur = h.currentBet;
const potAfterCall = h.potTotal + o.toCall;
const pot = (f) => cur + potAfterCall * f;
const times = (x) => ({ label: `${x}x`, to: cur * x });
const potPreset = { label: 'Pot', to: pot(1) };
if (h.street === 'PREFLOP') {
const r = h.raises || 0;
if (r === 0) {
// リンプした人がいれば 1人につき 1BB 足す
const limpers = Object.entries(h.players).filter(
([seat, p]) => Number(seat) !== h.bbSeat && !p.folded && p.bet >= cur
).length;
return {
title: limpers ? 'アイソレート' : 'オープン',
note: limpers ? `リンプ ${limpers}人(+${limpers}BB)` : '',
presets: [2, 2.5, 3, 4].map((x) => ({ label: `${x + limpers}BB`, to: (x + limpers) * bb })).concat(potPreset),
def: limpers ? 2 : 1,
};
}
const xs = r === 1 ? [2.5, 3, 4] : r === 2 ? [2.2, 2.5, 3] : [2, 2.5];
return {
title: `${r + 2}bet`,
note: `相手 ${chips(cur)}`,
presets: xs.map(times).concat(potPreset),
def: 1,
};
}
if (o.isBet) {
return {
title: 'ベット',
note: '',
presets: [
{ label: '33%', to: pot(1 / 3) },
{ label: '50%', to: pot(1 / 2) },
{ label: '75%', to: pot(3 / 4) },
potPreset,
{ label: '150%', to: pot(1.5) },
],
def: 1,
};
}
return {
title: (h.raises || 0) >= 2 ? 'リレイズ' : 'レイズ',
note: `相手 ${chips(cur)}`,
presets: [2.5, 3, 4].map(times).concat(potPreset),
def: 1,
};
}
// 候補の額を出せる範囲に収める(SB 単位に丸めるとブラインドが小さいうちは 50% が 57% になったりするので 1 単位)。
// 最小に届かない候補は押せない。オールインを超える候補は最初の 1つだけ All-in として残す。
// 左から額の小さい順に並べる(Pot が 4BB より小さいこともある)。def は並べ替える前の番号
function betPresets(o, ctx) {
let allInShown = false;
return ctx.presets
.map((p, i) => ({ label: p.label, raw: Math.round(p.to), def: i === ctx.def }))
.sort((a, b) => a.raw - b.raw)
.map((p) => {
const to = clampRaise(o, p.raw);
const allIn = to >= o.maxRaiseTo;
const disabled = p.raw < o.minRaiseTo || (allIn && allInShown);
if (allIn) allInShown = true;
return { label: p.label, to, allIn, disabled, def: p.def };
});
}
function clampRaise(o, v) {
if (!Number.isFinite(v)) return o.minRaiseTo;
return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v)));
}
// ベット額のスライダー: つまみはつかんだ位置のまま指に付いてくる。溝を押せばそこへ飛ぶ。
// 端を越えて引っぱるとゴムのように抵抗し、離すと端へ戻る
const slider = $('raise-range');
const sliderThumb = slider.querySelector('.bet-thumb');
const sliderFill = slider.querySelector('.bet-fill');
const THUMB = 28;
const knob = new Fluid.Spring(0, { precision: 0.0005 }); // つまみの位置(0 = 最小、1 = オールイン)
const sliderTracker = new Fluid.VelocityTracker();
let sliderDrag = null;
function paintKnob(f) {
const w = slider.clientWidth;
if (!w) return;
const x = f * (w - THUMB);
sliderThumb.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
sliderFill.style.transform = `scaleX(${Fluid.clamp((x + THUMB / 2) / w, 0, 1).toFixed(4)})`;
}
knob.onUpdate = paintKnob;
if ('ResizeObserver' in window) new ResizeObserver(() => paintKnob(knob.value)).observe(slider);
// つまみの位置と額は 2乗の関係にする(スタックが深くても、よく使う小さい額を細かく選べるように)
const CURVE = 2;
const fracOf = (o, v) =>
o.maxRaiseTo > o.minRaiseTo ? Fluid.clamp((v - o.minRaiseTo) / (o.maxRaiseTo - o.minRaiseTo), 0, 1) ** (1 / CURVE) : 1;
// SB 単位に丸める(最大はオールイン額そのまま)
function valueAt(o, f) {
if (f >= 1) return o.maxRaiseTo;
const unit = Math.max(1, state.smallBlind);
const raw = o.minRaiseTo + f ** CURVE * (o.maxRaiseTo - o.minRaiseTo);
return clampRaise(o, o.minRaiseTo + Math.round((raw - o.minRaiseTo) / unit) * unit);
}
const formatBB = (x) => (Math.round(x * 10) / 10).toLocaleString('en-US');
function renderRaisePanel(o) {
raiseValue = clampRaise(o, raiseValue);
const amount = $('raise-amount');
if (document.activeElement !== amount) amount.value = raiseValue;
amount.min = o.minRaiseTo;
amount.max = o.maxRaiseTo;
slider.setAttribute('aria-valuemin', o.minRaiseTo);
slider.setAttribute('aria-valuemax', o.maxRaiseTo);
slider.setAttribute('aria-valuenow', raiseValue);
slider.setAttribute('aria-valuetext', chips(raiseValue));
// 指で動かしている間は指の位置を優先する。それ以外(プリセット・数字の入力)はばねで滑らせる
if (!sliderDrag) {
const f = fracOf(o, raiseValue);
if (knob.target !== f) knob.to(f, { damping: 1, response: 0.3 });
}
paintKnob(knob.value);
const h = state.hand;
const ctx = betContext(o);
$('bet-title').textContent = ctx.title;
$('bet-context').textContent = [`ポット ${chips(h.potTotal)}`, ctx.note].filter(Boolean).join(' · ');
const presets = betPresets(o, ctx);
patch(
$('presets'),
presets
.map(
(p) =>
`<button type="button" class="chip-btn${p.allIn ? ' allin' : ''}" data-to="${p.to}"${p.disabled ? ' disabled' : ''}>` +
`<b>${esc(p.label)}</b><span>${p.disabled ? '—' : p.allIn ? 'All-in' : chips(p.to)}</span></button>`
)
.join('')
);
// 同じ額の候補が 2つあっても光らせるのは 1つだけ
let lit = false;
for (const b of $('presets').children) {
const on = !lit && !b.disabled && Number(b.dataset.to) === raiseValue;
if (on) lit = true;
b.classList.toggle('active', on);
}
$('raise-min-amt').textContent = chips(o.minRaiseTo);
$('raise-max-amt').textContent = chips(o.maxRaiseTo);
$('raise-min').classList.toggle('active', raiseValue === o.minRaiseTo);
$('raise-max').classList.toggle('active', raiseValue === o.maxRaiseTo);
$('raise-minus').disabled = raiseValue <= o.minRaiseTo;
$('raise-plus').disabled = raiseValue >= o.maxRaiseTo;
$('raise-bb').textContent = state.bigBlind > 0 ? `${formatBB(raiseValue / state.bigBlind)} BB` : '';
// 相手の額の何倍か(オープン以外のプリフロップとレイズ)、ポットの何%か(ベット)
let ratio = '';
if (o.isBet) ratio = h.potTotal > 0 ? `ポットの ${Math.round((raiseValue / h.potTotal) * 100)}%` : '';
else if (h.street !== 'PREFLOP' || h.raises > 0) ratio = `${formatBB(raiseValue / h.currentBet)}x`;
$('raise-ratio').textContent = ratio;
const allIn = raiseValue >= o.maxRaiseTo;
$('raise-confirm').textContent = allIn
? `All-in ${chips(raiseValue)}`
: `${o.isBet ? 'Bet' : 'Raise to'} ${chips(raiseValue)}`;
$('raise-confirm').disabled = pending;
}
function setRaise(o, v) {
raiseValue = clampRaise(o, v);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
}
function dragKnob(e, animate) {
const d = sliderDrag;
const o = options();
if (!d || !o) return;
const span = d.width - THUMB;
let px = e.clientX - d.left - d.grab;
if (px < 0) px = -Fluid.rubberband(-px, span);
else if (px > span) px = span + Fluid.rubberband(px - span, span);
const f = span > 0 ? px / span : 1;
if (animate) knob.to(f, { damping: 1, response: 0.25 });
else knob.set(f);
const before = raiseValue;
raiseValue = valueAt(o, Fluid.clamp(f, 0, 1));
if (raiseValue === before) return;
// 端(最小・オールイン)に着いた瞬間だけ軽く振動する
if (raiseValue === o.maxRaiseTo || raiseValue === o.minRaiseTo) Fluid.haptic(8);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
}
slider.addEventListener('pointerdown', (e) => {
const o = options();
if (!o || (e.pointerType === 'mouse' && e.button !== 0)) return;
e.preventDefault();
slider.focus({ preventScroll: true });
slider.setPointerCapture(e.pointerId);
knob.stop();
const rect = slider.getBoundingClientRect();
const thumbLeft = rect.left + knob.value * (rect.width - THUMB);
const onThumb = e.clientX >= thumbLeft - 8 && e.clientX <= thumbLeft + THUMB + 8;
sliderDrag = {
id: e.pointerId,
left: rect.left,
width: rect.width,
grab: onThumb ? e.clientX - thumbLeft : THUMB / 2,
};
sliderTracker.reset();
sliderTracker.add(e.clientX, 0, e.timeStamp);
slider.classList.add('active');
if (!onThumb) dragKnob(e, true);
});
slider.addEventListener('pointermove', (e) => {
if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
sliderTracker.add(e.clientX, 0, e.timeStamp);
dragKnob(e, false);
});
const releaseKnob = (e) => {
if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
const span = sliderDrag.width - THUMB;
sliderDrag = null;
slider.classList.remove('active');
const o = options();
if (!o) return;
sliderTracker.add(e.clientX, 0, e.timeStamp);
// 端を越えていたら、離したときの速さを引き継いで端へ戻る
const out = knob.value < 0 || knob.value > 1;
const velocity = out && span > 0 ? sliderTracker.velocity().x / span : 0;
knob.to(fracOf(o, raiseValue), { damping: 1, response: 0.3, velocity });
};
slider.addEventListener('pointerup', releaseKnob);
slider.addEventListener('pointercancel', releaseKnob);
slider.addEventListener('keydown', (e) => {
const o = options();
if (!o) return;
const unit = Math.max(1, state.bigBlind);
const step = { ArrowRight: unit, ArrowUp: unit, ArrowLeft: -unit, ArrowDown: -unit, PageUp: unit * 5, PageDown: -unit * 5 }[e.key];
if (e.key === 'Home') setRaise(o, o.minRaiseTo);
else if (e.key === 'End') setRaise(o, o.maxRaiseTo);
else if (step) setRaise(o, raiseValue + step);
else return;
e.preventDefault();
});
const options = () => (state && state.me && state.me.options) || null;
$('act-fold').addEventListener('click', async () => {
const o = options();
if (!o) return;
if (o.canCheck) {
const ok = await App.confirm({
title: 'フォールドしますか?',
message: 'いまはチップを出さずにチェックできます。',
ok: 'フォールド',
cancel: 'やめる',
danger: true,
});
// 確認中に手番が過ぎていたら何もしない
if (!ok || !options()) 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); // オールインしかない
// 場面のよくある額(オープン 2.5BB・3bet 3x・ベット 50% など)を選んだ状態で開く
const ctx = betContext(o);
const presets = betPresets(o, ctx);
const def = presets.find((p) => p.def && !p.disabled) || presets.find((p) => !p.disabled);
raising = true;
raiseValue = def ? def.to : o.minRaiseTo;
knob.set(fracOf(o, raiseValue));
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-to]');
const o = options();
if (!b || b.disabled || !o) return;
Fluid.haptic(6);
setRaise(o, Number(b.dataset.to));
});
$('raise-min').addEventListener('click', () => {
const o = options();
if (o) setRaise(o, o.minRaiseTo);
});
$('raise-max').addEventListener('click', () => {
const o = options();
if (o) setRaise(o, o.maxRaiseTo);
});
// −/+: 1BB ずつ。押し続けると少し待ってから連続で、だんだん速く動く
function stepper(btn, dir) {
let timer = null;
let count = 0;
const step = () => {
const o = options();
if (!o) return stop();
const before = raiseValue;
setRaise(o, raiseValue + dir * Math.max(1, state.bigBlind));
if (raiseValue === before) return stop();
if (raiseValue === o.maxRaiseTo || raiseValue === o.minRaiseTo) Fluid.haptic(8);
};
const stop = () => {
clearTimeout(timer);
timer = null;
};
const repeat = () => {
step();
count++;
if (timer) timer = setTimeout(repeat, count < 6 ? 140 : count < 20 ? 70 : 35);
};
btn.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault(); // 長押しで文字選択・メニューが出ないように
step();
count = 0;
timer = setTimeout(repeat, 380);
});
for (const type of ['pointerup', 'pointerleave', 'pointercancel']) btn.addEventListener(type, stop);
// キーボード(Enter / Space)から押されたとき
btn.addEventListener('click', (e) => {
if (e.detail === 0) step();
});
}
stepper($('raise-minus'), -1);
stepper($('raise-plus'), 1);
$('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', async () => {
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
? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。'
: '退席するとチップは失われます。';
const ok = await App.confirm({ title: 'テーブルから退席しますか?', message: msg, ok: '退席する', danger: true });
if (!ok) 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('サイトのリンクをコピーしました');
});
// iOS Safari は CSS だけではページのスクロール・引っ張り(バウンス)を止められないので、
// 1本指のドラッグを止める。スライダーと、中身がはみ出しているシート・勝者選択は除く。
// 2本指(ピンチ拡大)はアクセシビリティのため止めない。
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length !== 1) return;
const t = e.target;
if (!t.closest) return;
if (t.closest('.bet-slider')) return;
const box = t.closest('.sheet-scroll') || t.closest('.award-pots') || t.closest('.copy-text');
if (box && box.scrollHeight > box.clientHeight) return;
if (e.cancelable) e.preventDefault();
},
{ passive: false }
);
ensureName();
})();