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,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;