From a0473771f110ace34a788fc39f13adc3fe6c6098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Tue, 9 Jun 2026 01:28:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E8=B7=B3=E4=B8=80?= =?UTF-8?q?=E8=B7=B3=E8=BF=90=E8=A1=8C=E6=80=81=E4=B8=8E=E5=9C=B0=E5=9D=97?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 12 +- .hermes/shared-memory/pitfalls.md | 79 +- ...³•创作】跳一跳俯视角玩法模æ¿PRD-2026-05-19.md | 91 +- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 2 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 20 +- packages/shared/src/contracts/jumpHop.ts | 31 + server-rs/crates/api-server/src/jump_hop.rs | 978 ++++++++---- .../crates/module-jump-hop/src/application.rs | 145 +- .../src/vector_engine/client.rs | 113 +- .../src/vector_engine/curl_transport.rs | 21 +- .../platform-image/tests/vector_engine.rs | 73 +- .../crates/shared-contracts/src/jump_hop.rs | 39 + .../crates/spacetime-client/src/jump_hop.rs | 33 +- .../spacetime-client/src/mapper/jump_hop.rs | 44 +- .../spacetime-client/src/module_bindings.rs | 4 + .../jump_hop_tile_asset_snapshot_type.rs | 3 + .../jump_hop_tile_face_asset_snapshot_type.rs | 24 + ...jump_hop_tile_face_assets_snapshot_type.rs | 22 + .../crates/spacetime-module/src/jump_hop.rs | 2 +- .../spacetime-module/src/jump_hop/types.rs | 28 + .../JumpHopRuntimeShell.test.tsx | 425 ++++- .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 1362 +++++++++++++---- .../PlatformEntryFlowShellImpl.tsx | 94 +- ...gEntryFlowShell.agent.interaction.test.tsx | 105 ++ .../JumpHopCreationWorkspace.test.tsx | 2 +- .../workspaces/JumpHopCreationWorkspace.tsx | 2 +- .../jump-hop/jumpHopRuntimeModel.test.ts | 136 +- src/services/jump-hop/jumpHopRuntimeModel.ts | 280 +++- .../miniGameDraftGenerationProgress.test.ts | 8 +- .../miniGameDraftGenerationProgress.ts | 12 +- 30 files changed, 3180 insertions(+), 1010 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0cbdb334..003bc244 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1390,10 +1390,10 @@ - éªŒè¯æ–¹å¼ï¼šä»Žå¹³å°æŽ¨èæˆ–å…¬å¼€è¯¦æƒ…è¿›å…¥è·³ä¸€è·³ä½œå“æ—¶ï¼Œè·¯ç”± source type 为 `jump-hop`ã€public code 为 `JH-*`,è¿è¡Œæ€å¯åŠ¨æ¶ˆè´¹åŽç«¯è¿”回的完整 profile / run æ•°æ®ï¼›åŽç«¯ smoke 统一使用 `npm run dev:api-server` å¯åŠ¨å¹¶æ£€æŸ¥ `/healthz`。 - å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 -## 2026-05-28 跳一跳é‡è®¾è®¡ä¸º 5x5 地å—图集与弹弓拖拽 +## 2026-05-28 跳一跳é‡è®¾è®¡ä¸º UV 地æ¿å›¾é›†ä¸Žé•¿æŒ‰è“„力 - 背景:旧跳一跳模æ¿ä»ä¿ç•™è§’è‰²ç”Ÿå›¾ã€æœ‰é™è·¯å¾„ã€score/combo å’Œ `2x3` 地å—图集å£å¾„,和当å‰â€œä¿¯è§†è§’å¹³å°è·³è·ƒ + 主题生æˆåœ°å—æ±  + æ— é™è·¯å¾„â€çš„产å“需求ä¸ä¸€è‡´ã€‚ -- 决策:`jump-hop` v1 创作端åªä¿ç•™ä¸»é¢˜è¾“入;image2 生æˆä¸€å¼  `5x5`ã€å…± 25 个 2D 地å—图标的图集,åŽç«¯æŒ‰å‡åŒ€ç½‘格切出 25 个 `JumpHopTileAsset`。角色ä¸å†å•独生图,è¿è¡Œæ€ä½¿ç”¨é™¶æ³¥å„¿ logo 逿˜Ž PNG 角色;è¿è¡Œæ€è¾“入为按ä½åŽæ‹‰è“„åŠ›ã€æ¾æ‰‹åå‘弹出,å‰ç«¯æäº¤ `chargeMs + dragVectorX + dragVectorY`,åŽç«¯è£å†³è½ç‚¹ã€‚è‰ç¨¿è¯•玩必须使用 `runtimeMode=draft`,正å¼ä½œå“使用 `runtimeMode=published`;排行榜按作å“维度æ¯çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºä¸ºæˆåŠŸè·³è·ƒæ¬¡æ•°é™åºã€æ¸¸æˆæ—¶é•¿å‡åºã€æ›´æ–°æ—¶é—´å‡åºã€‚ +- 决策:`jump-hop` v1 创作端åªä¿ç•™ä¸»é¢˜è¾“入;image2 åªç”Ÿæˆä¸€å¼  `1024x1536` 竖版图集,按 `3列*6行` 容纳 18 个立方体主题物体 UV 展开包装,æ¯ä¸ªå¤§å•元内部固定 `4列*3行` UV 网并切出 `top/front/right/back/left/bottom` å…­å¼ é¢è´´å›¾ï¼ŒåŽç«¯å…±æŒä¹…化 108 å¼  `256x256` ä¸é€æ˜Ž PNG。`JumpHopTileAsset.faceAssets` ä¿å­˜å…­é¢è´´å›¾ï¼ŒåŽ†å² `imageSrc/imageObjectKey/assetObjectId` 写 top é¢ä½œä¸ºæ—§å•贴图 fallbackï¼›æ—§ä½œå“æ²¡æœ‰ `faceAssets` æ—¶è¿è¡Œæ€ä»å¯æŠŠå•张贴图应用到立方体所有é¢ã€‚角色ä¸å†å•独生图,è¿è¡Œæ€ä½¿ç”¨é™¶æ³¥å„¿ logo 逿˜Ž PNG 角色;è¿è¡Œæ€è¾“å…¥ä¸ºé•¿æŒ‰è“„åŠ›ã€æ¾æ‰‹èµ·è·³ï¼Œå‰ç«¯åªæäº¤è“„力值,åŽç«¯å§‹ç»ˆæ²¿å½“å‰åœ°å—中心到下一å—地å—中心方å‘è£å†³çœŸå®žè½ç‚¹ï¼›`dragVectorX/dragVectorY` 仅作为旧客户端兼容字段ä¿ç•™ä¸”ä¸å‚与è£å†³ã€‚è‰ç¨¿è¯•玩必须使用 `runtimeMode=draft`,正å¼ä½œå“使用 `runtimeMode=published`;排行榜按作å“维度æ¯çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºä¸ºæˆåŠŸè·³è·ƒæ¬¡æ•°é™åºã€æ¸¸æˆæ—¶é•¿å‡åºã€æ›´æ–°æ—¶é—´å‡åºã€‚ - 决策补充:跳一跳创作入å£çš„事实æºä»æ˜¯ SpacetimeDB `creation_entry_type_config`。默认ç§å­å’Œæ—§é»˜è®¤è¡Œéƒ½å¿…é¡»åŒæ­¥è¿ç§»åˆ° `subtitle=主题驱动平å°è·³è·ƒ`ã€`image_src=/creation-type-references/jump-hop.webp`ï¼›åŽç«¯åªåœ¨ç³»ç»Ÿé»˜è®¤æ—§å€¼å‘½ä¸­æ—¶è‡ªåŠ¨çº å,é¿å…覆盖åŽå°æ‰‹åЍé…置。 - å½±å“范围:`jump-hop` PRDã€`api-server` 生æˆç¼–排ã€`module-jump-hop` 领域规则ã€`spacetime-module` / `spacetime-client` 跳一跳契约ã€å‰ç«¯å·¥ä½œå° / 结果页 / runtime / å¹³å°å£³è°ƒç”¨é“¾ã€‚ - éªŒè¯æ–¹å¼ï¼š`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`ã€`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`ã€`cargo check -p api-server --manifest-path server-rs/Cargo.toml`ã€`npm run check:spacetime-schema`ã€è·³ä¸€è·³å·¥ä½œå°å’Œ runtime 定å‘å‰ç«¯æµ‹è¯•。 @@ -1407,10 +1407,10 @@ - éªŒè¯æ–¹å¼ï¼š`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`ã€`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。 - å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 -## 2026-06-02 跳一跳起跳è·ç¦»å‡åŠå¹¶åŠ å…¥é£žè¡ŒåŠ¨ç”»ç¼“å†² +## 2026-06-02 跳一跳飞行动画缓冲与真实è½ç‚¹å±•示 -- 背景:用户å馈当å‰è·³è·ƒåˆ°ç›®æ ‡ä½ç½®éœ€è¦æ‹–å¾—å¤ªè¿œï¼Œä¸”æ¾æ‰‹åŽç¼ºå°‘角色翻腾到目标地å—的过渡动画,导致跳跃手感å硬。 -- 决策:`jump-hop` çš„ `chargeToDistanceRatio` 统一从 `0.004` æå‡åˆ° `0.008`,让åŒç­‰è·³è·ƒè·ç¦»æ‰€éœ€æ‹–动è·ç¦»å‡åŠï¼›å‰ç«¯ runtime 把“åŽç«¯çœŸå®ž runâ€å’Œâ€œå½“å‰å±å¹•显示æ€â€æ‹†å¼€ï¼Œæ¾æ‰‹çž¬é—´å…ˆç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` 的飞行动画;该路径ä¸å¾—等待åŽç«¯æ–° run。角色弹到预测è½ç‚¹åŽè‹¥æ–° run 尚未返回,必须åœåœ¨é¢„测è½ç‚¹ç­‰å¾…,å†è¿›å…¥çº¦ `1440ms` çš„ç›¸æœºå±‚æŽ¨è¿›è¿‡æ¸¡ã€‚æŽ¨è¿›æœŸé—´åœ°å— DOM 层和 DOM 角色层统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—自然离开视野,新预览地å—从上方露出,é¿å… p1/p2 å•独 top/left 过渡导致角色和地å—ä¸åŒæ­¥ã€‚ç›¸æœºæŽ¨è¿›å¿…é¡»åŒæ—¶ä½¿ç”¨ X/Y å移,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸èƒ½å…ˆæ¨ªå‘瞬切居中å†çºµå‘推进。地å—ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale è¡¨è¾¾ï¼Œå¹¶åœ¨ç›¸æœºæŽ¨è¿›æœŸé—´åŒæ ·ä½¿ç”¨ `1440ms` ç¼“åŠ¨ï¼›å½“å‰æ€ä¸å†é¢å¤–å  CSS scale。 +- 背景:用户å馈长按蓄力版本的跳跃手感å硬,æˆåŠŸåŽè§’色容易被å¸å›žåœ°å—中心,且åŽç«¯å›žåŒ…或相机推进时会出现飞过很远å†çž¬é—´æ‹‰å›žçš„闪现。 +- 决策:`jump-hop` 当å‰é•¿æŒ‰è“„力统一使用 `chargeToDistanceRatio=0.004`,相åŒè“„力时间的世界跳跃è·ç¦»æ¯”上一轮 `0.008` é™ä½Žä¸€åŠã€‚å‰ç«¯ runtime 把“åŽç«¯çœŸå®ž runâ€å’Œâ€œå½“å‰å±å¹•显示æ€â€æ‹†å¼€ï¼Œæ¾æ‰‹çž¬é—´å…ˆç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测真实è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` 的飞行动画;该路径ä¸å¾—等待åŽç«¯æ–° run。角色弹到预测真实è½ç‚¹åŽè‹¥æ–° run 尚未返回,必须åœåœ¨é¢„测真实è½ç‚¹ç­‰å¾…。æˆåŠŸè½åœ°åŽè§’色ä½ç½®å¿…é¡»ä¿ç•™ `lastJump.landedX/landedY` 映射出的真实å移,ä¸å¾—å¸é™„回目标地å—中心。相机推进以旧窗å£çœŸå®žè½ç‚¹å’Œæ–°çª—å£çœŸå®žè½ç‚¹ä¸ºé”šç‚¹ï¼Œä½¿ç”¨çº¦ `1440ms` è¿‡æ¸¡ï¼›æŽ¨è¿›æœŸé—´åœ°å— DOM 层和 DOM 角色层统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—自然离开视野,新预览地å—从上方露出,é¿å… p1/p2 å•独 top/left 过渡导致角色和地å—ä¸åŒæ­¥ã€‚ç›¸æœºæŽ¨è¿›å¿…é¡»åŒæ—¶ä½¿ç”¨ X/Y å移,ä¸èƒ½å…ˆæ¨ªå‘瞬切居中å†çºµå‘推进。地å—ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale è¡¨è¾¾ï¼Œå¹¶åœ¨ç›¸æœºæŽ¨è¿›æœŸé—´åŒæ ·ä½¿ç”¨ `1440ms` ç¼“åŠ¨ï¼›å½“å‰æ€ä¸å†é¢å¤–å  CSS scale。 - å½±å“范围:`server-rs/crates/module-jump-hop/src/application.rs`ã€`src/services/jump-hop/jumpHopRuntimeModel.ts`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€è·³ä¸€è·³è¿è¡Œæ€å®šå‘测试。 - éªŒè¯æ–¹å¼ï¼š`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`ã€`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`ã€`npm run check:encoding`。 - å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 @@ -1418,7 +1418,7 @@ ## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 逿˜Ž PNG - 背景:跳一跳è¿è¡Œæ€æ­¤å‰ä»ä½¿ç”¨æ—§å†…ç½® / CSS è§’è‰²å½¢è±¡ï¼Œå’Œç”¨æˆ·è¦æ±‚的陶泥儿 logo 角色ä¸ä¸€è‡´ï¼Œä¹Ÿå®¹æ˜“å’Œ DOM 地å—å±‚å‡ºçŽ°é®æŒ¡å±‚级问题。 -- 决策:`jump-hop` v1 ä¸å†æ¸²æŸ“内置 3D 角色几何体;è¿è¡Œæ€å’Œç»“果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处ç†ä¸ºé€æ˜Ž PNG åŽæŽ¥å…¥ã€‚è“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ˜Žæ˜¾æ‹‰é•¿ï¼Œè½åœ°åŽå‘åæ–¹å‘回弹两次。`characterAsset` 继续仅作为历å²å…¼å®¹æè¿°å­—段,ä¸èƒ½é‡æ–°æ‰“开角色生图槽或把角色图片作为创作者å¯é…置输入。 +- 决策:`jump-hop` v1 ä¸å†æ¸²æŸ“内置 3D 角色几何体;è¿è¡Œæ€å’Œç»“果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处ç†ä¸ºé€æ˜Ž PNG åŽæŽ¥å…¥ã€‚è“„åŠ›æ—¶è§’è‰²åªåšåž‚直压缩,è½åœ°åŽä¿ç•™çœŸå®žè½ç‚¹å¹¶è½»é‡å›žå¼¹ã€‚`characterAsset` 继续仅作为历å²å…¼å®¹æè¿°å­—段,ä¸èƒ½é‡æ–°æ‰“开角色生图槽或把角色图片作为创作者å¯é…置输入。 - å½±å“范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`src/components/jump-hop-result/JumpHopResultView.tsx`ã€è·³ä¸€è·³ PRD 和平å°é“¾è·¯æ–‡æ¡£ã€‚ - éªŒè¯æ–¹å¼ï¼šè·³ä¸€è·³è¿è¡Œæ€ / ç»“æžœé¡µæµ‹è¯•éœ€è¦æ–­è¨€è§’色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback ä¸å†å‡ºçŽ°ã€‚ - å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index bb648e65..2972bbf6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -71,13 +71,13 @@ - 验è¯ï¼š`CreationAgentWorkspace` 测试应断言进度标题ã€ç™¾åˆ†æ¯”å’Œæç¤ºæ–‡æœ¬å¸¦ä¸“属 classï¼›`src/index.test.ts` 应断言这些 class 在 remap surface 内有白色覆盖规则;移动端截图中暗色å¡ç‰‡æ–‡å­—åº”ä¿æŒå¯è¯»ã€‚ - å…³è”:`src/components/creation-agent/CreationAgentWorkspace.tsx`ã€`src/components/creation-agent/CreationAgentWorkspace.test.tsx`ã€`src/index.css`ã€`src/index.test.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 -## VectorEngine å›¾ç‰‡ç”Ÿæˆ SendRequest è¶…æ—¶è¦æŒ‰ä¼ è¾“失败排查 +## VectorEngine å›¾ç‰‡ç”Ÿæˆ request_send ä¼ è¾“é”™è¯¯è¦æŒ‰å¯é‡è¯•网络抖动排查 -- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`ã€`timeout=true`ã€`statusCode=null`,`errorSource` å¯èƒ½æ˜¯ `client error (SendRequest)` 或更完整的 reqwest 底层错误链,å‰ç«¯åªçŸ¥é“图片生æˆå¤±è´¥ã€‚ -- 原因:`timeout=true` æ¥è‡ª `reqwest::Error::is_timeout()`ï¼Œä¸æ˜¯ä¸šåС代ç å›ºå®šå†™æ­»ï¼›`SendRequest` 是 Hyper å‘é€è¯·æ±‚é˜¶æ®µçš„é”™è¯¯æ¥æºæ ‡ç­¾ï¼Œåªè¯´æ˜Žè¯·æ±‚未拿到å¯å½’类的 HTTP å“应,ä¸ä¼šåŒ…å«ä¸Šæ¸¸ JSON 错误体。 -- 处ç†ï¼šå…ˆæŒ‰ `provider/failureStage/statusClass` èšåˆï¼Œå†ç”¨ `user_id` / `profile_id` å’Œ `metadata_json.userId/profileId/requestId` 定ä½è§¦å‘者ã€è‰ç¨¿ / 作å“å’ŒåŒä¸€æ¬¡ HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`ã€è¯·æ±‚体大å°ã€å‚考图数é‡ã€å‡ºå£ç½‘络ã€ä»£ç†/Nginxã€VectorEngine 当时å¯ç”¨æ€§å’ŒåŒä¸€ request_id æ—¥å¿—ã€‚å½“å‰ `platform-image` 对 `request_send` çš„ `timeout` / `connect` 错误最多é‡è¯• 3 次,multipart `/v1/images/edits` æ¯æ¬¡é‡è¯•都必须é‡å»º form;看到 `VectorEngine 图片请求å‘é€å¤±è´¥ï¼Œå‡†å¤‡é‡è¯•` åªæ˜¯å•次 attempt 失败,最终 `external_api_call_failure` æ‰ä»£è¡¨è¯¥ç”¨æˆ·è¯·æ±‚整体失败。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败å¦è¡Œå¤„ç†ï¼Œä¸è¦å½’到传输超时。 +- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`ã€`statusCode=null`,`errorSource` å¯èƒ½æ˜¯ `client error (SendRequest)`ã€`[35] SSL connect error (Recv failure: Connection reset by peer)`ã€`[56] Failure when receiving data from the peer (... unexpected eof while reading ...)`;也å¯èƒ½çœ‹åˆ° `failureStage=upstream_status`ã€`statusCode=502`ã€é”™è¯¯ä½“是 Nginx HTML `502 Bad Gateway`。å‰ç«¯åªçŸ¥é“图片生æˆå¤±è´¥ã€‚ +- 原因:`request_send` 表示请求未拿到å¯å½’类的 HTTP å“应,ä¸ä¼šåŒ…å«ä¸Šæ¸¸ JSON 错误体;`upstream_status=502/5xx/429/408` 表示拿到了上游错误å“应但ä»å±žäºŽå¯é‡è¯•的过载 / 网关抖动。`timeout=true` æ¥è‡ªè¶…时判定,`connect=true` ä¼šåŒæ—¶è¦†ç›– DNS / connect å¤±è´¥ä»¥åŠ libcurl 35 SSL æ¡æ‰‹ã€libcurl 56 收包æå‰ EOFã€connection reset 这类临时传输错误。 +- 处ç†ï¼šå…ˆæŒ‰ `provider/failureStage/statusClass` èšåˆï¼Œå†ç”¨ `user_id` / `profile_id` å’Œ `metadata_json.userId/profileId/requestId` 定ä½è§¦å‘者ã€è‰ç¨¿ / 作å“å’ŒåŒä¸€æ¬¡ HTTP 请求;`request_send + timeout/connect=true` 或 `upstream_status + statusCode=408/429/5xx` 优先查 provider 日志的 `source_chain`ã€è¯·æ±‚体大å°ã€å‚考图数é‡ã€å‡ºå£ç½‘络ã€ä»£ç†/Nginxã€VectorEngine 当时å¯ç”¨æ€§å’ŒåŒä¸€ request_id æ—¥å¿—ã€‚å½“å‰ `platform-image` 对 request_send çš„ timeout / connect / SSL connect reset / recv error / unexpected eof / send errorï¼Œä»¥åŠ upstream_status çš„ 408 / 429 / 5xx 最多å‘é€ 5 次,multipart `/v1/images/edits` æ¯æ¬¡é‡è¯•éƒ½ä¼šé‡æ–°æž„造 form;看到 `VectorEngine 图片请求å‘é€å¤±è´¥ï¼Œå‡†å¤‡é‡è¯•` 或 `VectorEngine 图片上游状æ€å¯é‡è¯•,准备é‡è¯•` åªæ˜¯å•次 attempt 失败,最终 `external_api_call_failure` æ‰ä»£è¡¨è¯¥ç”¨æˆ·è¯·æ±‚整体失败。若记录有 `429 moderation_blocked` 或明确审核错误,按审核失败å¦è¡Œå¤„ç†ï¼Œä¸è¦å½’到网络抖动。 - 拼图关å¡èµ„äº§ç”ŸæˆæŒ‰ `level_scene -> ui_spritesheet -> level_background` é¡ºåºæ‰§è¡Œï¼Œæ¯ä¸ªèµ„产会输出 `slot`ã€`asset_kind`ã€`elapsed_ms`;排查拼图è‰ç¨¿å¤±è´¥æ—¶ä¼˜å…ˆçœ‹åŒä¸€ request_id 下最åŽä¸€ä¸ªå¤±è´¥ slot。 -- 验è¯ï¼š`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`ã€`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触å‘者 `user_id` å’Œå¯ç”¨çš„ `profile_id`。 +- 验è¯ï¼š`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_send_retry_policy -- --nocapture`ã€`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`ã€`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触å‘者 `user_id` å’Œå¯ç”¨çš„ `profile_id`。 - å…³è”:`server-rs/crates/platform-image/src/vector_engine/client.rs`ã€`server-rs/crates/api-server/src/external_api_audit.rs`ã€`server-rs/crates/api-server/src/openai_image_generation.rs`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 ## “我的â€é¡µæ¯æ—¥ä»»åŠ¡å¡ä¸è¦ç¡¬ç¼–ç è¿›åº¦ï¼Œä¹Ÿä¸è¦è·¨æ—¥ä¿ç•™æ—§çŠ¶æ€ @@ -457,6 +457,14 @@ - 验è¯ï¼šæœªç™»å½•推è页å¯ä»¥ç›´æŽ¥è¿›å…¥è·³ä¸€è·³è¿è¡Œæ€ï¼Œä¸” `work_play_start` 事件ä»ä¼šè½åº“或出现在 outbox 中,metadata å«åŒ¿å标记。 - å…³è”:`server-rs/crates/api-server/src/jump_hop.rs`ã€`server-rs/crates/api-server/src/auth.rs`ã€`server-rs/crates/api-server/src/work_play_tracking.rs`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 +## 跳一跳直接打开空 runtime 路由ä¸èƒ½åœåœ¨åŠ è½½æ€ + +- 现象:直接访问 `/runtime/jump-hop` 时页é¢çœ‹èµ·æ¥ä¸€ç›´åœåœ¨â€œæ­£åœ¨è½½å…¥æ¸¸æˆ / 正在加载内容â€ï¼ŒDOM å†…éƒ¨åªæœ‰ç©ºçš„跳一跳è¿è¡Œæ€ï¼Œæ²¡æœ‰å¹³å°ã€åœ°å—或 run æ•°æ®ã€‚ +- 原因:`appPageRoutes` 会把该路径解æžä¸º `jump-hop-runtime`,但裸路径没有 `work=JH-*` 公开作å“ç ï¼Œä¹Ÿæ²¡æœ‰ä»Žè¯¦æƒ…页å¯åЍåŽå†™å…¥çš„ `jumpHopRun`,平å°å£³ä»æŒ‚è½½ `JumpHopRuntimeShell`。 +- 处ç†ï¼šå¹³å°å£³åœ¨ `jump-hop-runtime` 且缺少 run 时先看 `work` 傿•°ï¼›æœ‰ `JH-*` 则通过公开 gallery detail 回读 profile å¹¶å¯åЍ published run,没有则回到平å°é¦–页。全局作å“ç æ¢å¤ effect 在跳一跳 runtime 阶段è¦è·³è¿‡ï¼Œé¿å…å’Œè¿è¡Œæ€æ¢å¤äº’相抢路由。 +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct jump hop runtime route"`ï¼›æµè§ˆå™¨ smoke 分别打开 `/`ã€`/runtime/jump-hop` å’Œ `/runtime/jump-hop?work=JH-*`。 +- å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/routing/appPageRoutes.ts`ã€`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 + ## release tracking outbox æƒé™é”™è¯¯å…ˆæŸ¥ env 缺失 - 现象:release 机器 `journalctl -u genarrative-api.service` æ¯ç§’刷 `tracking outbox 定时å°å­˜ active 文件失败 error=Permission denied (os error 13)` å’Œ `tracking outbox 批é‡å†™å…¥ SpacetimeDB 失败`。 @@ -1740,18 +1748,18 @@ - 验è¯ï¼š`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - å…³è”:`src/components/platform-entry/platformEntryTypes.ts`ã€`src/routing/appPageRoutes.ts`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 -## 跳一跳地å—图集固定走 5x5 åœ°å—æ±  +## 跳一跳地å—图集固定走 18 个 UV 大å•å…ƒ - 现象:跳一跳åˆå§‹è‰ç¨¿ç”Ÿæˆæ—¶æŠ¥ `系列素æå›¾é›†çš„物å“行数ä¸èƒ½è¶…过 n。`,或者生æˆå®ŒæˆåŽåªæœ‰ atlas 预览路径,地å—切片没有真正è½ç›˜ã€‚ -- 原因:旧模æ¿å…ˆåŽå°è¯•过通用系列素æ helper å’Œ `2x3` 六格固定 tileType,但当å‰è·³ä¸€è·³å·²ç»é‡è®¾è®¡ä¸ºâ€œä¸»é¢˜ -> 5x5 地å—图集 -> 25 个等æƒåœ°å—æ±  -> æ— é™è·¯å¾„â€ï¼Œæ—§çš„物å“行数 / 固定类型模型都会把创作链路带å。 -- 处ç†ï¼šè·³ä¸€è·³åœ°å—固定生æˆä¸€å¼  `5x5` 主题图集,åŽç«¯æŒ‰å‡åŒ€ç½‘格切出 25 å¼  PNG,并对æ¯å¼ åˆ‡ç‰‡å„自走 OSS 上传ã€asset_object 确认和 entity bindï¼›ä¸è¦å†æ¢å¤ `2行*3列`ã€`start / normal / target / finish / bonus / accent` å…­æ ¼å£å¾„。 -- 验è¯ï¼š`jump_hop.rs` ä¸åº”å†è°ƒç”¨é€šç”¨ç‰©å“行数模型处ç†åœ°å—图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,è¿è¡Œæ€æ— é™è·¯å¾„ä»Žåœ°å—æ± éšæœºå–æã€‚ +- 原因:旧模æ¿å…ˆåŽå°è¯•过通用系列素æ helperã€`2x3` 六格固定 tileType å’Œ `5x5` å•贴图池,但当å‰è·³ä¸€è·³å·²ç»é‡è®¾è®¡ä¸ºâ€œä¸»é¢˜ -> 一张 `1024x1536` 图集 -> 18 个 `3列*6行` UV 大å•å…ƒ -> æ¯æ ¼ `4列*3行` å…­é¢è´´å›¾ -> æ— é™è·¯å¾„â€ï¼Œæ—§çš„物å“行数 / 固定类型模型都会把创作链路带å。 +- 处ç†ï¼šè·³ä¸€è·³åœ°å—固定åªç”Ÿæˆä¸€å¼  `1024x1536` 主题 UV 展开图集,åŽç«¯å…ˆåˆ‡å‡º 18 个大å•元,å†ä»Žæ¯æ ¼å›ºå®š UV 网切出 top/front/right/back/left/bottom å…­å¼  `256x256` ä¸é€æ˜Ž PNG,并对 108 å¼ é¢è´´å›¾å„自走 OSS 上传ã€asset_object 确认和 entity bindï¼›ä¸è¦å†æ¢å¤ `2行*3列`ã€`5x5` å•贴图ã€`start / normal / target / finish / bonus / accent` å…­æ ¼å£å¾„。 +- 验è¯ï¼š`jump_hop.rs` ä¸åº”å†è°ƒç”¨é€šç”¨ç‰©å“行数模型处ç†åœ°å—图集;公开结果里应能拿到 18 个独立 `JumpHopTileAsset` 且æ¯ä¸ªæ–°èµ„äº§åŒ…å« `faceAssets` å…­é¢è´´å›¾ï¼Œè¿è¡Œæ€æ— é™è·¯å¾„ä»Žåœ°å—æ± éšæœºå–æï¼›æ—§èµ„äº§æ²¡æœ‰ `faceAssets` æ—¶ä»èƒ½ç”¨ `imageSrc` å•贴图 fallback。 - å…³è”:`server-rs/crates/api-server/src/jump_hop.rs`ã€`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 ## 跳一跳å®å¯æ¢¦ä¸»é¢˜åœ°å—图集 safety rejection åªåšä¸“项改写 - 现象:跳一跳è‰ç¨¿ä½¿ç”¨â€œå®å¯æ¢¦ / Pokemon / çš®å¡ä¸˜ / ç²¾çµçƒâ€ç­‰ä¸»é¢˜æ—¶ï¼ŒèƒŒæ™¯åº•图和返回按钮å¯èƒ½å·²ç”ŸæˆæˆåŠŸï¼Œä½†åœ°å—图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地å—图集生æˆå¤±è´¥"`ã€`status=429`ã€`code="invalid_prompt"`。 -- 原因:25 个è½ç‚¹å›¾é›† prompt ä¼šæŠŠè¿™äº›è¯æ”¾è¿›â€œä¸»é¢˜ç‰©ä½“图集â€è¯­å¢ƒï¼Œå®¹æ˜“被上游ç†è§£ä¸ºè¦æ±‚生æˆå…·ä½“å®å¯æ¢¦è§’色或标志é“具,触å‘å®‰å…¨æ‹¦æˆªï¼›è¿™ä¸æ˜¯æ™®é€šå¹³å°é€ åž‹è¯ã€æŠ å›¾æˆ–超时问题。 +- 原因:18 个立方体主题物体 UV 展开图集 prompt ä¼šæŠŠè¿™äº›è¯æ”¾è¿›â€œä¸»é¢˜ç‰©ä½“图集â€è¯­å¢ƒï¼Œå®¹æ˜“被上游ç†è§£ä¸ºè¦æ±‚生æˆå…·ä½“å®å¯æ¢¦è§’色或标志é“具,触å‘å®‰å…¨æ‹¦æˆªï¼›è¿™ä¸æ˜¯æ™®é€šå¹³å°é€ åž‹è¯ã€æŠ å›¾æˆ–超时问题。 - 处ç†ï¼šä»…åœ¨è·³ä¸€è·³å›¾ç‰‡ç”Ÿæˆ prompt 文本命中å®å¯æ¢¦ç›¸å…³è¯æ—¶åšç”Ÿæˆä¾§æ›¿æ¢ï¼ŒæŠŠ `å®å¯æ¢¦ / 神奇å®è´ / å£è¢‹å¦–怪 / Pokemon` 改为“原创幻想èŒå® å†’险é“å…·â€ï¼ŒæŠŠ `ç²¾çµçƒ` 改为“彩色冒险能é‡çƒâ€ï¼ŒæŠŠ `çš®å¡ä¸˜ / Pikachu` 改为“黄色闪电èŒå® ç¬¦å·â€ï¼›ä¸è¦æŠŠæ‰€æœ‰ä¸»é¢˜éƒ½åР免局 IP ç¦æ­¢çº¦æŸï¼Œç”¨æˆ·è‰ç¨¿æ ‡é¢˜å’Œä¸»é¢˜å±•ç¤ºä¹Ÿä¸æ”¹ã€‚ - 验è¯ï¼š`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖å®å¯æ¢¦è¯ä¸“项替æ¢ï¼›çœŸå®žè”调时åŒä¸€è‰ç¨¿é‡è¯•åŽï¼Œåœ°å—图集请求的 prompt ä¸å†åŒ…å«å®å¯æ¢¦ç›¸å…³è¯ã€‚ - å…³è”:`server-rs/crates/api-server/src/jump_hop.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 @@ -1759,9 +1767,9 @@ ## 跳一跳地å—切片ä¸è¦æŒ‰ tileType å¤ç”¨èµ„äº§æ§½ä½ - 现象:跳一跳生æˆå®ŒæˆåŽï¼Œè¿è¡Œæ€çœ‹èµ·æ¥ä»åƒåœ¨æ˜¾ç¤ºé»˜è®¤å‡ ä½•地å—,或者地å—å›¾ç‰‡åœ¨åŠ è½½æ—¶é¢‘é—ªï¼›ç»“æžœé¡µåœ°å—æ± ä¹Ÿå¯èƒ½åªçœ‹åˆ°å°‘é‡é‡å¤ç´ æã€‚ -- 原因:`tileType` åªæ˜¯è·¯å¾„å¹³å°çš„玩法类型标签,25 个 atlas 切片里会é‡å¤å‡ºçް `normal / target / bonus / accent` 等类型。若åŽç«¯æŒä¹…化时用 `tileType` ç”Ÿæˆ slot/path,åŒç±»åž‹åˆ‡ç‰‡ä¼šå†™å…¥åŒä¸€ä¸ª `/generated-jump-hop-assets///image.png`,åŽä¸Šä¼ çš„切片覆盖先上传的切片,å‰ç«¯æ¢ç­¾ç¼“存也会读到é‡å¤æˆ–旧对象。 -- 处ç†ï¼šåŽç«¯åˆ‡å›¾åŽå¿…须按 atlas å•元格写入 `tile-01` 到 `tile-25` 的唯一 slot/pathï¼›å‰ç«¯ç»“果页和è¿è¡Œæ€å±•示生æˆå›¾æ—¶ç”¨ `assetObjectId` 作为 `refreshKey`,é¿å…é‡ç”ŸæˆåŽå¤ç”¨æ—§ç­¾å或旧图片缓存。 -- 验è¯ï¼š`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` åº”åŒ…å« `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`ï¼›å‰ç«¯è¿è¡Œæ€æµ‹è¯•åº”æ–­è¨€åœ°å—æ¢ç­¾å¸¦ `assetObjectId` 刷新键。 +- 原因:`tileType` åªæ˜¯è·¯å¾„å¹³å°çš„玩法类型标签,18 个 atlas 大å•元里会é‡å¤å‡ºçް `normal / target / bonus / accent` 等类型。若åŽç«¯æŒä¹…化时用 `tileType` ç”Ÿæˆ slot/path,åŒç±»åž‹åˆ‡ç‰‡ä¼šå†™å…¥åŒä¸€ä¸ª `/generated-jump-hop-assets///image.png`,åŽä¸Šä¼ çš„切片覆盖先上传的切片,å‰ç«¯æ¢ç­¾ç¼“存也会读到é‡å¤æˆ–旧对象。 +- 处ç†ï¼šåŽç«¯åˆ‡å›¾åŽå¿…须按 atlas å•元格写入 `tile-01` 到 `tile-18` 的唯一 tile slot,并把六é¢è´´å›¾å†™å…¥ `tile-XX-top/front/right/back/left/bottom` 唯一 face slotï¼›å‰ç«¯ç»“果页和è¿è¡Œæ€å±•示生æˆå›¾æ—¶ç”¨ `assetObjectId` 作为 `refreshKey`,é¿å…é‡ç”ŸæˆåŽå¤ç”¨æ—§ç­¾å或旧图片缓存。 +- 验è¯ï¼š`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` åº”åŒ…å« `jump_hop_tile_asset_slots_are_unique_for_eighteen_slices`ï¼›å‰ç«¯è¿è¡Œæ€æµ‹è¯•åº”æ–­è¨€åœ°å—æ¢ç­¾å¸¦ `assetObjectId` 刷新键,并覆盖新 UV 资产会解æžå…­å¼ é¢è´´å›¾ã€‚ - å…³è”:`server-rs/crates/api-server/src/jump_hop.rs`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`src/components/jump-hop-result/JumpHopResultView.tsx`。 ## 跳一跳è½ç‚¹è¾…助标识ä¸è¦å†ç”¨èˆžå°é«˜åº¦å¸¸é‡æ‹è„‘袋投影 @@ -1772,12 +1780,12 @@ - 验è¯ï¼šæ‹–拽åŠç¨‹æ—¶è¾…助点应è½åœ¨å½“å‰åœ°å—和目标地å—之间,完整拖拽时应逼近目标地å—中心;è¿è¡Œæ€æˆªå›¾é‡Œè¾…助点必须始终压在地å—与角色之上。 - å…³è”:`src/services/jump-hop/jumpHopRuntimeModel.ts`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。 -## 跳一跳è½ç‚¹è¾…助和åŽç«¯è£å†³å¿…é¡»ç»Ÿä¸€åæ ‡æ¢ç®— +## 跳一跳长按蓄力ä¸èƒ½å†æ¶ˆè´¹æ‹–æ‹½æ–¹å‘ -- 现象:è½ç‚¹è¾…助标识已ç»åŽ‹åœ¨ç›®æ ‡åœ°å—å›¾ç‰‡ä¸Šï¼Œæ¾æ‰‹åŽåŽç«¯ä»åˆ¤å®šå¤±è´¥ï¼ŒçŽ©å®¶çœ‹åˆ°çš„æ˜¯â€œæ˜Žæ˜Žçž„å‡†äº†å´æ²¡è½ä¸ŠåŽ»â€ã€‚ -- 原因:å‰ç«¯è¾…助标识使用å±å¹•åƒç´ å标绘制,而åŽç«¯è£å†³ä½¿ç”¨ä¸–ç•Œåæ ‡ã€‚å±å¹• y è½´å‘下为正ã€ä¸–界 y è½´å‘ä¸Šä¸ºæ­£ï¼›åŒæ—¶å±å¹• x/y æ¯ä¸ªä¸–界å•ä½å¯¹åº”çš„åƒç´ æ¯”例ä¸åŒã€‚è‹¥å‰ç«¯ç›´æŽ¥æŠŠå±å¹•åƒç´ æ‹–拽å‘é‡å‘ç»™åŽç«¯ï¼Œè¾…助点和åŽç«¯è½ç‚¹æ–¹å‘会ä¸ä¸€è‡´ã€‚ -- 处ç†ï¼šå‰ç«¯è¿è¡Œæ€ä¿ç•™åŽŸå§‹å±å¹•拖拽å‘é‡ç”¨äºŽç”»å¼¹å¼“和辅助点,但æäº¤åŽç«¯å‰å¿…须按当å‰åœ°å—到目标地å—çš„å±å¹•跨度 / 世界跨度把 xã€y 分别æ¢ç®—æˆä¸–界尺度一致的å‘é‡ï¼›åŽç«¯ç»§ç»­åªè´Ÿè´£åå‘弹射和è½ç‚¹è£å†³ã€‚ -- 验è¯ï¼šå‰ç«¯å›žå½’测试è¦åŒæ—¶è¦†ç›–辅助点完整拖拽到目标地å—ï¼Œä»¥åŠæäº¤ç»™åŽç«¯çš„å‘é‡å·²å®Œæˆä¸–界尺度æ¢ç®—ï¼›åŽç«¯é¢†åŸŸæµ‹è¯•覆盖å±å¹•å‘åŽä¸‹æ‹‰æ—¶åº”å‘世界 y 正方å‘跳出并命中。 +- 现象:跳一跳改æˆé•¿æŒ‰è“„力åŽï¼Œå¦‚æžœå‰ç«¯æˆ–åŽç«¯ä»æ¶ˆè´¹ `dragVectorX/dragVectorY`,玩家手指轻微移动就会改å˜è·³è·ƒæ–¹å‘,和“始终æœä¸‹ä¸€å—中心跳â€çš„体验ä¸ä¸€è‡´ã€‚ +- 原因:历å²å¼¹å¼“拖拽版本把å±å¹•拖拽方å‘作为正å¼è£å†³è¾“入,契约字段ä»ä¸ºå…¼å®¹æ—§å®¢æˆ·ç«¯ä¿ç•™ï¼Œå®¹æ˜“è¢«è¯¯è®¤ä¸ºä»æ˜¯å½“å‰çŽ©æ³•è§„åˆ™ã€‚ +- 处ç†ï¼šå‰ç«¯è¿è¡Œæ€åªç”¨é•¿æŒ‰æ—¶é•¿æäº¤ `dragDistance` 兼容字段,ä¸å†å‘逿–¹å‘字段;è½ç‚¹é¢„测按当å‰åœ°å—中心到下一å—地å—ä¸­å¿ƒçš„æ–¹å‘æŠ•å½±ã€‚åŽç«¯ `module-jump-hop` å³ä½¿æ”¶åˆ°æ—§å®¢æˆ·ç«¯ `dragVectorX/dragVectorY` ä¹Ÿå¿…é¡»å¿½ç•¥ï¼ŒåªæŒ‰å½“å‰åœ°å—到下一å—地å—中心的å•ä½å‘é‡è£å†³ã€‚ +- 验è¯ï¼šå‰ç«¯å›žå½’æµ‹è¯•è¦†ç›–æ‰‹æŒ‡ç§»åŠ¨ä¸æ”¹å˜æäº¤æ–¹å‘ã€é¢„测è½ç‚¹å¿½ç•¥æ—§æ–¹å‘字段;åŽç«¯é¢†åŸŸæµ‹è¯•è¦†ç›–æ—§å®¢æˆ·ç«¯ä¼ é”™è¯¯æ–¹å‘æ—¶ä»æŒ‰ä¸‹ä¸€å—中心命中。 - å…³è”:`src/services/jump-hop/jumpHopRuntimeModel.ts`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`server-rs/crates/module-jump-hop/src/application.rs`。 ## è·³ä¸€è·³åˆ›ä½œå…¥å£æ—§æ–‡æ¡ˆå…ˆæŸ¥ SpacetimeDB é…ç½® @@ -2055,24 +2063,32 @@ - çŽ°è±¡ï¼šè·³ä¸€è·³æ¾æ‰‹åŽå¦‚æžœåŽç«¯å¾ˆå¿«è¿”回下一帧 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。相机推进期间角色自身也ä¸èƒ½ä¿ç•™ `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`。 +- 处ç†ï¼šå‰ç«¯ä¿ç•™ç‹¬ç«‹ `displayRun`ï¼Œæ¾æ‰‹åŽå…ˆè¿›å…¥ `isJumpAnimating=true`ï¼Œè§’è‰²åœ¨å½“å‰æ˜¾ç¤ºçª—å£å†…飞å‘å‰ç«¯é¢„测真实è½ç‚¹ï¼›è§†è§‰é¢„æµ‹å¿…é¡»ç”¨å½“å‰æ˜¾ç¤ºçª—å£çš„ current/next 地å—ä½œä¸ºæ–¹å‘æ¥æºï¼Œä¸èƒ½æ‹¿å·²ç»æå‰è¿”回的åŽç«¯æ–° run ç›®æ ‡é…æ—§çª—å£è§’色,å¦åˆ™ä¸‹ä¸€è·³ä¼šæœå®žé™…ç›®æ ‡åæ–¹å‘飞。飞行动画完æˆåŽå†æŠŠ `displayRun` 切到最新åŽç«¯ run,并进入约 `1440ms` çš„ `platformAdvancing` 表现æ€ã€‚æˆåŠŸåŽçš„角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实å移,ä¸è¦å¸é™„到目标地å—ä¸­å¿ƒã€‚æŽ¨è¿›æœŸé—´åœ°å— DOM 层和 DOM 角色层必须统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—先跟éšç›¸æœºå移离开主视野,之åŽåªä¿ç•™åœ¨å±å¹•åŽæ–¹ï¼›ä¸è¦ç»™æ—§åœ°å—加独立å‘上 / å‘下飞走 keyframes,也ä¸è¦å› ä¸ºæ—§åœ°å—还在ä¿ç•™åˆ—表里阻塞下一跳。玩家继续å‘å‰è·³æ—¶ï¼Œå·²å®Œæˆæ—§åœ°å—继续被新的相机推进自然带离å±å¹•,超过离å±é˜ˆå€¼åŽé”€æ¯ã€‚ç›¸æœºå±‚å¿…é¡»åŒæ—¶è®¾ç½® `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗å£çœŸå®žè½ç‚¹å’Œæ–°çª—å£çœŸå®žè½ç‚¹ä¸ºé”šç‚¹ï¼Œé¿å…先横å‘瞬切居中å†çºµå‘推进;è¿è¡Œæ€ç›¸æœºå±‚当å‰ä¸ºçº¦ `1.3x` è¿‘è·ç¼©æ”¾ã€‚地å—ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + 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`,DOM 角色层与地å—层åŒåœ¨ `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` å’Œ `--jump-hop-camera-shift-y` 完æˆç›¸æœºæ–œå‘推进,并校验å¯è§åœ°å—按深度ä¿ç•™ä¸åŒè§†è§‰å°ºå¯¸ã€è¿è¡Œæ€å¹³å°å®½é«˜ä½¿ç”¨å›ºå®šåŸºå‡†å€¼ã€æŽ¨è¿›æ€ transform transition 为 `1440ms`ã€æŽ¨è¿›æ€è§’色 transition ä¸åŒ…å« `left/top`ã€æ—§åœ°å—没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳ä¸ä¼šè¢«æ—§åœ°å—ä¿ç•™æ€é˜»å¡žã€‚ - å…³è”:`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 å’Œæµè§ˆå™¨ç¼“存。 +- 原因:旧地å—进入 exiting çŠ¶æ€æ—¶å¦‚æžœ React key 从 `platformId` å˜æˆ `platformId-exiting`ï¼Œå›¾ç‰‡ç»„ä»¶ä¼šé‡æ–°æŒ‚载并丢失已加载状æ€ï¼›åŒæ—¶ `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` å°šæœªè§¦å‘æ—¶æ˜¾ç¤º fallback 原型地å—。Three.js å¹³å°å±‚接入åŽï¼Œå¦‚æžœéšè—预加载åªè®©æµè§ˆå™¨ç¼“å­˜ ``ï¼Œä½†æ²¡æœ‰æŠŠæœªæ¥ `platformId` çš„çº¹ç† URL 写入 `platformTextureUrlsByRenderKey`,相机推进时新预览地å—会短暂缺 Three 贴图;若旧 blob 贴图在空 URL 回调时先被 revoke,å†ç»§ç»­ä¿ç•™åœ¨ state 中,也会留下一个看似 readyã€å®žé™…已失效的贴图地å€ã€‚ +- 处ç†ï¼šexiting 地å—继续使用稳定 `platformId` key,让旧图片组件在推进期å¤ç”¨ï¼›æœ‰çœŸå®ž `resolvedUrl` 且未错误时直接ä¿ç•™çœŸå®ž ``,åªåœ¨æ—  URL 或加载失败时显示 fallbackï¼›å½“å‰ 3 å—之外的åŽç»­åœ°å—通过éšè—预加载图片æå‰è§£æžç­¾å URL å’Œæµè§ˆå™¨ç¼“å­˜ï¼Œå¹¶åŒæ­¥æŒ‰æœªæ¥ `platformId` å‘布 Three çº¹ç† URL。Three å¹³å°å±‚åœ¨å½“å‰ render items 全部有贴图 URL åŽç»§ç»­æ‰¿æŽ¥åŒ…å« exiting 地å—在内的 3D 渲染;退出地å—åªéšç›¸æœºæŽ¨è¿›è‡ªç„¶ç¦»å±ï¼Œä¸æ’­æ”¾ç‹¬ç«‹é£žèµ°åŠ¨ç”»ï¼Œé¿å…退出期露出被放大的平é¢è´´å›¾æˆ–é‡å¤é£žå¤šæ¬¡ï¼›è´´å›¾ URL 替æ¢å¿…须等新 URL 到达åŽå†é‡Šæ”¾æ—§ parent-owned blob,空 URL 回调ä¸å¾—清空或 revoke ä»åœ¨æ´»è·ƒ / 预加载 key 上的旧贴图。 - 验è¯ï¼š`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`。 -## è·³ä¸€è·³åœ°å—æŠ å›¾ä¸è¦ç”¨ç»¿å¹•或近白底识别 +## 跳一跳 Three.js å¹³å°å±‚ä¸èƒ½å·¦å³é•œåƒ DOM åæ ‡ -- 现象:跳一跳生æˆè‰åœ°ã€èбã€é›ªåœ°ã€ç™½çŸ³æˆ–äº‘æœµåœ°å—æ—¶ï¼Œé€æ˜ŽåŒ–会把绿色 / 白色主体局部扣掉,è¿è¡Œæ€çœ‹åˆ°å¹³å°ç¼ºå£ã€å˜è–„或主体消失。 -- 原因:通用图集默认按绿幕和近白底åšé€æ˜ŽåŒ–ï¼Œé€‚åˆ 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 与绿 / 白地å—切片。 +- 现象:视觉上下一å—地å—在角色å³ä¾§ï¼Œä½†è“„力引导和角色飞行动画æœå·¦ä¾§ï¼›åŽç«¯å›žåŒ…åŽåœ°å—窗å£åˆé—ªçŽ°æ‘†å›žæ­£ç¡®ä½ç½®ï¼Œåƒæ˜¯å…ˆæŒ‰åæ–¹å‘飞ã€å†ç”±å¿«ç…§åˆ·æ–°çº æ­£ã€‚ +- 原因:Three.js å¹³å°å±‚如果把相机 `up` 设置æˆåå‘,或在 Three 容器上åšå·¦å³é•œåƒï¼Œä¼šè®© WebGL 地å—çš„å±å¹• X è½´å’Œ DOM 角色 / è½ç‚¹é¢„测的å±å¹• X 轴相åã€‚è§„åˆ™å±‚ä»æ²¿å½“å‰åœ°å—中心到下一å—中心è£å†³ï¼Œæ‰€ä»¥åŽç«¯å¿«ç…§ä¼šæŠŠçжæ€çº æ­£å›žæ¥ï¼Œè¡¨çŽ°ä¸ºè·³åŽåˆ·æ–°ã€‚ +- 处ç†ï¼šThree ç›¸æœºä¿æŒ `up=(0, 1, 0)`,å†ç”¨å†…éƒ¨æŠ•å½±å…¬å¼æŠµæ¶ˆ 45° 下压导致的 Y 轴压缩;ä¸è¦é€šè¿‡åå‘ `camera.up` 解决上下方å‘。DOM 角色ã€è“„力引导ã€è½ç‚¹é¢„测和 Three å¹³å°å±‚必须共用åŒå‘å±å¹•åæ ‡ã€‚ +- 验è¯ï¼š`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM å±å¹•åæ ‡åŒå‘。 +- å…³è”:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`ã€`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 + +## 跳一跳立方体贴图ä¸è¦èµ°é€æ˜Žä¸»ä½“切片 + +- çŽ°è±¡ï¼šæ°´æžœç­‰ä¸»é¢˜ç”ŸæˆæˆåŠŸåŽï¼Œè¿è¡Œæ€åœ°å—看起æ¥åƒè–„的纯水果 PNGã€æžœåˆ‡è´´çº¸ã€é€æ˜Ž cutout;或者å过æ¥å…­ä¸ªé¢éƒ½æ˜¯åŒä¸€å¼ å¹³é“ºæžœçš® / 果肉æè´¨ï¼Œæ— æ³•ç»„åˆæˆæ–¹å—苹果 / æ–¹å—香蕉这类完整主题对象表达。 +- 原因:跳一跳地æ¿å·²ç»æ”¹ä¸º Three.js 标准 `1x1x1` 等比æžå°å€’角立方体å¤ç”¨å‡ ä½•体,è¿è¡Œæ€è§†è§’固定为近è·ç›¸æœºå’Œ 45° 下压视角;image2 åº”ç”Ÿæˆ `1024x1536` çš„ 18 个 cube object UV unwrap,æ¯ä¸ªå¤§å•元内的 top/front/right/back/left/bottom å…­é¢è¦å…±åŒåŒ…装åŒä¸€ä¸ªä¸»é¢˜ç‰©ä½“。åªå¼ºè°ƒ full-bleed å®¹æ˜“è®©æ°´æžœä¸»é¢˜é€€åŒ–æˆæžœçš®ã€æžœè‚‰ã€å¶è„‰ç­‰è¡¨é¢çº¹ç†ï¼›å¦‚æžœä»æŠŠä¸€å¼ å›¾è´´ç»™å…­ä¸ªé¢ï¼Œæ¨¡åž‹ä¹Ÿä¸éœ€è¦ç†è§£æ­£å和跨é¢è¿žç»­ç‰¹å¾ã€‚旧切图链路若把洋红 key 转 alphaã€è£è¾¹ã€åªä¿ç•™æœ€å¤§ alpha è¿žé€šä¸»ä½“å¹¶è¡¥é€æ˜Žå®‰å…¨è¾¹ï¼Œä¼šæŠŠæ•´æ ¼è´´å›¾é‡æ–°æŠ æˆè‹¹æžœ / 香蕉 / 果切等居中主体,贴到立方体上åŽå››è§’和侧é¢éƒ½å˜é€æ˜Žã€‚ +- 处ç†ï¼šè·³ä¸€è·³åœ°æ¿å›¾é›† prompt å›ºå®šè¦æ±‚ `cube object UV unwrap atlas / 立方体主题物体六é¢å±•开图集`,一张图åªç”Ÿæˆ 18 个大å•元,æ¯ä¸ªå¤§å•元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottomï¼›æ°´æžœä¸»é¢˜è¦æ˜Žç¡®ç”Ÿæˆèƒ½ä¸€çœ¼è¯´å‡ºå称的方å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—西瓜等å¯è¯†åˆ«å¯¹è±¡ï¼Œå¹¶è¦æ±‚果柄å¶ç‰‡ã€å‰¥çš®æ¡å¸¦ã€æ”¾å°„切é¢ã€çº¢ç“¤é»‘籽等身份特å¾è·¨é¢è¿žç»­ã€‚ç¦æ­¢è‡ªç„¶åœ†å½¢æ°´æžœã€è‡ªç„¶é•¿æ¡é¦™è•‰ã€éžæ–¹å—åŒ–å®Œæ•´æ°´æžœã€æžœåˆ‡å°è´´çº¸ã€å±…中å°ç‰©ä½“ã€é€æ˜ŽèƒŒæ™¯å’Œç•™ç™½ï¼ŒåŒæ—¶ä¹Ÿç¦æ­¢â€œå•纯平铺æè´¨ / æŠ½è±¡çº¹ç† / åªé“ºä¸»é¢˜é¢œè‰² / 纯果皮æè´¨ / çº¯æžœè‚‰çº¹ç† / 纯å¶è„‰çº¹ç†â€ã€‚åŽç«¯æŒ‰ 3x6 大å•元和 4x3 UV 网切出 108 å¼  `256x256` ä¸é€æ˜Žé¢è´´å›¾ï¼Œä¸å†è°ƒç”¨é€æ˜ŽåŒ–ã€æœ€å¤§ alpha 连通主体ä¿ç•™æˆ–逿˜Žè¡¥è¾¹ã€‚洋红 `#FF00FF` åªä½œä¸ºå›¾é›†å®‰å…¨ç¼ / UV ç©ºä½ / 外圈 key 色,è£åˆ‡åŽè‹¥ä»æœ‰æžå°‘残留则转æˆä¸é€æ˜Žæè´¨åº•色;绿色ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°ã€èŠ±æœµã€æžœè‚‰ç²‰è‰²å’Œæµ…黄色等主题颜色必须完整ä¿ç•™ã€‚ +- 验è¯ï¼š`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap promptã€18 个大å•å…ƒã€108 å¼ ä¸é€æ˜Žé¢è´´å›¾ã€ç»¿è‰² / 白色æè´¨ä¸è¢«é€æ˜ŽåŒ–ã€æ´‹çº¢ key 残留ä¸ä½œä¸ºé€æ˜Žæ´žï¼›å‰ç«¯ `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解æžå…­å¼ é¢è´´å›¾ï¼Œæ—§å•贴图资产ä»å¯ fallback。 - å…³è”:`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 æºç  @@ -2138,3 +2154,12 @@ - 处ç†ï¼š`api-server` 构造生æˆç»“æžœè®¢é˜…æ¶ˆæ¯æ—¶ï¼Œ`time4` 固定格å¼åŒ–为北京时间 `YYYY-MM-DD HH:mm`ï¼›ä¸è¦å¤ç”¨ `shared_kernel::format_timestamp_micros`。 - 验è¯ï¼š`cargo test --manifest-path server-rs\Cargo.toml -p api-server generation_result_template -- --nocapture`ï¼›dev 日志中ä¸åº”å†å‡ºçް `data.time4.value invalid`。 - å…³è”:`server-rs/crates/api-server/src/wechat_subscribe_message.rs`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 + +## 待解决:跳一跳生æˆè¶…æ—¶åŽå¯èƒ½åŽå°ç»§ç»­æˆåŠŸ + +- 风险程度:高。 +- 现象:跳一跳生æˆé¡µå¯èƒ½åœ¨ `98% 写入正å¼è‰ç¨¿` åŽæŠ¥â€œè¯·æ±‚è¶…æ—¶ï¼Œè¯·ç¨åŽé‡è¯•â€ï¼Œä½†åŽç«¯ä»åœ¨ç»§ç»­ç”Ÿæˆï¼Œç¨åŽæ‰æŠŠåŒä¸€ session å†™æˆ `DraftCompiled=100`。2026-06-08 排查 `jump-hop-session-6db8fa7af57c4fa2a71e6430cc808412` 时,背景底图 image2 æˆåŠŸä½†è€—æ—¶çº¦ `18分25ç§’`,返回按钮约 `2分44ç§’`,地æ¿å›¾é›†çº¦ `1分46ç§’`,总耗时超过å‰ç«¯ 20 分钟等待窗å£ï¼Œæœ€ç»ˆåœ¨å‰ç«¯è¶…æ—¶åŽçº¦ 3 分钟写è‰ç¨¿æˆåŠŸã€‚ +- åŽŸå› ï¼šè·³ä¸€è·³åˆ›ä½œé“¾è·¯ä»æŠŠèƒŒæ™¯ã€è¿”回按钮ã€åœ°æ¿å›¾é›†ã€åˆ‡ç‰‡å’Œ OSS 写入串在一次 HTTP 请求里;VectorEngine image2 啿­¥ timeout/connect 失败会在åŽç«¯é‡è¯•ï¼Œå•æ­¥è€—æ—¶å¯èƒ½è¶…过å‰ç«¯æ€»ç­‰å¾…窗å£ã€‚中间资产和真实阶段没有è½åº“,session 在完æˆå‰ä»æ˜¾ç¤º `Collecting`ã€`progress_percent=0`,å‰ç«¯åªèƒ½æŒ‰æ—¶é—´æ˜¾ç¤ºå‡è¿›åº¦ï¼›è¶…æ—¶åŽé‡è¯•åŒä¸€ session 时,åŽç«¯è¿˜å¯èƒ½å› ä¸º session 没有中间素æè€Œé‡æ–°ä»ŽèƒŒæ™¯å¼€å§‹ç”Ÿæˆã€‚ +- 待处ç†ï¼šå°†è·³ä¸€è·³ç”Ÿæˆæ”¹ä¸ºåŽç«¯ä»»åŠ¡åŒ– / å¯è½®è¯¢çœŸå®žé˜¶æ®µè¿›åº¦ï¼ŒæŒ‰èƒŒæ™¯ã€è¿”回按钮ã€å›¾é›†ã€åˆ‡ç‰‡ã€æŒä¹…化ã€å†™è‰ç¨¿åˆ†é˜¶æ®µè½åº“;统一åŽç«¯å…¨å±€ç”Ÿæˆ deadlineã€VectorEngine é‡è¯•预算ã€å‰ç«¯ç­‰å¾…窗å£å’Œå¤±è´¥æ€å›žå†™ã€‚è¶…æ—¶åŽå†æ¬¡è¿›å…¥åŒä¸€ session 应优先æ¢å¤æ­£åœ¨è¿è¡Œæˆ–已完æˆçš„任务,ä¸åº”é‡å¤ç”Ÿå›¾ã€‚ +- 验è¯ï¼šæ¨¡æ‹Ÿé¦–å¼  image2 超长耗时或超时é‡è¯•时,生æˆé¡µåº”æ˜¾ç¤ºçœŸå®žé˜¶æ®µå’Œå¯æ¢å¤çжæ€ï¼›å‰ç«¯è¯·æ±‚è¶…æ—¶ä¸åº”把最终æˆåŠŸè‰ç¨¿æ ‡è®°ä¸ºå¤±è´¥ï¼›åˆ·æ–° `/creation/jump-hop/generating?sessionId=` åŽåº”能æ¢å¤åˆ°åŽç«¯çœŸå®žçжæ€ï¼›åŒä¸€ session é‡è¯•ä¸å¾—é‡å¤ç”Ÿæˆå·²å®Œæˆé˜¶æ®µã€‚ +- å…³è”:`src/services/jump-hop/jumpHopClient.ts`ã€`src/services/miniGameDraftGenerationProgress.ts`ã€`server-rs/crates/api-server/src/jump_hop.rs`ã€`server-rs/crates/platform-image/src/vector_engine/client.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 diff --git a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md index 07751c9e..e64d4371 100644 --- a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md +++ b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md @@ -2,15 +2,15 @@ ## 1. 目标 -`jump-hop` é‡å®šä¹‰ä¸ºç«–å±ä¿¯è§†è§’å¹³å°è·³è·ƒæ¸¸æˆã€‚创作者åªè¾“入主题,系统生æˆä¸€å¼ è¯¥ä¸»é¢˜çš„ `5x5` 地å—资æºå›¾é›†ï¼Œåˆ‡æˆ 25 个 2D 地å—ç´ æï¼›è¿è¡Œæ€ä½¿ç”¨æŠ é™¤ç™½åº•åŽçš„陶泥儿 logo 逿˜Ž PNG 作为玩家角色,并和这些 2D 地å—èµ„äº§ç»„æˆæ— é™å¹³å°æµã€‚ +`jump-hop` é‡å®šä¹‰ä¸ºç«–å±ä¿¯è§†è§’å¹³å°è·³è·ƒæ¸¸æˆã€‚创作者åªè¾“入主题,系统生æˆä¸€å¼ è¯¥ä¸»é¢˜çš„ `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方å—,æ¯ä¸ªæ–¹å—冿Œ‰å›ºå®š `4列*3行` UV ç½‘åˆ‡æˆ top/front/right/back/left/bottom å…­å¼ é¢è´´å›¾ï¼›è¿è¡Œæ€ä½¿ç”¨ Three.js å¤ç”¨æ ‡å‡† `1x1x1` 等比æžå°å€’角立方体几何体,把六é¢è´´å›¾è´´åˆ°ç«‹æ–¹ä½“地æ¿ä¸Šç»„æˆæ— é™å¹³å°æµï¼ŒåŒæ—¶ä½¿ç”¨é™¶æ³¥å„¿ logo 逿˜Ž PNG 作为玩家角色。 首版目标: 1. 创作输入åªä¿ç•™ä¸»é¢˜ï¼Œæ ‡é¢˜ã€ç®€ä»‹ã€æ ‡ç­¾å’Œæç¤ºè¯ç”±ç³»ç»Ÿæ´¾ç”Ÿï¼› -2. image2 åªç”Ÿæˆä¸€å¼  `5x5` 地å—图集,åŽç«¯å‡åŒ€åˆ‡æˆ 25 å¼  PNGï¼› +2. image2 åªç”Ÿæˆä¸€å¼  `1024x1536` åœ°æ¿ UV 展开图集,åŽç«¯åˆ‡æˆ 18 组ã€å…± 108 å¼ é¢è´´å›¾ PNGï¼› 3. 角色ä¸å†å•独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 逿˜Ž PNGï¼› 4. è¿è¡Œæ€æ¯å±åªå±•示 3 个地å—:当å‰åœ°å—ã€ç›®æ ‡åœ°å—ã€ä¸‹ä¸€é¢„览地å—ï¼› -5. æ“作方å¼ä¸ºæŒ‰ä½å±å¹•å‘åŽæ‹–åŠ¨è“„åŠ›ï¼Œæ¾æ‰‹åŽè§’è‰²å‘æ‹–æ‹½åæ–¹å‘弹出; +5. æ“作方å¼ä¸ºé•¿æŒ‰å±å¹•è“„åŠ›ï¼Œæ¾æ‰‹åŽè§’色æœä¸‹ä¸€å—地å—中心方å‘弹出; 6. åªè¦è½ç‚¹æœªå‘½ä¸­ä¸‹ä¸€ä¸ªåœ°å—,本局立å³å¤±è´¥å¹¶å†»ç»“计时; 7. æˆç»©è®°å½•æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼› 8. 排行榜按作å“维度展示玩家 IDã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼ŒæŽ’åºä¸ºæˆåŠŸè·³è·ƒæ¬¡æ•°é™åºã€æ¸¸æˆæ—¶é•¿å‡åºã€æ›´æ–°æ—¶é—´å‡åºã€‚ @@ -21,10 +21,10 @@ - 展示å:`跳一跳` - 工程域:`jump-hop` - 创作入å£å¡ï¼š`subtitle = 主题驱动平å°è·³è·ƒ`,`imageSrc = /creation-type-references/jump-hop.webp` -- è¿è¡Œæ€ï¼š`DOM å¹³å° / DOM 角色 + Three.js 逿˜Žæ‰©å±•层 + DOM HUD` +- è¿è¡Œæ€ï¼š`Three.js 标准 1x1x1 等比æžå°å€’è§’ç«‹æ–¹ä½“åœ°æ¿ + DOM 角色 + DOM HUD` - ç”»é¢æ¯”例:移动端竖å±ä¼˜å…ˆï¼Œæ¡Œé¢ç«¯å±…中承载 `9:16` -- ç´ æç­–略:2D 地å—图集 + 陶泥儿 logo 逿˜Žè§’色 -- 渲染分层:生æˆåœ°å—切片必须由 DOM å¹³å°å±‚直接渲染为图片;角色必须由 DOM 逿˜Ž PNG å±‚æ¸²æŸ“å¹¶ä¿æŒæœ€é«˜å±‚级,Three.js 逿˜Žç”»å¸ƒåªä½œä¸ºåŽç»­æ‰©å±•层,ä¸èƒ½æŠŠåœ°å—图片或角色回退为 WebGL å ä½æè´¨ +- ç´ æç­–略:18 个立方体主题物体 UV 展开包装 + Three.js å¤ç”¨æ ‡å‡† 1x1x1 等比立方体几何 + 陶泥儿 logo 逿˜Žè§’色 +- 渲染分层:Three.js å¹³å°å±‚å¤ç”¨ä¸€ä»½æ ‡å‡† `1x1x1` 等比æžå°å€’角立方体几何体,`tileAssets[]` 切片åªä½œä¸ºä¸»é¢˜èº«ä»½æ–¹å—包装贴图;å•å—ç«‹æ–¹ä½“å¿…é¡»æ­£è½´å‘æ‘†æ”¾ï¼Œä¸åš Y è½´å航或 Z 轴歪斜旋转,也ä¸å¾—用ä¸åŒ x/y/z scale åŽ‹æˆæ‰ç›’å­ï¼›è¿è¡Œæ€è§†è§’采用约 `1.3x` è¿‘è·ç›¸æœºå’Œ 45° 下压视角,当å‰è„šä¸‹åœ°å—基准ä½äºŽå±å¹•中线略下方,åŽç»­ä¸¤å—å‘ä¸Šå±•å¼€ä¸”ä¿æŒç´§å‡‘çš„çºµå‘ / 横å‘é—´è·ï¼›Three.js å¹³å°å±‚与 DOM è§’è‰²å±‚å¿…é¡»ä¿æŒå±å¹• X è½´åŒå‘ï¼Œç¦æ­¢é€šè¿‡åå‘相机 `up` 或镜åƒå®¹å™¨æŠŠå¹³å°å·¦å³ç¿»è½¬ï¼›DOM 地å—图片层åªç”¨äºŽæ¢ç­¾ã€é¢„加载ã€WebGL ä¸å¯ç”¨å’Œæµ‹è¯• fallback,Three.js å¹³å°å±‚ ready åŽå¿…é¡»éšè— DOM 地å—图片和 DOM 阴影,退出地å—åªéšç›¸æœºæŽ¨è¿›è‡ªç„¶ç¦»å±ï¼Œä¸æ’­æ”¾ç‹¬ç«‹é£žèµ°åŠ¨ç”»ï¼Œè¶…è¿‡å±å¹•åŽå†é”€æ¯ï¼Œé¿å…旧地å—é€€å‡ºæœŸéœ²å‡ºè¢«æ”¾å¤§çš„å¹³é¢ DOM 贴图;角色必须由 DOM 逿˜Ž PNG å±‚æ¸²æŸ“å¹¶ä¿æŒåœ¨ Three.js å¹³å°å±‚之上 æœ¬çŽ©æ³•ä¸æ˜¯æ¨ªç‰ˆå¹³å°è·³è·ƒï¼Œä¹Ÿä¸æ˜¯å…³å¡åˆ¶é—¯å…³ã€‚å¹³å°ä»Žå±å¹•下方å‘上无é™å»¶å±•,目标地å—在当å‰åœ°å—上方ä¸åŒ x è½´ä½ç½®éšæœºå‡ºçŽ°ã€‚ @@ -35,12 +35,12 @@ - å•图资产槽ä½ï¼šæ— ç‹¬ç«‹è§’色图槽ä½ï¼›v1 固定使用陶泥儿 logo 逿˜Ž PNG 角色 - ç³»åˆ—ç´ ææ§½ä½ï¼š - `batchId = jump-hop-tile-atlas` - - `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / åŽç«¯åˆ‡å›¾é€æ˜ŽåŒ–` - - `slotSpecs = tile-01 ... tile-25`,æ¯ä¸ª slot 必须对应唯一 OSS path / `assetObjectId` - - 切图规则:按原图宽高å‡åˆ†ä¸º 5 行 5 列,从上到下ã€ä»Žå·¦åˆ°å³åˆ‡å‡º 25 å¼  PNGï¼›æ¯æ ¼é€æ˜ŽåŒ–åŽåªä¿ç•™æœ€å¤§çš„ alpha 连通主体,å†è£è¾¹å¹¶è¡¥é€æ˜Žå®‰å…¨è¾¹ï¼Œé¿å…相邻格越界碎片或方形æ‚边进入 tile - - 逿˜ŽåŒ–è§„åˆ™ï¼šç”Ÿæˆæ—¶è¦æ±‚绿幕背景,åŽç«¯ä¸Šä¼  OSS 剿Рæˆé€æ˜Ž PNG,并清ç†ä¸Žä¸»ä½“分离的å°åž‹æ®‹ç‰‡ + - `sheetSpec = 1024x1536 / 3列*6行大å•å…ƒ / æ¯æ ¼4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全ç¼ä¸Žå¤–圈背景 / åŽç«¯åˆ‡å›¾ä¸ºé¢è´´å›¾ PNG` + - `slotSpecs = tile-01 ... tile-18`,æ¯ä¸ª tile å†åŒ…å« `top/front/right/back/left/bottom` å…­ä¸ªé¢ slot,所有 slot 必须对应唯一 OSS path / `assetObjectId` + - 切图规则:先按原图宽高å‡åˆ†ä¸º 3 列 6 行,从上到下ã€ä»Žå·¦åˆ°å³å¾—到 18 个大å•元;æ¯ä¸ªå¤§å•元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;æ¯ä¸ªé¢è¾“出 `256x256` ä¸é€æ˜Ž PNG + - 逿˜ŽåŒ–è§„åˆ™ï¼šç”Ÿæˆæ—¶è¦æ±‚纯洋红 key 安全ç¼å’Œ UV 空ä½ï¼ŒåŽç«¯ä¸åšé€æ˜ŽåŒ–æŠ å›¾ï¼ŒåªæŠŠè£åˆ‡åŽæ®‹ç•™çš„æ´‹çº¢ key 色转为ä¸é€æ˜Žæè´¨åº•色,ä¿ç•™ç»¿è‰²ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°ã€èŠ±æœµã€æžœè‚‰ç²‰è‰²å’Œæµ…é»„è‰²ç­‰ä¸»é¢˜çº¹ç† - 失败回写:生æˆå¤±è´¥æ—¶ session ä¿æŒ failed,å¯ä»Žç”Ÿæˆé¡µé‡è¯• - - 局部é‡ç”Ÿæˆï¼šç»“果页å…许é‡ç”Ÿæˆåœ°å—图集,ä»åªè°ƒç”¨ä¸€æ¬¡ image2ï¼›å‰ç«¯å±•示生æˆå›¾æ—¶ä»¥ `assetObjectId` 作为刷新键,é¿å…åŒä¸€è·¯å¾„é‡å†™åŽçš„æ—§ç­¾å或旧缓存 + - 局部é‡ç”Ÿæˆï¼šç»“果页å…许é‡ç”Ÿæˆåœ°æ¿è´´å›¾å›¾é›†ï¼Œä»åªè°ƒç”¨ä¸€æ¬¡ image2ï¼›å‰ç«¯å±•示生æˆå›¾æ—¶ä»¥ `assetObjectId` 作为刷新键,é¿å…åŒä¸€è·¯å¾„é‡å†™åŽçš„æ—§ç­¾å或旧缓存 - API 命å空间:`/api/creation/jump-hop/*`ã€`/api/runtime/jump-hop/*` - 业务真相:åŽç«¯è£å†³è½ç‚¹ã€å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€å†»ç»“时长和排行榜 - 创作工具模å¼ä¾‹å¤–:无 @@ -55,33 +55,35 @@ 1. ä½œå“æ ‡é¢˜ï¼šä¸»é¢˜ä¸ºç©ºç™½ä¿®å‰ªåŽçš„短标题,默认å‰ç¼€ä¸å¤–露; 2. 作å“简介:基于主题生æˆä¸€å¥çŸ­ç®€ä»‹ï¼› 3. 标签:`跳一跳`ã€`休闲` 和主题关键è¯ï¼› -4. åœ°å—æç¤ºè¯ï¼šå›´ç»•ä¸»é¢˜ç”Ÿæˆ 25 个风格一致的俯视角清爽游æˆåŒ– 2D å¹³å°ç´ æï¼Œæ¯ä¸€å—都是符åˆä¸»é¢˜çš„å•独å¯è·³è·ƒå¹³å°ï¼›å®žé™… image2 prompt 使用“独立å¯è½è„šå¹³å°ç´ æ / å¹³å°è£¸ç´ æ / 完整平å°â€æŽªè¾žï¼Œä¸å†æŠŠæ­£å‘主体æè¿°æˆå›¾æ ‡é›†æˆ–游æˆç•Œé¢èµ„æºï¼› +4. 地æ¿è´´å›¾æç¤ºè¯ï¼šå›´ç»•ä¸»é¢˜ç”Ÿæˆ 18 个风格一致的立方体主题物体 UV 展开包装,æ¯ä¸ªåŒ…装由 top/front/right/back/left/bottom å…­é¢ç»„æˆï¼Œä¾› Three.js 标准 1x1x1 等比æžå°å€’角立方体地æ¿å¤ç”¨ï¼›å®žé™… image2 prompt 使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlasâ€æŽªè¾žï¼Œè¦æ±‚å…­é¢å…±åŒè¡¨è¾¾åŒä¸€ä¸ªå®Œæ•´æ–¹å—化主题物体,例如水果主题è¦ç”Ÿæˆå¯ä¸€çœ¼è¾¨è®¤çš„æ–¹å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—è¥¿ç“œç­‰ï¼Œè€Œä¸æ˜¯å•纯生æˆå¹³é“ºæè´¨ã€æŠ½è±¡çº¹ç†ã€å¹³å°ã€è·³å°ã€åœ°å—æˆå“ã€å•张图é‡å¤å…­é¢æˆ–游æˆç•Œé¢èµ„æºï¼› 5. åˆå§‹å¹³å°æµå‚数:固定 v1 æ ‡å‡†å‚æ•°ï¼Œä¸è®©åˆ›ä½œè€…手工调规则。 -## 5. 地å—图集 +## 5. 地æ¿è´´å›¾å›¾é›† -image2 åªç”Ÿæˆä¸€å¼  `1:1` 图片,画é¢ä¸º `5x5` å‡åŒ€åˆ†å¸ƒå¹³å°è£¸ç´ æï¼›å®žé™…æç¤ºè¯å¿…须先约æŸâ€œç”»é¢åªåŒ…å« 25 个独立跳一跳å¯è½è„šå¹³å°ç´ æâ€ï¼Œå¹¶æ˜Žç¡®ä¸æ˜¯æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…å¤‡æ æˆ–图标集页é¢ã€‚ +image2 åªç”Ÿæˆä¸€å¼  `1024x1536` 竖版图片,画é¢ä¸º `3列*6行` å‡åŒ€åˆ†å¸ƒçš„立方体主题物体 UV 展开包装;实际æç¤ºè¯å¿…须先约æŸâ€œç”»é¢åªåŒ…å« 18 个用于跳一跳地æ¿çš„立方体主题物体 UV 展开包装图â€ï¼Œå¹¶æ˜Žç¡®è¿™æ˜¯ä¾› Three.js 标准 1x1x1 等比æžå°å€’角立方体使用的 cube object UV unwrap atlas。æ¯ä¸ªå¤§å•元格代表一个完整方å—化主题物体,并在固定 `4列*3行` UV 网中æä¾›å…­å¼ é¢è´´å›¾ï¼›ä¸æ˜¯å•纯æè´¨è´´ç‰‡ã€å•张图é‡å¤å…­é¢ã€åœ°å—æˆå“图ã€è·³æ¿ã€ç‰©ä½“å‰ªå½±ã€æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…å¤‡æ æˆ–图标集页é¢ã€‚ å›¾é›†è¦æ±‚: -1. æ¯æ ¼åªæ”¾ä¸€ä¸ªå®Œæ•´åœ°å—资æºï¼› -2. 资æºä¸ºçº¯ 2D å¹³é¢ç´ æï¼Œä½†è¦è¡¨çŽ°ä¸ºç¬¦åˆä¸»é¢˜ä¸”有设计感的俯视角清爽游æˆåŒ–立体感平å°ï¼Œæœ‰é¡¶é¢ã€ä¸»ä½“内部明暗和清晰轮廓;主题元素必须直接æˆä¸ºå¹³å°ä¸»ä½“,例如“水果â€åº”生æˆè‹¹æžœåˆ‡ç‰‡ã€æ©™å­åˆ‡ç‰‡ã€è¥¿ç“œå—ã€è‰èŽ“ã€è èã€é¦™è•‰ç­‰æ°´æžœé€ åž‹å¹³å°ï¼› -3. 25 ä¸ªåœ°å—æ¥è‡ªåŒä¸€ä¸»é¢˜ã€åŒä¸€å…‰å‘å’ŒåŒä¸€æè´¨ä½“系; -4. 背景为纯绿色绿幕,方便åŽç«¯é€æ˜ŽåŒ–ï¼› -5. ä¸åŒ…å«è§’è‰²ã€æ–‡å­—ã€æ°´å°ã€UIã€æ¸¸æˆé¢æ¿ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€æŒ‰é’®ã€æ ‡é¢˜ã€å¤–层边框ã€ç½‘格线ã€åœºæ™¯èƒŒæ™¯ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ–¹å½¢åº•æ¿ã€ç™½åº•ã€ç°åº•或黑底; -6. 地å—ä¸èƒ½è·¨æ ¼ã€è´´è¾¹æˆ–进入相邻格,主体必须居中并ä¿ç•™è‡³å°‘ 18% 纯绿色安全留白;æ¯ä¸ªå¹³å°ä¹‹é—´åªèƒ½æ˜¯çº¯ç»¿è‰²ç©ºç™½ï¼Œä¸ç”»å®¹å™¨æ¡†æˆ–棋盘格。 +1. æ¯ä¸ªå¤§å•元内部固定使用 `4列*3行` UV ç½‘ï¼Œåªæœ‰å…­ä¸ªä½ç½®æœ‰è´´å›¾ï¼šç¬¬ 1 行第 2 列是 `top`;第 2 行第 1-4 åˆ—ä¾æ¬¡æ˜¯ `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它ä½ç½®ä¿æŒçº¯æ´‹çº¢ `#FF00FF`ï¼› +2. æ¯ä¸ªé¢éƒ½æ˜¯ full-bleed ä¸é€æ˜Žæ­£æ–¹å½¢è´´å›¾ï¼Œå››è§’ã€è¾¹ç¼˜å’Œä¸­å¿ƒéƒ½è¦æœ‰å¯è¯†åˆ«å†…容;六个é¢å…±åŒç»„æˆåŒä¸€ä¸ªå®Œæ•´æ–¹å—化主题物体,ä¸èƒ½æŠŠåŒä¸€å¼ çº¹ç†é‡å¤å…­æ¬¡ï¼Œä¹Ÿä¸èƒ½å…­é¢å„画互ä¸ç›¸å…³çš„å°å›¾æ ‡ï¼› +3. 贴图ä¸ç”Ÿæˆå·²ç»æ¸²æŸ“好的é€è§† 3D å—体æˆå“,ä¸åŒ…嫿‘„åƒæœºè§’度ã€å·²çƒ˜ç„™ä¾§å£ã€å·²çƒ˜ç„™åŽšåº¦ã€è‡ªèº«æŠ•å½±ã€æŽ¥è§¦é˜´å½±æˆ–çƒ˜ç„™é«˜å…‰ï¼›çœŸå®žå€’è§’ã€ä¾§å£ã€é€è§†å’Œé˜´å½±ç”±è¿è¡Œæ€ Three.js 生æˆï¼› +4. 18 ä¸ªæ–¹å—æ¥è‡ªåŒä¸€ä¸»é¢˜ã€åŒä¸€å“‘光手绘包装体系,但应表达ä¸åŒæ–¹å—化主题物体或明显ä¸åŒçš„包装识别特å¾ï¼›æ°´æžœä¸»é¢˜è¦æ··æŽ’æ–¹å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—è¥¿ç“œã€æ–¹å—è‰èŽ“ã€æ–¹å—è‘¡è„ã€æ–¹å—å¥‡å¼‚æžœã€æ–¹å—è èã€æ–¹å—æŸ æª¬ã€æ–¹å—桃å­ã€æ–¹å—æ¢¨ã€æ–¹å—è“èŽ“ã€æ–¹å—èŠ’æžœã€æ–¹å—椰å­ã€æ–¹å—ç«é¾™æžœã€æ–¹å—æ¨±æ¡ƒã€æ–¹å—å“ˆå¯†ç“œã€æ–¹å—石榴,ä¸è¦ 18 个方å—éƒ½åªæ˜¯åŒä¸€ç§æžœçš®ã€æžœè‚‰æˆ–å¶è„‰çº¹ç†ï¼› +5. 大å•元之间ã€UV 空ä½ã€å…­é¢ä¹‹é—´å’Œç”»å¸ƒå¤–圈为纯洋红 `#FF00FF`,方便åŽç«¯å®‰å…¨åˆ‡å›¾ï¼› +6. ä¸åŒ…å«è§’è‰²ã€æ–‡å­—ã€æ°´å°ã€UIã€æ¸¸æˆé¢æ¿ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€æŒ‰é’®ã€æ ‡é¢˜ã€å¤–层边框ã€å¯è§ç½‘格线ã€åœºæ™¯èƒŒæ™¯ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ–¹å½¢åº•æ¿ã€ç™½åº•ã€ç°åº•或黑底; +7. 贴图ä¸èƒ½è·¨æ ¼ã€è´´è¾¹ä¸²è‰²æˆ–进入相邻格;æ¯ä¸ªé¢è´´å›¾åº”å°½é‡é“ºæ»¡è‡ªå·±çš„ UV é¢ï¼Œçº¯æ´‹çº¢åªä½œä¸ºå®‰å…¨ç¼ã€UV 空ä½å’Œå¤–圈 key 色。 -切片顺åºå›ºå®šä¸ºï¼š +大å•元切片顺åºå›ºå®šä¸ºï¼š ```text -tile-01 tile-02 tile-03 tile-04 tile-05 -tile-06 tile-07 tile-08 tile-09 tile-10 -tile-11 tile-12 tile-13 tile-14 tile-15 -tile-16 tile-17 tile-18 tile-19 tile-20 -tile-21 tile-22 tile-23 tile-24 tile-25 +tile-01 tile-02 tile-03 +tile-04 tile-05 tile-06 +tile-07 tile-08 tile-09 +tile-10 tile-11 tile-12 +tile-13 tile-14 tile-15 +tile-16 tile-17 tile-18 ``` -è¿è¡Œæ€éšæœºä½¿ç”¨è¿™ 25 个地å—作为åŽç»­å¹³å°å¤–观。起点地å—å¯å¤ç”¨ç¬¬ä¸€ä¸ªåˆ‡ç‰‡ï¼Œå…¶ä½™å¹³å°ä»Žå®Œæ•´æ± ä¸­éšæœºé€‰æ‹©ã€‚ +æ¯ä¸ª `tile-XX` å†åˆ‡å‡º `top/front/right/back/left/bottom` 六个é¢è´´å›¾å¹¶å†™å…¥ `tileAssets[].faceAssets`。历å²å…¼å®¹å­—段 `imageSrc/imageObjectKey/assetObjectId` ä¿å­˜ top é¢ï¼Œæ—§ä½œå“没有 `faceAssets` æ—¶è¿è¡Œæ€ä»å¯æŠŠå•张旧贴图应用到立方体所有é¢ã€‚è¿è¡Œæ€éšæœºä½¿ç”¨è¿™ 18 个地å—作为åŽç»­å¹³å°å¤–观。起点地å—å¯å¤ç”¨ç¬¬ä¸€ä¸ªåˆ‡ç‰‡ï¼Œå…¶ä½™å¹³å°ä»Žå®Œæ•´æ± ä¸­éšæœºé€‰æ‹©ã€‚ ## 6. è¿è¡Œæ€è§„则 @@ -97,23 +99,24 @@ tile-21 tile-22 tile-23 tile-24 tile-25 ### 6.2 æ“作 -1. 用户按ä½å½“å‰åœ°å—或画é¢ï¼› -2. å‘åŽæ‹–动形æˆè“„力å‘é‡ï¼› -3. æ¾æ‰‹åŽè§’è‰²æ²¿æ‹–æ‹½åæ–¹å‘弹出; -4. 拖拽è·ç¦»å†³å®šåŠ›åº¦ï¼Œæ‹–æ‹½æ–¹å‘决定è½ç‚¹æ–¹å‘ï¼› -5. 力度和方å‘都由å‰ç«¯æäº¤ç»™åŽç«¯è£å†³ã€‚ +1. 用户按ä½å½“å‰åœ°å—或画é¢å¼€å§‹è“„力; +2. 长按时长形æˆè“„力值,达到 `maxChargeMs` åŽå°é¡¶ï¼› +3. æ¾æ‰‹åŽè§’色æœä¸‹ä¸€å—地å—中心方å‘弹出; +4. 蓄力值决定跳跃è·ç¦»ï¼Œè·³è·ƒæ–¹å‘ä¸å—手指拖动方å‘å½±å“ï¼› +5. å‰ç«¯åªæäº¤è“„力值,åŽç«¯åŸºäºŽå½“å‰åœ°å—中心到下一å—地å—中心的方å‘è£å†³çœŸå®žè½ç‚¹ã€‚ -æ‰‹æ„Ÿå‚æ•°å›ºå®šç”±åŽç«¯ `module-jump-hop` æä¾›ï¼š`chargeToDistanceRatio = 0.008`。该值表示åŒç­‰ä¸–界跳跃è·ç¦»åªéœ€è¦æ—§ç‰ˆ `0.004` é…置的一åŠå±å¹•拖动è·ç¦»ï¼›æ—§ä½œå“è¿è¡Œæ—¶è‹¥ä»æºå¸¦ `0.004`,开局归一化为 `0.008`。 +æ‰‹æ„Ÿå‚æ•°å›ºå®šç”±åŽç«¯ `module-jump-hop` æä¾›ï¼š`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃è·ç¦»çš„æ¢ç®—ç³»æ•°ï¼›æ—§ä½œå“è¿è¡Œæ—¶è‹¥ä»æºå¸¦å…¶å®ƒç³»æ•°ï¼Œå¼€å±€å½’一化为 `0.004`。契约中的 `dragDistance` 继续作为兼容字段ä¿ç•™ï¼Œä½†å½“å‰è¯­ä¹‰æ˜¯å‰ç«¯æäº¤çš„蓄力值;`dragVectorX/dragVectorY` 仅兼容旧客户端,åŽç«¯è£å†³å¿…须忽略。 -æ¾æ‰‹åŽå‰ç«¯å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` 的角色飞行动画;角色从当å‰åœ°å—å¼¹å‘预测è½ç‚¹ï¼Œè“„åŠ›é˜¶æ®µè§’è‰²åº”æ²¿æ‹–æ‹½æ–¹å‘æ˜Žæ˜¾æ‹‰é•¿ï¼Œè½åœ°åŽå†å‘åæ–¹å‘回弹两次。动画期间 DOM 地å—窗å£ä¿æŒåœ¨æœ¬æ¬¡èµ·è·³å‰çš„ 3 å—布局,动画路径ä¸å¾—等待åŽç«¯æ–° run。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测è½ç‚¹ç­‰å¾…,直到新 run 到达åŽå†æŠŠæ˜¾ç¤ºæ€åˆ‡åˆ°åŽç«¯è¿”回的最新 run,并进入约 `1440ms` çš„ç›¸æœºæŽ¨è¿›è¿‡æ¸¡ã€‚æŽ¨è¿›è¿‡æ¸¡ä¸­ï¼Œåœ°å— DOM 层和 DOM 角色层必须放在åŒä¸€ä¸ªç›¸æœºå±‚里统一ä½ç§»ï¼Œä¸å…许 p1/p2 å•独改 `top/left` åšè¿‡æ¸¡ï¼›æ—§å½“å‰åœ°å—éšç›¸æœºæŽ¨è¿›è‡ªç„¶ç¦»å¼€è§†é‡Žï¼Œæ–°é¢„览地å—从上方自然露出,é¿å…角色和地å—ä¸åŒæ­¥æˆ–é—ªçŽ°ã€‚ç›¸æœºæŽ¨è¿›å¿…é¡»åŒæ—¶æºå¸¦ X/Y å移,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸å…许先横å‘瞬切居中åŽå†åªåšçºµå‘滑动。地å—å¯ä»¥ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用åŒä¸€ `1440ms` 缓动过渡;ä¸å¾—通过直接改宽高造æˆçž¬åˆ‡å˜å¤§ã€‚当å‰åœ°å—高亮ä¸å¾—é¢å¤–通过 CSS `scale` 放大。该动画åªå±žäºŽè¡¨çŽ°å±‚ï¼Œå‘½ä¸­ã€å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œå†»ç»“æ—¶é•¿ä»ä»¥åŽç«¯è£å†³ä¸ºå‡†ã€‚ +æ¾æ‰‹åŽå‰ç«¯å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测真实è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` çš„è§’è‰²é£žè¡ŒåŠ¨ç”»ï¼›è§†è§‰é¢„æµ‹å¿…é¡»ä½¿ç”¨å½“å‰æ˜¾ç¤ºçª—å£çš„ current/next 地å—ä½œä¸ºæ–¹å‘æ¥æºï¼Œå³ä½¿åŽç«¯æœ€æ–° run å·²æå‰è¿”回,也ä¸èƒ½æ‹¿æ–° run ç›®æ ‡é…æ—§çª—å£è§’色导致下一跳åå‘;角色从当å‰åœ°å—沿下一å—地å—中心方å‘å¼¹å‘预测真实è½ç‚¹ï¼Œè“„力阶段角色åªåšåž‚ç›´åŽ‹ç¼©ï¼Œä¸æ²¿ç›®æ ‡æ–¹å‘拉长。æˆåŠŸè½åœ°åŽå¿…é¡»ä¿ç•™ `lastJump.landedX/landedY` 对应的真实è½ç‚¹å移,ä¸å¾—强制å¸é™„回目标地å—中心;è½åœ°åŽå¯ä»¥è½»é‡å›žå¼¹ï¼Œä½†ä¸èƒ½æŠŠè§’色ä½ç½®æ‹‰ç¦»çœŸå®žè½ç‚¹ã€‚动画期间 DOM 地å—窗å£ä¿æŒåœ¨æœ¬æ¬¡èµ·è·³å‰çš„ 3 å—布局,动画路径ä¸å¾—等待åŽç«¯æ–° run。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测真实è½ç‚¹ç­‰å¾…;新 run 到达åŽåº”先使用åŽç«¯çœŸå®žè½ç‚¹å¯¹é½æ˜¾ç¤ºæ€ï¼Œå†è¿›å…¥çº¦ `1440ms` 的相机推进过渡,é¿å…角色先飞过很远å†çž¬é—´æ‹‰å›žåœ°å—ã€‚æŽ¨è¿›è¿‡æ¸¡ä¸­ï¼Œåœ°å— DOM 层和 DOM 角色层必须放在åŒä¸€ä¸ªç›¸æœºå±‚里统一ä½ç§»ï¼Œä¸å…许 p1/p2 å•独改 `top/left` åšè¿‡æ¸¡ï¼›æ—§å½“å‰åœ°å—åªéšç›¸æœºæŽ¨è¿›ä¿ç•™åœ¨å±å¹•åŽæ–¹ï¼Œä¸å•独执行飞走动画,玩家继续å‘å‰è·³æ—¶å†è¢«æ–°çš„相机推进自然带出å±å¹•并销æ¯ï¼Œæ–°é¢„览地å—从上方自然露出,é¿å…角色和地å—ä¸åŒæ­¥æˆ–é—ªçŽ°ã€‚ç›¸æœºæŽ¨è¿›å¿…é¡»åŒæ—¶æºå¸¦ X/Y å移,从旧真实è½ç‚¹ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸å…许先横å‘瞬切居中åŽå†åªåšçºµå‘滑动。地å—å¯ä»¥ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用åŒä¸€ `1440ms` 缓动过渡;ä¸å¾—通过直接改宽高造æˆçž¬åˆ‡å˜å¤§ã€‚当å‰åœ°å—高亮ä¸å¾—é¢å¤–通过 CSS `scale` 放大。该动画åªå±žäºŽè¡¨çŽ°å±‚ï¼Œå‘½ä¸­ã€å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œå†»ç»“æ—¶é•¿ä»ä»¥åŽç«¯è£å†³ä¸ºå‡†ã€‚ ### 6.3 判定 1. 目标永远是当å‰åœ°å—åŽçš„下一个地å—ï¼› -2. è½ç‚¹è¿›å…¥ä¸‹ä¸€ä¸ªåœ°å—è½åœ°åŠå¾„,则æˆåŠŸï¼› -3. è½ç‚¹æœªè¿›å…¥ä¸‹ä¸€ä¸ªåœ°å—è½åœ°åŠå¾„,则失败; -4. 失败åŽçŠ¶æ€æ”¹ä¸º `failed`,计时冻结; -5. v1 没有通关状æ€ã€comboã€perfect 或生命数。 +2. 真实è½ç‚¹æ²¿å½“å‰åœ°å—中心到下一å—地å—中心方å‘计算; +3. è½ç‚¹è¿›å…¥ä¸‹ä¸€ä¸ªåœ°å—å¯è§é¡¶é¢ footprint,则æˆåŠŸï¼›footprint 使用当å‰è·¯å¾„é‡Œè¯¥åœ°å— `width/height` 的收缩矩形模拟 45° 视角下的å¯è§é¡¶é¢ï¼Œå½“å‰å‘½ä¸­åŒºçº¦ä¸ºå®½åº¦ 72% 和高度 52%ï¼› +4. è½ç‚¹æœªè¿›å…¥ä¸‹ä¸€ä¸ªåœ°å—å¯è§é¡¶é¢ footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅ä¿ç•™å…¼å®¹è¯»å†™ï¼Œä¸å†ä½œä¸ºå½“å‰ v1 æˆåŠŸåˆ¤å®šï¼› +5. 失败åŽçŠ¶æ€æ”¹ä¸º `failed`,计时冻结; +6. v1 没有通关状æ€ã€comboã€perfect 或生命数。 ### 6.4 计分与时间 @@ -149,7 +152,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 结果页展示: 1. 陶泥儿 logo 逿˜Žè§’色预览; -2. 25 个地å—èµ„æºæ± é¢„览; +2. 18 个地å—èµ„æºæ± é¢„览; 3. é¦–å± 3 å—å¹³å°é¢„览; 4. 试玩; 5. å‘布; @@ -183,14 +186,14 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc ## 10. 验收 1. åˆ›ä½œé¡µåªæ˜¾ç¤ºä¸»é¢˜è¾“入; -2. 生æˆé“¾è·¯åªè°ƒç”¨ä¸€æ¬¡åœ°å—图集 image2,ä¸å†è°ƒç”¨è§’色生图; -3. 地å—图集为 `5x5`,åŽç«¯åˆ‡å‡º 25 ä¸ªåœ°å— PNGï¼› +2. 生æˆé“¾è·¯åªè°ƒç”¨ä¸€æ¬¡åœ°æ¿è´´å›¾å›¾é›† image2,ä¸å†è°ƒç”¨è§’色生图; +3. 地æ¿è´´å›¾å›¾é›†ä¸º `1024x1536 / 3列*6行 / æ¯æ ¼4列*3行UV网`,åŽç«¯åˆ‡å‡º 18 组ã€å…± 108 å¼ é¢è´´å›¾ PNGï¼› 4. 结果页ä¸ä¾èµ–旧角色图片槽; 5. è¿è¡Œæ€ä¸ºç«–å±ä¿¯è§†è§’,首å±ä¿æŒ 3 个地å—å¯è§ï¼› -6. 拖拽方å‘和力度会影å“è½ç‚¹ï¼› +6. 长按蓄力值影å“è½ç‚¹è·ç¦»ï¼Œè·³è·ƒæ–¹å‘固定æœä¸‹ä¸€å—地å—中心; 7. 未è½åˆ°ä¸‹ä¸€ä¸ªåœ°å—ç«‹å³å¤±è´¥ï¼› 8. æˆåŠŸè·³è·ƒæ¬¡æ•°ç´¯åŠ ï¼Œå¤±è´¥åŽè®¡æ—¶å†»ç»“ï¼› 9. 排行榜按æˆåŠŸè·³è·ƒæ¬¡æ•°ä¼˜å…ˆæŽ’åºï¼› 10. 作å“å¯ä¿å­˜ã€å‘布ã€åˆ†äº«å¹¶ä»Žå…¬å¼€å…¥å£å¯åŠ¨ã€‚ -11. è¿è¡Œæ€åœ°å—必须显示 `tileAssets[]` 中的生æˆåˆ‡ç‰‡å›¾ç‰‡ï¼›æ‹–拽蓄力ã€è®¡æ—¶åˆ·æ–°å’Œè§’色ä½ç½®æ›´æ–°ä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€å¹³å°å›¾ç‰‡å±‚或 DOM 角色层。 -12. åŒç­‰è·³è·ƒè·ç¦»çš„æ‹–动è·ç¦»å¿…须比旧 `0.004` 系数缩短一åŠï¼Œæ¾æ‰‹åŽå¿…须先看到角色飞行动画,å†çœ‹åˆ°åœ°å—窗å£å‰ç§»ã€‚ +11. è¿è¡Œæ€ Three.js 地æ¿å¿…须优先把 `tileAssets[].faceAssets` å…­é¢è´´å›¾æŒ‰ right/left/top/bottom/front/back æè´¨é¡ºåºè´´åˆ°æ ‡å‡† `1x1x1` ç­‰æ¯”ç«‹æ–¹ä½“ä¸Šï¼›æ—§ä½œå“æ²¡æœ‰ `faceAssets` æ—¶æ‰ä½¿ç”¨ `tileAssets[].imageSrc` å•贴图 fallbackã€‚ç«‹æ–¹ä½“æ­£è½´å‘æ‘†æ”¾ï¼Œä¸åš Y è½´å航或 Z 轴歪斜旋转,ä¸å¾—把 x/y/z ç¼©æ”¾æˆæ‰ç›’å­ï¼›ç›¸æœºä¿æŒè¿‘è· 45° 下压视角,当å‰è„šä¸‹åœ°å—基准ä½äºŽå±å¹•中线略下方,å¯è§ä¸‰å—地æ¿ä¹‹é—´çš„å±å¹•é—´è·å¿…é¡»å紧凑;长按蓄力ã€è®¡æ—¶åˆ·æ–°å’Œè§’色ä½ç½®æ›´æ–°ä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€å¹³å°è´´å›¾é¢„加载层或 DOM 角色层。 +12. åŒç­‰ä¸–界è·ç¦»çš„蓄力æ¢ç®—必须使用 `0.004` ç³»æ•°ï¼Œæ¾æ‰‹åŽå¿…须先看到角色飞行动画,å†çœ‹åˆ°åœ°å—窗å£å‰ç§»ï¼›æˆåŠŸè½åœ°æ˜¾ç¤ºå¿…é¡»ä¿ç•™çœŸå®žè½ç‚¹å移。 diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index ac61896e..255026f0 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -73,7 +73,7 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `.env`ã€`.env.local` 或 `.env.secrets.local` 修改åŽå¿…é¡»é‡å¯ `api-server` æ‰ä¼šç”Ÿæ•ˆï¼›è‹¥å·²ç»é€šè¿‡ `npm run dev` å¯åŠ¨å®Œæ•´è”调,å¯åœ¨è¯¥ç»ˆç«¯è¾“å…¥ `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`ã€`VECTOR_ENGINE_API_KEY` å’Œ `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` åªåœ¨æœ¬åœ°æˆ–æœåŠ¡å™¨å¯†é’¥æ–‡ä»¶ä¸­é…置,ä¸èƒ½å†™å…¥ Git。VectorEngine `gpt-image-2` 图片åè®®ã€URL / base64 å“应解æžã€è¿œç«¯å›¾ç‰‡ä¸‹è½½å’Œ provider 侧结构化日志在 `server-rs/crates/platform-image`ï¼›`api-server` åªåšé…ç½®ã€çŽ©æ³•ç¼–æŽ’ã€OSS / asset æŒä¹…化ã€è®¡è´¹å’Œå¤±è´¥å®¡è®¡è½åº“。开局 CG 故事æ¿ã€é¦–图ã€èƒŒæ™¯å’Œå›¾é›†éƒ½å±žäºŽé•¿è€—时图片请求;åŽç«¯é»˜è®¤ä¼šæŠŠ `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 䏋陿”¶å£åˆ° `1000000`,旧进程ä»å¯èƒ½æ²¿ç”¨é‡å¯å‰çš„短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看åŒä¸€ `request_id` çš„ provider 日志字段 `source`ã€`source_chain`ã€`source_chain_depth`ï¼Œå†æŸ¥ `external_api_call_failure.metadata_json.errorSource`ï¼›å½“å‰ multipart `/v1/images/edits` å•独强制 HTTP/1.1。拼图关å¡èµ„产按 `level_scene -> ui_spritesheet -> level_background` 顺åºç”Ÿæˆï¼Œæ—¥å¿—会带 `slot`ã€`asset_kind` å’Œ `elapsed_ms`。 -VectorEngine å›¾ç‰‡ç”Ÿæˆ / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对åŒä¸€è¯·æ±‚最多å‘é€ 3 次;multipart å›¾ç‰‡ç¼–è¾‘æ¯æ¬¡é‡è¯•éƒ½ä¼šé‡æ–°æž„造 form,é¿å…å¤ç”¨å·²æ¶ˆè´¹çš„ body。日志中 `VectorEngine 图片请求å‘é€å¤±è´¥ï¼Œå‡†å¤‡é‡è¯•` 表示本次失败已进入下一次å°è¯•;最终ä»å¤±è´¥æ—¶æ‰ä¼šå†™å…¥ `external_api_call_failure` 并返回 504ã€‚æŽ’æŸ¥ç”Ÿäº§å¤±è´¥æ—¶åº”åŒæ—¶ç»Ÿè®¡ retry å‰çš„å°è¯•日志和最终 audit,é¿å…把一次用户请求内的多次å‘é€è¯¯åˆ¤æˆå¤šä¸ªç”¨æˆ·è¯·æ±‚。 +VectorEngine å›¾ç‰‡ç”Ÿæˆ / 编辑在 `request_send` 阶段出现 `timeout`ã€`connect`ã€libcurl 35 SSL connect resetã€libcurl 56 receive error / `unexpected eof while reading`ã€recv failure 等临时传输错误,或在 `upstream_status` 阶段收到 408 / 429 / 5xx(例如 Nginx HTML `502 Bad Gateway`)时,`platform-image` 会对åŒä¸€è¯·æ±‚最多å‘é€ 5 次;multipart å›¾ç‰‡ç¼–è¾‘æ¯æ¬¡é‡è¯•éƒ½ä¼šé‡æ–°æž„造 form,é¿å…å¤ç”¨å·²æ¶ˆè´¹çš„ body。日志中 `VectorEngine 图片请求å‘é€å¤±è´¥ï¼Œå‡†å¤‡é‡è¯•` 或 `VectorEngine 图片上游状æ€å¯é‡è¯•,准备é‡è¯•` 表示本次失败已进入下一次å°è¯•;最终ä»å¤±è´¥æ—¶æ‰ä¼šå†™å…¥ `external_api_call_failure` 并返回 504 / 502ã€‚æŽ’æŸ¥ç”Ÿäº§å¤±è´¥æ—¶åº”åŒæ—¶ç»Ÿè®¡ retry å‰çš„å°è¯•日志和最终 audit,é¿å…把一次用户请求内的多次å‘é€è¯¯åˆ¤æˆå¤šä¸ªç”¨æˆ·è¯·æ±‚。 拼图入å£ç›´åˆ›çš„ `compile_puzzle_draft` 是长耗时链路:åŽç«¯ä¼šå…ˆå¿«é€Ÿç¼–译è‰ç¨¿å¹¶è¿”回 `image_refining` / `generating` 快照,然åŽåœ¨ api-server åŽå°ä»»åŠ¡ä¸­å®Œæˆé¦–图ã€UI 资产ã€OSS æŒä¹…化ã€ä½œå“投影ã€è®¡è´¹é€€æ¬¾å’Œå¤±è´¥æ€å›žå†™ã€‚生产排查å°ç¨‹åº `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`ã€`upstream_status=-`,说明客户端或 WebView 先断开;此时ä¸åº”å†æŠŠé•¿ POST 是å¦è¿”å›žä½œä¸ºç”Ÿæˆæˆè´¥ä¾æ®ï¼Œè€Œåº”继续按实际 `session_id` 查åŽå°ä»»åŠ¡æ—¥å¿—ã€VectorEngine provider 日志ã€`external_api_call_failure` å’ŒåŽç»­ GET 轮询结果。åŒä¸€ç”¨æˆ·å¯èƒ½å…ˆè½®è¯¢æ—§çš„ `puzzle-session-*`,éšåŽ POST æ–°å»ºå®žé™…ç”Ÿæˆ session;必须用 action POST çš„ `request_id` å’Œ `/api/runtime/puzzle/agent/sessions//actions` 路径对é½çœŸå®žå¤±è´¥è¯·æ±‚,é¿å…被å‰ç«¯æ˜¾ç¤ºçš„â€œæ¥æºè‰ç¨¿â€è¯¯å¯¼ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 31ea9b3d..76e75013 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -170,21 +170,25 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ— 1. 创作端åªä¿ç•™ä¸»é¢˜è¾“å…¥ï¼Œä½œå“æ ‡é¢˜ã€ç®€ä»‹ã€æ ‡ç­¾å’Œåœ°å—æç¤ºè¯ç”±ç³»ç»Ÿæ´¾ç”Ÿï¼› 2. v1 ä¸å†å•独生æˆè§’色图片,è¿è¡Œæ€å›ºå®šä½¿ç”¨æŠ é™¤ç™½åº•åŽçš„陶泥儿 logo 逿˜Ž PNG 作为玩家角色; -3. 地å—åªè°ƒç”¨ä¸€æ¬¡ image2,输出一张 `5行*5列`ã€`1:1`ã€å•一纯洋红 `#FF00FF` key 背景的主题地å—图集;跳一跳地å—常包å«è‰åœ°ã€èбã€é›ªã€ç™½çŸ³å’Œäº‘朵,åŽç«¯é€æ˜ŽåŒ–必须使用跳一跳专用洋红 key,ä¸å¯ç”¨è¿‘ç™½åº•æ‰£é™¤ï¼Œä¹Ÿä¸æ¸…ç†éžè¾¹ç¼˜è¿žé€šçš„ key 色åƒç´ ï¼Œé¿å…把绿色或白色主体误扣;åŽå¤„ç†å¿…须对边缘连通 key 色åšå®¹å·®æ¸…ç†ã€åŽ»å½©è¾¹ defringe 和底部残影清ç†ï¼Œä¸»ä½“图ä¸å¾—自带洋红阴影ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€å½©è‰²å…‰æ™•或å‘光底边,è¿è¡Œæ€é˜´å½±ç»Ÿä¸€ç”± DOM 绘制;地å—造型æç¤ºè¯è¦æ±‚以主题物体本身外轮廓为准,å…许苹果近似圆形ã€é¦™è•‰è¿‘ä¼¼é•¿æ¡æˆ–长方形ã€è¥¿ç“œè¿‘似扇形等自然差异,åªç»Ÿä¸€å•格规格ã€å®‰å…¨ç•™ç™½ã€æ­£é¢30度视角和 2D/2.5D 手绘风格包装;所有地å—ç´ æå¿…é¡»ä¿æŒç»Ÿä¸€æ­£é¢30度视角,相机ä½äºŽç‰©ä½“æ­£å‰æ–¹ç•¥é«˜ä½ç½®ã€é•œå¤´å‘下约30度,必须看到清晰正é¢ã€ä¾§å£ã€ä¸‹æ²¿ã€æ˜Žæ˜¾è‡ªèº«åŽšåº¦å’Œå°‘é‡ä¸Šè¡¨é¢ï¼Œä¸»ä½“æ­£é¢æˆ–ä¾§å£å¯è§é¢ç§¯å¿…须接近或大于顶é¢é¢ç§¯ï¼Œé¡¶é¢åªèƒ½ä½œä¸ºè¾…助å¯è§é¢ï¼›æ°´æžœä¸»é¢˜éœ€è¦æ˜Žç¡®è¦æ±‚橙瓣看到橙皮正é¢å¤–ä¾§å’Œæžœè‚‰åŽšåº¦ã€æ¤°å­çœ‹åˆ°å£³çš„æ­£é¢ä¾§å£å’Œåˆ‡å£åŽšåº¦ã€æµ†æžœä¸èƒ½åªæ˜¯ä»Žä¸Šå¾€ä¸‹çœ‹çš„圆形çƒé¡¶ï¼›é¿å…生æˆçº¯ä¿¯è§†ã€æ­£ä¸Šæ–¹ä¿¯æ‹ã€é¸Ÿçž°åœ°å›¾å—ã€å¹³é“ºä¿¯æ‹ã€åœ†å½¢é¡¶è§†å›¾æˆ–æ‰å¹³å›¾æ ‡ï¼›ä¸»é¢˜ç‰©ä½“本身必须是唯一å¯è½è„šä½“,åªèƒ½ç”¨è‡ªèº«åˆ‡é¢ã€è¾¹ç¼˜åŽšåº¦ã€èŠ±ç“£å±‚æˆ–æžœçš®è¾¹è¡¨çŽ°æ‰¿é‡ï¼Œç¦æ­¢åœ¨ä¸»é¢˜ç‰©ä½“下方é¢å¤–垫石å°ã€åœŸå¢©ã€æœ¨æ¿ã€åœ†å°ã€æ‰˜ç›˜ã€å²›å±¿åº•座或通用地æ¿ï¼›å‰ç«¯å’ŒåŽç«¯é»˜è®¤ `tilePrompt` 都必须使用“正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹â€çš„å£å¾„,ä¸å†æäº¤â€œå¹³å°ç´ æ / è·³å° / åœ°å— / 地砖â€ç­‰ä¼šæŠŠæ¨¡åž‹æ‹‰å›žé€šç”¨å¹³å°é€ åž‹çš„è¯ï¼ŒåŽç«¯ç”Ÿæˆå‰ä¹Ÿä¼šæ¸…æ´—æ—§è‰ç¨¿é—留的这些è¯ï¼›å½“ä¸»é¢˜æˆ–åœ°å—æç¤ºè¯å‘½ä¸­å®å¯æ¢¦ / 神奇å®è´ / å£è¢‹å¦–怪 / Pokemon / Pikachu / ç²¾çµçƒç­‰å®å¯æ¢¦ç›¸å…³è¯æ—¶ï¼Œä»…生图请求侧改写为“原创幻想èŒå® å†’险é“å…· / 彩色冒险能é‡çƒ / 黄色闪电èŒå® ç¬¦å·â€ï¼Œç”¨æˆ·è‰ç¨¿æ ‡é¢˜å’Œä¸»é¢˜å±•ç¤ºä¸æ”¹ï¼› +3. 地æ¿è´´å›¾åªè°ƒç”¨ä¸€æ¬¡ image2,输出一张 `1024x1536` 竖版ã€`3列*6行`ã€å•一纯洋红 `#FF00FF` key å®‰å…¨ç¼ / 外圈背景的立方体主题物体 UV 展开图集;image2 è¦ç”Ÿæˆ 18 个完整 `1x1x1` 立方体主题物体包装,æ¯ä¸ªå¤§å•元格内部固定为 `4列*3行` UV 网:第 1 行第 2 列为 `top`,第 2 è¡Œä¾æ¬¡ä¸º `left / front / right / back`,第 3 行第 2 列为 `bottom`,其它 UV 空ä½ä¿æŒçº¯æ´‹çº¢ã€‚æ¯ä¸ªå¤§å•元格的六个é¢å¿…须属于åŒä¸€ä¸ªæ–¹å—化主题物体,top/front/right/back/left/bottom 之间的果皮ã€åˆ‡é¢ã€ç±½ç‚¹ã€æ¡çº¹ã€æžœæŸ„ã€å¶ç‰‡ç­‰èº«ä»½ç‰¹å¾è¦è¿žç»­ä¸€è‡´ï¼Œä¸èƒ½æŠŠåŒä¸€å¼ çº¹ç†é‡å¤å…­æ¬¡ï¼Œä¹Ÿä¸èƒ½å…­é¢å„画互ä¸ç›¸å…³çš„å°å›¾æ ‡ã€‚æ°´æžœä¸»é¢˜åº”ç”Ÿæˆ 18 ç§å¯ä¸€çœ¼è¾¨è®¤çš„æ–¹å—æ°´æžœ UV,例如方å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—è¥¿ç“œã€æ–¹å—è‰èŽ“ã€æ–¹å—è‘¡è„ã€æ–¹å—å¥‡å¼‚æžœã€æ–¹å—è èã€æ–¹å—æŸ æª¬ã€æ–¹å—桃å­ã€æ–¹å—æ¢¨ã€æ–¹å—è“èŽ“ã€æ–¹å—èŠ’æžœã€æ–¹å—椰å­ã€æ–¹å—ç«é¾™æžœã€æ–¹å—æ¨±æ¡ƒã€æ–¹å—å“ˆå¯†ç“œã€æ–¹å—çŸ³æ¦´ï¼›è‹¹æžœéœ€è¦æžœæŸ„å¶ç‰‡è·¨ top/front,香蕉需è¦å‰¥çš®æ¡å¸¦è·¨ front/right,橙å­éœ€è¦æ”¾å°„切é¢è·¨ top/front,西瓜需è¦çº¢ç“¤é»‘籽和绿皮æ¡çº¹åœ¨å„é¢è¿žç»­ã€‚ç¦æ­¢æ–‡å­—ã€UIã€åº•åº§ã€æ‰˜ç›˜ã€åœ†å°ã€åœ°æ¿åž«å±‚ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ´‹çº¢æè¾¹ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€å½©è‰²å…‰æ™•ã€å‘光边ã€é€æ˜ŽèƒŒæ™¯ã€ç•™ç™½ã€è‡ªç„¶åœ†å½¢æ°´æžœã€è‡ªç„¶é•¿æ¡é¦™è•‰ã€å­¤ç«‹æ°´æžœç…§ç‰‡ã€å°åž‹è´´çº¸ã€çº¯æžœçš®æè´¨ã€çº¯æžœè‚‰çº¹ç†ã€çº¯å¶è„‰çº¹ç†å’Œæ— æ³•分辨具体物体的抽象纹ç†ï¼›çœŸå®žé€è§†ã€æžå°å€’è§’ã€ä¾§å£åŽšåº¦å’Œé˜´å½±ç»Ÿä¸€ç”±è¿è¡Œæ€ Three.js 标准 `1x1x1` 等比立方体生æˆã€‚åŽç«¯åªæŠŠæ´‹çº¢ key 作为图集安全边界处ç†ï¼Œå…ˆæŒ‰ 3x6 大å•元格切出 18 个方å—ï¼Œå†æŒ‰æ¯æ ¼ 4x3 UV 网切出 108 å¼  `256x256` ä¸é€æ˜Žé¢è´´å›¾ï¼Œä¸å†è¿è¡Œé€æ˜ŽåŒ–æŠ å›¾ã€æœ€å¤§ alpha 连通主体ä¿ç•™æˆ–逿˜Žå®‰å…¨è¾¹è¡¥ç™½ï¼›è‹¥è£åˆ‡åŽä»æ®‹ç•™æžå°‘洋红 key 色,会转æˆä¸é€æ˜Žæè´¨åº•色。å‰ç«¯å’ŒåŽç«¯é»˜è®¤ `tilePrompt` 都必须使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlasâ€çš„å£å¾„,ä¸å†æäº¤â€œæ­£é¢30度主题物体 / å¹³å°ç´ æ / è·³å° / åœ°å—æˆå“ / 地砖 / æè´¨è´´ç‰‡ / 平铺纹ç†â€ç­‰ä¼šæŠŠæ¨¡åž‹æ‹‰å›ž 2D 地å—ã€å¹³å°æˆ–å•纯æè´¨çš„è¯ï¼ŒåŽç«¯ç”Ÿæˆå‰ä¹Ÿä¼šæ¸…æ´—æ—§è‰ç¨¿é—留的这些è¯ï¼›å½“ä¸»é¢˜æˆ–åœ°å—æç¤ºè¯å‘½ä¸­å®å¯æ¢¦ / 神奇å®è´ / å£è¢‹å¦–怪 / Pokemon / Pikachu / ç²¾çµçƒç­‰å®å¯æ¢¦ç›¸å…³è¯æ—¶ï¼Œä»…生图请求侧改写为“原创幻想èŒå® å†’险é“å…· / 彩色冒险能é‡çƒ / 黄色闪电èŒå® ç¬¦å·â€ï¼Œç”¨æˆ·è‰ç¨¿æ ‡é¢˜å’Œä¸»é¢˜å±•ç¤ºä¸æ”¹ï¼› 4. èƒŒæ™¯åº•å›¾åŒæ ·ç”± image2 生æˆï¼Œå¤ç”¨çŽ°æœ‰ `coverComposite` / `coverImageSrc` 作为è¿è¡Œæ€èƒŒæ™¯è¯»å†™å­—段,OSS æ§½ä½å›ºå®šä¸º `background/image.png`ï¼›æç¤ºè¯å¿…须严格以用户主题关键è¯ä¸ºèƒŒæ™¯ä¸»é¢˜ï¼Œç»“构以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼Œä¸­å¤®çºµè½´ 1/2 åŒºåŸŸä¿æŒå°‘元素ã€ç®€æ´ã€å¯è¯»ä¸”有纵深感,两侧å…许更强立体层次和行进感;背景åªä½œä¸ºåº•å›¾ï¼Œç¦æ­¢ç”Ÿæˆè·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIã€è¿”å›žæŒ‰é’®ã€æ–‡å­—ã€è·¯å¾„箭头或海报排版;左上角返回按钮ä¸å…许画进背景,而是å•ç‹¬ç”Ÿæˆ `backButtonAsset` 逿˜Ž PNG,OSS æ§½ä½å›ºå®šä¸º `back-button/image.png`,æç¤ºè¯è¦æ±‚标准圆形ã€ä¸»é¢˜è‰²æè´¨åŒ…装ã€å±…中左箭头ã€çº¯ç»¿è‰² key 背景,åŽç«¯åŽ»ç»¿åŽå†™å…¥ä½œå“ profileï¼› -5. åŽç«¯æŒ‰ä»Žä¸Šåˆ°ä¸‹ã€ä»Žå·¦åˆ°å³å‡åŒ€åˆ‡åˆ†ä¸º `tile-01` 到 `tile-25` çš„é€æ˜Ž PNG,æ¯ä¸ªåˆ‡ç‰‡å¿…须使用唯一 slot/path æŒä¹…化,ä¸èƒ½æŒ‰é‡å¤çš„ `tileType` å¤ç”¨æ§½ä½ï¼› -6. 结果页åªå±•示陶泥儿 logo 逿˜Žè§’色预览ã€åœ°å—æ± é¢„è§ˆå’Œé¦–å± 3 地å—预览;ä¸å†æä¾›æ—§è§’è‰²å›¾ç”Ÿæˆæ§½ï¼› -7. å‰ç«¯è·³ä¸€è·³åˆ›ä½œ client 的创建会è¯ä¸Žæ‰§è¡Œç”ŸæˆåŠ¨ä½œè¯·æ±‚éƒ½å¿…é¡»ä½¿ç”¨ 20 分钟等待窗å£ï¼Œé¿å…背景底图ã€åœ°å—图集ã€åˆ‡ç‰‡ã€æŠ å›¾å’Œ OSS 写入ä»åœ¨åŽç«¯æ‰§è¡Œæ—¶è¢«å…±åˆ›ä¼šè¯é»˜è®¤ 15 秒超时中断。 +5. åŽç«¯æŒ‰ä»Žä¸Šåˆ°ä¸‹ã€ä»Žå·¦åˆ°å³å‡åŒ€åˆ‡åˆ†ä¸º `tile-01` 到 `tile-18`,æ¯ä¸ªæ–¹å—冿Œä¹…化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path,ä¸èƒ½æŒ‰é‡å¤çš„ `tileType` å¤ç”¨æ§½ä½ï¼›`tileAssets[].faceAssets` ä¿å­˜å…­é¢è´´å›¾ï¼Œåކå²å…¼å®¹å­—段 `imageSrc/imageObjectKey/assetObjectId` 写 top é¢ä½œä¸ºæ—§å•贴图 fallback,è¿è¡Œæ€å¯¹æ—§ä½œå“没有 `faceAssets` æ—¶ä»å¯æŠŠå•张贴图应用到立方体所有é¢ï¼› +6. 结果页åªå±•示陶泥儿 logo 逿˜Žè§’色预览ã€åœ°å—æ± é¢„è§ˆå’Œé¦–å± 3 地å—预览;ä¸å†æä¾›æ—§è§’è‰²å›¾ç”Ÿæˆæ§½ï¼›ç§»åŠ¨ç«¯ç»“æžœé¡µå¿…é¡»ç”±ç»“æžœé¡µæ ¹å®¹å™¨æ‰¿æŽ¥çºµå‘æ»šåЍ并ä¿ç•™åº•部安全区,确ä¿ç´ æé¢„览较长时ä»èƒ½ä¸‹æ»‘到返回编辑ã€è¯•玩和å‘布按钮; +7. å‰ç«¯è·³ä¸€è·³åˆ›ä½œ client 的创建会è¯ä¸Žæ‰§è¡Œç”ŸæˆåŠ¨ä½œè¯·æ±‚éƒ½å¿…é¡»ä½¿ç”¨ 20 分钟等待窗å£ï¼Œé¿å…背景底图ã€è¿”回按钮去绿ã€åœ°æ¿è´´å›¾å›¾é›†åˆ‡ç‰‡å’Œ OSS 写入ä»åœ¨åŽç«¯æ‰§è¡Œæ—¶è¢«å…±åˆ›ä¼šè¯é»˜è®¤ 15 秒超时中断。 -è¿è¡Œæ€è§„则真相必须沉到 `module-jump-hop`,å‰ç«¯åªåšæ‹–æ‹½è“„åŠ›ã€è§’色ä½ç§»ã€æŠ•影和è½åœ°åé¦ˆã€‚å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ¸¸æˆæ—¶é•¿å†»ç»“ã€è¿è¡Œæ€å¿«ç…§å’Œå‘布作å“状æ€ä»¥åŽç«¯ä¸ºå‡†ã€‚v1 ä¸ä¿ç•™å…¬å¼€ combo / perfect / 通关语义,旧 `score` 兼容映射为æˆåŠŸè·³è·ƒæ¬¡æ•°ã€‚å…¬å¼€åˆ—è¡¨åº”èµ° `jump_hop_gallery_card_view` 订阅缓存,ä¸è¦æ¯æ¬¡ HTTP 请求调用 procedure 组装全é‡åˆ—表。 +待解决问题(风险程度:高):跳一跳创作链路目å‰ä»æ˜¯ä¸€æ¬¡ HTTP 请求内串行生æˆèƒŒæ™¯åº•图ã€è¿”回按钮ã€åœ°æ¿è´´å›¾å›¾é›†ã€åˆ‡ç‰‡å’Œ OSS 写入;VectorEngine image2 啿­¥ timeout/connect 失败会在åŽç«¯æœ€å¤šé‡è¯• 5 次,而å‰ç«¯åªæœ‰ 20 分钟总等待窗å£ã€‚è‹¥æŸæ¬¡èƒŒæ™¯åº•å›¾ç”ŸæˆæŽ¥è¿‘æˆ–è¶…è¿‡ 18 分钟,å‰ç«¯ä¼šå…ˆæŠ¥â€œè¯·æ±‚超时,请ç¨åŽé‡è¯•â€ï¼Œä½†åŽç«¯å¯èƒ½ç»§ç»­è·‘完并在数分钟åŽå†™å…¥è‰ç¨¿ï¼›åŒæ—¶å› ä¸ºèƒŒæ™¯ã€è¿”回按钮和图集等中间资产未按阶段è½åº“,åŒä¸€ session è¶…æ—¶åŽé‡è¯•ä¼šé‡æ–°ä»ŽèƒŒæ™¯å›¾å¼€å§‹ç”Ÿæˆï¼Œå­˜åœ¨é‡å¤ç”Ÿå›¾ã€é‡å¤è®¡è´¹ã€ç”¨æˆ·è¯¯ä»¥ä¸ºå¤±è´¥ã€ä½œå“架状æ€çŸ­æ—¶é—´ä¸ä¸€è‡´çš„风险。åŽç»­åº”å°†è·³ä¸€è·³ç”Ÿæˆæ”¹ä¸ºåŽç«¯ä»»åŠ¡åŒ– / å¯è½®è¯¢çœŸå®žé˜¶æ®µè¿›åº¦ï¼Œå¹¶åœ¨æ¯ä¸ªç´ æé˜¶æ®µæˆåŠŸåŽå†™å…¥å¯æ¢å¤çжæ€ï¼›åŒæ—¶æ”¶å£åŽç«¯å…¨å±€ç”Ÿæˆ deadlineã€å‰ç«¯ç­‰å¾…策略和失败æ€å›žå†™ï¼Œç¡®ä¿è¶…æ—¶ã€é‡è¯•和最终æˆåŠŸä¸ä¼šäº’相打架。 + +生æˆé¡µâ€œå½“å‰è·³ä¸€è·³ä¿¡æ¯â€åªå±•示实际å‚与创作æç¤ºè¯çš„主题ã€åœ°å—æç¤ºè¯ç­‰ç”¨æˆ·å¯ç†è§£ä¿¡æ¯ï¼›`stylePreset` 等未å‚ä¸Žå½“å‰ image2 æç¤ºè¯ç»„装的内部风格枚举ä¸å¾—作为兜底内容展示,é¿å…把 `minimal-blocks`ã€`paper-toy` 等工程值暴露给创作者。 + +è¿è¡Œæ€è§„则真相必须沉到 `module-jump-hop`,å‰ç«¯åªåšé•¿æŒ‰è“„力ã€è§’色ä½ç§»ã€æŠ•影和è½åœ°åé¦ˆã€‚å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ¸¸æˆæ—¶é•¿å†»ç»“ã€è¿è¡Œæ€å¿«ç…§å’Œå‘布作å“状æ€ä»¥åŽç«¯ä¸ºå‡†ã€‚v1 ä¸ä¿ç•™å…¬å¼€ combo / perfect / 通关语义,旧 `score` 兼容映射为æˆåŠŸè·³è·ƒæ¬¡æ•°ã€‚å…¬å¼€åˆ—è¡¨åº”èµ° `jump_hop_gallery_card_view` 订阅缓存,ä¸è¦æ¯æ¬¡ HTTP 请求调用 procedure 组装全é‡åˆ—表。 æ¯å±åªå±•示 3 个地å—:当å‰åœ°å—ã€ç›®æ ‡åœ°å—和下一预览地å—ã€‚å¹³å°æµæŒ‰åŒä¸€ seed æ— é™ç”Ÿæˆï¼Œå‰ç«¯ä¸å¾—è‡ªè¡Œç”Ÿæˆæ­£å¼è·¯å¾„。è¿è¡Œæ€ HUD 顶部åªä¿ç•™è¿”回按钮和æˆåŠŸè·³è·ƒæ¬¡æ•°ï¼Œä¸å±•示计时器或å³ä¸Šè§’é‡å¼€æŒ‰é’®ï¼›ç”ŸæˆèƒŒæ™¯å’Œæ¸¸æˆèˆžå°å¿…须覆盖整个è¿è¡Œæ€è§†å£ï¼ŒHUD 直接ç»å¯¹å®šä½åŽ‹åœ¨èƒŒæ™¯ä¸Šï¼Œä¸å†ç”¨å¤–层白底ã€å±…中窄æ ã€å¡ç‰‡è¾¹æ¡†æˆ–游æˆåŒºåŸŸåœ†è§’è£åˆ‡èƒŒæ™¯ã€‚返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`ã€æ¡Œé¢çº¦ `62px`ï¼Œä¸æ˜¾ç¤ºâ€œè¿”å›žâ€æ–‡å­—ï¼Œå¹¶é€šè¿‡é¡¶éƒ¨é”šç‚¹å¾®è°ƒä¸Žå¾—åˆ†æ ‡é¢˜ç‰Œä¿æŒå调;è¿è¡Œæ€ä¼˜å…ˆä½¿ç”¨ç‹¬ç«‹ `backButtonAsset` 逿˜Ž PNG 作为真实å¯ç‚¹å‡»æŒ‰é’®å›¾ï¼Œæ—§ä½œå“缺失该字段时æ‰ä½¿ç”¨åŒå°ºå¯¸ CSS 主题色圆形按钮兜底。上方æˆåŠŸè·³è·ƒæ¬¡æ•° UI å¤ç”¨æ‹¼å›¾æ¨¡æ¿é¡¶éƒ¨ HUD 结构:`puzzle-runtime-header-card` 内包å«é™¶æ³¥å„¿ IP logoã€å±…ä¸­çš„â€œå¾—åˆ†â€æ ‡é¢˜ç‰Œï¼Œä»¥åŠä¸‹æŒ‚ `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字å¡ï¼›æ•°å­—å¡å±•示æˆåŠŸè·³è·ƒæ¬¡æ•°è€Œä¸æ˜¯å€’è®¡æ—¶ã€‚æ¸¸çŽ©ä¸­ä¸æ˜¾ç¤ºå·¦ä¸‹è§’“进行中â€çжæ€ï¼Œä¹Ÿä¸åœ¨å±å¹•底部常驻排行榜。排行榜按作å“维度展示玩家 IDã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼›æ¯ä½çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºå›ºå®šä¸º `æˆåŠŸè·³è·ƒæ¬¡æ•° desc -> æ¸¸æˆæ—¶é•¿ asc -> æ›´æ–°æ—¶é—´ asc`,并åªåœ¨å¤±è´¥ç»“算弹窗内展示,弹窗ä¿ç•™é‡å¼€å’Œè¿”回动作。 -è¿è¡Œæ€æ¸²æŸ“分层固定为:舞å°åº•层 `.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 状æ€ï¼Œä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€èƒŒæ™¯æˆ–å¹³å°å›¾ç‰‡å±‚,å¦åˆ™ä¼šé€ æˆèƒŒæ™¯ã€åœ°å—和角色层频闪。 +è¿è¡Œæ€æ¸²æŸ“分层固定为:舞å°åº•层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读å–ç»§ç»­èµ°å¹³å°èµ„产æ¢ç­¾ï¼Œæ²¡æœ‰èƒŒæ™¯æ—¶æ‰å›žé€€åˆ°å†…ç½®æ¸å˜ï¼›Three.js å¹³å°å±‚å¤ç”¨åŒä¸€ä»½æ ‡å‡† `1x1x1` 等比æžå°å€’è§’ç«‹æ–¹ä½“å‡ ä½•ä½“ï¼ŒåªæŒ‰å•一 side ç­‰æ¯”ç¼©æ”¾å½“å‰ / 目标 / 预览地å—,并把 `tileAssets[]` 的生æˆåˆ‡ç‰‡ä½œä¸ºä¸»é¢˜èº«ä»½æ–¹å—包装贴图加载到立方体表é¢ï¼›å•å—地æ¿ä¿æŒæ­£è½´å‘摆放,ä¸åš Y è½´å航或 Z 轴歪斜旋转;è¿è¡Œæ€é‡‡ç”¨çº¦ `1.3x` è¿‘è·ç›¸æœºã€45° 下压视角和更紧凑的å¯è§åœ°æ¿é—´è·ï¼Œå½“å‰è„šä¸‹åœ°å—基准ä½äºŽå±å¹•中线略下方,目标和预览地å—å‘上展开,侧å£ã€å€’è§’ã€é€è§†å’Œè½¯æ¤­åœ†é˜´å½±å‡ç”± Three.js 统一表现;Three.js 相机和 DOM è§’è‰²å±‚å¿…é¡»ä¿æŒå±å¹• X è½´åŒå‘,ä¸å¾—通过åå‘ `camera.up` æˆ–é•œåƒ wrapper 把平å°å±‚å·¦å³ç¿»è½¬ï¼Œå¦åˆ™ä¼šå‡ºçŽ°åœ°å—æ˜¾ç¤ºåœ¨å³ä¾§ä½†è“„力与飞行动画æœå·¦ä¾§çš„åå‘错觉;DOM 地å—图片层åªä½œä¸ºèµ„产æ¢ç­¾ã€é¢„加载ã€WebGL ä¸å¯ç”¨å’Œæµ‹è¯•环境 fallback,Three.js å¹³å°å±‚ ready åŽå¿…é¡»éšè— DOM 地å—图片和 DOM 阴影,é¿å…éœ²å‡ºæ—§åŽŸåž‹æ–¹å—æˆ–åŒå±‚闪现;推进期存在旧地å—退出ä¿ç•™æ—¶ï¼ŒThree å¹³å°å±‚必须继续承接 3D åœ°å—æ¸²æŸ“,旧地å—åªè·ŸéšåŽç»­ç›¸æœºæŽ¨è¿›é€æ­¥ç¦»å±ï¼Œä¸æ’­æ”¾ç‹¬ç«‹é£žèµ°åŠ¨ç”»ï¼Œè¶…è¿‡å±å¹•åŽè‡ªç„¶é”€æ¯ï¼›å›¾ç‰‡è¯»å–ç»§ç»­èµ°å¹³å°èµ„产æ¢ç­¾ï¼Œå¹¶ä»¥ `assetObjectId` 作为刷新键é¿å…é‡ç”ŸæˆåŽæ²¿ç”¨æ—§ç­¾å或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 逿˜Ž PNG å¹¶ä¿æŒåœ¨ Three.js å¹³å°å±‚之上。长按蓄力ã€è®¡æ—¶åˆ·æ–°å’Œè§’色ä½ç½®å˜åŒ–åªèƒ½æ›´æ–° refs 或 DOM 状æ€ï¼Œä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€èƒŒæ™¯ã€å¹³å°è´´å›¾é¢„加载层或 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。相机推进期间角色自身必须ç¦ç”¨ `left/top` transition,åªå…许父级 camera layer è´Ÿè´£ä½ç§»ï¼Œå¦åˆ™è§’è‰²å±€éƒ¨åæ ‡åˆ‡æ¢å’Œç›¸æœºæŽ¨è¿›ä¼šå åŠ ï¼Œè¡¨çŽ°ä¸ºè½åœ°åŽåˆä»Žå±å¹•外闪回。 +跳一跳当å‰é•¿æŒ‰è“„力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长æ¢ç®—æˆä¸–界跳跃è·ç¦»ï¼›å¦‚果历å²è·¯å¾„ä»ä¿å­˜å…¶å®ƒç³»æ•°ï¼Œ`start_run` 会在开局归一化到新系数。用户按ä½ç”»é¢å¼€å§‹è“„åŠ›ï¼Œæ¾æ‰‹ç«‹å³èµ·è·³ï¼›è·³è·ƒæœå‘永远由当å‰åœ°å—中心指å‘下一å—地å—中心,å‰ç«¯ä¸å†æäº¤æ‹–拽方å‘,åŽç«¯å³ä½¿æ”¶åˆ°æ—§å®¢æˆ·ç«¯çš„ `dragVectorX/dragVectorY` 也必须忽略。实际è½ç‚¹åªç”±è“„力时长æ¢ç®—出的跳跃è·ç¦»å†³å®šï¼ŒæˆåŠŸåˆ¤å®šä½¿ç”¨ä¸‹ä¸€å—地å—å¯è§é¡¶é¢ footprint:åŽç«¯ä»¥è¯¥åœ°å— `width/height` 的收缩矩形模拟 45° 视角下的å¯è§é¡¶é¢ï¼Œå½“å‰å‘½ä¸­åŒºçº¦ä¸ºå®½åº¦ 72% 和高度 52%,è½ç‚¹è¿›å…¥è¯¥è§†è§‰é¡¶é¢åˆ™æˆåŠŸï¼Œæœªè¿›å…¥åˆ™å¤±è´¥ï¼›æ—§ `landingRadius/perfectRadius` åªä¿ç•™å…¼å®¹è¯»å†™ï¼Œä¸å†ä½œä¸ºå½“å‰å‘½ä¸­çœŸç›¸ã€‚蓄力中角色åªåšåž‚ç›´åŽ‹ç¼©ï¼Œä¸æ²¿ç›®æ ‡æ–¹å‘拉伸;蓄力åé¦ˆå¯æ˜¾ç¤ºæœå‘下一å—中心的轻é‡å¼•å¯¼ï¼Œä½†ä¸æ˜¾ç¤ºè½ç‚¹è¾…åŠ©ç‚¹ã€æŠ•å½±åœˆæˆ–å…¶å®ƒå‘½ä¸­æç¤ºã€‚æ¾æ‰‹åŽè¿è¡Œæ€å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测真实è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` çš„è§’è‰²é£žè¡ŒåŠ¨ç”»ï¼šè§†è§‰é¢„æµ‹å¿…é¡»ä½¿ç”¨å½“å‰æ˜¾ç¤ºçª—å£çš„ current/next 地å—ä½œä¸ºæ–¹å‘æ¥æºï¼Œå³ä½¿åŽç«¯æœ€æ–° run å·²æå‰è¿”回,也ä¸èƒ½æ‹¿æ–° run ç›®æ ‡é…æ—§çª—å£è§’色导致下一跳åå‘;角色沿当å‰åœ°å—中心到下一å—地å—中心方å‘å¼¹å‘预测真实è½ç‚¹ï¼ŒæˆåŠŸä¹Ÿä¸å¾—强制å¸é™„回目标地å—中心。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测真实è½ç‚¹ç­‰å¾…;新 run 到达åŽåº”优先用 `lastJump.landedX/landedY` 映射出的真实è½ç‚¹æ˜¾ç¤ºè§’è‰²ï¼Œå†æŠŠæ˜¾ç¤ºæ€åˆ‡åˆ°åŽç«¯æœ€æ–° 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 失败ã€åˆ·æ–°å›žé¦–页。 +å¹³å°é¦–页推èã€ç²¾é€‰ã€æœ€æ–°ã€å…¬å¼€è¯¦æƒ…ã€æœç´¢ã€å·²çŽ©ä½œå“和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作å“å·è¯†åˆ«è·³ä¸€è·³ä½œå“ï¼›ä»Žå…¬å¼€è¯¦æƒ…æˆ–æŽ¨èæµå¯åЍè¿è¡Œæ€æ—¶ï¼Œè‹¥å¡ç‰‡æ‘˜è¦ä¸è¶³ä»¥æºå¸¦åœ°æ¿è´´å›¾å›¾é›†å’Œè·¯å¾„é…置,必须先补读完整 work profile å†ä¼ å…¥è¿è¡Œæ€ã€‚`/runtime/jump-hop?work=JH-*` è¿™ç±»æ­£å¼æ·±é“¾å¿…须先通过公开作å“å·å›žè¯» gallery detail,å†ä»¥ profileId å¯åЍ published run;直接打开没有 `work` 傿•°çš„ `/runtime/jump-hop` æ—¶ä¸èƒ½åœç•™åœ¨ç©ºè¿è¡Œæ€æˆ–“正在加载内容â€ï¼Œåº”回到平å°é¦–页。平å°å£³å±‚å¿…é¡»åŒæ­¥æ³¨å†Œ `jump-hop-workspace`ã€`jump-hop-generating`ã€`jump-hop-result`ã€`jump-hop-runtime`ã€`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop`ã€`/creation/jump-hop/generating`ã€`/creation/jump-hop/result`ã€`/gallery/jump-hop/detail`ã€`/runtime/jump-hop`ï¼ŒåŒæ—¶æŒæœ‰ sessionã€workã€runã€galleryã€busy/error 与生æˆè¿›åº¦çжæ€ï¼Œé¿å…åªåˆå…¥æ¸²æŸ“åˆ†æ”¯ä½†é—æ¼çŠ¶æ€æºæˆ–分享路径导致 typecheck 失败ã€åˆ·æ–°å›žé¦–页。 è·³ä¸€è·³ä½œå“æž¶èµ°åˆ›ä½œä¸­å¿ƒçš„统一作å“列表:å‰ç«¯é€šè¿‡ `/api/creation/jump-hop/works` 拉å–ä½œå“æ‘˜è¦ï¼Œè‰ç¨¿æ€ä¼šä¸Ž pending notice åˆå¹¶åŽæ˜¾ç¤ºåœ¨ä½œå“架里,已å‘布作å“点击åŽä¼šå…ˆæŒ‰ profileId 读å–完整详情å†è¿›å…¥è¯¦æƒ…或è¿è¡Œæ€ã€‚生æˆä¸­ä½œå“ä»ä»¥åŽç«¯æ‘˜è¦é‡Œçš„ `generationStatus` 为准,刷新åŽåº”能æ¢å¤ç­‰å¾…é®ç½©ï¼Œä¸èƒ½åªä¾èµ–内存 notice。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 8b2621e1..9379baa0 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -96,6 +96,37 @@ export interface JumpHopTileAsset { visualHeight: number; topSurfaceRadius: number; landingRadius: number; + faceAssets?: JumpHopTileFaceAssets | null; +} + +export type JumpHopTileFaceKey = + | 'top' + | 'front' + | 'right' + | 'back' + | 'left' + | 'bottom'; + +export interface JumpHopTileFaceAsset { + face: JumpHopTileFaceKey; + assetId: string; + imageSrc: string; + imageObjectKey: string; + assetObjectId: string; + generationProvider: string; + prompt: string; + width: number; + height: number; + sourceAtlasCell: string; +} + +export interface JumpHopTileFaceAssets { + top: JumpHopTileFaceAsset; + front: JumpHopTileFaceAsset; + right: JumpHopTileFaceAsset; + back: JumpHopTileFaceAsset; + left: JumpHopTileFaceAsset; + bottom: JumpHopTileFaceAsset; } export interface JumpHopScoring { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index f76aa730..015a510e 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -15,23 +15,20 @@ use shared_contracts::jump_hop::{ JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, - JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, - JumpHopWorkMutationResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, + JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileFaceAsset, JumpHopTileFaceAssets, + JumpHopTileFaceKey, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ - collections::{BTreeMap, VecDeque}, + collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, }; use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, - generated_asset_sheets::{ - GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options, - crop_generated_asset_sheet_view_edge_matte_with_options, - }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, @@ -51,7 +48,7 @@ use crate::{ work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; -const JUMP_HOP_TILE_ITEM_COUNT: usize = 25; +const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -59,9 +56,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; +const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; +const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; +const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; +const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; 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; @@ -73,9 +76,26 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; struct JumpHopTileAtlasSlice { tile_type: JumpHopTileType, source_atlas_cell: String, + faces: JumpHopTileFaceSlices, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct JumpHopTileFaceSlice { + face: JumpHopTileFaceKey, + source_atlas_cell: String, bytes: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct JumpHopTileFaceSlices { + top: JumpHopTileFaceSlice, + front: JumpHopTileFaceSlice, + right: JumpHopTileFaceSlice, + back: JumpHopTileFaceSlice, + left: JumpHopTileFaceSlice, + bottom: JumpHopTileFaceSlice, +} + pub async fn create_jump_hop_session( State(state): State, Extension(request_context): Extension, @@ -720,10 +740,10 @@ async fn maybe_generate_jump_hop_assets( &settings, sheet_prompt.as_str(), Some(build_jump_hop_tile_atlas_negative_prompt()), - "1024*1024", + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, 1, &[], - "跳一跳地å—图集生æˆå¤±è´¥", + "跳一跳地æ¿è´´å›¾å›¾é›†ç”Ÿæˆå¤±è´¥", ) .await .map_err(|error| { @@ -735,7 +755,7 @@ async fn maybe_generate_jump_hop_assets( JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", - "message": "跳一跳地å—å›¾é›†ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + "message": "跳一跳地æ¿è´´å›¾å›¾é›†ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", })), ) })?; @@ -750,8 +770,8 @@ async fn maybe_generate_jump_hop_assets( tile_prompt.as_str(), tile_image, LegacyAssetPrefix::JumpHopAssets, - 1024, - 1024, + JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH, + JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT, request_context, ) .await?; @@ -836,7 +856,7 @@ fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String { return value; } - // 中文注释:仅对å®å¯æ¢¦ç›¸å…³è¯åšç”Ÿæˆä¾§è„±æ•,é¿å…地å—图集触å‘上游安全拦截。 + // 中文注释:仅对å®å¯æ¢¦ç›¸å…³è¯åšç”Ÿæˆä¾§è„±æ•,é¿å…贴图图集触å‘上游安全拦截。 const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [ ("å®å¯æ¢¦", "原创幻想èŒå® å†’险é“å…·"), ("神奇å®è´", "原创幻想èŒå® å†’险é“å…·"), @@ -896,12 +916,12 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri }; format!( - "生æˆä¸€å¼ 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, no colored shadow or magenta fringe around objects, 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." + "生æˆä¸€å¼ 1024x1536竖版图片,主题为“{theme_text}â€ã€‚\nç”»é¢åªåŒ…å«18个用于跳一跳地æ¿çš„立方体主题物体 UV 展开包装图,按三列六行å‡åŒ€æŽ’布;æ¯ä¸ªå¤§å•元格代表一个完整的 1x1x1 立方体方å—物体,è¿è¡Œæ€ä¼šæŠŠè¯¥å•元内的六张é¢è´´å›¾ç²¾ç¡®è´´åˆ° Three.js 标准æžå°å€’角立方体的六个é¢ä¸Šã€‚\nç”»é¢å†…容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六é¢å±•å¼€å›¾é›†ï¼Œä¸æ˜¯å•纯平铺æè´¨ã€ä¸æ˜¯æŠ½è±¡çº¹ç†ã€ä¸æ˜¯åªæŠŠä¸»é¢˜é¢œè‰²é“ºæ»¡ï¼Œä¹Ÿä¸æ˜¯å·²ç»æ¸²æŸ“好的 3D æ–¹å—æˆå“ã€æ¸¸æˆç•Œé¢æˆ–图标集页é¢ã€‚\næ¯ä¸ªå¤§å•元格内部必须使用固定 4列x3行 UV å±•å¼€ç»“æž„ï¼Œåªæœ‰ä»¥ä¸‹å…­ä¸ªä½ç½®æœ‰è´´å›¾ï¼Œå…¶å®ƒä½ç½®ä¿æŒçº¯æ´‹çº¢å®‰å…¨è‰²ï¼šç¬¬1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。ä¸è¦æ”¹å˜é¡ºåºï¼Œä¸è¦æ—‹è½¬é¢ï¼Œä¸è¦æŠŠå…­ä¸ªé¢ç”»æˆä¸€å¼ è¿žç»­é€è§†å›¾ã€‚\næ¯ä¸ªæ–¹å—éƒ½å¿…é¡»è¡¨çŽ°ä¸ºâ€œä¸€ä¸ªå®Œæ•´ä¸»é¢˜ç‰©ä½“è¢«å¡‘é€ æˆ 1x1x1 立方体åŽçš„å…­é¢åŒ…装â€ï¼Œå…­ä¸ªé¢è¦å±žäºŽåŒä¸€ä¸ªç‰©ä½“å¹¶èƒ½ç»„åˆæˆå®Œæ•´æ–¹å—造型;top/front/right/back/left/bottom 之间的颜色ã€è¾¹ç¼˜çº¹ç†ã€åˆ‡é¢ã€æžœçš®ã€ç±½ç‚¹ã€æ¡çº¹ã€æžœæŸ„å’Œå¶ç‰‡å¿…须连续一致,ä¸èƒ½å…­é¢å„画互ä¸ç›¸å…³çš„图案,也ä¸èƒ½æŠŠåŒä¸€å¼ çº¹ç†é‡å¤å…­æ¬¡ã€‚\n水果主题è¦ç”Ÿæˆ18ç§å¯ä¸€çœ¼è¾¨è®¤çš„æ–¹å—æ°´æžœ UV:方å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—è¥¿ç“œã€æ–¹å—è‰èŽ“ã€æ–¹å—è‘¡è„ã€æ–¹å—å¥‡å¼‚æžœã€æ–¹å—è èã€æ–¹å—æŸ æª¬ã€æ–¹å—桃å­ã€æ–¹å—æ¢¨ã€æ–¹å—è“èŽ“ã€æ–¹å—èŠ’æžœã€æ–¹å—椰å­ã€æ–¹å—ç«é¾™æžœã€æ–¹å—æ¨±æ¡ƒã€æ–¹å—å“ˆå¯†ç“œã€æ–¹å—çŸ³æ¦´ç­‰ï¼›è‹¹æžœéœ€è¦æžœæŸ„å¶ç‰‡è·¨ top/front,香蕉需è¦å‰¥çš®æ¡å¸¦è·¨ front/right,橙å­éœ€è¦æ”¾å°„切é¢è·¨ top/front,西瓜需è¦çº¢ç“¤é»‘籽和绿皮æ¡çº¹åœ¨å„é¢è¿žç»­ã€‚ä¸è¦åªç”»é‡å¤æžœçš®çº¹ç†ã€éšæœºæ–‘ç‚¹ã€å¶è„‰çº¹ç†æˆ–抽象æè´¨ã€‚\næ¯ä¸ªé¢éƒ½æ˜¯æ»¡ç‰ˆä¸é€æ˜Žæ­£æ–¹å½¢è´´å›¾ / full-bleed opaque square face texture:四角ã€è¾¹ç¼˜å’Œä¸­å¿ƒéƒ½è¦æœ‰å¯è¯†åˆ«å†…容,ä¸ç•™é€æ˜Žã€ä¸ç•™ç©ºç™½ã€ä¸ç•™å®žåº•背景;å…许大é¢ç§¯æ°´æžœåˆ‡é¢ã€æžœæŸ„å¶ç‰‡ã€å‰¥ç𮿡另ã€ç±½ç‚¹ã€æ¡çº¹å’Œè½®å»“图案作为包装身份锚点,但ä¸è¦æŠŠä¸€ä¸ªå°æ°´æžœã€å°å¶ç‰‡ã€å°çŸ³å¤´æˆ–å°ç‰©ä½“放在é¢ä¸­å¤®ï¼Œä¹Ÿä¸è¦ç”»å°è´´çº¸ã€å°å›¾æ ‡ã€å¾½ç« æˆ–孤立主体。\nè¿™ä¸æ˜¯é€è§†æ¸²æŸ“图:ä¸è¦ç”»æ‘„åƒæœºè§†è§’ã€é€è§†å—ã€å·²çƒ˜ç„™ä¾§å£ã€å·²çƒ˜ç„™åŽšåº¦ã€è‡ªèº«æŠ•å½±ã€æŽ¥è§¦é˜´å½±æˆ–é«˜å…‰å…‰æ–‘ï¼›çœŸå®žé€è§†ã€å€’è§’ã€ä¾§å£å’Œé˜´å½±ä¼šç”±è¿è¡Œæ€ Three.js 统一生æˆã€‚æ¯ä¸ªé¢è´´å›¾åœ¨è¿è¡Œæ€ä¼šä»¥çº¦45度下压视角和较å°å°ºå¯¸æ˜¾ç¤ºï¼Œæ‰€ä»¥å¿…须使用大色å—ã€é«˜å¯¹æ¯”ã€ç²—线æ¡å’Œç®€å•图形,ä¿è¯åœ¨64x64缩略图里ä»èƒ½åˆ†è¾¨ä¸»é¢˜ç‰©ä½“身份。\n排布必须安全:18个大å•元格必须完整è½åœ¨è‡ªå·±çš„三列六行网格内,ä¸èƒ½è·¨æ ¼ã€è´´è¾¹ä¸²è‰²æˆ–进入相邻方å—;大å•元之间ã€UV 空ä½ã€å…­é¢ä¹‹é—´å’Œç”»å¸ƒæœ€å¤–圈åªèƒ½ä½¿ç”¨å•一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 作为切图安全色,å…许æžç»†çº¯æ´‹çº¢å®‰å…¨ç¼ï¼Œä½†ä¸è¦ç”»å¯è§ç½‘格线ã€è¾¹æ¡†ã€ç¼–å·ã€face label 或è£åˆ‡æ ‡è®°ã€‚\n贴图内部å¯ä»¥ä½¿ç”¨ç»¿è‰²ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°ã€èŠ±æœµã€æžœè‚‰ç²‰è‰²å’Œæµ…黄色等主题颜色,但ä¸å¾—使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘ä¸å¾—有洋红æè¾¹ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€å½©è‰²å…‰æ™•或å‘光边。\nç¦æ­¢æ–‡å­—ã€Logoã€æ°´å°ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€è§’æ ‡ã€è£…饰边框ã€face labelã€top/front/right/back/left/bottom文字ã€èƒŒåŒ…ã€è£…备æ ã€èœå•ã€è§’色ã€å®Œæ•´åœºæ™¯ã€è‡ªç„¶åœ†å½¢æ°´æžœã€è‡ªç„¶é•¿æ¡é¦™è•‰ã€éžæ–¹å—化完整水果ã€å­¤ç«‹æ°´æžœç…§ç‰‡ã€å°åž‹æžœåˆ‡è´´çº¸ã€å°åž‹æ©™ç‰‡è´´çº¸ã€å°è´´çº¸å›¾æ ‡ã€å°ç‰©ä½“居中ã€çº¯æžœçš®æè´¨ã€çº¯æžœè‚‰çº¹ç†ã€çº¯å¶è„‰çº¹ç†ã€çº¯é¢œè‰²å—ã€é€æ˜ŽèƒŒæ™¯ã€ç•™ç™½ã€3Då¹³å°ã€åœ†å°ã€åº•åº§ã€æ‰˜ç›˜ã€ç‰©ä½“摆在平å°ä¸Šã€é€è§†åœ°å—ã€æ­£é¢30度物体图ã€é¸Ÿçž°åœ°å›¾å—ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€ç™½åº•ã€ç°åº•ã€é»‘底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 col2 front, row2 col3 right, row2 col4 back, row3 col2 bottom; empty UV cells and gutters are solid magenta {JUMP_HOP_TILE_ATLAS_KEY_HEX}; generate six different face textures that stitch into one recognizable cubified theme object, not one repeated texture and not unrelated icons; fruit theme must create 18 distinct cubified fruits with continuous identity marks across faces; no text labels, no perspective cube render, no baked lighting, no baked shadows, no pedestal, no floor slab, no small centered stickers, no generic flat material; every face is full-bleed opaque square texture and remains recognizable at 64x64 in a 45-degree game camera." ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字ã€Logoã€æ°´å°ã€UI按钮ã€UI å­—ã€æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€å›¾æ ‡é›†é¡µé¢ã€å¤–层颿¿ã€èœå•ã€å·¥å…·æ ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“ã€å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™è´¨æ„Ÿã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æµ·æŠ¥ã€UI图标å¡ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€è£…饰边框ã€çº¯ä¿¯è§†è§’ã€æ­£ä¸Šæ–¹è§†è§’ã€é¸Ÿçž°è§†è§’ã€å¹³é“ºä¿¯æ‹ã€é¡¶é¢å ä¸»ç”»é¢ã€åªçœ‹é¡¶é¢ã€åœ†å½¢é¡¶è§†å›¾ã€æ‰å¹³å›¾æ ‡ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ´‹çº¢é˜´å½±ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€æ´‹çº¢è‰²æè¾¹ã€å½©è‰²å…‰æ™•ã€å‘å…‰åº•è¾¹ã€æ–¹å½¢åº•æ¿ã€é¢å¤–åº•åº§ã€æ‰¿æ‰˜åº•座ã€å°åº§ã€çŸ³å°ã€åœŸå¢©ã€æœ¨æ¿åº•座ã€åœ†å°ã€åº•ç›˜ã€æ‰˜ç›˜ã€å²›å±¿åº•座ã€èŠ±ç›†åº•åº§ã€åœ°é¢å—ã€è„šä¸‹åœ°æ¿ã€ç‰©ä½“摆在平å°ä¸Šã€ç‰©ä½“下方垫地æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘åº•ã€æš—色背景ã€èƒŒæ™¯è‰²å—ã€è´´è¾¹ã€è·¨æ ¼ã€è¶Šç•Œ" + "文字ã€Logoã€æ°´å°ã€UI按钮ã€UI å­—ã€æ¸¸æˆç•Œé¢ã€æ£‹ç›˜ã€èƒŒåŒ…ã€è£…备æ ã€å›¾æ ‡é›†é¡µé¢ã€å¤–层颿¿ã€èœå•ã€å·¥å…·æ ã€ä½Žæ¸…晰度ã€ç•¸å½¢è‚¢ä½“ã€å¤šä½™è§’色ã€è£åˆ‡ä¸»ä½“ã€å†™å®žæ‘„å½±ã€æ²¹äº®é«˜å…‰ã€å¡‘æ–™è´¨æ„Ÿã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æµ·æŠ¥ã€UI图标å¡ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€è£…饰边框ã€å•纯平铺æè´¨ã€æŠ½è±¡çº¹ç†ã€éšæœºæ–‘ç‚¹ã€åªé“ºä¸»é¢˜é¢œè‰²ã€çº¯æžœçš®æè´¨ã€çº¯æžœè‚‰çº¹ç†ã€çº¯å¶è„‰çº¹ç†ã€æ— æ³•分辨具体物体ã€è‡ªç„¶åœ†å½¢æ°´æžœã€è‡ªç„¶é•¿æ¡é¦™è•‰ã€éžæ–¹å—化完整水果ã€å­¤ç«‹æ°´æžœç…§ç‰‡ã€æžœåˆ‡å°è´´çº¸ã€æ©™ç‰‡å°è´´çº¸ã€å°æ°´æžœå±…中ã€è‹¹æžœå°è´´çº¸ã€é¦™è•‰å°è´´çº¸ã€å°è´´çº¸å›¾æ ‡ã€å°ç‰©ä½“居中ã€é€æ˜ŽèƒŒæ™¯ã€ç•™ç™½ã€3Då¹³å°ã€è·³æ¿æˆå“ã€åœ°å—æˆå“ã€ç‰©ä½“å‰ªå½±ã€æ­£é¢30度物体图ã€çº¯ä¿¯è§†åœ°å›¾å—ã€é¸Ÿçž°åœ°å›¾å—ã€é€è§†åœ°å—ã€å·²ç»ç”»å¥½çš„ä¾§å£ã€å·²ç»ç”»å¥½çš„厚度ã€çƒ˜ç„™é«˜å…‰ã€çƒ˜ç„™é˜´å½±ã€è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ´‹çº¢é˜´å½±ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€æ´‹çº¢è‰²æè¾¹ã€å½©è‰²å…‰æ™•ã€å‘å…‰åº•è¾¹ã€æ–¹å½¢åº•æ¿ã€é¢å¤–åº•åº§ã€æ‰¿æ‰˜åº•座ã€å°åº§ã€çŸ³å°ã€åœŸå¢©ã€æœ¨æ¿åº•座ã€åœ†å°ã€åº•ç›˜ã€æ‰˜ç›˜ã€å²›å±¿åº•座ã€èŠ±ç›†åº•åº§ã€åœ°é¢å—ã€è„šä¸‹åœ°æ¿ã€ç‰©ä½“摆在平å°ä¸Šã€ç‰©ä½“下方垫地æ¿ã€ç™½åº•ã€ç°åº•ã€é»‘åº•ã€æš—色背景ã€èƒŒæ™¯è‰²å—ã€è´´è¾¹ã€è·¨æ ¼ã€è¶Šç•Œã€å¯è§ç½‘格线ã€ç¼–å·ã€è£åˆ‡æ ‡è®°" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { @@ -911,32 +931,41 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { } value = replace_jump_hop_pokemon_prompt_terms(value.as_str()); - const REPLACEMENTS: [(&str, &str); 18] = [ - ("俯视角", "æ­£é¢30度视角"), - ("正上方视角", "æ­£é¢30度视角"), - ("鸟瞰视角", "æ­£é¢30度视角"), - ("平铺俯æ‹", "æ­£é¢30度视角"), - ("å¯è½è„šå¹³å°ç´ æ", "跳跃è½ç‚¹ä¸»é¢˜ç‰©ä½“"), - ("清爽游æˆåŒ–立体感平å°ç´ æ", "清爽游æˆåŒ–立体感主题物体"), - ("å¹³å°è£¸ç´ æ", "主题物体裸素æ"), - ("æ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°", "æ¯æ ¼ä¸€ä¸ªå®Œæ•´ä¸»é¢˜ç‰©ä½“"), - ("å¹³å°ç´ æ", "主题物体"), - ("å¯è½è„šå¹³å°", "跳跃è½ç‚¹"), - ("å¯è½è„š", "è½ç‚¹"), - ("å¹³å°", "主题物体"), - ("è·³å°", "è½ç‚¹"), - ("地å—", "主题物体"), - ("地砖", "主题物体"), + const REPLACEMENTS: [(&str, &str); 24] = [ + ("æ­£é¢30度视角主题物体图集", "3D立方体主题身份方å—包装图集"), + ("物体本身作为跳跃è½ç‚¹", "主题物体方å—化åŽä½œä¸ºç«‹æ–¹ä½“包装"), + ("3D立方体主题方å—包装图集", "3D立方体主题身份方å—包装图集"), + ("立方体主题方å—包装图集", "立方体主题身份方å—包装图集"), + ("俯视角", "正交平é¢"), + ("正上方视角", "正交平é¢"), + ("鸟瞰视角", "正交平é¢"), + ("平铺俯æ‹", "正交平é¢"), + ("å¯è½è„šå¹³å°ç´ æ", "立方体主题身份方å—包装贴图"), + ( + "清爽游æˆåŒ–立体感平å°ç´ æ", + "清爽游æˆåŒ–立方体主题身份方å—包装贴图", + ), + ("å¹³å°è£¸ç´ æ", "立方体主题身份方å—包装贴图"), + ("æ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°", "æ¯æ ¼ä¸€å¼ å®Œæ•´èº«ä»½æ–¹å—包装贴图"), + ("主题物体图集", "立方体主题身份方å—包装图集"), + ("主题物体", "主题身份方å—包装"), + ("å¹³å°ç´ æ", "立方体身份方å—包装贴图"), + ("å¯è½è„šå¹³å°", "立方体主题身份方å—包装"), + ("å¯è½è„š", "å¯è´´å›¾"), + ("å¹³å°", "立方体地æ¿"), + ("è·³å°", "立方体地æ¿"), + ("地å—", "身份方å—包装贴图"), + ("地砖", "身份方å—包装贴图"), ("底座", "承托物"), ("底盘", "承托物"), - ("地æ¿", "承托物"), + ("地æ¿", "立方体地æ¿"), ]; for (from, to) in REPLACEMENTS { value = value.replace(from, to); } - while value.contains("æ­£é¢30度视角正é¢30度视角") { - value = value.replace("æ­£é¢30度视角正é¢30度视角", "æ­£é¢30度视角"); + while value.contains("立方体立方体") { + value = value.replace("立方体立方体", "立方体"); } value @@ -945,14 +974,14 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { - let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地å—图集解ç å¤±è´¥ï¼š{error}"), - })) - })?; - let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(); - let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options); + let source = image::load_from_memory(image.bytes.as_slice()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地æ¿è´´å›¾å›¾é›†è§£ç å¤±è´¥ï¼š{error}"), + })) + })? + .to_rgba8(); let width = source.width(); let height = source.height(); let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; @@ -961,7 +990,7 @@ fn slice_jump_hop_tile_atlas( return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, - "message": "跳一跳地å—图集尺寸过å°ï¼Œæ— æ³•切割。", + "message": "跳一跳地æ¿è´´å›¾å›¾é›†å°ºå¯¸è¿‡å°ï¼Œæ— æ³•切割。", })), ); } @@ -974,133 +1003,187 @@ fn slice_jump_hop_tile_atlas( let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; - let cropped = source.crop_imm( + let tile_width = x1.saturating_sub(x0).max(1); + let tile_height = y1.saturating_sub(y0).max(1); + let faces = slice_jump_hop_tile_uv_faces( + &source, x0, y0, - x1.saturating_sub(x0).max(1), - y1.saturating_sub(y0).max(1), - ); - 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_with_options(cleaned, alpha_options); - let cleaned = pad_jump_hop_tile_slice_image(cleaned); - let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地å—图集切割失败:{error}"), - })) - })?; + tile_width, + tile_height, + row, + col, + )?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), - bytes: cursor.into_inner(), + faces, }); } Ok(slices) } -fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage { - let source = image.to_rgba8(); - let (width, height) = source.dimensions(); - if width == 0 || height == 0 { - return image::DynamicImage::ImageRgba8(source); - } +fn slice_jump_hop_tile_uv_faces( + source: &image::RgbaImage, + tile_x: u32, + tile_y: u32, + tile_width: u32, + tile_height: u32, + atlas_row: u32, + atlas_col: u32, +) -> Result { + let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS); + let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS); + let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2); + let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2); - // 中文注释:生图å¶å°”会让主体贴近å•元格边缘;切片入库å‰è¡¥é€æ˜Žå®‰å…¨è¾¹ï¼Œ - // é¿å…è¿è¡Œæ€ç¼©æ”¾æˆ–滤镜让主体看起æ¥è¢«è£æŽ‰ã€‚ - let pad_x = (width / 12).clamp(8, 24); - let pad_y = (height / 12).clamp(8, 24); - let mut padded = image::RgbaImage::from_pixel( - width.saturating_add(pad_x.saturating_mul(2)), - height.saturating_add(pad_y.saturating_mul(2)), - image::Rgba([0, 0, 0, 0]), - ); - image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into()); - image::DynamicImage::ImageRgba8(padded) + Ok(JumpHopTileFaceSlices { + top: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + )?, + front: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + )?, + right: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + )?, + back: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + )?, + left: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + )?, + bottom: slice_jump_hop_tile_uv_face( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + )?, + }) } -fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage { - let mut source = image.to_rgba8(); - let (width, height) = source.dimensions(); - if width == 0 || height == 0 { - return image::DynamicImage::ImageRgba8(source); +#[allow(clippy::too_many_arguments)] +fn slice_jump_hop_tile_uv_face( + source: &image::RgbaImage, + uv_x: u32, + uv_y: u32, + face_side: u32, + atlas_row: u32, + atlas_col: u32, + face: JumpHopTileFaceKey, + face_col: u32, + face_row: u32, +) -> Result { + let cleaned = crop_jump_hop_tile_texture_cell( + source, + uv_x.saturating_add(face_col.saturating_mul(face_side)), + uv_y.saturating_add(face_row.saturating_mul(face_side)), + face_side, + face_side, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("è·³ä¸€è·³åœ°æ¿ UV é¢è´´å›¾åˆ‡å‰²å¤±è´¥ï¼š{error}"), + })) + })?; + let face_label = jump_hop_tile_face_key_label(&face); + + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!( + "row-{}-col-{}/{}", + atlas_row + 1, + atlas_col + 1, + face_label + ), + bytes: cursor.into_inner(), + }) +} + +fn crop_jump_hop_tile_texture_cell( + source: &image::RgbaImage, + x0: u32, + y0: u32, + width: u32, + height: u32, +) -> image::DynamicImage { + let min_side = width.min(height).max(1); + let safe_inset = (min_side / 32).clamp(2, 12); + let inset_x = safe_inset.min(width.saturating_sub(1) / 2); + let inset_y = safe_inset.min(height.saturating_sub(1) / 2); + let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1); + let crop_height = height.saturating_sub(inset_y.saturating_mul(2)).max(1); + let cropped = image::imageops::crop_imm( + source, + x0.saturating_add(inset_x), + y0.saturating_add(inset_y), + crop_width, + crop_height, + ) + .to_image(); + let mut resized = image::imageops::resize( + &cropped, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + image::imageops::FilterType::Lanczos3, + ); + normalize_jump_hop_tile_texture_pixels(&mut resized); + image::DynamicImage::ImageRgba8(resized) +} + +fn normalize_jump_hop_tile_texture_pixels(image: &mut image::RgbaImage) { + let fallback = average_jump_hop_tile_texture_color(image); + for pixel in image.pixels_mut() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { + *pixel = fallback; + } + pixel.0[3] = 255; } +} - // 中文注释:模型å¶å°”会让相邻格的å¶ç‰‡ã€æžœæ¢—æˆ–é˜´å½±è¶Šç•Œè¿›å½“å‰æ ¼ï¼› - // æ¯æ ¼åªä¿ç•™æœ€å¤§çš„ alpha 连通主体,能去掉这些å°ç¢Žç‰‡å†å…¥åº“。 - let width_usize = width as usize; - let height_usize = height as usize; - let pixel_count = width_usize.saturating_mul(height_usize); - let mut visited = vec![false; pixel_count]; - let mut best_component = Vec::::new(); +fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba { + let mut total_r = 0u64; + let mut total_g = 0u64; + let mut total_b = 0u64; + let mut count = 0u64; - for start in 0..pixel_count { - if visited[start] || source.as_raw()[start * 4 + 3] <= 16 { - visited[start] = true; + for pixel in image.pixels() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { continue; } - - let mut queue = VecDeque::from([start]); - let mut component = Vec::::new(); - visited[start] = true; - - while let Some(index) = queue.pop_front() { - component.push(index); - let x = index % width_usize; - let y = index / width_usize; - - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 - { - continue; - } - let next = next_y as usize * width_usize + next_x as usize; - if visited[next] { - continue; - } - visited[next] = true; - if source.as_raw()[next * 4 + 3] > 16 { - queue.push_back(next); - } - } - } - } - - if component.len() > best_component.len() { - best_component = component; - } + total_r += pixel.0[0] as u64; + total_g += pixel.0[1] as u64; + total_b += pixel.0[2] as u64; + count += 1; } - if best_component.is_empty() { - return image::DynamicImage::ImageRgba8(source); + if count == 0 { + return image::Rgba([148, 163, 184, 255]); } - let mut keep = vec![false; pixel_count]; - for index in best_component { - keep[index] = true; - } - for index in 0..pixel_count { - if keep[index] { - continue; - } - let pixel = - source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32); - pixel.0[3] = 0; - } + image::Rgba([ + (total_r / count) as u8, + (total_g / count) as u8, + (total_b / count) as u8, + 255, + ]) +} - image::DynamicImage::ImageRgba8(source) +fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { + let [red, green, blue, _] = pixel.0; + let red_delta = red.abs_diff(255) as u32; + let green_delta = green as u32; + let blue_delta = blue.abs_diff(255) as u32; + + red_delta.saturating_mul(red_delta) + + green_delta.saturating_mul(green_delta) + + blue_delta.saturating_mul(blue_delta) + <= 24u32.saturating_mul(24) } fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { @@ -1117,6 +1200,25 @@ fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { format!("tile-{:02}", tile_index + 1) } +fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { + match face { + JumpHopTileFaceKey::Top => "top", + JumpHopTileFaceKey::Front => "front", + JumpHopTileFaceKey::Right => "right", + JumpHopTileFaceKey::Back => "back", + JumpHopTileFaceKey::Left => "left", + JumpHopTileFaceKey::Bottom => "bottom", + } +} + +fn jump_hop_tile_face_asset_slot_name(tile_index: usize, face: &JumpHopTileFaceKey) -> String { + format!( + "{}-{}", + jump_hop_tile_asset_slot_name(tile_index), + jump_hop_tile_face_key_label(face) + ) +} + #[allow(clippy::too_many_arguments)] async fn persist_jump_hop_tile_asset( state: &AppState, @@ -1127,8 +1229,97 @@ async fn persist_jump_hop_tile_asset( request_context: &RequestContext, ) -> Result { let slot = jump_hop_tile_asset_slot_name(tile_index); + let top = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.top, + request_context, + ) + .await?; + let front = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.front, + request_context, + ) + .await?; + let right = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.right, + request_context, + ) + .await?; + let back = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.back, + request_context, + ) + .await?; + let left = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.left, + request_context, + ) + .await?; + let bottom = persist_jump_hop_tile_face_asset( + state, + owner_user_id, + profile_id, + tile_index, + tile_slice.faces.bottom, + request_context, + ) + .await?; + let primary = top.clone(); + + Ok(JumpHopTileAsset { + tile_type: tile_slice.tile_type, + tile_id: Some(slot), + image_src: primary.image_src.clone(), + image_object_key: primary.image_object_key.clone(), + asset_object_id: primary.asset_object_id.clone(), + source_atlas_cell: tile_slice.source_atlas_cell, + atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), + atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), + visual_width: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + visual_height: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + top_surface_radius: 42.0, + landing_radius: 34.0, + face_assets: Some(JumpHopTileFaceAssets { + top, + front, + right, + back, + left, + bottom, + }), + }) +} + +async fn persist_jump_hop_tile_face_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + tile_index: usize, + face_slice: JumpHopTileFaceSlice, + request_context: &RequestContext, +) -> Result { + let slot = jump_hop_tile_face_asset_slot_name(tile_index, &face_slice.face); let image = crate::openai_image_generation::DownloadedOpenAiImage { - bytes: tile_slice.bytes, + bytes: face_slice.bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }; @@ -1138,31 +1329,29 @@ async fn persist_jump_hop_tile_asset( profile_id, slot.as_str(), &format!( - "跳一跳地å—切片 {}:{}", + "è·³ä¸€è·³åœ°æ¿ UV é¢è´´å›¾ {}:{}", tile_index + 1, - tile_slice.source_atlas_cell + face_slice.source_atlas_cell ), image, LegacyAssetPrefix::JumpHopAssets, - 256, - 192, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, request_context, ) .await?; - Ok(JumpHopTileAsset { - tile_type: tile_slice.tile_type, - tile_id: Some(slot), + Ok(JumpHopTileFaceAsset { + face: face_slice.face, + asset_id: persisted.asset_id, image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, - source_atlas_cell: tile_slice.source_atlas_cell, - atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), - atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, + generation_provider: persisted.generation_provider, + prompt: persisted.prompt, + width: persisted.width, + height: persisted.height, + source_atlas_cell: face_slice.source_atlas_cell, }) } @@ -1432,7 +1621,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}主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹"), + &format!("{theme_text}主题的3D立方体主题身份方å—包装图集"), ), end_mood_prompt: payload .end_mood_prompt @@ -1631,68 +1820,80 @@ mod tests { } #[test] - fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { + fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游æˆåŒ–立体感平å°"); - 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("æ¯ä¸€ä¸ªè½ç‚¹éƒ½å¿…须直接使用主题物体或åˆç†å‘散物体")); - 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("生æˆä¸€å¼ 1024x1536竖版图片")); + assert!(prompt.contains("18个用于跳一跳地æ¿çš„立方体主题物体 UV 展开包装图")); + assert!(prompt.contains("按三列六行å‡åŒ€æŽ’布")); + assert!(prompt.contains("æ¯ä¸ªå¤§å•元格代表一个完整的 1x1x1 立方体方å—物体")); + assert!(prompt.contains("该å•元内的六张é¢è´´å›¾ç²¾ç¡®è´´åˆ° Three.js 标准æžå°å€’角立方体的六个é¢ä¸Š")); + assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六é¢å±•开图集")); + assert!(prompt.contains("䏿˜¯å•纯平铺æè´¨ã€ä¸æ˜¯æŠ½è±¡çº¹ç†ã€ä¸æ˜¯åªæŠŠä¸»é¢˜é¢œè‰²é“ºæ»¡")); + assert!(prompt.contains("游æˆç•Œé¢æˆ–图标集页é¢")); + assert!(prompt.contains("固定 4列x3行 UV 展开结构")); + assert!(prompt.contains("第1行第2列是 top")); + assert!(prompt.contains("第2行第1列是 left")); + assert!(prompt.contains("第2行第2列是 front")); + assert!(prompt.contains("第2行第3列是 right")); + assert!(prompt.contains("第2行第4列是 back")); + assert!(prompt.contains("第3行第2列是 bottom")); + assert!(prompt.contains("ä¸è¦æ”¹å˜é¡ºåºï¼Œä¸è¦æ—‹è½¬é¢")); + assert!(prompt.contains("六个é¢è¦å±žäºŽåŒä¸€ä¸ªç‰©ä½“å¹¶èƒ½ç»„åˆæˆå®Œæ•´æ–¹å—造型")); + assert!(prompt.contains("ä¸èƒ½å…­é¢å„画互ä¸ç›¸å…³çš„图案,也ä¸èƒ½æŠŠåŒä¸€å¼ çº¹ç†é‡å¤å…­æ¬¡")); + assert!(prompt.contains("水果主题è¦ç”Ÿæˆ18ç§å¯ä¸€çœ¼è¾¨è®¤çš„æ–¹å—æ°´æžœ UV")); + assert!(prompt.contains("æ–¹å—è‹¹æžœã€æ–¹å—é¦™è•‰ã€æ–¹å—æ©™å­ã€æ–¹å—西瓜")); + assert!(prompt.contains("è‹¹æžœéœ€è¦æžœæŸ„å¶ç‰‡è·¨ top/front")); + assert!(prompt.contains("香蕉需è¦å‰¥çš®æ¡å¸¦è·¨ front/right")); + assert!(prompt.contains("西瓜需è¦çº¢ç“¤é»‘籽和绿皮æ¡çº¹åœ¨å„é¢è¿žç»­")); + assert!(prompt.contains("ä¸è¦åªç”»é‡å¤æžœçš®çº¹ç†ã€éšæœºæ–‘ç‚¹ã€å¶è„‰çº¹ç†æˆ–抽象æè´¨")); + assert!(prompt.contains("full-bleed opaque square face texture")); + assert!(prompt.contains("四角ã€è¾¹ç¼˜å’Œä¸­å¿ƒéƒ½è¦æœ‰å¯è¯†åˆ«å†…容")); + assert!(prompt.contains("ä¸ç•™é€æ˜Žã€ä¸ç•™ç©ºç™½ã€ä¸ç•™å®žåº•背景")); + assert!(prompt.contains("å…许大é¢ç§¯æ°´æžœåˆ‡é¢ã€æžœæŸ„å¶ç‰‡ã€å‰¥ç𮿡另ã€ç±½ç‚¹ã€æ¡çº¹å’Œè½®å»“图案作为包装身份锚点")); + assert!(prompt.contains("ä¸è¦æŠŠä¸€ä¸ªå°æ°´æžœã€å°å¶ç‰‡ã€å°çŸ³å¤´æˆ–å°ç‰©ä½“放在é¢ä¸­å¤®")); + assert!(prompt.contains("è¿™ä¸æ˜¯é€è§†æ¸²æŸ“图")); + assert!(prompt.contains("ä¸è¦ç”»æ‘„åƒæœºè§†è§’ã€é€è§†å—ã€å·²çƒ˜ç„™ä¾§å£")); + assert!(prompt.contains("真实é€è§†ã€å€’è§’ã€ä¾§å£å’Œé˜´å½±ä¼šç”±è¿è¡Œæ€ Three.js 统一生æˆ")); + assert!(prompt.contains("64x64缩略图里ä»èƒ½åˆ†è¾¨ä¸»é¢˜ç‰©ä½“身份")); + assert!(prompt.contains("18个大å•元格必须完整è½åœ¨è‡ªå·±çš„三列六行网格内")); + assert!(prompt.contains("大å•元之间ã€UV 空ä½ã€å…­é¢ä¹‹é—´å’Œç”»å¸ƒæœ€å¤–圈åªèƒ½ä½¿ç”¨å•一纯洋红")); assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); - assert!(prompt.contains("主体å…许使用绿色ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°å’ŒèŠ±æœµ")); - assert!(prompt.contains("ä¸ç»˜åˆ¶è½åœ°æŠ•å½±")); - assert!(prompt.contains("ä¸ç»˜åˆ¶è½åœ°æŠ•å½±ã€æŽ¥è§¦é˜´å½±ã€æ–¹å½¢é˜´å½±ã€æ´‹çº¢é˜´å½±")); - 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") + prompt.contains("贴图内部å¯ä»¥ä½¿ç”¨ç»¿è‰²ã€ç™½è‰²ã€é›ªåœ°ã€äº‘朵ã€è‰åœ°ã€èŠ±æœµã€æžœè‚‰ç²‰è‰²å’Œæµ…黄色") ); - 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("ä¸å¾—使用接近")); + assert!(prompt.contains("贴图边缘ä¸å¾—有洋红æè¾¹ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹")); + assert!(prompt.contains("自然圆形水果ã€è‡ªç„¶é•¿æ¡é¦™è•‰ã€éžæ–¹å—化完整水果")); + assert!(prompt.contains("å°è´´çº¸å›¾æ ‡ã€å°ç‰©ä½“居中ã€çº¯æžœçš®æè´¨ã€çº¯æžœè‚‰çº¹ç†")); + assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("one vertical 1024x1536 image")); + assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); + assert!(prompt.contains("row1 col2 top")); + assert!(prompt.contains("row2 col1 left")); + assert!(prompt.contains("row2 col2 front")); + assert!(prompt.contains("row2 col3 right")); + assert!(prompt.contains("row2 col4 back")); + assert!(prompt.contains("row3 col2 bottom")); + assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); + assert!(prompt.contains("no generic flat material")); + assert!(prompt.contains("no small centered stickers")); + assert!(prompt.contains("every face is full-bleed opaque square texture")); + assert!(prompt.contains("no perspective cube render")); + assert!(prompt.contains("no baked shadows")); assert!(prompt.contains("no pedestal")); assert!(prompt.contains("no floor slab")); - assert!(prompt.contains("no colored shadow or magenta fringe around objects")); + assert!(prompt.contains("empty UV cells and gutters are solid magenta")); 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("æ­£é¢30度视角")); + assert!(!prompt.contains("五行五列")); + assert!(!prompt.contains("25张用于跳一跳地æ¿")); + assert!(!prompt.contains("25 full-bleed")); + assert!(!prompt.contains("one square 5x5")); assert!(!prompt.contains("基础轮廓优先åšä¸è§„则主题剪影")); assert!(!prompt.contains("25æ ¼é€ åž‹è¦æ··æŽ’")); assert!(!prompt.contains("no simple circles")); @@ -1811,7 +2012,7 @@ mod tests { let normal_prompt = build_jump_hop_tile_atlas_prompt("æ°´æžœ", "水果主题的正é¢30度视角主题物体图集"); assert!(normal_prompt.contains("主题为“水果â€")); - assert!(normal_prompt.contains("ç”»é¢å†…容是水果主题的正é¢30度视角主题物体图集")); + assert!(normal_prompt.contains("ç”»é¢å†…容是水果主题的3D立方体主题身份方å—包装图集")); } #[test] @@ -1821,14 +2022,15 @@ mod tests { "科幻芯片主题的俯视角清爽游æˆåŒ–立体感平å°ç´ æ", ); - assert!(prompt.contains("ç”»é¢å†…容是科幻芯片主题的正é¢30度视角清爽游æˆåŒ–立体感主题物体")); + assert!(prompt.contains("ç”»é¢å†…å®¹æ˜¯ç§‘å¹»èŠ¯ç‰‡ä¸»é¢˜çš„æ­£äº¤å¹³é¢æ¸…爽游æˆåŒ–立方体主题身份方å—包装贴图")); 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("圆形立方体地æ¿")); assert!(!top_down_prompt.contains("ç”»é¢å†…容是水果主题鸟瞰视角")); assert!(!top_down_prompt.contains("ç”»é¢å†…容是水果主题平铺俯æ‹")); @@ -1837,8 +2039,8 @@ mod tests { "雪花主题å¯è½è„šå¹³å°ç´ æï¼Œæ¯æ ¼ä¸€ä¸ªå®Œæ•´å¹³å°ï¼Œä¸è¦åº•座", ); - 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("ç”»é¢å†…容是雪花主题å¯è½è„š")); @@ -1854,13 +2056,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("3Då¹³å°")); + assert!(negative_prompt.contains("è·³æ¿æˆå“")); + assert!(negative_prompt.contains("åœ°å—æˆå“")); + assert!(negative_prompt.contains("物体剪影")); + assert!(negative_prompt.contains("æ­£é¢30度物体图")); + 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("紫色底边")); @@ -1874,6 +2091,8 @@ mod tests { 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("规则方å—")); @@ -1884,100 +2103,195 @@ mod tests { assert!(!negative_prompt.contains("楼房")); } - #[test] - fn jump_hop_tile_slice_keeps_largest_alpha_component() { - let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0])); - for y in 12..52 { - for x in 12..52 { - image.put_pixel(x, y, image::Rgba([220, 70, 50, 255])); - } - } - for y in 68..74 { - for x in 36..42 { - image.put_pixel(x, y, image::Rgba([40, 190, 80, 255])); - } - } - - let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image)) - .to_rgba8(); - - assert_eq!(cleaned.get_pixel(20, 20).0[3], 255); - assert_eq!( - cleaned.get_pixel(38, 70).0[3], - 0, - "相邻格侵入的å°ç¢Žç‰‡ä¸åº”扩大当å‰åœ°å—切片边界" + fn paint_test_uv_face( + atlas: &mut image::RgbaImage, + atlas_col: u32, + atlas_row: u32, + face_col: u32, + face_row: u32, + color: image::Rgba, + ) { + let cell_width = atlas.width() / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = atlas.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let tile_x = atlas_col.saturating_mul(cell_width); + let tile_y = atlas_row.saturating_mul(cell_height); + let uv_x = tile_x.saturating_add( + cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, ); + let uv_y = tile_y.saturating_add( + cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2, + ); + for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side { + for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side { + atlas.put_pixel(x, y, color); + } + } } - #[test] - fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() { - let width = 500; - let height = 500; - let mut atlas = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { - let index = row * 5 + col; - let color = image::Rgba([ - 40 + index as u8 * 3, - 24 + index as u8 * 5, - 120 + index as u8 * 2, - 255, - ]); - for y in row as u32 * 100..(row as u32 + 1) * 100 { - for x in col as u32 * 100..(col as u32 + 1) * 100 { - atlas.put_pixel(x, y, color); - } - } - } + fn load_test_png(bytes: Vec) -> crate::openai_image_generation::DownloadedOpenAiImage { + crate::openai_image_generation::DownloadedOpenAiImage { + bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), } + } + + fn encode_test_atlas(atlas: image::RgbaImage) -> Vec { 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(), - }; + encoded.into_inner() + } + + fn assert_png_contains_color(bytes: &[u8], color: [u8; 4], message: &str) { + let decoded = image::load_from_memory(bytes) + .expect("tile face slice should decode") + .to_rgba8(); + assert_eq!( + decoded.dimensions(), + ( + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE + ), + "{message}" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == color), + "{message}" + ); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "{message}" + ); + } + + #[test] + fn jump_hop_tile_atlas_slices_eighteen_cube_uv_unwrap_tiles() { + let width = 384; + let height = 576; + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let index = row * JUMP_HOP_TILE_ATLAS_COLS + col; + let base = index as u8; + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 0, + image::Rgba([40 + base * 3, 24 + base * 2, 100, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 1, + image::Rgba([50 + base * 3, 34 + base * 2, 110, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 2, + 1, + image::Rgba([60 + base * 3, 44 + base * 2, 120, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 3, + 1, + image::Rgba([70 + base * 3, 54 + base * 2, 130, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 0, + 1, + image::Rgba([80 + base * 3, 64 + base * 2, 140, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 2, + image::Rgba([90 + base * 3, 74 + base * 2, 150, 255]), + ); + } + } + let image = load_test_png(encode_test_atlas(atlas)); let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); for (index, slice) in slices.iter().enumerate() { + let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let base = index as u8; assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); assert_eq!( slice.source_atlas_cell, - format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1) + format!("row-{}-col-{}", row + 1, col + 1) ); - let decoded = image::load_from_memory(slice.bytes.as_slice()) - .expect("tile slice should decode") - .to_rgba8(); assert_eq!( - decoded.dimensions(), - (116, 116), - "跳一跳地å—切片应在 100x100 å•å…ƒæ ¼å¤–è¡¥é€æ˜Žå®‰å…¨è¾¹" + slice.faces.top.source_atlas_cell, + format!("row-{}-col-{}/top", row + 1, col + 1) ); - let color = [ - 40 + index as u8 * 3, - 24 + index as u8 * 5, - 120 + index as u8 * 2, - 255, - ]; - assert!( - decoded.pixels().any(|pixel| pixel.0 == color), - "第 {index} 个地å—切片应ä¿ç•™å¯¹åº”æ ¼å­çš„主体颜色" + assert_eq!( + slice.faces.front.source_atlas_cell, + format!("row-{}-col-{}/front", row + 1, col + 1) + ); + assert_png_contains_color( + slice.faces.top.bytes.as_slice(), + [40 + base * 3, 24 + base * 2, 100, 255], + "top é¢åº”ä»Žæ¯æ ¼ç¬¬1行第2列切出", + ); + assert_png_contains_color( + slice.faces.front.bytes.as_slice(), + [50 + base * 3, 34 + base * 2, 110, 255], + "front é¢åº”ä»Žæ¯æ ¼ç¬¬2行第2列切出", + ); + assert_png_contains_color( + slice.faces.right.bytes.as_slice(), + [60 + base * 3, 44 + base * 2, 120, 255], + "right é¢åº”ä»Žæ¯æ ¼ç¬¬2行第3列切出", + ); + assert_png_contains_color( + slice.faces.back.bytes.as_slice(), + [70 + base * 3, 54 + base * 2, 130, 255], + "back é¢åº”ä»Žæ¯æ ¼ç¬¬2行第4列切出", + ); + assert_png_contains_color( + slice.faces.left.bytes.as_slice(), + [80 + base * 3, 64 + base * 2, 140, 255], + "left é¢åº”ä»Žæ¯æ ¼ç¬¬2行第1列切出", + ); + assert_png_contains_color( + slice.faces.bottom.bytes.as_slice(), + [90 + base * 3, 74 + base * 2, 150, 255], + "bottom é¢åº”ä»Žæ¯æ ¼ç¬¬3行第2列切出", ); } } #[test] fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() { - let width = 500; - let height = 500; + let width = 384; + let height = 576; 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 { + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { let color = if row == 0 && col == 0 { image::Rgba([62, 188, 74, 255]) } else if row == 0 && col == 1 { @@ -1985,30 +2299,16 @@ mod tests { } 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); - } - } + paint_test_uv_face(&mut atlas, col, row, 1, 0, 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 image = load_test_png(encode_test_atlas(atlas)); let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); - let green_tile = image::load_from_memory(slices[0].bytes.as_slice()) + let green_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice()) .expect("green tile should decode") .to_rgba8(); - let white_tile = image::load_from_memory(slices[1].bytes.as_slice()) + let white_tile = image::load_from_memory(slices[1].faces.top.bytes.as_slice()) .expect("white tile should decode") .to_rgba8(); @@ -2022,12 +2322,32 @@ mod tests { .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); + assert_eq!(green_tile.get_pixel(0, 0).0[3], 255); + assert_eq!(white_tile.get_pixel(0, 0).0[3], 255); + assert!( + green_tile.pixels().all(|pixel| pixel.0[3] == 255), + "绿色主题æè´¨ä¸èƒ½è¢«é€æ˜ŽåŒ–扣掉" + ); + assert!( + white_tile.pixels().all(|pixel| pixel.0[3] == 255), + "白色主题æè´¨ä¸èƒ½è¢«é€æ˜ŽåŒ–扣掉" + ); + assert!( + green_tile + .pixels() + .all(|pixel| pixel.0 != [255, 0, 255, 255]), + "残留洋红 key 色应被转æˆä¸é€æ˜Žæè´¨åº•色,ä¸èƒ½ç•™æˆå¯è§è¾¹" + ); + assert!( + white_tile + .pixels() + .all(|pixel| pixel.0 != [255, 0, 255, 255]), + "残留洋红 key 色应被转æˆä¸é€æ˜Žæè´¨åº•色,ä¸èƒ½ç•™æˆå¯è§è¾¹" + ); } #[test] - fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() { + fn jump_hop_tile_asset_slots_are_unique_for_eighteen_slices() { let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) .map(jump_hop_tile_asset_slot_name) .collect::>(); @@ -2039,7 +2359,31 @@ mod tests { assert_eq!( unique_slots.len(), JUMP_HOP_TILE_ITEM_COUNT, - "25 个地å—切片必须写入 25 个独立 slot/path,ä¸èƒ½æŒ‰é‡å¤çš„ tile_type 互相覆盖" + "18 ä¸ªåœ°æ¿ UV 大å•元必须写入 18 个独立 slot/path,ä¸èƒ½æŒ‰é‡å¤çš„ tile_type 互相覆盖" + ); + + let face_slots = (0..JUMP_HOP_TILE_ITEM_COUNT) + .flat_map(|index| { + [ + JumpHopTileFaceKey::Top, + JumpHopTileFaceKey::Front, + JumpHopTileFaceKey::Right, + JumpHopTileFaceKey::Back, + JumpHopTileFaceKey::Left, + JumpHopTileFaceKey::Bottom, + ] + .into_iter() + .map(move |face| jump_hop_tile_face_asset_slot_name(index, &face)) + }) + .collect::>(); + let unique_face_slots = face_slots + .iter() + .cloned() + .collect::>(); + assert_eq!( + unique_face_slots.len(), + JUMP_HOP_TILE_ITEM_COUNT * 6, + "18 ä¸ªåœ°æ¿ UV 大å•元的 108 å¼ é¢è´´å›¾å¿…须写入独立 slot/path" ); } } diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index 71a990d5..8e7021a6 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -6,7 +6,9 @@ use crate::{ }; const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; -const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008; +const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.004; +const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 0.72; +const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 0.52; pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); @@ -62,8 +64,8 @@ pub fn start_run( pub fn apply_jump( run: &JumpHopRunSnapshot, drag_distance: f32, - drag_vector_x: Option, - drag_vector_y: Option, + _drag_vector_x: Option, + _drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { @@ -86,20 +88,15 @@ pub fn apply_jump( let vector_x = target.x - current.x; let vector_y = target.y - current.y; let target_distance = vector_x.hypot(vector_y).max(0.0001); - let (unit_x, unit_y) = normalize_jump_direction( - drag_vector_x, - drag_vector_y, - vector_x / target_distance, - vector_y / target_distance, - ); + let unit_x = vector_x / target_distance; + let unit_y = vector_y / target_distance; let landed_x = current.x + unit_x * jump_distance; let landed_y = current.y + unit_y * jump_distance; - let landing_error = (landed_x - target.x).hypot(landed_y - target.y); - let target_landing_radius = target.landing_radius; + let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y); let mut next = run.clone(); next.path = path; - let result = if landing_error <= target_landing_radius { + let result = if landed_on_target { JumpHopJumpResultKind::Hit } else { JumpHopJumpResultKind::Miss @@ -128,6 +125,19 @@ pub fn apply_jump( Ok(next) } +fn is_landing_inside_platform_footprint( + platform: &JumpHopPlatform, + landed_x: f32, + landed_y: f32, +) -> bool { + let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0); + let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0); + let error_x = landed_x - platform.x; + let error_y = landed_y - platform.y; + + error_x.abs() <= half_width && error_y.abs() <= half_height +} + pub fn restart_run( run: &JumpHopRunSnapshot, next_run_id: String, @@ -250,30 +260,6 @@ fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHop path } -fn normalize_jump_direction( - drag_vector_x: Option, - drag_vector_y: Option, - fallback_x: f32, - fallback_y: f32, -) -> (f32, f32) { - let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - // å‰ç«¯æäº¤çš„æ˜¯å±å¹•拖拽å‘é‡ï¼šx è½´åŒå‘,y è½´å‘下为正。 - // 真实起跳需è¦â€œåå‘弹出â€ï¼ŒåŒæ—¶æŠŠå±å¹• y ç¿»å›žä¸–ç•Œåæ ‡çš„å‘上为正。 - let jump_x = -drag_x; - let jump_y = drag_y; - let length = jump_x.hypot(jump_y); - if length < 0.0001 { - (fallback_x, fallback_y) - } else { - (jump_x / length, jump_y / length) - } -} - fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { @@ -353,8 +339,8 @@ impl DeterministicRng { #[cfg(test)] mod tests { use crate::{ - JumpHopDifficulty, JumpHopJumpResultKind, JumpHopRunStatus, apply_jump, - generate_jump_hop_path, restart_run, start_run, + JumpHopDifficulty, JumpHopJumpResultKind, JumpHopPlatform, JumpHopRunStatus, + JumpHopTileType, apply_jump, generate_jump_hop_path, restart_run, start_run, }; #[test] @@ -371,16 +357,17 @@ mod tests { } #[test] - fn difficulty_charge_to_distance_ratio_is_doubled() { + fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() { let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy); let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard); let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced); - let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); + let challenge = + generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); - assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008); - assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(easy.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(standard.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.004); + assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.004); } #[test] @@ -454,7 +441,7 @@ mod tests { None, 200, ) - .expect("jump should resolve"); + .expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, @@ -463,7 +450,7 @@ mod tests { } #[test] - fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() { + fn jump_resolution_ignores_client_drag_direction_and_targets_next_center() { let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); let run = start_run( "run-screen-axis".to_string(), @@ -478,21 +465,49 @@ mod tests { let target_distance = (target.x - current.x).hypot(target.y - current.y); let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; - let result = apply_jump( - &run, - charge as f32, - Some(-(target.x - current.x)), - Some(target.y - current.y), - 200, - ) - .expect("jump should resolve"); + let result = apply_jump(&run, charge as f32, Some(-999.0), Some(-999.0), 200) + .expect("jump should resolve"); + let last_jump = result.last_jump.as_ref().expect("last jump should exist"); assert_eq!(result.status, JumpHopRunStatus::Playing); - assert_eq!( - result.last_jump.as_ref().unwrap().result, - JumpHopJumpResultKind::Hit - ); + assert_eq!(last_jump.result, JumpHopJumpResultKind::Hit); assert_eq!(result.current_platform_index, 1); + assert!((last_jump.landed_x - target.x).abs() < target.landing_radius); + assert!((last_jump.landed_y - target.y).abs() < target.landing_radius); + } + + #[test] + fn jump_resolution_uses_visual_top_face_footprint_instead_of_landing_radius() { + let mut path = generate_jump_hop_path("seed-footprint", JumpHopDifficulty::Easy); + path.platforms[0] = test_platform("p0", 0.0, 0.0, 1.2, 1.0); + path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 0.6); + path.scoring.max_charge_ms = 600; + let run = start_run( + "run-footprint".to_string(), + "user-footprint".to_string(), + "profile-footprint".to_string(), + path, + 100, + ) + .expect("run should start"); + + let edge_hit_charge = 1.6 / run.path.scoring.charge_to_distance_ratio; + let edge_hit = + apply_jump(&run, edge_hit_charge, None, None, 200).expect("jump should resolve"); + let last_hit = edge_hit.last_jump.as_ref().expect("last jump should exist"); + assert_eq!(edge_hit.status, JumpHopRunStatus::Playing); + assert_eq!(last_hit.result, JumpHopJumpResultKind::Hit); + assert!(last_hit.landed_x > 1.5); + assert!(last_hit.landed_x <= 1.72); + + let outside_charge = 1.8 / run.path.scoring.charge_to_distance_ratio; + let outside = + apply_jump(&run, outside_charge, None, None, 200).expect("jump should resolve"); + assert_eq!(outside.status, JumpHopRunStatus::Failed); + assert_eq!( + outside.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Miss + ); } #[test] @@ -551,4 +566,18 @@ mod tests { assert!(run.path.platforms.len() >= 12); assert!(run.finished_at_ms.is_none()); } + + fn test_platform(id: &str, x: f32, y: f32, width: f32, height: f32) -> JumpHopPlatform { + JumpHopPlatform { + platform_id: id.to_string(), + tile_type: JumpHopTileType::Normal, + x, + y, + width, + height, + landing_radius: 0.2, + perfect_radius: 0.1, + score_value: 1, + } + } } diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index 6fcd23dd..70754cae 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -18,6 +18,7 @@ use super::{ }, response::handle_vector_engine_response, types::{GeneratedImages, ReferenceImage, VectorEngineImageSettings}, + util::truncate_raw, }; pub async fn create_vector_engine_image_generation( @@ -66,7 +67,25 @@ pub async fn create_vector_engine_image_generation( ) .await { - Ok(response) => break response, + Ok(response) => { + if should_retry_vector_engine_upstream_status(response.status, attempt) { + retry_vector_engine_upstream_status_after_delay( + "generation", + request_url.as_str(), + attempt, + response.status, + response.body.as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + Some(&request_body), + ) + .await; + attempt += 1; + continue; + } + break response; + } Err(error) => { if should_retry_vector_engine_curl_send_error(&error, attempt) { retry_vector_engine_send_after_delay( @@ -75,7 +94,7 @@ pub async fn create_vector_engine_image_generation( "request_send", attempt, error.is_timeout(), - error.is_connect(), + error.is_connect() || error.is_transient_transport(), true, false, error.to_string().as_str(), @@ -220,7 +239,25 @@ pub async fn create_vector_engine_image_edit_with_references( ) .await { - Ok(response) => break response, + Ok(response) => { + if should_retry_vector_engine_upstream_status(response.status, attempt) { + retry_vector_engine_upstream_status_after_delay( + "edit", + request_url.as_str(), + attempt, + response.status, + response.body.as_str(), + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + ) + .await; + attempt += 1; + continue; + } + break response; + } Err(error) => { if should_retry_vector_engine_curl_send_error(&error, attempt) { retry_vector_engine_send_after_delay( @@ -229,7 +266,7 @@ pub async fn create_vector_engine_image_edit_with_references( "request_send", attempt, error.is_timeout(), - error.is_connect(), + error.is_connect() || error.is_transient_transport(), true, false, error.to_string().as_str(), @@ -290,7 +327,12 @@ fn should_retry_vector_engine_curl_send_error( error: &super::curl_transport::VectorEngineCurlError, attempt: u32, ) -> bool { - attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect()) + attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS + && (error.is_timeout() || error.is_connect() || error.is_transient_transport()) +} + +fn should_retry_vector_engine_upstream_status(status: u16, attempt: u32) -> bool { + attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (status == 408 || status == 429 || status >= 500) } async fn retry_vector_engine_send_after_delay( @@ -334,6 +376,40 @@ async fn retry_vector_engine_send_after_delay( tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; } +async fn retry_vector_engine_upstream_status_after_delay( + request_kind: &'static str, + request_url: &str, + attempt: u32, + status: u16, + raw_body: &str, + elapsed_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + request_params: Option<&serde_json::Value>, +) { + let delay_ms = vector_engine_send_retry_delay_ms(attempt, vector_engine_send_retry_jitter_ms()); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + request_kind, + failure_stage = "upstream_status", + attempt, + max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS, + retry_delay_ms = delay_ms, + status, + retryable = true, + elapsed_ms, + prompt_chars, + reference_image_count, + raw_excerpt = %truncate_raw(raw_body), + request_params = %request_params + .map(|value| value.to_string()) + .unwrap_or_default(), + "VectorEngine 图片上游状æ€å¯é‡è¯•,准备é‡è¯•" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; +} + fn vector_engine_send_retry_delay_ms(attempt: u32, jitter_ms: u64) -> u64 { let exponential_factor = 1_u64 << attempt.saturating_sub(1).min(10); let bounded_jitter_ms = jitter_ms.min(VECTOR_ENGINE_SEND_RETRY_MAX_JITTER_MS); @@ -357,6 +433,33 @@ mod tests { assert_eq!(VECTOR_ENGINE_SEND_MAX_ATTEMPTS, 5); } + #[test] + fn vector_engine_send_retry_policy_treats_ssl_reset_as_transient_transport() { + let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(35)); + + assert!(error.is_transient_transport()); + assert!(should_retry_vector_engine_curl_send_error(&error, 1)); + assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + } + + #[test] + fn vector_engine_send_retry_policy_treats_recv_eof_as_transient_transport() { + let error = super::super::curl_transport::VectorEngineCurlError::Curl(curl::Error::new(56)); + + assert!(error.is_transient_transport()); + assert!(should_retry_vector_engine_curl_send_error(&error, 1)); + assert!(!should_retry_vector_engine_curl_send_error(&error, 5)); + } + + #[test] + fn vector_engine_send_retry_policy_treats_upstream_502_as_retryable() { + assert!(should_retry_vector_engine_upstream_status(502, 1)); + assert!(should_retry_vector_engine_upstream_status(429, 1)); + assert!(should_retry_vector_engine_upstream_status(408, 1)); + assert!(!should_retry_vector_engine_upstream_status(400, 1)); + assert!(!should_retry_vector_engine_upstream_status(502, 5)); + } + #[test] fn vector_engine_send_retry_delay_uses_exponential_backoff_with_bounded_jitter() { assert_eq!(vector_engine_send_retry_delay_ms(1, 0), 500); diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index 1991bdda..a5c6af67 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -45,6 +45,25 @@ impl VectorEngineCurlError { Self::Form(_) | Self::WorkerJoin(_) => false, } } + + pub(crate) fn is_transient_transport(&self) -> bool { + match self { + Self::Curl(error) => { + let message = error.to_string().to_ascii_lowercase(); + error.is_ssl_connect_error() + || error.is_recv_error() + || error.is_send_error() + || message.contains("connection reset") + || message.contains("recv failure") + || message.contains("receive failure") + || message.contains("receiving data") + || message.contains("unexpected eof") + || message.contains("send failure") + || message.contains("broken pipe") + } + Self::Form(_) | Self::WorkerJoin(_) => false, + } + } } impl fmt::Display for VectorEngineCurlError { @@ -136,7 +155,7 @@ pub(crate) fn map_curl_error( request_params: Option<&Value>, ) -> PlatformImageError { let is_timeout = error.is_timeout(); - let is_connect = error.is_connect(); + let is_connect = error.is_connect() || error.is_transient_transport(); let source = error.to_string(); let message = format!("{context}:{source}"); let audit = build_failure_audit( diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index c53d63c2..8dadd9eb 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -1,8 +1,8 @@ use platform_image::vector_engine::{ GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, - create_vector_engine_image_edit, vector_engine_images_edit_url, - vector_engine_images_generation_url, + create_vector_engine_image_edit, create_vector_engine_image_generation, + vector_engine_images_edit_url, vector_engine_images_generation_url, }; use std::{ sync::{ @@ -109,3 +109,72 @@ async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { assert_eq!(request_count.load(Ordering::SeqCst), 2); server.abort(); } + +#[tokio::test] +async fn vector_engine_image_generation_retries_upstream_502_once_and_succeeds() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let server_addr = listener + .local_addr() + .expect("mock server address should be readable"); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_for_server = Arc::clone(&request_count); + + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let _ = stream.read(&mut buffer).await; + if request_index == 0 { + let body = "502 Bad Gateway

502 Bad Gateway


nginx
"; + let response = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + + let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + let settings = VectorEngineImageSettings { + base_url: format!("http://{server_addr}/v1"), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let http_client = + build_vector_engine_image_http_client(&settings).expect("client should build"); + + let generated = create_vector_engine_image_generation( + &http_client, + &settings, + "测试æç¤ºè¯", + None, + "1024x1024", + 1, + &[], + "测试 VectorEngine 图片生æˆå¤±è´¥", + ) + .await + .expect("second attempt should return generated image"); + + assert_eq!(generated.images.len(), 1); + assert_eq!(generated.images[0].mime_type, "image/png"); + assert_eq!(request_count.load(Ordering::SeqCst), 2); + server.abort(); +} diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 826130f4..3bc62911 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -166,6 +166,45 @@ pub struct JumpHopTileAsset { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + #[serde(default)] + pub face_assets: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopTileFaceKey { + Top, + Front, + Right, + Back, + Left, + Bottom, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAsset { + pub face: JumpHopTileFaceKey, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssets { + pub top: JumpHopTileFaceAsset, + pub front: JumpHopTileFaceAsset, + pub right: JumpHopTileFaceAsset, + pub back: JumpHopTileFaceAsset, + pub left: JumpHopTileFaceAsset, + pub bottom: JumpHopTileFaceAsset, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 9f1aeef1..6274315c 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -473,9 +473,9 @@ fn validate_jump_hop_runtime_ready( } validate_jump_hop_default_character_ready(work)?; validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; - if work.tile_assets.len() < 25 { + if work.tile_assets.len() < 18 { return Err(SpacetimeClientError::validation_failed( - "jump-hop runtime éœ€è¦ 25 个地å—资产", + "jump-hop runtime éœ€è¦ 18 个地å—资产", )); } for (index, asset) in work.tile_assets.iter().enumerate() { @@ -761,12 +761,12 @@ fn build_compile_input( draft.default_character = Some(default_jump_hop_default_character()); let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实地å—图集资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", + "jump-hop compile-draft 缺少真实地æ¿è´´å›¾å›¾é›†èµ„产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", ) })?; - let tile_assets = if draft.tile_assets.len() < 25 { + let tile_assets = if draft.tile_assets.len() < 18 { return Err(SpacetimeClientError::validation_failed( - "jump-hop compile-draft éœ€è¦ 25 个真实地å—资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", + "jump-hop compile-draft éœ€è¦ 18 个真实地å—资产,请先由 api-server 生æˆå¹¶æŒä¹…化 asset_object", )); } else { draft.tile_assets.clone() @@ -878,7 +878,7 @@ fn default_draft() -> JumpHopDraftResponse { style_preset: JumpHopStylePreset::MinimalBlocks, default_character: Some(default_jump_hop_default_character()), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: "跳一跳主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹".to_string(), + tile_prompt: "跳一跳主题的3D立方体主题身份方å—包装图集".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, @@ -994,7 +994,7 @@ mod tests { const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] - fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character() + fn jump_hop_action_compile_draft_builds_compile_input_with_18_tile_assets_and_builtin_character() { let session = session_with_draft(draft_without_character_asset()); let payload = action(JumpHopActionType::CompileDraft); @@ -1028,9 +1028,9 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("old-tile-25-object") + .contains("old-tile-18-object") ); - assert_eq!(draft.tile_assets.len(), 25); + assert_eq!(draft.tile_assets.len(), 18); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } @@ -1040,7 +1040,7 @@ mod tests { let mut payload = action(JumpHopActionType::RegenerateTiles); payload.tile_prompt = Some("æ–°çš„åœ°å—æç¤ºè¯".to_string()); payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS)); - payload.tile_assets = Some(tile_assets("new", 25)); + payload.tile_assets = Some(tile_assets("new", 18)); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) @@ -1082,7 +1082,7 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("new-tile-25-object") + .contains("new-tile-18-object") ); } @@ -1196,7 +1196,7 @@ mod tests { JumpHopDraftResponse { profile_id: None, tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), - tile_assets: tile_assets("old", 25), + tile_assets: tile_assets("old", 18), ..base_draft() } } @@ -1206,7 +1206,7 @@ mod tests { profile_id: Some(PROFILE_ID.to_string()), character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")), tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), - tile_assets: tile_assets("old", 25), + tile_assets: tile_assets("old", 18), path: Some(sample_jump_hop_path()), cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), generation_status: JumpHopGenerationStatus::Ready, @@ -1243,13 +1243,14 @@ mod tests { index + 1 ), asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1), - source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1), - atlas_row: Some(index as u32 / 5 + 1), - atlas_col: Some(index as u32 % 5 + 1), + source_atlas_cell: format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1), + atlas_row: Some(index as u32 / 3 + 1), + atlas_col: Some(index as u32 % 3 + 1), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, landing_radius: 34.0, + face_assets: None, }) .collect() } diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index 5a5a8a5e..b37dc8f3 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -8,9 +8,9 @@ pub use shared_contracts::jump_hop::{ JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, - JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, + JumpHopTileFaceAsset, JumpHopTileFaceAssets, JumpHopTileFaceKey, JumpHopTileType, + JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, + JumpHopWorkSummaryResponse, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; pub(crate) fn map_jump_hop_agent_session_procedure_result( @@ -267,6 +267,33 @@ fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset { visual_height: snapshot.visual_height, top_surface_radius: snapshot.top_surface_radius, landing_radius: snapshot.landing_radius, + face_assets: snapshot.face_assets.map(map_tile_face_assets), + } +} + +fn map_tile_face_assets(snapshot: JumpHopTileFaceAssetsSnapshot) -> JumpHopTileFaceAssets { + JumpHopTileFaceAssets { + top: map_tile_face_asset(snapshot.top), + front: map_tile_face_asset(snapshot.front), + right: map_tile_face_asset(snapshot.right), + back: map_tile_face_asset(snapshot.back), + left: map_tile_face_asset(snapshot.left), + bottom: map_tile_face_asset(snapshot.bottom), + } +} + +fn map_tile_face_asset(snapshot: JumpHopTileFaceAssetSnapshot) -> JumpHopTileFaceAsset { + JumpHopTileFaceAsset { + face: parse_tile_face_key(&snapshot.face), + asset_id: snapshot.asset_id, + image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, + asset_object_id: snapshot.asset_object_id, + generation_provider: snapshot.generation_provider, + prompt: snapshot.prompt, + width: snapshot.width, + height: snapshot.height, + source_atlas_cell: snapshot.source_atlas_cell, } } @@ -405,6 +432,17 @@ fn parse_tile_type(value: &str) -> JumpHopTileType { } } +fn parse_tile_face_key(value: &str) -> JumpHopTileFaceKey { + match value { + "front" => JumpHopTileFaceKey::Front, + "right" => JumpHopTileFaceKey::Right, + "back" => JumpHopTileFaceKey::Back, + "left" => JumpHopTileFaceKey::Left, + "bottom" => JumpHopTileFaceKey::Bottom, + _ => JumpHopTileFaceKey::Top, + } +} + fn parse_domain_tile_type(value: crate::module_bindings::JumpHopTileType) -> JumpHopTileType { match value { crate::module_bindings::JumpHopTileType::Start => JumpHopTileType::Start, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index acdf3fc5..d2ac5477 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -463,6 +463,8 @@ pub mod jump_hop_runtime_run_row_type; pub mod jump_hop_runtime_run_table; pub mod jump_hop_scoring_type; pub mod jump_hop_tile_asset_snapshot_type; +pub mod jump_hop_tile_face_asset_snapshot_type; +pub mod jump_hop_tile_face_assets_snapshot_type; pub mod jump_hop_tile_type_type; pub mod jump_hop_work_delete_input_type; pub mod jump_hop_work_get_input_type; @@ -1567,6 +1569,8 @@ pub use jump_hop_runtime_run_row_type::JumpHopRuntimeRunRow; pub use jump_hop_runtime_run_table::*; pub use jump_hop_scoring_type::JumpHopScoring; pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; +pub use jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot; +pub use jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot; pub use jump_hop_tile_type_type::JumpHopTileType; pub use jump_hop_work_delete_input_type::JumpHopWorkDeleteInput; pub use jump_hop_work_get_input_type::JumpHopWorkGetInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs index 9ca1fe02..5223f15a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs @@ -4,6 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::jump_hop_tile_face_assets_snapshot_type::JumpHopTileFaceAssetsSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct JumpHopTileAssetSnapshot { @@ -19,6 +21,7 @@ pub struct JumpHopTileAssetSnapshot { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + pub face_assets: Option, } impl __sdk::InModule for JumpHopTileAssetSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs new file mode 100644 index 00000000..b2b27196 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_asset_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopTileFaceAssetSnapshot { + pub face: String, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +impl __sdk::InModule for JumpHopTileFaceAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs new file mode 100644 index 00000000..7625d48f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_face_assets_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_tile_face_asset_snapshot_type::JumpHopTileFaceAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopTileFaceAssetsSnapshot { + pub top: JumpHopTileFaceAssetSnapshot, + pub front: JumpHopTileFaceAssetSnapshot, + pub right: JumpHopTileFaceAssetSnapshot, + pub back: JumpHopTileFaceAssetSnapshot, + pub left: JumpHopTileFaceAssetSnapshot, + pub bottom: JumpHopTileFaceAssetSnapshot, +} + +impl __sdk::InModule for JumpHopTileFaceAssetsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 743d62f9..ee865db2 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1311,7 +1311,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}主题的正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹"), + tile_prompt: format!("{seed}主题的3D立方体主题身份方å—包装图集"), end_mood_prompt: String::new(), } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs index 42c1d12b..218a4b46 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -232,6 +232,34 @@ pub struct JumpHopTileAssetSnapshot { pub visual_height: u32, pub top_surface_radius: f32, pub landing_radius: f32, + #[serde(default)] + pub face_assets: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssetSnapshot { + pub face: String, + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, + pub source_atlas_cell: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileFaceAssetsSnapshot { + pub top: JumpHopTileFaceAssetSnapshot, + pub front: JumpHopTileFaceAssetSnapshot, + pub right: JumpHopTileFaceAssetSnapshot, + pub back: JumpHopTileFaceAssetSnapshot, + pub left: JumpHopTileFaceAssetSnapshot, + pub bottom: JumpHopTileFaceAssetSnapshot, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index e5497b6a..0fb789b3 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -8,9 +8,12 @@ import type { JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; -import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel'; import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; -import { JumpHopRuntimeShell } from './JumpHopRuntimeShell'; +import { + JUMP_HOP_THREE_CAMERA_UP_Y, + JumpHopRuntimeShell, + getJumpHopThreeProjectedY, +} from './JumpHopRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({ @@ -44,22 +47,10 @@ function dispatchPointerEvent( target.dispatchEvent(event); } -test('跳一跳è¿è¡Œæ€æ¾æ‰‹æ—¶æäº¤å‘åŽæ‹–动å‘é‡', async () => { +test('跳一跳è¿è¡Œæ€æ¾æ‰‹æ—¶åªæäº¤é•¿æŒ‰è“„力值', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); - const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); - const current = visiblePlatforms[0]!; - const target = visiblePlatforms[1]!; - const stageSize = { width: 320, height: 568 }; - const xPixelsPerWorldUnit = - Math.abs( - ((target.screenX - current.screenX) / 100) * stageSize.width, - ) / Math.abs(target.platform.x - current.platform.x); - const yPixelsPerWorldUnit = - Math.abs( - ((target.screenY - current.screenY) / 100) * stageSize.height, - ) / Math.abs(target.platform.y - current.platform.y); render( { clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(360); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { @@ -96,28 +90,17 @@ test('跳一跳è¿è¡Œæ€æ¾æ‰‹æ—¶æäº¤å‘åŽæ‹–动å‘é‡', async () => { expect(onJump).toHaveBeenCalledTimes(1); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragDistance).toBeGreaterThan(74); - expect(jumpPayload?.dragDistance).toBeLessThan(76); + expect(jumpPayload?.dragVectorX).toBeUndefined(); + expect(jumpPayload?.dragVectorY).toBeUndefined(); + expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360); + expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380); vi.useRealTimers(); }); -test('跳一跳è¿è¡Œæ€æ‹–æ‹½æ–¹å‘æŒ‰æ‰‹æŒ‡èµ·ç‚¹åˆ°æ¾æ‰‹ç‚¹è®¡ç®—', async () => { +test('跳一跳è¿è¡Œæ€æ‰‹æŒ‡ç§»åЍ䏿”¹å˜æäº¤æ–¹å‘', async () => { + vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); - const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); - const current = visiblePlatforms[0]!; - const target = visiblePlatforms[1]!; - const stageSize = { width: 320, height: 568 }; - const xPixelsPerWorldUnit = - Math.abs( - ((target.screenX - current.screenX) / 100) * stageSize.width, - ) / Math.abs(target.platform.x - current.platform.x); - const yPixelsPerWorldUnit = - Math.abs( - ((target.screenY - current.screenY) / 100) * stageSize.height, - ) / Math.abs(target.platform.y - current.platform.y); render( { + await vi.advanceTimersByTimeAsync(240); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -152,15 +138,61 @@ test('跳一跳è¿è¡Œæ€æ‹–æ‹½æ–¹å‘æŒ‰æ‰‹æŒ‡èµ·ç‚¹åˆ°æ¾æ‰‹ç‚¹è®¡ç®—', async () }); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeLessThan(0); - expect(jumpPayload?.dragVectorY).toBeLessThan(0); - expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30); - expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20); - expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2); - expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragVectorX).toBeUndefined(); + expect(jumpPayload?.dragVectorY).toBeUndefined(); + expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240); + expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260); + vi.useRealTimers(); }); -test('跳一跳è¿è¡Œæ€ä¸å†æ˜¾ç¤ºæ—§åœ†å¼§è“„力æ¡è€Œæ˜¯æ˜¾ç¤ºå¼¹å¼“拉线', async () => { +test('跳一跳è¿è¡Œæ€é•¿æŒ‰è“„力ä¸ä¼šè¶…过åŽç«¯ä¸Šé™', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const baseRun = buildRun(); + const run: JumpHopRuntimeRunSnapshotResponse = { + ...baseRun, + path: { + ...baseRun.path, + scoring: { + ...baseRun.path.scoring, + maxChargeMs: 300, + }, + }, + }; + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(780); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + + expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300); + vi.useRealTimers(); +}); + +test('跳一跳è¿è¡Œæ€ä¸å†æ˜¾ç¤ºæ—§åœ†å¼§è“„力æ¡è€Œæ˜¯æ˜¾ç¤ºé•¿æŒ‰è“„力引导', async () => { const onJump = vi.fn().mockResolvedValue(undefined); render( @@ -183,10 +215,12 @@ test('跳一跳è¿è¡Œæ€ä¸å†æ˜¾ç¤ºæ—§åœ†å¼§è“„力æ¡è€Œæ˜¯æ˜¾ç¤ºå¼¹å¼“拉线', expect(screen.queryByText('èµ·è·³')).toBeNull(); expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); }); -test('è·³ä¸€è·³è“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ‹‰ä¼¸', async () => { +test('跳一跳蓄力时角色åªåšåž‚直压缩', async () => { + vi.useFakeTimers(); render( { }); }); await act(async () => { - dispatchPointerEvent(stage, 'pointermove', { - pointerId: 1, - clientX: 132, - clientY: 478, - }); + await vi.advanceTimersByTimeAsync(180); }); const character = screen.getByTestId('jump-hop-character-logo') @@ -221,12 +251,20 @@ test('è·³ä¸€è·³è“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ‹‰ä¼¸', async () => { .map((style) => style.textContent ?? '') .join('\n'); - expect(stretchTransform).toContain('matrix('); - expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)'); + expect(stretchTransform).toMatch(/^scale\((?[\d.]+), (?[\d.]+)\)$/); + const scaleMatch = stretchTransform.match( + /^scale\((?[\d.]+), (?[\d.]+)\)$/, + ); + const scaleX = Number(scaleMatch?.groups?.x ?? 1); + const scaleY = Number(scaleMatch?.groups?.y ?? 1); + expect(scaleX).toBeGreaterThan(1); + expect(scaleY).toBeLessThan(1); + expect(scaleY).toBeLessThan(scaleX); expect(styleText).toContain('var(--jump-hop-character-stretch-transform)'); expect(styleText).not.toContain( 'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))', ); + vi.useRealTimers(); }); test('跳一跳è¿è¡Œæ€æ¸¸çީ䏭åªä¿ç•™å¾—分并éšè—常驻排行榜', () => { @@ -379,7 +417,7 @@ test('跳一跳è‰ç¨¿è¿è¡Œå¤±è´¥åŽä¸è¯·æ±‚公开排行榜', () => { expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); }); -test('跳一跳角色层永远压在地å—层之上', () => { +test('跳一跳 Three.js 地æ¿å±‚ä½äºŽ DOM 角色层下方', () => { render( { const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0] ?.parentElement?.parentElement as HTMLElement | undefined; - expect(threeScene.style.zIndex).toBe('100'); + expect(threeScene.style.zIndex).toBe('42'); expect(Number(threeScene.style.zIndex)).toBeGreaterThan( Number(firstPlatform?.style.zIndex ?? 0), ); }); -test('跳一跳拖拽时éšè—è½ç‚¹è¾…助标识但ä¿ç•™å¼¹å¼“拉线', async () => { +test('跳一跳 Three.js å¹³å°å±‚å’Œ DOM è§’è‰²å±‚ä¿æŒåŒå‘å±å¹•åæ ‡', () => { + expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1); + expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284); + expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284); +}); + +test('跳一跳蓄力时éšè—è½ç‚¹è¾…助标识但ä¿ç•™è“„力引导', async () => { const onJump = vi.fn().mockResolvedValue(undefined); render( @@ -429,7 +473,7 @@ test('跳一跳拖拽时éšè—è½ç‚¹è¾…助标识但ä¿ç•™å¼¹å¼“拉线', async () }); expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); await act(async () => { dispatchPointerEvent(stage, 'pointermove', { @@ -440,10 +484,11 @@ test('跳一跳拖拽时éšè—è½ç‚¹è¾…助标识但ä¿ç•™å¼¹å¼“拉线', async () }); expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); }); -test('跳一跳è¿è¡Œæ€ç›´æŽ¥æ¸²æŸ“生æˆçš„地å—切片图片', () => { +test('跳一跳è¿è¡Œæ€ç›´æŽ¥æ¸²æŸ“生æˆçš„地æ¿è´´å›¾åˆ‡ç‰‡å›¾ç‰‡', () => { render( { + render( + {}} + />, + ); + + const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image'); + const faceImageSources = preloadImages + .map((image) => image.getAttribute('src') ?? '') + .filter((source) => + source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'), + ); + + const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/); + const firstTileNumber = firstTileMatch?.[1]; + expect(firstTileNumber).toBeTruthy(); + expect(faceImageSources).toEqual( + expect.arrayContaining([ + expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`), + expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`), + ]), + ); + const frontSource = `/tile-${firstTileNumber}-front/image.png`; + const frontRefreshKey = `asset-object-${firstTileNumber}-front`; + expect( + vi + .mocked(useResolvedAssetReadUrl) + .mock.calls.some( + ([source, options]) => + source?.includes(frontSource) && options?.refreshKey === frontRefreshKey, + ), + ).toBe(true); +}); + test('跳一跳è¿è¡Œæ€é¦–å—地å—è½åœ¨ä¸­ä¸‹æ–¹å¹¶ä¸”åŽç»­ä¸¤å—å‘中央和上方展开', () => { render( { @@ -604,11 +691,11 @@ test('跳一跳åŽç«¯å›žåŒ…较慢时角色åœåœ¨ç›®æ ‡ç‚¹ç­‰å¾…推进', async () successfulJumpCount: 1, score: 1, lastJump: { - chargeMs: 150, - jumpDistance: 1.44, + chargeMs: 420, + jumpDistance: 1.68, targetPlatformIndex: 1, - landedX: 0.8, - landedY: 1.2, + landedX: 0.93, + landedY: 1.4, result: 'hit', }, }; @@ -636,6 +723,9 @@ test('跳一跳åŽç«¯å›žåŒ…较慢时角色åœåœ¨ç›®æ ‡ç‚¹ç­‰å¾…推进', async () clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(420); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -678,6 +768,63 @@ test('跳一跳åŽç«¯å›žåŒ…较慢时角色åœåœ¨ç›®æ ‡ç‚¹ç­‰å¾…推进', async () vi.useRealTimers(); }); +test('跳一跳æˆåŠŸè½ç‚¹åç§»åŽä¸‹ä¸€è·³è§†è§‰ä»æœä¸‹ä¸€å—åœ°å—æ–¹å‘', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const run: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 300, + jumpDistance: 1.0, + targetPlatformIndex: 1, + landedX: 0, + landedY: 1.2, + result: 'hit', + }, + }; + + render( + {}} + />, + ); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + const initialLeft = Number.parseFloat(character.style.left); + const initialTop = Number.parseFloat(character.style.top); + const stage = screen.getByTestId('jump-hop-stage'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(1); + expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft); + expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop); + vi.useRealTimers(); +}); + test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); @@ -688,11 +835,25 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async successfulJumpCount: 1, score: 1, lastJump: { - chargeMs: 150, - jumpDistance: 1.44, + chargeMs: 420, + jumpDistance: 1.68, targetPlatformIndex: 1, - landedX: 0.8, - landedY: 1.2, + landedX: 0.93, + landedY: 1.4, + result: 'hit', + }, + }; + const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = { + ...buildRunWithExtraPreviewPlatform(), + currentPlatformIndex: 2, + successfulJumpCount: 2, + score: 2, + lastJump: { + chargeMs: 360, + jumpDistance: 1.44, + targetPlatformIndex: 2, + landedX: -0.2, + landedY: 2.4, result: 'hit', }, }; @@ -720,6 +881,9 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async clientY: 478, }); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(420); + }); await act(async () => { dispatchPointerEvent(stage, 'pointerup', { pointerId: 1, @@ -731,7 +895,7 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async expect(onJump).toHaveBeenCalledTimes(1); expect(stage.getAttribute('data-jump-animating')).toBe('true'); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( 'true', @@ -753,7 +917,7 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async 'true', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( 'true', @@ -775,6 +939,8 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async const landedCharacter = screen.getByTestId('jump-hop-character-logo') .parentElement as HTMLElement; expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true'); + expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1); + expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1); expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe( '0px', ); @@ -783,18 +949,23 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async ); const cameraLayer = screen.getByTestId('jump-hop-camera-layer'); expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true'); + expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe( + '1.3', + ); expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe( - '-28%', + '-17%', ); expect( Number.parseFloat( cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), ), - ).toBeCloseTo(12.29, 2); + ).toBeCloseTo(8.96, 2); const styleText = Array.from(document.querySelectorAll('style')) .map((style) => style.textContent ?? '') .join('\n'); expect(styleText).toContain('@keyframes jump-hop-character-recoil'); + expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift'); + expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))'); expect(styleText).toMatch( /data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/, ); @@ -826,7 +997,7 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async 'p1', ); expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( - '78%', + '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( '1.08', @@ -835,7 +1006,7 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async 'p2', ); expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe( - '50%', + '47%', ); await act(async () => { @@ -870,19 +1041,78 @@ test('è·³ä¸€è·³æ¾æ‰‹åŽå…ˆæ’­æ”¾é£žè¡ŒåŠ¨ç”»å†åˆ‡æ¢åˆ°ä¸‹ä¸€å—地å—', async expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe( 'false', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( - '78%', + const retainedOldPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p0']") as HTMLElement | null; + expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe( + 'exiting', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + expect(retainedOldPlatform?.style.top).toBe('81%'); + const currentPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p1']") as HTMLElement | null; + expect(currentPlatform?.style.top).toBe('64%'); + expect(currentPlatform?.getAttribute('data-current')).toBe( 'true', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + expect(currentPlatform?.getAttribute('data-platform-id')).toBe( 'p1', ); - expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( - '50%', + expect( + ( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p2']") as HTMLElement | null + )?.style.top, + ).toBe('47%'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 2, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(160); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 2, + clientX: 180, + clientY: 420, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(2); + + rerender( + {}} + />, ); + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + const movedOldPlatform = screen + .getByTestId('jump-hop-stage') + .querySelector("[data-platform-id='p0']") as HTMLElement | null; + if (movedOldPlatform) { + expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81); + } + expect( + ( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-current='true']") as HTMLElement | null + )?.getAttribute('data-platform-id'), + ).toBe('p2'); + vi.useRealTimers(); }); @@ -994,22 +1224,51 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse { }; } -function buildTileAssets() { - return Array.from({ length: 25 }, (_, index) => { +function buildTileAssets(options: { withFaceAssets?: boolean } = {}) { + return Array.from({ length: 18 }, (_, index) => { const tileNumber = String(index + 1).padStart(2, '0'); + const atlasRow = Math.floor(index / 3) + 1; + const atlasCol = (index % 3) + 1; + const buildFaceAsset = ( + face: keyof NonNullable< + JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets'] + >, + ) => ({ + face, + assetId: `asset-${tileNumber}-${face}`, + imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`, + imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`, + assetObjectId: `asset-object-${tileNumber}-${face}`, + generationProvider: 'vector-engine', + prompt: `tile ${tileNumber} ${face}`, + width: 256, + height: 256, + sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`, + }); + const faceAssets: NonNullable< + JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets'] + > = { + top: buildFaceAsset('top'), + front: buildFaceAsset('front'), + right: buildFaceAsset('right'), + back: buildFaceAsset('back'), + left: buildFaceAsset('left'), + bottom: buildFaceAsset('bottom'), + }; return { tileType: index === 0 ? 'start' : 'normal', tileId: `tile-${tileNumber}`, imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, assetObjectId: `asset-object-${tileNumber}`, - sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`, - atlasRow: Math.floor(index / 5) + 1, - atlasCol: (index % 5) + 1, + sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`, + atlasRow, + atlasCol, visualWidth: 256, - visualHeight: 192, + visualHeight: 256, topSurfaceRadius: 42, landingRadius: 34, + faceAssets: options.withFaceAssets ? faceAssets : undefined, } satisfies JumpHopWorkProfileResponse['tileAssets'][number]; }); } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index b0c9f1f4..3855216b 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -14,15 +14,19 @@ import { import jumpHopRuntimeLevelLogo from '../../../media/logo.png'; import type { JumpHopRuntimeRunSnapshotResponse, + JumpHopTileFaceAsset, JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { + isGeneratedLegacyPath, + readAssetBytes, +} from '../../services/assetReadUrlService'; import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpHopClient'; import { buildJumpHopVisiblePlatforms, formatJumpHopDurationLabel, - getJumpHopBackendDragVector, getJumpHopCharacterVisualPosition, getJumpHopJumpFeedbackLabel, getJumpHopLandingAssistVisualPosition, @@ -40,8 +44,8 @@ import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMa type JumpHopRuntimeJumpPayload = { dragDistance: number; - dragVectorX: number; - dragVectorY: number; + dragVectorX?: number; + dragVectorY?: number; }; type JumpHopVisualJump = { @@ -49,6 +53,25 @@ type JumpHopVisualJump = { to: JumpHopCharacterVisualPosition; }; +type JumpHopPlatformRenderItem = JumpHopVisiblePlatform & { + renderKey: string; + advanceState: 'exiting' | 'camera' | 'idle'; +}; + +type JumpHopTilePreloadItem = { + textureKey: string; + asset: JumpHopTileAsset; +}; + +type JumpHopTileFaceKey = 'top' | 'front' | 'right' | 'back' | 'left' | 'bottom'; + +type JumpHopTileTextureSource = { + imageSrc: string; + imageObjectKey?: string; + assetObjectId?: string; + tileId?: string; +}; + type JumpHopRuntimeShellProps = { profile?: JumpHopWorkProfileResponse | null; run?: JumpHopRuntimeRunSnapshotResponse | null; @@ -67,9 +90,25 @@ const DEFAULT_MAX_DRAG_DISTANCE_PX = 180; const JUMP_HOP_ANIMATION_DURATION_MS = 560; const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; +const JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y = 122; +const JUMP_HOP_CAMERA_ZOOM = 1.3; const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = '/branding/jump-hop-taonier-character.png'; const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3; +const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [ + 'top', + 'front', + 'right', + 'back', + 'left', + 'bottom', +]; +const JUMP_HOP_THREE_CAMERA_PITCH_RAD = Math.PI / 4; +const JUMP_HOP_THREE_CAMERA_PITCH_COS = Math.cos( + JUMP_HOP_THREE_CAMERA_PITCH_RAD, +); +export const JUMP_HOP_THREE_CAMERA_UP_Y = 1; +const JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER = 1.34; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -82,36 +121,6 @@ function formatJumpHopCssNumber(value: number) { return value.toFixed(4).replace(/\.?0+$/, ''); } -function buildJumpHopDirectionalScaleMatrix({ - directionX, - directionY, - stretchScale, - crossScale, -}: { - directionX: number; - directionY: number; - stretchScale: number; - crossScale: number; -}) { - const distance = Math.hypot(directionX, directionY); - if (distance < 0.1) { - return 'matrix(1, 0, 0, 1, 0, 0)'; - } - - const unitX = directionX / distance; - const unitY = directionY / distance; - const stretchDelta = stretchScale - crossScale; - const a = crossScale + stretchDelta * unitX * unitX; - const b = stretchDelta * unitX * unitY; - const c = stretchDelta * unitX * unitY; - const d = crossScale + stretchDelta * unitY * unitY; - return `matrix(${formatJumpHopCssNumber(a)}, ${formatJumpHopCssNumber( - b, - )}, ${formatJumpHopCssNumber(c)}, ${formatJumpHopCssNumber( - d, - )}, 0, 0)`; -} - function getRun( run: JumpHopRuntimeRunSnapshotResponse | null | undefined, snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined, @@ -173,6 +182,41 @@ function buildJumpHopCharacterVisualPositionFromPlatform( }; } +function getJumpHopRunLandingVisualPosition({ + run, + platforms, + stageSize, +}: { + run: JumpHopRuntimeRunSnapshotResponse; + platforms: JumpHopVisiblePlatform[]; + stageSize: { width: number; height: number }; +}) { + const lastJump = run.lastJump; + if (!lastJump || stageSize.width <= 0 || stageSize.height <= 0) { + return null; + } + + return getJumpHopCharacterVisualPosition(run, platforms, stageSize); +} + +function getJumpHopThreeCubeSide( + platform: JumpHopVisiblePlatform['platform'], + scale: number, +) { + const platformSize = getJumpHopPlatformVisualSize(platform, scale); + return Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86); +} + +export function getJumpHopThreeProjectedY( + screenY: number, + viewportHeight: number, +) { + return ( + viewportHeight / 2 + + (viewportHeight / 2 - screenY) / JUMP_HOP_THREE_CAMERA_PITCH_COS + ); +} + function IsometricFallbackTile({ platform, }: { @@ -196,10 +240,104 @@ function IsometricFallbackTile({ ); } -function getJumpHopTileAssetRefreshKey(asset: JumpHopTileAsset | null) { +function getJumpHopTileAssetRefreshKey( + asset: JumpHopTileTextureSource | null | undefined, +) { return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; } +function getJumpHopTileFaceAsset( + asset: JumpHopTileAsset | null | undefined, + face: JumpHopTileFaceKey, +): JumpHopTileFaceAsset | null { + return asset?.faceAssets?.[face] ?? null; +} + +function getJumpHopTileTextureSource( + asset: JumpHopTileAsset | null | undefined, + face?: JumpHopTileFaceKey, +): JumpHopTileTextureSource | null { + const faceAsset = face ? getJumpHopTileFaceAsset(asset, face) : null; + if (faceAsset?.imageSrc) { + return { + imageSrc: faceAsset.imageSrc, + imageObjectKey: faceAsset.imageObjectKey, + assetObjectId: faceAsset.assetObjectId, + tileId: `${asset?.tileId ?? asset?.sourceAtlasCell ?? 'tile'}-${face}`, + }; + } + if (!asset?.imageSrc) { + return null; + } + return { + imageSrc: asset.imageSrc, + imageObjectKey: asset.imageObjectKey, + assetObjectId: asset.assetObjectId, + tileId: asset.tileId ?? asset.sourceAtlasCell, + }; +} + +function getJumpHopTileTextureKey(renderKey: string, face: JumpHopTileFaceKey) { + return `${renderKey}::${face}`; +} + +function getJumpHopTileTextureUrl( + textureUrls: Record, + renderKey: string, + face: JumpHopTileFaceKey, +) { + return textureUrls[getJumpHopTileTextureKey(renderKey, face)] ?? textureUrls[renderKey] ?? ''; +} + +function hasJumpHopTileTexturesReady( + textureUrls: Record, + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.imageSrc) { + return true; + } + if (!asset.faceAssets) { + return Boolean(textureUrls[renderKey]); + } + return JUMP_HOP_TILE_FACE_KEYS.every((face) => + Boolean(textureUrls[getJumpHopTileTextureKey(renderKey, face)]), + ); +} + +function getJumpHopActiveTextureKeys( + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.faceAssets) { + return [renderKey]; + } + return [ + renderKey, + ...JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureKey(renderKey, face), + ), + ]; +} + +function buildJumpHopTileTextureEntries( + asset: JumpHopTileAsset, + textureKey: string, +) { + if (!asset.faceAssets) { + const source = getJumpHopTileTextureSource(asset); + return source ? [{ textureKey, source }] : []; + } + + return JUMP_HOP_TILE_FACE_KEYS.map((face) => ({ + textureKey: getJumpHopTileTextureKey(textureKey, face), + source: getJumpHopTileTextureSource(asset, face), + })).filter( + (item): item is { textureKey: string; source: JumpHopTileTextureSource } => + Boolean(item.source?.imageSrc), + ); +} + function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { const value = source?.trim() ?? ''; if (!value) { @@ -214,13 +352,22 @@ function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { function JumpHopTileImage({ asset, platform, + textureKey, + onResolvedTextureUrl, }: { asset: JumpHopTileAsset | null; platform: JumpHopVisiblePlatform['platform']; + textureKey?: string; + onResolvedTextureUrl?: ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => void; }) { - const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); + const textureSource = getJumpHopTileTextureSource(asset, 'top'); + const assetRefreshKey = getJumpHopTileAssetRefreshKey(textureSource); const { resolvedUrl, isResolving } = useResolvedAssetReadUrl( - asset?.imageSrc, + textureSource?.imageSrc, { refreshKey: assetRefreshKey, }, @@ -236,14 +383,82 @@ function JumpHopTileImage({ const shouldShowImage = Boolean(resolvedUrl && !hasError); const shouldShowFallback = !shouldShowImage; + useEffect(() => { + if (!textureKey || !onResolvedTextureUrl) { + return; + } + + let disposed = false; + const assetSource = textureSource?.imageSrc?.trim() ?? ''; + const publishTextureUrl = ( + url: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + if (!disposed) { + onResolvedTextureUrl(textureKey, url, options); + } + }; + + publishTextureUrl(''); + if (!assetSource || !shouldShowImage || !isLoaded) { + return () => { + disposed = true; + }; + } + + if (!isGeneratedLegacyPath(assetSource)) { + publishTextureUrl(resolvedUrl ?? ''); + return () => { + disposed = true; + }; + } + + // 中文注释:Three.js 纹ç†ä¸èƒ½ç›´æŽ¥ä¾èµ–跨域 OSS ç­¾å URLï¼›è½¬åŒæºå­—节为 blob,é¿å… bucket CORS 导致 WebGL 贴图失败。 + void readAssetBytes(assetSource) + .then((response) => response.blob()) + .then((blob) => { + if (disposed) { + return; + } + publishTextureUrl(URL.createObjectURL(blob), { + parentOwnedObjectUrl: true, + }); + }) + .catch(() => { + publishTextureUrl(''); + }); + + return () => { + disposed = true; + onResolvedTextureUrl(textureKey, ''); + }; + }, [ + isLoaded, + onResolvedTextureUrl, + resolvedUrl, + shouldShowImage, + textureSource?.imageSrc, + textureKey, + ]); + return (
{shouldShowFallback ? : null} + {asset?.faceAssets && textureKey + ? buildJumpHopTileTextureEntries(asset, textureKey).map((item) => ( + + )) + : null} {shouldShowImage ? ( void; +}) { + const assetRefreshKey = getJumpHopTileAssetRefreshKey(source); + const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(source.imageSrc, { refreshKey: assetRefreshKey, }); + useEffect(() => { + if (!textureKey || !onResolvedTextureUrl) { + return undefined; + } + + let disposed = false; + const assetSource = source.imageSrc?.trim() ?? ''; + const publishTextureUrl = ( + url: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + if (!disposed) { + onResolvedTextureUrl(textureKey, url, options); + } + }; + + if (!assetSource || !resolvedUrl) { + return () => { + disposed = true; + }; + } + + if (!isGeneratedLegacyPath(assetSource)) { + publishTextureUrl(resolvedUrl); + return () => { + disposed = true; + }; + } + + void readAssetBytes(assetSource) + .then((response) => response.blob()) + .then((blob) => { + if (disposed) { + return; + } + publishTextureUrl(URL.createObjectURL(blob), { + parentOwnedObjectUrl: true, + }); + }) + .catch(() => {}); + + return () => { + disposed = true; + }; + }, [ + onResolvedTextureUrl, + resolvedUrl, + source.imageSrc, + textureKey, + ]); + if (!resolvedUrl) { return ( @@ -284,7 +562,7 @@ function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { return ( <> @@ -300,6 +578,35 @@ function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { ); } +function JumpHopTilePreloadImage({ + asset, + textureKey, + onResolvedTextureUrl, +}: { + asset: JumpHopTileAsset; + textureKey: string; + onResolvedTextureUrl?: ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => void; +}) { + const sources = buildJumpHopTileTextureEntries(asset, textureKey); + + return ( + <> + {sources.map((item) => ( + + ))} + + ); +} + function hasJumpHopWebGLSupport() { if (import.meta.env.MODE === 'test') { return false; @@ -338,21 +645,29 @@ function JumpHopThreeScene({ characterPosition, chargeRatio, isJumpAnimating, + platforms, platformCount, renderCharacter, + textureUrlsByRenderKey, onCharacterLayerReadyChange, + onPlatformLayerReadyChange, }: { characterPosition: JumpHopCharacterVisualPosition | null; chargeRatio: number; isJumpAnimating: boolean; + platforms: JumpHopPlatformRenderItem[]; platformCount: number; renderCharacter: boolean; + textureUrlsByRenderKey: Record; onCharacterLayerReadyChange: Dispatch>; + onPlatformLayerReadyChange: Dispatch>; }) { const hostRef = useRef(null); const characterPositionRef = useRef(characterPosition); const chargeRatioRef = useRef(chargeRatio); const isJumpAnimatingRef = useRef(isJumpAnimating); + const platformsRef = useRef(platforms); + const textureUrlsByRenderKeyRef = useRef(textureUrlsByRenderKey); useEffect(() => { characterPositionRef.current = characterPosition; @@ -366,6 +681,14 @@ function JumpHopThreeScene({ isJumpAnimatingRef.current = isJumpAnimating; }, [isJumpAnimating]); + useEffect(() => { + platformsRef.current = platforms; + }, [platforms]); + + useEffect(() => { + textureUrlsByRenderKeyRef.current = textureUrlsByRenderKey; + }, [textureUrlsByRenderKey]); + useEffect(() => { const host = hostRef.current; if (!host) { @@ -373,15 +696,17 @@ function JumpHopThreeScene({ } onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); host.replaceChildren(); const fallbackCanvas = document.createElement('canvas'); applyJumpHopCanvasLayout(fallbackCanvas); fallbackCanvas.setAttribute('data-testid', 'jump-hop-three-canvas'); host.appendChild(fallbackCanvas); - if (!renderCharacter || !hasJumpHopWebGLSupport()) { + if (!hasJumpHopWebGLSupport()) { return () => { onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); fallbackCanvas.remove(); }; } @@ -391,7 +716,10 @@ function JumpHopThreeScene({ let cleanup: (() => void) | null = null; const setup = async () => { - const three = await import('three'); + const [three, roundedBoxModule] = await Promise.all([ + import('three'), + import('three/examples/jsm/geometries/RoundedBoxGeometry.js'), + ]); if (disposed || !hostRef.current) { return; } @@ -403,66 +731,290 @@ function JumpHopThreeScene({ }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); renderer.outputColorSpace = three.SRGBColorSpace; + renderer.sortObjects = true; const scene = new three.Scene(); scene.background = null; - const camera = new three.OrthographicCamera(0, 320, 0, 568, -100, 100); - camera.position.set(0, 0, 50); - camera.lookAt(0, 0, 0); + const camera = new three.OrthographicCamera( + -160, + 160, + 284, + -284, + 1, + 2400, + ); + // ä¸­æ–‡æ³¨é‡Šï¼šä¿æŒ Three å¹³å°å±‚å’Œ DOM 角色层的å±å¹• X è½´åŒå‘,é¿å… WebGL 地å—å·¦å³é•œåƒåŽè®©è·³è·ƒçœ‹èµ·æ¥åå‘。 + camera.up.set(0, JUMP_HOP_THREE_CAMERA_UP_Y, 0); - scene.add(new three.AmbientLight(0xffffff, 1.45)); - const keyLight = new three.DirectionalLight(0xffffff, 2.2); - keyLight.position.set(-80, 120, 80); + scene.add(new three.AmbientLight(0xffffff, 1.22)); + const keyLight = new three.DirectionalLight(0xffffff, 2.45); + keyLight.position.set(-90, 105, 110); scene.add(keyLight); - const rimLight = new three.DirectionalLight(0xffedd5, 0.8); - rimLight.position.set(120, 80, 60); + const fillLight = new three.DirectionalLight(0xfef3c7, 0.82); + fillLight.position.set(110, 96, 70); + scene.add(fillLight); + const rimLight = new three.DirectionalLight(0xffedd5, 0.64); + rimLight.position.set(120, 44, 120); scene.add(rimLight); - const character = new three.Group(); - const body = new three.Mesh( - new three.CapsuleGeometry(10, 22, 8, 18), - new three.MeshStandardMaterial({ - color: 0xdf7f40, - roughness: 0.74, - }), - ); - body.position.y = -28; - const head = new three.Mesh( - new three.SphereGeometry(11, 28, 20), - new three.MeshStandardMaterial({ - color: 0xf59e0b, - roughness: 0.7, - }), - ); - head.position.y = -62; - const accent = new three.Mesh( - new three.BoxGeometry(15, 7, 7), - new three.MeshStandardMaterial({ - color: 0x2563eb, - roughness: 0.64, - }), - ); - accent.position.set(0, -36, 10); - character.add(body, head, accent); - scene.add(character); + const character = renderCharacter ? new three.Group() : null; + if (character) { + const body = new three.Mesh( + new three.CapsuleGeometry(10, 22, 8, 18), + new three.MeshStandardMaterial({ + color: 0xdf7f40, + roughness: 0.74, + }), + ); + body.position.y = -28; + const head = new three.Mesh( + new three.SphereGeometry(11, 28, 20), + new three.MeshStandardMaterial({ + color: 0xf59e0b, + roughness: 0.7, + }), + ); + head.position.y = -62; + const accent = new three.Mesh( + new three.BoxGeometry(15, 7, 7), + new three.MeshStandardMaterial({ + color: 0x2563eb, + roughness: 0.64, + }), + ); + accent.position.set(0, -36, 10); + character.add(body, head, accent); + scene.add(character); + } - const size = { + const platformGroup = new three.Group(); + platformGroup.renderOrder = 20; + scene.add(platformGroup); + + // 中文注释:平å°å‡ ä½•åªåˆ›å»ºä¸€ä»½ï¼Œè¿è¡Œæ€åªåšç­‰æ¯”ç¼©æ”¾ï¼Œä¿æŒæ ‡å‡† 1x1x1 立方体规格。 + const platformGeometry = new roundedBoxModule.RoundedBoxGeometry( + 1, + 1, + 1, + 2, + 0.035, + ); + const shadowGeometry = new three.CircleGeometry(1, 48); + const shadowMaterial = new three.MeshBasicMaterial({ + color: 0x0f172a, + depthWrite: false, + opacity: 0.16, + transparent: true, + }); + const textureLoader = new three.TextureLoader(); + textureLoader.setCrossOrigin('anonymous'); + const textureCache = new Map(); + const materialCache = new Map< + string, + import('three').Material | import('three').Material[] + >(); + const fallbackMaterialCache = new Map(); + let platformSignature = ''; + + const getTexture = (url: string) => { + const cached = textureCache.get(url); + if (cached) { + return cached; + } + + const texture = textureLoader.load(url, () => { + renderer.render(scene, camera); + }); + texture.colorSpace = three.SRGBColorSpace; + texture.wrapS = three.ClampToEdgeWrapping; + texture.wrapT = three.ClampToEdgeWrapping; + texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 6); + textureCache.set(url, texture); + return texture; + }; + + const getPlatformMaterial = ( + item: JumpHopPlatformRenderItem, + textureUrls: Record, + ) => { + const textureUrl = getJumpHopTileTextureUrl( + textureUrls, + item.renderKey, + 'top', + ); + if (item.asset?.faceAssets && textureUrl) { + const cacheKey = JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureUrl(textureUrls, item.renderKey, face), + ).join('|'); + const cached = materialCache.get(cacheKey); + if (cached) { + return cached; + } + + // 中文注释:Three.js Box/RoundedBox æè´¨é¡ºåºä¸º right, left, top, bottom, front, back。 + const materials = [ + 'right', + 'left', + 'top', + 'bottom', + 'front', + 'back', + ].map((face) => { + const faceUrl = + getJumpHopTileTextureUrl( + textureUrls, + item.renderKey, + face as JumpHopTileFaceKey, + ) || textureUrl; + return new three.MeshStandardMaterial({ + alphaTest: 0.04, + map: getTexture(faceUrl), + metalness: 0, + roughness: 0.76, + transparent: true, + }); + }); + materialCache.set(cacheKey, materials); + return materials; + } + + if (textureUrl) { + const cached = materialCache.get(textureUrl); + if (cached) { + return cached; + } + + const material = new three.MeshStandardMaterial({ + alphaTest: 0.04, + map: getTexture(textureUrl), + metalness: 0, + roughness: 0.76, + transparent: true, + }); + materialCache.set(textureUrl, material); + return material; + } + + const tone = getJumpHopTileTone(item.platform.tileType); + const cached = fallbackMaterialCache.get(tone); + if (cached) { + return cached; + } + + const material = new three.MeshStandardMaterial({ + color: new three.Color(tone), + metalness: 0, + roughness: 0.82, + }); + fallbackMaterialCache.set(tone, material); + return material; + }; + + const viewportSize = { width: 320, height: 568, }; + + const syncCamera = () => { + const distance = + Math.max(viewportSize.width, viewportSize.height) * + JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER; + const targetX = viewportSize.width / 2; + const targetY = viewportSize.height / 2; + camera.left = -viewportSize.width / 2; + camera.right = viewportSize.width / 2; + camera.top = viewportSize.height / 2; + camera.bottom = -viewportSize.height / 2; + camera.position.set( + targetX, + targetY - + Math.cos(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance, + Math.sin(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance, + ); + camera.lookAt(targetX, targetY, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + }; + + const syncPlatformMeshes = () => { + const nextPlatforms = platformsRef.current; + const textureUrls = textureUrlsByRenderKeyRef.current; + const nextSignature = nextPlatforms + .map((item) => { + const cubeSide = getJumpHopThreeCubeSide( + item.platform, + item.scale, + ); + return [ + item.renderKey, + item.platform.platformId, + item.screenX.toFixed(3), + item.screenY.toFixed(3), + item.scale.toFixed(3), + cubeSide.toFixed(2), + textureUrls[item.renderKey] ?? '', + item.advanceState, + ].join(':'); + }) + .join('|'); + + if (nextSignature === platformSignature) { + return; + } + + platformSignature = nextSignature; + platformGroup.clear(); + + nextPlatforms.forEach((item) => { + const cubeSide = getJumpHopThreeCubeSide(item.platform, item.scale); + const root = new three.Group(); + const rootBaseX = (item.screenX / 100) * viewportSize.width; + const rootBaseY = getJumpHopThreeProjectedY( + (item.screenY / 100) * viewportSize.height, + viewportSize.height, + ); + root.position.set(rootBaseX, rootBaseY, 0); + root.renderOrder = 20 + item.index; + root.userData = { + advanceState: item.advanceState, + baseX: rootBaseX, + baseY: rootBaseY, + }; + + const shadow = new three.Mesh(shadowGeometry, shadowMaterial); + shadow.position.set(0, cubeSide * 0.32, -9); + shadow.scale.set( + Math.max(24, cubeSide * 0.48), + Math.max(7, cubeSide * 0.13), + 1, + ); + shadow.renderOrder = 10 + item.index; + + const mesh = new three.Mesh( + platformGeometry, + getPlatformMaterial(item, textureUrls), + ); + mesh.position.set(0, 0, 0); + mesh.rotation.set(0, 0, 0); + mesh.scale.setScalar(cubeSide); + mesh.renderOrder = 30 + item.index; + + root.add(shadow, mesh); + platformGroup.add(root); + }); + }; + const resize = () => { const rect = host.getBoundingClientRect(); const width = Math.max(1, rect.width || host.clientWidth || 320); const height = Math.max(1, rect.height || host.clientHeight || 568); - size.width = width; - size.height = height; + viewportSize.width = width; + viewportSize.height = height; renderer.setSize(width, height, false); - camera.left = 0; - camera.right = width; - camera.top = 0; - camera.bottom = height; - camera.updateProjectionMatrix(); + syncCamera(); + platformSignature = ''; + syncPlatformMeshes(); renderer.render(scene, camera); }; @@ -471,15 +1023,17 @@ function JumpHopThreeScene({ : null; resizeObserver?.observe(host); resize(); - onCharacterLayerReadyChange(true); + onCharacterLayerReadyChange(Boolean(character)); + onPlatformLayerReadyChange(true); const animate = () => { + syncPlatformMeshes(); const nextCharacterPosition = characterPositionRef.current; - if (nextCharacterPosition) { + if (character && nextCharacterPosition) { const nextChargeRatio = chargeRatioRef.current; const canvasPosition = resolveJumpHopCharacterCanvasPosition( nextCharacterPosition, - size, + viewportSize, ); character.visible = true; character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0); @@ -499,7 +1053,7 @@ function JumpHopThreeScene({ 1 - nextChargeRatio * 0.12, 1 + nextChargeRatio * 0.08, ); - } else { + } else if (character) { character.visible = false; } renderer.render(scene, camera); @@ -513,8 +1067,21 @@ function JumpHopThreeScene({ } resizeObserver?.disconnect(); disposeJumpHopThreeObject(scene); + textureCache.forEach((texture) => texture.dispose()); + materialCache.forEach((material) => { + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material.dispose(); + } + }); + fallbackMaterialCache.forEach((material) => material.dispose()); + shadowMaterial.dispose(); + platformGeometry.dispose(); + shadowGeometry.dispose(); renderer.dispose(); onCharacterLayerReadyChange(false); + onPlatformLayerReadyChange(false); }; }; @@ -526,14 +1093,18 @@ function JumpHopThreeScene({ fallbackCanvas.remove(); host.replaceChildren(); }; - }, [onCharacterLayerReadyChange, renderCharacter]); + }, [ + onCharacterLayerReadyChange, + onPlatformLayerReadyChange, + renderCharacter, + ]); return (
); @@ -610,10 +1181,11 @@ export function JumpHopRuntimeShell({ const [nowMs, setNowMs] = useState(() => Date.now()); const [isThreeCharacterLayerReady, setIsThreeCharacterLayerReady] = useState(false); - const [dragPointerPosition, setDragPointerPosition] = useState<{ - x: number; - y: number; - } | null>(null); + const [isThreePlatformLayerReady, setIsThreePlatformLayerReady] = + useState(false); + const [platformTextureUrlsByRenderKey, setPlatformTextureUrlsByRenderKey] = + useState>({}); + const platformTextureParentObjectUrlsRef = useRef>(new Set()); const [dragVector, setDragVector] = useState({ x: 0, y: 0 }); const [jumpAnimationProgress, setJumpAnimationProgress] = useState(0); const [isPlatformAdvancing, setIsPlatformAdvancing] = useState(false); @@ -625,8 +1197,8 @@ export function JumpHopRuntimeShell({ useState(0); const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); const stageRef = useRef(null); - const dragStartRef = useRef<{ x: number; y: number } | null>(null); - const dragCurrentRef = useRef<{ x: number; y: number } | null>(null); + const chargeStartedAtRef = useRef(null); + const chargeFrameRef = useRef(null); const animationFrameRef = useRef(null); const animationEndTimerRef = useRef(null); const landingRecoilEndTimerRef = useRef(null); @@ -715,13 +1287,33 @@ export function JumpHopRuntimeShell({ platformAdvanceExitingPlatforms, visiblePlatforms, ]); + const platformRenderKeySignature = useMemo( + () => platformRenderItems.map((item) => item.renderKey).join('|'), + [platformRenderItems], + ); + const shouldUseThreePlatformLayer = useMemo( + () => + isThreePlatformLayerReady && + platformRenderItems.every((item) => + hasJumpHopTileTexturesReady( + platformTextureUrlsByRenderKey, + item.renderKey, + item.asset, + ), + ), + [ + isThreePlatformLayerReady, + platformRenderItems, + platformTextureUrlsByRenderKey, + ], + ); 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(); + const assets = new Map(); for ( let index = startIndex; @@ -745,8 +1337,11 @@ export function JumpHopRuntimeShell({ if (!asset) { continue; } - const key = getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc; - assets.set(key, asset); + const key = platform.platformId; + assets.set(key, { + textureKey: platform.platformId, + asset, + }); } return [...assets.values()]; @@ -756,10 +1351,26 @@ export function JumpHopRuntimeShell({ stageRun?.path, visiblePlatforms.length, ]); + const landingAssistStageSize = + stageSize.width > 0 && stageSize.height > 0 + ? stageSize + : { width: 320, height: 568 }; const characterPosition = getJumpHopCharacterVisualPosition( stageRun, visiblePlatforms, + landingAssistStageSize, ); + const currentPlatformOriginPosition = useMemo(() => { + if (!stageRun) { + return null; + } + const currentPlatform = visiblePlatforms.find( + (item) => item.index === stageRun.currentPlatformIndex, + ); + return currentPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform(currentPlatform) + : null; + }, [stageRun, visiblePlatforms]); const jumpTargetPlatform = useMemo(() => { if (!stageRun) { return null; @@ -770,6 +1381,27 @@ export function JumpHopRuntimeShell({ ) ?? null ); }, [stageRun, visiblePlatforms]); + const targetDirection = useMemo(() => { + const directionOrigin = currentPlatformOriginPosition ?? characterPosition; + if (!directionOrigin || !jumpTargetPlatform) { + return null; + } + const targetCharacterPosition = + buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform); + const directionX = targetCharacterPosition.screenX - directionOrigin.screenX; + const directionY = targetCharacterPosition.screenY - directionOrigin.screenY; + const distance = Math.hypot(directionX, directionY); + if (distance < 0.0001) { + return null; + } + + return { + screenX: directionX, + screenY: directionY, + unitScreenX: directionX / distance, + unitScreenY: directionY / distance, + }; + }, [characterPosition, currentPlatformOriginPosition, jumpTargetPlatform]); const visualCharacterPosition = useMemo(() => { if (!characterPosition) { return null; @@ -777,65 +1409,35 @@ export function JumpHopRuntimeShell({ if (isJumpAnimating && visualJump) { return visualJump.to; } - if (!isJumpAnimating || !jumpTargetPlatform) { - return characterPosition; - } - - const targetCharacterPosition = buildJumpHopCharacterVisualPositionFromPlatform( - jumpTargetPlatform, - false, - ); - const easedProgress = 1 - Math.pow(1 - clamp(jumpAnimationProgress, 0, 1), 3); - const arcOffset = Math.sin(Math.PI * easedProgress) * -24; - - return { - screenX: - characterPosition.screenX + - (targetCharacterPosition.screenX - characterPosition.screenX) * easedProgress, - screenY: - characterPosition.screenY + - (targetCharacterPosition.screenY - characterPosition.screenY) * easedProgress + - arcOffset, - sceneX: - characterPosition.sceneX + - (targetCharacterPosition.sceneX - characterPosition.sceneX) * easedProgress, - sceneY: - characterPosition.sceneY + - (targetCharacterPosition.sceneY - characterPosition.sceneY) * easedProgress, - sceneZ: - characterPosition.sceneZ + - (targetCharacterPosition.sceneZ - characterPosition.sceneZ) * easedProgress, - isMiss: characterPosition.isMiss, - }; + return characterPosition; }, [ characterPosition, isJumpAnimating, - jumpAnimationProgress, - jumpTargetPlatform, visualJump, ]); - const landingAssistStageSize = - stageSize.width > 0 && stageSize.height > 0 - ? stageSize - : { width: 320, height: 568 }; const characterMotionStyle = useMemo(() => { const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)'; const recoilDistance = Math.hypot(dragVector.x, dragVector.y); - const recoilUnitX = recoilDistance > 0 ? dragVector.x / recoilDistance : 0; - const recoilUnitY = recoilDistance > 0 ? dragVector.y / recoilDistance : 0; + const recoilUnitX = + recoilDistance > 0 + ? dragVector.x / recoilDistance + : targetDirection + ? -targetDirection.unitScreenX + : 0; + const recoilUnitY = + recoilDistance > 0 + ? dragVector.y / recoilDistance + : targetDirection + ? -targetDirection.unitScreenY + : 0; let stretchTransform = idleTransform; - if (isCharging && dragPointerPosition && characterPosition) { - const anchorX = - landingAssistStageSize.width * (characterPosition.screenX / 100); - const anchorY = - landingAssistStageSize.height * (characterPosition.screenY / 100); - stretchTransform = buildJumpHopDirectionalScaleMatrix({ - directionX: dragPointerPosition.x - anchorX, - directionY: dragPointerPosition.y - anchorY, - stretchScale: 1 + chargeRatio * 0.62, - crossScale: 1 - chargeRatio * 0.16, - }); + if (isCharging) { + const squashY = 1 - chargeRatio * 0.32; + const squashX = 1 + chargeRatio * 0.1; + stretchTransform = `scale(${formatJumpHopCssNumber( + squashX, + )}, ${formatJumpHopCssNumber(squashY)})`; } return { @@ -857,13 +1459,12 @@ export function JumpHopRuntimeShell({ }; }, [ chargeRatio, - characterPosition, - dragPointerPosition, dragVector.x, dragVector.y, isCharging, landingAssistStageSize.height, landingAssistStageSize.width, + targetDirection, visualJump, ]); const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); @@ -881,6 +1482,79 @@ export function JumpHopRuntimeShell({ visiblePlatformsRef.current = visiblePlatforms; }, [visiblePlatforms]); + useEffect(() => { + const activeKeys = new Set([ + ...platformRenderItems.flatMap((item) => + getJumpHopActiveTextureKeys(item.renderKey, item.asset), + ), + ...preloadTileAssets.flatMap((item) => + getJumpHopActiveTextureKeys(item.textureKey, item.asset), + ), + ]); + setPlatformTextureUrlsByRenderKey((current) => { + let changed = false; + const next: Record = {}; + for (const [key, value] of Object.entries(current)) { + if (activeKeys.has(key)) { + next[key] = value; + } else { + changed = true; + if ( + value.startsWith('blob:') && + platformTextureParentObjectUrlsRef.current.has(value) + ) { + URL.revokeObjectURL(value); + platformTextureParentObjectUrlsRef.current.delete(value); + } + } + } + return changed ? next : current; + }); + }, [platformRenderItems, platformRenderKeySignature, preloadTileAssets]); + + const handleResolvedPlatformTextureUrl = useCallback( + ( + textureKey: string, + resolvedUrl: string, + options?: { parentOwnedObjectUrl?: boolean }, + ) => { + setPlatformTextureUrlsByRenderKey((current) => { + const previousUrl = current[textureKey]; + if (!resolvedUrl) { + return current; + } + if (previousUrl === resolvedUrl) { + return current; + } + if ( + previousUrl && + previousUrl.startsWith('blob:') && + platformTextureParentObjectUrlsRef.current.has(previousUrl) + ) { + URL.revokeObjectURL(previousUrl); + platformTextureParentObjectUrlsRef.current.delete(previousUrl); + } + if (options?.parentOwnedObjectUrl && resolvedUrl.startsWith('blob:')) { + platformTextureParentObjectUrlsRef.current.add(resolvedUrl); + } + return { + ...current, + [textureKey]: resolvedUrl, + }; + }); + }, + [], + ); + + useEffect(() => { + return () => { + platformTextureParentObjectUrlsRef.current.forEach((url) => { + URL.revokeObjectURL(url); + }); + platformTextureParentObjectUrlsRef.current.clear(); + }; + }, []); + useEffect(() => { tileAssetsRef.current = profile?.tileAssets; }, [profile?.tileAssets]); @@ -904,6 +1578,13 @@ export function JumpHopRuntimeShell({ setIsLandingRecoilAnimating(false); }, []); + const stopChargeFrame = useCallback(() => { + if (chargeFrameRef.current != null) { + window.cancelAnimationFrame(chargeFrameRef.current); + chargeFrameRef.current = null; + } + }, []); + const beginPlatformAdvance = useCallback( ( fromRun: JumpHopRuntimeRunSnapshotResponse, @@ -923,30 +1604,75 @@ export function JumpHopRuntimeShell({ const toPlatformIds = new Set( toVisiblePlatforms.map((item) => item.platform.platformId), ); - const fromLandingPlatform = fromVisiblePlatforms.find( - (item) => item.index === toRun.currentPlatformIndex, - ); + const fromLandingPosition = + getJumpHopRunLandingVisualPosition({ + run: toRun, + platforms: fromVisiblePlatforms, + stageSize: landingAssistStageSize, + }) ?? + (() => { + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + return fromLandingPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform( + fromLandingPlatform, + ) + : null; + })(); + const toLandingPosition = + getJumpHopRunLandingVisualPosition({ + run: toRun, + platforms: toVisiblePlatforms, + stageSize: landingAssistStageSize, + }) ?? + (() => { + const toCurrentPlatform = toVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + return toCurrentPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform( + toCurrentPlatform, + ) + : null; + })(); const toCurrentPlatform = toVisiblePlatforms.find( (item) => item.index === toRun.currentPlatformIndex, ); + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); const cameraOffsetX = - (fromLandingPlatform?.screenX ?? toCurrentPlatform?.screenX ?? 0) - - (toCurrentPlatform?.screenX ?? fromLandingPlatform?.screenX ?? 0); + (fromLandingPosition?.screenX ?? fromLandingPlatform?.screenX ?? 0) - + (toLandingPosition?.screenX ?? toCurrentPlatform?.screenX ?? 0); const cameraOffsetY = Math.max( 0, - (toCurrentPlatform?.screenY ?? 0) - - (fromLandingPlatform?.screenY ?? 0), + (toLandingPosition?.screenY ?? toCurrentPlatform?.screenY ?? 0) - + (fromLandingPosition?.screenY ?? fromLandingPlatform?.screenY ?? 0), ); - setPlatformAdvanceExitingPlatforms( - fromVisiblePlatforms + const movePlatformBehindCamera = (item: JumpHopVisiblePlatform) => ({ + ...item, + screenX: item.screenX - cameraOffsetX, + screenY: item.screenY + cameraOffsetY, + }); + setPlatformAdvanceExitingPlatforms((currentRetainedPlatforms) => { + const retainedPlatforms = currentRetainedPlatforms .filter((item) => !toPlatformIds.has(item.platform.platformId)) - .map((item) => ({ - ...item, - screenX: item.screenX - cameraOffsetX, - screenY: item.screenY + cameraOffsetY, - })), - ); + .map(movePlatformBehindCamera); + const newlyRetainedPlatforms = fromVisiblePlatforms + .filter((item) => !toPlatformIds.has(item.platform.platformId)) + .map(movePlatformBehindCamera); + const byPlatformId = new Map(); + + [...retainedPlatforms, ...newlyRetainedPlatforms].forEach((item) => { + if (item.screenY < JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y) { + byPlatformId.set(item.platform.platformId, item); + } + }); + + return [...byPlatformId.values()]; + }); setPlatformAdvanceCameraOffsetX(cameraOffsetX); setPlatformAdvanceCameraOffsetY(cameraOffsetY); setIsPlatformAdvancing(true); @@ -957,12 +1683,11 @@ export function JumpHopRuntimeShell({ platformAdvanceEndTimerRef.current = window.setTimeout(() => { platformAdvanceEndTimerRef.current = null; setIsPlatformAdvancing(false); - setPlatformAdvanceExitingPlatforms([]); setPlatformAdvanceCameraOffsetX(0); setPlatformAdvanceCameraOffsetY(0); }, JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS); }, - [clearPlatformAdvanceState], + [clearPlatformAdvanceState, landingAssistStageSize], ); const finishJumpHopFlightAnimation = useCallback( @@ -1057,11 +1782,10 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(false); setJumpAnimationProgress(0); setIsCharging(false); - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); setNowMs(Date.now()); return; } @@ -1083,11 +1807,10 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(false); setJumpAnimationProgress(0); setIsCharging(false); - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); setNowMs(Date.now()); return; } @@ -1118,6 +1841,7 @@ export function JumpHopRuntimeShell({ finishJumpHopFlightAnimation, isJumpAnimating, jumpAnimationProgress, + stopChargeFrame, ]); useEffect(() => { @@ -1134,6 +1858,9 @@ export function JumpHopRuntimeShell({ if (landingRecoilEndTimerRef.current != null) { window.clearTimeout(landingRecoilEndTimerRef.current); } + if (chargeFrameRef.current != null) { + window.cancelAnimationFrame(chargeFrameRef.current); + } }; }, []); @@ -1202,50 +1929,39 @@ export function JumpHopRuntimeShell({ }; }, [finishJumpHopFlightAnimation, isJumpAnimating]); - const getStageLocalPoint = (event: PointerEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - }; - - const updateDragState = (x: number, y: number) => { - const dragStart = dragStartRef.current; - dragCurrentRef.current = { x, y }; - setDragPointerPosition({ x, y }); - if (!dragStart) { - setDragDistance(0); - setDragVector({ x: 0, y: 0 }); - return; - } - setDragVector({ - x: x - dragStart.x, - y: y - dragStart.y, - }); - setDragDistance(Math.hypot(x - dragStart.x, y - dragStart.y)); - }; - const beginCharge = (event: PointerEvent) => { if (!canJump) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); - const dragPoint = getStageLocalPoint(event); - dragStartRef.current = dragPoint; - dragCurrentRef.current = dragPoint; - setDragPointerPosition(dragPoint); + chargeStartedAtRef.current = Date.now(); + stopChargeFrame(); + clearLandingRecoilState(); setIsCharging(true); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - }; - const updateDragVector = (event: PointerEvent) => { - if (!isCharging) { - return; - } - const dragPoint = getStageLocalPoint(event); - updateDragState(dragPoint.x, dragPoint.y); + const tick = () => { + const chargeStartedAt = chargeStartedAtRef.current; + if (chargeStartedAt == null) { + chargeFrameRef.current = null; + return; + } + + const nextDragDistance = clamp( + Date.now() - chargeStartedAt, + 0, + maxDragDistancePx, + ); + setDragDistance(nextDragDistance); + if (nextDragDistance < maxDragDistancePx) { + chargeFrameRef.current = window.requestAnimationFrame(tick); + return; + } + chargeFrameRef.current = null; + }; + + chargeFrameRef.current = window.requestAnimationFrame(tick); }; const finishCharge = async (event?: PointerEvent) => { @@ -1253,56 +1969,54 @@ export function JumpHopRuntimeShell({ return; } if (event) { - const dragPoint = getStageLocalPoint(event); - updateDragState(dragPoint.x, dragPoint.y); + event.currentTarget.releasePointerCapture?.(event.pointerId); } - const dragStart = dragStartRef.current; - const dragCurrent = dragCurrentRef.current ?? dragStart; - const dragVectorX = - dragStart && dragCurrent ? dragCurrent.x - dragStart.x : 0; - const dragVectorY = - dragStart && dragCurrent ? dragCurrent.y - dragStart.y : 0; - const nextDragDistance = Math.hypot(dragVectorX, dragVectorY); - const backendDragVector = getJumpHopBackendDragVector( - activeRun, - visiblePlatforms, - landingAssistStageSize, - dragVectorX, - dragVectorY, - ); + const chargeStartedAt = chargeStartedAtRef.current; + const nextDragDistance = + chargeStartedAt == null + ? 0 + : clamp( + Date.now() - chargeStartedAt, + 0, + maxDragDistancePx, + ); + const predictionRun = stageRun ?? activeRun; const predictedLandingPosition = - activeRun && characterPosition + predictionRun && characterPosition ? getJumpHopLandingAssistVisualPosition( - activeRun, + predictionRun, visiblePlatforms, characterPosition, landingAssistStageSize, nextDragDistance, - dragVectorX, - dragVectorY, ) : null; - const fallbackLandingPosition = jumpTargetPlatform - ? buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform) - : characterPosition; - if (characterPosition && (predictedLandingPosition || fallbackLandingPosition)) { + if (characterPosition) { + const predictionOrigin = + currentPlatformOriginPosition ?? characterPosition; + const visualDeltaX = predictedLandingPosition + ? predictedLandingPosition.screenX - predictionOrigin.screenX + : 0; + const visualDeltaY = predictedLandingPosition + ? predictedLandingPosition.screenY - predictionOrigin.screenY + : 0; setVisualJump({ from: characterPosition, to: predictedLandingPosition ? { ...characterPosition, - screenX: predictedLandingPosition.screenX, - screenY: predictedLandingPosition.screenY, - isMiss: false, + screenX: clamp(characterPosition.screenX + visualDeltaX, 6, 94), + screenY: clamp(characterPosition.screenY + visualDeltaY, 10, 92), + isMiss: !predictedLandingPosition.isOnTargetPlatform, } - : fallbackLandingPosition!, + : characterPosition, }); } else { setVisualJump(null); } - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); clearLandingRecoilState(); setIsCharging(false); setJumpAnimationProgress(0); @@ -1310,27 +2024,23 @@ export function JumpHopRuntimeShell({ setIsJumpAnimating(true); setDragDistance(nextDragDistance); setDragVector({ - x: dragVectorX, - y: dragVectorY, + x: targetDirection ? -targetDirection.unitScreenX : 0, + y: targetDirection ? -targetDirection.unitScreenY : 0, }); - setDragPointerPosition(null); await onJump({ dragDistance: nextDragDistance, - dragVectorX: backendDragVector.dragVectorX, - dragVectorY: backendDragVector.dragVectorY, }); }; const cancelCharge = () => { - dragStartRef.current = null; - dragCurrentRef.current = null; + chargeStartedAtRef.current = null; + stopChargeFrame(); clearLandingRecoilState(); hasJumpAnimationReachedTargetRef.current = false; setVisualJump(null); setIsCharging(false); setDragDistance(0); setDragVector({ x: 0, y: 0 }); - setDragPointerPosition(null); }; return ( @@ -1353,7 +2063,6 @@ export function JumpHopRuntimeShell({ data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'} className="jump-hop-runtime__stage absolute inset-0 h-full w-full touch-none select-none overflow-hidden" onPointerDown={beginCharge} - onPointerMove={updateDragVector} onPointerUp={(event) => void finishCharge(event)} onPointerCancel={cancelCharge} > @@ -1375,11 +2084,13 @@ export function JumpHopRuntimeShell({
@@ -1387,9 +2098,12 @@ export function JumpHopRuntimeShell({ characterPosition={visualCharacterPosition} chargeRatio={chargeRatio} isJumpAnimating={isJumpAnimating} + platforms={platformRenderItems} platformCount={platformRenderItems.length} renderCharacter={false} + textureUrlsByRenderKey={platformTextureUrlsByRenderKey} onCharacterLayerReadyChange={setIsThreeCharacterLayerReady} + onPlatformLayerReadyChange={setIsThreePlatformLayerReady} /> {platformRenderItems.map((item) => { @@ -1424,6 +2138,8 @@ export function JumpHopRuntimeShell({
); @@ -1431,10 +2147,12 @@ export function JumpHopRuntimeShell({ {preloadTileAssets.length > 0 ? ( @@ -1477,34 +2195,42 @@ export function JumpHopRuntimeShell({ ) : null}
- {isCharging && dragPointerPosition && characterPosition ? ( + {isCharging && characterPosition ? (