/** 장기 화면 (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 = { cho: '초', han: '한' }; const INK: Record = { cho: '#1d4ed8', han: '#c0262d' }; const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho'); const HANJA: Record> = { king: { cho: '楚', han: '漢' }, cha: { cho: '車', han: '車' }, po: { cho: '包', han: '包' }, ma: { cho: '馬', han: '馬' }, sang: { cho: '象', han: '象' }, sa: { cho: '士', han: '士' }, jol: { cho: '卒', han: '兵' }, }; const HANGUL: Record> = { king: { cho: '궁', han: '궁' }, cha: { cho: '차', han: '차' }, po: { cho: '포', han: '포' }, ma: { cho: '마', han: '마' }, sang: { cho: '상', han: '상' }, sa: { cho: '사', han: '사' }, jol: { cho: '졸', han: '병' }, }; const VALUE: Record = { 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 = { 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 ( {HANJA[piece.kind][piece.side]} {hangul && ( {HANGUL[piece.kind][piece.side]} )} ); } 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 ( {Array.from({ length: 10 }, (_, y) => ( ))} {Array.from({ length: 9 }, (_, x) => ( ))} {[0, 7].map((y) => ( ))} {MARKS.map(([x, y]) => ( {[-1, 1].map((sx) => x + sx < 0 || x + sx > 8 ? null : [-1, 1].map((sy) => ( )), )} ))} ); } type Bubble = { text: string; color: string; id: number }; function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps) { 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(null); const [hover, setHover] = useState(null); const [preview, setPreview] = useState(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(null); const svgRef = useRef(null); const prev = useRef({ count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase }); const legal = useMemo(() => { const m = new Map(); 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): 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 = { 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(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(); 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 (
{ 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} > {Array.from({ length: 9 }, (_, x) => ( {fromScreen(x, 0).f + 1} ))} {Array.from({ length: 10 }, (_, y) => ( {(10 - fromScreen(0, y).r) % 10} ))} {last && [last.from, last.to].map((q, k) => { const s = toScreen(q); return ; })} {kingInCheck >= 0 && (() => { const s = toScreen(fromKey(kingInCheck)); return ; })()} {v.board.map((p, i) => { if (!p) return null; const s = toScreen(fromKey(i)); return ; })} {setupGhost && v.mySide && SLOT_X.map((x, k) => { const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang'; return ; })} {myTurn && v.threatened.map((q) => { const s = toScreen(q); return ; })} {[...vias].map((i) => { const s = toScreen(fromKey(i)); return ; })} {selected !== null && (() => { const s = toScreen(fromKey(selected)); return ; })()} {targets.map((t) => { const s = toScreen(fromKey(t.to)); return v.board[t.to] ? ( ) : ( ); })} {ghostTo !== null && selected !== null && v.board[selected] && (() => { const s = toScreen(fromKey(ghostTo)); return ; })()} {kbd && (myTurn || mustChoose) && ( )} {bubble && (
{bubble.text}
)}

{announce}

{v.phase === 'playing' && v.inCheck && (
{v.inCheck === v.mySide ? '장군이에요! 궁을 지켜야 해요.' : `${SIDE_KO[v.inCheck]} 장군!`}
)} {v.phase === 'playing' && v.bikjang && (
{v.bikjang.calledBy === v.mySide ? '빅장을 불렀어요. 상대가 막지 못하면 점수로 판정해요.' : v.mySide ? '빅장이에요! 궁 사이를 막거나 궁을 옆으로 옮기지 않으면 점수로 판정해요.' : `${SIDE_KO[v.bikjang.calledBy]} 빅장!`}
)} {myTurn && v.repeatWarning && (
{v.rules.repetition === 'forbid' ? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.' : v.rules.repetition === 'lose' ? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!' : '같은 국면이 3번 나왔어요. 한 번 더 반복하면 점수로 판정해요.'}
)} {preview !== null && selected !== null && myTurn && (
)} {v.phase === 'setup' && ( )}
); } function ArrangementMini({ arr, side }: { arr: Arrangement; side: Side }) { return ( {[0, 1, 2.4, 3.4].map((x, k) => ( ))} ); } 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(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 (
{mine ? `${ARR_NAME[mine][0]}(${ARR_NAME[mine][1]})을 골랐어요. ` : ''} {v.setup.waitingFor.length ? `${v.setup.waitingFor.map((s) => SIDE_KO[s]).join('·')}이 상차림을 고르는 중이에요…` : ''}
); } const hanChose = v.setup.order === 'hanFirst' && me === 'cho' ? v.setup.choice.han : null; return (
상차림을 골라 주세요 (마·상 놓는 자리, 내 쪽에서 본 왼쪽→오른쪽) {hanChose &&

한은 {ARR_NAME[hanChose][0]}({ARR_NAME[hanChose][1]})을 골랐어요.

}
{ARRS.map((a) => ( ))}
{picked && ( )}
); } // ---------------------------------------------------------------- side panel function JanggiSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const [confirm, setConfirm] = useState(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 ( <> {v.phase === 'setup' && v.mySide && ( )} {v.phase === 'playing' && v.mySide && (
{(v.rules.allowPass || v.canPass) && ( )} {v.undo.allowed && ( )} {v.draw.allowed && ( )}
)} {outgoing && v.phase === 'playing' && (
상대의 답을 기다리는 중…
)} {incoming && ( {}} label="상대의 요청">

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

{incoming === 'undo' ? '수락하면 기물이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}

)} {confirm === 'resign' && ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> )} {confirm === 'abort' && ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> )} {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} {confirm === 'pass' && ( act({ type: 'pass' })} onClose={() => setConfirm(null)} /> )} ); } function ScoreBar({ view: v }: { view: JanggiView }) { const { cho, han } = v.scores; const pct = (cho / (cho + han)) * 100; return (
초 {fmtScore(cho)} {v.rules.scoring ? '기물 점수 (한 덤 1.5)' : '기물 점수 (참고)'} {fmtScore(han)} 한
); } 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) ? : null; } const active = v.phase === 'playing' && v.turn === side; if (tc.kind === 'none') return active ? : 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 (
{big}
{main > 0 ? `초읽기 ${tc.periodSec}초 ` : '초읽기 '} {Array.from({ length: Math.max(periods, 0) }, () => '●').join('')}
); } function PlayerBar({ view: v, side, players, deadline, me }: { view: JanggiView; side: Side; players: Record; 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 (
{u.nickname} {id === me && (나)}
{SIDE_KO[side]} {side === 'cho' ? '· 선수' : '· 후수 덤 1.5'} {v.phase === 'playing' && v.turn === side ? ' · 두는 중' : ''} {v.inCheck === side ? ' · 장군!' : ''}
{caps.length > 0 && (
HANGUL[k][other(side)]).join(', ')}`}> 잡은 기물 {caps.map((k, i) => ( ))}
)}
); } 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(null); useEffect(() => { ref.current?.scrollTo({ top: ref.current.scrollHeight }); }, [v.moveCount]); if (v.moveCount === 0) return null; return (
기보 ({v.moveCount}수)
    {v.moves.map((m, i) => (
  1. {i + 1}. {moveText(m)}
  2. ))}
); } function statusText(v: JanggiView, me: string | null, players: Record): 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ title, children }: { title: string; children: React.ReactNode }) { return (
{title} {children}
); } const BIKJANG_SHORT: Record = { 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) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const setTc = (patch: Partial) => 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 (
set({ scoring: k === 'on' })} /> set({ bikjang })} /> { if (k === 'none') setTc({ kind: 'none' }); else setTc({ kind: 'byoyomi', mainSec: Number(k.slice(1)) }); }} /> set({ sideAssignment })} /> {more && ( <> {o.timeControl.kind === 'byoyomi' && ( <> setTc({ periodSec })} /> setTc({ periods })} /> )} set({ setupOrder })} /> set({ repetition })} /> set({ moveLimit })} /> set({ undo })} /> set({ hints })} /> set({ allowPass: k === 'on' })} /> set({ drawOffer: k === 'on' })} /> )}
); } 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 (

초(파랑)와 한(빨강)이 16개씩 기물을 움직여 상대 궁을 잡을 수밖에 없게(외통) 만들면 이겨요. 초가 먼저 둬요. 기물은 칸이 아니라 선이 만나는 점에 놓여요.

상차림: 시작 전에 뒷줄 마·상 네 개의 자리를 골라요(안상차림·바깥상차림·왼상차림·오른상차림).{' '} {o?.setupOrder === 'simultaneous' ? '두 사람이 동시에 몰래 고르고, 둘 다 고르면 공개돼요.' : '한이 먼저 골라 공개하고, 초가 보고 골라요.'}

기물 움직임
  • 궁(楚·漢)·사(士): 궁성(X자가 그려진 3×3) 안에서 선을 따라 한 칸. 대각선은 X 선이 있는 곳에서만 가요.
  • 차(車): 가로·세로로 막히지 않으면 몇 칸이든. 궁성 안에서는 X 선을 따라서도 가요.
  • 포(包): 반드시 기물 하나(포다리)를 넘어서 가요. 포다리 없이는 못 움직이고, 포는 포를 넘거나 잡을 수 없어요.
  • 마(馬): 한 칸 곧게 간 뒤 대각선으로 한 칸. 처음 곧게 가는 칸(멱)에 기물이 있으면 못 가요.
  • 상(象): 한 칸 곧게 간 뒤 대각선으로 두 칸. 지나가는 두 칸 중 하나라도 막히면 못 가요.
  • 졸(卒)·병(兵): 앞이나 옆으로 한 칸. 뒤로는 못 가요. 상대 궁성 안에서는 앞쪽 대각선으로도 가요.

장군: 다음 수에 궁을 잡을 수 있는 상태예요. 장군을 받으면 반드시 피해야 하고(멍군), 내 궁이 잡히게 되는 수는 둘 수 없어요. 피할 수 없으면{' '} 외통으로 져요.

{bik !== 'off' && (

빅장: 두 궁이 같은 세로줄에서 사이에 아무것도 없이 마주보게 만들면 빅장이에요 {bik === 'kjf' ? '(기물이 하나라도 잡힌 다음부터)' : '(양쪽 기물 점수가 모두 30점 미만일 때만)'}. 빅장을 받은 쪽이 다음 수로 막지 못하면{' '} {scoring ? '대국이 끝나고 점수로 승패를 가려요' : '무승부로 끝나요'}.

)}

한수쉼: {o?.allowPass === false ? '이 방에서는 쓰지 않아요(움직일 수 있는 기물이 없을 때만 쉬어요).' : '기물을 움직이지 않고 차례를 넘길 수 있어요. 장군을 받았을 때는 안 돼요.'} 두 사람이 연달아 쉬면 {scoring ? '점수로 판정해요' : '무승부예요'}.

반복수: 같은 국면은 3번까지 괜찮아요. 4번째는{' '} {rep === 'forbid' ? '만들 수 없어요(그 수를 둘 수 없어요).' : rep === 'lose' ? '만든 쪽이 실격패해요.' : '나오는 순간 점수로 판정해요.'}

점수: 차 13, 포 7, 마 5, 상 3, 사 3, 졸·병 2점. 후수인 한은 덤 1.5점을 더 받아 시작 점수는 초 72 : 한 73.5예요. 점수 판정은 남은 기물 점수가 높은 쪽이 이기고, 덤 때문에 동점은 없어요.

{o?.timeControl.kind !== 'none' && (

시간: 기본 시간을 다 쓰면 초읽기에 들어가요. 초읽기 시간 안에 두면 횟수가 줄지 않고, 넘기면 한 번씩 줄어요. 다 쓰면 시간패예요.

)}

(사)대한장기연맹 규정을 바탕으로 하고, 온라인에 맞게 일부 방식을 정했어요.

); } const ui: GameUI = { Board: JanggiBoard, Side: JanggiSide, statusText, isMyTurn, Options, optionsSummary, Rules, quickStart, helpNow, }; export default ui;