1
This commit is contained in:
126
src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx
Normal file
126
src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: () => ({
|
||||
resolvedUrl: '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const clearedRun: PuzzleRunSnapshot = {
|
||||
runId: 'run-1',
|
||||
entryProfileId: 'profile-1',
|
||||
clearedLevelCount: 1,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: 3,
|
||||
playedProfileIds: ['profile-1'],
|
||||
previousLevelTags: ['奇幻'],
|
||||
recommendedNextProfileId: 'profile-2',
|
||||
leaderboardEntries: [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试作者',
|
||||
elapsedMs: 12_340,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: 18_120,
|
||||
},
|
||||
],
|
||||
currentLevel: {
|
||||
runId: 'run-1',
|
||||
levelIndex: 1,
|
||||
gridSize: 3,
|
||||
profileId: 'profile-1',
|
||||
levelName: '潮雾拼图',
|
||||
authorDisplayName: '测试作者',
|
||||
themeTags: ['奇幻'],
|
||||
coverImageSrc: null,
|
||||
status: 'cleared',
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13_340,
|
||||
elapsedMs: 12_340,
|
||||
leaderboardEntries: [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试作者',
|
||||
elapsedMs: 12_340,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: 18_120,
|
||||
},
|
||||
],
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: true,
|
||||
mergedGroups: [],
|
||||
pieces: Array.from({ length: 9 }, (_, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / 3),
|
||||
correctCol: index % 3,
|
||||
currentRow: Math.floor(index / 3),
|
||||
currentCol: index % 3,
|
||||
mergedGroupId: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
||||
expect(within(dialog).getByText('#1')).toBeTruthy();
|
||||
expect(within(dialog).getByText('测试作者')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
render(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -27,9 +28,26 @@ type PuzzleBoardPieceViewModel = {
|
||||
col: number;
|
||||
correctRow: number;
|
||||
correctCol: number;
|
||||
mergedGroupId: string | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PuzzleMergedGroupViewModel = {
|
||||
groupId: string;
|
||||
pieceIds: string[];
|
||||
anchorPieceId: string;
|
||||
minRow: number;
|
||||
minCol: number;
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
pieces: Array<
|
||||
PuzzleBoardPieceViewModel & {
|
||||
localRow: number;
|
||||
localCol: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function boardCellKey(position: PuzzleCellPosition) {
|
||||
return `${position.row}:${position.col}`;
|
||||
}
|
||||
@@ -46,6 +64,59 @@ function buildPieceLabel(pieceId: string) {
|
||||
return fallback || '块';
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
groups: PuzzleMergedGroupState[],
|
||||
pieces: PuzzleBoardPieceViewModel[],
|
||||
) {
|
||||
const pieceById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
||||
return groups
|
||||
.map<PuzzleMergedGroupViewModel | null>((group) => {
|
||||
const groupPieces = group.pieceIds
|
||||
.map((pieceId) => pieceById.get(pieceId) ?? null)
|
||||
.filter((piece): piece is PuzzleBoardPieceViewModel => Boolean(piece));
|
||||
if (groupPieces.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
const rows = groupPieces.map((piece) => piece.row);
|
||||
const cols = groupPieces.map((piece) => piece.col);
|
||||
const minRow = Math.min(...rows);
|
||||
const maxRow = Math.max(...rows);
|
||||
const minCol = Math.min(...cols);
|
||||
const maxCol = Math.max(...cols);
|
||||
const anchorPiece = groupPieces[0];
|
||||
if (!anchorPiece) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
pieceIds: group.pieceIds,
|
||||
anchorPieceId: anchorPiece.pieceId,
|
||||
minRow,
|
||||
minCol,
|
||||
rowSpan: maxRow - minRow + 1,
|
||||
colSpan: maxCol - minCol + 1,
|
||||
pieces: groupPieces.map((piece) => ({
|
||||
...piece,
|
||||
localRow: piece.row - minRow,
|
||||
localCol: piece.col - minCol,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((group): group is PuzzleMergedGroupViewModel => Boolean(group));
|
||||
}
|
||||
|
||||
function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
const normalizedMs = Math.max(0, Math.round(elapsedMs ?? 0));
|
||||
const totalSeconds = Math.floor(normalizedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((normalizedMs % 1000) / 10);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
@@ -60,13 +131,24 @@ export function PuzzleRuntimeShell({
|
||||
onAdvanceNextLevel,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [dragState, setDragState] = useState<{
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
dragging: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
} | null>(null);
|
||||
const dragVisualTargetRef = useRef<{
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const dragVisualFrameRef = useRef<number | null>(null);
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(null);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
@@ -84,20 +166,27 @@ export function PuzzleRuntimeShell({
|
||||
col: piece.currentCol,
|
||||
correctRow: piece.correctRow,
|
||||
correctCol: piece.correctCol,
|
||||
mergedGroupId: piece.mergedGroupId,
|
||||
label: buildPieceLabel(piece.pieceId),
|
||||
}));
|
||||
}, [board]);
|
||||
|
||||
const mergedCellKeys = useMemo(() => {
|
||||
const mergedGroups = useMemo(() => {
|
||||
if (!board) {
|
||||
return new Set<string>();
|
||||
return [];
|
||||
}
|
||||
return new Set(
|
||||
board.mergedGroups.flatMap((group) =>
|
||||
group.occupiedCells.map((cell) => boardCellKey(cell)),
|
||||
return buildMergedGroupViewModels(board.mergedGroups, pieces);
|
||||
}, [board, pieces]);
|
||||
|
||||
const mergedCellKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
mergedGroups.flatMap((group) =>
|
||||
group.pieces.map((piece) => boardCellKey(piece)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [board]);
|
||||
[mergedGroups],
|
||||
);
|
||||
|
||||
const pieceByCell = useMemo(() => {
|
||||
const map = new Map<string, PuzzleBoardPieceViewModel>();
|
||||
@@ -106,6 +195,119 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
return map;
|
||||
}, [pieces]);
|
||||
const pieceById = useMemo(
|
||||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||||
[pieces],
|
||||
);
|
||||
|
||||
const resetDragVisualTarget = () => {
|
||||
const dragVisualTarget = dragVisualTargetRef.current;
|
||||
if (!dragVisualTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
|
||||
if (groupElement) {
|
||||
groupElement.style.transform = '';
|
||||
groupElement.style.willChange = '';
|
||||
groupElement.style.zIndex = '';
|
||||
groupElement.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
|
||||
const cancelDragVisualFrame = () => {
|
||||
if (dragVisualFrameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
window.cancelAnimationFrame(dragVisualFrameRef.current);
|
||||
dragVisualFrameRef.current = null;
|
||||
};
|
||||
|
||||
const resetDragInteraction = () => {
|
||||
cancelDragVisualFrame();
|
||||
dragOffsetRef.current = null;
|
||||
dragSessionRef.current = null;
|
||||
resetDragVisualTarget();
|
||||
};
|
||||
|
||||
const flushDragVisual = () => {
|
||||
dragVisualFrameRef.current = null;
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession || !dragSession.dragging) {
|
||||
resetDragVisualTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const piece = pieceById.get(dragSession.pieceId) ?? null;
|
||||
const groupId = piece?.mergedGroupId ?? null;
|
||||
const nextTarget = {
|
||||
pieceId: dragSession.pieceId,
|
||||
groupId,
|
||||
};
|
||||
const previousTarget = dragVisualTargetRef.current;
|
||||
if (
|
||||
previousTarget &&
|
||||
(previousTarget.pieceId !== nextTarget.pieceId ||
|
||||
previousTarget.groupId !== nextTarget.groupId)
|
||||
) {
|
||||
resetDragVisualTarget();
|
||||
}
|
||||
dragVisualTargetRef.current = nextTarget;
|
||||
|
||||
const offsetX = dragSession.currentX - dragSession.startX;
|
||||
const offsetY = dragSession.currentY - dragSession.startY;
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY };
|
||||
|
||||
if (groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(groupId);
|
||||
if (groupElement) {
|
||||
groupElement.style.willChange = 'transform';
|
||||
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
|
||||
groupElement.style.zIndex = '80';
|
||||
groupElement.style.opacity = '0.95';
|
||||
}
|
||||
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.willChange = 'transform';
|
||||
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
|
||||
pieceElement.style.zIndex = '70';
|
||||
pieceElement.style.opacity = '0.95';
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDragVisual = () => {
|
||||
if (dragVisualFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
}, []);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
@@ -174,18 +376,19 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const currentDragState = dragState;
|
||||
if (!currentDragState || currentDragState.pieceId !== pieceId) {
|
||||
const currentDragSession = dragSessionRef.current;
|
||||
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
|
||||
if (currentDragState.dragging) {
|
||||
if (currentDragSession.dragging) {
|
||||
const targetCell = resolveBoardCellFromPointer(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
resetDragInteraction();
|
||||
if (targetCell) {
|
||||
onDragPiece({
|
||||
pieceId,
|
||||
@@ -194,18 +397,73 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
}
|
||||
setSelectedPieceId(null);
|
||||
setDragState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState(null);
|
||||
resetDragInteraction();
|
||||
handlePieceClick(pieceId);
|
||||
};
|
||||
|
||||
const handlePiecePointerDown = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
resetDragInteraction();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragSessionRef.current = {
|
||||
pieceId,
|
||||
pointerId: event.pointerId,
|
||||
dragging: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
currentY: event.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePiecePointerMove = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (
|
||||
!dragSession ||
|
||||
dragSession.pieceId !== pieceId ||
|
||||
dragSession.pointerId !== event.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const deltaX = event.clientX - dragSession.startX;
|
||||
const deltaY = event.clientY - dragSession.startY;
|
||||
const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
|
||||
dragSession.dragging = dragging;
|
||||
dragSession.currentX = event.clientX;
|
||||
dragSession.currentY = event.clientY;
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 拖动中的视觉更新直接写入 DOM transform,避免 pointermove 触发整盘 React 重渲染导致跟手延迟。
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
|
||||
const leaderboardEntries =
|
||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||
? currentLevel.leaderboardEntries
|
||||
: (run.leaderboardEntries ?? []);
|
||||
const isClearResultOpen =
|
||||
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -246,7 +504,8 @@ export function PuzzleRuntimeShell({
|
||||
<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"
|
||||
data-testid="puzzle-board"
|
||||
className="relative grid aspect-square w-full max-w-[min(92vw,92vh)] touch-none select-none 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))`,
|
||||
}}
|
||||
@@ -263,64 +522,55 @@ export function PuzzleRuntimeShell({
|
||||
className="relative p-1"
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (!piece) {
|
||||
return;
|
||||
}
|
||||
if (node) {
|
||||
pieceElementRefMap.current.set(piece.pieceId, node);
|
||||
return;
|
||||
}
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
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-transparent bg-transparent text-white'
|
||||
: 'border-white/18 bg-white/12 text-white'
|
||||
: 'border-white/8 bg-black/18 text-white/20'
|
||||
} ${
|
||||
isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]'
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
if (!piece || isBusy) {
|
||||
if (!piece || isMerged) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragState({
|
||||
pieceId: piece.pieceId,
|
||||
pointerId: event.pointerId,
|
||||
dragging: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
});
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
!piece ||
|
||||
!dragState ||
|
||||
dragState.pieceId !== piece.pieceId ||
|
||||
dragState.pointerId !== event.pointerId ||
|
||||
dragState.dragging
|
||||
) {
|
||||
if (!piece || isMerged) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (piece) {
|
||||
if (piece && !isMerged) {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDragState(null);
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{piece ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
||||
{resolvedCoverImage ? (
|
||||
{isMerged ? null : resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
@@ -341,9 +591,11 @@ export function PuzzleRuntimeShell({
|
||||
<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">
|
||||
{!isMerged ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -352,22 +604,97 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => (
|
||||
<div
|
||||
key={group.groupId}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
groupElementRefMap.current.set(group.groupId, node);
|
||||
return;
|
||||
}
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
className="pointer-events-none absolute z-10 p-1"
|
||||
style={{
|
||||
left: `${(group.minCol / board.cols) * 100}%`,
|
||||
top: `${(group.minRow / board.rows) * 100}%`,
|
||||
width: `${(group.colSpan / board.cols) * 100}%`,
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none relative grid h-full w-full touch-none overflow-visible active:scale-[0.992]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{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(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/8" />
|
||||
</div>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[1rem] ring-2 ring-emerald-100/58 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_14px_32px_rgba(6,78,59,0.24)]" />
|
||||
</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="absolute bottom-0 left-0 z-20 flex w-full items-end justify-end gap-3 px-4 py-4">
|
||||
<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}
|
||||
{selectedPieceId && currentLevel.status !== 'cleared' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
{nextAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -389,6 +716,107 @@ export function PuzzleRuntimeShell({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClearResultOpen ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black text-white"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="mt-1 line-clamp-1 text-xs text-white/62">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/8 text-white/72 transition hover:bg-white/14 hover:text-white"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4 rounded-[1rem] border border-amber-200/24 bg-amber-200/10 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/24 text-amber-100">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white/72">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xl font-black text-amber-100">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold text-white">排行榜</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-white/10">
|
||||
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'bg-amber-200/14 text-amber-50'
|
||||
: 'border-t border-white/8 text-white/78'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">#{entry.rank}</span>
|
||||
<span className="truncate font-semibold">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !nextAvailable}
|
||||
onClick={onAdvanceNextLevel}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user