/** 체스 화면 (docs/games/chess.md §9). Legal moves come from the server view; the client has no rule code. */ import { useEffect, useMemo, useRef, useState } from 'react'; import type { ChessOptions, ChessView, LegalMove } from '@bg/games/chess'; import type { PublicUser } from '@bg/shared/lite'; import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; type Color = 'white' | 'black'; type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p'; type Promo = 'q' | 'r' | 'b' | 'n'; const COLOR_KO: Record = { white: '백', black: '흑' }; const PIECE_KO: Record = { k: '킹', q: '퀸', r: '룩', b: '비숍', n: '나이트', p: '폰' }; const FILES = 'abcdefgh'; const other = (c: Color): Color => (c === 'white' ? 'black' : 'white'); // ---------------------------------------------------------------- squares /** Board array index (a8 = 0 … h1 = 63) for a square name. */ const idxOf = (sq: string) => (8 - Number(sq[1])) * 8 + FILES.indexOf(sq[0]!); const sqOf = (file: number, rank: number) => `${FILES[file]}${rank + 1}`; /** Display cell (0..7 from the top-left) for a square, given which colour sits at the bottom. */ function cellOf(sq: string, bottom: Color): { x: number; y: number } { const f = FILES.indexOf(sq[0]!); const r = Number(sq[1]) - 1; return bottom === 'white' ? { x: f, y: 7 - r } : { x: 7 - f, y: r }; } function squareAt(x: number, y: number, bottom: Color): string { return bottom === 'white' ? sqOf(x, 7 - y) : sqOf(7 - x, y); } const pieceAt = (v: ChessView, sq: string) => v.board[idxOf(sq)] ?? null; const colorOfPiece = (p: string): Color => (p === p.toUpperCase() ? 'white' : 'black'); // ---------------------------------------------------------------- pieces (hand-drawn SVG, 100×100 box) const BASE = 'M20 91 H80 Q84 91 84 87 V84 Q84 79 79 79 H21 Q16 79 16 84 V87 Q16 91 20 91 Z'; const ROYAL_BODY = 'M30 79 L36 57 H64 L70 79 Z'; const SHAPES: Record = { p: { fill: [ 'M33 79 C35 68 39 62 43 58 H57 C61 62 65 68 67 79 Z', 'M38 59 H62 Q65 59 65 56 Q65 52 61 52 H39 Q35 52 35 56 Q35 59 38 59 Z', ], balls: [[50, 38, 14]], }, r: { fill: ['M30 79 L34 47 H66 L70 79 Z', 'M28 47 L27 19 H37 V26 H45 V19 H55 V26 H63 V19 H73 L72 47 Z'], detail: ['M33 39 H67', 'M33 66 H67'], }, b: { fill: [ 'M35 79 C36 72 40 68 42 64 H58 C60 68 64 72 65 79 Z', 'M37 65 H63 Q66 65 66 62 Q66 58 62 58 H38 Q34 58 34 62 Q34 65 37 65 Z', 'M50 17 C62 26 68 38 65 49 Q63 56 58 58 H42 Q37 56 35 49 C32 38 38 26 50 17 Z', ], balls: [[50, 13, 5.5]], detail: ['M57 29 L47 42'], }, n: { fill: [ 'M33 79 C32 66 37 58 46 51 C41 51 36 53 31 57 C27 60 21 58 20 52 C19 47 23 43 28 39 L43 25 C45 20 48 16 52 12 L56 19 C71 22 80 40 77 79 Z', ], detail: ['M61 26 C69 36 72 52 70 70'], balls: [ [44, 32, 2.8], [26, 51, 1.6], ], }, q: { fill: [ROYAL_BODY, 'M32 57 L19 30 L33 44 L35 24 L44 41 L50 19 L56 41 L65 24 L67 44 L81 30 L68 57 Z'], balls: [ [19, 29, 4.5], [35, 23, 4.5], [50, 17, 4.5], [65, 23, 4.5], [81, 29, 4.5], ], detail: ['M34 63 H66'], }, k: { fill: [ ROYAL_BODY, 'M32 57 C23 48 25 33 36 33 C43 33 47 38 50 44 C53 38 57 33 64 33 C75 33 77 48 68 57 Z', 'M47 8 H53 V16 H60 V22 H53 V36 H47 V22 H40 V16 H47 Z', ], detail: ['M34 63 H66', 'M50 44 V57'], }, }; function PieceShape({ type, color }: { type: PieceType; color: Color }) { const shape = SHAPES[type]; const fill = color === 'white' ? '#fbfaf5' : '#2c2a28'; const stroke = color === 'white' ? '#1d1b19' : '#0c0b0a'; const detail = color === 'white' ? '#1d1b19' : '#ece7dc'; return ( {shape.fill.map((d, i) => ( ))} {shape.balls?.map(([cx, cy, r], i) => type === 'n' ? ( ) : ( ), )} {shape.detail?.map((d, i) => ( ))} ); } /** A piece as a standalone inline icon (player bars, picker, rules). */ function PieceIcon({ type, color, size = 22 }: { type: PieceType; color: Color; size?: number }) { return ( ); } function letterPiece(p: string): { type: PieceType; color: Color } { return { type: p.toLowerCase() as PieceType, color: colorOfPiece(p) }; } // ---------------------------------------------------------------- board const LIGHT = '#efd9b4'; const DARK = '#b38660'; function ChessBoard({ view: v, act, pending }: BoardProps) { const myTurn = v.phase === 'playing' && v.myColor !== null && v.myColor === v.turn; const [flip, setFlip] = useState(false); const bottom: Color = v.myColor ?? (flip ? 'black' : 'white'); const [selected, setSelected] = useState(null); const [hover, setHover] = useState(null); const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 6 }); const [kbd, setKbd] = useState(false); const [promo, setPromo] = useState<{ from: string; to: string } | null>(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const drag = useRef<{ from: string; wasSelected: boolean; sx: number; sy: number; moved: boolean } | null>(null); const [announce, setAnnounce] = useState(''); const svgRef = useRef(null); const prevCount = useRef(v.moves.length); const movedRef = useRef(null); const dropped = useRef(false); const byFrom = useMemo(() => { const m = new Map(); for (const mv of v.legalMoves ?? []) m.set(mv.from, [...(m.get(mv.from) ?? []), mv]); return m; }, [v.legalMoves]); const targets = selected ? (byFrom.get(selected) ?? []) : []; const showDots = v.hints !== 'off'; useEffect(() => { if (v.moves.length !== prevCount.current) { const last = v.moves[v.moves.length - 1]; if (v.moves.length > prevCount.current && last) { // Slide the moved piece in (~150ms) unless I just dropped it there myself. const el = movedRef.current; if (el && v.lastMove && !dropped.current && !matchMedia('(prefers-reduced-motion: reduce)').matches) { const a = cellOf(v.lastMove.from, bottom); const b = cellOf(v.lastMove.to, bottom); el.animate([{ transform: `translate(${a.x - b.x}px, ${a.y - b.y}px)` }, { transform: 'translate(0px, 0px)' }], { duration: 150, easing: 'ease-out' }); } sounds.stone(); setAnnounce(`${COLOR_KO[last.color]}: ${last.san}${v.checkSquare ? ', 체크!' : ''}`); if (myTurn) { haptic(40); if (document.visibilityState === 'hidden') sounds.myTurn(); } } else setAnnounce('수를 물렀어요.'); } prevCount.current = v.moves.length; dropped.current = false; setSelected(null); setPromo(null); }, [v.moves.length]); // eslint-disable-line react-hooks/exhaustive-deps const send = (from: string, to: string, promotion?: Promo) => { act({ type: 'move', from, to, ...(promotion ? { promotion } : {}) }); setSelected(null); setPromo(null); setHover(null); }; const attempt = (from: string, to: string) => { const mv = (byFrom.get(from) ?? []).find((m) => m.to === to); if (!mv || pending) return; if (mv.promotion && !v.autoQueen) setPromo({ from, to }); else send(from, to); }; const ownMovable = (sq: string) => myTurn && byFrom.has(sq); const click = (sq: string, wasSelected: boolean) => { if (!myTurn) return; if (selected && targets.some((m) => m.to === sq)) return attempt(selected, sq); const p = pieceAt(v, sq); if (p && colorOfPiece(p) === v.myColor) { if (!byFrom.has(sq)) { setSelected(null); setAnnounce(`${PIECE_KO[p.toLowerCase() as PieceType]}은 지금 움직일 수 없어요.`); haptic([30, 30, 30]); return; } setSelected(wasSelected ? null : sq); return; } if (selected) { setAnnounce('그 칸으로는 갈 수 없어요.'); haptic([30, 30, 30]); } setSelected(null); }; const toSvg = (e: React.PointerEvent) => { const svg = svgRef.current; if (!svg) return null; const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY; return pt.matrixTransform(svg.getScreenCTM()!.inverse()); }; const cellAt = (e: React.PointerEvent): string | null => { const p = toSvg(e); if (!p) return null; const x = Math.floor(p.x); const y = Math.floor(p.y); return x >= 0 && y >= 0 && x < 8 && y < 8 ? squareAt(x, y, bottom) : null; }; const onKey = (e: React.KeyboardEvent) => { setKbd(true); const d: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; const step = d[e.key]; if (step) { e.preventDefault(); setCursor((c) => ({ x: Math.min(7, Math.max(0, c.x + step[0])), y: Math.min(7, Math.max(0, c.y + step[1])) })); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const sq = squareAt(cursor.x, cursor.y, bottom); click(sq, selected === sq); } else if (e.key === 'Escape') setSelected(null); }; const cell = (sq: string) => cellOf(sq, bottom); const lastFrom = v.lastMove ? cell(v.lastMove.from) : null; const lastTo = v.lastMove ? cell(v.lastMove.to) : null; const check = v.checkSquare ? cell(v.checkSquare) : null; const hoverTarget = hover && selected && targets.some((m) => m.to === hover) ? cell(hover) : null; const dragging = drag.current?.moved && dragPos ? drag.current.from : null; const cursorSq = squareAt(cursor.x, cursor.y, bottom); return (
{ setKbd(false); const sq = cellAt(e); if (!sq || !ownMovable(sq) || pending) return; drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false }; setSelected(sq); svgRef.current?.setPointerCapture(e.pointerId); }} onPointerMove={(e) => { const sq = cellAt(e); if (sq !== hover) setHover(sq); const d = drag.current; if (!d) return; if (!d.moved && Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > 6) d.moved = true; if (d.moved) setDragPos(toSvg(e)); }} onPointerLeave={() => setHover(null)} onPointerUp={(e) => { const sq = cellAt(e); const d = drag.current; drag.current = null; setDragPos(null); if (d?.moved) { if (sq && sq !== d.from) { dropped.current = true; attempt(d.from, sq); } return; } if (sq) click(sq, d ? d.wasSelected : false); }} onPointerCancel={() => { drag.current = null; setDragPos(null); }} > {Array.from({ length: 64 }, (_, i) => { const x = i % 8; const y = Math.floor(i / 8); return ; })} {lastFrom && } {lastTo && } {selected && } {v.threatened?.map((sq) => { const c = cell(sq); return ; })} {check && } {/* Coordinates: files on the bottom row, ranks on the left column. */} {Array.from({ length: 8 }, (_, i) => ( {squareAt(i, 7, bottom)[0]} {squareAt(0, i, bottom)[1]} ))} {hoverTarget && } {v.board.map((p, i) => { if (!p) return null; const sq = sqOf(i % 8, 7 - Math.floor(i / 8)); if (sq === dragging) return null; const c = cell(sq); const { type, color } = letterPiece(p); return ( ); })} {showDots && targets.map((m) => { const c = cell(m.to); const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]); return capture ? ( ) : ( ); })} {check && ( 체크! )} {dragging && dragPos && ( )} {kbd && }

{announce}

{!v.myColor && (
)} {promo && v.myColor && ( setPromo(null)} label="승진할 기물 고르기">

어떤 기물로 바꿀까요?

{(['q', 'r', 'b', 'n'] as const).map((t) => ( ))}
)}
); } // ---------------------------------------------------------------- side panel const REASON_HELP: Partial['reason'], string>> = { checkmate: '킹이 공격받고 있는데 피할 곳도, 막을 방법도 없어요.', stalemate: '체크는 아니지만 둘 수 있는 수가 하나도 없어서 비겼어요.', insufficientMaterial: '남은 기물로는 어느 쪽도 체크메이트할 수 없어서 비겼어요.', threefold: '같은 국면이 세 번 나와서 비겼어요.', fivefold: '같은 국면이 다섯 번 나와서 비겼어요.', fiftyMove: '50수 동안 폰 이동도 잡기도 없어서 비겼어요.', seventyFiveMove: '75수 동안 폰 이동도 잡기도 없어서 비겼어요.', timeoutVsInsufficient: '시간이 다 됐지만 상대도 체크메이트할 기물이 없어서 비겼어요.', aborted: '첫 수를 두지 않아 대국이 취소됐어요. 승패는 기록되지 않아요.', }; function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const [confirm, setConfirm] = useState(null); const mine = v.myColor; const incoming = v.phase === 'playing' && mine ? v.undo.pending && v.undo.pending.by !== mine ? 'undo' : v.draw.pendingBy && v.draw.pendingBy !== mine ? 'draw' : null : null; const outgoing = !!mine && (v.undo.pending?.by === mine || v.draw.pendingBy === mine); const undoLeft = mine ? v.undo.remaining[mine] : null; const top: Color = mine ? other(mine) : 'black'; const bottom: Color = mine ?? 'white'; const beforeFirst = !!mine && !v.started[mine]; const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine; return ( <> {v.phase === 'playing' && mine && (
{v.undo.allowed && ( )} {v.draw.allowed && ( )}
)} {canClaim && ( )} {outgoing && (
상대의 답을 기다리는 중…
)} {v.result && REASON_HELP[v.result.reason] && (
{v.result.summary}
{REASON_HELP[v.result.reason]}
)} {incoming && ( {}} label="상대의 요청">

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

{incoming === 'undo' ? '수락하면 기물이 되돌아가요. 시계는 되돌아가지 않아요.' : '수락하면 이 판은 무승부로 끝나요. 거절하거나 내가 수를 두면 제안이 사라져요.'}

)} {confirm === 'resign' && (beforeFirst ? ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> ) : ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> ))} {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} {confirm === 'claim' && ( act({ type: 'claimDraw' })} onClose={() => setConfirm(null)} /> )} ); } function MoveList({ view: v }: { view: ChessView }) { const ref = useRef(null); useEffect(() => { ref.current?.scrollTo({ top: ref.current.scrollHeight }); }, [v.moves.length]); if (v.moves.length === 0) return null; // Rows of [white, black]; a game started by black would show "…" first (not possible in standard chess). const rows: { no: number; w?: string; b?: string }[] = []; for (const m of v.moves) { if (m.color === 'white' || rows.length === 0) rows.push({ no: rows.length + 1 }); rows[rows.length - 1]![m.color === 'white' ? 'w' : 'b'] = m.san; } const lastIdx = v.moves.length - 1; return (
기보 ({Math.ceil(v.moves.length / 2)}수)
{rows.map((r, i) => ( ))}
); } function MoveRow({ no, w, b, lastW, lastB }: { no: number; w?: string; b?: string; lastW: boolean; lastB: boolean }) { const cell = (san: string | undefined, last: boolean) => ( {san ?? ''} ); return ( <> {no}. {cell(w, lastW)} {cell(b, lastB)} ); } function fmtClock(ms: number): string { const clamped = Math.max(0, ms); if (clamped < 20_000) return `${Math.floor(clamped / 1000)}.${Math.floor((clamped % 1000) / 100)}`; const s = Math.ceil(clamped / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); return h ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`; } function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView; color: Color; players: Record; deadline: number | null; me: string | null }) { const id = v.players[color]; const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; const active = v.phase === 'playing' && v.turn === color; const now = useServerNow(100); const tc = v.timeControl; let clock: React.ReactNode = null; if (active && !v.started[color]) { clock = ( 첫 수 ); } else if (tc.kind === 'fischer') { const ms = v.remainingMs[color] - (active && v.started[color] ? now - v.turnStartedAt : 0); const low = ms <= 10_000; clock = ( {fmtClock(ms)} ); } else if (active) { clock = ; } const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b)); const adv = color === 'white' ? v.materialDiff : -v.materialDiff; return (
{u.nickname} {id === me && (나)}
{COLOR_KO[color]} {active ? ' · 두는 중' : ''}
{clock}
{(caps.length > 0 || adv > 0) && (
PIECE_KO[c as PieceType]).join(', ')}`}> {caps.map((c, i) => ( ))} {adv > 0 && +{adv}}
)}
); } function statusText(v: ChessView, _me: string | null, players: Record): string { if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; const mine = v.myColor; if (mine && v.undo.pending && v.undo.pending.by !== mine) return '상대가 무르기를 요청했어요'; if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요'; if (mine === v.turn) { if (v.checkSquare) return '체크! 킹을 지켜야 해요'; if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`; if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요'; return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`; } const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; const check = v.checkSquare ? ' (체크)' : ''; return mine ? `${nick}님이 생각 중이에요…${check}` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요${check}`; } // ---------------------------------------------------------------- options function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { return (
{options.map(([k, l]) => ( ))}
); } const TIME_PRESETS: [string, string, ChessOptions['timeControl']][] = [ ['3+2', '3+2 블리츠', { kind: 'fischer', baseMin: 3, incSec: 2 }], ['5+0', '5+0', { kind: 'fischer', baseMin: 5, incSec: 0 }], ['5+3', '5+3', { kind: 'fischer', baseMin: 5, incSec: 3 }], ['10+0', '10+0 래피드', { kind: 'fischer', baseMin: 10, incSec: 0 }], ['10+5', '10+5', { kind: 'fischer', baseMin: 10, incSec: 5 }], ['15+10', '15+10', { kind: 'fischer', baseMin: 15, incSec: 10 }], ['30+0', '30+0 클래식', { kind: 'fischer', baseMin: 30, incSec: 0 }], ['none', '시간 제한 없음', { kind: 'none', baseMin: 10, incSec: 5 }], ]; const timeKey = (tc: ChessOptions['timeControl']) => (tc.kind === 'none' ? 'none' : `${tc.baseMin}+${tc.incSec}`); function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const key = timeKey(o.timeControl); const isPreset = TIME_PRESETS.some((p) => p[0] === key); const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min)); return (
시간 (기본 분 + 한 수마다 추가 초) [k, l] as [string, string]), ...(isPreset ? [] : [[key, `${timeLabel(o.timeControl)} (직접 설정)`] as [string, string]])]} onChange={(k) => { const preset = TIME_PRESETS.find((p) => p[0] === k); if (preset) set({ timeControl: preset[2] }); }} />
백(먼저 두는 쪽) set({ colorAssignment })} />
초보 도움말 set({ hints })} />
{more && ( <> {o.timeControl.kind === 'fischer' && (
시간 직접 정하기
)} {o.timeControl.kind === 'none' && (
응답이 없으면 시간패 set({ idleLimitSec })} />
)}
첫 수 제한 (넘기면 대국 취소) set({ firstMoveSec })} />
3회 반복 · 50수 무승부 set({ drawMode })} />
무르기 (상대 동의) set({ undo })} />
그 밖에 set({ autoQueen: k === 'on' })} /> set({ drawOffer: k === 'on' })} />
)}
); } function timeLabel(tc: ChessOptions['timeControl']): string { if (tc.kind === 'none') return '시간 제한 없음'; return `${tc.baseMin}분 + ${tc.incSec}초`; } function optionsSummary(o: ChessOptions): string { const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`; return ['표준', timeLabel(o.timeControl), undo].join(' · '); } // ---------------------------------------------------------------- rules const PIECE_RULES: [PieceType, string][] = [ ['k', '가로·세로·대각선으로 한 칸. 공격받는 칸으로는 갈 수 없어요. 잡히면 안 되는 가장 중요한 기물이에요.'], ['q', '가로·세로·대각선으로 몇 칸이든. 가장 강한 기물이에요(9점).'], ['r', '가로·세로로 몇 칸이든(5점).'], ['b', '대각선으로 몇 칸이든. 처음 칸과 같은 색 칸만 다녀요(3점).'], ['n', "'ㄱ'자(두 칸 + 옆 한 칸)로 움직이고, 다른 기물을 뛰어넘을 수 있어요(3점)."], ['p', '앞으로 한 칸(처음 자리에서는 두 칸까지). 잡을 때만 대각선 앞으로 한 칸 가요(1점).'], ]; function Rules({ options }: { options?: ChessOptions }) { const fide = options?.drawMode === 'fide'; return (

백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 킹을 공격해서(체크) 어디로도 피할 수 없게 만들면(체크메이트) 이겨요. 내 킹이 공격받게 되는 수는 둘 수 없어요.

{PIECE_RULES.map(([t, text]) => (
{PIECE_KO[t]} {text}
))}

캐슬링: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.

앙파상: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.

승진: 폰이 끝 줄에 닿으면 퀸·룩·비숍·나이트 중 하나로 바꿔요. 보통은 퀸을 골라요.

비기는 경우: 체크가 아닌데 둘 수가 없을 때(스테일메이트), 남은 기물로 체크메이트가 불가능할 때(예: 킹 대 킹), 같은 국면이 3번 나오거나 50수 동안 폰 이동과 잡기가 없을 때{fide ? '(이 방은 차례인 사람이 [무승부 주장]을 눌러야 해요. 5번 반복·75수는 자동)' : '(이 방은 자동으로 비겨요)'}, 그리고 서로 합의했을 때예요.

시간: 수를 둘 때마다 정해진 초가 더해져요. 시간이 다 되면 져요. 단 상대에게 체크메이트할 기물이 없으면 비겨요. 각자 첫 수는 시계가 줄지 않지만 정해진 시간 안에 두지 않으면 대국이 취소돼요.

조작: 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 기물을 끌어다 놓으면 둬요.

); } const ui: GameUI = { Board: ChessBoard, Side: ChessSide, statusText, isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, Options, optionsSummary, Rules, }; export default ui;