From 0041b95f728cc2b195564363c8ec339038c8b01d 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: Thu, 4 Jun 2026 22:34:19 +0800 Subject: [PATCH] feat(jump-hop): optimize generated assets and runtime background --- .hermes/shared-memory/pitfalls.md | 20 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 15 +- .../api-server/src/generated_asset_sheets.rs | 9 +- server-rs/crates/api-server/src/jump_hop.rs | 493 +++++++++++++++--- .../src/generated_asset_sheets/alpha.rs | 179 +++++-- .../src/generated_asset_sheets/color.rs | 18 + .../src/generated_asset_sheets/mod.rs | 8 +- .../src/generated_asset_sheets/sheet.rs | 125 ++++- .../tests/generated_asset_sheets.rs | 68 ++- .../crates/spacetime-client/src/jump_hop.rs | 2 +- .../spacetime-client/src/mapper/runtime.rs | 16 +- .../crates/spacetime-module/src/jump_hop.rs | 2 +- .../JumpHopRuntimeShell.test.tsx | 163 +++++- .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 230 ++++++-- .../JumpHopCreationWorkspace.test.tsx | 4 +- .../workspaces/JumpHopCreationWorkspace.tsx | 4 +- .../miniGameDraftGenerationProgress.test.ts | 4 +- 17 files changed, 1160 insertions(+), 200 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9b4f695e..a92c08d3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1804,10 +1804,26 @@ - çŽ°è±¡ï¼šè·³ä¸€è·³æ¾æ‰‹åŽå¦‚æžœåŽç«¯å¾ˆå¿«è¿”回下一帧 run,地å—窗å£ä¼šç«‹åˆ»å‰ç§»ï¼Œè§’色翻腾动画看起æ¥åƒæ²¡æ’­æ”¾ï¼›è‹¥åŒæ—¶åˆ·æ–°å›¾ç‰‡èµ„产,还å¯èƒ½è¢«è¯¯è®¤ä¸ºåœ°å—频闪。 - 原因:åŽç«¯ run 是规则真相,å‰ç«¯ runtime åˆéœ€è¦ä½Žå»¶è¿Ÿè¡¨çŽ°ã€‚å¦‚æžœ DOM å¹³å°å±‚直接用最新 `run.currentPlatformIndex` 渲染,åŽç«¯å›žåŒ…会抢在动画å‰å®Œæˆè§†è§‰åˆ‡æ¢ã€‚ -- 处ç†ï¼šå‰ç«¯ä¿ç•™ç‹¬ç«‹ `displayRun`ï¼Œæ¾æ‰‹åŽå…ˆè¿›å…¥ `isJumpAnimating=true`,角色在当å‰çª—å£å†…æ’值飞å‘目标地å—;约 `300ms` åŽå†æŠŠ `displayRun` 切到最新åŽç«¯ run,并进入约 `1440ms` çš„ `platformAdvancing` 表现æ€ã€‚æŽ¨è¿›æœŸé—´åœ°å— DOM 层和 Three.js 角色层必须统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—用相机å移自然离开视野,新预览地å—从上方露出;ä¸è¦å†è®© p1/p2 å„自 top/left è¿‡æ¸¡ã€‚ç›¸æœºå±‚å¿…é¡»åŒæ—¶è®¾ç½® `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œé¿å…先横å‘瞬切居中å†çºµå‘推进。地å—ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,ä¸èƒ½ç›´æŽ¥æ”¹å®½é«˜çž¬åˆ‡ï¼›å½“剿€ä¸è¦é¢å¤–å  CSS scale。正å¼èƒœè´Ÿã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ—¶é•¿å’ŒæŽ’行榜ä»ä»¥åŽç«¯ run 为准,å‰ç«¯åªå»¶è¿Ÿæ˜¾ç¤ºæ€ã€‚ -- 验è¯ï¼š`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平å°ä»åœåœ¨æ—§çª—å£ï¼ŒåŠ¨ç”»ç»“æŸåŽè¿›å…¥ `data-platform-advancing=true`,Three 角色层与地å—层åŒåœ¨ `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` å’Œ `--jump-hop-camera-shift-y` 完æˆç›¸æœºæ–œå‘推进,并校验å¯è§åœ°å—按深度ä¿ç•™ä¸åŒè§†è§‰å°ºå¯¸ã€è¿è¡Œæ€å¹³å°å®½é«˜ä½¿ç”¨å›ºå®šåŸºå‡†å€¼ã€æŽ¨è¿›æ€ transform transition 为 `1440ms`。 +- 处ç†ï¼šå‰ç«¯ä¿ç•™ç‹¬ç«‹ `displayRun`ï¼Œæ¾æ‰‹åŽå…ˆè¿›å…¥ `isJumpAnimating=true`,角色在当å‰çª—å£å†…æ’值飞å‘目标地å—;约 `300ms` åŽå†æŠŠ `displayRun` 切到最新åŽç«¯ run,并进入约 `1440ms` çš„ `platformAdvancing` 表现æ€ã€‚æŽ¨è¿›æœŸé—´åœ°å— DOM 层和 Three.js 角色层必须统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—用相机å移自然离开视野,新预览地å—从上方露出;ä¸è¦å†è®© p1/p2 å„自 top/left è¿‡æ¸¡ã€‚ç›¸æœºå±‚å¿…é¡»åŒæ—¶è®¾ç½® `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œé¿å…先横å‘瞬切居中å†çºµå‘推进。地å—ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,ä¸èƒ½ç›´æŽ¥æ”¹å®½é«˜çž¬åˆ‡ï¼›å½“剿€ä¸è¦é¢å¤–å  CSS scale。相机推进期间角色自身也ä¸èƒ½ä¿ç•™ `left/top` transition,å¦åˆ™ `displayRun` 切æ¢é€ æˆçš„è§’è‰²å±€éƒ¨åæ ‡å˜æ›´ä¼šå’Œçˆ¶çº§ camera layer ä½ç§»å åŠ ï¼Œè§†è§‰ä¸Šåƒè½åœ°åŽåˆä»Žå±å¹•外飞回;角色推进期åªå…许 transform / opacity transition。正å¼èƒœè´Ÿã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ—¶é•¿å’ŒæŽ’行榜ä»ä»¥åŽç«¯ run 为准,å‰ç«¯åªå»¶è¿Ÿæ˜¾ç¤ºæ€ã€‚ +- 验è¯ï¼š`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平å°ä»åœåœ¨æ—§çª—å£ï¼ŒåŠ¨ç”»ç»“æŸåŽè¿›å…¥ `data-platform-advancing=true`,Three 角色层与地å—层åŒåœ¨ `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` å’Œ `--jump-hop-camera-shift-y` 完æˆç›¸æœºæ–œå‘推进,并校验å¯è§åœ°å—按深度ä¿ç•™ä¸åŒè§†è§‰å°ºå¯¸ã€è¿è¡Œæ€å¹³å°å®½é«˜ä½¿ç”¨å›ºå®šåŸºå‡†å€¼ã€æŽ¨è¿›æ€ transform transition 为 `1440ms`ã€æŽ¨è¿›æ€è§’色 transition ä¸åŒ…å« `left/top`。 - å…³è”:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`src/services/jump-hop/jumpHopRuntimeModel.ts`ã€`server-rs/crates/module-jump-hop/src/application.rs`。 +## 跳一跳相机推进ä¸è¦è®©åœ°å—å›¾ç‰‡å›žé€€åˆ°åŽŸåž‹æ–¹å— + +- 现象:角色è½åˆ°ä¸‹ä¸€å—åŽï¼Œç›¸æœºæŽ¨è¿›æ—¶æ—§åœ°å—图片çªç„¶æ¶ˆå¤±ï¼Œæˆ–新预览地å—先露出浅色原型方å—,éšåŽçœŸå®ž image2 切片æ‰å‡ºçŽ°ã€‚ +- 原因:旧地å—进入 exiting çŠ¶æ€æ—¶å¦‚æžœ React key 从 `platformId` å˜æˆ `platformId-exiting`ï¼Œå›¾ç‰‡ç»„ä»¶ä¼šé‡æ–°æŒ‚载并丢失已加载状æ€ï¼›åŒæ—¶ `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` å°šæœªè§¦å‘æ—¶æ˜¾ç¤º fallback 原型地å—。 +- 处ç†ï¼šexiting 地å—继续使用稳定 `platformId` key,让旧图片组件在推进期å¤ç”¨ï¼›æœ‰çœŸå®ž `resolvedUrl` 且未错误时直接ä¿ç•™çœŸå®ž ``,åªåœ¨æ—  URL 或加载失败时显示 fallbackï¼›å½“å‰ 3 å—之外的åŽç»­åœ°å—通过éšè—预加载图片æå‰è§£æžç­¾å URL å’Œæµè§ˆå™¨ç¼“存。 +- 验è¯ï¼š`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL ä¸éœ²å‡º `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`。 +- å…³è”:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 + +## è·³ä¸€è·³åœ°å—æŠ å›¾ä¸è¦ç”¨ç»¿å¹•或近白底识别 + +- 现象:跳一跳生æˆè‰åœ°ã€èбã€é›ªåœ°ã€ç™½çŸ³æˆ–äº‘æœµåœ°å—æ—¶ï¼Œé€æ˜ŽåŒ–会把绿色 / 白色主体局部扣掉,è¿è¡Œæ€çœ‹åˆ°å¹³å°ç¼ºå£ã€å˜è–„或主体消失。 +- 原因:通用图集默认按绿幕和近白底åšé€æ˜ŽåŒ–ï¼Œé€‚åˆ UI / 普通物å“,但跳一跳地å—天然高频包å«ç»¿è‰²å’Œç™½è‰²ï¼›å¦‚果继续用 `#00FF00` ç»¿å¹•æˆ–è¿‘ç™½èƒŒæ™¯è¯†åˆ«ï¼Œç´ ææœ¬ä½“会è½å…¥èƒŒæ™¯åˆ†æ•°ã€‚旧逻辑还会清ç†éžè¾¹ç¼˜è¿žé€šçš„高置信 key 色å—,é‡åˆ°ä¸»ä½“内部撞色时也å¯èƒ½è¯¯ä¼¤ã€‚ +- 处ç†ï¼šè·³ä¸€è·³åœ°å—图集 prompt å›ºå®šè¦æ±‚å•一纯洋红 `#FF00FF` key 背景;切片å‰åŽé€æ˜ŽåŒ–调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`ï¼Œåªæ‰£æ´‹çº¢ keyï¼Œå…³é—­è¿‘ç™½æ‰£é™¤ï¼Œå¹¶ä¸”ä¸æ¸…ç†éžè¾¹ç¼˜è¿žé€š key 色åƒç´ ã€‚é€šç”¨ç»¿å¹•å‡½æ•°ä¿æŒé»˜è®¤ç»¿å¹• / 近白兼容,é¿å…影哿‹¼å›¾ã€æŠ“大鹅和敲木鱼。 +- 验è¯ï¼š`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key ä¿ç•™ç»¿è‰²ã€ç™½è‰²å’Œéžè¾¹ç¼˜è¿žé€š key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地å—切片。 +- å…³è”:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`ã€`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`ã€`server-rs/crates/api-server/src/jump_hop.rs`。 + ## å«ä¸­æ–‡ image2 live 验è¯ä¸è¦ç”¨ PowerShell 管é“å–‚ Node æºç  - 现象:本地用 `@'...'@ | node -` è·‘ VectorEngine / gpt-image-2 live éªŒè¯æ—¶ï¼Œ`request.json` 里的中文 prompt å¯èƒ½å…¨éƒ¨å˜æˆ `????`,生æˆå›¾ä¼šå˜æˆå®Œå…¨ä¸ç›¸å…³çš„ UIã€å»ºç­‘æµ·æŠ¥æˆ–å…¶å®ƒéšæœºå†…å®¹ï¼Œå®¹æ˜“è¯¯åˆ¤ä¸ºæ¨¡åž‹ä¸æœä»Žæç¤ºè¯ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index a1fe2e14..5d879b8e 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -42,7 +42,7 @@ å•图资产编辑统一通过 `CreativeImageInputPanel` 承载上传ã€AI é‡ç»˜ã€å‚考图ã€åކå²å›¾å’Œåˆ é™¤ç¡®è®¤ï¼›æ–°çŽ©æ³•é¡µé¢ä¸å¾—é‡å¤æ‰‹å†™è¿™äº›äº¤äº’。系列素æå›¾é›†ç”Ÿæˆç»Ÿä¸€èµ°â€œæ‰¹é‡è§„划 -> sheet 生图 -> åŽç«¯åˆ‡å›¾ -> 逿˜ŽåŒ– -> OSS æŒä¹…化 -> 状æ€å›žå†™ -> 局部é‡ç”Ÿæˆâ€æµç¨‹ï¼ŒçŽ©æ³•åªæä¾› `sheetSpec`ã€`slotSpecs`ã€æç¤ºè¯å’Œå­—æ®µæ˜ å°„ï¼Œä¸æŠŠä»»ä¸€çŽ©æ³•ä¸“å±žç´ æ DTO 当作平å°é€šç”¨æ¨¡åž‹ã€‚ -通用系列素æå›¾é›†èƒ½åŠ›çš„å®žçŽ°çœŸç›¸æºåœ¨ `platform-image::generated_asset_sheets`:`n` æ˜¯å¿…é€‰å‚æ•°ï¼Œæ¨¡å—负责组装 `n*n` sheet promptã€æŒ‰ `n*n` 切片ã€ç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–ã€å¯¼å‡º PNG å’Œ OSS æŒä¹…化请求。`api-server::generated_asset_sheets` åªä¿ç•™ `AppError` / `AppState` 适é…,ä¸å†æ‰¿è½½å›¾åƒå¤„ç†å’Œ OSS 请求构造细节。物å“åç§° prompt 和特殊设定 prompt 是å¯é€‰è¾“入;调用方å¯ä¼ å…¥ç±»ä¼¼â€œæ¯ä¸ªç‰©å“生æˆäº”个ä¸åŒè§†å›¾â€çš„视角约æŸï¼Œé€šç”¨æ¨¡å—会把 sheet promptã€ç‰©å“行 promptã€ç‰¹æ®Šè®¾å®š prompt ç¼–ç å†™å…¥ OSS 元数æ®ã€‚玩法ä»è´Ÿè´£è®¡è´¹ã€ç‰©å“规划ã€slot 映射ã€å¤±è´¥å›žå†™å’ŒæŠŠé€šç”¨åˆ‡ç‰‡ç»“果映射回自己的è‰ç¨¿ / profile / runtime 字段。 +通用系列素æå›¾é›†èƒ½åŠ›çš„å®žçŽ°çœŸç›¸æºåœ¨ `platform-image::generated_asset_sheets`:`n` æ˜¯å¿…é€‰å‚æ•°ï¼Œæ¨¡å—负责组装 `n*n` sheet promptã€æŒ‰ `n*n` 切片ã€é»˜è®¤ç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–ã€å¯¼å‡º PNG å’Œ OSS æŒä¹…åŒ–è¯·æ±‚ï¼›é«˜é£Žé™©æ’žè‰²çŽ©æ³•å¯æ˜¾å¼ä½¿ç”¨ä¸“用 key 色ã€å…³é—­è¿‘白扣除并é™åˆ¶ä¸ºè¾¹ç¼˜è¿žé€šèƒŒæ™¯æ‰£é™¤ã€‚`api-server::generated_asset_sheets` åªä¿ç•™ `AppError` / `AppState` 适é…,ä¸å†æ‰¿è½½å›¾åƒå¤„ç†å’Œ OSS 请求构造细节。物å“åç§° prompt 和特殊设定 prompt 是å¯é€‰è¾“入;调用方å¯ä¼ å…¥ç±»ä¼¼â€œæ¯ä¸ªç‰©å“生æˆäº”个ä¸åŒè§†å›¾â€çš„视角约æŸï¼Œé€šç”¨æ¨¡å—会把 sheet promptã€ç‰©å“行 promptã€ç‰¹æ®Šè®¾å®š prompt ç¼–ç å†™å…¥ OSS 元数æ®ã€‚玩法ä»è´Ÿè´£è®¡è´¹ã€ç‰©å“规划ã€slot 映射ã€å¤±è´¥å›žå†™å’ŒæŠŠé€šç”¨åˆ‡ç‰‡ç»“果映射回自己的è‰ç¨¿ / profile / runtime 字段。 当剿‰€æœ‰çŽ©æ³•ç”Ÿæˆé¡µ UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生æˆé¡µå›ºå®šå…¨å±èƒŒæ™¯å±‚循环é™éŸ³æ’­æ”¾ï¼Œä¸»è¿›åº¦åœ†çŽ¯å±…ä¸­è¦†ç›–åœ¨èƒŒæ™¯ä¹‹ä¸Šï¼Œå›´ç»•é™¶æ³¥å„¿è§†è§‰å±•ç¤ºï¼›é¡µé¢åªä¿ç•™å½“剿­¥éª¤åç§°å’Œå½“å‰æ­¥éª¤è¿›åº¦ï¼Œä¸å†æ¸²æŸ“步骤列表å—ã€‚è§†é¢‘å±‚éœ€è¦æ˜¾å¼è§¦å‘播放,ä¸èƒ½åªä¾èµ– `autoPlay/loop/muted` å±žæ€§ã€‚åœ†çŽ¯å†…éƒ¨ä¿æŒ `400x400` SVG åæ ‡ç³»ï¼Œå¤–层显示宽度以 `400px` 为上é™ï¼Œçª„屿Œ‰è§†å£å®½åº¦æ”¶ç¼©ï¼Œé¢„计等待 / 已耗时信æ¯å¡åœ¨çª„å±ä¸‹è½åˆ°åœ†çŽ¯ä¸‹æ–¹ï¼Œé¿å…å³ä¾§è£åˆ‡ã€‚共用生æˆé¡µ `CustomWorldGenerationView` 和汪汪声浪生æˆé¡µéƒ½å¿…é¡»éµå¾ªè¿™ä¸€å£å¾„。 @@ -146,17 +146,18 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ— 1. 创作端åªä¿ç•™ä¸»é¢˜è¾“å…¥ï¼Œä½œå“æ ‡é¢˜ã€ç®€ä»‹ã€æ ‡ç­¾å’Œåœ°å—æç¤ºè¯ç”±ç³»ç»Ÿæ´¾ç”Ÿï¼› 2. v1 ä¸å†å•独生æˆè§’色图片,è¿è¡Œæ€å›ºå®šä½¿ç”¨æŠ é™¤ç™½åº•åŽçš„陶泥儿 logo 逿˜Ž PNG 作为玩家角色; -3. 地å—åªè°ƒç”¨ä¸€æ¬¡ image2,输出一张 `5行*5列`ã€`1:1`ã€çº¯ç»¿è‰²ç»¿å¹•背景的主题地å—图集; -4. åŽç«¯æŒ‰ä»Žä¸Šåˆ°ä¸‹ã€ä»Žå·¦åˆ°å³å‡åŒ€åˆ‡åˆ†ä¸º `tile-01` 到 `tile-25` çš„é€æ˜Ž PNG,æ¯ä¸ªåˆ‡ç‰‡å¿…须使用唯一 slot/path æŒä¹…化,ä¸èƒ½æŒ‰é‡å¤çš„ `tileType` å¤ç”¨æ§½ä½ï¼› -5. 结果页åªå±•示陶泥儿 logo 逿˜Žè§’色预览ã€åœ°å—æ± é¢„è§ˆå’Œé¦–å± 3 地å—预览;ä¸å†æä¾›æ—§è§’è‰²å›¾ç”Ÿæˆæ§½ã€‚ +3. 地å—åªè°ƒç”¨ä¸€æ¬¡ image2,输出一张 `5行*5列`ã€`1:1`ã€å•一纯洋红 `#FF00FF` key 背景的主题地å—图集;跳一跳地å—常包å«è‰åœ°ã€èбã€é›ªã€ç™½çŸ³å’Œäº‘朵,åŽç«¯é€æ˜ŽåŒ–必须使用跳一跳专用洋红 key,ä¸å¯ç”¨è¿‘ç™½åº•æ‰£é™¤ï¼Œä¹Ÿä¸æ¸…ç†éžè¾¹ç¼˜è¿žé€šçš„ key 色åƒç´ ï¼Œé¿å…把绿色或白色主体误扣;地å—造型æç¤ºè¯è¦æ±‚以主题物体本身外轮廓为准,å…许苹果近似圆形ã€é¦™è•‰è¿‘ä¼¼é•¿æ¡æˆ–长方形ã€è¥¿ç“œè¿‘似扇形等自然差异,åªç»Ÿä¸€å•格规格ã€å®‰å…¨ç•™ç™½ã€æ­£é¢30度视角和 2D/2.5D 手绘风格包装;所有地å—ç´ æå¿…é¡»ä¿æŒç»Ÿä¸€æ­£é¢30度视角,相机ä½äºŽç‰©ä½“æ­£å‰æ–¹ç•¥é«˜ä½ç½®ã€é•œå¤´å‘下约30度,必须看到清晰正é¢ã€ä¾§å£ã€ä¸‹æ²¿ã€æ˜Žæ˜¾è‡ªèº«åŽšåº¦å’Œå°‘é‡ä¸Šè¡¨é¢ï¼Œä¸»ä½“æ­£é¢æˆ–ä¾§å£å¯è§é¢ç§¯å¿…须接近或大于顶é¢é¢ç§¯ï¼Œé¡¶é¢åªèƒ½ä½œä¸ºè¾…助å¯è§é¢ï¼›æ°´æžœä¸»é¢˜éœ€è¦æ˜Žç¡®è¦æ±‚橙瓣看到橙皮正é¢å¤–ä¾§å’Œæžœè‚‰åŽšåº¦ã€æ¤°å­çœ‹åˆ°å£³çš„æ­£é¢ä¾§å£å’Œåˆ‡å£åŽšåº¦ã€æµ†æžœä¸èƒ½åªæ˜¯ä»Žä¸Šå¾€ä¸‹çœ‹çš„圆形çƒé¡¶ï¼›é¿å…生æˆçº¯ä¿¯è§†ã€æ­£ä¸Šæ–¹ä¿¯æ‹ã€é¸Ÿçž°åœ°å›¾å—ã€å¹³é“ºä¿¯æ‹ã€åœ†å½¢é¡¶è§†å›¾æˆ–æ‰å¹³å›¾æ ‡ï¼›ä¸»é¢˜ç‰©ä½“本身必须是唯一å¯è½è„šä½“,åªèƒ½ç”¨è‡ªèº«åˆ‡é¢ã€è¾¹ç¼˜åŽšåº¦ã€èŠ±ç“£å±‚æˆ–æžœçš®è¾¹è¡¨çŽ°æ‰¿é‡ï¼Œç¦æ­¢åœ¨ä¸»é¢˜ç‰©ä½“下方é¢å¤–垫石å°ã€åœŸå¢©ã€æœ¨æ¿ã€åœ†å°ã€æ‰˜ç›˜ã€å²›å±¿åº•座或通用地æ¿ï¼›å‰ç«¯å’ŒåŽç«¯é»˜è®¤ `tilePrompt` 都必须使用“正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹â€çš„å£å¾„,ä¸å†æäº¤â€œå¹³å°ç´ æ / è·³å° / åœ°å— / 地砖â€ç­‰ä¼šæŠŠæ¨¡åž‹æ‹‰å›žé€šç”¨å¹³å°é€ åž‹çš„è¯ï¼ŒåŽç«¯ç”Ÿæˆå‰ä¹Ÿä¼šæ¸…æ´—æ—§è‰ç¨¿é—留的这些è¯ï¼› +4. èƒŒæ™¯åº•å›¾åŒæ ·ç”± image2 生æˆï¼Œå¤ç”¨çŽ°æœ‰ `coverComposite` / `coverImageSrc` 作为è¿è¡Œæ€èƒŒæ™¯è¯»å†™å­—段,OSS æ§½ä½å›ºå®šä¸º `background/image.png`ï¼Œä¸æ–°å¢ž SpacetimeDB 字段;æç¤ºè¯å¿…须严格以用户主题关键è¯ä¸ºèƒŒæ™¯ä¸»é¢˜ï¼Œç»“构以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼Œä¸­å¤®çºµè½´ 1/2 åŒºåŸŸä¿æŒå°‘元素ã€ç®€æ´ã€å¯è¯»ä¸”有纵深感,两侧å…许更强立体层次和行进感;背景åªä½œä¸ºåº•å›¾ï¼Œç¦æ­¢ç”Ÿæˆè·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIã€æ–‡å­—ã€è·¯å¾„箭头或海报排版; +5. åŽç«¯æŒ‰ä»Žä¸Šåˆ°ä¸‹ã€ä»Žå·¦åˆ°å³å‡åŒ€åˆ‡åˆ†ä¸º `tile-01` 到 `tile-25` çš„é€æ˜Ž PNG,æ¯ä¸ªåˆ‡ç‰‡å¿…须使用唯一 slot/path æŒä¹…化,ä¸èƒ½æŒ‰é‡å¤çš„ `tileType` å¤ç”¨æ§½ä½ï¼› +6. 结果页åªå±•示陶泥儿 logo 逿˜Žè§’色预览ã€åœ°å—æ± é¢„è§ˆå’Œé¦–å± 3 地å—预览;ä¸å†æä¾›æ—§è§’è‰²å›¾ç”Ÿæˆæ§½ã€‚ è¿è¡Œæ€è§„则真相必须沉到 `module-jump-hop`,å‰ç«¯åªåšæ‹–æ‹½è“„åŠ›ã€è§’色ä½ç§»ã€æŠ•影和è½åœ°åé¦ˆã€‚å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ¸¸æˆæ—¶é•¿å†»ç»“ã€è¿è¡Œæ€å¿«ç…§å’Œå‘布作å“状æ€ä»¥åŽç«¯ä¸ºå‡†ã€‚v1 ä¸ä¿ç•™å…¬å¼€ combo / perfect / 通关语义,旧 `score` 兼容映射为æˆåŠŸè·³è·ƒæ¬¡æ•°ã€‚å…¬å¼€åˆ—è¡¨åº”èµ° `jump_hop_gallery_card_view` 订阅缓存,ä¸è¦æ¯æ¬¡ HTTP 请求调用 procedure 组装全é‡åˆ—表。 -æ¯å±åªå±•示 3 个地å—:当å‰åœ°å—ã€ç›®æ ‡åœ°å—和下一预览地å—ã€‚å¹³å°æµæŒ‰åŒä¸€ seed æ— é™ç”Ÿæˆï¼Œå‰ç«¯ä¸å¾—è‡ªè¡Œç”Ÿæˆæ­£å¼è·¯å¾„。排行榜按作å“维度展示玩家 IDã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼›æ¯ä½çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºå›ºå®šä¸º `æˆåŠŸè·³è·ƒæ¬¡æ•° desc -> æ¸¸æˆæ—¶é•¿ asc -> æ›´æ–°æ—¶é—´ asc`。 +æ¯å±åªå±•示 3 个地å—:当å‰åœ°å—ã€ç›®æ ‡åœ°å—和下一预览地å—ã€‚å¹³å°æµæŒ‰åŒä¸€ seed æ— é™ç”Ÿæˆï¼Œå‰ç«¯ä¸å¾—è‡ªè¡Œç”Ÿæˆæ­£å¼è·¯å¾„。è¿è¡Œæ€ HUD 顶部åªä¿ç•™è¿”回按钮和æˆåŠŸè·³è·ƒæ¬¡æ•°ï¼Œä¸å±•示计时器或å³ä¸Šè§’é‡å¼€æŒ‰é’®ï¼›èˆžå°åŒºåŸŸä¸å¾—å†è¡¨çŽ°ä¸ºå¸¦è¾¹æ¡†å¡ç‰‡ï¼Œæ¸¸çީ䏭䏿˜¾ç¤ºå·¦ä¸‹è§’“进行中â€çжæ€ï¼Œä¹Ÿä¸åœ¨å±å¹•底部常驻排行榜。排行榜按作å“维度展示玩家 IDã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼›æ¯ä½çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºå›ºå®šä¸º `æˆåŠŸè·³è·ƒæ¬¡æ•° desc -> æ¸¸æˆæ—¶é•¿ asc -> æ›´æ–°æ—¶é—´ asc`,并åªåœ¨å¤±è´¥ç»“算弹窗内展示,弹窗ä¿ç•™é‡å¼€å’Œè¿”回动作。 -è¿è¡Œæ€æ¸²æŸ“分层固定为:DOM å¹³å°å±‚直接使用 `tileAssets[]` 的生æˆåˆ‡ç‰‡å›¾ç‰‡æ˜¾ç¤ºåœ°å—,图片读å–ç»§ç»­èµ°å¹³å°èµ„产æ¢ç­¾ï¼Œå¹¶ä»¥ `assetObjectId` 作为刷新键é¿å…é‡ç”ŸæˆåŽæ²¿ç”¨æ—§ç­¾å或旧图片缓存;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 逿˜Ž PNG å¹¶ä¿æŒæœ€é«˜å±‚级;Three.js 逿˜Žç”»å¸ƒä»…作为åŽç»­æ‰©å±•层。拖拽蓄力ã€è®¡æ—¶åˆ·æ–°å’Œè§’色ä½ç½®å˜åŒ–åªèƒ½æ›´æ–° refs 或 DOM 状æ€ï¼Œä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒæˆ–å¹³å°å›¾ç‰‡å±‚,å¦åˆ™ä¼šé€ æˆåœ°å—和角色层频闪。 +è¿è¡Œæ€æ¸²æŸ“分层固定为:舞å°åº•层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读å–ç»§ç»­èµ°å¹³å°èµ„产æ¢ç­¾ï¼Œæ²¡æœ‰èƒŒæ™¯æ—¶æ‰å›žé€€åˆ°å†…ç½®æ¸å˜ï¼›DOM å¹³å°å±‚直接使用 `tileAssets[]` 的生æˆåˆ‡ç‰‡å›¾ç‰‡æ˜¾ç¤ºåœ°å—,图片读å–ç»§ç»­èµ°å¹³å°èµ„产æ¢ç­¾ï¼Œå¹¶ä»¥ `assetObjectId` 作为刷新键é¿å…é‡ç”ŸæˆåŽæ²¿ç”¨æ—§ç­¾å或旧图片缓存;æ¯ä¸ªåœ°å—下方的统一软椭圆阴影æ¥è‡ªè¿è¡Œæ€ DOM çš„ `.jump-hop-runtime__platform-shadow`ï¼Œä¸æ˜¯ image2 地å—切片的必需内容,调整阴影优先改è¿è¡Œæ€ CSS;有真实地å—图片 URL æ—¶ä¸å¾—在加载空档显示 fallback 原型地å—,下一å±é¢„览地å—必须在进入相机视野å‰éšè—预加载;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 逿˜Ž PNG å¹¶ä¿æŒæœ€é«˜å±‚级;Three.js 逿˜Žç”»å¸ƒä»…作为åŽç»­æ‰©å±•层。拖拽蓄力ã€è®¡æ—¶åˆ·æ–°å’Œè§’色ä½ç½®å˜åŒ–åªèƒ½æ›´æ–° refs 或 DOM 状æ€ï¼Œä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€èƒŒæ™¯æˆ–å¹³å°å›¾ç‰‡å±‚,å¦åˆ™ä¼šé€ æˆèƒŒæ™¯ã€åœ°å—和角色层频闪。 -è·³ä¸€è·³å½“å‰æ‹–拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把åŒç­‰è·³è·ƒè·ç¦»æ‰€éœ€æ‹–拽è·ç¦»ç¼©çŸ­åˆ°æ—§ `0.004` 的一åŠï¼›å¦‚果历å²è·¯å¾„ä»ä¿å­˜æ—§ç³»æ•°ï¼Œ`start_run` ä¼šåœ¨å¼€å±€å½’ä¸€åŒ–åˆ°æ–°ç³»æ•°ã€‚æ¾æ‰‹åŽè¿è¡Œæ€å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` çš„è§’è‰²é£žè¡ŒåŠ¨ç”»ï¼šè“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ˜Žæ˜¾æ‹‰é•¿ï¼Œè§’色弹å‘预测è½ç‚¹ï¼Œè½åœ°åŽå‘åæ–¹å‘回弹两次;动画路径ä¸å¾—等待åŽç«¯æ–° run。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测è½ç‚¹ç­‰å¾…,直到新 run 到达åŽå†æŠŠæ˜¾ç¤ºæ€åˆ‡åˆ°åŽç«¯æœ€æ–° run,并用约 `1440ms` 的相机层推进过渡承接新窗å£ã€‚æŽ¨è¿›æ—¶åœ°å— DOM 层和 DOM 角色层统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—自然离开视野,新预览地å—ä»Žä¸Šæ–¹éœ²å‡ºï¼Œç¦æ­¢ç”¨ p1/p2 å„自 `top/left` 过渡造æˆè§’色和地å—ä¸åŒæ­¥ã€‚ç›¸æœºå±‚æŽ¨è¿›å¿…é¡»åŒæ—¶ä½¿ç”¨ X/Y å移,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸å¾—先横å‘瞬切到居中å†çºµå‘滑动。地å—å…许ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用åŒä¸€ `1440ms` 节å¥ï¼›ä¸è¦ç›´æŽ¥ä¿®æ”¹å®½é«˜é€ æˆçž¬åˆ‡ï¼Œä¹Ÿä¸è¦å†ç»™å½“剿€é¢å¤–å  CSS scale。 +è·³ä¸€è·³å½“å‰æ‹–拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把åŒç­‰è·³è·ƒè·ç¦»æ‰€éœ€æ‹–拽è·ç¦»ç¼©çŸ­åˆ°æ—§ `0.004` 的一åŠï¼›å¦‚果历å²è·¯å¾„ä»ä¿å­˜æ—§ç³»æ•°ï¼Œ`start_run` ä¼šåœ¨å¼€å±€å½’ä¸€åŒ–åˆ°æ–°ç³»æ•°ã€‚æ¾æ‰‹åŽè¿è¡Œæ€å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` çš„è§’è‰²é£žè¡ŒåŠ¨ç”»ï¼šè“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ˜Žæ˜¾æ‹‰é•¿ï¼Œè§’色弹å‘预测è½ç‚¹ï¼Œè½åœ°åŽå‘åæ–¹å‘回弹两次;动画路径ä¸å¾—等待åŽç«¯æ–° run。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测è½ç‚¹ç­‰å¾…,直到新 run 到达åŽå†æŠŠæ˜¾ç¤ºæ€åˆ‡åˆ°åŽç«¯æœ€æ–° run,并用约 `1440ms` 的相机层推进过渡承接新窗å£ã€‚æŽ¨è¿›æ—¶åœ°å— DOM 层和 DOM 角色层统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—自然离开视野,新预览地å—ä»Žä¸Šæ–¹éœ²å‡ºï¼Œç¦æ­¢ç”¨ p1/p2 å„自 `top/left` 过渡造æˆè§’色和地å—ä¸åŒæ­¥ã€‚ç›¸æœºå±‚æŽ¨è¿›å¿…é¡»åŒæ—¶ä½¿ç”¨ X/Y å移,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸å¾—先横å‘瞬切到居中å†çºµå‘滑动。地å—å…许ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用åŒä¸€ `1440ms` 节å¥ï¼›ä¸è¦ç›´æŽ¥ä¿®æ”¹å®½é«˜é€ æˆçž¬åˆ‡ï¼Œä¹Ÿä¸è¦å†ç»™å½“剿€é¢å¤–å  CSS scale。相机推进期间角色自身必须ç¦ç”¨ `left/top` transition,åªå…许父级 camera layer è´Ÿè´£ä½ç§»ï¼Œå¦åˆ™è§’è‰²å±€éƒ¨åæ ‡åˆ‡æ¢å’Œç›¸æœºæŽ¨è¿›ä¼šå åŠ ï¼Œè¡¨çŽ°ä¸ºè½åœ°åŽåˆä»Žå±å¹•外闪回。 å¹³å°é¦–页推èã€ç²¾é€‰ã€æœ€æ–°ã€å…¬å¼€è¯¦æƒ…ã€æœç´¢ã€å·²çŽ©ä½œå“和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作å“å·è¯†åˆ«è·³ä¸€è·³ä½œå“ï¼›ä»Žå…¬å¼€è¯¦æƒ…æˆ–æŽ¨èæµå¯åЍè¿è¡Œæ€æ—¶ï¼Œè‹¥å¡ç‰‡æ‘˜è¦ä¸è¶³ä»¥æºå¸¦åœ°å—图集和路径é…置,必须先补读完整 work profile å†ä¼ å…¥è¿è¡Œæ€ã€‚å¹³å°å£³å±‚å¿…é¡»åŒæ­¥æ³¨å†Œ `jump-hop-workspace`ã€`jump-hop-generating`ã€`jump-hop-result`ã€`jump-hop-runtime`ã€`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`ã€`/creation/jump-hop/generating`ã€`/creation/jump-hop/result`ã€`/gallery/jump-hop/detail`ã€`/runtime/jump-hop`ï¼ŒåŒæ—¶æŒæœ‰ sessionã€workã€runã€galleryã€busy/error 与生æˆè¿›åº¦çжæ€ï¼Œé¿å…åªåˆå…¥æ¸²æŸ“åˆ†æ”¯ä½†é—æ¼çŠ¶æ€æºæˆ–分享路径导致 typecheck 失败ã€åˆ·æ–°å›žé¦–页。 diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 7fafb80b..b5df860e 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -1,4 +1,4 @@ -use axum::http::StatusCode; +use axum::http::StatusCode; use platform_image::generated_asset_sheets as generated_asset_sheets_impl; use crate::{ @@ -8,9 +8,12 @@ use crate::{ #[allow(unused_imports)] pub(crate) use generated_asset_sheets_impl::{ - GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor, + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, - apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, + apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, + crop_generated_asset_sheet_view_edge_matte, + crop_generated_asset_sheet_view_edge_matte_with_options, }; pub(crate) fn build_generated_asset_sheet_prompt( diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 6a80629b..69ccf710 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -29,7 +29,8 @@ use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_asset_sheets::{ - apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, + GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options, + crop_generated_asset_sheet_view_edge_matte_with_options, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -56,6 +57,10 @@ 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 = 5; const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536"; +const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { @@ -428,12 +433,19 @@ async fn maybe_generate_jump_hop_assets( ) { return Ok(()); } - if payload.tile_atlas_asset.is_some() + let has_complete_tile_assets = payload.tile_atlas_asset.is_some() && payload .tile_assets .as_ref() - .is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT) - { + .is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT); + let has_real_background = payload + .cover_composite + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value)); + + if has_complete_tile_assets && has_real_background { return Ok(()); } let profile_id = payload @@ -464,78 +476,151 @@ async fn maybe_generate_jump_hop_assets( .theme_text .as_deref() .or(payload.work_title.as_deref()) - .unwrap_or("跳一跳"); - let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text); + .unwrap_or("跳一跳") + .to_string(); + let tile_prompt = payload + .tile_prompt + .clone() + .unwrap_or_else(|| theme_text.clone()); - let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); - let tile_generated = create_openai_image_generation( - &http_client, - &settings, - sheet_prompt.as_str(), - Some(build_jump_hop_tile_atlas_negative_prompt()), - "1024*1024", - 1, - &[], - "跳一跳地å—图集生æˆå¤±è´¥", - ) - .await - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { - jump_hop_error_response( - request_context, - JUMP_HOP_CREATION_PROVIDER, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "跳一跳地å—å›¾é›†ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", - })), + if !has_real_background { + let background_prompt = build_jump_hop_background_prompt(theme_text.as_str()); + let background_generated = create_openai_image_generation( + &http_client, + &settings, + background_prompt.as_str(), + Some(build_jump_hop_background_negative_prompt()), + JUMP_HOP_BACKGROUND_IMAGE_SIZE, + 1, + &[], + "跳一跳背景底图生æˆå¤±è´¥", ) - })?; - 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, - profile_id.as_str(), - "tile-atlas", - tile_prompt, - tile_image, - LegacyAssetPrefix::JumpHopAssets, - 1024, - 1024, - request_context, - ) - .await?; - 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, + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let background_image = background_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "è·³ä¸€è·³èƒŒæ™¯åº•å›¾ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + })), + ) + })?; + let background_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "background", + background_prompt.as_str(), + background_image, + LegacyAssetPrefix::JumpHopAssets, + JUMP_HOP_BACKGROUND_IMAGE_WIDTH, + JUMP_HOP_BACKGROUND_IMAGE_HEIGHT, + request_context, + ) + .await?; + payload.cover_composite = Some(background_asset.image_src); + } + + if !has_complete_tile_assets { + let sheet_prompt = + build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str()); + let tile_generated = create_openai_image_generation( + &http_client, + &settings, + sheet_prompt.as_str(), + Some(build_jump_hop_tile_atlas_negative_prompt()), + "1024*1024", + 1, + &[], + "跳一跳地å—图集生æˆå¤±è´¥", + ) + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { + jump_hop_error_response( request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "跳一跳地å—å›¾é›†ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + })), ) - .await?, - ); + })?; + 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, + profile_id.as_str(), + "tile-atlas", + tile_prompt.as_str(), + tile_image, + LegacyAssetPrefix::JumpHopAssets, + 1024, + 1024, + request_context, + ) + .await?; + 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.tile_atlas_asset = Some(tile_atlas_asset); + payload.tile_assets = Some(tile_assets); } if payload.character_asset.is_none() { payload.character_asset = Some(build_jump_hop_default_character_asset( profile_id.as_str(), - theme_text, + theme_text.as_str(), )); } - payload.tile_atlas_asset = Some(tile_atlas_asset); - payload.tile_assets = Some(tile_assets); - payload.cover_composite = payload.cover_composite.clone().or_else(|| { - Some(format!( - "/generated-jump-hop-assets/{profile_id}/cover-composite.png" - )) - }); Ok(()) } +fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool { + let value = value.trim(); + value.starts_with("/generated-jump-hop-assets/") + && (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-")) +} + +fn build_jump_hop_background_prompt(theme_text: &str) -> String { + let theme_text = theme_text.trim(); + let theme_text = if theme_text.is_empty() { + "跳一跳" + } else { + theme_text + }; + + format!( + "生æˆä¸€å¼ 9:16竖版跳一跳游æˆèƒŒæ™¯åº•图,主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œ{theme_text}â€ï¼Œä¸è¦é¢å¤–改æ¢ä¸»é¢˜ï¼›æ•´ä½“风格需è¦å’ŒåŒä¸€ä¸»é¢˜çš„跳一跳游æˆå…ƒç´ ä¸€è‡´ã€‚\nç”»é¢ç»“构必须以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼šå·¦ä¾§å’Œå³ä¾§å¯ä»¥ä½¿ç”¨ç¬¦åˆä¸»é¢˜çš„环境元素ã€è£…饰层次ã€å‰ä¸­åŽæ™¯é®æŒ¡ã€é€è§†èŠ‚å¥å’Œè¡Œè¿›æ„Ÿï¼Œè®©çŽ©å®¶æ„Ÿåˆ°ä»Žç”»é¢ä¸‹æ–¹å‘上方å‰è¿›ã€‚\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画é¢åº•部延伸到上方;该区域åªèƒ½ä½¿ç”¨å°‘é‡ä½Žå¯¹æ¯”度纹ç†ã€æŸ”和光影ã€ç©ºæ°”é€è§†å’Œçºµæ·±å¼•å¯¼çº¿ï¼Œç¦æ­¢å †æ”¾å¤§åž‹ä¸»ä½“。\n中央纵轴1/2åŒºåŸŸè¦æœ‰æ˜Žæ˜¾çºµæ·±æ„Ÿï¼Œä½†å…ƒç´ æ•°é‡å¿…须少,ä¸èƒ½æŠ¢è·³æ¿ã€è§’色和交互层的视觉;两侧å¯ä»¥æ›´æœ‰ç«‹ä½“感ã€ç©ºé—´å±‚次和主题氛围。\n背景åªä½œä¸ºåº•图,ä¸ç”»ä»»ä½•è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€æ–‡å­—ã€è·¯å¾„箭头ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€Logo或水å°ã€‚\nè§†è§’ä¿æŒæ­£é¢çº¦30度的2D/2.5D休闲手游视角,相机ä½äºŽåœºæ™¯æ­£å‰æ–¹ç•¥é«˜ä½ç½®ï¼Œç”»é¢æœ‰è½»å¾®å‘上行进的纵深,ä¸è¦ç”»æˆçº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸æˆ–真实摄影。\nè‰²å½©æ¸…çˆ½è‡ªç„¶ï¼Œå“‘å…‰æ‰‹ç»˜è´¨æ„Ÿï¼ŒæŸ”å’Œå…‰ç…§ï¼Œä¸»ä½“èƒŒæ™¯ä¸æ²¹äº®ã€ä¸åŽšé‡CGã€ä¸æš—黑;中央区域需è¦ç»™è¿è¡Œæ€åœ°å—和陶泥儿角色留出干净å¯è¯»ç©ºé—´ã€‚\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no text, consistent 2D/2.5D front-facing 30-degree game perspective." + ) +} + +fn build_jump_hop_background_negative_prompt() -> &'static str { + "文字ã€Logoã€æ°´å°ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€è§’色ã€äººç‰©ã€è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€å¹³å°ã€é“è·¯ç®­å¤´ã€æ£‹ç›˜ã€æ ¼å­ã€ä¸­å¿ƒå¤§åž‹ä¸»ä½“ã€ä¸­å¤®å †æ»¡å…ƒç´ ã€ä¸­å¤®é®æŒ¡ã€ä¸­å¤®é«˜å¯¹æ¯”装饰ã€ä¸­å¤®å¤æ‚花纹ã€çº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸ã€çœŸå®žæ‘„å½±ã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æ²¹äº®é«˜å…‰ã€å¡‘料质感" +} + fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String { let theme_text = theme_text.trim(); let theme_text = if theme_text.is_empty() { @@ -543,20 +628,57 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri } else { theme_text }; - let subject_text = tile_prompt.trim(); - let subject_text = if subject_text.is_empty() { + let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt); + let subject_text = if sanitized_tile_prompt.is_empty() { theme_text } else { - subject_text + sanitized_tile_prompt.as_str() }; format!( - "生æˆä¸€å¼ 1:1图片,主题为“{theme_text}â€ã€‚\nç”»é¢åªåŒ…å«25个独立的跳一跳å¯è½è„šå¹³å°ç´ æï¼ŒæŒ‰äº”行五列å‡åŒ€æ‘†æ”¾åœ¨çº¯ç»¿è‰²ç»¿å¹•画布上;ä¸è¦ç”»æˆæ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…å¤‡æ æˆ–图标集页é¢ã€‚\n视觉方å‘为俯视角平å°è·³è·ƒæ¸¸æˆï¼Œç”»é¢å†…容是{subject_text}。\næ¯ä¸€å—å¹³å°éƒ½å¿…须直接使用主题元素åšä¸»ä½“造型,主题è¦ä¸€çœ¼å¯è§ï¼›ä¾‹å¦‚ä¸»é¢˜ä¸ºæ°´æžœæ—¶ï¼Œåº”æ˜¯è‹¹æžœåˆ‡ç‰‡ã€æ©™å­åˆ‡ç‰‡ã€è¥¿ç“œå—ã€è‰èŽ“ã€è èã€é¦™è•‰ç­‰æ°´æžœé€ åž‹å¹³å°ï¼Œä¸å¾—å˜æˆçŸ³æ¿ã€é‡‘属按钮ã€å¾½ç« æˆ–装备。\nåªç”»å¹³å°è£¸ç´ æï¼Œä¸ç”»å¤–层颿¿ã€æ£‹ç›˜åº•座ã€èœå•ã€æŒ‰é’®ã€æ ‡é¢˜ã€æ–‡å­—ã€è§’æ ‡ã€è£…饰边框ã€å·¥å…·æ ã€è£…å¤‡ã€æ­¦å™¨ã€å¾½ç« ã€é“具或角色。\n整体风格为清爽自然的休闲手游平å°ç´ æï¼Œå2D/2.5D手绘质感,哑光æè´¨ï¼Œå¹²å‡€è‰²å—,轻微主体内部明暗,é¿å…å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™æ„Ÿã€æš—黑幻想风和厚é‡CG渲染。\næ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°ï¼Œæ˜¯ç¬¦åˆä¸»é¢˜ä¸”有设计感的立体感平å°ï¼Œæœ‰é¡¶é¢å’Œæ¸…晰轮廓;ä¸è¦é»˜è®¤ç”Ÿæˆç°è‰²çŸ³æ¿æˆ–金属地砖,除éžä¸»é¢˜æœ¬èº«å°±æ˜¯çŸ³å¤´æˆ–金属。\næ¯æ ¼ä¸»ä½“必须居中,视觉尺寸åªå å•æ ¼56%-64%,四周至少ä¿ç•™18%纯绿色绿幕安全留白;任何å¶ç‰‡ã€è£…饰ã€è½®å»“和光影都ä¸å¾—è´´è¾¹ã€è·¨æ ¼æˆ–越界。\næ¯ä¸ªå¹³å°åªä¿ç•™ä¸»ä½“内部明暗和外轮廓,ä¸ç»˜åˆ¶è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€åº•æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘底或背景色å—,è¿è¡Œæ€ä¼šç»Ÿä¸€æ·»åŠ é˜´å½±ã€‚\n25个平å°åŒä¸€æè´¨ä½“ç³»ã€åŒä¸€å…‰å‘,但形状和细节有å˜åŒ–ï¼›æ¯ä¸ªå¹³å°ä¹‹é—´åªèƒ½æ˜¯çº¯ç»¿è‰²ç©ºç™½ï¼Œä¸ç”»åˆ†éš”线ã€ç½‘格线ã€å®¹å™¨æ¡†æˆ–棋盘格。\næ•´å¼ ç”»å¸ƒèƒŒæ™¯ã€æ ¼é—´ç©ºç™½å’Œæ¯æ ¼èƒŒæ™¯éƒ½å¿…须是接近 #00FF00 的纯绿色绿幕,背景平整无纹ç†ã€æ— æ¸å˜ã€æ— é˜´å½±ã€æ— é»‘底;主体自身ä¸å¾—使用接近 #00FF00 的纯绿。\nç¦æ­¢è·¨æ ¼ã€è´´è¾¹ã€è¶Šç•Œã€æ–‡å­—ã€æ°´å°ã€UIã€è¾¹æ¡†ã€ç½‘格线ã€è§’色ã€åœºæ™¯ã€æ¸¸æˆé¢æ¿æˆ–é“具界é¢ã€‚\nEnglish guardrail: isolated top-down fruit-shaped jump pad assets only, green screen background, no text, no poster, no architecture, no building, no UI screen, no inventory icons." + "生æˆä¸€å¼ 1:1图片,主题为“{theme_text}â€ã€‚\nç”»é¢åªåŒ…å«25个独立的跳跃è½ç‚¹ä¸»é¢˜ç‰©ä½“,按五行五列å‡åŒ€æ‘†æ”¾åœ¨çº¯æ´‹çº¢æŠ å›¾ç”»å¸ƒä¸Šï¼›ä¸è¦ç”»æˆæ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…å¤‡æ æˆ–图标集页é¢ã€‚\n视觉方å‘为正é¢30度视角的跳跃游æˆç´ æï¼Œç”»é¢å†…容是{subject_text}。所有è½ç‚¹ç´ æéƒ½å¿…é¡»ä¿æŒç»Ÿä¸€çš„æ­£é¢30度视角:相机ä½äºŽç‰©ä½“æ­£å‰æ–¹ç•¥é«˜ä½ç½®ï¼Œé•œå¤´å‘下约30度,能看到清晰正é¢ã€ä¾§å£ã€ä¸‹æ²¿å’Œå°‘é‡ä¸Šè¡¨é¢ã€‚\næž„å›¾éªŒæ”¶æ ‡å‡†ï¼šä¸»ä½“æ­£é¢æˆ–ä¾§å£å¯è§é¢ç§¯å¿…须接近或大于顶é¢é¢ç§¯ï¼Œé¡¶é¢åªèƒ½ä½œä¸ºè¾…助å¯è§é¢ï¼›ä¸è¦è®©é¡¶é¢å æ®ä¸»è¦è§†è§‰ï¼Œä¸è¦ç”»æˆçº¯ä¿¯è§†ã€æ­£ä¸Šæ–¹ä¿¯æ‹ã€é¸Ÿçž°åœ°å›¾å—ã€å¹³é“ºä¿¯æ‹ã€åœ†å½¢é¡¶è§†å›¾æˆ–æ‰å¹³å›¾æ ‡ã€‚\n水果主题尤其è¦é¿å…俯æ‹ï¼šæ©™ç“£å¿…须看到橙皮正é¢å¤–侧和果肉厚度,椰å­å¿…须看到壳的正é¢ä¾§å£å’Œåˆ‡å£åŽšåº¦ï¼Œæµ†æžœä¸èƒ½åªæ˜¯ä¸€ä¸ªä»Žä¸Šå¾€ä¸‹çœ‹çš„圆形çƒé¡¶ã€‚\næ¯ä¸€ä¸ªè½ç‚¹éƒ½å¿…须直接使用主题物体或åˆç†å‘散物体åšä¸»ä½“造型,主题è¦ä¸€çœ¼å¯è§ï¼›ä¾‹å¦‚主题为水果时,å¯ä»¥æ˜¯è‹¹æžœåˆ‡ç‰‡ã€æ©™ç“£ã€è¥¿ç“œå—ã€è‰èŽ“ã€è èå—ã€é¦™è•‰ã€è‘¡è„串等水果物体,苹果å¯è¿‘似圆,香蕉å¯è¿‘ä¼¼é•¿æ¡æˆ–长方形,西瓜å¯è¿‘似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一å¯è½è„šä½“:雪花è½ç‚¹å°±æ˜¯ä¸€æžšå¸¦åŽšåº¦çš„é›ªèŠ±ï¼Œå‘æ—¥è‘µè½ç‚¹å°±æ˜¯ä¸€æœµå¸¦åŽšåº¦çš„å‘æ—¥è‘µï¼Œæ°´æžœè½ç‚¹å°±æ˜¯æ°´æžœåˆ‡ç‰‡æˆ–水果本体;ä¸è¦åœ¨ä¸»é¢˜ç‰©ä½“下é¢å†åž«ä»»ä½•石头ã€åœŸå—ã€æœ¨æ¿ã€åœ†å°ã€åº•ç›˜ã€æ‰˜ç›˜ã€å²›å±¿ã€èŠ±ç›†ã€åœ°é¢å—或通用承托物。\nåªç”»ä¸»é¢˜ç‰©ä½“裸素æï¼Œä¸ç”»å¤–层颿¿ã€æ£‹ç›˜åº•座ã€èœå•ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€æ–‡å­—ã€è§’æ ‡ã€è£…饰边框ã€å·¥å…·æ ã€è£…备æ ã€å›¾æ ‡å¡ã€è§’色或游æˆç•Œé¢ã€‚\n整体风格为清爽自然的休闲手游主题物体素æï¼Œå2D/2.5D手绘质感,哑光æè´¨ï¼Œå¹²å‡€è‰²å—,轻微主体内部明暗,é¿å…å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™æ„Ÿã€æš—黑幻想风和厚é‡CG渲染。\næ¯ä¸ªè½ç‚¹éƒ½æ˜¯ç¬¦åˆä¸»é¢˜ä¸”有设计感的立体感物体,有清晰轮廓和明显自身厚度;ä¸è¦æŠŠä¸åŒä¸»é¢˜ç‰©ä½“强行改造æˆç»Ÿä¸€åœ°ç –ã€ç»Ÿä¸€æŒ‰é’®æˆ–统一抽象图标。\n造型规则完全由物体本身决定:å…许圆形ã€é•¿æ¡ã€å¼§å½¢ã€ä¸‰è§’ã€æ‰‡å½¢ã€å—çŠ¶ã€æžå¶çжã€å¤šä»¶ç»„åˆã€è½»å¾®å¤¸å¼ å’Œä¸€å®šç¨‹åº¦å‘散;åªåœ¨åŒä¸€2D/2.5Dæ‰‹ç»˜é£Žæ ¼ã€æ­£é¢30åº¦è§†è§’ã€æè´¨åŒ…è£…ã€æ¸…晰轮廓ã€å•æ ¼è§„æ ¼å’Œå®‰å…¨ç•™ç™½ä¸Šä¿æŒä¸€è‡´ã€‚\n25个è½ç‚¹åº”å°½é‡é€‰æ‹©ä¸åŒä¸»é¢˜ç‰©ä½“æˆ–ç›¸å…³å‘æ•£ç‰©ä½“ï¼Œå·®å¼‚ä¸»è¦æ¥è‡ªç‰©ä½“ç§ç±»å’ŒåŽŸç”Ÿè½®å»“ï¼Œä¸ä½¿ç”¨å›ºå®šå½¢çŠ¶è„šæœ¬ï¼›ç›¸é‚»æ ¼å¯ä»¥å½¢çŠ¶ç›¸ä¼¼ï¼Œåªè¦ç‰©ä½“ä¸åŒä¸”主题清楚。\nå…许用主题物体自身的切é¢ã€è¾¹ç¼˜åŽšåº¦ã€èŠ±ç“£å±‚ã€æžœçš®è¾¹ã€é›ªèŠ±åŽšè¾¹æˆ–äº‘æœµä½“ç§¯è¡¨çŽ°å¯è½è„šæ„Ÿï¼›ç¦æ­¢é¢å¤–æ”¯æ’‘å±‚ã€æ‰¿æ‰˜åº•座ã€è„šä¸‹åœ°æ¿ã€ä¸‹æ–¹çŸ³å°ã€ä¸‹æ–¹åœŸå¢©ã€ä¸‹æ–¹åœ†ç›˜ã€ä¸‹æ–¹æ‰˜ç›˜æˆ–“物体摆在平å°ä¸Šâ€çš„画法。\næ¯ä¸ªè½ç‚¹å¿…须居中,视觉尺寸åªå å•æ ¼56%-64%,四周至少ä¿ç•™18%纯洋红安全留白;任何å¶ç‰‡ã€è£…饰ã€è½®å»“和光影都ä¸å¾—è´´è¾¹ã€è·¨æ ¼æˆ–越界。\næ¯ä¸ªè½ç‚¹åªä¿ç•™ä¸»ä½“内部明暗ã€å¤–轮廓和自身厚度,ä¸ç»˜åˆ¶è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€åº•æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘底或背景色å—,è¿è¡Œæ€ä¼šç»Ÿä¸€æ·»åŠ é˜´å½±ã€‚\n25个è½ç‚¹åŒä¸€æè´¨ä½“ç³»ã€åŒä¸€å…‰å‘å’ŒåŒä¸€æ­£é¢30度视角,但物体类别ã€å¤–轮廓和细节有å˜åŒ–ï¼›æ¯ä¸ªè½ç‚¹ä¹‹é—´åªèƒ½æ˜¯çº¯æ´‹çº¢ç©ºç™½ï¼Œä¸ç”»åˆ†éš”线ã€ç½‘格线ã€å®¹å™¨æ¡†æˆ–棋盘格。\næ•´å¼ ç”»å¸ƒèƒŒæ™¯ã€æ ¼é—´ç©ºç™½å’Œæ¯æ ¼èƒŒæ™¯éƒ½å¿…须是å•一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹ç†ã€æ— æ¸å˜ã€æ— é˜´å½±ã€æ— é»‘底;主体å…许使用绿色ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°å’ŒèŠ±æœµï¼Œä½†ä¸»ä½“è‡ªèº«ä¸å¾—使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色。\nç¦æ­¢è·¨æ ¼ã€è´´è¾¹ã€è¶Šç•Œã€æ–‡å­—ã€æ°´å°ã€UIã€è¾¹æ¡†ã€ç½‘格线ã€è§’色ã€åœºæ™¯ã€æ¸¸æˆé¢æ¿ã€å›¾æ ‡é›†é¡µé¢ã€ç‰©ä½“下方é¢å¤–底座或物体摆在地æ¿ä¸Šã€‚\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons." ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字ã€Logoã€æ°´å°ã€æŒ‰é’®ã€UI å­—ã€æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€å›¾æ ‡é›†é¡µé¢ã€å¤–层颿¿ã€èœå•ã€å·¥å…·æ ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“ã€å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™è´¨æ„Ÿã€æš—黑幻想风ã€åŽšé‡CG渲染ã€ç°è‰²çŸ³æ¿ã€é‡‘属地砖ã€å»ºç­‘ã€æ¥¼æˆ¿ã€æµ·æŠ¥ã€è£…å¤‡ã€æ­¦å™¨ã€å¾½ç« ã€é“具图标ã€UI图标å¡ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€è£…饰边框ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ–¹å½¢åº•æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘åº•ã€æš—色背景ã€èƒŒæ™¯è‰²å—ã€è´´è¾¹ã€è·¨æ ¼ã€è¶Šç•Œ" + "文字ã€Logoã€æ°´å°ã€UI按钮ã€UI å­—ã€æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€å›¾æ ‡é›†é¡µé¢ã€å¤–层颿¿ã€èœå•ã€å·¥å…·æ ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“ã€å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™è´¨æ„Ÿã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æµ·æŠ¥ã€UI图标å¡ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€è£…饰边框ã€çº¯ä¿¯è§†è§’ã€æ­£ä¸Šæ–¹è§†è§’ã€é¸Ÿçž°è§†è§’ã€å¹³é“ºä¿¯æ‹ã€é¡¶é¢å ä¸»ç”»é¢ã€åªçœ‹é¡¶é¢ã€åœ†å½¢é¡¶è§†å›¾ã€æ‰å¹³å›¾æ ‡ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ–¹å½¢åº•æ¿ã€é¢å¤–åº•åº§ã€æ‰¿æ‰˜åº•座ã€å°åº§ã€çŸ³å°ã€åœŸå¢©ã€æœ¨æ¿åº•座ã€åœ†å°ã€åº•ç›˜ã€æ‰˜ç›˜ã€å²›å±¿åº•座ã€èŠ±ç›†åº•åº§ã€åœ°é¢å—ã€è„šä¸‹åœ°æ¿ã€ç‰©ä½“摆在平å°ä¸Šã€ç‰©ä½“下方垫地æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘åº•ã€æš—色背景ã€èƒŒæ™¯è‰²å—ã€è´´è¾¹ã€è·¨æ ¼ã€è¶Šç•Œ" +} + +fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { + let mut value = tile_prompt.trim().to_string(); + if value.is_empty() { + return value; + } + + const REPLACEMENTS: [(&str, &str); 18] = [ + ("俯视角", "æ­£é¢30度视角"), + ("正上方视角", "æ­£é¢30度视角"), + ("鸟瞰视角", "æ­£é¢30度视角"), + ("平铺俯æ‹", "æ­£é¢30度视角"), + ("å¯è½è„šå¹³å°ç´ æ", "跳跃è½ç‚¹ä¸»é¢˜ç‰©ä½“"), + ("清爽游æˆåŒ–立体感平å°ç´ æ", "清爽游æˆåŒ–立体感主题物体"), + ("å¹³å°è£¸ç´ æ", "主题物体裸素æ"), + ("æ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°", "æ¯æ ¼ä¸€ä¸ªå®Œæ•´ä¸»é¢˜ç‰©ä½“"), + ("å¹³å°ç´ æ", "主题物体"), + ("å¯è½è„šå¹³å°", "跳跃è½ç‚¹"), + ("å¯è½è„š", "è½ç‚¹"), + ("å¹³å°", "主题物体"), + ("è·³å°", "è½ç‚¹"), + ("地å—", "主题物体"), + ("地砖", "主题物体"), + ("底座", "承托物"), + ("底盘", "承托物"), + ("地æ¿", "承托物"), + ]; + + for (from, to) in REPLACEMENTS { + value = value.replace(from, to); + } + while value.contains("æ­£é¢30度视角正é¢30度视角") { + value = value.replace("æ­£é¢30度视角正é¢30度视角", "æ­£é¢30度视角"); + } + + value } fn slice_jump_hop_tile_atlas( @@ -568,7 +690,8 @@ fn slice_jump_hop_tile_atlas( "message": format!("跳一跳地å—图集解ç å¤±è´¥ï¼š{error}"), })) })?; - let source = apply_generated_asset_sheet_green_screen_alpha(source); + let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(); + let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options); let width = source.width(); let height = source.height(); let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; @@ -596,9 +719,11 @@ fn slice_jump_hop_tile_atlas( x1.saturating_sub(x0).max(1), y1.saturating_sub(y0).max(1), ); - let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let cleaned = + crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options); let cleaned = keep_jump_hop_largest_alpha_component(cleaned); - let cleaned = crop_generated_asset_sheet_view_edge_matte(cleaned); + let cleaned = + crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options); let cleaned = pad_jump_hop_tile_slice_image(cleaned); let mut cursor = std::io::Cursor::new(Vec::new()); cleaned @@ -997,7 +1122,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"), tile_prompt: clean_or_default( &payload.tile_prompt, - &format!("{theme_text}主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ"), + &format!("{theme_text}主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹"), ), end_mood_prompt: payload .end_mood_prompt @@ -1164,17 +1289,67 @@ mod tests { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游æˆåŒ–立体感平å°"); assert!(prompt.contains("五行五列")); - assert!(prompt.contains("å…±25个")); - assert!(prompt.contains("å¯è½è„šå¹³å°ç´ æ")); + assert!(prompt.contains("25个独立")); + assert!(prompt.contains("跳跃è½ç‚¹ä¸»é¢˜ç‰©ä½“")); assert!(prompt.contains("ä¸è¦ç”»æˆæ¸¸æˆç•Œé¢")); + assert!(prompt.contains("视觉方å‘为正é¢30度视角")); + assert!(prompt.contains("所有è½ç‚¹ç´ æéƒ½å¿…é¡»ä¿æŒç»Ÿä¸€çš„æ­£é¢30度视角")); + assert!(prompt.contains("相机ä½äºŽç‰©ä½“æ­£å‰æ–¹ç•¥é«˜ä½ç½®")); + assert!(prompt.contains("镜头å‘下约30度")); + assert!(prompt.contains("能看到清晰正é¢ã€ä¾§å£ã€ä¸‹æ²¿å’Œå°‘é‡ä¸Šè¡¨é¢")); + assert!(prompt.contains("ä¸»ä½“æ­£é¢æˆ–ä¾§å£å¯è§é¢ç§¯å¿…须接近或大于顶é¢é¢ç§¯")); + assert!(prompt.contains("é¡¶é¢åªèƒ½ä½œä¸ºè¾…助å¯è§é¢")); + assert!(prompt.contains("ä¸è¦è®©é¡¶é¢å æ®ä¸»è¦è§†è§‰")); + assert!(prompt.contains("ä¸è¦ç”»æˆçº¯ä¿¯è§†ã€æ­£ä¸Šæ–¹ä¿¯æ‹ã€é¸Ÿçž°åœ°å›¾å—")); + assert!(prompt.contains("水果主题尤其è¦é¿å…俯æ‹")); + assert!(prompt.contains("橙瓣必须看到橙皮正é¢å¤–侧和果肉厚度")); + assert!(prompt.contains("浆果ä¸èƒ½åªæ˜¯ä¸€ä¸ªä»Žä¸Šå¾€ä¸‹çœ‹çš„圆形çƒé¡¶")); assert!(prompt.contains("主题è¦ä¸€çœ¼å¯è§")); - assert!(prompt.contains("æ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°")); - assert!(prompt.contains("清爽自然的休闲手游平å°ç´ æ")); - assert!(prompt.contains("符åˆä¸»é¢˜ä¸”有设计感的立体感平å°")); - assert!(prompt.contains("四周至少ä¿ç•™18%纯绿色绿幕安全留白")); + assert!(prompt.contains("æ¯ä¸ªè½ç‚¹éƒ½æ˜¯ç¬¦åˆä¸»é¢˜ä¸”有设计感的立体感物体")); + assert!(prompt.contains("清爽自然的休闲手游主题物体素æ")); + assert!(prompt.contains("符åˆä¸»é¢˜ä¸”有设计感的立体感物体")); + assert!(prompt.contains("æ¯ä¸€ä¸ªè½ç‚¹éƒ½å¿…须直接使用主题物体或åˆç†å‘散物体")); + assert!(prompt.contains("苹果å¯è¿‘似圆")); + assert!(prompt.contains("香蕉å¯è¿‘ä¼¼é•¿æ¡æˆ–长方形")); + assert!(prompt.contains("主题物体本身就是唯一å¯è½è„šä½“")); + assert!(prompt.contains("雪花è½ç‚¹å°±æ˜¯ä¸€æžšå¸¦åŽšåº¦çš„é›ªèŠ±")); + assert!(prompt.contains("ä¸è¦åœ¨ä¸»é¢˜ç‰©ä½“下é¢å†åž«ä»»ä½•石头ã€åœŸå—ã€æœ¨æ¿")); + assert!(prompt.contains("造型规则完全由物体本身决定")); + assert!(prompt.contains("å…许圆形ã€é•¿æ¡ã€å¼§å½¢ã€ä¸‰è§’ã€æ‰‡å½¢ã€å—状")); + assert!(prompt.contains("åªåœ¨åŒä¸€2D/2.5D手绘风格")); + assert!(prompt.contains("åŒä¸€æ­£é¢30度视角")); + assert!(prompt.contains("ä¸ä½¿ç”¨å›ºå®šå½¢çŠ¶è„šæœ¬")); + assert!(prompt.contains("å…许用主题物体自身的切é¢ã€è¾¹ç¼˜åŽšåº¦")); + assert!(prompt.contains("ç¦æ­¢é¢å¤–æ”¯æ’‘å±‚ã€æ‰¿æ‰˜åº•座ã€è„šä¸‹åœ°æ¿")); + assert!(prompt.contains("四周至少ä¿ç•™18%纯洋红安全留白")); + assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); + assert!(prompt.contains("主体å…许使用绿色ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°å’ŒèŠ±æœµ")); assert!(prompt.contains("ä¸ç»˜åˆ¶è½åœ°æŠ•å½±")); assert!(prompt.contains("ä¸ç”»åˆ†éš”线ã€ç½‘格线ã€å®¹å™¨æ¡†æˆ–棋盘格")); assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("front-facing 30-degree camera-pitch")); + assert!(prompt.contains("camera slightly above the object")); + assert!( + prompt.contains("visible front/side area must be close to or larger than the top area") + ); + assert!(prompt.contains("never produce top-down")); + assert!(prompt.contains("each object's native silhouette decides the shape")); + assert!(prompt.contains("no extra base under the object")); + assert!(prompt.contains("no pedestal")); + assert!(prompt.contains("no floor slab")); + assert!(!prompt.contains("å¯è½è„šå¹³å°ç´ æ")); + assert!(!prompt.contains("å¹³å°è£¸ç´ æ")); + assert!(!prompt.contains("æ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°")); + assert!(!prompt.contains("25个平å°")); + assert!(!prompt.contains("platform, each")); + assert!(!prompt.contains("only platform")); + assert!(!prompt.contains("基础轮廓优先åšä¸è§„则主题剪影")); + assert!(!prompt.contains("25æ ¼é€ åž‹è¦æ··æŽ’")); + assert!(!prompt.contains("no simple circles")); + assert!(!prompt.contains("no simple squares")); + assert!(!prompt.contains("纯绿色绿幕")); + assert!(!prompt.contains("#00FF00")); + assert!(!prompt.contains("isolated top-down")); assert!(!prompt.contains("按5行*5列")); assert!(!prompt.contains("2D地æ¿å›¾æ ‡")); assert!(!prompt.contains("清爽自然的游æˆå›¾æ ‡")); @@ -1184,6 +1359,91 @@ mod tests { assert!(!prompt.contains("ä¸åŒè§†å›¾")); } + #[test] + fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() { + let prompt = build_jump_hop_background_prompt("æ°´æžœ"); + + assert!(prompt.contains("9:16竖版跳一跳游æˆèƒŒæ™¯åº•图")); + assert!(prompt.contains("主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œæ°´æžœâ€")); + assert!(prompt.contains("整体风格需è¦å’ŒåŒä¸€ä¸»é¢˜çš„跳一跳游æˆå…ƒç´ ä¸€è‡´")); + assert!(prompt.contains("å·¦å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»")); + assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊")); + assert!(prompt.contains("该区域åªèƒ½ä½¿ç”¨å°‘é‡ä½Žå¯¹æ¯”度纹ç†")); + assert!(prompt.contains("中央纵轴1/2åŒºåŸŸè¦æœ‰æ˜Žæ˜¾çºµæ·±æ„Ÿ")); + assert!(prompt.contains("两侧å¯ä»¥æ›´æœ‰ç«‹ä½“感ã€ç©ºé—´å±‚次和主题氛围")); + assert!(prompt.contains("ä¸ç”»ä»»ä½•è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UI按钮")); + assert!(prompt.contains("è§†è§’ä¿æŒæ­£é¢çº¦30度")); + assert!(prompt.contains("中央区域需è¦ç»™è¿è¡Œæ€åœ°å—和陶泥儿角色留出干净å¯è¯»ç©ºé—´")); + assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("left and right sides carry the atmosphere")); + assert!(prompt.contains("central vertical half-width corridor stays simple")); + assert!(prompt.contains("no platforms")); + assert!(prompt.contains("no landing objects")); + } + + #[test] + fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() { + let negative_prompt = build_jump_hop_background_negative_prompt(); + + assert!(negative_prompt.contains("è·³æ¿")); + assert!(negative_prompt.contains("地å—")); + assert!(negative_prompt.contains("è½è„šç‰©")); + assert!(negative_prompt.contains("角色")); + assert!(negative_prompt.contains("UI按钮")); + assert!(negative_prompt.contains("中央堆满元素")); + assert!(negative_prompt.contains("䏭央鮿Œ¡")); + assert!(negative_prompt.contains("纯俯视地图")); + assert!(negative_prompt.contains("平铺俯æ‹")); + } + + #[test] + fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() { + assert!(is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png", + )); + assert!(is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png", + )); + assert!(!is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/background/image.png", + )); + assert!(!is_jump_hop_legacy_cover_composite_placeholder( + "/uploads/custom-cover.png", + )); + } + + #[test] + fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() { + let prompt = build_jump_hop_tile_atlas_prompt( + "科幻芯片", + "科幻芯片主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ", + ); + + assert!(prompt.contains("ç”»é¢å†…容是科幻芯片主题的正é¢30度视角清爽游æˆåŒ–立体感主题物体")); + assert!(!prompt.contains("ç”»é¢å†…容是科幻芯片主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ")); + assert!(!prompt.contains("ç”»é¢å†…容是科幻芯片主题的俯视角")); + + let top_down_prompt = + build_jump_hop_tile_atlas_prompt("æ°´æžœ", "水果主题鸟瞰视角平铺俯æ‹åœ†å½¢å¹³å°"); + + assert!(top_down_prompt.contains("ç”»é¢å†…容是水果主题正é¢30度视角圆形主题物体")); + assert!(!top_down_prompt.contains("ç”»é¢å†…容是水果主题鸟瞰视角")); + assert!(!top_down_prompt.contains("ç”»é¢å†…容是水果主题平铺俯æ‹")); + + let legacy_prompt = build_jump_hop_tile_atlas_prompt( + "雪花", + "雪花主题å¯è½è„šå¹³å°ç´ æï¼Œæ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°ï¼Œä¸è¦åº•座", + ); + + assert!(legacy_prompt.contains("雪花主题跳跃è½ç‚¹ä¸»é¢˜ç‰©ä½“")); + assert!(legacy_prompt.contains("æ¯æ ¼ä¸€ä¸ªå®Œæ•´ä¸»é¢˜ç‰©ä½“")); + assert!(legacy_prompt.contains("ä¸è¦æ‰¿æ‰˜ç‰©")); + assert!(!legacy_prompt.contains("ç”»é¢å†…容是雪花主题å¯è½è„šå¹³å°ç´ æ")); + assert!(!legacy_prompt.contains("ç”»é¢å†…容是雪花主题å¯è½è„š")); + assert!(!legacy_prompt.contains("ç”»é¢å†…容是雪花主题平å°")); + assert!(!legacy_prompt.contains("ç”»é¢å†…容是雪花主题地å—")); + } + #[test] fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() { let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); @@ -1192,9 +1452,28 @@ mod tests { assert!(negative_prompt.contains("厚é‡CG渲染")); assert!(negative_prompt.contains("游æˆç•Œé¢")); assert!(negative_prompt.contains("图标集页é¢")); - assert!(negative_prompt.contains("建筑")); + assert!(negative_prompt.contains("纯俯视角")); + assert!(negative_prompt.contains("正上方视角")); + assert!(negative_prompt.contains("鸟瞰视角")); + assert!(negative_prompt.contains("é¡¶é¢å ä¸»ç”»é¢")); + assert!(negative_prompt.contains("åªçœ‹é¡¶é¢")); + assert!(negative_prompt.contains("圆形顶视图")); + assert!(negative_prompt.contains("æ‰å¹³å›¾æ ‡")); assert!(negative_prompt.contains("方形阴影")); assert!(negative_prompt.contains("方形底æ¿")); + assert!(negative_prompt.contains("é¢å¤–底座")); + assert!(negative_prompt.contains("承托底座")); + assert!(negative_prompt.contains("å°åº§")); + assert!(negative_prompt.contains("物体摆在平å°ä¸Š")); + assert!(negative_prompt.contains("物体下方垫地æ¿")); + assert!(!negative_prompt.contains("规则圆盘")); + assert!(!negative_prompt.contains("正圆平å°")); + assert!(!negative_prompt.contains("规则方å—")); + assert!(!negative_prompt.contains("圆角矩形")); + assert!(!negative_prompt.contains("æ¯åž«")); + assert!(!negative_prompt.contains("é‡å¤åœ†å½¢")); + assert!(!negative_prompt.contains("建筑")); + assert!(!negative_prompt.contains("楼房")); } #[test] @@ -1283,6 +1562,62 @@ mod tests { } } + #[test] + fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() { + let width = 500; + let height = 500; + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..5 { + for col in 0..5 { + let color = if row == 0 && col == 0 { + image::Rgba([62, 188, 74, 255]) + } else if row == 0 && col == 1 { + image::Rgba([246, 246, 238, 255]) + } else { + image::Rgba([120, 96, 72, 255]) + }; + let center_x = col as u32 * 100 + 50; + let center_y = row as u32 * 100 + 50; + for y in center_y - 24..center_y + 24 { + for x in center_x - 28..center_x + 28 { + 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"); + let green_tile = image::load_from_memory(slices[0].bytes.as_slice()) + .expect("green tile should decode") + .to_rgba8(); + let white_tile = image::load_from_memory(slices[1].bytes.as_slice()) + .expect("white tile should decode") + .to_rgba8(); + + assert!( + green_tile + .pixels() + .any(|pixel| pixel.0 == [62, 188, 74, 255]) + ); + assert!( + white_tile + .pixels() + .any(|pixel| pixel.0 == [246, 246, 238, 255]) + ); + assert_eq!(green_tile.get_pixel(0, 0).0[3], 0); + assert_eq!(white_tile.get_pixel(0, 0).0[3], 0); + } + #[test] fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() { let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index 92810d15..782460ca 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -2,13 +2,80 @@ use super::color::{ GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE, GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_green_screen_score, + compute_generated_asset_sheet_key_color_score, compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetKeyColor { + pub red: u8, + pub green: u8, + pub blue: u8, +} + +impl GeneratedAssetSheetKeyColor { + pub const GREEN_SCREEN: Self = Self { + red: 0, + green: 255, + blue: 0, + }; + + pub const MAGENTA_SCREEN: Self = Self { + red: 255, + green: 0, + blue: 255, + }; + + pub fn is_green_screen(self) -> bool { + self == Self::GREEN_SCREEN + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GeneratedAssetSheetAlphaOptions { + pub key_color: GeneratedAssetSheetKeyColor, + pub remove_near_white_background: bool, + pub remove_disconnected_hard_key_background: bool, +} + +impl GeneratedAssetSheetAlphaOptions { + pub const fn green_screen() -> Self { + Self { + key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN, + remove_near_white_background: true, + remove_disconnected_hard_key_background: true, + } + } + + pub const fn jump_hop_magenta_screen() -> Self { + Self { + key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN, + remove_near_white_background: false, + remove_disconnected_hard_key_background: false, + } + } +} + +impl Default for GeneratedAssetSheetAlphaOptions { + fn default() -> Self { + Self::green_screen() + } +} + pub fn apply_generated_asset_sheet_green_screen_alpha( source: image::DynamicImage, +) -> image::DynamicImage { + apply_generated_asset_sheet_alpha_with_options( + source, + GeneratedAssetSheetAlphaOptions::default(), + ) +} + +pub fn apply_generated_asset_sheet_alpha_with_options( + source: image::DynamicImage, + options: GeneratedAssetSheetAlphaOptions, ) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha( image.as_mut(), width as usize, height as usize, + options, ); image::DynamicImage::ImageRgba8(image) } @@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background( pixels: &mut [u8], width: usize, height: usize, + options: GeneratedAssetSheetAlphaOptions, ) -> bool { let pixel_count = width.saturating_mul(height); if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { return false; } - let mut green_scores = vec![0.0f32; pixel_count]; + let mut key_scores = vec![0.0f32; pixel_count]; let mut white_scores = vec![0.0f32; pixel_count]; let mut background_hints = vec![0.0f32; pixel_count]; let mut background_mask = vec![0u8; pixel_count]; @@ -43,16 +112,19 @@ fn remove_generated_asset_sheet_green_screen_background( let green = pixels[offset + 1]; let blue = pixels[offset + 2]; let alpha = pixels[offset + 3]; - let green_score = - compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); - let white_score = - compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); + let key_score = + compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color); + let white_score = if options.remove_near_white_background { + compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]) + } else { + 0.0 + }; let transparency_hint = clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75; - green_scores[pixel_index] = green_score; + key_scores[pixel_index] = key_score; white_scores[pixel_index] = white_score; - background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); + background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint); } let seed_background_pixel = @@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background( } let alpha = pixels[pixel_index * 4 + 3]; let strong_candidate = alpha < 40 - || green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE || (alpha < 224 - && green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) - || white_scores[pixel_index] > 0.32; + && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) + || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); if !strong_candidate { return; } @@ -113,26 +185,34 @@ fn remove_generated_asset_sheet_green_screen_background( } let next_offset = next_pixel_index * 4; let alpha = pixels[next_offset + 3]; - let green_score = green_scores[next_pixel_index]; + let key_score = key_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index]; let hint = background_hints[next_pixel_index]; let reachable_soft_edge = hint > 0.08 && alpha < 224 - && (green_score > 0.04 || white_score > 0.08 || alpha < 180); - let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); - if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { + && (key_score > 0.04 + || (options.remove_near_white_background && white_score > 0.08) + || alpha < 180); + let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); + if alpha < 40 + || key_background + || (options.remove_near_white_background && white_score > 0.32) + || reachable_soft_edge + { background_mask[next_pixel_index] = 1; queue.push(next_pixel_index); } } } - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 - && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - { - background_mask[pixel_index] = 1; + if options.remove_disconnected_hard_key_background { + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 + && key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + { + background_mask[pixel_index] = 1; + } } } @@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background( pixels[offset + 2], pixels[offset + 3], ]; - let green_score = green_scores[pixel_index]; + let key_score = key_scores[pixel_index]; let white_score = white_scores[pixel_index]; - if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) - { + if !is_generated_asset_sheet_soft_key_matte_pixel( + pixel, + key_score, + white_score, + options, + ) { continue; } if !touches_generated_asset_sheet_background_mask( @@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background( continue; } let alpha = pixels[pixel_index * 4 + 3]; - let green_score = green_scores[pixel_index]; + let key_score = key_scores[pixel_index]; let white_score = white_scores[pixel_index]; let hint = background_hints[pixel_index]; let soft_matte_candidate = alpha < 224 - || white_score > 0.10 - || green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; + || (options.remove_near_white_background && white_score > 0.10) + || key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { continue; } @@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background( continue; } - let green_score = green_scores[pixel_index]; + let key_score = key_scores[pixel_index]; let white_score = white_scores[pixel_index]; - let contamination = green_score.max(white_score).max(if alpha < 220 { + let contamination = key_score.max(white_score).max(if alpha < 220 { ((220 - alpha) as f32 / 220.0) * 0.25 } else { 0.0 @@ -308,23 +392,23 @@ fn remove_generated_asset_sheet_green_screen_background( green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend); - if green_score > 0.04 { + if options.key_color.is_green_screen() && key_score > 0.04 { green = green.min(sample_green as f32 + 18.0); } - if white_score > 0.1 { + if options.remove_near_white_background && white_score > 0.1 { red = red.min(sample_red as f32 + 26.0); green = green.min(sample_green as f32 + 26.0); blue = blue.min(sample_blue as f32 + 26.0); } } else { - if green_score > 0.04 { + if options.key_color.is_green_screen() && key_score > 0.04 { let toned_green = (green - (green - red.max(blue)) * 0.78) .round() .max(red.max(blue)); green = green.min(toned_green).min(red.max(blue) + 18.0); } - if white_score > 0.12 { + if options.remove_near_white_background && white_score > 0.12 { let spread = red.max(green).max(blue) - red.min(green).min(blue); if spread < 20.0 { let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); @@ -336,7 +420,7 @@ fn remove_generated_asset_sheet_green_screen_background( } let mut next_alpha = alpha; - let edge_fade = (green_score * 0.35).max(white_score * 0.28); + let edge_fade = (key_score * 0.35).max(white_score * 0.28); if edge_fade > 0.08 { next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; if next_alpha < 10 { @@ -364,6 +448,35 @@ fn remove_generated_asset_sheet_green_screen_background( changed } +fn compute_generated_asset_sheet_key_score( + pixel: [u8; 4], + key_color: GeneratedAssetSheetKeyColor, +) -> f32 { + if key_color.is_green_screen() { + return compute_generated_asset_sheet_green_screen_score(pixel); + } + + compute_generated_asset_sheet_key_color_score( + pixel, + [key_color.red, key_color.green, key_color.blue], + ) +} + +fn is_generated_asset_sheet_soft_key_matte_pixel( + pixel: [u8; 4], + key_score: f32, + white_score: f32, + options: GeneratedAssetSheetAlphaOptions, +) -> bool { + if options.key_color.is_green_screen() { + return is_generated_asset_sheet_soft_green_matte_pixel(pixel, key_score, white_score); + } + + pixel[3] != 0 + && key_score >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE + && (!options.remove_near_white_background || white_score < 0.34) +} + fn collect_generated_asset_sheet_foreground_neighbor_color( pixels: &[u8], width: usize, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs index 833082ed..ecd5e2c8 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs @@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) - .clamp(0.0, 1.0) } +pub(super) fn compute_generated_asset_sheet_key_color_score( + pixel: [u8; 4], + key_color: [u8; 3], +) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let color_distance = (pixel[0] as f32 - key_color[0] as f32).abs() + + (pixel[1] as f32 - key_color[1] as f32).abs() + + (pixel[2] as f32 - key_color[2] as f32).abs(); + if color_distance >= 180.0 { + return 0.0; + } + + clamp_generated_asset_sheet_unit(1.0 - color_distance / 180.0) +} + pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 { if pixel[3] == 0 { return 1.0; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index 1abfdff2..fa55105e 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -5,7 +5,10 @@ pub mod persist; pub mod prompt; pub mod sheet; -pub use alpha::apply_generated_asset_sheet_green_screen_alpha; +pub use alpha::{ + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetKeyColor, + apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, +}; pub use error::GeneratedAssetSheetError; pub use persist::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload, @@ -14,5 +17,6 @@ pub use persist::{ pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; pub use sheet::{ GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, - slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, + crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet, + slice_generated_asset_sheet_two_items_per_row, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 8d2a6d6a..6bfbf96f 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -1,6 +1,9 @@ -use super::alpha::apply_generated_asset_sheet_green_screen_alpha; +use super::alpha::{ + GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha, +}; use super::color::{ - is_generated_asset_sheet_foreground_pixel, + compute_generated_asset_sheet_key_color_score, + compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel, is_generated_asset_sheet_green_contaminated_edge_pixel, is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel, @@ -130,10 +133,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row( pub fn crop_generated_asset_sheet_view_edge_matte( image: image::DynamicImage, +) -> image::DynamicImage { + crop_generated_asset_sheet_view_edge_matte_with_options( + image, + GeneratedAssetSheetAlphaOptions::default(), + ) +} + +pub fn crop_generated_asset_sheet_view_edge_matte_with_options( + image: image::DynamicImage, + options: GeneratedAssetSheetAlphaOptions, ) -> image::DynamicImage { let mut image = image.to_rgba8(); let (width, height) = image.dimensions(); - remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); + remove_generated_asset_sheet_view_edge_matte( + image.as_mut(), + width as usize, + height as usize, + options, + ); let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { GeneratedAssetSheetCellBounds { x0: 0, @@ -359,6 +377,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels: &mut [u8], width: usize, height: usize, + options: GeneratedAssetSheetAlphaOptions, ) -> bool { let pixel_count = width.saturating_mul(height); if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { @@ -403,7 +422,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 2], pixels[offset + 3], ]; - if !is_generated_asset_sheet_view_background_pixel(pixel) { + if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) { continue; } background_mask[pixel_index] = 1; @@ -434,7 +453,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 2], pixels[offset + 3], ]; - if !is_generated_asset_sheet_view_background_pixel(pixel) { + if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) { continue; } background_mask[next_pixel_index] = 1; @@ -452,12 +471,15 @@ fn remove_generated_asset_sheet_view_edge_matte( continue; } let offset = pixel_index * 4; - if !is_generated_asset_sheet_view_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { + if !is_generated_asset_sheet_view_background_pixel_with_options( + [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ], + options, + ) { continue; } @@ -526,7 +548,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 2], pixels[offset + 3], ]; - if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { + if !is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) { continue; } if !touches_generated_asset_sheet_background_mask( @@ -539,7 +561,7 @@ fn remove_generated_asset_sheet_view_edge_matte( continue; } - if is_generated_asset_sheet_strong_green_contamination(pixel) { + if is_generated_asset_sheet_strong_key_contamination(pixel, options) { pixels[offset] = 0; pixels[offset + 1] = 0; pixels[offset + 2] = 0; @@ -559,6 +581,7 @@ fn remove_generated_asset_sheet_view_edge_matte( y, &background_mask, &visible_mask, + options, ) .unwrap_or(( pixels[offset], @@ -605,6 +628,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color( y: usize, background_mask: &[u8], visible_mask: &[u8], + options: GeneratedAssetSheetAlphaOptions, ) -> Option<(u8, u8, u8)> { let mut total_weight = 0.0f32; let mut total_red = 0.0f32; @@ -638,8 +662,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color( pixels[next_offset + 2], next_alpha, ]; - if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) - || is_generated_asset_sheet_soft_edge_pixel(pixel) + if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) + || (options.key_color.is_green_screen() + && is_generated_asset_sheet_soft_edge_pixel(pixel)) { continue; } @@ -670,3 +695,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color( (total_blue / total_weight).round() as u8, )) } + +fn is_generated_asset_sheet_view_background_pixel_with_options( + pixel: [u8; 4], + options: GeneratedAssetSheetAlphaOptions, +) -> bool { + if options.key_color.is_green_screen() && options.remove_near_white_background { + return is_generated_asset_sheet_view_background_pixel(pixel); + } + + if pixel[3] < 16 { + return true; + } + + if options.key_color.is_green_screen() && is_generated_asset_sheet_soft_edge_pixel(pixel) { + return true; + } + + if !options.key_color.is_green_screen() + && compute_generated_asset_sheet_key_color_score( + pixel, + [ + options.key_color.red, + options.key_color.green, + options.key_color.blue, + ], + ) > 0.18 + { + return true; + } + + options.remove_near_white_background + && compute_generated_asset_sheet_white_screen_score(pixel) > 0.18 +} + +fn is_generated_asset_sheet_key_contaminated_edge_pixel( + pixel: [u8; 4], + options: GeneratedAssetSheetAlphaOptions, +) -> bool { + if options.key_color.is_green_screen() { + return is_generated_asset_sheet_green_contaminated_edge_pixel(pixel); + } + + pixel[3] != 0 + && compute_generated_asset_sheet_key_color_score( + pixel, + [ + options.key_color.red, + options.key_color.green, + options.key_color.blue, + ], + ) > 0.18 +} + +fn is_generated_asset_sheet_strong_key_contamination( + pixel: [u8; 4], + options: GeneratedAssetSheetAlphaOptions, +) -> bool { + if options.key_color.is_green_screen() { + return is_generated_asset_sheet_strong_green_contamination(pixel); + } + + compute_generated_asset_sheet_key_color_score( + pixel, + [ + options.key_color.red, + options.key_color.green, + options.key_color.blue, + ], + ) > 0.62 +} diff --git a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs index df530028..6473f756 100644 --- a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs +++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs @@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use platform_image::DownloadedImage; use platform_image::generated_asset_sheets::{ - GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, - GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha, + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput, + GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, + apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte, + crop_generated_asset_sheet_view_edge_matte_with_options, prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, }; @@ -142,6 +144,68 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() { assert_eq!(cleaned.get_pixel(10, 10).0[3], 255); } +#[test] +fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() { + let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255])); + for y in 6..22 { + for x in 6..14 { + sheet.put_pixel(x, y, Rgba([64, 188, 74, 255])); + } + } + for y in 6..22 { + for x in 14..22 { + sheet.put_pixel(x, y, Rgba([244, 244, 236, 255])); + } + } + for y in 12..16 { + for x in 12..16 { + sheet.put_pixel(x, y, Rgba([255, 0, 255, 255])); + } + } + + let cleaned = apply_generated_asset_sheet_alpha_with_options( + DynamicImage::ImageRgba8(sheet), + GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ) + .to_rgba8(); + + assert_eq!(cleaned.get_pixel(0, 0).0[3], 0); + assert_eq!(cleaned.get_pixel(8, 8).0[3], 255); + assert_eq!(cleaned.get_pixel(18, 8).0[3], 255); + assert_eq!( + cleaned.get_pixel(13, 13).0[3], + 255, + "éžè¾¹ç¼˜è¿žé€šçš„ key 色åƒç´ ä¸åº”被当æˆèƒŒæ™¯æ¸…掉" + ); +} + +#[test] +fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() { + let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0])); + for y in 2..22 { + for x in 2..22 { + sheet.put_pixel(x, y, Rgba([246, 246, 240, 255])); + } + } + for y in 0..24 { + sheet.put_pixel(0, y, Rgba([255, 0, 255, 255])); + sheet.put_pixel(23, y, Rgba([255, 0, 255, 255])); + } + + let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options( + DynamicImage::ImageRgba8(sheet), + GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ) + .to_rgba8(); + + assert_eq!(cleaned.get_pixel(1, 1).0[3], 255); + assert!( + cleaned + .pixels() + .any(|pixel| pixel.0 == [246, 246, 240, 255]) + ); +} + #[test] fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0])); diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 0ba46095..d4b84451 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -836,7 +836,7 @@ fn default_draft() -> JumpHopDraftResponse { style_preset: JumpHopStylePreset::MinimalBlocks, default_character: Some(default_jump_hop_default_character()), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: "跳一跳主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ".to_string(), + tile_prompt: "跳一跳主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index a821a317..47efee78 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -431,6 +431,7 @@ mod tests { event_prize_pool_mud_points: 0, event_starts_at_text: None, event_ends_at_text: None, + event_banners_json: None, } } @@ -448,6 +449,7 @@ mod tests { category_id: Some("recommended".to_string()), category_label: Some("热门推è".to_string()), category_sort_order: 20, + unified_creation_spec_json: None, } } @@ -465,7 +467,10 @@ mod tests { .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平å°è·³è·ƒ"); - assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); } #[test] @@ -489,7 +494,10 @@ mod tests { prize_pool_mud_points: 58_000, starts_at_text: "2024.10.20 10:00".to_string(), ends_at_text: "2024.11.20 23:59".to_string(), + render_mode: "structured".to_string(), + html_code: None, }, + event_banners_json: None, creation_types: vec![CreationEntryTypeSnapshot { id: "jump-hop".to_string(), title: "跳一跳".to_string(), @@ -503,6 +511,7 @@ mod tests { category_label: "热门推è".to_string(), category_sort_order: 20, updated_at_micros: 2_000_000, + unified_creation_spec_json: None, }], updated_at_micros: 1_000_000, }); @@ -514,7 +523,10 @@ mod tests { .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平å°è·³è·ƒ"); - assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index e78e5dd7..4f53d9df 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1179,7 +1179,7 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { difficulty: JumpHopDifficulty::Standard.as_str().to_string(), style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: format!("{seed}主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ"), + tile_prompt: format!("{seed}主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹"), end_mood_prompt: String::new(), } } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 7b16d9a1..1a574ed4 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { @@ -229,7 +229,31 @@ test('è·³ä¸€è·³è“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ‹‰ä¼¸', async () => { ); }); -test('跳一跳è¿è¡Œæ€éœ€è¦ä¸‰ç»´åœºæ™¯å®¿ä¸»å’ŒæŽ’è¡Œæ¦œé¢æ¿', () => { +test('跳一跳è¿è¡Œæ€æ¸¸çީ䏭åªä¿ç•™å¾—分并éšè—常驻排行榜', () => { + const runtimeRequestOptions = { + runtimeGuestToken: 'runtime-guest-token', + }; + + render( + {}} + />, + ); + + expect(useJumpHopLeaderboard).not.toHaveBeenCalled(); + expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy(); + expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); + expect(screen.queryByRole('button', { name: /é‡å¼€/ })).toBeNull(); + expect(screen.queryByText('进行中')).toBeNull(); + expect(screen.queryByText('00:00')).toBeNull(); + expect(screen.queryByRole('button', { name: /^èµ·è·³$/ })).toBeNull(); +}); + +test('跳一跳è¿è¡Œæ€å¤±è´¥åŽåœ¨å¼¹çª—中展示排行榜', () => { const runtimeRequestOptions = { runtimeGuestToken: 'runtime-guest-token', }; @@ -255,7 +279,7 @@ test('跳一跳è¿è¡Œæ€éœ€è¦ä¸‰ç»´åœºæ™¯å®¿ä¸»å’ŒæŽ’è¡Œæ¦œé¢æ¿', () => { render( {}} @@ -266,12 +290,12 @@ test('跳一跳è¿è¡Œæ€éœ€è¦ä¸‰ç»´åœºæ™¯å®¿ä¸»å’ŒæŽ’è¡Œæ¦œé¢æ¿', () => { 'jump-hop-profile-test', runtimeRequestOptions, ); - expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy(); - expect(screen.getByTestId('jump-hop-runtime-leaderboard')).toBeTruthy(); - expect(screen.getByText('player-1')).toBeTruthy(); - expect(screen.getByText('8 è·³')).toBeTruthy(); - expect(screen.getByText('00:08')).toBeTruthy(); - expect(screen.queryByRole('button', { name: /^èµ·è·³$/ })).toBeNull(); + expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy(); + const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard'); + expect(leaderboard).toBeTruthy(); + expect(within(leaderboard).getByText('player-1')).toBeTruthy(); + expect(within(leaderboard).getByText('8 è·³')).toBeTruthy(); + expect(within(leaderboard).getByText('00:08')).toBeTruthy(); }); test('跳一跳角色层永远压在地å—层之上', () => { @@ -356,6 +380,7 @@ test('跳一跳è¿è¡Œæ€ç›´æŽ¥æ¸²æŸ“生æˆçš„地å—切片图片', () => { const tileImages = screen.getAllByTestId('jump-hop-tile-image'); expect(tileImages).toHaveLength(3); + expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); const generatedReadUrlCalls = vi .mocked(useResolvedAssetReadUrl) .mock.calls.filter(([source]) => @@ -379,6 +404,25 @@ test('跳一跳è¿è¡Œæ€ç›´æŽ¥æ¸²æŸ“生æˆçš„地å—切片图片', () => { } }); +test('跳一跳è¿è¡Œæ€æå‰é¢„加载下一å±åœ°å—且ä¸åœ¨çœŸå®žå›¾ç‰‡åŠ è½½å‰éœ²å‡ºåŽŸåž‹æ–¹å—', () => { + render( + {}} + />, + ); + + expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3); + expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); + const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image'); + expect(preloadImages.length).toBeGreaterThan(0); + expect(preloadImages[0]?.getAttribute('src')).toContain( + '/generated-jump-hop-assets/jump-hop-profile-test/tile-', + ); +}); + test('跳一跳è¿è¡Œæ€é¦–å—地å—è½åœ¨ä¸­ä¸‹æ–¹å¹¶ä¸”åŽç»­ä¸¤å—å‘中央和上方展开', () => { render( [\s\S]*?)\}/, + )?.groups?.body; + expect(advancingCharacterRule).toContain('transform 120ms ease'); + expect(advancingCharacterRule).toContain('opacity 160ms ease'); + expect(advancingCharacterRule).not.toContain('left'); + expect(advancingCharacterRule).not.toContain('top'); expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe( cameraLayer, ); @@ -823,6 +875,50 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse { }; } +function buildFailedRun(): JumpHopRuntimeRunSnapshotResponse { + return { + ...buildRun(), + status: 'failed', + successfulJumpCount: 8, + durationMs: 8123, + score: 8, + combo: 0, + lastJump: { + chargeMs: 420, + jumpDistance: 1.62, + targetPlatformIndex: 1, + landedX: 0, + landedY: 0, + result: 'miss', + }, + finishedAtMs: 9123, + }; +} + +function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse { + const run = buildRun(); + return { + ...run, + path: { + ...run.path, + platforms: [ + ...run.path.platforms, + { + platformId: 'p3', + tileType: 'normal', + x: 0.5, + y: 3.6, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + ], + }, + }; +} + function buildTileAssets() { return Array.from({ length: 25 }, (_, index) => { const tileNumber = String(index + 1).padStart(2, '0'); @@ -845,6 +941,8 @@ function buildTileAssets() { function buildProfile(options: { tileAssets?: JumpHopWorkProfileResponse['tileAssets']; + coverComposite?: string | null; + coverImageSrc?: string | null; } = {}): JumpHopWorkProfileResponse { const characterAsset = { assetId: 'builtin', @@ -869,7 +967,7 @@ function buildProfile(options: { themeTags: ['测试'], difficulty: 'standard', stylePreset: 'minimal-blocks', - coverImageSrc: null, + coverImageSrc: options.coverImageSrc ?? null, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-27T00:00:00Z', @@ -901,7 +999,7 @@ function buildProfile(options: { tileAtlasAsset: characterAsset, tileAssets: options.tileAssets ?? [], path: buildRun().path, - coverComposite: null, + coverComposite: options.coverComposite ?? null, generationStatus: 'ready', }, path: buildRun().path, @@ -917,3 +1015,46 @@ function buildProfile(options: { tileAssets: options.tileAssets ?? [], }; } + +test('跳一跳è¿è¡Œæ€ä½¿ç”¨ image2 背景底图铺满舞å°åº•层', () => { + const backgroundSource = + '/generated-jump-hop-assets/jump-hop-profile-test/background/image.png'; + + render( + {}} + />, + ); + + const backgroundImage = screen.getByTestId('jump-hop-stage-background-image'); + expect(backgroundImage.getAttribute('src')).toBe(backgroundSource); + const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop'); + expect(backdrop?.getAttribute('data-has-background')).toBe('true'); + expect(useResolvedAssetReadUrl).toHaveBeenCalledWith( + backgroundSource, + expect.objectContaining({ + refreshKey: backgroundSource, + }), + ); +}); + +test('跳一跳è¿è¡Œæ€å¿½ç•¥æ—§ cover composite å ä½èƒŒæ™¯', () => { + render( + {}} + />, + ); + + expect(screen.queryByTestId('jump-hop-stage-background-image')).toBeNull(); + const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop'); + expect(backdrop?.getAttribute('data-has-background')).toBe('false'); +}); diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 8393f75d..fdc1413d 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; import { type CSSProperties, type Dispatch, @@ -29,6 +29,7 @@ import { getJumpHopRunDurationMs, getJumpHopStatusLabel, getJumpHopTileTone, + selectJumpHopTileAsset, type JumpHopCharacterVisualPosition, type JumpHopVisiblePlatform, resolveJumpHopCharacterCanvasPosition, @@ -66,6 +67,7 @@ const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = '/branding/jump-hop-taonier-character.png'; +const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -192,6 +194,21 @@ function IsometricFallbackTile({ ); } +function getJumpHopTileAssetRefreshKey(asset: JumpHopTileAsset | null) { + return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; +} + +function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { + const value = source?.trim() ?? ''; + if (!value) { + return false; + } + return !( + value.startsWith('/generated-jump-hop-assets/') && + (value.endsWith('/cover-composite.png') || value.includes('/cover-composite-')) + ); +} + function JumpHopTileImage({ asset, platform, @@ -199,8 +216,7 @@ function JumpHopTileImage({ asset: JumpHopTileAsset | null; platform: JumpHopVisiblePlatform['platform']; }) { - const assetRefreshKey = - asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; + const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, { refreshKey: assetRefreshKey, }); @@ -212,12 +228,13 @@ function JumpHopTileImage({ setHasError(false); }, [resolvedUrl]); - const shouldShowFallback = !resolvedUrl || !isLoaded || hasError; + const shouldShowImage = Boolean(resolvedUrl && !hasError); + const shouldShowFallback = !shouldShowImage; return (
{shouldShowFallback ? : null} - {resolvedUrl && !hasError ? ( + {shouldShowImage ? ( { setIsLoaded(true); }} @@ -238,6 +255,28 @@ function JumpHopTileImage({ ); } +function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { + const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); + const { resolvedUrl } = useResolvedAssetReadUrl(asset.imageSrc, { + refreshKey: assetRefreshKey, + }); + + if (!resolvedUrl) { + return null; + } + + return ( + + ); +} + function hasJumpHopWebGLSupport() { if (import.meta.env.MODE === 'test') { return false; @@ -573,6 +612,16 @@ export function JumpHopRuntimeShell({ const displayRunRef = useRef(displayRun); const visiblePlatformsRef = useRef([]); const tileAssetsRef = useRef(profile?.tileAssets); + const stageBackgroundSource = [ + profile?.draft.coverComposite, + profile?.summary.coverImageSrc, + ].find(isJumpHopGeneratedBackgroundSource); + const { resolvedUrl: stageBackgroundUrl } = useResolvedAssetReadUrl( + stageBackgroundSource, + { + refreshKey: stageBackgroundSource, + }, + ); useEffect(() => { activeRunRef.current = activeRun; @@ -612,7 +661,7 @@ export function JumpHopRuntimeShell({ const platformRenderItems = useMemo(() => { const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({ ...item, - renderKey: `${item.platform.platformId}-exiting`, + renderKey: item.platform.platformId, advanceState: 'exiting' as const, })); const visibleItems = visiblePlatforms.map((item) => ({ @@ -627,6 +676,47 @@ export function JumpHopRuntimeShell({ platformAdvanceExitingPlatforms, visiblePlatforms, ]); + const preloadTileAssets = useMemo(() => { + const path = stageRun?.path; + const tileAssets = profile?.tileAssets; + const platforms = path?.platforms ?? []; + const startIndex = + (stageRun?.currentPlatformIndex ?? 0) + visiblePlatforms.length; + const assets = new Map(); + + for ( + let index = startIndex; + index < + Math.min( + platforms.length, + startIndex + JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT, + ); + index += 1 + ) { + const platform = platforms[index]; + if (!platform) { + continue; + } + const asset = selectJumpHopTileAsset( + tileAssets, + path?.seed ?? null, + index, + platform.platformId, + ); + if (!asset) { + continue; + } + const key = getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc; + assets.set(key, asset); + } + + return [...assets.values()]; + }, [ + profile?.tileAssets, + stageRun?.currentPlatformIndex, + stageRun?.path, + visiblePlatforms.length, + ]); const showLandingAssist = import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating; const characterPosition = getJumpHopCharacterVisualPosition( @@ -753,6 +843,7 @@ export function JumpHopRuntimeShell({ const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); const isSettled = stageRun?.status === 'failed' || stageRun?.status === 'cleared'; + const shouldShowFailureLeaderboard = stageRun?.status === 'failed'; const successfulJumpCount = stageRun?.successfulJumpCount ?? 0; const durationLabel = formatJumpHopDurationLabel( getJumpHopRunDurationMs(stageRun, nowMs), @@ -1219,29 +1310,19 @@ export function JumpHopRuntimeShell({
-
+
{successfulJumpCount} - - {durationLabel}
- +
@@ -1251,7 +1332,7 @@ export function JumpHopRuntimeShell({ data-charging={isCharging ? 'true' : 'false'} data-jump-animating={isJumpAnimating ? 'true' : 'false'} data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'} - className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem] border border-white/70 bg-white/40 shadow-[0_24px_70px_rgba(44,125,182,0.2)]" + className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem]" onPointerDown={beginCharge} onPointerMove={updateDragVector} onPointerUp={(event) => void finishCharge(event)} @@ -1260,7 +1341,18 @@ export function JumpHopRuntimeShell({
0 ? ( + + ) : null} + {visualCharacterPosition && !isThreeCharacterLayerReady ? (
-
+
- {getJumpHopStatusLabel(stageRun?.status)} + + {getJumpHopStatusLabel(stageRun?.status)} +
{successfulJumpCount} è·³ {durationLabel}
+ {shouldShowFailureLeaderboard ? ( + + ) : null}