398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
|
import { useMemo, useRef, useState } from 'react';
|
|
|
|
import type {
|
|
DragPuzzlePieceRequest,
|
|
PuzzleBoardSnapshot,
|
|
PuzzleCellPosition,
|
|
PuzzleRunSnapshot,
|
|
SwapPuzzlePiecesRequest,
|
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
|
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
|
|
type PuzzleRuntimeShellProps = {
|
|
run: PuzzleRunSnapshot | null;
|
|
isBusy?: boolean;
|
|
error?: string | null;
|
|
onBack: () => void;
|
|
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
|
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
|
|
onAdvanceNextLevel: () => void;
|
|
};
|
|
|
|
type PuzzleBoardPieceViewModel = {
|
|
pieceId: string;
|
|
row: number;
|
|
col: number;
|
|
correctRow: number;
|
|
correctCol: number;
|
|
label: string;
|
|
};
|
|
|
|
function boardCellKey(position: PuzzleCellPosition) {
|
|
return `${position.row}:${position.col}`;
|
|
}
|
|
|
|
function buildBoardCells(board: PuzzleBoardSnapshot) {
|
|
return Array.from({ length: board.rows * board.cols }, (_, index) => ({
|
|
row: Math.floor(index / board.cols),
|
|
col: index % board.cols,
|
|
}));
|
|
}
|
|
|
|
function buildPieceLabel(pieceId: string) {
|
|
const fallback = pieceId.slice(-2).toUpperCase();
|
|
return fallback || '块';
|
|
}
|
|
|
|
/**
|
|
* 拼图运行时壳层。
|
|
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
|
*/
|
|
export function PuzzleRuntimeShell({
|
|
run,
|
|
isBusy = false,
|
|
error = null,
|
|
onBack,
|
|
onSwapPieces,
|
|
onDragPiece,
|
|
onAdvanceNextLevel,
|
|
}: PuzzleRuntimeShellProps) {
|
|
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
|
const [dragState, setDragState] = useState<{
|
|
pieceId: string;
|
|
pointerId: number;
|
|
dragging: boolean;
|
|
startX: number;
|
|
startY: number;
|
|
} | null>(null);
|
|
const boardRef = useRef<HTMLDivElement | null>(null);
|
|
const currentLevel = run?.currentLevel ?? null;
|
|
const board = currentLevel?.board ?? null;
|
|
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
|
currentLevel?.coverImageSrc ?? null,
|
|
);
|
|
|
|
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
|
|
if (!board) {
|
|
return [];
|
|
}
|
|
return board.pieces.map((piece) => ({
|
|
pieceId: piece.pieceId,
|
|
row: piece.currentRow,
|
|
col: piece.currentCol,
|
|
correctRow: piece.correctRow,
|
|
correctCol: piece.correctCol,
|
|
label: buildPieceLabel(piece.pieceId),
|
|
}));
|
|
}, [board]);
|
|
|
|
const mergedCellKeys = useMemo(() => {
|
|
if (!board) {
|
|
return new Set<string>();
|
|
}
|
|
return new Set(
|
|
board.mergedGroups.flatMap((group) =>
|
|
group.occupiedCells.map((cell) => boardCellKey(cell)),
|
|
),
|
|
);
|
|
}, [board]);
|
|
|
|
const pieceByCell = useMemo(() => {
|
|
const map = new Map<string, PuzzleBoardPieceViewModel>();
|
|
for (const piece of pieces) {
|
|
map.set(`${piece.row}:${piece.col}`, piece);
|
|
}
|
|
return map;
|
|
}, [pieces]);
|
|
|
|
if (!run || !currentLevel || !board) {
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
正在进入拼图关卡
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handlePieceClick = (pieceId: string) => {
|
|
if (isBusy) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedPieceId) {
|
|
setSelectedPieceId(pieceId);
|
|
return;
|
|
}
|
|
|
|
if (selectedPieceId === pieceId) {
|
|
setSelectedPieceId(null);
|
|
return;
|
|
}
|
|
|
|
onSwapPieces({
|
|
firstPieceId: selectedPieceId,
|
|
secondPieceId: pieceId,
|
|
});
|
|
setSelectedPieceId(null);
|
|
};
|
|
|
|
const resolveBoardCellFromPointer = (clientX: number, clientY: number) => {
|
|
const boardElement = boardRef.current;
|
|
if (!boardElement) {
|
|
return null;
|
|
}
|
|
|
|
const rect = boardElement.getBoundingClientRect();
|
|
if (
|
|
clientX < rect.left ||
|
|
clientX > rect.right ||
|
|
clientY < rect.top ||
|
|
clientY > rect.bottom
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const relativeX = clientX - rect.left;
|
|
const relativeY = clientY - rect.top;
|
|
const col = Math.min(
|
|
board.cols - 1,
|
|
Math.max(0, Math.floor((relativeX / rect.width) * board.cols)),
|
|
);
|
|
const row = Math.min(
|
|
board.rows - 1,
|
|
Math.max(0, Math.floor((relativeY / rect.height) * board.rows)),
|
|
);
|
|
|
|
return { row, col };
|
|
};
|
|
|
|
const handlePiecePointerUp = (
|
|
pieceId: string,
|
|
event: React.PointerEvent<HTMLDivElement>,
|
|
) => {
|
|
const currentDragState = dragState;
|
|
if (!currentDragState || currentDragState.pieceId !== pieceId) {
|
|
return;
|
|
}
|
|
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
|
|
if (currentDragState.dragging) {
|
|
const targetCell = resolveBoardCellFromPointer(
|
|
event.clientX,
|
|
event.clientY,
|
|
);
|
|
if (targetCell) {
|
|
onDragPiece({
|
|
pieceId,
|
|
targetRow: targetCell.row,
|
|
targetCol: targetCell.col,
|
|
});
|
|
}
|
|
setSelectedPieceId(null);
|
|
setDragState(null);
|
|
return;
|
|
}
|
|
|
|
setDragState(null);
|
|
handlePieceClick(pieceId);
|
|
};
|
|
|
|
const statusLabel =
|
|
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
|
const nextAvailable =
|
|
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
|
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
|
|
{currentLevel.coverImageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={currentLevel.coverImageSrc}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
|
|
/>
|
|
) : null}
|
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
|
|
|
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
|
|
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
|
|
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
|
|
PUZZLE
|
|
</div>
|
|
<div className="line-clamp-1 text-sm font-bold text-white">
|
|
{currentLevel.levelName}
|
|
</div>
|
|
<div className="text-xs text-white/74">
|
|
{currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '}
|
|
{statusLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
|
|
<div
|
|
ref={boardRef}
|
|
className="grid aspect-square w-full max-w-[min(92vw,92vh)] rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
{buildBoardCells(board).map((cell) => {
|
|
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
|
|
const occupied = Boolean(piece);
|
|
const isMerged = mergedCellKeys.has(boardCellKey(cell));
|
|
const isSelected = piece?.pieceId === selectedPieceId;
|
|
|
|
return (
|
|
<div
|
|
key={`${cell.row}:${cell.col}`}
|
|
className="relative p-1"
|
|
>
|
|
<div
|
|
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
|
|
occupied
|
|
? isSelected
|
|
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
|
|
: isMerged
|
|
? 'border-emerald-200/55 bg-emerald-300/26 text-white'
|
|
: 'border-white/18 bg-white/12 text-white'
|
|
: 'border-white/8 bg-black/18 text-white/20'
|
|
}`}
|
|
onPointerDown={(event) => {
|
|
if (!piece || isBusy) {
|
|
return;
|
|
}
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
setDragState({
|
|
pieceId: piece.pieceId,
|
|
pointerId: event.pointerId,
|
|
dragging: false,
|
|
startX: event.clientX,
|
|
startY: event.clientY,
|
|
});
|
|
}}
|
|
onPointerMove={(event) => {
|
|
if (
|
|
!piece ||
|
|
!dragState ||
|
|
dragState.pieceId !== piece.pieceId ||
|
|
dragState.pointerId !== event.pointerId ||
|
|
dragState.dragging
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = event.clientX - dragState.startX;
|
|
const deltaY = event.clientY - dragState.startY;
|
|
if (Math.hypot(deltaX, deltaY) >= 8) {
|
|
setDragState((current) =>
|
|
current && current.pieceId === piece.pieceId
|
|
? {
|
|
...current,
|
|
dragging: true,
|
|
}
|
|
: current,
|
|
);
|
|
}
|
|
}}
|
|
onPointerUp={(event) => {
|
|
if (piece) {
|
|
handlePiecePointerUp(piece.pieceId, event);
|
|
}
|
|
}}
|
|
onPointerCancel={() => {
|
|
setDragState(null);
|
|
}}
|
|
>
|
|
{piece ? (
|
|
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
|
{resolvedCoverImage ? (
|
|
<div
|
|
className="absolute inset-0"
|
|
style={{
|
|
backgroundImage: `url("${resolvedCoverImage}")`,
|
|
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
|
backgroundPosition: `${
|
|
board.cols > 1
|
|
? (piece.correctCol / (board.cols - 1)) * 100
|
|
: 0
|
|
}% ${
|
|
board.rows > 1
|
|
? (piece.correctRow / (board.rows - 1)) * 100
|
|
: 0
|
|
}%`,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
|
|
)}
|
|
<div className="absolute inset-0 bg-black/10" />
|
|
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
|
|
{piece.label}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
''
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-4 py-4">
|
|
<div className="max-w-[18rem] rounded-[1.1rem] bg-black/28 px-4 py-3 text-xs leading-6 text-white/74 backdrop-blur">
|
|
{selectedPieceId
|
|
? '已选择一块,再点另一块可交换;也可以直接拖到目标位置。'
|
|
: '点击两块可交换,拖动单块或合并块到目标格继续推进。'}
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2">
|
|
{error ? (
|
|
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
{nextAvailable ? (
|
|
<button
|
|
type="button"
|
|
disabled={isBusy}
|
|
onClick={onAdvanceNextLevel}
|
|
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
|
>
|
|
下一关
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
) : (
|
|
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
|
|
{isBusy
|
|
? '同步中...'
|
|
: currentLevel.status === 'cleared'
|
|
? '等待下一关候选'
|
|
: '完成整张图即可通关'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PuzzleRuntimeShell;
|