// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る // カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ // 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない) (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 seatingSheet = Fluid.Sheet($('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(); seatingSheet.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 `${rank}${SUIT_SYMBOL[c[1]]}`; } 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 (seatingSheet.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 = 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 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.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人以上で再開)'; 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); // ボタン 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 || awarding || reviewWaiting; 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}
このハンドの収支 ${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) { reviewSheet.close(); return; } // 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブルIDを渡す const back = `t=${encodeURIComponent(tableId)}`; $('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}&${back}` : `/review?${back}`; $('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) 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)); $('review-next').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: '収支 = 持っているチップ − バイインの合計(リバイを含む)', }; function rankingEntries(s) { const key = rankBy === '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 = rankBy === '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-note').textContent = RANK_NOTE[rankBy]; 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 seatingSheet.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); // 中身を入れてから開く(シートの高さを測るため) seatingSheet.open(); }); $('seating-cancel').addEventListener('click', () => seatingSheet.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; } seatingSheet.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(); })();