Rebuild the UI in an Apple style with spring-driven motion

Replace the look of every page (key, lobby, create, table, range, review)
with light/dark semantic colors, translucent materials, iOS-style grouped
lists and system typography. Add fluid.js, a small spring engine with
interruptible, velocity-aware animations, and use it for draggable bottom
sheets, sliding segmented controls, a custom bet slider with rubber-banding,
swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are
now reused between renders so animations are not cut off. Honors reduced
motion, reduced transparency and increased contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-21 21:44:21 +09:00
1 parent d5ef2d310f
commit c3fbfdd12e
18 files changed
+4877 -1278

No files matched your search

+357 -150
View File
@@ -1,5 +1,6 @@
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
// 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない)
(function () {
'use strict';
@@ -7,10 +8,9 @@
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
const AVATAR_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></svg>';
const BOT_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="11" y="1.5" width="2" height="3.5" rx="1"/><circle cx="12" cy="1.8" r="1.6"/><path d="M5 7.5A2.5 2.5 0 0 1 7.5 5h9A2.5 2.5 0 0 1 19 7.5v7a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 14.5zM9 9.2a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zm6 0a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zM2 9.5h2v4H2zm18 0h2v4h-2zM7 18.5h10a3 3 0 0 1 3 3V23H4v-1.5a3 3 0 0 1 3-3z"/></svg>';
const CHECK_SVG = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></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' };
@@ -22,26 +22,31 @@
let raising = false;
let raiseValue = 0;
let wasMyTurn = false;
let seenBoard = ''; // 新しくめくられたカードだけアニメーションする
let seenHand = 0;
let seenHand = 0; // 新しいハンドの手札だけ配る動きを付ける
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
let reviewKey = '';
const reviewSheet = Fluid.Sheet($('review-modal'));
const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
$('name-modal').hidden = false;
$('name-input').focus();
nameSheet.open();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
if (!name) return $('name-input').classList.add('invalid');
if (!name) {
$('name-input').classList.add('invalid');
Fluid.shake($('name-input'));
return;
}
Store.name = name;
$('name-modal').hidden = true;
nameSheet.close();
connect();
});
@@ -102,6 +107,7 @@
}
$('main').hidden = true;
$('action-bar').hidden = true;
reviewSheet.close();
$('not-found').hidden = false;
// 自分から切断するので「接続が切れました」は出さない
if (socket) {
@@ -114,6 +120,14 @@
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 `<span class="pcard back ${extra}"></span>`;
@@ -121,6 +135,30 @@
return `<span class="pcard suit-${c[1]} ${extra}"><b>${rank}</b><i>${SUIT_SYMBOL[c[1]]}</i></span>`;
}
function cardNode(c) {
const t = document.createElement('template');
t.innerHTML = c ? cardHtml(c) : '<span class="pcard slot"></span>';
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
@@ -128,29 +166,34 @@
: null;
}
// 場札: 5つの枠を使い回し、新しく出たカードだけめくる(フロップは少しずつずらして)
function renderBoard(s) {
const el = $('board');
const h = s.hand;
el.hidden = !s.cardsEnabled;
if (!s.cardsEnabled) return;
if (!h) {
el.innerHTML = '';
return;
if (!el.slots) {
el.innerHTML = '<span class="pcard slot"></span>'.repeat(5);
el.slots = ['', '', '', '', ''];
}
const [seenNo, seenLen] = seenBoard.split(':').map(Number);
const oldLen = seenNo === h.number ? seenLen : 0;
const best = winningCards(h);
let html = '';
let fresh = 0;
for (let i = 0; i < 5; i++) {
const c = h.board[i];
if (!c) {
html += '<span class="pcard slot"></span>';
continue;
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 cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
html += cardHtml(c, cls);
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');
}
el.innerHTML = html;
}
// ---------- 描画 ----------
@@ -165,14 +208,14 @@
$('table-name').textContent = s.name;
$('level').textContent = `Lv.${s.level}`;
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
$('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}分毎)`
? `次 ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
: 'ブラインドタイマーなし';
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
// 中央
$('pot').textContent = chips(h ? h.potTotal : 0);
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);
@@ -187,7 +230,6 @@
$('start').disabled = eligible < 2;
$('start').title = eligible < 2 ? '2人以上で開始できます' : '';
$('pause').hidden = !s.started;
$('pause').textContent = s.paused ? '▶' : '⏸';
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
@@ -199,20 +241,18 @@
$('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);
renderReview(s);
renderTimer();
// 自分の番になったら知らせる(タップ前は振動できない)
const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80);
// 自分の番になったら知らせる
if (myTurn && !wasMyTurn) Fluid.haptic(80);
wasMyTurn = myTurn;
if (h) {
seenBoard = `${h.number}:${h.board.length}`;
seenHand = h.number;
}
if (h) seenHand = h.number;
}
function renderResult(s) {
@@ -222,17 +262,60 @@
el.hidden = true;
return;
}
el.innerHTML = h.results
.map((r, i) => {
const label = h.results.length > 1 ? `<span class="pot-tag">${i === 0 ? 'メイン' : 'サイド'}</span>` : '';
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
const hand = r.handName ? ` <span class="rhand">${esc(r.handName)}</span>` : '';
return `<div>${label}<b>${who}</b> +${chips(r.amount)}${hand}</div>`;
})
.join('');
patch(
el,
h.results
.map((r, i) => {
const label = h.results.length > 1 ? `<span class="pot-tag">${i === 0 ? 'メイン' : 'サイド'}</span>` : '';
const who = r.winners.map((w) => esc(nameOf(w))).join('・');
const hand = r.handName ? ` <span class="rhand">${esc(r.handName)}</span>` : '';
return `<div>${label}<b>${who}</b> +${chips(r.amount)}${hand}</div>`;
})
.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 =
'<div class="avatar"><span class="face"></span><span class="markers"></span><span class="hole"></span></div>' +
'<div class="nameplate"><span class="tag-slot"></span><span class="pname"></span><span class="pstack"></span><span class="pnote"></span></div>' +
'<span class="bet-slot"></span>';
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;
@@ -240,84 +323,98 @@
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
const n = ordered.length;
const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1));
const showdown = s.phase === 'SHOWDOWN';
const finished = Boolean(h && h.finished);
const 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();
const container = $('seats');
$('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
$('felt-area').classList.toggle('with-cards', s.cardsEnabled);
container.innerHTML = ordered
.map((p, idx) => {
const classes = ['seat'];
if (p.seat === s.mySeat) classes.push('me');
if (p.bot) classes.push('bot');
if (!p.connected) classes.push('offline');
if (p.inHand && p.folded) classes.push('folded');
if (s.toAct === p.seat) classes.push('turn');
if (showdown && p.inHand && !p.folded) classes.push('contender');
if (finished && p.won > 0) classes.push('winner');
if (p.sittingOut && !p.inHand) classes.push('away');
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 markers = h
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 : `<span class="mono">${esc(initialOf(p.name))}</span>`);
q.avatar.style.setProperty('--hue', hueOf(p.name));
patch(
q.markers,
h
? [
p.seat === h.button ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
p.seat === h.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
p.seat === h.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
].join('')
: '';
: ''
);
// 手札(自分と公開された人は表、それ以外は裏)
let cards = '';
if (s.cardsEnabled && p.inHand && (!p.folded || p.cards)) {
const deal = h.number !== seenHand ? 'deal' : '';
const list = p.cards || [null, null];
const mark = (c) => (best && c ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : '');
cards = `<div class="hole">${list.map((c) => cardHtml(c, `${deal} ${mark(c)}`)).join('')}</div>`;
}
// 手札(自分と公開された人は表、それ以外は裏)
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' : ''));
// ベット(中央寄り)・獲得額
const below = Math.sin(angles[idx]) <= 0.35;
let bet = '';
if (finished && p.won > 0) bet = `<div class="bet win ${below ? 'below' : 'above'}">+${chips(p.won)}</div>`;
else if (p.bet > 0) bet = `<div class="bet ${below ? 'below' : 'above'}">${chips(p.bet)}</div>`;
let tag = '';
if (p.last && !(finished && p.won > 0)) {
tag = `<span class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</span>`;
} else if (p.sittingOut && !p.inHand) {
tag = '<span class="act-tag tag-away">離席中</span>';
}
patch(q.tag, tag);
let tag = '';
if (p.last && !(finished && p.won > 0)) {
tag = `<div class="act-tag tag-${p.last.type.toLowerCase()}">${ACTION_LABEL[p.last.type] || ''}</div>`;
} else if (p.sittingOut && !p.inHand) {
tag = '<div class="act-tag tag-away">離席中</div>';
}
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);
return `
<div class="${classes.join(' ')}">
<div class="avatar">${p.bot ? BOT_SVG : AVATAR_SVG}${markers}${cards}</div>
<div class="nameplate">
${tag}
<div class="pname">${esc(p.name)}</div>
<div class="pstack">${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}</div>
${p.handName && p.seat !== s.mySeat ? `<div class="phand">${esc(p.handName)}</div>` : ''}
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
</div>
${bet}
</div>`;
})
.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)}%`;
// ベット(中央寄り)・獲得額
const side = Math.sin(angle) <= 0.35 ? 'below' : 'above';
let bet = '';
if (finished && p.won > 0) bet = `<span class="bet win ${side}">+${chips(p.won)}</span>`;
else if (p.bet > 0) bet = `<span class="bet ${side}">${chips(p.bet)}</span>`;
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;
$('my-stack').textContent = chips(m.stack);
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' : '';
@@ -326,11 +423,16 @@
$('my-cards').hidden = !s.cardsEnabled;
if (s.cardsEnabled) {
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('')
const el = $('my-cards');
const myCards = seat.inHand && seat.cards ? seat.cards : [];
const base = myCards.length
? myCards.map((c) => cardHtml(c)).join('')
: '<span class="pcard slot"></span><span class="pcard slot"></span>';
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;
@@ -352,8 +454,10 @@
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', Boolean(o) || (awaiting && m.canAward));
$('status-line').classList.toggle('my-turn', myMove);
$('action-bar').classList.toggle('my-turn', myMove && !s.paused);
// ショーダウンの勝者選択
const awarding = awaiting && m.canAward && !s.paused;
@@ -371,8 +475,8 @@
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
$('actions').hidden = alt || raising || awarding || reviewWaiting;
$('raise-actions').hidden = alt || !raising;
$('raise-panel').hidden = alt || !raising;
$('status-line').hidden = raising;
// レイズ額のパネルはレイズボタンから出て、閉じるときはボタンへ戻る
Fluid.presence($('raise-panel'), !alt && raising, { scale: 0.86, y: 28 });
const fold = $('act-fold');
const call = $('act-call');
@@ -408,7 +512,7 @@
const l = r.last;
const h = l.hero;
const pre = l.preflop.length
? l.preflop
? `<div class="rv-items">${l.preflop
.map(
(j) => `
<div class="rv-item">${markHtml(j.mark)}<div>
@@ -417,7 +521,7 @@
${j.equity != null ? `<div class="rv-sub">推定勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
</div></div>`
)
.join('')
.join('')}</div>`
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
const post = l.decisions
.map(
@@ -449,7 +553,7 @@
</div>
<div class="rv-streets">${scores}</div>
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div>${post}` : ''}
${post ? `<div class="rv-section">フロップ以降(期待値の目安)</div><div class="rv-items">${post}</div>` : ''}
${allIn}
<div class="rv-foot">このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)</div>`;
}
@@ -458,7 +562,7 @@
const r = s.review;
$('review-open').hidden = !(r && r.last);
if (!r || !r.last) {
$('review-modal').hidden = true;
reviewSheet.close();
return;
}
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
@@ -471,7 +575,7 @@
// 新しいハンドのレビューが届いたら自動で開く
if (r.last.number !== seenReview) {
seenReview = r.last.number;
if (r.waiting) $('review-modal').hidden = false;
if (r.waiting) reviewSheet.open();
}
}
@@ -486,15 +590,12 @@
}
}
const openReview = () => { $('review-modal').hidden = false; };
const openReview = () => reviewSheet.open();
$('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview);
$('review-close').addEventListener('click', () => { $('review-modal').hidden = true; });
$('review-modal').addEventListener('click', (e) => {
if (e.target === $('review-modal')) $('review-modal').hidden = true;
});
$('review-close').addEventListener('click', () => reviewSheet.close());
const nextHand = () => {
$('review-modal').hidden = true;
reviewSheet.close();
emit('table:next');
};
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
@@ -509,18 +610,21 @@
// 参加者が1人だけのポットは最初から選んでおく
awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : []));
}
$('award-pots').innerHTML = pots
.map((p, i) => {
const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
const names = p.eligible
.map(
(seat) =>
`<button type="button" class="btn btn-sm pick${awardSel[i].has(seat) ? ' on' : ''}" data-pot="${i}" data-seat="${seat}">${esc(nameOf(seat))}</button>`
)
.join('');
return `<div class="award-pot"><div class="award-title">${title} <b>${chips(p.amount)}</b></div><div class="picks">${names}</div></div>`;
})
.join('');
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 `<button type="button" class="pick${on ? ' on' : ''}" aria-pressed="${on}" data-pot="${i}" data-seat="${seat}">${CHECK_SVG}${esc(nameOf(seat))}</button>`;
})
.join('');
return `<div class="award-pot"><div class="award-title">${title} <b>${chips(p.amount)}</b></div><div class="picks">${names}</div></div>`;
})
.join('')
);
const ready = awardSel.every((set) => set.size > 0);
const split = awardSel.some((set) => set.size > 1);
$('award-confirm').disabled = !ready || pending;
@@ -580,20 +684,56 @@
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);
const fracOf = (o, v) => (o.maxRaiseTo > o.minRaiseTo ? (v - o.minRaiseTo) / (o.maxRaiseTo - o.minRaiseTo) : 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);
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 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;
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);
for (const b of $('presets').children) {
b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue);
}
$('raise-meta').textContent = state.bigBlind > 0 ? `${formatBB(raiseValue / state.bigBlind)} BB` : '';
const allIn = raiseValue >= o.maxRaiseTo;
$('raise-confirm').textContent = allIn
? `All-in ${chips(raiseValue)}`
@@ -601,6 +741,85 @@
$('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 () => {
@@ -629,6 +848,7 @@
if (o.minRaiseTo >= o.maxRaiseTo) return sendAction('raise', o.maxRaiseTo); // オールインしかない
raising = true;
raiseValue = o.minRaiseTo;
knob.set(0);
render();
});
$('raise-cancel').addEventListener('click', () => {
@@ -642,21 +862,7 @@
$('presets').addEventListener('click', (e) => {
const b = e.target.closest('[data-preset]');
const o = options();
if (!b || !o) return;
raiseValue = presetValue(o, b.dataset.preset);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
});
$('raise-range').addEventListener('input', (e) => {
const o = options();
if (!o) return;
// SB 単位に丸める(最大はオールイン額そのまま)
const unit = Math.max(1, state.smallBlind);
let v = Number(e.target.value);
if (v < o.maxRaiseTo) v = o.minRaiseTo + Math.round((v - o.minRaiseTo) / unit) * unit;
raiseValue = clampRaise(o, v);
$('raise-amount').value = raiseValue;
renderRaisePanel(o);
if (b && o) setRaise(o, presetValue(o, b.dataset.preset));
});
$('raise-amount').addEventListener('input', (e) => {
const o = options();
@@ -724,16 +930,17 @@
});
// iOS Safari は CSS だけではページのスクロール・引っ張り(バウンス)を止められないので、
// 1本指のドラッグを止める。スライダーと、中身がはみ出している操作エリアは除く。
// 1本指のドラッグを止める。スライダーと、中身がはみ出しているシート・勝者選択は除く。
// 2本指(ピンチ拡大)はアクセシビリティのため止めない。
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length !== 1) return;
const t = e.target;
if (t.closest && t.closest('input[type="range"]')) return;
const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal'));
if (bar && bar.scrollHeight > bar.clientHeight) return;
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 }