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

@@ -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>
</>
);
}