1
This commit is contained in:
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal file
397
src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user