Files
Genarrative/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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;