예제 소스
장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개
JanggiBoard.tsx
"use client";
import { useCallback, useMemo, useRef } from "react";
import { inBoard, pieceEmpty, pieceGlyph, posEq, type Piece, type Pos } from "@/lib/types";
import styles from "./JanggiBoard.module.css";
type Props = {
squares: Piece[];
selected: Pos | null;
targets: Pos[];
lastMove: { from: Pos; to: Pos; pass: boolean } | null;
checkKing: Pos | null;
flipped: boolean;
interactive: boolean;
onSelect: (pos: Pos) => void;
};
function at(squares: Piece[], file: number, rank: number): Piece {
return squares[rank * 9 + file]!;
}
function pieceRadius(type: Piece["type"], cell: number): number {
switch (type) {
case "king":
return cell * 0.42;
case "chariot":
case "cannon":
return cell * 0.38;
case "horse":
case "elephant":
return cell * 0.36;
case "advisor":
return cell * 0.33;
case "pawn":
return cell * 0.29;
default:
return cell * 0.32;
}
}
function octagonPoints(cx: number, cy: number, r: number): string {
const pts: string[] = [];
for (let i = 0; i < 8; i++) {
const angle = ((22.5 + 45 * i) * Math.PI) / 180;
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
}
return pts.join(" ");
}
export function JanggiBoard({
squares,
selected,
targets,
lastMove,
checkKing,
flipped,
interactive,
onSelect,
}: Props) {
const svgRef = useRef<SVGSVGElement>(null);
const layout = useMemo(() => {
const w = 640;
const h = 760;
const left = 42;
const right = 28;
const top = 40;
const bottom = 40;
const cellW = (w - left - right) / 8;
const cellH = (h - top - bottom) / 9;
return { w, h, left, top, cellW, cellH };
}, []);
const pointFor = useCallback(
(pos: Pos) => {
let file = pos.file;
let rank = pos.rank;
if (flipped) {
file = 8 - file;
rank = 9 - rank;
}
return {
x: layout.left + file * layout.cellW,
y: layout.top + rank * layout.cellH,
};
},
[flipped, layout],
);
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
if (!interactive) return;
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const sx = ((e.clientX - rect.left) / rect.width) * layout.w;
const sy = ((e.clientY - rect.top) / rect.height) * layout.h;
let best: Pos | null = null;
let bestDist = Math.min(layout.cellW, layout.cellH) * 0.48;
for (let rank = 0; rank < 10; rank++) {
for (let file = 0; file < 9; file++) {
const pos = { file, rank };
const p = pointFor(pos);
const d = Math.hypot(sx - p.x, sy - p.y);
if (d < bestDist) {
bestDist = d;
best = pos;
}
}
}
if (best) onSelect(best);
};
const cell = Math.min(layout.cellW, layout.cellH);
const boardRect = {
x: layout.left - layout.cellW * 0.55,
y: layout.top - layout.cellH * 0.55,
w: layout.cellW * 8 + layout.cellW * 1.1,
h: layout.cellH * 9 + layout.cellH * 1.1,
};
return (
<div className={styles.frame}>
<svg
ref={svgRef}
className={styles.svg}
viewBox={`0 0 ${layout.w} ${layout.h}`}
role="img"
aria-label="장기판"
onClick={handleClick}
>
<defs>
<linearGradient id="boardBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#d6a86a" />
<stop offset="100%" stopColor="#ba844a" />
</linearGradient>
<linearGradient id="pieceFill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#fff4d6" />
<stop offset="100%" stopColor="#e8cc96" />
</linearGradient>
</defs>
<rect width={layout.w} height={layout.h} fill="url(#boardBg)" rx="14" />
<rect
x={boardRect.x}
y={boardRect.y}
width={boardRect.w}
height={boardRect.h}
rx="10"
fill="#e8c184"
stroke="rgba(92,54,24,0.7)"
strokeWidth="2"
/>
{Array.from({ length: 9 }, (_, file) => (
<line
key={`vf${file}`}
x1={pointFor({ file, rank: 0 }).x}
y1={pointFor({ file, rank: 0 }).y}
x2={pointFor({ file, rank: 9 }).x}
y2={pointFor({ file, rank: 9 }).y}
stroke="#3e2412"
strokeWidth="1.6"
/>
))}
{Array.from({ length: 10 }, (_, rank) => (
<line
key={`hr${rank}`}
x1={pointFor({ file: 0, rank }).x}
y1={pointFor({ file: 0, rank }).y}
x2={pointFor({ file: 8, rank }).x}
y2={pointFor({ file: 8, rank }).y}
stroke="#3e2412"
strokeWidth="1.6"
/>
))}
<line
x1={pointFor({ file: 3, rank: 0 }).x}
y1={pointFor({ file: 3, rank: 0 }).y}
x2={pointFor({ file: 5, rank: 2 }).x}
y2={pointFor({ file: 5, rank: 2 }).y}
stroke="#3e2412"
strokeWidth="2.1"
/>
<line
x1={pointFor({ file: 5, rank: 0 }).x}
y1={pointFor({ file: 5, rank: 0 }).y}
x2={pointFor({ file: 3, rank: 2 }).x}
y2={pointFor({ file: 3, rank: 2 }).y}
stroke="#3e2412"
strokeWidth="2.1"
/>
<line
x1={pointFor({ file: 3, rank: 7 }).x}
y1={pointFor({ file: 3, rank: 7 }).y}
x2={pointFor({ file: 5, rank: 9 }).x}
y2={pointFor({ file: 5, rank: 9 }).y}
stroke="#3e2412"
strokeWidth="2.1"
/>
<line
x1={pointFor({ file: 5, rank: 7 }).x}
y1={pointFor({ file: 5, rank: 7 }).y}
x2={pointFor({ file: 3, rank: 9 }).x}
y2={pointFor({ file: 3, rank: 9 }).y}
stroke="#3e2412"
strokeWidth="2.1"
/>
<rect
x={pointFor({ file: 3, rank: 0 }).x}
y={pointFor({ file: 3, rank: 0 }).y}
width={pointFor({ file: 5, rank: 2 }).x - pointFor({ file: 3, rank: 0 }).x}
height={pointFor({ file: 5, rank: 2 }).y - pointFor({ file: 3, rank: 0 }).y}
fill="none"
stroke="#3e2412"
strokeWidth="2.4"
/>
<rect
x={pointFor({ file: 3, rank: 7 }).x}
y={pointFor({ file: 3, rank: 7 }).y}
width={pointFor({ file: 5, rank: 9 }).x - pointFor({ file: 3, rank: 7 }).x}
height={pointFor({ file: 5, rank: 9 }).y - pointFor({ file: 3, rank: 7 }).y}
fill="none"
stroke="#3e2412"
strokeWidth="2.4"
/>
{Array.from({ length: 9 }, (_, file) => {
const shown = flipped ? 8 - file : file;
const top = pointFor({ file, rank: 0 });
const bot = pointFor({ file, rank: 9 });
return (
<g key={`fl${file}`}>
<text x={top.x} y={top.y - layout.cellH * 0.42} className={styles.label} textAnchor="middle">
{shown + 1}
</text>
<text x={bot.x} y={bot.y + layout.cellH * 0.42 + 4} className={styles.label} textAnchor="middle">
{shown + 1}
</text>
</g>
);
})}
{Array.from({ length: 10 }, (_, rank) => {
const shown = flipped ? 9 - rank : rank;
const left = pointFor({ file: 0, rank });
return (
<text
key={`rl${rank}`}
x={left.x - layout.cellW * 0.46}
y={left.y + 4}
className={styles.label}
textAnchor="middle"
>
{shown + 1}
</text>
);
})}
<text
x={pointFor({ file: 8, rank: flipped ? 0 : 9 }).x + layout.cellW * 0.42}
y={pointFor({ file: 8, rank: flipped ? 0 : 9 }).y + 4}
className={styles.choTag}
>
초
</text>
<text
x={pointFor({ file: 8, rank: flipped ? 9 : 0 }).x + layout.cellW * 0.42}
y={pointFor({ file: 8, rank: flipped ? 9 : 0 }).y + 4}
className={styles.hanTag}
>
한
</text>
{lastMove && !lastMove.pass ? (
<>
<circle
cx={pointFor(lastMove.from).x}
cy={pointFor(lastMove.from).y}
r={cell * 0.46}
fill="rgba(255,170,40,0.35)"
/>
<circle
cx={pointFor(lastMove.to).x}
cy={pointFor(lastMove.to).y}
r={cell * 0.46}
fill="rgba(255,140,20,0.47)"
/>
</>
) : null}
{checkKing && inBoard(checkKing) ? (
<circle
cx={pointFor(checkKing).x}
cy={pointFor(checkKing).y}
r={pieceRadius("king", cell) + 6}
fill="rgba(220,40,40,0.2)"
stroke="rgba(220,40,40,0.8)"
strokeWidth="3"
/>
) : null}
{selected ? (
<circle
cx={pointFor(selected).x}
cy={pointFor(selected).y}
r={cell * 0.46}
fill="rgba(80,140,255,0.27)"
stroke="rgb(40,90,200)"
strokeWidth="3"
/>
) : null}
{targets.map((t) => {
const capture = !pieceEmpty(at(squares, t.file, t.rank));
const p = pointFor(t);
if (capture) {
return (
<circle
key={`t-${t.file}-${t.rank}`}
cx={p.x}
cy={p.y}
r={cell * 0.42}
fill="none"
stroke="rgba(200,40,40,0.86)"
strokeWidth="2.5"
/>
);
}
return (
<circle
key={`t-${t.file}-${t.rank}`}
cx={p.x}
cy={p.y}
r="7"
fill="rgba(30,90,40,0.63)"
/>
);
})}
{Array.from({ length: 10 }, (_, rank) =>
Array.from({ length: 9 }, (_, file) => {
const piece = at(squares, file, rank);
if (pieceEmpty(piece)) return null;
const pos = { file, rank };
const p = pointFor(pos);
const r = pieceRadius(piece.type, cell);
const ink = piece.side === "han" ? "#b01c24" : "#126644";
return (
<g key={`p-${file}-${rank}`}>
<circle cx={p.x + 2.2} cy={p.y + 3.2} r={r} fill="rgba(40,24,10,0.27)" />
<polygon
points={octagonPoints(p.x, p.y, r)}
fill="url(#pieceFill)"
stroke="#5c3a18"
strokeWidth="1.8"
/>
<polygon
points={octagonPoints(p.x, p.y, r * 0.78)}
fill="none"
stroke={ink}
strokeOpacity="0.55"
strokeWidth="1"
/>
<text
x={p.x}
y={p.y + r * 0.38}
textAnchor="middle"
fontSize={r * 1.15}
fontWeight="700"
fill={ink}
fontFamily='"Songti SC","STSong","Noto Serif KR","Apple SD Gothic Neo",serif'
>
{pieceGlyph(piece)}
</text>
</g>
);
}),
)}
</svg>
</div>
);
}
export function samePos(a: Pos | null, b: Pos | null): boolean {
if (!a || !b) return false;
return posEq(a, b);
}