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:
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 }>;
|
||||
}
|
||||
Reference in New Issue
Block a user