From b6ce648b5ac280cbc6e2824f89e3a9fa682131f3 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Wed, 21 Oct 2026 22:18:26 +0900 Subject: [PATCH] Rebuild bet sizing as a bottom sheet with situation-aware presets Bet/Raise now opens a sheet whose presets follow the spot: open sizes in BB (adjusted for limpers), 3bet/4bet multiples, postflop pot fractions and raise multiples. Adds -/+ steppers with hold-to-repeat, Min/All-in at the slider ends, and a square-curve slider so small sizes stay precise with deep stacks. The hand view now exposes the street's raise count so the client can tell a 3bet from a 4bet. Co-Authored-By: Claude Opus 5.5 --- public/assets/style.css | 214 ++++++++++++++++++++++++++++++++++------ public/assets/table.js | 208 +++++++++++++++++++++++++++++++++----- public/table.html | 66 ++++++++----- server/hand.js | 1 + 4 files changed, 412 insertions(+), 77 deletions(-) diff --git a/public/assets/style.css b/public/assets/style.css index e97fab7..574b79a 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -2342,52 +2342,74 @@ body.page-table { color: var(--label-3); } -/* レイズ額: レイズボタンから上に開き、閉じるときはボタンへ戻る */ -.raise-panel { - position: absolute; - left: 12px; - right: 12px; - bottom: calc(100% + 10px); - z-index: 6; - max-width: 496px; - margin: 0 auto; - padding: 14px 16px 14px; - border-radius: 24px; +/* ベット/レイズ額のシート: テーブル(ポット・相手のベット)が見えるよう背景は薄く暗くするだけ */ +.bet-backdrop::before { + background: rgba(0, 0, 0, 0.16); +} +.bet-sheet { + max-width: 520px; background: var(--material-thick); -webkit-backdrop-filter: blur(30px) saturate(180%); backdrop-filter: blur(30px) saturate(180%); - box-shadow: var(--shadow-l), inset 0 0 0 0.5px var(--material-edge); - transform-origin: 82% 100%; + box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.2), inset 0 0.5px 0 var(--material-edge); } -.raise-head { +.bet-sheet::after { + background: var(--material-thick); +} +.bet-header { + padding-bottom: 4px; +} +.bet-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; + text-align: left; +} +.bet-context { + min-width: 0; + overflow: hidden; + color: var(--label-2); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} +.bet-body { + padding: 4px 20px 2px; +} +.bet-amount-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .raise-value { display: flex; align-items: baseline; min-width: 0; font-family: var(--font-num); + cursor: text; } .raise-value .cur { color: var(--label-2); - font-size: 1.5rem; + font-size: 1.75rem; font-weight: 700; } .raise-amount { - width: 8ch; + width: 7.5ch; min-width: 0; padding: 0; border: 0; border-radius: 0; outline: none; background: transparent; + color: var(--label); font-family: var(--font-num); - font-size: 2.125rem; + font-size: 2.5rem; font-weight: 700; - letter-spacing: -0.022em; + line-height: 1.15; + letter-spacing: -0.025em; font-variant-numeric: tabular-nums; -moz-appearance: textfield; appearance: textfield; @@ -2401,17 +2423,131 @@ body.page-table { box-shadow: inset 0 -2px 0 var(--orange); } .raise-meta { + display: grid; flex: none; + justify-items: end; + gap: 1px; + font-variant-numeric: tabular-nums; +} +.raise-meta b { + font-size: 1.0625rem; + font-weight: 700; +} +.raise-meta span { color: var(--label-2); font-size: 0.8125rem; font-weight: 600; +} +.raise-meta span:empty { + display: none; +} + +/* 場面ごとの候補(3bet の 3x・ベットの 50% など)。上に倍率、下にその額 */ +.bet-presets { + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + gap: 6px; + margin-top: 12px; +} +.chip-btn { + -webkit-appearance: none; + appearance: none; + display: grid; + place-content: center; + gap: 1px; + min-width: 0; + min-height: 54px; + padding: 6px 2px; + border: 0; + border-radius: 14px; + background: var(--fill-2); + color: var(--label); + font: inherit; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, transform 0.45s var(--ease-spring); +} +.chip-btn b { + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: -0.01em; +} +.chip-btn span { + overflow: hidden; + color: var(--label-2); + font-size: 0.6875rem; + font-weight: 600; font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + transition: color 0.2s ease; +} +.chip-btn:active:not(:disabled) { + transform: scale(0.93); + transition: transform 0.1s ease-out; +} +.chip-btn.active { + background: var(--orange); + color: var(--on-orange); +} +.chip-btn.active span { + color: var(--on-orange); + opacity: 0.72; +} +.chip-btn:disabled { + color: var(--label-3); + cursor: default; +} +.chip-btn:disabled span { + color: var(--label-3); +} + +/* −/+ とスライダー */ +.bet-adjust { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} +.step-btn { + -webkit-appearance: none; + appearance: none; + display: grid; + place-items: center; + flex: none; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--fill-2); + color: var(--label); + cursor: pointer; + touch-action: manipulation; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + transition: transform 0.45s var(--ease-spring), opacity 0.2s ease; +} +.step-btn .i { + width: 18px; + height: 18px; + stroke-width: 2.6; +} +.step-btn:active:not(:disabled) { + transform: scale(0.88); + transition: transform 0.1s ease-out; +} +.step-btn:disabled { + opacity: 0.35; + cursor: default; } /* 自前のスライダー: つかんだ位置のまま指に付いてくる。端を越えるとゴムのように抵抗する */ .bet-slider { position: relative; + flex: 1; + min-width: 0; height: 44px; - margin: 4px 0 8px; outline: none; touch-action: none; cursor: pointer; @@ -2460,33 +2596,51 @@ body.page-table { .bet-slider:focus-visible .bet-thumb::before { box-shadow: 0 0 0 3px var(--blue), 0 3px 8px rgba(0, 0, 0, 0.2); } -.presets { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 6px; + +/* スライダーの両端: 最小とオールイン */ +.bet-ends { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 2px; } -.chip-btn { +.end-btn { -webkit-appearance: none; appearance: none; - min-height: 34px; - padding: 4px 0; + min-height: 30px; + padding: 4px 12px; border: 0; border-radius: 999px; - background: var(--fill-2); - color: var(--label); + background: transparent; + color: var(--label-2); + font: inherit; font-size: 0.8125rem; font-weight: 600; + font-variant-numeric: tabular-nums; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease, transform 0.45s var(--ease-spring); } -.chip-btn:active { +.end-btn span { + color: var(--label); +} +.end-btn:active { transform: scale(0.93); transition: transform 0.1s ease-out; } -.chip-btn.active { +.end-btn.active { + background: var(--fill-2); + color: var(--label); +} +.end-btn.allin.active { background: var(--orange); color: var(--on-orange); } +.end-btn.allin.active span { + color: inherit; +} +.bet-sheet .sheet-footer .btn { + font-variant-numeric: tabular-nums; +} /* ショーダウンの勝者選択(キャッシュオンリー) */ .award-panel { diff --git a/public/assets/table.js b/public/assets/table.js index ff12a60..02f4831 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -31,6 +31,15 @@ const reviewSheet = Fluid.Sheet($('review-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() { @@ -108,6 +117,8 @@ $('main').hidden = true; $('action-bar').hidden = true; reviewSheet.close(); + raising = false; + betSheet.close(); $('not-found').hidden = false; // 自分から切断するので「接続が切れました」は出さない if (socket) { @@ -246,6 +257,7 @@ renderSeats(s); if (me) renderActionBar(s, me); + else betSheet.close(); renderReview(s); renderTimer(); @@ -473,10 +485,14 @@ $('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; - // レイズ額のパネルはレイズボタンから出て、閉じるときはボタンへ戻る - Fluid.presence($('raise-panel'), !alt && raising, { scale: 0.86, y: 28 }); + $('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'); @@ -494,8 +510,6 @@ : o.minRaiseTo >= o.maxRaiseTo ? `All-in ${chips(o.maxRaiseTo)}` : o.isBet ? 'Bet' : 'Raise'; - - if (raising) renderRaisePanel(o); } // ---------- レビュー(レビュー付きCPU戦の作成者のみ) ---------- @@ -665,18 +679,78 @@ }); // ---------- レイズ ---------- - function presetValue(o, kind) { + // 場面ごとの額の候補。プリフロップはオープン(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 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); + 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) { @@ -704,12 +778,15 @@ knob.onUpdate = paintKnob; if ('ResizeObserver' in window) new ResizeObserver(() => paintKnob(knob.value)).observe(slider); - const fracOf = (o, v) => (o.maxRaiseTo > o.minRaiseTo ? (v - o.minRaiseTo) / (o.maxRaiseTo - o.minRaiseTo) : 1); + // つまみの位置と額は 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 * (o.maxRaiseTo - o.minRaiseTo); + 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'); @@ -730,10 +807,42 @@ 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) { - b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue); + const on = !lit && !b.disabled && Number(b.dataset.to) === raiseValue; + if (on) lit = true; + b.classList.toggle('active', on); } - $('raise-meta').textContent = state.bigBlind > 0 ? `${formatBB(raiseValue / state.bigBlind)} BB` : ''; + $('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)}` @@ -846,9 +955,13 @@ 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 = o.minRaiseTo; - knob.set(0); + raiseValue = def ? def.to : o.minRaiseTo; + knob.set(fracOf(o, raiseValue)); render(); }); $('raise-cancel').addEventListener('click', () => { @@ -860,10 +973,57 @@ if (o) sendAction('raise', clampRaise(o, raiseValue)); }); $('presets').addEventListener('click', (e) => { - const b = e.target.closest('[data-preset]'); + const b = e.target.closest('[data-to]'); const o = options(); - if (b && o) setRaise(o, presetValue(o, b.dataset.preset)); + 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); diff --git a/public/table.html b/public/table.html index 3adad62..242b9da 100644 --- a/public/table.html +++ b/public/table.html @@ -67,25 +67,6 @@