비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화, 개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
'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<PlaneCell[]>([]);
|
|
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<string | null>(null);
|
|
const [landedKey, setLandedKey] = useState(0);
|
|
const [statusText, setStatusText] = useState('시계방향');
|
|
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
|
|
const [pickingDestination, setPickingDestination] = useState(false);
|
|
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'rgba(255,255,255,0.7)',
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
판 불러오는 중…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${planeSize.width}, minmax(0, 1fr))`,
|
|
gridTemplateRows: `repeat(${planeSize.height}, minmax(0, 1fr))`,
|
|
gap: 'clamp(6px, 1vw, 12px)',
|
|
padding: 'clamp(8px, 1.2vw, 16px)',
|
|
width: '100%',
|
|
height: '100%',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
{dicePlaneArray.map((plane, index) => {
|
|
const isStart = index === 0;
|
|
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
|
|
const showText = plane.contentVisible !== false;
|
|
return (
|
|
<div
|
|
key={plane.id}
|
|
role={pickingDestination ? 'button' : undefined}
|
|
tabIndex={pickingDestination ? 0 : undefined}
|
|
onClick={() => 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}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{chessmanCell && (
|
|
<ChessmanPiece
|
|
x={chessmanCell.x}
|
|
y={chessmanCell.y}
|
|
hopKey={hopKey}
|
|
src={chessmanSrc}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
style={{
|
|
gridColumn: `2 / ${2 + diceSpan}`,
|
|
gridRow: `2 / ${2 + diceSpan}`,
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
zIndex: 6,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Dice
|
|
onRolled={onDiceRolled}
|
|
disabled={moving || pickingDestination}
|
|
requireDiceQueue={requireDiceQueue}
|
|
diceQueue={diceQueue}
|
|
onConsumeDice={onConsumeDice}
|
|
/>
|
|
</div>
|
|
|
|
{settingsButton && (
|
|
<div
|
|
style={{
|
|
// 둘레 칸 밖: 안쪽 영역 왼쪽 아래
|
|
gridColumn: 2,
|
|
gridRow: Math.max(3, planeSize.height - 1),
|
|
zIndex: 8,
|
|
display: 'flex',
|
|
alignItems: 'flex-end',
|
|
justifyContent: 'flex-start',
|
|
padding: 4,
|
|
boxSizing: 'border-box',
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<div style={{ pointerEvents: 'auto' }}>{settingsButton}</div>
|
|
</div>
|
|
)}
|
|
|
|
{showChatFeed && (
|
|
<div
|
|
style={{
|
|
// 주사위 영역(왼쪽)과 겹치지 않는 안쪽 오른쪽 위 칸만 사용
|
|
gridColumn: `${Math.max(2 + diceSpan, planeSize.width - 2)} / ${planeSize.width}`,
|
|
gridRow: `2 / ${Math.min(4, planeSize.height)}`,
|
|
zIndex: 7,
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'stretch',
|
|
gap: 4,
|
|
padding: 4,
|
|
boxSizing: 'border-box',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<label
|
|
style={{
|
|
alignSelf: 'flex-end',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
margin: 0,
|
|
padding: '3px 7px',
|
|
borderRadius: 7,
|
|
background: 'rgba(0,0,0,0.55)',
|
|
color: '#fff',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
userSelect: 'none',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={chatOpen}
|
|
onChange={(e) => setChatOpen(e.target.checked)}
|
|
/>
|
|
채팅
|
|
</label>
|
|
|
|
{chatOpen && (
|
|
<div style={{ minHeight: 0, flex: 1, overflow: 'hidden' }}>
|
|
<ChatFeedPanel items={chatFeed} visible compact />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{SHOW_DEBUG_UI && (
|
|
<div className="pointer-events-none absolute bottom-3 left-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
|
{statusText}
|
|
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
|
|
</div>
|
|
)}
|
|
|
|
{landedLabel && (
|
|
<div
|
|
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
|
|
aria-live="polite"
|
|
>
|
|
<div
|
|
key={landedKey}
|
|
style={{
|
|
padding: 'clamp(12px, 2vw, 28px) clamp(20px, 4vw, 48px)',
|
|
borderRadius: 16,
|
|
background: 'rgba(15, 23, 42, 0.78)',
|
|
color: '#fff7ed',
|
|
fontSize: 'clamp(1.6rem, 6vw, 4rem)',
|
|
fontWeight: 800,
|
|
letterSpacing: '0.04em',
|
|
textShadow: '0 4px 24px rgba(0,0,0,0.45)',
|
|
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
|
|
animation: 'drinkmarble-landed-pop 420ms ease-out',
|
|
maxWidth: '80%',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{landedLabel}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|