2373 lines
86 KiB
TypeScript
2373 lines
86 KiB
TypeScript
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Clock,
|
||
Eye,
|
||
Lightbulb,
|
||
Loader2,
|
||
Snowflake,
|
||
Sparkles,
|
||
Trophy,
|
||
} from 'lucide-react';
|
||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||
|
||
import type {
|
||
DragPuzzlePieceRequest,
|
||
PuzzleBoardSnapshot,
|
||
PuzzleCellPosition,
|
||
PuzzleMergedGroupState,
|
||
PuzzleRecommendedNextWork,
|
||
PuzzleRunSnapshot,
|
||
PuzzleRuntimeLevelSnapshot,
|
||
PuzzleRuntimePropKind,
|
||
SwapPuzzlePiecesRequest,
|
||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||
import { isDebugMode } from '../../config/debugMode';
|
||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||
import {
|
||
createRuntimeDragInputController,
|
||
createRuntimeInputPointFromClient,
|
||
createRuntimeInputPointFromNormalized,
|
||
readRuntimeInputElementBounds,
|
||
resolveRuntimeInputGridCell,
|
||
type RuntimeDragInputSession,
|
||
type RuntimeInputPoint,
|
||
} from '../../services/input-devices';
|
||
import { useMocapInput } from '../../services/useMocapInput';
|
||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { PixelIcon } from '../PixelIcon';
|
||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||
import {
|
||
buildMergedGroupClipPath,
|
||
buildMergedGroupOutlinePath,
|
||
resolveDraggedMergedGroupLayer,
|
||
resolveDraggedPieceCellLayer,
|
||
resolveDraggedPieceLayer,
|
||
sanitizeSvgId,
|
||
} from './puzzleRuntimeShape';
|
||
|
||
type PuzzleRuntimeShellProps = {
|
||
run: PuzzleRunSnapshot | null;
|
||
isBusy?: boolean;
|
||
error?: string | null;
|
||
hideBackButton?: boolean;
|
||
hideExitControls?: boolean;
|
||
embedded?: boolean;
|
||
onBack: () => void;
|
||
onRemodelWork?: (profileId: string) => void | Promise<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 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 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;
|
||
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
|
||
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
|
||
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
|
||
const PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60;
|
||
|
||
const shownExitRemodelPromptProfileIds = new Set<string>();
|
||
|
||
function buildExitRemodelPromptStorageKey(profileId: string) {
|
||
return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent(
|
||
profileId,
|
||
)}`;
|
||
}
|
||
|
||
function hasSeenExitRemodelPrompt(profileId: string) {
|
||
const normalizedProfileId = profileId.trim();
|
||
if (!normalizedProfileId) {
|
||
return true;
|
||
}
|
||
if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) {
|
||
if (typeof window === 'undefined') {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const seen =
|
||
window.localStorage.getItem(
|
||
buildExitRemodelPromptStorageKey(normalizedProfileId),
|
||
) === '1';
|
||
if (seen) {
|
||
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
|
||
}
|
||
return seen;
|
||
} catch {
|
||
return shownExitRemodelPromptProfileIds.has(normalizedProfileId);
|
||
}
|
||
}
|
||
|
||
function markExitRemodelPromptSeen(profileId: string) {
|
||
const normalizedProfileId = profileId.trim();
|
||
if (!normalizedProfileId) {
|
||
return;
|
||
}
|
||
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
window.localStorage.setItem(
|
||
buildExitRemodelPromptStorageKey(normalizedProfileId),
|
||
'1',
|
||
);
|
||
} catch {
|
||
// 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。
|
||
}
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
type PuzzleMocapCursorState = {
|
||
x: number;
|
||
y: number;
|
||
state: string;
|
||
};
|
||
|
||
type PuzzleMocapCursorSample = PuzzleMocapCursorState & {
|
||
receivedAtMs: number;
|
||
};
|
||
|
||
type PuzzleRuntimeDragTargetState = {
|
||
pieceId: string;
|
||
groupId: string | null;
|
||
};
|
||
|
||
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,
|
||
hideBackButton = false,
|
||
hideExitControls = false,
|
||
embedded = false,
|
||
onBack,
|
||
onRemodelWork,
|
||
onSwapPieces,
|
||
onDragPiece,
|
||
onAdvanceNextLevel,
|
||
onRestartLevel,
|
||
onPauseChange,
|
||
onUseProp,
|
||
onTimeExpired,
|
||
}: PuzzleRuntimeShellProps) {
|
||
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
|
||
const authUi = useAuthUi();
|
||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||
const selectedPieceIdRef = useRef<string | null>(null);
|
||
const selectedPieceBeforeInputRef = useRef<string | null>(null);
|
||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
||
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 [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false);
|
||
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;
|
||
inputId: string;
|
||
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 [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
||
null,
|
||
);
|
||
const mocapCursorPreviousSampleRef = useRef<PuzzleMocapCursorSample | null>(
|
||
null,
|
||
);
|
||
const mocapCursorTargetSampleRef = useRef<PuzzleMocapCursorSample | null>(null);
|
||
const mocapCursorIntervalRef = useRef<number | null>(null);
|
||
const updateMocapCursorSampleRef = useRef<(
|
||
nextSample: PuzzleMocapCursorSample,
|
||
) => void>(() => {});
|
||
const runtimeDragInputControllerRef = useRef(
|
||
createRuntimeDragInputController<string>(),
|
||
);
|
||
const draggingTargetRef = useRef<PuzzleRuntimeDragTargetState | 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 currentLevelRef = useRef(currentLevel);
|
||
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 isInteractionLocked =
|
||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||
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 mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
|
||
const primaryMocapHandState = primaryMocapHand?.state;
|
||
const primaryMocapHandX = primaryMocapHand?.x;
|
||
const primaryMocapHandY = primaryMocapHand?.y;
|
||
const mocapActionsLabel =
|
||
mocapInput.latestCommand?.actions.length
|
||
? mocapInput.latestCommand.actions.join(', ')
|
||
: '无';
|
||
const mocapHandLabel =
|
||
primaryMocapHandState &&
|
||
typeof primaryMocapHandX === 'number' &&
|
||
typeof primaryMocapHandY === 'number'
|
||
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
|
||
: '无';
|
||
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
||
? mocapInput.latestCommand.parseWarnings.join(';')
|
||
: '无';
|
||
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
|
||
const shouldShowMocapDebugPanel = isDebugMode();
|
||
|
||
useEffect(() => {
|
||
currentLevelRef.current = currentLevel;
|
||
}, [currentLevel]);
|
||
|
||
const commitSelectedPieceId = (pieceId: string | null) => {
|
||
selectedPieceIdRef.current = pieceId;
|
||
setSelectedPieceId(pieceId);
|
||
};
|
||
|
||
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 resetDragInteractionState = () => {
|
||
cancelDragVisualFrame();
|
||
dragOffsetRef.current = null;
|
||
dragSessionRef.current = null;
|
||
draggingTargetRef.current = null;
|
||
resetDragVisualTarget();
|
||
};
|
||
|
||
const resetDragInteraction = () => {
|
||
runtimeDragInputControllerRef.current.cancel();
|
||
};
|
||
|
||
const flushDragVisual = () => {
|
||
dragVisualFrameRef.current = null;
|
||
const dragSession = dragSessionRef.current;
|
||
if (!dragSession || !dragSession.dragging) {
|
||
resetDragVisualTarget();
|
||
return;
|
||
}
|
||
|
||
const piece = pieceById.get(dragSession.pieceId) ?? null;
|
||
const groupId =
|
||
draggingTargetRef.current?.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 ||
|
||
isExitRemodelPromptOpen ||
|
||
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 === 'cleared') {
|
||
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]);
|
||
|
||
const handlePieceTap = (
|
||
pieceId: string,
|
||
selectedPieceIdBeforeInput: string | null,
|
||
) => {
|
||
if (isInteractionLocked) {
|
||
return;
|
||
}
|
||
|
||
if (!selectedPieceIdBeforeInput) {
|
||
commitSelectedPieceId(pieceId);
|
||
return;
|
||
}
|
||
|
||
if (selectedPieceIdBeforeInput === pieceId) {
|
||
commitSelectedPieceId(null);
|
||
return;
|
||
}
|
||
|
||
onSwapPieces({
|
||
firstPieceId: selectedPieceIdBeforeInput,
|
||
secondPieceId: pieceId,
|
||
});
|
||
commitSelectedPieceId(null);
|
||
};
|
||
|
||
const resolvePuzzleRuntimeDragTarget = (
|
||
pieceId: string,
|
||
): PuzzleRuntimeDragTargetState | null => {
|
||
const sourcePiece = pieceById.get(pieceId) ?? null;
|
||
if (!sourcePiece) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
pieceId: sourcePiece.pieceId,
|
||
groupId: sourcePiece.mergedGroupId ?? null,
|
||
};
|
||
};
|
||
|
||
const commitPuzzleRuntimeDrag = (
|
||
target: PuzzleRuntimeDragTargetState | null,
|
||
point: RuntimeInputPoint,
|
||
) => {
|
||
const dragSession = dragSessionRef.current;
|
||
if (!target || !dragSession) {
|
||
return;
|
||
}
|
||
|
||
const targetCell = board
|
||
? resolveRuntimeInputGridCell(point, board)
|
||
: null;
|
||
if (!targetCell) {
|
||
return;
|
||
}
|
||
|
||
onDragPiece({
|
||
pieceId: target.pieceId,
|
||
targetRow: targetCell.row,
|
||
targetCol: targetCell.col,
|
||
});
|
||
};
|
||
|
||
const resolveBoardInputPointFromClient = (
|
||
clientX: number,
|
||
clientY: number,
|
||
) =>
|
||
createRuntimeInputPointFromClient(
|
||
clientX,
|
||
clientY,
|
||
readRuntimeInputElementBounds(boardRef.current),
|
||
);
|
||
|
||
const resolveBoardInputPointFromNormalized = (
|
||
normalizedX: number,
|
||
normalizedY: number,
|
||
) =>
|
||
createRuntimeInputPointFromNormalized(
|
||
normalizedX,
|
||
normalizedY,
|
||
readRuntimeInputElementBounds(boardRef.current),
|
||
);
|
||
|
||
const resetMocapCursorInterpolation = () => {
|
||
mocapCursorPreviousSampleRef.current = null;
|
||
mocapCursorTargetSampleRef.current = null;
|
||
setMocapCursor(null);
|
||
};
|
||
|
||
updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => {
|
||
const previousTarget = mocapCursorTargetSampleRef.current;
|
||
mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample;
|
||
mocapCursorTargetSampleRef.current = nextSample;
|
||
if (!previousTarget) {
|
||
setMocapCursor(nextSample);
|
||
}
|
||
};
|
||
|
||
const syncRuntimeDragFromController = (
|
||
session: RuntimeDragInputSession<string> | null,
|
||
) => {
|
||
if (!session) {
|
||
return;
|
||
}
|
||
|
||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||
dragSessionRef.current = {
|
||
pieceId: session.targetId,
|
||
inputId: session.inputId,
|
||
dragging: session.dragging,
|
||
startX: session.startPoint.clientX,
|
||
startY: session.startPoint.clientY,
|
||
currentX: session.currentPoint.clientX,
|
||
currentY: session.currentPoint.clientY,
|
||
};
|
||
|
||
if (session.dragging) {
|
||
flushDragVisual();
|
||
scheduleDragVisual();
|
||
}
|
||
};
|
||
|
||
runtimeDragInputControllerRef.current.setOptions({
|
||
dragThresholdPx: 8,
|
||
onPress: (session) => {
|
||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||
syncRuntimeDragFromController(session);
|
||
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
||
commitSelectedPieceId(session.targetId);
|
||
triggerPuzzlePiecePressHapticFeedback();
|
||
},
|
||
onDragStart: (session) => {
|
||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||
syncRuntimeDragFromController(session);
|
||
},
|
||
onDragMove: (session) => {
|
||
syncRuntimeDragFromController(session);
|
||
},
|
||
onDrop: (session) => {
|
||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||
syncRuntimeDragFromController(session);
|
||
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
|
||
commitSelectedPieceId(null);
|
||
selectedPieceBeforeInputRef.current = null;
|
||
resetDragInteractionState();
|
||
},
|
||
onTap: (session) => {
|
||
handlePieceTap(session.targetId, selectedPieceBeforeInputRef.current);
|
||
selectedPieceBeforeInputRef.current = null;
|
||
resetDragInteractionState();
|
||
},
|
||
onCancel: () => {
|
||
commitSelectedPieceId(selectedPieceBeforeInputRef.current);
|
||
selectedPieceBeforeInputRef.current = null;
|
||
resetDragInteractionState();
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
const activeSession = runtimeDragInputControllerRef.current.getSession();
|
||
if (!board || runtimeStatus !== 'playing' || isInteractionLocked) {
|
||
runtimeDragInputControllerRef.current.cancel();
|
||
resetMocapCursorInterpolation();
|
||
return;
|
||
}
|
||
if (
|
||
!primaryMocapHandState ||
|
||
typeof primaryMocapHandX !== 'number' ||
|
||
typeof primaryMocapHandY !== 'number'
|
||
) {
|
||
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
|
||
resetMocapCursorInterpolation();
|
||
return;
|
||
}
|
||
|
||
const nextSample = {
|
||
x: primaryMocapHandX,
|
||
y: primaryMocapHandY,
|
||
state: primaryMocapHandState,
|
||
receivedAtMs: performance.now(),
|
||
};
|
||
updateMocapCursorSampleRef.current(nextSample);
|
||
const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y);
|
||
if (primaryMocapHandState === 'grab') {
|
||
if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||
const sourceCell = resolveRuntimeInputGridCell(handPoint, board);
|
||
const sourcePiece = sourceCell
|
||
? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null
|
||
: null;
|
||
if (!sourcePiece) {
|
||
runtimeDragInputControllerRef.current.cancel(
|
||
PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||
);
|
||
return;
|
||
}
|
||
|
||
runtimeDragInputControllerRef.current.press({
|
||
targetId: sourcePiece.pieceId,
|
||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||
deviceKind: 'mocap',
|
||
point: handPoint,
|
||
});
|
||
return;
|
||
}
|
||
|
||
runtimeDragInputControllerRef.current.move({
|
||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||
point: handPoint,
|
||
forceDragging: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||
runtimeDragInputControllerRef.current.release({
|
||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||
point: handPoint,
|
||
forceDrop: activeSession.deviceKind === 'mocap',
|
||
});
|
||
}
|
||
}, [
|
||
board,
|
||
isInteractionLocked,
|
||
pieceByCell,
|
||
primaryMocapHandState,
|
||
primaryMocapHandX,
|
||
primaryMocapHandY,
|
||
runtimeStatus,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!board || runtimeStatus !== 'playing') {
|
||
if (mocapCursorIntervalRef.current !== null) {
|
||
window.clearInterval(mocapCursorIntervalRef.current);
|
||
mocapCursorIntervalRef.current = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const tickMocapCursor = () => {
|
||
const targetSample = mocapCursorTargetSampleRef.current;
|
||
if (!targetSample) {
|
||
return;
|
||
}
|
||
const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample;
|
||
const durationMs = Math.max(
|
||
PUZZLE_MOCAP_CURSOR_FRAME_MS,
|
||
targetSample.receivedAtMs - previousSample.receivedAtMs,
|
||
);
|
||
const progress = targetSample.receivedAtMs === previousSample.receivedAtMs
|
||
? 1
|
||
: Math.min(
|
||
1,
|
||
Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs),
|
||
);
|
||
const nextCursor = {
|
||
x: previousSample.x + (targetSample.x - previousSample.x) * progress,
|
||
y: previousSample.y + (targetSample.y - previousSample.y) * progress,
|
||
state: targetSample.state,
|
||
};
|
||
const nextPoint = resolveBoardInputPointFromNormalized(
|
||
nextCursor.x,
|
||
nextCursor.y,
|
||
);
|
||
setMocapCursor(nextCursor);
|
||
const activeSession = runtimeDragInputControllerRef.current.getSession();
|
||
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
|
||
runtimeDragInputControllerRef.current.move({
|
||
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
|
||
point: nextPoint,
|
||
forceDragging: true,
|
||
});
|
||
}
|
||
};
|
||
|
||
tickMocapCursor();
|
||
mocapCursorIntervalRef.current = window.setInterval(
|
||
tickMocapCursor,
|
||
PUZZLE_MOCAP_CURSOR_FRAME_MS,
|
||
);
|
||
return () => {
|
||
if (mocapCursorIntervalRef.current !== null) {
|
||
window.clearInterval(mocapCursorIntervalRef.current);
|
||
mocapCursorIntervalRef.current = null;
|
||
}
|
||
};
|
||
}, [board, runtimeStatus]);
|
||
|
||
if (!run || !currentLevel || !board) {
|
||
return (
|
||
<div
|
||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||
>
|
||
<div className="puzzle-runtime-pill flex items-center gap-2 rounded-full px-5 py-3 text-sm">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
正在进入拼图关卡
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handlePiecePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||
runtimeDragInputControllerRef.current.release({
|
||
inputId: `pointer:${event.pointerId}`,
|
||
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||
});
|
||
};
|
||
|
||
const handlePiecePointerDown = (
|
||
pieceId: string,
|
||
event: React.PointerEvent<HTMLDivElement>,
|
||
) => {
|
||
if (isInteractionLocked) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
resetDragInteraction();
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
runtimeDragInputControllerRef.current.press({
|
||
targetId: pieceId,
|
||
inputId: `pointer:${event.pointerId}`,
|
||
deviceKind: 'pointer',
|
||
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||
});
|
||
};
|
||
|
||
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
runtimeDragInputControllerRef.current.move({
|
||
inputId: `pointer:${event.pointerId}`,
|
||
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||
});
|
||
};
|
||
|
||
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 exitPromptProfileId = currentLevel.profileId.trim();
|
||
const shouldHideBackButton = hideBackButton || hideExitControls;
|
||
const leaderboardEntries =
|
||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||
? currentLevel.leaderboardEntries
|
||
: (run.leaderboardEntries ?? []);
|
||
const isClearResultOpen =
|
||
currentLevel.status === 'cleared' &&
|
||
dismissedClearKey !== clearResultKey &&
|
||
isClearResultReady;
|
||
const handleBackRequest = () => {
|
||
if (hideExitControls) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
onRemodelWork &&
|
||
exitPromptProfileId &&
|
||
!hasSeenExitRemodelPrompt(exitPromptProfileId)
|
||
) {
|
||
markExitRemodelPromptSeen(exitPromptProfileId);
|
||
setIsExitRemodelPromptOpen(true);
|
||
return;
|
||
}
|
||
|
||
onBack();
|
||
};
|
||
|
||
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);
|
||
let useResult: PuzzleRunSnapshot | null | void = null;
|
||
try {
|
||
await pauseChangePromiseRef.current;
|
||
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') {
|
||
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
||
// 这种边界同步只关闭确认窗,不再播放冻结成功反馈。
|
||
const resultLevel =
|
||
useResult && typeof useResult === 'object'
|
||
? useResult.currentLevel
|
||
: currentLevelRef.current;
|
||
if (resultLevel?.status === 'playing') {
|
||
setIsFreezeEffectVisible(true);
|
||
window.setTimeout(() => {
|
||
setIsFreezeEffectVisible(false);
|
||
}, 900);
|
||
}
|
||
}
|
||
if (propKind === 'extendTime') {
|
||
setTimerNowMs(Date.now());
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||
>
|
||
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
|
||
{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="puzzle-runtime-stage__grid" />
|
||
|
||
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
|
||
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleBackRequest}
|
||
aria-label="返回上一页"
|
||
disabled={shouldHideBackButton}
|
||
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
|
||
shouldHideBackButton
|
||
? 'invisible pointer-events-none'
|
||
: 'inline-flex'
|
||
}`}
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
|
||
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
|
||
{levelLabel}
|
||
</span>
|
||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||
{currentLevel.levelName}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
|
||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||
? 'puzzle-runtime-timer--urgent'
|
||
: 'puzzle-runtime-timer'
|
||
}`}
|
||
>
|
||
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
|
||
{formatTimerMs(displayRemainingMs)}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsSettingsPanelOpen(true)}
|
||
aria-label="打开拼图设置"
|
||
title="打开拼图设置"
|
||
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||
>
|
||
<PixelIcon
|
||
src={CHROME_ICONS.settings}
|
||
className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)] sm:h-[1.4rem] sm:w-[1.4rem]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-24 pb-32 sm:px-4 sm:py-4 sm:pt-24 sm:pb-28">
|
||
<div
|
||
ref={boardRef}
|
||
data-testid="puzzle-board"
|
||
className="puzzle-runtime-board relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_14rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_13rem))] 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={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 text-sm font-black transition ${
|
||
occupied
|
||
? isSelected
|
||
? 'puzzle-runtime-piece--selected'
|
||
: isMerged
|
||
? 'puzzle-runtime-piece--merged'
|
||
: 'puzzle-runtime-piece--filled'
|
||
: 'puzzle-runtime-piece--empty'
|
||
} ${
|
||
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(event);
|
||
}}
|
||
onPointerUp={(event) => {
|
||
if (piece && !isMerged) {
|
||
handlePiecePointerUp(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>
|
||
) : (
|
||
''
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{mergedGroups.map((group) => {
|
||
const outlinePath = buildMergedGroupOutlinePath(group);
|
||
const clipPath = buildMergedGroupClipPath(group);
|
||
|
||
return (
|
||
<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}%`,
|
||
}}
|
||
>
|
||
{outlinePath ? (
|
||
<svg
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 z-20 h-full w-full overflow-visible"
|
||
preserveAspectRatio="none"
|
||
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
||
>
|
||
<defs>
|
||
<clipPath
|
||
clipPathUnits="objectBoundingBox"
|
||
id={`${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||
group.groupId,
|
||
)}`}
|
||
>
|
||
<path
|
||
clipRule="evenodd"
|
||
d={clipPath}
|
||
fillRule="evenodd"
|
||
/>
|
||
</clipPath>
|
||
</defs>
|
||
<path
|
||
d={outlinePath}
|
||
data-merged-group-outline="true"
|
||
fill="transparent"
|
||
fillRule="evenodd"
|
||
/>
|
||
<path
|
||
d={outlinePath}
|
||
data-merged-group-outline-stroke="true"
|
||
fill="none"
|
||
stroke="rgba(255, 255, 255, 0.22)"
|
||
strokeLinejoin="round"
|
||
strokeWidth="2"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
</svg>
|
||
) : null}
|
||
<div
|
||
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
|
||
style={{
|
||
WebkitClipPath: outlinePath
|
||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||
group.groupId,
|
||
)})`
|
||
: undefined,
|
||
clipPath: outlinePath
|
||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||
group.groupId,
|
||
)})`
|
||
: undefined,
|
||
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 shadow-[0_12px_30px_rgba(15,23,42,0.16)]"
|
||
data-merged-piece-outline="true"
|
||
style={{
|
||
gridColumn: piece.localCol + 1,
|
||
gridRow: piece.localRow + 1,
|
||
}}
|
||
onPointerDown={(event) => {
|
||
handlePiecePointerDown(piece.pieceId, event);
|
||
}}
|
||
onPointerMove={(event) => {
|
||
handlePiecePointerMove(event);
|
||
}}
|
||
onPointerUp={(event) => {
|
||
handlePiecePointerUp(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>
|
||
))}
|
||
</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}
|
||
{mocapCursor ? (
|
||
<div
|
||
data-testid="puzzle-mocap-cursor"
|
||
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||
mocapCursor.state === 'grab'
|
||
? 'border-amber-200 bg-amber-400/90 text-amber-950'
|
||
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
|
||
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
|
||
>
|
||
<span className="text-[10px] font-black leading-none">
|
||
{mocapCursor.state === 'grab' ? '抓' : '手'}
|
||
</span>
|
||
</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="puzzle-runtime-error-chip rounded-full px-3 py-1 text-xs">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
||
已选择
|
||
</div>
|
||
) : null}
|
||
{shouldShowMocapDebugPanel ? (
|
||
<section
|
||
data-testid="puzzle-mocap-debug"
|
||
className="w-[min(92vw,34rem)] overflow-hidden rounded-[0.9rem] border border-white/20 bg-slate-950/70 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-expanded={isMocapDebugExpanded}
|
||
aria-controls="puzzle-mocap-debug-content"
|
||
onClick={() => {
|
||
setIsMocapDebugExpanded((current) => !current);
|
||
}}
|
||
className="flex min-h-9 w-full items-center justify-between gap-3 px-3 py-2 text-left transition hover:bg-white/10"
|
||
>
|
||
<span className="min-w-0 truncate">
|
||
mocap: {mocapInput.status}
|
||
</span>
|
||
{isMocapDebugExpanded ? (
|
||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||
) : (
|
||
<ChevronUp className="h-3.5 w-3.5 shrink-0" />
|
||
)}
|
||
</button>
|
||
{isMocapDebugExpanded ? (
|
||
<div
|
||
id="puzzle-mocap-debug-content"
|
||
className="border-t border-white/10 px-3 pb-2 pt-2"
|
||
>
|
||
<div>动作: {mocapActionsLabel}</div>
|
||
<div>手势: {mocapHandLabel}</div>
|
||
<div>解析: {mocapParseWarningLabel}</div>
|
||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||
原始: {mocapRawPacketLabel}
|
||
</div>
|
||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
) : 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="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||
>
|
||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||
<ArrowRight className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
|
||
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={isInteractionLocked}
|
||
onClick={() => openPropDialog('hint', '使用提示')}
|
||
className="puzzle-runtime-tool-button 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 disabled:opacity-45"
|
||
>
|
||
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
|
||
提示
|
||
</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 disabled:opacity-45 ${
|
||
isOriginalOverlayVisible
|
||
? 'puzzle-runtime-tool-button--active'
|
||
: 'puzzle-runtime-tool-button'
|
||
}`}
|
||
>
|
||
<Eye className="h-6 w-6" />
|
||
原图
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isInteractionLocked}
|
||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||
className="puzzle-runtime-tool-button 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 disabled:opacity-45"
|
||
>
|
||
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
|
||
冻结
|
||
</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="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||
onClick={() => {
|
||
if (!isPropConfirming) {
|
||
setPropDialog(null);
|
||
}
|
||
}}
|
||
>
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-prop-confirm-title"
|
||
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
||
<span className="puzzle-runtime-primary-button inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||
<Sparkles className="h-4 w-4" />
|
||
</span>
|
||
<h2
|
||
id="puzzle-prop-confirm-title"
|
||
className="text-sm font-black"
|
||
>
|
||
{propDialog.title}
|
||
</h2>
|
||
</header>
|
||
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
|
||
消耗 1 光点
|
||
{propConfirmError ? (
|
||
<div className="puzzle-runtime-error-chip mt-3 rounded-[0.9rem] border px-3 py-2 text-xs leading-5">
|
||
{propConfirmError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-5 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPropDialog(null)}
|
||
disabled={isPropConfirming}
|
||
className="puzzle-runtime-secondary-button rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isPropConfirming}
|
||
onClick={() => {
|
||
void handleConfirmProp();
|
||
}}
|
||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-black transition hover:brightness-105 disabled:opacity-60"
|
||
>
|
||
{isPropConfirming ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : null}
|
||
确定
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{isSettingsPanelOpen ? (
|
||
<div
|
||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
>
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-settings-title"
|
||
className="puzzle-runtime-dialog 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.24)]"
|
||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="puzzle-runtime-dialog__line relative border-b 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"
|
||
>
|
||
拼图设置
|
||
</h2>
|
||
<div className="puzzle-runtime-dialog__soft mt-1 text-[11px]">
|
||
{hideExitControls
|
||
? '调整音乐音量,查看本局进度。'
|
||
: '调整音乐音量,查看本局进度,或返回上一页。'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭拼图设置"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 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="puzzle-runtime-settings-card rounded-2xl p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
||
音频
|
||
</div>
|
||
<div className="mt-2 text-sm font-semibold">
|
||
音乐音量
|
||
</div>
|
||
</div>
|
||
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
||
{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-[var(--puzzle-runtime-accent-text)]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
|
||
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
|
||
本局进度
|
||
</div>
|
||
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
||
<span className="font-semibold">
|
||
{levelLabel}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="puzzle-runtime-dialog__soft">已完成关卡</span>
|
||
<span className="font-semibold">
|
||
{run.clearedLevelCount}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="puzzle-runtime-dialog__soft">当前状态</span>
|
||
<span className="font-semibold">
|
||
{statusLabel}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||
<span className="font-mono font-semibold">
|
||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-4 py-3 sm:px-5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsSettingsPanelOpen(false)}
|
||
className="puzzle-runtime-secondary-button rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
|
||
>
|
||
继续拼图
|
||
</button>
|
||
{!hideExitControls ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsSettingsPanelOpen(false);
|
||
onBack();
|
||
}}
|
||
className={`puzzle-runtime-primary-button rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${
|
||
shouldHideBackButton ? 'hidden' : ''
|
||
}`}
|
||
>
|
||
返回上一页
|
||
</button>
|
||
) : null}
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{isExitRemodelPromptOpen && !hideExitControls ? (
|
||
<div
|
||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
|
||
>
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-exit-remodel-title"
|
||
className="puzzle-runtime-dialog relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[var(--puzzle-runtime-accent-text)] to-transparent" />
|
||
<header className="flex flex-col items-center px-6 pt-7 text-center">
|
||
<div className="puzzle-runtime-stat-card mb-4 grid h-14 w-14 place-items-center rounded-2xl">
|
||
<Sparkles className="puzzle-runtime-tool-button__warm h-7 w-7" />
|
||
</div>
|
||
<h2
|
||
id="puzzle-exit-remodel-title"
|
||
className="text-[1.75rem] font-black leading-[1.08]"
|
||
>
|
||
体验不佳?
|
||
<br />
|
||
试试改造功能!
|
||
</h2>
|
||
</header>
|
||
<footer className="grid gap-3 px-5 pb-5 pt-6">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
setIsExitRemodelPromptOpen(false);
|
||
void onRemodelWork?.(exitPromptProfileId);
|
||
}}
|
||
className="puzzle-runtime-primary-button min-h-[3.25rem] rounded-2xl px-5 text-sm font-black transition hover:brightness-105 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
作品改造
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsExitRemodelPromptOpen(false);
|
||
onBack();
|
||
}}
|
||
className="puzzle-runtime-secondary-button min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
|
||
>
|
||
保存并退出
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{runtimeStatus === 'failed' ? (
|
||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-failed-title"
|
||
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||
>
|
||
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
||
<h2
|
||
id="puzzle-failed-title"
|
||
className="text-lg font-black"
|
||
>
|
||
关卡失败
|
||
</h2>
|
||
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
||
{currentLevel.levelName}
|
||
</div>
|
||
</header>
|
||
<footer className="puzzle-runtime-dialog__line grid grid-cols-2 gap-3 border-t px-5 py-4">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
void onRestartLevel?.();
|
||
}}
|
||
className="puzzle-runtime-secondary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||
>
|
||
重新开始
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => openPropDialog('extendTime', '继续1分钟')}
|
||
className="puzzle-runtime-primary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
|
||
>
|
||
继续1分钟
|
||
</button>
|
||
</footer>
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
|
||
{isClearResultOpen ? (
|
||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||
<section
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="puzzle-clear-result-title"
|
||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||
>
|
||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||
<div className="min-w-0">
|
||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||
<Trophy className="h-4 w-4" />
|
||
</div>
|
||
<h2
|
||
id="puzzle-clear-result-title"
|
||
className="truncate text-lg font-black"
|
||
>
|
||
通关完成
|
||
</h2>
|
||
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||
{currentLevel.levelName}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭通关弹窗"
|
||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||
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="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||
<Clock className="h-4 w-4" />
|
||
</span>
|
||
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||
通关时间
|
||
</span>
|
||
</div>
|
||
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="mb-2 text-sm font-bold">
|
||
排行榜
|
||
</div>
|
||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||
<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 min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||
entry.isCurrentPlayer
|
||
? 'puzzle-runtime-leaderboard-row--active'
|
||
: 'puzzle-runtime-leaderboard-row border-t'
|
||
}`}
|
||
>
|
||
<span className="font-mono font-black">
|
||
#{entry.rank}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate font-semibold leading-tight">
|
||
{entry.nickname}
|
||
</span>
|
||
{entry.visibleTags?.length ? (
|
||
<span className="puzzle-runtime-leaderboard-tags">
|
||
{entry.visibleTags.map((tag) => (
|
||
<span
|
||
className="puzzle-runtime-leaderboard-tag"
|
||
key={tag}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="text-right font-mono text-xs font-bold">
|
||
{formatElapsedMs(entry.elapsedMs)}
|
||
</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||
{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="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||
<button
|
||
type="button"
|
||
disabled={isBusy}
|
||
onClick={() => {
|
||
onAdvanceNextLevel({
|
||
profileId: run.nextLevelProfileId ?? undefined,
|
||
levelId: run.nextLevelId ?? null,
|
||
});
|
||
}}
|
||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 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="puzzle-runtime-next-card group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] text-left transition disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
|
||
>
|
||
<div className="puzzle-runtime-next-card__media relative min-h-full 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="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||
</div>
|
||
<div className="min-w-0 px-3 py-2.5">
|
||
<div className="truncate text-sm font-black">
|
||
{item.levelName}
|
||
</div>
|
||
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
|
||
{item.authorDisplayName}
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{item.themeTags.slice(0, 2).map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="puzzle-runtime-next-card__tag max-w-full truncate rounded-full px-2 py-0.5 text-[10px] font-bold"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default PuzzleRuntimeShell;
|