Add Fluid.Popup, a centered dialog with the same open/close API as Sheet: it scales in like the confirm alert, fades out in place, and closes on backdrop tap or Escape with focus trapped inside. The host's seating editor now uses it instead of a bottom sheet; its list scrolls inside the popup on short screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1478 lines
62 KiB
JavaScript
1478 lines
62 KiB
JavaScript
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
|
||
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
|
||
// 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない)
|
||
(function () {
|
||
'use strict';
|
||
|
||
const { Store, esc, chips, toast } = App;
|
||
const $ = (id) => document.getElementById(id);
|
||
const tableId = decodeURIComponent(location.pathname.split('/').pop());
|
||
|
||
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' };
|
||
|
||
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 seatingPopup = Fluid.Popup($('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();
|
||
seatingPopup.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 `<span class="pcard back ${extra}"></span>`;
|
||
const rank = c[0] === 'T' ? '10' : c[0];
|
||
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
|
||
? 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 = '<span class="pcard slot"></span>'.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 (seatingPopup.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 ? `<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;
|
||
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 : `<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('')
|
||
: ''
|
||
);
|
||
|
||
// 手札(自分と公開された人は表、それ以外は裏)
|
||
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 = `<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);
|
||
|
||
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 = `<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;
|
||
|
||
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('')
|
||
: '<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;
|
||
|
||
// 状況
|
||
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) => `<span class="rv-mark ${MARK_CLASS[m] || ''}">${m}</span>`;
|
||
|
||
function reviewHtml(r) {
|
||
const l = r.last;
|
||
const h = l.hero;
|
||
const pre = l.preflop.length
|
||
? `<div class="rv-items">${l.preflop
|
||
.map(
|
||
(j) => `
|
||
<div class="rv-item">${markHtml(j.mark)}<div>
|
||
<div>${esc(j.situation)}</div>
|
||
<div class="rv-sub">推奨 <b>${KIND_LABEL[j.recommended]}</b> / あなた <b>${KIND_LABEL[j.actual]}</b> — ${esc(j.label)}</div>
|
||
${j.equity != null ? `<div class="rv-sub">推定勝率 ${j.equity}%(コールに必要 ${j.need}%)</div>` : ''}
|
||
</div></div>`
|
||
)
|
||
.join('')}</div>`
|
||
: '<div class="rv-sub">プリフロップで判断する場面はありませんでした</div>';
|
||
const post = l.decisions
|
||
.map(
|
||
(d) => `
|
||
<div class="rv-item">${markHtml(d.mark)}<div>
|
||
<div><b>${STREET_LABEL[d.street]}</b> ${optionLabel(d.actual)} <span class="rv-score">${d.score}点</span></div>
|
||
<div class="rv-sub">推定勝率 ${d.equity}%${d.need != null ? `(コールに必要 ${d.need}%)` : ''}${
|
||
d.lossBB > 0 ? ` ・ 最善は ${optionLabel(d.best)}(${d.lossBB}BB 損)` : ' ・ 最善の選択'
|
||
}</div>
|
||
</div></div>`
|
||
)
|
||
.join('');
|
||
const scores = STREETS.map((st) => {
|
||
const v = l.streetScores[st];
|
||
return `<div class="rv-street"><span>${STREET_SHORT[st]}</span><b>${v == null ? '—' : v}</b></div>`;
|
||
}).join('');
|
||
const a = h.allIn;
|
||
const allIn = a
|
||
? `<div class="rv-section">オールイン</div>
|
||
<div class="rv-sub">${STREET_LABEL[a.street]}でオールインした時点の勝率 <b>${a.equity}%</b> → 期待値 ${chips(a.ev)} / 実際 ${chips(a.won)}(運 ${signed(a.won - a.ev)})</div>`
|
||
: '';
|
||
return `
|
||
<div class="rv-head">
|
||
<div class="rv-cards">${h.cards.map((c) => cardHtml(c)).join('')}</div>
|
||
<div>
|
||
<div class="rv-hand"><b>${esc(h.key)}</b> <span class="rv-color cat-${esc(h.color)}">${esc(h.colorName)}</span></div>
|
||
<div class="rv-sub">${esc(h.colorLabel)} ・ ${esc(h.position)} ・ ハンド #${l.number}</div>
|
||
</div>
|
||
</div>
|
||
<div class="rv-streets">${scores}</div>
|
||
<div class="rv-section">プリフロップ(ハンドレンジ)</div>${pre}
|
||
${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>`;
|
||
}
|
||
|
||
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,
|
||
`<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 seatingPopup.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);
|
||
seatingPopup.open();
|
||
});
|
||
$('seating-cancel').addEventListener('click', () => seatingPopup.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;
|
||
}
|
||
seatingPopup.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 `<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;
|
||
$('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) =>
|
||
`<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) {
|
||
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();
|
||
})();
|