1953 lines
71 KiB
TypeScript
1953 lines
71 KiB
TypeScript
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
Clock,
|
||
Eye,
|
||
Lightbulb,
|
||
Loader2,
|
||
Snowflake,
|
||
Sparkles,
|
||
Trophy,
|
||
} from 'lucide-react';
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import type {
|
||
DragPuzzlePieceRequest,
|
||
PuzzleBoardSnapshot,
|
||
PuzzleCellPosition,
|
||
PuzzleMergedGroupState,
|
||
PuzzleRecommendedNextWork,
|
||
PuzzleRunSnapshot,
|
||
PuzzleRuntimeLevelSnapshot,
|
||
PuzzleRuntimePropKind,
|
||
SwapPuzzlePiecesRequest,
|
||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { PixelIcon } from '../PixelIcon';
|
||
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: (target?: PuzzleNextLevelTarget) => void;
|
||
onRestartLevel?: () => void | Promise<void>;
|
||
onPauseChange?: (paused: boolean) => void | Promise<void>;
|
||
onUseProp?: (
|
||
propKind: PuzzleRuntimePropKind,
|
||
) => Promise<PuzzleRunSnapshot | null | void>;
|
||
onTimeExpired?: () => void | Promise<void>;
|
||
};
|
||
|
||
export type PuzzleNextLevelTarget = {
|
||
profileId?: string;
|
||
levelId?: string | null;
|
||
};
|
||
|
||
type PuzzleBoardPieceViewModel = {
|
||
pieceId: string;
|
||
row: number;
|
||
col: number;
|
||
correctRow: number;
|
||
correctCol: number;
|
||
mergedGroupId: string | null;
|
||
};
|
||
|
||
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}`;
|
||
}
|
||
|
||
function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||
return Array.from({ length: board.rows * board.cols }, (_, index) => ({
|
||
row: Math.floor(index / board.cols),
|
||
col: index % board.cols,
|
||
}));
|
||
}
|
||
|
||
function buildLocalCellKey(row: number, col: number) {
|
||
return `${row}:${col}`;
|
||
}
|
||
|
||
export function resolveDraggedPieceCellLayer(
|
||
pieceId: string | null | undefined,
|
||
draggingPieceId: string | null,
|
||
isMerged: boolean,
|
||
) {
|
||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||
return undefined;
|
||
}
|
||
return 80;
|
||
}
|
||
|
||
export function resolveDraggedPieceLayer(
|
||
pieceId: string | null | undefined,
|
||
draggingPieceId: string | null,
|
||
isMerged: boolean,
|
||
) {
|
||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||
return undefined;
|
||
}
|
||
return 81;
|
||
}
|
||
|
||
export function resolveDraggedMergedGroupLayer(
|
||
groupId: string,
|
||
draggingGroupId: string | null,
|
||
) {
|
||
return groupId === draggingGroupId ? 90 : undefined;
|
||
}
|
||
|
||
function resolveMergedPieceOutlineClass(
|
||
group: PuzzleMergedGroupViewModel,
|
||
piece: PuzzleMergedGroupViewModel['pieces'][number],
|
||
) {
|
||
const groupCellKeys = new Set(
|
||
group.pieces.map((groupPiece) =>
|
||
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
|
||
),
|
||
);
|
||
const hasCell = (row: number, col: number) =>
|
||
groupCellKeys.has(buildLocalCellKey(row, col));
|
||
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
|
||
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
|
||
const hasBottomBoundary = (row: number, col: number) =>
|
||
!hasCell(row + 1, col);
|
||
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
|
||
const hasTopEdge = !groupCellKeys.has(
|
||
buildLocalCellKey(piece.localRow - 1, piece.localCol),
|
||
);
|
||
const hasRightEdge = !groupCellKeys.has(
|
||
buildLocalCellKey(piece.localRow, piece.localCol + 1),
|
||
);
|
||
const hasBottomEdge = !groupCellKeys.has(
|
||
buildLocalCellKey(piece.localRow + 1, piece.localCol),
|
||
);
|
||
const hasLeftEdge = !groupCellKeys.has(
|
||
buildLocalCellKey(piece.localRow, piece.localCol - 1),
|
||
);
|
||
const topLeftRadius =
|
||
hasTopEdge && hasLeftEdge
|
||
? 'rounded-tl-[0.85rem]'
|
||
: (!hasTopEdge && !hasLeftEdge) ||
|
||
(hasTopEdge &&
|
||
!hasLeftEdge &&
|
||
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
|
||
(hasLeftEdge &&
|
||
!hasTopEdge &&
|
||
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
|
||
? 'rounded-tl-[0.35rem]'
|
||
: 'rounded-tl-none';
|
||
const topRightRadius =
|
||
hasTopEdge && hasRightEdge
|
||
? 'rounded-tr-[0.85rem]'
|
||
: (!hasTopEdge && !hasRightEdge) ||
|
||
(hasTopEdge &&
|
||
!hasRightEdge &&
|
||
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
|
||
(hasRightEdge &&
|
||
!hasTopEdge &&
|
||
!hasRightBoundary(piece.localRow - 1, piece.localCol))
|
||
? 'rounded-tr-[0.35rem]'
|
||
: 'rounded-tr-none';
|
||
const bottomRightRadius =
|
||
hasBottomEdge && hasRightEdge
|
||
? 'rounded-br-[0.85rem]'
|
||
: (!hasBottomEdge && !hasRightEdge) ||
|
||
(hasBottomEdge &&
|
||
!hasRightEdge &&
|
||
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
|
||
(hasRightEdge &&
|
||
!hasBottomEdge &&
|
||
!hasRightBoundary(piece.localRow + 1, piece.localCol))
|
||
? 'rounded-br-[0.35rem]'
|
||
: 'rounded-br-none';
|
||
const bottomLeftRadius =
|
||
hasBottomEdge && hasLeftEdge
|
||
? 'rounded-bl-[0.85rem]'
|
||
: (!hasBottomEdge && !hasLeftEdge) ||
|
||
(hasBottomEdge &&
|
||
!hasLeftEdge &&
|
||
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
|
||
(hasLeftEdge &&
|
||
!hasBottomEdge &&
|
||
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
|
||
? 'rounded-bl-[0.35rem]'
|
||
: 'rounded-bl-none';
|
||
return [
|
||
hasTopEdge ? 'border-t-2' : 'border-t-0',
|
||
hasRightEdge ? 'border-r-2' : 'border-r-0',
|
||
hasBottomEdge ? 'border-b-2' : 'border-b-0',
|
||
hasLeftEdge ? 'border-l-2' : 'border-l-0',
|
||
topLeftRadius,
|
||
topRightRadius,
|
||
bottomRightRadius,
|
||
bottomLeftRadius,
|
||
].join(' ');
|
||
}
|
||
|
||
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')}`;
|
||
}
|
||
|
||
function formatTimerMs(value: number | null | undefined) {
|
||
const normalizedMs = Math.max(0, Math.ceil((value ?? 0) / 1000) * 1000);
|
||
const totalSeconds = Math.floor(normalizedMs / 1000);
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
function resolveAuthorAvatarLabel(authorDisplayName: string) {
|
||
return authorDisplayName.trim().slice(0, 1) || '玩';
|
||
}
|
||
|
||
function resolveActiveFreezeElapsedMs(
|
||
level: PuzzleRuntimeLevelSnapshot,
|
||
nowMs: number,
|
||
) {
|
||
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
||
return 0;
|
||
}
|
||
return Math.max(
|
||
0,
|
||
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
|
||
);
|
||
}
|
||
|
||
function resolveRuntimeRemainingMs(
|
||
level: PuzzleRuntimeLevelSnapshot,
|
||
nowMs: number,
|
||
uiPauseStartedAtMs: number | null,
|
||
) {
|
||
if (level.status !== 'playing') {
|
||
return level.remainingMs;
|
||
}
|
||
|
||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||
const snapshotPauseElapsedMs = level.pauseStartedAtMs
|
||
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
||
: 0;
|
||
const optimisticPauseElapsedMs =
|
||
!level.pauseStartedAtMs && uiPauseStartedAtMs
|
||
? Math.max(0, nowMs - uiPauseStartedAtMs)
|
||
: 0;
|
||
const effectiveElapsedMs = Math.max(
|
||
0,
|
||
nowMs -
|
||
level.startedAtMs -
|
||
level.pausedAccumulatedMs -
|
||
snapshotPauseElapsedMs -
|
||
optimisticPauseElapsedMs -
|
||
level.freezeAccumulatedMs -
|
||
resolveActiveFreezeElapsedMs(level, nowMs),
|
||
);
|
||
|
||
return Math.max(0, timeLimitMs - effectiveElapsedMs);
|
||
}
|
||
|
||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
|
||
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
|
||
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
|
||
|
||
type PuzzlePropDialogState = {
|
||
propKind: PuzzleRuntimePropKind;
|
||
title: string;
|
||
};
|
||
|
||
type PuzzleMergeFlashState = {
|
||
key: string;
|
||
groupId: string;
|
||
leftPercent: number;
|
||
topPercent: number;
|
||
};
|
||
|
||
type PuzzleHintDemoState = {
|
||
key: string;
|
||
pieceIds: string[];
|
||
offsetXPercent: number;
|
||
offsetYPercent: number;
|
||
};
|
||
|
||
function triggerPuzzlePiecePressHapticFeedback() {
|
||
if (typeof navigator === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const vibrate = navigator.vibrate;
|
||
if (typeof vibrate !== 'function') {
|
||
return;
|
||
}
|
||
|
||
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
|
||
}
|
||
|
||
/**
|
||
* 拼图运行时壳层。
|
||
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
|
||
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
|
||
*/
|
||
export function PuzzleRuntimeShell({
|
||
run,
|
||
isBusy = false,
|
||
error = null,
|
||
onBack,
|
||
onSwapPieces,
|
||
onDragPiece,
|
||
onAdvanceNextLevel,
|
||
onRestartLevel,
|
||
onPauseChange,
|
||
onUseProp,
|
||
onTimeExpired,
|
||
}: PuzzleRuntimeShellProps) {
|
||
const authUi = useAuthUi();
|
||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||
null,
|
||
);
|
||
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
|
||
useState(false);
|
||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
|
||
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
|
||
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
|
||
null,
|
||
);
|
||
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
||
const [uiPauseStartedAtMs, setUiPauseStartedAtMs] = useState<number | null>(
|
||
null,
|
||
);
|
||
const onPauseChangeRef = useRef(onPauseChange);
|
||
const onTimeExpiredRef = useRef(onTimeExpired);
|
||
const previousUiPauseActiveRef = useRef(false);
|
||
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
|
||
const timeExpiredSyncKeyRef = useRef<string | null>(null);
|
||
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 pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||
const [dragRenderTarget, setDragRenderTarget] = useState<{
|
||
pieceId: string;
|
||
groupId: string | null;
|
||
} | null>(null);
|
||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [isClearFlashVisible, setIsClearFlashVisible] = useState(false);
|
||
const [isClearResultReady, setIsClearResultReady] = useState(false);
|
||
const clearPresentationKeyRef = useRef<string | null>(null);
|
||
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
|
||
const mergeGroupSignatureRef = useRef<string | null>(null);
|
||
const hintDemoTimeoutRef = useRef<number | null>(null);
|
||
const mergeFlashTimeoutRef = useRef<number | null>(null);
|
||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||
const currentLevel = run?.currentLevel ?? null;
|
||
const board = currentLevel?.board ?? null;
|
||
const displayRemainingMs = currentLevel
|
||
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||
: 0;
|
||
const runtimeStatus = currentLevel
|
||
? currentLevel.status === 'playing' && displayRemainingMs <= 0
|
||
? 'failed'
|
||
: currentLevel.status
|
||
: 'playing';
|
||
const clearResultKey = currentLevel
|
||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||
: null;
|
||
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||
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,
|
||
mergedGroupId: piece.mergedGroupId,
|
||
}));
|
||
}, [board]);
|
||
|
||
const mergedGroups = useMemo(() => {
|
||
if (!board) {
|
||
return [];
|
||
}
|
||
return buildMergedGroupViewModels(board.mergedGroups, pieces);
|
||
}, [board, pieces]);
|
||
|
||
const largestMovableGroup = useMemo(() => {
|
||
const groups = mergedGroups.filter((group) =>
|
||
group.pieces.some(
|
||
(piece) =>
|
||
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
|
||
),
|
||
);
|
||
return (
|
||
groups.sort(
|
||
(left, right) =>
|
||
right.pieceIds.length - left.pieceIds.length ||
|
||
left.minRow - right.minRow ||
|
||
left.minCol - right.minCol,
|
||
)[0] ?? null
|
||
);
|
||
}, [mergedGroups]);
|
||
|
||
const mergedCellKeys = useMemo(
|
||
() =>
|
||
new Set(
|
||
mergedGroups.flatMap((group) =>
|
||
group.pieces.map((piece) => boardCellKey(piece)),
|
||
),
|
||
),
|
||
[mergedGroups],
|
||
);
|
||
|
||
const pieceByCell = useMemo(() => {
|
||
const map = new Map<string, PuzzleBoardPieceViewModel>();
|
||
for (const piece of pieces) {
|
||
map.set(`${piece.row}:${piece.col}`, piece);
|
||
}
|
||
return map;
|
||
}, [pieces]);
|
||
const pieceById = useMemo(
|
||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||
[pieces],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const signature =
|
||
board?.mergedGroups
|
||
.map(
|
||
(group) =>
|
||
`${group.groupId}:${group.pieceIds.slice().sort().join(',')}`,
|
||
)
|
||
.sort()
|
||
.join('|') ?? '';
|
||
const previousSignature = mergeGroupSignatureRef.current;
|
||
mergeGroupSignatureRef.current = signature;
|
||
if (!previousSignature || !board || currentLevel?.status !== 'playing') {
|
||
return;
|
||
}
|
||
|
||
const previousGroupSizes = new Map(
|
||
previousSignature
|
||
.split('|')
|
||
.filter(Boolean)
|
||
.map((entry) => {
|
||
const [groupId, pieceIds = ''] = entry.split(':');
|
||
return [groupId, pieceIds.split(',').filter(Boolean).length] as const;
|
||
}),
|
||
);
|
||
const newGroup = mergedGroups.find(
|
||
(group) =>
|
||
group.pieceIds.length > 1 &&
|
||
group.pieceIds.length > (previousGroupSizes.get(group.groupId) ?? 0),
|
||
);
|
||
if (!newGroup) {
|
||
return;
|
||
}
|
||
|
||
if (mergeFlashTimeoutRef.current !== null) {
|
||
window.clearTimeout(mergeFlashTimeoutRef.current);
|
||
}
|
||
setMergeFlash({
|
||
key: `${newGroup.groupId}:${Date.now()}`,
|
||
groupId: newGroup.groupId,
|
||
leftPercent:
|
||
((newGroup.minCol + newGroup.colSpan / 2) / board.cols) * 100,
|
||
topPercent: ((newGroup.minRow + newGroup.rowSpan / 2) / board.rows) * 100,
|
||
});
|
||
mergeFlashTimeoutRef.current = window.setTimeout(() => {
|
||
setMergeFlash(null);
|
||
}, PUZZLE_MERGE_FLASH_DURATION_MS);
|
||
}, [board, currentLevel?.status, mergedGroups]);
|
||
|
||
const resolvePieceCellElement = (pieceId: string) => {
|
||
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
|
||
const pieceCellElement =
|
||
(pieceElement?.parentElement as HTMLDivElement | null) ??
|
||
pieceCellElementRefMap.current.get(pieceId) ??
|
||
null;
|
||
return pieceCellElement;
|
||
};
|
||
|
||
const resetDragVisualTarget = () => {
|
||
const dragVisualTarget = dragVisualTargetRef.current;
|
||
setDragRenderTarget(null);
|
||
if (!dragVisualTarget) {
|
||
return;
|
||
}
|
||
|
||
const pieceElement = pieceElementRefMap.current.get(
|
||
dragVisualTarget.pieceId,
|
||
);
|
||
const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId);
|
||
if (pieceCellElement) {
|
||
pieceCellElement.style.zIndex = '';
|
||
}
|
||
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;
|
||
setDragRenderTarget((currentTarget) => {
|
||
if (
|
||
currentTarget?.pieceId === nextTarget.pieceId &&
|
||
currentTarget.groupId === nextTarget.groupId
|
||
) {
|
||
return currentTarget;
|
||
}
|
||
return 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 = '90';
|
||
groupElement.style.opacity = '0.95';
|
||
}
|
||
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
|
||
if (pieceCellElement) {
|
||
pieceCellElement.style.zIndex = '';
|
||
}
|
||
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
|
||
if (pieceElement) {
|
||
pieceElement.style.transform = '';
|
||
pieceElement.style.willChange = '';
|
||
pieceElement.style.zIndex = '';
|
||
pieceElement.style.opacity = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
|
||
if (pieceCellElement) {
|
||
// 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。
|
||
pieceCellElement.style.zIndex = '80';
|
||
}
|
||
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 = '81';
|
||
pieceElement.style.opacity = '0.95';
|
||
}
|
||
};
|
||
|
||
const scheduleDragVisual = () => {
|
||
if (dragVisualFrameRef.current !== null) {
|
||
return;
|
||
}
|
||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||
};
|
||
|
||
useEffect(
|
||
() => () => {
|
||
cancelDragVisualFrame();
|
||
resetDragVisualTarget();
|
||
},
|
||
[],
|
||
);
|
||
|
||
const clearPresentationTimeouts = () => {
|
||
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
|
||
window.clearTimeout(timeoutId);
|
||
}
|
||
clearPresentationTimeoutIdsRef.current = [];
|
||
};
|
||
|
||
useEffect(
|
||
() => () => {
|
||
clearPresentationTimeouts();
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
onPauseChangeRef.current = onPauseChange;
|
||
}, [onPauseChange]);
|
||
|
||
useEffect(() => {
|
||
onTimeExpiredRef.current = onTimeExpired;
|
||
}, [onTimeExpired]);
|
||
|
||
const isUiPauseActive =
|
||
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
|
||
|
||
useEffect(() => {
|
||
if (previousUiPauseActiveRef.current === isUiPauseActive) {
|
||
return;
|
||
}
|
||
previousUiPauseActiveRef.current = isUiPauseActive;
|
||
setUiPauseStartedAtMs((currentValue) =>
|
||
isUiPauseActive ? (currentValue ?? Date.now()) : null,
|
||
);
|
||
pauseChangePromiseRef.current = Promise.resolve(
|
||
onPauseChangeRef.current?.(isUiPauseActive),
|
||
).catch(() => undefined);
|
||
}, [isUiPauseActive]);
|
||
|
||
useEffect(() => {
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return;
|
||
}
|
||
|
||
const timerId = window.setInterval(() => {
|
||
setTimerNowMs(Date.now());
|
||
}, 250);
|
||
|
||
return () => window.clearInterval(timerId);
|
||
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
|
||
|
||
useEffect(() => {
|
||
if (!run || !currentLevel || currentLevel.status !== 'playing') {
|
||
return;
|
||
}
|
||
if (displayRemainingMs > 0) {
|
||
return;
|
||
}
|
||
|
||
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
|
||
if (timeExpiredSyncKeyRef.current === syncKey) {
|
||
return;
|
||
}
|
||
timeExpiredSyncKeyRef.current = syncKey;
|
||
void onTimeExpiredRef.current?.();
|
||
}, [
|
||
currentLevel?.levelIndex,
|
||
currentLevel?.startedAtMs,
|
||
currentLevel?.status,
|
||
displayRemainingMs,
|
||
run?.runId,
|
||
]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (hintDemoTimeoutRef.current !== null) {
|
||
window.clearTimeout(hintDemoTimeoutRef.current);
|
||
}
|
||
if (mergeFlashTimeoutRef.current !== null) {
|
||
window.clearTimeout(mergeFlashTimeoutRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!currentLevel || !clearResultKey) {
|
||
clearPresentationKeyRef.current = null;
|
||
clearPresentationTimeouts();
|
||
setIsClearFlashVisible(false);
|
||
setIsClearResultReady(false);
|
||
return;
|
||
}
|
||
|
||
if (currentLevel.status !== 'cleared') {
|
||
clearPresentationKeyRef.current = null;
|
||
clearPresentationTimeouts();
|
||
setIsClearFlashVisible(false);
|
||
setIsClearResultReady(false);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
dismissedClearKey === clearResultKey ||
|
||
clearPresentationKeyRef.current === clearResultKey
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
|
||
clearPresentationKeyRef.current = clearResultKey;
|
||
clearPresentationTimeouts();
|
||
setIsClearFlashVisible(true);
|
||
setIsClearResultReady(false);
|
||
clearPresentationTimeoutIdsRef.current = [
|
||
window.setTimeout(() => {
|
||
setIsClearFlashVisible(false);
|
||
}, PUZZLE_CLEAR_FLASH_DURATION_MS),
|
||
window.setTimeout(() => {
|
||
setIsClearResultReady(true);
|
||
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
|
||
];
|
||
}, [clearResultKey, currentLevel, dismissedClearKey]);
|
||
|
||
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 (isInteractionLocked) {
|
||
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 currentDragSession = dragSessionRef.current;
|
||
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
|
||
return;
|
||
}
|
||
|
||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||
|
||
if (currentDragSession.dragging) {
|
||
const targetCell = resolveBoardCellFromPointer(
|
||
event.clientX,
|
||
event.clientY,
|
||
);
|
||
resetDragInteraction();
|
||
if (targetCell) {
|
||
onDragPiece({
|
||
pieceId,
|
||
targetRow: targetCell.row,
|
||
targetCol: targetCell.col,
|
||
});
|
||
}
|
||
setSelectedPieceId(null);
|
||
return;
|
||
}
|
||
|
||
resetDragInteraction();
|
||
handlePieceClick(pieceId);
|
||
};
|
||
|
||
const handlePiecePointerDown = (
|
||
pieceId: string,
|
||
event: React.PointerEvent<HTMLDivElement>,
|
||
) => {
|
||
if (isInteractionLocked) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
resetDragInteraction();
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
|
||
triggerPuzzlePiecePressHapticFeedback();
|
||
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,确保层级提升不会滞后一帧;后续仍保留 raf 兜底连续刷新。
|
||
flushDragVisual();
|
||
scheduleDragVisual();
|
||
};
|
||
|
||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||
const freezeRemainingMs =
|
||
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
|
||
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
|
||
: 0;
|
||
const statusLabel =
|
||
runtimeStatus === 'cleared'
|
||
? '已通关'
|
||
: runtimeStatus === 'failed'
|
||
? '失败'
|
||
: '进行中';
|
||
const nextLevelMode =
|
||
run.nextLevelMode ?? 'none';
|
||
const recommendedNextWorks = run.recommendedNextWorks ?? [];
|
||
const hasSimilarWorkChoices =
|
||
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
|
||
const canAdvanceDefaultNextLevel =
|
||
currentLevel.status === 'cleared' &&
|
||
(nextLevelMode === 'sameWork' ||
|
||
(nextLevelMode === 'similarWorks'
|
||
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
|
||
!hasSimilarWorkChoices
|
||
: Boolean(run.recommendedNextProfileId)));
|
||
const canShowNextAction =
|
||
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||
const authorAvatarLabel = resolveAuthorAvatarLabel(
|
||
currentLevel.authorDisplayName,
|
||
);
|
||
const leaderboardEntries =
|
||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||
? currentLevel.leaderboardEntries
|
||
: (run.leaderboardEntries ?? []);
|
||
const isClearResultOpen =
|
||
currentLevel.status === 'cleared' &&
|
||
dismissedClearKey !== clearResultKey &&
|
||
isClearResultReady;
|
||
const isInteractionLocked =
|
||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||
|
||
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
|
||
const canOpen =
|
||
propKind === 'extendTime'
|
||
? runtimeStatus === 'failed'
|
||
: runtimeStatus === 'playing';
|
||
if (!canOpen) {
|
||
return;
|
||
}
|
||
setPropConfirmError(null);
|
||
setPropDialog({ propKind, title });
|
||
};
|
||
|
||
const playHintDemo = () => {
|
||
const targetGroup = largestMovableGroup;
|
||
const targetPieces = targetGroup?.pieces ?? [];
|
||
const fallbackPiece = pieces.find(
|
||
(piece) =>
|
||
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
|
||
);
|
||
const anchorPiece = targetPieces[0] ?? fallbackPiece ?? null;
|
||
if (!anchorPiece) {
|
||
return;
|
||
}
|
||
const pieceIds =
|
||
targetPieces.length > 0
|
||
? targetPieces.map((piece) => piece.pieceId)
|
||
: [anchorPiece.pieceId];
|
||
const offsetXPercent =
|
||
((anchorPiece.correctCol - anchorPiece.col) / board.cols) * 100;
|
||
const offsetYPercent =
|
||
((anchorPiece.correctRow - anchorPiece.row) / board.rows) * 100;
|
||
setHintDemo({
|
||
key: `${anchorPiece.pieceId}:${Date.now()}`,
|
||
pieceIds,
|
||
offsetXPercent,
|
||
offsetYPercent,
|
||
});
|
||
if (hintDemoTimeoutRef.current !== null) {
|
||
window.clearTimeout(hintDemoTimeoutRef.current);
|
||
}
|
||
hintDemoTimeoutRef.current = window.setTimeout(() => {
|
||
setHintDemo(null);
|
||
}, PUZZLE_HINT_DEMO_DURATION_MS);
|
||
};
|
||
|
||
const handleConfirmProp = async () => {
|
||
if (!propDialog) {
|
||
return;
|
||
}
|
||
const propKind = propDialog.propKind;
|
||
setIsPropConfirming(true);
|
||
setPropConfirmError(null);
|
||
try {
|
||
await pauseChangePromiseRef.current;
|
||
const useResult = await onUseProp?.(propKind);
|
||
if (useResult === null) {
|
||
return;
|
||
}
|
||
setPropDialog(null);
|
||
} catch (error) {
|
||
setPropConfirmError(
|
||
error instanceof Error ? error.message : '使用拼图道具失败',
|
||
);
|
||
return;
|
||
} finally {
|
||
setIsPropConfirming(false);
|
||
}
|
||
if (propKind === 'hint') {
|
||
playHintDemo();
|
||
}
|
||
if (propKind === 'reference') {
|
||
setIsOriginalOverlayVisible(true);
|
||
}
|
||
if (propKind === 'freezeTime') {
|
||
setIsFreezeEffectVisible(true);
|
||
window.setTimeout(() => {
|
||
setIsFreezeEffectVisible(false);
|
||
}, 900);
|
||
}
|
||
if (propKind === 'extendTime') {
|
||
setTimerNowMs(Date.now());
|
||
}
|
||
};
|
||
|
||
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 w-full px-4 py-4">
|
||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
aria-label="返回上一页"
|
||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="flex min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
|
||
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
|
||
{currentLevel.levelName}
|
||
</div>
|
||
<div
|
||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
|
||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||
? 'bg-red-500/24 text-red-100'
|
||
: 'bg-white/12 text-white'
|
||
}`}
|
||
>
|
||
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
|
||
{formatTimerMs(displayRemainingMs)}
|
||
</div>
|
||
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
|
||
<span
|
||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
|
||
aria-hidden="true"
|
||
>
|
||
{authorAvatarLabel}
|
||
</span>
|
||
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
|
||
{currentLevel.authorDisplayName}
|
||
</span>
|
||
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
|
||
{levelLabel}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsSettingsPanelOpen(true)}
|
||
aria-label="打开拼图设置"
|
||
title="打开拼图设置"
|
||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
|
||
>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.settings}
|
||
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-28 pb-32 sm:p-4">
|
||
<div
|
||
ref={boardRef}
|
||
data-testid="puzzle-board"
|
||
className="relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_16.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
|
||
style={{
|
||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||
gridTemplateRows: `repeat(${board.rows}, 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}`}
|
||
ref={(node) => {
|
||
if (!piece) {
|
||
return;
|
||
}
|
||
if (node) {
|
||
pieceCellElementRefMap.current.set(piece.pieceId, node);
|
||
return;
|
||
}
|
||
pieceCellElementRefMap.current.delete(piece.pieceId);
|
||
}}
|
||
data-piece-cell-id={piece?.pieceId ?? undefined}
|
||
className="relative"
|
||
style={{
|
||
zIndex: resolveDraggedPieceCellLayer(
|
||
piece?.pieceId,
|
||
draggingPieceId,
|
||
isMerged,
|
||
),
|
||
transform:
|
||
piece && hintDemo?.pieceIds.includes(piece.pieceId)
|
||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.03)`
|
||
: undefined,
|
||
transition: hintDemo?.pieceIds.includes(
|
||
piece?.pieceId ?? '',
|
||
)
|
||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||
: undefined,
|
||
}}
|
||
>
|
||
<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={`relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 border-white/22 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-transparent bg-transparent text-white'
|
||
: '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]'
|
||
}`}
|
||
style={{
|
||
zIndex: resolveDraggedPieceLayer(
|
||
piece?.pieceId,
|
||
draggingPieceId,
|
||
isMerged,
|
||
),
|
||
}}
|
||
onPointerDown={(event) => {
|
||
if (!piece || isMerged) {
|
||
return;
|
||
}
|
||
handlePiecePointerDown(piece.pieceId, event);
|
||
}}
|
||
onPointerMove={(event) => {
|
||
if (!piece || isMerged) {
|
||
return;
|
||
}
|
||
handlePiecePointerMove(piece.pieceId, event);
|
||
}}
|
||
onPointerUp={(event) => {
|
||
if (piece && !isMerged) {
|
||
handlePiecePointerUp(piece.pieceId, event);
|
||
}
|
||
}}
|
||
onPointerCancel={() => {
|
||
resetDragInteraction();
|
||
}}
|
||
onLostPointerCapture={() => {
|
||
resetDragInteraction();
|
||
}}
|
||
>
|
||
{piece ? (
|
||
<div className="relative h-full w-full overflow-hidden">
|
||
{isMerged ? null : 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>
|
||
) : (
|
||
''
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{mergedGroups.map((group) => (
|
||
<div
|
||
key={group.groupId}
|
||
ref={(node) => {
|
||
if (node) {
|
||
groupElementRefMap.current.set(group.groupId, node);
|
||
return;
|
||
}
|
||
groupElementRefMap.current.delete(group.groupId);
|
||
}}
|
||
data-merged-group-id={group.groupId}
|
||
className="pointer-events-none absolute z-10"
|
||
style={{
|
||
zIndex: resolveDraggedMergedGroupLayer(
|
||
group.groupId,
|
||
draggingGroupId,
|
||
),
|
||
transform: hintDemo?.pieceIds.some((pieceId) =>
|
||
group.pieceIds.includes(pieceId),
|
||
)
|
||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
|
||
: undefined,
|
||
transition: hintDemo?.pieceIds.some((pieceId) =>
|
||
group.pieceIds.includes(pieceId),
|
||
)
|
||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||
: undefined,
|
||
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 border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||
group,
|
||
piece,
|
||
)}`}
|
||
data-merged-piece-outline="true"
|
||
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>
|
||
</div>
|
||
))}
|
||
{isOriginalOverlayVisible && resolvedCoverImage ? (
|
||
<div
|
||
data-testid="puzzle-original-overlay"
|
||
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
|
||
>
|
||
<div
|
||
className="absolute inset-0 opacity-70"
|
||
style={{
|
||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||
backgroundSize: '100% 100%',
|
||
backgroundPosition: 'center',
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{mergeFlash ? (
|
||
<div
|
||
key={mergeFlash.key}
|
||
data-testid="puzzle-merge-flash"
|
||
className="pointer-events-none absolute z-50"
|
||
style={{
|
||
left: `${mergeFlash.leftPercent}%`,
|
||
top: `${mergeFlash.topPercent}%`,
|
||
}}
|
||
>
|
||
<div className="puzzle-merge-center-flash" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
|
||
{error ? (
|
||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||
已选择
|
||
</div>
|
||
) : null}
|
||
{canShowNextAction ? (
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
if (hasSimilarWorkChoices) {
|
||
setDismissedClearKey(null);
|
||
setIsClearResultReady(true);
|
||
return;
|
||
}
|
||
onAdvanceNextLevel({
|
||
profileId: run.nextLevelProfileId ?? undefined,
|
||
levelId: run.nextLevelId ?? null,
|
||
});
|
||
}}
|
||
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
|
||
>
|
||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||
<ArrowRight className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
|
||
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={isInteractionLocked}
|
||
onClick={() => openPropDialog('hint', '使用提示')}
|
||
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
||
>
|
||
<Lightbulb className="h-6 w-6 text-amber-100" />
|
||
提示
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={runtimeStatus !== 'playing'}
|
||
aria-pressed={isOriginalOverlayVisible}
|
||
onClick={() => {
|
||
if (isOriginalOverlayVisible) {
|
||
setIsOriginalOverlayVisible(false);
|
||
return;
|
||
}
|
||
openPropDialog('reference', '查看原图');
|
||
}}
|
||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition hover:bg-white/10 disabled:opacity-45 ${
|
||
isOriginalOverlayVisible
|
||
? 'bg-sky-200 text-slate-950'
|
||
: 'text-white/86'
|
||
}`}
|
||
>
|
||
<Eye className="h-6 w-6" />
|
||
原图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isInteractionLocked}
|
||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
|
||
>
|
||
<Snowflake className="h-6 w-6 text-cyan-100" />
|
||
冻结
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isClearFlashVisible ? (
|
||
<div
|
||
data-testid="puzzle-clear-flash"
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
||
>
|
||
<div className="puzzle-clear-flash-overlay absolute inset-0" />
|
||
<div className="puzzle-clear-flash-beam" />
|
||
</div>
|
||
) : null}
|
||
|
||
{freezeRemainingMs > 0 || isFreezeEffectVisible ? (
|
||
<div
|
||
data-testid="puzzle-freeze-effect"
|
||
className="pointer-events-none absolute inset-0 z-30"
|
||
>
|
||
<div className="puzzle-freeze-effect-layer absolute inset-0 backdrop-saturate-150" />
|
||
<div className="absolute left-1/2 top-28 -translate-x-1/2 rounded-full border border-cyan-100/30 bg-cyan-950/50 px-3 py-1.5 font-mono text-xs font-black text-cyan-50 backdrop-blur">
|
||
{formatTimerMs(freezeRemainingMs)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{propDialog ? (
|
||
<div
|
||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||
onClick={() => {
|
||
if (!isPropConfirming) {
|
||
setPropDialog(null);
|
||
}
|
||
}}
|
||
>
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-prop-confirm-title"
|
||
className="pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
|
||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
||
<Sparkles className="h-4 w-4" />
|
||
</span>
|
||
<h2
|
||
id="puzzle-prop-confirm-title"
|
||
className="text-sm font-black text-white"
|
||
>
|
||
{propDialog.title}
|
||
</h2>
|
||
</header>
|
||
<div className="px-5 py-4 text-sm text-white/72">
|
||
消耗 1 陶泥币
|
||
{propConfirmError ? (
|
||
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
|
||
{propConfirmError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPropDialog(null)}
|
||
disabled={isPropConfirming}
|
||
className="rounded-full border border-white/12 bg-black/20 px-4 py-2 text-xs font-bold text-zinc-200 transition hover:text-white"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isPropConfirming}
|
||
onClick={() => {
|
||
void handleConfirmProp();
|
||
}}
|
||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-60"
|
||
>
|
||
{isPropConfirming ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : null}
|
||
确定
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{isSettingsPanelOpen ? (
|
||
<div
|
||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
>
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-settings-title"
|
||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||
<div className="min-w-0 pr-10">
|
||
<h2
|
||
id="puzzle-settings-title"
|
||
className="text-sm font-semibold text-white"
|
||
>
|
||
拼图设置
|
||
</h2>
|
||
<div className="mt-1 text-[11px] text-zinc-500">
|
||
调整音乐音量,查看本局进度,或返回上一页。
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭拼图设置"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||
>
|
||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||
</button>
|
||
</header>
|
||
|
||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
|
||
音频
|
||
</div>
|
||
<div className="mt-2 text-sm font-semibold text-white">
|
||
音乐音量
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
|
||
{Math.round(musicVolume * 100)}%
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
aria-label="拼图音乐音量"
|
||
value={Math.round(musicVolume * 100)}
|
||
onChange={(event) =>
|
||
onMusicVolumeChange(
|
||
Number(event.currentTarget.value) / 100,
|
||
)
|
||
}
|
||
className="h-2 w-full cursor-pointer accent-sky-400"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||
本局进度
|
||
</div>
|
||
<div className="mt-3 space-y-2 text-sm text-white/82">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-white/56">关卡</span>
|
||
<span className="font-semibold text-white">
|
||
{levelLabel}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-white/56">已完成关卡</span>
|
||
<span className="font-semibold text-white">
|
||
{run.clearedLevelCount}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-white/56">当前状态</span>
|
||
<span className="font-semibold text-white">
|
||
{statusLabel}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-white/56">当前用时</span>
|
||
<span className="font-mono font-semibold text-white">
|
||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
|
||
>
|
||
继续拼图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsSettingsPanelOpen(false);
|
||
onBack();
|
||
}}
|
||
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
|
||
>
|
||
返回上一页
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{runtimeStatus === 'failed' ? (
|
||
<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-failed-title"
|
||
className="flex w-full max-w-[24rem] 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="border-b border-white/10 px-5 py-4">
|
||
<h2
|
||
id="puzzle-failed-title"
|
||
className="text-lg font-black text-white"
|
||
>
|
||
关卡失败
|
||
</h2>
|
||
<div className="mt-1 text-xs text-white/62">
|
||
{currentLevel.levelName}
|
||
</div>
|
||
</header>
|
||
<footer className="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
void onRestartLevel?.();
|
||
}}
|
||
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
|
||
>
|
||
重新开始
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
||
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
|
||
>
|
||
继续1分钟
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{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);
|
||
}}
|
||
>
|
||
<PixelIcon src={CHROME_ICONS.close} 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.length > 0 ? (
|
||
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 className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
||
{isBusy
|
||
? '正在同步真实排行榜…'
|
||
: '暂无真实排行榜成绩'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{hasSimilarWorkChoices ? (
|
||
<div className="mt-4">
|
||
<div className="grid gap-2 sm:grid-cols-3">
|
||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||
<PuzzleNextWorkCard
|
||
key={item.profileId}
|
||
item={item}
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
onAdvanceNextLevel({ profileId: item.profileId });
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{canAdvanceDefaultNextLevel ? (
|
||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
onAdvanceNextLevel({
|
||
profileId: run.nextLevelProfileId ?? undefined,
|
||
levelId: run.nextLevelId ?? null,
|
||
});
|
||
}}
|
||
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>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PuzzleNextWorkCard({
|
||
item,
|
||
disabled,
|
||
onClick,
|
||
}: {
|
||
item: PuzzleRecommendedNextWork;
|
||
disabled: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
|
||
>
|
||
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
|
||
{item.coverImageSrc ? (
|
||
<ResolvedAssetImage
|
||
src={item.coverImageSrc}
|
||
alt={item.levelName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
|
||
)}
|
||
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
|
||
</div>
|
||
<div className="min-w-0 px-3 py-2.5">
|
||
<div className="truncate text-sm font-black text-white">
|
||
{item.levelName}
|
||
</div>
|
||
<div className="mt-1 truncate text-xs font-semibold text-white/58">
|
||
{item.authorDisplayName}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{item.themeTags.slice(0, 2).map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default PuzzleRuntimeShell;
|