Switch to VectorEngine gpt-image-2 and edits

Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
2026-05-22 03:06:41 +08:00
parent 321e1ea33a
commit ae014ac881
90 changed files with 7078 additions and 3389 deletions

View File

@@ -2,17 +2,23 @@ import {
ArrowLeft,
ArrowRight,
Clock,
Eye,
Lightbulb,
Loader2,
Settings,
Snowflake,
Sparkles,
Trophy,
X,
} from 'lucide-react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from '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,
@@ -34,6 +40,13 @@ import {
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,
@@ -113,6 +126,42 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
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 buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[],
@@ -225,7 +274,9 @@ function resolveRuntimeElapsedMs(
) {
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
if (level.status !== 'playing') {
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
return (
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
);
}
const timeLimitMs = level.timeLimitMs || level.remainingMs;
@@ -369,8 +420,7 @@ export function PuzzleRuntimeShell({
const selectedPieceIdRef = useRef<string | null>(null);
const selectedPieceBeforeInputRef = useRef<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
@@ -414,6 +464,8 @@ export function PuzzleRuntimeShell({
pieceId: string;
groupId: string | null;
} | null>(null);
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
useState<PuzzleUiSpritesheetLayout | null>(null);
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
@@ -461,16 +513,27 @@ export function PuzzleRuntimeShell({
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 backgroundMusicSrc =
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedBackgroundMusicSrc } =
useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
);
const rawUiSpritesheetImage =
currentLevel?.uiSpritesheetImageSrc?.trim() ||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
: null);
const { resolvedUrl: resolvedUiSpritesheetImage } = useResolvedAssetReadUrl(
rawUiSpritesheetImage,
);
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
@@ -483,6 +546,34 @@ export function PuzzleRuntimeShell({
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
useEffect(() => {
if (!rawUiSpritesheetImage) {
setUiSpritesheetLayout(null);
return;
}
const controller = new AbortController();
setUiSpritesheetLayout(null);
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
signal: controller.signal,
})
.then((layout) => {
if (!controller.signal.aborted) {
setUiSpritesheetLayout(layout);
}
})
.catch(() => {
if (!controller.signal.aborted) {
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
setUiSpritesheetLayout(null);
}
});
return () => {
controller.abort();
};
}, [rawUiSpritesheetImage]);
useEffect(() => {
currentLevelRef.current = currentLevel;
}, [currentLevel]);
@@ -608,16 +699,16 @@ export function PuzzleRuntimeShell({
}, PUZZLE_MERGE_FLASH_DURATION_MS);
}, [board, currentLevel?.status, mergedGroups]);
const resolvePieceCellElement = (pieceId: string) => {
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 = () => {
const resetDragVisualTarget = useCallback(() => {
const dragVisualTarget = dragVisualTargetRef.current;
setDragRenderTarget(null);
if (!dragVisualTarget) {
@@ -653,7 +744,7 @@ export function PuzzleRuntimeShell({
}
dragVisualTargetRef.current = null;
};
}, [resolvePieceCellElement]);
const resetDragInteractionState = () => {
dragSessionRef.current = null;
@@ -728,7 +819,7 @@ export function PuzzleRuntimeShell({
() => () => {
resetDragVisualTarget();
},
[],
[resetDragVisualTarget],
);
const clearPresentationTimeouts = () => {
@@ -773,7 +864,7 @@ export function PuzzleRuntimeShell({
}, [isUiPauseActive]);
useEffect(() => {
if (!currentLevel || currentLevel.status !== 'playing') {
if (currentLevelStatus !== 'playing') {
return;
}
@@ -782,28 +873,33 @@ export function PuzzleRuntimeShell({
}, 250);
return () => window.clearInterval(timerId);
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status === 'cleared') {
if (
!runtimeRunId ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null ||
currentLevelStatus === 'cleared'
) {
return;
}
if (displayRemainingMs > 0) {
return;
}
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
if (timeExpiredSyncKeyRef.current === syncKey) {
return;
}
timeExpiredSyncKeyRef.current = syncKey;
void onTimeExpiredRef.current?.();
}, [
currentLevel?.levelIndex,
currentLevel?.startedAtMs,
currentLevel?.status,
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
run?.runId,
runtimeRunId,
]);
useEffect(() => {
@@ -945,9 +1041,7 @@ export function PuzzleRuntimeShell({
return;
}
const targetCell = board
? resolveRuntimeInputGridCell(point, board)
: null;
const targetCell = board ? resolveRuntimeInputGridCell(point, board) : null;
if (!targetCell) {
return;
}
@@ -959,10 +1053,7 @@ export function PuzzleRuntimeShell({
});
};
const resolveBoardInputPointFromClient = (
clientX: number,
clientY: number,
) =>
const resolveBoardInputPointFromClient = (clientX: number, clientY: number) =>
createRuntimeInputPointFromClient(
clientX,
clientY,
@@ -976,7 +1067,9 @@ export function PuzzleRuntimeShell({
return;
}
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
dragSessionRef.current = {
pieceId: session.targetId,
inputId: session.inputId,
@@ -995,14 +1088,18 @@ export function PuzzleRuntimeShell({
runtimeDragInputControllerRef.current.setOptions({
dragThresholdPx: 8,
onPress: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressFeedback(musicVolume);
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
setDragRenderTarget({
pieceId: session.targetId,
@@ -1014,7 +1111,9 @@ export function PuzzleRuntimeShell({
syncRuntimeDragFromController(session);
},
onDrop: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
commitSelectedPieceId(null);
@@ -1073,7 +1172,9 @@ export function PuzzleRuntimeShell({
});
};
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const handlePiecePointerMove = (
event: React.PointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
runtimeDragInputControllerRef.current.move({
inputId: `pointer:${event.pointerId}`,
@@ -1094,8 +1195,7 @@ export function PuzzleRuntimeShell({
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextLevelMode =
run.nextLevelMode ?? 'none';
const nextLevelMode = run.nextLevelMode ?? 'none';
const recommendedNextWorks = run.recommendedNextWorks ?? [];
const hasSimilarWorkChoices =
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
@@ -1106,8 +1206,7 @@ export function PuzzleRuntimeShell({
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
!hasSimilarWorkChoices
: Boolean(run.recommendedNextProfileId)));
const canShowNextAction =
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const canShowNextAction = canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const levelLabel = ` ${currentLevel.levelIndex} `;
const exitPromptProfileId = currentLevel.profileId.trim();
const shouldHideBackButton = hideBackButton || hideExitControls;
@@ -1119,6 +1218,9 @@ export function PuzzleRuntimeShell({
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 handleBackRequest = () => {
if (hideExitControls) {
return;
@@ -1230,6 +1332,157 @@ export function PuzzleRuntimeShell({
}
};
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"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
下一关
</button>
</footer>
) : null}
</section>
</div>
) : null;
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`}
@@ -1274,26 +1527,50 @@ export function PuzzleRuntimeShell({
onClick={handleBackRequest}
aria-label="返回上一页"
disabled={shouldHideBackButton}
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
shouldHideBackButton
? 'invisible pointer-events-none'
: 'inline-flex'
}`}
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'}`}
>
<ArrowLeft className="h-4 w-4" />
<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(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
<div className="flex max-w-full items-center justify-center gap-1.5">
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
<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-sm font-black sm:text-base">
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
{currentLevel.levelName}
</span>
</div>
<div
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
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'
@@ -1309,9 +1586,28 @@ export function PuzzleRuntimeShell({
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开拼图设置"
title="打开拼图设置"
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
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' : ''
}`}
>
<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]" />
<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>
@@ -1408,9 +1704,7 @@ export function PuzzleRuntimeShell({
}`}
style={{
clipPath: isMerged ? undefined : singlePieceClipUrl,
WebkitClipPath: isMerged
? undefined
: singlePieceClipUrl,
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
zIndex: resolveDraggedPieceLayer(
piece?.pieceId,
draggingPieceId,
@@ -1526,10 +1820,7 @@ export function PuzzleRuntimeShell({
</defs>
<g clipPath={`url(#${mergedGroupClipId})`}>
{group.pieces.map((piece) => (
<g
key={piece.pieceId}
data-merged-piece-visual="true"
>
<g key={piece.pieceId} data-merged-piece-visual="true">
<clipPath
id={sanitizeSvgId(
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
@@ -1656,23 +1947,47 @@ export function PuzzleRuntimeShell({
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="next"
layout={uiSpritesheetLayout}
className="h-8 w-12 rounded-full"
/>
{resolvedUiSpritesheetImage ? null : (
<ArrowRight className="h-4 w-4" />
)}
</button>
) : null}
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
<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-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
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'
: ''
}`}
>
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
<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) {
@@ -1681,23 +1996,50 @@ export function PuzzleRuntimeShell({
}
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 ${
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'
: ''
}`}
>
<Eye className="h-6 w-6" />
<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-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
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'
: ''
}`}
>
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
<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>
@@ -1863,9 +2205,7 @@ export function PuzzleRuntimeShell({
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
音频
</div>
<div className="mt-2 text-sm font-semibold">
</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)}%
@@ -1897,24 +2237,26 @@ export function PuzzleRuntimeShell({
<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>
<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="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 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="puzzle-runtime-dialog__soft">
当前用时
</span>
<span className="font-mono font-semibold">
{formatElapsedMs(displayElapsedMs)}
</span>
@@ -1951,9 +2293,7 @@ export function PuzzleRuntimeShell({
) : 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"
>
<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"
@@ -2011,10 +2351,7 @@ export function PuzzleRuntimeShell({
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 id="puzzle-failed-title" className="text-lg font-black">
关卡失败
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
@@ -2045,156 +2382,7 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{isClearResultOpen ? (
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-result-title"
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
<Trophy className="h-4 w-4" />
</div>
<h2
id="puzzle-clear-result-title"
className="truncate text-lg font-black"
>
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
{currentLevel.levelName}
</div>
</div>
<button
type="button"
aria-label="关闭通关弹窗"
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
onClick={() => {
setDismissedClearKey(clearResultKey);
}}
>
<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"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
) : null}
</section>
</div>
) : null}
{clearResultLayer}
</div>
</div>
);
@@ -2229,9 +2417,7 @@ function PuzzleNextWorkCard({
<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="truncate text-sm font-black">{item.levelName}</div>
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
{item.authorDisplayName}
</div>