Files
joke-app/apps/web/src/games/quoridor/index.tsx
2026-10-06 13:35:21 +09:00

941 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 벽 쌓기 길찾기 화면 (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';
import { helpNow } from './help';
import { quickStart } from './quick';
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,
quickStart,
helpNow,
};
export default ui;