Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
This commit is contained in:
@@ -5,11 +5,13 @@ import {
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
@@ -39,14 +41,15 @@ import {
|
||||
playRuntimeLevelClearSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildMergedGroupOutlinePath,
|
||||
buildRoundedGridCellClipPath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
sanitizeSvgId,
|
||||
} from './puzzleRuntimeShape';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -215,6 +218,25 @@ function resolveRuntimeRemainingMs(
|
||||
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;
|
||||
@@ -341,6 +363,7 @@ export function PuzzleRuntimeShell({
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const runtimeSvgClipId = useId();
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
@@ -351,7 +374,7 @@ export function PuzzleRuntimeShell({
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
|
||||
const [isOriginalImageViewerVisible, setIsOriginalImageViewerVisible] =
|
||||
useState(false);
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
@@ -384,8 +407,6 @@ export function PuzzleRuntimeShell({
|
||||
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>());
|
||||
@@ -415,13 +436,23 @@ export function PuzzleRuntimeShell({
|
||||
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);
|
||||
isBusy ||
|
||||
runtimeStatus !== 'playing' ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalImageViewerVisible;
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
@@ -524,6 +555,10 @@ export function PuzzleRuntimeShell({
|
||||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||||
[pieces],
|
||||
);
|
||||
const singlePieceClipId = sanitizeSvgId(
|
||||
`puzzle-single-piece-${runtimeSvgClipId}`,
|
||||
);
|
||||
const singlePieceClipUrl = `url(#${singlePieceClipId})`;
|
||||
|
||||
useEffect(() => {
|
||||
const signature =
|
||||
@@ -601,6 +636,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
@@ -612,23 +648,14 @@ export function PuzzleRuntimeShell({
|
||||
groupElement.style.willChange = '';
|
||||
groupElement.style.zIndex = '';
|
||||
groupElement.style.opacity = '';
|
||||
groupElement.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -639,7 +666,6 @@ export function PuzzleRuntimeShell({
|
||||
};
|
||||
|
||||
const flushDragVisual = () => {
|
||||
dragVisualFrameRef.current = null;
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession || !dragSession.dragging) {
|
||||
resetDragVisualTarget();
|
||||
@@ -653,28 +679,10 @@ export function PuzzleRuntimeShell({
|
||||
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);
|
||||
@@ -684,6 +692,7 @@ export function PuzzleRuntimeShell({
|
||||
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) {
|
||||
@@ -695,6 +704,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -710,19 +720,12 @@ export function PuzzleRuntimeShell({
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDragVisual = () => {
|
||||
if (dragVisualFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
@@ -754,7 +757,7 @@ export function PuzzleRuntimeShell({
|
||||
isSettingsPanelOpen ||
|
||||
isExitRemodelPromptOpen ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalOverlayVisible;
|
||||
isOriginalImageViewerVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (previousUiPauseActiveRef.current === isUiPauseActive) {
|
||||
@@ -986,7 +989,6 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
if (session.dragging) {
|
||||
flushDragVisual();
|
||||
scheduleDragVisual();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1002,6 +1004,11 @@ export function PuzzleRuntimeShell({
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
syncRuntimeDragFromController(session);
|
||||
setDragRenderTarget({
|
||||
pieceId: session.targetId,
|
||||
groupId: draggingTargetRef.current?.groupId ?? null,
|
||||
});
|
||||
flushDragVisual();
|
||||
},
|
||||
onDragMove: (session) => {
|
||||
syncRuntimeDragFromController(session);
|
||||
@@ -1029,7 +1036,7 @@ export function PuzzleRuntimeShell({
|
||||
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`}
|
||||
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" />
|
||||
@@ -1076,6 +1083,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
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)
|
||||
@@ -1201,7 +1209,7 @@ export function PuzzleRuntimeShell({
|
||||
playHintDemo();
|
||||
}
|
||||
if (propKind === 'reference') {
|
||||
setIsOriginalOverlayVisible(true);
|
||||
setIsOriginalImageViewerVisible(true);
|
||||
}
|
||||
if (propKind === 'freezeTime') {
|
||||
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
||||
@@ -1224,7 +1232,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
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`}
|
||||
>
|
||||
{resolvedBackgroundMusicSrc ? (
|
||||
<audio
|
||||
@@ -1303,10 +1311,7 @@ export function PuzzleRuntimeShell({
|
||||
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]"
|
||||
/>
|
||||
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1321,12 +1326,28 @@ export function PuzzleRuntimeShell({
|
||||
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 =
|
||||
!isMerged && piece?.pieceId === selectedPieceId;
|
||||
shouldDisplaySelectedState &&
|
||||
!isMerged &&
|
||||
piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1372,7 +1393,7 @@ export function PuzzleRuntimeShell({
|
||||
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 transition ${
|
||||
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'
|
||||
@@ -1386,6 +1407,10 @@ export function PuzzleRuntimeShell({
|
||||
: 'transition-[opacity,transform]'
|
||||
}`}
|
||||
style={{
|
||||
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged
|
||||
? undefined
|
||||
: singlePieceClipUrl,
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
@@ -1447,6 +1472,10 @@ export function PuzzleRuntimeShell({
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => {
|
||||
const mergedGroupClipId = sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}`,
|
||||
);
|
||||
const mergedGroupClipPath = buildMergedGroupOutlinePath(group);
|
||||
return (
|
||||
<div
|
||||
key={group.groupId}
|
||||
@@ -1480,8 +1509,71 @@ export function PuzzleRuntimeShell({
|
||||
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 overflow-hidden active:scale-[0.992]"
|
||||
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))`,
|
||||
@@ -1490,7 +1582,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden"
|
||||
className="pointer-events-auto relative touch-none"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
@@ -1511,48 +1603,12 @@ export function PuzzleRuntimeShell({
|
||||
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}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1575,7 +1631,9 @@ export function PuzzleRuntimeShell({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
{selectedPieceId &&
|
||||
shouldDisplaySelectedState &&
|
||||
runtimeStatus === 'playing' ? (
|
||||
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
||||
已选择
|
||||
</div>
|
||||
@@ -1614,17 +1672,17 @@ export function PuzzleRuntimeShell({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing'}
|
||||
aria-pressed={isOriginalOverlayVisible}
|
||||
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
||||
aria-pressed={isOriginalImageViewerVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalOverlayVisible) {
|
||||
setIsOriginalOverlayVisible(false);
|
||||
if (isOriginalImageViewerVisible) {
|
||||
setIsOriginalImageViewerVisible(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
|
||||
isOriginalImageViewerVisible
|
||||
? 'puzzle-runtime-tool-button--active'
|
||||
: 'puzzle-runtime-tool-button'
|
||||
}`}
|
||||
@@ -1667,6 +1725,39 @@ export function PuzzleRuntimeShell({
|
||||
</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"
|
||||
@@ -1680,8 +1771,7 @@ export function PuzzleRuntimeShell({
|
||||
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)}
|
||||
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">
|
||||
@@ -1739,8 +1829,7 @@ export function PuzzleRuntimeShell({
|
||||
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)}
|
||||
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">
|
||||
@@ -1763,7 +1852,7 @@ export function PuzzleRuntimeShell({
|
||||
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" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1827,7 +1916,7 @@ export function PuzzleRuntimeShell({
|
||||
<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)}
|
||||
{formatElapsedMs(displayElapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1987,7 +2076,7 @@ export function PuzzleRuntimeShell({
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user