Files
drinkmarble/app/game/diceplane.tsx

345 lines
10 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } 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 {
buildPlaneCells,
cellColorFor,
diceAreaSpan,
getOrCreateBoardConfig,
} 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,
}: {
requireDiceQueue?: boolean;
diceQueue?: number;
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
chatFeed?: ChatFeedItem[];
showChatFeed?: boolean;
}) {
const initial = getOrCreateBoardConfig();
const [planeSize, setPlaneSize] = useState({
width: initial.width,
height: initial.height,
});
const [dicePlaneArray, setDicePlaneArray] = useState<PlaneCell[]>(() =>
buildPlaneCells(initial),
);
const chessmanRef = useRef(new Chessman(initial.cells.length));
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(true);
useEffect(() => {
if (showChatFeed) setChatOpen(true);
}, [showChatFeed]);
const [landedLabel, setLandedLabel] = useState<string | null>(null);
const [landedKey, setLandedKey] = useState(0);
const [statusText, setStatusText] = useState('시계방향');
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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);
setDicePlaneArray(cells);
}, []);
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?.teleportTo != null) {
setChessmanIndex(result.teleportTo);
setHopKey((k) => k + 1);
showBanner(result.message);
return;
}
showBanner(result?.message ?? cellLabel(boardRef.current[landIndex], landIndex));
},
[showBanner],
);
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);
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);
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;
return (
<div
key={plane.id}
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)'
: isStart
? 'inset 0 0 0 2px #a7f3d0'
: undefined,
transform: isLanded ? 'scale(1.04)' : undefined,
transition: 'box-shadow 180ms ease, transform 180ms ease',
zIndex: isLanded ? 4 : undefined,
textShadow: plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' : undefined,
}}
>
{isStart ? '시작' : plane.type}
</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}
requireDiceQueue={requireDiceQueue}
diceQueue={diceQueue}
onConsumeDice={onConsumeDice}
/>
</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>
<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>
);
}