diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4c9d9197..d367bf19 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -52,7 +52,7 @@ 8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 -发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 +发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 ## RPG / 自定义世界 @@ -107,7 +107,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 -- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回和设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底;底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 +- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 - 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 09dbe102..68c05a44 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -95,7 +95,7 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 -10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 +10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。 12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index e2604ace..41bb3d1b 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -617,7 +617,11 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => { expect(within(dialog).getByText('#1')).toBeTruthy(); expect(within(dialog).getByText('测试作者')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '下一关' })); + const nextButton = within(dialog).getByRole('button', { name: '下一关' }); + expect(nextButton.textContent).toContain('下一关'); + expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); + + fireEvent.click(nextButton); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); vi.useRealTimers(); @@ -876,13 +880,16 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关 expect( screen.getByRole('button', { name: '打开拼图设置' }).className, ).not.toContain('rounded-full'); - const nextSprite = screen - .getByRole('button', { name: '下一关' }) - .querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null; - expect(nextSprite).toBeTruthy(); - expect(nextSprite?.style.backgroundSize).toBe('320% 480%'); - expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%'); - expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe(''); + const nextButton = screen.getByRole('button', { name: '下一关' }); + expect(nextButton.dataset.puzzleUiSprite).toBe('next'); + expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); + expect(nextButton.style.backgroundSize).toBe('320% 480%'); + expect(nextButton.style.backgroundPosition).toBe('50% 57.89473684210527%'); + expect(nextButton.className).not.toContain('puzzle-runtime-primary-button'); + expect(nextButton.className).not.toContain('rounded-full'); + expect(nextButton.className).not.toContain('px-5'); + expect(nextButton.className).not.toContain('py-2.5'); + expect(nextButton.textContent).toBe(''); expect( screen .getByRole('button', { name: '提示' }) @@ -962,7 +969,7 @@ test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => { expect(backgroundImage).toBeTruthy(); }); -test('关闭通关弹窗后保留底部下一关入口', () => { +test('关闭通关弹窗后保留底部下一关入口', async () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { @@ -988,10 +995,31 @@ test('关闭通关弹窗后保留底部下一关入口', () => { onAdvanceNextLevel={onAdvanceNextLevel} />, ); + await act(async () => {}); act(() => { vi.advanceTimersByTime(1_400); }); + const dialog = screen.getByRole('dialog', { name: '通关完成' }); + const dialogNextButton = within(dialog).getByRole('button', { + name: '下一关', + }); + expect(dialogNextButton.dataset.puzzleUiSprite).toBe('next'); + expect( + dialogNextButton.querySelector('[data-puzzle-ui-sprite="next"]'), + ).toBeNull(); + expect(dialogNextButton.style.backgroundSize).toBe('320% 480%'); + expect(dialogNextButton.style.backgroundPosition).toBe( + '50% 57.89473684210527%', + ); + expect(dialogNextButton.className).not.toContain( + 'puzzle-runtime-primary-button', + ); + expect(dialogNextButton.className).not.toContain('rounded-full'); + expect(dialogNextButton.className).not.toContain('px-5'); + expect(dialogNextButton.className).not.toContain('py-2.5'); + expect(dialogNextButton.textContent).toBe(''); + act(() => { fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); }); @@ -1050,9 +1078,8 @@ test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下 expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); const nextButton = screen.getByRole('button', { name: /下一关/u }); expect(nextButton).toBeTruthy(); - expect( - nextButton.querySelector('[data-puzzle-ui-sprite="next"]'), - ).toBeTruthy(); + expect(nextButton.dataset.puzzleUiSprite).toBe('next'); + expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull(); expect(nextButton.textContent?.trim()).toBe(''); vi.useRealTimers(); }); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index 92f395de..ee0bc2e2 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -162,6 +162,15 @@ function PuzzleUiSprite({ ); } +function resolvePuzzleUiSpriteAspectRatio( + kind: PuzzleUiSpriteKind, + layout: PuzzleUiSpritesheetLayout | null, + fallback: string, +) { + const region = layout?.regions[kind]; + return region ? `${region.width} / ${region.height}` : fallback; +} + function buildMergedGroupViewModels( groups: PuzzleMergedGroupState[], pieces: PuzzleBoardPieceViewModel[], @@ -1221,6 +1230,22 @@ export function PuzzleRuntimeShell({ const clearResultOverlayClassName = embedded ? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm` : 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm'; + const nextSpriteButtonClassName = + 'inline-flex h-12 appearance-none items-center justify-center border-0 bg-transparent p-0 leading-none shadow-none transition hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] disabled:cursor-not-allowed disabled:opacity-45'; + const nextSpriteButtonStyle = hasUiSpritesheet + ? { + ...buildPuzzleUiSpriteBackgroundStyle({ + src: resolvedUiSpritesheetImage, + kind: 'next', + layout: uiSpritesheetLayout, + }), + aspectRatio: resolvePuzzleUiSpriteAspectRatio( + 'next', + uiSpritesheetLayout, + '2 / 1', + ), + } + : undefined; const handleBackRequest = () => { if (hideExitControls) { return; @@ -1457,6 +1482,8 @@ export function PuzzleRuntimeShell({ ) : null} @@ -1933,6 +1969,7 @@ export function PuzzleRuntimeShell({ ) : null} diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 9cc830bb..d9ef46eb 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -2412,6 +2412,21 @@ test('mobile profile page matches the reference layout sections', async () => { .querySelector('.platform-profile-shortcut-grid') ?.classList.contains('platform-profile-shortcut-grid'), ).toBe(true); + expect( + shortcutRegion + .querySelector('.platform-profile-shortcut-grid') + ?.className, + ).toContain('!grid-cols-4'); + expect( + shortcutRegion + .querySelector('.platform-profile-shortcut-grid') + ?.className, + ).toContain('w-full'); + for (const shortcutButton of shortcutRegion.querySelectorAll( + '.platform-profile-shortcut-button', + )) { + expect(shortcutButton.className).toContain('w-full'); + } for (const label of [ '泥点充值', '兑换码', diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 3e6d4957..51e2db1e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -2434,7 +2434,7 @@ function ProfileShortcutButton({