// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る // カードは実物で配るので、ここで扱うのはチップのやり取りだけ (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 ACTION_LABEL = { FOLD: 'Fold', CHECK: 'Check', CALL: 'Call', BET: 'Bet', RAISE: 'Raise', ALLIN: 'All-in' }; let socket = null; let state = null; let clockOffset = 0; // serverNow - clientNow let pending = false; // 送信中(連打防止) let raising = false; let raiseValue = 0; let wasMyTurn = false; // ショーダウンの勝者選択(ポットごとの席番号の Set) let awardKey = ''; let awardSel = []; // ---------- 名前 ---------- function ensureName() { if (Store.name) return connect(); $('name-modal').hidden = false; $('name-input').focus(); } $('name-form').addEventListener('submit', (e) => { e.preventDefault(); const name = $('name-input').value.trim(); if (!name) return $('name-input').classList.add('invalid'); Store.name = name; $('name-modal').hidden = true; connect(); }); // ---------- 通信 ---------- function connect() { socket = App.connectSocket(); socket.on('connect', () => { $('conn-lost').hidden = true; join(); }); socket.on('disconnect', () => { $('conn-lost').hidden = false; }); socket.on('connect_error', () => { $('conn-lost').hidden = false; }); socket.on('table:state', (s) => { state = s; pending = false; clockOffset = s.serverNow - Date.now(); render(); }); } function join(manual) { socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => { if (res && res.ok) { if (res.seat < 0 && manual) toast('空いている席がありません'); return; } if (res && res.error === 'NOT_FOUND') return showNotFound(); toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー')); }); } function emit(event, payload) { if (!socket) return; socket.emit(event, payload || null, (res) => { if (res && !res.ok) { pending = false; toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error); if (state) render(); } }); } function sendAction(type, amount) { if (pending) return; pending = true; raising = false; emit('table:action', { type, amount }); render(); } function showNotFound() { $('main').hidden = true; $('action-bar').hidden = true; $('not-found').hidden = false; if (socket) socket.disconnect(); } const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者'); // ---------- 描画 ---------- function render() { const s = state; const h = s.hand; const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null; const myTurn = Boolean(s.me && (s.me.options || (s.me.canAward && !s.paused))); $('main').hidden = false; document.title = `${myTurn ? '▶ ' : ''}${s.name} - Hold'em Night`; $('table-name').textContent = s.name; $('level').textContent = `Lv.${s.level}`; $('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`; $('next-blinds').textContent = s.timer.enabled ? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)` : 'ブラインドタイマーなし'; $('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`; // 中央 $('pot').textContent = chips(h ? h.potTotal : 0); $('phase').textContent = s.paused ? 'PAUSED' : App.PHASE_LABELS[s.phase] || s.phase; $('phase').parentElement.classList.toggle('paused', s.paused); $('phase').parentElement.classList.toggle('showdown', s.phase === 'SHOWDOWN' && !s.paused); $('hand-no').textContent = s.handNumber ? `#${s.handNumber}` : ''; renderResult(s); // ホスト操作 $('host-controls').hidden = !s.isHost; 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); renderTimer(); // 自分の番になったら知らせる(タップ前は振動できない) const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive; if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80); wasMyTurn = myTurn; } function renderResult(s) { const el = $('result'); const h = s.hand; if (!h || !h.finished || !h.results) { el.hidden = true; return; } el.innerHTML = h.results .map((r, i) => { const label = h.results.length > 1 ? `${i === 0 ? 'メイン' : 'サイド'}` : ''; const who = r.winners.map((w) => esc(nameOf(w))).join('・'); return `
${label}${who} +${chips(r.amount)}
`; }) .join(''); el.hidden = false; } // 着席者を楕円上に配置。自分が一番下、そこから時計回り。 function renderSeats(s) { const h = s.hand; const occupied = s.seats.filter(Boolean); const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat)); const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx)); const n = ordered.length; const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1)); const showdown = s.phase === 'SHOWDOWN'; const finished = Boolean(h && h.finished); const container = $('seats'); $('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示 container.innerHTML = ordered .map((p, idx) => { const classes = ['seat']; if (p.seat === s.mySeat) classes.push('me'); if (!p.connected) classes.push('offline'); if (p.inHand && p.folded) classes.push('folded'); if (s.toAct === p.seat) classes.push('turn'); if (showdown && p.inHand && !p.folded) classes.push('contender'); if (finished && p.won > 0) classes.push('winner'); if (p.sittingOut && !p.inHand) classes.push('away'); const markers = h ? [ p.seat === h.button ? 'D' : '', p.seat === h.sbSeat ? 'SB' : '', p.seat === h.bbSeat ? 'BB' : '', ].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 `
${AVATAR_SVG}${markers}
${tag}
${esc(p.name)}
${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}
${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)}` : ''; $('sit-out').hidden = m.sittingOut; // 状況 const awaiting = Boolean(h && h.pots && !h.finished); let status = ''; if (!s.started) status = s.isHost ? '2人以上揃ったら START を押してください' : 'ホストの開始を待っています'; else if (s.paused) status = '一時停止中'; else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します'; else if (m.canRebuy) status = 'チップがなくなりました'; else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`; else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます'; else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`; else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します'; else if (h && h.finished) status = '次のハンドを準備中…'; else if (!h) status = '参加者を待っています(2人以上で再開)'; $('status-line').textContent = status; $('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward)); // ショーダウンの勝者選択 const awarding = awaiting && m.canAward && !s.paused; $('award-panel').hidden = !awarding; if (awarding) renderAwardPanel(h.pots); // ボタン const alt = (m.sittingOut || m.canRebuy) && !awarding; if (!o) raising = false; $('alt-actions').hidden = !alt; $('sit-in').hidden = !m.sittingOut; $('rebuy').hidden = !m.canRebuy; $('rebuy').textContent = `リバイ(${chips(s.startingStack)})`; $('actions').hidden = alt || raising || awarding; $('raise-actions').hidden = alt || !raising; $('raise-panel').hidden = alt || !raising; $('status-line').hidden = raising; const fold = $('act-fold'); const call = $('act-call'); const raise = $('act-raise'); fold.disabled = call.disabled = raise.disabled = !o || pending; if (!o) { call.textContent = 'Check'; raise.textContent = 'Raise'; return; } call.textContent = o.canCheck ? 'Check' : o.toCall >= m.stack ? `All-in ${chips(o.toCall)}` : `Call ${chips(o.toCall)}`; raise.disabled = pending || !o.canRaise; raise.textContent = !o.canRaise ? o.isBet ? 'Bet' : 'Raise' : o.minRaiseTo >= o.maxRaiseTo ? `All-in ${chips(o.maxRaiseTo)}` : o.isBet ? 'Bet' : 'Raise'; if (raising) renderRaisePanel(o); } // ---------- 勝者選択 ---------- function renderAwardPanel(pots) { const key = `${state.handNumber}:${JSON.stringify(pots)}`; if (key !== awardKey) { awardKey = key; // 参加者が1人だけのポットは最初から選んでおく awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : [])); } $('award-pots').innerHTML = pots .map((p, i) => { const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`; const names = p.eligible .map( (seat) => `` ) .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'); if (bar && bar.scrollHeight > bar.clientHeight) return; if (e.cancelable) e.preventDefault(); }, { passive: false } ); ensureName(); })();