This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -16,10 +16,42 @@
---
## 2026-05-14 抓大鹅草稿计划同时产出作品信息和默认封面
- 背景:抓大鹅草稿生成原本重点在物品名称和素材生成,作品名称、作品描述、作品标签和封面容易在结果页保持空值或只靠用户手动补齐。
- 决策:`match3d_compile_draft` 的作品生成计划必须同时返回 `gameName``summary``tags`、背景音乐、背景提示词和物品计划。后端先把 `gameName``summary` 写入 `match3d_work_profile`,再基于题材、作品名和描述自动调用作品标签生成器;标签生成失败时才合并计划 tags 与本地兜底。若作品尚无用户封面,草稿生成完成后默认使用生成的抓大鹅容器 UI 图作为 `coverImageSrc`,容器图缺失时才回退到纯背景图。
- 影响范围:`server-rs/crates/api-server/src/match3d.rs`、Match3D works shared contracts、`Match3DResultView` 手动标签生成、抓大鹅草稿素材生成流水线文档。
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml``cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml``npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 2026-05-13 抓大鹅草稿生成固定 10 光点且入口不生成点击音效
- 背景:抓大鹅入口草稿生成需要有明确、稳定的光点成本,且入口不再把点击音效作为草稿生成选项暴露,避免一次草稿生成混入额外音效任务与扣费。
- 决策:`match3d_compile_draft` 草稿生成固定预扣 `10` 光点,失败时按钱包流水退款;创作页按钮展示 `消耗10光点`,不展示 `生成音效` Toggle入口表单和重试均固定传 `generateClickSound = false`。物品点击音效只保留在结果页 `素材配置 > 物品` 详情面板手动生成,并按独立音效任务扣费。
- 影响范围:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-creation/Match3DAgentWorkspace.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、抓大鹅 PRD、F1 入口文档、素材生成流水线文档和音频 Tab 文档。
- 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx``npm run typecheck``cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml``cargo check -p api-server --manifest-path server-rs\Cargo.toml``npm run check:encoding`
## 2026-05-13 抓大鹅 5x5 物品素材固定绿幕生成并本地转透明
- 背景:抓大鹅 2D 五视角素材如果继续让上游自由生成白底、灰底、纹理底或透明感背景,切割后的物品图会带底色块,运行态叠加到中心容器时观感不稳定。
- 决策:抓大鹅物品素材 sheet 的 VectorEngine prompt 固定要求每格使用统一纯绿色绿幕背景;`api-server` 在按 5x5 切割前先对整张素材图执行绿幕 alpha 处理,再做格内前景边界校准和 PNG 上传。物品本体不得使用与绿幕相同的纯绿色,天然绿色物品需要用描边和不同绿相区分。
- 影响范围:`server-rs/crates/api-server/src/match3d.rs`、抓大鹅素材生成 prompt、5x5 切图、OSS 视角图上传和 Match3D 素材生成技术文档。
- 验证方式:`cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml` 覆盖 prompt 中的绿幕约束、绿幕转透明和主体保留;执行 `npm run check:encoding`
- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 2026-05-13 拼图和抓大鹅 UI 生图拆分为纯背景与运行态默认 UI
- 背景:拼图和抓大鹅此前的 UI 背景生成容易把拼图槽、锅状竞技区或其它 UI 元素烘进背景图,导致运行态默认 UI、真实物品和生成图层重复。
- 决策:拼图只生成 `9:16` 纯题材背景图不携带拼图槽、棋盘框、按钮、HUD、文字或物品拼图槽继续使用运行态默认样式。抓大鹅生成 `9:16` 纯背景图和一张独立 `1:1` 中心容器 UI 图;纯背景不得包含锅/圆盘/托盘/物品槽/HUD/文字/物品,容器图单独贴合题材设定并覆盖运行态中心容器,底部备选栏继续使用默认 UI。
- 影响范围:拼图 UI 背景 prompt、抓大鹅 `backgroundAsset` JSON、Match3D shared contracts、运行态背景/容器渲染、生成进度工作量和对应 PRD/技术文档。
- 验证方式:检查 VectorEngine 请求 prompt 不要求背景图生成锅或槽位;运行态有 `containerImageSrc/containerImageObjectKey` 时使用容器图,否则默认容器兜底;执行拼图/抓大鹅定向测试、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md`
## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格
- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。
- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。
- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`2026-05-13 起,入口参考图固定只展示 `1` 个完整独立物品,不能展示 `5` 个物品样张或多物品散点图。旧 3D 风格参考图不再保留为入口资产。
- 2026-05-13 补充:`像素复古` 不能只写“复古像素”或“有限色板”;前端、参考图脚本和后端素材图 prompt 必须要求约 `64x64` 低分辨率像素块、整数倍放大、硬边方块像素、`12-24` 色有限色板,并禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。后端收到 `assetStyleId = pixel-retro``assetStyleLabel = 像素复古` 时必须兜底补齐这些约束。
- 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。
- 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx``cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml``npm run typecheck``npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`
- 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md``docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`

View File

@@ -25,9 +25,9 @@
## generated 音频路径进运行态前要先换签
- 现象:草稿页 audio 控件能播放背景音乐但拼图或抓大鹅运行态开局后背景音乐不响Network 可能出现裸 `/generated-*-assets/...mp3` 私有路径 403。
- 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path浏览器 `<audio>` 不能像公开静态资源一样直接请求裸路径。
- 处理:运行态隐藏 `<audio>` 设置 `src` 前,先通过 `useResolvedAssetReadUrl``resolveAssetReadUrl` 换签;播放失败只静默兜底,不阻断局内交互。拼图读取 `currentLevel.backgroundMusic.audioSrc`,抓大鹅读取 `generatedItemAssets[].backgroundMusic.audioSrc`
- 验证:运行态开局后 `<audio loop>``src` 为签名 URL 或公开 URL`npm run typecheck` 不报契约字段缺失,后端 run response 带 `backgroundMusic`
- 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path浏览器 `<audio>` 不能像公开静态资源一样直接请求裸路径。另一个常见误判是浏览器拒绝自动播放,资源已经进入运行态但开局第一次 `audio.play()` 被拦截。
- 处理:结果页试听控件和运行态隐藏 `<audio>` 设置 `src` 前,先通过 `useResolvedAssetReadUrl``resolveAssetReadUrl` 换签;签名未就绪时不要回退请求裸 generated 路径。运行态自动播放失败只静默兜底,但玩家首次按下拼图块或点击抓大鹅物品时要重试同一个背景音乐播放函数。拼图读取 `currentLevel.backgroundMusic.audioSrc`,抓大鹅读取 `generatedItemAssets[].backgroundMusic.audioSrc`
- 验证:结果页试听和运行态 `<audio loop>``src` 为签名 URL 或公开 URL拼图/抓大鹅运行态首次局内交互后会再次尝试播放背景音乐;`npm run typecheck` 不报契约字段缺失,后端 run response 带 `backgroundMusic`
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 中文乱码与编码风险
@@ -51,6 +51,14 @@
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml``cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
- 关联:`server-rs/crates/api-server/src/password_management.rs``server-rs/crates/api-server/src/state.rs``docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`
## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run api-server` 看似启动但生成接口不可用。
- 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。
- 处理:前端 API 错误展示读取 `details.message` 后继续读取 `details.reason``api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 `5*5` 素材图提示词还必须要求相邻物体主体至少保留 `1/4` 单格宽度空白间距,避免切割后相邻格内容污染。
- 验证:`npm run test -- src/services/apiClient.test.ts` 覆盖 `details.reason``cargo test -p api-server state --manifest-path server-rs/Cargo.toml` 覆盖半配置 OSS 不阻断启动;`npm run api-server` 后按实际 `GENARRATIVE_API_PORT` 请求 `/healthz`,不要默认打 `3100`
- 关联:`packages/shared/src/http.ts``server-rs/crates/api-server/src/state.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md``docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md`
## `.hermes` 只放共享内容,不放个人 Hermes 配置
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
@@ -124,6 +132,30 @@
- 验证:后端单测应覆盖 `images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调先看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``src/services/puzzleReferenceImage.ts``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`
## 拼图 edits 报 error sending request 先看网络分类
- 现象:拼图有参考图时返回 `拼图图片生成失败:创建拼图 VectorEngine 图片编辑任务失败error sending request for url (https://api.vectorengine.ai/v1/images/edits)`,后端没有 `拼图 VectorEngine 图片编辑 HTTP 返回` 日志。
- 原因:这是 `reqwest``send()` 阶段失败,尚未收到 VectorEngine HTTP 响应;常见原因是服务器网络 / DNS / 防火墙 / 代理问题,或上游网关中断 multipart 连接。
- 处理:查看错误响应 `details.reason/source/connect/body/timeout/endpoint``拼图 VectorEngine 请求发送失败` 日志。拼图图片客户端已强制 HTTP/1.1,降低 multipart HTTP/2 兼容风险;若 `connect=true` 先查网络出口,若 `body=true` 先查参考图大小和 multipart 发送。
- 验证:`curl -i -X POST https://api.vectorengine.ai/v1/images/edits -H "Authorization: Bearer invalid" -F "model=gpt-image-2-all" -F "prompt=test" -F "n=1" -F "size=1024x1024"` 至少应返回 HTTP `401`说明域名、TLS 和路径可达;执行 `cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/puzzle.rs``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
## 拼图自动试玩缺 UI 背景先查本地运行态字段继承
- 现象:拼图草稿生成完成后,草稿数据里已有首关 `uiBackgroundImageSrc`,结果页素材配置也能看到背景图,但自动试玩或结果页“试玩”进入局内仍只显示封面模糊背景,甚至看不到 UI 背景。
- 原因:生成完成后的自动试玩走前端 `startLocalPuzzleRun(...)` 本地运行态兜底,不经过后端 `start_puzzle_run`;如果本地 run 只把 `coverImageSrc` 带入 `currentLevel`,就会丢掉 `levels[].uiBackgroundImageSrc``levels[].backgroundMusic`
- 处理:`startLocalPuzzleRun` 与本地下一关 handoff 都要从关卡 `levels[]` 复制 `uiBackgroundImageSrc``backgroundMusic``currentLevel``PuzzleRuntimeShell` 继续读取 `currentLevel.uiBackgroundImageSrc` 渲染全屏背景。
- 验证:`npm run test -- src/services/puzzle-runtime/puzzleLocalRuntime.test.ts src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
- 关联:`src/services/puzzle-runtime/puzzleLocalRuntime.ts``src/components/puzzle-runtime/PuzzleRuntimeShell.tsx``src/components/puzzle-result/PuzzleResultView.tsx``docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试
- 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway``504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。
- 原因:首图生成走 VectorEngine `gpt-image-2-all`,默认 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000`;若上游在该窗口内未返回,后端退款并返回超时错误。旧前端 action 写请求会对 502/503/504 自动重试一次,导致同一次点击重复触发生图与扣退费。
- 处理:拼图/创作 Agent 的 `executeAction` 默认不做前端自动重试;后端将 VectorEngine / 图片请求超时映射为 `504 Gateway Timeout``error.details.provider=vector-engine``timeout=true`。真实排障按日志同一 `session_id``拼图 VectorEngine 图片生成 HTTP 返回` 是否缺失,以及钱包流水扣费到退款的时间差是否接近 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts``cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run api-server` 后检查 `/healthz`
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts``server-rs/crates/api-server/src/puzzle.rs``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
## 旧后端路线文档造成判断漂移
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
@@ -228,6 +260,13 @@
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。
- 关联:`scripts/api-server-dev.mjs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## OSS 密钥键名不要把字母 O 写成数字 0
- 现象:`.env.secrets.local` 看起来已经配置 OSS AccessKey Secret但拼图或抓大鹅生成仍返回 `OSS 未完成环境变量配置`
- 原因:后端只读取 `ALIYUN_OSS_ACCESS_KEY_SECRET`。如果写成 `ALIYUN_0SS_ACCESS_KEY_SECRET`,中间是数字 `0`,配置合并检查会显示正确键缺失,`api-server` 不会初始化 OSS 客户端。另一个常见原因是外层 shell / IDE 预置了空的 `ALIYUN_OSS_*`,旧启动脚本会把空值当作最高优先级,导致 `.env.local``.env.secrets.local` 的真实值被跳过。
- 处理:只改键名为 `ALIYUN_OSS_ACCESS_KEY_SECRET`,保留原值;不要在日志、文档或对话里输出密钥内容。本地启动脚本应只保护非空外层环境变量,空字符串或全空白值不得遮蔽本地 env 文件。
- 验证:运行 `npm run check:api-server-env`,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``ALIYUN_OSS_BUCKET``ALIYUN_OSS_ENDPOINT``ALIYUN_OSS_ACCESS_KEY_ID``ALIYUN_OSS_ACCESS_KEY_SECRET` 都是 `present`,再重启 `npm run api-server``npm run dev`
## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败
- 现象:拼图创作表单生成进度卡在 98%`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `拼图图片生成失败OSS V4 签名时间格式化失败`
@@ -482,7 +521,7 @@
- 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB。
- 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材。
- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个视角,单张 1K 素材图固定 5x5 切割,一行对应一个物品,超过 5 个物品自动分批并行生图`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]` 或首图引用。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。
- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个视角,单张 1K 素材图固定 5x5,最多承载 5 个物品,一行对应一个物品,不足 5 个物品也补齐到完整 5 行;超过 5 个物品自动分批并行生图。素材图 prompt 固定要求纯绿色绿幕背景,切割前先把绿幕处理为透明 alpha再做格内内容前景边界校准并带留白避免固定内缩切掉贴近格线的主体`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]` 或首图引用。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。
- 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml``npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`
- 关联:`server-rs/crates/api-server/src/match3d.rs``src/components/match3d-runtime/Match3DRuntimeShell.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
@@ -506,10 +545,18 @@
- 现象:结果页能看到生成的物品图片,但点击试玩或从推荐 / 公开作品进入正式抓大鹅时,局内仍显示默认积木素材。
- 原因:结果页本地 `assetDrafts` 和作品 profile 的 `generatedItemAssets` 可能不同步;推荐流内嵌运行态若只读卡片摘要,卡片缺素材时会把已持久化 profile 素材丢掉;点击试玩时 React state 异步更新也可能让运行态第一帧读取旧 `match3dProfile`
- 处理:删除、批量新增、音效生成或封面引用物品素材后,都把当前 `generatedItemAssets` 写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有 `imageViews[]`首图引用补齐旧 draft点击试玩前把试玩可用物品种类通过 `itemTypeCountOverride` 降到已生成 2D 素材数量;推荐流内嵌运行态启动前若卡片摘要没有生成素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell``PlatformEntryFlowShellImpl` 需要维护 `match3dRuntimeProfile`,在 `startMatch3DRunFromProfile` 创建 run 后立即锁定本次完整 profileruntime 渲染时优先按 `run.profileId` 使用这份 profile而不是等待普通 `match3dProfile` state 下一轮刷新。
- 处理:删除、批量新增、音效生成或封面引用物品素材后,都把当前 `generatedItemAssets` 写回作品 profile`Match3DResultView` 合并同 `itemId` 的 draft/profile 素材,用 profile 已有 `imageViews[]`首图引用`backgroundMusic``backgroundAsset` 补齐旧 draft点击试玩前把试玩可用物品种类通过 `itemTypeCountOverride` 降到已生成 2D 素材数量;推荐流内嵌运行态启动前若卡片摘要没有物品图片素材,补读 `getMatch3DWorkDetail(profileId)` 并把详情资产传给 `Match3DRuntimeShell``PlatformEntryFlowShellImpl` 需要维护 `match3dRuntimeProfile`,在 `startMatch3DRunFromProfile` 创建 run 后立即锁定本次完整 profileruntime 渲染时优先按 `run.profileId` 使用这份 profile而不是等待普通 `match3dProfile` state 下一轮刷新。同 profile 下已有 `generatedItemAssets` 时不能因为图片完整性判断失败就覆盖为空数组。判断是否需要补读详情时只看 `imageViews[]``imageSrc/imageObjectKey`;背景、音乐、容器 UI 是附属运行态资产,不能单独证明物品素材已完整。
- 验证:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`,并检查历史草稿和公开 M3 作品的 Network 响应里 `generatedItemAssets[].imageViews/imageSrc/imageObjectKey`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/match3d-runtime/Match3DPhysicsBoard.tsx``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅结果页音频试听也要先换签
- 现象:抓大鹅草稿生成完成后,背景音乐已写在 `generatedItemAssets[0].backgroundMusic.audioSrc`,但 `素材配置 > 背景音乐` 或物品详情音效 `<audio>` 不能播放Network 可能请求裸 `/generated-match3d-assets/...mp3` 并返回 403。
- 原因:结果页试听控件和运行态一样运行在浏览器里,不能直接读取 generated 私有对象;只在运行态换签会造成“运行态可能有声,结果页不能预览”的割裂。
- 处理:结果页音频控件统一通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 取得签名 URL 后再传给 `<audio>`;换签失败时只显示“音频已绑定”,不要回退请求裸 generated path。
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx` 覆盖背景音乐和点击音效试听使用签名 URL。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/assetReadUrlService.ts``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 法律文档弹窗通过 portal 挂载时要显式带平台主题
- 现象:登录弹窗内点击协议链接打开法律文档时,弹窗可能继承不到 `platform-theme--light/dark` 变量,或者层级低于登录遮罩导致不可见。

View File

@@ -124,3 +124,4 @@
- 草稿作品卡在生成中展示“生成中”状态标记;新生成完成且用户尚未查看的草稿在卡片右上角展示红点。
- 底部一级“草稿”Tab 在存在未查看新完成草稿时展示红点用户点击查看带红点的作品后该作品红点消失。若草稿页已无任何带红点作品底部“草稿”Tab 红点同步消失。
- 生成完成时如果用户仍停留在对应生成过程页,可自动进入结果页;如果用户已经回到创作中心或其它功能页,不打断当前操作。
- 创作 Tab 的模板入口只允许被模板自身的开放状态禁用;某个草稿后台生成中时,不得用该玩法的 busy 状态禁用其它模板入口、同模板再次创建入口或阻止用户继续创建新作品。

View File

@@ -99,19 +99,20 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖:
- 2D 素材风格
- 难度选项
4. `需要消除次数` 与难度 `1~10` 数值不再作为独立输入框展示,由难度选项派生。
5. 生成抓大鹅草稿消耗 `20` 光点,生成按钮必须显式展示。
6. 结果页支持编辑游戏名称、标签、封面图等基础发布信息
7. 发布前支持试玩,并允许随时停止和修改配置
8. 发布不要求试玩通关
9. 单局运行态使用 `10` 分钟倒计时
10. 下方备选栏固定为 `7` 个格子
11. 玩家点击可见物品后,物品飞入备选栏
12. 备选栏中每凑齐 `3` 个相同物品 id 自动消除
13. 清空圆形空间中全部物品即胜利
14. 倒计时结束或备选栏满即失败
15. 胜利 / 失败后展示结算界面
16. 入口页的 2D 素材风格选择会进入素材图提示词,并作为后续物品素材新增和重绘的默认提示词依据
17. 点击、入槽、消除、失败、胜利的即时反馈效果由前端先行呈现,后端负责权威确认、状态落库和成绩可信性
5. 生成抓大鹅草稿固定消耗 `10` 光点,生成按钮必须显式展示。
6. 结果页背景音乐重新生成固定消耗 `5` 光点UI 背景重新生成固定消耗 `2` 光点;批量新增物品素材按实际可新增物品名每 `5` 个消耗 `2` 光点,不足 `5` 个向上取整
7. 结果页支持编辑游戏名称、标签、封面图等基础发布信息
8. 发布前支持试玩,并允许随时停止和修改配置
9. 发布不要求试玩通关
10. 单局运行态使用 `10` 分钟倒计时
11. 下方备选栏固定为 `7` 个格子
12. 玩家点击可见物品后,物品飞入备选栏。
13. 备选栏中每凑齐 `3` 个相同物品 id 自动消除
14. 清空圆形空间中全部物品即胜利
15. 倒计时结束或备选栏满即失败
16. 胜利 / 失败后展示结算界面
17. 入口页的 2D 素材风格选择会进入素材图提示词,并作为后续物品素材新增和重绘的默认提示词依据
18. 点击、入槽、消除、失败、胜利的即时反馈效果由前端先行呈现,后端负责权威确认、状态落库和成绩可信性。
---
@@ -191,17 +192,19 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义
```
每个内置选项使用 VectorEngine `gpt-image-2-all` 生成的画风参考图展示;参考图保存在 `public/match3d-style-references/`,只作为入口选择的视觉提示,不作为用户上传参考图。选择内置风格时,前端提交 `assetStyleId``assetStyleLabel` 与对应 `assetStylePrompt`。选择 `自定义` 时必须弹出独立面板,用户填写描述后才允许应用;自定义描述作为 `assetStylePrompt` 进入后端生成链路。
每个内置选项使用 VectorEngine `gpt-image-2-all` 生成的画风参考图展示;参考图保存在 `public/match3d-style-references/`,只作为入口选择的视觉提示,不作为用户上传参考图。每张参考图只展示 `1` 个完整独立物品,不能展示 `5` 个物品样张或多物品散点图,避免用户误判为物品数量配置。选择内置风格时,前端提交 `assetStyleId``assetStyleLabel` 与对应 `assetStylePrompt`。选择 `自定义` 时必须弹出独立面板,用户填写描述后才允许应用;自定义描述作为 `assetStylePrompt` 进入后端生成链路。
`像素复古``assetStylePrompt` 必须是强约束而不是泛化描述:真正复古像素 2D 游戏道具 sprite先按约 `64x64` 低分辨率像素块绘制再整数倍放大,硬边方块像素清晰可见,有限色板 `12-24` 色,并禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。后端收到 `assetStyleId = pixel-retro` 时必须兜底补齐这组约束。
## 6.3 参考图片
抓大鹅入口页不展示参考图片上传。题材表现由题材文本和草稿切割图片链路承接;草稿生成阶段会生成多视角 2D 物品素材并写入作品 profile。结果页 `素材配置 > 物品` 继续承接物品素材预览、删除、批量新增和音效配置。
## 6.4 生成音效开关
## 6.4 生成音效入口
抓大鹅入口页在生成按钮前提供 `生成音效` Toggle,默认关闭。关闭时,草稿生成只保存 `generatedItemAssets[].soundPrompt`,不调用 Vidu 生成点击音效。
抓大鹅入口页不展示 `生成音效` Toggle草稿生成阶段只保存 `generatedItemAssets[].soundPrompt`,不调用 Vidu 生成点击音效,也不产生点击音效相关扣费
用户打开 Toggle 后,前端在创建会话和执行 `match3d_compile_draft` 时提交 `generateClickSound=true`。后端完成物品名称与 `soundPrompt` 生成后,在图片素材生成阶段为每个生成物品调用 Vidu 生成点击音效,并把结果写入对应 `generatedItemAssets[].clickSound`。音效生成复用通用创作音频接口和资产落点;结果页仍保留单个物品音效的手动补生成入口。
结果页仍保留单个物品音效的手动补生成入口。用户在 `素材配置 > 物品` 详情面板中生成点击音效时,后端复用通用创作音频接口和资产落点,把结果写入对应 `generatedItemAssets[].clickSound`
---
@@ -228,10 +231,16 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
## 7.3 素材生成边界
抓大鹅草稿生成链路会根据难度生成题材物品素材文本模型生成物品名VectorEngine 分批生成 `1:1` 素材图并切割为每个物品 `5` 张不同视角图片,再转存 OSS。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页批量新增物品时继续以该风格作为默认提示词起点。
抓大鹅草稿生成链路会根据难度生成题材物品素材文本模型生成物品名VectorEngine 分批生成 `1:1` 素材图并切割为每个物品 `5` 张不同视角图片,再转存 OSS。实际生成数量必须向上补齐为 `5` 的倍数,并生成补齐物品的名称和对应图片;例如标准难度玩法使用 `9` 种物品,但素材实际生成 `10` 种。入口页选择的 `assetStylePrompt` 必须写入素材图提示词;结果页批量新增物品时继续以该风格作为默认提示词起点。
批量新增物品素材与草稿生成使用同一素材图处理流程:先按用户实际新增名称补齐到 `5` 的倍数,整图生成完成后丢弃补齐用临时物品,只对用户实际新增项继续绿幕抠背景、切割和 OSS 上传,并只把这些真实新增物品写入 `generatedItemAssets`;补齐用临时物品不进入作品资产列表、不参与发布校验和运行态映射。计费数量按清洗、去重、排除已有物品并截断到容量上限后的实际新增数量计算。
素材图提示词必须固定要求 `5*5` 严格均匀排布,每格主体完整居中,不得跨格、贴边或越界,避免切割成独立图片后出现相邻物品内容污染。
生成出的独立图片必须作为结果页 `素材配置 > 物品` 的预览资产返回。图片素材生成成功时 `generatedItemAssets[].status = image_ready`,并携带 `imageViews[]`,兼容字段 `imageSrc` / `imageObjectKey` 指向首张视角图;正式平台资产绑定和更完整的二次编辑流程以后续技术方案为准。
局内 UI 生成分为两类图片:`9:16` 纯背景图和 `1:1` 中心容器 UI 图。纯背景图只表现题材氛围和环境不得生成锅、圆盘、托盘、拼图槽、物品槽、HUD、文字、按钮、倒计时、分数或物品中心容器 UI 图单独生成,贴合题材设定,用于覆盖运行态默认圆形竞技容器。容器图必须参考 `public/match3d-background-references/pot-fused-reference.png` 的大尺寸轻俯视构图:容器外轮廓接近画布四边,宽度约占画布 `86%-92%`、高度约占 `82%-90%`,内口为横向椭圆,不能生成小圆盘、正俯视扁盘、侧视碗或小托盘。底部备选栏、顶部控件和拼图/物品槽位继续使用运行态默认 UI不烘进生成背景。
## 7.4 发布前试玩
发布前需要支持试玩当前关卡。
@@ -267,8 +276,8 @@ Match3D 首版参考拼图后期的入口表单收集方式,而不是早期的
运行时主交互空间是一个有边界的圆形空间。
1. 圆形空间使用俯视角。
2. 背景环境资源后续可以尝试伪 3D 视角效果
3. 圆形空间边界是中间交互图案的边界
2. 背景环境资源只作为纯背景层,不承接圆形空间边界
3. 圆形空间边界由运行态默认容器或生成出的中心容器 UI 图承接,不能烘进纯背景图
4. 物品不能超出圆形边界,也不能被边界压住或裁切。
5. 运行态快照中的 `x / y / radius` 使用前端可直接渲染的 `0~1` 归一化坐标;圆心固定为 `(0.5, 0.5)`,圆形可用半径为 `0.5`
6. 后端生成和前端兜底渲染都必须满足 `distance((x, y), (0.5, 0.5)) + radius <= 0.5 - safeMargin``safeMargin` 至少覆盖圆形边框和视觉阴影,避免可消除图案贴边裁切。
@@ -680,12 +689,11 @@ GET /api/runtime/match3d/runs/:runId
## 14.2 入口表单
入口表单只展示个输入块:
入口表单只展示个输入块:
1. `想做一个什么题材的抓大鹅?` 大文本输入框。
2. `2D素材风格` 横向滑动风格卡,最后一个为 `自定义`
3. `难度` 选项按钮。
4. `生成音效` Toggle默认关闭。
入口页不展示参考图、`需要消除次数` 数值输入、`难度数值` 滑杆,也不展示 `题材 / 物品 / 难度` 三个摘要框。`需要消除次数``difficulty` 由难度选项派生后提交给后端。
@@ -695,6 +703,8 @@ GET /api/runtime/match3d/runs/:runId
结果页保持清爽,复用平台已有作品结果页风格。
结果页 `难度配置` 中的四档难度使用横向离散拖动条呈现,拖动条只允许停在 `轻松 / 标准 / 进阶 / 硬核` 四个刻度上,刻度标签可点击切换,仍按同一映射派生 `需要消除次数``difficulty``物品种类`
点击按钮弹出独立面板时,不实现成在当前面板下面展开内容。
## 14.4 运行态
@@ -718,7 +728,7 @@ GET /api/runtime/match3d/runs/:runId
3. 入口页不展示参考图上传。
4. 内置风格显示画风参考图,自定义风格通过独立面板填写并进入提交 payload。
5. 移动端入口页所有内容一屏展示,不产生纵向滚动。
6. `生成音效` 关闭时草稿生成不产生 `clickSound`;打开时首批生成物品随图片素材生成并持久化点击音效。
6. 入口页不展示 `生成音效` Toggle草稿生成不产生 `clickSound`,结果页物品详情仍可手动生成点击音效。
7. 系统可生成待发布结果页,并在草稿中返回首批多视角 2D 切割图片素材预览。
8. 用户可编辑游戏名称、标签、封面图等基础信息。
9. 用户可发布前试玩,且试玩失败不阻断发布。

View File

@@ -101,11 +101,25 @@ HYPER3D_MODEL_REQUEST_TIMEOUT_MS / RODIN_MODEL_REQUEST_TIMEOUT_MS
3. 文本 LLM provider 为 `ark` 且未配置 `GENARRATIVE_LLM_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
5. 具体模型名缺失时不在配置层伪造默认模型,调用到对应能力时由下游配置校验返回缺配置错误。
6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*``GENARRATIVE_LLM_*` 或前端变量。
6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*``GENARRATIVE_LLM_*` 或前端变量。拼图 Agent 的生成 action 不做前端自动重试,避免一次点击在上游超时后重复触发外部生图与钱包扣退费;若 VectorEngine 请求达到该超时窗口api-server 返回 `504 Gateway Timeout``error.details.provider``vector-engine`,并保留具体超时 message。
7. 火山引擎语音能力由 `platform-speech` 收口协议帧与上游鉴权,`api-server` 只暴露平台鉴权后的代理路由,不向前端返回任何密钥字段。
8. Hyper3D Rodin Gen-2 使用公开默认 `https://api.hyper3d.com/api/v2`API Key 只读取 `HYPER3D_API_KEY` / `RODIN_API_KEY`,不复用文本 LLM、图片或音频网关密钥。
9. APIMart 当前只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态理解链路GPT-image-2 图片生成不得再读取 APIMart 配置。
10. 本地 `npm run api-server``npm run dev:rust``npm run dev` 的环境文件优先级固定为外层 shell 变量最高,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖;真实密钥建议放在 `.env.secrets.local`,防止 `.env` 中的空示例值覆盖私密配置。
10. 本地 `npm run api-server``npm run dev:rust``npm run dev``npm run dev:web` 的环境文件优先级固定为非空外层 shell 变量最高,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖;真实密钥建议放在 `.env.secrets.local`,防止 `.env` 中的空示例值覆盖私密配置。外层 shell 变量如果是空字符串或全空白,不再遮蔽本地 env 文件中的真实值。
11. OSS 客户端只在 `ALIYUN_OSS_BUCKET``ALIYUN_OSS_ENDPOINT``ALIYUN_OSS_ACCESS_KEY_ID``ALIYUN_OSS_ACCESS_KEY_SECRET` 四项齐全时初始化。四项全部缺失表示未启用 OSS部分缺失时 `api-server` 记录 warning 并继续启动,具体上传、换签或读取 generated 私有资产的接口返回 `OSS 未完成环境变量配置`,并在 `error.details.missingEnv` 中列出缺失变量。
12. 抓大鹅 2D 草稿素材生成需要同时具备 VectorEngine 与 OSS 配置VectorEngine 负责 `gpt-image-2-all` 生成 1K 素材图OSS 负责保存 5x5 切割后的五视角图片。缺少 VectorEngine 或 OSS 时应通过 `error.details.reason` 向前端暴露具体缺项,不能只显示泛化“服务暂不可用”。素材图生成、封面生成和背景图生成在调用 VectorEngine 前必须先预检 OSS避免已消耗外部生图后才发现无法落库。
13. 拼图有参考图且开启 AI 重绘时使用 VectorEngine `POST /v1/images/edits` multipart 接口。若返回 `error sending request for url`,代表后端未收到 HTTP 响应;响应 `details` 会带 `reason``source``connect``body``timeout``endpoint`排查时优先检查服务器网络、DNS、防火墙、代理和参考图大小。拼图图片客户端强制 HTTP/1.1,以降低上游 multipart HTTP/2 连接中断风险。
14. 本地排查 `OSS 未完成环境变量配置` 时必须核对键名是否精确为 `ALIYUN_OSS_ACCESS_KEY_SECRET`。常见误写是把 `OSS` 的首字母 `O` 写成数字 `0`,例如 `ALIYUN_0SS_ACCESS_KEY_SECRET`;该键不会被 `api-server` 读取。
## 本地配置检查
拼图真实生成同时依赖 VectorEngine 与 OSS。触发生成前可先运行
```bash
npm run check:api-server-env
```
该命令只输出配置项是否存在,不打印密钥值。若显示 `ALIYUN_0SS_*`,说明把 `OSS` 的字母 `O` 写成了数字 `0`。修正 env 文件后必须重启 `npm run api-server``npm run dev`,已经运行中的 `api-server` 进程不会自动读取新的环境变量。
## 示例文件

View File

@@ -20,6 +20,8 @@
- Big Fish Agent 动作 `big_fish_publish_game`
- Puzzle Agent 图片生成动作 `compile_puzzle_draft``generate_puzzle_images`
- Puzzle Agent 动作 `publish_puzzle_work`
- Match3D / 抓大鹅草稿生成动作 `match3d_compile_draft`
- 拼图 / 抓大鹅结果页手动生成背景音乐、UI 背景与抓大鹅批量新增物品素材
暂不接入以下入口:
@@ -31,6 +33,10 @@
## 计费规则
- 每次可计费资产操作消耗 `1` 枚光点。
- 例外Match3D / 抓大鹅草稿生成是一次完整草稿外部生成动作,固定消耗 `10` 枚光点;流水仍复用 `asset_operation_consume` / `asset_operation_refund``asset_kind = match3d_draft_generation`
- 例外:拼图 / 抓大鹅背景音乐生成固定消耗 `5` 枚光点;物品点击音效仍按单个音效任务消耗 `10` 枚光点。
- 例外:拼图 / 抓大鹅 UI 背景重新生成固定消耗 `2` 枚光点。
- 例外:抓大鹅结果页批量新增物品素材按实际可新增物品名计费,每 `5` 个消耗 `2` 枚光点,不足 `5` 个向上按 `5` 个计。重复名称、作品中已有名称和超过容量上限的名称不进入计费数量。
- 图片生成和作品发布都按资产操作计费;余额不足时禁止继续执行。
- 在调用外部图片生成或发布 mutation 前预扣,余额不足时直接返回业务错误,不继续调用后续资产操作。
- 如果图片生成、远程下载、OSS 写入、资产记录确认或发布 mutation 失败,资产操作服务自动发起同额退款。
@@ -38,10 +44,10 @@
## 钱包流水
公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作:
公开两个流水来源类型,统一覆盖“资产生成”和“资产发布”这两类资产操作。流水金额由具体资产操作成本决定,不再假定所有资产操作都是 `1` 枚光点
- `asset_operation_consume`:资产操作预扣,`amount_delta = -1`
- `asset_operation_refund`:资产操作失败退款,`amount_delta = +1`
- `asset_operation_consume`:资产操作预扣,`amount_delta = -points_cost`
- `asset_operation_refund`:资产操作失败退款,`amount_delta = +points_cost`
`wallet_ledger_id` 由 Axum 传入,格式:

View File

@@ -105,3 +105,18 @@ npm run dev:web
1. 远端 `xushi-p4wfr` 仍需恢复数据库挂起状态,否则对应玩法 procedure 仍不可用。
2. 本地开发如只为体验抓大鹅,可继续使用本地 SpacetimeDB 链路。
3. 认证快照同步失败会影响进程重启后的远端恢复完整性,需要在目标库恢复后重新完成一次成功同步。
## 7. 2026-05-13 补充:服务暂不可用的分层排查
抓大鹅生成页只看到“服务暂不可用”时,不应先回退旧 3D / Rodin 链路,应按 2D 素材生成链路逐层定位:
1. 前端通用 API 错误展示必须读取 `error.details.reason`。VectorEngine、OSS 等配置缺失类错误常把具体原因写在 `details.reason`,如果只读 `details.message`,用户只能看到泛化的“服务暂不可用”。
2. `api-server` 启动不应被 OSS 半配置阻断。`ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 已配置但 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET` 缺失时,服务应记录 warning 并跳过 OSS 客户端初始化;需要上传或读取 generated 私有资产的接口继续返回 `OSS 未完成环境变量配置`,并通过 `details.missingEnv` 明确缺少哪几项。
3. 启动阶段从 SpacetimeDB 恢复认证快照只能降级远端恢复能力,不能长期卡住 `/healthz`。本地库未发布、连接后立即 close 或远端库挂起时,`api-server` 应在启动恢复超时后使用本地 `auth_store` 继续进入监听。
4. 抓大鹅真实生成仍依赖两组私密配置:`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 用于 `gpt-image-2-all` 生成 1K 素材图;完整 `ALIYUN_OSS_*` 四件套用于上传切割后的 `generated-match3d-assets` 五视角图片。缺任一组都应返回明确 `details.reason`;抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS不应先消耗生图调用再失败也不应恢复 GLB 生成。
验证时以实际 `GENARRATIVE_API_PORT` 为准;本地 `.env.local` 可能覆盖脚本默认 `3100`。例如当前本地端口为 `8082` 时,应请求:
```powershell
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8082/healthz
```

View File

@@ -20,7 +20,7 @@
生成页步骤固定为:
```text
生成游戏名称 -> 生成物品名称与背景音乐名称 -> 生成背景提示词 -> 分批生成1K素材图 -> 切割五视角图片 -> 上传图片资产 -> 生成背景音乐 -> 生成背景图 -> 写入草稿页
建立草稿存档 -> 生成作品计划 -> 生成背景提示词 -> 分批生成素材图 -> 切割独立图片 -> 上传图片资产 -> 校验素材结构 -> 生成背景音乐 -> 生成UI背景与容器 -> 写入草稿页
```
生成页只展示题材和物品数量,不展示玩法规则说明。
@@ -34,22 +34,24 @@
`match3d_compile_draft` action 的后端顺序为:
1. 读取 session config。
2. 草稿编译先创建可恢复 profile素材生成数量由入口页难度派生的物品种类决定轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21`
3. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、音频或 OSS 成功后才执行
4. 基于入口页题材设定文本调用文本模型生成作品生成计划。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。生成计划还必须包含 `backgroundMusic.title``backgroundMusic.style``backgroundMusic.prompt``backgroundPrompt`,以及 `items[]` 中每个物品的 `name``soundPrompt``backgroundMusic.title` 是背景音乐名称,`backgroundMusic.prompt` 固定为空字符串,用于后续 Suno 纯音乐生成;`backgroundPrompt` 用于生成局内竖屏背景图,必须描述绿色纵向背景与居中浅锅/圆盘状竞技区融合为一张完整背景图,且不包含 UI、文字、按钮、倒计时或物品。文本模型不可用时保留第 3 步的本地兜底,不阻断草稿
5. 后端从同一份作品生成计划读取当前难度所需数量的短物品名称和音效提示词;不得再只生成物品名称而丢失后续音效生成上下文
6. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成 `1:1``1024x1024` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关
7. 每个物品固定需要 `5` 个不同视角。单张素材图最多切成 `5*5 = 25` 格;因此单张图最多承载 `5` 个物品。若草稿物品数超过 `5`,后端按每批最多 `5` 个物品自动分批,多张素材图并行生成
8. 将每张素材图按 `n*n` 网格切割成独立图片,并按物品顺序连续分配 `5` 张视角图。每个物品 JSON 写入 `imageViews[]`,同时把第一个视角兼容写入 `imageSrc/imageObjectKey`
9. 将素材图和每张独立视角图片上传到 OSS。每次获得可恢复的图片资产后都要回写 `match3d_work_profile.generated_item_assets_json`。成功素材状态为 `image_ready`;失败素材保留已成功图片引用并记录 `error`。每个素材 JSON 同步保存 `soundPrompt`,首个素材 JSON 同步保存 `backgroundMusicTitle``backgroundMusicStyle``backgroundMusicPrompt` 保存为空字符串作为兼容字段
10. 后端在图片素材生成后使用 `backgroundMusic.title` 提交 Suno 背景音乐任务,`prompt` 为空,`tags` 来自 `backgroundMusic.style`,并固定走纯音乐生成。轮询完成后通过通用创作音频资产链路转存 OSS、确认 `asset_object`、绑定到 `match3d_work/background_music`,再写回首个素材的 `backgroundMusic`。音乐生成失败只记录 warning不阻断草稿页进入用户可在结果页 `素材配置 > 背景音乐` 重试
11. 若入口页 `generateClickSound=true`,后端在图片素材生成后继续为缺少 `clickSound` 的已生成物品并行提交 Vidu 点击音效任务,轮询完成后通过通用创作音频资产链路转存 OSS、确认 `asset_object`、绑定实体并写回对应素材的 `clickSound`;若开关关闭则只保存 `soundPrompt`,不调用音频生成
12. 背景图生成同样由 `api-server` 调用 VectorEngine `gpt-image-2-all`,尺寸固定为 `9:16`,并固定传入 `public/match3d-background-references/pot-fused-reference.png` 作为参考图。参考图只表达抓大鹅绿色页面背景和锅状圆形竞技区的融合构图,不包含 HUD、物品、文字或按钮。生成后的背景图上传到 `generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png`,并作为 `backgroundAsset` 挂在首个 `generatedItemAssets[]` JSON 上HTTP DTO 同时顶层输出 `backgroundPrompt``backgroundImageSrc``backgroundImageObjectKey``generatedBackgroundAsset`
13. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息、背景音乐资产信息和背景资产信息;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材、音乐与背景
2. 对本次 `match3d_compile_draft` 生成动作按 `sessionId + profileId + action 时间戳` 构造幂等流水并预扣 `10` 光点。余额不足时不继续创建草稿;后续任一步失败时自动按同额退款
3. 草稿编译先创建可恢复 profile素材生成数量由入口页难度派生的物品种类决定轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21`
4. 先调用 SpacetimeDB compile procedure 写入草稿。首次执行使用新 `profileId`;重试时复用 session draft / work profile 中已有 `profileId`。这一步不能等待 LLM、图片、音频或 OSS 成功后才执行
5. 基于入口页题材设定文本调用文本模型生成作品生成计划。模型固定请求 `gpt-4o`,只返回 JSON其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`summary` 为 18 到 48 个中文字符的作品描述。生成计划还必须包含 `tags``backgroundMusic.title``backgroundMusic.style``backgroundMusic.prompt``backgroundPrompt`,以及 `items[]` 中每个物品的 `name``soundPrompt``backgroundMusic.title` 是背景音乐名称,`backgroundMusic.prompt` 固定为空字符串,用于后续 Suno 纯音乐生成;`backgroundPrompt` 用于生成局内竖屏纯背景图只描述题材氛围、色彩和环境不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品。文本模型不可用时保留第 4 步的本地兜底,不阻断草稿
6. 后端把生成计划中的 `gameName` `summary` 写入 `match3d_work_profile` 作品信息后,自动调用作品标签生成器。标签生成器使用题材、作品名称和作品描述生成 3 到 6 个中文短标签;若调用失败或返回不足,则使用生成计划 tags 和本地兜底标签补齐。结果页手动 `AI生成作品标签` 也使用同一接口,并传入当前作品描述
7. 后端从同一份作品生成计划读取当前难度所需数量的短物品名称和音效提示词;不得再只生成物品名称而丢失后续音效生成上下文
8. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成 `1:1``1024x1024` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`,并强制每格使用统一纯绿色绿幕背景,避免白底或纹理背景进入运行态素材。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine不重新接入 APIMart 图片网关
9. 每个物品固定需要 `5` 个不同视角。单张素材图固定为 `5*5 = 25` 格,因此单张图承载 `5` 个物品。若用户要求或难度派生的物品种类不是 `5` 的倍数,后端必须向上补齐物品名称和对应图片到最近的 `5` 的倍数;例如标准难度需要 `9` 种玩法物品,实际生成 `10` 个物品名称和对应五视角图片。若草稿物品数超过 `5`,后端按每批 `5` 个物品自动分批,多张素材图并行生成
10. 将每张素材图按固定 `5 行 * 5 列` 切割成独立图片,并按物品顺序连续分配 `5` 张视角图。素材图提示词必须要求 `5*5` 严格均匀排布、每格主体完整居中、统一纯绿色绿幕背景、相邻物体主体至少保留 `1/4` 单格宽度空白间距、不得跨格、贴边或越界,避免裁剪后相邻格内容污染。切割前必须先在整张素材图上把绿幕背景处理为透明 alpha再在每个理论格子内按透明背景/前景像素做内容边界校准,并带少量安全留白导出;不能做固定内缩裁剪,避免贴近格线但未跨格的樱桃、叶片、把手等主体边缘被切掉。每个物品 JSON 写入 `imageViews[]`,同时把第一个视角兼容写入 `imageSrc/imageObjectKey`
11. 将素材图和每张独立视角图片上传到 OSS。每次获得可恢复的图片资产后都要回写 `match3d_work_profile.generated_item_assets_json`。成功素材状态为 `image_ready`;失败素材保留已成功图片引用并记录 `error`。每个素材 JSON 同步保存 `soundPrompt`,首个素材 JSON 同步保存 `backgroundMusicTitle``backgroundMusicStyle``backgroundMusicPrompt` 保存为空字符串作为兼容字段
12. 后端在图片素材生成后使用 `backgroundMusic.title` 提交 Suno 背景音乐任务,`prompt` 为空,`tags` 来自 `backgroundMusic.style`,并固定走纯音乐生成。轮询完成后通过通用创作音频资产链路转存 OSS、确认 `asset_object`、绑定到 `match3d_work/background_music`,再写回首个素材的 `backgroundMusic`。音乐生成失败只记录 warning不阻断草稿页进入用户可在结果页 `素材配置 > 背景音乐` 重试
13. 草稿生成阶段不生成点击音效,只保存 `generatedItemAssets[].soundPrompt`;点击音效由结果页 `素材配置 > 物品` 详情面板手动生成并写回对应素材
14. UI 背景生成由 `api-server` 调用 VectorEngine `gpt-image-2-all` 分成两张资产:第一张是 `9:16` 纯背景图不传锅参考图且必须禁止锅、圆盘、托盘、拼图槽、物品槽、HUD、文字、按钮、倒计时、分数和物品第二张是 `1:1` 题材容器 UI 图,固定传入 `public/match3d-background-references/pot-fused-reference.png` 作为参考图,只生成一个贴合题材设定的圆形或浅盘状竞技容器,不生成整页背景、文字、按钮或物品。容器图必须沿用参考图的大尺寸轻俯视构图:外轮廓接近画布四边,宽度约占 `86%-92%`、高度约占 `82%-90%`,内口为横向椭圆,禁止生成小容器、正俯视圆盘、侧视碗、餐盘或小托盘。纯背景上传到 `generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.png`,容器 UI 图上传到 `generated-match3d-assets/{sessionId}/{profileId}/ui-container/{taskId}/container.png`,两者都作为 `backgroundAsset` 挂在首个 `generatedItemAssets[]` JSON 上HTTP DTO 同时顶层输出兼容的 `backgroundPrompt``backgroundImageSrc``backgroundImageObjectKey``generatedBackgroundAsset`,容器图通过 `generatedBackgroundAsset.containerImageSrc/containerImageObjectKey` 返回。若作品尚无用户自定义封面,草稿生成完成后默认把容器 UI 图写入 `coverImageSrc`,作为草稿架和作品信息的默认封面。
15. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息、背景音乐资产信息、背景资产信息和默认封面;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets``coverImageSrc` 恢复同一批素材、音乐、UI 与封面。
若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串
若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅`、本地作品描述与本地标签兜底,不阻断素材生成;标签仍通过作品标签生成器优先生成,失败后再用兜底标签补齐
草稿生成阶段不再调用 Hyper3D Rodin不生成 GLB也不等待任何模型轮询。前端 `match3d_compile_draft` action 的长耗时主要来自文本生成、分批 1K 生图、切图、OSS 上传、背景图和可选音频生成。批量新增物品由 `POST /api/creation/match3d/works/{profileId}/item-assets` 复用同一套 2D 素材图生成、5x5 切图、OSS 上传和可选点击音效链路,只补齐本次新增物品并把 `imageViews[]` 写回 `generatedItemAssets`
草稿生成阶段不再调用 Hyper3D Rodin不生成 GLB也不等待任何模型轮询。前端 `match3d_compile_draft` action 的长耗时主要来自文本生成、分批 1K 生图、切图、OSS 上传、背景图、容器 UI 图和可选音频生成。批量新增物品由 `POST /api/creation/match3d/works/{profileId}/item-assets` 复用同一套 2D 素材图生成、固定 `5*5` 切图、OSS 上传和可选点击音效链路;若本次新增数量不是 `5` 的倍数,同样向上补齐名称和图片到最近的 `5` 的倍数。整图生成完成后立即丢弃补齐用临时物品,只对用户实际新增项执行绿幕抠背景、切割和上传,并只把这些真实新增项的 `imageViews[]` 写回 `generatedItemAssets`
## 4. 图片提示词
@@ -57,15 +59,21 @@
```text
生成一张1:1图片
生成不超过5*5网格素材图
生成严格5*5均匀网格素材图
整体画风遵循:...
只绘制这些物品:...
不要出现文字、水印、UI、边框
每格背景必须是统一纯绿色绿幕背景,方便后续转透明
物品本身不得使用与绿幕相同的纯绿色
每格主体完整居中,禁止跨格、贴边或越界影响裁剪后的效果
相邻物体主体之间至少保留 1/4 单格宽度空白间距,物体主体不得占满格子
不要出现文字、水印、UI、边框、网格线、白色背景、灰色背景、纹理背景
```
`包含若干个物品名称` 在落地中解释为“按生成出的物品名称绘制对应主体”,不要求图片上写出物品名称。这样可以避免文字渲染污染切图和局内 2D 素材表现。
入口页内置 2D 风格参考图通过同一 VectorEngine `gpt-image-2-all` 能力生成,执行命令为 `npm run assets:match3d-style-references -- --live`,保存路径固定为:
内置 `像素复古` 不能只写“复古像素”或“有限色板”。入口页、参考图脚本和后端素材图 prompt 必须使用同一组硬约束:真正复古像素 2D 游戏道具 sprite先按约 `64x64` 低分辨率像素块绘制再整数倍放大,硬边方块像素清晰可见,有限色板 `12-24` 色;同时在负向约束中禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质、摄影光照和平滑插画。后端即使只收到 `assetStyleId = pixel-retro``assetStyleLabel = 像素复古`,也必须补齐这组约束,避免旧会话、恢复会话或批量新增物品退回普通插画风格。
入口页内置 2D 风格参考图通过同一 VectorEngine `gpt-image-2-all` 能力生成,执行命令为 `npm run assets:match3d-style-references -- --live`。每张入口参考图只展示 `1` 个完整独立物品,不能展示 `5` 个物品样张或多物品散点图,避免风格选择被误读为物品数量配置。保存路径固定为:
```text
public/match3d-style-references/flat-icon.png
@@ -78,13 +86,13 @@ public/match3d-style-references/painterly-icon.png
这些图片只作为入口页风格选择的视觉参考,不进入用户草稿资产,不替代生成时的物品素材图。
局内背景生成固定参考图路径为:
局内容器 UI 图生成固定参考图路径为:
```text
public/match3d-background-references/pot-fused-reference.png
```
这张图作为 VectorEngine `image` 参考输入使用,用来锁定“绿色竖屏背景 + 居中锅状竞技区”的构图。每次草稿生成仍会根据 `backgroundPrompt` 生成新的题材化背景图;参考图本身不作为运行态最终背景
这张图作为容器 UI 图的 VectorEngine `image` 参考输入,用来锁定“大尺寸轻俯视浅盘容器”的构图。参考图本身是 `1:1` 透明底容器素材,外轮廓接近画布四边,内口为横向椭圆;结果页没有真实生成容器时也只把它作为容器预览兜底,不能再作为 `9:16` 背景预览。每次草稿生成仍会根据 `backgroundPrompt` 生成新的题材化背景图;纯背景图不再传入该参考图,也不得生成锅或 UI 元素
## 5. OSS 路径
@@ -108,7 +116,7 @@ generated-match3d-assets/{sessionId}/{profileId}/background/{taskId}/background.
`itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key导致草稿页预览图全部一致。
HTTP DTO 同时返回兼容字段 `imageSrc``imageObjectKey`,以及正式 2D 字段 `imageViews[]``backgroundAsset``status`。图片素材生成成功后 `status = image_ready`;背景生成成功后首个素材的 `backgroundAsset.status = image_ready`。前端通过 `/api/assets/read-url` 将 generated legacy path 换签后加载私有图片,不直接请求裸 `/generated-match3d-assets/...` 路径。运行态背景图同样通过 `/api/assets/read-url` 换签后作为全屏 `object-cover` 背景加载
HTTP DTO 同时返回兼容字段 `imageSrc``imageObjectKey`,以及正式 2D 字段 `imageViews[]``backgroundAsset``status`。图片素材生成成功后 `status = image_ready`背景和容器 UI 图都生成成功后首个素材的 `backgroundAsset.status = image_ready`,并携带 `containerImageSrc/containerImageObjectKey`。前端通过 `/api/assets/read-url` 将 generated legacy path 换签后加载私有图片,不直接请求裸 `/generated-match3d-assets/...` 路径。运行态背景图和容器 UI 图同样通过 `/api/assets/read-url` 换签后加载:背景作为全屏 `object-cover`,容器作为中心棋盘覆盖层
## 5.1 运行态 2D 素材消费
@@ -122,17 +130,17 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
运行态按运行快照中的 `itemTypeId` 稳定排序后,把 `generatedItemAssets` 顺序映射到对应类型。加载某个物品实例时,从该类型素材的 `imageViews[]` 中按实例 id 稳定随机选择一个视角;若历史数据没有 `imageViews[]`,则回退到 `imageSrc/imageObjectKey`。没有生成图片或图片加载失败时,继续使用默认积木图标兜底。
运行态背景优先读取 `backgroundImageSrc` / `generatedBackgroundAsset.imageSrc`,为空时从 `generatedItemAssets[].backgroundAsset.imageSrc/imageObjectKey` 兜底。`Match3DRuntimeShell` 只保留顶部返回、倒计时、重开三个控件;进度、组数、版本等状态信息不得再作为顶部常驻 UI 出现,避免遮挡生成背景和锅状竞技区
运行态背景优先读取 `backgroundImageSrc` / `generatedBackgroundAsset.imageSrc`,为空时从 `generatedItemAssets[].backgroundAsset.imageSrc/imageObjectKey` 兜底。中心容器优先读取 `generatedItemAssets[].backgroundAsset.containerImageSrc/containerImageObjectKey`;为空时继续使用默认圆形容器样式。运行态入口判断是否需要补读作品详情时,只能把 `imageViews[]``imageSrc/imageObjectKey` 视为“已有物品图片素材”;`backgroundMusic.audioSrc``clickSound.audioSrc``backgroundAsset.image*``backgroundAsset.containerImage*` 是随物品素材一起传入的附属运行态资产,不能单独证明物品素材已完整。也不能继续只用历史 `modelSrc/modelObjectKey` 判断,否则新 2D 草稿会在试玩或推荐流中被当成“无素材”并回退默认积木。`Match3DRuntimeShell` 只保留顶部返回、倒计时、重开三个控件;这些顶部控件和底部备选栏统一使用题材无关的半透明玻璃组件样式,不能随背景题材改成木质、金属、果园、科幻等主题皮肤,也不能重新烘进 AI 背景图。进度、组数、版本等状态信息不得再作为顶部常驻 UI 出现,避免遮挡生成背景和中心容器
前端加载规则:
1. 优先读取 `imageViews[]` 中的 `imageSrc/imageObjectKey`,为空时使用兼容字段 `imageSrc/imageObjectKey`
2. 对 generated legacy path 通过同源 `/api/assets/read-url` 换签后交给浏览器图片加载。
2. 对 generated legacy path 通过同源 `/api/assets/read-url` 换签后交给浏览器图片加载;结果页 `素材配置 > 背景音乐``素材配置 > 物品` 的音频试听控件也必须先换签,不能把裸 `/generated-match3d-assets/...` 音频路径直接交给 `<audio>`
3. 场内物品、点击命中和备选栏继续使用后端快照中的 `itemInstanceId/itemTypeId/x/y/radius/layer`;生成 2D 图片只替换视觉表现,不承接规则真相。
4. 同一物品类型的多个实例可以展示不同视角,但同一实例在本局中应稳定使用同一个视角,避免移动或入槽时闪图。
5. 图片缺失、读取失败或解码失败时,继续使用默认积木素材,不能阻断开局、点击、入槽或结算。
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `imageViews[]``imageSrc``imageObjectKey` 补齐 draft不能让旧 draft 把素材覆盖成空列表。`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成图片素材写入 `match3dProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 2D 素材覆盖成空列表。
结果页点击 `试玩` 时,前端必须把当前结果页可见的 `generatedItemAssets` 带入运行态启动入参。`PUT /api/runtime/match3d/works/{profileId}` 若因为并发或旧快照返回了缺少素材的 profile`Match3DResultView` 需要把当前 draft / profile 的素材重新合并到运行态 profile并在启动试玩前调用生成素材保存接口把当前可见的 `generatedItemAssets` 写回作品 profile不能只在内存里把素材补到 `onStartTestRun(profile)`。发布同理必须先落库当前素材,再调用 `publish_match3d_work`,否则公开推荐流和正式运行态只能读到旧 profile 快照。结果页顶部返回按钮固定回到平台创作页,不再回到抓大鹅专属内嵌入口表单;需要修改题材时由用户在创作页重新选择或从草稿继续进入。若历史草稿同时存在旧 `draft.generatedItemAssets` 和较新的 `profile.generatedItemAssets`,同 `itemId` 下以 profile 中已有的 `imageViews[]``imageSrc``imageObjectKey``backgroundMusic``backgroundAsset` 补齐 draft不能让旧 draft 把素材覆盖成空列表。`PlatformEntryFlowShellImpl` 在渲染 `match3d-runtime` 时按 `run.profileId` 优先使用当前 `match3dRuntimeProfile / match3dProfile.generatedItemAssets`,只有 profileId 不匹配时才读取 `selectedPublicWorkDetail.generatedItemAssets`;即使当前 profile 暂时没有物品图片,也不能把同 profile 的已有 `generatedItemAssets` 覆盖为空数组。推荐流内嵌正式运行态也必须走同一解析器;当推荐卡片摘要缺少物品图片素材时,启动前补读 `getMatch3DWorkDetail(profileId)`,把详情里的生成图片、背景音乐和 UI 素材写入 `match3dRuntimeProfile` 后再传给运行态。这样可以避免从公开详情页残留状态或推荐卡片旧摘要进入试玩 / 正式游戏时,把已生成草稿的 2D 素材、音乐或 UI 覆盖成空列表。
历史草稿若仍保存 `status = model_ready``modelSrc``modelObjectKey`,仅作为旧版本兼容读取,不再参与新素材生产。历史外部模型链接转存接口只用于清理旧数据,不能被新草稿生成、批量新增或结果页普通编辑入口调用。
@@ -160,11 +168,11 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
结果页 `作品信息` Tab 字段命名对齐拼图草稿:
1. `作品名称` 对应 Match3D `gameName`
2. `作品描述` 对应 Match3D `summary`,草稿生成默认空
3. `作品标签` 对应 Match3D `tags`可由 AI 首次生成并允许用户继续编辑
4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选入口,避免和作品基础信息割裂。点击封面图必须弹出独立编辑面板,不允许在当前作品信息面板下方展开。封面面板布局参考拼图创作页上传卡:移动端优先、左侧/上方为方形预览,右侧/下方为提示词与操作区。面板支持三类输入:本地上传图片、上传后开启 AI 重绘、直接引用 `物品素材``UI素材` 中已有图片作为封面或 AI 重绘参考图。AI 重绘通过 `api-server` 的 Match3D 作品封面生成接口调用 VectorEngine `gpt-image-2-all`,生成结果转存到 `generated-match3d-assets/{sessionId}/{profileId}/cover/{taskId}/cover.png` 后再写回 `coverImageSrc`;关闭 AI 重绘时只把选中的 Data URL 或 generated legacy path 写入封面字段。
2. `作品描述` 对应 Match3D `summary`,草稿生成阶段由同一次作品生成计划自动填入
3. `作品标签` 对应 Match3D `tags`草稿生成阶段在写入名称和描述后自动调用标签生成器填入;结果页仍允许用户继续编辑或再次 AI 生成
4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选入口,避免和作品基础信息割裂。草稿生成默认使用生成出的中心容器 UI 图作为 `coverImageSrc`点击封面图必须弹出独立编辑面板,不允许在当前作品信息面板下方展开。封面面板布局参考拼图创作页上传卡:移动端优先、左侧/上方为方形预览,右侧/下方为提示词与操作区。面板支持三类输入:本地上传图片、上传后开启 AI 重绘、直接引用 `物品素材``UI素材` 中已有图片作为封面或 AI 重绘参考图。AI 重绘通过 `api-server` 的 Match3D 作品封面生成接口调用 VectorEngine `gpt-image-2-all`,生成结果转存到 `generated-match3d-assets/{sessionId}/{profileId}/cover/{taskId}/cover.png` 后再写回 `coverImageSrc`;关闭 AI 重绘时只把选中的 Data URL 或 generated legacy path 写入封面字段。
结果页 `难度配置` Tab 取代旧 `玩法配置`,不再展示旧的分散输入项。该 Tab 必须与创作入口页使用同一组难度选项,并统一把原“类型素材图片 / 局内类型”等口径归一为 `物品种类`
结果页 `难度配置` Tab 取代旧 `玩法配置`,不再展示旧的分散输入项。该 Tab 顶部使用横向离散拖动条调整难度,四个刻度分别为 `轻松 / 标准 / 进阶 / 硬核`;拖动条只能落在这四个点上,刻度标签可点击切换。该 Tab 必须与创作入口页使用同一组难度选项,并统一把原“类型素材图片 / 局内类型”等口径归一为 `物品种类`
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
| ---- | ---------: | ---------: | -------: | -------: |
@@ -178,7 +186,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
结果页 `素材配置` Tab 取代旧一级素材入口,并包含三个子 Tab
1. `物品`:显示 2D 物品素材列表、五视角预览、素材名称、点击音效提示词和点击音效生成入口。
2. `UI`:预览生成的竖屏游戏背景图读取顺序为 draft 顶层背景、draft `generatedBackgroundAsset`、profile 顶层背景、profile `generatedBackgroundAsset``generatedItemAssets[].backgroundAsset`、本地参考图兜底。该页必须展示默认画面描述提示词,默认值来自草稿生成计划的 `backgroundPrompt` 或持久化 `backgroundAsset.prompt`;用户修改后点击重新生成,后端继续固定使用 `public/match3d-background-references/pot-fused-reference.png` 作为 VectorEngine `image` 参考图,并把新的 `backgroundAsset` 写回同一份 `generated_item_assets_json`。UI 子 Tab 还必须提供独立的运行态 UI 预览面板,直接用当前背景图模拟抓大鹅竖屏页面的顶部返回倒计时重开控件、锅状竞技区和底部托盘,不在 Tab 下方内联展开。
2. `UI`:预览生成的竖屏游戏背景图和中心容器 UI 图。背景读取顺序为 draft 顶层背景、draft `generatedBackgroundAsset`、profile 顶层背景、profile `generatedBackgroundAsset``generatedItemAssets[].backgroundAsset`、本地兜底图;容器读取 `generatedItemAssets[].backgroundAsset.containerImageSrc/containerImageObjectKey`,缺失时使用默认圆形容器。该页必须展示默认画面描述提示词,默认值来自草稿生成计划的 `backgroundPrompt` 或持久化 `backgroundAsset.prompt`;用户修改后点击重新生成,后端同时生成纯背景图和容器 UI 图,并把新的 `backgroundAsset` 写回同一份 `generated_item_assets_json`。UI 子 Tab 还必须提供独立的运行态 UI 预览面板,直接用当前背景图、容器 UI 图、顶部返回/倒计时/重开控件和底部默认托盘模拟竖屏页面,不在 Tab 下方内联展开。
3. `背景音乐`:承载原一级音乐 Tab 的背景音乐曲名、风格、生成进度和试听控件;背景音乐始终按纯音乐生成,前端不提供提示词输入。
旧一级 `音乐` Tab 删除;抓大鹅背景音乐入口只保留在 `素材配置 > 背景音乐`
@@ -192,7 +200,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
详情页不再展示参考图、用途、模型提示词、文生/图生切换、状态查询、下载列表、taskUuid 或 subscriptionKey。
`物品素材` 列表项点击必须弹出独立预览面板,不允许在列表右侧或列表下方内联展示。预览面板只承担查看五视角图片、编辑素材名称、编辑点击音效提示词和生成点击音效;不再展示 `重新生成` 按钮。列表项自身支持单项删除,删除后立即把剩余 `generatedItemAssets` 写回作品 profile。批量新增通过列表顶部按钮打开独立面板面板内每个输入框只输入一个物品名称`新增物品名称` 按钮追加一个输入框;提交后按输入框顺序清洗、去重并调用 Match3D 作品批量生图接口。生成进度同时显示在批量新增面板和 `素材配置 > 物品` 列表顶部面板可关闭后台生成继续推进不阻塞封面、音频等其他生成操作。后端复用草稿生成的素材图、切图、OSS 上传和可选点击音效流程,但仅作用于本次新增名称,不重新生成已有物品,不新增 SpacetimeDB 表,最终仍写回同一份 `generated_item_assets_json`
`物品素材` 列表项点击必须弹出独立预览面板,不允许在列表右侧或列表下方内联展示。列表本身使用移动端至少两列的多列卡片布局;每个列表项只展示图片预览、物品名称和垃圾箱删除图标,不展示用途、状态胶囊、视角数量或 `2D素材` 标记。预览面板只承担查看五视角图片、编辑素材名称、编辑点击音效提示词和生成点击音效;不再展示 `重新生成` 按钮。列表项自身支持单项删除,删除后立即把剩余 `generatedItemAssets` 写回作品 profile。批量新增通过列表顶部按钮打开独立面板面板内每个输入框只输入一个物品名称`新增物品名称` 按钮追加一个输入框;提交后按输入框顺序清洗、去重并调用 Match3D 作品批量生图接口。生成进度同时显示在批量新增面板和 `素材配置 > 物品` 列表顶部面板可关闭后台生成继续推进不阻塞封面、音频等其他生成操作。后端复用草稿生成的素材图、切图、OSS 上传和可选点击音效流程,但仅按实际可新增名称持久化,不重新生成已有物品,不新增 SpacetimeDB 表,最终仍写回同一份 `generated_item_assets_json`批量新增先补齐到 `5` 个参与整图生成,随后丢弃补齐用临时物品,只对真实新增物品抠背景、切割和上传。批量新增计费按实际可新增名称每 `5` 个消耗 `2` 光点,不足 `5` 个向上取整;重复名称、已有名称和超过容量上限的名称不计费。
## 6.1 音频生成与扣费
@@ -202,9 +210,9 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
2. 物品点击音效默认读取对应 `generatedItemAssets[].soundPrompt`,用户可在 `素材配置 > 物品` 详情面板内编辑。
3. 背景音乐与物品音效生成过程必须显示进度条;提交任务、等待生成、转存资产和完成分别推进到不同进度,不再只展示旋转图标。
4. 音频生成完成后立即展示浏览器原生 audio 控件,支持试听。
5. `POST /api/creation/audio/background-music/{task_id}/asset``POST /api/creation/audio/sound-effect/{task_id}/asset` 在真正拿到音频并转存资产前,由后端按 `taskId + 资产槽位` 幂等预扣 `10` 光点任务仍在处理中时不扣费。资产下载、OSS 转存或资产绑定失败时后端自动退款。前端只展示生成按钮和进度,不自行计算或写入钱包。
5. `POST /api/creation/audio/background-music/{task_id}/asset``POST /api/creation/audio/sound-effect/{task_id}/asset` 在真正拿到音频并转存资产前,由后端按 `taskId + 资产槽位` 幂等预扣;背景音乐扣 `5` 光点,物品点击音效扣 `10` 光点任务仍在处理中时不扣费。资产下载、OSS 转存或资产绑定失败时后端自动退款。前端只展示生成按钮和进度,不自行计算或写入钱包。
入口页 `生成音效` Toggle 复用同一扣费与资产绑定规则。默认关闭,关闭时草稿生成阶段不产生音频任务也不扣除音频光点;开启时每个首批物品点击音效按单独任务和单独 `match3d_click_sound` 资产槽位扣费。音效生成失败不阻断草稿结果页进入,失败素材保留 `soundPrompt`,用户可在结果页物品详情面板手动重试。
创作入口不展示 `生成音效` Toggle草稿生成阶段不产生物品点击音效任务也不产生点击音效相关扣费;入口只产生一次固定 `10` 光点的草稿生成扣费。结果页 `素材配置 > UI` 重新生成背景固定扣 `2` 光点。物品点击音效由结果页 `素材配置 > 物品` 详情面板手动触发,每个音效按单独任务和单独 `match3d_click_sound` 资产槽位扣费。音效生成失败不影响草稿,失败素材保留 `soundPrompt`,用户可在结果页物品详情面板手动重试。
## 7. 验收
@@ -226,4 +234,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml
```
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;开启音频生成还需要对应音频上游配置。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`
真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,以及完整 `ALIYUN_OSS_BUCKET``ALIYUN_OSS_ENDPOINT``ALIYUN_OSS_ACCESS_KEY_ID``ALIYUN_OSS_ACCESS_KEY_SECRET`。如果只配置 bucket 和 endpoint抓大鹅素材、封面或背景生成会在调用 VectorEngine 前返回 `OSS 未完成环境变量配置``details.missingEnv` 会列出缺少的 AccessKey 项;不要回退到 Rodin/GLB 或伪造本地上传成功。开启音频生成还需要对应音频上游配置。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`

View File

@@ -2,9 +2,13 @@
> 2026-05-08 更新:抓大鹅创作端入口已重新开放,当前 `match3d.visible` 为 `true`。本文件记录 F1 接入能力,入口是否展示以 `NEW_WORK_ENTRY_CONFIG_2026-05-01.md` 和 `src/config/newWorkEntryConfig.ts` 为准。
>
> 2026-05-10 更新:抓大鹅入口页对齐拼图入口页,直接嵌入创作页模板 Tab。入口表单不再展示参考图、消除次数输入、难度数值滑杆和题材/物品/难度摘要框,仅保留题材主题大输入框和难度选项。难度选项负责派生 `clearCount` 与 `difficulty`,生成按钮必须展示 `消耗20光点`
> 2026-05-10 更新:抓大鹅入口页对齐拼图入口页,直接嵌入创作页模板 Tab。入口表单不再展示参考图、消除次数输入、难度数值滑杆和题材/物品/难度摘要框,仅保留题材主题大输入框和难度选项。难度选项负责派生 `clearCount` 与 `difficulty`。
>
> 2026-05-12 补充:入口页风格选择收敛为 `2D素材风格`,首批常见 2D 素材风格参考图通过 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成并保存到 `public/match3d-style-references/`。最后一个选项为 `自定义`,点击后弹出独立面板填写画风描述。
> 2026-05-12 补充:入口页风格选择收敛为 `2D素材风格`,首批常见 2D 素材风格参考图通过 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成并保存到 `public/match3d-style-references/`。2026-05-13 起,入口参考图固定只展示 `1` 个完整独立物品,不再展示 `5` 个物品样张或多物品散点图。最后一个选项为 `自定义`,点击后弹出独立面板填写画风描述。
>
> 2026-05-13 补充:草稿素材实际生成数量按 `5` 的倍数向上补齐,补齐物品同样需要生成名称和五视角图片。素材图提示词固定要求 `5*5` 严格均匀排布,禁止主体跨格、贴边或越界影响裁剪效果。
>
> 2026-05-13 补充:创作页隐藏抓大鹅 `生成音效` Toggle草稿生成固定预扣 `10` 光点,按钮展示 `消耗10光点`。点击音效生成只保留在结果页 `素材配置 > 物品` 详情面板中手动触发。
## 1. 阶段边界
@@ -40,12 +44,11 @@ badge: 可创建
创作页 `选择模板` Tab 中切换到 `抓大鹅` 时,直接渲染该表单,不创建会话,也不跳到独立工作台。点击生成后才创建 Match3D 会话并执行 `match3d_compile_draft`
表单只展示个输入块:
表单只展示个输入块:
1. `想做一个什么题材的抓大鹅?`:大文本输入框,收集 `themeText`
2. `2D素材风格`:横向滑动风格卡,选择会写入 `assetStyleId``assetStyleLabel``assetStylePrompt`
3. `难度`:四个选项按钮,选项内部派生消除次数和难度数值。
4. `生成音效`Toggle默认关闭开启后提交 `generateClickSound=true`
当前难度映射固定为:
@@ -66,7 +69,7 @@ badge: 可创建
自定义风格必须在弹出面板中填写描述后才能应用。入口表单必须在移动端创作页可视区内完成题材、风格、难度和生成按钮的展示,页面自身不产生纵向滚动;风格卡只允许横向滑动。
生成按钮文案为 `生成抓大鹅草稿`,按钮内必须同时展示 `消耗20光点`。UI 中不默认展示玩法规则长文,也不展示隐藏派生数值的摘要框。
生成按钮文案为 `生成抓大鹅草稿`,按钮内必须同时展示 `消耗10光点`。UI 中不默认展示玩法规则长文,也不展示隐藏派生数值的摘要框。
## 5. mock client
@@ -112,7 +115,7 @@ POST /api/creation/match3d/sessions/:sessionId/compile
2. 切换到 `抓大鹅` Tab 后,页面内直接显示抓大鹅入口表单,不提前创建会话。
3. 表单不展示参考图、`需要消除次数``难度数值``题材``物品``难度`摘要框。
4. 输入题材、选择风格和难度后,提交 payload 包含派生后的 `clearCount``difficulty``referenceImageSrc``null`,并包含 `assetStyleId``assetStyleLabel``assetStylePrompt`
5. 生成按钮展示 `消耗20光点`
5. 生成按钮展示 `消耗10光点`,创作页不展示 `生成音效` Toggle
6. 点击 `自定义` 风格弹出独立面板,填写后应用到提交 payload未填写时不能应用空自定义风格。
7. 移动端创作页内抓大鹅入口内容不产生纵向滚动,风格卡横向滑动。
8. 点击生成后创建会话并进入草稿生成/结果页链路。

View File

@@ -18,7 +18,7 @@
1. 入口表单只展示 `画面描述`、参考图和图片模型选择;`画面描述` 是唯一必填字段。
2. 表单自动保存只保存 `pictureDescription`,不再保存入口作品名称、作品描述或推断标签。
3. 点击“生成草稿”后进入生成进度页,步骤固定“编译首关草稿 -> 生成首关画面 -> 写入正式草稿”
3. 点击“生成草稿”后进入生成进度页,步骤固定对齐后端当前编排:“编译首关草稿 -> 生成关卡名称 / 生成首关画面 -> 生成背景音乐 / 生成UI背景 -> 写入正式草稿”。其中关卡名称文本生成与首关画面生成可并行;首关最终名称确定后,背景音乐与 UI 背景必须并行启动
4. 生成进度页“当前拼图信息”只展示画面描述;不得展示空作品名称、空作品描述或旧五锚点结构。
5. 结果页打开后,作品名称默认使用首关名称,作品描述与作品标签保持为空,等待用户在作品信息 Tab 补全或触发 AI 标签生成。
@@ -90,24 +90,30 @@
12. `compile_puzzle_draft` 中的图片上游失败不得映射成 `400 BAD_REQUEST`。DashScope 返回 `InvalidParameter` 或任务失败时api-server 统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留“拼图图片生成失败:...”的业务原因,避免生成页只显示“请求参数不合法”。
13. `compile_puzzle_draft` 前置光点预扣失败不得映射成 `400 BAD_REQUEST`。余额不足返回 `409 CONFLICT`SpacetimeDB procedure 不可用、绑定不匹配、钱包服务异常等统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留真实钱包错误。
14. 生成拼图作品草稿动作涉及的表单 seed prompt 与首图 prompt 来源选择统一收口在 `server-rs/crates/api-server/src/prompt/puzzle/draft.rs``puzzle.rs` 只负责调用 SpacetimeDB、计费、图片服务和持久化不再直接拼草稿 prompt 文本。
15. `compile_puzzle_draft_with_initial_cover` 中,首关文本名称生成与首关图片生成互不等待:首图 prompt 只读取画面描述OSS 临时路径使用现有关卡名或确定性兜底名;首图返回后再用图片语义尝试精修最终关卡名。最终关卡名确定后,背景音乐与首关 UI 背景图通过 `tokio::join!` 并行生成,任一失败只记录 warning 并保留草稿。
16. `compile_puzzle_draft_with_uploaded_cover` 中,上传图解析后,文本名称生成、图片语义名称生成和上传图转存 OSS 可并行;上传图转存失败必须立即返回,不得继续触发背景音乐或 UI 背景生成。上传图转存成功且最终关卡名确定后,背景音乐与首关 UI 背景图同样并行生成。
## 结果页
拼图草稿结果页分为四个 Tab
拼图草稿结果页分为三个一级 Tab
1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。
2. 作品信息:展示并编辑作品名称、作品描述、作品标签。
3. UI展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图和背景音乐后,`api-server` 会基于作品名称、作品描述、标签和首关信息自动生成首关 9:16 UI 背景图;结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成在 `api-server` 中读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 背景参考图,并调用 VectorEngine `gpt-image-2-all``9:16` 图片生成链路。生成结果写入首关 `levels_json``uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段
4. 音乐:编辑并生成背景音乐,音乐资产暂存到首关 `levels_json[0].backgroundMusic`
3. 素材配置:对齐抓大鹅草稿页结构,内部包含 `UI``背景音乐` 子 Tab
`素材配置 > UI` 展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图和背景音乐后,`api-server` 会基于作品名称、作品描述、标签和首关信息自动生成首关 9:16 纯背景图;结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成调用 VectorEngine `gpt-image-2-all``9:16` 图片生成链路。生成结果写入首关 `levels_json``uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。
`素材配置 > 背景音乐` 编辑并生成背景音乐,音乐资产暂存到首关 `levels_json[0].backgroundMusic`。拼图结果页不再保留一级 `UI` 或一级 `音乐` Tab。
### 2026-05-12 UI 背景生成补充
1. UI 背景图只生成拼图棋盘以外的运行态背景与 UI 容器层次,提示词必须要求中央正方形拼图区和外部 UI 背景之间有明确描边、容器或留白边界
2. UI 背景图不得生成文字、水印、按钮文字、数字、拼图碎片、完整拼图图像教程浮层,避免与真实拼图图块和运行态 HUD 混淆。
1. UI 背景图只生成拼图运行态的题材氛围纯背景不得把拼图槽、棋盘外框、按钮、HUD 或其它 UI 元素烘进图片。拼图槽位、棋盘边框和空格继续使用运行态默认样式绘制
2. UI 背景图不得生成文字、水印、按钮文字、数字、拼图碎片、完整拼图图像教程浮层、拼图槽或物品槽,避免与真实拼图图块和运行态 HUD 混淆。
3. 结果页 UI Tab 支持直接修改提示词并重新生成;点击生成前会把本地首关 `uiBackgroundPrompt` 同步进 `levelsJson`,使自动保存尚未完成时后端仍能拿到最新提示词。
4. 草稿编译阶段自动生成 UI 背景失败时只记录 warning并保留草稿进入结果页用户可在 UI Tab 重新生成,不因背景图上游波动阻断首图草稿主流程。
5. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSSSpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
5. `api-server` 负责拼接生成 prompt、调用 VectorEngine、下载并转存 OSSSpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
6. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。
7. 生成完成后的自动试玩和结果页“试玩”走前端本地运行态兜底时,`startLocalPuzzleRun` 也必须从 `PuzzleWorkSummary.levels[]` 复制 `uiBackgroundImageSrc``backgroundMusic``currentLevel`;不得只带 `coverImageSrc`,否则草稿结果页有背景但试玩局内空白。
### 2026-05-12 草稿生成完成自动试玩补充
@@ -152,7 +158,7 @@
1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择、背景音乐生成和首关 UI 背景图生成。
3. 首图生成请求使用玩家画面描述作为 prompt上传参考图时走图生图作品详情页展示玩家作品描述。
4. 结果页包含“拼图关卡”“作品信息”“UI”“音乐”四个 Tab关卡列表默认至少一关,支持新增、删除和进入关卡详情。
4. 结果页包含“拼图关卡”“作品信息”“素材配置”三个一级 Tab`素材配置` 内包含 `UI``背景音乐` Tab关卡列表默认至少一关,支持新增、删除和进入关卡详情。
5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。
6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。
7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`拼图棋盘区域和 UI 背景区域有明确边界
7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,拼图槽位和棋盘边界仍由默认运行态样式绘制

View File

@@ -4,9 +4,9 @@
本方案把 VectorEngine 音频生成能力从视觉小说结果页扩展到拼图与抓大鹅结果页:
1. 拼图结果页新增 `音乐` Tab支持通过 Suno 生成作品背景音乐。
1. 拼图结果页 `素材配置 > 背景音乐` 支持通过 Suno 生成作品背景音乐;旧一级 `音乐` Tab 已删除
2. 抓大鹅结果页在 `素材配置 > 背景音乐` 中支持通过 Suno 生成作品背景音乐;旧一级 `音乐` Tab 已删除。
3. 抓大鹅 `素材配置 > 物品` 支持为每个生成物体通过 Vidu 生成点击音效。
3. 抓大鹅 `素材配置 > 物品` 支持为每个生成物体通过 Vidu 手动生成点击音效;创作入口不展示点击音效生成开关
4. 拼图运行态与抓大鹅运行态内置默认关卡音频配置:通用点击音效 `/audio/ui-click-soft.wav`、过关音效 `/audio/ui-level-clear.wav`、倒计时临界音效 `/audio/ui-countdown-warning.wav`
5. 拼图和抓大鹅草稿生成阶段会自动生成背景音乐并转存 OSS结果页继续支持试听和重新生成。
@@ -30,7 +30,7 @@
3. 下载音频字节。
4. 写入 OSS 私有对象。
5. 确认 `asset_object` 并绑定 `asset_entity_binding`
6. 音频真正可下载并准备转存时,按 `taskId + assetKind + entityId + slot` 幂等扣除 `10` 光点任务仍在处理中不扣费,转存或资产绑定失败自动退款。
6. 音频真正可下载并准备转存时,按 `taskId + assetKind + entityId + slot` 幂等扣费;背景音乐固定扣除 `5` 光点,物品点击音效固定扣`10` 光点任务仍在处理中不扣费,转存或资产绑定失败自动退款。
通用背景音乐提交允许 `prompt = ""`。拼图和抓大鹅草稿生成都按纯音乐处理:后端提交 Suno 时固定带 `make_instrumental = true`,只用 `title``tags` 约束作品气质,不把歌词或规则描述写入 prompt。视觉小说原路由保持兼容内部继续复用同一套提交、轮询、转存逻辑。
@@ -68,12 +68,12 @@
结果页 UI 保持轻量:
1. `音乐` Tab 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。
1. 拼图 `素材配置 > 背景音乐` 与抓大鹅 `素材配置 > 背景音乐` 只展示必要输入、生成按钮、状态与音频预览,不展示供应商规则说明。
2. 生成完成后立即写回本地草稿状态,并触发既有保存链路或专用保存接口。
3. 抓大鹅每个物体音效生成入口放在对应素材详情面板内,不在列表下方展开大段配置。
4. 抓大鹅物体音效提示词允许在素材详情面板内编辑;背景音乐只允许在 `素材配置 > 背景音乐` 编辑曲名和风格,生成请求固定使用空 `prompt`
5. 背景音乐和物体音效生成期间都显示进度条,生成完成后展示 audio 控件试听。
6. 背景音乐重新生成只要求曲名非空;重新生成继续按纯音乐提交,`prompt = ""`
6. 背景音乐重新生成只要求曲名非空;重新生成继续按纯音乐提交,`prompt = ""`,按钮展示 `5` 光点成本
### 4.1 运行态默认点击音效
@@ -85,6 +85,15 @@
6. 默认关卡音效跟随现有 `musicVolume` 设置,不新增独立音量 UI不在运行态界面增加说明文案。
7. 拼图和抓大鹅运行态背景音乐同样跟随 `musicVolume`,读取 generated legacy path 时先换签,再交给隐藏 `<audio loop preload="auto">` 自动播放;浏览器拒绝自动播放时静默失败,不阻断游戏交互。
### 4.2 草稿音乐试听与自动播放补充
2026-05-13 修正:
1. 拼图 `素材配置 > 背景音乐` 的试听控件也必须通过 `useResolvedAssetReadUrl` 对 generated legacy path 换签后再设置 `<audio src>`;签名未就绪或失败时只显示“音频已绑定”,不得把裸 `/generated-puzzle-assets/...` 路径交给浏览器请求。
2. 抓大鹅结果页已使用同一换签口径,后续新增音频试听入口必须复用该模式。
3. 拼图和抓大鹅运行态在开局时会尝试自动播放背景音乐;若浏览器因自动播放策略拒绝,玩家首次按下拼图块或点击抓大鹅物品时必须再次调用同一个背景音乐播放函数,避免草稿音乐已经传入运行态但局内始终无声。
4. 播放失败仍只做静默兜底,不弹出规则说明或阻断局内交互。
## 5. 验收
建议执行:

View File

@@ -14,22 +14,25 @@
## 生成进度步骤
1. `compile` 展示为“编译首关草稿”:根据画面描述生成首关名称和结果页草稿,不在本步骤生成作品标签。
2. `puzzle-images` 展示为“生成首关画面”:按画面描述、参考图和当前图片模型生成第一张拼图图
3. `puzzle-select-image` 展示为“写入正式草稿”:把首图设为第一关正式图,并同步到结果页草稿
4. `ready` 文案提示进入结果页补作品信息;不得暗示作品名称、作品描述或作品标签已经完整生成
1. `compile` 展示为“编译首关草稿”:建立结果页草稿,不在本步骤生成作品标签。
2. `puzzle-level-name` 展示为“生成关卡名称”:按画面描述生成文本名,首图返回后可再基于图像语义精修最终关卡名
3. `puzzle-images` 展示为“生成首关画面”:按画面描述、参考图和当前图片模型生成第一张拼图图;后端允许该步骤与 `puzzle-level-name` 文本名生成并行
4. `puzzle-background-music` 展示为“生成背景音乐”:最终关卡名确定后生成纯音乐并转存音频资产
5. `puzzle-ui-background` 展示为“生成UI背景”最终关卡名确定后生成 9:16 纯背景图;后端必须与背景音乐并行启动。
6. `puzzle-select-image` 展示为“写入正式草稿”:把首图、最终关卡名、可用音乐和可用 UI 背景同步到结果页草稿。
7. `ready` 文案提示进入结果页补作品信息;不得暗示作品名称、作品描述或作品标签已经完整生成。
### 2026-05-08 进度页预计等待与步骤动效补充
1. 拼图草稿生成进度页预计等待时间固定按 `60` 秒展示和倒计时,后端真实完成后立即进入结果页,不强制等满 60 秒。
2. 60 秒进度拆成三段:`compile` 约 12 秒,`puzzle-images` 约 42 秒,`puzzle-select-image` 约 6 秒
2. 前端进度按本地时间展示为多段估算;后端真实编排中,首关名称文本生成与首关画面生成可并行,背景音乐与 UI 背景在最终关卡名确定后并行生成。进度条只是等待接口返回时的体验估算,不作为后端任务调度依据
3. 生成中即使后端 `compile_puzzle_draft` 仍是一次同步 action前端也必须按本地计时推进总进度和当前步骤进度避免页面停在静态等待态。
4. 每个步骤卡片都展示独立进度条;已完成步骤显示 100%,当前步骤按该段预计时长推进,后续步骤保留 0% 待处理状态。
5. 后端未返回前总进度最多推进到 98%,防止 UI 提前宣称生成完成;只有 action 成功并写回 `ready` 后才显示 100%。
## 草稿默认值
1. 后端先由 `module-puzzle` 生成可回滚的确定性草稿,再由 `api-server` 生成第一关关卡名。图片生成前可先基于画面描述生成临时关卡名;正式图片生成完成后,必须使用 APIMart Chat Completions 的 `gpt-4o-mini`,把正式图片 data URL 与画面描述一起传入模型,生成最终关卡名。
1. 后端先由 `module-puzzle` 生成可回滚的确定性草稿,再由 `api-server` 生成第一关关卡名。图片生成不等待文本关卡名,首图 OSS 临时路径使用现有关卡名或基于画面描述的确定性兜底名;正式图片生成完成后,必须使用 APIMart Chat Completions 的 `gpt-4o-mini`,把正式图片 data URL 与画面描述一起传入模型,生成最终关卡名。
2. 最终关卡名生成后,必须写回首关 `levelName`,并在入口直创默认场景下作为 `workTitle` 同步写入草稿和作品草稿卡;模型不可用、图片压缩失败或返回非法时,才保留前一步文本名或确定性兜底名。
3. `workDescription` 默认保持空字符串,不再回退为画面描述。
4. `themeTags` 默认保持空数组,不再由入口画面描述自动推断为正式作品标签。

View File

@@ -124,6 +124,8 @@ VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
1. `拼图参考图解析完成` / `拼图参考图解析跳过`:确认前端是否传入参考图,以及 Data URL 解析或旧 `/generated-*` OSS 读取耗时;日志只记录 `reference_mime``reference_bytes`,不记录图片内容。
2. `拼图 VectorEngine 图片生成 HTTP 返回`VectorEngine `POST /v1/images/generations` 的同步上游耗时,若这一段长,慢点在 VectorEngine 生图接口。
3. `拼图 VectorEngine 图片下载完成`:从 VectorEngine 返回的 `data[].url` 下载正式图耗时
4. `拼图生成图片已写入 OSS 与资产索引`:正式图上传 OSS、确认资产对象与实体绑定耗时
5. `拼图图片候选生成完成`:整段候选图生成总耗时。
3. `拼图 VectorEngine 图片编辑 HTTP 返回`:有参考图且 AI 重绘时命中 `POST /v1/images/edits`。如果前端报 `创建拼图 VectorEngine 图片编辑任务失败error sending request for url (...)` 且没有这条日志,说明请求在拿到 HTTP 响应前失败,先看响应 `details.reason/source/connect/body/timeout`,再查服务器 DNS、防火墙、代理或上游 multipart 连接中断
4. `拼图 VectorEngine 请求发送失败``reqwest``send()` 阶段失败时输出,包含 `connect``body``timeout` 和底层 `source`。拼图图片客户端强制 HTTP/1.1,以避开部分网关对 HTTP/2 multipart 的兼容问题
5. `拼图 VectorEngine 图片下载完成`:从 VectorEngine 返回的 `data[].url` 下载正式图耗时。
6. `拼图生成图片已写入 OSS 与资产索引`:正式图上传 OSS、确认资产对象与实体绑定耗时。
7. `拼图图片候选生成完成`:整段候选图生成总耗时。

View File

@@ -14,6 +14,7 @@
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
"api-server": "node scripts/api-server-dev.mjs",
"check:api-server-env": "node scripts/check-api-server-env.mjs",
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",

View File

@@ -18,6 +18,9 @@ export interface Match3DGeneratedBackgroundAsset {
prompt: string;
imageSrc?: string | null;
imageObjectKey?: string | null;
containerPrompt?: string | null;
containerImageSrc?: string | null;
containerImageObjectKey?: string | null;
status: string;
error?: string | null;
}
@@ -115,6 +118,7 @@ export interface PutMatch3DWorkRequest {
export interface GenerateMatch3DWorkTagsRequest {
gameName: string;
themeText: string;
summary?: string | null;
}
export interface GenerateMatch3DWorkTagsResponse {

View File

@@ -150,11 +150,21 @@ function readTrimmedMessage(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readApiErrorDetailMessage(details: unknown) {
if (!isRecord(details)) {
return '';
}
// 后端通用 message 常用于错误分类details.message / details.reason
// 才是给用户定位问题的业务原因,配置缺失类错误通常只带 reason。
return (
readTrimmedMessage(details.message) ||
readTrimmedMessage(details.reason)
);
}
export function getApiErrorDisplayMessage(error: ApiErrorPayload) {
// 后端通用 message 常用于错误分类details.message 才是给用户定位问题的业务原因。
const detailMessage = isRecord(error.details)
? readTrimmedMessage(error.details.message)
: '';
const detailMessage = readApiErrorDetailMessage(error.details);
return detailMessage || readTrimmedMessage(error.message);
}
@@ -177,9 +187,7 @@ export function parseApiErrorMessage(rawText: string, fallbackMessage: string) {
code?: string;
};
const detailMessage = isRecord(parsed.error?.details)
? readTrimmedMessage(parsed.error.details.message)
: '';
const detailMessage = readApiErrorDetailMessage(parsed.error?.details);
if (detailMessage) {
return detailMessage;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1004 KiB

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -8,9 +8,18 @@ const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const shellEnvKeys = new Set(Object.keys(process.env));
const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
function buildProtectedEnvKeys(baseEnv) {
return new Set(
Object.entries(baseEnv)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
}
const shellEnvKeys = buildProtectedEnvKeys(process.env);
function loadEnvFile(path, target, protectedKeys = shellEnvKeys) {
if (!existsSync(path)) {
return;
@@ -54,7 +63,7 @@ export function loadApiServerEnv(
export function mergeApiServerEnv(repoRootPath, baseEnv = process.env) {
const mergedEnv = { ...baseEnv };
loadApiServerEnv(repoRootPath, mergedEnv, new Set(Object.keys(baseEnv)));
loadApiServerEnv(repoRootPath, mergedEnv, buildProtectedEnvKeys(baseEnv));
return mergedEnv;
}

View File

@@ -63,4 +63,32 @@ describe('api-server-dev env merge', () => {
},
);
});
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
withTempEnvFiles(
{
'.env.local': [
'ALIYUN_OSS_BUCKET=dev-bucket',
'ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com',
].join('\n'),
'.env.secrets.local': [
'ALIYUN_OSS_ACCESS_KEY_ID=local-access-key',
'ALIYUN_OSS_ACCESS_KEY_SECRET=local-access-secret',
].join('\n'),
},
(_env, tempDir) => {
const env = mergeApiServerEnv(tempDir, {
ALIYUN_OSS_BUCKET: '',
ALIYUN_OSS_ENDPOINT: ' ',
ALIYUN_OSS_ACCESS_KEY_ID: 'shell-access-key',
ALIYUN_OSS_ACCESS_KEY_SECRET: '',
});
expect(env.ALIYUN_OSS_BUCKET).toBe('dev-bucket');
expect(env.ALIYUN_OSS_ENDPOINT).toBe('oss-cn-shanghai.aliyuncs.com');
expect(env.ALIYUN_OSS_ACCESS_KEY_ID).toBe('shell-access-key');
expect(env.ALIYUN_OSS_ACCESS_KEY_SECRET).toBe('local-access-secret');
},
);
});
});

View File

@@ -0,0 +1,51 @@
import { mergeApiServerEnv } from './api-server-dev.mjs';
const REQUIRED_FOR_PUZZLE_GENERATION = [
'VECTOR_ENGINE_BASE_URL',
'VECTOR_ENGINE_API_KEY',
'ALIYUN_OSS_BUCKET',
'ALIYUN_OSS_ENDPOINT',
'ALIYUN_OSS_ACCESS_KEY_ID',
'ALIYUN_OSS_ACCESS_KEY_SECRET',
];
const COMMON_MISTYPED_KEYS = [
'ALIYUN_0SS_BUCKET',
'ALIYUN_0SS_ENDPOINT',
'ALIYUN_0SS_ACCESS_KEY_ID',
'ALIYUN_0SS_ACCESS_KEY_SECRET',
];
function hasValue(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function printStatus(key, present) {
console.log(`${key}: ${present ? 'present' : 'missing'}`);
}
const env = mergeApiServerEnv(process.cwd(), process.env);
const missing = [];
console.log('[api-server-env] 拼图真实生成配置检查');
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
const present = hasValue(env[key]);
printStatus(key, present);
if (!present) {
missing.push(key);
}
}
const typoKeys = COMMON_MISTYPED_KEYS.filter((key) => hasValue(env[key]));
if (typoKeys.length > 0) {
console.log(
`[api-server-env] 检测到疑似拼错的 OSS 键名:${typoKeys.join(', ')}`,
);
}
if (missing.length > 0) {
console.error(`[api-server-env] 缺少:${missing.join(', ')}`);
process.exit(1);
}
console.log('[api-server-env] 配置齐全。重启 npm run api-server 或 npm run dev 后生效。');

View File

@@ -63,7 +63,11 @@ load_api_server_env_files() {
done < <(
node - "${env_files[@]}" <<'NODE'
const fs = require('fs');
const shellEnvKeys = new Set(Object.keys(process.env));
const shellEnvKeys = new Set(
Object.entries(process.env)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
const values = new Map();
for (const filePath of process.argv.slice(2)) {

View File

@@ -1,6 +1,7 @@
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {
findAvailablePort,
formatPortDecision,
@@ -8,7 +9,11 @@ import {
} from './dev-stack-port-utils.mjs';
const repoRoot = process.cwd();
const shellEnvKeys = new Set(Object.keys(process.env));
const shellEnvKeys = new Set(
Object.entries(process.env)
.filter(([, value]) => String(value ?? '').trim())
.map(([key]) => key),
);
function loadEnvFile(path, target) {
if (!existsSync(path)) {

View File

@@ -25,7 +25,7 @@ const styleTemplates = [
id: 'pixel-retro',
title: '像素复古',
prompt:
'复古像素游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,像 32-bit 休闲游戏图标。',
'真正复古像素游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,主体轮廓稳定,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
},
{
id: 'watercolor',
@@ -117,14 +117,15 @@ function buildVectorEngineImagesGenerationUrl(baseUrl) {
: `${baseUrl}/v1/images/generations`;
}
// 中文注释:入口缩略图只用于比较画风,必须展示单个代表道具,避免误导为一组待切割物品。
function buildPrompt(template) {
return [
'请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。',
'画面是一组 5 个小型游戏道具样张,题材统一为水果、甜点、玩具和宝石的混合展示。',
'画面只允许出现 1 个完整独立的游戏道具主体,题材固定为一颗红苹果,不要出现第二个物品。',
`整体风格:${template.prompt}`,
'要求:个道具是独立 2D 素材示例,主体集中,轮廓清晰,适合被切成抓大鹅局内物品素材。',
'构图:浅色干净背景,散点排列,留有呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
'避免文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
'要求:个道具是独立 2D 素材示例,主体集中,轮廓清晰,适合作为抓大鹅局内物品素材。',
'构图:浅色干净背景,单物体居中放大,四周留少量呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
'避免:多个物品、5 个物品、物品组合、重复视角、散点排列、文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
].join('');
}

View File

@@ -92,11 +92,10 @@ use crate::{
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
generate_match3d_background_image_for_work, generate_match3d_cover_image,
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail,
get_match3d_works, list_match3d_gallery, persist_match3d_generated_model,
publish_match3d_work, put_match3d_audio_assets, put_match3d_work,
restart_match3d_run, start_match3d_run, stop_match3d_run,
stream_match3d_agent_message, submit_match3d_agent_message,
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry,
password_management::{change_password, reset_password},
@@ -975,10 +974,9 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/creation/match3d/works/{profile_id}/item-assets",
post(generate_match3d_item_assets_for_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(generate_match3d_item_assets_for_work).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/creation/match3d/works/{profile_id}/generated-models",

View File

@@ -767,7 +767,9 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind("match3d_cover_image"));
assert!(super::is_supported_asset_history_kind(
"match3d_cover_image"
));
assert!(super::is_supported_asset_history_kind("match3d_item_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"

View File

@@ -80,14 +80,16 @@ mod work_author;
mod work_play_tracking;
use shared_logging::init_tracing;
use std::{collections::HashSet, env, fs, io, panic, thread};
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tracing::info;
use tokio::time::timeout;
use tracing::{info, warn};
use crate::{app::build_router, config::AppConfig, state::AppState};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
fn main() -> Result<(), io::Error> {
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
@@ -121,7 +123,7 @@ async fn run_server() -> Result<(), io::Error> {
let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::try_restore_auth_store_from_spacetime(config)
let state = restore_app_state_for_startup(config)
.await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
let router = build_router(state);
@@ -131,14 +133,47 @@ async fn run_server() -> Result<(), io::Error> {
axum::serve(listener, router).await
}
async fn restore_app_state_for_startup(
config: AppConfig,
) -> Result<AppState, state::AppStateInitError> {
let fallback_config = config.clone();
match timeout(
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
AppState::try_restore_auth_store_from_spacetime(config),
)
.await
{
Ok(result) => result,
Err(_) => {
warn!(
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
"启动恢复认证快照超时,跳过远端恢复并继续启动 api-server"
);
AppState::new(fallback_config)
}
}
}
fn load_local_env_files() {
let shell_env_keys = env::vars().map(|(key, _)| key).collect::<HashSet<_>>();
let shell_env_keys = protected_env_keys_from(env::vars());
for path in [".env", ".env.local", ".env.secrets.local"] {
load_env_file(path, &shell_env_keys);
}
}
fn protected_env_keys_from(vars: impl IntoIterator<Item = (String, String)>) -> HashSet<String> {
vars.into_iter()
.filter_map(|(key, value)| {
if value.trim().is_empty() {
None
} else {
Some(key)
}
})
.collect()
}
fn load_env_file(path: &str, shell_env_keys: &HashSet<String>) {
let Ok(raw_text) = fs::read_to_string(path) else {
return;
@@ -193,7 +228,7 @@ fn is_valid_env_key(key: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{is_valid_env_key, strip_env_value};
use super::{is_valid_env_key, protected_env_keys_from, strip_env_value};
#[test]
fn strip_env_value_removes_wrapping_quotes() {
@@ -218,4 +253,20 @@ mod tests {
assert!(!is_valid_env_key("1_BAD"));
assert!(!is_valid_env_key("BAD-KEY"));
}
#[test]
fn empty_shell_env_does_not_protect_dotenv_value() {
let protected = protected_env_keys_from([
("ALIYUN_OSS_BUCKET".to_string(), "".to_string()),
("ALIYUN_OSS_ENDPOINT".to_string(), " ".to_string()),
(
"ALIYUN_OSS_ACCESS_KEY_ID".to_string(),
"configured".to_string(),
),
]);
assert!(!protected.contains("ALIYUN_OSS_BUCKET"));
assert!(!protected.contains("ALIYUN_OSS_ENDPOINT"));
assert!(protected.contains("ALIYUN_OSS_ACCESS_KEY_ID"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
use std::{
collections::BTreeMap,
error::Error as StdError,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@@ -126,8 +127,6 @@ const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
const PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH: &str =
"public/ui-previews/puzzle-image-compact-ui-2026-05-08.png";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
@@ -3372,7 +3371,7 @@ fn normalize_puzzle_ui_background_prompt(
draft.work_description.trim(),
target_level.picture_description.trim(),
tags.as_str(),
"移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰",
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素",
]
.into_iter()
.filter(|value| !value.is_empty())
@@ -3391,7 +3390,7 @@ fn build_puzzle_ui_background_generation_prompt(level_name: &str, prompt: &str)
format!("当前拼图关卡名称:{level_name}")
};
format!(
"{title_clause}{prompt}\n严格参考输入图的构图关系:生成一张 9:16 竖屏拼图游戏 UI 背景图,中央必须预留清晰正方形拼图区,拼图区与外部 UI 背景必须有明确边界、描边或容器层次;拼图区之外可以生成与作品名称相关的氛围背景、顶部安全区和底部工具区背景,但不要画文字、按钮文字、数字、拼图碎片、完整拼图图像、教程浮层或水印"
"{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块"
)
}
@@ -3533,28 +3532,36 @@ async fn compile_puzzle_draft_with_initial_cover(
})?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
let image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:首图 prompt 只依赖画面描述关卡名分支可以和生图分支并行OSS 临时路径使用已有名或确定性兜底名。
let level_name_future =
generate_puzzle_first_level_name(state, &target_level.picture_description);
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates = generate_puzzle_image_candidates(
let candidates_future = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
&image_level_name,
&image_prompt,
reference_image_src,
true,
image_model,
1,
target_level.candidates.len(),
)
.await?;
);
let (generated_level_name, candidates_result) =
tokio::join!(level_name_future, candidates_future);
target_level.level_name = generated_level_name.clone();
let candidates = candidates_result?;
let selected_candidate_id = candidates
.iter()
.find(|candidate| candidate.record.selected)
@@ -3580,30 +3587,31 @@ async fn compile_puzzle_draft_with_initial_cover(
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
// 中文注释:音乐和 UI 背景都只依赖最终关卡名与草稿快照,名称确定后即可并行生成。
let (music_result, ui_background_result) = tokio::join!(
try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
),
try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
),
);
if let Some(music) = music_result {
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
if let Some((ui_prompt, ui_background)) = ui_background_result {
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
@@ -3736,14 +3744,16 @@ async fn compile_puzzle_draft_with_uploaded_cover(
})?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
let image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
@@ -3755,44 +3765,60 @@ async fn compile_puzzle_draft_with_uploaded_cover(
mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()),
bytes: uploaded_image.bytes,
};
if let Some(refined_level_name) = generate_puzzle_first_level_name_from_image(
let level_name_future =
generate_puzzle_first_level_name(state, &target_level.picture_description);
let image_level_name_future = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&uploaded_downloaded_image,
)
.await
{
target_level.level_name = refined_level_name;
}
);
let persist_upload_future = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
image_level_name.as_str(),
candidate_id.as_str(),
"uploaded-direct",
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let (generated_level_name, refined_level_name, persisted_upload_result) = tokio::join!(
level_name_future,
image_level_name_future,
persist_upload_future
);
target_level.level_name = refined_level_name.unwrap_or(generated_level_name.clone());
let generated_level_name = target_level.level_name.clone();
let persisted_upload = persisted_upload_result?;
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
let music_title = resolve_puzzle_background_music_title(&draft, &target_level);
if let Some(music) = try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
)
.await
{
// 中文注释:直用上传图时,名称分支和上传图落库完成后,再并行补齐音乐与 UI 背景。
let (music_result, ui_background_result) = tokio::join!(
try_generate_puzzle_background_music(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
profile_id.as_str(),
music_title.as_str(),
),
try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
),
);
if let Some(music) = music_result {
attach_puzzle_level_background_music(
&mut updated_levels,
target_level.level_id.as_str(),
music,
);
}
if let Some((ui_prompt, ui_background)) = try_generate_puzzle_initial_ui_background(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await
{
if let Some((ui_prompt, ui_background)) = ui_background_result {
attach_puzzle_level_ui_background(
&mut updated_levels,
target_level.level_id.as_str(),
@@ -3802,17 +3828,6 @@ async fn compile_puzzle_draft_with_uploaded_cover(
}
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
candidate_id.as_str(),
"uploaded-direct",
uploaded_downloaded_image,
current_utc_micros(),
)
.await?;
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate_id.clone(),
image_src: persisted_upload.image_src,
@@ -4769,15 +4784,14 @@ async fn generate_puzzle_ui_background_image(
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_puzzle_ui_background_reference_data_url().await?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、角色手指、模糊边界"),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
"9:16",
1,
&[reference_image],
&[],
"拼图 UI 背景图生成失败",
)
.await?;
@@ -4803,29 +4817,6 @@ fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt:
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}
async fn load_puzzle_ui_background_reference_data_url() -> Result<String, AppError> {
let bytes = tokio::fs::read(PUZZLE_UI_BACKGROUND_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("读取拼图 UI 背景参考图失败:{error}"),
}))
})?;
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图 UI 背景参考图为空",
})),
);
}
Ok(format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(bytes)
))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4914,6 +4905,32 @@ mod tests {
));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
Ok(_) => panic!("invalid url should fail request build"),
Err(error) => error,
};
let app_error = map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
"https://api.vectorengine.ai/v1/images/edits",
error,
);
let response = app_error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
@@ -5210,14 +5227,15 @@ mod tests {
}
#[test]
fn puzzle_ui_background_prompt_keeps_square_boundary_constraint() {
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt =
build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("中央必须预留清晰正方形拼图区"));
assert!(prompt.contains("明确边界"));
assert!(prompt.contains("不要画文字"));
assert!(prompt.contains("纯背景图"));
assert!(prompt.contains("不得出现拼图槽"));
assert!(prompt.contains("默认拼图槽"));
assert!(prompt.contains("文字"));
}
#[test]
@@ -5530,6 +5548,8 @@ fn build_puzzle_image_http_client(
reqwest::Client::builder()
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
// 中文注释VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。
.http1_only()
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
@@ -5690,9 +5710,11 @@ async fn create_puzzle_vector_engine_image_edit(
.send()
.await
.map_err(|error| {
map_puzzle_vector_engine_request_error(format!(
"创建拼图 VectorEngine 图片编辑任务失败{error}"
))
map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
&request_url,
error,
)
})?;
let status = response.status();
tracing::info!(
@@ -6361,19 +6383,107 @@ fn puzzle_mime_to_extension(mime_type: &str) -> &str {
}
fn map_puzzle_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": "puzzle-image",
"message": message,
"timeout": is_timeout,
}))
}
fn map_puzzle_vector_engine_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
let is_timeout = is_puzzle_request_timeout_message(message.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"timeout": is_timeout,
}))
}
fn map_puzzle_vector_engine_reqwest_error(
context: &str,
request_url: &str,
error: reqwest::Error,
) -> AppError {
let message = format!(
"{context}{}",
normalize_puzzle_reqwest_error_message(&error)
);
let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str());
let is_connect = error.is_connect();
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
let source = error.source().map(ToString::to_string).unwrap_or_default();
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
timeout = is_timeout,
connect = is_connect,
request = error.is_request(),
body = error.is_body(),
source = %source,
message = %message,
"拼图 VectorEngine 请求发送失败"
);
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"reason": resolve_puzzle_vector_engine_request_failure_reason(&error),
"endpoint": request_url,
"timeout": is_timeout,
"connect": is_connect,
"request": error.is_request(),
"body": error.is_body(),
"source": source,
}))
}
fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String {
error
.to_string()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn resolve_puzzle_vector_engine_request_failure_reason(error: &reqwest::Error) -> &'static str {
if error.is_timeout() {
return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
}
if error.is_connect() {
return "无法连接 VectorEngine 图片编辑接口请检查服务器网络、DNS、防火墙或代理配置";
}
if error.is_body() {
return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小";
}
"VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误"
}
fn is_puzzle_request_timeout_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("operation timed out")
|| lower.contains("deadline has elapsed")
}
fn map_puzzle_vector_engine_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,

View File

@@ -937,15 +937,41 @@ impl AdminRuntime {
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
|| config.oss_access_key_id.is_some()
|| config.oss_access_key_secret.is_some();
let oss_fields = [
("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()),
("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()),
(
"ALIYUN_OSS_ACCESS_KEY_ID",
config.oss_access_key_id.as_deref(),
),
(
"ALIYUN_OSS_ACCESS_KEY_SECRET",
config.oss_access_key_secret.as_deref(),
),
];
let has_any_oss_field = oss_fields
.iter()
.any(|(_, value)| value.is_some_and(|value| !value.trim().is_empty()));
if !has_any_oss_field {
return Ok(None);
}
let missing_fields = oss_fields
.iter()
.filter_map(|(name, value)| match value {
Some(value) if !value.trim().is_empty() => None,
_ => Some(*name),
})
.collect::<Vec<_>>();
if !missing_fields.is_empty() {
warn!(
missing_fields = %missing_fields.join(","),
"OSS 环境变量配置不完整,跳过 OSS 客户端初始化"
);
return Ok(None);
}
let oss_config = OssConfig::new(
config.oss_bucket.clone().unwrap_or_default(),
config.oss_endpoint.clone().unwrap_or_default(),
@@ -1085,6 +1111,17 @@ mod tests {
assert!(state.creative_agent_gpt5_client().is_none());
}
#[test]
fn app_state_skips_oss_client_when_oss_config_is_partial() {
let mut config = AppConfig::default();
config.oss_bucket = Some("genarrative-assets".to_string());
config.oss_endpoint = Some("oss-cn-hangzhou.aliyuncs.com".to_string());
let state = AppState::new(config).expect("state should build with partial oss config");
assert!(state.oss_client().is_none());
}
#[test]
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
let mut config = AppConfig::default();

View File

@@ -37,7 +37,8 @@ const SUNO_TAGS_MAX_CHARS: usize = 160;
const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
const DEFAULT_SOUND_EFFECT_DURATION_SECONDS: u8 = 5;
const MAX_GENERATED_AUDIO_BYTES: usize = 40 * 1024 * 1024;
const CREATION_AUDIO_POINTS_COST: u64 = 10;
const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10;
#[derive(Clone, Debug)]
struct VectorEngineAudioSettings {
@@ -260,13 +261,8 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text(&prompt, "prompt", VIDU_PROMPT_MAX_CHARS)?;
let task = create_sound_effect_task_response(
state,
normalized_prompt.clone(),
duration,
seed,
)
.await?;
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
@@ -284,9 +280,9 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("音效生成完成但缺少播放地址")
})?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
@@ -309,11 +305,8 @@ pub(crate) async fn generate_background_music_asset_for_creation(
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let normalized_prompt = normalize_limited_text_allow_empty(
&prompt,
"prompt",
SUNO_PROMPT_MAX_CHARS,
)?;
let normalized_prompt =
normalize_limited_text_allow_empty(&prompt, "prompt", SUNO_PROMPT_MAX_CHARS)?;
let normalized_title = normalize_limited_text(&title, "title", SUNO_TITLE_MAX_CHARS)?;
let task = create_background_music_task_response(
state,
@@ -340,9 +333,9 @@ pub(crate) async fn generate_background_music_asset_for_creation(
target,
)
.await?;
let audio_src = generated.audio_src.ok_or_else(|| {
vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址")
})?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
@@ -623,12 +616,13 @@ async fn publish_generated_audio_asset(
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
let billing_asset_kind = target.asset_kind.clone();
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
let points_cost = resolve_creation_audio_points_cost(slot, &target);
let persisted = execute_billable_asset_operation_with_cost(
state,
owner_user_id,
billing_asset_kind.as_str(),
billing_asset_id.as_str(),
CREATION_AUDIO_POINTS_COST,
points_cost,
async {
let audio = download_generated_audio(&http_client, &audio_url, slot.provider()).await?;
persist_generated_audio_asset(
@@ -673,7 +667,12 @@ async fn wait_for_generated_audio_asset(
target.clone(),
)
.await?;
if response.audio_src.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
if response
.audio_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Ok(response);
}
latest_status = response.status;
@@ -704,6 +703,16 @@ fn build_audio_billing_asset_id(
)
}
fn resolve_creation_audio_points_cost(
slot: AudioAssetSlot,
_target: &AudioAssetBindingTarget,
) -> u64 {
match slot {
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
}
}
async fn fetch_audio_task_payload(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
@@ -1442,6 +1451,28 @@ mod tests {
assert!(is_failed_task_status("failed"));
}
#[test]
fn creation_audio_billing_uses_lower_cost_for_background_music() {
let target = AudioAssetBindingTarget {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
storage_scope: "puzzle_work".to_string(),
};
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
5
);
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
10
);
}
#[test]
fn validates_prompt_length() {
let prompt = "".repeat(VIDU_PROMPT_MAX_CHARS + 1);

View File

@@ -113,6 +113,12 @@ pub struct Match3DGeneratedBackgroundAssetResponse {
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default)]
pub container_prompt: Option<String>,
#[serde(default)]
pub container_image_src: Option<String>,
#[serde(default)]
pub container_image_object_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,

View File

@@ -18,6 +18,21 @@ pub struct PutMatch3DWorkRequest {
pub difficulty: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DWorkTagsRequest {
pub game_name: String,
pub theme_text: String,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GenerateMatch3DWorkTagsResponse {
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PutMatch3DAudioAssetsRequest {
@@ -134,6 +149,12 @@ pub struct Match3DGeneratedBackgroundAssetResponse {
pub image_src: Option<String>,
#[serde(default)]
pub image_object_key: Option<String>,
#[serde(default)]
pub container_prompt: Option<String>,
#[serde(default)]
pub container_image_src: Option<String>,
#[serde(default)]
pub container_image_object_key: Option<String>,
pub status: String,
#[serde(default)]
pub error: Option<String>,

View File

@@ -15,12 +15,12 @@ pub use mapper::{
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord,
CustomWorldAgentActionExecuteRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput,
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput,
@@ -42,18 +42,17 @@ pub use mapper::{
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
PuzzleFormDraftSaveRecordInput,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentMessageRecord,
SquareHoleAgentMessageSubmitRecordInput, SquareHoleAgentSessionCreateRecordInput,
SquareHoleAgentSessionRecord, SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord,
@@ -421,28 +420,32 @@ impl SpacetimeClient {
let connect_sender = Arc::new(Mutex::new(Some(sender)));
let broken_flag = broken.clone();
let disconnect_sender = connect_sender.clone();
let connection = tokio::task::spawn_blocking(move || {
DbConnection::builder()
.with_uri(config.server_url)
.with_database_name(config.database)
.with_token(config.token)
.on_connect(move |_, _, _| {
send_connect_once(&connect_sender, Ok(()));
})
.on_disconnect(move |_, error| {
broken_flag.store(true, Ordering::SeqCst);
let message = error
.map(|error| error.to_string())
.unwrap_or_else(|| "SpacetimeDB 连接已断开".to_string());
send_connect_once(
&disconnect_sender,
Err(SpacetimeClientError::Procedure(message)),
);
})
.build()
.map_err(|error| SpacetimeClientError::Build(error.to_string()))
})
let connection = timeout(
self.config.procedure_timeout,
tokio::task::spawn_blocking(move || {
DbConnection::builder()
.with_uri(config.server_url)
.with_database_name(config.database)
.with_token(config.token)
.on_connect(move |_, _, _| {
send_connect_once(&connect_sender, Ok(()));
})
.on_disconnect(move |_, error| {
broken_flag.store(true, Ordering::SeqCst);
let message = error
.map(|error| error.to_string())
.unwrap_or_else(|| "SpacetimeDB 连接已断开".to_string());
send_connect_once(
&disconnect_sender,
Err(SpacetimeClientError::Procedure(message)),
);
})
.build()
.map_err(|error| SpacetimeClientError::Build(error.to_string()))
}),
)
.await
.map_err(|_| SpacetimeClientError::Timeout)?
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??;
let runner = connection.run_threaded();

View File

@@ -73,10 +73,10 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.getByText('2D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成音效' })).toBeTruthy();
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.getByText('消耗10光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
@@ -142,7 +142,39 @@ test('match3d workspace supports custom 2d asset style prompt', () => {
);
});
test('match3d workspace can enable click sound generation from entry toggle', () => {
test('match3d workspace submits strict pixel-retro style prompt', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '复古水果铺' },
});
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStyleId: 'pixel-retro',
assetStyleLabel: '像素复古',
assetStylePrompt: expect.stringContaining('64x64'),
}),
);
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
}),
);
});
test('match3d workspace keeps click sound generation disabled from entry form', () => {
const onCreateFromForm = vi.fn();
render(
@@ -157,13 +189,12 @@ test('match3d workspace can enable click sound generation from entry toggle', ()
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '海岛甜品' },
});
fireEvent.click(screen.getByRole('button', { name: '生成音效' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
themeText: '海岛甜品',
generateClickSound: true,
generateClickSound: false,
}),
);
});

View File

@@ -1,4 +1,4 @@
import { Loader2, Music2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -26,7 +26,6 @@ type Match3DFormState = {
difficultyOptionId: Match3DDifficultyOptionId;
assetStyleId: Match3DAssetStyleOptionId;
customAssetStylePrompt: string;
generateClickSound: boolean;
};
const EMPTY_FORM_STATE: Match3DFormState = {
@@ -34,7 +33,6 @@ const EMPTY_FORM_STATE: Match3DFormState = {
difficultyOptionId: 'standard',
assetStyleId: 'flat-icon',
customAssetStylePrompt: '',
generateClickSound: false,
};
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
@@ -68,7 +66,7 @@ const MATCH3D_ASSET_STYLE_OPTIONS = [
label: '像素复古',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'复古像素 2D 游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,不使用真实 3D 渲染。',
'真正复古像素 2D 游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
},
{
id: 'watercolor',
@@ -186,10 +184,6 @@ function resolveInitialFormState(
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
customAssetStylePrompt: assetStylePrompt,
generateClickSound:
initialFormPayload?.generateClickSound ??
config?.generateClickSound ??
false,
};
}
@@ -259,13 +253,12 @@ export function Match3DAgentWorkspace({
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
generateClickSound: formState.generateClickSound,
generateClickSound: false,
}),
[
assetStyleLabel,
assetStylePrompt,
formState.assetStyleId,
formState.generateClickSound,
selectedDifficultyOption,
themeText,
],
@@ -298,7 +291,7 @@ export function Match3DAgentWorkspace({
if (session) {
onExecuteAction({
action: 'match3d_compile_draft',
generateClickSound: formState.generateClickSound,
generateClickSound: false,
});
}
};
@@ -458,43 +451,6 @@ export function Match3DAgentWorkspace({
})}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() =>
setFormState((current) => ({
...current,
generateClickSound: !current.generateClickSound,
}))
}
className={`flex min-h-12 shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border px-3 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
formState.generateClickSound
? 'border-rose-200 bg-rose-50/80 text-rose-700 shadow-[0_8px_18px_rgba(244,63,94,0.10)]'
: 'border-[var(--platform-subpanel-border)] bg-white/58 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white/86'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={formState.generateClickSound}
aria-label="生成音效"
>
<span className="inline-flex min-w-0 items-center gap-2">
<Music2 className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-black"></span>
</span>
<span
className={`relative h-6 w-11 shrink-0 rounded-full transition ${
formState.generateClickSound
? 'bg-rose-400'
: 'bg-slate-200'
}`}
aria-hidden="true"
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.generateClickSound ? 'left-6' : 'left-1'
}`}
/>
</span>
</button>
</div>
</div>
@@ -524,7 +480,7 @@ export function Match3DAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
10
</span>
</span>
</button>

View File

@@ -25,6 +25,9 @@ vi.mock('../ResolvedAssetImage', () => ({
vi.mock('../../services/assetReadUrlService', () => ({
isGeneratedLegacyPath: (value: string) =>
/^\/?generated-[^/?#]+\/.+/u.test(value.trim()),
resolveAssetReadUrl: vi.fn((value: string) =>
Promise.resolve(`https://signed.example.com/${value.replace(/^\/+/u, '')}`),
),
}));
vi.mock('../../services/match3d-works', () => ({
@@ -167,6 +170,7 @@ describe('Match3DResultView', () => {
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
});
expect(screen.getByText('果园')).toBeTruthy();
expect(screen.getByText('轻量休闲')).toBeTruthy();
@@ -500,6 +504,7 @@ describe('Match3DResultView', () => {
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByText('暂无音效')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
@@ -633,7 +638,8 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 4'), {
target: { value: '苹果' },
});
fireEvent.click(screen.getByRole('button', { name: '生成物品素材' }));
expect(screen.getByRole('button', { name: /生成物品素材 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DItemAssets).toHaveBeenCalledWith(
@@ -674,7 +680,7 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 1'), {
target: { value: '草莓' },
});
fireEvent.click(screen.getByRole('button', { name: '生成物品素材' }));
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
await waitFor(() => {
expect(screen.getAllByText('生成中').length).toBeGreaterThan(0);
@@ -716,14 +722,20 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }).getAttribute('aria-pressed'),
screen.getByRole('button', { name: '轻松 8次 · 3种' }),
).toBeTruthy();
const difficultySlider = screen.getByRole('slider', { name: '难度' });
expect((difficultySlider as HTMLInputElement).value).toBe('1');
expect(
screen
.getByRole('button', { name: '标准 12次 · 9种' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByText('36 件')).toBeTruthy();
expect(screen.getAllByText('9 种').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.change(difficultySlider, { target: { value: '3' } });
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
@@ -799,7 +811,6 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('物品1')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image
@@ -845,7 +856,29 @@ describe('Match3DResultView', () => {
expect(
imageSources.some((source) => source.includes('views/view-05.png')),
).toBe(true);
expect(screen.getAllByText('5 视角').length).toBeGreaterThan(0);
});
test('物品详情五视角预览使用 1:1 五格布局', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [createReadyGeneratedItemAsset(1)],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(
screen.getByRole('button', { name: '打开物品1物品素材' }),
);
const preview = screen.getByLabelText('物品1五视角预览');
expect(preview.className).toContain('aspect-square');
expect(preview.className).toContain('grid-cols-[repeat(5,minmax(0,1fr))]');
expect(preview.querySelectorAll('img')).toHaveLength(5);
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
@@ -882,7 +915,11 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('items/strawberry/image.png'),
),
).toBe(true);
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
@@ -997,6 +1034,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/old/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/old/background.png',
containerPrompt: '旧容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/old/container.png',
status: 'image_ready',
error: null,
},
@@ -1015,6 +1057,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
@@ -1026,6 +1073,11 @@ describe('Match3DResultView', () => {
'/generated-match3d-assets/session/profile/background/new/background.png',
imageObjectKey:
'generated-match3d-assets/session/profile/background/new/background.png',
containerPrompt: '新容器提示词',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/new/container.png',
status: 'image_ready',
error: null,
},
@@ -1057,7 +1109,8 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
expect(screen.getByRole('button', { name: /重新生成 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
await waitFor(() => {
expect(
@@ -1074,6 +1127,8 @@ describe('Match3DResultView', () => {
prompt: '新背景提示词',
imageSrc:
'/generated-match3d-assets/session/profile/background/new/background.png',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
}),
}),
],
@@ -1128,7 +1183,11 @@ describe('Match3DResultView', () => {
);
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('素材已就绪').length).toBeGreaterThan(0);
expect(
[...document.querySelectorAll('img')].some((image) =>
image.getAttribute('src')?.includes('views/view-01.png'),
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -1240,11 +1299,9 @@ describe('Match3DResultView', () => {
],
}),
);
expect(
document.querySelector(
'audio[src="/generated-match3d-assets/audio/click.wav"]',
),
).toBeTruthy();
expect(screen.getByLabelText('草莓点击音效').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/click.wav',
);
});
});
@@ -1312,6 +1369,7 @@ describe('Match3DResultView', () => {
'轻快, 休闲',
);
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.getByRole('button', { name: /生成音乐 · 5光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));
@@ -1351,11 +1409,9 @@ describe('Match3DResultView', () => {
],
}),
);
expect(
document.querySelector(
'audio[src="/generated-match3d-assets/audio/music.wav"]',
),
).toBeTruthy();
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-match3d-assets/audio/music.wav',
);
});
});
});

View File

@@ -1,6 +1,5 @@
import {
ArrowLeft,
Box,
CheckCircle2,
Eye,
ImageIcon,
@@ -10,6 +9,7 @@
Play,
Plus,
Send,
Trash2,
Wand2,
X,
} from 'lucide-react';
@@ -29,6 +29,8 @@ import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
createBackgroundMusicTask,
createSoundEffectTask,
@@ -40,8 +42,8 @@ import {
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
publishMatch3DWork,
generateMatch3DWorkTags,
publishMatch3DWork,
updateMatch3DGeneratedItemAssets,
updateMatch3DWork,
} from '../../services/match3d-works';
@@ -50,8 +52,14 @@ import {
resolveMatch3DGeneratedImageAssetSource,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import {
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,
} from '../match3d-runtime/match3dRuntimeUiStyles';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
@@ -135,7 +143,11 @@ const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND = 'match3d_background_music';
const MATCH3D_CLICK_SOUND_ASSET_KIND = 'match3d_click_sound';
const MATCH3D_AUDIO_POINTS_COST = 10;
const MATCH3D_BACKGROUND_MUSIC_POINTS_COST = 5;
const MATCH3D_CLICK_SOUND_POINTS_COST = 10;
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
{ id: 'work', label: '作品信息' },
@@ -180,13 +192,11 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
type Match3DDifficultyOptionId =
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
type Match3DDifficultyOption = (typeof MATCH3D_DIFFICULTY_OPTIONS)[number];
const MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC =
const MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC =
'/match3d-background-references/pot-fused-reference.png';
const MATCH3D_DIFFICULTY_CARD_CLASS =
'min-h-[5.25rem] rounded-[1rem] border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200';
const MATCH3D_MATERIAL_TAB_BUTTON_CLASS =
'min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition';
@@ -279,7 +289,7 @@ function resolveMatch3DBackgroundPreviewSource(
'',
)
.find(Boolean) ||
MATCH3D_FALLBACK_BACKGROUND_PREVIEW_SRC
''
);
}
@@ -300,9 +310,24 @@ function resolveMatch3DBackgroundPrompt(
);
}
function resolveMatch3DContainerPreviewSource(
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || ''
);
}
function buildFallbackMatch3DBackgroundPrompt(themeText: string) {
const theme = themeText.trim() || '抓大鹅';
return `${theme}题材抓大鹅游戏竖屏背景图,绿色纵向渐变背景与居中浅锅、圆盘状竞技区域自然融合,中央区域留出清晰可玩空间,无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
return `${theme}题材抓大鹅游戏竖屏背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品。`;
}
function normalizeTags(value: string) {
@@ -348,19 +373,6 @@ function buildFallbackMatch3DClickSoundPrompt(
return `${normalizedTheme}题材抓大鹅中“${normalizedName}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。`;
}
function buildFallbackMatch3DBackgroundMusicPrompt(
editState: Match3DResultEditState,
) {
return [
editState.gameName.trim(),
editState.themeText.trim(),
editState.summary.trim(),
'轻快、适合抓大鹅消除游戏循环播放的背景音乐',
]
.filter(Boolean)
.join('');
}
function normalizeMatch3DTag(value: string) {
return value
.trim()
@@ -380,18 +392,6 @@ function normalizeMatch3DTagListText(value: string) {
];
}
function parseMatch3DItemNameInput(value: string) {
return [
...new Set(
value
.split(/[\n,;]/u)
.map((item) => item.trim().replace(/^[-*\d.)\s]+/u, ''))
.filter(Boolean)
.map((item) => item.slice(0, 12)),
),
];
}
function normalizeMatch3DItemName(value: string) {
return value
.trim()
@@ -403,6 +403,16 @@ function normalizeMatch3DItemNameList(values: readonly string[]) {
return [...new Set(values.map(normalizeMatch3DItemName).filter(Boolean))];
}
function calculateMatch3DItemAssetsPointsCost(itemCount: number) {
if (itemCount <= 0) {
return 0;
}
return (
Math.ceil(itemCount / MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE) *
MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH
);
}
function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}
@@ -462,6 +472,8 @@ function hasPersistableMatch3DGeneratedItemAsset(
asset.subscriptionKey?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
asset.backgroundAsset?.prompt?.trim() ||
asset.backgroundMusic ||
asset.clickSound,
@@ -499,6 +511,9 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
asset.backgroundAsset?.prompt?.trim() ?? '',
asset.backgroundAsset?.imageSrc?.trim() ?? '',
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
asset.backgroundAsset?.status?.trim() ?? '',
asset.backgroundAsset?.error?.trim() ?? '',
asset.clickSound?.audioSrc?.trim() ??
@@ -817,28 +832,6 @@ function normalizeMatch3DAssetStatus(status: string): Match3DAssetTaskStatus {
return 'unknown';
}
function getMatch3DAssetStatusLabel(status: Match3DAssetTaskStatus) {
if (status === 'idle') return '未生成';
if (status === 'submitting') return '提交中';
if (status === 'waiting') return '排队中';
if (status === 'generating') return '生成中';
if (status === 'image_ready') return '素材已就绪';
if (status === 'done') return '已完成';
if (status === 'failed') return '失败';
return '待确认';
}
function getMatch3DAssetStatusPillClass(status: Match3DAssetTaskStatus) {
if (status === 'done') return 'platform-pill--success';
if (status === 'failed') return 'platform-pill--rose';
if (status === 'image_ready') return 'platform-pill--cool';
if (status === 'generating' || status === 'submitting') {
return 'platform-pill--warm';
}
if (status === 'waiting') return 'platform-pill--cool';
return 'platform-pill--neutral';
}
function Match3DAudioProgress({
label,
progress,
@@ -863,6 +856,35 @@ function Match3DAudioProgress({
);
}
function Match3DResolvedAudio({
ariaLabel,
src,
}: {
ariaLabel?: string;
src: string;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
expireSeconds: 300,
});
if (!resolvedUrl) {
return (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
);
}
return (
<audio
className="mt-3 w-full"
controls
src={resolvedUrl}
aria-label={ariaLabel}
/>
);
}
function getMatch3DBatchGenerationStatusLabel(
phase: Match3DBatchItemGenerationState['phase'],
) {
@@ -1653,48 +1675,120 @@ function Match3DConfigTab({
onChange: (nextState: Match3DResultEditState) => void;
}) {
const selectedOption = getMatch3DDifficultyOptionFromEditState(editState);
const selectedOptionIndex = MATCH3D_DIFFICULTY_OPTIONS.findIndex(
(option) => option.id === selectedOption.id,
);
const selectedSliderIndex = Math.max(0, selectedOptionIndex);
const runtimeTypeCount = selectedOption.itemTypeCount;
const readyItemTypeCount = getMatch3DReadyItemTypeCount(generatedItemAssets);
const trackProgress =
selectedSliderIndex / Math.max(1, MATCH3D_DIFFICULTY_OPTIONS.length - 1);
const applyDifficultyOption = (option: Match3DDifficultyOption) => {
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
});
};
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextIndex = Number.parseInt(event.target.value, 10);
const nextOption = MATCH3D_DIFFICULTY_OPTIONS[nextIndex];
if (nextOption) {
applyDifficultyOption(nextOption);
}
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() =>
onChange({
...editState,
clearCountText: String(option.clearCount),
difficultyText: String(option.difficulty),
})
}
className={`${MATCH3D_DIFFICULTY_CARD_CLASS} ${
selected
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_10px_24px_rgba(244,63,94,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-base font-black">{option.label}</div>
<div className="relative px-1 pb-1 pt-2">
<div className="relative mx-[1.35rem] h-10">
<div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-white/75 shadow-[inset_0_0_0_1px_rgba(244,114,182,0.16)]" />
<div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,#ff8aac_0%,#ff5f7e_54%,#ff9b88_100%)] transition-[width] duration-200"
style={{ width: `${trackProgress * 100}%` }}
/>
{MATCH3D_DIFFICULTY_OPTIONS.map((option, index) => {
const selected = selectedOption.id === option.id;
return (
<div
className={`mt-2 grid grid-cols-2 gap-1 text-[11px] font-bold ${
key={option.id}
aria-hidden="true"
className={`absolute top-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'text-white/88'
: 'text-[var(--platform-text-base)]'
}`}
? 'border-[#ff5f7e] bg-white shadow-[0_8px_18px_rgba(244,63,94,0.2)]'
: 'border-rose-100 bg-white/90 hover:border-rose-200'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
style={{
left: `${(index / (MATCH3D_DIFFICULTY_OPTIONS.length - 1)) * 100}%`,
}}
>
<span>{option.clearCount} </span>
<span>{option.itemTypeCount} </span>
<span
className={`h-3.5 w-3.5 rounded-full ${
selected
? 'bg-[var(--platform-accent)]'
: 'bg-rose-100'
}`}
/>
</div>
</button>
);
})}
);
})}
<input
type="range"
min={0}
max={MATCH3D_DIFFICULTY_OPTIONS.length - 1}
step={1}
value={selectedSliderIndex}
disabled={isBusy}
onChange={handleSliderChange}
className="absolute inset-x-0 top-1/2 z-10 h-10 -translate-y-1/2 cursor-pointer opacity-0 disabled:cursor-not-allowed"
aria-label="难度"
aria-valuetext={selectedOption.label}
/>
</div>
<div className="mt-3 grid grid-cols-4 gap-1">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected = selectedOption.id === option.id;
return (
<button
key={option.id}
type="button"
disabled={isBusy}
onClick={() => applyDifficultyOption(option)}
className={`rounded-[0.9rem] px-1.5 py-2 text-center transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
selected
? 'bg-[#fff1f5] text-[var(--platform-text-strong)] shadow-[inset_0_0_0_1px_rgba(244,63,94,0.18)]'
: 'text-[var(--platform-text-base)] hover:bg-white/58'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
>
<div className="text-sm font-black">{option.label}</div>
<div className="mt-1 text-[10px] font-bold leading-4 text-[var(--platform-text-soft)]">
{option.clearCount} · {option.itemTypeCount}
</div>
</button>
);
})}
</div>
</div>
<div className="mt-3 rounded-[1rem] border border-rose-100/80 bg-white/62 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-lg font-black text-[var(--platform-text-strong)]">
{selectedOption.label}
</div>
<div className="mt-1 text-xs font-bold text-[var(--platform-text-base)]">
{selectedOption.clearCount} · {selectedOption.itemTypeCount}{' '}
</div>
</div>
<div className="rounded-full bg-[var(--platform-accent)] px-3 py-1 text-xs font-black text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]">
{selectedOption.difficulty}
</div>
</div>
</div>
<div className="sr-only" aria-live="polite">
{selectedOption.label}
</div>
</section>
@@ -1735,67 +1829,51 @@ function Match3DItemAssetListCard({
onClick: () => void;
onDelete: () => void;
}) {
const pillClass = getMatch3DAssetStatusPillClass(asset.status);
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
const previewSource = previewSources[0] ?? asset.referenceImageSrc.trim();
return (
<div
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
active ? 'border-emerald-300/55 bg-emerald-500/10' : 'platform-subpanel'
className={`group min-w-0 rounded-[1.15rem] border p-2 text-left transition-colors ${
active
? 'border-rose-300/70 bg-rose-50/80'
: 'border-[var(--platform-subpanel-border)] bg-white/76 hover:border-rose-200 hover:bg-white'
}`}
>
<div className="flex items-start gap-3">
<div className="grid min-h-full grid-rows-[minmax(0,1fr)_auto] gap-2">
<button
type="button"
onClick={onClick}
className="flex min-w-0 flex-1 items-start gap-3 text-left"
className="grid min-h-0 gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
aria-label={`打开${asset.name}物品素材`}
>
<div className="platform-subpanel grid h-[4.75rem] w-[4.75rem] shrink-0 place-items-center overflow-hidden rounded-[1rem]">
{asset.referenceImageSrc ? (
<div className="grid aspect-square min-h-0 place-items-center overflow-hidden rounded-[0.95rem] border border-[var(--platform-subpanel-border)] bg-white/82">
{previewSource ? (
<ResolvedAssetImage
src={asset.referenceImageSrc}
src={previewSource}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
className="h-full w-full object-contain p-1"
/>
) : (
<Box className="h-7 w-7 text-[var(--platform-text-soft)]" />
<ImageIcon className="h-7 w-7 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</div>
<span
className={`platform-pill ${pillClass} shrink-0 px-2.5 py-1 text-[10px]`}
>
{getMatch3DAssetStatusLabel(asset.status)}
</span>
</div>
<div className="mt-1.5 line-clamp-2 text-sm leading-5 text-[var(--platform-text-base)]">
{asset.usage}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{previewSources.length}
</span>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
2D素材
</span>
</div>
</div>
</button>
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0"
aria-label="删除物品素材"
title="删除"
>
<X className="h-4 w-4" />
<span className="truncate text-[13px] font-bold leading-5 text-[var(--platform-text-strong)]">
{asset.name}
</span>
</button>
<div className="flex min-w-0 justify-end">
<button
type="button"
onClick={onDelete}
className="platform-icon-button h-8 w-8 shrink-0 text-rose-500"
aria-label="删除物品素材"
title="删除"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
@@ -1821,11 +1899,14 @@ function Match3DItemAssetDetail({
return (
<section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
<div className="grid aspect-square min-h-[18rem] grid-cols-2 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<div
className="grid aspect-square min-h-[18rem] grid-cols-[repeat(5,minmax(0,1fr))] grid-rows-1 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3"
aria-label={`${asset.name}五视角预览`}
>
{previewSources.map((source, index) => (
<div
key={`${source}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
className="grid aspect-square h-auto min-h-0 w-full self-center place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
>
<ResolvedAssetImage
src={source}
@@ -1835,6 +1916,14 @@ function Match3DItemAssetDetail({
/>
</div>
))}
{previewSources.length <= 0 ? (
<div
className="col-span-5 grid min-h-0 place-items-center text-[var(--platform-text-soft)]"
aria-hidden="true"
>
<ImageIcon className="h-10 w-10" />
</div>
) : null}
</div>
<div className="min-h-0 space-y-3">
@@ -1862,8 +1951,8 @@ function Match3DItemAssetDetail({
disabled={busy || soundBusy}
onClick={() => onGenerateClickSound(asset)}
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label={`生成点击音效,${MATCH3D_AUDIO_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_AUDIO_POINTS_COST}光点`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}光点`}
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1892,10 +1981,9 @@ function Match3DItemAssetDetail({
/>
) : null}
{asset.clickSound?.audioSrc ? (
<audio
className="mt-3 w-full"
controls
<Match3DResolvedAudio
src={asset.clickSound.audioSrc}
ariaLabel={`${asset.name}点击音效`}
/>
) : (
<div className="mt-3 text-sm font-semibold text-[var(--platform-text-soft)]">
@@ -1948,7 +2036,10 @@ function Match3DAssetsTab({
</button>
</div>
<Match3DBatchGenerationProgress generationState={batchGenerationState} />
<section className="space-y-3" aria-label="抓大鹅 2D 素材列表">
<section
className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4"
aria-label="抓大鹅 2D 素材列表"
>
{assets.map((asset) => (
<Match3DItemAssetListCard
key={asset.id}
@@ -2002,6 +2093,7 @@ function Match3DBatchAddItemsPanel({
}) {
const parsedNames = normalizeMatch3DItemNameList(values);
const isGenerating = generationState.phase === 'generating';
const pointsCost = calculateMatch3DItemAssetsPointsCost(parsedNames.length);
return (
<Match3DModalShell title="批量新增物品" onClose={onClose}>
@@ -2061,7 +2153,7 @@ function Match3DBatchAddItemsPanel({
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
</button>
</div>
</Match3DModalShell>
@@ -2191,7 +2283,10 @@ function Match3DMusicTab({
/>
) : null}
{currentMusic?.audioSrc ? (
<audio className="mt-3 w-full" controls src={currentMusic.audioSrc} />
<Match3DResolvedAudio
src={currentMusic.audioSrc}
ariaLabel="抓大鹅背景音乐"
/>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -2236,7 +2331,7 @@ function Match3DMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_AUDIO_POINTS_COST}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
</button>
</section>
@@ -2279,6 +2374,7 @@ function Match3DAssetConfigTabs({
function Match3DUIAssetsTab({
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
busy,
isGenerating,
@@ -2286,6 +2382,7 @@ function Match3DUIAssetsTab({
onGenerate,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
busy: boolean;
isGenerating: boolean;
@@ -2353,7 +2450,7 @@ function Match3DUIAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
</div>
@@ -2369,6 +2466,7 @@ function Match3DUIAssetsTab({
{isPreviewOpen ? (
<Match3DUIRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
@@ -2378,9 +2476,11 @@ function Match3DUIAssetsTab({
function Match3DUIRuntimePreviewPanel({
backgroundPreviewSrc,
containerPreviewSrc,
onClose,
}: {
backgroundPreviewSrc: string;
containerPreviewSrc: string;
onClose: () => void;
}) {
return (
@@ -2395,14 +2495,14 @@ function Match3DUIRuntimePreviewPanel({
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<header className="relative z-10 flex items-center justify-between gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<ArrowLeft size={20} />
</span>
<span className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
1:30
</span>
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 backdrop-blur">
<span className="h-4 w-4 rounded-full border-2 border-white/84 border-l-transparent" />
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
</span>
</header>
@@ -2412,16 +2512,25 @@ function Match3DUIRuntimePreviewPanel({
style={{ width: 'min(92%, 58dvh, 100%)' }}
aria-hidden="true"
>
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{containerPreviewSrc ? (
<ResolvedAssetImage
src={containerPreviewSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] h-[108%] w-[108%] object-contain"
/>
) : (
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
</div>
</section>
<section className="relative z-10 mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={`relative z-10 ${MATCH3D_RUNTIME_GLASS_TRAY_CLASS}`}>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: 7 }).map((_, index) => (
<span
key={index}
className="h-14 rounded-xl bg-white/10 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
/>
))}
</div>
@@ -2437,6 +2546,7 @@ function Match3DAssetConfigTab({
activeAssetId,
assetDrafts,
backgroundPreviewSrc,
containerPreviewSrc,
backgroundPrompt,
backgroundGenerationError,
batchGenerationState,
@@ -2459,6 +2569,7 @@ function Match3DAssetConfigTab({
activeAssetId: string | null;
assetDrafts: Match3DItemAssetDraft[];
backgroundPreviewSrc: string;
containerPreviewSrc: string;
backgroundPrompt: string;
backgroundGenerationError: string | null;
batchGenerationState: Match3DBatchItemGenerationState;
@@ -2507,6 +2618,7 @@ function Match3DAssetConfigTab({
{activeAssetConfigTab === 'ui' ? (
<Match3DUIAssetsTab
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
busy={busy}
isGenerating={isGeneratingBackground}
@@ -2578,7 +2690,7 @@ export function Match3DResultView({
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const generatedItemAssets = useMemo(
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
[draft?.generatedItemAssets, profile],
[draft, profile],
);
const blockers = useMemo(
() => buildPublishBlockers(editState, generatedItemAssets),
@@ -2606,6 +2718,12 @@ export function Match3DResultView({
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
[draft, generatedItemAssets, profile],
);
const containerPreviewSrc = useMemo(
() =>
resolveMatch3DContainerPreviewSource(generatedItemAssets) ||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
[generatedItemAssets],
);
const coverSourceAssets = useMemo(
() => resolveMatch3DCoverSourceAssets(assetDrafts, backgroundPreviewSrc),
[assetDrafts, backgroundPreviewSrc],
@@ -2621,6 +2739,8 @@ export function Match3DResultView({
setCoverPanelError(null);
setBackgroundGenerationError(null);
setIsGeneratingBackground(false);
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
@@ -2628,6 +2748,8 @@ export function Match3DResultView({
setActiveAssetId(null);
setSoundBusyAssetId(null);
setSoundGenerationProgress(null);
// 中文注释:素材草稿只跟随持久化素材字段和作品切换重建,避免无关 profile 字段刷新关闭当前面板。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
draft?.generatedItemAssets,
profile.generatedItemAssets,
@@ -2690,7 +2812,7 @@ export function Match3DResultView({
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, generatedItemAssets, onSaved, profile]);
}, [editState, generatedItemAssets, isGeneratingBackground, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
@@ -2823,7 +2945,11 @@ export function Match3DResultView({
setIsGeneratingTags(true);
try {
const response = await generateMatch3DWorkTags({ gameName, themeText });
const response = await generateMatch3DWorkTags({
gameName,
themeText,
summary: editState.summary.trim(),
});
const nextTags = normalizeTags(response.tags.join(''));
if (nextTags.length <= 0) {
throw new Error('未生成有效标签。');
@@ -3223,6 +3349,7 @@ export function Match3DResultView({
activeAssetId={activeAssetId}
assetDrafts={assetDrafts}
backgroundPreviewSrc={backgroundPreviewSrc}
containerPreviewSrc={containerPreviewSrc}
backgroundPrompt={backgroundPrompt}
backgroundGenerationError={backgroundGenerationError}
batchGenerationState={batchGenerationState}

View File

@@ -358,6 +358,123 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
);
});
test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
const baseRun = startLocalMatch3DRun(3);
const baseTypeIds = [...new Set(baseRun.items.map((item) => item.itemTypeId))];
const run: Match3DRunSnapshot = {
...baseRun,
items: baseRun.items.map((item) =>
item.itemTypeId === baseTypeIds[0]
? {...item, itemTypeId: 'match3d-type-01'}
: item.itemTypeId === baseTypeIds[1]
? {...item, itemTypeId: 'match3d-type-02'}
: item,
),
};
const typeOneItem = run.items.find(
(item) => item.itemTypeId === 'match3d-type-01',
);
expect(typeOneItem).toBeTruthy();
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '樱桃',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/cherry-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/apple-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
renderRuntime(run, generatedItemAssets);
const token = screen.getByTestId(
`match3d-item-${typeOneItem!.itemInstanceId}`,
);
await waitFor(() => {
expect(token.querySelector('img')?.getAttribute('src')).toContain(
'/match3d/cherry-view-01.png',
);
});
});
test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-container.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const hardRun = startLocalMatch3DRun(20);

View File

@@ -47,6 +47,12 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from './match3dRuntimeUiStyles';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
@@ -158,6 +164,11 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
.sort(compareMatch3DGeneratedTypeId);
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
@@ -166,14 +177,28 @@ function buildMatch3DImageSourcesByType(
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets
.map((asset) => getMatch3DGeneratedImageViewSources(asset))
.filter((sources) => sources.length > 0);
const readyAssets = generatedItemAssets.flatMap((asset, fallbackIndex) => {
const sources = getMatch3DGeneratedImageViewSources(asset);
return sources.length > 0
? [
{
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
const sources = readyAssets[index];
return sources ? [[typeId, sources] as const] : [];
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
readyAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
@@ -543,6 +568,15 @@ export function Match3DRuntimeShell({
)
.find(Boolean) ||
'';
const containerAssetSrc =
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
@@ -566,6 +600,7 @@ export function Match3DRuntimeShell({
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -584,7 +619,7 @@ export function Match3DRuntimeShell({
);
}, [generatedItemAssets, run]);
useEffect(() => {
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
@@ -596,6 +631,10 @@ export function Match3DRuntimeShell({
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
useEffect(() => {
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
@@ -668,6 +707,35 @@ export function Match3DRuntimeShell({
};
}, [backgroundAssetSrc]);
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = [
...new Set(
@@ -730,6 +798,7 @@ export function Match3DRuntimeShell({
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
setPendingClick({
clientEventId,
@@ -823,19 +892,19 @@ export function Match3DRuntimeShell({
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<div className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onRestart}
aria-label="重新开始"
>
@@ -853,7 +922,17 @@ export function Match3DRuntimeShell({
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{resolvedContainerImageSrc ? (
<img
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
data-testid="match3d-container-image"
/>
) : (
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
@@ -876,7 +955,7 @@ export function Match3DRuntimeShell({
</div>
</section>
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
@@ -892,7 +971,7 @@ export function Match3DRuntimeShell({
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
data-testid="match3d-tray-slot"
>
<Match3DTrayToken

View File

@@ -0,0 +1,15 @@
// 中文注释:运行态 HUD 使用题材无关的半透明玻璃样式,避免和 AI 生成背景、容器素材绑定。
export const MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/65 bg-white/72 text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md transition hover:bg-white/86 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/82';
export const MATCH3D_RUNTIME_GLASS_TIMER_CLASS =
'flex min-w-[4.25rem] items-center justify-center gap-1.5 rounded-full border border-white/65 bg-white/72 px-3 py-2 text-sm font-black text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_SPINNER_CLASS =
'h-4 w-4 rounded-full border-2 border-slate-700/76 border-l-transparent';
export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
'mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/56 bg-white/34 p-2 shadow-[0_14px_32px_rgba(15,23,42,0.16)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';

View File

@@ -162,7 +162,10 @@ import {
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import { preloadMatch3DGeneratedModelAssets } from '../../services/match3dGeneratedModelCache';
import {
hasMatch3DGeneratedImageAsset,
preloadMatch3DGeneratedRuntimeAssets,
} from '../../services/match3dGeneratedModelCache';
import {
buildBabyObjectMatchGenerationAnchorEntries,
buildBigFishGenerationAnchorEntries,
@@ -359,6 +362,18 @@ type PendingDraftShelfMap = Partial<
Record<string, PendingDraftShelfState>
>
>;
type Match3DBackgroundCompileTask = {
session: Match3DAgentSessionSnapshot;
payload: CreateMatch3DSessionRequest;
generationState: MiniGameDraftGenerationState;
error: string | null;
};
type PuzzleBackgroundCompileTask = {
session: PuzzleAgentSessionSnapshot;
payload: CreatePuzzleAgentSessionRequest;
generationState: MiniGameDraftGenerationState;
error: string | null;
};
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
@@ -667,14 +682,10 @@ function buildMatch3DProfileFromSession(
};
}
function hasMatch3DGeneratedModelAsset(
function hasMatch3DRuntimeAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return Boolean(
assets?.some(
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
),
);
return hasMatch3DGeneratedImageAsset(assets);
}
function resolveMatch3DRuntimeGeneratedItemAssets(
@@ -690,7 +701,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
: [];
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DGeneratedModelAsset(profileAssets)) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
}
@@ -699,10 +710,12 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DGeneratedModelAsset(publicDetailAssets)
return hasMatch3DRuntimeAsset(publicDetailAssets)
? publicDetailAssets
: profileAssets;
}
return profileAssets;
}
if (
@@ -714,9 +727,10 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
return publicDetailAssets;
}
return hasMatch3DGeneratedModelAsset(profileAssets)
? profileAssets
: publicDetailAssets;
if (hasMatch3DRuntimeAsset(profileAssets)) {
return profileAssets;
}
return publicDetailAssets.length > 0 ? publicDetailAssets : profileAssets;
}
function resolveActiveMatch3DRuntimeProfile(
@@ -777,23 +791,23 @@ function resolveMatch3DGenerationStateFromAssets(
const assetList = assets ?? [];
const imageReadyCount = assetList.filter(
(asset) => asset.imageObjectKey?.trim() || asset.imageSrc?.trim(),
).length;
const modelReadyCount = assetList.filter(
(asset) =>
asset.status === 'model_ready' &&
(asset.modelObjectKey?.trim() || asset.modelSrc?.trim()),
asset.imageViews?.some(
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
) ||
asset.imageObjectKey?.trim() ||
asset.imageSrc?.trim(),
).length;
const totalAssetCount = Math.max(3, assetList.length);
const totalAssetCount = Math.max(5, assetList.length);
const failedAsset = assetList.find((asset) => asset.error?.trim());
return {
...current,
phase:
imageReadyCount > 0 || modelReadyCount > 0
imageReadyCount > 0
? 'match3d-generate-views'
: current.phase,
completedAssetCount: modelReadyCount,
completedAssetCount: imageReadyCount,
totalAssetCount,
error: failedAsset?.error?.trim() || current.error,
};
@@ -2071,6 +2085,8 @@ export function PlatformEntryFlowShellImpl({
useState<CreateMatch3DSessionRequest | null>(null);
const [match3dGenerationState, setMatch3DGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [match3dBackgroundCompileTasks, setMatch3DBackgroundCompileTasks] =
useState<Record<string, Match3DBackgroundCompileTask>>({});
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
const [squareHoleWorks, setSquareHoleWorks] = useState<
SquareHoleWorkSummary[]
@@ -2155,6 +2171,8 @@ export function PlatformEntryFlowShellImpl({
);
const [puzzleGenerationState, setPuzzleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [puzzleBackgroundCompileTasks, setPuzzleBackgroundCompileTasks] =
useState<Record<string, PuzzleBackgroundCompileTask>>({});
const [miniGameGenerationProgressNowMs, setMiniGameGenerationProgressNowMs] =
useState(() => Date.now());
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
@@ -2287,6 +2305,8 @@ export function PlatformEntryFlowShellImpl({
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const selectionStageRef = useRef(selectionStage);
const activeMatch3DGenerationSessionIdRef = useRef<string | null>(null);
const activePuzzleGenerationSessionIdRef = useRef<string | null>(null);
const [draftGenerationNotices, setDraftGenerationNotices] =
useState<DraftGenerationNoticeMap>({});
const [pendingDraftShelfItems, setPendingDraftShelfItems] =
@@ -2437,6 +2457,24 @@ export function PlatformEntryFlowShellImpl({
},
[updatePendingDraftShelfItem],
);
const getMatch3DBackgroundCompileTask = useCallback(
(sessionId: string | null | undefined) => {
const normalizedSessionId = normalizeDraftNoticeId(sessionId);
return normalizedSessionId
? (match3dBackgroundCompileTasks[normalizedSessionId] ?? null)
: null;
},
[match3dBackgroundCompileTasks],
);
const getPuzzleBackgroundCompileTask = useCallback(
(sessionId: string | null | undefined) => {
const normalizedSessionId = normalizeDraftNoticeId(sessionId);
return normalizedSessionId
? (puzzleBackgroundCompileTasks[normalizedSessionId] ?? null)
: null;
},
[puzzleBackgroundCompileTasks],
);
useEffect(() => {
let cancelled = false;
@@ -2501,6 +2539,18 @@ export function PlatformEntryFlowShellImpl({
selectionStageRef.current = 'platform';
setSelectionStage('platform');
}, [enterCreateTab, setSelectionStage]);
const isViewingMatch3DGeneration = useCallback((sessionId: string) => {
return (
selectionStageRef.current === 'match3d-generating' &&
activeMatch3DGenerationSessionIdRef.current === sessionId
);
}, []);
const isViewingPuzzleGeneration = useCallback((sessionId: string) => {
return (
selectionStageRef.current === 'puzzle-generating' &&
activePuzzleGenerationSessionIdRef.current === sessionId
);
}, []);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
@@ -3515,9 +3565,9 @@ export function PlatformEntryFlowShellImpl({
...current,
phase: 'ready',
completedAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
response.session.draft?.generatedItemAssets?.length ?? 5,
totalAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 3,
response.session.draft?.generatedItemAssets?.length ?? 5,
}
: current,
);
@@ -3552,7 +3602,7 @@ export function PlatformEntryFlowShellImpl({
);
if (openResult && runtimeProfile) {
try {
await preloadMatch3DGeneratedModelAssets(
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
@@ -4151,6 +4201,39 @@ export function PlatformEntryFlowShellImpl({
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const activeMatch3DBackgroundCompileTask =
getMatch3DBackgroundCompileTask(match3dSession?.sessionId);
const match3dGenerationViewState =
activeMatch3DBackgroundCompileTask?.generationState ??
match3dGenerationState;
const match3dGenerationViewSession =
activeMatch3DBackgroundCompileTask?.session ?? match3dSession;
const match3dGenerationViewPayload =
activeMatch3DBackgroundCompileTask?.payload ?? match3dFormDraftPayload;
const match3dGenerationViewError =
activeMatch3DBackgroundCompileTask?.error ?? match3dError;
const isMatch3DGenerationViewBusy =
isMatch3DBusy ||
isMiniGameDraftGenerating(
activeMatch3DBackgroundCompileTask?.generationState ?? null,
);
const activePuzzleBackgroundCompileTask = getPuzzleBackgroundCompileTask(
puzzleSession?.sessionId,
);
const puzzleGenerationViewState =
activePuzzleBackgroundCompileTask?.generationState ?? puzzleGenerationState;
const puzzleGenerationViewSession =
activePuzzleBackgroundCompileTask?.session ?? puzzleSession;
const puzzleGenerationViewPayload =
activePuzzleBackgroundCompileTask?.payload ?? puzzleFormDraftPayload;
const puzzleGenerationViewError =
activePuzzleBackgroundCompileTask?.error ?? puzzleError;
const isPuzzleGenerationViewBusy =
isPuzzleBusy ||
isMiniGameDraftGenerating(
activePuzzleBackgroundCompileTask?.generationState ?? null,
);
const match3DGeneratingSessionId =
selectionStage === 'match3d-generating' ? match3dSession?.sessionId : null;
@@ -4302,60 +4385,366 @@ export function PlatformEntryFlowShellImpl({
const createPuzzleDraftFromForm = useCallback(
async (payload: CreatePuzzleAgentSessionRequest) => {
setPuzzleFormDraftPayload(payload);
setPuzzleGenerationState(null);
const nextSession =
puzzleFlow.session && !isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
? puzzleFlow.session
: await puzzleFlow.openWorkspace(payload);
if (!nextSession) {
setPuzzleCreationError(null);
setPuzzleError(null);
let nextSession: PuzzleAgentSessionSnapshot;
try {
const response = await createPuzzleAgentSession(payload);
nextSession = response.session;
} catch (error) {
const errorMessage = resolvePuzzleErrorMessage(
error,
'开启拼图创作工作台失败。',
);
setPuzzleCreationError(errorMessage);
setPuzzleError(errorMessage);
return;
}
markPendingDraftGenerating('puzzle', nextSession.sessionId);
await puzzleFlow.executeAction(
buildPuzzleCompileActionFromFormPayload(payload),
nextSession,
);
const generationState = createMiniGameDraftGenerationState('puzzle');
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState,
error: null,
},
}));
puzzleFlow.setSession(nextSession);
setPuzzleGenerationState(generationState);
markDraftGenerating('puzzle', [
nextSession.sessionId,
buildPuzzleResultWorkId(nextSession.sessionId),
nextSession.publishedProfileId,
buildPuzzleResultProfileId(nextSession.sessionId),
]);
markPendingDraftGenerating('puzzle', nextSession.sessionId);
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
setSelectionStage('puzzle-generating');
try {
const actionPayload = buildPuzzleCompileActionFromFormPayload(payload);
const response = await executePuzzleAgentAction(
nextSession.sessionId,
actionPayload,
);
setPuzzleOperation(response.operation);
const openResult = isViewingPuzzleGeneration(nextSession.sessionId);
const readyGenerationState = {
...generationState,
phase: 'ready' as const,
completedAssetCount: 1,
totalAssetCount: 1,
};
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: response.session,
payload,
generationState: readyGenerationState,
error: null,
},
}));
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
puzzleFlow.setSession(response.session);
setPuzzleGenerationState(readyGenerationState);
}
const profileId =
response.session.publishedProfileId ??
buildPuzzleResultProfileId(response.session.sessionId);
markPendingDraftReady('puzzle', response.session.sessionId, openResult);
markDraftReady(
'puzzle',
[
response.session.sessionId,
buildPuzzleResultWorkId(response.session.sessionId),
profileId,
],
openResult,
);
void refreshPuzzleShelf();
if (openResult && response.session.draft) {
const draft = response.session.draft;
const draftProfileId =
response.session.publishedProfileId ??
buildPuzzleResultProfileId(response.session.sessionId);
if (!draft.coverImageSrc || !draftProfileId) {
setPuzzleError(
!draft.coverImageSrc
? '请先选择一张正式拼图图片。'
: '这份拼图草稿缺少会话信息,请重新开始创作。',
);
setSelectionStage('puzzle-result');
return;
}
try {
const { item } = await updatePuzzleWork(draftProfileId, {
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
});
const run = startLocalPuzzleRun(item);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
);
setSelectionStage('puzzle-result');
}
}
} catch (error) {
const errorMessage = resolvePuzzleErrorMessage(
error,
'执行拼图操作失败。',
);
const failedGenerationState = {
...generationState,
phase: 'failed' as const,
error: errorMessage,
};
setPuzzleBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingPuzzleGeneration(nextSession.sessionId)) {
setPuzzleError(errorMessage);
setPuzzleGenerationState(failedGenerationState);
}
}
},
[markPendingDraftGenerating, puzzleFlow],
[
markDraftGenerating,
markDraftReady,
markPendingDraftGenerating,
markPendingDraftReady,
isViewingPuzzleGeneration,
puzzleFlow,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setPuzzleError,
setSelectionStage,
],
);
const createMatch3DDraftFromForm = useCallback(
async (payload: CreateMatch3DSessionRequest) => {
setMatch3DFormDraftPayload(payload);
setMatch3DGenerationState(null);
setMatch3DSession(null);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DRun(null);
setMatch3DError(null);
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
const nextSession = await match3dFlow.openWorkspace(payload);
if (!nextSession) {
let nextSession: Match3DAgentSessionSnapshot;
try {
const response = await match3dCreationClient.createSession(payload);
nextSession = response.session;
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '开启抓大鹅共创工作台失败。'),
);
return;
}
markDraftGenerating('match3d', [nextSession.sessionId]);
markPendingDraftGenerating('match3d', nextSession.sessionId);
await match3dFlow.executeAction(
{
action: 'match3d_compile_draft',
generateClickSound: payload.generateClickSound,
const generationState = createMiniGameDraftGenerationState('match3d');
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState,
error: null,
},
nextSession,
);
}));
setMatch3DSession(nextSession);
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DRun(null);
setMatch3DGenerationState(generationState);
markDraftGenerating('match3d', [
nextSession.draft?.profileId,
nextSession.publishedProfileId,
nextSession.sessionId,
]);
markPendingDraftGenerating('match3d', nextSession.sessionId);
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId;
setSelectionStage('match3d-generating');
try {
const response = await match3dCreationClient.executeAction(
nextSession.sessionId,
{
action: 'match3d_compile_draft',
generateClickSound: payload.generateClickSound,
},
);
const openResult = isViewingMatch3DGeneration(nextSession.sessionId);
const readyGenerationState = {
...generationState,
phase: 'ready' as const,
completedAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 5,
totalAssetCount:
response.session.draft?.generatedItemAssets?.length ?? 5,
};
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: response.session,
payload,
generationState: readyGenerationState,
error: null,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DSession(response.session);
setMatch3DGenerationState(readyGenerationState);
}
const profileId = response.session.draft?.profileId;
if (!profileId) {
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
}
return;
}
let runtimeProfile: Match3DWorkProfile | null = null;
try {
const { item } = await getMatch3DWorkDetail(profileId);
runtimeProfile = {
...item,
generatedItemAssets:
response.session.draft?.generatedItemAssets ??
item.generatedItemAssets,
};
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
}
await refreshMatch3DShelf().catch(() => undefined);
} catch {
runtimeProfile = buildMatch3DProfileFromSession(response.session);
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DProfile(runtimeProfile);
}
}
markPendingDraftReady(
'match3d',
response.session.sessionId,
openResult,
);
markDraftReady(
'match3d',
[profileId, response.session.sessionId],
openResult,
);
if (openResult && runtimeProfile) {
try {
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
const { run } = await match3dRuntimeAdapter.startRun(
runtimeProfile.profileId,
);
setMatch3DRuntimeProfile(runtimeProfile);
setMatch3DRun(run);
setMatch3DProfile(runtimeProfile);
setMatch3DRuntimeReturnStage('match3d-result');
setSelectionStage('match3d-runtime');
} catch (error) {
setMatch3DError(
resolveMatch3DErrorMessage(error, '启动抓大鹅玩法失败。'),
);
setSelectionStage('match3d-result');
}
}
} catch (error) {
const errorMessage = resolveMatch3DErrorMessage(
error,
'执行抓大鹅操作失败。',
);
const failedGenerationState = {
...generationState,
phase: 'failed' as const,
error: errorMessage,
};
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: nextSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DError(errorMessage);
setMatch3DGenerationState(failedGenerationState);
}
try {
const { session: latestSession } =
await match3dCreationClient.getSession(nextSession.sessionId);
setMatch3DBackgroundCompileTasks((current) => ({
...current,
[nextSession.sessionId]: {
session: latestSession,
payload,
generationState: failedGenerationState,
error: errorMessage,
},
}));
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
setMatch3DSession(latestSession);
const profileId =
latestSession.draft?.profileId ?? latestSession.publishedProfileId;
if (profileId) {
const { item } = await getMatch3DWorkDetail(profileId);
setMatch3DProfile(item);
}
}
await refreshMatch3DShelf().catch(() => undefined);
} catch {
await refreshMatch3DShelf().catch(() => undefined);
}
}
},
[
match3dFlow,
match3dRuntimeAdapter,
isViewingMatch3DGeneration,
markDraftGenerating,
markDraftReady,
markPendingDraftGenerating,
markPendingDraftReady,
refreshMatch3DShelf,
resolveMatch3DErrorMessage,
setIsStreamingMatch3DReply,
setMatch3DError,
setMatch3DProfile,
setMatch3DRun,
setMatch3DSession,
setSelectionStage,
setStreamingMatch3DReplyText,
],
);
@@ -4552,6 +4941,8 @@ export function PlatformEntryFlowShellImpl({
setMatch3DProfile(null);
setMatch3DRuntimeProfile(null);
setMatch3DFormDraftPayload(null);
setMatch3DBackgroundCompileTasks({});
activeMatch3DGenerationSessionIdRef.current = null;
setActiveCreationFormType('puzzle');
setMatch3DWorks([]);
setMatch3DGalleryEntries([]);
@@ -4585,6 +4976,8 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleGenerationState(null);
setPuzzleBackgroundCompileTasks({});
activePuzzleGenerationSessionIdRef.current = null;
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
setPuzzleCreationError(null);
@@ -5361,7 +5754,7 @@ export function PlatformEntryFlowShellImpl({
const executePuzzleBackgroundAction = useCallback(
async (payload: PuzzleAgentActionRequest) => {
const targetSession = puzzleFlow.session;
const targetSession = puzzleSession;
if (!targetSession) {
return;
}
@@ -5383,7 +5776,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
}
},
[puzzleFlow, resolvePuzzleErrorMessage, setPuzzleError],
[puzzleFlow, puzzleSession, resolvePuzzleErrorMessage, setPuzzleError],
);
const retryPuzzleDraftGeneration = useCallback(() => {
@@ -5427,7 +5820,7 @@ export function PlatformEntryFlowShellImpl({
(payload: PuzzleAgentActionRequest) => {
if (
payload.action === 'compile_puzzle_draft' &&
isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
isEmptyPuzzleFormOnlyDraft(puzzleSession)
) {
const formPayload = buildPuzzleFormPayloadFromAction(payload);
if (formPayload) {
@@ -5447,7 +5840,7 @@ export function PlatformEntryFlowShellImpl({
createPuzzleDraftFromForm,
executePuzzleAction,
executePuzzleBackgroundAction,
puzzleFlow.session,
puzzleSession,
],
);
@@ -5815,7 +6208,7 @@ export function PlatformEntryFlowShellImpl({
try {
let runtimeProfile = profile;
if (!hasMatch3DGeneratedModelAsset(profile.generatedItemAssets)) {
if (!hasMatch3DRuntimeAsset(profile.generatedItemAssets)) {
try {
const { item } = await getMatch3DWorkDetail(profile.profileId);
runtimeProfile = item;
@@ -5823,7 +6216,7 @@ export function PlatformEntryFlowShellImpl({
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
}
}
await preloadMatch3DGeneratedModelAssets(
await preloadMatch3DGeneratedRuntimeAssets(
runtimeProfile.generatedItemAssets,
{ expireSeconds: 300 },
);
@@ -7628,10 +8021,31 @@ export function PlatformEntryFlowShellImpl({
if (
item.sourceSessionId === puzzleSession?.sessionId &&
isMiniGameDraftGenerating(puzzleGenerationState)
isMiniGameDraftGenerating(puzzleGenerationViewState)
) {
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('puzzle-generating');
return;
}
const backgroundTask = getPuzzleBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
) {
puzzleFlow.setSession(backgroundTask.session);
setPuzzleFormDraftPayload(backgroundTask.payload);
setPuzzleGenerationState(backgroundTask.generationState);
if (backgroundTask.error) {
setPuzzleError(backgroundTask.error);
}
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('puzzle-generating');
return;
}
@@ -7655,10 +8069,11 @@ export function PlatformEntryFlowShellImpl({
},
[
enterCreateTab,
getPuzzleBackgroundCompileTask,
markDraftNoticeSeen,
openPuzzleDetail,
puzzleFlow,
puzzleGenerationState,
puzzleGenerationViewState,
puzzleSession?.sessionId,
refreshPuzzleShelf,
setPuzzleError,
@@ -7695,10 +8110,31 @@ export function PlatformEntryFlowShellImpl({
if (
item.sourceSessionId === match3dSession?.sessionId &&
isMiniGameDraftGenerating(match3dGenerationState)
isMiniGameDraftGenerating(match3dGenerationViewState)
) {
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
}
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
);
if (
backgroundTask &&
isMiniGameDraftGenerating(backgroundTask.generationState)
) {
setMatch3DSession(backgroundTask.session);
setMatch3DFormDraftPayload(backgroundTask.payload);
setMatch3DGenerationState(backgroundTask.generationState);
if (backgroundTask.error) {
setMatch3DError(backgroundTask.error);
}
enterCreateTab();
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('match3d-generating');
return;
}
@@ -7726,9 +8162,10 @@ export function PlatformEntryFlowShellImpl({
},
[
enterCreateTab,
getMatch3DBackgroundCompileTask,
markDraftNoticeSeen,
match3dFlow,
match3dGenerationState,
match3dGenerationViewState,
match3dSession?.sessionId,
openPublicWorkDetail,
refreshMatch3DShelf,
@@ -9630,18 +10067,7 @@ export function PlatformEntryFlowShellImpl({
>
{getVisiblePlatformCreationTypes(creationEntryTypes).map((item) => {
const selected = item.id === activeCreationFormType;
const disabled =
item.locked ||
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy;
const disabled = item.locked;
return (
<button
@@ -9708,7 +10134,7 @@ export function PlatformEntryFlowShellImpl({
>
<Match3DAgentWorkspace
session={match3dSession}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
isBusy={isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onExecuteAction={(payload) => {
@@ -9765,7 +10191,7 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
@@ -10287,7 +10713,7 @@ export function PlatformEntryFlowShellImpl({
>
<Match3DAgentWorkspace
session={match3dSession}
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
isBusy={isStreamingMatch3DReply}
error={match3dError}
onBack={leaveMatch3DFlow}
onExecuteAction={(payload) => {
@@ -10311,19 +10737,19 @@ export function PlatformEntryFlowShellImpl({
>
<CustomWorldGenerationView
settingText={
match3dSession?.lastAssistantReply ??
match3dGenerationViewSession?.lastAssistantReply ??
'正在生成本局抓大鹅物品素材。'
}
anchorEntries={buildMatch3DGenerationAnchorEntries(
match3dSession,
match3dFormDraftPayload,
match3dGenerationViewSession,
match3dGenerationViewPayload,
)}
progress={buildMiniGameDraftGenerationProgress(
match3dGenerationState,
match3dGenerationViewState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isMatch3DBusy}
error={match3dError}
isGenerating={isMatch3DGenerationViewBusy}
error={match3dGenerationViewError}
onBack={returnToCreationCenterFromGeneration}
onEditSetting={() => {
setSelectionStage('match3d-agent-workspace');
@@ -10364,7 +10790,7 @@ export function PlatformEntryFlowShellImpl({
isBusy={isMatch3DBusy}
error={match3dError}
onBack={() => {
setSelectionStage('match3d-agent-workspace');
returnToCreationCenterFromGeneration();
}}
onSaved={(profile) => {
setMatch3DProfile(profile);
@@ -10388,7 +10814,6 @@ export function PlatformEntryFlowShellImpl({
}}
onStartTestRun={(profile, options) => {
setMatch3DProfile(profile);
setMatch3DRuntimeProfile(profile);
void startMatch3DRunFromProfile(
profile,
'match3d-result',
@@ -10895,7 +11320,7 @@ export function PlatformEntryFlowShellImpl({
>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
isBusy={isStreamingPuzzleReply}
error={puzzleError}
onBack={leavePuzzleFlow}
onSubmitMessage={(payload) => {
@@ -10950,18 +11375,19 @@ export function PlatformEntryFlowShellImpl({
>
<CustomWorldGenerationView
settingText={
puzzleSession?.lastAssistantReply ?? '正在整理当前拼图草稿。'
puzzleGenerationViewSession?.lastAssistantReply ??
'正在整理当前拼图草稿。'
}
anchorEntries={buildPuzzleGenerationAnchorEntries(
puzzleSession,
puzzleFormDraftPayload,
puzzleGenerationViewSession,
puzzleGenerationViewPayload,
)}
progress={buildMiniGameDraftGenerationProgress(
puzzleGenerationState,
puzzleGenerationViewState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isPuzzleBusy}
error={puzzleError}
isGenerating={isPuzzleGenerationViewBusy}
error={puzzleGenerationViewError}
onBack={returnToCreationCenterFromGeneration}
onEditSetting={() => {
setSelectionStage('puzzle-agent-workspace');
@@ -11537,18 +11963,7 @@ export function PlatformEntryFlowShellImpl({
{creationEntryConfig ? (
<PlatformEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy
}
isBusy={sessionController.isCreatingAgentSession}
error={
creationEntryConfigError ??
bigFishError ??
@@ -11564,18 +11979,7 @@ export function PlatformEntryFlowShellImpl({
entryConfig={creationEntryConfig}
creationTypes={creationEntryTypes}
onClose={() => {
if (
sessionController.isCreatingAgentSession ||
isCreativeAgentBusy ||
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply ||
isBabyObjectMatchBusy
) {
if (sessionController.isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);

View File

@@ -20,11 +20,22 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) => (
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null
),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -37,6 +48,16 @@ vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -157,6 +178,8 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
@@ -171,6 +194,33 @@ describe('PuzzleResultView', () => {
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -645,7 +695,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
@@ -657,6 +707,11 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
const preview = screen.getByRole('dialog', { name: 'UI预览' });
expect(
within(preview)
.getByTestId('puzzle-ui-runtime-preview-background')
.getAttribute('src'),
).toBe('/generated-puzzle-assets/session/ui/background.png');
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
});
@@ -671,11 +726,12 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
expect(screen.getByRole('button', { name: /生成UI背景 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
@@ -696,6 +752,46 @@ describe('PuzzleResultView', () => {
]);
});
test('素材配置背景音乐试听使用签名地址', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5光点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -711,7 +807,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
});

View File

@@ -18,8 +18,8 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
@@ -33,6 +33,7 @@ import {
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -61,7 +62,8 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type DraftEditState = {
workTitle: string;
@@ -74,12 +76,27 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
estimateSeconds: number;
@@ -419,13 +436,8 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'ui' as const, label: 'UI' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
@@ -444,6 +456,34 @@ function PuzzleResultTabs({
);
}
function PuzzleAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleAssetConfigTabId;
onChange: (tab: PuzzleAssetConfigTabId) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
function PuzzleThemeTagEditor({
editState,
isBusy,
@@ -1467,7 +1507,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1543,6 +1583,7 @@ function PuzzleUiRuntimePreviewPanel({
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
alt=""
data-testid="puzzle-ui-runtime-preview-background"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
@@ -1632,6 +1673,10 @@ function PuzzleMusicTab({
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
@@ -1708,12 +1753,17 @@ function PuzzleMusicTab({
</span>
) : null}
</div>
{currentMusic?.audioSrc ? (
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={currentMusic.audioSrc}
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -1758,7 +1808,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
@@ -1771,22 +1821,75 @@ function PuzzleMusicTab({
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
<PuzzleAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'ui' ? (
<PuzzleUiAssetsTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
canStartTestRun,
publishReady,
publishBlockers,
onPublish,
onStartTestRun,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
canStartTestRun: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
onStartTestRun?: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
@@ -1798,6 +1901,19 @@ function PuzzleResultActionBar({
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -1844,6 +1960,8 @@ export function PuzzleResultView({
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
@@ -2093,6 +2211,7 @@ export function PuzzleResultView({
generationStatus: level.generationStatus,
levels: [level],
});
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
@@ -2174,13 +2293,17 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'ui' ? (
<PuzzleUiAssetsTab
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerate={(prompt) => {
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
@@ -2207,15 +2330,6 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId ?? null}
sessionId={session.sessionId}
isBusy={isBusy}
onChange={setEditState}
/>
) : null}
</div>
{error ? (
@@ -2234,8 +2348,14 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
canStartTestRun={canStartTestRun}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
}
onPublish={() => {
if (!publishState.publishReady) {
return;

View File

@@ -22,7 +22,15 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const mocapMock = vi.hoisted(() => ({
@@ -623,6 +631,36 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
remainingMs: 300_000,
timeLimitMs: 300_000,
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithUiBackground}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const backgroundImage = container.querySelector(
'img[src="/generated-puzzle-assets/session/ui/background.png"]',
);
expect(backgroundImage).toBeTruthy();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();

View File

@@ -11,7 +11,7 @@ import {
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
@@ -472,6 +472,17 @@ export function PuzzleRuntimeShell({
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
currentLevel?.uiBackgroundImageSrc ?? null,
);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
@@ -498,16 +509,8 @@ export function PuzzleRuntimeShell({
}, [currentLevel]);
useEffect(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
@@ -949,6 +952,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
if (!selectedPieceIdBeforeInput) {
commitSelectedPieceId(pieceId);
@@ -1257,6 +1261,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);

View File

@@ -63,6 +63,7 @@ import {
listMatch3DGallery,
listMatch3DWorks,
} from '../../services/match3d-works';
import * as match3dGeneratedModelCache from '../../services/match3dGeneratedModelCache';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
@@ -436,7 +437,20 @@ vi.mock('../../services/match3d-works', () => ({
}));
vi.mock('../../services/match3dGeneratedModelCache', () => ({
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
hasMatch3DGeneratedImageAsset: vi.fn(
(assets: Match3DWorkSummary['generatedItemAssets']) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
),
preloadMatch3DGeneratedRuntimeAssets: vi.fn(() => Promise.resolve()),
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
@@ -719,9 +733,11 @@ vi.mock('../match3d-result/Match3DResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
isBusy,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
@@ -736,8 +752,12 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<div data-testid="match3d-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
@@ -773,6 +793,54 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
).length
}
</div>
<div data-testid="match3d-runtime-generated-asset-count">
{
generatedItemAssets.filter(
(asset) =>
asset.modelSrc?.trim() ||
asset.modelObjectKey?.trim() ||
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
) ||
asset.backgroundMusic?.audioSrc?.trim() ||
asset.clickSound?.audioSrc?.trim() ||
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-generated-item-image-count">
{
generatedItemAssets.filter(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
).length
}
</div>
<div data-testid="match3d-runtime-background-music-count">
{
generatedItemAssets.filter((asset) =>
asset.backgroundMusic?.audioSrc?.trim(),
).length
}
</div>
<div data-testid="match3d-runtime-container-ui-count">
{
generatedItemAssets.filter(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
).length
}
</div>
<button type="button" onClick={onBack}>
</button>
@@ -1618,6 +1686,23 @@ function TestWrapper({
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(
match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset,
).mockImplementation((assets) =>
Boolean(
assets?.some(
(asset) =>
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.imageViews?.some(
(view) => view.imageSrc?.trim() || view.imageObjectKey?.trim(),
),
),
),
);
vi.mocked(
match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets,
).mockResolvedValue(undefined);
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
@@ -2676,6 +2761,228 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-running-session',
draft: null,
stage: 'collecting_config',
});
let resolveCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: runningSession,
});
vi.mocked(match3dCreationClient.executeAction).mockReturnValue(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
const puzzleReadySession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-parallel-1',
seedText: '暖灯猫街',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: {
workTitle: '并行拼图',
workDescription: '抓大鹅后台生成时创建的新拼图。',
levelName: '并行拼图',
summary: '抓大鹅后台生成时创建的新拼图。',
themeTags: ['并行创作'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-parallel-1',
levelName: '并行拼图',
pictureDescription: '一只猫在雨夜灯牌下回头。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-parallel-1',
imageSrc: '/puzzle/parallel-candidate.png',
assetId: 'asset-parallel-1',
prompt: '暖灯猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-parallel-1',
coverImageSrc: '/puzzle/parallel-candidate.png',
coverAssetId: 'asset-parallel-1',
generationStatus: 'ready',
},
],
},
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-05-13T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-parallel-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: puzzleReadySession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
expect((generatePuzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generatePuzzleButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
});
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'compile_puzzle_draft',
}),
);
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
});
});
test('running match3d form generation keeps same template generation available', async () => {
const user = userEvent.setup();
const firstSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
draft: null,
stage: 'collecting_config',
});
const secondSession = buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
draft: null,
stage: 'collecting_config',
});
let resolveFirstCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
let resolveSecondCompile!: (value: {
session: Match3DAgentSessionSnapshot;
}) => void;
vi.mocked(match3dCreationClient.createSession)
.mockResolvedValueOnce({ session: firstSession })
.mockResolvedValueOnce({ session: secondSession });
vi.mocked(match3dCreationClient.executeAction)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstCompile = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondCompile = resolve;
}),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
});
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByTestId('match3d-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(2);
});
expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(2);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
1,
'match3d-parallel-session-1',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(match3dCreationClient.executeAction).toHaveBeenNthCalledWith(
2,
'match3d-parallel-session-2',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
expect(screen.getAllByText('抓大鹅草稿').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('生成中').length).toBeGreaterThanOrEqual(2);
});
await act(async () => {
resolveFirstCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-1',
}),
});
resolveSecondCompile({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-parallel-session-2',
}),
});
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -2761,6 +3068,166 @@ test('match3d result trial passes generated models into first runtime mount', as
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
test('match3d result trial passes generated 2D image views into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
viewIndex,
imageSrc:
`/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-${String(viewIndex).padStart(2, '0')}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
];
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-2d-1',
profileId: 'match3d-profile-draft-2d-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-13T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-2d-1',
draft: {
profileId: 'match3d-profile-draft-2d-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
);
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '0');
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
});
test('match3d result back returns to platform creation page', async () => {
const user = userEvent.setup();
const match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-back-1',
profileId: 'match3d-profile-back-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T12:10:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-back-1',
draft: {
profileId: 'match3d-profile-back-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '休闲', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
@@ -3915,7 +4382,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
});
});
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
test('home recommendation Match3D runtime keeps image, music and UI assets without requiring models', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
profileId: 'match3d-profile-card-image-only',
@@ -3949,6 +4416,108 @@ test('home recommendation Match3D runtime refetches detail when stale card only
subscriptionKey: null,
status: 'image_ready',
error: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-12T10:00:00.000Z',
},
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(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-asset-count'),
).toHaveProperty('textContent', '1');
});
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-card-image-only',
);
expect(
screen.getByTestId('match3d-runtime-background-music-count'),
).toHaveProperty('textContent', '1');
expect(screen.getByTestId('match3d-runtime-container-ui-count')).toHaveProperty(
'textContent',
'1',
);
});
test('home recommendation Match3D runtime reloads detail when card only has UI assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-ui-only',
profileId: 'match3d-profile-card-ui-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-ui-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果素材。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
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,
},
},
],
};
@@ -3956,17 +4525,29 @@ test('home recommendation Match3D runtime refetches detail when stale card only
...match3dCard,
generatedItemAssets: [
{
...match3dCard.generatedItemAssets![0]!,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
@@ -3981,14 +4562,12 @@ test('home recommendation Match3D runtime refetches detail when stale card only
await waitFor(() => {
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
'match3d-profile-card-image-only',
'match3d-profile-card-ui-only',
);
});
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
expect(
await screen.findByTestId('match3d-runtime-generated-item-image-count'),
).toHaveProperty('textContent', '1');
});
test('home recommendation surfaces start failure instead of staying in loading state', async () => {

View File

@@ -586,4 +586,46 @@ describe('apiClient', () => {
},
});
});
it('uses api error details.reason when details.message is absent', async () => {
setStoredAccessToken('details-reason-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 503,
body: JSON.stringify({
ok: false,
data: null,
error: {
code: 'SERVICE_UNAVAILABLE',
message: '服务暂不可用',
details: {
provider: 'vector-engine',
reason: 'VECTOR_ENGINE_API_KEY 未配置',
},
},
meta: {},
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
await expect(
requestJson(
'/api/creation/match3d/sessions/test/actions',
{
method: 'POST',
},
'执行抓大鹅共创操作失败',
),
).rejects.toMatchObject({
message: 'VECTOR_ENGINE_API_KEY 未配置',
status: 503,
code: 'SERVICE_UNAVAILABLE',
details: {
provider: 'vector-engine',
},
});
});
});

View File

@@ -0,0 +1,50 @@
import { beforeEach, expect, test, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
vi.mock('../apiClient', () => ({
fetchWithApiAuth: vi.fn(),
requestJson: requestJsonMock,
}));
import { createCreationAgentClient } from './creationAgentClientFactory';
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ session: { sessionId: 'session-1' } });
});
test('creation agent action requests are not auto-retried by default', async () => {
const client = createCreationAgentClient<
Record<string, never>,
{ session: { sessionId: string } },
{ session: { sessionId: string } },
{ sessionId: string },
{ text: string },
{ session: { sessionId: string } },
{ action: string },
{ session: { sessionId: string } }
>({
apiBase: '/api/runtime/puzzle/agent/sessions',
messages: {
createSession: '创建失败',
getSession: '读取失败',
sendMessage: '发送失败',
streamIncomplete: '流式结果不完整',
executeAction: '执行失败',
},
});
await client.executeAction('session-1', { action: 'compile_puzzle_draft' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/puzzle/agent/sessions/session-1/actions',
expect.objectContaining({ method: 'POST' }),
'执行失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 0 }),
}),
);
});

View File

@@ -22,6 +22,7 @@ type CreationAgentClientOptions = {
executeActionTimeoutMs?: number;
readRetry?: ApiRetryOptions;
writeRetry?: ApiRetryOptions;
executeActionRetry?: ApiRetryOptions;
};
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
@@ -37,6 +38,10 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
retryUnsafeMethods: true,
};
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
function buildJsonPostInit(payload: unknown): RequestInit {
return {
method: 'POST',
@@ -88,6 +93,7 @@ export function createCreationAgentClient<
executeActionTimeoutMs,
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
executeActionRetry = DEFAULT_CREATION_AGENT_ACTION_RETRY,
}: CreationAgentClientOptions) {
const createSession = (
payload: TCreateSessionPayload,
@@ -153,7 +159,7 @@ export function createCreationAgentClient<
buildJsonPostInit(payload),
messages.executeAction,
{
retry: writeRetry,
retry: executeActionRetry,
timeoutMs: executeActionTimeoutMs,
},
);

View File

@@ -4,7 +4,10 @@ import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
import {
clearMatch3DGeneratedModelBytesCache,
getMatch3DGeneratedImageViewSources,
getMatch3DGeneratedImageAssetSources,
getMatch3DGeneratedModelAssetSources,
hasMatch3DGeneratedImageAsset,
preloadMatch3DGeneratedImageAssets,
preloadMatch3DGeneratedModelAssets,
readMatch3DGeneratedModelBytes,
} from './match3dGeneratedModelCache';
@@ -145,4 +148,119 @@ describe('match3dGeneratedModelCache', () => {
false,
);
});
test('运行态图片素材判断只认物品图片,不把背景或音频当物品素材', () => {
expect(
hasMatch3DGeneratedImageAsset([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc:
'/generated-match3d-assets/session/profile/audio/background.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-13T10:00:00.000Z',
},
backgroundAsset: {
prompt: '果园背景',
imageSrc:
'/generated-match3d-assets/session/profile/background/background.png',
imageObjectKey: null,
containerPrompt: '果园浅盘',
containerImageSrc:
'/generated-match3d-assets/session/profile/ui-container/container.png',
containerImageObjectKey: null,
status: 'image_ready',
error: null,
},
},
]),
).toBe(false);
expect(
hasMatch3DGeneratedImageAsset([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc:
'/generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
imageObjectKey: null,
},
],
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
]),
).toBe(true);
});
test('运行态预加载使用 2D 图片源而不是旧模型源', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/view-01.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
const assets = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
},
],
modelSrc:
'/generated-match3d-assets/session/profile/items/item-1/model/model.glb',
modelObjectKey: null,
status: 'image_ready',
},
];
expect(getMatch3DGeneratedImageAssetSources(assets)).toEqual([
'generated-match3d-assets/session/profile/items/item-1/views/view-01.png',
]);
await preloadMatch3DGeneratedImageAssets(assets, { expireSeconds: 300 });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-url',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'views%2Fview-01.png',
);
});
});

View File

@@ -1,5 +1,5 @@
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
import { readAssetBytes } from './assetReadUrlService';
import { readAssetBytes, resolveAssetReadUrl } from './assetReadUrlService';
type CachedMatch3DModelBytes = {
accessedAt: number;
@@ -117,6 +117,14 @@ export function getMatch3DGeneratedImageAssetSources(
];
}
export function hasMatch3DGeneratedImageAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return Boolean(
assets?.some((asset) => getMatch3DGeneratedImageViewSources(asset).length > 0),
);
}
export function getMatch3DGeneratedModelAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
) {
@@ -198,6 +206,28 @@ export function preloadMatch3DGeneratedModelAssets(
);
}
export async function preloadMatch3DGeneratedImageAssets(
assets: readonly Match3DGeneratedItemAsset[] = [],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
const sources = getMatch3DGeneratedImageAssetSources(assets);
await Promise.allSettled(
sources.map((source) =>
resolveAssetReadUrl(source, {
expireSeconds: options.expireSeconds,
}),
),
);
}
export async function preloadMatch3DGeneratedRuntimeAssets(
assets: readonly Match3DGeneratedItemAsset[] = [],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
// 中文注释:新抓大鹅运行态以 2D 图片为主3D 模型只作为历史草稿预览兼容。
await preloadMatch3DGeneratedImageAssets(assets, options);
}
export function clearMatch3DGeneratedModelBytesCache() {
match3dModelBytesCache.clear();
}

View File

@@ -20,23 +20,26 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
const progress = buildMiniGameDraftGenerationProgress(state, 2500);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成首关画面',
'生成背景音乐',
'生成UI背景',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'理解画面描述,生成首关名称与可编辑草稿。',
'读取画面描述,建立可编辑草稿与首关结构。',
);
expect(progress?.estimatedRemainingMs).toBe(59_500);
expect(progress?.estimatedRemainingMs).toBe(178_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
test('puzzle draft generation advances steps across the 60 second estimate', () => {
test('puzzle draft generation advances steps across the current asset pipeline', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
@@ -46,18 +49,23 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 16_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 56_000);
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const musicProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 146_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 176_000);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(45_000);
expect(imageProgress?.steps[0]?.status).toBe('completed');
expect(imageProgress?.steps[1]?.status).toBe('active');
expect(imageProgress?.steps[1]?.completed).toBeGreaterThan(0);
expect(imageProgress?.estimatedRemainingMs).toBe(155_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(musicProgress?.phaseId).toBe('puzzle-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[1]?.status).toBe('completed');
expect(writeBackProgress?.steps[2]?.status).toBe('active');
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
expect(writeBackProgress?.steps[5]?.status).toBe('active');
});
test('puzzle draft generation keeps moving without claiming completion before response', () => {
@@ -70,12 +78,12 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 80_000);
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);
expect(progress?.estimatedRemainingMs).toBe(0);
expect(progress?.steps[2]?.completed).toBe(1);
expect(progress?.steps[5]?.completed).toBe(1);
});
test('puzzle ready copy points to result page work info completion', () => {
@@ -158,20 +166,24 @@ describe('miniGameDraftGenerationProgress', () => {
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 17_000,
state.startedAtMs + 30_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-work-title',
'match3d-item-names',
'match3d-background-prompt',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-views',
'match3d-background-music',
'match3d-background-image',
'match3d-write-draft',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
expect(progress?.estimatedRemainingMs).toBe(583_000);
expect(progress?.phaseLabel).toBe('分批生成素材图');
expect(progress?.estimatedRemainingMs).toBe(570_000);
});
test('match3d draft generation starts from title generation', () => {
@@ -183,8 +195,10 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.phaseId).toBe('match3d-work-title');
expect(progress?.phaseLabel).toBe('生成游戏名称');
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
expect(progress?.phaseLabel).toBe('建立草稿存档');
expect(progress?.steps[0]?.detail).toBe(
'创建可恢复作品草稿,锁定本次题材和难度。',
);
});
test('match3d draft generation keeps backend observed asset phase', () => {
@@ -201,9 +215,33 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps.at(-1)?.detail).toContain('点击音效');
expect(progress?.steps.at(-1)?.completed).toBe(1);
expect(progress?.steps.at(-1)?.total).toBe(3);
expect(progress?.steps[6]?.detail).toContain('音效提示词');
expect(progress?.steps[6]?.completed).toBe(1);
expect(progress?.steps[6]?.total).toBe(3);
});
test('match3d draft generation reaches music, background image and writeback phases', () => {
const state = createMiniGameDraftGenerationState('match3d');
const musicProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 400_000,
);
const backgroundProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 500_000,
);
const writeProgress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 550_000,
);
expect(musicProgress?.phaseId).toBe('match3d-background-music');
expect(musicProgress?.phaseLabel).toBe('生成背景音乐');
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
});
test('match3d generation anchors show theme and difficulty item count', () => {
@@ -223,7 +261,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'match3d-items',
label: '物品数量',
value: '21 件',
value: '25 件',
},
]);
});

View File

@@ -28,6 +28,7 @@ export type MiniGameDraftGenerationKind =
export type MiniGameDraftGenerationPhase =
| 'idle'
| 'compile'
| 'puzzle-level-name'
| 'big-fish-draft'
| 'big-fish-levels'
| 'big-fish-runtime'
@@ -37,15 +38,21 @@ export type MiniGameDraftGenerationPhase =
| 'square-hole-ready'
| 'match3d-work-title'
| 'match3d-item-names'
| 'match3d-background-prompt'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-views'
| 'match3d-background-music'
| 'match3d-background-image'
| 'match3d-write-draft'
| 'match3d-ready'
| 'baby-object-draft'
| 'baby-object-images'
| 'baby-object-ready'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
| 'ready'
| 'failed';
@@ -76,35 +83,61 @@ const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译首关草稿',
detail: '理解画面描述,生成首关名称与可编辑草稿。',
weight: 20,
detail: '读取画面描述,建立可编辑草稿与首关结构。',
weight: 10,
},
{
id: 'puzzle-level-name',
label: '生成关卡名称',
detail: '根据画面描述和图像语义整理首关题目。',
weight: 8,
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 70,
weight: 42,
},
{
id: 'puzzle-background-music',
label: '生成背景音乐',
detail: '用作品题目生成纯音乐并转存音频资产。',
weight: 18,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 14,
},
{
id: 'puzzle-select-image',
label: '写入正式草稿',
detail: '确认首图并同步关卡数据,准备进入结果页。',
weight: 10,
detail: '写入首图、音乐、UI背景和首关数据。',
weight: 8,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 60_000;
const PUZZLE_ESTIMATED_WAIT_MS = 180_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const PUZZLE_PHASE_TIMELINE: Array<{
phase: Extract<
MiniGameDraftGenerationPhase,
'compile' | 'puzzle-images' | 'puzzle-select-image'
| 'compile'
| 'puzzle-level-name'
| 'puzzle-images'
| 'puzzle-background-music'
| 'puzzle-ui-background'
| 'puzzle-select-image'
>;
durationMs: number;
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-images', durationMs: 42_000 },
{ phase: 'puzzle-select-image', durationMs: 6_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-background-music', durationMs: 48_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];
const BIG_FISH_STEPS = [
@@ -152,39 +185,63 @@ const SQUARE_HOLE_STEPS = [
const MATCH3D_STEPS = [
{
id: 'match3d-work-title',
label: '生成游戏名称',
detail: '根据题材设定生成作品名称与标签。',
label: '建立草稿存档',
detail: '创建可恢复作品草稿,锁定本次题材和难度。',
weight: 8,
},
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据难度生成本局物品名称。',
weight: 8,
label: '生成作品计划',
detail: '生成游戏名称、物品名称、音乐名称与标签。',
weight: 10,
},
{
id: 'match3d-background-prompt',
label: '生成背景提示词',
detail: '整理纯背景图与容器 UI 图提示词。',
weight: 6,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
label: '分批生成素材图',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 18,
weight: 22,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成每个物品的五个视角。',
weight: 8,
weight: 10,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入独立 2D 视角素材。',
weight: 8,
detail: '上传每个物品的 2D 视角素材。',
weight: 12,
},
{
id: 'match3d-generate-views',
label: '整理素材',
detail: '校验多视角素材并按需并行生成点击音效。',
weight: 50,
label: '校验素材结构',
detail: '确认物品顺序、五视角图片和音效提示词。',
weight: 6,
},
{
id: 'match3d-background-music',
label: '生成背景音乐',
detail: '用音乐名称生成纯音乐并转存音频资产。',
weight: 14,
},
{
id: 'match3d-background-image',
label: '生成UI背景',
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
weight: 16,
},
{
id: 'match3d-write-draft',
label: '写入草稿页',
detail: '保存素材、音乐、背景、容器和作品草稿。',
weight: 2,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -193,10 +250,14 @@ const MATCH3D_PHASE_ORDER: Partial<
> = {
'match3d-work-title': 0,
'match3d-item-names': 1,
'match3d-material-sheet': 2,
'match3d-slice-images': 3,
'match3d-upload-images': 4,
'match3d-generate-views': 5,
'match3d-background-prompt': 2,
'match3d-material-sheet': 3,
'match3d-slice-images': 4,
'match3d-upload-images': 5,
'match3d-generate-views': 6,
'match3d-background-music': 7,
'match3d-background-image': 8,
'match3d-write-draft': 9,
};
const BABY_OBJECT_MATCH_STEPS = [
@@ -331,17 +392,25 @@ function resolveMatch3DPhaseByElapsedMs(
currentPhase: MiniGameDraftGenerationPhase,
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 92_000
? 'match3d-generate-views'
: elapsedMs >= 72_000
? 'match3d-upload-images'
: elapsedMs >= 58_000
? 'match3d-slice-images'
: elapsedMs >= 16_000
? 'match3d-material-sheet'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
elapsedMs >= 540_000
? 'match3d-write-draft'
: elapsedMs >= 460_000
? 'match3d-background-image'
: elapsedMs >= 370_000
? 'match3d-background-music'
: elapsedMs >= 340_000
? 'match3d-generate-views'
: elapsedMs >= 260_000
? 'match3d-upload-images'
: elapsedMs >= 210_000
? 'match3d-slice-images'
: elapsedMs >= 28_000
? 'match3d-material-sheet'
: elapsedMs >= 12_000
? 'match3d-background-prompt'
: elapsedMs >= 4_000
? 'match3d-item-names'
: 'match3d-work-title';
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
@@ -645,18 +714,19 @@ function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
) {
if (clearCount === 8) return 3;
if (clearCount === 12) return 9;
if (clearCount === 16) return 15;
if (clearCount === 20 || clearCount === 21) return 21;
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
if (clearCount === 8) return roundToSheet(3);
if (clearCount === 12) return roundToSheet(9);
if (clearCount === 16) return roundToSheet(15);
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
const normalizedDifficulty =
typeof difficulty === 'number' && Number.isFinite(difficulty)
? Math.max(1, Math.min(10, Math.round(difficulty)))
: 4;
if (normalizedDifficulty <= 2) return 3;
if (normalizedDifficulty <= 4) return 9;
if (normalizedDifficulty <= 6) return 15;
return 21;
if (normalizedDifficulty <= 2) return roundToSheet(3);
if (normalizedDifficulty <= 4) return roundToSheet(9);
if (normalizedDifficulty <= 6) return roundToSheet(15);
return roundToSheet(21);
}
export function buildBabyObjectMatchGenerationAnchorEntries(

View File

@@ -535,6 +535,45 @@ describe('puzzleLocalRuntime', () => {
).toBe('explicit-level');
});
test('本地试玩继承关卡 UI 背景和背景音乐资源', () => {
const workWithRuntimeAssets: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
backgroundMusic: {
taskId: 'audio-task-1',
provider: 'vector-engine',
assetObjectId: 'asset-audio-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio.mp3',
prompt: '雨夜猫街音乐',
title: '雨夜猫街',
updatedAt: '2026-05-12T00:00:00.000Z',
},
generationStatus: 'ready',
},
],
};
const run = startLocalPuzzleRun(workWithRuntimeAssets);
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(run.currentLevel?.backgroundMusic?.audioSrc).toBe(
'/generated-puzzle-assets/session/audio.mp3',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -802,6 +802,10 @@ function buildFallbackLocalLevel(
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
nextLevel?.uiBackgroundImageSrc ?? currentLevel.uiBackgroundImageSrc;
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
const nextRun: PuzzleRunSnapshot = {
...run,
@@ -830,6 +834,8 @@ function buildFallbackLocalLevel(
clearedAtMs: null,
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
},
@@ -854,6 +860,8 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = firstLevel?.uiBackgroundImageSrc ?? null;
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
runId,
@@ -873,6 +881,8 @@ export function startLocalPuzzleRun(
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',
startedAtMs,