Files
joke-app/apps/web/src/games/chess/index.tsx
EJClaw 62a6caa6a7 M2: 체스·장기 추가
- 체스(chess.js 1.4.0, BSD-2): 캐슬링·앙파상·승진·체크메이트·스테일메이트·3회 반복·50/75수·기물 부족,
  피셔 시계(상대가 메이트 불가면 시간패 대신 무승부), 무르기·무승부·기권, 직접 그린 SVG 기물,
  클릭·드래그 이동, 승진 선택, 판 돌리기 (테스트 40개, perft 포함)
- 장기: 궁성 대각·포다리·멱·장군 합법성·빅장(KJF/KJA/끔)·반복 수 처리·한수쉼·점수 판정(한 덤 1.5),
  상차림 단계(순서/동시 비공개), 초읽기, 팔각 기물(한자+한글), 한 쪽이면 판 뒤집기 (테스트 46개)
- 게임 공통 E2E에 체스·장기 추가(PC 방장 + 휴대폰 친구, 상차림→첫 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:24:11 +09:00

857 lines
39 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/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<Color, string> = { white: '백', black: '흑' };
const PIECE_KO: Record<PieceType, string> = { 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<PieceType, { fill: string[]; balls?: [number, number, number][]; detail?: string[] }> = {
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 (
<g strokeLinejoin="round" strokeLinecap="round">
<path d={BASE} fill={fill} stroke={stroke} strokeWidth={3.2} />
{shape.fill.map((d, i) => (
<path key={i} d={d} fill={fill} stroke={stroke} strokeWidth={3.2} />
))}
{shape.balls?.map(([cx, cy, r], i) =>
type === 'n' ? (
<circle key={i} cx={cx} cy={cy} r={r} fill={detail} />
) : (
<circle key={i} cx={cx} cy={cy} r={r} fill={fill} stroke={stroke} strokeWidth={3.2} />
),
)}
{shape.detail?.map((d, i) => (
<path key={`d${i}`} d={d} fill="none" stroke={detail} strokeWidth={2.6} />
))}
</g>
);
}
/** A piece as a standalone inline icon (player bars, picker, rules). */
function PieceIcon({ type, color, size = 22 }: { type: PieceType; color: Color; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<PieceShape type={type} color={color} />
</svg>
);
}
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<ChessView>) {
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<string | null>(null);
const [hover, setHover] = useState<string | null>(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<SVGSVGElement>(null);
const prevCount = useRef(v.moves.length);
const movedRef = useRef<SVGGElement>(null);
const dropped = useRef(false);
const byFrom = useMemo(() => {
const m = new Map<string, LegalMove[]>();
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<SVGSVGElement>) => {
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<SVGSVGElement>): 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<string, [number, number]> = { 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 (
<div className="board-wrap stack" style={{ gap: 10 }}>
<svg
ref={svgRef}
viewBox="-0.02 -0.02 8.04 8.04"
style={{
width: '100%',
display: 'block',
borderRadius: 8,
boxShadow: 'var(--shadow)',
touchAction: 'none',
userSelect: 'none',
cursor: dragging ? 'grabbing' : hover && (ownMovable(hover) || hoverTarget) ? 'pointer' : 'default',
}}
tabIndex={0}
role="grid"
aria-label={`체스판. ${v.myColor ? `나는 ${COLOR_KO[v.myColor]}이에요. ` : ''}방향키로 칸을 고르고 Enter로 기물을 집거나 놓아요. 현재 칸 ${cursorSq}${pieceAt(v, cursorSq) ? ` ${COLOR_KO[colorOfPiece(pieceAt(v, cursorSq)!)]} ${PIECE_KO[pieceAt(v, cursorSq)!.toLowerCase() as PieceType]}` : ''}`}
onKeyDown={onKey}
onPointerDown={(e) => {
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);
}}
>
<defs>
<radialGradient id="chess-check">
<stop offset="0%" stopColor="#ff2a2a" stopOpacity={0.95} />
<stop offset="45%" stopColor="#e11d1d" stopOpacity={0.75} />
<stop offset="100%" stopColor="#a10000" stopOpacity={0} />
</radialGradient>
</defs>
{Array.from({ length: 64 }, (_, i) => {
const x = i % 8;
const y = Math.floor(i / 8);
return <rect key={i} x={x} y={y} width={1} height={1} fill={(x + y) % 2 === 0 ? LIGHT : DARK} />;
})}
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
{v.threatened?.map((sq) => {
const c = cell(sq);
return <rect key={`t${sq}`} x={c.x + 0.04} y={c.y + 0.04} width={0.92} height={0.92} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.14 0.08" />;
})}
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
{/* Coordinates: files on the bottom row, ranks on the left column. */}
{Array.from({ length: 8 }, (_, i) => (
<g key={`c${i}`} fontSize={0.2} fontWeight={700} style={{ pointerEvents: 'none' }}>
<text x={i + 0.94} y={7.94} textAnchor="end" fill={(i + 7) % 2 === 0 ? DARK : LIGHT}>
{squareAt(i, 7, bottom)[0]}
</text>
<text x={0.06} y={i + 0.22} fill={i % 2 === 0 ? DARK : LIGHT}>
{squareAt(0, i, bottom)[1]}
</text>
</g>
))}
{hoverTarget && <rect x={hoverTarget.x} y={hoverTarget.y} width={1} height={1} fill="#4f7a28" opacity={0.35} />}
{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 (
<g key={sq} transform={`translate(${c.x} ${c.y})`} style={{ pointerEvents: 'none' }}>
<g ref={v.lastMove?.to === sq ? movedRef : undefined}>
<g transform="translate(0.04 0.04) scale(0.0092)">
<PieceShape type={type} color={color} />
</g>
</g>
</g>
);
})}
{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 ? (
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} />
) : (
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.16} fill="rgba(20,40,10,0.38)" style={{ pointerEvents: 'none' }} />
);
})}
{check && (
<g transform={`translate(${check.x + 0.5} ${check.y < 1 ? check.y + 1.22 : check.y - 0.22})`} style={{ pointerEvents: 'none' }}>
<rect x={-0.42} y={-0.17} width={0.84} height={0.34} rx={0.12} fill="#b91c1c" />
<text textAnchor="middle" dominantBaseline="central" fontSize={0.2} fontWeight={800} fill="#fff">
체크!
</text>
</g>
)}
{dragging && dragPos && (
<g transform={`translate(${dragPos.x - 0.55} ${dragPos.y - 0.6}) scale(0.011)`} style={{ pointerEvents: 'none' }}>
<PieceShape {...letterPiece(pieceAt(v, dragging)!)} />
</g>
)}
{kbd && <rect x={cursor.x + 0.03} y={cursor.y + 0.03} width={0.94} height={0.94} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
</svg>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{!v.myColor && (
<div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}>
⇅ 판 돌리기
</button>
</div>
)}
{promo && v.myColor && (
<Sheet onClose={() => setPromo(null)} label="승진할 기물 고르기">
<h2>어떤 기물로 바꿀까요?</h2>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr', gap: 8 }}>
{(['q', 'r', 'b', 'n'] as const).map((t) => (
<button
key={t}
className={`btn ${t === 'q' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flexDirection: 'column', padding: 8, minHeight: 96 }}
autoFocus={t === 'q'}
onClick={() => send(promo.from, promo.to, t)}
>
<PieceIcon type={t} color={v.myColor!} size={t === 'q' ? 60 : 44} />
<span>{PIECE_KO[t]}</span>
</button>
))}
</div>
<button className="btn btn-ghost btn-block" onClick={() => setPromo(null)}>
취소
</button>
</Sheet>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
const REASON_HELP: Partial<Record<NonNullable<ChessView['result']>['reason'], string>> = {
checkmate: '킹이 공격받고 있는데 피할 곳도, 막을 방법도 없어요.',
stalemate: '체크는 아니지만 둘 수 있는 수가 하나도 없어서 비겼어요.',
insufficientMaterial: '남은 기물로는 어느 쪽도 체크메이트할 수 없어서 비겼어요.',
threefold: '같은 국면이 세 번 나와서 비겼어요.',
fivefold: '같은 국면이 다섯 번 나와서 비겼어요.',
fiftyMove: '50수 동안 폰 이동도 잡기도 없어서 비겼어요.',
seventyFiveMove: '75수 동안 폰 이동도 잡기도 없어서 비겼어요.',
timeoutVsInsufficient: '시간이 다 됐지만 상대도 체크메이트할 기물이 없어서 비겼어요.',
aborted: '첫 수를 두지 않아 대국이 취소됐어요. 승패는 기록되지 않아요.',
};
function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<ChessView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'claim'>(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 (
<>
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && mine && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={outgoing || !!v.draw.pendingBy || undoLeft === 0 || !v.moves.some((m) => m.color === mine)}
onClick={() => act({ type: 'requestUndo' })}
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={outgoing || !!v.undo.pending} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
{beforeFirst ? '✕ 대국 취소' : '🏳 기권'}
</button>
</div>
)}
{canClaim && (
<button className="btn btn-primary" onClick={() => setConfirm('claim')}>
⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'})
</button>
)}
{outgoing && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">상대의 답을 기다리는 중…</span>
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
{v.result && REASON_HELP[v.result.reason] && (
<div className="card" style={{ padding: 12 }}>
<strong>{v.result.summary}</strong>
<div className="muted small">{REASON_HELP[v.result.reason]}</div>
</div>
)}
<MoveList view={v} />
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">{incoming === 'undo' ? '수락하면 기물이 되돌아가요. 시계는 되돌아가지 않아요.' : '수락하면 이 판은 무승부로 끝나요. 거절하거나 내가 수를 두면 제안이 사라져요.'}</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' &&
(beforeFirst ? (
<ConfirmSheet title="대국을 취소할까요?" body="아직 첫 수를 두기 전이라 승패 없이 끝나요." confirm="대국 취소" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
) : (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
))}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" body="상대가 수락하면 비겨요." confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
{confirm === 'claim' && (
<ConfirmSheet title="무승부를 주장할까요?" body="규칙에 따라 상대 동의 없이 바로 비겨요." confirm="무승부 주장" onConfirm={() => act({ type: 'claimDraw' })} onClose={() => setConfirm(null)} />
)}
</>
);
}
function MoveList({ view: v }: { view: ChessView }) {
const ref = useRef<HTMLDivElement>(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 (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({Math.ceil(v.moves.length / 2)}수)</summary>
<div ref={ref} style={{ marginTop: 8, maxHeight: 180, overflow: 'auto', display: 'grid', gridTemplateColumns: '2.5em 1fr 1fr', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{rows.map((r, i) => (
<MoveRow key={i} no={r.no} w={r.w} b={r.b} lastW={i * 2 === lastIdx} lastB={i * 2 + 1 === lastIdx} />
))}
</div>
</details>
);
}
function MoveRow({ no, w, b, lastW, lastB }: { no: number; w?: string; b?: string; lastW: boolean; lastB: boolean }) {
const cell = (san: string | undefined, last: boolean) => (
<span className="small" style={{ fontWeight: last ? 800 : 500, background: last ? 'var(--surface-2)' : undefined, borderRadius: 4, padding: '0 4px' }}>
{san ?? ''}
</span>
);
return (
<>
<span className="small muted">{no}.</span>
{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<string, PublicUser>; 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 = (
<span className="row" style={{ gap: 6 }}>
<span className="small muted">첫 수</span>
<TurnTimer deadline={deadline} totalMs={v.firstMoveSec * 1000} mine={id === me} />
</span>
);
} else if (tc.kind === 'fischer') {
const ms = v.remainingMs[color] - (active && v.started[color] ? now - v.turnStartedAt : 0);
const low = ms <= 10_000;
clock = (
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontWeight: 800,
fontSize: '1.35rem',
padding: '2px 10px',
borderRadius: 8,
background: active ? (low ? 'var(--danger)' : 'var(--text)') : 'var(--surface-2)',
color: active ? 'var(--surface)' : low ? 'var(--danger)' : undefined,
}}
role="timer"
aria-label={`${COLOR_KO[color]} 남은 시간 ${fmtClock(ms)}`}
>
{fmtClock(ms)}
</span>
);
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={v.idleLimitSec * 1000} mine={id === me} />;
}
const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b));
const adv = color === 'white' ? v.materialDiff : -v.materialDiff;
return (
<div className="card" style={{ padding: '8px 12px', border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
<div className="row" style={{ gap: 10 }}>
<PieceIcon type="k" color={color} size={30} />
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{id === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small">
{COLOR_KO[color]}
{active ? ' · 두는 중' : ''}
</div>
</div>
{clock}
</div>
{(caps.length > 0 || adv > 0) && (
<div className="row" style={{ gap: 0, marginTop: 4, minHeight: 20, flexWrap: 'wrap' }} aria-label={`잡은 기물 ${caps.map((c) => PIECE_KO[c as PieceType]).join(', ')}`}>
{caps.map((c, i) => (
<PieceIcon key={i} type={c as PieceType} color={other(color)} size={20} />
))}
{adv > 0 && <span className="small muted" style={{ marginLeft: 6, fontWeight: 700 }}>+{adv}</span>}
</div>
)}
</div>
);
}
function statusText(v: ChessView, _me: string | null, players: Record<string, PublicUser>): 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<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
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<ChessOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<ChessOptions>) => 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 (
<div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>시간 (기본 분 + 한 수마다 추가 초)</strong>
<Seg
value={key}
options={[...TIME_PRESETS.map(([k, l]) => [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] });
}}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>백(먼저 두는 쪽)</strong>
<Seg
value={o.colorAssignment}
options={[['random', '무작위'], ['hostWhite', '방장이 백'], ['hostBlack', '방장이 흑'], ['alternate', '판마다 번갈아']]}
onChange={(colorAssignment) => set({ colorAssignment })}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>초보 도움말</strong>
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['movesAndThreats', '+ 위험한 기물 경고'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
{o.timeControl.kind === 'fischer' && (
<div className="stack" style={{ gap: 6 }}>
<strong>시간 직접 정하기</strong>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<label className="row" style={{ gap: 6 }}>
기본
<input
className="input"
type="number"
min={1}
max={90}
value={o.timeControl.baseMin}
onChange={(e) => set({ timeControl: { ...o.timeControl, baseMin: num(Number(e.target.value), 1, 90) } })}
style={{ width: 80 }}
/>
분
</label>
<label className="row" style={{ gap: 6 }}>
+ 한 수마다
<input
className="input"
type="number"
min={0}
max={60}
value={o.timeControl.incSec}
onChange={(e) => set({ timeControl: { ...o.timeControl, incSec: num(Number(e.target.value), 0, 60) } })}
style={{ width: 80 }}
/>
초
</label>
</div>
</div>
)}
{o.timeControl.kind === 'none' && (
<div className="stack" style={{ gap: 6 }}>
<strong>응답이 없으면 시간패</strong>
<Seg value={o.idleLimitSec} options={[[300, '5분'], [600, '10분']]} onChange={(idleLimitSec) => set({ idleLimitSec })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>첫 수 제한 (넘기면 대국 취소)</strong>
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>3회 반복 · 50수 무승부</strong>
<Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>무르기 (상대 동의)</strong>
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>그 밖에</strong>
<Seg value={o.autoQueen ? 'on' : 'off'} options={[['off', '승진할 때 기물 고르기'], ['on', '승진하면 바로 퀸']]} onChange={(k) => set({ autoQueen: k === 'on' })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
</>
)}
</div>
);
}
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 (
<div className="stack">
<p>
백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요.
내 킹이 공격받게 되는 수는 둘 수 없어요.
</p>
<div className="stack" style={{ gap: 6 }}>
{PIECE_RULES.map(([t, text]) => (
<div key={t} className="row" style={{ gap: 10, alignItems: 'flex-start' }}>
<PieceIcon type={t} color="white" size={34} />
<div>
<strong>{PIECE_KO[t]}</strong> <span className="muted">{text}</span>
</div>
</div>
))}
</div>
<p>
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
</p>
<p>
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
</p>
<p>
<strong>승진</strong>: 폰이 끝 줄에 닿으면 퀸·룩·비숍·나이트 중 하나로 바꿔요. 보통은 퀸을 골라요.
</p>
<p>
<strong>비기는 경우</strong>: 체크가 아닌데 둘 수가 없을 때(스테일메이트), 남은 기물로 체크메이트가 불가능할 때(예: 킹 대 킹), 같은 국면이 3번 나오거나 50수 동안 폰 이동과 잡기가
없을 때{fide ? '(이 방은 차례인 사람이 [무승부 주장]을 눌러야 해요. 5번 반복·75수는 자동)' : '(이 방은 자동으로 비겨요)'}, 그리고 서로 합의했을 때예요.
</p>
<p>
<strong>시간</strong>: 수를 둘 때마다 정해진 초가 더해져요. 시간이 다 되면 져요. 단 상대에게 체크메이트할 기물이 없으면 비겨요. 각자 첫 수는 시계가 줄지 않지만 정해진 시간 안에 두지
않으면 대국이 취소돼요.
</p>
<p>조작: 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 기물을 끌어다 놓으면 둬요.</p>
</div>
);
}
const ui: GameUI<ChessView, ChessOptions> = {
Board: ChessBoard,
Side: ChessSide,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
Options,
optionsSummary,
Rules,
};
export default ui;