- 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>
188 lines
6.9 KiB
JavaScript
188 lines
6.9 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>';
|
||
|
||
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();
|
||
})();
|