- Create screen: choose キャッシュオンリー (real cards, chips only), 友人戦 (server deals and settles) or CPU戦 (友人戦 plus 1–8 CPU players); replaces the card toggle. The lobby shows each table's type - Poker AI written from scratch (server/ai.js, no libraries): fast 7-card evaluator (checked against bestHand), Monte Carlo equity, and decisions from hand rank, pot odds, raise count, opponents and a per-CPU style (aggression, bluff rate); continuation bets via the last aggressor. It never sees other players' hole cards - CPU seats: think time of 0.7–1.8s (faster when no human is left in the hand), automatic rebuy, never the host, no new hand unless a human is playing, re-think after pause - Play UI: robot avatars and a "考えています…" status for CPU turns - Tests for the evaluator, equity, legality of AI actions and CPU tables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
605 lines
24 KiB
JavaScript
605 lines
24 KiB
JavaScript
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
|
||
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
|
||
(function () {
|
||
'use strict';
|
||
|
||
const { Store, esc, chips, toast } = App;
|
||
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>';
|
||
// 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 seenBoard = ''; // 新しくめくられたカードだけアニメーションする
|
||
let seenHand = 0;
|
||
// ショーダウンの勝者選択(ポットごとの席番号の Set)
|
||
let awardKey = '';
|
||
let awardSel = [];
|
||
|
||
// ---------- 名前 ----------
|
||
function ensureName() {
|
||
if (Store.name) return connect();
|
||
$('name-modal').hidden = false;
|
||
$('name-input').focus();
|
||
}
|
||
$('name-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const name = $('name-input').value.trim();
|
||
if (!name) return $('name-input').classList.add('invalid');
|
||
Store.name = name;
|
||
$('name-modal').hidden = true;
|
||
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: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();
|
||
}
|
||
|
||
function showNotFound() {
|
||
$('main').hidden = true;
|
||
$('action-bar').hidden = true;
|
||
$('not-found').hidden = false;
|
||
if (socket) socket.disconnect();
|
||
}
|
||
|
||
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
|
||
|
||
// ---------- カード(カード機能ありのテーブルのみ) ----------
|
||
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>`;
|
||
}
|
||
|
||
// ショーダウンで勝った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;
|
||
}
|
||
|
||
function renderBoard(s) {
|
||
const el = $('board');
|
||
const h = s.hand;
|
||
el.hidden = !s.cardsEnabled;
|
||
if (!s.cardsEnabled) return;
|
||
if (!h) {
|
||
el.innerHTML = '';
|
||
return;
|
||
}
|
||
const [seenNo, seenLen] = seenBoard.split(':').map(Number);
|
||
const oldLen = seenNo === h.number ? seenLen : 0;
|
||
const best = winningCards(h);
|
||
let html = '';
|
||
for (let i = 0; i < 5; i++) {
|
||
const c = h.board[i];
|
||
if (!c) {
|
||
html += '<span class="pcard slot"></span>';
|
||
continue;
|
||
}
|
||
const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
|
||
html += cardHtml(c, cls);
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ---------- 描画 ----------
|
||
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)}`;
|
||
|
||
// 中央
|
||
$('pot').textContent = chips(h ? h.potTotal : 0);
|
||
$('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').textContent = s.paused ? '▶' : '⏸';
|
||
$('pause').title = s.paused ? '再開' : '一時停止';
|
||
$('pause').setAttribute('aria-label', $('pause').title);
|
||
$('pause').classList.toggle('paused', s.paused);
|
||
|
||
// 自分
|
||
$('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 ? '退席する' : 'ロビーへ戻る';
|
||
|
||
renderSeats(s);
|
||
if (me) renderActionBar(s, me);
|
||
renderTimer();
|
||
|
||
// 自分の番になったら知らせる(タップ前は振動できない)
|
||
const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
|
||
if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80);
|
||
wasMyTurn = myTurn;
|
||
if (h) {
|
||
seenBoard = `${h.number}:${h.board.length}`;
|
||
seenHand = h.number;
|
||
}
|
||
}
|
||
|
||
function renderResult(s) {
|
||
const el = $('result');
|
||
const h = s.hand;
|
||
if (!h || !h.finished || !h.results) {
|
||
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('');
|
||
el.hidden = false;
|
||
}
|
||
|
||
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
|
||
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 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 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');
|
||
|
||
const 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 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 = `<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>';
|
||
}
|
||
|
||
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)}%`;
|
||
});
|
||
}
|
||
|
||
function renderActionBar(s, seat) {
|
||
const m = s.me;
|
||
const h = s.hand;
|
||
const o = m.options;
|
||
|
||
$('my-stack').textContent = chips(m.stack);
|
||
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 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('')
|
||
: '<span class="pcard slot"></span><span class="pcard slot"></span>';
|
||
}
|
||
$('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 (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人以上で再開)';
|
||
$('status-line').textContent = status;
|
||
$('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward));
|
||
|
||
// ショーダウンの勝者選択
|
||
const awarding = awaiting && m.canAward && !s.paused;
|
||
$('award-panel').hidden = !awarding;
|
||
if (awarding) renderAwardPanel(h.pots);
|
||
|
||
// ボタン
|
||
const alt = (m.sittingOut || m.canRebuy) && !awarding;
|
||
if (!o) raising = false;
|
||
$('alt-actions').hidden = !alt;
|
||
$('sit-in').hidden = !m.sittingOut;
|
||
$('rebuy').hidden = !m.canRebuy;
|
||
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
|
||
$('actions').hidden = alt || raising || awarding;
|
||
$('raise-actions').hidden = alt || !raising;
|
||
$('raise-panel').hidden = alt || !raising;
|
||
$('status-line').hidden = raising;
|
||
|
||
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';
|
||
|
||
if (raising) renderRaisePanel(o);
|
||
}
|
||
|
||
// ---------- 勝者選択 ----------
|
||
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 : []));
|
||
}
|
||
$('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('');
|
||
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', () => {
|
||
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で確定しますか?';
|
||
if (!confirm(msg)) return;
|
||
pending = true;
|
||
emit('table:award', { winners });
|
||
render();
|
||
});
|
||
|
||
// ---------- レイズ ----------
|
||
function presetValue(o, kind) {
|
||
const h = state.hand;
|
||
const potAfterCall = h.potTotal + o.toCall;
|
||
const base = h.currentBet;
|
||
const v = {
|
||
min: o.minRaiseTo,
|
||
half: base + Math.round(potAfterCall / 2),
|
||
threeq: base + Math.round((potAfterCall * 3) / 4),
|
||
pot: base + potAfterCall,
|
||
max: o.maxRaiseTo,
|
||
}[kind];
|
||
return clampRaise(o, v);
|
||
}
|
||
|
||
function clampRaise(o, v) {
|
||
if (!Number.isFinite(v)) return o.minRaiseTo;
|
||
return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v)));
|
||
}
|
||
|
||
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;
|
||
for (const b of $('presets').children) {
|
||
b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue);
|
||
}
|
||
const allIn = raiseValue >= o.maxRaiseTo;
|
||
$('raise-confirm').textContent = allIn
|
||
? `All-in ${chips(raiseValue)}`
|
||
: `${o.isBet ? 'Bet' : 'Raise to'} ${chips(raiseValue)}`;
|
||
$('raise-confirm').disabled = pending;
|
||
}
|
||
|
||
const options = () => (state && state.me && state.me.options) || null;
|
||
|
||
$('act-fold').addEventListener('click', () => {
|
||
const o = options();
|
||
if (!o) return;
|
||
if (o.canCheck && !confirm('チェックできます。フォールドしますか?')) 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); // オールインしかない
|
||
raising = true;
|
||
raiseValue = o.minRaiseTo;
|
||
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-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);
|
||
});
|
||
$('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', () => {
|
||
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
|
||
? 'ハンドの途中です。退席するとフォールドになり、チップは失われます。退席しますか?'
|
||
: 'テーブルから退席しますか?(チップは失われます)';
|
||
if (!confirm(msg)) 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 && t.closest('input[type="range"]')) return;
|
||
const bar = t.closest && t.closest('.action-bar');
|
||
if (bar && bar.scrollHeight > bar.clientHeight) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
},
|
||
{ passive: false }
|
||
);
|
||
|
||
ensureName();
|
||
})();
|