Files
Poker/public/assets/table.js
T
agomizi1cmandClaude Opus 5.5 b2c91062f1 Initial commit: private Texas Hold'em ring game site (lobby + table stub)
- 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits
- Lobby with saved player name and live table list
- Table creation (name, starting stack, blinds, blind timer)
- Table play screen stub (seats, button/SB/BB, phase, blinds, timer)
- Security hardening: strict CSP, origin checks, host pinning, input sanitizing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:51:15 +09:00

188 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ
(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>';
let socket = null;
let state = null;
let clockOffset = 0; // serverNow - clientNow
// ---------- 名前 ----------
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;
socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => {
if (res && res.ok) return;
if (res && res.error === 'NOT_FOUND') return showNotFound();
toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー'));
});
});
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
socket.on('table:state', (s) => {
state = s;
clockOffset = s.serverNow - Date.now();
render();
});
}
function emit(event) {
if (!socket) return;
socket.emit(event, null, (res) => {
if (res && !res.ok) toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error);
});
}
function showNotFound() {
$('main').hidden = true;
$('action-bar').hidden = true;
$('not-found').hidden = false;
if (socket) socket.disconnect();
}
// ---------- 描画 ----------
function render() {
const s = state;
$('main').hidden = false;
document.title = `${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(s.pot);
$('phase').textContent = App.PHASE_LABELS[s.phase] || s.phase;
$('hand-no').textContent = s.handNumber ? `Hand #${s.handNumber}` : '';
// ホスト操作
const players = s.seats.filter(Boolean).length;
$('host-controls').hidden = !s.isHost;
$('host-spacer').hidden = s.isHost;
$('start').disabled = s.phase !== 'WAITING' || players < 2;
$('start').title = players < 2 ? '2人以上で開始できます' : '';
$('next-phase').disabled = s.phase === 'WAITING';
$('pause').disabled = !s.started;
$('pause').textContent = s.paused ? '再開' : '一時停止';
// 自分
const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null;
$('action-bar').hidden = !me;
$('spectator-note').hidden = Boolean(me);
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
if (me) $('my-stack').textContent = chips(me.stack);
renderSeats(s);
renderTimer();
}
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
function renderSeats(s) {
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 container = $('seats');
container.innerHTML = ordered
.map((p) => {
const classes = ['seat'];
if (p.seat === s.mySeat) classes.push('me');
if (!p.connected) classes.push('offline');
const markers = [
p.seat === s.buttonSeat ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
p.seat === s.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
p.seat === s.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
].join('');
return `
<div class="${classes.join(' ')}">
<div class="avatar">${AVATAR_SVG}${markers}</div>
<div class="nameplate">
<div class="pname">${p.seat === s.hostSeat ? '<span title="ホスト">👑</span>' : ''}${esc(p.name)}</div>
<div class="pstack">${chips(p.stack)}</div>
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
</div>
</div>`;
})
.join('');
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
[...container.children].forEach((el, i) => {
const angle = Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1);
el.style.left = `${(50 + 38 * Math.cos(angle)).toFixed(2)}%`;
el.style.top = `${(50 + 41 * Math.sin(angle)).toFixed(2)}%`;
});
}
function renderTimer() {
const el = $('timer');
if (!state) return;
const t = state.timer;
el.classList.toggle('paused', state.paused);
if (!t.enabled) {
el.textContent = state.paused ? '⏸ 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'));
$('next-phase').addEventListener('click', () => emit('table:nextPhase'));
$('pause').addEventListener('click', () => emit('table:pause'));
$('leave').addEventListener('click', () => {
const seated = state && state.mySeat >= 0;
if (!seated) return (location.href = '/');
if (!confirm('テーブルから退席しますか?(チップは失われます)')) 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('サイトのリンクをコピーしました');
});
ensureName();
})();