Files
joke-app/apps/web/src/games/janggi/index.tsx
EJClaw 373231b553 위협 가이드를 관리자 전용 옵션으로(기본 꺼짐) + 최고관리자 추가 + 계정 메뉴에 관리자 메뉴
- 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움)
- 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬
- 체스·장기 hints는 off / moves만 남김
- 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함)
- 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin
- 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:59:32 +09:00

993 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 장기 화면 (docs/games/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';
import { helpNow } from './help';
import { quickStart } from './quick';
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', '갈 수 있는 곳'], ['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,
quickStart,
helpNow,
};
export default ui;