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>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-15 16:51:15 +09:00
commit b2c91062f1
22 files changed
+3450

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
// ブラインド計算(サーバ・ブラウザ共用)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.Blinds = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 1, 2, 3, 4, 5, 6, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 150, ...
const MANTISSAS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5];
const LADDER = [];
for (let e = 0; e <= 9; e++) {
for (const m of MANTISSAS) {
const v = Math.round(m * Math.pow(10, e) * 100) / 100;
if (Number.isInteger(v)) LADDER.push(v);
}
}
function nearestNice(x) {
if (!(x >= 1)) return 1;
let best = LADDER[0];
for (const v of LADDER) if (Math.abs(v - x) < Math.abs(best - x)) best = v;
return best;
}
// 開始スタックに合うブラインド(BB = スタックの約1/100)
function defaultBlinds(stack) {
const sb = nearestNice(stack / 200);
return { sb, bb: sb * 2 };
}
// 1レベルごとに約1.5倍
function nextSmallBlind(sb) {
const target = sb * 1.4;
for (const v of LADDER) if (v >= target && v > sb) return v;
return Math.ceil(sb * 1.5);
}
// level は 1 始まり
function blindsAt(sb0, bb0, level) {
const ratio = bb0 / sb0;
let sb = sb0;
let bb = bb0;
for (let i = 2; i <= level; i++) {
sb = nextSmallBlind(sb);
bb = Math.round(sb * ratio);
}
return { sb, bb };
}
function schedule(sb0, bb0, levels) {
const out = [];
for (let i = 1; i <= levels; i++) out.push(Object.assign({ level: i }, blindsAt(sb0, bb0, i)));
return out;
}
return { defaultBlinds, blindsAt, schedule };
});
+99
View File
@@ -0,0 +1,99 @@
// 各ページ共通のユーティリティ
(function () {
'use strict';
function lsGet(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch { /* プライベートモード等 */ }
}
// 非HTTPS環境でも動くよう crypto.randomUUID ではなく getRandomValues を使う
function randomHex(bytes) {
const a = new Uint8Array(bytes);
crypto.getRandomValues(a);
return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join('');
}
const Store = {
get name() { return lsGet('poker.name') || ''; },
set name(v) { lsSet('poker.name', v); },
// 席の再接続用。ブラウザごとに固定
get token() {
let t = lsGet('poker.token');
if (!t || !/^[0-9a-f]{32}$/.test(t)) {
t = randomHex(16);
lsSet('poker.token', t);
}
return t;
},
};
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function chips(n) {
return '$' + Number(n).toLocaleString('en-US');
}
let toastTimer;
function toast(msg) {
let el = document.querySelector('.toast');
if (!el) {
el = document.createElement('div');
el.className = 'toast';
document.body.appendChild(el);
}
el.textContent = msg;
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
}
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
});
if (res.status === 401 && url !== '/api/auth') {
location.href = '/key?next=' + encodeURIComponent(location.pathname);
throw new Error('unauthorized');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'エラーが発生しました');
return data;
}
// サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する
function connectSocket() {
const socket = io({ transports: ['websocket'] });
socket.on('connect_error', () => {
fetch('/api/tables', { credentials: 'same-origin' })
.then((res) => {
if (res.status === 401) location.href = '/key?next=' + encodeURIComponent(location.pathname);
})
.catch(() => {});
});
return socket;
}
// 自サイト内のパスだけ許可("//x" や "/\x" は外部サイト扱いになるので拒否)
function safeNext(next) {
return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/';
}
const PHASE_LABELS = {
WAITING: 'WAITING',
PREFLOP: 'PREFLOP',
FLOP: 'FLOP',
TURN: 'TURN',
RIVER: 'RIVER',
SHOWDOWN: 'SHOWDOWN',
};
window.App = { Store, esc, chips, toast, api, connectSocket, safeNext, PHASE_LABELS };
})();
+143
View File
@@ -0,0 +1,143 @@
(function () {
'use strict';
const { Store, api, chips } = App;
if (!Store.name) {
location.replace('/');
return;
}
const $ = (id) => document.getElementById(id);
const form = $('form');
const nameInput = $('table-name');
const stackInput = $('stack');
const sbInput = $('sb');
const bbInput = $('bb');
const timerInput = $('timer');
const intervalInput = $('interval');
const scheduleEl = $('schedule');
const blindHint = $('blind-hint');
const errorEl = $('error');
const submitBtn = $('submit');
// ブラインドを手で変えるまではスタックに追従させる
let blindsTouched = false;
async function suggestName() {
try {
const { name } = await api('GET', '/api/tables/suggest-name');
nameInput.value = name;
} catch { /* 失敗時は手入力 */ }
}
const int = (el) => {
const n = Number(el.value);
return Number.isInteger(n) ? n : NaN;
};
function applyDefaultBlinds() {
const stack = int(stackInput);
if (!(stack >= 10)) return;
const { sb, bb } = Blinds.defaultBlinds(stack);
sbInput.value = sb;
bbInput.value = bb;
}
function updateHint() {
const stack = int(stackInput);
const bb = int(bbInput);
if (blindsTouched && stack > 0 && bb > 0) {
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
} else {
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
}
}
function updateSchedule() {
const sb = int(sbInput);
const bb = int(bbInput);
const on = timerInput.checked;
intervalInput.disabled = !on;
scheduleEl.hidden = !on || !(sb > 0 && bb >= sb);
if (scheduleEl.hidden) return;
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
.map((l, i) => `<span>Lv.${l.level} <b>${chips(l.sb)}/${chips(l.bb)}</b>${mins ? ` · ${i * mins}分〜` : ''}</span>`)
.join('');
}
function refresh() {
updateHint();
updateSchedule();
}
stackInput.addEventListener('input', () => {
if (!blindsTouched) applyDefaultBlinds();
refresh();
});
[sbInput, bbInput].forEach((el) =>
el.addEventListener('input', () => {
blindsTouched = true;
if (el === sbInput && int(sbInput) > 0) bbInput.value = int(sbInput) * 2;
refresh();
})
);
blindHint.addEventListener('click', (e) => {
if (e.target.id !== 'reset-blinds') return;
e.preventDefault();
blindsTouched = false;
applyDefaultBlinds();
refresh();
});
timerInput.addEventListener('change', refresh);
intervalInput.addEventListener('input', refresh);
$('reroll').addEventListener('click', suggestName);
function validate() {
const name = nameInput.value.trim();
const stack = int(stackInput);
const sb = int(sbInput);
const bb = int(bbInput);
const interval = int(intervalInput);
if (!name) return [nameInput, 'テーブル名を入力してください'];
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
if (!(bb >= sb)) return [bbInput, 'BBはSB以上にしてください'];
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
return null;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
const problem = validate();
if (problem) {
problem[0].classList.add('invalid');
problem[0].focus();
errorEl.textContent = problem[1];
return;
}
errorEl.textContent = '';
submitBtn.disabled = true;
try {
const { id } = await api('POST', '/api/tables', {
name: nameInput.value.trim(),
startingStack: int(stackInput),
smallBlind: int(sbInput),
bigBlind: int(bbInput),
blindTimerEnabled: timerInput.checked,
blindIntervalMin: int(intervalInput),
token: Store.token,
});
location.href = '/table/' + encodeURIComponent(id);
} catch (err) {
errorEl.textContent = err.message;
submitBtn.disabled = false;
}
});
applyDefaultBlinds();
refresh();
suggestName();
})();
+82
View File
@@ -0,0 +1,82 @@
(function () {
'use strict';
const form = document.getElementById('form');
const box = document.getElementById('digits');
const inputs = [...box.querySelectorAll('input')];
const errorEl = document.getElementById('error');
const submitBtn = document.getElementById('submit');
let busy = false;
const value = () => inputs.map((i) => i.value).join('');
function fill(digits, from) {
let i = from;
for (const d of digits) {
if (i >= inputs.length) break;
inputs[i++].value = d;
}
inputs[Math.min(i, inputs.length - 1)].focus();
if (value().length === 6) submit();
}
inputs.forEach((input, idx) => {
input.addEventListener('input', () => {
const digits = input.value.replace(/\D/g, '');
input.value = '';
if (digits) fill(digits, idx);
errorEl.textContent = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && idx > 0) {
inputs[idx - 1].value = '';
inputs[idx - 1].focus();
e.preventDefault();
} else if (e.key === 'ArrowLeft' && idx > 0) {
inputs[idx - 1].focus();
} else if (e.key === 'ArrowRight' && idx < inputs.length - 1) {
inputs[idx + 1].focus();
}
});
input.addEventListener('paste', (e) => {
const digits = (e.clipboardData.getData('text') || '').replace(/\D/g, '');
if (!digits) return;
e.preventDefault();
fill(digits, idx);
});
input.addEventListener('focus', () => input.select());
});
async function submit() {
if (busy) return;
const key = value();
if (key.length !== 6) {
errorEl.textContent = '6桁すべて入力してください。';
return;
}
busy = true;
submitBtn.disabled = true;
try {
await App.api('POST', '/api/auth', { key });
const next = new URLSearchParams(location.search).get('next');
location.replace(App.safeNext(next));
} catch (err) {
errorEl.textContent = err.message;
box.classList.remove('shake');
void box.offsetWidth;
box.classList.add('shake');
inputs.forEach((i) => { i.value = ''; });
inputs[0].focus();
} finally {
busy = false;
submitBtn.disabled = false;
}
}
form.addEventListener('submit', (e) => {
e.preventDefault();
submit();
});
inputs[0].focus();
})();
+72
View File
@@ -0,0 +1,72 @@
(function () {
'use strict';
const { Store, esc, chips } = App;
const nameInput = document.getElementById('name');
const nameError = document.getElementById('name-error');
const listEl = document.getElementById('list');
const countEl = document.getElementById('count');
nameInput.value = Store.name;
nameInput.addEventListener('input', () => {
Store.name = nameInput.value.trim();
nameInput.classList.remove('invalid');
nameError.textContent = '';
});
function requireName() {
if (Store.name) return true;
nameInput.classList.add('invalid');
nameError.textContent = '先に名前を入力してください。';
nameInput.focus();
return false;
}
document.getElementById('create').addEventListener('click', () => {
if (requireName()) location.href = '/create';
});
listEl.addEventListener('click', (e) => {
const btn = e.target.closest('[data-join]');
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
function phaseBadge(t) {
if (t.paused) return '<span class="badge paused">PAUSED</span>';
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
return '<span class="badge live">PLAYING</span>';
}
function render(tables) {
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
if (!tables.length) {
listEl.innerHTML = '<div class="empty">開催中のテーブルはありません。<br>テーブルを作成して友達を招待しよう。</div>';
return;
}
listEl.innerHTML = tables
.map((t) => {
const full = t.playerCount >= t.maxSeats;
const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
return `
<div class="table-item">
<div class="info">
<div class="name">${esc(t.name)}${phaseBadge(t)}</div>
<div class="meta">
<span>👤 <b>${t.playerCount}/${t.maxSeats}</b></span>
<span>開始 <b>${chips(t.startingStack)}</b></span>
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
<span>${timer}</span>
</div>
</div>
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
${full ? '観戦' : '参加'}
</button>
</div>`;
})
.join('');
}
const socket = App.connectSocket();
socket.on('connect', () => socket.emit('lobby:subscribe'));
socket.on('lobby:tables', render);
})();
+514
View File
@@ -0,0 +1,514 @@
:root {
--bg: #24493b;
--bg-deep: #1b3a2e;
--panel: #1a2620;
--panel-2: #26332c;
--input: #303a35;
--line: #3a4a42;
--text: #f2f5f3;
--muted: #9fb2a8;
--felt: #2ecc71;
--felt-dark: #23a85b;
--gold: #f6c744;
--orange: #f0a43a;
--blue: #4a90e2;
--green: #2ecc71;
--red: #ff5a5a;
--radius: 14px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
body { min-height: 100dvh; }
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 24px 16px 40px;
}
h1, h2, h3 { margin: 0; }
.logo {
text-align: center;
font-weight: 900;
line-height: 0.95;
letter-spacing: -0.02em;
font-size: 48px;
margin: 28px 0 32px;
}
.logo small {
display: block;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3em;
color: var(--gold);
margin-top: 10px;
}
.section-title {
font-size: 18px;
font-weight: 800;
margin: 28px 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title .sub { font-size: 12px; font-weight: 600; color: var(--muted); }
label, .label {
display: block;
font-size: 14px;
color: var(--muted);
margin: 18px 0 8px;
}
input[type="text"], input[type="number"], input[type="tel"] {
width: 100%;
font: inherit;
font-size: 18px;
color: var(--text);
background: var(--input);
border: 2px solid transparent;
border-radius: 10px;
padding: 14px 16px;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: var(--felt); }
input:disabled { opacity: 0.4; }
input.invalid { border-color: var(--red); }
.row { display: flex; gap: 10px; align-items: center; }
.row > * { flex: 1; min-width: 0; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-size: 17px;
font-weight: 800;
border: 0;
border-radius: 10px;
padding: 14px 18px;
color: #fff;
cursor: pointer;
text-decoration: none;
transition: transform 0.06s, filter 0.15s, opacity 0.15s;
white-space: nowrap;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-orange { background: var(--orange); }
.btn-blue { background: var(--blue); }
.btn-green { background: var(--green); }
.btn-dark { background: var(--panel-2); color: var(--text); }
.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
.btn-block { width: 100%; }
.btn-sm { font-size: 14px; padding: 9px 14px; border-radius: 8px; }
.icon-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.18);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
text-decoration: none;
flex: none;
}
.divider { height: 1px; background: var(--line); margin: 24px 0; }
.error-text { color: var(--red); font-size: 14px; min-height: 20px; margin-top: 10px; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.card {
background: var(--panel);
border-radius: var(--radius);
padding: 16px;
}
/* ---------- 鍵入力 ---------- */
.key-inputs { display: flex; gap: 6px; justify-content: center; margin: 8px 0 4px; }
.key-inputs input {
flex: 1;
min-width: 0;
max-width: 52px;
height: 60px;
padding: 0;
text-align: center;
font-size: 28px;
font-weight: 800;
}
.shake { animation: shake 0.35s; }
@keyframes shake {
20%, 60% { transform: translateX(-8px); }
40%, 80% { transform: translateX(8px); }
}
/* ---------- ロビー ---------- */
.table-list { display: flex; flex-direction: column; gap: 10px; }
.table-item {
background: var(--panel);
border-radius: var(--radius);
padding: 14px 14px 14px 16px;
display: flex;
gap: 12px;
align-items: center;
}
.table-item .info { flex: 1; min-width: 0; }
.table-item .name {
font-weight: 800;
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-item .meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 13px;
color: var(--muted);
margin-top: 6px;
}
.table-item .meta b { color: var(--text); font-weight: 700; }
.table-item .meta .gold { color: var(--gold); }
.badge {
display: inline-block;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
vertical-align: 2px;
margin-left: 6px;
}
.badge.live { background: rgba(46, 204, 113, 0.18); color: var(--green); }
.badge.paused { background: rgba(255, 90, 90, 0.15); color: var(--red); }
.empty {
text-align: center;
color: var(--muted);
padding: 32px 16px;
border: 2px dashed var(--line);
border-radius: var(--radius);
font-size: 14px;
}
/* ---------- テーブル作成 ---------- */
.topbar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.topbar h1 { font-size: 20px; flex: 1; }
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22px;
font-weight: 800;
font-size: 17px;
}
.switch { position: relative; width: 52px; height: 30px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch span {
position: absolute;
inset: 0;
background: var(--input);
border-radius: 999px;
cursor: pointer;
transition: background 0.2s;
}
.switch span::before {
content: "";
position: absolute;
width: 24px;
height: 24px;
left: 3px;
top: 3px;
background: #ddd;
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.switch input:checked + span { background: var(--green); }
.switch input:checked + span::before { transform: translateX(22px); background: #fff; }
.switch input:focus-visible + span { outline: 2px solid var(--gold); }
.unit-input { position: relative; }
.unit-input input { padding-right: 56px; }
.unit-input .unit {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-weight: 700;
pointer-events: none;
}
.schedule {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.schedule span {
font-size: 12px;
background: var(--panel);
color: var(--muted);
padding: 4px 8px;
border-radius: 6px;
}
.schedule span b { color: var(--gold); font-weight: 700; }
/* ---------- プレイ画面 ---------- */
.table-page { display: flex; flex-direction: column; min-height: 100dvh; }
.table-page .wrap { padding-top: 16px; padding-bottom: 0; flex: 1; display: flex; flex-direction: column; width: 100%; }
.table-header { display: flex; align-items: center; gap: 12px; }
.table-header h1 {
flex: 1;
text-align: center;
font-size: 22px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); }
.host-controls { display: flex; justify-content: center; gap: 8px; margin: 16px 0 12px; flex-wrap: wrap; }
.host-controls .btn { border-radius: 999px; padding: 10px 16px; font-size: 14px; }
.level-bar {
background: var(--panel);
border-radius: var(--radius);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.level-bar .lv { color: var(--gold); font-weight: 800; }
.level-bar .blinds { font-weight: 800; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.level-bar .timer { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 18px; }
.level-bar .timer.warn { color: var(--orange); }
.level-bar .timer.paused { color: var(--red); }
.level-sub { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin: 6px 4px 0; }
.felt-area {
position: relative;
flex: 1;
min-height: 400px;
margin: 8px 0;
}
.felt {
position: absolute;
left: 50%;
top: 50%;
width: 62%;
height: 72%;
transform: translate(-50%, -50%);
border-radius: 999px;
background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-dark) 100%);
box-shadow: 0 0 0 14px rgba(46, 204, 113, 0.18), inset 0 0 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.pot-label { font-weight: 900; color: rgba(0, 0, 0, 0.3); letter-spacing: 0.1em; font-size: 14px; }
.pot {
font-size: 40px;
font-weight: 900;
color: var(--gold);
text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3);
line-height: 1;
}
.phase {
margin-top: 8px;
background: rgba(0, 0, 0, 0.18);
color: #fff;
font-weight: 900;
letter-spacing: 0.08em;
font-size: 13px;
padding: 5px 14px;
border-radius: 999px;
}
.hand-no { font-size: 11px; color: rgba(0, 0, 0, 0.45); font-weight: 700; margin-top: 2px; }
.seat {
position: absolute;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
width: 92px;
transition: left 0.3s, top 0.3s;
}
.avatar {
width: 58px;
height: 58px;
border-radius: 50%;
background: #3b4a5c;
border: 3px solid #8a96a3;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.avatar svg { width: 28px; height: 28px; fill: #fff; }
.seat.me .avatar { background: #d35400; border-color: var(--gold); }
.seat.offline .avatar { opacity: 0.45; }
.nameplate {
margin-top: -10px;
background: rgba(10, 30, 20, 0.88);
border-radius: 10px;
padding: 4px 8px;
min-width: 84px;
max-width: 92px;
text-align: center;
position: relative;
}
.nameplate .pname {
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nameplate .pstack { font-size: 13px; font-weight: 800; color: var(--gold); }
.chip-marker {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #222;
}
.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; }
.blind-marker {
position: absolute;
top: -4px;
left: -10px;
font-size: 10px;
font-weight: 900;
padding: 2px 5px;
border-radius: 6px;
color: #111;
}
.blind-marker.sb { background: #7fd3ff; }
.blind-marker.bb { background: var(--gold); }
.host-mark { position: absolute; bottom: -2px; right: -6px; font-size: 14px; }
.offline-tag { font-size: 10px; color: var(--muted); }
.spectator-note {
text-align: center;
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.action-bar {
background: #161c19;
padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
}
.action-bar .inner { max-width: 480px; margin: 0 auto; }
.my-stack {
text-align: center;
font-size: 28px;
font-weight: 900;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.my-stack .wallet { color: var(--gold); font-size: 22px; }
.actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.actions .btn { background: #555; color: #bbb; font-size: 18px; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 10;
}
.modal { background: var(--bg-deep); border-radius: var(--radius); padding: 20px; width: 100%; max-width: 380px; }
.modal h2 { font-size: 18px; margin-bottom: 4px; }
.toast {
position: fixed;
left: 50%;
bottom: 160px;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
font-size: 14px;
z-index: 20;
pointer-events: none;
max-width: calc(100% - 32px);
text-align: center;
}
.center-message { text-align: center; padding: 60px 16px; color: var(--muted); }
.center-message h2 { color: var(--text); margin-bottom: 12px; }
.conn-lost {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--red);
color: #fff;
text-align: center;
font-size: 13px;
font-weight: 700;
padding: 6px;
z-index: 30;
}
[hidden] { display: none !important; }
/* ---------- ユーティリティ(CSP で style 属性を使わないため) ---------- */
.text-center { text-align: center; }
.mt-4 { margin-top: 4px; }
.mt-8 { margin-top: 8px; }
.mt-12 { margin-top: 12px; }
.key-title { font-size: 18px; text-align: center; }
.key-hint { text-align: center; margin: 6px 0 16px; }
.slash { flex: none !important; color: var(--muted); font-weight: 800; }
label.switch { margin: 0; display: inline-block; }
.link-gold { color: var(--gold); }
.host-spacer { height: 12px; }
+187
View File
@@ -0,0 +1,187 @@
// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ
(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();
})();