diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5d239741..8536eea5 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -39,6 +39,15 @@ - 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 - 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck`、`npm run check:encoding` 通过。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 2026-05-26 生成页总进度圆弧逆时针回调 5 度 + +- 背景:创作生成页的总进度圆弧在 `160deg` 位置仍需轻微向左微调,用户要求向左逆时针回调 `5deg`。 +- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角从 `160deg` 调整为 `155deg`,track 和 fill 都使用同一个 `rotate(155 200 200)` 变换;仍保持 `270deg` 扫描角和正下方 `90deg` 留空。 +- 决策:总进度标题与百分比数字在 `GenerationProgressHero` 中显式提升到圆环之上,圆环 SVG 维持背景层级。 +- 决策:总进度标题与百分比数字的内容区上边距从 `pt-[4%]` 收紧到 `pt-[2%]`,桌面端使用 `sm:pt-[1.5%]`,进一步拉开与圆环弧线的距离。 +- 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。 +- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=155` 且 track / fill transform 都是 `rotate(155 200 200)`。 +- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 ## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态 @@ -996,6 +1005,14 @@ - 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分 + +- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。 +- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。 +- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 + # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 - 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。 @@ -1047,3 +1064,10 @@ - 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。 - 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径 + +- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。 +- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。 +- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 +- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 538880eb..acec81b3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1138,6 +1138,22 @@ - 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`。 - 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。 +## 拼图发布检查阶段会在事件落库时炸 wasm + +- 现象:拼图发布在“发布检查”环节直接报 `The module instance encountered a fatal error`,wasm backtrace 指向 `spacetime_module::puzzle::publish_puzzle_work`,并停在 `procedure_commit_mut_tx` 的 commit 阶段。 +- 原因:`publish_puzzle_work_tx` 会无条件调用 `emit_puzzle_work_published_event` 写入 `puzzle_event`;该表的 `event_id` 是主键,而事件 ID 由 `profile_id + published_at_micros` 组成。只要同一发布动作被重复执行、重放,或极端情况下发生时间戳碰撞,commit 时就会因主键冲突触发 fatal error。 +- 处理:待修复。发布事件写入需要改成幂等,或在重复发布时显式跳过已存在的 `event_id`;发布动作本身也应补一层更明确的幂等键,避免把重复提交直接推到事务提交阶段。 +- 验证:对同一 `session_id/profile_id/published_at_micros` 重复调用 `publish_puzzle_work` 时,不应再在 commit 阶段炸 wasm;正常发布仍应生成作品、更新 session,并可进入公开详情。 +- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/spacetime-client/src/module_bindings/puzzle_event_table.rs`。 + +## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 + +- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 +- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 +- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。 +- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 + ## WebGL 画布在高 DPR 移动端放大溢出 - 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。 @@ -1508,6 +1524,14 @@ - 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 跳一跳地块图集不要套通用系列素材 n 行模型 + +- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。 +- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。 +- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。 +- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## image2 dry-run 带参考图时不要直接打印 data URL - 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。 diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md index 238a200b..fa2c56f6 100644 --- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md +++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md @@ -276,6 +276,7 @@ HTTP 路由: POST /api/creation/wooden-fish/sessions GET /api/creation/wooden-fish/sessions/{sessionId} POST /api/creation/wooden-fish/sessions/{sessionId}/actions +GET /api/creation/wooden-fish/works GET /api/creation/wooden-fish/works/{profileId} POST /api/creation/wooden-fish/works/{profileId}/publish GET /api/runtime/wooden-fish/works/{profileId} @@ -304,6 +305,8 @@ finish 敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession` 或 `executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮三次 image2 和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。本地测试中该 action 可能达到数分钟级;生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,不展示“提示词生成音效”阶段,因为当前木鱼音效只支持上传、录音或默认音。 +作品架使用 `GET /api/creation/wooden-fish/works` 读取当前用户草稿和已发布摘要,前端发布成功后必须刷新该列表和 `GET /api/runtime/wooden-fish/gallery` 公开列表,使刚发布作品立即出现在草稿 Tab 的已发布筛选和推荐 / 最新流中。 + ## 9. SpacetimeDB 表和 view 新增表: diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 63af3568..a3ab635a 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -168,7 +168,7 @@ jump-hop-gallery-detail ### 6.2 地块只生一次图集 -地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。 +地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。 地块图集要求: @@ -176,17 +176,24 @@ jump-hop-gallery-detail 2. 必须表现出顶面、侧面和投影; 3. 必须与角色图保持同一光向; 4. 必须有清晰的立体层次,但仍然是 2D 图片; -5. 必须包含至少以下地块类型: +5. 六格必须按固定顺序包含以下地块类型: - 起点地块; - 普通地块; - 目标地块; - - 终点地块。 + - 终点地块; + - 奖励地块; + - 视觉强调地块。 -建议额外包含: +固定格位为: -1. 奖励地块; -2. 视觉强调地块; -3. 风格化变体地块。 +| 格位 | tileType | 语义 | +| --- | --- | --- | +| 第 1 行第 1 列 | `start` | 起点地块 | +| 第 1 行第 2 列 | `normal` | 普通地块 | +| 第 1 行第 3 列 | `target` | 目标地块 | +| 第 2 行第 1 列 | `finish` | 终点地块 | +| 第 2 行第 2 列 | `bonus` | 奖励地块 | +| 第 2 行第 3 列 | `accent` | 视觉强调地块 | 图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4eeca38c..6092eeb5 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -48,6 +48,7 @@ 6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。 7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。 8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 @@ -131,7 +132,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 1. 初始草稿生成时,角色形象单独调用一次生图; 2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集; -3. 地块图集由后端切分为起点、普通、目标、终点等透明 PNG; +3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG; 4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图; 5. 显式重生成角色或地块时,只重生成对应资产槽位。 diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md index 8b5730a9..ef97e819 100644 --- a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md +++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md @@ -13,6 +13,9 @@ - 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。 - 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。 - 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。 +- 圆弧描边以圆心为中心整体按 `155deg` 起始;在当前 SVG 坐标系下,这相对 `160deg` 会向左逆时针回调 `5deg`。track 和 fill 都必须共用同一个 `rotate(155 200 200)` 变换,避免只改视觉起点却让填充和轨道错位。 +- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。 +- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。 - 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。 - 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。 - 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。 diff --git a/packages/shared/src/contracts/woodenFish.ts b/packages/shared/src/contracts/woodenFish.ts index ffb2729e..040866f8 100644 --- a/packages/shared/src/contracts/woodenFish.ts +++ b/packages/shared/src/contracts/woodenFish.ts @@ -134,6 +134,10 @@ export interface WoodenFishWorkProfileResponse { floatingWords: string[]; } +export interface WoodenFishWorksResponse { + items: WoodenFishWorkSummaryResponse[]; +} + export interface WoodenFishWorkDetailResponse { item: WoodenFishWorkProfileResponse; } diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 30a71516..2339e842 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -29,8 +29,7 @@ use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_asset_sheets::{ - GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt, - slice_generated_asset_sheet, + apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -56,6 +55,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; +const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2; +const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct JumpHopTileAtlasSlice { + tile_type: JumpHopTileType, + source_atlas_cell: String, + bytes: Vec, +} pub async fn create_jump_hop_session( State(state): State, @@ -379,7 +387,7 @@ pub async fn get_jump_hop_gallery_detail( async fn maybe_generate_jump_hop_assets( state: &AppState, request_context: &RequestContext, - session_id: &str, + _session_id: &str, owner_user_id: &str, payload: &mut JumpHopActionRequest, ) -> Result<(), Response> { @@ -457,21 +465,7 @@ async fn maybe_generate_jump_hop_assets( ) .await?; - let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { - subject_text: tile_prompt, - item_names: &vec![ - "start".to_string(), - "normal".to_string(), - "target".to_string(), - "finish".to_string(), - "bonus".to_string(), - "accent".to_string(), - ], - grid_size: 3, - item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"), - special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"), - }) - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt); let tile_generated = create_openai_image_generation( &http_client, &settings, @@ -494,19 +488,9 @@ async fn maybe_generate_jump_hop_assets( })), ) })?; - let tile_slices = slice_generated_asset_sheet( - &tile_image, - &vec![ - "start".to_string(), - "normal".to_string(), - "target".to_string(), - "finish".to_string(), - "bonus".to_string(), - "accent".to_string(), - ], - 3, - ) - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; + let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; let tile_atlas_asset = persist_jump_hop_generated_image_asset( state, owner_user_id, @@ -520,28 +504,20 @@ async fn maybe_generate_jump_hop_assets( request_context, ) .await?; - let tile_assets = tile_slices - .into_iter() - .enumerate() - .map(|(index, row)| JumpHopTileAsset { - tile_type: match index { - 0 => JumpHopTileType::Start, - 1 => JumpHopTileType::Normal, - 2 => JumpHopTileType::Target, - 3 => JumpHopTileType::Finish, - 4 => JumpHopTileType::Bonus, - _ => JumpHopTileType::Accent, - }, - image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"), - asset_object_id: format!("{profile_id}-tile-{index}-object"), - source_atlas_cell: format!("cell-{index}"), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, - }) - .collect::>(); + let mut tile_assets = Vec::with_capacity(tile_slices.len()); + for (index, tile_slice) in tile_slices.into_iter().enumerate() { + tile_assets.push( + persist_jump_hop_tile_asset( + state, + owner_user_id, + profile_id.as_str(), + index, + tile_slice, + request_context, + ) + .await?, + ); + } payload.character_asset = Some(character_asset); payload.tile_atlas_asset = Some(tile_atlas_asset); payload.tile_assets = Some(tile_assets); @@ -553,6 +529,153 @@ async fn maybe_generate_jump_hop_assets( Ok(()) } +fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String { + let subject_text = tile_prompt.trim(); + let subject_text = if subject_text.is_empty() { + "等距立体地块图集" + } else { + subject_text + }; + let cell_plan = [ + "第1行第1列:start 起点地块", + "第1行第2列:normal 普通地块", + "第1行第3列:target 目标地块", + "第2行第1列:finish 终点地块", + "第2行第2列:bonus 奖励地块", + "第2行第3列:accent 视觉强调地块", + ] + .join(";"); + + format!( + "生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。" + ) +} + +fn slice_jump_hop_tile_atlas( + image: &crate::openai_image_generation::DownloadedOpenAiImage, +) -> Result, AppError> { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地块图集解码失败:{error}"), + })) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + let width = source.width(); + let height = source.height(); + let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS; + if cell_width == 0 || cell_height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": "跳一跳地块图集尺寸过小,无法切割。", + })), + ); + } + + let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len()); + for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() { + let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; + let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; + let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; + let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; + let cropped = source.crop_imm( + x0, + y0, + x1.saturating_sub(x0).max(1), + y1.saturating_sub(y0).max(1), + ); + let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地块图集切割失败:{error}"), + })) + })?; + slices.push(JumpHopTileAtlasSlice { + tile_type: jump_hop_tile_type_by_index(index), + source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), + bytes: cursor.into_inner(), + }); + } + + Ok(slices) +} + +fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { + match index { + 0 => JumpHopTileType::Start, + 1 => JumpHopTileType::Normal, + 2 => JumpHopTileType::Target, + 3 => JumpHopTileType::Finish, + 4 => JumpHopTileType::Bonus, + _ => JumpHopTileType::Accent, + } +} + +fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str { + match tile_type { + JumpHopTileType::Start => "tile-start", + JumpHopTileType::Normal => "tile-normal", + JumpHopTileType::Target => "tile-target", + JumpHopTileType::Finish => "tile-finish", + JumpHopTileType::Bonus => "tile-bonus", + JumpHopTileType::Accent => "tile-accent", + } +} + +#[allow(clippy::too_many_arguments)] +async fn persist_jump_hop_tile_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + tile_index: usize, + tile_slice: JumpHopTileAtlasSlice, + request_context: &RequestContext, +) -> Result { + let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type); + let image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: tile_slice.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let persisted = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id, + slot, + &format!( + "跳一跳地块切片 {}:{}", + tile_index + 1, + tile_slice.source_atlas_cell + ), + image, + LegacyAssetPrefix::JumpHopAssets, + 256, + 192, + request_context, + ) + .await?; + + Ok(JumpHopTileAsset { + tile_type: tile_slice.tile_type, + image_src: persisted.image_src, + image_object_key: persisted.image_object_key, + asset_object_id: persisted.asset_object_id, + source_atlas_cell: tile_slice.source_atlas_cell, + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }) +} + async fn persist_jump_hop_generated_image_asset( state: &AppState, owner_user_id: &str, @@ -882,3 +1005,71 @@ fn current_utc_micros() -> i64 { .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() { + let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块"); + + assert!(prompt.contains("2行*3列")); + assert!(prompt.contains("第1行第1列:start 起点地块")); + assert!(prompt.contains("第2行第3列:accent 视觉强调地块")); + assert!(!prompt.contains("每个物品生成")); + assert!(!prompt.contains("不同视图")); + } + + #[test] + fn jump_hop_tile_atlas_slices_one_png_per_tile_type() { + let width = 300; + let height = 200; + let colors = [ + [220, 24, 24, 255], + [240, 150, 32, 255], + [248, 220, 72, 255], + [52, 168, 84, 255], + [38, 132, 255, 255], + [156, 92, 220, 255], + ]; + let mut atlas = image::RgbaImage::new(width, height); + for row in 0..2 { + for col in 0..3 { + let color = image::Rgba(colors[row * 3 + col]); + for y in row as u32 * 100..(row as u32 + 1) * 100 { + for x in col as u32 * 100..(col as u32 + 1) * 100 { + atlas.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(atlas) + .write_to(&mut encoded, image::ImageFormat::Png) + .expect("atlas should encode"); + let image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); + + assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len()); + for (index, slice) in slices.iter().enumerate() { + assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); + assert_eq!( + slice.source_atlas_cell, + format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1) + ); + let decoded = image::load_from_memory(slice.bytes.as_slice()) + .expect("tile slice should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel.0 == colors[index]), + "第 {index} 个地块切片应保留对应格子的主体颜色" + ); + } + } +} diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs index daef33ad..556c31b0 100644 --- a/server-rs/crates/api-server/src/modules/wooden_fish.rs +++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs @@ -9,8 +9,8 @@ use crate::{ wooden_fish::{ checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work, - get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work, - start_wooden_fish_run, + get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works, + publish_wooden_fish_work, start_wooden_fish_run, }, }; @@ -37,6 +37,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/wooden-fish/works", + get(list_wooden_fish_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/wooden-fish/works/{profile_id}/publish", post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index ae738867..7763ea0e 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -21,6 +21,7 @@ use shared_contracts::wooden_fish::{ WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse, WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest, WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest, + WoodenFishWorksResponse, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; @@ -193,6 +194,31 @@ pub async fn publish_wooden_fish_work( )) } +pub async fn list_wooden_fish_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let works = state + .spacetime_client() + .list_wooden_fish_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + WoodenFishWorksResponse { + items: works.into_iter().map(|work| work.summary).collect(), + }, + )) +} + pub async fn get_wooden_fish_runtime_work( State(state): State, Path(profile_id): Path, diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs index c7116993..422ea650 100644 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -203,6 +203,12 @@ pub struct WoodenFishWorkProfileResponse { pub floating_words: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WoodenFishWorksResponse { + pub items: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WoodenFishWorkDetailResponse { diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx index 82ee365c..d20da96d 100644 --- a/src/components/CustomWorldGenerationView.test.tsx +++ b/src/components/CustomWorldGenerationView.test.tsx @@ -131,7 +131,10 @@ describe('CustomWorldGenerationView', () => { 'justify-start', ); expect(screen.getByTestId('generation-hero-progress-content').className).toContain( - 'pt-[4%]', + 'z-30', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[2%]', ); expect(screen.getByText('总进度').className).toContain('text-[9px]'); expect(screen.getByText('42%').className).toContain('text-[1.15rem]'); @@ -149,7 +152,7 @@ describe('CustomWorldGenerationView', () => { screen .getByRole('progressbar', { name: progressTitle }) .getAttribute('data-ring-start-degrees'), - ).toBe('225'); + ).toBe('155'); expect( screen .getByRole('progressbar', { name: progressTitle }) @@ -168,6 +171,9 @@ describe('CustomWorldGenerationView', () => { expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( 'svg', ); + expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain( + 'z-0', + ); expect( screen .getByTestId('generation-hero-progress-ring') @@ -183,6 +189,16 @@ describe('CustomWorldGenerationView', () => { .getByTestId('generation-hero-progress-ring-track') .getAttribute('stroke-width'), ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx index 9883d96c..9fa0af3a 100644 --- a/src/components/GenerationProgressHero.tsx +++ b/src/components/GenerationProgressHero.tsx @@ -4,7 +4,7 @@ import { useEffect, useId, useRef } from 'react'; import generationHeroVideo from '../../media/create_bg_video.mp4'; -const GENERATION_PROGRESS_RING_START_DEGREES = 225; +const GENERATION_PROGRESS_RING_START_DEGREES = 155; const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270; const GENERATION_PROGRESS_RING_VIEWBOX = 400; const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2; @@ -173,7 +173,7 @@ export function GenerationProgressHero({ >
-
+
总进度
-
+
{safeProgress}%
diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx index 02af134b..e2d25871 100644 --- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx @@ -116,7 +116,10 @@ describe('BarkBattleGeneratingView', () => { 'justify-start', ); expect(screen.getByTestId('generation-hero-progress-content').className).toContain( - 'pt-[4%]', + 'z-30', + ); + expect(screen.getByTestId('generation-hero-progress-content').className).toContain( + 'pt-[2%]', ); expect(screen.getByText('玩家形象')).toBeTruthy(); expect(screen.getByText('进行中 36%')).toBeTruthy(); @@ -142,7 +145,7 @@ describe('BarkBattleGeneratingView', () => { screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) .getAttribute('data-ring-start-degrees'), - ).toBe('225'); + ).toBe('155'); expect( screen .getByRole('progressbar', { name: '汪汪声浪素材生成进度' }) @@ -161,6 +164,9 @@ describe('BarkBattleGeneratingView', () => { expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe( 'svg', ); + expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain( + 'z-0', + ); expect( screen .getByTestId('generation-hero-progress-ring') @@ -176,6 +182,16 @@ describe('BarkBattleGeneratingView', () => { .getByTestId('generation-hero-progress-ring-track') .getAttribute('stroke-width'), ).toBe('18'); + expect( + screen + .getByTestId('generation-hero-progress-ring-track') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); + expect( + screen + .getByTestId('generation-hero-progress-ring-fill') + .getAttribute('transform'), + ).toBe('rotate(155 200 200)'); expect( screen .getByTestId('generation-hero-progress-ring-fill') diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 12520cf8..857f9f48 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -7,6 +7,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; @@ -64,6 +65,9 @@ type CustomWorldCreationHubProps = { jumpHopItems?: JumpHopWorkSummaryResponse[]; onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; + woodenFishItems?: WoodenFishWorkSummaryResponse[]; + onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null; + onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; @@ -174,6 +178,9 @@ export function CustomWorldCreationHub({ jumpHopItems = [], onOpenJumpHopDetail, onDeleteJumpHop = null, + woodenFishItems = [], + onOpenWoodenFishDetail = null, + onDeleteWoodenFish = null, puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, @@ -207,6 +214,7 @@ export function CustomWorldCreationHub({ match3dItems, squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], jumpHopItems, + woodenFishItems, puzzleItems, babyObjectMatchItems, barkBattleItems, @@ -217,6 +225,7 @@ export function CustomWorldCreationHub({ canDeleteSquareHole: isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), canDeleteJumpHop: Boolean(onDeleteJumpHop), + canDeleteWoodenFish: Boolean(onDeleteWoodenFish), canDeletePuzzle: Boolean(onDeletePuzzle), canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), canDeleteBarkBattle: Boolean(onDeleteBarkBattle), @@ -232,6 +241,8 @@ export function CustomWorldCreationHub({ onDeleteSquareHole: onDeleteSquareHole ?? undefined, onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined, onDeleteJumpHop: onDeleteJumpHop ?? undefined, + onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined, + onDeleteWoodenFish: onDeleteWoodenFish ?? undefined, onOpenPuzzleDetail, onDeletePuzzle: onDeletePuzzle ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, @@ -259,6 +270,7 @@ export function CustomWorldCreationHub({ onDeleteBarkBattle, onDeleteVisualNovel, onDeleteJumpHop, + onDeleteWoodenFish, onClaimPuzzlePointIncentive, onOpenBigFishDetail, onOpenDraft, @@ -268,6 +280,7 @@ export function CustomWorldCreationHub({ onOpenPuzzleDetail, onOpenSquareHoleDetail, onOpenVisualNovelDetail, + onOpenWoodenFishDetail, onEnterPublished, getWorkState, puzzleItems, @@ -275,6 +288,7 @@ export function CustomWorldCreationHub({ onOpenSquareHoleDetail, onOpenJumpHopDetail, jumpHopItems, + woodenFishItems, visualNovelItems, ], ); @@ -325,6 +339,9 @@ export function CustomWorldCreationHub({ case 'jump-hop': onOpenJumpHopDetail?.(item.source.item); return; + case 'wooden-fish': + onOpenWoodenFishDetail?.(item.source.item); + return; case 'rpg': if (item.status === 'draft') { onOpenDraft(item.source.item); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 24fde099..392282c4 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -60,6 +60,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record = match3d: '/creation-type-references/match3d.webp', 'square-hole': '/creation-type-references/square-hole.webp', 'jump-hop': '/creation-type-references/jump-hop.webp', + 'wooden-fish': '/wooden-fish/default-hit-object.png', puzzle: '/creation-type-references/puzzle.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp', 'bark-battle': '/creation-type-references/bark-battle.webp', diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 1db7d6d6..6388732a 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -56,6 +56,47 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code', expect(items[1]?.publicWorkCode).toBeNull(); }); +test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => { + const onOpenWoodenFishDetail = vi.fn(); + const woodenFishWork = { + runtimeKind: 'wooden-fish' as const, + workId: 'wooden-fish-work-1', + profileId: 'wooden-fish-profile-12345678', + ownerUserId: 'user-1', + sourceSessionId: 'wooden-fish-session-1', + workTitle: '苹果敲木鱼', + workDescription: '苹果主题木鱼。', + themeTags: ['苹果', '休闲'], + coverImageSrc: '/wooden-fish/apple-cover.png', + publicationStatus: 'published', + playCount: 9, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + publishReady: true, + generationStatus: 'ready' as const, + }; + + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [], + woodenFishItems: [woodenFishWork], + onOpenWoodenFishDetail, + }); + + items[0]?.actions.open(); + + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe('wooden-fish'); + expect(items[0]?.status).toBe('published'); + expect(items[0]?.publicWorkCode).toBe('WF-12345678'); + expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678'); + expect(items[0]?.openActionLabel).toBe('查看详情'); + expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true); + expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9); + expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork); +}); + test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e1fecab8..e44022a2 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -8,6 +8,7 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, @@ -19,6 +20,7 @@ import { buildPuzzlePublicWorkCode, buildSquareHolePublicWorkCode, buildVisualNovelPublicWorkCode, + buildWoodenFishPublicWorkCode, } from '../../services/publicWorkCode'; import type { CustomWorldProfile } from '../../types'; @@ -34,6 +36,7 @@ export type CreationWorkShelfKind = | 'match3d' | 'square-hole' | 'jump-hop' + | 'wooden-fish' | 'puzzle' | 'baby-object-match' | 'bark-battle' @@ -90,6 +93,10 @@ export type CreationWorkShelfSource = kind: 'jump-hop'; item: JumpHopWorkSummaryResponse; } + | { + kind: 'wooden-fish'; + item: WoodenFishWorkSummaryResponse; + } | { kind: 'puzzle'; item: PuzzleWorkSummary; @@ -145,6 +152,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems?: Match3DWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[]; jumpHopItems?: JumpHopWorkSummaryResponse[]; + woodenFishItems?: WoodenFishWorkSummaryResponse[]; puzzleItems: PuzzleWorkSummary[]; babyObjectMatchItems?: BabyObjectMatchDraft[]; barkBattleItems?: BarkBattleWorkSummary[]; @@ -154,6 +162,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteMatch3D?: boolean; canDeleteSquareHole?: boolean; canDeleteJumpHop?: boolean; + canDeleteWoodenFish?: boolean; canDeletePuzzle?: boolean; canDeleteBabyObjectMatch?: boolean; canDeleteBarkBattle?: boolean; @@ -169,6 +178,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void; onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void; + onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void; + onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; @@ -189,6 +200,7 @@ export function buildCreationWorkShelfItems(params: { match3dItems = [], squareHoleItems = [], jumpHopItems = [], + woodenFishItems = [], puzzleItems, babyObjectMatchItems = [], barkBattleItems = [], @@ -198,6 +210,7 @@ export function buildCreationWorkShelfItems(params: { canDeleteMatch3D = false, canDeleteSquareHole = false, canDeleteJumpHop = false, + canDeleteWoodenFish = false, canDeletePuzzle = false, canDeleteBabyObjectMatch = false, canDeleteBarkBattle = false, @@ -213,6 +226,8 @@ export function buildCreationWorkShelfItems(params: { onDeleteSquareHole, onOpenJumpHopDetail, onDeleteJumpHop, + onOpenWoodenFishDetail, + onDeleteWoodenFish, onOpenPuzzleDetail, onDeletePuzzle, onClaimPuzzlePointIncentive, @@ -257,6 +272,12 @@ export function buildCreationWorkShelfItems(params: { onDelete: onDeleteJumpHop, }), ), + ...woodenFishItems.map((item) => + mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { + onOpen: onOpenWoodenFishDetail, + onDelete: onDeleteWoodenFish, + }), + ), ...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { onOpen: onOpenPuzzleDetail, @@ -815,6 +836,54 @@ function mapJumpHopWorkToShelfItem( }; } +function mapWoodenFishWorkToShelfItem( + item: WoodenFishWorkSummaryResponse, + canDelete: boolean, + adapter: WorkShelfAdapter, +): CreationWorkShelfItem { + const status = item.publicationStatus === 'published' ? 'published' : 'draft'; + const publicWorkCode = + status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null; + const title = item.workTitle.trim() || '敲木鱼'; + const summary = + item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : ''); + + return { + id: item.workId, + kind: 'wooden-fish', + status, + title, + summary, + authorDisplayName: resolveAuthorDisplayName(item), + updatedAt: item.updatedAt, + coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc), + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + publicWorkCode, + sharePath: + publicWorkCode && status === 'published' + ? buildPublicWorkStagePath('work-detail', publicWorkCode) + : null, + openActionLabel: status === 'published' ? '查看详情' : '继续创作', + canDelete, + canShare: status === 'published' && Boolean(publicWorkCode), + badges: [ + buildStatusBadge(status), + { id: 'type', label: '敲木鱼', tone: 'neutral' }, + ], + metrics: + status === 'published' + ? buildPublishedMetrics({ + playCount: item.playCount, + remixCount: 0, + likeCount: 0, + }) + : [], + actions: buildWorkShelfActions(item, adapter), + source: { kind: 'wooden-fish', item }, + }; +} + function resolveAuthorDisplayName( ...sources: Array @@ -1026,6 +1095,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { return item.source.item.generationStatus === 'generating'; case 'puzzle': return isPersistedPuzzleDraftGenerating(item.source.item); + case 'wooden-fish': + return item.source.item.generationStatus === 'generating'; case 'bark-battle': return isPersistedBarkBattleDraftGenerating(item.source.item); default: diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3409dc1a..48a1278c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -343,6 +343,7 @@ import { type WoodenFishWorkProfileResponse, type WoodenFishWorkspaceCreateRequest, } from '../../services/wooden-fish/woodenFishClient'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PublishShareModal } from '../common/PublishShareModal'; @@ -2375,6 +2376,15 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { item.source.item.workId, item.source.item.draftId, ]); + case 'wooden-fish': + return collectDraftNoticeKeys('wooden-fish', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + default: + return []; } } @@ -3164,6 +3174,9 @@ export function PlatformEntryFlowShellImpl({ >(null); const [woodenFishWork, setWoodenFishWork] = useState(null); + const [woodenFishWorks, setWoodenFishWorks] = useState< + WoodenFishWorkSummaryResponse[] + >([]); const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState< WoodenFishGalleryCardResponse[] >([]); @@ -3911,6 +3924,20 @@ export function PlatformEntryFlowShellImpl({ } }, []); + const refreshWoodenFishShelf = useCallback(async () => { + try { + const worksResponse = await woodenFishClient.listWorks(); + setWoodenFishWorks(worksResponse.items); + return worksResponse.items; + } catch (error) { + setWoodenFishWorks([]); + setWoodenFishError( + resolvePuzzleErrorMessage(error, '读取敲木鱼作品列表失败。'), + ); + return []; + } + }, [resolvePuzzleErrorMessage]); + const refreshPuzzleShelf = useCallback(async () => { setIsPuzzleLoadingLibrary(true); @@ -4481,6 +4508,10 @@ export function PlatformEntryFlowShellImpl({ ], [jumpHopWorks, pendingDraftShelfItems], ); + const woodenFishShelfItems = useMemo( + () => woodenFishWorks, + [woodenFishWorks], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -4563,6 +4594,13 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, ]), ), + ...woodenFishShelfItems.flatMap((item) => + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), ...match3dShelfItems.flatMap((item) => collectDraftNoticeKeys('match3d', [ item.workId, @@ -4606,6 +4644,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleShelfItems, bigFishShelfItems, jumpHopShelfItems, + woodenFishShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -9059,6 +9098,8 @@ export function PlatformEntryFlowShellImpl({ try { const response = await woodenFishClient.publishWork(profileId); setWoodenFishWork(response.item); + void refreshWoodenFishShelf(); + void refreshWoodenFishGallery(); openPublishShareModal({ title: response.item.summary.workTitle || '敲木鱼', publicWorkCode: buildWoodenFishPublicWorkCode( @@ -9076,6 +9117,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ openPublishShareModal, + refreshWoodenFishGallery, + refreshWoodenFishShelf, setSelectionStage, woodenFishWork?.summary.profileId, ]); @@ -11714,6 +11757,48 @@ export function PlatformEntryFlowShellImpl({ [openPublicWorkDetail, setSelectionStage], ); + const openWoodenFishDraft = useCallback( + async (item: WoodenFishWorkSummaryResponse) => { + markDraftNoticeSeen( + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ); + + if (item.publicationStatus === 'published') { + void openWoodenFishPublicWorkDetail(item.profileId); + return; + } + + setWoodenFishError(null); + setPublicWorkDetailError(null); + setIsWoodenFishBusy(true); + try { + const detail = await woodenFishClient.getWorkDetail(item.profileId); + setWoodenFishSession(null); + setWoodenFishRun(null); + setWoodenFishWork(detail.item); + setWoodenFishRuntimeReturnStage('wooden-fish-result'); + enterCreateTab(); + setSelectionStage('wooden-fish-result'); + } catch (error) { + setWoodenFishError( + resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), + ); + } finally { + setIsWoodenFishBusy(false); + } + }, + [ + enterCreateTab, + markDraftNoticeSeen, + openWoodenFishPublicWorkDetail, + setSelectionStage, + ], + ); + const openPublicGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { if (isBigFishGalleryEntry(entry)) { @@ -14582,6 +14667,7 @@ export function PlatformEntryFlowShellImpl({ if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } + void refreshWoodenFishShelf(); void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); } @@ -14594,6 +14680,7 @@ export function PlatformEntryFlowShellImpl({ refreshBarkBattleShelf, refreshMatch3DShelf, refreshPuzzleShelf, + refreshWoodenFishShelf, refreshSquareHoleShelf, refreshVisualNovelShelf, selectionStage, @@ -14688,6 +14775,7 @@ export function PlatformEntryFlowShellImpl({ void refreshSquareHoleShelf(); } void refreshPuzzleShelf(); + void refreshWoodenFishShelf(); if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } @@ -14741,6 +14829,7 @@ export function PlatformEntryFlowShellImpl({ rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} + woodenFishItems={woodenFishShelfItems} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -14769,6 +14858,13 @@ export function PlatformEntryFlowShellImpl({ : null } onDeleteJumpHop={null} + onOpenWoodenFishDetail={(item) => { + runProtectedAction(() => { + markCreationFlowReturnToDraftShelf(); + void openWoodenFishDraft(item); + }); + }} + onDeleteWoodenFish={null} match3dItems={match3dShelfItems} onOpenMatch3DDetail={(item) => { runProtectedAction(() => { diff --git a/src/services/wooden-fish/woodenFishClient.test.ts b/src/services/wooden-fish/woodenFishClient.test.ts index f99ec1d8..aef88dee 100644 --- a/src/services/wooden-fish/woodenFishClient.test.ts +++ b/src/services/wooden-fish/woodenFishClient.test.ts @@ -1,5 +1,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; +const requestJsonMock = vi.hoisted(() => vi.fn()); + const { createCreationAgentClientMock } = vi.hoisted(() => ({ createCreationAgentClientMock: vi.fn(), })); @@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({ })); vi.mock('../apiClient', () => ({ - requestJson: vi.fn(), + requestJson: requestJsonMock, })); beforeEach(() => { @@ -22,6 +24,7 @@ beforeEach(() => { streamMessage: vi.fn(), executeAction: vi.fn(), }); + requestJsonMock.mockReset(); }); test('wooden fish creation keeps image2 generation requests alive long enough', async () => { @@ -34,3 +37,16 @@ test('wooden fish creation keeps image2 generation requests alive long enough', }), ); }); + +test('wooden fish list works uses creation works endpoint', async () => { + const { woodenFishClient } = await import('./woodenFishClient'); + requestJsonMock.mockResolvedValueOnce({ items: [] }); + + await woodenFishClient.listWorks(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/creation/wooden-fish/works', + { method: 'GET' }, + '读取敲木鱼作品列表失败', + ); +}); diff --git a/src/services/wooden-fish/woodenFishClient.ts b/src/services/wooden-fish/woodenFishClient.ts index 8aa08ef4..f6f31005 100644 --- a/src/services/wooden-fish/woodenFishClient.ts +++ b/src/services/wooden-fish/woodenFishClient.ts @@ -13,6 +13,7 @@ import type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; @@ -57,6 +58,7 @@ export type { WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkProfileResponse, + WoodenFishWorksResponse, WoodenFishWorkspaceCreateRequest, }; export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest; @@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) { return normalizeWoodenFishWorkDetailResponse(response); } +export async function listWoodenFishWorks() { + const response = await requestJson( + WOODEN_FISH_WORKS_API_BASE, + { method: 'GET' }, + '读取敲木鱼作品列表失败', + ); + return response; +} + export async function listWoodenFishGallery() { return requestJson( `${WOODEN_FISH_RUNTIME_API_BASE}/gallery`, @@ -312,6 +323,7 @@ export const woodenFishClient = { getSession: getWoodenFishCreationSession, getWorkDetail: getWoodenFishWorkDetail, listGallery: listWoodenFishGallery, + listWorks: listWoodenFishWorks, publishWork: publishWoodenFishWork, startRun: startWoodenFishRuntimeRun, };