M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy

- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장),
  링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정),
  오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내),
  결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동)
- WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리,
  ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리
- @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip
- e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더
- deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example
- 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:37:33 +09:00
parent b1f3d7c500
commit 17b1d0fcda
46 changed files with 3590 additions and 90 deletions

View File

@@ -7,6 +7,8 @@ export interface Config {
discord: { clientId: string; clientSecret: string } | null;
sessionSecret: string;
secureCookies: boolean;
/** Trust X-Forwarded-For (only when running behind our reverse proxy). */
trustProxy: boolean;
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
@@ -26,5 +28,6 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
secureCookies: publicOrigin.startsWith('https://'),
trustProxy: env.TRUST_PROXY === '1',
};
}

View File

@@ -45,7 +45,9 @@ export function startServer(opts: StartOptions) {
let ready = true;
const ipOf = (req: Request) =>
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || server?.requestIP(req)?.address || 'unknown';
(config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) ||
server?.requestIP(req)?.address ||
'unknown';
const app = createHttpApp({
config,

View File

@@ -185,6 +185,9 @@ export class Gateway {
case 'rematch':
e = room.rematch(u);
break;
case 'void':
e = room.voidGame(u);
break;
case 'sync':
room.sendSync(this.connFor(ws));
return;

19
apps/web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#F7F5F0" />
<meta name="description" content="광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임" />
<meta property="og:title" content="같이 놀자 보드게임" />
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>같이 놀자 보드게임</title>
</head>
<body>
<div id="root"></div>
<noscript>이 사이트를 쓰려면 자바스크립트를 켜 주세요.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -10,11 +10,13 @@
},
"dependencies": {
"@bg/shared": "workspace:*",
"pretendard": "1.3.9",
"qrcode-generator": "2.0.4",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0",
"zustand": "5.0.15"
"zustand": "5.0.15",
"@bg/games": "workspace:*"
},
"devDependencies": {
"@types/react": "19.3.0",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -0,0 +1,82 @@
/** 6-digit code entry: big boxes, numeric keypad, paste detection, auto-submit (docs/06 §3). */
import { useEffect, useRef, useState } from 'react';
import { normalizeRoomCode } from '@bg/shared/lite';
export function CodeInput({ onComplete, busy }: { onComplete: (code: string) => void; busy?: boolean }) {
const [digits, setDigits] = useState('');
const [clip, setClip] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
// Offer a big paste button if the clipboard already holds a code or invite link.
navigator.clipboard
?.readText?.()
.then((t) => setClip(normalizeRoomCode(t)))
.catch(() => {});
}, []);
const set = (v: string) => {
const code = normalizeRoomCode(v);
const next = code ?? v.replace(/\D/g, '').slice(0, 6);
setDigits(next);
if (next.length === 6) onComplete(next);
};
return (
<div className="stack">
<label htmlFor="code-input" className="sr-only">
방 코드 6자리
</label>
<div style={{ position: 'relative' }} onClick={() => inputRef.current?.focus()}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 6 }} aria-hidden>
{[0, 1, 2, -1, 3, 4, 5].map((i, k) =>
i < 0 ? (
<span key={k} style={{ alignSelf: 'center', textAlign: 'center', fontSize: '1.4rem', color: 'var(--text-sub)' }}>
·
</span>
) : (
<span
key={k}
style={{
height: 64,
borderRadius: 12,
border: `2px solid ${i === digits.length ? 'var(--primary)' : 'var(--border)'}`,
background: 'var(--surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
}}
>
{digits[i] ?? ''}
</span>
),
)}
</div>
<input
id="code-input"
ref={inputRef}
value={digits}
onChange={(e) => set(e.target.value)}
onPaste={(e) => {
e.preventDefault();
set(e.clipboardData.getData('text'));
}}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={40}
disabled={busy}
style={{ position: 'absolute', inset: 0, opacity: 0, fontSize: 16 }}
/>
</div>
{clip && digits.length < 6 && (
<button className="btn btn-soft btn-block" onClick={() => set(clip)}>
📋 복사한 코드 {clip.slice(0, 3)} {clip.slice(3)} 붙여넣기
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
/** One-field start: nickname (pre-filled suggestion) + start button; Discord as secondary (docs/05 §1). */
import { useState } from 'react';
import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/lite';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) {
const [nick, setNick] = useState(() => suggestNickname());
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const discord = useSession((s) => s.discordEnabled);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const n = normalizeNickname(nick);
const err = nicknameError(n);
if (err) return setError(err);
setBusy(true);
try {
const r = await api.guest(n);
useSession.getState().setMe(r.me);
await useSession.getState().refresh();
onDone?.();
} catch (ex) {
setError((ex as Error).message);
} finally {
setBusy(false);
}
};
return (
<form className="stack" onSubmit={submit}>
<label className="field">
내 이름(닉네임)
<input
className="input"
value={nick}
onChange={(e) => {
setNick(e.target.value);
setError(null);
}}
onFocus={(e) => e.target.select()}
maxLength={20}
autoComplete="nickname"
enterKeyHint="go"
/>
</label>
{error && <p className="field-error">{error}</p>}
<button className="btn btn-primary btn-block" disabled={busy}>
{busy ? '잠시만요…' : submitLabel}
</button>
{discord && (
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
디스코드로 로그인
</a>
)}
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
</form>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { Sheet, CodeLabel } from './ui';
/** QR is generated lazily so the library is not in the first-page bundle. */
export function QrModal({ url, code, onClose }: { url: string; code: string; onClose: () => void }) {
const [cells, setCells] = useState<{ n: number; path: string } | null>(null);
useEffect(() => {
let alive = true;
import('qrcode-generator').then(({ default: qrcode }) => {
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
const n = qr.getModuleCount();
let path = '';
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) path += `M${x} ${y}h1v1h-1z`;
if (alive) setCells({ n, path });
});
return () => {
alive = false;
};
}, [url]);
return (
<Sheet onClose={onClose} label="QR 코드">
<h2>휴대폰 카메라로 찍으면 들어와요</h2>
<div style={{ background: '#fff', borderRadius: 16, padding: 12, width: 'min(80vw, 320px)', aspectRatio: '1', alignSelf: 'center' }}>
{cells ? (
<svg viewBox={`-2 -2 ${cells.n + 4} ${cells.n + 4}`} width="100%" height="100%" role="img" aria-label="초대 QR 코드" shapeRendering="crispEdges">
<path d={cells.path} fill="#000" />
</svg>
) : (
<p className="muted">만드는 중…</p>
)}
</div>
<p style={{ textAlign: 'center', fontSize: '1.6rem', fontWeight: 700 }}>
코드 <CodeLabel code={code} />
</p>
<button className="btn btn-secondary btn-block" onClick={onClose}>
닫기
</button>
</Sheet>
);
}

View File

@@ -0,0 +1,44 @@
/** Ring countdown to a server deadline (docs/07 §4). */
import { useEffect, useRef, useState } from 'react';
import { socket } from '../net/socket';
import { haptic, sounds } from '../lib/feedback';
export function useServerNow(intervalMs = 250): number {
const [now, setNow] = useState(() => socket.now());
useEffect(() => {
const t = setInterval(() => setNow(socket.now()), intervalMs);
return () => clearInterval(t);
}, [intervalMs]);
return now;
}
export function TurnTimer({ deadline, totalMs, mine, size = 44 }: { deadline: number | null; totalMs: number; mine: boolean; size?: number }) {
const now = useServerNow();
const warned = useRef<number | null>(null);
const left = deadline ? Math.max(0, deadline - now) : null;
const sec = left === null ? null : Math.ceil(left / 1000);
useEffect(() => {
if (!mine || sec === null) return;
if ((sec === 10 || sec === 5) && warned.current !== sec) {
warned.current = sec;
sounds.warn();
if (sec === 5) haptic([60, 40, 60]);
}
}, [sec, mine]);
if (left === null) return null;
const frac = Math.min(1, left / Math.max(1, totalMs));
const color = sec! <= 5 ? 'var(--danger)' : sec! <= 10 ? 'var(--warning)' : 'var(--success)';
const r = size / 2 - 4;
const c = 2 * Math.PI * r;
return (
<span style={{ position: 'relative', width: size, height: size, display: 'inline-flex' }} role="timer" aria-label={`남은 시간 ${sec}초`}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }} aria-hidden>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-2)" strokeWidth={4} />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={4} strokeDasharray={c} strokeDashoffset={c * (1 - frac)} strokeLinecap="round" />
</svg>
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: size > 40 ? '0.95rem' : '0.8rem', color }}>
{sec! >= 600 ? `${Math.floor(sec! / 60)}분` : sec! >= 100 ? `${Math.floor(sec! / 60)}:${String(sec! % 60).padStart(2, '0')}` : sec}
</span>
</span>
);
}

View File

@@ -0,0 +1,163 @@
/** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } from '@bg/shared/lite';
import { formatRoomCode } from '@bg/shared/lite';
import { useToasts, toast } from '../store/toast';
import { useRoom } from '../net/socket';
import { haptic } from '../lib/feedback';
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nickname' | 'avatar'>; size?: number }) {
let h = 0;
for (const ch of user.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
return (
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]}
</span>
);
}
export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: string; className?: string }) {
const [copied, setCopied] = useState(false);
const onClick = async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for old browsers / insecure contexts.
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
if (!ok) {
toast(`복사가 안 돼요. 직접 적어 주세요: ${text}`, 'error', 6000);
return;
}
}
haptic(30);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button type="button" className={className} onClick={onClick} aria-live="polite">
{copied ? done : label}
</button>
);
}
export function ShareButton({ title, text, url, className = 'btn btn-secondary' }: { title: string; text: string; url: string; className?: string }) {
const canShare = typeof navigator !== 'undefined' && 'share' in navigator;
if (!canShare) return <CopyButton text={`${text}\n${url}`} label="💬 초대글 복사" className={className} />;
return (
<button
type="button"
className={className}
onClick={() => navigator.share({ title, text, url }).catch(() => {})}
>
📤 공유하기
</button>
);
}
export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
ref.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
prev?.focus?.();
};
}, [onClose]);
return (
<div className="sheet-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="sheet" role="dialog" aria-modal="true" aria-label={label} tabIndex={-1} ref={ref}>
{children}
</div>
</div>
);
}
export function ConfirmSheet({
title,
body,
confirm,
cancel = '취소',
danger,
onConfirm,
onClose,
}: {
title: string;
body?: ReactNode;
confirm: string;
cancel?: string;
danger?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<Sheet onClose={onClose} label={title}>
<h2>{title}</h2>
{body && <div className="muted">{body}</div>}
<div className="stack">
<button
className={`btn btn-block ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirm}
</button>
<button className="btn btn-block btn-secondary" onClick={onClose}>
{cancel}
</button>
</div>
</Sheet>
);
}
export function Toasts() {
const toasts = useToasts((s) => s.toasts);
return (
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.kind === 'error' ? 'error' : ''}`}>
{t.text}
</div>
))}
</div>
);
}
export function ConnectionBanner() {
const status = useRoom((s) => s.status);
const code = useRoom((s) => s.code);
if (!code) return null;
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
return null;
}
export function CodeLabel({ code }: { code: string }) {
return <span style={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '0.08em' }}>{formatRoomCode(code)}</span>;
}
export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) {
return (
<header className="topbar">
{onBack && (
<button className="icon-btn" onClick={onBack} aria-label="뒤로">
←<span className="lbl">뒤로</span>
</button>
)}
<div className="topbar-title">{title}</div>
{right}
</header>
);
}

View File

@@ -0,0 +1,431 @@
/** 오목 화면 (docs/games/omok.md §9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { OmokOptions, OmokView, Pt } from '@bg/games/omok';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
type Color = 'black' | 'white';
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
const COLS = 'ABCDEFGHIJKLMNOPQRS';
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
const RULE_KO: Record<OmokOptions['ruleSet'], string> = { korean: '한국식', renju: '렌주룰', free: '자유룰' };
function stars(size: number): Pt[] {
const s = size === 19 ? [3, 9, 15] : [3, 7, 11];
return s.flatMap((x) => s.map((y) => ({ x, y })));
}
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
return (
<g opacity={ghost ? 0.55 : 1}>
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.06} />
{color === 'black' ? (
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
) : (
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
)}
</g>
);
}
function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps<OmokView>) {
const n = v.size;
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
const [preview, setPreview] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
const [announce, setAnnounce] = useState('');
const [kbd, setKbd] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const prevTurn = useRef(v.turn);
const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]);
// Sounds, vibration and screen-reader announcements on new moves.
useEffect(() => {
if (v.moveCount > prevCount.current && v.lastMove) {
sounds.stone();
const who = v.moves[v.moves.length - 1]!.color;
setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`);
}
if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
prevCount.current = v.moveCount;
prevTurn.current = v.turn;
setPreview(null);
}, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps
const canPlace = (p: Pt) =>
myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2));
const place = (p: Pt) => {
act({ type: 'place', x: p.x, y: p.y });
setPreview(null);
};
const tap = (p: Pt) => {
setCursor(p);
if (!myTurn) return;
if (v.board[p.y * n + p.x] !== 0) return;
if (forbidden.has(p.y * n + p.x)) {
haptic([30, 30, 30]);
setAnnounce('여기는 금수 자리라 둘 수 없어요.');
}
if (!tapConfirm) {
place(p);
return;
}
if (preview && preview.x === p.x && preview.y === p.y) place(p);
else setPreview(p);
};
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
const svg = svgRef.current;
if (!svg) return null;
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
const x = Math.round(loc.x);
const y = Math.round(loc.y);
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
};
const onKey = (e: React.KeyboardEvent) => {
setKbd(true);
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
if (d[e.key]) {
e.preventDefault();
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (canPlace(cursor)) place(cursor);
}
};
const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x));
const incoming =
v.phase === 'playing' && v.myColor
? v.undo.pending && v.undo.pending.by !== v.myColor
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
? 'draw'
: null
: null;
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
const opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null;
const pad = 0.6;
return (
<div className="stack" style={{ gap: 10 }}>
<PlayerBar view={v} color={opp ?? 'white'} players={players} deadline={deadline} me={me} />
<div className="board-wrap">
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default' }}
onPointerUp={(e) => {
setKbd(false);
const p = pointToCell(e);
if (p) tap(p);
}}
tabIndex={0}
role="grid"
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
onKeyDown={onKey}
>
{Array.from({ length: n }, (_, i) => (
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
<line x1={0} y1={i} x2={n - 1} y2={i} />
<line x1={i} y1={0} x2={i} y2={n - 1} />
</g>
))}
{stars(n).map((p) => (
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
))}
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
{v.forbidden.map((p) => (
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
</g>
))}
{v.board.map((c, i) =>
c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />,
)}
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
{[...winSet].map((i) => (
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
))}
{preview && v.myColor && <Stone color={v.myColor} cx={preview.x} cy={preview.y} r={0.46} ghost />}
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} className="kbd-cursor" />}
</svg>
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{preview && myTurn && (
<div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
{forbidden.has(preview.y * n + preview.x) ? '여기는 둘 수 없어요' : `${label(n, preview)}에 두기`}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
취소
</button>
</div>
)}
<PlayerBar view={v} color={v.myColor ?? 'black'} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && v.myColor && (
<div className="row" style={{ justifyContent: 'center' }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
onClick={() => act({ type: 'requestUndo' })}
>
↩ 무르기{undoLeft !== null ? ` (${undoLeft}번 남음)` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
🤝 무승부 제안
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
)}
{outgoing && (
<div className="card row" style={{ justifyContent: 'space-between' }}>
<span>상대의 답을 기다리는 중이에요…</span>
<button className="btn btn-ghost" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">{incoming === 'undo' ? '수락하면 돌이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
수락
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
거절
</button>
</div>
</Sheet>
)}
{confirm === 'resign' && (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
)}
{confirm === 'draw' && (
<ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />
)}
</div>
);
}
function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const id = v.players[color];
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = v.phase === 'playing' && v.turn === color;
const now = useServerNow(500);
const tc = v.timeControl;
let clock: React.ReactNode = null;
if (tc.kind === 'fischer') {
const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0));
const s = Math.ceil(ms / 1000);
clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
}
return (
<div
className="card row"
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
>
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
<Stone color={color} cx={0} cy={0} r={0.9} />
</svg>
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{id === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small">{COLOR_KO[color]}{active ? ' · 두는 중' : ''}</div>
</div>
{clock}
</div>
);
}
function statusText(v: OmokView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
if (v.myColor === v.turn) {
if (v.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요';
return `내 차례예요 (${COLOR_KO[v.turn]}). 놓을 곳을 눌러 주세요`;
}
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
const TIME_PRESETS: [string, string, Partial<OmokOptions['timeControl']>][] = [
['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }],
['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }],
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }],
['none', '시간 제한 없음', { kind: 'none' }],
];
function timeKey(tc: OmokOptions['timeControl']): string {
if (tc.kind === 'none') return 'none';
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
return `f${Math.round(tc.baseSec / 60)}`;
}
function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<OmokOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>규칙</strong>
<Seg value={o.ruleSet} options={[['korean', '한국식(쌍삼 금지)'], ['renju', '렌주룰'], ['free', '자유룰']]} onChange={(ruleSet) => set({ ruleSet })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>시간</strong>
<Seg
value={timeKey(o.timeControl)}
options={TIME_PRESETS.map(([k, l]) => [k, l] as [string, string])}
onChange={(k) => set({ timeControl: { ...o.timeControl, ...TIME_PRESETS.find((p) => p[0] === k)![2] } })}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>흑(먼저 두는 쪽)</strong>
<Seg
value={o.colorAssignment}
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
onChange={(colorAssignment) => set({ colorAssignment })}
/>
</div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<div className="stack" style={{ gap: 6 }}>
<strong>판 크기</strong>
<Seg value={o.boardSize} options={[[15, '15줄'], [19, '19줄']]} onChange={(boardSize) => set({ boardSize })} />
</div>
{o.ruleSet === 'free' && (
<div className="stack" style={{ gap: 6 }}>
<strong>자유룰 승리</strong>
<Seg value={o.freeWin} options={[['fivePlus', '5목 이상'], ['exactFive', '정확히 5목']]} onChange={(freeWin) => set({ freeWin })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>무르기</strong>
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</div>
{o.timeControl.kind === 'perMove' && (
<div className="stack" style={{ gap: 6 }}>
<strong>시간이 다 되면</strong>
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>첫 수 가운데</strong>
<Seg value={o.firstMoveCenter} options={[['auto', '렌주만'], ['on', '항상'], ['off', '안 함']]} onChange={(firstMoveCenter) => set({ firstMoveCenter })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>도움 표시</strong>
<Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} />
<Seg value={o.hints} options={[['warn', '위험한 자리 알려 주기'], ['off', '알려 주지 않기']]} onChange={(hints) => set({ hints })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
</>
)}
</div>
);
}
function optionsSummary(o: OmokOptions): string {
const t = TIME_PRESETS.find((p) => p[0] === timeKey(o.timeControl))?.[1] ?? '';
return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · ');
}
function Rules({ options }: { options?: OmokOptions }) {
const rule = options?.ruleSet ?? 'korean';
return (
<div className="stack">
<p>흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 <strong>5개</strong>를 먼저 이으면 이겨요. 흑이 먼저 둬요.</p>
{rule === 'korean' && (
<p>
<strong>한국식</strong>: 흑과 백 모두 <strong>쌍삼(열린 3을 두 개 동시에 만드는 수)</strong>을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요.
</p>
)}
{rule === 'renju' && (
<p>
<strong>렌주룰</strong>: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요.
</p>
)}
{rule === 'free' && (
<p>
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
</p>
)}
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).</p>
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
</div>
);
}
const ui: GameUI<OmokView, OmokOptions> = {
Board: OmokBoard,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,22 @@
import type { GameUI } from './types';
/** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
omok: () => import('./omok'),
};
const cache = new Map<string, GameUI>();
export async function loadGameUI(id: string): Promise<GameUI | null> {
const hit = cache.get(id);
if (hit) return hit;
const loader = GAME_UI[id];
if (!loader) return null;
const mod = (await loader()).default;
cache.set(id, mod);
return mod;
}
export function cachedGameUI(id: string): GameUI | null {
return cache.get(id) ?? null;
}

View File

@@ -0,0 +1,27 @@
import type { ComponentType } from 'react';
import type { PublicUser } from '@bg/shared/lite';
export interface BoardProps<V = unknown> {
view: V;
me: string | null;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
deadline: number | null;
active: string[];
tapConfirm: boolean;
}
export interface OptionsProps<O = unknown> {
value: O;
onChange: (next: O) => void;
}
export interface GameUI<V = any, O = any> {
Board: ComponentType<BoardProps<V>>;
statusText(view: V, me: string | null, players: Record<string, PublicUser>): string;
isMyTurn(view: V, me: string | null): boolean;
Options: ComponentType<OptionsProps<O>>;
optionsSummary(options: O): string;
Rules: ComponentType<{ options?: O }>;
}

View File

@@ -0,0 +1,13 @@
export const GAME_ICON: Record<string, string> = {
omok: '⚫',
baduk: '⚪',
chess: '♟️',
janggi: '🀄',
seotda: '🎴',
poker: '🃏',
mahjong: '🀇',
yut: '🪵',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -0,0 +1,48 @@
/** Tiny synthesized sounds + vibration (no audio files; docs/07 §6). */
import { useSession } from '../store/session';
let ctx: AudioContext | null = null;
function audio(): AudioContext | null {
if (!useSession.getState().settings.sound) return null;
try {
ctx ??= new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
}
}
function tone(freq: number, ms: number, type: OscillatorType = 'sine', gain = 0.15, delay = 0): void {
const a = audio();
if (!a) return;
const t = a.currentTime + delay / 1000;
const o = a.createOscillator();
const g = a.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000);
o.connect(g).connect(a.destination);
o.start(t);
o.stop(t + ms / 1000 + 0.02);
}
export const sounds = {
stone: () => tone(900, 70, 'triangle', 0.25),
myTurn: () => {
tone(660, 120);
tone(880, 160, 'sine', 0.15, 120);
},
warn: () => tone(520, 90, 'square', 0.06),
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)),
lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)),
};
export function haptic(pattern: number | number[]): void {
if (!useSession.getState().settings.vibrate) return;
try {
navigator.vibrate?.(pattern);
} catch {}
}

64
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { StrictMode, Suspense, lazy, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router';
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css';
import './styles/global.css';
import { ConnectionBanner, Toasts } from './components/ui';
import { useSession } from './store/session';
import { toast } from './store/toast';
import { Home } from './pages/Home';
const JoinPage = lazy(() => import('./pages/JoinPage'));
const RoomPage = lazy(() => import('./pages/RoomPage'));
const GamePage = lazy(() => import('./pages/GamePage'));
const MePage = lazy(() => import('./pages/MePage'));
const RulesPage = lazy(() => import('./pages/RulesPage'));
const NotFound = lazy(() => import('./pages/NotFound'));
function Shell() {
const loaded = useSession((s) => s.loaded);
const nav = useNavigate();
const loc = useLocation();
useEffect(() => {
void useSession.getState().refresh();
const onUnauthorized = () => void useSession.getState().refresh();
window.addEventListener('bg:unauthorized', onUnauthorized);
return () => window.removeEventListener('bg:unauthorized', onUnauthorized);
}, []);
useEffect(() => {
const q = new URLSearchParams(loc.search);
if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.');
if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error');
if (q.has('linked') || q.has('login')) {
q.delete('linked');
q.delete('login');
nav({ pathname: loc.pathname, search: q.toString() }, { replace: true });
}
}, [loc.search]); // eslint-disable-line react-hooks/exhaustive-deps
if (!loaded) return <div className="page muted">불러오는 중…</div>;
return (
<>
<ConnectionBanner />
<Toasts />
<Suspense fallback={<div className="page muted">불러오는 중…</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/j" element={<JoinPage />} />
<Route path="/r/:code" element={<RoomPage />} />
<Route path="/g/:gameId" element={<GamePage />} />
<Route path="/rules/:gameId" element={<RulesPage />} />
<Route path="/me" element={<MePage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</>
);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<Shell />
</BrowserRouter>
</StrictMode>,
);

72
apps/web/src/net/api.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
return data;
}
export interface Settings {
fontScale?: 'normal' | 'large' | 'xlarge';
theme?: 'auto' | 'light' | 'dark';
sound?: boolean;
vibrate?: boolean;
colorBlind?: boolean;
tapConfirm?: boolean;
}
export interface MeResponse {
me: PublicUser | null;
settings?: Settings;
useAvatar?: boolean;
hasDiscordAvatar?: boolean;
activeRoom?: string | null;
}
export interface RoomPreview {
code: string;
codeLabel: string;
gameId: string;
host: string;
seated: number;
maxPlayers: number;
status: string;
}
export const api = {
config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'),
me: () => call<MeResponse>('GET', '/api/me'),
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
publicRooms: (gameId: string) =>
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
};
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;

344
apps/web/src/net/socket.ts Normal file
View File

@@ -0,0 +1,344 @@
/**
* WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8).
* Room/game state lives in the zustand store below; components only read it.
*/
import { create } from 'zustand';
import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite';
import { toast } from '../store/toast';
export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline';
export interface ChatLine {
id: number;
from: PublicUser | null;
text: string;
at: number;
}
export interface GameState {
seq: number;
view: unknown;
active: string[];
deadline: number | null;
events: unknown[];
}
interface RoomStore {
status: ConnStatus;
me: PublicUser | null;
code: string | null;
room: RoomView | null;
roomSeq: number;
game: GameState | null;
result: GameResultView | null;
chat: ChatLine[];
emotes: { id: number; from: string; emote: string }[];
pending: number | null;
slow: boolean;
rttMs: number | null;
clockOffset: number;
joinError: string | null;
kicked: boolean;
needsRefresh: boolean;
}
export const useRoom = create<RoomStore>(() => ({
status: 'idle',
me: null,
code: null,
room: null,
roomSeq: -1,
game: null,
result: null,
chat: [],
emotes: [],
pending: null,
slow: false,
rttMs: null,
clockOffset: 0,
joinError: null,
kicked: false,
needsRefresh: false,
}));
const BACKOFF = [500, 1000, 2000, 4000, 8000];
const MAX_BACKOFF = 10_000;
const PING_EVERY = 10_000;
const PONG_TIMEOUT = 5_000;
class GameSocket {
private ws: WebSocket | null = null;
private attempt = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
private offsets: number[] = [];
private wanted = false;
private cs = Date.now();
private chatId = 1;
private onMessageHooks = new Set<(m: ServerMessage) => void>();
constructor() {
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.kick();
});
window.addEventListener('online', () => this.kick());
window.addEventListener('offline', () => useRoom.setState({ status: 'offline' }));
}
}
/** Start (or keep) a connection. Safe to call repeatedly. */
start(): void {
this.wanted = true;
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.open();
}
stop(): void {
this.wanted = false;
this.clearTimers();
this.ws?.close();
this.ws = null;
useRoom.setState({ status: 'idle' });
}
/** Reconnect immediately if we should be connected but are not. */
private kick(): void {
if (!this.wanted) return;
if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
if (this.retryTimer) clearTimeout(this.retryTimer);
this.retryTimer = null;
this.open();
} else if (this.ws.readyState === WebSocket.OPEN) {
this.ping();
}
}
private open(): void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
this.ws = ws;
useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' });
ws.onopen = () => {
this.attempt = 0;
this.pingTimer = setInterval(() => this.ping(), PING_EVERY);
};
ws.onmessage = (e) => {
let m: ServerMessage;
try {
m = JSON.parse(String(e.data));
} catch {
return;
}
this.handle(m);
for (const h of this.onMessageHooks) h(m);
};
ws.onclose = (e) => {
if (this.ws !== ws) return;
this.clearTimers();
this.ws = null;
const st = useRoom.getState().status;
if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return;
if (e.code === 4401 || e.code === 1008) {
// Not logged in (session expired): stop and let the page ask for a nickname.
useRoom.setState({ status: 'idle' });
this.wanted = false;
window.dispatchEvent(new Event('bg:unauthorized'));
return;
}
if (this.attempt >= 1) {
// Repeated failures may mean the session is gone (the upgrade got a 401).
fetch('/api/me', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d: { me: unknown }) => {
if (d.me === null) {
this.wanted = false;
useRoom.setState({ status: 'idle' });
window.dispatchEvent(new Event('bg:unauthorized'));
}
})
.catch(() => {});
}
this.scheduleReconnect();
};
}
private scheduleReconnect(extraDelay = 0): void {
const base = BACKOFF[this.attempt] ?? MAX_BACKOFF;
const jitter = base * (0.7 + Math.random() * 0.6);
this.attempt++;
useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' });
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
if (this.wanted) this.open();
}, jitter + extraDelay);
}
private clearTimers(): void {
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pingTimer = null;
this.pongTimer = null;
}
private ping(): void {
if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return;
this.send({ t: 'ping', ts: Date.now() });
this.pongTimer = setTimeout(() => {
this.pongTimer = null;
// Dead connection: force a reconnect.
this.ws?.close();
}, PONG_TIMEOUT);
}
send(m: unknown): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(m));
return true;
}
subscribe(fn: (m: ServerMessage) => void): () => void {
this.onMessageHooks.add(fn);
return () => this.onMessageHooks.delete(fn);
}
// ------------------------------------------------------------- room commands
join(code: string, as: 'player' | 'spectator' = 'player'): void {
const cur = useRoom.getState();
if (cur.code !== code) {
useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null });
}
this.start();
this.send({ t: 'join', code, as });
}
leave(): void {
this.send({ t: 'leave' });
useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null });
}
/** Sends a game action; returns false when not connected. */
act(a: unknown): boolean {
const cs = ++this.cs;
if (!this.send({ t: 'act', cs, a })) {
toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error');
return false;
}
useRoom.setState({ pending: cs, slow: false });
if (this.pendingTimer) clearTimeout(this.pendingTimer);
this.pendingTimer = setTimeout(() => {
if (useRoom.getState().pending === cs) {
useRoom.setState({ slow: true });
this.ping();
}
}, 2000);
return true;
}
/** Server time estimate. */
now(): number {
return Date.now() + useRoom.getState().clockOffset;
}
// ------------------------------------------------------------- inbound
private handle(m: ServerMessage): void {
const st = useRoom.getState();
switch (m.t) {
case 'welcome': {
if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true });
useRoom.setState({ status: 'open', me: m.me });
this.offsets = [m.serverTime - Date.now()];
useRoom.setState({ clockOffset: this.offsets[0]! });
// Re-join the room we were in (reconnect path).
if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' });
break;
}
case 'room':
if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) {
useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null });
if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null });
}
break;
case 'state': {
if (st.game && m.seq < st.game.seq) break; // stale
const patch: Partial<RoomStore> = {
game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events },
};
if (m.cs !== undefined && m.cs === st.pending) {
patch.pending = null;
patch.slow = false;
}
useRoom.setState(patch);
break;
}
case 'reject':
if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false });
toast(m.reason, 'error');
break;
case 'result':
useRoom.setState({ result: m.result });
break;
case 'chat':
useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) });
break;
case 'emote': {
const id = this.chatId++;
useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) });
setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500);
break;
}
case 'notice':
toast(m.message);
break;
case 'error':
if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message });
else toast(m.message, 'error');
break;
case 'pong': {
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pongTimer = null;
const now = Date.now();
const rtt = now - m.ts;
const offset = m.serverTime + rtt / 2 - now;
this.offsets = [...this.offsets, offset].slice(-5);
const sorted = [...this.offsets].sort((a, b) => a - b);
useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! });
if (st.slow && st.pending) {
// Connection is alive but our action got no answer: resync instead of resending.
this.send({ t: 'sync' });
useRoom.setState({ pending: null, slow: false });
}
break;
}
case 'bye':
if (m.reason === 'restart') {
toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.');
this.attempt = 0;
this.ws?.close();
// onclose schedules a reconnect; add 1–3s so clients don't all return at once.
this.ws = null;
this.clearTimers();
this.scheduleReconnect(1000 + Math.random() * 2000);
} else if (m.reason === 'replaced') {
useRoom.setState({ status: 'replaced' });
} else if (m.reason === 'kicked') {
useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' });
}
break;
}
}
/** "Use here" after another tab took over. */
reclaim(): void {
useRoom.setState({ status: 'connecting' });
this.wanted = true;
this.attempt = 0;
this.open();
}
}
export const socket = new GameSocket();

View File

@@ -0,0 +1,103 @@
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { catalogById, formatRoomCode } from '@bg/shared/lite';
import { Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
import { GAME_ICON } from '../lib/catalog-ui';
export default function GamePage() {
const { gameId = '' } = useParams();
const g = catalogById(gameId);
const nav = useNavigate();
const me = useSession((s) => s.me);
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!g) return;
const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps
if (!g) return <main className="page">없는 게임이에요.</main>;
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const run = (p: () => Promise<{ code: string }>) =>
withName(async () => {
setBusy(true);
try {
nav(`/r/${(await p()).code}`);
} catch (e) {
toast((e as Error).message, 'error');
} finally {
setBusy(false);
}
});
return (
<>
<TopBar title={g.nameKo} onBack={() => nav('/')} />
<main className="page">
<div className="row" style={{ gap: 16 }}>
<span style={{ fontSize: '3.5rem' }} aria-hidden>
{GAME_ICON[g.id] ?? '🎲'}
</span>
<div className="stack" style={{ gap: 4 }}>
<h1>{g.nameKo}</h1>
<p className="muted">{g.blurb}</p>
<p className="small">
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · 약 {g.minutes}분 · {g.level}
</p>
</div>
</div>
<div className="stack">
<button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}>
+ 친구랑 하기 (방 만들기)
</button>
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
⚡ 아무나와 빠른 시작
</button>
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
📖 규칙 보기
</Link>
</div>
<section className="stack">
<h2>기다리는 방</h2>
{rooms.length === 0 && <p className="muted">지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between' }}>
<div>
<strong>{r.host}님의 방</strong>
<div className="muted small">
{r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</section>
{needName && (
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
<h2>먼저 이름을 정해 주세요</h2>
<NicknameForm
next={`/g/${g.id}`}
onDone={() => {
const fn = needName;
setNeedName(null);
fn();
}}
/>
</Sheet>
)}
</main>
</>
);
}

109
apps/web/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,109 @@
/** 홈: 큰 버튼 두 개 + 진행 중 게임 + 게임 카드 (docs/06 §2). */
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG } from '@bg/shared/lite';
import { Avatar, Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
import { formatRoomCode } from '@bg/shared/lite';
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
export function Home() {
const { me, activeRoom } = useSession();
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
const nav = useNavigate();
const [picker, setPicker] = useState(false);
const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false);
const available = catalog.filter((g) => g.available);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string) =>
withName(async () => {
setBusy(true);
try {
const { code } = await api.createRoom(gameId);
nav(`/r/${code}`);
} catch (e) {
toast((e as Error).message, 'error');
} finally {
setBusy(false);
}
});
return (
<main className="page">
<header className="row" style={{ justifyContent: 'space-between' }}>
<h1>같이 놀자 보드게임</h1>
<Link to="/me" className="btn btn-ghost" aria-label="내 정보">
{me ? <Avatar user={me} size={36} /> : '👤'} <span className="small">{me ? me.nickname : '내 정보'}</span>
</Link>
</header>
{activeRoom && (
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block">
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})
</Link>
)}
<div className="stack">
<button className="btn btn-primary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }} disabled={busy} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}>
+ 방 만들기
</button>
<Link to="/j" className="btn btn-secondary btn-block" style={{ minHeight: 72, fontSize: '1.25rem' }}>
🔢 코드로 참여하기
</Link>
</div>
<section className="stack">
<h2>게임 고르기</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
{available.map((g) => (
<Link key={g.id} to={`/g/${g.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: '2.4rem' }} aria-hidden>
{GAME_ICON[g.id] ?? '🎲'}
</span>
<strong style={{ fontSize: '1.15rem' }}>{g.nameKo}</strong>
<span className="muted small">{g.blurb}</span>
<span className="small">
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · {g.minutes}분
</span>
</Link>
))}
</div>
</section>
<section className="stack">
<h2>곧 나와요</h2>
<p className="muted">{UPCOMING.join(' · ')}</p>
</section>
{picker && (
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
<h2>어떤 게임을 할까요?</h2>
{available.map((g) => (
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}>
{GAME_ICON[g.id]} {g.nameKo}
</button>
))}
</Sheet>
)}
{needName && (
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
<h2>먼저 이름을 정해 주세요</h2>
<NicknameForm
next="/"
onDone={() => {
const fn = needName;
setNeedName(null);
fn();
}}
/>
</Sheet>
)}
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { CodeInput } from '../components/CodeInput';
import { TopBar } from '../components/ui';
import { api } from '../net/api';
export default function JoinPage() {
const nav = useNavigate();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [key, setKey] = useState(0);
const go = async (code: string) => {
setBusy(true);
setError(null);
try {
await api.room(code);
nav(`/r/${code}`);
} catch (e) {
setError((e as Error).message);
setKey((k) => k + 1); // reset the boxes
} finally {
setBusy(false);
}
};
return (
<>
<TopBar title="코드로 참여하기" onBack={() => nav('/')} />
<main className="page">
<h1>방 코드 6자리를 눌러 주세요</h1>
<p className="muted">방을 만든 사람에게 받은 숫자예요. 다 누르면 바로 들어가요.</p>
<CodeInput key={key} onComplete={go} busy={busy} />
{error && <p className="field-error">{error}</p>}
</main>
</>
);
}

View File

@@ -0,0 +1,206 @@
/** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={k} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
</div>
);
}
export default function MePage() {
const nav = useNavigate();
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession();
const [editing, setEditing] = useState(false);
const [nick, setNick] = useState(me?.nickname ?? '');
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]);
const [confirm, setConfirm] = useState<null | 'logout' | 'logoutAll' | 'delete'>(null);
useEffect(() => {
if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {});
}, [me]);
const saveNick = async () => {
const n = normalizeNickname(nick);
const e = nicknameError(n);
if (e) return setError(e);
try {
const r = await api.updateMe({ nickname: n });
useSession.getState().setMe(r.me);
setEditing(false);
toast('닉네임을 바꿨어요.');
} catch (ex) {
setError((ex as Error).message);
}
};
const display = (
<section className="card stack">
<h2>화면 설정</h2>
<Seg label="글자 크기" value={settings.fontScale} options={[['normal', '보통'], ['large', '크게'], ['xlarge', '아주 크게']]} onChange={(fontScale) => updateSettings({ fontScale })} />
<Seg label="화면 색" value={settings.theme} options={[['auto', '자동'], ['light', '밝게'], ['dark', '어둡게']]} onChange={(theme) => updateSettings({ theme })} />
<Seg label="소리" value={settings.sound ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ sound: v === 'on' })} />
<Seg label="진동" value={settings.vibrate ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ vibrate: v === 'on' })} />
<Seg
label="돌·카드 놓기"
value={settings.tapConfirm ? 'on' : 'off'}
options={[['on', '두 번 눌러 확정(실수 방지)'], ['off', '한 번에 놓기']]}
onChange={(v) => updateSettings({ tapConfirm: v === 'on' })}
/>
</section>
);
if (!me) {
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack">
<h2>시작하기</h2>
<NicknameForm next="/me" />
</section>
{display}
</main>
</>
);
}
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
<Avatar user={me} size={88} />
{editing ? (
<div className="stack" style={{ width: '100%' }}>
<input className="input" value={nick} onChange={(e) => (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" />
{error && <p className="field-error">{error}</p>}
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} onClick={saveNick}>
저장
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => (setEditing(false), setNick(me.nickname), setError(null))}>
취소
</button>
</div>
<p className="muted small">게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.</p>
</div>
) : (
<>
<h1>{me.nickname}</h1>
<button className="btn btn-secondary" onClick={() => (setNick(me.nickname), setEditing(true))}>
✏️ 닉네임 바꾸기
</button>
</>
)}
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
{me.kind === 'guest' && discordEnabled && (
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
디스코드 연결하기 (기록을 안전하게 보관)
</a>
)}
{me.kind === 'member' && hasDiscordAvatar && (
<button
className="btn btn-ghost"
onClick={async () => {
await api.updateMe({ useAvatar: !useAvatar });
await useSession.getState().refresh();
}}
>
{useAvatar ? '디스코드 프로필 사진 숨기기' : '디스코드 프로필 사진 쓰기'}
</button>
)}
</section>
{display}
<section className="card stack">
<h2>내 기록</h2>
{stats.length === 0 ? (
<p className="muted">아직 끝까지 한 게임이 없어요.</p>
) : (
stats.map((s) => (
<div key={s.gameId} className="row" style={{ justifyContent: 'space-between' }}>
<strong>{catalogById(s.gameId)?.nameKo ?? s.gameId}</strong>
<span>
{s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''}
</span>
</div>
))
)}
</section>
<section className="stack">
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
로그아웃
</button>
{me.kind === 'member' && (
<button className="btn btn-ghost btn-block" onClick={() => setConfirm('logoutAll')}>
모든 기기에서 로그아웃
</button>
)}
<button className="btn btn-ghost btn-block small muted" onClick={() => setConfirm('delete')}>
계정 삭제
</button>
</section>
{confirm === 'logout' && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {
await api.logout();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'logoutAll' && (
<ConfirmSheet
title="모든 기기에서 로그아웃할까요?"
confirm="모두 로그아웃"
onConfirm={async () => {
await api.logoutAll();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'delete' && (
<ConfirmSheet
title="계정을 삭제할까요?"
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
confirm="삭제하기"
danger
onConfirm={async () => {
await api.deleteMe();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
</main>
</>
);
}

View File

@@ -0,0 +1,12 @@
import { Link } from 'react-router';
export default function NotFound() {
return (
<main className="page">
<h1>페이지를 찾을 수 없어요</h1>
<Link className="btn btn-primary" to="/">
홈으로
</Link>
</main>
);
}

View File

@@ -0,0 +1,621 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import { create } from 'zustand';
import { useNavigate, useParams } from 'react-router';
import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { QrModal } from '../components/QrModal';
import { api, type RoomPreview } from '../net/api';
import { socket, useRoom } from '../net/socket';
import { useSession } from '../store/session';
import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types';
import { sounds } from '../lib/feedback';
export default function RoomPage() {
const { code = '' } = useParams();
const me = useSession((s) => s.me);
const nav = useNavigate();
const [preview, setPreview] = useState<RoomPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
useEffect(() => {
if (!/^\d{6}$/.test(code)) {
setPreviewError('방 코드가 올바르지 않아요.');
return;
}
if (!me) api.room(code).then(setPreview, (e) => setPreviewError((e as Error).message));
}, [code, me]);
useEffect(() => {
if (me && /^\d{6}$/.test(code)) socket.join(code);
}, [code, me]);
if (!me) {
return (
<>
<TopBar title="방에 들어가기" onBack={() => nav('/')} />
<main className="page">
{previewError ? (
<RoomGone message={previewError} />
) : (
<section className="card stack">
{preview ? (
<>
<h1>
{catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요
</h1>
<p className="muted">
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
</p>
</>
) : (
<p className="muted">방 정보를 불러오는 중…</p>
)}
<NicknameForm next={`/r/${code}`} submitLabel="이 이름으로 입장하기" />
</section>
)}
</main>
</>
);
}
return <Room code={code} me={me} />;
}
function RoomGone({ message }: { message: string }) {
const nav = useNavigate();
return (
<section className="card stack">
<h1>{message}</h1>
<button className="btn btn-primary btn-block" onClick={() => nav('/j')}>
다른 코드 입력하기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
홈으로
</button>
</section>
);
}
function Room({ code, me }: { code: string; me: PublicUser }) {
const nav = useNavigate();
const { room, game, result, status, joinError, pending, slow, needsRefresh } = useRoom();
const [ui, setUi] = useState<GameUI | null>(null);
const [menu, setMenu] = useState(false);
const [leaveAsk, setLeaveAsk] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const gameId = room?.gameId;
useEffect(() => {
if (gameId) loadGameUI(gameId).then(setUi);
}, [gameId]);
const players = useMemo(() => {
const map: Record<string, PublicUser> = {};
for (const s of room?.seats ?? []) if (s) map[s.user.id] = s.user;
for (const u of room?.spectators ?? []) map[u.id] = u;
return map;
}, [room]);
useTurnTitle(ui, game?.view, me.id);
if (status === 'replaced') {
return (
<main className="page">
<section className="card stack">
<h1>다른 창에서 이 방을 열었어요</h1>
<p className="muted">한 번에 한 창에서만 할 수 있어요.</p>
<button className="btn btn-primary btn-block" onClick={() => socket.reclaim()}>
여기서 계속하기
</button>
</section>
</main>
);
}
if (joinError) {
return (
<>
<TopBar title="방" onBack={() => nav('/')} />
<main className="page">
<RoomGone message={joinError} />
</main>
</>
);
}
if (!room || room.code !== code || !ui) {
return (
<>
<TopBar title={`방 ${formatRoomCode(code)}`} onBack={() => nav('/')} />
<main className="page muted">방에 들어가는 중…</main>
</>
);
}
const g = catalogById(room.gameId);
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
const isHost = room.hostId === me.id;
const doLeave = () => {
socket.leave();
nav('/');
};
return (
<>
<TopBar
title={
<>
{g?.nameKo} · <CodeLabel code={code} />
</>
}
onBack={() => nav('/')}
right={
<>
<ChatButton onOpen={() => setChatOpen(true)} />
<button className="icon-btn" onClick={() => setMenu(true)} aria-label="메뉴">
⋯<span className="lbl">메뉴</span>
</button>
</>
}
/>
{needsRefresh && (
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
<span>새 버전이 있어요.</span>
<button className="btn btn-primary btn-sm" onClick={() => location.reload()}>
새로고침
</button>
</div>
)}
{inGame && game ? (
<GameView room={room} ui={ui} me={me} players={players} pending={pending !== null} slow={slow} />
) : (
<Lobby room={room} ui={ui} me={me} />
)}
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
<ChatDock room={room} me={me} open={chatOpen} onClose={() => setChatOpen(false)} />
{menu && (
<Sheet onClose={() => setMenu(false)} label="메뉴">
<h2>메뉴</h2>
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙 보기
</button>
<button className="btn btn-secondary btn-block" onClick={() => nav('/me')}>
⚙️ 글자 크기 · 소리 설정
</button>
{isHost && room.status === 'paused' && (
<button className="btn btn-danger btn-block" onClick={() => (socket.send({ t: 'void' }), setMenu(false))}>
게임 끝내기(무효)
</button>
)}
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
🚪 방 나가기
</button>
</Sheet>
)}
{leaveAsk && (
<ConfirmSheet
title="방에서 나갈까요?"
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
confirm="나가기"
danger
onConfirm={doLeave}
onClose={() => setLeaveAsk(false)}
/>
)}
</>
);
}
/** Blink the tab title when it's my turn and the tab is hidden (docs/07 §6). */
function useTurnTitle(ui: GameUI | null, view: unknown, me: string) {
const myTurn = !!ui && !!view && ui.isMyTurn(view, me);
const prev = useRef(false);
useEffect(() => {
if (myTurn && !prev.current && document.visibilityState === 'hidden') sounds.myTurn();
prev.current = myTurn;
if (!myTurn) return;
const base = document.title;
let on = false;
const t = setInterval(() => {
if (document.visibilityState === 'visible') {
document.title = base;
return;
}
on = !on;
document.title = on ? '● 내 차례예요' : base;
}, 1000);
return () => {
clearInterval(t);
document.title = base;
};
}, [myTurn]);
}
// ---------------------------------------------------------------- lobby
function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {
const [qr, setQr] = useState(false);
const [settings, setSettings] = useState(false);
const isHost = room.hostId === me.id;
const mySeat = room.seats.findIndex((s) => s?.user.id === me.id);
const seated = room.seats.filter(Boolean).length;
const need = Math.max(0, room.minPlayers - seated);
const url = `${location.origin}/r/${room.code}`;
const gameName = catalogById(room.gameId)?.nameKo ?? '게임';
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
const [askStart, setAskStart] = useState(false);
return (
<main className="page">
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
<p className="muted">친구에게 이 코드를 알려 주세요</p>
<p style={{ fontSize: '3rem', fontWeight: 800, lineHeight: 1.1 }}>
<CodeLabel code={room.code} />
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<CopyButton text={room.code} label="🔢 코드 복사" />
<CopyButton text={url} label="🔗 링크 복사" />
<ShareButton title={`${gameName} 같이 해요`} text={shareText} url={url} />
<button className="btn btn-secondary" onClick={() => setQr(true)}>
📷 QR 보기
</button>
</div>
</section>
<section className="stack" aria-label="자리">
<h2>
자리 ({seated}/{room.maxPlayers})
</h2>
{room.seats.map((s, i) =>
s ? (
<div key={i} className="card row" style={{ gap: 12 }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
{s.user.nickname}
{s.user.id === me.id && <span className="muted small"> (나)</span>}
</div>
<div className="row small" style={{ gap: 6 }}>
<span className="dot" style={{ background: s.connected ? 'var(--success)' : s.away ? 'var(--text-sub)' : 'var(--warning)' }} />
{s.connected ? '접속 중' : s.away ? '자리 비움' : '연결 끊김'}
{room.hostId !== s.user.id && (s.ready ? <span className="badge success">준비 완료</span> : <span className="badge">준비 중</span>)}
</div>
</div>
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
</div>
) : (
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 72, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button>
),
)}
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
{mySeat >= 0 && room.allowSpectators && (
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'unseat' })}>
관전으로 바꾸기
</button>
)}
</section>
<section className="card stack">
<div className="row" style={{ justifyContent: 'space-between' }}>
<div>
<h3>게임 설정</h3>
<p className="muted">{ui.optionsSummary(room.options)}</p>
</div>
{isHost && (
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
설정 바꾸기
</button>
)}
</div>
<p className="muted small">
{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}
</p>
</section>
<div className="bottom-bar">
<div className="bottom-bar-inner">
{isHost ? (
<button className="btn btn-primary" disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
</button>
) : mySeat >= 0 ? (
room.seats[mySeat]!.ready ? (
<button className="btn btn-secondary" onClick={() => socket.send({ t: 'ready', ready: false })}>
준비 취소
</button>
) : (
<button className="btn btn-success" onClick={() => socket.send({ t: 'ready', ready: true })}>
✓ 준비하기
</button>
)
) : (
<button className="btn btn-primary" disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
</button>
)}
</div>
</div>
{qr && <QrModal url={url} code={room.code} onClose={() => setQr(false)} />}
{settings && <SettingsSheet room={room} ui={ui} onClose={() => setSettings(false)} />}
{askStart && (
<ConfirmSheet title="준비 안 된 사람이 있어요" body="그래도 시작할까요?" confirm="시작하기" onConfirm={() => socket.send({ t: 'start' })} onClose={() => setAskStart(false)} />
)}
</main>
);
}
function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) {
const [open, setOpen] = useState(false);
const [kick, setKick] = useState(false);
void room;
return (
<>
<button className="btn btn-ghost" onClick={() => setOpen(true)} aria-label={`${target.nickname} 관리`}>
⋯
</button>
{open && (
<Sheet onClose={() => setOpen(false)} label={`${target.nickname} 관리`}>
<h2>{target.nickname}</h2>
<button className="btn btn-secondary btn-block" onClick={() => (socket.send({ t: 'host', userId: target.id }), setOpen(false))}>
👑 방장 넘기기
</button>
<button className="btn btn-danger btn-block" onClick={() => (setOpen(false), setKick(true))}>
내보내기
</button>
</Sheet>
)}
{kick && (
<ConfirmSheet
title={`${target.nickname}님을 내보낼까요?`}
body="내보낸 사람은 이 방에 다시 들어올 수 없어요."
confirm="내보내기"
danger
onConfirm={() => socket.send({ t: 'kick', userId: target.id })}
onClose={() => setKick(false)}
/>
)}
</>
);
}
function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) {
const [options, setOptions] = useState(room.options);
const [visibility, setVisibility] = useState(room.visibility);
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators);
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled);
const save = () => {
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
onClose();
};
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
<div className="segmented">
<button type="button" aria-pressed={value} onClick={() => set(true)}>
{on}
</button>
<button type="button" aria-pressed={!value} onClick={() => set(false)}>
{off}
</button>
</div>
);
return (
<Sheet onClose={onClose} label="게임 설정">
<h2>게임 설정</h2>
<ui.Options value={options} onChange={setOptions} />
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
<strong>방 공개</strong>
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나(공개)', '친구만')}
<strong>관전</strong>
{seg(allowSpectators, setAllowSpectators, '관전 허용', '관전 안 됨')}
<strong>채팅</strong>
{seg(chatEnabled, setChatEnabled, '채팅 켜기', '채팅 끄기')}
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} onClick={save}>
저장
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
취소
</button>
</div>
</Sheet>
);
}
// ---------------------------------------------------------------- game
function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record<string, PublicUser>; pending: boolean; slow: boolean }) {
const game = useRoom((s) => s.game)!;
const tapConfirm = useSession((s) => s.settings.tapConfirm);
const myTurn = ui.isMyTurn(game.view, me.id);
const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined;
return (
<main className="page" style={{ gap: 12 }}>
<div
className="card"
role="status"
aria-live="polite"
style={{
padding: '12px 16px',
fontWeight: 700,
fontSize: '1.1rem',
background: myTurn ? 'var(--primary)' : 'var(--surface)',
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
}}
>
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
{slow && <span className="small"> · 연결 확인 중…</span>}
{!isPlayer && <span className="badge" style={{ marginLeft: 8 }}>관전 중</span>}
</div>
<ui.Board
view={game.view}
me={me.id}
players={players}
act={(a) => socket.act(a)}
pending={pending}
deadline={game.deadline}
active={game.active}
tapConfirm={tapConfirm}
/>
</main>
);
}
function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: PublicUser; players: Record<string, PublicUser>; onLeave: () => void }) {
const result = useRoom((s) => s.result)!;
const [hidden, setHidden] = useState(false);
const played = useRef(false);
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.id);
const voted = room.rematchVotes.includes(me.id);
const allFirst = result.ranking.length === 1;
const won = !allFirst && result.ranking[0]?.includes(me.id);
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
useEffect(() => {
if (played.current) return;
played.current = true;
if (won) sounds.win();
else if (lost) sounds.lose();
}, [won, lost]);
if (hidden) {
return (
<div className="bottom-bar">
<div className="bottom-bar-inner">
<button className="btn btn-primary" onClick={() => setHidden(false)}>
결과 다시 보기
</button>
</div>
</div>
);
}
return (
<Sheet onClose={() => setHidden(true)} label="게임 결과">
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
<p style={{ textAlign: 'center' }}>{result.summary}</p>
<div className="stack">
{result.ranking.map((group, i) =>
group.map((u) => (
<div key={u} className="row">
<strong style={{ width: 40 }}>{i + 1}등</strong>
{players[u] && <Avatar user={players[u]!} size={28} />}
<span>{players[u]?.nickname ?? '나간 사람'}</span>
<span className="muted small" style={{ marginLeft: 'auto' }}>
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
</span>
</div>
)),
)}
</div>
{amSeated && (
<button className="btn btn-primary btn-block" disabled={voted} onClick={() => socket.send({ t: 'rematch' })}>
{voted ? `다른 사람을 기다리는 중… (${room.rematchVotes.length}/${seated.length})` : `🔁 한 판 더 (${room.rematchVotes.length}/${seated.length})`}
</button>
)}
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
판 다시 보기
</button>
<button className="btn btn-ghost btn-block" onClick={onLeave}>
방 나가기
</button>
{room.lastSeed && (
<details className="small muted">
<summary>공정성 확인</summary>
<p>시작할 때 공개한 값: {room.seedHash ?? '-'}</p>
<p>이번 판의 난수 씨앗: {room.lastSeed}</p>
<p>씨앗의 SHA-256이 위 값과 같으면 서버가 게임 도중 난수를 바꾸지 않았다는 뜻이에요.</p>
</details>
)}
</Sheet>
);
}
// ---------------------------------------------------------------- chat
/** Unread counter shared between the top-bar button and the chat sheet. */
const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 }));
function ChatButton({ onOpen }: { onOpen: () => void }) {
const total = useRoom((s) => s.chat.length);
const seen = useChatSeen((s) => s.seen);
const unread = Math.max(0, total - seen);
return (
<button className="icon-btn" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
💬<span className="lbl">채팅</span>
{unread > 0 && <span className="badge warning">{unread}</span>}
</button>
);
}
function ChatDock({ room, me, open, onClose }: { room: RoomView; me: PublicUser; open: boolean; onClose: () => void }) {
const chat = useRoom((s) => s.chat);
const emotes = useRoom((s) => s.emotes);
const setOpen = (v: boolean) => !v && onClose();
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const [hiddenUsers, setHiddenUsers] = useState<Set<string>>(new Set());
useEffect(() => {
if (open) {
useChatSeen.setState({ seen: chat.length });
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}
}, [open, chat.length]);
const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? '';
return (
<>
<div style={{ position: 'fixed', right: 16, bottom: 'calc(96px + env(safe-area-inset-bottom))', zIndex: 16, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', pointerEvents: 'none' }}>
{emotes.map((e) => (
<div key={e.id} className="toast" style={{ pointerEvents: 'none' }}>
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
</div>
))}
</div>
{open && (
<Sheet onClose={() => setOpen(false)} label="채팅">
<h2>채팅</h2>
<div className="row">
{EMOTES.map((id) => (
<button key={id} className="btn btn-secondary btn-sm" onClick={() => socket.send({ t: 'emote', id })}>
{EMOTE_LABEL[id]}
</button>
))}
</div>
<div ref={listRef} className="stack" style={{ maxHeight: '40vh', overflow: 'auto', gap: 8 }} aria-live="polite">
{chat.length === 0 && <p className="muted">아직 메시지가 없어요.</p>}
{chat
.filter((m) => !m.from || !hiddenUsers.has(m.from.id))
.map((m) => (
<div key={m.id}>
<strong>{m.from?.nickname ?? '알림'}</strong>{' '}
{m.from && m.from.id !== me.id && (
<button className="btn-ghost small muted" style={{ border: 0, background: 'none', cursor: 'pointer' }} onClick={() => setHiddenUsers(new Set([...hiddenUsers, m.from!.id]))}>
(가리기)
</button>
)}
<div>{m.text}</div>
</div>
))}
</div>
{room.chatEnabled ? (
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
setText('');
}}
>
<input className="input" style={{ flex: 1 }} value={text} onChange={(e) => setText(e.target.value)} maxLength={CHAT_MAX} placeholder="메시지 입력" aria-label="메시지" enterKeyHint="send" />
<button className="btn btn-primary">보내기</button>
</form>
) : (
<p className="muted">이 방은 채팅을 쓰지 않아요. 위의 반응 버튼은 쓸 수 있어요.</p>
)}
</Sheet>
)}
</>
);
}

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { catalogById } from '@bg/shared/lite';
import { TopBar } from '../components/ui';
import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types';
export default function RulesPage() {
const { gameId = '' } = useParams();
const nav = useNavigate();
const [ui, setUi] = useState<GameUI | null | undefined>(undefined);
useEffect(() => {
loadGameUI(gameId).then(setUi);
}, [gameId]);
const g = catalogById(gameId);
return (
<>
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
<main className="page">
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
</main>
</>
);
}

View File

@@ -0,0 +1,88 @@
/** Who am I + display settings (docs/05 §7, docs/07 §2). */
import { create } from 'zustand';
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
import { api, type Settings } from '../net/api';
const LS_KEY = 'bg.settings';
export const DEFAULT_SETTINGS: Required<Settings> = {
fontScale: 'normal',
theme: 'auto',
sound: true,
vibrate: true,
colorBlind: false,
tapConfirm: true,
};
function loadLocal(): Required<Settings> {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(LS_KEY) ?? '{}') };
} catch {
return DEFAULT_SETTINGS;
}
}
export function applySettings(s: Required<Settings>): void {
const html = document.documentElement;
html.dataset.fontScale = s.fontScale;
html.dataset.theme = s.theme;
html.dataset.colorBlind = String(s.colorBlind);
const dark = s.theme === 'dark' || (s.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#16181d' : '#F7F5F0');
}
interface SessionState {
loaded: boolean;
me: PublicUser | null;
activeRoom: string | null;
discordEnabled: boolean;
catalog: CatalogEntry[];
settings: Required<Settings>;
useAvatar: boolean;
hasDiscordAvatar: boolean;
refresh(): Promise<void>;
setMe(me: PublicUser | null): void;
updateSettings(patch: Settings): void;
}
export const useSession = create<SessionState>((set, get) => ({
loaded: false,
me: null,
activeRoom: null,
discordEnabled: false,
catalog: [],
settings: loadLocal(),
useAvatar: true,
hasDiscordAvatar: false,
async refresh() {
const [cfg, me] = await Promise.all([
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[] })),
api.me().catch(() => ({ me: null }) as Awaited<ReturnType<typeof api.me>>),
]);
const settings = { ...get().settings, ...(me.settings ?? {}) };
applySettings(settings);
localStorage.setItem(LS_KEY, JSON.stringify(settings));
set({
loaded: true,
me: me.me,
activeRoom: me.activeRoom ?? null,
discordEnabled: cfg.discord,
catalog: cfg.catalog,
settings,
useAvatar: me.useAvatar ?? true,
hasDiscordAvatar: me.hasDiscordAvatar ?? false,
});
},
setMe(me) {
set({ me });
},
updateSettings(patch) {
const settings = { ...get().settings, ...patch };
applySettings(settings);
localStorage.setItem(LS_KEY, JSON.stringify(settings));
set({ settings });
if (get().me) api.updateMe({ settings }).catch(() => {});
},
}));
applySettings(loadLocal());

View File

@@ -0,0 +1,17 @@
import { create } from 'zustand';
export interface Toast {
id: number;
text: string;
kind: 'info' | 'error';
}
let nextId = 1;
export const useToasts = create<{ toasts: Toast[] }>(() => ({ toasts: [] }));
export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3000): void {
const id = nextId++;
useToasts.setState((s) => ({ toasts: [...s.toasts.filter((t) => t.text !== text), { id, text, kind }].slice(-3) }));
setTimeout(() => useToasts.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), ms);
}

View File

@@ -0,0 +1,530 @@
/* Design tokens: docs/07-ui-ux.md §2 */
:root {
--bg: #f7f5f0;
--surface: #ffffff;
--surface-2: #efece4;
--text: #1c1e21;
--text-sub: #4a4f57;
--border: #d9d5cb;
--primary: #2563eb;
--primary-press: #1d4fd0;
--primary-text: #ffffff;
--primary-soft: #dbe7ff;
--success: #15803d;
--success-soft: #dcfce7;
--warning: #b45309;
--warning-soft: #fef3c7;
--danger: #b91c1c;
--danger-soft: #fee2e2;
--felt: #1f6b4a;
--wood: #e3b76b;
--wood-line: #7a5a22;
--focus: #f59e0b;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
--radius: 12px;
--radius-lg: 16px;
--tap: 48px;
--btn-h: 56px;
color-scheme: light;
}
:root[data-theme='dark'] {
--bg: #16181d;
--surface: #20232a;
--surface-2: #2a2e37;
--text: #f2f3f5;
--text-sub: #b8bcc4;
--border: #3a3f4a;
--primary: #5b8def;
--primary-press: #4a7de0;
--primary-text: #0b1020;
--primary-soft: #1e2a44;
--success: #4ade80;
--success-soft: #133222;
--warning: #fbbf24;
--warning-soft: #3a2c0c;
--danger: #f87171;
--danger-soft: #3d1717;
--felt: #17513a;
--wood: #b98c45;
--wood-line: #4a3612;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root[data-theme='auto'] {
--bg: #16181d;
--surface: #20232a;
--surface-2: #2a2e37;
--text: #f2f3f5;
--text-sub: #b8bcc4;
--border: #3a3f4a;
--primary: #5b8def;
--primary-press: #4a7de0;
--primary-text: #0b1020;
--primary-soft: #1e2a44;
--success: #4ade80;
--success-soft: #133222;
--warning: #fbbf24;
--warning-soft: #3a2c0c;
--danger: #f87171;
--danger-soft: #3d1717;
--felt: #17513a;
--wood: #b98c45;
--wood-line: #4a3612;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
}
/* Font scale setting: 보통 / 크게 / 아주 크게. Everything else is in rem. */
html {
font-size: 106.25%; /* 17px */
}
html[data-font-scale='large'] {
font-size: 127.5%;
}
html[data-font-scale='xlarge'] {
font-size: 148.75%;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-weight: 500;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
word-break: keep-all;
overflow-wrap: anywhere;
}
h1,
h2,
h3 {
margin: 0;
font-weight: 700;
line-height: 1.3;
}
h1 {
font-size: 1.65rem;
}
h2 {
font-size: 1.3rem;
}
h3 {
font-size: 1.1rem;
}
p {
margin: 0;
}
button,
input,
select {
font: inherit;
color: inherit;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* ---------- layout ---------- */
.page {
max-width: 960px;
margin: 0 auto;
padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 20px;
}
.stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 16px;
}
.muted {
color: var(--text-sub);
}
.small {
font-size: 0.9rem;
}
/* ---------- buttons ---------- */
.btn {
min-height: var(--btn-h);
min-width: var(--tap);
padding: 0 20px;
border-radius: var(--radius);
border: 2px solid transparent;
font-size: 1.06rem;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.08s ease, background 0.15s ease;
text-decoration: none;
user-select: none;
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary);
color: var(--primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-press);
}
.btn-secondary {
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.btn-soft {
background: var(--primary-soft);
color: var(--text);
}
.btn-danger {
background: var(--surface);
color: var(--danger);
border-color: var(--danger);
}
.btn-success {
background: var(--success);
color: #fff;
}
.btn-block {
width: 100%;
}
.btn-sm {
min-height: var(--tap);
padding: 0 14px;
font-size: 0.95rem;
}
.btn-ghost {
background: transparent;
color: var(--text);
min-height: var(--tap);
padding: 0 12px;
}
/* ---------- inputs ---------- */
.input {
min-height: var(--btn-h);
width: 100%;
border-radius: var(--radius);
border: 2px solid var(--border);
background: var(--surface);
padding: 0 16px;
font-size: 1.1rem;
}
.input:focus {
border-color: var(--primary);
outline: none;
}
.field-error {
color: var(--danger);
font-size: 0.95rem;
}
label.field {
display: flex;
flex-direction: column;
gap: 6px;
font-weight: 700;
}
.segmented {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.segmented button {
min-height: var(--tap);
padding: 0 14px;
border-radius: 999px;
border: 2px solid var(--border);
background: var(--surface);
cursor: pointer;
font-weight: 600;
}
.segmented button[aria-pressed='true'] {
border-color: var(--primary);
background: var(--primary-soft);
font-weight: 700;
}
/* ---------- top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--bg);
display: flex;
align-items: center;
gap: 8px;
min-height: 56px;
padding: 4px 8px;
padding-top: calc(4px + env(safe-area-inset-top));
border-bottom: 1px solid var(--border);
}
.topbar-title {
flex: 1;
font-weight: 700;
font-size: 1.05rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---------- bottom action bar ---------- */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(6px);
border-top: 1px solid var(--border);
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
}
.bottom-bar-inner {
max-width: 960px;
margin: 0 auto;
display: flex;
gap: 8px;
}
.bottom-bar-inner > * {
flex: 1;
}
/* ---------- banners & toasts ---------- */
.conn-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
background: var(--warning);
color: #fff;
text-align: center;
padding: 8px 12px;
padding-top: calc(8px + env(safe-area-inset-top));
font-weight: 700;
}
.toasts {
position: fixed;
top: calc(64px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 60;
display: flex;
flex-direction: column;
gap: 8px;
width: min(92vw, 420px);
pointer-events: none;
}
.toast {
background: var(--text);
color: var(--bg);
border-radius: var(--radius);
padding: 12px 16px;
font-weight: 600;
box-shadow: var(--shadow);
animation: toast-in 0.2s ease;
}
.toast.error {
background: var(--danger);
color: #fff;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
}
/* ---------- sheets / modals ---------- */
.sheet-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
animation: fade 0.15s ease;
}
@media (min-width: 720px) {
.sheet-backdrop {
align-items: center;
}
}
.sheet {
background: var(--surface);
width: min(100%, 520px);
max-height: 88vh;
overflow: auto;
border-radius: 20px 20px 0 0;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 16px;
animation: sheet-up 0.2s ease;
}
@media (min-width: 720px) {
.sheet {
border-radius: 20px;
}
}
@keyframes sheet-up {
from {
transform: translateY(24px);
opacity: 0;
}
}
@keyframes fade {
from {
opacity: 0;
}
}
/* ---------- avatar ---------- */
.avatar {
border-radius: 50%;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.85rem;
font-weight: 700;
background: var(--surface-2);
}
.badge.success {
background: var(--success-soft);
color: var(--success);
}
.badge.warning {
background: var(--warning-soft);
color: var(--warning);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
/* Icon + small label stacked (keeps text with icons but saves width on phones). */
.icon-btn {
min-width: var(--tap);
min-height: var(--tap);
padding: 2px 8px;
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
border-radius: var(--radius);
font-size: 1.25rem;
line-height: 1.1;
position: relative;
}
.icon-btn .lbl {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-sub);
}
.icon-btn .badge {
position: absolute;
top: 0;
right: 0;
padding: 0 6px;
font-size: 0.7rem;
}
.sheet:focus {
outline: none;
}
.board-wrap {
position: relative;
width: 100%;
max-width: min(100%, calc(100vh - 300px));
min-width: min(100%, 320px);
align-self: center;
}

9
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}

19
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
'/healthz': 'http://localhost:3000',
'/ws': { target: 'ws://localhost:3000', ws: true },
},
},
build: {
target: 'es2022',
cssCodeSplit: true,
sourcemap: false,
},
});