diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3eb5bbb9..7ec7b82b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,54 @@ --- +## 2026-05-15 抓大鹅结果页 UI 预览复用运行态布局 + +- 背景:抓大鹅结果页 `素材配置 > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实运行态顶部关卡卡片、右上设置入口、容器图定位及槽位样式出现漂移。 +- 决策:结果页 UI 预览只组合 `match3dRuntimeUiStyles` 中的运行态 HUD、棋盘、容器图和槽位样式常量;运行态尺寸或视觉层级调整时,同步由这些常量影响预览,不再在结果页单独维护另一套预览 UI。 +- 影响范围:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`、抓大鹅结果页测试和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认预览顶部 HUD、设置入口、容器图定位和槽位样式与运行态一致。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-15 拼图拖拽视觉即时跟手且拼块裁切圆角 + +- 背景:拼图运行态在触控拖拽时不能有追手延迟,同时拼图片需要真实圆角裁切,合并后的 L 形 / 凹形块不能靠单格圆角拼接。 +- 决策:`PuzzleRuntimeShell` 拖拽视觉在 `pointermove` 期间直接写入当前可见拼块 DOM 的 `translate3d(...)`,拖拽阶段禁用 transform transition,不依赖后端回包、React 重渲染或 `requestAnimationFrame` 队列。拖动过程中不展示拼块选中态或底部“已选择”提示,选中状态只保留给点击交换语义。单块通过 `buildRoundedGridCellClipPath()` 裁切独立圆角;合并块视觉层必须用 SVG 原生 `clipPath` 裁切整体外轮廓,不只依赖 HTML `clip-path:url(#...)`,外凸角和内凹角分开计算半径,内凹角半径更大以避免移动端看起来仍是直角。 +- 影响范围:拼图运行态拖拽手感、`puzzleRuntimeShape` 圆角路径、拼图运行态测试和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-15 拼图运行态点击弹层不使用像素素材框 + +- 背景:拼图运行态主界面已经切到平台主色主题,但提示和设置弹层仍复用像素九宫格素材框,和当前拼图视觉不一致。 +- 决策:拼图运行态的提示、设置等点击弹层跟随 `puzzle-runtime-*` 主色主题,使用普通圆角主题面板和 CSS 变量分层,不再套 `pixel-nine-slice` / `pixel-modal-shell` 或 `UI_CHROME.modalPanel`。 +- 影响范围:`PuzzleRuntimeShell` 的道具确认弹窗、设置弹窗、拼图运行态样式和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,确认提示和设置 dialog 不包含像素框类名。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-15 拼图运行态壳层必须自带平台主题类 + +- 背景:`/puzzle` 直达页和运行态提前返回分支不一定挂在外层平台壳里,若壳层只依赖父级主题类,就会出现修改已经生效但页面看起来还是旧样式的错觉。 +- 决策:`PuzzleRuntimeShell` 自身在正常运行态和等待态都补齐 `platform-ui-shell platform-theme platform-theme--light|dark`,让直达页和平台内嵌页共享同一套主题变量。 +- 影响范围:`PuzzleRuntimeShell` 根容器、路由直达页、拼图运行态测试和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx` 与 `npm run typecheck`,确认壳层 className 包含平台主题类。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-15 抓大鹅草稿自动编排不再要求背景音乐 + +- 背景:抓大鹅音频入口关闭后,草稿生成仍可能在后端自动编排中调用 Suno,导致“提交 Suno 背景音乐任务失败”阻断草稿生成。 +- 决策:`match3d_compile_draft` 的完成条件只包含 2D 五视角物品图片、UI 背景和容器图;点击音效只在 `generateClickSound=true` 的历史配置下作为可选补齐。背景音乐字段仅作为历史兼容传递,不再由草稿自动生成或补齐。 +- 影响范围:`api-server` Match3D 草稿资产编排、抓大鹅音频关闭口径、玩法链路文档。 +- 验证方式:执行 `cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-15 抓大鹅运行态右上角统一为设置入口 + +- 背景:抓大鹅运行态右上角原先直接暴露重新开始按钮,和拼图的设置入口口径不一致,也不利于把设置、重开和后续扩展动作收口到统一面板。 +- 决策:`Match3DRuntimeShell` 右上角改为设置按钮,点击后打开独立设置面板;重新开始动作仅放在设置面板内,结算弹层继续保留再来一局。拼图右上角设置图标同步改为非像素风 `lucide-react` `Settings` 图标。 +- 影响范围:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、对应测试和玩法链路文档。 +- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认拼图设置按钮不再渲染像素图片、抓大鹅右上角打开设置面板且面板内可重新开始。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-15 汪汪声浪和宝贝识物入口设为敬请期待 - 背景:当前需要暂时关闭汪汪声浪和宝贝识物两个模板的创建链路,但仍保留创作 Tab 中的模板占位。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index b027d78b..e001f6ba 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -70,6 +70,22 @@ - 验证:`npm run test -- src\services\match3dGeneratedModelCache.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`;平台推荐流定向跑 `RpgEntryFlowShell.agent.interaction.test.tsx` 中的 Match3D runtime assets 用例;`npm run typecheck`。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 抓大鹅草稿关闭背景音乐后仍触发 Suno 先查后端自动编排 + +- 现象:用户已关闭抓大鹅草稿里的 `生成音效` 或隐藏结果页音频入口,但点击生成仍报 `提交 Suno 背景音乐任务失败`。 +- 原因:入口开关只影响前端可见控件或点击音效,后端 `match3d_compile_draft` 之前还会把 `backgroundMusic` 当作自动完成条件,继续调用 `generate_background_music_asset_for_creation()`,从而直接发 `/suno/submit/music`。 +- 处理:抓大鹅草稿自动编排只保留 2D 物品图片、UI 背景和容器图;背景音乐字段只作为历史兼容数据,不再作为草稿完成条件,也不再由自动编排提交 Suno。 +- 验证:`cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml` 通过,`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 无新的编译问题。 +- 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.hermes/shared-memory/decision-log.md`。 + +## 抓大鹅 UI 预览和实际游戏不一致先查运行态样式复用 + +- 现象:结果页 `素材配置 > UI` 打开的预览弹层和真实抓大鹅运行态不一致,例如顶部只显示倒计时、右上还是转圈占位、中心容器尺寸或定位和局内不同。 +- 原因:预览层手写了简化 HUD、棋盘尺寸和容器图样式,没有消费 `Match3DRuntimeShell` 运行态使用的共享样式常量。 +- 处理:把运行态 HUD、棋盘、容器图和槽位的 Tailwind 类抽到 `match3dRuntimeUiStyles.ts`,结果页预览只复用这些常量;运行态之后改顶部设置入口、关卡卡片、容器图宽度或棋盘兜底样式时,不要在结果页再写一套。 +- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 中文乱码与编码风险 - 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。 @@ -469,6 +485,14 @@ - 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 推荐页拼图改造返回后又卡在进入中 + +- 现象:推荐页进入拼图作品后点击改造,返回推荐页时卡在“正在进入拼图关卡”或空白加载态,像是作品还在启动。 +- 原因:改造或其他页面流程会清掉当前 `puzzleRun`,但推荐页自动启动逻辑曾只检查 `activeRecommendEntryKey` 是否仍在推荐列表中;旧 key 还在、玩法 run 已丢失时,会继续渲染失效的 `PuzzleRuntimeShell` 占位。 +- 处理:从推荐页拼图进入改造结果页时,先收口推荐嵌入态;推荐页自动启动逻辑还必须校验当前推荐作品对应的 runtime 数据是否存在,例如拼图要有 `puzzleRun`,抓大鹅要有 `match3dRun`。key 还在但 runtime 不完整时,优先重新启动当前作品,不要误判为已激活。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle|home recommendation surfaces start failure|first puzzle runtime back click can open remix result page"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`。 + ## 推荐页未登录入口误打开公开详情 - 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。 @@ -696,6 +720,13 @@ - 验证:执行 `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`;浏览器 Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` 换签,局内出现 `match3d-background-image` 和 `match3d-container-image` 对应图片。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 抓大鹅 UI 背景和容器只在物品挂载字段时也要提升 + +- 现象:草稿恢复、结果页素材配置、UI 预览或试玩时仍显示默认背景 / 默认容器,但 work profile 的首个 `generatedItemAssets[].backgroundAsset` 里已经有生成的背景和容器图。 +- 原因:UI 背景和容器资产没有独立表字段,后端持久化常落在 `generatedItemAssets[].backgroundAsset`;如果 session 映射、结果页 profile、推荐运行态详情补读后不提升到顶层 `generatedBackgroundAsset` 和 `backgroundImageSrc`,后续组件会误判“没有生成 UI 资产”。 +- 处理:Agent session 返回前要用持久化 work profile 资产回填 draft;前端进入结果页、构建草稿 profile、推荐 / 公开作品启动运行态前,都要把 `generatedItemAssets[].backgroundAsset` 提升为顶层背景字段。容器图在运行态和 UI 预览复用同一套居中 `object-contain` 样式,移动端宽度接近屏宽,只有缺失或加载失败时才使用透明参考图兜底。 +- 验证:`cargo test -p api-server match3d_agent_session_response_hydrates_persisted_ui_assets --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 + ## 抓大鹅重启时不要清空 generated 图片签名缓存 - 现象:抓大鹅进入局内时背景或生成物品首帧缺失;点击右上角重启后,局内短暂显示默认积木,过一段时间才换回实际生成素材。 @@ -788,10 +819,18 @@ - 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。 - 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。 -- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。 +- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边;每个视角单图还要以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,再按剩余可见主体收紧裁边,同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。 - 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 5x5 切图。 - 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 抓大鹅背景 UI 图出现透明区域先查背景不透明后处理 + +- 现象:抓大鹅生成的 `9:16` 局内背景 UI 图边缘、角落或局部出现透明 alpha,运行态可能露出底色或透明棋盘底。 +- 原因:背景图和中心容器图是两张资产;容器图需要透明 alpha,但背景图是整屏底图。如果只靠提示词约束,生图服务仍可能返回带透明通道的 PNG。 +- 处理:`generate_match3d_background_image` 上传背景前必须调用 `make_match3d_background_image_opaque()`,把所有半透明 / 全透明像素合成到不透明底色;不要把这条逻辑套到容器图,容器图仍由 `make_match3d_container_image_transparent()` 保持透明。 +- 验证:`cargo test -p api-server match3d_background_image_postprocess_removes_transparent_pixels --manifest-path server-rs\Cargo.toml`,并确认背景提示词包含“全画幅不透明”和“透明 alpha”禁用约束。 +- 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 抓大鹅物品详情大方格只做单张大图查看 - 现象:结果页 `素材配置 > 物品` 打开详情后,上方大方格仍显示横向五图带、焦点内框或小缩略图边框,物品本体看起来偏小且像带着素材自带边框。 @@ -856,6 +895,22 @@ - 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽合并大块时底层单格不显示选中色块"`,并确认合并块拖拽时底层 `[data-piece-id]` 仍为 `puzzle-runtime-piece--merged`。 - 关联:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 拼图拖拽延迟和圆角异常先查即时 DOM 视觉层 + +- 现象:拼图块拖动时有明显追手延迟,或合并块的 L 形 / 凹形内角圆角和外凸角表现一致、出现方角。 +- 原因:拖拽视觉如果依赖 `requestAnimationFrame` 排队、React 重渲染或 transform transition,会在触控设备上产生一帧以上延迟;合并块如果只靠单格 `border-radius`,无法正确处理整体外轮廓和内凹角。 +- 处理:`PuzzleRuntimeShell` 的 `pointermove` 期间直接给当前单块或合并组 DOM 写入 `translate3d(...)`,拖拽阶段禁用 transform transition,并隐藏拼块选中态和底部“已选择”提示;单块使用 `buildRoundedGridCellClipPath()` 独立裁切,合并块视觉层使用 SVG 原生 `clipPath` 裁切整体轮廓,不只依赖 HTML `clip-path:url(#...)`,外凸角和内凹角半径分开计算,内凹角半径更大。 +- 验证:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,重点覆盖拖拽 move 后 DOM transform 立即变化且不调用 rAF、拖动中不显示已选择状态、基础单块有圆角裁切、合并块内凹角路径使用更大半径且视觉层为 SVG 裁切。 +- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/puzzle-runtime/puzzleRuntimeShape.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 拼图设置面板当前用时为 0 先查结算字段误用 + +- 现象:拼图运行态还在游玩时,设置面板里的“当前用时”一直显示 `0:00.00`,但顶部倒计时正常变化。 +- 原因:`currentLevel.elapsedMs` 是通关后的结算字段,进行中关卡按契约通常为 `null`;设置面板若直接格式化该字段,就会被归一化为 0。 +- 处理:设置面板的当前用时按 `startedAtMs`、`pausedAccumulatedMs`、UI 暂停起点、`freezeAccumulatedMs` 和当前冻结区间实时派生,和剩余时间使用同一套暂停 / 冻结扣除口径。 +- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拼图设置面板展示进行中关卡的实时当前用时"`。 +- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 拼图历史图片列表不要把账号归属当图片名 - 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 44ed9d73..4f448db2 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,7 +12,7 @@ 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。 -3. 删除、分享等低频动作进入左滑或长按操作层,常态不外露破坏卡片密度。 +3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 @@ -30,7 +30,12 @@ - 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。 - 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。 - 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。 -- 拼图运行态默认棋盘不叠加分块蒙版、描边、阴影、圆角切块、选中底色或合并块 SVG 轮廓;原图道具只在用户主动确认后临时覆盖。 +- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 +- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 +- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 +- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。 +- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 +- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 ## 抓大鹅 Match3D @@ -55,21 +60,22 @@ 当前素材生成流水线: 1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。 -2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成。 +2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。 3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。 4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。 -5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;不要先裁剪单格再各自去绿。 +5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 -8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品;容器图走 `/v1/images/edits` 参考透明容器图。 +8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 +10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。 结果页当前结构: - `作品信息`:名称、描述、标签;封面编辑收口到发布面板。 - `难度配置`:四档离散拖动条,显示需要消除、总物品数、物品种类、已生成物品种类。 - `素材配置 > 物品`:两列素材卡,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。 -- `素材配置 > UI`:纯背景图与运行态 UI 预览,重生成消耗 `2` 泥点。 +- `素材配置 > UI`:纯背景图与运行态 UI 预览,重生成消耗 `2` 泥点;UI 预览必须复用运行态顶部 HUD、中央容器棋盘、容器图定位和底部槽位样式,不单独维护一套简化预览 UI。 - `素材配置 > 容器形象`:单独预览和重生成中心容器,消耗 `2` 泥点。 运行态当前口径: @@ -80,9 +86,11 @@ - 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。 - 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。 - 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。 -- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。 +- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。 +- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。 +- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 ## 视觉小说 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index b489bf87..8a40e07c 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -85,6 +85,7 @@ server-rs + Axum + SpacetimeDB 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 +10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 ## 文案与编码 diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index b425c069..4d42df69 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -81,8 +81,7 @@ use crate::{ request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ - GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, - generate_sound_effect_asset_for_creation, + GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -120,7 +119,6 @@ const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str = const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; -const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND: &str = "match3d_background_music"; const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound"; const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。"; @@ -209,18 +207,10 @@ struct Match3DGeneratedItemPlan { sound_prompt: String, } -#[derive(Clone, Debug)] -struct Match3DGeneratedBackgroundMusicPlan { - title: String, - style: String, - prompt: String, -} - #[derive(Clone, Debug)] struct Match3DGeneratedDraftPlan { metadata: Match3DGeneratedWorkMetadata, items: Vec, - background_music: Match3DGeneratedBackgroundMusicPlan, background_prompt: String, } @@ -387,7 +377,12 @@ pub async fn create_match3d_agent_session( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -420,7 +415,12 @@ pub async fn get_match3d_agent_session( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -445,7 +445,12 @@ pub async fn submit_match3d_agent_message( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -479,7 +484,12 @@ pub async fn stream_match3d_agent_message( match result { Ok(session) => { - let session_response = map_match3d_agent_session_response(session); + let session_response = load_match3d_agent_session_response_with_persisted_assets( + &state, + owner_user_id.as_str(), + session, + ) + .await; if let Some(reply) = session_response.last_assistant_reply.clone() { yield Ok::(match3d_sse_json_event_or_error( "reply_delta", @@ -1368,7 +1378,7 @@ pub async fn generate_match3d_item_assets_for_work( item: map_match3d_work_profile_response(profile), generated_item_assets: sort_match3d_generated_assets(assets) .into_iter() - .map(Match3DGeneratedItemAssetJson::from) + .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, @@ -1849,6 +1859,37 @@ async fn submit_and_finalize_match3d_message( }) } +async fn load_match3d_agent_session_response_with_persisted_assets( + state: &AppState, + owner_user_id: &str, + session: Match3DAgentSessionRecord, +) -> Match3DAgentSessionSnapshotResponse { + let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else { + return map_match3d_agent_session_response(session); + }; + let assets = + get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await; + map_match3d_agent_session_response_with_assets(session, &assets) +} + +fn resolve_match3d_session_existing_profile_id( + session: &Match3DAgentSessionRecord, +) -> Option { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) +} + async fn compile_match3d_draft_for_session( state: &AppState, request_context: &RequestContext, @@ -1985,7 +2026,6 @@ async fn compile_match3d_draft_for_session( profile_id.as_str(), &config, generated_work_metadata.items, - generated_work_metadata.background_music, existing_assets, ) .await?; @@ -2626,7 +2666,9 @@ impl From for Match3DGeneratedItemAsset { Self { item_id: asset.item_id, item_name: asset.item_name, - item_size: asset.item_size.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), + item_size: asset + .item_size + .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), image_src: asset.image_src, image_object_key: asset.image_object_key, image_views: asset.image_views, @@ -2715,23 +2757,12 @@ async fn generate_match3d_item_assets( profile_id: &str, config: &Match3DConfigJson, item_plan: Vec, - background_music_plan: Match3DGeneratedBackgroundMusicPlan, existing_assets: Vec, ) -> Result, Response> { - // 中文注释:外部生图、音频和 OSS 写入都留在 api-server,SpacetimeDB reducer 只保存确定性草稿。 + // 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。 let target_item_count = resolve_match3d_generated_item_count(config); let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); - if has_match3d_required_item_images(&assets, target_item_count) - && assets - .iter() - .take(target_item_count) - .any(has_match3d_background_music_audio) - && (!config.generate_click_sound - || assets - .iter() - .take(target_item_count) - .all(|asset| asset.click_sound.is_some())) - { + if has_match3d_required_generated_assets(&assets, target_item_count, config) { return Ok(assets.into_iter().take(target_item_count).collect()); } @@ -2749,17 +2780,6 @@ async fn generate_match3d_item_assets( ) .await?; } - assets = ensure_match3d_background_music_asset( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - &background_music_plan, - assets, - ) - .await?; assets = ensure_match3d_click_sound_assets( state, request_context, @@ -3159,85 +3179,6 @@ async fn ensure_match3d_click_sound_assets( Ok(assets) } -#[allow(clippy::too_many_arguments)] -async fn ensure_match3d_background_music_asset( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - plan: &Match3DGeneratedBackgroundMusicPlan, - assets: Vec, -) -> Result, Response> { - let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); - if assets.iter().any(has_match3d_background_music_audio) { - return Ok(assets); - } - - let Some(first_index) = assets - .iter() - .enumerate() - .min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str())) - .map(|(index, _)| index) - else { - return Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - match3d_background_music_missing_error("抓大鹅草稿缺少可写入背景音乐的物品素材"), - )); - }; - - let title = require_match3d_background_music_title(plan) - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let style = normalize_match3d_audio_style(plan.style.as_str()); - match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style) - .await - { - Ok(music) => { - let asset = &mut assets[first_index]; - asset.background_music_title = Some(title); - asset.background_music_style = (!style.trim().is_empty()).then_some(style); - asset.background_music_prompt = Some(String::new()); - asset.background_music = Some(music); - asset.error = None; - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } - Err(error) => { - tracing::error!( - provider = MATCH3D_AGENT_PROVIDER, - session_id, - profile_id, - error = %error, - "抓大鹅草稿背景音乐生成失败,终止本次草稿生成并等待重试" - ); - return Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - error, - )); - } - } - - Ok(assets) -} - -fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool { - asset - .background_music - .as_ref() - .is_some_and(|music| !music.audio_src.trim().is_empty()) -} - async fn generate_match3d_click_sound_asset( state: &AppState, owner_user_id: &str, @@ -3266,32 +3207,6 @@ async fn generate_match3d_click_sound_asset( Ok(asset) } -async fn generate_match3d_background_music_asset( - state: &AppState, - owner_user_id: &str, - profile_id: &str, - title: &str, - style: &str, -) -> Result { - generate_background_music_asset_for_creation( - state, - owner_user_id, - String::new(), - title.to_string(), - (!style.trim().is_empty()).then_some(style.to_string()), - None, - GeneratedCreationAudioTarget { - entity_kind: "match3d_work".to_string(), - entity_id: profile_id.to_string(), - slot: "background_music".to_string(), - asset_kind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND.to_string(), - profile_id: Some(profile_id.to_string()), - storage_prefix: LegacyAssetPrefix::Match3DAssets, - }, - ) - .await -} - #[allow(clippy::too_many_arguments)] async fn append_match3d_new_item_assets( state: &AppState, @@ -3620,32 +3535,6 @@ fn parse_match3d_draft_plan( .collect::>() }) .unwrap_or_default(); - let background_music = value - .get("backgroundMusic") - .or_else(|| value.get("background_music")) - .and_then(|music| { - let title = music - .get("title") - .and_then(Value::as_str) - .map(normalize_match3d_audio_title) - .filter(|value| !value.is_empty())?; - let style = music - .get("style") - .and_then(Value::as_str) - .map(normalize_match3d_audio_style) - .filter(|value| !value.is_empty())?; - let prompt = music - .get("prompt") - .and_then(Value::as_str) - .map(normalize_match3d_audio_prompt) - .unwrap_or_default(); - Some(Match3DGeneratedBackgroundMusicPlan { - title, - style, - prompt, - }) - }) - .unwrap_or(fallback.background_music); let background_prompt = value .get("backgroundPrompt") .or_else(|| value.get("background_prompt")) @@ -3661,7 +3550,6 @@ fn parse_match3d_draft_plan( tags: normalize_match3d_tag_candidates(tags), }, items: normalize_match3d_item_plan(config, items), - background_music, background_prompt, }) } @@ -3706,26 +3594,6 @@ fn normalize_match3d_work_summary(raw: &str) -> String { .to_string() } -fn normalize_match3d_audio_title(raw: &str) -> String { - raw.trim() - .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) - .chars() - .filter(|character| !character.is_control()) - .take(40) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_audio_style(raw: &str) -> String { - raw.split([',', ',', '、', '\n']) - .map(normalize_match3d_tag) - .filter(|value| !value.is_empty()) - .take(6) - .collect::>() - .join(", ") -} - fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { let theme = theme_text.trim(); let normalized_theme = if theme.is_empty() { "主题" } else { theme }; @@ -3751,23 +3619,11 @@ fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDr .collect::>(); Match3DGeneratedDraftPlan { background_prompt: build_fallback_match3d_background_prompt(config), - background_music: build_fallback_match3d_background_music_plan(config, &metadata.game_name), metadata, items, } } -fn build_fallback_match3d_background_music_plan( - _config: &Match3DConfigJson, - game_name: &str, -) -> Match3DGeneratedBackgroundMusicPlan { - Match3DGeneratedBackgroundMusicPlan { - title: normalize_match3d_audio_title(format!("{game_name}音乐").as_str()), - style: "轻快, 休闲, 消除, instrumental".to_string(), - prompt: String::new(), - } -} - fn normalize_match3d_item_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) @@ -3780,11 +3636,19 @@ fn normalize_match3d_item_name(raw: &str) -> String { } fn normalize_match3d_item_size(raw: &str) -> String { - let normalized = raw.trim().trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']); + let normalized = raw + .trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']); match normalized { - "大" | "大型" | "偏大" | "large" | "Large" | "L" | "l" => MATCH3D_ITEM_SIZE_LARGE.to_string(), - "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => MATCH3D_ITEM_SIZE_MEDIUM.to_string(), - "小" | "小型" | "偏小" | "small" | "Small" | "S" | "s" => MATCH3D_ITEM_SIZE_SMALL.to_string(), + "大" | "大型" | "偏大" | "large" | "Large" | "L" | "l" => { + MATCH3D_ITEM_SIZE_LARGE.to_string() + } + "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => { + MATCH3D_ITEM_SIZE_MEDIUM.to_string() + } + "小" | "小型" | "偏小" | "small" | "Small" | "S" | "s" => { + MATCH3D_ITEM_SIZE_SMALL.to_string() + } _ => String::new(), } } @@ -3792,16 +3656,16 @@ fn normalize_match3d_item_size(raw: &str) -> String { fn infer_match3d_item_size(item_name: &str) -> String { let name = item_name.trim(); let large_keywords = [ - "西瓜", "南瓜", "椰子", "箱", "盒", "桶", "盆", "锅", "坛", "瓶子", "大瓶", "包", - "书包", "枕", "抱枕", "玩偶", "球", "圆球", "足球", "篮球", "鼓", + "西瓜", "南瓜", "椰子", "箱", "盒", "桶", "盆", "锅", "坛", "瓶子", "大瓶", "包", "书包", + "枕", "抱枕", "玩偶", "球", "圆球", "足球", "篮球", "鼓", ]; if large_keywords.iter().any(|keyword| name.contains(keyword)) { return MATCH3D_ITEM_SIZE_LARGE.to_string(); } let small_keywords = [ - "草莓", "蓝莓", "葡萄", "樱桃", "莓", "糖", "糖果", "钥匙", "硬币", "纽扣", "徽章", - "戒指", "耳环", "铃铛", "星星", "宝石", "叶片", "花瓣", "蘑菇", "贝壳", "印章", - "彩蛋", "棋子", "骰子", "挂件", + "草莓", "蓝莓", "葡萄", "樱桃", "莓", "糖", "糖果", "钥匙", "硬币", "纽扣", "徽章", "戒指", + "耳环", "铃铛", "星星", "宝石", "叶片", "花瓣", "蘑菇", "贝壳", "印章", "彩蛋", "棋子", + "骰子", "挂件", ]; if small_keywords.iter().any(|keyword| name.contains(keyword)) { return MATCH3D_ITEM_SIZE_SMALL.to_string(); @@ -4236,6 +4100,19 @@ fn has_match3d_required_item_images( .all(is_match3d_generated_asset_image_ready) } +fn has_match3d_required_generated_assets( + assets: &[Match3DGeneratedItemAsset], + required_item_count: usize, + config: &Match3DConfigJson, +) -> bool { + has_match3d_required_item_images(assets, required_item_count) + && (!config.generate_click_sound + || assets + .iter() + .take(required_item_count) + .all(|asset| asset.click_sound.is_some())) +} + fn upsert_match3d_generated_item_asset( assets: &mut Vec, asset: Match3DGeneratedItemAsset, @@ -4545,7 +4422,9 @@ async fn generate_match3d_background_image( &http_client, &settings, build_match3d_background_generation_prompt(config, prompt).as_str(), - Some("文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单"), + Some( + "文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底", + ), "9:16", 1, &[], @@ -4562,6 +4441,7 @@ async fn generate_match3d_background_image( "message": "抓大鹅背景图生成失败:未返回图片", })) })?; + let background_image = make_match3d_background_image_opaque(background_image)?; let background_upload = persist_match3d_generated_bytes( state, owner_user_id, @@ -4743,7 +4623,7 @@ fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); format!( - "{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透,方便运行态后续叠加默认交互容器和物品素材。" + "{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。必须全画幅不透明,四边和角落都要有完整环境像素,不得出现透明 alpha、透明底、镂空或棋盘格透明区域。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透,方便运行态后续叠加默认交互容器和物品素材。" ) } @@ -4756,6 +4636,132 @@ fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: ) } +// 中文注释:9:16 运行背景是整屏底图,必须和中心容器透明素材分层处理,避免局内露出透明底。 +fn make_match3d_background_image_opaque( + image: DownloadedOpenAiImage, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅背景图解码失败:{error}"), + })) + })?; + let mut rgba = source.to_rgba8(); + let matte = sample_match3d_background_opaque_matte(&rgba).unwrap_or([246, 243, 236]); + let mut changed = false; + + for pixel in rgba.pixels_mut() { + let alpha = pixel.0[3]; + if alpha == 255 { + continue; + } + pixel.0 = blend_match3d_background_pixel_over_matte(pixel.0, matte); + changed = true; + } + + if !changed { + return Ok(image); + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅背景图不透明化失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn sample_match3d_background_opaque_matte(image: &image::RgbaImage) -> Option<[u8; 3]> { + sample_match3d_background_matte_from_edges(image) + .or_else(|| sample_match3d_background_matte_from_pixels(image)) +} + +fn sample_match3d_background_matte_from_edges(image: &image::RgbaImage) -> Option<[u8; 3]> { + let (width, height) = image.dimensions(); + if width == 0 || height == 0 { + return None; + } + + let mut sampler = Match3DBackgroundMatteSampler::default(); + for x in 0..width { + sampler.push(image.get_pixel(x, 0).0); + sampler.push(image.get_pixel(x, height - 1).0); + } + for y in 1..height.saturating_sub(1) { + sampler.push(image.get_pixel(0, y).0); + sampler.push(image.get_pixel(width - 1, y).0); + } + sampler.finish() +} + +fn sample_match3d_background_matte_from_pixels(image: &image::RgbaImage) -> Option<[u8; 3]> { + let mut sampler = Match3DBackgroundMatteSampler::default(); + for pixel in image.pixels() { + sampler.push(pixel.0); + } + sampler.finish() +} + +#[derive(Default)] +struct Match3DBackgroundMatteSampler { + red: u64, + green: u64, + blue: u64, + weight: u64, +} + +impl Match3DBackgroundMatteSampler { + fn push(&mut self, pixel: [u8; 4]) { + let alpha = pixel[3] as u64; + if alpha < 32 { + return; + } + self.red = self.red.saturating_add(pixel[0] as u64 * alpha); + self.green = self.green.saturating_add(pixel[1] as u64 * alpha); + self.blue = self.blue.saturating_add(pixel[2] as u64 * alpha); + self.weight = self.weight.saturating_add(alpha); + } + + fn finish(self) -> Option<[u8; 3]> { + (self.weight > 0).then(|| { + [ + (self.red / self.weight) as u8, + (self.green / self.weight) as u8, + (self.blue / self.weight) as u8, + ] + }) + } +} + +fn blend_match3d_background_pixel_over_matte(pixel: [u8; 4], matte: [u8; 3]) -> [u8; 4] { + let alpha = pixel[3] as u16; + let inverse_alpha = 255u16.saturating_sub(alpha); + [ + blend_match3d_background_channel(pixel[0], matte[0], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[1], matte[1], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[2], matte[2], alpha, inverse_alpha), + 255, + ] +} + +fn blend_match3d_background_channel( + foreground: u8, + matte: u8, + alpha: u16, + inverse_alpha: u16, +) -> u8 { + ((foreground as u16 * alpha + matte as u16 * inverse_alpha + 127) / 255) as u8 +} + fn make_match3d_container_image_transparent( image: DownloadedOpenAiImage, ) -> Result { @@ -5733,8 +5739,9 @@ fn slice_match3d_material_sheet( let (crop_x, crop_y, crop_width, crop_height) = resolve_match3d_material_cell_crop(&source, row_count, row, col); let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_match3d_material_view_edge_matte(cropped); let mut cursor = std::io::Cursor::new(Vec::new()); - cropped + cleaned .write_to(&mut cursor, ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -5778,6 +5785,34 @@ fn resolve_match3d_material_cell_crop( crop.to_crop_tuple() } +fn crop_match3d_material_view_edge_matte(image: image::DynamicImage) -> image::DynamicImage { + let mut image = image.to_rgba8(); + let (width, height) = image.dimensions(); + remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize); + let bounds = detect_match3d_material_visible_bounds(&image).unwrap_or_else(|| { + Match3DMaterialCellBounds { + x0: 0, + y0: 0, + x1: width, + y1: height, + } + }); + if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height { + return image::DynamicImage::ImageRgba8(image); + } + + image::DynamicImage::ImageRgba8( + image::imageops::crop_imm( + &image, + bounds.x0, + bounds.y0, + bounds.width(), + bounds.height(), + ) + .to_image(), + ) +} + #[derive(Clone, Copy, Debug)] struct Match3DMaterialCellBounds { x0: u32, @@ -5862,6 +5897,45 @@ fn detect_match3d_material_foreground_bounds( }) } +fn detect_match3d_material_visible_bounds( + image: &image::RgbaImage, +) -> Option { + let (width, height) = image.dimensions(); + let mut bounds: Option = None; + let mut visible_pixels = 0u32; + + for y in 0..height { + for x in 0..width { + let pixel = image.get_pixel(x, y).0; + if !is_match3d_material_visible_pixel(pixel) { + continue; + } + visible_pixels = visible_pixels.saturating_add(1); + bounds = Some(match bounds { + Some(current) => Match3DMaterialCellBounds { + x0: current.x0.min(x), + y0: current.y0.min(y), + x1: current.x1.max(x.saturating_add(1)), + y1: current.y1.max(y.saturating_add(1)), + }, + None => Match3DMaterialCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); + bounds.filter(|visible_bounds| { + visible_pixels >= min_visible_pixels + && visible_bounds.width() > 2 + && visible_bounds.height() > 2 + }) +} + fn sample_match3d_material_cell_background( source: &image::DynamicImage, cell: Match3DMaterialCellBounds, @@ -5932,6 +6006,167 @@ fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD } +fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, height: usize) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut changed = false; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if pixels[offset + 3] == 0 { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘; + // 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。 + let edge_width = resolve_match3d_material_view_edge_cleanup_width(width, height); + for y in 0..height { + for x in 0..width { + if x >= edge_width + && y >= edge_width + && x.saturating_add(edge_width) < width + && y.saturating_add(edge_width) < height + { + continue; + } + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for _ in 0..edge_width { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + if !is_match3d_material_view_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + continue; + } + + if touches_match3d_material_background_mask(x, y, width, height, &background_mask) { + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + // 中文注释:边缘抗锯齿圈要直接从可见像素里剔除,再按剩余主体重新收紧裁边。 + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 + || pixels[offset] != 0 + || pixels[offset + 1] != 0 + || pixels[offset + 2] != 0 + { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + changed = true; + } + } + + changed +} + +fn resolve_match3d_material_view_edge_cleanup_width(width: usize, height: usize) -> usize { + let min_side = width.min(height).max(1); + (min_side / 24).clamp(4, 12).min(min_side) +} + +fn is_match3d_material_view_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 + || is_match3d_material_soft_edge_pixel(pixel) + || compute_match3d_material_white_screen_score(pixel) > 0.18 +} + +fn is_match3d_material_visible_pixel(pixel: [u8; 4]) -> bool { + pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) +} + +fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 188 + && green.saturating_sub(red.max(blue)) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -7143,10 +7378,12 @@ mod tests { .expect("view should decode") .to_rgba8(); - assert_eq!( - decoded.get_pixel(0, 0).0[3], - 0, - "绿幕背景必须在切割输出中变成透明 alpha" + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) + }), + "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), @@ -7245,25 +7482,93 @@ mod tests { } #[test] - fn match3d_background_music_title_is_required_for_auto_draft() { - let missing = - require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { - title: " ,。 ".to_string(), - style: "轻快, 休闲".to_string(), - prompt: String::new(), - }) - .expect_err("自动草稿背景音乐必须有可提交给 Suno 的曲名"); + fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { + let width = 500; + let height = 500; + let item_names = vec!["丸子".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 22..78 { + for x in 22..78 { + if x <= 24 || x >= 75 || y <= 24 || y >= 75 { + sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); + } + } + } + for y in 40..60 { + for x in 40..60 { + sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } - assert!(missing.body_text().contains("背景音乐")); + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; - let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { - title: " 果园轻舞。 ".to_string(), - style: "轻快, 休闲".to_string(), - prompt: String::new(), - }) - .expect("valid title should pass"); + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); - assert_eq!(title, "果园轻舞"); + assert!( + decoded.width() <= 24 && decoded.height() <= 24, + "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", + decoded.width(), + decoded.height() + ); + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单素材输出 PNG 不能保留浅绿抗锯齿边像素" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "单素材二次裁边不能误删物品主体" + ); + } + + #[test] + fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { + let width = 72; + let height = 72; + let mut view = + image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); + for y in 10..62 { + for x in 10..62 { + view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); + } + } + for y in 24..48 { + for x in 24..48 { + view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + + let cleaned = + crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); + + assert!( + cleaned.width() <= 28 && cleaned.height() <= 28, + "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", + cleaned.width(), + cleaned.height() + ); + assert!( + cleaned + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单图外缘浅绿框不能残留为可见像素" + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "扩大边缘清理宽度不能误删物品主体" + ); } #[test] @@ -7351,6 +7656,42 @@ mod tests { ); } + #[test] + fn match3d_background_image_postprocess_removes_transparent_pixels() { + let width = 16; + let height = 16; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); + image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("background should encode"); + let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("background should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed background should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" + ); + assert_ne!( + decoded.get_pixel(0, 0).0, + [0, 0, 0, 0], + "原透明角落必须被合成到不透明背景色上" + ); + } + #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( @@ -7454,6 +7795,17 @@ mod tests { ); } + #[test] + fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { + let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; + + assert!(has_match3d_required_generated_assets( + &assets, + 1, + &config("水果", 3, 3) + )); + } + #[test] fn match3d_item_asset_points_cost_counts_five_item_batches() { assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); @@ -7759,6 +8111,8 @@ mod tests { assert!(background_prompt.contains("不得出现锅")); assert!(background_prompt.contains("拼图槽")); assert!(background_prompt.contains("物品槽")); + assert!(background_prompt.contains("全画幅不透明")); + assert!(background_prompt.contains("透明 alpha")); assert!(background_prompt.contains("默认交互容器")); assert!(container_prompt.contains("1:1")); @@ -7993,6 +8347,131 @@ mod tests { ); } + #[test] + fn match3d_agent_session_response_hydrates_persisted_ui_assets() { + let session = Match3DAgentSessionRecord { + session_id: "match3d-session-1".to_string(), + current_turn: 3, + progress_percent: 100, + stage: "DraftCompiled".to_string(), + anchor_pack: Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "水果".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }, + config: None, + draft: Some(Match3DResultDraftRecord { + profile_id: "match3d-profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["水果".to_string(), "抓大鹅".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 12, + difficulty: 4, + total_item_count: 36, + publish_ready: false, + blockers: Vec::new(), + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some( + "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), + ), + image_views: Vec::new(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + let response = map_match3d_agent_session_response_with_assets(session, &assets); + let draft = response.draft.expect("session draft should exist"); + + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft.generated_item_assets[0] + .background_asset + .as_ref() + .and_then(|asset| asset.image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + } + #[test] fn match3d_tag_normalization_only_strips_numbered_list_prefix() { assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index b74051e7..3bf0da7a 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -427,26 +427,6 @@ pub(super) fn match3d_bad_gateway(message: impl Into) -> AppError { })) } -pub(super) fn match3d_background_music_missing_error(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": MATCH3D_AGENT_PROVIDER, - "message": message.into(), - "missingAssets": ["背景音乐"], - })) -} - -pub(super) fn require_match3d_background_music_title( - plan: &Match3DGeneratedBackgroundMusicPlan, -) -> Result { - let title = normalize_match3d_audio_title(plan.title.as_str()); - if title.is_empty() { - return Err(match3d_background_music_missing_error( - "抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成", - )); - } - Ok(title) -} - pub(super) fn map_match3d_work_profile_response( item: Match3DWorkProfileRecord, ) -> Match3DWorkProfileResponse { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 59a8f390..9c2d722e 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -633,7 +633,7 @@ test('creation hub published work delete action is revealed without opening card ); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); - expect(screen.queryByRole('button', { name: '分享' })).toBeNull(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); await user.keyboard('{ArrowLeft}'); @@ -684,7 +684,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); -test('creation hub published swipe share button copies share text without opening the card', async () => { +test('creation hub published share icon copies share text without opening the card', async () => { const user = userEvent.setup(); const writeText = vi.fn(async () => undefined); const onOpenPuzzleDetail = vi.fn(); @@ -727,9 +727,11 @@ test('creation hub published swipe share button copies share text without openin />, ); - screen.getByRole('button', { name: /查看详情《沉钟拼图》/u }).focus(); - await user.keyboard('{ArrowLeft}'); - await user.click(screen.getByRole('button', { name: '分享' })); + const shareButton = screen.getByRole('button', { name: '分享' }); + expect(shareButton).toBeTruthy(); + expect(screen.queryByText('删除')).toBeNull(); + + await user.click(shareButton); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('邀请你来玩《沉钟拼图》'), @@ -746,6 +748,45 @@ test('creation hub published swipe share button copies share text without openin ).toBeTruthy(); }); +test('creation hub published share icon is shown directly on the card header', () => { + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); +}); + test('creation hub left swipe draft reveals delete without opening card', () => { const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index d940663d..5fd9cc13 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -248,7 +248,7 @@ export function CustomWorldWorkCard({ const isPublished = item.status === 'published'; const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath); - const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0); + const swipeActionCount = onDelete ? 1 : 0; const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX; const canClaimPointIncentive = Boolean(onClaimPointIncentive) && @@ -584,43 +584,6 @@ export function CustomWorldWorkCard({ className="creation-work-card__swipe-underlay" >
- {canUseShareAction ? ( - - ) : null} {onDelete ? (
+ {canUseShareAction ? ( + + ) : null}
diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 28e29f4b..3ded600e 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -1349,12 +1349,121 @@ describe('Match3DResultView', () => { fireEvent.click(screen.getByRole('button', { name: '预览UI页面' })); expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy(); + expect(screen.getByText('第 1 关')).toBeTruthy(); + expect(screen.getByText('抓大鹅')).toBeTruthy(); expect(screen.getByText('1:30')).toBeTruthy(); + const previewBoard = screen.getByTestId('match3d-ui-preview-board'); + expect(previewBoard.className).toContain('bg-transparent'); + expect(previewBoard.className).not.toContain('rounded-full'); + const containerImage = document.querySelector( + 'img[src="/match3d-background-references/pot-fused-reference.png"]', + ); + expect(containerImage).toBeTruthy(); + expect(containerImage?.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage?.className).toContain('-translate-x-1/2'); + expect( + document.querySelector('.animate-spin, [class*="border-l-transparent"]'), + ).toBeNull(); + expect( + document.querySelector( + 'svg[class*="lucide-settings"], [data-lucide="settings"]', + ), + ).toBeTruthy(); + }); + + test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => { + const onStartTestRun = vi.fn(); + const profile = createProfile({ + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [ + { + ...createReadyGeneratedItemAsset(1), + itemName: '草莓', + backgroundAsset: { + prompt: '果园背景', + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + imageObjectKey: + 'generated-match3d-assets/session/profile/background/background.png', + containerPrompt: '果园容器', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + containerImageObjectKey: + 'generated-match3d-assets/session/profile/ui-container/container.png', + status: 'image_ready', + error: null, + }, + }, + ], + }); + vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({ + item: createProfile({ generatedItemAssets: [] }), + }); + vi.mocked( + match3dWorksService.updateMatch3DGeneratedItemAssets, + ).mockResolvedValue({ + item: profile, + }); + + render( + {}} + onStartTestRun={onStartTestRun} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '素材配置' })); + fireEvent.click(screen.getByRole('button', { name: 'UI' })); + + expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe( + '/generated-match3d-assets/session/profile/background/background.png', + ); + expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty( + 'value', + '果园背景', + ); + + fireEvent.click(screen.getByRole('button', { name: '预览UI页面' })); + expect( + document.querySelector( + 'img[src="/generated-match3d-assets/session/profile/background/background.png"]', + ), + ).toBeTruthy(); + expect( + document.querySelector( + 'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]', + ), + ).toBeTruthy(); expect( document.querySelector( 'img[src="/match3d-background-references/pot-fused-reference.png"]', ), - ).toBeTruthy(); + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '试玩' })); + + await waitFor(() => { + expect(onStartTestRun).toHaveBeenCalledWith( + expect.objectContaining({ + backgroundPrompt: '果园背景', + backgroundImageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + generatedBackgroundAsset: expect.objectContaining({ + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + }), + }), + { + itemTypeCountOverride: 1, + }, + ); + }); }); test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => { diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 172fbff5..09e09969 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -8,6 +8,7 @@ import { Play, Plus, Send, + Settings, Trash2, Wand2, X, @@ -24,6 +25,7 @@ import { createPortal } from 'react-dom'; import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio'; import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; import type { + Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkProfile, PutMatch3DWorkRequest, @@ -49,11 +51,19 @@ import { import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import { + MATCH3D_RUNTIME_BOARD_BASE_CLASS, + MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, + MATCH3D_RUNTIME_BOARD_WIDTH, + MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, + MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, + MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, - MATCH3D_RUNTIME_GLASS_SPINNER_CLASS, - MATCH3D_RUNTIME_GLASS_TIMER_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, + MATCH3D_RUNTIME_HEADER_CARD_CLASS, + MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, + MATCH3D_RUNTIME_STAGE_CLASS, + MATCH3D_RUNTIME_TIMER_CLASS, } from '../match3d-runtime/match3dRuntimeUiStyles'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -299,6 +309,44 @@ function resolveMatch3DBackgroundPreviewSource( ); } +function findMatch3DGeneratedBackgroundAsset( + generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], +): Match3DGeneratedBackgroundAsset | null { + return ( + generatedItemAssets.find((asset) => asset.backgroundAsset)?.backgroundAsset ?? + null + ); +} + +function promoteMatch3DGeneratedBackgroundAsset( + profile: Match3DWorkProfile, +): Match3DWorkProfile { + const fallbackBackground = + profile.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []); + if (!fallbackBackground) { + return profile; + } + + return { + ...profile, + backgroundPrompt: + profile.backgroundPrompt ?? fallbackBackground.prompt ?? null, + backgroundImageSrc: + profile.backgroundImageSrc ?? + fallbackBackground.imageSrc ?? + fallbackBackground.imageObjectKey ?? + null, + backgroundImageObjectKey: + profile.backgroundImageObjectKey ?? + fallbackBackground.imageObjectKey ?? + fallbackBackground.imageSrc ?? + null, + generatedBackgroundAsset: + profile.generatedBackgroundAsset ?? fallbackBackground, + }; +} + function resolveMatch3DBackgroundPrompt( profile: Match3DWorkProfile, draft: Match3DResultDraft | null, @@ -1144,11 +1192,14 @@ function buildPlayableProfile( ) { const payload = buildSavePayload(editState); if (!payload) { - return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets); + return promoteMatch3DGeneratedBackgroundAsset( + attachMatch3DGeneratedItemAssets(profile, generatedItemAssets), + ); } - return attachMatch3DGeneratedItemAssets( - { + return promoteMatch3DGeneratedBackgroundAsset( + attachMatch3DGeneratedItemAssets( + { ...profile, gameName: payload.gameName, themeText: payload.themeText ?? profile.themeText, @@ -1157,8 +1208,9 @@ function buildPlayableProfile( coverImageSrc: payload.coverImageSrc, clearCount: payload.clearCount, difficulty: payload.difficulty, - }, - generatedItemAssets, + }, + generatedItemAssets, + ), ); } @@ -1219,15 +1271,15 @@ function attachMatch3DGeneratedItemAssets( generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { if (generatedItemAssets.length <= 0) { - return profile; + return promoteMatch3DGeneratedBackgroundAsset(profile); } // 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。 - return { + return promoteMatch3DGeneratedBackgroundAsset({ ...profile, generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets), - }; + }); } function attachMatch3DGeneratedBackgroundAsset( @@ -2996,35 +3048,46 @@ function Match3DUIRuntimePreviewPanel({ aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-cover" /> -
+
- 1:30 + + + + 第 1 关 + + + 抓大鹅 + + + 1:30 + - +
-
+
@@ -3152,6 +3215,10 @@ export function Match3DResultView({ onPublished, onStartTestRun, }: Match3DResultViewProps) { + const promotedProfile = useMemo( + () => promoteMatch3DGeneratedBackgroundAsset(profile), + [profile], + ); const [editState, setEditState] = useState(() => createEditState(profile)); const [activeTab, setActiveTab] = useState('work'); const [activeAssetConfigTab, setActiveAssetConfigTab] = @@ -3207,8 +3274,8 @@ export function Match3DResultView({ const [isStartingTestRun, setIsStartingTestRun] = useState(false); const [isGeneratingTags, setIsGeneratingTags] = useState(false); const generatedItemAssets = useMemo( - () => resolveMatch3DResultGeneratedItemAssets(profile, draft), - [draft, profile], + () => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft), + [draft, promotedProfile], ); const blockers = useMemo( () => buildPublishBlockers(editState, generatedItemAssets), @@ -3221,34 +3288,44 @@ export function Match3DResultView({ const canStartTestRun = testRunBlockers.length === 0; const canSubmit = blockers.length === 0; const totalItemCount = - (normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) * - 3; + (normalizePositiveInteger(editState.clearCountText) ?? + promotedProfile.clearCount) * 3; const backgroundPreviewSrc = useMemo( () => resolveMatch3DBackgroundPreviewSource( - profile, + promotedProfile, draft, generatedItemAssets, ), - [draft, generatedItemAssets, profile], + [draft, generatedItemAssets, promotedProfile], ); const backgroundPrompt = useMemo( - () => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets), - [draft, generatedItemAssets, profile], + () => + resolveMatch3DBackgroundPrompt( + promotedProfile, + draft, + generatedItemAssets, + ), + [draft, generatedItemAssets, promotedProfile], ); const containerPrompt = useMemo( - () => resolveMatch3DContainerPrompt(profile, draft, generatedItemAssets), - [draft, generatedItemAssets, profile], + () => + resolveMatch3DContainerPrompt( + promotedProfile, + draft, + generatedItemAssets, + ), + [draft, generatedItemAssets, promotedProfile], ); const containerPreviewSrc = useMemo( () => resolveMatch3DContainerPreviewSource( - profile, + promotedProfile, draft, generatedItemAssets, ) || MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC, - [draft, generatedItemAssets, profile], + [draft, generatedItemAssets, promotedProfile], ); const coverSourceAssets = useMemo( () => diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index c8135ce5..ed1a51fd 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; @@ -200,6 +207,33 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => { expect(screen.getByText('第 1 关')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); expect(screen.getByText('10:00')).toBeTruthy(); + expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull(); +}); + +test('抓大鹅右上角设置面板内置重新开始', () => { + const run = startLocalMatch3DRun(4); + const onRestart = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' })); + + const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' }); + expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy(); + expect(within(dialog).getByText('已清除 0/12')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' })); + + expect(onRestart).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull(); }); test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => { @@ -991,7 +1025,7 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => { const containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; - expect(containerImage.className).toContain('w-[min(96vw,28rem)]'); + expect(containerImage.className).toContain('w-[min(99vw,34rem)]'); expect(containerImage.className).toContain('h-auto'); expect(containerImage.className).toContain('left-1/2'); expect(containerImage.className).toContain('-translate-x-1/2'); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index ed4f1721..c6745bea 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, Clock3, RotateCcw, + Settings, Sparkles, XCircle, } from 'lucide-react'; @@ -49,11 +50,18 @@ import { resolveRenderableItemFrame, } from './match3dRuntimePresentation'; import { + MATCH3D_RUNTIME_BOARD_BASE_CLASS, + MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, + MATCH3D_RUNTIME_BOARD_WIDTH, + MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, + MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, + MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, MATCH3D_RUNTIME_HEADER_CARD_CLASS, MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, + MATCH3D_RUNTIME_STAGE_CLASS, MATCH3D_RUNTIME_TIMER_CLASS, MATCH3D_RUNTIME_TIMER_URGENT_CLASS, } from './match3dRuntimeUiStyles'; @@ -697,6 +705,7 @@ export function Match3DRuntimeShell({ const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] = useState(''); + const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME; const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG; const runtimeGeneratedItemAssets = useMemo( @@ -1251,6 +1260,7 @@ export function Match3DRuntimeShell({ isRunState(run.status, 'running') ? MATCH3D_RUNTIME_TIMER_URGENT_CLASS : MATCH3D_RUNTIME_TIMER_CLASS; + const canRestartRun = Boolean(run?.runId) && !isBusy; return (
setIsSettingsPanelOpen(true)} + aria-label="打开抓大鹅设置" > - +
-
+
* { + position: relative; + z-index: 1; } .puzzle-runtime-dialog__line { diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts index 9cd4bed7..f6a4f3da 100644 --- a/src/routing/RouteImageReadyGate.test.ts +++ b/src/routing/RouteImageReadyGate.test.ts @@ -59,7 +59,7 @@ describe('RouteImageReadyGate image url helpers', () => { RouteImageReadyGate, { eyebrow: '正在载入游戏', - text: '正在载入冒险...', + text: '正在加载内容', }, createElement( 'section', @@ -100,7 +100,7 @@ describe('RouteImageReadyGate image url helpers', () => { const { container } = render( createElement(RouteLoadingScreen, { eyebrow: '正在载入游戏', - text: '正在载入冒险...', + text: '正在加载内容', }), ); const shell = container.firstElementChild; diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 87de1131..27ffa541 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -164,7 +164,7 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute { return { kind: 'game', loadingEyebrow: '正在载入游戏', - loadingText: '正在载入冒险...', + loadingText: '正在加载内容', Component: GameApp, }; }