等待推荐页运行态全部资源
推荐页 ready 持续观察运行态图片、背景、音视频和资源 pending 标记 资源换签与玩法图集解析中通过隐藏标记阻止遮罩提前消失 补齐拼图、跳一跳、抓大鹅和敲木鱼运行态资源等待接入 补充推荐页资源等待回归测试和团队文档
This commit is contained in:
@@ -2083,6 +2083,14 @@
|
|||||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`。
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial and runtime back opens draft result"`。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 推荐页 ready 不能只等主图或首次 DOM 图片
|
||||||
|
|
||||||
|
- 现象:移动端推荐页卡面遮罩在作品主图加载后就渐隐,但游戏内 UI 图集、背景、道具图或换签中的 generated 图片还没有准备好,用户会看到运行态半成品或资源闪入。
|
||||||
|
- 原因:推荐页 ready probe 如果只扫描首次挂载时已有的 `<img>`,就会漏掉 React effect、`/api/assets/read-url` 换签、spritesheet 解析或后续 state 更新才新增的资源。
|
||||||
|
- 处理:推荐页 runtime 遮罩必须持续观察运行态 DOM 内新增图片、内联 `background-image` 和 `data-runtime-resource-pending` 隐藏标记;各玩法对换签中、解析中的资源源头要暴露 pending 标记,失败后释放标记并交给玩法兜底,避免遮罩永久卡住。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend cover waits for async runtime resources beyond the main image|mobile recommend cover waits until runtime images are ready"`。
|
||||||
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/common/RuntimeResourcePendingMarker.tsx`、`src/components/ResolvedAssetImage.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 拼图文字直创的 compile 回包不等于生成完成
|
## 拼图文字直创的 compile 回包不等于生成完成
|
||||||
|
|
||||||
- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。
|
- 现象:只输入文字点击生成拼图时,页面刚进入生成页就弹出“生成任务已完成,可以继续查看草稿。”,随后又提示“请先选择一张正式拼图图片。”,结果页关卡里也没有图。
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
||||||
|
|
||||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,且必须持续观察后续新增图片、内联 `background-image` 和换签中的资源标记,不能只在首次挂载时扫描主图或封面;`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||||
|
import { RuntimeResourcePendingMarker } from './common/RuntimeResourcePendingMarker';
|
||||||
|
|
||||||
type ResolvedAssetImageProps = Omit<
|
type ResolvedAssetImageProps = Omit<
|
||||||
ImgHTMLAttributes<HTMLImageElement>,
|
ImgHTMLAttributes<HTMLImageElement>,
|
||||||
@@ -19,39 +20,50 @@ export function ResolvedAssetImage({
|
|||||||
onError,
|
onError,
|
||||||
...rest
|
...rest
|
||||||
}: ResolvedAssetImageProps) {
|
}: ResolvedAssetImageProps) {
|
||||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||||
refreshKey,
|
refreshKey,
|
||||||
});
|
});
|
||||||
|
const normalizedSource = src?.trim() ?? '';
|
||||||
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
|
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
|
||||||
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
|
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
|
||||||
const finalSrc =
|
const finalSrc =
|
||||||
useFallbackSrc && normalizedFallbackSrc
|
useFallbackSrc && normalizedFallbackSrc
|
||||||
? normalizedFallbackSrc
|
? normalizedFallbackSrc
|
||||||
: resolvedUrl || normalizedFallbackSrc;
|
: resolvedUrl || normalizedFallbackSrc;
|
||||||
|
const pendingMarker = (
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={normalizedSource}
|
||||||
|
kind="image"
|
||||||
|
isPending={shouldResolve && isResolving}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUseFallbackSrc(false);
|
setUseFallbackSrc(false);
|
||||||
}, [normalizedFallbackSrc, resolvedUrl]);
|
}, [normalizedFallbackSrc, resolvedUrl]);
|
||||||
|
|
||||||
if (!finalSrc) {
|
if (!finalSrc) {
|
||||||
return null;
|
return pendingMarker;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<>
|
||||||
{...rest}
|
{pendingMarker}
|
||||||
src={finalSrc}
|
<img
|
||||||
alt={alt}
|
{...rest}
|
||||||
onError={(event) => {
|
src={finalSrc}
|
||||||
if (
|
alt={alt}
|
||||||
normalizedFallbackSrc &&
|
onError={(event) => {
|
||||||
!useFallbackSrc &&
|
if (
|
||||||
finalSrc !== normalizedFallbackSrc
|
normalizedFallbackSrc &&
|
||||||
) {
|
!useFallbackSrc &&
|
||||||
setUseFallbackSrc(true);
|
finalSrc !== normalizedFallbackSrc
|
||||||
}
|
) {
|
||||||
onError?.(event);
|
setUseFallbackSrc(true);
|
||||||
}}
|
}
|
||||||
/>
|
onError?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/components/common/RuntimeResourcePendingMarker.tsx
Normal file
29
src/components/common/RuntimeResourcePendingMarker.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
type RuntimeResourcePendingMarkerProps = {
|
||||||
|
source?: string | null;
|
||||||
|
isPending?: boolean;
|
||||||
|
kind?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RUNTIME_RESOURCE_PENDING_SELECTOR =
|
||||||
|
'[data-runtime-resource-pending="true"]';
|
||||||
|
|
||||||
|
export function RuntimeResourcePendingMarker({
|
||||||
|
source,
|
||||||
|
isPending = true,
|
||||||
|
kind,
|
||||||
|
}: RuntimeResourcePendingMarkerProps) {
|
||||||
|
const normalizedSource = source?.trim() ?? '';
|
||||||
|
if (!isPending || !normalizedSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
hidden
|
||||||
|
aria-hidden="true"
|
||||||
|
data-runtime-resource-pending="true"
|
||||||
|
data-runtime-resource-src={normalizedSource}
|
||||||
|
data-runtime-resource-kind={kind}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,12 +30,13 @@ import {
|
|||||||
getJumpHopRunDurationMs,
|
getJumpHopRunDurationMs,
|
||||||
getJumpHopStatusLabel,
|
getJumpHopStatusLabel,
|
||||||
getJumpHopTileTone,
|
getJumpHopTileTone,
|
||||||
selectJumpHopTileAsset,
|
|
||||||
type JumpHopCharacterVisualPosition,
|
type JumpHopCharacterVisualPosition,
|
||||||
type JumpHopVisiblePlatform,
|
type JumpHopVisiblePlatform,
|
||||||
resolveJumpHopCharacterCanvasPosition,
|
resolveJumpHopCharacterCanvasPosition,
|
||||||
|
selectJumpHopTileAsset,
|
||||||
} from '../../services/jump-hop/jumpHopRuntimeModel';
|
} from '../../services/jump-hop/jumpHopRuntimeModel';
|
||||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||||
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||||
|
|
||||||
type JumpHopRuntimeJumpPayload = {
|
type JumpHopRuntimeJumpPayload = {
|
||||||
dragDistance: number;
|
dragDistance: number;
|
||||||
@@ -218,9 +219,12 @@ function JumpHopTileImage({
|
|||||||
platform: JumpHopVisiblePlatform['platform'];
|
platform: JumpHopVisiblePlatform['platform'];
|
||||||
}) {
|
}) {
|
||||||
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
||||||
const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, {
|
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(
|
||||||
refreshKey: assetRefreshKey,
|
asset?.imageSrc,
|
||||||
});
|
{
|
||||||
|
refreshKey: assetRefreshKey,
|
||||||
|
},
|
||||||
|
);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
@@ -234,6 +238,11 @@ function JumpHopTileImage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jump-hop-runtime__tile-image-stack">
|
<div className="jump-hop-runtime__tile-image-stack">
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={asset?.imageSrc}
|
||||||
|
kind="image"
|
||||||
|
isPending={isResolving}
|
||||||
|
/>
|
||||||
{shouldShowFallback ? <IsometricFallbackTile platform={platform} /> : null}
|
{shouldShowFallback ? <IsometricFallbackTile platform={platform} /> : null}
|
||||||
{shouldShowImage ? (
|
{shouldShowImage ? (
|
||||||
<img
|
<img
|
||||||
@@ -258,23 +267,36 @@ function JumpHopTileImage({
|
|||||||
|
|
||||||
function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) {
|
function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) {
|
||||||
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
||||||
const { resolvedUrl } = useResolvedAssetReadUrl(asset.imageSrc, {
|
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(asset.imageSrc, {
|
||||||
refreshKey: assetRefreshKey,
|
refreshKey: assetRefreshKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resolvedUrl) {
|
if (!resolvedUrl) {
|
||||||
return null;
|
return (
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={asset.imageSrc}
|
||||||
|
kind="image"
|
||||||
|
isPending={isResolving}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<>
|
||||||
src={resolvedUrl}
|
<RuntimeResourcePendingMarker
|
||||||
alt=""
|
source={asset.imageSrc}
|
||||||
aria-hidden="true"
|
kind="image"
|
||||||
draggable={false}
|
isPending={isResolving}
|
||||||
data-testid="jump-hop-tile-preload-image"
|
/>
|
||||||
className="jump-hop-runtime__tile-preload-image"
|
<img
|
||||||
/>
|
src={resolvedUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
draggable={false}
|
||||||
|
data-testid="jump-hop-tile-preload-image"
|
||||||
|
className="jump-hop-runtime__tile-preload-image"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,26 +641,26 @@ export function JumpHopRuntimeShell({
|
|||||||
profile?.draft.coverComposite,
|
profile?.draft.coverComposite,
|
||||||
profile?.summary.coverImageSrc,
|
profile?.summary.coverImageSrc,
|
||||||
].find(isJumpHopGeneratedBackgroundSource);
|
].find(isJumpHopGeneratedBackgroundSource);
|
||||||
const { resolvedUrl: stageBackgroundUrl } = useResolvedAssetReadUrl(
|
const {
|
||||||
stageBackgroundSource,
|
resolvedUrl: stageBackgroundUrl,
|
||||||
{
|
isResolving: isStageBackgroundResolving,
|
||||||
refreshKey: stageBackgroundSource,
|
} = useResolvedAssetReadUrl(stageBackgroundSource, {
|
||||||
},
|
refreshKey: stageBackgroundSource,
|
||||||
);
|
});
|
||||||
const backButtonAssetSource =
|
const backButtonAssetSource =
|
||||||
profile?.backButtonAsset?.imageSrc?.trim() ||
|
profile?.backButtonAsset?.imageSrc?.trim() ||
|
||||||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
|
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
|
||||||
null;
|
null;
|
||||||
const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl(
|
const {
|
||||||
backButtonAssetSource,
|
resolvedUrl: backButtonAssetUrl,
|
||||||
{
|
isResolving: isBackButtonAssetResolving,
|
||||||
refreshKey:
|
} = useResolvedAssetReadUrl(backButtonAssetSource, {
|
||||||
profile?.backButtonAsset?.assetObjectId ||
|
refreshKey:
|
||||||
profile?.draft.backButtonAsset?.assetObjectId ||
|
profile?.backButtonAsset?.assetObjectId ||
|
||||||
backButtonAssetSource ||
|
profile?.draft.backButtonAsset?.assetObjectId ||
|
||||||
undefined,
|
backButtonAssetSource ||
|
||||||
},
|
undefined,
|
||||||
);
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRunRef.current = activeRun;
|
activeRunRef.current = activeRun;
|
||||||
@@ -1313,6 +1335,16 @@ export function JumpHopRuntimeShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="platform-remap-surface jump-hop-runtime relative h-full min-h-dvh w-full overflow-hidden bg-[#fffdf9] text-slate-950">
|
<div className="platform-remap-surface jump-hop-runtime relative h-full min-h-dvh w-full overflow-hidden bg-[#fffdf9] text-slate-950">
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={stageBackgroundSource}
|
||||||
|
kind="image"
|
||||||
|
isPending={isStageBackgroundResolving}
|
||||||
|
/>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={backButtonAssetSource}
|
||||||
|
kind="image"
|
||||||
|
isPending={isBackButtonAssetResolving}
|
||||||
|
/>
|
||||||
<section
|
<section
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
data-testid="jump-hop-stage"
|
data-testid="jump-hop-stage"
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
readAssetBytes,
|
readAssetBytes,
|
||||||
resolveAssetReadUrl,
|
resolveAssetReadUrl,
|
||||||
} from '../../services/assetReadUrlService';
|
} from '../../services/assetReadUrlService';
|
||||||
|
import {
|
||||||
|
buildMatch3DTrayInsertionPlan,
|
||||||
|
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||||
|
syncMatch3DItemTraySlotIndexes,
|
||||||
|
} from '../../services/match3d-runtime/match3dTrayLayout';
|
||||||
import {
|
import {
|
||||||
getMatch3DGeneratedImageViewSources,
|
getMatch3DGeneratedImageViewSources,
|
||||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||||
@@ -41,11 +46,6 @@ import {
|
|||||||
loadMatch3DSpritesheetAssetRegions,
|
loadMatch3DSpritesheetAssetRegions,
|
||||||
type Match3DDecodedSpritesheetRegion,
|
type Match3DDecodedSpritesheetRegion,
|
||||||
} from '../../services/match3dSpritesheetParser';
|
} from '../../services/match3dSpritesheetParser';
|
||||||
import {
|
|
||||||
buildMatch3DTrayInsertionPlan,
|
|
||||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
|
||||||
syncMatch3DItemTraySlotIndexes,
|
|
||||||
} from '../../services/match3d-runtime/match3dTrayLayout';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||||
playRuntimeClickSound,
|
playRuntimeClickSound,
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
resolveRuntimeCountdownSecondBucket,
|
resolveRuntimeCountdownSecondBucket,
|
||||||
} from '../../services/runtimeAudioFeedback';
|
} from '../../services/runtimeAudioFeedback';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||||
import {
|
import {
|
||||||
findMatch3DHitItem,
|
findMatch3DHitItem,
|
||||||
type Match3DAlphaHitMask,
|
type Match3DAlphaHitMask,
|
||||||
@@ -398,12 +399,6 @@ function indexMatch3DUiSpritesheetRegions(
|
|||||||
return new Map(regions.map((region) => [region.label, region]));
|
return new Map(regions.map((region) => [region.label, region]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMatch3DImageReadUrlCacheKey(
|
|
||||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
|
||||||
) {
|
|
||||||
return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMatch3DImageReadUrlSources(
|
function resolveMatch3DImageReadUrlSources(
|
||||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||||
) {
|
) {
|
||||||
@@ -1047,6 +1042,10 @@ export function Match3DRuntimeShell({
|
|||||||
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
|
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
|
||||||
Match3DItemSpritesheetViewGroup[]
|
Match3DItemSpritesheetViewGroup[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [isUiSpritesheetResolving, setIsUiSpritesheetResolving] =
|
||||||
|
useState(false);
|
||||||
|
const [isItemSpritesheetResolving, setIsItemSpritesheetResolving] =
|
||||||
|
useState(false);
|
||||||
const uiSpritesheetRegionByLabel = useMemo(
|
const uiSpritesheetRegionByLabel = useMemo(
|
||||||
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
|
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
|
||||||
[uiSpritesheetRegions],
|
[uiSpritesheetRegions],
|
||||||
@@ -1077,11 +1076,13 @@ export function Match3DRuntimeShell({
|
|||||||
setUiSpritesheetRegions((current) =>
|
setUiSpritesheetRegions((current) =>
|
||||||
current.length > 0 ? [] : current,
|
current.length > 0 ? [] : current,
|
||||||
);
|
);
|
||||||
|
setIsUiSpritesheetResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
setIsUiSpritesheetResolving(true);
|
||||||
void loadMatch3DSpritesheetAssetRegions({
|
void loadMatch3DSpritesheetAssetRegions({
|
||||||
source: uiSpritesheetSource,
|
source: uiSpritesheetSource,
|
||||||
labels: MATCH3D_UI_SPRITESHEET_LABELS,
|
labels: MATCH3D_UI_SPRITESHEET_LABELS,
|
||||||
@@ -1093,11 +1094,13 @@ export function Match3DRuntimeShell({
|
|||||||
.then((regions) => {
|
.then((regions) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUiSpritesheetRegions(regions);
|
setUiSpritesheetRegions(regions);
|
||||||
|
setIsUiSpritesheetResolving(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setUiSpritesheetRegions([]);
|
setUiSpritesheetRegions([]);
|
||||||
|
setIsUiSpritesheetResolving(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1112,11 +1115,13 @@ export function Match3DRuntimeShell({
|
|||||||
setItemSpritesheetViewGroups((current) =>
|
setItemSpritesheetViewGroups((current) =>
|
||||||
current.length > 0 ? [] : current,
|
current.length > 0 ? [] : current,
|
||||||
);
|
);
|
||||||
|
setIsItemSpritesheetResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
setIsItemSpritesheetResolving(true);
|
||||||
void loadMatch3DSpritesheetAssetRegions({
|
void loadMatch3DSpritesheetAssetRegions({
|
||||||
source: itemSpritesheetSource,
|
source: itemSpritesheetSource,
|
||||||
maxRegions: 100,
|
maxRegions: 100,
|
||||||
@@ -1132,11 +1137,13 @@ export function Match3DRuntimeShell({
|
|||||||
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
|
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
setIsItemSpritesheetResolving(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setItemSpritesheetViewGroups([]);
|
setItemSpritesheetViewGroups([]);
|
||||||
|
setIsItemSpritesheetResolving(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1315,13 +1322,17 @@ export function Match3DRuntimeShell({
|
|||||||
),
|
),
|
||||||
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
|
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
|
||||||
);
|
);
|
||||||
|
const imageReadUrlSources = useMemo(
|
||||||
|
() => resolveMatch3DImageReadUrlSources(imageSourcesByType),
|
||||||
|
[imageSourcesByType],
|
||||||
|
);
|
||||||
const itemSizeByType = useMemo(
|
const itemSizeByType = useMemo(
|
||||||
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
||||||
[runtimeGeneratedItemAssets, run],
|
[runtimeGeneratedItemAssets, run],
|
||||||
);
|
);
|
||||||
const imageReadUrlCacheKey = useMemo(
|
const imageReadUrlCacheKey = useMemo(
|
||||||
() => resolveMatch3DImageReadUrlCacheKey(imageSourcesByType),
|
() => imageReadUrlSources.join('|'),
|
||||||
[imageSourcesByType],
|
[imageReadUrlSources],
|
||||||
);
|
);
|
||||||
const [resolvedImageSources, setResolvedImageSources] = useState<
|
const [resolvedImageSources, setResolvedImageSources] = useState<
|
||||||
Map<string, string>
|
Map<string, string>
|
||||||
@@ -1329,6 +1340,8 @@ export function Match3DRuntimeShell({
|
|||||||
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
|
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
|
const [isImageSourcesResolving, setIsImageSourcesResolving] =
|
||||||
|
useState(false);
|
||||||
const resolvedImageSourceEntriesByType = useMemo(
|
const resolvedImageSourceEntriesByType = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildResolvedMatch3DImageSourceEntriesByType(
|
buildResolvedMatch3DImageSourceEntriesByType(
|
||||||
@@ -1354,6 +1367,12 @@ export function Match3DRuntimeShell({
|
|||||||
useState('');
|
useState('');
|
||||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
|
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
|
||||||
useState('');
|
useState('');
|
||||||
|
const [isBackgroundMusicResolving, setIsBackgroundMusicResolving] =
|
||||||
|
useState(false);
|
||||||
|
const [isBackgroundImageResolving, setIsBackgroundImageResolving] =
|
||||||
|
useState(false);
|
||||||
|
const [isContainerImageResolving, setIsContainerImageResolving] =
|
||||||
|
useState(false);
|
||||||
const clickSoundByTypeId = useMemo(() => {
|
const clickSoundByTypeId = useMemo(() => {
|
||||||
if (!run) {
|
if (!run) {
|
||||||
return new Map<string, string>();
|
return new Map<string, string>();
|
||||||
@@ -1397,16 +1416,19 @@ export function Match3DRuntimeShell({
|
|||||||
const source = backgroundMusicSrc?.trim() ?? '';
|
const source = backgroundMusicSrc?.trim() ?? '';
|
||||||
if (!source) {
|
if (!source) {
|
||||||
setResolvedBackgroundMusicSrc('');
|
setResolvedBackgroundMusicSrc('');
|
||||||
|
setIsBackgroundMusicResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!isGeneratedLegacyPath(source)) {
|
if (!isGeneratedLegacyPath(source)) {
|
||||||
setResolvedBackgroundMusicSrc(source);
|
setResolvedBackgroundMusicSrc(source);
|
||||||
|
setIsBackgroundMusicResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setResolvedBackgroundMusicSrc('');
|
setResolvedBackgroundMusicSrc('');
|
||||||
|
setIsBackgroundMusicResolving(true);
|
||||||
void resolveAssetReadUrl(source, {
|
void resolveAssetReadUrl(source, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
expireSeconds: 300,
|
expireSeconds: 300,
|
||||||
@@ -1420,6 +1442,11 @@ export function Match3DRuntimeShell({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResolvedBackgroundMusicSrc('');
|
setResolvedBackgroundMusicSrc('');
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsBackgroundMusicResolving(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1439,15 +1466,19 @@ export function Match3DRuntimeShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!backgroundAssetSrc) {
|
if (!backgroundAssetSrc) {
|
||||||
setResolvedBackgroundImageSrc('');
|
setResolvedBackgroundImageSrc('');
|
||||||
|
setIsBackgroundImageResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
|
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
|
||||||
setResolvedBackgroundImageSrc(backgroundAssetSrc);
|
setResolvedBackgroundImageSrc(backgroundAssetSrc);
|
||||||
|
setIsBackgroundImageResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
setResolvedBackgroundImageSrc('');
|
||||||
|
setIsBackgroundImageResolving(true);
|
||||||
void resolveAssetReadUrl(backgroundAssetSrc, {
|
void resolveAssetReadUrl(backgroundAssetSrc, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
expireSeconds: 300,
|
expireSeconds: 300,
|
||||||
@@ -1461,6 +1492,11 @@ export function Match3DRuntimeShell({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResolvedBackgroundImageSrc('');
|
setResolvedBackgroundImageSrc('');
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsBackgroundImageResolving(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1475,8 +1511,10 @@ export function Match3DRuntimeShell({
|
|||||||
setResolvedContainerImageSrc('');
|
setResolvedContainerImageSrc('');
|
||||||
if (!isGeneratedLegacyPath(containerAssetSrc)) {
|
if (!isGeneratedLegacyPath(containerAssetSrc)) {
|
||||||
setResolvedContainerImageSrc(containerAssetSrc);
|
setResolvedContainerImageSrc(containerAssetSrc);
|
||||||
|
setIsContainerImageResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
setIsContainerImageResolving(true);
|
||||||
void resolveAssetReadUrl(containerAssetSrc, {
|
void resolveAssetReadUrl(containerAssetSrc, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
expireSeconds: 300,
|
expireSeconds: 300,
|
||||||
@@ -1494,6 +1532,11 @@ export function Match3DRuntimeShell({
|
|||||||
: MATCH3D_CONTAINER_REFERENCE_SRC,
|
: MATCH3D_CONTAINER_REFERENCE_SRC,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsContainerImageResolving(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1503,7 +1546,7 @@ export function Match3DRuntimeShell({
|
|||||||
}, [containerAssetSrc]);
|
}, [containerAssetSrc]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rawSources = resolveMatch3DImageReadUrlSources(imageSourcesByType);
|
const rawSources = imageReadUrlSources;
|
||||||
if (rawSources.length <= 0) {
|
if (rawSources.length <= 0) {
|
||||||
setResolvedImageSources((current) =>
|
setResolvedImageSources((current) =>
|
||||||
current.size > 0 ? new Map() : current,
|
current.size > 0 ? new Map() : current,
|
||||||
@@ -1511,6 +1554,7 @@ export function Match3DRuntimeShell({
|
|||||||
setFailedImageSources((current) =>
|
setFailedImageSources((current) =>
|
||||||
current.size > 0 ? new Set() : current,
|
current.size > 0 ? new Set() : current,
|
||||||
);
|
);
|
||||||
|
setIsImageSourcesResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1519,6 +1563,7 @@ export function Match3DRuntimeShell({
|
|||||||
setFailedImageSources((current) =>
|
setFailedImageSources((current) =>
|
||||||
current.size > 0 ? new Set() : current,
|
current.size > 0 ? new Set() : current,
|
||||||
);
|
);
|
||||||
|
setIsImageSourcesResolving(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,6 +1580,7 @@ export function Match3DRuntimeShell({
|
|||||||
return retained;
|
return retained;
|
||||||
});
|
});
|
||||||
setFailedImageSources(new Set());
|
setFailedImageSources(new Set());
|
||||||
|
setIsImageSourcesResolving(true);
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
rawSources.map(async (source) => {
|
rawSources.map(async (source) => {
|
||||||
if (nextSources.has(source)) {
|
if (nextSources.has(source)) {
|
||||||
@@ -1565,13 +1611,18 @@ export function Match3DRuntimeShell({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setFailedImageSources(new Set(rawSources));
|
setFailedImageSources(new Set(rawSources));
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsImageSourcesResolving(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [imageReadUrlCacheKey, imageSourcesByType]);
|
}, [imageReadUrlCacheKey, imageReadUrlSources]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rawSources = alphaHitMaskCacheKey
|
const rawSources = alphaHitMaskCacheKey
|
||||||
@@ -1925,6 +1976,44 @@ export function Match3DRuntimeShell({
|
|||||||
<main
|
<main
|
||||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
||||||
>
|
>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={backgroundMusicSrc}
|
||||||
|
kind="audio"
|
||||||
|
isPending={isBackgroundMusicResolving}
|
||||||
|
/>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={backgroundAssetSrc}
|
||||||
|
kind="image"
|
||||||
|
isPending={isBackgroundImageResolving}
|
||||||
|
/>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={containerAssetSrc}
|
||||||
|
kind="image"
|
||||||
|
isPending={isContainerImageResolving}
|
||||||
|
/>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={uiSpritesheetSource}
|
||||||
|
kind="image"
|
||||||
|
isPending={isUiSpritesheetResolving}
|
||||||
|
/>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={itemSpritesheetSource}
|
||||||
|
kind="image"
|
||||||
|
isPending={isItemSpritesheetResolving}
|
||||||
|
/>
|
||||||
|
{imageReadUrlSources.map((source) => (
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
key={`match3d-runtime-resource:${source}`}
|
||||||
|
source={source}
|
||||||
|
kind="image"
|
||||||
|
isPending={
|
||||||
|
isImageSourcesResolving &&
|
||||||
|
isGeneratedLegacyPath(source) &&
|
||||||
|
!resolvedImageSources.has(source) &&
|
||||||
|
!failedImageSources.has(source)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||||
{resolvedBackgroundImageSrc ? (
|
{resolvedBackgroundImageSrc ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
|
import { loadPuzzleUiSpritesheetLayout } from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
|
||||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||||
import {
|
import {
|
||||||
buildMergedGroupOutlinePath,
|
buildMergedGroupOutlinePath,
|
||||||
@@ -938,6 +939,48 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关
|
|||||||
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
|
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('运行态 UI spritesheet 解析失败后释放推荐页资源等待标记', async () => {
|
||||||
|
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(
|
||||||
|
<PuzzleRuntimeShell
|
||||||
|
run={runWithSpritesheet}
|
||||||
|
onBack={vi.fn()}
|
||||||
|
onSwapPieces={vi.fn()}
|
||||||
|
onDragPiece={vi.fn()}
|
||||||
|
onAdvanceNextLevel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 时仍渲染生成背景', () => {
|
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
|
||||||
const runWithUiBackground: PuzzleRunSnapshot = {
|
const runWithUiBackground: PuzzleRunSnapshot = {
|
||||||
...clearedRun,
|
...clearedRun,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
resolveRuntimeCountdownSecondBucket,
|
resolveRuntimeCountdownSecondBucket,
|
||||||
} from '../../services/runtimeAudioFeedback';
|
} from '../../services/runtimeAudioFeedback';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import {
|
import {
|
||||||
buildMergedGroupOutlinePath,
|
buildMergedGroupOutlinePath,
|
||||||
@@ -475,6 +476,8 @@ export function PuzzleRuntimeShell({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
|
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
|
||||||
useState<PuzzleUiSpritesheetLayout | null>(null);
|
useState<PuzzleUiSpritesheetLayout | null>(null);
|
||||||
|
const [isUiSpritesheetLayoutResolving, setIsUiSpritesheetLayoutResolving] =
|
||||||
|
useState(false);
|
||||||
const runtimeDragInputControllerRef = useRef(
|
const runtimeDragInputControllerRef = useRef(
|
||||||
createRuntimeDragInputController<string>(),
|
createRuntimeDragInputController<string>(),
|
||||||
);
|
);
|
||||||
@@ -526,22 +529,28 @@ export function PuzzleRuntimeShell({
|
|||||||
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
||||||
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
||||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||||
const { resolvedUrl: resolvedBackgroundMusicSrc } =
|
const {
|
||||||
useResolvedAssetReadUrl(backgroundMusicSrc);
|
resolvedUrl: resolvedBackgroundMusicSrc,
|
||||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
isResolving: isBackgroundMusicResolving,
|
||||||
|
} = useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||||
|
const { resolvedUrl: resolvedCoverImage, isResolving: isCoverImageResolving } =
|
||||||
|
useResolvedAssetReadUrl(
|
||||||
currentLevel?.coverImageSrc ?? null,
|
currentLevel?.coverImageSrc ?? null,
|
||||||
);
|
);
|
||||||
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
|
const rawUiBackgroundImage = resolvePuzzleUiBackgroundSource(currentLevel);
|
||||||
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
|
const {
|
||||||
);
|
resolvedUrl: resolvedUiBackgroundImage,
|
||||||
|
isResolving: isUiBackgroundResolving,
|
||||||
|
} = useResolvedAssetReadUrl(rawUiBackgroundImage ?? null);
|
||||||
const rawUiSpritesheetImage =
|
const rawUiSpritesheetImage =
|
||||||
currentLevel?.uiSpritesheetImageSrc?.trim() ||
|
currentLevel?.uiSpritesheetImageSrc?.trim() ||
|
||||||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
|
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
|
||||||
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
|
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
|
||||||
: null);
|
: null);
|
||||||
const { resolvedUrl: resolvedUiSpritesheetImage } = useResolvedAssetReadUrl(
|
const {
|
||||||
rawUiSpritesheetImage,
|
resolvedUrl: resolvedUiSpritesheetImage,
|
||||||
);
|
isResolving: isUiSpritesheetResolving,
|
||||||
|
} = useResolvedAssetReadUrl(rawUiSpritesheetImage);
|
||||||
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
|
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
|
||||||
const tryPlayBackgroundMusic = useCallback(() => {
|
const tryPlayBackgroundMusic = useCallback(() => {
|
||||||
const audio = backgroundAudioRef.current;
|
const audio = backgroundAudioRef.current;
|
||||||
@@ -558,23 +567,27 @@ export function PuzzleRuntimeShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawUiSpritesheetImage) {
|
if (!rawUiSpritesheetImage) {
|
||||||
setUiSpritesheetLayout(null);
|
setUiSpritesheetLayout(null);
|
||||||
|
setIsUiSpritesheetLayoutResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setUiSpritesheetLayout(null);
|
setUiSpritesheetLayout(null);
|
||||||
|
setIsUiSpritesheetLayoutResolving(true);
|
||||||
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
|
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then((layout) => {
|
.then((layout) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setUiSpritesheetLayout(layout);
|
setUiSpritesheetLayout(layout);
|
||||||
|
setIsUiSpritesheetLayoutResolving(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
|
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
|
||||||
setUiSpritesheetLayout(null);
|
setUiSpritesheetLayout(null);
|
||||||
|
setIsUiSpritesheetLayoutResolving(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1523,6 +1536,26 @@ export function PuzzleRuntimeShell({
|
|||||||
<div
|
<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`}
|
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 ? (
|
{resolvedBackgroundMusicSrc ? (
|
||||||
<audio
|
<audio
|
||||||
ref={backgroundAudioRef}
|
ref={backgroundAudioRef}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
within,
|
within,
|
||||||
} from '@testing-library/react';
|
} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -4095,6 +4095,11 @@ test('mobile recommend runtime cover does not swap to a late signed cover', asyn
|
|||||||
test('mobile recommend cover waits until runtime images are ready', async () => {
|
test('mobile recommend cover waits until runtime images are ready', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const animationCallbacks: FrameRequestCallback[] = [];
|
const animationCallbacks: FrameRequestCallback[] = [];
|
||||||
|
const flushAnimationFrames = () => {
|
||||||
|
act(() => {
|
||||||
|
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||||
|
});
|
||||||
|
};
|
||||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -4137,6 +4142,102 @@ test('mobile recommend cover waits until runtime images are ready', async () =>
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(520);
|
vi.advanceTimersByTime(520);
|
||||||
});
|
});
|
||||||
|
flushAnimationFrames();
|
||||||
|
flushAnimationFrames();
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
|
).toContain('platform-recommend-runtime-cover--hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile recommend cover waits for async runtime resources beyond the main image', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const animationCallbacks: FrameRequestCallback[] = [];
|
||||||
|
const flushAnimationFrames = () => {
|
||||||
|
act(() => {
|
||||||
|
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
animationCallbacks.push(callback);
|
||||||
|
return animationCallbacks.length;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function AsyncRuntimeAssets() {
|
||||||
|
const [showExtraResource, setShowExtraResource] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setShowExtraResource(true);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<img src="/runtime-main.png" alt="runtime main" />
|
||||||
|
{showExtraResource ? (
|
||||||
|
<img src="/runtime-extra.png" alt="runtime extra" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
hidden
|
||||||
|
data-runtime-resource-pending="true"
|
||||||
|
data-runtime-resource-src="/runtime-extra.png"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
|
isRecommendRuntimeReady: true,
|
||||||
|
recommendRuntimeContent: <AsyncRuntimeAssets />,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.load(screen.getByAltText('runtime main'));
|
||||||
|
flushAnimationFrames();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
await act(async () => undefined);
|
||||||
|
flushAnimationFrames();
|
||||||
|
flushAnimationFrames();
|
||||||
|
expect(screen.getByAltText('runtime extra')).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
});
|
||||||
|
flushAnimationFrames();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(520);
|
||||||
|
});
|
||||||
|
flushAnimationFrames();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
|
).not.toContain('platform-recommend-runtime-cover--hidden');
|
||||||
|
|
||||||
|
fireEvent.load(screen.getByAltText('runtime extra'));
|
||||||
|
flushAnimationFrames();
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
});
|
||||||
|
flushAnimationFrames();
|
||||||
|
flushAnimationFrames();
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
@@ -4144,7 +4245,24 @@ test('mobile recommend cover waits until runtime images are ready', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend keeps runtime visual stable when active entry changes', async () => {
|
test('mobile recommend keeps runtime visual stable when active entry changes', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const animationCallbacks: FrameRequestCallback[] = [];
|
const animationCallbacks: FrameRequestCallback[] = [];
|
||||||
|
const flushAnimationFrames = () => {
|
||||||
|
act(() => {
|
||||||
|
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const settleRecommendRuntimeReady = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
});
|
||||||
|
flushAnimationFrames();
|
||||||
|
flushAnimationFrames();
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(520);
|
||||||
|
});
|
||||||
|
await act(async () => undefined);
|
||||||
|
};
|
||||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -4183,14 +4301,10 @@ test('mobile recommend keeps runtime visual stable when active entry changes', a
|
|||||||
isRecommendRuntimeReady: true,
|
isRecommendRuntimeReady: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
await settleRecommendRuntimeReady();
|
||||||
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
expect(
|
||||||
});
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
await waitFor(() => {
|
).toContain('platform-recommend-runtime-cover--hidden');
|
||||||
expect(
|
|
||||||
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
|
||||||
).toContain('platform-recommend-runtime-cover--hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<AuthUiContext.Provider
|
<AuthUiContext.Provider
|
||||||
@@ -4243,6 +4357,7 @@ test('mobile recommend keeps runtime visual stable when active entry changes', a
|
|||||||
/>
|
/>
|
||||||
</AuthUiContext.Provider>,
|
</AuthUiContext.Provider>,
|
||||||
);
|
);
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
const rail = document.querySelector(
|
const rail = document.querySelector(
|
||||||
'.platform-recommend-swipe-rail',
|
'.platform-recommend-swipe-rail',
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import {
|
|||||||
LEGAL_DOCUMENTS,
|
LEGAL_DOCUMENTS,
|
||||||
type LegalDocumentId,
|
type LegalDocumentId,
|
||||||
} from '../common/legalDocuments';
|
} from '../common/legalDocuments';
|
||||||
|
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
|
||||||
import {
|
import {
|
||||||
buildCenteredSquareImageCropRect,
|
buildCenteredSquareImageCropRect,
|
||||||
clampSquareImageCropRect,
|
clampSquareImageCropRect,
|
||||||
@@ -267,6 +268,8 @@ const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
|||||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||||
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
|
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
|
||||||
|
const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80;
|
||||||
|
const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2;
|
||||||
|
|
||||||
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
|
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
|
||||||
|
|
||||||
@@ -438,19 +441,67 @@ function useResolvedRecommendCoverImages(
|
|||||||
return resolvedCoverUrls;
|
return resolvedCoverUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRecommendRuntimeReady(
|
function scheduleRecommendRuntimeReady(signal: AbortSignal, onReady: () => void) {
|
||||||
signal: AbortSignal,
|
|
||||||
onReady: (value: boolean) => void,
|
|
||||||
) {
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.requestAnimationFrame(() => {
|
let animationFrameId: number | null = null;
|
||||||
if (!signal.aborted) {
|
let remainingFrameCount = RECOMMEND_RUNTIME_READY_FRAME_COUNT;
|
||||||
onReady(true);
|
const tick = () => {
|
||||||
|
animationFrameId = null;
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingFrameCount -= 1;
|
||||||
|
if (remainingFrameCount <= 0) {
|
||||||
|
onReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecommendRuntimeImageSource(image: HTMLImageElement) {
|
||||||
|
return (
|
||||||
|
image.currentSrc ||
|
||||||
|
image.getAttribute('src') ||
|
||||||
|
image.getAttribute('srcset') ||
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecommendRuntimeMediaSource(media: HTMLMediaElement) {
|
||||||
|
return (media.currentSrc || media.getAttribute('src') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRecommendRuntimeBackgroundUrls(root: HTMLElement) {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
root.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
|
||||||
|
const backgroundImage = element.style.backgroundImage;
|
||||||
|
if (!backgroundImage || backgroundImage === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = /url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/giu;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(backgroundImage))) {
|
||||||
|
const url = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||||||
|
if (url) {
|
||||||
|
urls.add(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readyRecommendRuntime(
|
function readyRecommendRuntime(
|
||||||
@@ -461,63 +512,312 @@ function readyRecommendRuntime(
|
|||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingImages = Array.from(root.querySelectorAll('img')).filter(
|
const runtimeRoot = root;
|
||||||
(image) => !image.complete,
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let animationFrameId: number | null = null;
|
let scanFrameId: number | null = null;
|
||||||
|
let readyIdleTimeoutId: number | null = null;
|
||||||
|
let pendingRecheckTimeoutId: number | null = null;
|
||||||
|
let readyFrameCleanup: (() => void) | null = null;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const cleanupCallbacks: Array<() => void> = [];
|
const cleanupCallbacks: Array<() => void> = [];
|
||||||
|
const pendingImageListeners = new Map<
|
||||||
|
HTMLImageElement,
|
||||||
|
{ src: string; cleanup: () => void }
|
||||||
|
>();
|
||||||
|
const pendingMediaListeners = new Map<
|
||||||
|
HTMLMediaElement,
|
||||||
|
{ src: string; cleanup: () => void }
|
||||||
|
>();
|
||||||
|
const settledImageSources = new WeakMap<HTMLImageElement, string>();
|
||||||
|
const settledMediaSources = new WeakMap<HTMLMediaElement, string>();
|
||||||
|
const loadedBackgroundUrls = new Set<string>();
|
||||||
|
const pendingBackgroundPreloads = new Map<string, () => void>();
|
||||||
|
|
||||||
|
const cancelReadySchedule = () => {
|
||||||
|
if (readyIdleTimeoutId !== null) {
|
||||||
|
window.clearTimeout(readyIdleTimeoutId);
|
||||||
|
readyIdleTimeoutId = null;
|
||||||
|
}
|
||||||
|
if (readyFrameCleanup) {
|
||||||
|
readyFrameCleanup();
|
||||||
|
readyFrameCleanup = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancelPendingRecheck = () => {
|
||||||
|
if (pendingRecheckTimeoutId !== null) {
|
||||||
|
window.clearTimeout(pendingRecheckTimeoutId);
|
||||||
|
pendingRecheckTimeoutId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
const finish = (value: boolean) => {
|
const finish = (value: boolean) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
|
cancelReadySchedule();
|
||||||
|
cancelPendingRecheck();
|
||||||
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||||||
if (animationFrameId !== null) {
|
if (scanFrameId !== null) {
|
||||||
window.cancelAnimationFrame(animationFrameId);
|
window.cancelAnimationFrame(scanFrameId);
|
||||||
}
|
}
|
||||||
resolve(value);
|
resolve(value);
|
||||||
};
|
};
|
||||||
const abort = () => finish(false);
|
const abort = () => finish(false);
|
||||||
signal.addEventListener('abort', abort, { once: true });
|
signal.addEventListener('abort', abort, { once: true });
|
||||||
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
|
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
|
||||||
|
cleanupCallbacks.push(() => {
|
||||||
|
pendingImageListeners.forEach(({ cleanup }) => cleanup());
|
||||||
|
pendingImageListeners.clear();
|
||||||
|
pendingMediaListeners.forEach(({ cleanup }) => cleanup());
|
||||||
|
pendingMediaListeners.clear();
|
||||||
|
pendingBackgroundPreloads.forEach((cleanup) => cleanup());
|
||||||
|
pendingBackgroundPreloads.clear();
|
||||||
|
});
|
||||||
|
|
||||||
if (pendingImages.length === 0) {
|
const scheduleScan = () => {
|
||||||
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
if (settled) {
|
||||||
if (animationFrameId === null) {
|
|
||||||
finish(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let remaining = pendingImages.length;
|
|
||||||
const markImageReady = () => {
|
|
||||||
remaining -= 1;
|
|
||||||
if (remaining > 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
cancelReadySchedule();
|
||||||
if (animationFrameId === null) {
|
cancelPendingRecheck();
|
||||||
finish(false);
|
if (scanFrameId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scanFrameId = window.requestAnimationFrame(() => {
|
||||||
|
scanFrameId = null;
|
||||||
|
scanResources();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReady = () => {
|
||||||
|
cancelReadySchedule();
|
||||||
|
readyIdleTimeoutId = window.setTimeout(() => {
|
||||||
|
readyIdleTimeoutId = null;
|
||||||
|
readyFrameCleanup = scheduleRecommendRuntimeReady(signal, () =>
|
||||||
|
finish(true),
|
||||||
|
);
|
||||||
|
if (readyFrameCleanup === null) {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preloadBackgroundUrl = (url: string) => {
|
||||||
|
if (loadedBackgroundUrls.has(url) || pendingBackgroundPreloads.has(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof Image === 'undefined') {
|
||||||
|
loadedBackgroundUrls.add(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
const cleanup = () => {
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
};
|
||||||
|
const markReady = () => {
|
||||||
|
cleanup();
|
||||||
|
pendingBackgroundPreloads.delete(url);
|
||||||
|
loadedBackgroundUrls.add(url);
|
||||||
|
scheduleScan();
|
||||||
|
};
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = markReady;
|
||||||
|
image.onerror = markReady;
|
||||||
|
pendingBackgroundPreloads.set(url, cleanup);
|
||||||
|
image.src = url;
|
||||||
|
if (image.complete) {
|
||||||
|
markReady();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pendingImages.forEach((image) => {
|
function scanResources() {
|
||||||
const cleanupImageListeners = () => {
|
if (signal.aborted) {
|
||||||
image.removeEventListener('load', markImageReady);
|
finish(false);
|
||||||
image.removeEventListener('error', markImageReady);
|
return;
|
||||||
};
|
|
||||||
image.addEventListener('load', markImageReady, { once: true });
|
|
||||||
image.addEventListener('error', markImageReady, { once: true });
|
|
||||||
cleanupCallbacks.push(cleanupImageListeners);
|
|
||||||
|
|
||||||
if (image.complete) {
|
|
||||||
cleanupImageListeners();
|
|
||||||
markImageReady();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const currentImages = new Set(
|
||||||
|
Array.from(runtimeRoot.querySelectorAll<HTMLImageElement>('img')),
|
||||||
|
);
|
||||||
|
const currentMedia = new Set(
|
||||||
|
Array.from(
|
||||||
|
runtimeRoot.querySelectorAll<HTMLMediaElement>('audio,video'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pendingImageListeners.forEach((entry, image) => {
|
||||||
|
const currentSrc = getRecommendRuntimeImageSource(image);
|
||||||
|
if (!currentImages.has(image) || currentSrc !== entry.src || !currentSrc) {
|
||||||
|
entry.cleanup();
|
||||||
|
pendingImageListeners.delete(image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingMediaListeners.forEach((entry, media) => {
|
||||||
|
const currentSrc = getRecommendRuntimeMediaSource(media);
|
||||||
|
if (!currentMedia.has(media) || currentSrc !== entry.src || !currentSrc) {
|
||||||
|
entry.cleanup();
|
||||||
|
pendingMediaListeners.delete(media);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentImages.forEach((image) => {
|
||||||
|
const imageSrc = getRecommendRuntimeImageSource(image);
|
||||||
|
const settledImageSrc = settledImageSources.get(image);
|
||||||
|
if (settledImageSrc && settledImageSrc !== imageSrc) {
|
||||||
|
settledImageSources.delete(image);
|
||||||
|
}
|
||||||
|
if (!imageSrc || image.complete) {
|
||||||
|
if (imageSrc && image.complete) {
|
||||||
|
settledImageSources.set(image, imageSrc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settledImageSources.get(image) === imageSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = pendingImageListeners.get(image);
|
||||||
|
if (existingEntry?.src === imageSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingEntry?.cleanup();
|
||||||
|
const markImageReady = () => {
|
||||||
|
const activeEntry = pendingImageListeners.get(image);
|
||||||
|
if (activeEntry?.src !== imageSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settledImageSources.set(image, imageSrc);
|
||||||
|
activeEntry.cleanup();
|
||||||
|
pendingImageListeners.delete(image);
|
||||||
|
scheduleScan();
|
||||||
|
};
|
||||||
|
const cleanupImageListeners = () => {
|
||||||
|
image.removeEventListener('load', markImageReady);
|
||||||
|
image.removeEventListener('error', markImageReady);
|
||||||
|
};
|
||||||
|
image.addEventListener('load', markImageReady, { once: true });
|
||||||
|
image.addEventListener('error', markImageReady, { once: true });
|
||||||
|
pendingImageListeners.set(image, {
|
||||||
|
src: imageSrc,
|
||||||
|
cleanup: cleanupImageListeners,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
markImageReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
currentMedia.forEach((media) => {
|
||||||
|
const mediaSrc = getRecommendRuntimeMediaSource(media);
|
||||||
|
const settledMediaSrc = settledMediaSources.get(media);
|
||||||
|
const mediaReadyThreshold =
|
||||||
|
typeof HTMLMediaElement !== 'undefined'
|
||||||
|
? HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
: 2;
|
||||||
|
if (settledMediaSrc && settledMediaSrc !== mediaSrc) {
|
||||||
|
settledMediaSources.delete(media);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!mediaSrc ||
|
||||||
|
media.readyState >= mediaReadyThreshold ||
|
||||||
|
media.error
|
||||||
|
) {
|
||||||
|
if (mediaSrc && media.readyState >= mediaReadyThreshold) {
|
||||||
|
settledMediaSources.set(media, mediaSrc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settledMediaSources.get(media) === mediaSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = pendingMediaListeners.get(media);
|
||||||
|
if (existingEntry?.src === mediaSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingEntry?.cleanup();
|
||||||
|
const markMediaReady = () => {
|
||||||
|
const activeEntry = pendingMediaListeners.get(media);
|
||||||
|
if (activeEntry?.src !== mediaSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settledMediaSources.set(media, mediaSrc);
|
||||||
|
activeEntry.cleanup();
|
||||||
|
pendingMediaListeners.delete(media);
|
||||||
|
scheduleScan();
|
||||||
|
};
|
||||||
|
const cleanupMediaListeners = () => {
|
||||||
|
media.removeEventListener('loadeddata', markMediaReady);
|
||||||
|
media.removeEventListener('canplaythrough', markMediaReady);
|
||||||
|
media.removeEventListener('error', markMediaReady);
|
||||||
|
};
|
||||||
|
media.addEventListener('loadeddata', markMediaReady, { once: true });
|
||||||
|
media.addEventListener('canplaythrough', markMediaReady, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
media.addEventListener('error', markMediaReady, { once: true });
|
||||||
|
pendingMediaListeners.set(media, {
|
||||||
|
src: mediaSrc,
|
||||||
|
cleanup: cleanupMediaListeners,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (media.readyState >= mediaReadyThreshold || media.error) {
|
||||||
|
markMediaReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBackgroundUrls =
|
||||||
|
collectRecommendRuntimeBackgroundUrls(runtimeRoot);
|
||||||
|
pendingBackgroundPreloads.forEach((cleanup, url) => {
|
||||||
|
if (!currentBackgroundUrls.has(url)) {
|
||||||
|
cleanup();
|
||||||
|
pendingBackgroundPreloads.delete(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentBackgroundUrls.forEach((url) => preloadBackgroundUrl(url));
|
||||||
|
|
||||||
|
const hasPendingResourceMarker = Boolean(
|
||||||
|
runtimeRoot.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasPendingResourceMarker ||
|
||||||
|
pendingImageListeners.size > 0 ||
|
||||||
|
pendingMediaListeners.size > 0 ||
|
||||||
|
pendingBackgroundPreloads.size > 0
|
||||||
|
) {
|
||||||
|
cancelReadySchedule();
|
||||||
|
if (pendingRecheckTimeoutId === null) {
|
||||||
|
pendingRecheckTimeoutId = window.setTimeout(() => {
|
||||||
|
pendingRecheckTimeoutId = null;
|
||||||
|
scheduleScan();
|
||||||
|
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingRecheck();
|
||||||
|
scheduleReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(scheduleScan);
|
||||||
|
observer.observe(runtimeRoot, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: [
|
||||||
|
'src',
|
||||||
|
'srcset',
|
||||||
|
'style',
|
||||||
|
'data-runtime-resource-pending',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
cleanupCallbacks.push(() => observer.disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
scanResources();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import woodenFishRuntimeLogo from '../../../media/logo.png';
|
||||||
import type {
|
import type {
|
||||||
WoodenFishRuntimeRunSnapshotResponse,
|
WoodenFishRuntimeRunSnapshotResponse,
|
||||||
WoodenFishWordCounter,
|
WoodenFishWordCounter,
|
||||||
WoodenFishWorkProfileResponse,
|
WoodenFishWorkProfileResponse,
|
||||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import woodenFishRuntimeLogo from '../../../media/logo.png';
|
|
||||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||||
import {
|
import {
|
||||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
|
WOODEN_FISH_DEFAULT_HIT_OBJECT_SRC,
|
||||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||||
|
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import {
|
import {
|
||||||
applyWoodenFishTap,
|
applyWoodenFishTap,
|
||||||
@@ -114,7 +115,8 @@ export function WoodenFishRuntimeShell({
|
|||||||
profile?.hitSoundAsset?.audioSrc ??
|
profile?.hitSoundAsset?.audioSrc ??
|
||||||
profile?.draft.hitSoundAsset?.audioSrc ??
|
profile?.draft.hitSoundAsset?.audioSrc ??
|
||||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET.audioSrc;
|
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET.audioSrc;
|
||||||
const { resolvedUrl: resolvedAudioUrl } = useResolvedAssetReadUrl(hitSoundSrc);
|
const { resolvedUrl: resolvedAudioUrl, isResolving: isHitSoundResolving } =
|
||||||
|
useResolvedAssetReadUrl(hitSoundSrc);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentSnapshotRef.current = { totalTapCount, wordCounters };
|
currentSnapshotRef.current = { totalTapCount, wordCounters };
|
||||||
@@ -254,6 +256,11 @@ export function WoodenFishRuntimeShell({
|
|||||||
className="wooden-fish-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#f7f4ec] text-slate-950"
|
className="wooden-fish-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#f7f4ec] text-slate-950"
|
||||||
onPointerDown={registerTap}
|
onPointerDown={registerTap}
|
||||||
>
|
>
|
||||||
|
<RuntimeResourcePendingMarker
|
||||||
|
source={hitSoundSrc}
|
||||||
|
kind="audio"
|
||||||
|
isPending={isHitSoundResolving}
|
||||||
|
/>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.92),transparent_26%),linear-gradient(180deg,#fff8e8_0%,#eef7ed_55%,#e5f2f7_100%)]" />
|
||||||
{backgroundSrc ? (
|
{backgroundSrc ? (
|
||||||
<ResolvedAssetImage
|
<ResolvedAssetImage
|
||||||
|
|||||||
@@ -22,21 +22,25 @@ export function useResolvedAssetReadUrl(
|
|||||||
const [resolvedUrl, setResolvedUrl] = useState(
|
const [resolvedUrl, setResolvedUrl] = useState(
|
||||||
shouldResolve ? '' : normalizedSource,
|
shouldResolve ? '' : normalizedSource,
|
||||||
);
|
);
|
||||||
|
const [isResolving, setIsResolving] = useState(shouldResolve);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!normalizedSource) {
|
if (!normalizedSource) {
|
||||||
setResolvedUrl('');
|
setResolvedUrl('');
|
||||||
|
setIsResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldResolve) {
|
if (!shouldResolve) {
|
||||||
setResolvedUrl(normalizedSource);
|
setResolvedUrl(normalizedSource);
|
||||||
|
setIsResolving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
|
// 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
|
||||||
setResolvedUrl('');
|
setResolvedUrl('');
|
||||||
|
setIsResolving(true);
|
||||||
|
|
||||||
void resolveAssetReadUrl(normalizedSource, {
|
void resolveAssetReadUrl(normalizedSource, {
|
||||||
expireSeconds: options.expireSeconds,
|
expireSeconds: options.expireSeconds,
|
||||||
@@ -52,6 +56,11 @@ export function useResolvedAssetReadUrl(
|
|||||||
// 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
|
// 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
|
||||||
setResolvedUrl('');
|
setResolvedUrl('');
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsResolving(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -66,7 +75,7 @@ export function useResolvedAssetReadUrl(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
resolvedUrl,
|
resolvedUrl,
|
||||||
isResolving: shouldResolve && !resolvedUrl,
|
isResolving,
|
||||||
shouldResolve,
|
shouldResolve,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user