// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る // カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ (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 `${rank}${SUIT_SYMBOL[c[1]]}`; } // ショーダウンで勝った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}
このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)
`; } 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(); })();