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:
988
apps/web/src/games/janggi/index.tsx
Normal file
988
apps/web/src/games/janggi/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user