// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
// 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない)
(function () {
'use strict';
const { Store, esc, chips, toast } = App;
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
const BOT_SVG =
'';
const CHECK_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 ``;
const rank = c[0] === 'T' ? '10' : c[0];
return ``;
}
function cardNode(c) {
const t = document.createElement('template');
t.innerHTML = c ? cardHtml(c) : '';
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 = ''.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 ? `${i === 0 ? 'メイン' : 'サイド'}` : '';
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
const hand = r.handName ? ` ${esc(r.handName)}` : '';
return `
${label}${who} +${chips(r.amount)}${hand}
`;
})
.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 =
'
' +
'
' +
'';
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 : `${esc(initialOf(p.name))}`);
q.avatar.style.setProperty('--hue', hueOf(p.name));
patch(
q.markers,
h
? [
p.seat === h.button ? 'D' : '',
p.seat === h.sbSeat ? 'SB' : '',
p.seat === h.bbSeat ? 'BB' : '',
].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 = `${ACTION_LABEL[p.last.type] || ''}`;
} else if (p.sittingOut && !p.inHand) {
tag = '離席中';
}
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 = `+${chips(p.won)}`;
else if (p.bet > 0) bet = `${chips(p.bet)}`;
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('')
: '';
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) => `${m}`;
function reviewHtml(r) {
const l = r.last;
const h = l.hero;
const pre = l.preflop.length
? `${l.preflop
.map(
(j) => `
${markHtml(j.mark)}
${esc(j.situation)}
推奨 ${KIND_LABEL[j.recommended]} / あなた ${KIND_LABEL[j.actual]} — ${esc(j.label)}
${j.equity != null ? `
推定勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''}
`
)
.join('')}
`
: 'プリフロップで判断する場面はありませんでした
';
const post = l.decisions
.map(
(d) => `
${markHtml(d.mark)}
${STREET_LABEL[d.street]} ${optionLabel(d.actual)} ${d.score}点
推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${
d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択'
}
`
)
.join('');
const scores = STREETS.map((st) => {
const v = l.streetScores[st];
return `${STREET_SHORT[st]}${v == null ? '—' : v}
`;
}).join('');
const a = h.allIn;
const allIn = a
? `オールイン
${STREET_LABEL[a.street]}でオールインした時点の勝率 ${a.equity}% → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})
`
: '';
return `
${h.cards.map((c) => cardHtml(c)).join('')}
${esc(h.key)} ${esc(h.colorName)}
${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}
${scores}
プリフロップ(ハンドレンジ)
${pre}
${post ? `フロップ以降(期待値の目安)
${post}
` : ''}
${allIn}
`;
}
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,
`${rank}` +
`${p.bot ? BOT_SVG : esc(initialOf(p.name))}` +
`${esc(p.name)}${sub ? `${sub}` : ''}` +
`${net ? signed(value) : chips(value)}`
);
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 = 'まだ誰も座っていません';
}
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 = '';
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,
`${p.bot ? BOT_SVG : esc(initialOf(p.name))}` +
`${esc(p.name)}${sub}` +
(mine
? '固定'
: ``)
);
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 = '';
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 ``;
})
.join('');
return `${title} ${chips(p.amount)}
${names}
`;
})
.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) =>
``
)
.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();
})();