M7: 쿼리도(벽 쌓기 길찾기) 규칙·화면·테스트

- 규칙 모듈 packages/games/src/quoridor (id 'quoridor', 표시 이름 '벽 쌓기 길찾기'):
  2인/4인(3인 거부, playerCountError), 직선·대각 점프(wallEdgePawn/wallOnly),
  벽 겹침·판 밖·남은 벽·BFS 길 막기 검사, 도착 즉시 승리, 4인 순위(남은 거리, 공동 순위),
  continueForRanking, 기권·시간패(4인은 말 제거·남은 벽 소멸), 자동 패스,
  무르기(2인 최대 2개 / 4인 다음 사람 전, 전원 동의)·무승부, perMove/fischer/none 시계,
  autoMove(최단 거리 칸, 동률 RNG, 연속 한도 넘으면 시간패).
  view는 차례인 사람에게만 legalPawnMoves·legalWalls(거리 변화량 포함)·myShortestPath.
- 테스트: 문서 §10 시나리오 전부, 1,000판 빠른 무작위 대국(불변식·정보 누출·onTimeout 합법),
  결정성·기록 재생, 엔진 randomPlayout 24판, 합법 벽 목록 성능(약 1.2ms).
- 화면 apps/web/src/games/quoridor: 내 출발 쪽이 아래로 오게 판 회전, 홈 교차점 스냅 →
  미리보기(불가하면 빨간색+이유) → 확인, 가로/세로 전환, 키보드(방향키·R·W·Enter),
  이동 가능 칸·최단 길 점선·목표 줄 색 띠·남은 벽 막대, 무르기/무승부/기권, 규칙 그림.
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON. E2E quoridor 플랜(PC 방장+휴대폰 친구).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:52:20 +09:00
parent 559fb8507c
commit 1fc4d8c7fa
9 changed files with 2811 additions and 0 deletions

View File

@@ -0,0 +1,936 @@
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
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 { toast } from '../../store/toast';
import type { BoardProps, GameUI, OptionsProps } from '../types';
type O = 'h' | 'v';
interface Pt {
x: number;
y: number;
}
const N = 9;
const G = 0.2; // 홈(groove) 폭
const PAD = 0.78;
const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
const SEAT_COLOR: Record<Seat, string> = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' };
const COLOR_KO: Record<Seat, string> = { S: '빨강', W: '초록', N: '파랑', E: '주황' };
const SIDE_KO = { top: '맨 위', bottom: '맨 아래', left: '맨 왼쪽', right: '맨 오른쪽' } as const;
type Side = keyof typeof SIDE_KO;
const label = (p: Cell) => `${'abcdefghi'[p.c]}${p.r + 1}`;
const wallKey = (w: Wall) => `${w.o}:${w.c},${w.r}`;
/** 가로·세로는 보는 사람 화면 기준(서·동 자리는 판이 90° 돌아가 있음). */
const wallText = (w: Wall, swap = false) => `${(w.o === 'h') !== swap ? '가로' : '세로'} 벽 ${label(w)}`;
const swapFor = (seat: Seat | null) => seat === 'W' || seat === 'E';
const CSS = `
.qd-pawn { transition: transform 220ms ease-out; }
.qd-wall-new { animation: qd-drop 260ms ease-out both; transform-box: fill-box; transform-origin: center; }
.qd-turn-ring { animation: qd-pulse 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes qd-drop { 0% { transform: scale(1.35); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
@keyframes qd-pulse { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.35; } }
.qd-board * { pointer-events: none; }
.qd-board [data-cell], .qd-board [data-anchor] { pointer-events: auto; }
.qd-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.qd-modes .btn { min-height: 52px; padding-inline: 8px; white-space: nowrap; font-size: 1rem; }
.qd-modes .btn[aria-pressed='true'] { box-shadow: inset 0 0 0 3px var(--focus); }
.qd-tray { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
.qd-tray .btn { min-height: 48px; }
.qd-hint { flex: 1 1 100%; min-width: 0; font-weight: 700; }
.qd-tray .qd-confirm { flex: 2 1 0; }
.qd-tray .qd-rotate { flex: 1 1 0; }
.qd-hint.bad { color: var(--danger); }
.qd-bars { display: inline-flex; gap: 2px; align-items: flex-end; }
.qd-bars i { display: inline-block; width: 4px; height: 16px; border-radius: 2px; background: var(--warning); }
.qd-bars i.used { background: var(--border); opacity: 0.6; }
@media (prefers-reduced-motion: reduce) {
.qd-pawn { transition: none; }
.qd-wall-new, .qd-turn-ring { animation: none; }
}`;
/** 판 돌리기: 모델 좌표(x 오른쪽, y 위쪽, 0~9) ↔ 화면 좌표(y 아래쪽). 네 가지 모두 자기 자신이 역변환. */
function makeTf(seat: Seat | null) {
const s = seat ?? 'S';
const tf = (x: number, y: number): [number, number] =>
s === 'S' ? [x, N - y] : s === 'N' ? [N - x, y] : s === 'W' ? [N - y, N - x] : [y, x];
const swap = s === 'W' || s === 'E';
return {
swap,
cellToScreen: (p: Cell): Pt => {
const [x, y] = tf(p.c + 0.5, p.r + 0.5);
return { x: Math.floor(x), y: Math.floor(y) };
},
screenToCell: (p: Pt): Cell => {
const [x, y] = tf(p.x + 0.5, p.y + 0.5);
return { c: Math.floor(x), r: Math.floor(y) };
},
/** 화면 앵커(0~7)와 화면 방향. */
wallToScreen: (w: Wall): { a: Pt; o: O } => {
const [x, y] = tf(w.c + 1, w.r + 1);
return { a: { x: Math.round(x) - 1, y: Math.round(y) - 1 }, o: swap ? (w.o === 'h' ? 'v' : 'h') : w.o };
},
screenToWall: (a: Pt, o: O): Wall => {
const [x, y] = tf(a.x + 1, a.y + 1);
return { c: Math.round(x) - 1, r: Math.round(y) - 1, o: swap ? (o === 'h' ? 'v' : 'h') : o };
},
goalSide: (seat: Seat): Side => {
const g: Record<Seat, [number, number]> = { S: [4.5, 9], N: [4.5, 0], W: [9, 4.5], E: [0, 4.5] };
const [x, y] = tf(...g[seat]);
return y < 0.5 ? 'top' : y > 8.5 ? 'bottom' : x < 0.5 ? 'left' : 'right';
},
};
}
function overlapsAny(walls: Wall[], w: Wall): boolean {
const has = (o: O, c: number, r: number) => walls.some((x) => x.o === o && x.c === c && x.r === r);
if (w.o === 'h') return has('h', w.c - 1, w.r) || has('h', w.c, w.r) || has('h', w.c + 1, w.r) || has('v', w.c, w.r);
return has('v', w.c, w.r - 1) || has('v', w.c, w.r) || has('v', w.c, w.r + 1) || has('h', w.c, w.r);
}
function wallRect(a: Pt, o: O) {
return o === 'h' ? { x: a.x + 0.05, y: a.y + 1 - G / 2, width: 2 - 0.1, height: G } : { x: a.x + 1 - G / 2, y: a.y + 0.05, width: G, height: 2 - 0.1 };
}
function moveText(m: QuoridorMove, swap = false): string {
if (m.kind === 'move') return `${SEAT_KO[m.seat]} ${label(m.to)}${m.auto ? '(자동)' : ''}`;
if (m.kind === 'wall') return `${SEAT_KO[m.seat]} ${wallText(m.wall, swap)}`;
return `${SEAT_KO[m.seat]} 쉼`;
}
function PawnMark({ seat, cx, cy, r = 0.34, ghost }: { seat: Seat; cx: number; cy: number; r?: number; ghost?: boolean }) {
return (
<g opacity={ghost ? 0.5 : 1}>
{!ghost && <circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={0.25} />}
<circle cx={cx} cy={cy} r={r} fill={SEAT_COLOR[seat]} stroke="#fff" strokeWidth={0.05} />
<text x={cx} y={cy + 0.01} fontSize={r * 0.95} fontWeight={800} fill="#fff" textAnchor="middle" dominantBaseline="central">
{SEAT_KO[seat]}
</text>
</g>
);
}
type Mode = 'move' | 'wall';
function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps<QuoridorView>) {
const me = v.seats.find((x) => x.seat === v.mySeat) ?? null;
const myTurn = v.phase === 'playing' && !!me && v.turn === me.seat && v.legalPawnMoves !== null;
const t = useMemo(() => makeTf(v.mySeat), [v.mySeat]);
const [mode, setMode] = useState<Mode>('move');
const [orient, setOrient] = useState<O>('h');
const [wallAt, setWallAt] = useState<Pt | null>(null); // 화면 앵커(고정된 미리보기)
const [hoverAnchor, setHoverAnchor] = useState<Pt | null>(null);
const [hoverCell, setHoverCell] = useState<Pt | null>(null);
const [movePreview, setMovePreview] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: 4, y: 8 });
const [kbd, setKbd] = useState(false);
const [announce, setAnnounce] = useState('');
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const prevWalls = useRef(v.walls.length);
const [newWall, setNewWall] = useState<string | null>(null);
const legalCells = useMemo(() => {
const m = new Set<string>();
for (const c of v.legalPawnMoves ?? []) {
const p = t.cellToScreen(c);
m.add(`${p.x},${p.y}`);
}
return m;
}, [v.legalPawnMoves, t]);
const legalWalls = useMemo(() => {
const m = new Map<string, QuoridorLegalWall>();
for (const w of v.legalWalls ?? []) m.set(wallKey(w), w);
return m;
}, [v.legalWalls]);
// 새 수: 소리·진동·스크린리더 안내, 미리보기 지우기, 차례가 오면 커서를 내 말로.
useLayoutEffect(() => {
const prev = prevCount.current;
prevCount.current = v.moveCount;
setMovePreview(null);
setWallAt(null);
if (v.walls.length > prevWalls.current) setNewWall(wallKey(v.walls[v.walls.length - 1]!));
else setNewWall(null);
prevWalls.current = v.walls.length;
if (v.moveCount <= prev) return;
const added = v.moves.slice(prev);
const texts = added.map((m) => (m.kind === 'pass' ? `${SEAT_KO[m.seat]}은 움직일 곳이 없어 차례를 넘겼어요.` : `${moveText(m, t.swap)}.`));
for (const m of added) if (m.kind === 'pass' && m.seat === v.mySeat) toast('움직일 곳도 세울 벽도 없어서 차례를 넘겼어요');
setAnnounce(texts.join(' '));
if (added.some((m) => m.kind !== 'pass')) sounds.stone();
if (v.phase === 'playing' && v.mySeat === v.turn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (myTurn && me?.pos) setCursor(t.cellToScreen(me.pos));
if (!myTurn) setMode('move');
}, [myTurn]); // eslint-disable-line react-hooks/exhaustive-deps
const wallsLeft = me?.wallsLeft ?? 0;
const canWall = myTurn && wallsLeft > 0;
/** 화면 앵커·방향의 벽에 대한 판정: 합법이면 정보, 아니면 이유. */
const judge = (a: Pt, o: O): { wall: Wall; legal: QuoridorLegalWall | null; why: string | null } => {
const wall = t.screenToWall(a, o);
if (wallsLeft <= 0) return { wall, legal: null, why: '남은 벽이 없어요.' };
const legal = legalWalls.get(wallKey(wall)) ?? null;
if (legal) return { wall, legal, why: null };
return { wall, legal: null, why: overlapsAny(v.walls, wall) ? '다른 벽과 겹쳐요.' : '누군가의 길을 완전히 막을 수 없어요.' };
};
const doMove = (p: Pt) => {
act({ type: 'move', to: t.screenToCell(p) });
setMovePreview(null);
setHoverCell(null);
};
const doWall = (a: Pt) => {
const j = judge(a, orient);
if (j.why) {
haptic([30, 30, 30]);
setAnnounce(j.why);
toast(j.why);
return;
}
act({ type: 'placeWall', ...j.wall });
setWallAt(null);
setHoverAnchor(null);
};
const refuseCell = (p: Pt) => {
const c = t.screenToCell(p);
const occupied = v.seats.some((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
const msg = occupied ? '말이 있는 칸이에요.' : '그 칸으로는 갈 수 없어요. 밝게 표시된 칸으로 움직일 수 있어요.';
haptic([30, 30, 30]);
setAnnounce(msg);
toast(msg);
};
const tapCell = (p: Pt, mouse: boolean) => {
setCursor(p);
if (!myTurn) return;
if (!legalCells.has(`${p.x},${p.y}`)) {
refuseCell(p);
return;
}
if (mouse || !tapConfirm || (movePreview && movePreview.x === p.x && movePreview.y === p.y)) {
if (!pending) doMove(p);
return;
}
setMovePreview(p);
};
const tapAnchor = (a: Pt) => {
if (!canWall) return;
if (wallAt && wallAt.x === a.x && wallAt.y === a.y) {
setOrient((o) => (o === 'h' ? 'v' : 'h'));
return;
}
setWallAt(a);
setCursor(a);
};
const pointer = (e: React.PointerEvent<SVGSVGElement>): { x: number; y: number } | 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());
return { x: loc.x, y: loc.y };
};
const toCell = (p: { x: number; y: number }): Pt | null => {
const x = Math.floor(p.x);
const y = Math.floor(p.y);
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
};
/** 가장 가까운 교차점으로 스냅. */
const toAnchor = (p: { x: number; y: number }): Pt | null => {
if (p.x < -0.3 || p.y < -0.3 || p.x > N + 0.3 || p.y > N + 0.3) return null;
return { x: Math.min(7, Math.max(0, Math.round(p.x) - 1)), y: Math.min(7, Math.max(0, Math.round(p.y) - 1)) };
};
const setModeTo = (m: Mode) => {
setMode(m);
setMovePreview(null);
setWallAt(null);
if (m === 'wall' && me?.pos) {
const p = t.cellToScreen(me.pos);
// 내 말 바로 위쪽 교차점에서 시작(상대 쪽으로 세우기 쉽게).
setCursor({ x: Math.min(7, Math.max(0, p.x - 1)), y: Math.min(7, Math.max(0, p.y - 2)) });
} else if (m === 'move' && me?.pos) setCursor(t.cellToScreen(me.pos));
setAnnounce(m === 'wall' ? '벽 세우기: 방향키로 자리를 고르고 R로 방향을 바꾼 뒤 Enter로 세워요.' : '말 움직이기: 방향키로 칸을 고르고 Enter로 움직여요.');
};
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] };
const max = mode === 'wall' ? 7 : 8;
if (d[e.key]) {
e.preventDefault();
const next = { x: Math.min(max, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(max, Math.max(0, cursor.y + d[e.key]![1])) };
setCursor(next);
if (mode === 'wall') {
setWallAt(next);
const j = judge(next, orient);
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
} else {
const c = t.screenToCell(next);
const who = v.seats.find((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
setAnnounce(`${label(c)} ${who ? `${SEAT_KO[who.seat]} 말` : legalCells.has(`${next.x},${next.y}`) ? '갈 수 있음' : '빈칸'}`);
}
} else if ((e.key === 'r' || e.key === 'R') && mode === 'wall') {
e.preventDefault();
const o = orient === 'h' ? 'v' : 'h';
setOrient(o);
setWallAt(cursor);
const j = judge(cursor, o);
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
} else if (e.key === 'w' || e.key === 'W') {
e.preventDefault();
if (canWall) setModeTo(mode === 'wall' ? 'move' : 'wall');
} else if (e.key === 'Escape') {
setWallAt(null);
setMovePreview(null);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!myTurn || pending) return;
if (mode === 'wall') doWall(cursor);
else if (legalCells.has(`${cursor.x},${cursor.y}`)) doMove(cursor);
else refuseCell(cursor);
}
};
// 미리보기 대상(고정 > 마우스 올림 > 키보드 커서).
const previewAnchor = mode === 'wall' && canWall ? (wallAt ?? hoverAnchor ?? (kbd ? cursor : null)) : null;
const preview = previewAnchor ? judge(previewAnchor, orient) : null;
const ghostCell = mode === 'move' && myTurn ? (movePreview ?? (hoverCell && legalCells.has(`${hoverCell.x},${hoverCell.y}`) ? hoverCell : null)) : null;
const path = myTurn && v.showPath && v.myShortestPath && me?.pos ? [me.pos, ...v.myShortestPath].map((c) => t.cellToScreen(c)) : null;
const goalSides = v.seats.filter((x) => x.status === 'playing' || (x.status === 'finished' && v.phase === 'finished')).map((x) => ({ seat: x.seat, side: t.goalSide(x.seat) }));
const nick = (id: string) => players[id]?.nickname ?? '알 수 없음';
const lastWall = v.lastMove?.kind === 'wall' ? wallKey(v.lastMove.wall) : null;
const lastTo = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.to) : null;
const lastFrom = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.from) : null;
const myGoal = me ? SIDE_KO[t.goalSide(me.seat)] : null;
const band = (side: Side, k: number) => {
const w = 0.16;
const off = -0.1 - w - k * (w + 0.04);
if (side === 'top') return { x: 0, y: off, width: N, height: w };
if (side === 'bottom') return { x: 0, y: N - off - w, width: N, height: w };
if (side === 'left') return { x: off, y: 0, width: w, height: N };
return { x: N - off - w, y: 0, width: w, height: N };
};
return (
<div className="board-wrap stack" style={{ gap: 10, maxWidth: 'min(100%, calc(100vh - 340px))', minWidth: 'min(100%, 300px)' }}>
<style>{CSS}</style>
<svg
ref={svgRef}
className="qd-board"
viewBox={`${-PAD} ${-PAD} ${N + 2 * PAD} ${N + 2 * PAD}`}
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
data-my-seat={v.mySeat ?? ''}
data-mode={mode}
data-path={path && v.myShortestPath ? v.myShortestPath.map(label).join(' ') : ''}
data-preview={preview ? `${preview.wall.o}:${label(preview.wall)}${preview.why ? ':bad' : ''}` : ''}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || !myTurn) return;
const p = pointer(e);
if (!p) return;
if (mode === 'wall') {
const a = toAnchor(p);
if (a?.x !== hoverAnchor?.x || a?.y !== hoverAnchor?.y) setHoverAnchor(a);
} else {
const c = toCell(p);
if (c?.x !== hoverCell?.x || c?.y !== hoverCell?.y) setHoverCell(c);
}
}}
onPointerLeave={() => {
setHoverAnchor(null);
setHoverCell(null);
}}
onPointerUp={(e) => {
setKbd(false);
const p = pointer(e);
if (!p) return;
if (mode === 'wall') {
const a = toAnchor(p);
if (a) tapAnchor(a);
} else {
const c = toCell(p);
if (c) tapCell(c, e.pointerType === 'mouse');
}
}}
tabIndex={0}
role="grid"
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
onKeyDown={onKey}
>
{goalSides.map((g, k) => {
const sameSide = goalSides.filter((x, j) => j < k && x.side === g.side).length;
return <rect key={`g${g.seat}`} {...band(g.side, sameSide)} rx={0.06} fill={SEAT_COLOR[g.seat]} opacity={0.9} />;
})}
{Array.from({ length: N }, (_, i) => {
const bottom = t.screenToCell({ x: i, y: N - 1 });
const left = t.screenToCell({ x: 0, y: i });
return (
<g key={`l${i}`} fill="var(--wood-line)" fontSize={0.28} fontWeight={700} textAnchor="middle" dominantBaseline="central" aria-hidden>
<text x={i + 0.5} y={N + PAD - 0.2}>
{t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]}
</text>
<text x={-PAD + 0.2} y={i + 0.5}>
{t.swap ? 'abcdefghi'[left.c] : left.r + 1}
</text>
</g>
);
})}
{Array.from({ length: N * N }, (_, i) => {
const x = i % N;
const y = Math.floor(i / N);
const legal = myTurn && mode === 'move' && legalCells.has(`${x},${y}`);
return (
<rect
key={`c${i}`}
x={x + G / 2}
y={y + G / 2}
width={1 - G}
height={1 - G}
rx={0.08}
fill={legal ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.32)'}
stroke={legal ? 'var(--primary)' : 'none'}
strokeWidth={legal ? 0.06 : 0}
data-cell={label(t.screenToCell({ x, y }))}
data-legal={legal ? '1' : undefined}
/>
);
})}
{lastFrom && <rect x={lastFrom.x + G / 2 + 0.05} y={lastFrom.y + G / 2 + 0.05} width={1 - G - 0.1} height={1 - G - 0.1} rx={0.08} fill="none" stroke="var(--wood-line)" strokeWidth={0.04} strokeDasharray="0.1 0.08" />}
{path && path.length > 1 && (
<polyline
points={path.map((p) => `${p.x + 0.5},${p.y + 0.5}`).join(' ')}
fill="none"
stroke={SEAT_COLOR[me!.seat]}
strokeWidth={0.07}
strokeDasharray="0.14 0.12"
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.75}
/>
)}
{myTurn &&
mode === 'move' &&
[...legalCells].map((k) => {
const [x, y] = k.split(',').map(Number) as [number, number];
return <circle key={`d${k}`} cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" opacity={0.8} />;
})}
{v.walls.map((w) => {
const s = t.wallToScreen(w);
const k = wallKey(w);
return (
<rect
key={k}
className={k === newWall ? 'qd-wall-new' : undefined}
{...wallRect(s.a, s.o)}
rx={0.05}
fill="#3b2412"
stroke={k === lastWall ? 'var(--focus)' : '#f3e2c0'}
strokeWidth={k === lastWall ? 0.05 : 0.025}
data-wall={`${w.o}:${label(w)}`}
/>
);
})}
{v.seats.map((x) => {
if (!x.pos) return null;
const p = t.cellToScreen(x.pos);
const turn = v.phase === 'playing' && v.turn === x.seat;
return (
<g key={x.seat} className="qd-pawn" style={{ transform: `translate(${p.x + 0.5}px, ${p.y + 0.5}px)` }} data-pawn={x.seat} data-at={label(x.pos)}>
{turn && <circle className="qd-turn-ring" cx={0} cy={0} r={0.45} fill="none" stroke={SEAT_COLOR[x.seat]} strokeWidth={0.06} />}
<PawnMark seat={x.seat} cx={0} cy={0} />
</g>
);
})}
{lastTo && <circle cx={lastTo.x + 0.5 + 0.27} cy={lastTo.y + 0.5 - 0.27} r={0.08} fill="var(--focus)" stroke="#fff" strokeWidth={0.02} />}
{ghostCell && me && <PawnMark seat={me.seat} cx={ghostCell.x + 0.5} cy={ghostCell.y + 0.5} ghost />}
{preview && previewAnchor && (
<rect
{...wallRect(previewAnchor, orient)}
rx={0.05}
fill={preview.why ? 'rgba(220, 38, 38, 0.6)' : SEAT_COLOR[me!.seat]}
opacity={preview.why ? 1 : 0.6}
stroke={preview.why ? '#7f1d1d' : '#fff'}
strokeWidth={0.04}
strokeDasharray={wallAt ? undefined : '0.12 0.08'}
/>
)}
{myTurn && mode === 'wall' && (
<g aria-hidden>
{Array.from({ length: 64 }, (_, i) => {
const a = { x: i % 8, y: Math.floor(i / 8) };
const w = t.screenToWall(a, 'h');
return <circle key={`a${i}`} cx={a.x + 1} cy={a.y + 1} r={0.09} fill="var(--wood-line)" opacity={0.35} data-anchor={label(w)} />;
})}
</g>
)}
{myTurn && kbd && mode === 'move' && (
<rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.1} fill="none" stroke="var(--focus)" strokeWidth={0.07} />
)}
{myTurn && kbd && mode === 'wall' && <circle cx={cursor.x + 1} cy={cursor.y + 1} r={0.16} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
</svg>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{me && v.phase === 'playing' && me.status === 'playing' && (
<div className="qd-modes" role="group" aria-label="할 일 고르기">
<button type="button" className={`btn ${mode === 'move' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'move'} disabled={!myTurn} onClick={() => setModeTo('move')}>
🚶 말 움직이기
</button>
<button type="button" className={`btn ${mode === 'wall' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'wall'} disabled={!canWall} onClick={() => setModeTo('wall')}>
🧱 벽 세우기 ({wallsLeft}개)
</button>
</div>
)}
{myTurn && mode === 'wall' && (
<div className="qd-tray">
<span className={`qd-hint small${preview?.why ? ' bad' : ''}`} role="status">
{!preview
? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.'
: preview.why
? `${wallText(preview.wall, t.swap)}: ${preview.why}`
: `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
</span>
<button type="button" className="btn btn-secondary qd-rotate" onClick={() => {
const o = orient === 'h' ? 'v' : 'h';
setOrient(o);
}} aria-label={`벽 방향 바꾸기, 지금 ${orient === 'h' ? '가로' : '세로'}`}>
↻ {orient === 'h' ? '가로' : '세로'}
</button>
<button type="button" className="btn btn-primary qd-confirm" disabled={!previewAnchor || !!preview?.why || pending} onClick={() => previewAnchor && doWall(previewAnchor)}>
벽 세우기 확인
</button>
</div>
)}
{myTurn && mode === 'move' && movePreview && (
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(movePreview)}>
{label(t.screenToCell(movePreview))}으로 이동
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMovePreview(null)}>
취소
</button>
</div>
)}
{me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
{me.status === 'playing'
? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.`
: me.status === 'finished'
? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.'
: '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}
</p>
)}
{!me && (
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
function WallBars({ left, total }: { left: number; total: number }) {
return (
<span className="qd-bars" aria-label={`남은 벽 ${left}개`} role="img">
{Array.from({ length: total }, (_, i) => (
<i key={i} className={i < left ? undefined : 'used'} />
))}
</span>
);
}
function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const u = players[x.playerId] ?? { id: x.playerId, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = v.phase === 'playing' && v.turn === x.seat;
const now = useServerNow(500);
const tc = v.timeControl;
let clock: React.ReactNode = null;
if (x.status === 'playing' && tc.kind === 'fischer') {
const ms = Math.max(0, v.remainingMs[x.seat] - (active ? now - v.turnStartedAt : 0));
const s = Math.ceil(ms / 1000);
clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, 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 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
}
const state =
x.status === 'finished'
? `${x.finishOrder}등 도착`
: x.status === 'resigned'
? '기권'
: x.status === 'timedOut'
? '시간패'
: `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`;
return (
<div
className="card row"
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? SEAT_COLOR[x.seat] : 'transparent'}`, opacity: x.status === 'resigned' || x.status === 'timedOut' ? 0.6 : 1 }}
>
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
<PawnMark seat={x.seat} cx={0} cy={0} r={0.45} />
</svg>
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{x.playerId === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{COLOR_KO[x.seat]} · {state}
</div>
</div>
<WallBars left={x.wallsLeft} total={v.wallsPerPlayer} />
<span className="small" style={{ fontWeight: 800, minWidth: '2ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{x.wallsLeft}
</span>
{clock}
</div>
);
}
function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps<QuoridorView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
const mine = v.seats.find((x) => x.seat === v.mySeat);
const live = v.phase === 'playing' && mine?.status === 'playing';
const incoming =
live && v.mySeat
? v.undo.pending && v.undo.pending.by !== v.mySeat && !v.undo.pending.accepted.includes(v.mySeat)
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== v.mySeat && !v.draw.accepted.includes(v.mySeat)
? 'draw'
: null
: null;
const waitingOthers = live && v.mySeat && ((v.undo.pending && (v.undo.pending.by === v.mySeat || v.undo.pending.accepted.includes(v.mySeat))) || (v.draw.pendingBy && (v.draw.pendingBy === v.mySeat || v.draw.accepted.includes(v.mySeat))));
const outgoing = !!v.mySeat && (v.undo.pending?.by === v.mySeat || v.draw.pendingBy === v.mySeat);
const undoLeft = v.mySeat ? v.undo.remaining[v.mySeat] : null;
const requester = v.undo.pending?.by ?? v.draw.pendingBy;
// 내 자리가 맨 아래, 나머지는 차례 순서대로 위에.
const order = mine ? [...v.seats.filter((x) => x.seat !== mine.seat), mine] : v.seats;
const four = v.seats.length === 4;
return (
<>
{order.map((x) => (
<PlayerRow key={x.seat} v={v} x={x} players={players} deadline={deadline} me={me} />
))}
{live && (
<div style={{ display: 'grid', gridTemplateColumns: v.undo.allowed && v.draw.allowed ? '1fr 1fr 1fr' : '1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!requester || undoLeft === 0 || !v.moves.some((m) => m.kind !== 'pass' && m.seat === v.mySeat)} onClick={() => act({ type: 'requestUndo' })} title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!requester} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
)}
{waitingOthers && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">다른 사람의 답을 기다리는 중…</span>
{outgoing && (
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
)}
</div>
)}
<MoveList view={v} />
{incoming && requester && (
<Sheet onClose={() => {}} label="다른 사람의 요청">
<h2>{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}</h2>
<p className="muted">
{incoming === 'undo'
? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}`
: `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}
</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={four ? '기권하면 내 말이 판에서 빠지고, 남은 벽은 사라져요.' : '기권하면 이 판은 상대가 이겨요.'}
confirm="기권하기"
danger
onConfirm={() => act({ type: 'resign' })}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
</>
);
}
function MoveList({ view: v }: { view: QuoridorView }) {
const ref = useRef<HTMLOListElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moveCount]);
if (v.moveCount === 0) return null;
let n = 0;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)</summary>
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{v.moves.map((m, i) => (
<li key={i} className={`small${m.kind === 'pass' ? ' muted' : ''}`}>
{m.kind !== 'pass' && <span className="muted">{++n}. </span>}
<span style={{ color: SEAT_COLOR[m.seat], fontWeight: 800 }} aria-hidden>
●{' '}
</span>
{moveText(m, swapFor(v.mySeat))}
</li>
))}
</ol>
</details>
);
}
function statusText(v: QuoridorView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summaryKo ?? '게임이 끝났어요';
const mine = v.seats.find((x) => x.seat === v.mySeat);
if (mine && mine.status === 'playing') {
if (v.undo.pending && v.undo.pending.by !== mine.seat && !v.undo.pending.accepted.includes(mine.seat)) return '무르기 요청이 왔어요';
if (v.draw.pendingBy && v.draw.pendingBy !== mine.seat && !v.draw.accepted.includes(mine.seat)) return '무승부 제안이 왔어요';
}
if (mine && v.turn === mine.seat && mine.status === 'playing') {
return mine.wallsLeft > 0 ? '내 차례예요. 말을 움직이거나 벽을 세워요' : '내 차례예요. 남은 벽이 없어서 말을 움직여요';
}
const turnSeat = v.seats.find((x) => x.seat === v.turn)!;
const nick = players[turnSeat.playerId]?.nickname ?? SEAT_KO[v.turn];
if (mine?.status === 'finished') return `도착했어요! ${nick}님 차례예요`;
return mine ? `${nick}님(${COLOR_KO[v.turn]})이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={aria}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label: title, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{title}</strong>
{children}
</div>
);
}
const TIME_PRESETS: [string, string, Partial<QuoridorOptions['timeControl']>][] = [
['pm15', '한 수 15초', { kind: 'perMove', perMoveSec: 15 }],
['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 }],
['none', '시간 제한 없음', { kind: 'none' }],
];
function timeKey(tc: QuoridorOptions['timeControl']): string {
if (tc.kind === 'none') return 'none';
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
return `f${Math.round(tc.baseSec / 60)}`;
}
function timeLabel(tc: QuoridorOptions['timeControl']): string {
if (tc.kind === 'none') return '시간 제한 없음';
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
const m = Math.floor(tc.baseSec / 60);
const sec = tc.baseSec % 60;
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
}
function Options({ value: o, onChange }: OptionsProps<QuoridorOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<QuoridorOptions>) => onChange({ ...o, ...patch });
const onOff = (v: boolean) => (v ? 'on' : 'off');
const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl));
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="시간">
<Seg
label="시간"
value={timeKey(o.timeControl)}
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(known ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]])]}
onChange={(k) => {
const preset = TIME_PRESETS.find((p) => p[0] === k);
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
}}
/>
</Field>
<Field label="2명일 때 1인당 벽 수">
<Seg
label="2명일 때 1인당 벽 수"
value={o.wallsPerPlayer}
options={[...([10, 8, 6] as const).map((n) => [n, n === 10 ? '10개(기본)' : `${n}개(빠른 게임)`] as [number, string]), ...([10, 8, 6].includes(o.wallsPerPlayer) ? [] : [[o.wallsPerPlayer, `${o.wallsPerPlayer}개`] as [number, string]])]}
onChange={(wallsPerPlayer) => set({ wallsPerPlayer })}
/>
<span className="small muted">4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요.</span>
</Field>
<Field label="내 최단 길 힌트">
<Seg label="내 최단 길 힌트" value={onOff(o.showPath)} options={[['on', '점선으로 보여 주기'], ['off', '안 보여 주기']]} onChange={(k) => set({ showPath: k === 'on' })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="대각선으로 넘어갈 수 있는 때">
<Seg
label="대각선으로 넘어갈 수 있는 때"
value={o.diagonalJumpTrigger}
options={[
['wallEdgePawn', '말 뒤가 벽·판 끝·다른 말'],
['wallOnly', '말 뒤가 벽일 때만'],
]}
onChange={(diagonalJumpTrigger) => set({ diagonalJumpTrigger })}
/>
</Field>
<Field label="시작 자리">
<Seg label="시작 자리" value={o.seatOrder} options={[['random', '무작위'], ['joinOrder', '들어온 순서']]} onChange={(seatOrder) => set({ seatOrder })} />
</Field>
<Field label="4명일 때 1등이 나오면">
<Seg label="4명일 때 1등이 나오면" value={onOff(o.continueForRanking)} options={[['off', '바로 끝내기'], ['on', '계속해서 2·3등 가리기']]} onChange={(k) => set({ continueForRanking: k === 'on' })} />
</Field>
{o.timeControl.kind === 'perMove' && (
<Field label="시간이 다 되면">
<Seg label="시간이 다 되면" value={o.timeoutPolicy} options={[['autoMove', '최단 길로 한 칸 자동 이동'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
</Field>
)}
<Field label="무르기">
<Seg label="무르기" value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</Field>
<Field label="무승부 제안">
<Seg label="무승부 제안" value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: QuoridorOptions): string {
return [timeLabel(o.timeControl), `벽 ${o.wallsPerPlayer}개(2명)`, ...(o.showPath ? ['길 힌트'] : []), ...(o.diagonalJumpTrigger === 'wallOnly' ? ['대각은 벽 뒤만'] : []), ...(o.continueForRanking ? ['순위 끝까지'] : [])].join(' · ');
}
// ---------------------------------------------------------------- rules
/** 규칙 그림: 5×5 조각판. pawns: [열, 행(위=0), 자리], walls: [앵커 열, 앵커 행, 방향], marks: 갈 수 있는 칸. */
function Diagram({ pawns, walls = [], marks = [], caption }: { pawns: [number, number, Seat][]; walls?: [number, number, O, boolean?][]; marks?: [number, number][]; caption: string }) {
const n = 5;
return (
<figure style={{ margin: 0, textAlign: 'center' }}>
<svg viewBox={`-0.1 -0.1 ${n + 0.2} ${n + 0.2}`} style={{ width: '100%', maxWidth: 170, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 8 }} aria-hidden>
{Array.from({ length: n * n }, (_, i) => (
<rect key={i} x={(i % n) + G / 2} y={Math.floor(i / n) + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.35)" />
))}
{marks.map(([x, y]) => (
<g key={`m${x}${y}`}>
<rect x={x + G / 2} y={y + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.92)" stroke="var(--primary)" strokeWidth={0.06} />
<circle cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" />
</g>
))}
{walls.map(([x, y, o, red], i) => (
<rect key={`w${i}`} {...wallRect({ x, y }, o)} rx={0.05} fill={red ? 'rgba(220, 38, 38, 0.7)' : '#3b2412'} stroke={red ? '#7f1d1d' : '#f3e2c0'} strokeWidth={0.03} strokeDasharray={red ? '0.12 0.08' : undefined} />
))}
{pawns.map(([x, y, s]) => (
<PawnMark key={`p${x}${y}`} seat={s} cx={x + 0.5} cy={y + 0.5} />
))}
</svg>
<figcaption className="small muted" style={{ marginTop: 4 }}>
{caption}
</figcaption>
</figure>
);
}
function Rules({ options }: { options?: QuoridorOptions }) {
const wallOnly = options?.diagonalJumpTrigger === 'wallOnly';
return (
<div className="stack">
<p>
9×9 판에서 <strong>내 말을 반대편 끝줄까지 먼저 보내면</strong> 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.
</p>
<p>
내 차례에는 <strong>말을 한 칸 움직이거나</strong>, <strong>벽을 하나 세워요</strong>(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.
</p>
<p>
<strong>말 이동</strong>: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.
</p>
<p>
<strong>뛰어넘기</strong>: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} marks={[[2, 1], [1, 3], [3, 3], [2, 4]]} caption="① 앞의 말을 곧게 뛰어넘기" />
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h']]} marks={[[1, 2], [3, 2], [1, 3], [3, 3], [2, 4]]} caption="② 뒤가 벽이면 대각선으로" />
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h'], [1, 1, 'v']]} marks={[[3, 2], [1, 3], [3, 3], [2, 4]]} caption="③ 한쪽도 벽이면 다른 쪽만" />
</div>
<p>
<strong>벽 세우기</strong>: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 <strong>누구의 길도 완전히 막으면 안 돼요</strong>. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h'], [3, 0, 'v', true]]} caption="④ 길을 완전히 막는 벽은 안 돼요" />
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h']]} marks={[[3, 0]]} caption="⑤ 돌아갈 길이 남으면 괜찮아요" />
</div>
<p>
<strong>세우는 방법</strong>: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).
</p>
<p>
<strong>이기는 방법</strong>: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.
</p>
<p className="muted">팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.</p>
</div>
);
}
const ui: GameUI<QuoridorView, QuoridorOptions> = {
Board: QuoridorBoard,
Side: QuoridorSide,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.mySeat && v.seats.some((x) => x.seat === v.turn && x.playerId === me && x.status === 'playing'),
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -19,6 +19,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'),
quoridor: () => import('./quoridor'),
};
const cache = new Map<string, GameUI>();

View File

@@ -16,6 +16,7 @@ export const GAME_ICON: Record<string, string> = {
marble: '🏙️',
bang: '🤠',
'halli-galli': '🔔',
quoridor: '🧱',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */