From bb60ca91ef56e04940f829b9d9fe31040995f3e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 15 May 2026 08:49:59 +0800 Subject: [PATCH] Match3D & Puzzle: runtime UI, assets, drag fix Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes. --- .hermes/shared-memory/decision-log.md | 48 + .hermes/shared-memory/pitfalls.md | 57 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 22 +- ...项目基线】当å‰äº§å“与工程约æŸ-2026-05-15.md | 1 + server-rs/crates/api-server/src/match3d.rs | 955 +++++++++++++----- .../crates/api-server/src/match3d/mappers.rs | 20 - ...ustomWorldCreationHub.interaction.test.tsx | 51 +- .../custom-world-home/CustomWorldWorkCard.tsx | 76 +- .../match3d-result/Match3DResultView.test.tsx | 111 +- .../match3d-result/Match3DResultView.tsx | 141 ++- .../Match3DRuntimeShell.test.tsx | 38 +- .../match3d-runtime/Match3DRuntimeShell.tsx | 108 +- .../match3d-runtime/match3dRuntimeUiStyles.ts | 20 + .../PlatformEntryFlowShellImpl.tsx | 204 +++- .../PuzzleRuntimeShell.test.tsx | 240 ++++- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 303 ++++-- .../puzzle-runtime/puzzleRuntimeShape.ts | 82 +- ...gEntryFlowShell.agent.interaction.test.tsx | 91 ++ .../RpgEntryHomeView.recharge.test.tsx | 21 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 37 +- src/index.css | 88 +- src/routing/RouteImageReadyGate.test.ts | 4 +- src/routing/appRoutes.tsx | 2 +- 23 files changed, 2127 insertions(+), 593 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3eb5bbb9..7ec7b82b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,54 @@ --- +## 2026-05-15 抓大鹅结果页 UI 预览å¤ç”¨è¿è¡Œæ€å¸ƒå±€ + +- 背景:抓大鹅结果页 `ç´ æé…ç½® > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实è¿è¡Œæ€é¡¶éƒ¨å…³å¡å¡ç‰‡ã€å³ä¸Šè®¾ç½®å…¥å£ã€å®¹å™¨å›¾å®šä½åŠæ§½ä½æ ·å¼å‡ºçŽ°æ¼‚ç§»ã€‚ +- 决策:结果页 UI 预览åªç»„åˆ `match3dRuntimeUiStyles` 中的è¿è¡Œæ€ HUDã€æ£‹ç›˜ã€å®¹å™¨å›¾å’Œæ§½ä½æ ·å¼å¸¸é‡ï¼›è¿è¡Œæ€å°ºå¯¸æˆ–è§†è§‰å±‚çº§è°ƒæ•´æ—¶ï¼ŒåŒæ­¥ç”±è¿™äº›å¸¸é‡å½±å“预览,ä¸å†åœ¨ç»“果页å•独维护å¦ä¸€å¥—预览 UI。 +- å½±å“范围:`src/components/match3d-result/Match3DResultView.tsx`ã€`src/components/match3d-runtime/Match3DRuntimeShell.tsx`ã€`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`ã€æŠ“å¤§é¹…ç»“æžœé¡µæµ‹è¯•å’ŒçŽ©æ³•é“¾è·¯æ–‡æ¡£ã€‚ +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`ã€`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认预览顶部 HUDã€è®¾ç½®å…¥å£ã€å®¹å™¨å›¾å®šä½å’Œæ§½ä½æ ·å¼ä¸Žè¿è¡Œæ€ä¸€è‡´ã€‚ +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-15 æ‹¼å›¾æ‹–æ‹½è§†è§‰å³æ—¶è·Ÿæ‰‹ä¸”拼å—è£åˆ‡åœ†è§’ + +- 背景:拼图è¿è¡Œæ€åœ¨è§¦æŽ§æ‹–拽时ä¸èƒ½æœ‰è¿½æ‰‹å»¶è¿Ÿï¼ŒåŒæ—¶æ‹¼å›¾ç‰‡éœ€è¦çœŸå®žåœ†è§’è£åˆ‡ï¼Œåˆå¹¶åŽçš„ L å½¢ / 凹形å—ä¸èƒ½é å•格圆角拼接。 +- 决策:`PuzzleRuntimeShell` 拖拽视觉在 `pointermove` 期间直接写入当å‰å¯è§æ‹¼å— DOM çš„ `translate3d(...)`,拖拽阶段ç¦ç”¨ transform transition,ä¸ä¾èµ–åŽç«¯å›žåŒ…ã€React 釿¸²æŸ“或 `requestAnimationFrame` 队列。拖动过程中ä¸å±•示拼å—é€‰ä¸­æ€æˆ–åº•éƒ¨â€œå·²é€‰æ‹©â€æç¤ºï¼Œé€‰ä¸­çŠ¶æ€åªä¿ç•™ç»™ç‚¹å‡»äº¤æ¢è¯­ä¹‰ã€‚å•å—通过 `buildRoundedGridCellClipPath()` è£åˆ‡ç‹¬ç«‹åœ†è§’ï¼›åˆå¹¶å—视觉层必须用 SVG 原生 `clipPath` è£åˆ‡æ•´ä½“外轮廓,ä¸åªä¾èµ– HTML `clip-path:url(#...)`,外凸角和内凹角分开计算åŠå¾„,内凹角åŠå¾„更大以é¿å…移动端看起æ¥ä»æ˜¯ç›´è§’。 +- å½±å“范围:拼图è¿è¡Œæ€æ‹–拽手感ã€`puzzleRuntimeShape` åœ†è§’è·¯å¾„ã€æ‹¼å›¾è¿è¡Œæ€æµ‹è¯•和玩法链路文档。 +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`ã€`npm run typecheck`ã€`npm run check:encoding`。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-15 拼图è¿è¡Œæ€ç‚¹å‡»å¼¹å±‚ä¸ä½¿ç”¨åƒç´ ç´ ææ¡† + +- 背景:拼图è¿è¡Œæ€ä¸»ç•Œé¢å·²ç»åˆ‡åˆ°å¹³å°ä¸»è‰²ä¸»é¢˜ï¼Œä½†æç¤ºå’Œè®¾ç½®å¼¹å±‚ä»å¤ç”¨åƒç´ ä¹å®«æ ¼ç´ ææ¡†ï¼Œå’Œå½“剿‹¼å›¾è§†è§‰ä¸ä¸€è‡´ã€‚ +- 决策:拼图è¿è¡Œæ€çš„æç¤ºã€è®¾ç½®ç­‰ç‚¹å‡»å¼¹å±‚è·Ÿéš `puzzle-runtime-*` ä¸»è‰²ä¸»é¢˜ï¼Œä½¿ç”¨æ™®é€šåœ†è§’ä¸»é¢˜é¢æ¿å’Œ CSS å˜é‡åˆ†å±‚,ä¸å†å¥— `pixel-nine-slice` / `pixel-modal-shell` 或 `UI_CHROME.modalPanel`。 +- å½±å“范围:`PuzzleRuntimeShell` çš„é“具确认弹窗ã€è®¾ç½®å¼¹çª—ã€æ‹¼å›¾è¿è¡Œæ€æ ·å¼å’ŒçŽ©æ³•é“¾è·¯æ–‡æ¡£ã€‚ +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,确认æç¤ºå’Œè®¾ç½® dialog ä¸åŒ…å«åƒç´ æ¡†ç±»å。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-15 拼图è¿è¡Œæ€å£³å±‚必须自带平å°ä¸»é¢˜ç±» + +- 背景:`/puzzle` 直达页和è¿è¡Œæ€æå‰è¿”回分支ä¸ä¸€å®šæŒ‚在外层平å°å£³é‡Œï¼Œè‹¥å£³å±‚åªä¾èµ–父级主题类,就会出现修改已ç»ç”Ÿæ•ˆä½†é¡µé¢çœ‹èµ·æ¥è¿˜æ˜¯æ—§æ ·å¼çš„错觉。 +- 决策:`PuzzleRuntimeShell` 自身在正常è¿è¡Œæ€å’Œç­‰å¾…æ€éƒ½è¡¥é½ `platform-ui-shell platform-theme platform-theme--light|dark`,让直达页和平å°å†…嵌页共享åŒä¸€å¥—主题å˜é‡ã€‚ +- å½±å“范围:`PuzzleRuntimeShell` 根容器ã€è·¯ç”±ç›´è¾¾é¡µã€æ‹¼å›¾è¿è¡Œæ€æµ‹è¯•和玩法链路文档。 +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx` 与 `npm run typecheck`,确认壳层 className 包å«å¹³å°ä¸»é¢˜ç±»ã€‚ +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-15 抓大鹅è‰ç¨¿è‡ªåŠ¨ç¼–æŽ’ä¸å†è¦æ±‚èƒŒæ™¯éŸ³ä¹ + +- 背景:抓大鹅音频入å£å…³é—­åŽï¼Œè‰ç¨¿ç”Ÿæˆä»å¯èƒ½åœ¨åŽç«¯è‡ªåŠ¨ç¼–æŽ’ä¸­è°ƒç”¨ Suno,导致“æäº¤ Suno 背景音ä¹ä»»åŠ¡å¤±è´¥â€é˜»æ–­è‰ç¨¿ç”Ÿæˆã€‚ +- 决策:`match3d_compile_draft` çš„å®Œæˆæ¡ä»¶åªåŒ…å« 2D 五视角物å“图片ã€UI 背景和容器图;点击音效åªåœ¨ `generateClickSound=true` 的历å²é…置下作为å¯é€‰è¡¥é½ã€‚背景音ä¹å­—段仅作为历å²å…¼å®¹ä¼ é€’,ä¸å†ç”±è‰ç¨¿è‡ªåŠ¨ç”Ÿæˆæˆ–è¡¥é½ã€‚ +- å½±å“范围:`api-server` Match3D è‰ç¨¿èµ„äº§ç¼–æŽ’ã€æŠ“å¤§é¹…éŸ³é¢‘å…³é—­å£å¾„ã€çŽ©æ³•é“¾è·¯æ–‡æ¡£ã€‚ +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml`ã€`cargo check -p api-server --manifest-path server-rs/Cargo.toml`ã€`npm run check:encoding`。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 2026-05-15 抓大鹅è¿è¡Œæ€å³ä¸Šè§’ç»Ÿä¸€ä¸ºè®¾ç½®å…¥å£ + +- 背景:抓大鹅è¿è¡Œæ€å³ä¸Šè§’åŽŸå…ˆç›´æŽ¥æš´éœ²é‡æ–°å¼€å§‹æŒ‰é’®ï¼Œå’Œæ‹¼å›¾çš„设置入å£å£å¾„ä¸ä¸€è‡´ï¼Œä¹Ÿä¸åˆ©äºŽæŠŠè®¾ç½®ã€é‡å¼€å’ŒåŽç»­æ‰©å±•动作收å£åˆ°ç»Ÿä¸€é¢æ¿ã€‚ +- 决策:`Match3DRuntimeShell` å³ä¸Šè§’æ”¹ä¸ºè®¾ç½®æŒ‰é’®ï¼Œç‚¹å‡»åŽæ‰“å¼€ç‹¬ç«‹è®¾ç½®é¢æ¿ï¼›é‡æ–°å¼€å§‹åŠ¨ä½œä»…æ”¾åœ¨è®¾ç½®é¢æ¿å†…,结算弹层继续ä¿ç•™å†æ¥ä¸€å±€ã€‚拼图å³ä¸Šè§’è®¾ç½®å›¾æ ‡åŒæ­¥æ”¹ä¸ºéžåƒç´ é£Ž `lucide-react` `Settings` 图标。 +- å½±å“范围:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`ã€`src/components/match3d-runtime/Match3DRuntimeShell.tsx`ã€å¯¹åº”测试和玩法链路文档。 +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认拼图设置按钮ä¸å†æ¸²æŸ“åƒç´ å›¾ç‰‡ã€æŠ“大鹅å³ä¸Šè§’æ‰“å¼€è®¾ç½®é¢æ¿ä¸”颿¿å†…å¯é‡æ–°å¼€å§‹ã€‚ +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 2026-05-15 汪汪声浪和å®è´è¯†ç‰©å…¥å£è®¾ä¸ºæ•¬è¯·æœŸå¾… - 背景:当å‰éœ€è¦æš‚时关闭汪汪声浪和å®è´è¯†ç‰©ä¸¤ä¸ªæ¨¡æ¿çš„创建链路,但ä»ä¿ç•™åˆ›ä½œ Tab 中的模æ¿å ä½ã€‚ diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index b027d78b..e001f6ba 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -70,6 +70,22 @@ - 验è¯ï¼š`npm run test -- src\services\match3dGeneratedModelCache.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`ï¼›å¹³å°æŽ¨èæµå®šå‘è·‘ `RpgEntryFlowShell.agent.interaction.test.tsx` 中的 Match3D runtime assets 用例;`npm run typecheck`。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 +## 抓大鹅è‰ç¨¿å…³é—­èƒŒæ™¯éŸ³ä¹åŽä»è§¦å‘ Suno 先查åŽç«¯è‡ªåŠ¨ç¼–æŽ’ + +- 现象:用户已关闭抓大鹅è‰ç¨¿é‡Œçš„ `生æˆéŸ³æ•ˆ` 或éšè—结果页音频入å£ï¼Œä½†ç‚¹å‡»ç”Ÿæˆä»æŠ¥ `æäº¤ Suno 背景音ä¹ä»»åŠ¡å¤±è´¥`。 +- 原因:入å£å¼€å…³åªå½±å“å‰ç«¯å¯è§æŽ§ä»¶æˆ–点击音效,åŽç«¯ `match3d_compile_draft` 之å‰è¿˜ä¼šæŠŠ `backgroundMusic` å½“ä½œè‡ªåŠ¨å®Œæˆæ¡ä»¶ï¼Œç»§ç»­è°ƒç”¨ `generate_background_music_asset_for_creation()`ï¼Œä»Žè€Œç›´æŽ¥å‘ `/suno/submit/music`。 +- 处ç†ï¼šæŠ“大鹅è‰ç¨¿è‡ªåŠ¨ç¼–æŽ’åªä¿ç•™ 2D 物å“图片ã€UI 背景和容器图;背景音ä¹å­—段åªä½œä¸ºåކå²å…¼å®¹æ•°æ®ï¼Œä¸å†ä½œä¸ºè‰ç¨¿å®Œæˆæ¡ä»¶ï¼Œä¹Ÿä¸å†ç”±è‡ªåŠ¨ç¼–æŽ’æäº¤ Suno。 +- 验è¯ï¼š`cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml` 通过,`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 无新的编译问题。 +- å…³è”:`server-rs/crates/api-server/src/match3d.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`.hermes/shared-memory/decision-log.md`。 + +## 抓大鹅 UI 预览和实际游æˆä¸ä¸€è‡´å…ˆæŸ¥è¿è¡Œæ€æ ·å¼å¤ç”¨ + +- 现象:结果页 `ç´ æé…ç½® > UI` 打开的预览弹层和真实抓大鹅è¿è¡Œæ€ä¸ä¸€è‡´ï¼Œä¾‹å¦‚é¡¶éƒ¨åªæ˜¾ç¤ºå€’计时ã€å³ä¸Šè¿˜æ˜¯è½¬åœˆå ä½ã€ä¸­å¿ƒå®¹å™¨å°ºå¯¸æˆ–定ä½å’Œå±€å†…ä¸åŒã€‚ +- 原因:预览层手写了简化 HUDã€æ£‹ç›˜å°ºå¯¸å’Œå®¹å™¨å›¾æ ·å¼ï¼Œæ²¡æœ‰æ¶ˆè´¹ `Match3DRuntimeShell` è¿è¡Œæ€ä½¿ç”¨çš„共享样å¼å¸¸é‡ã€‚ +- 处ç†ï¼šæŠŠè¿è¡Œæ€ HUDã€æ£‹ç›˜ã€å®¹å™¨å›¾å’Œæ§½ä½çš„ Tailwind 类抽到 `match3dRuntimeUiStyles.ts`,结果页预览åªå¤ç”¨è¿™äº›å¸¸é‡ï¼›è¿è¡Œæ€ä¹‹åŽæ”¹é¡¶éƒ¨è®¾ç½®å…¥å£ã€å…³å¡å¡ç‰‡ã€å®¹å™¨å›¾å®½åº¦æˆ–æ£‹ç›˜å…œåº•æ ·å¼æ—¶ï¼Œä¸è¦åœ¨ç»“果页å†å†™ä¸€å¥—。 +- 验è¯ï¼š`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 +- å…³è”:`src/components/match3d-result/Match3DResultView.tsx`ã€`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 中文乱ç ä¸Žç¼–ç é£Žé™© - çŽ°è±¡ï¼šä¸­æ–‡æ–‡æ¡ˆã€æ³¨é‡Šã€å‰§æƒ…或文档显示为乱ç ï¼Œæˆ–被改写æˆè‹±æ–‡ã€‚ @@ -469,6 +485,14 @@ - 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 +## 推è页拼图改造返回åŽåˆå¡åœ¨è¿›å…¥ä¸­ + +- 现象:推è页进入拼图作å“åŽç‚¹å‡»æ”¹é€ ï¼Œè¿”回推è页时å¡åœ¨â€œæ­£åœ¨è¿›å…¥æ‹¼å›¾å…³å¡â€æˆ–空白加载æ€ï¼Œåƒæ˜¯ä½œå“还在å¯åŠ¨ã€‚ +- åŽŸå› ï¼šæ”¹é€ æˆ–å…¶ä»–é¡µé¢æµç¨‹ä¼šæ¸…æŽ‰å½“å‰ `puzzleRun`,但推è页自动å¯åŠ¨é€»è¾‘æ›¾åªæ£€æŸ¥ `activeRecommendEntryKey` 是å¦ä»åœ¨æŽ¨è列表中;旧 key 还在ã€çŽ©æ³• run 已丢失时,会继续渲染失效的 `PuzzleRuntimeShell` å ä½ã€‚ +- 处ç†ï¼šä»ŽæŽ¨èé¡µæ‹¼å›¾è¿›å…¥æ”¹é€ ç»“æžœé¡µæ—¶ï¼Œå…ˆæ”¶å£æŽ¨è嵌入æ€ï¼›æŽ¨è页自动å¯åŠ¨é€»è¾‘è¿˜å¿…é¡»æ ¡éªŒå½“å‰æŽ¨è作å“对应的 runtime æ•°æ®æ˜¯å¦å­˜åœ¨ï¼Œä¾‹å¦‚æ‹¼å›¾è¦æœ‰ `puzzleRun`ï¼ŒæŠ“å¤§é¹…è¦æœ‰ `match3dRun`。key 还在但 runtime ä¸å®Œæ•´æ—¶ï¼Œä¼˜å…ˆé‡æ–°å¯åЍ当å‰ä½œå“,ä¸è¦è¯¯åˆ¤ä¸ºå·²æ¿€æ´»ã€‚ +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle|home recommendation surfaces start failure|first puzzle runtime back click can open remix result page"`。 +- å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`。 + ## 推è页未登录入å£è¯¯æ‰“开公开详情 - 现象:新用户默认在å‘现页,但点击推è页或推èå°é¢åŽï¼Œå¦‚æžœå¤ç”¨å…¬å¼€ä½œå“详情入å£ï¼Œå¯èƒ½ç»•过推èé¡µâ€œç™»å½•åŽæ¸¸çŽ©â€çš„产å“é—¨ç¦ã€‚ @@ -696,6 +720,13 @@ - 验è¯ï¼šæ‰§è¡Œ `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` å’Œ `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`ï¼›æµè§ˆå™¨ Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` æ¢ç­¾ï¼Œå±€å†…出现 `match3d-background-image` å’Œ `match3d-container-image` 对应图片。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 +## 抓大鹅 UI 背景和容器åªåœ¨ç‰©å“æŒ‚è½½å­—æ®µæ—¶ä¹Ÿè¦æå‡ + +- 现象:è‰ç¨¿æ¢å¤ã€ç»“果页素æé…ç½®ã€UI é¢„è§ˆæˆ–è¯•çŽ©æ—¶ä»æ˜¾ç¤ºé»˜è®¤èƒŒæ™¯ / 默认容器,但 work profile 的首个 `generatedItemAssets[].backgroundAsset` é‡Œå·²ç»æœ‰ç”Ÿæˆçš„背景和容器图。 +- 原因:UI 背景和容器资产没有独立表字段,åŽç«¯æŒä¹…化常è½åœ¨ `generatedItemAssets[].backgroundAsset`;如果 session 映射ã€ç»“果页 profileã€æŽ¨èè¿è¡Œæ€è¯¦æƒ…补读åŽä¸æå‡åˆ°é¡¶å±‚ `generatedBackgroundAsset` å’Œ `backgroundImageSrc`,åŽç»­ç»„ä»¶ä¼šè¯¯åˆ¤â€œæ²¡æœ‰ç”Ÿæˆ UI 资产â€ã€‚ +- 处ç†ï¼šAgent session 返回å‰è¦ç”¨æŒä¹…化 work profile 资产回填 draftï¼›å‰ç«¯è¿›å…¥ç»“æžœé¡µã€æž„建è‰ç¨¿ profileã€æŽ¨è / 公开作å“å¯åЍè¿è¡Œæ€å‰ï¼Œéƒ½è¦æŠŠ `generatedItemAssets[].backgroundAsset` æå‡ä¸ºé¡¶å±‚背景字段。容器图在è¿è¡Œæ€å’Œ UI 预览å¤ç”¨åŒä¸€å¥—居中 `object-contain` æ ·å¼ï¼Œç§»åŠ¨ç«¯å®½åº¦æŽ¥è¿‘å±å®½ï¼Œåªæœ‰ç¼ºå¤±æˆ–加载失败时æ‰ä½¿ç”¨é€æ˜Žå‚考图兜底。 +- 验è¯ï¼š`cargo test -p api-server match3d_agent_session_response_hydrates_persisted_ui_assets --manifest-path server-rs/Cargo.toml`ã€`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 + ## 抓大鹅é‡å¯æ—¶ä¸è¦æ¸…空 generated 图片签å缓存 - 现象:抓大鹅进入局内时背景或生æˆç‰©å“首帧缺失;点击å³ä¸Šè§’é‡å¯åŽï¼Œå±€å†…çŸ­æš‚æ˜¾ç¤ºé»˜è®¤ç§¯æœ¨ï¼Œè¿‡ä¸€æ®µæ—¶é—´æ‰æ¢å›žå®žé™…生æˆç´ æã€‚ @@ -788,10 +819,18 @@ - 现象:抓大鹅生æˆçš„物å“视角图è£å‰ªåŽä»å¸¦ç™½è¾¹ï¼Œæˆ–者整å—çº¯ç»¿è‰²ç»¿å¹•èƒŒæ™¯æ²¡æœ‰è¢«é€æ˜ŽåŒ–,è¿è¡Œæ€çœ‹åˆ°ç»¿è‰²æ–¹å—。 - 原因:素æ sheet å¯èƒ½æ˜¯â€œæ¯æ ¼å†…éƒ¨ç»¿å¹•ã€æ•´å¼ å›¾å¤–圈近白底â€ï¼Œå†…部绿幕ä¸ä¸€å®šè¿žé€šåˆ° sheet 外边缘;旧 flood fill åªä»Žå¤–è¾¹ç¼˜æ‰¾èƒŒæ™¯ä¼šæ¼æŽ‰è¿™ç§ç»¿å¹•å—。白底抗锯齿如果ä¸çº³å…¥æŠ åƒå’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œä¹Ÿä¼šéšè£å‰ªè¾“出æˆä¸€åœˆç™½è¾¹ã€‚å³ä½¿é¡ºåºå·²æ˜¯å…ˆæ•´å¼  sheet 去绿å†è£å‰ªï¼Œè¾ƒåŽšçš„åŠé€æ˜Žæˆ–混色软绿边ä»å¯èƒ½ä½ŽäºŽé«˜ç½®ä¿¡ç»¿å¹•é˜ˆå€¼ï¼Œè¢«å½“ä½œå‰æ™¯å¸¦è¿›ç‹¬ç«‹ PNG。 -- 处ç†ï¼š`api-server` çš„ `slice_match3d_material_sheet` 必须先在整张 sheet 上åšé€æ˜ŽèƒŒæ™¯åŽå¤„ç†ï¼šå¤–边缘连通绿幕/近白底清 alpha,éžè¿žé€šä½†é«˜ç½®ä¿¡çº¯ç»¿å—也清 alpha,沿整张 sheet 逿˜ŽèƒŒæ™¯ç»§ç»­åƒæŽ‰è½¯ç»¿è¾¹ï¼Œè¾¹ç¼˜è¿‘白和绿幕抗锯齿åšé€æ˜Žæˆ–åŽ»æ±¡æŸ“ï¼›åŒæ—¶ä¿æŠ¤ä¸å¤Ÿçº¯çš„绿色主体åƒç´ ã€‚ä¸è¦æ”¹æˆå…ˆè£å‰ªå•æ ¼å†åŽ»ç»¿ã€‚ +- 处ç†ï¼š`api-server` çš„ `slice_match3d_material_sheet` 必须先在整张 sheet 上åšé€æ˜ŽèƒŒæ™¯åŽå¤„ç†ï¼šå¤–边缘连通绿幕/近白底清 alpha,éžè¿žé€šä½†é«˜ç½®ä¿¡çº¯ç»¿å—也清 alpha,沿整张 sheet 逿˜ŽèƒŒæ™¯ç»§ç»­åƒæŽ‰è½¯ç»¿è¾¹ï¼›æ¯ä¸ªè§†è§’å•图还è¦ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå†æŒ‰å‰©ä½™å¯è§ä¸»ä½“æ”¶ç´§è£è¾¹ï¼ŒåŒæ—¶ä¿æŠ¤ä¸å¤Ÿçº¯çš„绿色主体åƒç´ ã€‚ä¸è¦æ”¹æˆå…ˆè£å‰ªå•æ ¼å†åŽ»ç»¿ã€‚ - 验è¯ï¼š`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖éžè¿žé€šç»¿å¹•ã€ç™½è¾¹ã€è´´è¾¹ä¸»ä½“ä¿ç•™å’Œå›ºå®š 5x5 切图。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 +## 抓大鹅背景 UI å›¾å‡ºçŽ°é€æ˜ŽåŒºåŸŸå…ˆæŸ¥èƒŒæ™¯ä¸é€æ˜ŽåŽå¤„ç† + +- 现象:抓大鹅生æˆçš„ `9:16` 局内背景 UI 图边缘ã€è§’è½æˆ–å±€éƒ¨å‡ºçŽ°é€æ˜Ž alpha,è¿è¡Œæ€å¯èƒ½éœ²å‡ºåº•è‰²æˆ–é€æ˜Žæ£‹ç›˜åº•。 +- 原因:背景图和中心容器图是两张资产;容器图需è¦é€æ˜Ž alpha,但背景图是整å±åº•图。如果åªé æç¤ºè¯çº¦æŸï¼Œç”Ÿå›¾æœåŠ¡ä»å¯èƒ½è¿”å›žå¸¦é€æ˜Žé€šé“çš„ PNG。 +- 处ç†ï¼š`generate_match3d_background_image` 上传背景å‰å¿…须调用 `make_match3d_background_image_opaque()`,把所有åŠé€æ˜Ž / 免逿˜Žåƒç´ åˆæˆåˆ°ä¸é€æ˜Žåº•色;ä¸è¦æŠŠè¿™æ¡é€»è¾‘套到容器图,容器图ä»ç”± `make_match3d_container_image_transparent()` ä¿æŒé€æ˜Žã€‚ +- 验è¯ï¼š`cargo test -p api-server match3d_background_image_postprocess_removes_transparent_pixels --manifest-path server-rs\Cargo.toml`,并确认背景æç¤ºè¯åŒ…å«â€œå…¨ç”»å¹…ä¸é€æ˜Žâ€å’Œâ€œé€æ˜Ž alphaâ€ç¦ç”¨çº¦æŸã€‚ +- å…³è”:`server-rs/crates/api-server/src/match3d.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 抓大鹅物å“详情大方格åªåšå•张大图查看 - 现象:结果页 `ç´ æé…ç½® > 物å“` 打开详情åŽï¼Œä¸Šæ–¹å¤§æ–¹æ ¼ä»æ˜¾ç¤ºæ¨ªå‘五图带ã€ç„¦ç‚¹å†…框或å°ç¼©ç•¥å›¾è¾¹æ¡†ï¼Œç‰©å“本体看起æ¥åå°ä¸”åƒå¸¦ç€ç´ æè‡ªå¸¦è¾¹æ¡†ã€‚ @@ -856,6 +895,22 @@ - 验è¯ï¼šè¿è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽åˆå¹¶å¤§å—æ—¶åº•å±‚å•æ ¼ä¸æ˜¾ç¤ºé€‰ä¸­è‰²å—"`,并确认åˆå¹¶å—拖拽时底层 `[data-piece-id]` ä»ä¸º `puzzle-runtime-piece--merged`。 - å…³è”:`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 +## æ‹¼å›¾æ‹–æ‹½å»¶è¿Ÿå’Œåœ†è§’å¼‚å¸¸å…ˆæŸ¥å³æ—¶ DOM 视觉层 + +- çŽ°è±¡ï¼šæ‹¼å›¾å—æ‹–动时有明显追手延迟,或åˆå¹¶å—çš„ L å½¢ / 凹形内角圆角和外凸角表现一致ã€å‡ºçŽ°æ–¹è§’ã€‚ +- 原因:拖拽视觉如果ä¾èµ– `requestAnimationFrame` 排队ã€React 釿¸²æŸ“或 transform transition,会在触控设备上产生一帧以上延迟;åˆå¹¶å—如果åªé å•æ ¼ `border-radius`ï¼Œæ— æ³•æ­£ç¡®å¤„ç†æ•´ä½“外轮廓和内凹角。 +- 处ç†ï¼š`PuzzleRuntimeShell` çš„ `pointermove` 期间直接给当å‰å•å—æˆ–åˆå¹¶ç»„ DOM 写入 `translate3d(...)`,拖拽阶段ç¦ç”¨ transform transition,并éšè—拼å—选中æ€å’Œåº•éƒ¨â€œå·²é€‰æ‹©â€æç¤ºï¼›å•å—使用 `buildRoundedGridCellClipPath()` 独立è£åˆ‡ï¼Œåˆå¹¶å—视觉层使用 SVG 原生 `clipPath` è£åˆ‡æ•´ä½“轮廓,ä¸åªä¾èµ– HTML `clip-path:url(#...)`,外凸角和内凹角åŠå¾„分开计算,内凹角åŠå¾„更大。 +- 验è¯ï¼šæ‰§è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,é‡ç‚¹è¦†ç›–拖拽 move åŽ DOM transform ç«‹å³å˜åŒ–且ä¸è°ƒç”¨ rAFã€æ‹–åŠ¨ä¸­ä¸æ˜¾ç¤ºå·²é€‰æ‹©çжæ€ã€åŸºç¡€å•å—æœ‰åœ†è§’è£åˆ‡ã€åˆå¹¶å—内凹角路径使用更大åŠå¾„且视觉层为 SVG è£åˆ‡ã€‚ +- å…³è”:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`ã€`src/components/puzzle-runtime/puzzleRuntimeShape.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## æ‹¼å›¾è®¾ç½®é¢æ¿å½“å‰ç”¨æ—¶ä¸º 0 先查结算字段误用 + +- 现象:拼图è¿è¡Œæ€è¿˜åœ¨æ¸¸çŽ©æ—¶ï¼Œè®¾ç½®é¢æ¿é‡Œçš„“当å‰ç”¨æ—¶â€ä¸€ç›´æ˜¾ç¤º `0:00.00`,但顶部倒计时正常å˜åŒ–。 +- 原因:`currentLevel.elapsedMs` 是通关åŽçš„ç»“ç®—å­—æ®µï¼Œè¿›è¡Œä¸­å…³å¡æŒ‰å¥‘约通常为 `null`ï¼›è®¾ç½®é¢æ¿è‹¥ç›´æŽ¥æ ¼å¼åŒ–该字段,就会被归一化为 0。 +- 处ç†ï¼šè®¾ç½®é¢æ¿çš„当å‰ç”¨æ—¶æŒ‰ `startedAtMs`ã€`pausedAccumulatedMs`ã€UI æš‚åœèµ·ç‚¹ã€`freezeAccumulatedMs` 和当å‰å†»ç»“区间实时派生,和剩余时间使用åŒä¸€å¥—æš‚åœ / 冻结扣除å£å¾„。 +- 验è¯ï¼šè¿è¡Œ `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "æ‹¼å›¾è®¾ç½®é¢æ¿å±•示进行中关å¡çš„实时当å‰ç”¨æ—¶"`。 +- å…³è”:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 拼图历å²å›¾ç‰‡åˆ—表ä¸è¦æŠŠè´¦å·å½’属当图片å - 现象:拼图创作页或结果页打开“选择历å²å›¾ç‰‡â€åŽï¼Œåކå²åˆ—表显示 `è´¦å· user-1` ä¹‹ç±»å½’å±žæ–‡æ¡ˆè€Œä¸æ˜¯å›¾ç‰‡åï¼›`1713686400.000000Z` 这类时间显示为未知;选中åŽé¢„览或生æˆå‚考图å¯èƒ½è¢«æ€€ç–‘ä¸å¯ç”¨ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 44ed9d73..4f448db2 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -12,7 +12,7 @@ 1. è‰ç¨¿é¡µä½œå“å¡å¯¹é½å‘现页列表å¡é£Žæ ¼ï¼šå·¦ä¾§ä¿¡æ¯ï¼Œå³ä¾§å°é¢å›¾ï¼Œç§»åŠ¨ç«¯å•列,桌é¢ä¸¤åˆ°ä¸‰åˆ—。 2. è‰ç¨¿ / å·²å‘布状æ€å°½é‡å›¾æ ‡åŒ–,ä¸ä½¿ç”¨å¤§æ®µçŠ¶æ€æ–‡æ¡ˆã€‚ -3. 删除ã€åˆ†äº«ç­‰ä½Žé¢‘动作进入左滑或长按æ“作层,常æ€ä¸å¤–露破åå¡ç‰‡å¯†åº¦ã€‚ +3. è‰ç¨¿å¡å¸¸æ€ä¸å¤–露低频动作;已å‘布作å“å¡å³ä¸Šè§’å¯ç›´æŽ¥æ˜¾ç¤ºæ— è¾¹æ¡†åˆ†äº« iconï¼Œåˆ é™¤ç­‰ç ´åæ€§åŠ¨ä½œç»§ç»­æ”¶å£åˆ°å·¦æ»‘或长按æ“作层。 4. 生æˆä¸­ä½œå“在整å¡ä¸ŠåŠ ç­‰å¾…é®ç½©ï¼Œä½†ä¸ç§»é™¤ä½œå“基础信æ¯ã€‚ 5. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 @@ -30,7 +30,12 @@ - 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ ä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ ä¸»å›¾åŽä¸é‡ç»˜ã€‚ - è‰ç¨¿ç”Ÿæˆä¼šä¿ç•™å…³å¡å›¾å’Œ UI 背景;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚ - 结果页素æé…置当å‰åªä¿ç•™ UI 相关能力;旧背景音ä¹å…¥å£éšè—。 -- 拼图è¿è¡Œæ€é»˜è®¤æ£‹ç›˜ä¸å åŠ åˆ†å—è’™ç‰ˆã€æè¾¹ã€é˜´å½±ã€åœ†è§’切å—ã€é€‰ä¸­åº•色或åˆå¹¶å— SVG 轮廓;原图é“å…·åªåœ¨ç”¨æˆ·ä¸»åŠ¨ç¡®è®¤åŽä¸´æ—¶è¦†ç›–。 +- 拼图è¿è¡Œæ€æ£‹ç›˜ä¸å åŠ åˆ†å—è’™ç‰ˆã€æè¾¹ã€é˜´å½±ã€é€‰ä¸­åº•色或åˆå¹¶å— SVG 轮廓;拼图片本体需è¦è£åˆ‡ä¸ºåœ†è§’形状,å•å—使用独立圆角è£åˆ‡ï¼Œåˆå¹¶å—使用 SVG 原生 `clipPath` è£åˆ‡æ•´ä½“外轮廓,外凸角和内凹角分别计算åŠå¾„,内凹角åŠå¾„è¦æ¯”外凸角更明显以é¿å…手机 WebView 中看起æ¥ä»æ˜¯ç›´è§’。原图é“å…·åªåœ¨ç”¨æˆ·ä¸»åŠ¨ç¡®è®¤åŽæ‰“开独立原图查看层,ä¸åœ¨å½“剿‹¼å›¾æ£‹ç›˜ä¸Šå åŠ åŽŸå›¾ã€‚ +- 拼图è¿è¡Œæ€æ‹–æ‹½å¿…é¡»å®Œå…¨è·Ÿéšæ‰‹æŒ‡æˆ–é¼ æ ‡ä½ç½®ï¼Œ`pointermove` æœŸé—´å³æ—¶å†™å…¥å¯è§æ‹¼å—çš„ transform,ä¸ä¾èµ–等待åŽç«¯å›žåŒ…ã€React 釿¸²æŸ“或下一帧动画队列;进入拖动åŽä¸å±•示拼å—é€‰ä¸­æ€æˆ–â€œå·²é€‰æ‹©â€æç¤ºï¼Œæ¾æ‰‹åŽå†æäº¤ç›®æ ‡æ ¼åŒæ­¥è§„则真相。 +- 拼图è¿è¡Œæ€çš„æç¤ºã€è®¾ç½®ç­‰ç‚¹å‡»å¼¹å±‚è·Ÿéšå½“å‰è¿è¡Œæ€ä¸»è‰²ä¸»é¢˜ï¼Œä½¿ç”¨æ™®é€šåœ†è§’ä¸»é¢˜é¢æ¿ï¼Œä¸å¤ç”¨åƒç´ ä¹å®«æ ¼ç´ ææ¡†ã€‚ +- 拼图è¿è¡Œæ€å£³å±‚自身è¦è¡¥é½ `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,ä¸èƒ½ä¾èµ–外层平å°å£³æ¥æä¾›ä¸»é¢˜å˜é‡ï¼›`/puzzle` 直达页和平å°å†…嵌页都必须渲染åŒä¸€å¥—主题语义类。 +- 拼图è¿è¡Œæ€è¿›è¡Œä¸­å…³å¡çš„ `elapsedMs` 仿˜¯ç»“ç®—å­—æ®µï¼Œè®¾ç½®é¢æ¿çš„“当å‰ç”¨æ—¶â€å¿…须按 `startedAtMs`ã€æš‚åœç´¯è®¡å’Œå†»ç»“累计实时派生;ä¸è¦ç›´æŽ¥æŠŠè¿›è¡Œä¸­çš„ `currentLevel.elapsedMs` 当作展示值。 +- 推è页里的拼图作å“如果从è¿è¡Œæ€è¿›å…¥â€œæ”¹é€ â€ç»“果页,返回平å°åŽè¦æ¸…掉推è嵌入æ€çš„ `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,å†é‡æ–°æŒ‰æŽ¨è页自动å¯åŠ¨é€»è¾‘è¿›å…¥ä½œå“,ä¸èƒ½å¤ç”¨å·²ç»è¢«æ¸…空的旧 `puzzleRun`。 - 拼图è¿è¡Œæ€å…许å‰ç«¯ä½Žå»¶è¿Ÿäº¤äº’è¡¨çŽ°ï¼Œä½†é€šå…³ã€æŽ’è¡Œæ¦œã€å¥–励和作å“状æ€ä»ä»¥åŽç«¯ç¡®è®¤ä¸ºå‡†ã€‚ ## 抓大鹅 Match3D @@ -55,21 +60,22 @@ 当å‰ç´ æç”Ÿæˆæµæ°´çº¿ï¼š 1. 点击生æˆå‰å¼¹å‡ºæ³¥ç‚¹ç¡®è®¤ï¼Œè‰ç¨¿ç”Ÿæˆå›ºå®šæ¶ˆè€— `10` 泥点。 -2. å…ˆå†™å…¥å¯æ¢å¤è‰ç¨¿ profileï¼Œå†æ‰§è¡Œæ–‡æœ¬è®¡åˆ’ã€å›¾ç‰‡ç”Ÿæˆã€åˆ‡å›¾ã€OSS 上传ã€èƒŒæ™¯å’Œå®¹å™¨ç”Ÿæˆã€‚ +2. å…ˆå†™å…¥å¯æ¢å¤è‰ç¨¿ profileï¼Œå†æ‰§è¡Œæ–‡æœ¬è®¡åˆ’ã€å›¾ç‰‡ç”Ÿæˆã€åˆ‡å›¾ã€OSS 上传ã€èƒŒæ™¯å’Œå®¹å™¨ç”Ÿæˆï¼›è‰ç¨¿å®Œæˆæ¡ä»¶ä¸åŒ…å« `backgroundMusic`。 3. 物å“ç´ æä¸å†è°ƒç”¨ Hyper3D Rodin,ä¸å†ç”Ÿæˆ GLB。新è‰ç¨¿å’Œæ‰¹é‡æ–°å¢žå›ºå®šç”Ÿæˆ 2D 五视角素æã€‚ 4. ç‰©å“ sheet èµ° VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,å•å¼  `1:1` 图固定 `5*5`,æ¯å¼ æ‰¿è½½ `5` 个物å“ã€æ¯ä¸ªç‰©å“ `5` 个视角。 -5. 切图å‰å…ˆåœ¨æ•´å¼  sheet 上åšç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–å’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œå†æŒ‰æ ¼å­å¯¼å‡ºç‹¬ç«‹ PNGï¼›ä¸è¦å…ˆè£å‰ªå•æ ¼å†å„自去绿。 +5. 切图å‰å…ˆåœ¨æ•´å¼  sheet 上åšç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–å’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œå†æŒ‰æ ¼å­å¯¼å‡ºç‹¬ç«‹ PNGï¼›æ¯ä¸ªè§†è§’图å†ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå¹¶æŒ‰å‰©ä½™å¯è§ä¸»ä½“二次收紧;ä¸è¦å…ˆè£å‰ªå•æ ¼å†å„自去绿。 6. `generatedItemAssets[].imageViews[]` 是新素æä¸»å­—段,`imageSrc/imageObjectKey` åªå…¼å®¹é¦–张视角。 7. 文本生æˆç‰©å“åç§°æ—¶å¿…é¡»åŒæ—¶ç”Ÿæˆ `itemSize`,åªå…许 `大`ã€`中`ã€`å°`ã€‚è¯¥å­—æ®µéš `generatedItemAssets[].itemSize` æŒä¹…化并下å‘;历å²ç¼ºå¤±å­—æ®µçš„ç´ ææŒ‰ `大` å…¼å®¹ï¼Œæ¨¡åž‹ç¼ºå¤±æˆ–éžæ³•值按物å“åæœ¬åœ°æŽ¨æ–­ã€‚ -8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生æˆã€‚纯背景ä¸å¾—包å«é”…ã€ç›˜ã€æ‰˜ç›˜ã€HUDã€æŒ‰é’®ã€æ–‡å­—或物å“;容器图走 `/v1/images/edits` å‚è€ƒé€æ˜Žå®¹å™¨å›¾ã€‚ +8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生æˆã€‚纯背景ä¸å¾—包å«é”…ã€ç›˜ã€æ‰˜ç›˜ã€HUDã€æŒ‰é’®ã€æ–‡å­—或物å“,且入库å‰å¿…é¡»åˆæˆä¸ºå…¨ç”»å¹…ä¸é€æ˜Žå›¾ç‰‡ï¼Œä¸å…è®¸å‡ºçŽ°é€æ˜ŽåŒºåŸŸï¼›å®¹å™¨å›¾èµ° `/v1/images/edits` å‚è€ƒé€æ˜Žå®¹å™¨å›¾ã€‚ 9. å½“å‰æŠ“å¤§é¹…éŸ³é¢‘ç”Ÿæˆå…³é—­ï¼šå…¥å£æ—  `生æˆéŸ³æ•ˆ`,è‰ç¨¿ä¸ç”ŸæˆèƒŒæ™¯éŸ³ä¹æˆ–点击音效,结果页ä¸å±•ç¤ºèƒŒæ™¯éŸ³ä¹ Tab 或点击音效生æˆå…¥å£ã€‚åŽ†å² `backgroundMusic` / `clickSound` 字段继续兼容传递。 +10. UI 背景和容器资产的æŒä¹…化真相ä»åœ¨ `generatedItemAssets[].backgroundAsset`ï¼›Agent sessionã€work summary/detailã€ç»“果页和è¿è¡Œæ€å…¥å£éƒ½å¿…须把该字段æå‡ä¸º `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读å–,é¿å…è‰ç¨¿é‡è¿›ã€ç»“果页预览或试玩退回默认素æã€‚ 结果页当å‰ç»“构: - `作å“ä¿¡æ¯`:åç§°ã€æè¿°ã€æ ‡ç­¾ï¼›å°é¢ç¼–辑收å£åˆ°å‘å¸ƒé¢æ¿ã€‚ - `难度é…ç½®`:四档离散拖动æ¡ï¼Œæ˜¾ç¤ºéœ€è¦æ¶ˆé™¤ã€æ€»ç‰©å“æ•°ã€ç‰©å“ç§ç±»ã€å·²ç”Ÿæˆç‰©å“ç§ç±»ã€‚ - `ç´ æé…ç½® > 物å“`:两列素æå¡ï¼Œç‚¹å‡»æ‰“å¼€ç‹¬ç«‹äº”è§†è§’é¢„è§ˆé¢æ¿ï¼›æ”¯æŒåˆ é™¤ã€æ‰¹é‡æ–°å¢žå’Œæ‰¹é‡é‡æ–°ç”Ÿæˆã€‚æ›¿æ¢æ¨¡å¼å¿…é¡»ä¿ç•™åŽŸ `itemId` 和列表顺åºã€‚ -- `ç´ æé…ç½® > UI`:纯背景图与è¿è¡Œæ€ UI 预览,é‡ç”Ÿæˆæ¶ˆè€— `2` 泥点。 +- `ç´ æé…ç½® > UI`:纯背景图与è¿è¡Œæ€ UI 预览,é‡ç”Ÿæˆæ¶ˆè€— `2` 泥点;UI 预览必须å¤ç”¨è¿è¡Œæ€é¡¶éƒ¨ HUDã€ä¸­å¤®å®¹å™¨æ£‹ç›˜ã€å®¹å™¨å›¾å®šä½å’Œåº•éƒ¨æ§½ä½æ ·å¼ï¼Œä¸å•独维护一套简化预览 UI。 - `ç´ æé…ç½® > 容器形象`:å•独预览和é‡ç”Ÿæˆä¸­å¿ƒå®¹å™¨ï¼Œæ¶ˆè€— `2` 泥点。 è¿è¡Œæ€å½“å‰å£å¾„: @@ -80,9 +86,11 @@ - åˆå§‹ç‰©å“åæ ‡å›´ç»•容器å£ä¸­å¿ƒç”Ÿæˆï¼Œå¹¶ä¿ç•™å†…缩安全è·ç¦»ï¼Œé¿å…贴边和局部角è½èšé›†ã€‚ - 本地试玩与 Rust `module-match3d` åŽç«¯é¢†åŸŸç”Ÿæˆä½¿ç”¨åŒä¸€å¥—中心铺开å£å¾„;生æˆç‚¹è¦†ç›–四象é™ä¸”å‡å€¼æŽ¥è¿‘中心。 - è¿è¡Œæ€ä¼˜å…ˆæ¶ˆè´¹ 2D 生æˆå›¾ï¼›é»˜è®¤ç§¯æœ¨ / 程åºåŒ– 3D 表现åªä½œä¸ºè§†è§‰åˆ†æ”¯å’Œå…œåº•ï¼Œä¸æ”¹å˜è§„则真相。 -- è¿è¡Œæ€å¯åЍå‰è¦é¢„加载 `generatedItemAssets[].imageViews[]`ã€é¡¶å±‚ `generatedBackgroundAsset`ã€ç‰©å“挂载 `backgroundAsset` 中的背景和容器图;å¡ç‰‡æ‘˜è¦ç¼º UI 背景或容器字段时,进入è¿è¡Œæ€å‰å¿…须补读 work detail。 +- è¿è¡Œæ€å¯åЍå‰è¦é¢„加载 `generatedItemAssets[].imageViews[]`ã€é¡¶å±‚ `generatedBackgroundAsset`ã€ç‰©å“挂载 `backgroundAsset` 中的背景和容器图;å¡ç‰‡æ‘˜è¦ç¼º UI 背景或容器字段时,进入è¿è¡Œæ€å‰å¿…须补读 work detail。补读åŽçš„ profile 也è¦å†æ¬¡æå‡ `generatedItemAssets[].backgroundAsset`,确ä¿èƒŒæ™¯å’Œå®¹å™¨å­—段传给 `Match3DRuntimeShell`。 +- 局内容器图在移动端宽度接近å±å¹•å®½åº¦å¹¶å±…ä¸­æ˜¾ç¤ºï¼Œä¿æŒåŽŸå›¾æ¯”ä¾‹ä¸æ‹‰ä¼¸ï¼›ç”Ÿæˆå®¹å™¨å›¾åŠ è½½æˆåŠŸåŽæ£‹ç›˜å¤–壳逿˜Žä¸” `overflow-visible`ï¼Œåªæœ‰ç”Ÿæˆå›¾ç¼ºå¤±æˆ–åŠ è½½å¤±è´¥æ—¶æ‰æ˜¾ç¤ºé€æ˜Žå‚考容器兜底。 - generated ç§æœ‰å›¾æ¢ç­¾æœªå®Œæˆæ—¶ï¼Œå±€å†…物å“å…ˆéšè—等待,ä¸å¾—短暂显示默认积木;åŒä¸€æ‰¹èµ„æºåœ¨é‡å¯ run æ—¶ä¿ç•™å·²è§£æžç­¾å URLï¼Œåªæœ‰èµ„æºæºåˆ—表å˜åŒ–或æ¢ç­¾å¤±è´¥åŽæ‰å…许进入兜底视觉。 - `itemSize` åªç¼©æ”¾ç”Ÿæˆ 2D 图片本体:`大` 使用当å‰é»˜è®¤æ˜¾ç¤ºå°ºå¯¸ï¼Œ`中` å’Œ `å°` ç¼©å°æ˜¾ç¤ºï¼›ä¸æ”¹å˜åŽç«¯ä¸‹å‘的布局åŠå¾„ã€ç‚¹å‡»åŠå¾„或三消规则。 +- 抓大鹅è¿è¡Œæ€å³ä¸Šè§’常驻设置入å£ï¼Œä¸ç›´æŽ¥æš´éœ²é‡æ–°å¼€å§‹æŒ‰é’®ï¼›é‡æ–°å¼€å§‹æ”¶å£åˆ°è®¾ç½®é¢æ¿å†…,结算弹层ä»ä¿ç•™ç»“æžœæ€çš„冿¥ä¸€å±€åŠ¨ä½œã€‚ - 高 DPR 移动端 WebGL canvas å¿…é¡»é”定 CSS 尺寸,é¿å…å³ä¸‹æº¢å‡ºã€‚ ## 视觉å°è¯´ diff --git a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md index b489bf87..8a40e07c 100644 --- a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md +++ b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md @@ -85,6 +85,7 @@ server-rs + Axum + SpacetimeDB 7. 主站入å£å·²é”定移动端页é¢çº§ç¼©æ”¾ï¼›å•个游æˆé¡µé¢ä¸è¦å†é‡å¤å®žçŽ°æ•´é¡µç¼©æ”¾é”定。 8. 图åƒè¾“入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`ã€‚å¤–å±‚é¡µé¢æŒæœ‰ä¸šåŠ¡çŠ¶æ€ï¼Œç»„ä»¶åªæ‰¿æ‹…上传å¡ã€é¢„览ã€å‚考图缩略图ã€AI é‡ç»˜å¼€å…³ã€é”™è¯¯å±•示和æäº¤æŒ‰é’®ã€‚ 9. å‘现页 `分类` å­é¢‘é“的筛选必须打开独立 dialog / drawer / modal,至少支æŒçŽ©æ³•ç±»åž‹è¿‡æ»¤ä¸ŽæŽ’åºåˆ‡æ¢ï¼›ç­›é€‰ç»“果为空时显示空状æ€ï¼Œä¸æŠŠç­›é€‰å†…容展开在当å‰åˆ—表下方。 +10. “我的â€é¡µæ³¥ç‚¹ã€æ¸¸æˆæ—¶é•¿ã€çŽ©è¿‡ä¸‰å¼ ç»Ÿè®¡å¡åªå±•示å„è‡ªæ ‡ç­¾å’Œå€¼ï¼Œå†…å®¹å±…ä¸­ä¸”ä¸æ¢è¡Œï¼Œä¸åœ¨ç»Ÿè®¡åŒºåº•éƒ¨å±•ç¤ºâ€œæ›´æ–°äºŽâ€æ—¶é—´ã€‚ ## æ–‡æ¡ˆä¸Žç¼–ç  diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index b425c069..4d42df69 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -81,8 +81,7 @@ use crate::{ request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ - GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, - generate_sound_effect_asset_for_creation, + GeneratedCreationAudioTarget, generate_sound_effect_asset_for_creation, }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; @@ -120,7 +119,6 @@ const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str = const MATCH3D_QUESTION_THEME: &str = "你想创作什么题æ"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "éœ€è¦æ¶ˆé™¤å¤šå°‘次æ‰èƒ½é€šå…³"; const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你è¦åˆ›ä½œçš„关塿˜¯éš¾åº¦å‡ "; -const MATCH3D_BACKGROUND_MUSIC_ASSET_KIND: &str = "match3d_background_music"; const MATCH3D_CLICK_SOUND_ASSET_KIND: &str = "match3d_click_sound"; const MATCH3D_PIXEL_RETRO_STYLE_PROMPT: &str = "真正å¤å¤åƒç´  2D 游æˆé“å…· sprite 风格,先以约 64x64 低分辨率åƒç´ å—ç»˜åˆ¶å†æŒ‰æ•´æ•°å€æ”¾å¤§ï¼Œç¡¬è¾¹æ–¹å—åƒç´ æ¸…æ™°å¯è§ï¼Œæœ‰é™è‰²æ¿ 12-24 è‰²ï¼Œç¦æ­¢æŠ—é”¯é½¿ã€æŸ”焦ã€å¹³æ»‘æ¸å˜ã€çœŸå®ž 3D 渲染ã€PBR æè´¨å’Œæ‘„影光照。"; @@ -209,18 +207,10 @@ struct Match3DGeneratedItemPlan { sound_prompt: String, } -#[derive(Clone, Debug)] -struct Match3DGeneratedBackgroundMusicPlan { - title: String, - style: String, - prompt: String, -} - #[derive(Clone, Debug)] struct Match3DGeneratedDraftPlan { metadata: Match3DGeneratedWorkMetadata, items: Vec, - background_music: Match3DGeneratedBackgroundMusicPlan, background_prompt: String, } @@ -387,7 +377,12 @@ pub async fn create_match3d_agent_session( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -420,7 +415,12 @@ pub async fn get_match3d_agent_session( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -445,7 +445,12 @@ pub async fn submit_match3d_agent_message( Ok(json_success_body( Some(&request_context), Match3DAgentSessionResponse { - session: map_match3d_agent_session_response(session), + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, }, )) } @@ -479,7 +484,12 @@ pub async fn stream_match3d_agent_message( match result { Ok(session) => { - let session_response = map_match3d_agent_session_response(session); + let session_response = load_match3d_agent_session_response_with_persisted_assets( + &state, + owner_user_id.as_str(), + session, + ) + .await; if let Some(reply) = session_response.last_assistant_reply.clone() { yield Ok::(match3d_sse_json_event_or_error( "reply_delta", @@ -1368,7 +1378,7 @@ pub async fn generate_match3d_item_assets_for_work( item: map_match3d_work_profile_response(profile), generated_item_assets: sort_match3d_generated_assets(assets) .into_iter() - .map(Match3DGeneratedItemAssetJson::from) + .map(Match3DGeneratedItemAssetJson::from) .map(map_match3d_generated_item_asset_for_work) .collect(), }, @@ -1849,6 +1859,37 @@ async fn submit_and_finalize_match3d_message( }) } +async fn load_match3d_agent_session_response_with_persisted_assets( + state: &AppState, + owner_user_id: &str, + session: Match3DAgentSessionRecord, +) -> Match3DAgentSessionSnapshotResponse { + let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else { + return map_match3d_agent_session_response(session); + }; + let assets = + get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await; + map_match3d_agent_session_response_with_assets(session, &assets) +} + +fn resolve_match3d_session_existing_profile_id( + session: &Match3DAgentSessionRecord, +) -> Option { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) +} + async fn compile_match3d_draft_for_session( state: &AppState, request_context: &RequestContext, @@ -1985,7 +2026,6 @@ async fn compile_match3d_draft_for_session( profile_id.as_str(), &config, generated_work_metadata.items, - generated_work_metadata.background_music, existing_assets, ) .await?; @@ -2626,7 +2666,9 @@ impl From for Match3DGeneratedItemAsset { Self { item_id: asset.item_id, item_name: asset.item_name, - item_size: asset.item_size.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), + item_size: asset + .item_size + .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), image_src: asset.image_src, image_object_key: asset.image_object_key, image_views: asset.image_views, @@ -2715,23 +2757,12 @@ async fn generate_match3d_item_assets( profile_id: &str, config: &Match3DConfigJson, item_plan: Vec, - background_music_plan: Match3DGeneratedBackgroundMusicPlan, existing_assets: Vec, ) -> Result, Response> { - // 中文注释:外部生图ã€éŸ³é¢‘å’Œ OSS 写入都留在 api-server,SpacetimeDB reducer åªä¿å­˜ç¡®å®šæ€§è‰ç¨¿ã€‚ + // 中文注释:抓大鹅音频生æˆå½“å‰å…³é—­ï¼›è‡ªåЍè‰ç¨¿åªè¡¥é½ 2D 物å“图片和å¯é€‰ç‚¹å‡»éŸ³æ•ˆã€‚ let target_item_count = resolve_match3d_generated_item_count(config); let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); - if has_match3d_required_item_images(&assets, target_item_count) - && assets - .iter() - .take(target_item_count) - .any(has_match3d_background_music_audio) - && (!config.generate_click_sound - || assets - .iter() - .take(target_item_count) - .all(|asset| asset.click_sound.is_some())) - { + if has_match3d_required_generated_assets(&assets, target_item_count, config) { return Ok(assets.into_iter().take(target_item_count).collect()); } @@ -2749,17 +2780,6 @@ async fn generate_match3d_item_assets( ) .await?; } - assets = ensure_match3d_background_music_asset( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - &background_music_plan, - assets, - ) - .await?; assets = ensure_match3d_click_sound_assets( state, request_context, @@ -3159,85 +3179,6 @@ async fn ensure_match3d_click_sound_assets( Ok(assets) } -#[allow(clippy::too_many_arguments)] -async fn ensure_match3d_background_music_asset( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - plan: &Match3DGeneratedBackgroundMusicPlan, - assets: Vec, -) -> Result, Response> { - let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); - if assets.iter().any(has_match3d_background_music_audio) { - return Ok(assets); - } - - let Some(first_index) = assets - .iter() - .enumerate() - .min_by_key(|(_, asset)| match3d_item_sort_index(asset.item_id.as_str())) - .map(|(index, _)| index) - else { - return Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - match3d_background_music_missing_error("抓大鹅è‰ç¨¿ç¼ºå°‘å¯å†™å…¥èƒŒæ™¯éŸ³ä¹çš„物å“ç´ æ"), - )); - }; - - let title = require_match3d_background_music_title(plan) - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - let style = normalize_match3d_audio_style(plan.style.as_str()); - match generate_match3d_background_music_asset(state, owner_user_id, profile_id, &title, &style) - .await - { - Ok(music) => { - let asset = &mut assets[first_index]; - asset.background_music_title = Some(title); - asset.background_music_style = (!style.trim().is_empty()).then_some(style); - asset.background_music_prompt = Some(String::new()); - asset.background_music = Some(music); - asset.error = None; - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } - Err(error) => { - tracing::error!( - provider = MATCH3D_AGENT_PROVIDER, - session_id, - profile_id, - error = %error, - "抓大鹅è‰ç¨¿èƒŒæ™¯éŸ³ä¹ç”Ÿæˆå¤±è´¥ï¼Œç»ˆæ­¢æœ¬æ¬¡è‰ç¨¿ç”Ÿæˆå¹¶ç­‰å¾…é‡è¯•" - ); - return Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - error, - )); - } - } - - Ok(assets) -} - -fn has_match3d_background_music_audio(asset: &Match3DGeneratedItemAsset) -> bool { - asset - .background_music - .as_ref() - .is_some_and(|music| !music.audio_src.trim().is_empty()) -} - async fn generate_match3d_click_sound_asset( state: &AppState, owner_user_id: &str, @@ -3266,32 +3207,6 @@ async fn generate_match3d_click_sound_asset( Ok(asset) } -async fn generate_match3d_background_music_asset( - state: &AppState, - owner_user_id: &str, - profile_id: &str, - title: &str, - style: &str, -) -> Result { - generate_background_music_asset_for_creation( - state, - owner_user_id, - String::new(), - title.to_string(), - (!style.trim().is_empty()).then_some(style.to_string()), - None, - GeneratedCreationAudioTarget { - entity_kind: "match3d_work".to_string(), - entity_id: profile_id.to_string(), - slot: "background_music".to_string(), - asset_kind: MATCH3D_BACKGROUND_MUSIC_ASSET_KIND.to_string(), - profile_id: Some(profile_id.to_string()), - storage_prefix: LegacyAssetPrefix::Match3DAssets, - }, - ) - .await -} - #[allow(clippy::too_many_arguments)] async fn append_match3d_new_item_assets( state: &AppState, @@ -3620,32 +3535,6 @@ fn parse_match3d_draft_plan( .collect::>() }) .unwrap_or_default(); - let background_music = value - .get("backgroundMusic") - .or_else(|| value.get("background_music")) - .and_then(|music| { - let title = music - .get("title") - .and_then(Value::as_str) - .map(normalize_match3d_audio_title) - .filter(|value| !value.is_empty())?; - let style = music - .get("style") - .and_then(Value::as_str) - .map(normalize_match3d_audio_style) - .filter(|value| !value.is_empty())?; - let prompt = music - .get("prompt") - .and_then(Value::as_str) - .map(normalize_match3d_audio_prompt) - .unwrap_or_default(); - Some(Match3DGeneratedBackgroundMusicPlan { - title, - style, - prompt, - }) - }) - .unwrap_or(fallback.background_music); let background_prompt = value .get("backgroundPrompt") .or_else(|| value.get("background_prompt")) @@ -3661,7 +3550,6 @@ fn parse_match3d_draft_plan( tags: normalize_match3d_tag_candidates(tags), }, items: normalize_match3d_item_plan(config, items), - background_music, background_prompt, }) } @@ -3706,26 +3594,6 @@ fn normalize_match3d_work_summary(raw: &str) -> String { .to_string() } -fn normalize_match3d_audio_title(raw: &str) -> String { - raw.trim() - .trim_matches(['"', '\'', '“', 'â€', '。', ',', ',', 'ã€']) - .chars() - .filter(|character| !character.is_control()) - .take(40) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_audio_style(raw: &str) -> String { - raw.split([',', ',', 'ã€', '\n']) - .map(normalize_match3d_tag) - .filter(|value| !value.is_empty()) - .take(6) - .collect::>() - .join(", ") -} - fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { let theme = theme_text.trim(); let normalized_theme = if theme.is_empty() { "主题" } else { theme }; @@ -3751,23 +3619,11 @@ fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDr .collect::>(); Match3DGeneratedDraftPlan { background_prompt: build_fallback_match3d_background_prompt(config), - background_music: build_fallback_match3d_background_music_plan(config, &metadata.game_name), metadata, items, } } -fn build_fallback_match3d_background_music_plan( - _config: &Match3DConfigJson, - game_name: &str, -) -> Match3DGeneratedBackgroundMusicPlan { - Match3DGeneratedBackgroundMusicPlan { - title: normalize_match3d_audio_title(format!("{game_name}音ä¹").as_str()), - style: "轻快, 休闲, 消除, instrumental".to_string(), - prompt: String::new(), - } -} - fn normalize_match3d_item_name(raw: &str) -> String { raw.trim() .trim_matches(['"', '\'', '“', 'â€', '。', ',', ',', 'ã€']) @@ -3780,11 +3636,19 @@ fn normalize_match3d_item_name(raw: &str) -> String { } fn normalize_match3d_item_size(raw: &str) -> String { - let normalized = raw.trim().trim_matches(['"', '\'', '“', 'â€', '。', ',', ',', 'ã€']); + let normalized = raw + .trim() + .trim_matches(['"', '\'', '“', 'â€', '。', ',', ',', 'ã€']); match normalized { - "大" | "大型" | "å大" | "large" | "Large" | "L" | "l" => MATCH3D_ITEM_SIZE_LARGE.to_string(), - "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => MATCH3D_ITEM_SIZE_MEDIUM.to_string(), - "å°" | "å°åž‹" | "åå°" | "small" | "Small" | "S" | "s" => MATCH3D_ITEM_SIZE_SMALL.to_string(), + "大" | "大型" | "å大" | "large" | "Large" | "L" | "l" => { + MATCH3D_ITEM_SIZE_LARGE.to_string() + } + "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => { + MATCH3D_ITEM_SIZE_MEDIUM.to_string() + } + "å°" | "å°åž‹" | "åå°" | "small" | "Small" | "S" | "s" => { + MATCH3D_ITEM_SIZE_SMALL.to_string() + } _ => String::new(), } } @@ -3792,16 +3656,16 @@ fn normalize_match3d_item_size(raw: &str) -> String { fn infer_match3d_item_size(item_name: &str) -> String { let name = item_name.trim(); let large_keywords = [ - "西瓜", "å—瓜", "椰å­", "ç®±", "ç›’", "æ¡¶", "盆", "é”…", "å›", "ç“¶å­", "大瓶", "包", - "书包", "æž•", "抱枕", "玩å¶", "çƒ", "圆çƒ", "è¶³çƒ", "篮çƒ", "鼓", + "西瓜", "å—瓜", "椰å­", "ç®±", "ç›’", "æ¡¶", "盆", "é”…", "å›", "ç“¶å­", "大瓶", "包", "书包", + "æž•", "抱枕", "玩å¶", "çƒ", "圆çƒ", "è¶³çƒ", "篮çƒ", "鼓", ]; if large_keywords.iter().any(|keyword| name.contains(keyword)) { return MATCH3D_ITEM_SIZE_LARGE.to_string(); } let small_keywords = [ - "è‰èŽ“", "è“莓", "è‘¡è„", "樱桃", "莓", "ç³–", "ç³–æžœ", "钥匙", "硬å¸", "纽扣", "徽章", - "戒指", "耳环", "铃铛", "星星", "å®çŸ³", "å¶ç‰‡", "花瓣", "蘑è‡", "è´å£³", "å°ç« ", - "彩蛋", "棋å­", "骰å­", "挂件", + "è‰èŽ“", "è“莓", "è‘¡è„", "樱桃", "莓", "ç³–", "ç³–æžœ", "钥匙", "硬å¸", "纽扣", "徽章", "戒指", + "耳环", "铃铛", "星星", "å®çŸ³", "å¶ç‰‡", "花瓣", "蘑è‡", "è´å£³", "å°ç« ", "彩蛋", "棋å­", + "骰å­", "挂件", ]; if small_keywords.iter().any(|keyword| name.contains(keyword)) { return MATCH3D_ITEM_SIZE_SMALL.to_string(); @@ -4236,6 +4100,19 @@ fn has_match3d_required_item_images( .all(is_match3d_generated_asset_image_ready) } +fn has_match3d_required_generated_assets( + assets: &[Match3DGeneratedItemAsset], + required_item_count: usize, + config: &Match3DConfigJson, +) -> bool { + has_match3d_required_item_images(assets, required_item_count) + && (!config.generate_click_sound + || assets + .iter() + .take(required_item_count) + .all(|asset| asset.click_sound.is_some())) +} + fn upsert_match3d_generated_item_asset( assets: &mut Vec, asset: Match3DGeneratedItemAsset, @@ -4545,7 +4422,9 @@ async fn generate_match3d_background_image( &http_client, &settings, build_match3d_background_generation_prompt(config, prompt).as_str(), - Some("æ–‡å­—ã€æ°´å°ã€UIã€æŒ‰é’®ã€å€’计时ã€åˆ†æ•°ã€ç‰©å“ã€è§’è‰²ã€æ‰‹ã€è¾¹æ¡†ã€æ•™ç¨‹æµ®å±‚ã€èœå•"), + Some( + "æ–‡å­—ã€æ°´å°ã€UIã€æŒ‰é’®ã€å€’计时ã€åˆ†æ•°ã€ç‰©å“ã€è§’è‰²ã€æ‰‹ã€è¾¹æ¡†ã€æ•™ç¨‹æµ®å±‚ã€èœå•ã€é€æ˜ŽåŒºåŸŸã€é€æ˜Ž alphaã€é•‚ç©ºã€æ£‹ç›˜æ ¼é€æ˜Žåº•", + ), "9:16", 1, &[], @@ -4562,6 +4441,7 @@ async fn generate_match3d_background_image( "message": "抓大鹅背景图生æˆå¤±è´¥ï¼šæœªè¿”回图片", })) })?; + let background_image = make_match3d_background_image_opaque(background_image)?; let background_upload = persist_match3d_generated_bytes( state, owner_user_id, @@ -4743,7 +4623,7 @@ fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt .map(|style| format!("整体美术风格å‚考:{style}。")) .unwrap_or_default(); format!( - "{prompt}\n{style_clause}生æˆä¸€å¼  9:16 ç«–å±æŠ“å¤§é¹…æ¸¸æˆçº¯èƒŒæ™¯å›¾ï¼Œåªè¡¨çŽ°é¢˜ææ°›å›´ã€è‰²å½©å±‚次和场景环境。画é¢ä¸å¾—出现锅ã€åœ†ç›˜ã€æ‰˜ç›˜ã€æ‹¼å›¾æ§½ã€ç‰©å“æ§½ã€æ£‹ç›˜ã€å®¹å™¨è¾¹æ¡†ã€HUDã€æ–‡å­—ã€æŒ‰é’®ã€å€’计时ã€åˆ†æ•°ã€ç‰©å“ã€è§’è‰²æˆ–æ‰‹ã€‚ä¸­å¤®åŒºåŸŸä¿æŒå¹²å‡€é€šé€ï¼Œæ–¹ä¾¿è¿è¡Œæ€åŽç»­å åŠ é»˜è®¤äº¤äº’å®¹å™¨å’Œç‰©å“ç´ æã€‚" + "{prompt}\n{style_clause}生æˆä¸€å¼  9:16 ç«–å±æŠ“å¤§é¹…æ¸¸æˆçº¯èƒŒæ™¯å›¾ï¼Œåªè¡¨çŽ°é¢˜ææ°›å›´ã€è‰²å½©å±‚次和场景环境。必须全画幅ä¸é€æ˜Žï¼Œå››è¾¹å’Œè§’è½éƒ½è¦æœ‰å®Œæ•´çŽ¯å¢ƒåƒç´ ï¼Œä¸å¾—å‡ºçŽ°é€æ˜Ž alphaã€é€æ˜Žåº•ã€é•‚ç©ºæˆ–æ£‹ç›˜æ ¼é€æ˜ŽåŒºåŸŸã€‚ç”»é¢ä¸å¾—出现锅ã€åœ†ç›˜ã€æ‰˜ç›˜ã€æ‹¼å›¾æ§½ã€ç‰©å“æ§½ã€æ£‹ç›˜ã€å®¹å™¨è¾¹æ¡†ã€HUDã€æ–‡å­—ã€æŒ‰é’®ã€å€’计时ã€åˆ†æ•°ã€ç‰©å“ã€è§’è‰²æˆ–æ‰‹ã€‚ä¸­å¤®åŒºåŸŸä¿æŒå¹²å‡€é€šé€ï¼Œæ–¹ä¾¿è¿è¡Œæ€åŽç»­å åŠ é»˜è®¤äº¤äº’å®¹å™¨å’Œç‰©å“ç´ æã€‚" ) } @@ -4756,6 +4636,132 @@ fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: ) } +// 中文注释:9:16 è¿è¡ŒèƒŒæ™¯æ˜¯æ•´å±åº•å›¾ï¼Œå¿…é¡»å’Œä¸­å¿ƒå®¹å™¨é€æ˜Žç´ æåˆ†å±‚处ç†ï¼Œé¿å…å±€å†…éœ²å‡ºé€æ˜Žåº•。 +fn make_match3d_background_image_opaque( + image: DownloadedOpenAiImage, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅背景图解ç å¤±è´¥ï¼š{error}"), + })) + })?; + let mut rgba = source.to_rgba8(); + let matte = sample_match3d_background_opaque_matte(&rgba).unwrap_or([246, 243, 236]); + let mut changed = false; + + for pixel in rgba.pixels_mut() { + let alpha = pixel.0[3]; + if alpha == 255 { + continue; + } + pixel.0 = blend_match3d_background_pixel_over_matte(pixel.0, matte); + changed = true; + } + + if !changed { + return Ok(image); + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅背景图ä¸é€æ˜ŽåŒ–失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn sample_match3d_background_opaque_matte(image: &image::RgbaImage) -> Option<[u8; 3]> { + sample_match3d_background_matte_from_edges(image) + .or_else(|| sample_match3d_background_matte_from_pixels(image)) +} + +fn sample_match3d_background_matte_from_edges(image: &image::RgbaImage) -> Option<[u8; 3]> { + let (width, height) = image.dimensions(); + if width == 0 || height == 0 { + return None; + } + + let mut sampler = Match3DBackgroundMatteSampler::default(); + for x in 0..width { + sampler.push(image.get_pixel(x, 0).0); + sampler.push(image.get_pixel(x, height - 1).0); + } + for y in 1..height.saturating_sub(1) { + sampler.push(image.get_pixel(0, y).0); + sampler.push(image.get_pixel(width - 1, y).0); + } + sampler.finish() +} + +fn sample_match3d_background_matte_from_pixels(image: &image::RgbaImage) -> Option<[u8; 3]> { + let mut sampler = Match3DBackgroundMatteSampler::default(); + for pixel in image.pixels() { + sampler.push(pixel.0); + } + sampler.finish() +} + +#[derive(Default)] +struct Match3DBackgroundMatteSampler { + red: u64, + green: u64, + blue: u64, + weight: u64, +} + +impl Match3DBackgroundMatteSampler { + fn push(&mut self, pixel: [u8; 4]) { + let alpha = pixel[3] as u64; + if alpha < 32 { + return; + } + self.red = self.red.saturating_add(pixel[0] as u64 * alpha); + self.green = self.green.saturating_add(pixel[1] as u64 * alpha); + self.blue = self.blue.saturating_add(pixel[2] as u64 * alpha); + self.weight = self.weight.saturating_add(alpha); + } + + fn finish(self) -> Option<[u8; 3]> { + (self.weight > 0).then(|| { + [ + (self.red / self.weight) as u8, + (self.green / self.weight) as u8, + (self.blue / self.weight) as u8, + ] + }) + } +} + +fn blend_match3d_background_pixel_over_matte(pixel: [u8; 4], matte: [u8; 3]) -> [u8; 4] { + let alpha = pixel[3] as u16; + let inverse_alpha = 255u16.saturating_sub(alpha); + [ + blend_match3d_background_channel(pixel[0], matte[0], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[1], matte[1], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[2], matte[2], alpha, inverse_alpha), + 255, + ] +} + +fn blend_match3d_background_channel( + foreground: u8, + matte: u8, + alpha: u16, + inverse_alpha: u16, +) -> u8 { + ((foreground as u16 * alpha + matte as u16 * inverse_alpha + 127) / 255) as u8 +} + fn make_match3d_container_image_transparent( image: DownloadedOpenAiImage, ) -> Result { @@ -5733,8 +5739,9 @@ fn slice_match3d_material_sheet( let (crop_x, crop_y, crop_width, crop_height) = resolve_match3d_material_cell_crop(&source, row_count, row, col); let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_match3d_material_view_edge_matte(cropped); let mut cursor = std::io::Cursor::new(Vec::new()); - cropped + cleaned .write_to(&mut cursor, ImageFormat::Png) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ @@ -5778,6 +5785,34 @@ fn resolve_match3d_material_cell_crop( crop.to_crop_tuple() } +fn crop_match3d_material_view_edge_matte(image: image::DynamicImage) -> image::DynamicImage { + let mut image = image.to_rgba8(); + let (width, height) = image.dimensions(); + remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize); + let bounds = detect_match3d_material_visible_bounds(&image).unwrap_or_else(|| { + Match3DMaterialCellBounds { + x0: 0, + y0: 0, + x1: width, + y1: height, + } + }); + if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height { + return image::DynamicImage::ImageRgba8(image); + } + + image::DynamicImage::ImageRgba8( + image::imageops::crop_imm( + &image, + bounds.x0, + bounds.y0, + bounds.width(), + bounds.height(), + ) + .to_image(), + ) +} + #[derive(Clone, Copy, Debug)] struct Match3DMaterialCellBounds { x0: u32, @@ -5862,6 +5897,45 @@ fn detect_match3d_material_foreground_bounds( }) } +fn detect_match3d_material_visible_bounds( + image: &image::RgbaImage, +) -> Option { + let (width, height) = image.dimensions(); + let mut bounds: Option = None; + let mut visible_pixels = 0u32; + + for y in 0..height { + for x in 0..width { + let pixel = image.get_pixel(x, y).0; + if !is_match3d_material_visible_pixel(pixel) { + continue; + } + visible_pixels = visible_pixels.saturating_add(1); + bounds = Some(match bounds { + Some(current) => Match3DMaterialCellBounds { + x0: current.x0.min(x), + y0: current.y0.min(y), + x1: current.x1.max(x.saturating_add(1)), + y1: current.y1.max(y.saturating_add(1)), + }, + None => Match3DMaterialCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); + bounds.filter(|visible_bounds| { + visible_pixels >= min_visible_pixels + && visible_bounds.width() > 2 + && visible_bounds.height() > 2 + }) +} + fn sample_match3d_material_cell_background( source: &image::DynamicImage, cell: Match3DMaterialCellBounds, @@ -5932,6 +6006,167 @@ fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD } +fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, height: usize) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut changed = false; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if pixels[offset + 3] == 0 { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + // 中文注释:å•å›¾è¢«å‰æ™¯è¾¹ç•Œæ”¶ç´§åŽï¼Œæµ…绿框å¯èƒ½æ­£å¥½è´´åœ¨ PNG 外缘; + // 把外缘一段宽度作为去背ç§å­ï¼Œä½†åªæ¸…ç†ç»¿å¹• / 近白 matte,é¿å…误伤贴边主体。 + let edge_width = resolve_match3d_material_view_edge_cleanup_width(width, height); + for y in 0..height { + for x in 0..width { + if x >= edge_width + && y >= edge_width + && x.saturating_add(edge_width) < width + && y.saturating_add(edge_width) < height + { + continue; + } + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for _ in 0..edge_width { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + if !is_match3d_material_view_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + continue; + } + + if touches_match3d_material_background_mask(x, y, width, height, &background_mask) { + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + // 中文注释:边缘抗锯齿圈è¦ç›´æŽ¥ä»Žå¯è§åƒç´ é‡Œå‰”é™¤ï¼Œå†æŒ‰å‰©ä½™ä¸»ä½“釿–°æ”¶ç´§è£è¾¹ã€‚ + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 + || pixels[offset] != 0 + || pixels[offset + 1] != 0 + || pixels[offset + 2] != 0 + { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + changed = true; + } + } + + changed +} + +fn resolve_match3d_material_view_edge_cleanup_width(width: usize, height: usize) -> usize { + let min_side = width.min(height).max(1); + (min_side / 24).clamp(4, 12).min(min_side) +} + +fn is_match3d_material_view_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 + || is_match3d_material_soft_edge_pixel(pixel) + || compute_match3d_material_white_screen_score(pixel) > 0.18 +} + +fn is_match3d_material_visible_pixel(pixel: [u8; 4]) -> bool { + pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) +} + +fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 188 + && green.saturating_sub(red.max(blue)) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -7143,10 +7378,12 @@ mod tests { .expect("view should decode") .to_rgba8(); - assert_eq!( - decoded.get_pixel(0, 0).0[3], - 0, - "ç»¿å¹•èƒŒæ™¯å¿…é¡»åœ¨åˆ‡å‰²è¾“å‡ºä¸­å˜æˆé€æ˜Ž alpha" + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) + }), + "ç»¿å¹•èƒŒæ™¯å¿…é¡»åœ¨åˆ‡å‰²è¾“å‡ºä¸­å˜æˆé€æ˜Žæˆ–被å•ç´ æäºŒæ¬¡è£è¾¹ç§»é™¤" ); assert!( decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), @@ -7245,25 +7482,93 @@ mod tests { } #[test] - fn match3d_background_music_title_is_required_for_auto_draft() { - let missing = - require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { - title: " ,。 ".to_string(), - style: "轻快, 休闲".to_string(), - prompt: String::new(), - }) - .expect_err("自动è‰ç¨¿èƒŒæ™¯éŸ³ä¹å¿…é¡»æœ‰å¯æäº¤ç»™ Suno 的曲å"); + fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { + let width = 500; + let height = 500; + let item_names = vec!["丸å­".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 22..78 { + for x in 22..78 { + if x <= 24 || x >= 75 || y <= 24 || y >= 75 { + sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); + } + } + } + for y in 40..60 { + for x in 40..60 { + sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } - assert!(missing.body_text().contains("背景音ä¹")); + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; - let title = require_match3d_background_music_title(&Match3DGeneratedBackgroundMusicPlan { - title: " 果园轻舞。 ".to_string(), - style: "轻快, 休闲".to_string(), - prompt: String::new(), - }) - .expect("valid title should pass"); + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); - assert_eq!(title, "果园轻舞"); + assert!( + decoded.width() <= 24 && decoded.height() <= 24, + "å•ç´ æè£å‰ªåŽå¿…é¡»å†åƒæŽ‰æµ…绿抗锯齿边,ä¸èƒ½æŠŠç´ æè‡ªå¸¦ç»¿è¾¹ç®—进输出尺寸;got {}x{}", + decoded.width(), + decoded.height() + ); + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "å•ç´ æè¾“出 PNG ä¸èƒ½ä¿ç•™æµ…绿抗锯齿边åƒç´ " + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "å•ç´ æäºŒæ¬¡è£è¾¹ä¸èƒ½è¯¯åˆ ç‰©å“主体" + ); + } + + #[test] + fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { + let width = 72; + let height = 72; + let mut view = + image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); + for y in 10..62 { + for x in 10..62 { + view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); + } + } + for y in 24..48 { + for x in 24..48 { + view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + + let cleaned = + crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); + + assert!( + cleaned.width() <= 28 && cleaned.height() <= 28, + "å•图外缘浅绿框å³ä½¿è´´ä½ PNG è¾¹ç•Œï¼Œä¹Ÿå¿…é¡»è¢«é€æ˜ŽåŒ–并从å¯è§è¾¹ç•Œä¸­ç§»é™¤ï¼›got {}x{}", + cleaned.width(), + cleaned.height() + ); + assert!( + cleaned + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "å•图外缘浅绿框ä¸èƒ½æ®‹ç•™ä¸ºå¯è§åƒç´ " + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "扩大边缘清ç†å®½åº¦ä¸èƒ½è¯¯åˆ ç‰©å“主体" + ); } #[test] @@ -7351,6 +7656,42 @@ mod tests { ); } + #[test] + fn match3d_background_image_postprocess_removes_transparent_pixels() { + let width = 16; + let height = 16; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); + image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("background should encode"); + let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("background should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed background should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "抓大鹅 9:16 背景图入库å‰å¿…é¡»ç§»é™¤æ‰€æœ‰é€æ˜Ž alpha" + ); + assert_ne!( + decoded.get_pixel(0, 0).0, + [0, 0, 0, 0], + "åŽŸé€æ˜Žè§’è½å¿…é¡»è¢«åˆæˆåˆ°ä¸é€æ˜ŽèƒŒæ™¯è‰²ä¸Š" + ); + } + #[test] fn match3d_work_metadata_parses_gpt4o_json() { let metadata = parse_match3d_work_metadata( @@ -7454,6 +7795,17 @@ mod tests { ); } + #[test] + fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { + let assets = vec![test_match3d_generated_item_asset(1, "è‰èŽ“")]; + + assert!(has_match3d_required_generated_assets( + &assets, + 1, + &config("æ°´æžœ", 3, 3) + )); + } + #[test] fn match3d_item_asset_points_cost_counts_five_item_batches() { assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); @@ -7759,6 +8111,8 @@ mod tests { assert!(background_prompt.contains("ä¸å¾—出现锅")); assert!(background_prompt.contains("拼图槽")); assert!(background_prompt.contains("ç‰©å“æ§½")); + assert!(background_prompt.contains("全画幅ä¸é€æ˜Ž")); + assert!(background_prompt.contains("逿˜Ž alpha")); assert!(background_prompt.contains("默认交互容器")); assert!(container_prompt.contains("1:1")); @@ -7993,6 +8347,131 @@ mod tests { ); } + #[test] + fn match3d_agent_session_response_hydrates_persisted_ui_assets() { + let session = Match3DAgentSessionRecord { + session_id: "match3d-session-1".to_string(), + current_turn: 3, + progress_percent: 100, + stage: "DraftCompiled".to_string(), + anchor_pack: Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题æä¸»é¢˜".to_string(), + value: "æ°´æžœ".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }, + config: None, + draft: Some(Match3DResultDraftRecord { + profile_id: "match3d-profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "æ°´æžœ".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["æ°´æžœ".to_string(), "抓大鹅".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 12, + difficulty: 4, + total_item_count: 36, + publish_ready: false, + blockers: Vec::new(), + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "è‰èŽ“".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some( + "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), + ), + image_views: Vec::new(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + let response = map_match3d_agent_session_response_with_assets(session, &assets); + let draft = response.draft.expect("session draft should exist"); + + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft.generated_item_assets[0] + .background_asset + .as_ref() + .and_then(|asset| asset.image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + } + #[test] fn match3d_tag_normalization_only_strips_numbered_list_prefix() { assert_eq!(normalize_match3d_tag("3Dç´ æ"), "3Dç´ æ"); diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index b74051e7..3bf0da7a 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -427,26 +427,6 @@ pub(super) fn match3d_bad_gateway(message: impl Into) -> AppError { })) } -pub(super) fn match3d_background_music_missing_error(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": MATCH3D_AGENT_PROVIDER, - "message": message.into(), - "missingAssets": ["背景音ä¹"], - })) -} - -pub(super) fn require_match3d_background_music_title( - plan: &Match3DGeneratedBackgroundMusicPlan, -) -> Result { - let title = normalize_match3d_audio_title(plan.title.as_str()); - if title.is_empty() { - return Err(match3d_background_music_missing_error( - "抓大鹅è‰ç¨¿èƒŒæ™¯éŸ³ä¹å称为空,无法完æˆèƒŒæ™¯éŸ³ä¹ç”Ÿæˆ", - )); - } - Ok(title) -} - pub(super) fn map_match3d_work_profile_response( item: Match3DWorkProfileRecord, ) -> Match3DWorkProfileResponse { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 59a8f390..9c2d722e 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -633,7 +633,7 @@ test('creation hub published work delete action is revealed without opening card ); expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); - expect(screen.queryByRole('button', { name: '分享' })).toBeNull(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); await user.keyboard('{ArrowLeft}'); @@ -684,7 +684,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => { expect(openedItems).toEqual([persistedDraft]); }); -test('creation hub published swipe share button copies share text without opening the card', async () => { +test('creation hub published share icon copies share text without opening the card', async () => { const user = userEvent.setup(); const writeText = vi.fn(async () => undefined); const onOpenPuzzleDetail = vi.fn(); @@ -727,9 +727,11 @@ test('creation hub published swipe share button copies share text without openin />, ); - screen.getByRole('button', { name: /查看详情《沉钟拼图》/u }).focus(); - await user.keyboard('{ArrowLeft}'); - await user.click(screen.getByRole('button', { name: '分享' })); + const shareButton = screen.getByRole('button', { name: '分享' }); + expect(shareButton).toBeTruthy(); + expect(screen.queryByText('删除')).toBeNull(); + + await user.click(shareButton); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('邀请你æ¥çŽ©ã€Šæ²‰é’Ÿæ‹¼å›¾ã€‹'), @@ -746,6 +748,45 @@ test('creation hub published swipe share button copies share text without openin ).toBeTruthy(); }); +test('creation hub published share icon is shown directly on the card header', () => { + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); +}); + test('creation hub left swipe draft reveals delete without opening card', () => { const onDeletePublished = vi.fn(); const onOpenDraft = vi.fn(); diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index d940663d..5fd9cc13 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -248,7 +248,7 @@ export function CustomWorldWorkCard({ const isPublished = item.status === 'published'; const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath); - const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0); + const swipeActionCount = onDelete ? 1 : 0; const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX; const canClaimPointIncentive = Boolean(onClaimPointIncentive) && @@ -584,43 +584,6 @@ export function CustomWorldWorkCard({ className="creation-work-card__swipe-underlay" >
- {canUseShareAction ? ( - - ) : null} {onDelete ? (
+ {canUseShareAction ? ( + + ) : null}
diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 28e29f4b..3ded600e 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -1349,12 +1349,121 @@ describe('Match3DResultView', () => { fireEvent.click(screen.getByRole('button', { name: '预览UI页é¢' })); expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy(); + expect(screen.getByText('第 1 å…³')).toBeTruthy(); + expect(screen.getByText('抓大鹅')).toBeTruthy(); expect(screen.getByText('1:30')).toBeTruthy(); + const previewBoard = screen.getByTestId('match3d-ui-preview-board'); + expect(previewBoard.className).toContain('bg-transparent'); + expect(previewBoard.className).not.toContain('rounded-full'); + const containerImage = document.querySelector( + 'img[src="/match3d-background-references/pot-fused-reference.png"]', + ); + expect(containerImage).toBeTruthy(); + expect(containerImage?.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage?.className).toContain('-translate-x-1/2'); + expect( + document.querySelector('.animate-spin, [class*="border-l-transparent"]'), + ).toBeNull(); + expect( + document.querySelector( + 'svg[class*="lucide-settings"], [data-lucide="settings"]', + ), + ).toBeTruthy(); + }); + + test('ç´ æé…ç½® UI å­ Tab ä»Žç‰©å“æŒ‚载资产展示生æˆèƒŒæ™¯å’Œå®¹å™¨', async () => { + const onStartTestRun = vi.fn(); + const profile = createProfile({ + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [ + { + ...createReadyGeneratedItemAsset(1), + itemName: 'è‰èŽ“', + backgroundAsset: { + prompt: '果园背景', + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + imageObjectKey: + 'generated-match3d-assets/session/profile/background/background.png', + containerPrompt: '果园容器', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + containerImageObjectKey: + 'generated-match3d-assets/session/profile/ui-container/container.png', + status: 'image_ready', + error: null, + }, + }, + ], + }); + vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({ + item: createProfile({ generatedItemAssets: [] }), + }); + vi.mocked( + match3dWorksService.updateMatch3DGeneratedItemAssets, + ).mockResolvedValue({ + item: profile, + }); + + render( + {}} + onStartTestRun={onStartTestRun} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: 'ç´ æé…ç½®' })); + fireEvent.click(screen.getByRole('button', { name: 'UI' })); + + expect(screen.getByAltText('游æˆèƒŒæ™¯å›¾').getAttribute('src')).toBe( + '/generated-match3d-assets/session/profile/background/background.png', + ); + expect(screen.getByLabelText('UIèƒŒæ™¯å›¾ç”»é¢æè¿°æç¤ºè¯')).toHaveProperty( + 'value', + '果园背景', + ); + + fireEvent.click(screen.getByRole('button', { name: '预览UI页é¢' })); + expect( + document.querySelector( + 'img[src="/generated-match3d-assets/session/profile/background/background.png"]', + ), + ).toBeTruthy(); + expect( + document.querySelector( + 'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]', + ), + ).toBeTruthy(); expect( document.querySelector( 'img[src="/match3d-background-references/pot-fused-reference.png"]', ), - ).toBeTruthy(); + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '试玩' })); + + await waitFor(() => { + expect(onStartTestRun).toHaveBeenCalledWith( + expect.objectContaining({ + backgroundPrompt: '果园背景', + backgroundImageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + generatedBackgroundAsset: expect.objectContaining({ + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + }), + }), + { + itemTypeCountOverride: 1, + }, + ); + }); }); test('ç´ æé…ç½® UI å­ Tab 修改æç¤ºè¯åŽè°ƒç”¨èƒŒæ™¯å›¾ç”ŸæˆæŽ¥å£å¹¶åˆ·æ–°ç´ æ', async () => { diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx index 172fbff5..09e09969 100644 --- a/src/components/match3d-result/Match3DResultView.tsx +++ b/src/components/match3d-result/Match3DResultView.tsx @@ -8,6 +8,7 @@ import { Play, Plus, Send, + Settings, Trash2, Wand2, X, @@ -24,6 +25,7 @@ import { createPortal } from 'react-dom'; import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio'; import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent'; import type { + Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkProfile, PutMatch3DWorkRequest, @@ -49,11 +51,19 @@ import { import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import { + MATCH3D_RUNTIME_BOARD_BASE_CLASS, + MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, + MATCH3D_RUNTIME_BOARD_WIDTH, + MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, + MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, + MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, - MATCH3D_RUNTIME_GLASS_SPINNER_CLASS, - MATCH3D_RUNTIME_GLASS_TIMER_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, + MATCH3D_RUNTIME_HEADER_CARD_CLASS, + MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, + MATCH3D_RUNTIME_STAGE_CLASS, + MATCH3D_RUNTIME_TIMER_CLASS, } from '../match3d-runtime/match3dRuntimeUiStyles'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -299,6 +309,44 @@ function resolveMatch3DBackgroundPreviewSource( ); } +function findMatch3DGeneratedBackgroundAsset( + generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [], +): Match3DGeneratedBackgroundAsset | null { + return ( + generatedItemAssets.find((asset) => asset.backgroundAsset)?.backgroundAsset ?? + null + ); +} + +function promoteMatch3DGeneratedBackgroundAsset( + profile: Match3DWorkProfile, +): Match3DWorkProfile { + const fallbackBackground = + profile.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []); + if (!fallbackBackground) { + return profile; + } + + return { + ...profile, + backgroundPrompt: + profile.backgroundPrompt ?? fallbackBackground.prompt ?? null, + backgroundImageSrc: + profile.backgroundImageSrc ?? + fallbackBackground.imageSrc ?? + fallbackBackground.imageObjectKey ?? + null, + backgroundImageObjectKey: + profile.backgroundImageObjectKey ?? + fallbackBackground.imageObjectKey ?? + fallbackBackground.imageSrc ?? + null, + generatedBackgroundAsset: + profile.generatedBackgroundAsset ?? fallbackBackground, + }; +} + function resolveMatch3DBackgroundPrompt( profile: Match3DWorkProfile, draft: Match3DResultDraft | null, @@ -1144,11 +1192,14 @@ function buildPlayableProfile( ) { const payload = buildSavePayload(editState); if (!payload) { - return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets); + return promoteMatch3DGeneratedBackgroundAsset( + attachMatch3DGeneratedItemAssets(profile, generatedItemAssets), + ); } - return attachMatch3DGeneratedItemAssets( - { + return promoteMatch3DGeneratedBackgroundAsset( + attachMatch3DGeneratedItemAssets( + { ...profile, gameName: payload.gameName, themeText: payload.themeText ?? profile.themeText, @@ -1157,8 +1208,9 @@ function buildPlayableProfile( coverImageSrc: payload.coverImageSrc, clearCount: payload.clearCount, difficulty: payload.difficulty, - }, - generatedItemAssets, + }, + generatedItemAssets, + ), ); } @@ -1219,15 +1271,15 @@ function attachMatch3DGeneratedItemAssets( generatedItemAssets: readonly Match3DGeneratedItemAsset[], ) { if (generatedItemAssets.length <= 0) { - return profile; + return promoteMatch3DGeneratedBackgroundAsset(profile); } // 中文注释:试玩入å£ä¾èµ–当å‰é¡µé¢å¯è§çš„生æˆç´ æï¼›ä¿å­˜æŽ¥å£è‹¥è¿”回旧快照,ä¸èƒ½æŠŠç´ æä»Žè¿è¡Œæ€å…¥å‚里丢掉。 - return { + return promoteMatch3DGeneratedBackgroundAsset({ ...profile, generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets), - }; + }); } function attachMatch3DGeneratedBackgroundAsset( @@ -2996,35 +3048,46 @@ function Match3DUIRuntimePreviewPanel({ aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-cover" /> -
+
- 1:30 + + + + 第 1 关 + + + 抓大鹅 + + + 1:30 + - +
-
+
@@ -3152,6 +3215,10 @@ export function Match3DResultView({ onPublished, onStartTestRun, }: Match3DResultViewProps) { + const promotedProfile = useMemo( + () => promoteMatch3DGeneratedBackgroundAsset(profile), + [profile], + ); const [editState, setEditState] = useState(() => createEditState(profile)); const [activeTab, setActiveTab] = useState('work'); const [activeAssetConfigTab, setActiveAssetConfigTab] = @@ -3207,8 +3274,8 @@ export function Match3DResultView({ const [isStartingTestRun, setIsStartingTestRun] = useState(false); const [isGeneratingTags, setIsGeneratingTags] = useState(false); const generatedItemAssets = useMemo( - () => resolveMatch3DResultGeneratedItemAssets(profile, draft), - [draft, profile], + () => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft), + [draft, promotedProfile], ); const blockers = useMemo( () => buildPublishBlockers(editState, generatedItemAssets), @@ -3221,34 +3288,44 @@ export function Match3DResultView({ const canStartTestRun = testRunBlockers.length === 0; const canSubmit = blockers.length === 0; const totalItemCount = - (normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) * - 3; + (normalizePositiveInteger(editState.clearCountText) ?? + promotedProfile.clearCount) * 3; const backgroundPreviewSrc = useMemo( () => resolveMatch3DBackgroundPreviewSource( - profile, + promotedProfile, draft, generatedItemAssets, ), - [draft, generatedItemAssets, profile], + [draft, generatedItemAssets, promotedProfile], ); const backgroundPrompt = useMemo( - () => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets), - [draft, generatedItemAssets, profile], + () => + resolveMatch3DBackgroundPrompt( + promotedProfile, + draft, + generatedItemAssets, + ), + [draft, generatedItemAssets, promotedProfile], ); const containerPrompt = useMemo( - () => resolveMatch3DContainerPrompt(profile, draft, generatedItemAssets), - [draft, generatedItemAssets, profile], + () => + resolveMatch3DContainerPrompt( + promotedProfile, + draft, + generatedItemAssets, + ), + [draft, generatedItemAssets, promotedProfile], ); const containerPreviewSrc = useMemo( () => resolveMatch3DContainerPreviewSource( - profile, + promotedProfile, draft, generatedItemAssets, ) || MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC, - [draft, generatedItemAssets, profile], + [draft, generatedItemAssets, promotedProfile], ); const coverSourceAssets = useMemo( () => diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index c8135ce5..ed1a51fd 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; @@ -200,6 +207,33 @@ test('顶部 HUD 坹齿‹¼å›¾æ ·å¼å±•示关å¡å和倒计时', () => { expect(screen.getByText('第 1 å…³')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); expect(screen.getByText('10:00')).toBeTruthy(); + expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '釿–°å¼€å§‹' })).toBeNull(); +}); + +test('抓大鹅å³ä¸Šè§’è®¾ç½®é¢æ¿å†…ç½®é‡æ–°å¼€å§‹', () => { + const run = startLocalMatch3DRun(4); + const onRestart = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' })); + + const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' }); + expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy(); + expect(within(dialog).getByText('已清除 0/12')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '釿–°å¼€å§‹' })); + + expect(onRestart).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull(); }); test('推è页抓大鹅è¿è¡Œæ€éšè—返回按钮和结算返回入å£', () => { @@ -991,7 +1025,7 @@ test('è¿è¡Œæ€ä¼šæ¢ç­¾å¹¶æ¸²æŸ“抓大鹅中心容器 UI 图', async () => { const containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; - expect(containerImage.className).toContain('w-[min(96vw,28rem)]'); + expect(containerImage.className).toContain('w-[min(99vw,34rem)]'); expect(containerImage.className).toContain('h-auto'); expect(containerImage.className).toContain('left-1/2'); expect(containerImage.className).toContain('-translate-x-1/2'); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index ed4f1721..c6745bea 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, Clock3, RotateCcw, + Settings, Sparkles, XCircle, } from 'lucide-react'; @@ -49,11 +50,18 @@ import { resolveRenderableItemFrame, } from './match3dRuntimePresentation'; import { + MATCH3D_RUNTIME_BOARD_BASE_CLASS, + MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, + MATCH3D_RUNTIME_BOARD_WIDTH, + MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, + MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, + MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, MATCH3D_RUNTIME_HEADER_CARD_CLASS, MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, + MATCH3D_RUNTIME_STAGE_CLASS, MATCH3D_RUNTIME_TIMER_CLASS, MATCH3D_RUNTIME_TIMER_URGENT_CLASS, } from './match3dRuntimeUiStyles'; @@ -697,6 +705,7 @@ export function Match3DRuntimeShell({ const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] = useState(''); + const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME; const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG; const runtimeGeneratedItemAssets = useMemo( @@ -1251,6 +1260,7 @@ export function Match3DRuntimeShell({ isRunState(run.status, 'running') ? MATCH3D_RUNTIME_TIMER_URGENT_CLASS : MATCH3D_RUNTIME_TIMER_CLASS; + const canRestartRun = Boolean(run?.runId) && !isBusy; return (
setIsSettingsPanelOpen(true)} + aria-label="打开抓大鹅设置" > - +
-
+
* { + position: relative; + z-index: 1; } .puzzle-runtime-dialog__line { diff --git a/src/routing/RouteImageReadyGate.test.ts b/src/routing/RouteImageReadyGate.test.ts index 9cd4bed7..f6a4f3da 100644 --- a/src/routing/RouteImageReadyGate.test.ts +++ b/src/routing/RouteImageReadyGate.test.ts @@ -59,7 +59,7 @@ describe('RouteImageReadyGate image url helpers', () => { RouteImageReadyGate, { eyebrow: '正在载入游æˆ', - text: '正在载入冒险...', + text: '正在加载内容', }, createElement( 'section', @@ -100,7 +100,7 @@ describe('RouteImageReadyGate image url helpers', () => { const { container } = render( createElement(RouteLoadingScreen, { eyebrow: '正在载入游æˆ', - text: '正在载入冒险...', + text: '正在加载内容', }), ); const shell = container.firstElementChild; diff --git a/src/routing/appRoutes.tsx b/src/routing/appRoutes.tsx index 87de1131..27ffa541 100644 --- a/src/routing/appRoutes.tsx +++ b/src/routing/appRoutes.tsx @@ -164,7 +164,7 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute { return { kind: 'game', loadingEyebrow: '正在载入游æˆ', - loadingText: '正在载入冒险...', + loadingText: '正在加载内容', Component: GameApp, }; }