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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
apps/web/dist
|
||||||
|
test-results
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
deploy/.env
|
||||||
34
README.md
34
README.md
@@ -1,2 +1,34 @@
|
|||||||
# joke-app
|
# 같이 놀자 보드게임
|
||||||
|
|
||||||
|
광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임 사이트.
|
||||||
|
계획서는 [docs/](docs/README.md)에 있고, 구현은 계획서를 따른다.
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run --filter '@bg/server' dev # 서버 :3000
|
||||||
|
bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록시)
|
||||||
|
```
|
||||||
|
브라우저에서 http://localhost:5173 을 연다. 디스코드 값이 없으면 게스트 로그인만 보인다.
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
bun test # 규칙·서버 통합·방 단위 테스트
|
||||||
|
(cd apps/web && bunx vite build) # 웹 빌드
|
||||||
|
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
`deploy/.env.example`을 `deploy/.env`로 복사해 값을 채운 뒤:
|
||||||
|
```bash
|
||||||
|
cd deploy && docker compose up -d --build
|
||||||
|
```
|
||||||
|
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
|
||||||
|
- `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용)
|
||||||
|
- `packages/games/<id>` — 게임 규칙(순수 함수)
|
||||||
|
- `apps/server` — Bun 서버(HTTP API, WebSocket, 방 관리, SQLite)
|
||||||
|
- `apps/web` — React 웹
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface Config {
|
|||||||
discord: { clientId: string; clientSecret: string } | null;
|
discord: { clientId: string; clientSecret: string } | null;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
secureCookies: boolean;
|
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 {
|
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,
|
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
|
||||||
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
|
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
|
||||||
secureCookies: publicOrigin.startsWith('https://'),
|
secureCookies: publicOrigin.startsWith('https://'),
|
||||||
|
trustProxy: env.TRUST_PROXY === '1',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export function startServer(opts: StartOptions) {
|
|||||||
let ready = true;
|
let ready = true;
|
||||||
|
|
||||||
const ipOf = (req: Request) =>
|
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({
|
const app = createHttpApp({
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ export class Gateway {
|
|||||||
case 'rematch':
|
case 'rematch':
|
||||||
e = room.rematch(u);
|
e = room.rematch(u);
|
||||||
break;
|
break;
|
||||||
|
case 'void':
|
||||||
|
e = room.voidGame(u);
|
||||||
|
break;
|
||||||
case 'sync':
|
case 'sync':
|
||||||
room.sendSync(this.connFor(ws));
|
room.sendSync(this.connFor(ws));
|
||||||
return;
|
return;
|
||||||
|
|||||||
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal 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>
|
||||||
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bg/shared": "workspace:*",
|
"@bg/shared": "workspace:*",
|
||||||
|
"pretendard": "1.3.9",
|
||||||
"qrcode-generator": "2.0.4",
|
"qrcode-generator": "2.0.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"react-router": "8.4.0",
|
"react-router": "8.4.0",
|
||||||
"zustand": "5.0.15"
|
"zustand": "5.0.15",
|
||||||
|
"@bg/games": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.3.0",
|
||||||
|
|||||||
1
apps/web/public/favicon.svg
Normal file
1
apps/web/public/favicon.svg
Normal 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 |
82
apps/web/src/components/CodeInput.tsx
Normal file
82
apps/web/src/components/CodeInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/src/components/NicknameForm.tsx
Normal file
60
apps/web/src/components/NicknameForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/web/src/components/QrModal.tsx
Normal file
42
apps/web/src/components/QrModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/components/TurnTimer.tsx
Normal file
44
apps/web/src/components/TurnTimer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
apps/web/src/components/ui.tsx
Normal file
163
apps/web/src/components/ui.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
431
apps/web/src/games/omok/index.tsx
Normal file
431
apps/web/src/games/omok/index.tsx
Normal 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;
|
||||||
22
apps/web/src/games/registry.ts
Normal file
22
apps/web/src/games/registry.ts
Normal 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;
|
||||||
|
}
|
||||||
27
apps/web/src/games/types.ts
Normal file
27
apps/web/src/games/types.ts
Normal 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 }>;
|
||||||
|
}
|
||||||
13
apps/web/src/lib/catalog-ui.ts
Normal file
13
apps/web/src/lib/catalog-ui.ts
Normal 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 = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||||
48
apps/web/src/lib/feedback.ts
Normal file
48
apps/web/src/lib/feedback.ts
Normal 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
64
apps/web/src/main.tsx
Normal 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
72
apps/web/src/net/api.ts
Normal 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
344
apps/web/src/net/socket.ts
Normal 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();
|
||||||
103
apps/web/src/pages/GamePage.tsx
Normal file
103
apps/web/src/pages/GamePage.tsx
Normal 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
109
apps/web/src/pages/Home.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/web/src/pages/JoinPage.tsx
Normal file
36
apps/web/src/pages/JoinPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/web/src/pages/MePage.tsx
Normal file
206
apps/web/src/pages/MePage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/src/pages/NotFound.tsx
Normal file
12
apps/web/src/pages/NotFound.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
621
apps/web/src/pages/RoomPage.tsx
Normal file
621
apps/web/src/pages/RoomPage.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/pages/RulesPage.tsx
Normal file
24
apps/web/src/pages/RulesPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/web/src/store/session.ts
Normal file
88
apps/web/src/store/session.ts
Normal 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());
|
||||||
17
apps/web/src/store/toast.ts
Normal file
17
apps/web/src/store/toast.ts
Normal 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);
|
||||||
|
}
|
||||||
530
apps/web/src/styles/global.css
Normal file
530
apps/web/src/styles/global.css
Normal 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
9
apps/web/tsconfig.json
Normal 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
19
apps/web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
7
bun.lock
7
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "boardgame-site",
|
"name": "boardgame-site",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.4.2",
|
"@types/bun": "1.4.2",
|
||||||
|
"playwright-core": "1.63.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -25,7 +26,9 @@
|
|||||||
"name": "@bg/web",
|
"name": "@bg/web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bg/games": "workspace:*",
|
||||||
"@bg/shared": "workspace:*",
|
"@bg/shared": "workspace:*",
|
||||||
|
"pretendard": "1.3.9",
|
||||||
"qrcode-generator": "2.0.4",
|
"qrcode-generator": "2.0.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
@@ -163,8 +166,12 @@
|
|||||||
|
|
||||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||||
|
|
||||||
|
"pretendard": ["pretendard@1.3.9", "", {}, "sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw=="],
|
||||||
|
|
||||||
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
|
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
|
||||||
|
|
||||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||||
|
|||||||
9
deploy/.env.example
Normal file
9
deploy/.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Copy to deploy/.env and fill in. Never commit the real file.
|
||||||
|
SITE_DOMAIN=game.example.com
|
||||||
|
PUBLIC_ORIGIN=https://game.example.com
|
||||||
|
# 32+ random characters: openssl rand -base64 48
|
||||||
|
SESSION_SECRET=
|
||||||
|
# Discord developer portal → OAuth2 (redirect: https://<domain>/api/auth/discord/callback). Leave empty to hide Discord login.
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
LOG_LEVEL=info
|
||||||
17
deploy/Caddyfile
Normal file
17
deploy/Caddyfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{$SITE_DOMAIN} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Internal metrics/health detail must not be public.
|
||||||
|
respond /internal/* 404
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; img-src 'self' https://cdn.discordapp.com data:; connect-src 'self' wss://{$SITE_DOMAIN}; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# The app serves the built web assets with long-lived caching for /assets/* and no-cache for index.html.
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
35
deploy/Dockerfile
Normal file
35
deploy/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Multi-stage build: install → build web → slim runtime (docs/11-deployment-ops.md)
|
||||||
|
FROM oven/bun:1.3 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml tsconfig.base.json ./
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY apps ./apps
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN cd apps/web && bunx vite build
|
||||||
|
|
||||||
|
# Production-only dependencies (no vite/typescript/playwright in the runtime image).
|
||||||
|
FROM oven/bun:1.3 AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml ./
|
||||||
|
COPY packages/engine/package.json packages/engine/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/games/package.json packages/games/
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
RUN bun install --frozen-lockfile --production --filter '@bg/server'
|
||||||
|
|
||||||
|
FROM oven/bun:1.3-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production PORT=3000 DB_PATH=/data/app.db TRUST_PROXY=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/packages ./packages
|
||||||
|
COPY --from=build /app/apps/server ./apps/server
|
||||||
|
COPY --from=build /app/apps/web/dist ./apps/web/dist
|
||||||
|
RUN mkdir -p /data && chown bun:bun /data
|
||||||
|
USER bun
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD bun -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
|
CMD ["bun", "apps/server/src/index.ts"]
|
||||||
33
deploy/docker-compose.yml
Normal file
33
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
image: boardgame-site:${APP_VERSION:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
|
stop_grace_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: { max-size: "50m", max-file: "5" }
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
environment:
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:?set SITE_DOMAIN in deploy/.env}
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
depends_on: [app]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
|
caddy-data:
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| `chat` | `text`(1~200자) | 채팅 |
|
| `chat` | `text`(1~200자) | 채팅 |
|
||||||
| `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |
|
| `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |
|
||||||
| `rematch` | — | 게임 끝난 뒤 "한 판 더" 투표 |
|
| `rematch` | — | 게임 끝난 뒤 "한 판 더" 투표 |
|
||||||
|
| `void` | — | 방장: 오류로 멈춘 게임을 무효로 끝내기 |
|
||||||
| `sync` | — | 전체 상태 다시 요청 |
|
| `sync` | — | 전체 상태 다시 요청 |
|
||||||
| `ping` | `ts` | 지연 측정(10초마다) |
|
| `ping` | `ts` | 지연 측정(10초마다) |
|
||||||
|
|
||||||
|
|||||||
129
e2e/omok.e2e.ts
Normal file
129
e2e/omok.e2e.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* E2E (docs/10 §2): guest start → create room → second user joins by link → omok to the end.
|
||||||
|
* Run: bun e2e/omok.e2e.ts (needs apps/web/dist built)
|
||||||
|
*/
|
||||||
|
import { chromium, type Page } from 'playwright-core';
|
||||||
|
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { loadConfig } from '../apps/server/src/config';
|
||||||
|
import { startServer } from '../apps/server/src/server';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.E2E_PORT ?? 4517);
|
||||||
|
const ORIGIN = `http://localhost:${PORT}`;
|
||||||
|
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
|
||||||
|
mkdirSync(SHOTS, { recursive: true });
|
||||||
|
|
||||||
|
const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-'));
|
||||||
|
const app = startServer({
|
||||||
|
config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }),
|
||||||
|
log: () => {},
|
||||||
|
staticDir: join(import.meta.dir, '../apps/web/dist'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
|
||||||
|
let failed = false;
|
||||||
|
const step = async (name: string, fn: () => Promise<void>) => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function tapCell(page: Page, x: number, y: number, n = 15) {
|
||||||
|
const box = (await page.locator('svg[role="grid"]').boundingBox())!;
|
||||||
|
const span = n - 1 + 1.2;
|
||||||
|
const px = box.x + ((x + 0.6) / span) * box.width;
|
||||||
|
const py = box.y + ((y + 0.6) / span) * box.height;
|
||||||
|
await page.mouse.click(px, py);
|
||||||
|
await page.waitForTimeout(60);
|
||||||
|
await page.mouse.click(px, py); // second tap confirms (tap-confirm mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctxA = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' });
|
||||||
|
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: 'ko-KR' });
|
||||||
|
const a = await ctxA.newPage();
|
||||||
|
const b = await ctxB.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
|
||||||
|
let code = '';
|
||||||
|
await step('A: 홈 → 방 만들기 → 닉네임 → 대기실', async () => {
|
||||||
|
await a.goto(ORIGIN + '/');
|
||||||
|
await a.getByRole('button', { name: '+ 방 만들기' }).click();
|
||||||
|
await a.getByLabel('내 이름(닉네임)').fill('호랑이');
|
||||||
|
await a.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||||
|
await a.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
code = a.url().slice(-6);
|
||||||
|
await a.getByText('친구에게 이 코드를 알려 주세요').waitFor();
|
||||||
|
await a.screenshot({ path: join(SHOTS, '1-lobby-mobile.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('B: 링크로 입장 → 닉네임 → 자동 착석', async () => {
|
||||||
|
await b.goto(`${ORIGIN}/r/${code}`);
|
||||||
|
await b.getByText('방에 초대받았어요').waitFor();
|
||||||
|
await b.getByLabel('내 이름(닉네임)').fill('고양이');
|
||||||
|
await b.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||||
|
await b.getByText('고양이').first().waitFor();
|
||||||
|
await a.getByText('고양이').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('A: 설정(방장 흑, 자유룰) → 시작', async () => {
|
||||||
|
await a.getByRole('button', { name: '설정 바꾸기' }).click();
|
||||||
|
await a.getByRole('button', { name: '자유룰' }).click();
|
||||||
|
await a.getByRole('button', { name: '방장이 흑' }).click();
|
||||||
|
await a.getByRole('button', { name: '저장' }).click();
|
||||||
|
await a.getByText('자유룰 · 15줄').waitFor();
|
||||||
|
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||||
|
await a.getByRole('button', { name: '시작하기' }).click();
|
||||||
|
await a.getByText('내 차례예요').waitFor();
|
||||||
|
await b.getByText('호랑이님이 생각 중이에요').waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('대국: 흑 가로 5목', async () => {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await tapCell(a, 5 + i, 7);
|
||||||
|
await b.getByText('내 차례예요').waitFor();
|
||||||
|
await tapCell(b, 5 + i, 3);
|
||||||
|
await a.getByText('내 차례예요').waitFor();
|
||||||
|
if (i === 1) await a.screenshot({ path: join(SHOTS, '2-game-mobile.png') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('B: 새로고침해도 판이 그대로', async () => {
|
||||||
|
await b.reload();
|
||||||
|
await b.getByText('호랑이님이 생각 중이에요').waitFor();
|
||||||
|
const stones = await b.locator('svg[role="grid"] circle[r="0.46"]').count();
|
||||||
|
if (stones !== 8) throw new Error(`expected 8 stones after reload, got ${stones}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('흑 승리 → 결과 화면', async () => {
|
||||||
|
await tapCell(a, 9, 7);
|
||||||
|
await a.getByText('🎉 이겼어요!').waitFor();
|
||||||
|
await b.getByText('아쉽게 졌어요').waitFor();
|
||||||
|
await a.waitForTimeout(500); // let the sheet animation finish
|
||||||
|
await a.screenshot({ path: join(SHOTS, '3-result-mobile.png') });
|
||||||
|
await b.screenshot({ path: join(SHOTS, '4-result-desktop.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('한 판 더 → 새 게임 시작', async () => {
|
||||||
|
await a.getByRole('button', { name: /한 판 더/ }).click();
|
||||||
|
await b.getByRole('button', { name: /한 판 더/ }).click();
|
||||||
|
await a.getByText(/차례예요|생각 중이에요/).waitFor();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
|
||||||
|
} catch (e) {
|
||||||
|
failed = true;
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await app.stop();
|
||||||
|
}
|
||||||
|
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.4.2",
|
"@types/bun": "1.4.2",
|
||||||
|
"playwright-core": "1.63.0",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": {
|
||||||
"dependencies": { "zod": "4.6.5" }
|
".": "./src/index.ts",
|
||||||
|
"./lite": "./src/lite.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "4.6.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
|
export * from './lite';
|
||||||
export * from './protocol';
|
export * from './protocol';
|
||||||
export * from './nickname';
|
|
||||||
export * from './room-code';
|
|
||||||
export * from './catalog';
|
|
||||||
|
|||||||
5
packages/shared/src/lite.ts
Normal file
5
packages/shared/src/lite.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** Browser-safe exports (no zod). */
|
||||||
|
export * from './types';
|
||||||
|
export * from './nickname';
|
||||||
|
export * from './room-code';
|
||||||
|
export * from './catalog';
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
/** WebSocket protocol, docs/03-realtime-protocol.md. */
|
/** WebSocket protocol client→server schema (zod; server-side validation), docs/03-realtime-protocol.md. */
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { CHAT_MAX } from './types';
|
||||||
|
|
||||||
export const PROTOCOL = 1;
|
export * from './types';
|
||||||
export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
|
|
||||||
export const CHAT_MAX = 200;
|
|
||||||
|
|
||||||
const code = z.string().regex(/^\d{6}$/);
|
const code = z.string().regex(/^\d{6}$/);
|
||||||
const userId = z.string().min(1).max(64);
|
const userId = z.string().min(1).max(64);
|
||||||
@@ -32,86 +31,9 @@ export const ClientMessage = z.discriminatedUnion('t', [
|
|||||||
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
|
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
|
||||||
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
|
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
|
||||||
z.object({ t: z.literal('rematch') }),
|
z.object({ t: z.literal('rematch') }),
|
||||||
|
z.object({ t: z.literal('void') }),
|
||||||
z.object({ t: z.literal('sync') }),
|
z.object({ t: z.literal('sync') }),
|
||||||
z.object({ t: z.literal('ping'), ts: z.number() }),
|
z.object({ t: z.literal('ping'), ts: z.number() }),
|
||||||
]);
|
]);
|
||||||
export type ClientMessage = z.infer<typeof ClientMessage>;
|
export type ClientMessage = z.infer<typeof ClientMessage>;
|
||||||
|
|
||||||
export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const;
|
|
||||||
export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = {
|
|
||||||
thumbs: '👍',
|
|
||||||
laugh: '😂',
|
|
||||||
wow: '😮',
|
|
||||||
sad: '😢',
|
|
||||||
clap: '👏',
|
|
||||||
nice: '잘했어요!',
|
|
||||||
hurry: '조금만 서둘러 주세요',
|
|
||||||
gg: '좋은 게임이었어요',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PublicUser {
|
|
||||||
id: string;
|
|
||||||
nickname: string;
|
|
||||||
avatar: string | null;
|
|
||||||
kind: 'guest' | 'member';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeatView {
|
|
||||||
user: PublicUser;
|
|
||||||
ready: boolean;
|
|
||||||
connected: boolean;
|
|
||||||
away: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken';
|
|
||||||
|
|
||||||
export interface RoomView {
|
|
||||||
code: string;
|
|
||||||
hostId: string;
|
|
||||||
gameId: string;
|
|
||||||
options: unknown;
|
|
||||||
maxPlayers: number;
|
|
||||||
minPlayers: number;
|
|
||||||
visibility: 'private' | 'public';
|
|
||||||
turnSeconds: number | null;
|
|
||||||
allowSpectators: boolean;
|
|
||||||
chatEnabled: boolean;
|
|
||||||
chatFilter: boolean;
|
|
||||||
seats: (SeatView | null)[];
|
|
||||||
spectators: PublicUser[];
|
|
||||||
status: RoomStatus;
|
|
||||||
gameNo: number;
|
|
||||||
rematchVotes: string[];
|
|
||||||
sessionStats: Record<string, { played: number; wins: number }>;
|
|
||||||
seedHash: string | null;
|
|
||||||
/** Seed revealed after the game for fairness check. */
|
|
||||||
lastSeed: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameResultView {
|
|
||||||
ranking: string[][];
|
|
||||||
scores?: Record<string, number>;
|
|
||||||
summary: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerMessage =
|
|
||||||
| { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null }
|
|
||||||
| { t: 'room'; seq: number; room: RoomView }
|
|
||||||
| {
|
|
||||||
t: 'state';
|
|
||||||
seq: number;
|
|
||||||
view: unknown;
|
|
||||||
events: unknown[];
|
|
||||||
active: string[];
|
|
||||||
deadline: number | null;
|
|
||||||
cs?: number;
|
|
||||||
}
|
|
||||||
| { t: 'reject'; cs: number; reason: string }
|
|
||||||
| { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string }
|
|
||||||
| { t: 'emote'; from: string; id: string }
|
|
||||||
| { t: 'result'; seq: number; result: GameResultView }
|
|
||||||
| { t: 'notice'; code: string; message: string }
|
|
||||||
| { t: 'error'; code: string; message: string }
|
|
||||||
| { t: 'pong'; ts: number; serverTime: number }
|
|
||||||
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
|
|
||||||
|
|||||||
83
packages/shared/src/types.ts
Normal file
83
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/** Protocol constants and message types shared by server and web (no runtime deps). */
|
||||||
|
export const PROTOCOL = 1;
|
||||||
|
export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
|
||||||
|
export const CHAT_MAX = 200;
|
||||||
|
|
||||||
|
export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const;
|
||||||
|
export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = {
|
||||||
|
thumbs: '👍',
|
||||||
|
laugh: '😂',
|
||||||
|
wow: '😮',
|
||||||
|
sad: '😢',
|
||||||
|
clap: '👏',
|
||||||
|
nice: '잘했어요!',
|
||||||
|
hurry: '조금만 서둘러 주세요',
|
||||||
|
gg: '좋은 게임이었어요',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
kind: 'guest' | 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeatView {
|
||||||
|
user: PublicUser;
|
||||||
|
ready: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
away: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken';
|
||||||
|
|
||||||
|
export interface RoomView {
|
||||||
|
code: string;
|
||||||
|
hostId: string;
|
||||||
|
gameId: string;
|
||||||
|
options: unknown;
|
||||||
|
maxPlayers: number;
|
||||||
|
minPlayers: number;
|
||||||
|
visibility: 'private' | 'public';
|
||||||
|
turnSeconds: number | null;
|
||||||
|
allowSpectators: boolean;
|
||||||
|
chatEnabled: boolean;
|
||||||
|
chatFilter: boolean;
|
||||||
|
seats: (SeatView | null)[];
|
||||||
|
spectators: PublicUser[];
|
||||||
|
status: RoomStatus;
|
||||||
|
gameNo: number;
|
||||||
|
rematchVotes: string[];
|
||||||
|
sessionStats: Record<string, { played: number; wins: number }>;
|
||||||
|
seedHash: string | null;
|
||||||
|
/** Seed revealed after the game for fairness check. */
|
||||||
|
lastSeed: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameResultView {
|
||||||
|
ranking: string[][];
|
||||||
|
scores?: Record<string, number>;
|
||||||
|
summary: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerMessage =
|
||||||
|
| { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null }
|
||||||
|
| { t: 'room'; seq: number; room: RoomView }
|
||||||
|
| {
|
||||||
|
t: 'state';
|
||||||
|
seq: number;
|
||||||
|
view: unknown;
|
||||||
|
events: unknown[];
|
||||||
|
active: string[];
|
||||||
|
deadline: number | null;
|
||||||
|
cs?: number;
|
||||||
|
}
|
||||||
|
| { t: 'reject'; cs: number; reason: string }
|
||||||
|
| { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string }
|
||||||
|
| { t: 'emote'; from: string; id: string }
|
||||||
|
| { t: 'result'; seq: number; result: GameResultView }
|
||||||
|
| { t: 'notice'; code: string; message: string }
|
||||||
|
| { t: 'error'; code: string; message: string }
|
||||||
|
| { t: 'pong'; ts: number; serverTime: number }
|
||||||
|
| { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };
|
||||||
Reference in New Issue
Block a user