Add player stats, in-table ranking, host seating and review return
- Stats page (/stats): play style radar (TAG/LAG/rock/fish), VPIP, PFR, ATS, 3bet, WTSD, W$SD, WWSF, per-street fold rate, aggression frequency, hand win rate and net in BB, per table type, each with a bands-and-explanation sheet. Hands are counted per human player when they finish and stored as totals under data/stats/, keyed by a hash of the player ID (sent in a POST body, never in the URL). - Ranking sheet at the table: everyone seated, ordered by stack or by net (stack minus buy-ins), updating live. - The host can reorder seats by dragging (with a mini table preview and shuffle). Takes effect immediately between hands, otherwise at the start of the next hand; the dealer button stays with the same player. - The full-session review opens in the same tab and its back button returns to the table it was opened from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49203f202c
commit
dd39c043c4
18 files changed
+2699
-18
No files matched your search
+368
-1
@@ -30,6 +30,8 @@
|
||||
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'), {
|
||||
@@ -117,6 +119,8 @@
|
||||
$('main').hidden = true;
|
||||
$('action-bar').hidden = true;
|
||||
reviewSheet.close();
|
||||
rankingSheet.close();
|
||||
seatingSheet.close();
|
||||
raising = false;
|
||||
betSheet.close();
|
||||
$('not-found').hidden = false;
|
||||
@@ -244,6 +248,10 @@
|
||||
$('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;
|
||||
@@ -259,6 +267,8 @@
|
||||
if (me) renderActionBar(s, me);
|
||||
else betSheet.close();
|
||||
renderReview(s);
|
||||
if (rankingSheet.isOpen) renderRanking(s);
|
||||
if (seatingSheet.isOpen) syncSeating(s);
|
||||
renderTimer();
|
||||
|
||||
// 自分の番になったら知らせる
|
||||
@@ -579,7 +589,9 @@
|
||||
reviewSheet.close();
|
||||
return;
|
||||
}
|
||||
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
|
||||
// 同じタブで開き、レビューの画面の「テーブル」で戻ってこられるようにテーブル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) {
|
||||
@@ -616,6 +628,361 @@
|
||||
$('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,
|
||||
`<span class="rank-no${rank <= 3 ? ` top-${rank}` : ''}">${rank}</span>` +
|
||||
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
|
||||
`<span class="cell-title">${esc(p.name)}${sub ? `<span class="cell-sub">${sub}</span>` : ''}</span>` +
|
||||
`<span class="rank-value${net ? (value > 0 ? ' plus' : value < 0 ? ' minus' : '') : ''}">${net ? signed(value) : chips(value)}</span>`
|
||||
);
|
||||
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 = '<li class="empty">まだ誰も座っていません</li>';
|
||||
}
|
||||
|
||||
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 = '<svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h14M5 12h14M5 16h14"/></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,
|
||||
`<span class="rank-avatar${p.bot ? ' bot' : ''}">${p.bot ? BOT_SVG : esc(initialOf(p.name))}</span>` +
|
||||
`<span class="cell-title">${esc(p.name)}<span class="cell-sub">${sub}</span></span>` +
|
||||
(mine
|
||||
? '<span class="seat-pin">固定</span>'
|
||||
: `<button type="button" class="seat-handle" aria-label="${esc(p.name)}の席を移動(↑↓キーで並べ替え)">${HANDLE_SVG}</button>`)
|
||||
);
|
||||
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 = '<span class="rank-avatar"></span><span class="seating-name"></span>';
|
||||
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)}`;
|
||||
|
||||
Reference in new issue
Block a user