게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 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);
}