From 927dcf5664f955d32d77618c335f9f13489a84e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Tue, 26 May 2026 23:00:08 +0800 Subject: [PATCH] Sync local updates with origin/master --- .hermes/shared-memory/decision-log.md | 24 ++ .hermes/shared-memory/pitfalls.md | 24 ++ ...€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md | 3 + ...³•创作】跳一跳俯视角玩法模æ¿PRD-2026-05-19.md | 21 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 3 +- ...玩法创作】生æˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md | 3 + packages/shared/src/contracts/woodenFish.ts | 4 + server-rs/crates/api-server/src/jump_hop.rs | 297 ++++++++++++++---- .../api-server/src/modules/wooden_fish.rs | 11 +- .../crates/api-server/src/wooden_fish.rs | 26 ++ .../shared-contracts/src/wooden_fish.rs | 6 + .../CustomWorldGenerationView.test.tsx | 20 +- src/components/GenerationProgressHero.tsx | 10 +- .../BarkBattleGeneratingView.test.tsx | 20 +- .../CustomWorldCreationHub.tsx | 17 + .../custom-world-home/CustomWorldWorkCard.tsx | 1 + .../creationWorkShelf.test.ts | 41 +++ .../custom-world-home/creationWorkShelf.ts | 71 +++++ .../PlatformEntryFlowShellImpl.tsx | 96 ++++++ .../wooden-fish/woodenFishClient.test.ts | 18 +- src/services/wooden-fish/woodenFishClient.ts | 12 + 21 files changed, 655 insertions(+), 73 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index abe7a502..4982f080 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 d427a240..d9c2fc8e 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 dc7ce515..253ad754 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -352,6 +352,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'; @@ -2384,6 +2385,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 []; } } @@ -3173,6 +3183,9 @@ export function PlatformEntryFlowShellImpl({ >(null); const [woodenFishWork, setWoodenFishWork] = useState(null); + const [woodenFishWorks, setWoodenFishWorks] = useState< + WoodenFishWorkSummaryResponse[] + >([]); const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState< WoodenFishGalleryCardResponse[] >([]); @@ -3920,6 +3933,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); @@ -4499,6 +4526,10 @@ export function PlatformEntryFlowShellImpl({ ], [jumpHopWorks, pendingDraftShelfItems], ); + const woodenFishShelfItems = useMemo( + () => woodenFishWorks, + [woodenFishWorks], + ); const match3dShelfItems = useMemo( () => [ ...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks), @@ -4581,6 +4612,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, @@ -4624,6 +4662,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleShelfItems, bigFishShelfItems, jumpHopShelfItems, + woodenFishShelfItems, creationHubItems, isSquareHoleCreationVisible, match3dShelfItems, @@ -9088,6 +9127,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( @@ -9105,6 +9146,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ openPublishShareModal, + refreshWoodenFishGallery, + refreshWoodenFishShelf, setSelectionStage, woodenFishWork?.summary.profileId, ]); @@ -11750,6 +11793,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)) { @@ -14622,6 +14707,7 @@ export function PlatformEntryFlowShellImpl({ if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } + void refreshWoodenFishShelf(); void refreshBabyObjectMatchShelf(); void refreshBarkBattleShelf(); } @@ -14634,6 +14720,7 @@ export function PlatformEntryFlowShellImpl({ refreshBarkBattleShelf, refreshMatch3DShelf, refreshPuzzleShelf, + refreshWoodenFishShelf, refreshSquareHoleShelf, refreshVisualNovelShelf, selectionStage, @@ -14728,6 +14815,7 @@ export function PlatformEntryFlowShellImpl({ void refreshSquareHoleShelf(); } void refreshPuzzleShelf(); + void refreshWoodenFishShelf(); if (isVisualNovelCreationOpen) { void refreshVisualNovelShelf(); } @@ -14781,6 +14869,7 @@ export function PlatformEntryFlowShellImpl({ rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []} jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []} + woodenFishItems={woodenFishShelfItems} onOpenBigFishDetail={ isBigFishCreationVisible ? (item) => { @@ -14809,6 +14898,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, };