/** 벽 쌓기 길찾기 화면 (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 = { S: '남', W: '서', N: '북', E: '동' }; const SEAT_COLOR: Record = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' }; const COLOR_KO: Record = { 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 = { 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 ( {!ghost && } {SEAT_KO[seat]} ); } type Mode = 'move' | 'wall'; function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps) { 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('move'); const [orient, setOrient] = useState('h'); const [wallAt, setWallAt] = useState(null); // 화면 앵커(고정된 미리보기) const [hoverAnchor, setHoverAnchor] = useState(null); const [hoverCell, setHoverCell] = useState(null); const [movePreview, setMovePreview] = useState(null); const [cursor, setCursor] = useState({ x: 4, y: 8 }); const [kbd, setKbd] = useState(false); const [announce, setAnnounce] = useState(''); const svgRef = useRef(null); const prevCount = useRef(v.moveCount); const prevWalls = useRef(v.walls.length); const [newWall, setNewWall] = useState(null); const legalCells = useMemo(() => { const m = new Set(); 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(); 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): { 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 = { 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 (
{ 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 ; })} {Array.from({ length: N }, (_, i) => { const bottom = t.screenToCell({ x: i, y: N - 1 }); const left = t.screenToCell({ x: 0, y: i }); return ( {t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]} {t.swap ? 'abcdefghi'[left.c] : left.r + 1} ); })} {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 ( ); })} {lastFrom && } {path && path.length > 1 && ( `${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 ; })} {v.walls.map((w) => { const s = t.wallToScreen(w); const k = wallKey(w); return ( ); })} {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 ( {turn && } ); })} {lastTo && } {ghostCell && me && } {preview && previewAnchor && ( )} {myTurn && mode === 'wall' && ( {Array.from({ length: 64 }, (_, i) => { const a = { x: i % 8, y: Math.floor(i / 8) }; const w = t.screenToWall(a, 'h'); return ; })} )} {myTurn && kbd && mode === 'move' && ( )} {myTurn && kbd && mode === 'wall' && }

{announce}

{me && v.phase === 'playing' && me.status === 'playing' && (
)} {myTurn && mode === 'wall' && (
{!preview ? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.' : preview.why ? `${wallText(preview.wall, t.swap)}: ${preview.why}` : `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
)} {myTurn && mode === 'move' && movePreview && (
)} {me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (

{me.status === 'playing' ? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.` : me.status === 'finished' ? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.' : '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}

)} {!me && (

{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}

)}
); } // ---------------------------------------------------------------- side panel function WallBars({ left, total }: { left: number; total: number }) { return ( {Array.from({ length: total }, (_, i) => ( ))} ); } function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record; 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 = ( {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} ); } else if (active) { const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000; clock = ; } const state = x.status === 'finished' ? `${x.finishOrder}등 도착` : x.status === 'resigned' ? '기권' : x.status === 'timedOut' ? '시간패' : `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`; return (
{u.nickname} {x.playerId === me && (나)}
{COLOR_KO[x.seat]} · {state}
{x.wallsLeft} {clock}
); } function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const [confirm, setConfirm] = useState(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) => ( ))} {live && (
{v.undo.allowed && ( )} {v.draw.allowed && ( )}
)} {waitingOthers && (
다른 사람의 답을 기다리는 중… {outgoing && ( )}
)} {incoming && requester && ( {}} label="다른 사람의 요청">

{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}

{incoming === 'undo' ? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}` : `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}

)} {confirm === 'resign' && ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> )} {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} ); } function MoveList({ view: v }: { view: QuoridorView }) { const ref = useRef(null); useEffect(() => { ref.current?.scrollTo({ top: ref.current.scrollHeight }); }, [v.moveCount]); if (v.moveCount === 0) return null; let n = 0; return (
기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)
    {v.moves.map((m, i) => (
  1. {m.kind !== 'pass' && {++n}. } ●{' '} {moveText(m, swapFor(v.mySeat))}
  2. ))}
); } function statusText(v: QuoridorView, me: string | null, players: Record): 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({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ label: title, children }: { label: string; children: React.ReactNode }) { return (
{title} {children}
); } const TIME_PRESETS: [string, string, Partial][] = [ ['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) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const onOff = (v: boolean) => (v ? 'on' : 'off'); const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)); return (
[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] } }); }} /> [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 })} /> 4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요. set({ showPath: k === 'on' })} /> {more && ( <> set({ diagonalJumpTrigger })} /> set({ seatOrder })} /> set({ continueForRanking: k === 'on' })} /> {o.timeControl.kind === 'perMove' && ( set({ timeoutPolicy })} /> )} set({ undo })} /> set({ drawOffer: k === 'on' })} /> )}
); } 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 (
{Array.from({ length: n * n }, (_, i) => ( ))} {marks.map(([x, y]) => ( ))} {walls.map(([x, y, o, red], i) => ( ))} {pawns.map(([x, y, s]) => ( ))}
{caption}
); } function Rules({ options }: { options?: QuoridorOptions }) { const wallOnly = options?.diagonalJumpTrigger === 'wallOnly'; return (

9×9 판에서 내 말을 반대편 끝줄까지 먼저 보내면 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.

내 차례에는 말을 한 칸 움직이거나, 벽을 하나 세워요(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.

말 이동: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.

뛰어넘기: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.

벽 세우기: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 누구의 길도 완전히 막으면 안 돼요. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).

세우는 방법: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).

이기는 방법: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.

팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.

); } const ui: GameUI = { 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;