/** 오목 화면 (docs/games/omok.md §9). */ import { useEffect, useMemo, useRef, useState } from 'react'; import type { OmokOptions, OmokView, Pt } from '@bg/games/omok'; 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 = { black: '흑', white: '백' }; const COLS = 'ABCDEFGHIJKLMNOPQRS'; const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`; const RULE_KO: Record = { korean: '한국식', renju: '렌주룰', free: '자유룰' }; function stars(size: number): Pt[] { const s = size === 19 ? [3, 9, 15] : [3, 7, 11]; return s.flatMap((x) => s.map((y) => ({ x, y }))); } function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) { return ( {color === 'black' ? ( ) : ( )} ); } function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps) { const n = v.size; const myTurn = v.phase === 'playing' && v.myColor === v.turn; const [preview, setPreview] = useState(null); const [cursor, setCursor] = useState({ x: (n - 1) / 2, y: (n - 1) / 2 }); const [confirm, setConfirm] = useState(null); const [announce, setAnnounce] = useState(''); const [kbd, setKbd] = useState(false); const svgRef = useRef(null); const prevCount = useRef(v.moveCount); const prevTurn = useRef(v.turn); const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]); // Sounds, vibration and screen-reader announcements on new moves. useEffect(() => { if (v.moveCount > prevCount.current && v.lastMove) { sounds.stone(); const who = v.moves[v.moves.length - 1]!.color; setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`); } if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) { haptic(40); if (document.visibilityState === 'hidden') sounds.myTurn(); } prevCount.current = v.moveCount; prevTurn.current = v.turn; setPreview(null); }, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps const canPlace = (p: Pt) => myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2)); const place = (p: Pt) => { act({ type: 'place', x: p.x, y: p.y }); setPreview(null); }; const tap = (p: Pt) => { setCursor(p); if (!myTurn) return; if (v.board[p.y * n + p.x] !== 0) return; if (forbidden.has(p.y * n + p.x)) { haptic([30, 30, 30]); setAnnounce('여기는 금수 자리라 둘 수 없어요.'); } if (!tapConfirm) { place(p); return; } if (preview && preview.x === p.x && preview.y === p.y) place(p); else setPreview(p); }; const pointToCell = (e: React.PointerEvent): 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 = { 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(); if (canPlace(cursor)) place(cursor); } }; const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x)); 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 | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null; const pad = 0.6; return (
{ setKbd(false); const p = pointToCell(e); if (p) tap(p); }} tabIndex={0} role="grid" aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`} onKeyDown={onKey} > {Array.from({ length: n }, (_, i) => ( ))} {stars(n).map((p) => ( ))} {myTurn && v.hints?.urgent.map((p) => )} {myTurn && v.hints?.warn.map((p) => )} {v.forbidden.map((p) => ( ))} {v.board.map((c, i) => c === 0 ? null : , )} {v.lastMove && } {[...winSet].map((i) => ( ))} {preview && v.myColor && } {myTurn && kbd && }

{announce}

{preview && myTurn && (
)} {v.phase === 'playing' && v.myColor && (
{v.undo.allowed && ( )} {v.draw.allowed && ( )}
)} {outgoing && (
상대의 답을 기다리는 중이에요…
)} {incoming && ( {}} label="상대의 요청">

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

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

)} {confirm === 'resign' && ( act({ type: 'resign' })} onClose={() => setConfirm(null)} /> )} {confirm === 'draw' && ( act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} /> )}
); } function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; color: Color; players: Record; 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(500); const tc = v.timeControl; let clock: React.ReactNode = null; if (tc.kind === 'fischer') { const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0)); const s = Math.ceil(ms / 1000); clock = ( {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} ); } else if (active) { const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000; clock = ; } return (
{u.nickname} {id === me && (나)}
{COLOR_KO[color]}{active ? ' · 두는 중' : ''}
{clock}
); } function statusText(v: OmokView, me: string | null, players: Record): string { if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; 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.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요'; 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({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { return (
{options.map(([k, l]) => ( ))}
); } const TIME_PRESETS: [string, string, Partial][] = [ ['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }], ['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }], ['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }], ['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }], ['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }], ['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }], ['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }], ['none', '시간 제한 없음', { kind: 'none' }], ]; function timeKey(tc: OmokOptions['timeControl']): string { if (tc.kind === 'none') return 'none'; if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`; return `f${Math.round(tc.baseSec / 60)}`; } function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
규칙 set({ ruleSet })} />
시간 [k, l] as [string, string]), ...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]), ]} onChange={(k) => { const preset = TIME_PRESETS.find((p) => p[0] === k); if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } }); }} />
흑(먼저 두는 쪽) set({ colorAssignment })} />
{more && ( <>
판 크기 set({ boardSize })} />
{o.ruleSet === 'free' && (
자유룰 승리 set({ freeWin })} />
)}
무르기 set({ undo })} />
{o.timeControl.kind === 'perMove' && (
시간이 다 되면 set({ timeoutPolicy })} />
)}
첫 수 가운데 set({ firstMoveCenter })} />
도움 표시 set({ showForbidden: k === 'on' })} /> set({ hints })} /> set({ drawOffer: k === 'on' })} />
)}
); } function timeLabel(tc: OmokOptions['timeControl']): string { if (tc.kind === 'none') return '시간 제한 없음'; if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`; const m = Math.floor(tc.baseSec / 60); const sec = tc.baseSec % 60; return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`; } function optionsSummary(o: OmokOptions): string { const t = timeLabel(o.timeControl); return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · '); } function Rules({ options }: { options?: OmokOptions }) { const rule = options?.ruleSet ?? 'korean'; return (

흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 5개를 먼저 이으면 이겨요. 흑이 먼저 둬요.

{rule === 'korean' && (

한국식: 흑과 백 모두 쌍삼(열린 3을 두 개 동시에 만드는 수)을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요.

)} {rule === 'renju' && (

렌주룰: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요.

)} {rule === 'free' && (

자유룰: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.

)}

둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).

휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.

); } const ui: GameUI = { Board: OmokBoard, statusText, isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, Options, optionsSummary, Rules, }; export default ui;