Merge branch 'm7/quoridor' into codex/owner/grp_discord_joke-app
This commit is contained in:
936
apps/web/src/games/quoridor/index.tsx
Normal file
936
apps/web/src/games/quoridor/index.tsx
Normal 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;
|
||||
@@ -21,6 +21,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
marble: () => import('./marble'),
|
||||
dalmuti: () => import('./dalmuti'),
|
||||
splendor: () => import('./splendor'),
|
||||
quoridor: () => import('./quoridor'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -18,6 +18,7 @@ export const GAME_ICON: Record<string, string> = {
|
||||
'halli-galli': '🔔',
|
||||
dalmuti: '👑',
|
||||
splendor: '💎',
|
||||
quoridor: '🧱',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
|
||||
Reference in New Issue
Block a user