// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
(function () {
'use strict';
const { Store, esc, chips, toast } = App;
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
const AVATAR_SVG =
'';
const BOT_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 seenBoard = ''; // 新しくめくられたカードだけアニメーションする
let seenHand = 0;
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
let reviewKey = '';
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
$('name-modal').hidden = false;
$('name-input').focus();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
if (!name) return $('name-input').classList.add('invalid');
Store.name = name;
$('name-modal').hidden = true;
connect();
});
// ---------- 通信 ----------
function connect() {
socket = App.connectSocket();
socket.on('connect', () => {
$('conn-lost').hidden = true;
join();
});
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
socket.on('table: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;
$('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 : '退席者');
// ---------- カード(カード機能ありのテーブルのみ) ----------
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``;
const rank = c[0] === 'T' ? '10' : c[0];
return ``;
}
// ショーダウンで勝った5枚(メインポット)
function winningCards(h) {
return h && h.finished && h.showdown && h.results && h.results[0] && h.results[0].bestCards
? new Set(h.results[0].bestCards)
: null;
}
function renderBoard(s) {
const el = $('board');
const h = s.hand;
el.hidden = !s.cardsEnabled;
if (!s.cardsEnabled) return;
if (!h) {
el.innerHTML = '';
return;
}
const [seenNo, seenLen] = seenBoard.split(':').map(Number);
const oldLen = seenNo === h.number ? seenLen : 0;
const best = winningCards(h);
let html = '';
for (let i = 0; i < 5; i++) {
const c = h.board[i];
if (!c) {
html += '';
continue;
}
const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
html += cardHtml(c, cls);
}
el.innerHTML = html;
}
// ---------- 描画 ----------
function render() {
const s = state;
const h = s.hand;
const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null;
const myTurn = Boolean(s.me && (s.me.options || (s.me.canAward && !s.paused)));
$('main').hidden = false;
document.title = `${myTurn ? '▶ ' : ''}${s.name} - Hold'em Night`;
$('table-name').textContent = s.name;
$('level').textContent = `Lv.${s.level}`;
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
$('next-blinds').textContent = s.timer.enabled
? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
: 'ブラインドタイマーなし';
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
// 中央
$('pot').textContent = chips(h ? h.potTotal : 0);
$('phase').textContent = s.paused ? 'PAUSED' : App.PHASE_LABELS[s.phase] || s.phase;
$('phase').parentElement.classList.toggle('paused', s.paused);
$('phase').parentElement.classList.toggle('showdown', s.phase === 'SHOWDOWN' && !s.paused);
$('hand-no').textContent = s.handNumber ? `#${s.handNumber}` : '';
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').textContent = s.paused ? '▶' : '⏸';
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
// 自分
$('action-bar').hidden = !me;
$('spectator-note').hidden = Boolean(me);
const hasFreeSeat = s.seats.some((p) => !p);
$('take-seat').hidden = Boolean(me) || !hasFreeSeat;
$('spectator-text').textContent = hasFreeSeat ? '観戦中です' : '満席のため観戦中です';
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
renderSeats(s);
if (me) renderActionBar(s, me);
renderReview(s);
renderTimer();
// 自分の番になったら知らせる(タップ前は振動できない)
const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80);
wasMyTurn = myTurn;
if (h) {
seenBoard = `${h.number}:${h.board.length}`;
seenHand = h.number;
}
}
function renderResult(s) {
const el = $('result');
const h = s.hand;
if (!h || !h.finished || !h.results) {
el.hidden = true;
return;
}
el.innerHTML = h.results
.map((r, i) => {
const label = h.results.length > 1 ? `${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;
}
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
function renderSeats(s) {
const h = s.hand;
const occupied = s.seats.filter(Boolean);
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
const n = ordered.length;
const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1));
const showdown = s.phase === 'SHOWDOWN';
const finished = Boolean(h && h.finished);
const best = winningCards(h);
const container = $('seats');
$('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
$('felt-area').classList.toggle('with-cards', s.cardsEnabled);
container.innerHTML = ordered
.map((p, idx) => {
const classes = ['seat'];
if (p.seat === s.mySeat) classes.push('me');
if (p.bot) classes.push('bot');
if (!p.connected) classes.push('offline');
if (p.inHand && p.folded) classes.push('folded');
if (s.toAct === p.seat) classes.push('turn');
if (showdown && p.inHand && !p.folded) classes.push('contender');
if (finished && p.won > 0) classes.push('winner');
if (p.sittingOut && !p.inHand) classes.push('away');
const markers = h
? [
p.seat === h.button ? 'D' : '',
p.seat === h.sbSeat ? 'SB' : '',
p.seat === h.bbSeat ? 'BB' : '',
].join('')
: '';
// 手札(自分と公開された人は表、それ以外は裏)
let cards = '';
if (s.cardsEnabled && p.inHand && (!p.folded || p.cards)) {
const deal = h.number !== seenHand ? 'deal' : '';
const list = p.cards || [null, null];
const mark = (c) => (best && c ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : '');
cards = `${list.map((c) => cardHtml(c, `${deal} ${mark(c)}`)).join('')}
`;
}
// ベット(中央寄り)・獲得額
const below = Math.sin(angles[idx]) <= 0.35;
let bet = '';
if (finished && p.won > 0) bet = `+${chips(p.won)}
`;
else if (p.bet > 0) bet = `${chips(p.bet)}
`;
let tag = '';
if (p.last && !(finished && p.won > 0)) {
tag = `${ACTION_LABEL[p.last.type] || ''}
`;
} else if (p.sittingOut && !p.inHand) {
tag = '離席中
';
}
return `
${p.bot ? BOT_SVG : AVATAR_SVG}${markers}${cards}
${tag}
${esc(p.name)}
${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}
${p.handName && p.seat !== s.mySeat ? `
${esc(p.handName)}
` : ''}
${p.connected ? '' : '
オフライン
'}
${bet}
`;
})
.join('');
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
[...container.children].forEach((el, i) => {
el.style.left = `${(50 + 38 * Math.cos(angles[i])).toFixed(2)}%`;
el.style.top = `${(50 + 41 * Math.sin(angles[i])).toFixed(2)}%`;
});
}
function renderActionBar(s, seat) {
const m = s.me;
const h = s.hand;
const o = m.options;
$('my-stack').textContent = chips(m.stack);
const net = m.net;
$('my-net').textContent = `収支 ${net > 0 ? '+' : net < 0 ? '−' : '±'}${chips(Math.abs(net))}`;
$('my-net').className = net > 0 ? 'plus' : net < 0 ? 'minus' : '';
$('my-bet').textContent = seat.inHand && seat.bet > 0 ? `ベット中 ${chips(seat.bet)}` : '';
$('my-hand').textContent = (seat.inHand && seat.handName) || '';
$('my-cards').hidden = !s.cardsEnabled;
if (s.cardsEnabled) {
const myCards = seat.inHand && seat.cards ? seat.cards : null;
const best = winningCards(h);
$('my-cards').innerHTML = myCards
? myCards.map((c) => cardHtml(c, [seat.folded ? 'dim' : '', best && best.has(c) ? 'hl' : ''].join(' '))).join('')
: '';
}
$('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.review && s.review.waiting) status = 'レビューを確認したら「次のハンドへ」を押してください';
else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します';
else if (m.canRebuy) status = 'チップがなくなりました';
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます';
else if (h && !h.finished && s.toAct != null) {
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人以上で再開)';
$('status-line').textContent = status;
$('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward));
// ショーダウンの勝者選択
const awarding = awaiting && m.canAward && !s.paused;
$('award-panel').hidden = !awarding;
if (awarding) renderAwardPanel(h.pots);
// ボタン
const reviewWaiting = Boolean(s.review && s.review.waiting);
const alt = (m.sittingOut || m.canRebuy) && !awarding && !reviewWaiting;
if (!o) raising = false;
$('review-actions').hidden = !reviewWaiting;
$('alt-actions').hidden = !alt;
$('sit-in').hidden = !m.sittingOut;
$('rebuy').hidden = !m.canRebuy;
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
$('actions').hidden = alt || raising || awarding || reviewWaiting;
$('raise-actions').hidden = alt || !raising;
$('raise-panel').hidden = alt || !raising;
$('status-line').hidden = raising;
const fold = $('act-fold');
const call = $('act-call');
const raise = $('act-raise');
fold.disabled = call.disabled = raise.disabled = !o || pending;
if (!o) {
call.textContent = 'Check';
raise.textContent = 'Raise';
return;
}
call.textContent = o.canCheck ? 'Check' : o.toCall >= m.stack ? `All-in ${chips(o.toCall)}` : `Call ${chips(o.toCall)}`;
raise.disabled = pending || !o.canRaise;
raise.textContent = !o.canRaise
? o.isBet ? 'Bet' : 'Raise'
: o.minRaiseTo >= o.maxRaiseTo
? `All-in ${chips(o.maxRaiseTo)}`
: o.isBet ? 'Bet' : 'Raise';
if (raising) renderRaisePanel(o);
}
// ---------- レビュー(レビュー付き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) {
$('review-modal').hidden = true;
return;
}
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
$('review-modal-next').hidden = !r.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 (r.waiting) $('review-modal').hidden = false;
}
}
const openReview = () => { $('review-modal').hidden = false; };
$('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview);
$('review-close').addEventListener('click', () => { $('review-modal').hidden = true; });
$('review-modal').addEventListener('click', (e) => {
if (e.target === $('review-modal')) $('review-modal').hidden = true;
});
const nextHand = () => {
$('review-modal').hidden = true;
emit('table:next');
};
$('review-next').addEventListener('click', nextHand);
$('review-modal-next').addEventListener('click', nextHand);
// ---------- 勝者選択 ----------
function renderAwardPanel(pots) {
const key = `${state.handNumber}:${JSON.stringify(pots)}`;
if (key !== awardKey) {
awardKey = key;
// 参加者が1人だけのポットは最初から選んでおく
awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : []));
}
$('award-pots').innerHTML = pots
.map((p, i) => {
const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
const names = p.eligible
.map(
(seat) =>
``
)
.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', () => {
if (pending || !awardSel.every((set) => set.size > 0)) return;
const winners = awardSel.map((set) => [...set]);
const names = winners.map((w) => w.map(nameOf).join('・'));
const msg =
names.length === 1
? `${names[0]} の勝ちで確定しますか?`
: names.map((n, i) => `${i === 0 ? 'メイン' : 'サイド'}: ${n}`).join('\n') + '\nで確定しますか?';
if (!confirm(msg)) return;
pending = true;
emit('table:award', { winners });
render();
});
// ---------- レイズ ----------
function presetValue(o, kind) {
const h = state.hand;
const potAfterCall = h.potTotal + o.toCall;
const base = h.currentBet;
const v = {
min: o.minRaiseTo,
half: base + Math.round(potAfterCall / 2),
threeq: base + Math.round((potAfterCall * 3) / 4),
pot: base + potAfterCall,
max: o.maxRaiseTo,
}[kind];
return clampRaise(o, v);
}
function clampRaise(o, v) {
if (!Number.isFinite(v)) return o.minRaiseTo;
return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v)));
}
function renderRaisePanel(o) {
raiseValue = clampRaise(o, raiseValue);
const range = $('raise-range');
range.min = o.minRaiseTo;
range.max = o.maxRaiseTo;
range.step = 1;
range.value = raiseValue;
const amount = $('raise-amount');
if (document.activeElement !== amount) amount.value = raiseValue;
amount.min = o.minRaiseTo;
amount.max = o.maxRaiseTo;
for (const b of $('presets').children) {
b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue);
}
const allIn = raiseValue >= o.maxRaiseTo;
$('raise-confirm').textContent = allIn
? `All-in ${chips(raiseValue)}`
: `${o.isBet ? 'Bet' : 'Raise to'} ${chips(raiseValue)}`;
$('raise-confirm').disabled = pending;
}
const options = () => (state && state.me && state.me.options) || null;
$('act-fold').addEventListener('click', () => {
const o = options();
if (!o) return;
if (o.canCheck && !confirm('チェックできます。フォールドしますか?')) return;
sendAction('fold');
});
$('act-call').addEventListener('click', () => {
const o = options();
if (o) sendAction(o.canCheck ? 'check' : 'call');
});
$('act-raise').addEventListener('click', () => {
const o = options();
if (!o || !o.canRaise) return;
if (o.minRaiseTo >= o.maxRaiseTo) return sendAction('raise', o.maxRaiseTo); // オールインしかない
raising = true;
raiseValue = o.minRaiseTo;
render();
});
$('raise-cancel').addEventListener('click', () => {
raising = false;
render();
});
$('raise-confirm').addEventListener('click', () => {
const o = options();
if (o) sendAction('raise', clampRaise(o, raiseValue));
});
$('presets').addEventListener('click', (e) => {
const b = e.target.closest('[data-preset]');
const o = options();
if (!b || !o) return;
raiseValue = presetValue(o, b.dataset.preset);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
});
$('raise-range').addEventListener('input', (e) => {
const o = options();
if (!o) return;
// SB 単位に丸める(最大はオールイン額そのまま)
const unit = Math.max(1, state.smallBlind);
let v = Number(e.target.value);
if (v < o.maxRaiseTo) v = o.minRaiseTo + Math.round((v - o.minRaiseTo) / unit) * unit;
raiseValue = clampRaise(o, v);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
});
$('raise-amount').addEventListener('input', (e) => {
const o = options();
const v = Number(e.target.value);
if (!o || !Number.isFinite(v) || v <= 0) return;
raiseValue = clampRaise(o, v);
renderRaisePanel(o);
});
$('raise-amount').addEventListener('blur', () => {
const o = options();
if (o) $('raise-amount').value = clampRaise(o, raiseValue);
});
// ---------- ブラインドタイマー ----------
function renderTimer() {
if (!state) return;
const el = $('timer');
const t = state.timer;
el.classList.toggle('paused', state.paused);
if (!t.enabled) {
el.textContent = state.paused ? '⏸' : '';
return;
}
const remaining = t.running ? t.endsAt - (Date.now() + clockOffset) : t.remainingMs;
const sec = Math.max(0, Math.ceil(remaining / 1000));
const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
el.textContent = state.paused ? `⏸ ${time}` : time;
el.classList.toggle('warn', t.running && sec <= 60);
}
setInterval(renderTimer, 250);
// ---------- その他の操作 ----------
$('start').addEventListener('click', () => emit('table:start'));
$('pause').addEventListener('click', () => emit('table:pause'));
$('sit-out').addEventListener('click', () => emit('table:sitOut'));
$('sit-in').addEventListener('click', () => emit('table:sitIn'));
$('rebuy').addEventListener('click', () => emit('table:rebuy'));
$('take-seat').addEventListener('click', () => join(true));
$('leave').addEventListener('click', () => {
const me = state && state.mySeat >= 0 ? state.seats[state.mySeat] : null;
if (!me) return (location.href = '/');
const inHand = state.hand && !state.hand.finished && me.inHand && !me.folded;
const msg = inHand
? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。退席しますか?'
: 'テーブルから退席しますか?(チップは失われます)';
if (!confirm(msg)) return;
socket.emit('table:leave', null, () => { location.href = '/'; });
});
$('share').addEventListener('click', async () => {
const url = location.origin + '/';
try {
await navigator.clipboard.writeText(url);
} catch {
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
toast('サイトのリンクをコピーしました');
});
// iOS Safari は CSS だけではページのスクロール・引っ張り(バウンス)を止められないので、
// 1本指のドラッグを止める。スライダーと、中身がはみ出している操作エリアは除く。
// 2本指(ピンチ拡大)はアクセシビリティのため止めない。
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length !== 1) return;
const t = e.target;
if (t.closest && t.closest('input[type="range"]')) return;
const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal'));
if (bar && bar.scrollHeight > bar.clientHeight) return;
if (e.cancelable) e.preventDefault();
},
{ passive: false }
);
ensureName();
})();