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 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-21 22:18:26 +09:00
1 parent ed5617a47a
commit b6ce648b5a
4 files changed
+412 -77

No files matched your search

+184 -30
View File
@@ -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 {
+184 -24
View File
@@ -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) =>
`<button type="button" class="chip-btn${p.allIn ? ' allin' : ''}" data-to="${p.to}"${p.disabled ? ' disabled' : ''}>` +
`<b>${esc(p.label)}</b><span>${p.disabled ? '—' : p.allIn ? 'All-in' : chips(p.to)}</span></button>`
)
.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);