M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
859
apps/web/src/games/baduk/index.tsx
Normal file
859
apps/web/src/games/baduk/index.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
/** 바둑 화면 (docs/games/baduk.md §9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { BadukOptions, BadukView, Pt, ScoreBreakdown } from '@bg/games/baduk';
|
||||
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';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const COLS = 'ABCDEFGHJKLMNOPQRST';
|
||||
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const ILLEGAL_KO = {
|
||||
suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).',
|
||||
ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.',
|
||||
superko: '같은 모양이 반복되어 둘 수 없어요.',
|
||||
} as const;
|
||||
|
||||
function stars(size: number): Pt[] {
|
||||
if (size === 9) return [{ x: 2, y: 2 }, { x: 6, y: 2 }, { x: 2, y: 6 }, { x: 6, y: 6 }, { x: 4, y: 4 }];
|
||||
if (size === 13) return [{ x: 3, y: 3 }, { x: 9, y: 3 }, { x: 3, y: 9 }, { x: 9, y: 9 }, { x: 6, y: 6 }];
|
||||
const s = [3, 9, 15];
|
||||
return s.flatMap((x) => s.map((y) => ({ x, y })));
|
||||
}
|
||||
|
||||
/** Points like "흑 2집 반" (territory) or "1.5점" (area). */
|
||||
function fmtScore(n: number, method: 'territory' | 'area'): string {
|
||||
if (method === 'area') return String(n);
|
||||
const whole = Math.floor(n);
|
||||
const half = n - whole >= 0.5;
|
||||
return half ? (whole === 0 ? '반' : `${whole}.5`) : String(whole);
|
||||
}
|
||||
|
||||
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.05} />
|
||||
{color === 'black' ? (
|
||||
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
|
||||
) : (
|
||||
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkMode = 'dead' | 'seki';
|
||||
|
||||
function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const marking = v.phase === 'scoring' && v.myColor !== null;
|
||||
const [mode, setMode] = useState<MarkMode>('dead');
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) >> 1, y: (n - 1) >> 1 });
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [bubble, setBubble] = useState<{ p: Pt; text: string } | null>(null);
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevDead = useRef<Set<number> | null>(null);
|
||||
|
||||
const illegal = useMemo(() => new Map(v.illegalPoints.map((p) => [p.y * n + p.x, p.reason])), [v.illegalPoints, n]);
|
||||
// Dead stones: the live marks while scoring, or (after the game) stones owned by the other colour.
|
||||
const dead = useMemo(() => {
|
||||
if (v.scoring) return new Set(v.scoring.dead.map((p) => p.y * n + p.x));
|
||||
const own = v.result?.score?.ownership;
|
||||
const out = new Set<number>();
|
||||
if (own) v.board.forEach((c, i) => c !== 0 && own[i] !== c && out.add(i));
|
||||
return out;
|
||||
}, [v.scoring, v.result, v.board, n]);
|
||||
const seki = useMemo(() => new Set((v.scoring?.seki ?? []).map((p) => p.y * n + p.x)), [v.scoring?.seki, n]);
|
||||
|
||||
// Move sounds and announcements.
|
||||
useEffect(() => {
|
||||
if (v.moveCount > prevCount.current) {
|
||||
const m = v.moves[v.moves.length - 1]!;
|
||||
if (m.pt) {
|
||||
sounds.stone();
|
||||
if (m.captured > 0) haptic(30);
|
||||
setAnnounce(`${COLOR_KO[m.color]}이 ${label(n, m.pt)}에 두었어요.${m.captured ? ` 돌 ${m.captured}개를 따냈어요.` : ''}`);
|
||||
} else setAnnounce(`${COLOR_KO[m.color]}이 패스했어요.`);
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}
|
||||
prevCount.current = v.moveCount;
|
||||
setPreview(null);
|
||||
setBubble(null);
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Highlight groups whose dead/alive mark just changed (e.g. by the opponent).
|
||||
useEffect(() => {
|
||||
if (!v.scoring) {
|
||||
prevDead.current = null;
|
||||
return;
|
||||
}
|
||||
const before = prevDead.current;
|
||||
prevDead.current = dead;
|
||||
if (!before) return;
|
||||
const diff = new Set<number>();
|
||||
for (const i of dead) if (!before.has(i)) diff.add(i);
|
||||
for (const i of before) if (!dead.has(i)) diff.add(i);
|
||||
if (diff.size === 0) return;
|
||||
setChanged(diff);
|
||||
const t = setTimeout(() => setChanged(new Set()), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [dead]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
|
||||
const at = (p: Pt) => v.board[p.y * n + p.x];
|
||||
const canPlace = (p: Pt) => myTurn && !pending && at(p) === 0 && !illegal.has(p.y * n + p.x);
|
||||
const canMark = (p: Pt) => marking && !pending && (mode === 'dead' ? at(p) !== 0 : at(p) === 0);
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
const mark = (p: Pt) => act(mode === 'dead' ? { type: 'toggleDead', x: p.x, y: p.y } : { type: 'toggleSeki', x: p.x, y: p.y });
|
||||
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (marking) {
|
||||
if (canMark(p)) mark(p);
|
||||
else if (mode === 'dead' && at(p) === 0) setBubble({ p, text: '돌을 눌러 사석/생석을 바꿔요. 빈 곳은 [빅 표시]에서 눌러요.' });
|
||||
return;
|
||||
}
|
||||
if (!myTurn || at(p) !== 0) return;
|
||||
const why = illegal.get(p.y * n + p.x);
|
||||
if (why) {
|
||||
haptic([30, 30, 30]);
|
||||
setBubble({ p, text: ILLEGAL_KO[why] });
|
||||
setAnnounce(ILLEGAL_KO[why]);
|
||||
return;
|
||||
}
|
||||
if (mouse || !tapConfirm) {
|
||||
if (canPlace(p)) place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | 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);
|
||||
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
|
||||
};
|
||||
|
||||
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(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tap(cursor, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Ownership marks: live preview while marking, final result after the game.
|
||||
const score: ScoreBreakdown | undefined = v.scoring?.preview ?? v.result?.score;
|
||||
const ghost = preview ?? (hover && canPlace(hover) ? hover : null);
|
||||
const hoverMark = marking && hover && canMark(hover) ? hover : null;
|
||||
const lastColor: Color | null = v.lastMove ? (at(v.lastMove) === 1 ? 'black' : 'white') : null;
|
||||
const pad = 0.95;
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
background: 'var(--wood)',
|
||||
borderRadius: 12,
|
||||
touchAction: 'manipulation',
|
||||
cursor: myTurn || marking ? 'pointer' : 'default',
|
||||
boxShadow: 'var(--shadow)',
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !(myTurn || marking)) return;
|
||||
const p = pointToCell(e);
|
||||
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`바둑판 ${n}줄. 방향키로 움직이고 Enter로 ${marking ? '표시를 바꿉니다' : '둡니다'}. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.36} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
<text x={i} y={-0.62}>{COLS[i]}</text>
|
||||
<text x={i} y={n - 1 + 0.62}>{COLS[i]}</text>
|
||||
<text x={-0.62} y={i}>{n - i}</text>
|
||||
<text x={n - 1 + 0.62} y={i}>{n - i}</text>
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{/* Ko / superko points for the mover (suicide points are explained on click only). */}
|
||||
{myTurn &&
|
||||
v.illegalPoints
|
||||
.filter((p) => p.reason !== 'suicide')
|
||||
.map((p) => <rect key={`k${p.x}-${p.y}`} x={p.x - 0.18} y={p.y - 0.18} width={0.36} height={0.36} fill="none" stroke="#b91c1c" strokeWidth={0.07} />)}
|
||||
{v.board.map((c, i) => {
|
||||
if (c === 0) return null;
|
||||
const color: Color = c === 1 ? 'black' : 'white';
|
||||
const x = i % n;
|
||||
const y = Math.floor(i / n);
|
||||
const isDead = dead.has(i);
|
||||
return (
|
||||
<g key={i} opacity={isDead ? 0.45 : 1}>
|
||||
<Stone color={color} cx={x} cy={y} r={0.47} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Stones just captured fade out. */}
|
||||
{v.phase === 'playing' &&
|
||||
v.lastCaptured.map((p) => (
|
||||
<g key={`cap${v.moveCount}-${p.x}-${p.y}`} opacity={0}>
|
||||
<animate attributeName="opacity" from={0.7} to={0} dur="0.6s" fill="freeze" />
|
||||
<Stone color={lastColor ? other(lastColor) : 'white'} cx={p.x} cy={p.y} r={0.47} />
|
||||
</g>
|
||||
))}
|
||||
{/* Territory / area marks (small squares) and seki marks. */}
|
||||
{score &&
|
||||
score.ownership.map((o, i) => {
|
||||
if (o === 0 || (v.board[i] !== 0 && !dead.has(i))) return null;
|
||||
return <rect key={`o${i}`} x={(i % n) - 0.17} y={Math.floor(i / n) - 0.17} width={0.34} height={0.34} fill={o === 1 ? '#111' : '#fafafa'} stroke={o === 1 ? '#000' : '#555'} strokeWidth={0.03} />;
|
||||
})}
|
||||
{[...seki].map((i) => (
|
||||
<path key={`seki${i}`} d={`M ${i % n} ${Math.floor(i / n) - 0.2} l 0.2 0.2 l -0.2 0.2 l -0.2 -0.2 z`} fill="#f59e0b" opacity={0.9} />
|
||||
))}
|
||||
{[...dead].map((i) => (
|
||||
<g key={`x${i}`} stroke={v.board[i] === 1 ? '#fafafa' : '#111'} strokeWidth={0.08}>
|
||||
<line x1={(i % n) - 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) + 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||
<line x1={(i % n) + 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) - 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{[...changed].map((i) => (
|
||||
<circle key={`ch${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.52} fill="none" stroke="#3b82f6" strokeWidth={0.09}>
|
||||
<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="3" />
|
||||
</circle>
|
||||
))}
|
||||
{v.lastMove && lastColor && (
|
||||
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
|
||||
)}
|
||||
{myTurn &&
|
||||
v.atariWarnings.map((p) => (
|
||||
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
|
||||
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
|
||||
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
|
||||
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
{bubble && <circle cx={bubble.p.x} cy={bubble.p.y} r={0.48} fill="none" stroke="#dc2626" strokeWidth={0.09} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{bubble && (
|
||||
<div className="card" role="status" style={{ padding: '8px 12px', border: '2px solid var(--danger)', fontWeight: 600 }}>
|
||||
{label(n, bubble.p)}: {bubble.text}
|
||||
</div>
|
||||
)}
|
||||
{marking && (
|
||||
<div className="card stack" style={{ padding: 12, gap: 8 }}>
|
||||
<div className="segmented" role="group" aria-label="표시 종류">
|
||||
<button type="button" aria-pressed={mode === 'dead'} onClick={() => setMode('dead')}>
|
||||
✕ 사석 표시
|
||||
</button>
|
||||
<button type="button" aria-pressed={mode === 'seki'} onClick={() => setMode('seki')}>
|
||||
◆ 빅 표시
|
||||
</button>
|
||||
</div>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
{mode === 'dead'
|
||||
? '죽은 돌 묶음을 누르면 사석(✕)으로 바뀌고, 다시 누르면 살아 있는 돌로 돌아와요.'
|
||||
: '빅(세키) 안의 빈 곳을 누르면 ◆로 표시돼요. 한국·일본식 계산에서는 빅 안의 집을 세지 않아요.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function ScoreTable({ score, heading }: { score: ScoreBreakdown; heading?: boolean }) {
|
||||
const t = score.method === 'territory';
|
||||
const rows: [string, string | number, string | number][] = t
|
||||
? [
|
||||
['집', score.black.territory, score.white.territory],
|
||||
['따낸 돌(사석 포함)', score.black.captures, score.white.captures],
|
||||
['덤', '-', score.white.komi],
|
||||
]
|
||||
: [
|
||||
['판 위 살아 있는 돌', score.black.stones, score.white.stones],
|
||||
['영역(빈 곳)', score.black.territory, score.white.territory],
|
||||
['덤', '-', score.white.komi],
|
||||
...(score.white.handicapComp ? [['치석 보정', '-', score.white.handicapComp] as [string, string, number]] : []),
|
||||
];
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<thead>
|
||||
<tr className="small muted">
|
||||
<th style={{ textAlign: 'left', fontWeight: 600 }}>{heading ? (t ? '집 계산' : '영역 계산') : ''}</th>
|
||||
<th style={{ textAlign: 'right' }}>● 흑</th>
|
||||
<th style={{ textAlign: 'right' }}>○ 백</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([k, b, w]) => (
|
||||
<tr key={k} className="small">
|
||||
<td>{k}</td>
|
||||
<td style={{ textAlign: 'right' }}>{b}</td>
|
||||
<td style={{ textAlign: 'right' }}>{w}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr style={{ fontWeight: 800, borderTop: '2px solid var(--border)' }}>
|
||||
<td>합계</td>
|
||||
<td style={{ textAlign: 'right' }}>{score.black.total}</td>
|
||||
<td style={{ textAlign: 'right' }}>{score.white.total}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoringPanel({ view: v, act, pending }: { view: BadukView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [confirmResume, setConfirmResume] = useState(false);
|
||||
const sc = v.scoring!;
|
||||
const p = sc.preview;
|
||||
const lead = p.diff === 0 ? '동점' : `${p.diff > 0 ? '흑' : '백'} ${fmtScore(Math.abs(p.diff), p.method)}${p.method === 'territory' ? '집' : '점'} 앞섬`;
|
||||
const mine = v.myColor;
|
||||
return (
|
||||
<div className="card stack" style={{ padding: 14, gap: 10 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong style={{ fontSize: '1.1rem' }}>사석 확인</strong>
|
||||
<TurnTimer deadline={sc.deadlineAt} totalMs={sc.durationMs} mine={!!mine && !sc.agreed[mine]} size={40} />
|
||||
</div>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
대국이 멈췄어요. 판 위의 <strong>죽은 돌</strong>을 눌러 ✕ 표시를 맞춘 뒤 두 사람 모두 <strong>동의</strong>하면 계가가 끝나요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.
|
||||
</p>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
흑 {p.black.total} : 백 {p.white.total}
|
||||
</div>
|
||||
<div className="muted small" style={{ textAlign: 'center' }}>{lead}</div>
|
||||
<ScoreTable score={p} heading />
|
||||
<div className="row small" style={{ justifyContent: 'space-around' }}>
|
||||
<span>● 흑 {sc.agreed.black ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||
<span>○ 백 {sc.agreed.white ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||
</div>
|
||||
{mine && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<button className="btn btn-primary btn-block" style={{ fontSize: '1.1rem' }} disabled={pending || sc.agreed[mine]} onClick={() => act({ type: 'agreeScore' })}>
|
||||
{sc.agreed[mine] ? '동의했어요 · 상대를 기다려요' : '✔ 이 표시대로 동의'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" disabled={pending} onClick={() => setConfirmResume(true)}>
|
||||
이의 – 대국 재개
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{confirmResume && (
|
||||
<ConfirmSheet
|
||||
title="대국을 다시 둘까요?"
|
||||
body="표시를 모두 지우고 대국으로 돌아가요. 다시 시작하면 상대가 먼저 둬요."
|
||||
confirm="대국 재개"
|
||||
onConfirm={() => act({ type: 'resumePlay' })}
|
||||
onClose={() => setConfirmResume(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BadukSide({ view: v, me, players, act, pending, deadline }: BoardProps<BadukView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const opp: Color = v.myColor === 'white' ? 'black' : 'white';
|
||||
const mine: Color = v.myColor ?? 'black';
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const last = v.moves[v.moves.length - 1];
|
||||
const oppPassed = myTurn && last && !last.pt && last.color !== v.myColor;
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||
{oppPassed && (
|
||||
<div className="card" style={{ padding: 10, border: '2px solid var(--warning)' }}>
|
||||
<strong>상대가 패스했어요.</strong> 끝내려면 나도 패스하세요. 더 둘 곳이 있으면 계속 둬도 돼요.
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'scoring' && <ScoringPanel view={v} act={act} pending={pending} />}
|
||||
{v.phase === 'finished' && v.result?.score && (
|
||||
<div className="card stack" style={{ padding: 14, gap: 8 }}>
|
||||
<strong style={{ fontSize: '1.15rem' }}>{v.result.summaryKo}</strong>
|
||||
<ScoreTable score={v.result.score} heading />
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-block" disabled={!myTurn || pending} onClick={() => setConfirm('pass')}>
|
||||
✋ 패스
|
||||
</button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'scoring' && v.myColor && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{outgoing && (
|
||||
<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 === 'pass' && (
|
||||
<ConfirmSheet
|
||||
title="패스할까요?"
|
||||
body={v.consecutivePasses > 0 ? '상대도 패스했으니, 패스하면 대국이 멈추고 사석 확인으로 넘어가요.' : '이번 차례에 돌을 두지 않고 넘겨요.'}
|
||||
confirm="패스하기"
|
||||
onConfirm={() => act({ type: 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대의 불계승이에요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: BadukView }) {
|
||||
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: 150, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {m.pt ? label(v.size, m.pt) : '패스'}
|
||||
{m.captured ? <span className="muted"> ×{m.captured}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const mmss = (ms: number) => {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(250);
|
||||
const c = v.clock;
|
||||
const elapsed = active ? Math.max(0, now - c.turnStartedAt) : 0;
|
||||
const mine = id === me;
|
||||
let clock: React.ReactNode = null;
|
||||
let periodSecLeft: number | null = null;
|
||||
if (c.kind === 'byoyomi') {
|
||||
const main = c.mainMs[color];
|
||||
if (elapsed < main) {
|
||||
clock = <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem' }}>{mmss(main - elapsed)}</span>;
|
||||
} else {
|
||||
const periodMs = c.periodSec * 1000;
|
||||
const over = elapsed - main;
|
||||
const used = Math.floor(over / periodMs);
|
||||
const left = Math.max(0, c.periodsLeft[color] - used);
|
||||
periodSecLeft = active ? Math.ceil((periodMs - (over % periodMs)) / 1000) : c.periodSec;
|
||||
const urgent = active && periodSecLeft <= 10;
|
||||
clock = (
|
||||
<span className="stack" style={{ alignItems: 'flex-end', gap: 2 }}>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: urgent ? '1.6rem' : '1.15rem', color: urgent ? 'var(--danger)' : undefined }}>
|
||||
{periodSecLeft}초
|
||||
</span>
|
||||
<span aria-label={`초읽기 ${left}번 남음`} style={{ letterSpacing: 2 }}>
|
||||
{Array.from({ length: c.periods }, (_, i) => (
|
||||
<span key={i} style={{ color: i < left ? 'var(--primary)' : 'var(--border)' }}>●</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else if (c.kind === 'fischer') {
|
||||
const ms = c.mainMs[color] - elapsed;
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
|
||||
);
|
||||
} else if (active) {
|
||||
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
|
||||
}
|
||||
|
||||
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period).
|
||||
const beeped = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!active || !mine || periodSecLeft === null || periodSecLeft > 5 || beeped.current === periodSecLeft) return;
|
||||
beeped.current = periodSecLeft;
|
||||
sounds.warn();
|
||||
}, [periodSecLeft, active, mine]);
|
||||
|
||||
const urgentBorder = active && periodSecLeft !== null && periodSecLeft <= 10;
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${urgentBorder ? 'var(--danger)' : active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
|
||||
<Stone color={color} cx={0} cy={0} r={0.9} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{mine && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{COLOR_KO[color]} · 따낸 돌 {v.captures[color]}
|
||||
{color === 'white' && v.rules.komi ? ` · 덤 ${v.rules.komi}` : ''}
|
||||
{active ? ' · 두는 중' : ''}
|
||||
{v.phase === 'scoring' && v.scoring?.agreed[color] ? ' · 동의함' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: BadukView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요';
|
||||
if (v.phase === 'scoring') {
|
||||
if (v.myColor && !v.scoring!.agreed[v.myColor]) return '사석을 확인하고 [동의]를 눌러 주세요';
|
||||
if (v.myColor) return '상대가 사석을 확인하고 있어요…';
|
||||
return '사석 확인 중이에요';
|
||||
}
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
|
||||
if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`;
|
||||
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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>
|
||||
);
|
||||
}
|
||||
|
||||
type TC = BadukOptions['timeControl'];
|
||||
const TIME_PRESETS: [string, string, Partial<TC>][] = [
|
||||
['auto', '판 크기에 맞춤 + 초읽기 30초×3', { kind: 'byoyomi', mainSecAuto: true, periodSec: 30, periods: 3 }],
|
||||
['b5', '5분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 300, periodSec: 30, periods: 3 }],
|
||||
['b10', '10분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 600, periodSec: 30, periods: 3 }],
|
||||
['b30', '30분 + 60초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 1800, periodSec: 60, periods: 3 }],
|
||||
['f', '피셔 10분 + 10초', { kind: 'fischer', mainSecAuto: false, mainSec: 600, incSec: 10 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: TC): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'fischer') return tc.mainSec === 600 && tc.incSec === 10 && !tc.mainSecAuto ? 'f' : 'custom';
|
||||
if (tc.mainSecAuto && tc.periodSec === 30 && tc.periods === 3) return 'auto';
|
||||
const hit = TIME_PRESETS.find(([k, , p]) => k.startsWith('b') && !tc.mainSecAuto && p.mainSec === tc.mainSec && p.periodSec === tc.periodSec && p.periods === tc.periods);
|
||||
return hit ? hit[0] : 'custom';
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN: Record<number, number> = { 9: 180, 13: 600, 19: 1200 };
|
||||
function timeLabel(o: BadukOptions): string {
|
||||
const tc = o.timeControl;
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
const main = tc.mainSecAuto ? DEFAULT_MAIN[o.boardSize]! : tc.mainSec;
|
||||
const m = Math.floor(main / 60);
|
||||
const mainLabel = main === 0 ? '' : `${m ? `${m}분` : ''}${main % 60 ? ` ${main % 60}초` : ''}`.trim();
|
||||
if (tc.kind === 'fischer') return `${mainLabel} + 수당 ${tc.incSec}초`;
|
||||
return `${mainLabel ? `${mainLabel} + ` : ''}${tc.periodSec}초×${tc.periods}`;
|
||||
}
|
||||
|
||||
function komiOf(o: BadukOptions): number {
|
||||
if (!o.komiAuto) return o.komi;
|
||||
if (o.handicap > 0) return 0.5;
|
||||
return o.scoring === 'area' ? 7.5 : 6.5;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<BadukOptions>) => onChange({ ...o, ...patch });
|
||||
const setTc = (patch: Partial<TC>) => set({ timeControl: { ...o.timeControl, ...patch } });
|
||||
const maxHandicap = o.boardSize === 9 ? 5 : 9;
|
||||
const handicaps = ([0, 2, 3, 4, 5, 6, 7, 8, 9] as const).filter((h) => h <= maxHandicap);
|
||||
const tk = timeKey(o.timeControl);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>판 크기</strong>
|
||||
<Seg
|
||||
value={o.boardSize}
|
||||
options={[[9, '9줄 (처음이면 추천)'], [13, '13줄'], [19, '19줄']]}
|
||||
onChange={(boardSize) => set({ boardSize, handicap: boardSize === 9 && o.handicap > 5 ? 5 : o.handicap })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>계가 방식</strong>
|
||||
<Seg value={o.scoring} options={[['territory', '집 계산 (한국·일본식)'], ['area', '영역 계산 (중국식)']]} onChange={(scoring) => set({ scoring })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>접바둑</strong>
|
||||
<Seg value={o.handicap} options={handicaps.map((h) => [h, h === 0 ? '맞바둑' : `${h}점`] as [BadukOptions['handicap'], string])} onChange={(handicap) => set({ handicap })} />
|
||||
{o.handicap > 0 && <span className="muted small">흑(하수)이 치석 {o.handicap}개를 미리 놓고, 백이 먼저 둬요. 덤은 0.5집이에요.</span>}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={tk}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(tk === 'custom' ? [['custom', `${timeLabel(o)}(관리자 설정)`] as [string, string]] : []),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) setTc(preset[2]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑백 정하기</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['nigiri', '무작위'], ['hostBlack', o.handicap ? '방장이 흑(하수)' : '방장이 흑'], ['hostWhite', o.handicap ? '방장이 백(상수)' : '방장이 백']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>덤 (지금 {komiOf(o)})</strong>
|
||||
<Seg value={o.komiAuto ? 'auto' : 'custom'} options={[['auto', '자동'], ['custom', '직접 정하기']]} onChange={(k) => set({ komiAuto: k === 'auto' })} />
|
||||
{!o.komiAuto && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 160 }}
|
||||
type="number"
|
||||
min={-10}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={o.komi}
|
||||
onChange={(e) => {
|
||||
const n = Math.round(Number(e.target.value) * 2) / 2;
|
||||
if (Number.isFinite(n)) set({ komi: Math.max(-10, Math.min(10, n)) });
|
||||
}}
|
||||
aria-label="덤"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>패 규칙</strong>
|
||||
<Seg value={o.koRule} options={[['positionalSuperko', '같은 모양 반복 금지'], ['korean', '한국식(삼패 등은 무승부)']]} onChange={(koRule) => set({ koRule })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'byoyomi' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>초읽기</strong>
|
||||
<Seg value={o.timeControl.periodSec} options={[[10, '10초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
|
||||
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>도움 표시</strong>
|
||||
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>사석 확인 제한 시간</strong>
|
||||
<Seg value={o.scoringDeadlineSec} options={[[120, '2분'], [180, '3분'], [300, '5분']]} onChange={(scoringDeadlineSec) => set({ scoringDeadlineSec })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: BadukOptions): string {
|
||||
return [
|
||||
o.scoring === 'territory' ? '집 계산' : '영역 계산',
|
||||
`${o.boardSize}줄`,
|
||||
o.handicap ? `${o.handicap}점 접바둑` : `덤 ${komiOf(o)}`,
|
||||
timeLabel(o),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: BadukOptions }) {
|
||||
const scoring = options?.scoring ?? 'territory';
|
||||
const koRule = options?.koRule ?? 'positionalSuperko';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>흑과 백이 번갈아 판의 <strong>교차점</strong>에 돌을 하나씩 놓아요. 흑이 먼저 둬요(접바둑이면 흑이 치석을 미리 놓고 백이 먼저 둬요). 한 번 놓은 돌은 움직이지 않아요.</p>
|
||||
<p>
|
||||
<strong>따내기</strong>: 돌에 상하좌우로 맞닿은 빈 점을 <strong>활로</strong>라고 해요. 상하좌우로 붙은 같은 색 돌은 한 덩어리로 활로를 함께 써요. 상대 돌 덩어리의 활로를 모두 막으면 그 돌을 판에서 들어내고, 따낸 돌로 세어요. 활로가 하나만 남은 상태를 <strong>단수</strong>라고 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>자충수 금지</strong>: 놓는 순간 내 돌의 활로가 없어지는 자리에는 둘 수 없어요. 단, 그 수로 상대 돌을 따내서 활로가 생기면 둘 수 있어요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>패</strong>: 돌 하나를 서로 번갈아 따낼 수 있는 모양에서는, 따인 쪽이 <strong>바로</strong> 되따낼 수 없어요. 다른 곳에 한 수 둔 뒤에 되따낼 수 있어요.
|
||||
{koRule === 'positionalSuperko'
|
||||
? ' 이 방은 판 전체 모양이 이전과 똑같아지는 수를 모두 금지해요(동형 반복 금지).'
|
||||
: ' 이 방은 한국식이라 같은 국면이 세 번 나오면(삼패 등) 무승부로 끝나요.'}
|
||||
{' '}둘 수 없는 패 자리는 빨간 네모로 보여 줘요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끝내기</strong>: 더 둘 곳이 없으면 <strong>패스</strong>해요. 두 사람이 연달아 패스하면 대국이 멈추고 <strong>사석 확인</strong>을 해요. 판에서 죽은 돌을 눌러 ✕ 표시를 맞추고, 두 사람 모두 [동의]하면 점수가 나와요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 결론을 내요.
|
||||
</p>
|
||||
{scoring === 'territory' ? (
|
||||
<p>
|
||||
<strong>집 계산(한국·일본식)</strong>: 내 돌로만 둘러싼 빈 점(집) + 따낸 돌(사석 포함)이 내 점수예요. 백은 먼저 둔 흑의 이득을 메우려고 <strong>덤</strong>을 더 받아요. 빅(서로 잡을 수 없이 공배를 나눠 사는 모양) 안의 집은 세지 않아요.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>영역 계산(중국식)</strong>: 판 위의 살아 있는 내 돌 + 내 돌로만 둘러싼 빈 점이 내 점수예요. 따낸 돌은 세지 않으니 끝내기 전에 공배(양쪽 사이의 빈 점)를 메운 뒤 패스하세요. 백은 덤을 더 받고, 접바둑이면 치석 수만큼 더 받아요.
|
||||
</p>
|
||||
)}
|
||||
<p>점수가 높은 쪽이 이겨요. 덤이 0.5 단위면 비기는 일이 없어요. 언제든 기권(불계패)할 수 있어요. 처음이라면 <strong>9줄</strong> 판으로 시작해 보세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<BadukView, BadukOptions> = {
|
||||
Board: BadukBoard,
|
||||
Side: BadukSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => {
|
||||
if (!v.myColor || v.players[v.myColor] !== me) return false;
|
||||
if (v.phase === 'playing') return v.turn === v.myColor;
|
||||
if (v.phase === 'scoring') return !v.scoring!.agreed[v.myColor];
|
||||
return false;
|
||||
},
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -6,6 +6,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
othello: () => import('./othello'),
|
||||
chess: () => import('./chess'),
|
||||
janggi: () => import('./janggi'),
|
||||
baduk: () => import('./baduk'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
Reference in New Issue
Block a user