// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る (function () { 'use strict'; const { Store, esc, chips, toast } = App; const $ = (id) => document.getElementById(id); const tableId = decodeURIComponent(location.pathname.split('/').pop()); const AVATAR_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 seenBoard = ''; // 新しくめくられたカードだけアニメーションする let seenHand = 0; let wasMyTurn = false; // ---------- 名前 ---------- 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, onDone) { 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(); } if (onDone) onDone(res); }); } 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(); } // ---------- カード ---------- 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] ? new Set(h.results[0].bestCards) : null; } // ---------- 描画 ---------- 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); $('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); $('hand-no').textContent = s.handNumber ? `#${s.handNumber}` : ''; renderBoard(h); renderResult(s); // ホスト操作 $('host-controls').hidden = !s.isHost; $('host-spacer').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 ? '再開' : '一時停止'; // 自分 $('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; if (h) { seenBoard = `${h.number}:${h.board.length}`; seenHand = h.number; } } function renderBoard(h) { const el = $('board'); 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 renderResult(s) { const el = $('result'); const h = s.hand; if (!h || !h.finished || !h.results) { el.hidden = true; return; } const nameOf = (seat) => (s.seats[seat] ? s.seats[seat].name : '退席者'); 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 best = winningCards(h); const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1)); const container = $('seats'); $('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示 container.innerHTML = ordered .map((p, idx) => { const classes = ['seat']; const isTurn = s.turn && s.turn.seat === p.seat; if (p.seat === s.mySeat) classes.push('me'); if (!p.connected) classes.push('offline'); if (p.inHand && p.folded) classes.push('folded'); if (isTurn) classes.push('turn'); if (h && h.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 (p.inHand && (!p.folded || p.cards)) { const deal = h.number !== seenHand ? 'deal' : ''; const list = p.cards || [null, null]; cards = `
${list .map((c) => cardHtml(c, [deal, best && c ? (best.has(c) ? 'hl' : 'dim') : ''].join(' '))) .join('')}
`; } // ベット(中央寄り)・獲得額 const below = Math.sin(angles[idx]) <= 0.35; let bet = ''; if (h && h.finished && p.won > 0) bet = `
+${chips(p.won)}
`; else if (p.bet > 0) bet = `
${chips(p.bet)}
`; let tag = ''; if (p.last && !(h && h.finished && p.won > 0)) { tag = `
${ACTION_LABEL[p.last.type] || ''}
`; } else if (p.sittingOut && !p.inHand) { tag = '
離席中
'; } return `
${AVATAR_SVG}${markers}${cards}
${tag}
${esc(p.name)}
${p.allIn && !(h && h.finished) ? 'ALL-IN' : chips(p.stack)}
${p.handName && p.seat !== s.mySeat ? `
${esc(p.handName)}
` : ''} ${p.connected ? '' : '
オフライン
'} ${isTurn ? '
' : ''}
${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-hand').textContent = seat.handName || ''; 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; // 状況 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 (h && !h.finished && s.turn) status = `${(s.seats[s.turn.seat] || {}).name || ''} の番です`; else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します'; else if (h && !h.finished && !s.turn) status = 'オールイン — 残りのカードをめくります'; else if (h && h.finished) status = '次のハンドを準備中…'; else if (!h) status = '参加者を待っています(2人以上で再開)'; $('status-line').textContent = status; $('status-line').classList.toggle('my-turn', Boolean(o)); // ボタン const alt = m.sittingOut || m.canRebuy; $('alt-actions').hidden = !alt; $('sit-in').hidden = !m.sittingOut; $('rebuy').hidden = !m.canRebuy; $('rebuy').textContent = `リバイ(${chips(s.startingStack)})`; if (!o) raising = false; $('actions').hidden = alt || raising; $('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 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; // BB 単位に丸める(最大はオールイン額そのまま) 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; const now = Date.now() + clockOffset; el.classList.toggle('paused', state.paused); if (!t.enabled) { el.textContent = state.paused ? '⏸' : ''; } else { const remaining = t.running ? t.endsAt - now : 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); } // 手番の残り時間 const turn = state.turn; const ratio = turn ? Math.max(0, Math.min(1, (turn.endsAt != null ? turn.endsAt - now : turn.remainingMs) / turn.totalMs)) : 0; const width = `${(ratio * 100).toFixed(1)}%`; const bar = document.querySelector('.seat.turn .seat-timer i'); if (bar) { bar.style.width = width; bar.classList.toggle('low', ratio < 0.3); } const mine = Boolean(turn && turn.seat === state.mySeat); $('turn-progress').hidden = !mine; if (mine) { $('turn-bar').style.width = width; $('turn-bar').classList.toggle('low', ratio < 0.3); } } setInterval(renderTimer, 200); // ---------- その他の操作 ---------- $('start').addEventListener('click', () => emit('table:start')); $('pause').addEventListener('click', () => emit('table:pause')); $('sit-out').addEventListener('click', () => emit('table:sitOut')); $('take-seat').addEventListener('click', () => join(true)); $('sit-in').addEventListener('click', () => emit('table:sitIn')); $('rebuy').addEventListener('click', () => emit('table:rebuy')); $('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('サイトのリンクをコピーしました'); }); ensureName(); })();