'use client'; import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import Dice from './dice'; import ChatFeedPanel, { type ChatFeedItem } from './chat-feed'; import { Chessman } from './chessman'; import ChessmanPiece from './chessman-piece'; import { applyLandEvent, type PlaneCell } from './cell-event'; import { getAssets } from './asset-storage'; import { SHOW_DEBUG_UI } from '@/lib/ui/debug'; import { buildPlaneCells, cellColorFor, DEFAULT_PLANE_SIZE, diceAreaSpan, getOrCreateBoardConfig, perimeterLength, } from './board-config'; const STEP_MS = 280; function cellLabel(cell: PlaneCell | undefined, index: number) { if (!cell) return ''; return index === 0 ? '시작' : cell.type; } export default function DicePlane({ requireDiceQueue = false, diceQueue = 0, onConsumeDice, chatFeed = [], showChatFeed = false, settingsButton = null, }: { requireDiceQueue?: boolean; diceQueue?: number; onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>; chatFeed?: ChatFeedItem[]; showChatFeed?: boolean; /** 판 안쪽 왼쪽 아래(둘레 칸 밖)에 배치할 설정 버튼 */ settingsButton?: ReactNode; }) { const [boardReady, setBoardReady] = useState(false); const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({ width: DEFAULT_PLANE_SIZE.width, height: DEFAULT_PLANE_SIZE.height, }); const [dicePlaneArray, setDicePlaneArray] = useState([]); const chessmanRef = useRef(new Chessman(perimeterLength(DEFAULT_PLANE_SIZE.width, DEFAULT_PLANE_SIZE.height))); const boardRef = useRef(dicePlaneArray); const [chessmanIndex, setChessmanIndex] = useState(0); const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png'); const [hopKey, setHopKey] = useState(0); const [moving, setMoving] = useState(false); const [chatOpen, setChatOpen] = useState(false); const [landedLabel, setLandedLabel] = useState(null); const [landedKey, setLandedKey] = useState(0); const [statusText, setStatusText] = useState('시계방향'); const [islandTurnsLeft, setIslandTurnsLeft] = useState(0); const [pickingDestination, setPickingDestination] = useState(false); const moveTimerRef = useRef | null>(null); const pendingRollRef = useRef(0); // SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드 useEffect(() => { const board = getOrCreateBoardConfig(); const cells = buildPlaneCells(board); chessmanRef.current = new Chessman(cells.length, 0); boardRef.current = cells; setPlaneSize({ width: board.width, height: board.height }); setChessmanIndex(0); setChessmanSrc(getAssets().chessman); setHopKey(0); setLandedLabel(null); setStatusText('시계방향'); setIslandTurnsLeft(0); setPickingDestination(false); setDicePlaneArray(cells); setBoardReady(true); }, []); useEffect(() => { boardRef.current = dicePlaneArray; }, [dicePlaneArray]); useEffect(() => { return () => { if (moveTimerRef.current) clearTimeout(moveTimerRef.current); }; }, []); const showBanner = useCallback((message: string) => { setLandedLabel(message); setLandedKey((k) => k + 1); }, []); const applyLanding = useCallback( (landIndex: number, roll: number) => { const man = chessmanRef.current; const result = applyLandEvent({ chessman: man, board: boardRef.current, roll, landIndex, }); setStatusText(man.facing === 1 ? '시계방향' : '반시계방향'); setIslandTurnsLeft(man.islandTurnsLeft); if (result?.chooseDestination) { pendingRollRef.current = roll; setPickingDestination(true); showBanner(result.message); return; } setPickingDestination(false); if (result?.teleportTo != null) { setChessmanIndex(result.teleportTo); setHopKey((k) => k + 1); showBanner(result.message); return; } showBanner(result?.message ?? cellLabel(boardRef.current[landIndex], landIndex)); }, [showBanner], ); const onCellPick = useCallback( (index: number) => { if (!pickingDestination || moving) return; const man = chessmanRef.current; man.teleport(index); setChessmanIndex(index); setHopKey((k) => k + 1); setPickingDestination(false); applyLanding(index, pendingRollRef.current); }, [pickingDestination, moving, applyLanding], ); const animateMove = useCallback( (steps: number, roll: number) => { const amount = Math.max(0, Math.trunc(steps)); if (amount === 0) { setMoving(false); return; } setMoving(true); let remaining = amount; const stepOnce = () => { const next = chessmanRef.current.moveOne(); setChessmanIndex(next); setHopKey((k) => k + 1); remaining -= 1; if (remaining > 0) { moveTimerRef.current = setTimeout(stepOnce, STEP_MS); } else { moveTimerRef.current = null; setMoving(false); applyLanding(next, roll); } }; stepOnce(); }, [applyLanding], ); const onDiceRolled = useCallback( (rollResult: { sum: number; faces: number[] }) => { const roll = Math.max(0, Math.trunc(rollResult.sum)); if (roll === 0) return; setLandedLabel(null); setPickingDestination(false); const man = chessmanRef.current; if (man.isOnIsland) { const outcome = man.resolveIslandRoll(roll, rollResult.faces); setIslandTurnsLeft(man.islandTurnsLeft); if (outcome === 'wait') { showBanner( man.isOnIsland ? `무인도 대기 (남은 ${man.islandTurnsLeft}턴)` : '무인도 3턴 종료!', ); return; } showBanner('무인도 탈출!'); } animateMove(roll, roll); }, [animateMove, showBanner], ); const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0]; const diceSpan = diceAreaSpan(planeSize.width, planeSize.height); if (!boardReady || dicePlaneArray.length === 0) { return (
판 불러오는 중…
); } return (
{dicePlaneArray.map((plane, index) => { const isStart = index === 0; const isLanded = !moving && index === chessmanIndex && landedLabel != null; const showText = plane.contentVisible !== false; return (
onCellPick(index)} onKeyDown={(e) => { if (!pickingDestination) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onCellPick(index); } }} style={{ gridColumn: plane.x + 1, gridRow: plane.y + 1, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: cellColorFor(plane.eventId, index), backgroundImage: plane.imageSrc ? `url(${plane.imageSrc})` : undefined, backgroundSize: 'cover', backgroundPosition: 'center', color: 'white', borderRadius: 8, fontSize: 'clamp(11px, 1.6vw, 16px)', fontWeight: 600, textAlign: 'center', padding: 4, boxShadow: isLanded ? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)' : pickingDestination ? '0 0 0 2px rgba(125, 211, 252, 0.85)' : isStart ? 'inset 0 0 0 2px #a7f3d0' : undefined, transform: isLanded ? 'scale(1.04)' : undefined, transition: 'box-shadow 180ms ease, transform 180ms ease', zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined, cursor: pickingDestination ? 'pointer' : undefined, pointerEvents: pickingDestination ? 'auto' : undefined, textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined, }} > {showText ? (isStart ? '시작' : plane.type) : null}
); })} {chessmanCell && ( )}
{settingsButton && (
{settingsButton}
)} {showChatFeed && (
{chatOpen && (
)}
)}
{SHOW_DEBUG_UI && (
{statusText} {islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
)} {landedLabel && (
{landedLabel}
)}
); }