diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4888ef7b..04be2504 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -207,7 +207,7 @@ ## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态 - 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。 -- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。 +- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置。 - 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。 - 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index fa82cc45..063f48f8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -126,7 +126,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 - 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 -- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。 +- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 @@ -156,7 +156,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c87120ba..b4d77e98 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -545,6 +545,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed'; type RecommendRuntimeState = { activeKind: RecommendRuntimeKind | null; + barkBattlePublishedConfig: BarkBattlePublishedConfig | null; babyObjectMatchDraft: BabyObjectMatchDraft | null; bigFishRun: BigFishRuntimeSnapshotResponse | null; jumpHopRun: JumpHopRunResponse['run'] | null; @@ -730,7 +731,7 @@ function isRecommendRuntimeReadyForEntry( return Boolean(state.visualNovelRun); } if (expectedKind === 'bark-battle') { - return true; + return Boolean(state.barkBattlePublishedConfig); } if (expectedKind === 'edutainment') { return Boolean(state.babyObjectMatchDraft); @@ -15003,6 +15004,29 @@ export function PlatformEntryFlowShellImpl({ isDesktopLayout, ]); + const activeRecommendEntry = + activeRecommendEntryKey && !isDesktopLayout + ? (recommendRuntimeEntries.find( + (entry) => + getPlatformPublicGalleryEntryKey(entry) === + activeRecommendEntryKey, + ) ?? null) + : null; + const isActiveRecommendRuntimeReady = + activeRecommendEntry !== null && + isRecommendRuntimeReadyForEntry(activeRecommendEntry, { + activeKind: activeRecommendRuntimeKind, + barkBattlePublishedConfig, + babyObjectMatchDraft, + bigFishRun, + jumpHopRun, + match3dRun, + puzzleRun, + squareHoleRun, + visualNovelRun, + woodenFishRun, + }); + useEffect(() => { if ( isDesktopLayout || @@ -15020,25 +15044,6 @@ export function PlatformEntryFlowShellImpl({ return; } - const activeRecommendEntry = activeRecommendEntryKey - ? (recommendRuntimeEntries.find( - (entry) => - getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey, - ) ?? null) - : null; - const isActiveRecommendRuntimeReady = - activeRecommendEntry !== null && - isRecommendRuntimeReadyForEntry(activeRecommendEntry, { - activeKind: activeRecommendRuntimeKind, - babyObjectMatchDraft, - bigFishRun, - jumpHopRun, - match3dRun, - puzzleRun, - squareHoleRun, - visualNovelRun, - woodenFishRun, - }); if ( (activeRecommendEntry !== null && isActiveRecommendRuntimeReady) || isStartingRecommendEntry @@ -15054,9 +15059,12 @@ export function PlatformEntryFlowShellImpl({ }, [ activeRecommendEntryKey, activeRecommendRuntimeKind, + activeRecommendEntry, + barkBattlePublishedConfig, babyObjectMatchDraft, bigFishRun, jumpHopRun, + isActiveRecommendRuntimeReady, isStartingRecommendEntry, match3dRun, platformBootstrap.isLoadingPlatform, @@ -16399,6 +16407,7 @@ export function PlatformEntryFlowShellImpl({ onOpenRecommendGalleryDetail={openRecommendGalleryDetail} recommendRuntimeContent={recommendRuntimeContent} activeRecommendEntryKey={activeRecommendEntryKey} + isRecommendRuntimeReady={isActiveRecommendRuntimeReady} isStartingRecommendEntry={ isStartingRecommendEntry || isBigFishBusy || diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 07f39d36..20f7a2f9 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -823,6 +823,7 @@ function renderLoggedOutHomeView( | 'recommendRuntimeContent' | 'activeRecommendEntryKey' | 'isStartingRecommendEntry' + | 'isRecommendRuntimeReady' | 'recommendRuntimeError' | 'onSelectNextRecommendEntry' | 'onSelectPreviousRecommendEntry' @@ -883,6 +884,7 @@ function renderLoggedOutHomeView( } activeRecommendEntryKey={overrides.activeRecommendEntryKey} isStartingRecommendEntry={overrides.isStartingRecommendEntry} + isRecommendRuntimeReady={overrides.isRecommendRuntimeReady} recommendRuntimeError={overrides.recommendRuntimeError} onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry} onSelectPreviousRecommendEntry={ @@ -3703,7 +3705,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () => ); expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); - expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); + expect( + document.querySelector('.platform-recommend-runtime-cover'), + ).toBeTruthy(); + expect(screen.queryByText('加载中...')).toBeNull(); expect( document.querySelector('.platform-public-work-card__cover'), ).toBeNull(); @@ -3712,7 +3717,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () => expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); -test('mobile recommend loading state is themed instead of hardcoded black', () => { +test('mobile recommend startup keeps cover visible without loading copy', () => { renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', @@ -3720,8 +3725,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () = recommendRuntimeContent: null, }); - expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); - expect(screen.getByText('加载中...')).toBeTruthy(); + expect( + document.querySelector('.platform-recommend-runtime-cover'), + ).toBeTruthy(); + expect(screen.queryByText('加载中...')).toBeNull(); + expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); +}); + +test('mobile recommend next level keeps runtime visual stable when active work changes', async () => { + const animationCallbacks: FrameRequestCallback[] = []; + 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(), + }); + const firstEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-feed-1', + profileId: 'puzzle-profile-feed-1', + ownerUserId: 'user-feed-1', + publicWorkCode: 'PZ-FEED1', + worldName: '当前拼图', + coverImageSrc: 'current-cover.png', + } satisfies PlatformPublicGalleryCard; + const similarEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-similar-1', + profileId: 'puzzle-profile-similar-1', + ownerUserId: 'user-feed-2', + publicWorkCode: 'PZ-SIMILAR1', + worldName: '相似拼图', + coverImageSrc: 'similar-cover.png', + } satisfies PlatformPublicGalleryCard; + + const { rerender } = renderLoggedOutHomeView(vi.fn(), { + latestEntries: [firstEntry, similarEntry], + activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1', + isRecommendRuntimeReady: true, + }); + + act(() => { + animationCallbacks.splice(0).forEach((callback) => callback(16)); + }); + await waitFor(() => { + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).toContain('platform-recommend-runtime-cover--hidden'); + }); + + rerender( + undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + } + activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1" + isRecommendRuntimeReady + onOpenLibraryDetail={vi.fn()} + onSearchPublicCode={vi.fn()} + /> + , + ); + + const rail = document.querySelector( + '.platform-recommend-swipe-rail', + ) as HTMLElement | null; + expect(rail?.className).toContain('platform-recommend-swipe-rail--settled'); + expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)'); + expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy(); + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).toContain('platform-recommend-runtime-cover--hidden'); }); test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 9d2a37d9..6766dbc8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -39,6 +39,7 @@ import { type CSSProperties, type PointerEvent, type ReactNode, + Suspense, useCallback, useEffect, useMemo, @@ -195,6 +196,7 @@ export interface RpgEntryHomeViewProps { recommendRuntimeContent?: ReactNode; activeRecommendEntryKey?: string | null; isStartingRecommendEntry?: boolean; + isRecommendRuntimeReady?: boolean; recommendRuntimeError?: string | null; onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void; onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void; @@ -946,6 +948,115 @@ function RecommendRuntimePreviewCard({ ); } +function RecommendRuntimeCover({ + entry, + className = '', +}: { + entry: PlatformPublicGalleryCard; + className?: string; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry); + + return ( +