+
{shouldShowFallback ?
: null}
{shouldShowImage ? (
![]()
+ );
}
return (
-

+ <>
+
+

+ >
);
}
@@ -619,26 +641,26 @@ export function JumpHopRuntimeShell({
profile?.draft.coverComposite,
profile?.summary.coverImageSrc,
].find(isJumpHopGeneratedBackgroundSource);
- const { resolvedUrl: stageBackgroundUrl } = useResolvedAssetReadUrl(
- stageBackgroundSource,
- {
- refreshKey: stageBackgroundSource,
- },
- );
+ const {
+ resolvedUrl: stageBackgroundUrl,
+ isResolving: isStageBackgroundResolving,
+ } = useResolvedAssetReadUrl(stageBackgroundSource, {
+ refreshKey: stageBackgroundSource,
+ });
const backButtonAssetSource =
profile?.backButtonAsset?.imageSrc?.trim() ||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
null;
- const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl(
- backButtonAssetSource,
- {
- refreshKey:
- profile?.backButtonAsset?.assetObjectId ||
- profile?.draft.backButtonAsset?.assetObjectId ||
- backButtonAssetSource ||
- undefined,
- },
- );
+ const {
+ resolvedUrl: backButtonAssetUrl,
+ isResolving: isBackButtonAssetResolving,
+ } = useResolvedAssetReadUrl(backButtonAssetSource, {
+ refreshKey:
+ profile?.backButtonAsset?.assetObjectId ||
+ profile?.draft.backButtonAsset?.assetObjectId ||
+ backButtonAssetSource ||
+ undefined,
+ });
useEffect(() => {
activeRunRef.current = activeRun;
@@ -1313,6 +1335,16 @@ export function JumpHopRuntimeShell({
return (
+
+
[region.label, region]));
}
-function resolveMatch3DImageReadUrlCacheKey(
- imageSourcesByType: ReadonlyMap,
-) {
- return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|');
-}
-
function resolveMatch3DImageReadUrlSources(
imageSourcesByType: ReadonlyMap,
) {
@@ -1047,6 +1042,10 @@ export function Match3DRuntimeShell({
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
Match3DItemSpritesheetViewGroup[]
>([]);
+ const [isUiSpritesheetResolving, setIsUiSpritesheetResolving] =
+ useState(false);
+ const [isItemSpritesheetResolving, setIsItemSpritesheetResolving] =
+ useState(false);
const uiSpritesheetRegionByLabel = useMemo(
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
[uiSpritesheetRegions],
@@ -1077,11 +1076,13 @@ export function Match3DRuntimeShell({
setUiSpritesheetRegions((current) =>
current.length > 0 ? [] : current,
);
+ setIsUiSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
+ setIsUiSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: uiSpritesheetSource,
labels: MATCH3D_UI_SPRITESHEET_LABELS,
@@ -1093,11 +1094,13 @@ export function Match3DRuntimeShell({
.then((regions) => {
if (!cancelled) {
setUiSpritesheetRegions(regions);
+ setIsUiSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setUiSpritesheetRegions([]);
+ setIsUiSpritesheetResolving(false);
}
});
@@ -1112,11 +1115,13 @@ export function Match3DRuntimeShell({
setItemSpritesheetViewGroups((current) =>
current.length > 0 ? [] : current,
);
+ setIsItemSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
+ setIsItemSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetSource,
maxRegions: 100,
@@ -1132,11 +1137,13 @@ export function Match3DRuntimeShell({
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
),
);
+ setIsItemSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetViewGroups([]);
+ setIsItemSpritesheetResolving(false);
}
});
@@ -1315,13 +1322,17 @@ export function Match3DRuntimeShell({
),
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
);
+ const imageReadUrlSources = useMemo(
+ () => resolveMatch3DImageReadUrlSources(imageSourcesByType),
+ [imageSourcesByType],
+ );
const itemSizeByType = useMemo(
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageReadUrlCacheKey = useMemo(
- () => resolveMatch3DImageReadUrlCacheKey(imageSourcesByType),
- [imageSourcesByType],
+ () => imageReadUrlSources.join('|'),
+ [imageReadUrlSources],
);
const [resolvedImageSources, setResolvedImageSources] = useState<
Map
@@ -1329,6 +1340,8 @@ export function Match3DRuntimeShell({
const [failedImageSources, setFailedImageSources] = useState>(
() => new Set(),
);
+ const [isImageSourcesResolving, setIsImageSourcesResolving] =
+ useState(false);
const resolvedImageSourceEntriesByType = useMemo(
() =>
buildResolvedMatch3DImageSourceEntriesByType(
@@ -1354,6 +1367,12 @@ export function Match3DRuntimeShell({
useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
useState('');
+ const [isBackgroundMusicResolving, setIsBackgroundMusicResolving] =
+ useState(false);
+ const [isBackgroundImageResolving, setIsBackgroundImageResolving] =
+ useState(false);
+ const [isContainerImageResolving, setIsContainerImageResolving] =
+ useState(false);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map();
@@ -1397,16 +1416,19 @@ export function Match3DRuntimeShell({
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
setResolvedBackgroundMusicSrc('');
+ setIsBackgroundMusicResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(source)) {
setResolvedBackgroundMusicSrc(source);
+ setIsBackgroundMusicResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundMusicSrc('');
+ setIsBackgroundMusicResolving(true);
void resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
@@ -1420,6 +1442,11 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setResolvedBackgroundMusicSrc('');
}
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsBackgroundMusicResolving(false);
+ }
});
return () => {
@@ -1439,15 +1466,19 @@ export function Match3DRuntimeShell({
useEffect(() => {
if (!backgroundAssetSrc) {
setResolvedBackgroundImageSrc('');
+ setIsBackgroundImageResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
setResolvedBackgroundImageSrc(backgroundAssetSrc);
+ setIsBackgroundImageResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
+ setResolvedBackgroundImageSrc('');
+ setIsBackgroundImageResolving(true);
void resolveAssetReadUrl(backgroundAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -1461,6 +1492,11 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setResolvedBackgroundImageSrc('');
}
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsBackgroundImageResolving(false);
+ }
});
return () => {
@@ -1475,8 +1511,10 @@ export function Match3DRuntimeShell({
setResolvedContainerImageSrc('');
if (!isGeneratedLegacyPath(containerAssetSrc)) {
setResolvedContainerImageSrc(containerAssetSrc);
+ setIsContainerImageResolving(false);
return undefined;
}
+ setIsContainerImageResolving(true);
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -1494,6 +1532,11 @@ export function Match3DRuntimeShell({
: MATCH3D_CONTAINER_REFERENCE_SRC,
);
}
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsContainerImageResolving(false);
+ }
});
return () => {
@@ -1503,7 +1546,7 @@ export function Match3DRuntimeShell({
}, [containerAssetSrc]);
useEffect(() => {
- const rawSources = resolveMatch3DImageReadUrlSources(imageSourcesByType);
+ const rawSources = imageReadUrlSources;
if (rawSources.length <= 0) {
setResolvedImageSources((current) =>
current.size > 0 ? new Map() : current,
@@ -1511,6 +1554,7 @@ export function Match3DRuntimeShell({
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
+ setIsImageSourcesResolving(false);
return undefined;
}
@@ -1519,6 +1563,7 @@ export function Match3DRuntimeShell({
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
+ setIsImageSourcesResolving(false);
return undefined;
}
@@ -1535,6 +1580,7 @@ export function Match3DRuntimeShell({
return retained;
});
setFailedImageSources(new Set());
+ setIsImageSourcesResolving(true);
void Promise.all(
rawSources.map(async (source) => {
if (nextSources.has(source)) {
@@ -1565,13 +1611,18 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setFailedImageSources(new Set(rawSources));
}
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsImageSourcesResolving(false);
+ }
});
return () => {
cancelled = true;
controller.abort();
};
- }, [imageReadUrlCacheKey, imageSourcesByType]);
+ }, [imageReadUrlCacheKey, imageReadUrlSources]);
useEffect(() => {
const rawSources = alphaHitMaskCacheKey
@@ -1925,6 +1976,44 @@ export function Match3DRuntimeShell({
+
+
+
+
+
+ {imageReadUrlSources.map((source) => (
+
+ ))}
{resolvedBackgroundImageSrc ? (
{
+ vi.mocked(loadPuzzleUiSpritesheetLayout).mockRejectedValueOnce(
+ new Error('spritesheet parse failed'),
+ );
+ const runWithSpritesheet: PuzzleRunSnapshot = {
+ ...clearedRun,
+ currentLevel: {
+ ...clearedRun.currentLevel!,
+ status: 'playing',
+ coverImageSrc: '/generated-puzzle-assets/session/cover.png',
+ uiSpritesheetImageSrc:
+ '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
+ remainingMs: 120_000,
+ timeLimitMs: 300_000,
+ },
+ };
+
+ const { container } = renderPuzzleRuntime(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(loadPuzzleUiSpritesheetLayout).toHaveBeenCalledWith(
+ '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+ });
+ await waitFor(() => {
+ expect(
+ container.querySelector(
+ '[data-runtime-resource-pending="true"][data-runtime-resource-src="/generated-puzzle-assets/session/ui-spritesheet/sheet.png"]',
+ ),
+ ).toBeNull();
+ });
+});
+
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
index ee0bc2e2..cfbd941d 100644
--- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
+++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
@@ -55,6 +55,7 @@ import {
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
+import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildMergedGroupOutlinePath,
@@ -475,6 +476,8 @@ export function PuzzleRuntimeShell({
} | null>(null);
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
useState(null);
+ const [isUiSpritesheetLayoutResolving, setIsUiSpritesheetLayoutResolving] =
+ useState(false);
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController(),
);
@@ -526,22 +529,28 @@ export function PuzzleRuntimeShell({
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
- const { resolvedUrl: resolvedBackgroundMusicSrc } =
- useResolvedAssetReadUrl(backgroundMusicSrc);
- const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
+ const {
+ resolvedUrl: resolvedBackgroundMusicSrc,
+ isResolving: isBackgroundMusicResolving,
+ } = useResolvedAssetReadUrl(backgroundMusicSrc);
+ const { resolvedUrl: resolvedCoverImage, isResolving: isCoverImageResolving } =
+ useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
- const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
- resolvePuzzleUiBackgroundSource(currentLevel) ?? 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 } = useResolvedAssetReadUrl(
- rawUiSpritesheetImage,
- );
+ const {
+ resolvedUrl: resolvedUiSpritesheetImage,
+ isResolving: isUiSpritesheetResolving,
+ } = useResolvedAssetReadUrl(rawUiSpritesheetImage);
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
@@ -558,23 +567,27 @@ export function PuzzleRuntimeShell({
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);
}
});
@@ -1523,6 +1536,26 @@ export function PuzzleRuntimeShell({
+
+
+
+
{resolvedBackgroundMusicSrc ? (