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>
This commit is contained in:
EJClaw
2026-10-04 05:24:11 +09:00
parent c5c30d4ada
commit 62a6caa6a7
11 changed files with 4822 additions and 0 deletions

View File

@@ -0,0 +1,856 @@
/** 체스 화면 (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;

View File

@@ -0,0 +1,988 @@
/** 장기 화면 (docs/games/janggi.md §9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Arrangement, JanggiMove, JanggiOptions, JanggiView, Piece, PieceKind, Side, Sq } from '@bg/games/janggi';
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';
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
const HANJA: Record<PieceKind, Record<Side, string>> = {
king: { cho: '楚', han: '漢' },
cha: { cho: '車', han: '車' },
po: { cho: '包', han: '包' },
ma: { cho: '馬', han: '馬' },
sang: { cho: '象', han: '象' },
sa: { cho: '士', han: '士' },
jol: { cho: '卒', han: '兵' },
};
const HANGUL: Record<PieceKind, Record<Side, string>> = {
king: { cho: '궁', han: '궁' },
cha: { cho: '차', han: '차' },
po: { cho: '포', han: '포' },
ma: { cho: '마', han: '마' },
sang: { cho: '상', han: '상' },
sa: { cho: '사', han: '사' },
jol: { cho: '졸', han: '병' },
};
const VALUE: Record<PieceKind, number> = { king: 0, cha: 13, po: 7, ma: 5, sang: 3, sa: 3, jol: 2 };
const ARRS: Arrangement[] = ['MSSM', 'SMMS', 'SMSM', 'MSMS'];
const ARR_NAME: Record<Arrangement, [string, string]> = {
MSSM: ['안상차림', '원앙마'],
SMMS: ['바깥상차림', '양귀마'],
SMSM: ['왼상차림', '귀마'],
MSMS: ['오른상차림', '귀마'],
};
/** Screen x of my four back-row slots (left→right from my seat; same for both sides once flipped). */
const SLOT_X = [1, 2, 6, 7];
const sqKey = (q: Sq) => q.r * 9 + q.f;
const fromKey = (i: number): Sq => ({ f: i % 9, r: Math.floor(i / 9) });
/** Korean-style square code: row 1–9,0 from 한's back rank, then file 1–9 (초 seat). */
const sqCode = (q: Sq) => `${(10 - q.r) % 10}${q.f + 1}`;
const pieceName = (p: { side: Side; kind: PieceKind }) => HANGUL[p.kind][p.side];
const fmtScore = (n: number) => String(n);
function pieceRadius(kind: PieceKind): number {
return kind === 'king' ? 0.47 : kind === 'sa' || kind === 'jol' ? 0.33 : 0.41;
}
function octagon(cx: number, cy: number, r: number): string {
const pts: string[] = [];
for (let i = 0; i < 8; i++) {
const a = Math.PI / 8 + (i * Math.PI) / 4;
pts.push(`${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`);
}
return pts.join(' ');
}
function PieceGlyph({ piece, cx, cy, ghost, hangul, scale = 1 }: { piece: Piece; cx: number; cy: number; ghost?: boolean; hangul?: boolean; scale?: number }) {
const r = pieceRadius(piece.kind) * scale;
const ink = INK[piece.side];
const fs = r * (piece.kind === 'king' ? 1.05 : 1.08);
return (
<g opacity={ghost ? 0.5 : 1} style={{ pointerEvents: 'none' }}>
<polygon points={octagon(cx + 0.03 * scale, cy + 0.05 * scale, r)} fill="#000" opacity={0.18} />
<polygon points={octagon(cx, cy, r)} fill="#fbf3dc" stroke={ink} strokeWidth={0.05 * scale} />
<polygon points={octagon(cx, cy, r * 0.84)} fill="none" stroke={ink} strokeWidth={0.015 * scale} opacity={0.6} />
<text
x={cx}
y={hangul ? cy - r * 0.16 : cy}
fill={ink}
fontSize={hangul ? fs * 0.82 : fs}
fontWeight={700}
fontFamily="'Noto Serif KR','Noto Serif CJK KR','Batang','Times New Roman',serif"
textAnchor="middle"
dominantBaseline="central"
>
{HANJA[piece.kind][piece.side]}
</text>
{hangul && (
<text x={cx} y={cy + r * 0.56} fill={ink} fontSize={r * 0.42} fontWeight={700} textAnchor="middle" dominantBaseline="central">
{HANGUL[piece.kind][piece.side]}
</text>
)}
</g>
);
}
function useHangulLabels(): [boolean, (v: boolean) => void] {
const [on, setOn] = useState(() => {
try {
return localStorage.getItem('janggi.hangul') !== '0';
} catch {
return true;
}
});
return [
on,
(v: boolean) => {
setOn(v);
try {
localStorage.setItem('janggi.hangul', v ? '1' : '0');
} catch {}
},
];
}
/** Small corner ticks at the 졸/포 starting points, like a printed board. */
const MARKS: [number, number][] = [
[1, 2], [7, 2], [0, 3], [2, 3], [4, 3], [6, 3], [8, 3],
[1, 7], [7, 7], [0, 6], [2, 6], [4, 6], [6, 6], [8, 6],
];
function BoardLines() {
const d = 0.08;
const l = 0.16;
return (
<g stroke="var(--wood-line)" strokeLinecap="round">
{Array.from({ length: 10 }, (_, y) => (
<line key={`h${y}`} x1={0} y1={y} x2={8} y2={y} strokeWidth={0.035} />
))}
{Array.from({ length: 9 }, (_, x) => (
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={9} strokeWidth={0.035} />
))}
<rect x={0} y={0} width={8} height={9} fill="none" strokeWidth={0.07} />
{[0, 7].map((y) => (
<g key={`p${y}`} strokeWidth={0.035}>
<line x1={3} y1={y} x2={5} y2={y + 2} />
<line x1={5} y1={y} x2={3} y2={y + 2} />
</g>
))}
{MARKS.map(([x, y]) => (
<g key={`m${x}-${y}`} strokeWidth={0.03} fill="none">
{[-1, 1].map((sx) =>
x + sx < 0 || x + sx > 8
? null
: [-1, 1].map((sy) => (
<polyline key={`${sx}${sy}`} points={`${x + sx * d},${y + sy * (d + l)} ${x + sx * d},${y + sy * d} ${x + sx * (d + l)},${y + sy * d}`} />
)),
)}
</g>
))}
</g>
);
}
type Bubble = { text: string; color: string; id: number };
function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps<JanggiView>) {
const flip = v.mySide === 'han';
const toScreen = (q: Sq) => (flip ? { x: 8 - q.f, y: q.r } : { x: q.f, y: 9 - q.r });
const fromScreen = (x: number, y: number): Sq => (flip ? { f: 8 - x, r: y } : { f: x, r: 9 - y });
const myTurn = v.phase === 'playing' && v.mySide === v.turn;
const [hangul, setHangul] = useHangulLabels();
const [selected, setSelected] = useState<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
const [preview, setPreview] = useState<number | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 8 });
const [kbd, setKbd] = useState(false);
const [announce, setAnnounce] = useState('');
const [bubble, setBubble] = useState<Bubble | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const prev = useRef({ count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase });
const legal = useMemo(() => {
const m = new Map<number, { to: number; via: number | null }[]>();
for (const x of v.legalMoves ?? []) {
const k = sqKey(x.from);
if (!m.has(k)) m.set(k, []);
m.get(k)!.push({ to: sqKey(x.to), via: x.via ? sqKey(x.via) : null });
}
return m;
}, [v.legalMoves]);
const showHints = v.rules.hints !== 'off';
// Sounds, bubbles and announcements when the position changes.
useEffect(() => {
const p = prev.current;
const show = (text: string, color: string) => setBubble({ text, color, id: Date.now() });
if (v.moveCount > p.count) {
const m = v.moves[v.moves.length - 1]!;
if (m.kind === 'move') {
sounds.stone();
setAnnounce(`${SIDE_KO[m.side]} ${HANGUL[m.piece][m.side]}이 ${sqCode(m.from)}에서 ${sqCode(m.to)}로 갔어요${m.captured ? ` (${HANGUL[m.captured][other(m.side)]} 잡음)` : ''}.`);
} else {
setAnnounce(`${SIDE_KO[m.side]}이 한수쉼했어요.`);
}
if (v.inCheck && v.inCheck !== p.inCheck) {
show('장군!', '#b91c1c');
setAnnounce((a) => `${a} 장군!`);
} else if (p.inCheck && !v.inCheck && v.phase === 'playing') {
show('멍군!', '#1d4ed8');
}
if (v.bikjang && !p.bikjang) show('빅장!', '#7c3aed');
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}
if (p.phase === 'setup' && v.phase === 'playing') setAnnounce('상차림이 끝났어요. 초부터 둬요.');
prev.current = { count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase };
setSelected(null);
setPreview(null);
}, [v.moveCount, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), 1600);
return () => clearTimeout(t);
}, [bubble]);
const doMove = (from: number, to: number) => {
act({ type: 'move', from: fromKey(from), to: fromKey(to) });
setSelected(null);
setPreview(null);
setHover(null);
};
const tap = (i: number, direct: boolean) => {
const sq = fromKey(i);
setCursor(toScreen(sq));
if (!myTurn || pending) return;
const piece = v.board[i];
if (piece && piece.side === v.mySide) {
setPreview(null);
if (selected === i) {
setSelected(null);
return;
}
setSelected(i);
const n = legal.get(i)?.length ?? 0;
setAnnounce(`${pieceName(piece)} ${sqCode(sq)} 선택. ${n ? `갈 수 있는 곳 ${n}곳` : '지금은 움직일 수 없어요'}`);
if (!n) haptic([30, 30, 30]);
return;
}
if (selected === null) return;
const ok = legal.get(selected)?.some((m) => m.to === i);
if (!ok && showHints) {
// Targets are drawn, so a click elsewhere just deselects.
setSelected(null);
setPreview(null);
return;
}
// Without hints, an illegal target goes to the server, which explains why (멱, 포다리, 장군 …).
if (!ok || direct || !tapConfirm) {
doMove(selected, i);
return;
}
if (preview === i) doMove(selected, i);
else setPreview(i);
};
const pointToIndex = (e: React.PointerEvent<SVGSVGElement>): 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());
const x = Math.round(loc.x);
const y = Math.round(loc.y);
if (x < 0 || y < 0 || x > 8 || y > 9) return null;
return sqKey(fromScreen(x, y));
};
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] };
if (d[e.key]) {
e.preventDefault();
setCursor((c) => ({ x: Math.min(8, Math.max(0, c.x + d[e.key]![0])), y: Math.min(9, Math.max(0, c.y + d[e.key]![1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tap(sqKey(fromScreen(cursor.x, cursor.y)), true);
} else if (e.key === 'Escape') {
setSelected(null);
setPreview(null);
}
};
// Setup: card hover previews my 마·상 on the board.
const [setupHover, setSetupHover] = useState<Arrangement | null>(null);
const mustChoose = v.phase === 'setup' && !!v.mySide && v.setup.waitingFor.includes(v.mySide);
const myChoice = v.mySide ? v.setup.choice[v.mySide] : null;
const setupGhost = v.phase === 'setup' && v.mySide ? (setupHover ?? (v.setup.order === 'simultaneous' ? myChoice : null)) : null;
const focusFrom = selected ?? (hover !== null && v.board[hover]?.side === v.mySide && myTurn ? hover : null);
const targets = focusFrom !== null && showHints ? (legal.get(focusFrom) ?? []) : [];
const faint = selected === null;
const ghostTo = selected !== null ? (preview ?? (hover !== null && legal.get(selected)?.some((m) => m.to === hover) ? hover : null)) : null;
const vias = new Set<number>();
for (const t of targets) if (t.via !== null && (ghostTo === null || t.to === ghostTo)) vias.add(t.via);
const kingInCheck = v.inCheck ? v.board.findIndex((p) => p?.kind === 'king' && p.side === v.inCheck) : -1;
const last = v.lastMove;
const pad = 0.95;
const setupMaxW = v.phase === 'setup' ? 'min(100%, calc((100vh - 400px) * 0.9))' : 'min(100%, calc((100vh - 250px) * 0.9))';
return (
<div className="board-wrap stack" style={{ gap: 10, maxWidth: setupMaxW, minWidth: 'min(100%, 300px)' }}>
<div style={{ position: 'relative' }}>
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${8 + 2 * pad} ${9 + 2 * pad}`}
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || !myTurn) return;
const i = pointToIndex(e);
if (i !== hover) setHover(i);
}}
onPointerLeave={() => setHover(null)}
onPointerUp={(e) => {
setKbd(false);
const i = pointToIndex(e);
if (i !== null) tap(i, e.pointerType === 'mouse');
}}
tabIndex={0}
role="grid"
aria-label={`장기판. ${flip ? '한' : '초'} 쪽이 아래예요. 방향키로 움직이고 Enter로 기물을 고르거나 둡니다. 현재 위치 ${sqCode(fromScreen(cursor.x, cursor.y))}`}
onKeyDown={onKey}
>
<BoardLines />
{Array.from({ length: 9 }, (_, x) => (
<text key={`fx${x}`} x={x} y={9 + 0.72} fontSize={0.26} fill="var(--wood-line)" textAnchor="middle" dominantBaseline="central" opacity={0.75}>
{fromScreen(x, 0).f + 1}
</text>
))}
{Array.from({ length: 10 }, (_, y) => (
<text key={`ry${y}`} x={-0.74} y={y} fontSize={0.26} fill="var(--wood-line)" textAnchor="middle" dominantBaseline="central" opacity={0.75}>
{(10 - fromScreen(0, y).r) % 10}
</text>
))}
{last &&
[last.from, last.to].map((q, k) => {
const s = toScreen(q);
return <rect key={`lm${k}`} x={s.x - 0.46} y={s.y - 0.46} width={0.92} height={0.92} rx={0.12} fill={k ? 'rgba(250,204,21,0.45)' : 'none'} stroke="#ca8a04" strokeWidth={0.05} strokeDasharray={k ? undefined : '0.12 0.08'} />;
})}
{kingInCheck >= 0 && (() => {
const s = toScreen(fromKey(kingInCheck));
return <circle cx={s.x} cy={s.y} r={0.58} fill="rgba(220,38,38,0.25)" stroke="#dc2626" strokeWidth={0.07} />;
})()}
{v.board.map((p, i) => {
if (!p) return null;
const s = toScreen(fromKey(i));
return <PieceGlyph key={i} piece={p} cx={s.x} cy={s.y} hangul={hangul} />;
})}
{setupGhost &&
v.mySide &&
SLOT_X.map((x, k) => {
const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang';
return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />;
})}
{myTurn &&
v.threatened.map((q) => {
const s = toScreen(q);
return <circle key={`t${sqKey(q)}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#f59e0b" strokeWidth={0.06} strokeDasharray="0.14 0.09" />;
})}
{[...vias].map((i) => {
const s = toScreen(fromKey(i));
return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />;
})}
{selected !== null && (() => {
const s = toScreen(fromKey(selected));
return <circle cx={s.x} cy={s.y} r={0.52} fill="none" stroke="#16a34a" strokeWidth={0.08} />;
})()}
{targets.map((t) => {
const s = toScreen(fromKey(t.to));
return v.board[t.to] ? (
<circle key={`g${t.to}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08} opacity={faint ? 0.5 : 1} />
) : (
<circle key={`g${t.to}`} cx={s.x} cy={s.y} r={0.15} fill="#16a34a" opacity={faint ? 0.45 : 0.85} />
);
})}
{ghostTo !== null && selected !== null && v.board[selected] && (() => {
const s = toScreen(fromKey(ghostTo));
return <PieceGlyph piece={v.board[selected]!} cx={s.x} cy={s.y} hangul={hangul} ghost />;
})()}
{kbd && (myTurn || mustChoose) && (
<rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />
)}
</svg>
{bubble && (
<div
key={bubble.id}
aria-hidden
style={{
position: 'absolute',
left: '50%',
top: '42%',
transform: 'translate(-50%, -50%)',
background: 'var(--surface)',
color: bubble.color,
border: `4px solid ${bubble.color}`,
borderRadius: 24,
padding: '12px 28px',
fontSize: 'clamp(1.8rem, 5vw, 3rem)',
fontWeight: 900,
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
pointerEvents: 'none',
}}
>
{bubble.text}
</div>
)}
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{v.phase === 'playing' && v.inCheck && (
<div className="card" role="alert" style={{ padding: '8px 12px', background: 'var(--danger-soft)', color: 'var(--danger)', fontWeight: 700, textAlign: 'center' }}>
{v.inCheck === v.mySide ? '장군이에요! 궁을 지켜야 해요.' : `${SIDE_KO[v.inCheck]} 장군!`}
</div>
)}
{v.phase === 'playing' && v.bikjang && (
<div className="card" style={{ padding: '8px 12px', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 700, textAlign: 'center' }}>
{v.bikjang.calledBy === v.mySide
? '빅장을 불렀어요. 상대가 막지 못하면 점수로 판정해요.'
: v.mySide
? '빅장이에요! 궁 사이를 막거나 궁을 옆으로 옮기지 않으면 점수로 판정해요.'
: `${SIDE_KO[v.bikjang.calledBy]} 빅장!`}
</div>
)}
{myTurn && v.repeatWarning && (
<div className="card" style={{ padding: '8px 12px', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 700, textAlign: 'center' }}>
{v.rules.repetition === 'forbid'
? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.'
: v.rules.repetition === 'lose'
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
: '같은 국면이 3번 나왔어요. 한 번 더 반복하면 점수로 판정해요.'}
</div>
)}
{preview !== null && selected !== null && myTurn && (
<div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(selected, preview)}>
{`${v.board[selected] ? pieceName(v.board[selected]!) : ''} ${sqCode(fromKey(preview))}로 두기`}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
취소
</button>
</div>
)}
{v.phase === 'setup' && (
<SetupPicker view={v} act={act} pending={pending} tapConfirm={tapConfirm} mustChoose={mustChoose} onHover={setSetupHover} />
)}
<label className="row small muted" style={{ justifyContent: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={hangul} onChange={(e) => setHangul(e.target.checked)} />
기물에 한글 이름 같이 보기
</label>
</div>
);
}
function ArrangementMini({ arr, side }: { arr: Arrangement; side: Side }) {
return (
<svg viewBox="-0.55 -0.55 4.5 1.1" style={{ width: '100%', display: 'block' }} aria-hidden>
<line x1={0} y1={0} x2={3.4} y2={0} stroke="var(--wood-line)" strokeWidth={0.04} />
{[0, 1, 2.4, 3.4].map((x, k) => (
<PieceGlyph key={x} piece={{ side, kind: arr[k] === 'M' ? 'ma' : 'sang' }} cx={x} cy={0} scale={1.15} hangul />
))}
</svg>
);
}
function SetupPicker({
view: v,
act,
pending,
tapConfirm,
mustChoose,
onHover,
}: {
view: JanggiView;
act: (a: unknown) => void;
pending: boolean;
tapConfirm: boolean;
mustChoose: boolean;
onHover: (a: Arrangement | null) => void;
}) {
const [picked, setPicked] = useState<Arrangement | null>(null);
const me = v.mySide;
const choose = (a: Arrangement) => {
act({ type: 'chooseSetup', arrangement: a });
setPicked(null);
onHover(null);
};
if (!me || !mustChoose) {
const mine = me ? v.setup.choice[me] : null;
return (
<div className="card" style={{ padding: 12, textAlign: 'center' }}>
{mine ? `${ARR_NAME[mine][0]}(${ARR_NAME[mine][1]})을 골랐어요. ` : ''}
{v.setup.waitingFor.length ? `${v.setup.waitingFor.map((s) => SIDE_KO[s]).join('·')}이 상차림을 고르는 중이에요…` : ''}
</div>
);
}
const hanChose = v.setup.order === 'hanFirst' && me === 'cho' ? v.setup.choice.han : null;
return (
<div className="stack" style={{ gap: 8 }}>
<strong style={{ textAlign: 'center' }}>
상차림을 골라 주세요 <span className="muted small">(마·상 놓는 자리, 내 쪽에서 본 왼쪽→오른쪽)</span>
</strong>
{hanChose && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>한은 {ARR_NAME[hanChose][0]}({ARR_NAME[hanChose][1]})을 골랐어요.</p>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 8 }}>
{ARRS.map((a) => (
<button
key={a}
type="button"
className="card"
disabled={pending}
aria-pressed={picked === a}
aria-label={`${ARR_NAME[a][0]}, ${ARR_NAME[a][1]}`}
style={{ padding: 10, cursor: 'pointer', border: `3px solid ${picked === a ? 'var(--primary)' : 'var(--border)'}`, textAlign: 'center', font: 'inherit', color: 'inherit' }}
onPointerEnter={(e) => e.pointerType === 'mouse' && onHover(a)}
onPointerLeave={(e) => e.pointerType === 'mouse' && onHover(picked)}
onFocus={() => onHover(a)}
onClick={(e) => {
const pt = (e.nativeEvent as PointerEvent).pointerType;
if (pt === 'touch' && tapConfirm) {
setPicked(a);
onHover(a);
} else choose(a);
}}
>
<ArrangementMini arr={a} side={me} />
<div style={{ fontWeight: 800, marginTop: 6 }}>{ARR_NAME[a][0]}</div>
<div className="small muted">{ARR_NAME[a][1]}</div>
</button>
))}
</div>
{picked && (
<button className="btn btn-primary btn-block" disabled={pending} onClick={() => choose(picked)}>
{ARR_NAME[picked][0]}으로 정하기
</button>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
function JanggiSide({ view: v, me, players, act, pending, deadline }: BoardProps<JanggiView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass' | 'abort'>(null);
const incoming =
v.phase === 'playing' && v.mySide
? v.undo.pending && v.undo.pending.by !== v.mySide
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== v.mySide
? 'draw'
: null
: null;
const outgoing = v.mySide && (v.undo.pending?.by === v.mySide || v.draw.pendingBy === v.mySide);
const undoLeft = v.mySide ? v.undo.remaining[v.mySide] : null;
const top: Side = v.mySide ? other(v.mySide) : 'han';
const bottom: Side = v.mySide ?? 'cho';
const myTurn = v.phase === 'playing' && v.mySide === v.turn;
const lastWasPass = v.moves.length > 0 && v.moves[v.moves.length - 1]!.kind === 'pass';
return (
<>
<ScoreBar view={v} />
<PlayerBar view={v} side={top} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} side={bottom} players={players} deadline={deadline} me={me} />
{v.phase === 'setup' && v.mySide && (
<button className="btn btn-secondary btn-sm" onClick={() => setConfirm('abort')}>
대국 취소
</button>
)}
{v.phase === 'playing' && v.mySide && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{(v.rules.allowPass || v.canPass) && (
<button className="btn btn-secondary btn-sm" disabled={!myTurn || !v.canPass || pending} onClick={() => setConfirm('pass')} title={v.inCheck === v.mySide ? '장군을 받은 상태에서는 쉴 수 없어요' : undefined}>
⏸ 한수쉼
</button>
)}
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.side === v.mySide)}
onClick={() => act({ type: 'requestUndo' })}
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
)}
{outgoing && v.phase === 'playing' && (
<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>
)}
<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' && (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
)}
{confirm === 'abort' && (
<ConfirmSheet title="대국을 취소할까요?" body="상차림 중에 취소하면 승패 없이 끝나요." confirm="취소하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
)}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
{confirm === 'pass' && (
<ConfirmSheet
title="한수쉼할까요?"
body={
lastWasPass
? `상대도 방금 쉬었어요. 지금 쉬면 대국이 끝나고 ${v.rules.scoring ? '점수로 판정해요' : '무승부가 돼요'}.`
: '이번 차례에 기물을 움직이지 않고 넘겨요.'
}
confirm="한수쉼"
onConfirm={() => act({ type: 'pass' })}
onClose={() => setConfirm(null)}
/>
)}
</>
);
}
function ScoreBar({ view: v }: { view: JanggiView }) {
const { cho, han } = v.scores;
const pct = (cho / (cho + han)) * 100;
return (
<div className="card" style={{ padding: '8px 12px' }} aria-label={`점수 초 ${fmtScore(cho)}, 한 ${fmtScore(han)}`}>
<div className="row" style={{ justifyContent: 'space-between', fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}>
<span style={{ color: INK.cho }}>초 {fmtScore(cho)}</span>
<span className="small muted">{v.rules.scoring ? '기물 점수 (한 덤 1.5)' : '기물 점수 (참고)'}</span>
<span style={{ color: INK.han }}>{fmtScore(han)} 한</span>
</div>
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 6 }} aria-hidden>
<div style={{ width: `${pct}%`, background: INK.cho }} />
<div style={{ flex: 1, background: INK.han }} />
</div>
</div>
);
}
function mmss(ms: number): string {
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function Clock({ view: v, side, deadline, mine }: { view: JanggiView; side: Side; deadline: number | null; mine: boolean }) {
const now = useServerNow(250);
const tc = v.rules.timeControl;
if (v.phase === 'setup') {
return v.setup.waitingFor.includes(side) ? <TurnTimer deadline={deadline} totalMs={v.rules.setupTimeSec * 1000} mine={mine} /> : null;
}
const active = v.phase === 'playing' && v.turn === side;
if (tc.kind === 'none') return active ? <TurnTimer deadline={deadline} totalMs={v.rules.idleLimitSec * 1000} mine={mine} /> : null;
const periodMs = tc.periodSec * 1000;
const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0;
const main = v.clock.mainMs[side] - elapsed;
let big: string;
let periods = v.clock.periodsLeft[side];
let danger = false;
if (main > 0) {
big = mmss(main);
} else {
const over = -main;
const used = Math.floor(over / periodMs);
periods = Math.max(0, periods - used);
const left = periods > 0 ? periodMs - (over % periodMs) : 0;
big = active ? String(Math.ceil(left / 1000)) : `${tc.periodSec}`;
danger = active && left <= 10_000;
}
return (
<div style={{ textAlign: 'right' }} role="timer" aria-label={`${SIDE_KO[side]} 남은 시간 ${big}, 초읽기 ${periods}회`}>
<div style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: '1.2rem', color: danger ? 'var(--danger)' : undefined }}>{big}</div>
<div className="small muted" aria-hidden>
{main > 0 ? `초읽기 ${tc.periodSec}초 ` : '초읽기 '}
{Array.from({ length: Math.max(periods, 0) }, () => '●').join('')}
</div>
</div>
);
}
function PlayerBar({ view: v, side, players, deadline, me }: { view: JanggiView; side: Side; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const id = v.players[side];
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = (v.phase === 'playing' && v.turn === side) || (v.phase === 'setup' && v.setup.waitingFor.includes(side));
const caps = [...v.captured[side]].sort((a, b) => VALUE[b] - VALUE[a]);
return (
<div className="card" style={{ padding: '8px 12px', border: `3px solid ${active ? INK[side] : 'transparent'}`, transition: 'border-color 0.2s' }}>
<div className="row" style={{ gap: 10 }}>
<svg width={32} height={32} viewBox="-0.55 -0.55 1.1 1.1" aria-hidden>
<PieceGlyph piece={{ side, kind: 'king' }} cx={0} cy={0} />
</svg>
<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="small" style={{ color: INK[side], fontWeight: 700 }}>
{SIDE_KO[side]} {side === 'cho' ? '· 선수' : '· 후수 덤 1.5'}
{v.phase === 'playing' && v.turn === side ? ' · 두는 중' : ''}
{v.inCheck === side ? ' · 장군!' : ''}
</div>
</div>
<Clock view={v} side={side} deadline={deadline} mine={id === me} />
</div>
{caps.length > 0 && (
<div className="row" style={{ gap: 2, marginTop: 6, flexWrap: 'wrap' }} aria-label={`잡은 기물 ${caps.map((k) => HANGUL[k][other(side)]).join(', ')}`}>
<span className="small muted" style={{ marginRight: 4 }}>
잡은 기물
</span>
{caps.map((k, i) => (
<svg key={i} width={22} height={22} viewBox="-0.5 -0.5 1 1" aria-hidden>
<PieceGlyph piece={{ side: other(side), kind: k }} cx={0} cy={0} scale={k === 'king' ? 1 : 1.1} />
</svg>
))}
</div>
)}
</div>
);
}
function moveText(m: JanggiMove): string {
if (m.kind === 'pass') return '한수쉼';
return `${HANGUL[m.piece][m.side]} ${sqCode(m.from)}→${sqCode(m.to)}${m.captured ? ` ×${HANGUL[m.captured][other(m.side)]}` : ''}`;
}
function MoveList({ view: v }: { view: JanggiView }) {
const ref = useRef<HTMLOListElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moveCount]);
if (v.moveCount === 0) return null;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 160, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{v.moves.map((m, i) => (
<li key={i} className="small" style={{ color: INK[m.side] }}>
<span className="muted">{i + 1}.</span> {moveText(m)}
</li>
))}
</ol>
</details>
);
}
function statusText(v: JanggiView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요';
const nick = (s: Side) => players[v.players[s]]?.nickname ?? SIDE_KO[s];
if (v.phase === 'setup') {
if (v.mySide && v.setup.waitingFor.includes(v.mySide)) {
const han = v.setup.choice.han;
if (v.mySide === 'cho' && v.setup.order === 'hanFirst' && han) return `한이 ${ARR_NAME[han][0]}을 골랐어요. 내 상차림을 골라 주세요`;
return `상차림을 골라 주세요 (나는 ${SIDE_KO[v.mySide]})`;
}
const w = v.setup.waitingFor;
return w.length ? `${w.map(nick).join('·')}님이 상차림을 고르는 중이에요…` : '곧 대국이 시작돼요';
}
if (v.mySide && v.undo.pending && v.undo.pending.by !== v.mySide) return '상대가 무르기를 요청했어요';
if (v.mySide && v.draw.pendingBy && v.draw.pendingBy !== v.mySide) return '상대가 무승부를 제안했어요';
if (v.mySide === v.turn) {
if (v.inCheck === v.mySide) return '장군이에요! 궁을 지켜 주세요';
if (v.bikjang && v.bikjang.calledBy !== v.mySide) return '빅장이에요! 막지 않으면 점수로 판정해요';
return `내 차례예요 (${SIDE_KO[v.turn]}). 움직일 기물을 눌러 주세요`;
}
return v.mySide ? `${nick(v.turn)}님이 생각 중이에요…` : `${nick(v.turn)}님(${SIDE_KO[v.turn]}) 차례예요`;
}
function isMyTurn(v: JanggiView, me: string | null): boolean {
if (!me || !v.mySide || v.players[v.mySide] !== me) return false;
if (v.phase === 'setup') return v.setup.waitingFor.includes(v.mySide);
return v.phase === 'playing' && v.turn === v.mySide;
}
// ---------------------------------------------------------------- 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>
);
}
function Field({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{title}</strong>
{children}
</div>
);
}
const BIKJANG_SHORT: Record<JanggiOptions['bikjang'], string> = { off: '빅장 없음', kjf: '빅장(연맹식)', kja30: '빅장(30점 미만)' };
function timeLabel(tc: JanggiOptions['timeControl']): string {
if (tc.kind === 'none') return '시간 제한 없음';
const m = Math.floor(tc.mainSec / 60);
const s = tc.mainSec % 60;
return `${m ? `${m}분` : ''}${s ? ` ${s}초` : ''}${tc.mainSec === 0 ? '0분' : ''} + 초읽기 ${tc.periodSec}초×${tc.periods}`;
}
function Options({ value: o, onChange }: OptionsProps<JanggiOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<JanggiOptions>) => onChange({ ...o, ...patch });
const setTc = (patch: Partial<JanggiOptions['timeControl']>) => set({ timeControl: { ...o.timeControl, ...patch } });
const timeKey = o.timeControl.kind === 'none' ? 'none' : `m${o.timeControl.mainSec}`;
const presets: [string, string][] = [
['m300', '5분'],
['m600', '10분'],
['m1200', '20분'],
['none', '시간 제한 없음'],
];
if (!presets.some((p) => p[0] === timeKey)) presets.unshift([timeKey, `${timeLabel(o.timeControl)}(관리자 설정)`]);
return (
<div className="stack" style={{ gap: 16 }}>
<Field title="대국 방식">
<Seg value={o.scoring ? 'on' : 'off'} options={[['on', '점수제(판정은 기물 점수로)'], ['off', '비점수제(판정 상황은 무승부)']]} onChange={(k) => set({ scoring: k === 'on' })} />
</Field>
<Field title="빅장">
<Seg value={o.bikjang} options={[['kjf', '연맹식(기물 잡힌 뒤부터)'], ['kja30', '양쪽 30점 미만일 때'], ['off', '빅장 없음']]} onChange={(bikjang) => set({ bikjang })} />
</Field>
<Field title="기본 시간 (+ 초읽기)">
<Seg
value={timeKey}
options={presets}
onChange={(k) => {
if (k === 'none') setTc({ kind: 'none' });
else setTc({ kind: 'byoyomi', mainSec: Number(k.slice(1)) });
}}
/>
</Field>
<Field title="초·한 정하기 (초가 먼저 둬요)">
<Seg
value={o.sideAssignment}
options={[['random', '무작위'], ['hostCho', '방장이 초'], ['hostHan', '방장이 한'], ['alternate', '판마다 번갈아']]}
onChange={(sideAssignment) => set({ sideAssignment })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
{o.timeControl.kind === 'byoyomi' && (
<>
<Field title="초읽기 한 번">
<Seg value={o.timeControl.periodSec} options={[[20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
</Field>
<Field title="초읽기 횟수">
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [4, '4번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
</Field>
</>
)}
<Field title="상차림 순서">
<Seg value={o.setupOrder} options={[['hanFirst', '한이 먼저 공개(연맹식)'], ['simultaneous', '동시에 몰래 고르기']]} onChange={(setupOrder) => set({ setupOrder })} />
</Field>
<Field title="같은 국면 4번째">
<Seg value={o.repetition} options={[['forbid', '그 수를 못 둠'], ['lose', '만든 쪽 실격패'], ['scoreEnd', '점수로 판정']]} onChange={(repetition) => set({ repetition })} />
</Field>
<Field title="수 제한 (양쪽 합산)">
<Seg value={o.moveLimit} options={[[0, '없음'], [150, '150수'], [200, '200수'], [250, '250수']]} onChange={(moveLimit) => set({ moveLimit })} />
</Field>
<Field title="무르기">
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</Field>
<Field title="도움 표시">
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['movesAndThreats', '+ 잡힐 위험'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</Field>
<Field title="그 밖에">
<Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: JanggiOptions): string {
return [o.scoring ? '점수제' : '비점수제', BIKJANG_SHORT[o.bikjang], timeLabel(o.timeControl)].join(' · ');
}
function Rules({ options }: { options?: JanggiOptions }) {
const o = options;
const bik = o?.bikjang ?? 'kjf';
const rep = o?.repetition ?? 'forbid';
const scoring = o?.scoring ?? true;
return (
<div className="stack">
<p>
초(파랑)와 한(빨강)이 16개씩 기물을 움직여 상대 <strong>궁</strong>을 잡을 수밖에 없게(외통) 만들면 이겨요. <strong>초가 먼저</strong> 둬요. 기물은 칸이 아니라 선이
만나는 점에 놓여요.
</p>
<p>
<strong>상차림</strong>: 시작 전에 뒷줄 마·상 네 개의 자리를 골라요(안상차림·바깥상차림·왼상차림·오른상차림).{' '}
{o?.setupOrder === 'simultaneous' ? '두 사람이 동시에 몰래 고르고, 둘 다 고르면 공개돼요.' : '한이 먼저 골라 공개하고, 초가 보고 골라요.'}
</p>
<div>
<strong>기물 움직임</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: 20 }}>
<li>
<strong>궁(楚·漢)·사(士)</strong>: 궁성(X자가 그려진 3×3) 안에서 선을 따라 한 칸. 대각선은 X 선이 있는 곳에서만 가요.
</li>
<li>
<strong>차(車)</strong>: 가로·세로로 막히지 않으면 몇 칸이든. 궁성 안에서는 X 선을 따라서도 가요.
</li>
<li>
<strong>포(包)</strong>: 반드시 기물 하나(포다리)를 넘어서 가요. 포다리 없이는 못 움직이고, 포는 포를 넘거나 잡을 수 없어요.
</li>
<li>
<strong>마(馬)</strong>: 한 칸 곧게 간 뒤 대각선으로 한 칸. 처음 곧게 가는 칸(멱)에 기물이 있으면 못 가요.
</li>
<li>
<strong>상(象)</strong>: 한 칸 곧게 간 뒤 대각선으로 두 칸. 지나가는 두 칸 중 하나라도 막히면 못 가요.
</li>
<li>
<strong>졸(卒)·병(兵)</strong>: 앞이나 옆으로 한 칸. 뒤로는 못 가요. 상대 궁성 안에서는 앞쪽 대각선으로도 가요.
</li>
</ul>
</div>
<p>
<strong>장군</strong>: 다음 수에 궁을 잡을 수 있는 상태예요. 장군을 받으면 반드시 피해야 하고(멍군), 내 궁이 잡히게 되는 수는 둘 수 없어요. 피할 수 없으면{' '}
<strong>외통</strong>으로 져요.
</p>
{bik !== 'off' && (
<p>
<strong>빅장</strong>: 두 궁이 같은 세로줄에서 사이에 아무것도 없이 마주보게 만들면 빅장이에요
{bik === 'kjf' ? '(기물이 하나라도 잡힌 다음부터)' : '(양쪽 기물 점수가 모두 30점 미만일 때만)'}. 빅장을 받은 쪽이 다음 수로 막지 못하면{' '}
{scoring ? '대국이 끝나고 점수로 승패를 가려요' : '무승부로 끝나요'}.
</p>
)}
<p>
<strong>한수쉼</strong>: {o?.allowPass === false ? '이 방에서는 쓰지 않아요(움직일 수 있는 기물이 없을 때만 쉬어요).' : '기물을 움직이지 않고 차례를 넘길 수 있어요. 장군을 받았을 때는 안 돼요.'} 두 사람이 연달아
쉬면 {scoring ? '점수로 판정해요' : '무승부예요'}.
</p>
<p>
<strong>반복수</strong>: 같은 국면은 3번까지 괜찮아요. 4번째는{' '}
{rep === 'forbid' ? '만들 수 없어요(그 수를 둘 수 없어요).' : rep === 'lose' ? '만든 쪽이 실격패해요.' : '나오는 순간 점수로 판정해요.'}
</p>
<p>
<strong>점수</strong>: 차 13, 포 7, 마 5, 상 3, 사 3, 졸·병 2점. 후수인 한은 덤 1.5점을 더 받아 시작 점수는 초 72 : 한 73.5예요. 점수 판정은 남은 기물 점수가 높은 쪽이 이기고,
덤 때문에 동점은 없어요.
</p>
{o?.timeControl.kind !== 'none' && (
<p>
<strong>시간</strong>: 기본 시간을 다 쓰면 초읽기에 들어가요. 초읽기 시간 안에 두면 횟수가 줄지 않고, 넘기면 한 번씩 줄어요. 다 쓰면 시간패예요.
</p>
)}
<p className="small muted">(사)대한장기연맹 규정을 바탕으로 하고, 온라인에 맞게 일부 방식을 정했어요.</p>
</div>
);
}
const ui: GameUI<JanggiView, JanggiOptions> = {
Board: JanggiBoard,
Side: JanggiSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -4,6 +4,8 @@ import type { GameUI } from './types';
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
omok: () => import('./omok'),
othello: () => import('./othello'),
chess: () => import('./chess'),
janggi: () => import('./janggi'),
};
const cache = new Map<string, GameUI>();