推荐页 ready 持续观察运行态图片、背景、音视频和资源 pending 标记 资源换签与玩法图集解析中通过隐藏标记阻止遮罩提前消失 补齐拼图、跳一跳、抓大鹅和敲木鱼运行态资源等待接入 补充推荐页资源等待回归测试和团队文档
2512 lines
90 KiB
TypeScript
2512 lines
90 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Clock,
|
|
Loader2,
|
|
Settings,
|
|
Sparkles,
|
|
Trophy,
|
|
X,
|
|
} from 'lucide-react';
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import puzzleLevelLogo from '../../../media/logo.png';
|
|
import type {
|
|
DragPuzzlePieceRequest,
|
|
PuzzleBoardSnapshot,
|
|
PuzzleCellPosition,
|
|
PuzzleMergedGroupState,
|
|
PuzzleRecommendedNextWork,
|
|
PuzzleRunSnapshot,
|
|
PuzzleRuntimeLevelSnapshot,
|
|
PuzzleRuntimePropKind,
|
|
SwapPuzzlePiecesRequest,
|
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
|
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
|
import {
|
|
createRuntimeDragInputController,
|
|
createRuntimeInputPointFromClient,
|
|
readRuntimeInputElementBounds,
|
|
resolveRuntimeInputGridCell,
|
|
type RuntimeDragInputSession,
|
|
type RuntimeInputPoint,
|
|
} from '../../services/input-devices';
|
|
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
|
import {
|
|
buildPuzzleUiSpriteBackgroundStyle,
|
|
buildPuzzleUiSpriteHitZoneStyle,
|
|
loadPuzzleUiSpritesheetLayout,
|
|
type PuzzleUiSpriteKind,
|
|
type PuzzleUiSpritesheetLayout,
|
|
} from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
|
|
import {
|
|
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
|
playRuntimeClickSound,
|
|
playRuntimeCountdownSound,
|
|
playRuntimeLevelClearSound,
|
|
resolveRuntimeCountdownSecondBucket,
|
|
} from '../../services/runtimeAudioFeedback';
|
|
import { useAuthUi } from '../auth/AuthUiContext';
|
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
import {
|
|
buildMergedGroupOutlinePath,
|
|
buildRoundedGridCellClipPath,
|
|
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 PuzzleUiSprite({
|
|
src,
|
|
kind,
|
|
layout,
|
|
className = '',
|
|
withHitZone = false,
|
|
}: {
|
|
src: string | null;
|
|
kind: PuzzleUiSpriteKind;
|
|
layout: PuzzleUiSpritesheetLayout | null;
|
|
className?: string;
|
|
withHitZone?: boolean;
|
|
}) {
|
|
if (!src) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
data-puzzle-ui-sprite={kind}
|
|
className={`relative inline-block shrink-0 bg-no-repeat ${className}`}
|
|
style={buildPuzzleUiSpriteBackgroundStyle({ src, kind, layout })}
|
|
>
|
|
{withHitZone ? (
|
|
<span
|
|
aria-hidden="true"
|
|
data-puzzle-ui-sprite-hit-zone={kind}
|
|
className="puzzle-runtime-ui-sprite-hit-zone absolute"
|
|
style={buildPuzzleUiSpriteHitZoneStyle({ kind, layout })}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function resolvePuzzleUiSpriteAspectRatio(
|
|
kind: PuzzleUiSpriteKind,
|
|
layout: PuzzleUiSpritesheetLayout | null,
|
|
fallback: string,
|
|
) {
|
|
const region = layout?.regions[kind];
|
|
return region ? `${region.width} / ${region.height}` : fallback;
|
|
}
|
|
|
|
function buildMergedGroupViewModels(
|
|
groups: PuzzleMergedGroupState[],
|
|
pieces: PuzzleBoardPieceViewModel[],
|
|
) {
|
|
const pieceById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
|
return groups
|
|
.map<PuzzleMergedGroupViewModel | null>((group) => {
|
|
const groupPieces = group.pieceIds
|
|
.map((pieceId) => pieceById.get(pieceId) ?? null)
|
|
.filter((piece): piece is PuzzleBoardPieceViewModel => Boolean(piece));
|
|
if (groupPieces.length <= 1) {
|
|
return null;
|
|
}
|
|
const rows = groupPieces.map((piece) => piece.row);
|
|
const cols = groupPieces.map((piece) => piece.col);
|
|
const minRow = Math.min(...rows);
|
|
const maxRow = Math.max(...rows);
|
|
const minCol = Math.min(...cols);
|
|
const maxCol = Math.max(...cols);
|
|
const anchorPiece = groupPieces[0];
|
|
if (!anchorPiece) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
groupId: group.groupId,
|
|
pieceIds: group.pieceIds,
|
|
anchorPieceId: anchorPiece.pieceId,
|
|
minRow,
|
|
minCol,
|
|
rowSpan: maxRow - minRow + 1,
|
|
colSpan: maxCol - minCol + 1,
|
|
pieces: groupPieces.map((piece) => ({
|
|
...piece,
|
|
localRow: piece.row - minRow,
|
|
localCol: piece.col - minCol,
|
|
})),
|
|
};
|
|
})
|
|
.filter((group): group is PuzzleMergedGroupViewModel => Boolean(group));
|
|
}
|
|
|
|
function formatElapsedMs(elapsedMs: number | null | undefined) {
|
|
const normalizedMs = Math.max(0, Math.round(elapsedMs ?? 0));
|
|
const totalSeconds = Math.floor(normalizedMs / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
const centiseconds = Math.floor((normalizedMs % 1000) / 10);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds
|
|
.toString()
|
|
.padStart(2, '0')}`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function resolveRuntimeElapsedMs(
|
|
level: PuzzleRuntimeLevelSnapshot,
|
|
nowMs: number,
|
|
uiPauseStartedAtMs: number | null,
|
|
) {
|
|
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
|
|
if (level.status !== 'playing') {
|
|
return (
|
|
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
|
|
);
|
|
}
|
|
|
|
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
|
const remainingMs = resolveRuntimeRemainingMs(
|
|
level,
|
|
nowMs,
|
|
uiPauseStartedAtMs,
|
|
);
|
|
return Math.max(0, timeLimitMs - remainingMs);
|
|
}
|
|
|
|
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 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 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]);
|
|
}
|
|
|
|
function triggerPuzzlePiecePressFeedback(volume: number) {
|
|
triggerPuzzlePiecePressHapticFeedback();
|
|
playRuntimeClickSound(undefined, volume);
|
|
}
|
|
|
|
/**
|
|
* 拼图运行时壳层。
|
|
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
|
|
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
|
|
*/
|
|
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 runtimeSvgClipId = 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 [isOriginalImageViewerVisible, setIsOriginalImageViewerVisible] =
|
|
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 clearSoundKeyRef = useRef<string | null>(null);
|
|
const countdownSoundKeyRef = 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 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 [uiSpritesheetLayout, setUiSpritesheetLayout] =
|
|
useState<PuzzleUiSpritesheetLayout | null>(null);
|
|
const [isUiSpritesheetLayoutResolving, setIsUiSpritesheetLayoutResolving] =
|
|
useState(false);
|
|
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 backgroundAudioRef = useRef<HTMLAudioElement | 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 displayElapsedMs = currentLevel
|
|
? resolveRuntimeElapsedMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
|
: 0;
|
|
const runtimeStatus = currentLevel
|
|
? currentLevel.status === 'playing' && displayRemainingMs <= 0
|
|
? 'failed'
|
|
: currentLevel.status
|
|
: 'playing';
|
|
const platformThemeClass =
|
|
authUi?.platformTheme === 'dark'
|
|
? 'platform-theme--dark'
|
|
: 'platform-theme--light';
|
|
const isInteractionLocked =
|
|
isBusy ||
|
|
runtimeStatus !== 'playing' ||
|
|
Boolean(propDialog) ||
|
|
isOriginalImageViewerVisible;
|
|
const clearResultKey = currentLevel
|
|
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
|
: null;
|
|
const runtimeRunId = run?.runId ?? null;
|
|
const currentLevelIndex = currentLevel?.levelIndex ?? null;
|
|
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
|
|
const currentLevelStatus = currentLevel?.status ?? null;
|
|
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
|
const backgroundMusicSrc =
|
|
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
|
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
|
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
|
const {
|
|
resolvedUrl: resolvedBackgroundMusicSrc,
|
|
isResolving: isBackgroundMusicResolving,
|
|
} = useResolvedAssetReadUrl(backgroundMusicSrc);
|
|
const { resolvedUrl: resolvedCoverImage, isResolving: isCoverImageResolving } =
|
|
useResolvedAssetReadUrl(
|
|
currentLevel?.coverImageSrc ?? null,
|
|
);
|
|
const rawUiBackgroundImage = resolvePuzzleUiBackgroundSource(currentLevel);
|
|
const {
|
|
resolvedUrl: resolvedUiBackgroundImage,
|
|
isResolving: isUiBackgroundResolving,
|
|
} = useResolvedAssetReadUrl(rawUiBackgroundImage ?? null);
|
|
const rawUiSpritesheetImage =
|
|
currentLevel?.uiSpritesheetImageSrc?.trim() ||
|
|
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
|
|
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
|
|
: null);
|
|
const {
|
|
resolvedUrl: resolvedUiSpritesheetImage,
|
|
isResolving: isUiSpritesheetResolving,
|
|
} = useResolvedAssetReadUrl(rawUiSpritesheetImage);
|
|
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
|
|
const tryPlayBackgroundMusic = useCallback(() => {
|
|
const audio = backgroundAudioRef.current;
|
|
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
|
|
if (audio) {
|
|
audio.pause();
|
|
}
|
|
return;
|
|
}
|
|
audio.volume = Math.max(0, Math.min(1, musicVolume));
|
|
void audio.play().catch(() => {});
|
|
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!rawUiSpritesheetImage) {
|
|
setUiSpritesheetLayout(null);
|
|
setIsUiSpritesheetLayoutResolving(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setUiSpritesheetLayout(null);
|
|
setIsUiSpritesheetLayoutResolving(true);
|
|
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((layout) => {
|
|
if (!controller.signal.aborted) {
|
|
setUiSpritesheetLayout(layout);
|
|
setIsUiSpritesheetLayoutResolving(false);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!controller.signal.aborted) {
|
|
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
|
|
setUiSpritesheetLayout(null);
|
|
setIsUiSpritesheetLayoutResolving(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [rawUiSpritesheetImage]);
|
|
|
|
useEffect(() => {
|
|
currentLevelRef.current = currentLevel;
|
|
}, [currentLevel]);
|
|
|
|
useEffect(() => {
|
|
tryPlayBackgroundMusic();
|
|
}, [tryPlayBackgroundMusic]);
|
|
|
|
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],
|
|
);
|
|
const singlePieceClipId = sanitizeSvgId(
|
|
`puzzle-single-piece-${runtimeSvgClipId}`,
|
|
);
|
|
const singlePieceClipUrl = `url(#${singlePieceClipId})`;
|
|
|
|
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 = useCallback((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 = useCallback(() => {
|
|
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 = '';
|
|
pieceElement.style.transition = '';
|
|
}
|
|
|
|
if (dragVisualTarget.groupId) {
|
|
const groupElement = groupElementRefMap.current.get(
|
|
dragVisualTarget.groupId,
|
|
);
|
|
if (groupElement) {
|
|
groupElement.style.transform = '';
|
|
groupElement.style.willChange = '';
|
|
groupElement.style.zIndex = '';
|
|
groupElement.style.opacity = '';
|
|
groupElement.style.transition = '';
|
|
}
|
|
}
|
|
|
|
dragVisualTargetRef.current = null;
|
|
}, [resolvePieceCellElement]);
|
|
|
|
const resetDragInteractionState = () => {
|
|
dragSessionRef.current = null;
|
|
draggingTargetRef.current = null;
|
|
resetDragVisualTarget();
|
|
};
|
|
|
|
const resetDragInteraction = () => {
|
|
runtimeDragInputControllerRef.current.cancel();
|
|
};
|
|
|
|
const flushDragVisual = () => {
|
|
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,
|
|
};
|
|
dragVisualTargetRef.current = nextTarget;
|
|
|
|
const offsetX = dragSession.currentX - dragSession.startX;
|
|
const offsetY = dragSession.currentY - dragSession.startY;
|
|
|
|
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';
|
|
groupElement.style.transition = 'none';
|
|
}
|
|
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 = '';
|
|
pieceElement.style.transition = '';
|
|
}
|
|
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';
|
|
pieceElement.style.transition = 'none';
|
|
}
|
|
};
|
|
|
|
useEffect(
|
|
() => () => {
|
|
resetDragVisualTarget();
|
|
},
|
|
[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) ||
|
|
isOriginalImageViewerVisible;
|
|
|
|
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 (currentLevelStatus !== 'playing') {
|
|
return;
|
|
}
|
|
|
|
const timerId = window.setInterval(() => {
|
|
setTimerNowMs(Date.now());
|
|
}, 250);
|
|
|
|
return () => window.clearInterval(timerId);
|
|
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!runtimeRunId ||
|
|
currentLevelIndex === null ||
|
|
currentLevelStartedAtMs === null ||
|
|
currentLevelStatus === 'cleared'
|
|
) {
|
|
return;
|
|
}
|
|
if (displayRemainingMs > 0) {
|
|
return;
|
|
}
|
|
|
|
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
|
|
if (timeExpiredSyncKeyRef.current === syncKey) {
|
|
return;
|
|
}
|
|
timeExpiredSyncKeyRef.current = syncKey;
|
|
void onTimeExpiredRef.current?.();
|
|
}, [
|
|
currentLevelIndex,
|
|
currentLevelStartedAtMs,
|
|
currentLevelStatus,
|
|
displayRemainingMs,
|
|
runtimeRunId,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!runtimeRunId ||
|
|
currentLevelStatus !== 'playing' ||
|
|
currentLevelIndex === null ||
|
|
currentLevelStartedAtMs === null
|
|
) {
|
|
countdownSoundKeyRef.current = null;
|
|
return;
|
|
}
|
|
const secondBucket =
|
|
displayRemainingMs <= levelAudioConfig.countdownWarningThresholdMs
|
|
? resolveRuntimeCountdownSecondBucket(displayRemainingMs)
|
|
: null;
|
|
if (secondBucket === null) {
|
|
countdownSoundKeyRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const soundKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}:${secondBucket}`;
|
|
if (countdownSoundKeyRef.current === soundKey) {
|
|
return;
|
|
}
|
|
countdownSoundKeyRef.current = soundKey;
|
|
playRuntimeCountdownSound(musicVolume);
|
|
}, [
|
|
currentLevelIndex,
|
|
currentLevelStartedAtMs,
|
|
currentLevelStatus,
|
|
displayRemainingMs,
|
|
levelAudioConfig.countdownWarningThresholdMs,
|
|
musicVolume,
|
|
runtimeRunId,
|
|
]);
|
|
|
|
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;
|
|
if (clearSoundKeyRef.current !== clearResultKey) {
|
|
clearSoundKeyRef.current = clearResultKey;
|
|
playRuntimeLevelClearSound(musicVolume);
|
|
}
|
|
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, musicVolume]);
|
|
|
|
const handlePieceTap = (
|
|
pieceId: string,
|
|
selectedPieceIdBeforeInput: string | null,
|
|
) => {
|
|
if (isInteractionLocked) {
|
|
return;
|
|
}
|
|
tryPlayBackgroundMusic();
|
|
|
|
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 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();
|
|
}
|
|
};
|
|
|
|
runtimeDragInputControllerRef.current.setOptions({
|
|
dragThresholdPx: 8,
|
|
onPress: (session) => {
|
|
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
|
session.targetId,
|
|
);
|
|
syncRuntimeDragFromController(session);
|
|
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
|
commitSelectedPieceId(session.targetId);
|
|
triggerPuzzlePiecePressFeedback(musicVolume);
|
|
},
|
|
onDragStart: (session) => {
|
|
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
|
session.targetId,
|
|
);
|
|
syncRuntimeDragFromController(session);
|
|
setDragRenderTarget({
|
|
pieceId: session.targetId,
|
|
groupId: draggingTargetRef.current?.groupId ?? null,
|
|
});
|
|
flushDragVisual();
|
|
},
|
|
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();
|
|
},
|
|
});
|
|
|
|
if (!run || !currentLevel || !board) {
|
|
return (
|
|
<div
|
|
className={`platform-ui-shell platform-theme ${platformThemeClass} 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;
|
|
}
|
|
tryPlayBackgroundMusic();
|
|
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 shouldDisplaySelectedState = !dragRenderTarget;
|
|
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 clearResultOverlayClassName = embedded
|
|
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
|
|
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
|
|
const nextSpriteButtonClassName =
|
|
'inline-flex h-12 appearance-none items-center justify-center border-0 bg-transparent p-0 leading-none shadow-none transition hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] disabled:cursor-not-allowed disabled:opacity-45';
|
|
const nextSpriteButtonStyle = hasUiSpritesheet
|
|
? {
|
|
...buildPuzzleUiSpriteBackgroundStyle({
|
|
src: resolvedUiSpritesheetImage,
|
|
kind: 'next',
|
|
layout: uiSpritesheetLayout,
|
|
}),
|
|
aspectRatio: resolvePuzzleUiSpriteAspectRatio(
|
|
'next',
|
|
uiSpritesheetLayout,
|
|
'2 / 1',
|
|
),
|
|
}
|
|
: undefined;
|
|
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') {
|
|
setIsOriginalImageViewerVisible(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());
|
|
}
|
|
};
|
|
|
|
const clearResultDialog = isClearResultOpen ? (
|
|
<div className={clearResultOverlayClassName}>
|
|
<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);
|
|
}}
|
|
>
|
|
<X 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"
|
|
aria-label="下一关"
|
|
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
|
|
disabled={isBusy}
|
|
onClick={() => {
|
|
onAdvanceNextLevel({
|
|
profileId: run.nextLevelProfileId ?? undefined,
|
|
levelId: run.nextLevelId ?? null,
|
|
});
|
|
}}
|
|
style={nextSpriteButtonStyle}
|
|
className={
|
|
hasUiSpritesheet
|
|
? nextSpriteButtonClassName
|
|
: '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'
|
|
}
|
|
>
|
|
{hasUiSpritesheet ? null : (
|
|
<>
|
|
{isBusy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<ArrowRight className="h-4 w-4" />
|
|
)}
|
|
下一关
|
|
</>
|
|
)}
|
|
</button>
|
|
</footer>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
) : null;
|
|
const clearResultLayer =
|
|
embedded && clearResultDialog && typeof document !== 'undefined'
|
|
? createPortal(clearResultDialog, document.body)
|
|
: clearResultDialog;
|
|
|
|
return (
|
|
<div
|
|
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
|
>
|
|
<RuntimeResourcePendingMarker
|
|
source={backgroundMusicSrc}
|
|
kind="audio"
|
|
isPending={isBackgroundMusicResolving}
|
|
/>
|
|
<RuntimeResourcePendingMarker
|
|
source={currentLevel.coverImageSrc}
|
|
kind="image"
|
|
isPending={isCoverImageResolving}
|
|
/>
|
|
<RuntimeResourcePendingMarker
|
|
source={rawUiBackgroundImage}
|
|
kind="image"
|
|
isPending={isUiBackgroundResolving}
|
|
/>
|
|
<RuntimeResourcePendingMarker
|
|
source={rawUiSpritesheetImage}
|
|
kind="image"
|
|
isPending={isUiSpritesheetResolving || isUiSpritesheetLayoutResolving}
|
|
/>
|
|
{resolvedBackgroundMusicSrc ? (
|
|
<audio
|
|
ref={backgroundAudioRef}
|
|
src={resolvedBackgroundMusicSrc}
|
|
loop
|
|
preload="auto"
|
|
/>
|
|
) : null}
|
|
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
|
|
{resolvedUiBackgroundImage ? (
|
|
<ResolvedAssetImage
|
|
src={resolvedUiBackgroundImage}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
) : null}
|
|
{currentLevel.coverImageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={currentLevel.coverImageSrc}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className={`absolute inset-0 h-full w-full object-cover blur-2xl ${
|
|
resolvedUiBackgroundImage ? 'opacity-[0.06]' : 'opacity-[0.16]'
|
|
}`}
|
|
/>
|
|
) : null}
|
|
<div
|
|
className={`puzzle-runtime-stage__grid ${
|
|
resolvedUiBackgroundImage ? 'opacity-20' : ''
|
|
}`}
|
|
/>
|
|
|
|
<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 sm:h-11 sm:w-11 ${
|
|
hasUiSpritesheet
|
|
? 'puzzle-runtime-icon-button--sprite'
|
|
: 'rounded-full'
|
|
} ${
|
|
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
|
} ${shouldHideBackButton ? 'invisible pointer-events-none' : 'inline-flex'}`}
|
|
>
|
|
<PuzzleUiSprite
|
|
src={resolvedUiSpritesheetImage}
|
|
kind="back"
|
|
layout={uiSpritesheetLayout}
|
|
className={`${
|
|
hasUiSpritesheet
|
|
? 'puzzle-runtime-top-ui-sprite'
|
|
: 'h-7 w-7 rounded-full'
|
|
}`}
|
|
withHitZone
|
|
/>
|
|
{resolvedUiSpritesheetImage ? null : (
|
|
<ArrowLeft className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
|
|
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
|
|
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
|
|
<span aria-hidden="true" className="puzzle-runtime-level-logo">
|
|
<img
|
|
src={puzzleLevelLogo}
|
|
alt=""
|
|
data-testid="puzzle-runtime-level-logo"
|
|
className="puzzle-runtime-level-logo__image"
|
|
draggable={false}
|
|
/>
|
|
</span>
|
|
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
|
|
{levelLabel}
|
|
</span>
|
|
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
|
|
{currentLevel.levelName}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none 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 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 ${
|
|
hasUiSpritesheet
|
|
? 'puzzle-runtime-icon-button--sprite'
|
|
: 'rounded-full'
|
|
} ${
|
|
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
|
}`}
|
|
>
|
|
<PuzzleUiSprite
|
|
src={resolvedUiSpritesheetImage}
|
|
kind="settings"
|
|
layout={uiSpritesheetLayout}
|
|
className={`${
|
|
hasUiSpritesheet
|
|
? 'puzzle-runtime-top-ui-sprite'
|
|
: 'h-7 w-7 rounded-full'
|
|
}`}
|
|
withHitZone
|
|
/>
|
|
{resolvedUiSpritesheetImage ? null : (
|
|
<Settings className="h-4 w-4" />
|
|
)}
|
|
</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))`,
|
|
}}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute h-0 w-0 overflow-hidden"
|
|
focusable="false"
|
|
>
|
|
<defs>
|
|
<clipPath
|
|
id={singlePieceClipId}
|
|
clipPathUnits="objectBoundingBox"
|
|
>
|
|
<path d={buildRoundedGridCellClipPath()} />
|
|
</clipPath>
|
|
</defs>
|
|
</svg>
|
|
{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 =
|
|
shouldDisplaySelectedState &&
|
|
!isMerged &&
|
|
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 border-0 text-sm font-black ${
|
|
occupied
|
|
? isSelected
|
|
? 'puzzle-runtime-piece--selected'
|
|
: isMerged
|
|
? 'puzzle-runtime-piece--merged'
|
|
: 'puzzle-runtime-piece--filled'
|
|
: 'puzzle-runtime-piece--empty'
|
|
} ${
|
|
isMerged
|
|
? 'transition-opacity'
|
|
: 'transition-[opacity,transform]'
|
|
}`}
|
|
style={{
|
|
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
|
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
|
|
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 mergedGroupClipId = sanitizeSvgId(
|
|
`${runtimeSvgClipId}-${group.groupId}`,
|
|
);
|
|
const mergedGroupClipPath = buildMergedGroupOutlinePath(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}%`,
|
|
}}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
|
|
data-merged-group-clip="true"
|
|
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
|
preserveAspectRatio="none"
|
|
>
|
|
<defs>
|
|
<clipPath
|
|
id={mergedGroupClipId}
|
|
clipPathUnits="userSpaceOnUse"
|
|
>
|
|
<path d={mergedGroupClipPath} />
|
|
</clipPath>
|
|
</defs>
|
|
<g clipPath={`url(#${mergedGroupClipId})`}>
|
|
{group.pieces.map((piece) => (
|
|
<g key={piece.pieceId} data-merged-piece-visual="true">
|
|
<clipPath
|
|
id={sanitizeSvgId(
|
|
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
|
)}
|
|
clipPathUnits="userSpaceOnUse"
|
|
>
|
|
<rect
|
|
x={piece.localCol}
|
|
y={piece.localRow}
|
|
width={1}
|
|
height={1}
|
|
/>
|
|
</clipPath>
|
|
<g
|
|
clipPath={`url(#${sanitizeSvgId(
|
|
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
|
)})`}
|
|
>
|
|
{resolvedCoverImage ? (
|
|
<image
|
|
href={resolvedCoverImage}
|
|
xlinkHref={resolvedCoverImage}
|
|
x={piece.localCol - piece.correctCol}
|
|
y={piece.localRow - piece.correctRow}
|
|
width={board.cols}
|
|
height={board.rows}
|
|
preserveAspectRatio="none"
|
|
/>
|
|
) : (
|
|
<rect
|
|
x={piece.localCol}
|
|
y={piece.localRow}
|
|
width={1}
|
|
height={1}
|
|
fill="rgba(16,185,129,0.42)"
|
|
/>
|
|
)}
|
|
</g>
|
|
</g>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
<div
|
|
className="pointer-events-none relative z-10 grid h-full w-full touch-none active:scale-[0.992]"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
|
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
{group.pieces.map((piece) => (
|
|
<div
|
|
key={piece.pieceId}
|
|
className="pointer-events-auto relative touch-none"
|
|
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();
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{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 &&
|
|
shouldDisplaySelectedState &&
|
|
runtimeStatus === 'playing' ? (
|
|
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
|
已选择
|
|
</div>
|
|
) : null}
|
|
{canShowNextAction ? (
|
|
<button
|
|
type="button"
|
|
disabled={isBusy}
|
|
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
|
|
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
|
onClick={() => {
|
|
if (hasSimilarWorkChoices) {
|
|
setDismissedClearKey(null);
|
|
setIsClearResultReady(true);
|
|
return;
|
|
}
|
|
onAdvanceNextLevel({
|
|
profileId: run.nextLevelProfileId ?? undefined,
|
|
levelId: run.nextLevelId ?? null,
|
|
});
|
|
}}
|
|
style={nextSpriteButtonStyle}
|
|
className={
|
|
hasUiSpritesheet
|
|
? nextSpriteButtonClassName
|
|
: 'puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45'
|
|
}
|
|
>
|
|
{hasUiSpritesheet ? null : (
|
|
<>
|
|
<ArrowRight className="h-4 w-4" />
|
|
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
|
</>
|
|
)}
|
|
</button>
|
|
) : null}
|
|
|
|
<div className="grid w-full max-w-[23rem] grid-cols-3 items-center justify-items-center gap-3 px-1 sm:max-w-[26rem] sm:gap-4">
|
|
<button
|
|
type="button"
|
|
disabled={isInteractionLocked}
|
|
aria-label="提示"
|
|
onClick={() => openPropDialog('hint', '使用提示')}
|
|
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
|
resolvedUiSpritesheetImage
|
|
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
|
: ''
|
|
}`}
|
|
>
|
|
<PuzzleUiSprite
|
|
src={resolvedUiSpritesheetImage}
|
|
kind="hint"
|
|
layout={uiSpritesheetLayout}
|
|
className="puzzle-runtime-bottom-ui-sprite"
|
|
withHitZone
|
|
/>
|
|
{resolvedUiSpritesheetImage ? null : (
|
|
<span className="puzzle-runtime-tool-button__warm text-lg font-black">
|
|
?
|
|
</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
|
aria-label="原图"
|
|
aria-pressed={isOriginalImageViewerVisible}
|
|
onClick={() => {
|
|
if (isOriginalImageViewerVisible) {
|
|
setIsOriginalImageViewerVisible(false);
|
|
return;
|
|
}
|
|
openPropDialog('reference', '查看原图');
|
|
}}
|
|
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
|
isOriginalImageViewerVisible
|
|
? 'puzzle-runtime-tool-button--active'
|
|
: 'puzzle-runtime-tool-button'
|
|
} ${
|
|
resolvedUiSpritesheetImage
|
|
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
|
: ''
|
|
}`}
|
|
>
|
|
<PuzzleUiSprite
|
|
src={resolvedUiSpritesheetImage}
|
|
kind="reference"
|
|
layout={uiSpritesheetLayout}
|
|
className="puzzle-runtime-bottom-ui-sprite"
|
|
withHitZone
|
|
/>
|
|
{resolvedUiSpritesheetImage ? null : (
|
|
<span className="text-lg font-black">□</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={isInteractionLocked}
|
|
aria-label="冻结"
|
|
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
|
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
|
resolvedUiSpritesheetImage
|
|
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
|
: ''
|
|
}`}
|
|
>
|
|
<PuzzleUiSprite
|
|
src={resolvedUiSpritesheetImage}
|
|
kind="freezeTime"
|
|
layout={uiSpritesheetLayout}
|
|
className="puzzle-runtime-bottom-ui-sprite"
|
|
withHitZone
|
|
/>
|
|
{resolvedUiSpritesheetImage ? null : (
|
|
<span className="puzzle-runtime-tool-button__cool text-lg font-black">
|
|
*
|
|
</span>
|
|
)}
|
|
</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}
|
|
|
|
{isOriginalImageViewerVisible && resolvedCoverImage ? (
|
|
<div
|
|
data-testid="puzzle-original-viewer"
|
|
className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-4 backdrop-blur-sm"
|
|
style={{ background: 'rgba(2, 6, 23, 0.94)' }}
|
|
onClick={() => {
|
|
setIsOriginalImageViewerVisible(false);
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭原图"
|
|
className="puzzle-runtime-secondary-button absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full transition hover:brightness-105"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setIsOriginalImageViewerVisible(false);
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
<div
|
|
className="flex h-full w-full items-center justify-center"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<img
|
|
src={resolvedCoverImage}
|
|
alt={`${currentLevel.levelName} 原图`}
|
|
className="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain"
|
|
/>
|
|
</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 w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
|
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 flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
|
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"
|
|
>
|
|
<X 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(displayElapsedMs)}
|
|
</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}
|
|
|
|
{clearResultLayer}
|
|
</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;
|