1
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -6,6 +16,8 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
PuzzleRuntimePropKind,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -23,6 +35,11 @@ type PuzzleRuntimeShellProps = {
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
|
||||
onAdvanceNextLevel: () => void;
|
||||
onPauseChange?: (paused: boolean) => void | Promise<void>;
|
||||
onUseProp?: (
|
||||
propKind: PuzzleRuntimePropKind,
|
||||
) => Promise<PuzzleRunSnapshot | null | void>;
|
||||
onTimeExpired?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type PuzzleBoardPieceViewModel = {
|
||||
@@ -103,6 +120,13 @@ function resolveMergedPieceOutlineClass(
|
||||
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),
|
||||
);
|
||||
@@ -115,15 +139,63 @@ function resolveMergedPieceOutlineClass(
|
||||
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',
|
||||
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
|
||||
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
|
||||
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
|
||||
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
bottomRightRadius,
|
||||
bottomLeftRadius,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
@@ -180,9 +252,82 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
.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 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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
@@ -196,10 +341,34 @@ export function PuzzleRuntimeShell({
|
||||
onSwapPieces,
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
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;
|
||||
@@ -229,9 +398,20 @@ export function PuzzleRuntimeShell({
|
||||
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;
|
||||
@@ -262,6 +442,23 @@ export function PuzzleRuntimeShell({
|
||||
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(
|
||||
@@ -284,6 +481,54 @@ export function PuzzleRuntimeShell({
|
||||
[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 =
|
||||
@@ -447,6 +692,76 @@ export function PuzzleRuntimeShell({
|
||||
[],
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -498,7 +813,7 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
const handlePieceClick = (pieceId: string) => {
|
||||
if (isBusy) {
|
||||
if (isInteractionLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -585,7 +900,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (isBusy) {
|
||||
if (isInteractionLocked) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -631,7 +946,18 @@ export function PuzzleRuntimeShell({
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
|
||||
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 nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
@@ -643,8 +969,85 @@ export function PuzzleRuntimeShell({
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
|
||||
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
|
||||
if (runtimeStatus !== 'playing') {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -680,6 +1083,16 @@ export function PuzzleRuntimeShell({
|
||||
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||
{levelLabel}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
|
||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||
? 'bg-red-500/22 text-red-100'
|
||||
: 'bg-white/10 text-white/86'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatTimerMs(displayRemainingMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -697,13 +1110,14 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
|
||||
<div className="absolute inset-0 flex items-center justify-center p-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(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"
|
||||
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.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:rounded-[1.45rem]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{buildBoardCells(board).map((cell) => {
|
||||
@@ -726,13 +1140,22 @@ export function PuzzleRuntimeShell({
|
||||
pieceCellElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-cell-id={piece?.pieceId ?? undefined}
|
||||
className="relative p-1"
|
||||
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
|
||||
@@ -747,13 +1170,13 @@ export function PuzzleRuntimeShell({
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
|
||||
className={`relative flex h-full items-center justify-center 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'
|
||||
: 'border-white/18 bg-white/12 text-white'
|
||||
: 'bg-white/12 text-white'
|
||||
: 'border-white/8 bg-black/18 text-white/20'
|
||||
} ${
|
||||
isMerged
|
||||
@@ -792,7 +1215,7 @@ export function PuzzleRuntimeShell({
|
||||
}}
|
||||
>
|
||||
{piece ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{isMerged ? null : resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -833,12 +1256,22 @@ export function PuzzleRuntimeShell({
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
data-merged-group-id={group.groupId}
|
||||
className="pointer-events-none absolute z-10 p-1"
|
||||
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}%`,
|
||||
@@ -855,7 +1288,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
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,
|
||||
)}`}
|
||||
@@ -906,17 +1339,86 @@ export function PuzzleRuntimeShell({
|
||||
</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 items-end justify-end gap-3 px-4 py-4">
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('hint', '使用提示')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4 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-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
|
||||
isOriginalOverlayVisible
|
||||
? 'bg-sky-200 text-slate-950'
|
||||
: 'text-white/86'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
原图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Snowflake className="h-4 w-4 text-cyan-100" />
|
||||
冻结
|
||||
</button>
|
||||
</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}
|
||||
{selectedPieceId && currentLevel.status !== 'cleared' ? (
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
@@ -935,9 +1437,11 @@ export function PuzzleRuntimeShell({
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
|
||||
{isBusy
|
||||
? '同步中...'
|
||||
: currentLevel.status === 'cleared'
|
||||
: runtimeStatus === 'cleared'
|
||||
? '等待下一关候选'
|
||||
: '完成整张图即可通关'}
|
||||
: runtimeStatus === 'failed'
|
||||
? '本关失败'
|
||||
: '完成整张图即可通关'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -954,6 +1458,81 @@ export function PuzzleRuntimeShell({
|
||||
</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"
|
||||
@@ -1079,6 +1658,38 @@ export function PuzzleRuntimeShell({
|
||||
</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="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
|
||||
>
|
||||
返回
|
||||
</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
|
||||
@@ -1163,7 +1774,9 @@ export function PuzzleRuntimeShell({
|
||||
))
|
||||
) : (
|
||||
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
{isBusy
|
||||
? '正在同步真实排行榜…'
|
||||
: '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user