From cd8088d1fde7230de1343efd08aa67fd44822211 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: Fri, 5 Jun 2026 22:55:40 +0800 Subject: [PATCH] feat: polish jump hop themed runtime assets --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 + ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 2 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 8 +- packages/shared/src/contracts/jumpHop.ts | 3 + server-rs/crates/api-server/src/jump_hop.rs | 222 +++++- .../crates/shared-contracts/src/jump_hop.rs | 6 + .../crates/spacetime-client/src/jump_hop.rs | 14 + .../spacetime-client/src/mapper/jump_hop.rs | 3 + .../get_jump_hop_leaderboard_procedure.rs | 16 +- .../jump_hop_draft_compile_input_type.rs | 1 + .../jump_hop_draft_snapshot_type.rs | 1 + .../jump_hop_work_profile_row_type.rs | 6 + .../jump_hop_work_snapshot_type.rs | 1 + .../crates/spacetime-module/src/jump_hop.rs | 25 + .../spacetime-module/src/jump_hop/tables.rs | 3 + .../spacetime-module/src/jump_hop/types.rs | 3 + .../crates/spacetime-module/src/migration.rs | 6 + .../JumpHopResultView.test.tsx | 2 + .../JumpHopRuntimeShell.test.tsx | 83 ++- .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 650 +++++++++--------- ...gEntryFlowShell.agent.interaction.test.tsx | 2 + 22 files changed, 719 insertions(+), 354 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 81b5c42a..f1bca0e8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,14 @@ - éªŒè¯æ–¹å¼ï¼š`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实è”调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认åªå‡ºçŽ°å¯¹è±¡å®šä½å’Œçжæ€å­—段,ä¸å‡ºçŽ°ç­¾åææ–™ã€‚ - å…³è”æ–‡æ¡£ï¼š`server-rs/crates/platform-oss/README.md`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 +## 2026-06-05 跳一跳返回按钮改为独立主题资产 + +- 背景:跳一跳è¿è¡Œæ€æ›¾æŠŠå·¦ä¸Šè§’返回按钮视觉锚点写进背景 image2 prompt,导致返回按钮åƒé™æ€èƒŒæ™¯å…ƒç´ ï¼Œä¸èƒ½æ›¿ä»£çœŸå®žå¯ç‚¹å‡»æŒ‰é’®ã€‚ +- 决策:跳一跳背景 prompt ç¦æ­¢ç”Ÿæˆä»»ä½• UI 或左上角图标;返回按钮由 `backButtonAsset` å•ç‹¬ç”Ÿæˆ 1:1 纯绿 key 图,åŽç«¯åŽ»ç»¿åŽä½œä¸ºé€æ˜Ž PNG æŒä¹…åŒ–åˆ°ä½œå“ profile,è¿è¡Œæ€å·¦ä¸Šè§’真实按钮优先渲染该资产。顶部得分 HUD å¤ç”¨æ‹¼å›¾æ¨¡æ¿ç»“构,包å«é™¶æ³¥å„¿ IP logoã€æ ‡é¢˜ç‰Œå’Œä¸‹æŒ‚æ•°å­—å¡ã€‚ +- å½±å“范围:`packages/shared/src/contracts/jumpHop.ts`ã€`shared-contracts`ã€`spacetime-module` / `spacetime-client` bindingsã€`api-server` 跳一跳生æˆé“¾è·¯ã€`JumpHopRuntimeShell`ã€çŽ©æ³•é“¾è·¯æ–‡æ¡£å’ŒåŽç«¯æ•°æ®å¥‘约文档。 +- éªŒè¯æ–¹å¼ï¼š`npm run spacetime:generate`ã€`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml`ã€`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`ã€`npm run check:spacetime-schema`。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`。 + ## 2026-06-03 创作入å£å…³é—­ä¸ä¸‹æž¶å·²å‘å¸ƒä½œå“ - 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由å‰ç¼€ç»Ÿä¸€ç†”断,导致用户进入平å°é¦–页或å¯åЍ已å‘å¸ƒä½œå“æ—¶ä¹Ÿå¯èƒ½çœ‹åˆ°â€œåˆ›ä½œå…¥å£å·²å…³é—­â€é”™è¯¯ã€‚ diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index d6bae5a0..feb5b666 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1660,6 +1660,14 @@ - 验è¯ï¼š`jump_hop.rs` ä¸åº”å†è°ƒç”¨é€šç”¨ç‰©å“行数模型处ç†åœ°å—图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,è¿è¡Œæ€æ— é™è·¯å¾„ä»Žåœ°å—æ± éšæœºå–æã€‚ - å…³è”:`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 ä¼šæŠŠè¿™äº›è¯æ”¾è¿›â€œä¸»é¢˜ç‰©ä½“图集â€è¯­å¢ƒï¼Œå®¹æ˜“被上游ç†è§£ä¸ºè¦æ±‚生æˆå…·ä½“å®å¯æ¢¦è§’色或标志é“具,触å‘å®‰å…¨æ‹¦æˆªï¼›è¿™ä¸æ˜¯æ™®é€šå¹³å°é€ åž‹è¯ã€æŠ å›¾æˆ–超时问题。 +- 处ç†ï¼šä»…åœ¨è·³ä¸€è·³å›¾ç‰‡ç”Ÿæˆ 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`。 + ## 跳一跳地å—切片ä¸è¦æŒ‰ tileType å¤ç”¨èµ„äº§æ§½ä½ - 现象:跳一跳生æˆå®ŒæˆåŽï¼Œè¿è¡Œæ€çœ‹èµ·æ¥ä»åƒåœ¨æ˜¾ç¤ºé»˜è®¤å‡ ä½•地å—,或者地å—å›¾ç‰‡åœ¨åŠ è½½æ—¶é¢‘é—ªï¼›ç»“æžœé¡µåœ°å—æ± ä¹Ÿå¯èƒ½åªçœ‹åˆ°å°‘é‡é‡å¤ç´ æã€‚ diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 530b7179..a5fc8c2b 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -423,7 +423,7 @@ npm run check:server-rs-ddd - Rust 结构体:`JumpHopWorkProfileRow` - æºç ï¼š`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` -- è¯´æ˜Žï¼šä½œå“æŠ•å½±æŒä¹…化独立 `theme_text`,用于生æˆä¸»é¢˜å’Œå…¬å¼€å¡ç‰‡ä¸»é¢˜å±•示;历å²è¡Œä¸ºç©ºæ—¶æŒ‰ `work_title` 兜底。 +- è¯´æ˜Žï¼šä½œå“æŠ•å½±æŒä¹…化独立 `theme_text`,用于生æˆä¸»é¢˜å’Œå…¬å¼€å¡ç‰‡ä¸»é¢˜å±•示;历å²è¡Œä¸ºç©ºæ—¶æŒ‰ `work_title` 兜底。`back_button_asset_json` ä¿å­˜ image2 å•独生æˆå¹¶åŽ»ç»¿åŽçš„ 1:1 左上角返回按钮资产快照;旧è¿ç§»æ•°æ®æŒ‰ `None` 兼容,è¿è¡Œæ€ç¼ºå¤±è¯¥å­—段时使用åŒå°ºå¯¸ CSS 主题按钮兜底。 ### SpacetimeDB view:`jump_hop_gallery_card_view` diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index cebf146d..6c68b970 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -146,19 +146,19 @@ 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度视角主题物体图集,物体本身作为跳跃è½ç‚¹â€çš„å£å¾„,ä¸å†æäº¤â€œå¹³å°ç´ æ / è·³å° / åœ°å— / 地砖â€ç­‰ä¼šæŠŠæ¨¡åž‹æ‹‰å›žé€šç”¨å¹³å°é€ åž‹çš„è¯ï¼ŒåŽç«¯ç”Ÿæˆå‰ä¹Ÿä¼šæ¸…æ´—æ—§è‰ç¨¿é—留的这些è¯ï¼› -4. èƒŒæ™¯åº•å›¾åŒæ ·ç”± image2 生æˆï¼Œå¤ç”¨çŽ°æœ‰ `coverComposite` / `coverImageSrc` 作为è¿è¡Œæ€èƒŒæ™¯è¯»å†™å­—段,OSS æ§½ä½å›ºå®šä¸º `background/image.png`ï¼Œä¸æ–°å¢ž SpacetimeDB 字段;æç¤ºè¯å¿…须严格以用户主题关键è¯ä¸ºèƒŒæ™¯ä¸»é¢˜ï¼Œç»“构以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼Œä¸­å¤®çºµè½´ 1/2 åŒºåŸŸä¿æŒå°‘元素ã€ç®€æ´ã€å¯è¯»ä¸”有纵深感,两侧å…许更强立体层次和行进感;背景åªä½œä¸ºåº•å›¾ï¼Œç¦æ­¢ç”Ÿæˆè·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIã€æ–‡å­—ã€è·¯å¾„箭头或海报排版; +3. 地å—åªè°ƒç”¨ä¸€æ¬¡ image2,输出一张 `5行*5列`ã€`1:1`ã€å•一纯洋红 `#FF00FF` key 背景的主题地å—图集;跳一跳地å—常包å«è‰åœ°ã€èбã€é›ªã€ç™½çŸ³å’Œäº‘朵,åŽç«¯é€æ˜ŽåŒ–必须使用跳一跳专用洋红 key,ä¸å¯ç”¨è¿‘ç™½åº•æ‰£é™¤ï¼Œä¹Ÿä¸æ¸…ç†éžè¾¹ç¼˜è¿žé€šçš„ key 色åƒç´ ï¼Œé¿å…把绿色或白色主体误扣;åŽå¤„ç†å¿…须对边缘连通 key 色åšå®¹å·®æ¸…ç†ã€åŽ»å½©è¾¹ defringe 和底部残影清ç†ï¼Œä¸»ä½“图ä¸å¾—自带洋红阴影ã€ç´«è‰²åº•è¾¹ã€ç²‰è‰²è„è¾¹ã€å½©è‰²å…‰æ™•或å‘光底边,è¿è¡Œæ€é˜´å½±ç»Ÿä¸€ç”± DOM 绘制;地å—造型æç¤ºè¯è¦æ±‚以主题物体本身外轮廓为准,å…许苹果近似圆形ã€é¦™è•‰è¿‘ä¼¼é•¿æ¡æˆ–长方形ã€è¥¿ç“œè¿‘似扇形等自然差异,åªç»Ÿä¸€å•格规格ã€å®‰å…¨ç•™ç™½ã€æ­£é¢30度视角和 2D/2.5D 手绘风格包装;所有地å—ç´ æå¿…é¡»ä¿æŒç»Ÿä¸€æ­£é¢30度视角,相机ä½äºŽç‰©ä½“æ­£å‰æ–¹ç•¥é«˜ä½ç½®ã€é•œå¤´å‘下约30度,必须看到清晰正é¢ã€ä¾§å£ã€ä¸‹æ²¿ã€æ˜Žæ˜¾è‡ªèº«åŽšåº¦å’Œå°‘é‡ä¸Šè¡¨é¢ï¼Œä¸»ä½“æ­£é¢æˆ–ä¾§å£å¯è§é¢ç§¯å¿…须接近或大于顶é¢é¢ç§¯ï¼Œé¡¶é¢åªèƒ½ä½œä¸ºè¾…助å¯è§é¢ï¼›æ°´æžœä¸»é¢˜éœ€è¦æ˜Žç¡®è¦æ±‚橙瓣看到橙皮正é¢å¤–ä¾§å’Œæžœè‚‰åŽšåº¦ã€æ¤°å­çœ‹åˆ°å£³çš„æ­£é¢ä¾§å£å’Œåˆ‡å£åŽšåº¦ã€æµ†æžœä¸èƒ½åªæ˜¯ä»Žä¸Šå¾€ä¸‹çœ‹çš„圆形çƒé¡¶ï¼›é¿å…生æˆçº¯ä¿¯è§†ã€æ­£ä¸Šæ–¹ä¿¯æ‹ã€é¸Ÿçž°åœ°å›¾å—ã€å¹³é“ºä¿¯æ‹ã€åœ†å½¢é¡¶è§†å›¾æˆ–æ‰å¹³å›¾æ ‡ï¼›ä¸»é¢˜ç‰©ä½“本身必须是唯一å¯è½è„šä½“,åªèƒ½ç”¨è‡ªèº«åˆ‡é¢ã€è¾¹ç¼˜åŽšåº¦ã€èŠ±ç“£å±‚æˆ–æžœçš®è¾¹è¡¨çŽ°æ‰¿é‡ï¼Œç¦æ­¢åœ¨ä¸»é¢˜ç‰©ä½“下方é¢å¤–垫石å°ã€åœŸå¢©ã€æœ¨æ¿ã€åœ†å°ã€æ‰˜ç›˜ã€å²›å±¿åº•座或通用地æ¿ï¼›å‰ç«¯å’ŒåŽç«¯é»˜è®¤ `tilePrompt` 都必须使用“正é¢30度视角主题物体图集,物体本身作为跳跃è½ç‚¹â€çš„å£å¾„,ä¸å†æäº¤â€œå¹³å°ç´ æ / è·³å° / åœ°å— / 地砖â€ç­‰ä¼šæŠŠæ¨¡åž‹æ‹‰å›žé€šç”¨å¹³å°é€ åž‹çš„è¯ï¼ŒåŽç«¯ç”Ÿæˆå‰ä¹Ÿä¼šæ¸…æ´—æ—§è‰ç¨¿é—留的这些è¯ï¼›å½“ä¸»é¢˜æˆ–åœ°å—æç¤ºè¯å‘½ä¸­å®å¯æ¢¦ / 神奇å®è´ / å£è¢‹å¦–怪 / 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 秒超时中断。 è¿è¡Œæ€è§„则真相必须沉到 `module-jump-hop`,å‰ç«¯åªåšæ‹–æ‹½è“„åŠ›ã€è§’色ä½ç§»ã€æŠ•影和è½åœ°åé¦ˆã€‚å¤±è´¥ã€æˆåŠŸè·³è·ƒæ¬¡æ•°ã€æ¸¸æˆæ—¶é•¿å†»ç»“ã€è¿è¡Œæ€å¿«ç…§å’Œå‘布作å“状æ€ä»¥åŽç«¯ä¸ºå‡†ã€‚v1 ä¸ä¿ç•™å…¬å¼€ combo / perfect / 通关语义,旧 `score` 兼容映射为æˆåŠŸè·³è·ƒæ¬¡æ•°ã€‚å…¬å¼€åˆ—è¡¨åº”èµ° `jump_hop_gallery_card_view` 订阅缓存,ä¸è¦æ¯æ¬¡ HTTP 请求调用 procedure 组装全é‡åˆ—表。 -æ¯å±åªå±•示 3 个地å—:当å‰åœ°å—ã€ç›®æ ‡åœ°å—和下一预览地å—ã€‚å¹³å°æµæŒ‰åŒä¸€ seed æ— é™ç”Ÿæˆï¼Œå‰ç«¯ä¸å¾—è‡ªè¡Œç”Ÿæˆæ­£å¼è·¯å¾„。è¿è¡Œæ€ HUD 顶部åªä¿ç•™è¿”回按钮和æˆåŠŸè·³è·ƒæ¬¡æ•°ï¼Œä¸å±•示计时器或å³ä¸Šè§’é‡å¼€æŒ‰é’®ï¼›èˆžå°åŒºåŸŸä¸å¾—å†è¡¨çŽ°ä¸ºå¸¦è¾¹æ¡†å¡ç‰‡ï¼Œæ¸¸çީ䏭䏿˜¾ç¤ºå·¦ä¸‹è§’“进行中â€çжæ€ï¼Œä¹Ÿä¸åœ¨å±å¹•底部常驻排行榜。排行榜按作å“维度展示玩家 IDã€æˆåŠŸè·³è·ƒæ¬¡æ•°å’Œæ¸¸æˆæ—¶é•¿ï¼›æ¯ä½çީ家åªä¿ç•™ 1 æ¡æœ€ä½³è®°å½•,排åºå›ºå®šä¸º `æˆåŠŸè·³è·ƒæ¬¡æ•° desc -> æ¸¸æˆæ—¶é•¿ asc -> æ›´æ–°æ—¶é—´ asc`,并åªåœ¨å¤±è´¥ç»“算弹窗内展示,弹窗ä¿ç•™é‡å¼€å’Œè¿”回动作。 +æ¯å±åªå±•示 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 状æ€ï¼Œä¸å¾—销æ¯é‡å»ºé€æ˜Žç”»å¸ƒã€èƒŒæ™¯æˆ–å¹³å°å›¾ç‰‡å±‚,å¦åˆ™ä¼šé€ æˆèƒŒæ™¯ã€åœ°å—和角色层频闪。 -è·³ä¸€è·³å½“å‰æ‹–拽手感统一采用 `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.008`,用于把åŒç­‰è·³è·ƒè·ç¦»æ‰€éœ€æ‹–拽è·ç¦»ç¼©çŸ­åˆ°æ—§ `0.004` 的一åŠï¼›å¦‚果历å²è·¯å¾„ä»ä¿å­˜æ—§ç³»æ•°ï¼Œ`start_run` ä¼šåœ¨å¼€å±€å½’ä¸€åŒ–åˆ°æ–°ç³»æ•°ã€‚æ‹–æ‹½ä¸­åªæ˜¾ç¤ºå¼¹å¼“æ‹‰çº¿ï¼Œä¸æ˜¾ç¤ºè½ç‚¹è¾…åŠ©ç‚¹ã€æŠ•å½±åœˆæˆ–å…¶å®ƒå‘½ä¸­æç¤ºã€‚æ¾æ‰‹åŽè¿è¡Œæ€å¿…须立å³ç”Ÿæˆ `visualJump`,用当å‰è§’色ä½ç½®ä½œä¸ºèµ·ç‚¹ã€å‰ç«¯é¢„测è½ç‚¹ä½œä¸ºç»ˆç‚¹ï¼Œæ’­æ”¾çº¦ `560ms` çš„è§’è‰²é£žè¡ŒåŠ¨ç”»ï¼šè“„åŠ›æ—¶è§’è‰²æ²¿æ‹–æ‹½æ–¹å‘æ˜Žæ˜¾æ‹‰é•¿ï¼Œè§’色弹å‘预测è½ç‚¹ï¼Œè½åœ°åŽå‘åæ–¹å‘回弹两次;动画路径ä¸å¾—等待åŽç«¯æ–° run。若åŽç«¯æ–° run 晚于飞行动画返回,角色必须åœåœ¨é¢„测è½ç‚¹ç­‰å¾…,直到新 run 到达åŽå†æŠŠæ˜¾ç¤ºæ€åˆ‡åˆ°åŽç«¯æœ€æ–° run,并用约 `1440ms` 的相机层推进过渡承接新窗å£ã€‚æŽ¨è¿›æ—¶åœ°å— DOM 层和 DOM 角色层统一包在åŒä¸€ä¸ª camera layer 下移动,旧当å‰åœ°å—自然离开视野,新预览地å—ä»Žä¸Šæ–¹éœ²å‡ºï¼Œç¦æ­¢ç”¨ p1/p2 å„自 `top/left` 过渡造æˆè§’色和地å—ä¸åŒæ­¥ã€‚ç›¸æœºå±‚æŽ¨è¿›å¿…é¡»åŒæ—¶ä½¿ç”¨ X/Y å移,从旧目标地å—ä½ç½®æ–œå‘滑到新当å‰åœ°å—èšç„¦ä½ç½®ï¼Œä¸å¾—先横å‘瞬切到居中å†çºµå‘滑动。地å—å…许ä¿ç•™å½“å‰ / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用åŒä¸€ `1440ms` 节å¥ï¼›ä¸è¦ç›´æŽ¥ä¿®æ”¹å®½é«˜é€ æˆçž¬åˆ‡ï¼Œä¹Ÿä¸è¦å†ç»™å½“剿€é¢å¤–å  CSS scale。相机推进期间角色自身必须ç¦ç”¨ `left/top` transition,åªå…许父级 camera layer è´Ÿè´£ä½ç§»ï¼Œå¦åˆ™è§’è‰²å±€éƒ¨åæ ‡åˆ‡æ¢å’Œç›¸æœºæŽ¨è¿›ä¼šå åŠ ï¼Œè¡¨çŽ°ä¸ºè½åœ°åŽåˆä»Žå±å¹•外闪回。 å¹³å°é¦–页推èã€ç²¾é€‰ã€æœ€æ–°ã€å…¬å¼€è¯¦æƒ…ã€æœç´¢ã€å·²çŽ©ä½œå“和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作å“å·è¯†åˆ«è·³ä¸€è·³ä½œå“ï¼›ä»Žå…¬å¼€è¯¦æƒ…æˆ–æŽ¨èæµå¯åЍè¿è¡Œæ€æ—¶ï¼Œè‹¥å¡ç‰‡æ‘˜è¦ä¸è¶³ä»¥æºå¸¦åœ°å—图集和路径é…置,必须先补读完整 work profile å†ä¼ å…¥è¿è¡Œæ€ã€‚å¹³å°å£³å±‚å¿…é¡»åŒæ­¥æ³¨å†Œ `jump-hop-workspace`ã€`jump-hop-generating`ã€`jump-hop-result`ã€`jump-hop-runtime`ã€`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`ã€`/creation/jump-hop/generating`ã€`/creation/jump-hop/result`ã€`/gallery/jump-hop/detail`ã€`/runtime/jump-hop`ï¼ŒåŒæ—¶æŒæœ‰ sessionã€workã€runã€galleryã€busy/error 与生æˆè¿›åº¦çжæ€ï¼Œé¿å…åªåˆå…¥æ¸²æŸ“åˆ†æ”¯ä½†é—æ¼çŠ¶æ€æºæˆ–分享路径导致 typecheck 失败ã€åˆ·æ–°å›žé¦–页。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 2127fd7f..a5b6d9e9 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -61,6 +61,7 @@ export interface JumpHopActionRequest { tileAtlasAsset?: JumpHopCharacterAsset | null; tileAssets?: JumpHopTileAsset[] | null; coverComposite?: string | null; + backButtonAsset?: JumpHopCharacterAsset | null; } export interface JumpHopCharacterAsset { @@ -153,6 +154,7 @@ export interface JumpHopDraftResponse { tileAssets: JumpHopTileAsset[]; path: JumpHopPath | null; coverComposite: string | null; + backButtonAsset?: JumpHopCharacterAsset | null; generationStatus: JumpHopGenerationStatus; } @@ -204,6 +206,7 @@ export interface JumpHopWorkProfileResponse { characterAsset: JumpHopCharacterAsset; tileAtlasAsset: JumpHopCharacterAsset; tileAssets: JumpHopTileAsset[]; + backButtonAsset?: JumpHopCharacterAsset | null; } export interface JumpHopWorksResponse { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index dcc06949..762f7c24 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -61,6 +61,9 @@ const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536"; const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536; +const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024"; +const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { @@ -444,8 +447,12 @@ async fn maybe_generate_jump_hop_assets( .map(str::trim) .filter(|value| !value.is_empty()) .is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value)); + let has_back_button_asset = payload + .back_button_asset + .as_ref() + .is_some_and(is_jump_hop_image_asset_usable); - if has_complete_tile_assets && has_real_background { + if has_complete_tile_assets && has_real_background && has_back_button_asset { return Ok(()); } let profile_id = payload @@ -529,6 +536,58 @@ async fn maybe_generate_jump_hop_assets( payload.cover_composite = Some(background_asset.image_src); } + if !has_back_button_asset { + let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str()); + let back_button_generated = create_openai_image_generation( + &http_client, + &settings, + back_button_prompt.as_str(), + Some(build_jump_hop_back_button_negative_prompt()), + JUMP_HOP_BACK_BUTTON_IMAGE_SIZE, + 1, + &[], + "跳一跳返回按钮图生æˆå¤±è´¥", + ) + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let back_button_image = + back_button_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "è·³ä¸€è·³è¿”å›žæŒ‰é’®å›¾ç”ŸæˆæˆåŠŸä½†æœªè¿”å›žå›¾ç‰‡ã€‚", + })), + ) + })?; + let back_button_image = + prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图") + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let back_button_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "back-button", + back_button_prompt.as_str(), + back_button_image, + LegacyAssetPrefix::JumpHopAssets, + JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH, + JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT, + request_context, + ) + .await?; + payload.back_button_asset = Some(back_button_asset); + } + if !has_complete_tile_assets { let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str()); @@ -604,33 +663,110 @@ fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool { && (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-")) } -fn build_jump_hop_background_prompt(theme_text: &str) -> String { +fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool { + !asset.image_src.trim().is_empty() + && !asset.image_object_key.trim().is_empty() + && !asset.asset_object_id.trim().is_empty() + && !asset.generation_provider.trim().is_empty() +} + +fn prepare_jump_hop_green_screen_image_for_persist( + image: crate::openai_image_generation::DownloadedOpenAiImage, + failure_label: &str, +) -> Result { + 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!("{failure_label}è§£ç å¤±è´¥ï¼š{error}"), + })) + })?; + let mut encoded = std::io::Cursor::new(Vec::new()); + crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source) + .write_to(&mut encoded, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("{failure_label}绿幕去背失败:{error}"), + })) + })?; + + Ok(crate::openai_image_generation::DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String { let theme_text = theme_text.trim(); - let theme_text = if theme_text.is_empty() { - "跳一跳" - } else { - theme_text - }; + if theme_text.is_empty() { + return "跳一跳".to_string(); + } + + replace_jump_hop_pokemon_prompt_terms(theme_text) +} + +fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String { + let mut value = value.trim().to_string(); + if value.is_empty() { + return value; + } + + // 中文注释:仅对å®å¯æ¢¦ç›¸å…³è¯åšç”Ÿæˆä¾§è„±æ•,é¿å…地å—图集触å‘上游安全拦截。 + const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [ + ("å®å¯æ¢¦", "原创幻想èŒå® å†’险é“å…·"), + ("神奇å®è´", "原创幻想èŒå® å†’险é“å…·"), + ("å£è¢‹å¦–怪", "原创幻想èŒå® å†’险é“å…·"), + ("ç²¾çµçƒ", "彩色冒险能é‡çƒ"), + ("çš®å¡ä¸˜", "黄色闪电èŒå® ç¬¦å·"), + ("Pokémon", "原创幻想èŒå® å†’险é“å…·"), + ("Pokemon", "原创幻想èŒå® å†’险é“å…·"), + ("POKEMON", "原创幻想èŒå® å†’险é“å…·"), + ("pokemon", "原创幻想èŒå® å†’险é“å…·"), + ("Pikachu", "黄色闪电èŒå® ç¬¦å·"), + ("PIKACHU", "黄色闪电èŒå® ç¬¦å·"), + ("pikachu", "黄色闪电èŒå® ç¬¦å·"), + ("Poké Ball", "彩色冒险能é‡çƒ"), + ("Poke Ball", "彩色冒险能é‡çƒ"), + ("pokeball", "彩色冒险能é‡çƒ"), + ]; + + for (from, to) in POKEMON_REPLACEMENTS { + value = value.replace(from, to); + } + + value +} + +fn build_jump_hop_background_prompt(theme_text: &str) -> String { + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); format!( - "生æˆä¸€å¼ 9:16竖版跳一跳游æˆèƒŒæ™¯åº•图,主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œ{theme_text}â€ï¼Œä¸è¦é¢å¤–改æ¢ä¸»é¢˜ï¼›æ•´ä½“风格需è¦å’ŒåŒä¸€ä¸»é¢˜çš„跳一跳游æˆå…ƒç´ ä¸€è‡´ã€‚\nç”»é¢ç»“构必须以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼šå·¦ä¾§å’Œå³ä¾§å¯ä»¥ä½¿ç”¨ç¬¦åˆä¸»é¢˜çš„环境元素ã€è£…饰层次ã€å‰ä¸­åŽæ™¯é®æŒ¡ã€é€è§†èŠ‚å¥å’Œè¡Œè¿›æ„Ÿï¼Œè®©çŽ©å®¶æ„Ÿåˆ°ä»Žç”»é¢ä¸‹æ–¹å‘上方å‰è¿›ã€‚\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画é¢åº•部延伸到上方;该区域åªèƒ½ä½¿ç”¨å°‘é‡ä½Žå¯¹æ¯”度纹ç†ã€æŸ”和光影ã€ç©ºæ°”é€è§†å’Œçºµæ·±å¼•å¯¼çº¿ï¼Œç¦æ­¢å †æ”¾å¤§åž‹ä¸»ä½“。\n中央纵轴1/2åŒºåŸŸè¦æœ‰æ˜Žæ˜¾çºµæ·±æ„Ÿï¼Œä½†å…ƒç´ æ•°é‡å¿…须少,ä¸èƒ½æŠ¢è·³æ¿ã€è§’色和交互层的视觉;两侧å¯ä»¥æ›´æœ‰ç«‹ä½“感ã€ç©ºé—´å±‚次和主题氛围。\n背景åªä½œä¸ºåº•图,ä¸ç”»ä»»ä½•è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€æ–‡å­—ã€è·¯å¾„箭头ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€Logo或水å°ã€‚\nè§†è§’ä¿æŒæ­£é¢çº¦30度的2D/2.5D休闲手游视角,相机ä½äºŽåœºæ™¯æ­£å‰æ–¹ç•¥é«˜ä½ç½®ï¼Œç”»é¢æœ‰è½»å¾®å‘上行进的纵深,ä¸è¦ç”»æˆçº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸æˆ–真实摄影。\nè‰²å½©æ¸…çˆ½è‡ªç„¶ï¼Œå“‘å…‰æ‰‹ç»˜è´¨æ„Ÿï¼ŒæŸ”å’Œå…‰ç…§ï¼Œä¸»ä½“èƒŒæ™¯ä¸æ²¹äº®ã€ä¸åŽšé‡CGã€ä¸æš—黑;中央区域需è¦ç»™è¿è¡Œæ€åœ°å—和陶泥儿角色留出干净å¯è¯»ç©ºé—´ã€‚\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no text, consistent 2D/2.5D front-facing 30-degree game perspective." + "生æˆä¸€å¼ 9:16竖版跳一跳游æˆèƒŒæ™¯åº•图,主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œ{theme_text}â€ï¼Œä¸è¦é¢å¤–改æ¢ä¸»é¢˜ï¼›æ•´ä½“风格需è¦å’ŒåŒä¸€ä¸»é¢˜çš„跳一跳游æˆå…ƒç´ ä¸€è‡´ã€‚\nç”»é¢ç»“构必须以左å³ä¸¤ä¾§æ°›å›´ä¸ºä¸»ï¼šå·¦ä¾§å’Œå³ä¾§å¯ä»¥ä½¿ç”¨ç¬¦åˆä¸»é¢˜çš„环境元素ã€è£…饰层次ã€å‰ä¸­åŽæ™¯é®æŒ¡ã€é€è§†èŠ‚å¥å’Œè¡Œè¿›æ„Ÿï¼Œè®©çŽ©å®¶æ„Ÿåˆ°ä»Žç”»é¢ä¸‹æ–¹å‘上方å‰è¿›ã€‚\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画é¢åº•部延伸到上方;该区域åªèƒ½ä½¿ç”¨å°‘é‡ä½Žå¯¹æ¯”度纹ç†ã€æŸ”和光影ã€ç©ºæ°”é€è§†å’Œçºµæ·±å¼•å¯¼çº¿ï¼Œç¦æ­¢å †æ”¾å¤§åž‹ä¸»ä½“。\n中央纵轴1/2åŒºåŸŸè¦æœ‰æ˜Žæ˜¾çºµæ·±æ„Ÿï¼Œä½†å…ƒç´ æ•°é‡å¿…须少,ä¸èƒ½æŠ¢è·³æ¿ã€è§’色和交互层的视觉;两侧å¯ä»¥æ›´æœ‰ç«‹ä½“感ã€ç©ºé—´å±‚次和主题氛围。\n背景åªä½œä¸ºåº•图,ä¸ç”»ä»»ä½•è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€æ–‡å­—ã€è·¯å¾„箭头ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€Logo或水å°ï¼›å·¦ä¸Šè§’也ä¸è¦ç”»è¿”回按钮或任何固定图标,è¿è¡Œæ€ä¼šå åŠ ç‹¬ç«‹å¯ç‚¹å‡»æŒ‰é’®èµ„产。\nè§†è§’ä¿æŒæ­£é¢çº¦30度的2D/2.5D休闲手游视角,相机ä½äºŽåœºæ™¯æ­£å‰æ–¹ç•¥é«˜ä½ç½®ï¼Œç”»é¢æœ‰è½»å¾®å‘上行进的纵深,ä¸è¦ç”»æˆçº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸æˆ–真实摄影。\nè‰²å½©æ¸…çˆ½è‡ªç„¶ï¼Œå“‘å…‰æ‰‹ç»˜è´¨æ„Ÿï¼ŒæŸ”å’Œå…‰ç…§ï¼Œä¸»ä½“èƒŒæ™¯ä¸æ²¹äº®ã€ä¸åŽšé‡CGã€ä¸æš—黑;中央区域需è¦ç»™è¿è¡Œæ€åœ°å—和陶泥儿角色留出干净å¯è¯»ç©ºé—´ã€‚\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective." ) } fn build_jump_hop_background_negative_prompt() -> &'static str { - "文字ã€Logoã€æ°´å°ã€UIæŒ‰é’®ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€è§’色ã€äººç‰©ã€è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€å¹³å°ã€é“è·¯ç®­å¤´ã€æ£‹ç›˜ã€æ ¼å­ã€ä¸­å¿ƒå¤§åž‹ä¸»ä½“ã€ä¸­å¤®å †æ»¡å…ƒç´ ã€ä¸­å¤®é®æŒ¡ã€ä¸­å¤®é«˜å¯¹æ¯”装饰ã€ä¸­å¤®å¤æ‚花纹ã€çº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸ã€çœŸå®žæ‘„å½±ã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æ²¹äº®é«˜å…‰ã€å¡‘料质感" + "文字ã€Logoã€æ°´å°ã€UI按钮ã€è¿”回按钮ã€å·¦ä¸Šè§’图标ã€å³ä¸Šè§’按钮ã€åº•部按钮ã€UI颿¿ã€æ ‡é¢˜ã€è¯´æ˜Žæ–‡å­—ã€åˆ†æ•°ã€è¾¹æ¡†ã€æµ·æŠ¥æŽ’版ã€è§’色ã€äººç‰©ã€è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€å¹³å°ã€é“è·¯ç®­å¤´ã€æ£‹ç›˜ã€æ ¼å­ã€ä¸­å¿ƒå¤§åž‹ä¸»ä½“ã€ä¸­å¤®å †æ»¡å…ƒç´ ã€ä¸­å¤®é®æŒ¡ã€ä¸­å¤®é«˜å¯¹æ¯”装饰ã€ä¸­å¤®å¤æ‚花纹ã€çº¯ä¿¯è§†åœ°å›¾ã€å¹³é“ºä¿¯æ‹ã€æ‰å¹³å£çº¸ã€çœŸå®žæ‘„å½±ã€æš—黑幻想风ã€åŽšé‡CGæ¸²æŸ“ã€æ²¹äº®é«˜å…‰ã€å¡‘料质感" +} + +fn build_jump_hop_back_button_prompt(theme_text: &str) -> String { + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); + + format!( + "生æˆè·³ä¸€è·³è¿è¡Œæ€å·¦ä¸Šè§’è¿”å›žæŒ‰é’®çš„ç‹¬ç«‹é€æ˜Žç´ æã€‚主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œ{theme_text}â€ï¼ŒæŒ‰é’®çš„åº•è‰²ã€æè´¨ã€æè¾¹å’Œè½»å¾®è£…é¥°è·Ÿéšè¯¥ä¸»é¢˜ï¼Œä½†å¿…é¡»ä»ç„¶æ˜¯æ¸…æ™°å¯è¯†åˆ«çš„æ¸¸æˆ UI 返回按钮。\n按钮必须是å•个标准圆形图标,圆心居中,主体视觉尺寸å ç”»å¸ƒçº¦72%-82%,外沿有一圈干净æè¾¹ï¼Œå†…éƒ¨åªæœ‰ä¸€ä¸ªå±…中的å‘左箭头;ä¸è¦å†™â€œè¿”å›žâ€æ–‡å­—,ä¸è¦æ•°å­—ã€Logoã€æ°´å°ã€æŒ‰é’®å¤–标签或é¢å¤– UI 颿¿ã€‚\nå…许在圆形底色里åšå¾ˆè½»çš„主题æè´¨åŒ…装,例如水果主题å¯ç”¨æžœçš®è‰²å’Œæžœè‚‰è‰²ã€æ£®æž—主题å¯ç”¨å¶ç‰‡è‰²å’Œæœ¨è´¨æè¾¹ã€æœªæ¥ä¸»é¢˜å¯ç”¨é‡‘属边和å‘光内环;但ä¸è¦æŠŠæŒ‰é’®ç”»æˆä¸»é¢˜ç‰©ä½“本身,ä¸è¦ç»§æ‰¿å¤æ‚èŠ±çº¹ã€æµ®é›•è¾¹ã€å¼‚形外框ã€è´´çº¸å †å æˆ–徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是å•一纯绿色 #00FF00 且平整无纹ç†ã€æ— æ¸å˜ã€æ— é˜´å½±ï¼›æŒ‰é’®ä¸»ä½“边缘干净,åŽç»­ç”±æœåŠ¡ç«¯æ‰£é™¤ç»¿è‰²èƒŒæ™¯ã€‚æŒ‰é’®åº•è‰²ä¸è¦ä½¿ç”¨ä¸Žç»¿å¹•接近的纯绿色,若主题天然包å«ç»¿è‰²ï¼Œè¯·ä½¿ç”¨åæ·±ã€å黄或åè“的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal." + ) +} + +fn build_jump_hop_back_button_negative_prompt() -> &'static str { + "文字ã€è¿”回文字ã€Logoã€æ°´å°ã€æ•°å­—ã€å¤šä¸ªæŒ‰é’®ã€UI颿¿ã€æµ·æŠ¥æŽ’版ã€å¤æ‚徽章ã€èŠ±ç›˜ã€æµ®é›•è¾¹ã€å¼‚形外框ã€ä¸»é¢˜ç‰©ä½“ä¸»ä½“ã€æœ¨æ§Œã€è§’色ã€è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€å¹³å°ã€é€æ˜Žæ£‹ç›˜æ ¼ã€ç™½åº•ã€é»‘底ã€ç°åº•ã€çœŸå®žæ‘„å½±ã€åŽšé‡CGã€æš—é»‘å¹»æƒ³é£Žã€æ²¹äº®å¡‘æ–™ã€çº¯ç»¿è‰²æŒ‰é’®ä¸»ä½“ã€ä¸Žç»¿å¹•混在一起" } fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String { - let theme_text = theme_text.trim(); - let theme_text = if theme_text.is_empty() { - "跳一跳" - } else { - theme_text - }; + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt); let subject_text = if sanitized_tile_prompt.is_empty() { - theme_text + theme_text.as_str() } else { sanitized_tile_prompt.as_str() }; @@ -649,6 +785,7 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { if value.is_empty() { return value; } + value = replace_jump_hop_pokemon_prompt_terms(value.as_str()); const REPLACEMENTS: [(&str, &str); 18] = [ ("俯视角", "æ­£é¢30度视角"), @@ -1134,6 +1271,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JumpHopGenerationStatus::Draft, } } @@ -1376,13 +1514,32 @@ mod tests { assert!(prompt.contains("中央纵轴1/2åŒºåŸŸè¦æœ‰æ˜Žæ˜¾çºµæ·±æ„Ÿ")); assert!(prompt.contains("两侧å¯ä»¥æ›´æœ‰ç«‹ä½“感ã€ç©ºé—´å±‚次和主题氛围")); assert!(prompt.contains("ä¸ç”»ä»»ä½•è·³æ¿ã€åœ°å—ã€è½è„šç‰©ã€è§’色ã€UI按钮")); + assert!(prompt.contains("左上角也ä¸è¦ç”»è¿”回按钮或任何固定图标")); + assert!(prompt.contains("è¿è¡Œæ€ä¼šå åŠ ç‹¬ç«‹å¯ç‚¹å‡»æŒ‰é’®èµ„产")); assert!(prompt.contains("è§†è§’ä¿æŒæ­£é¢çº¦30度")); assert!(prompt.contains("中央区域需è¦ç»™è¿è¡Œæ€åœ°å—和陶泥儿角色留出干净å¯è¯»ç©ºé—´")); assert!(prompt.contains("English guardrail")); assert!(prompt.contains("left and right sides carry the atmosphere")); assert!(prompt.contains("central vertical half-width corridor stays simple")); + assert!(prompt.contains("no top-left back button")); assert!(prompt.contains("no platforms")); assert!(prompt.contains("no landing objects")); + assert!(prompt.contains("no other UI panels")); + } + + #[test] + fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() { + let prompt = build_jump_hop_back_button_prompt("æ°´æžœ"); + + assert!(prompt.contains("ç‹¬ç«‹é€æ˜Žç´ æ")); + assert!(prompt.contains("主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œæ°´æžœâ€")); + assert!(prompt.contains("å•个标准圆形图标")); + assert!(prompt.contains("å†…éƒ¨åªæœ‰ä¸€ä¸ªå±…中的å‘左箭头")); + assert!(prompt.contains("ä¸è¦å†™â€œè¿”å›žâ€æ–‡å­—")); + assert!(prompt.contains("背景必须是å•一纯绿色 #00FF00")); + assert!(prompt.contains("åŽç»­ç”±æœåŠ¡ç«¯æ‰£é™¤ç»¿è‰²èƒŒæ™¯")); + assert!(prompt.contains("one standalone circular mobile game back button asset only")); + assert!(prompt.contains("solid #00FF00 green-screen background")); } #[test] @@ -1394,6 +1551,11 @@ mod tests { assert!(negative_prompt.contains("è½è„šç‰©")); assert!(negative_prompt.contains("角色")); assert!(negative_prompt.contains("UI按钮")); + assert!(negative_prompt.contains("返回按钮")); + assert!(negative_prompt.contains("左上角图标")); + assert!(negative_prompt.contains("å³ä¸Šè§’按钮")); + assert!(negative_prompt.contains("底部按钮")); + assert!(negative_prompt.contains("UI颿¿")); assert!(negative_prompt.contains("中央堆满元素")); assert!(negative_prompt.contains("䏭央鮿Œ¡")); assert!(negative_prompt.contains("纯俯视地图")); @@ -1416,6 +1578,34 @@ mod tests { )); } + #[test] + fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() { + let background_prompt = build_jump_hop_background_prompt("å®å¯æ¢¦"); + assert!(background_prompt.contains("主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œåŽŸåˆ›å¹»æƒ³èŒå® å†’险é“å…·â€")); + assert!(!background_prompt.contains("å®å¯æ¢¦")); + + let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon"); + assert!(back_button_prompt.contains("主题关键è¯ä¸¥æ ¼åªä½¿ç”¨â€œåŽŸåˆ›å¹»æƒ³èŒå® å†’险é“å…·â€")); + assert!(!back_button_prompt.contains("Pokemon")); + + let tile_prompt = build_jump_hop_tile_atlas_prompt( + "å®å¯æ¢¦", + "å®å¯æ¢¦ä¸»é¢˜çš„æ­£é¢30度视角主题物体图集,包å«çš®å¡ä¸˜å’Œç²¾çµçƒè£…饰", + ); + assert!(tile_prompt.contains("主题为“原创幻想èŒå® å†’险é“å…·â€")); + assert!(tile_prompt.contains("ç”»é¢å†…容是原创幻想èŒå® å†’险é“具主题")); + assert!(tile_prompt.contains("黄色闪电èŒå® ç¬¦å·")); + assert!(tile_prompt.contains("彩色冒险能é‡çƒ")); + assert!(!tile_prompt.contains("å®å¯æ¢¦")); + assert!(!tile_prompt.contains("çš®å¡ä¸˜")); + assert!(!tile_prompt.contains("ç²¾çµçƒ")); + + let normal_prompt = + build_jump_hop_tile_atlas_prompt("æ°´æžœ", "水果主题的正é¢30度视角主题物体图集"); + assert!(normal_prompt.contains("主题为“水果â€")); + assert!(normal_prompt.contains("ç”»é¢å†…容是水果主题的正é¢30度视角主题物体图集")); + } + #[test] fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() { let prompt = build_jump_hop_tile_atlas_prompt( diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 0684a314..cbad6f68 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -121,6 +121,8 @@ pub struct JumpHopActionRequest { pub tile_assets: Option>, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -240,6 +242,8 @@ pub struct JumpHopDraftResponse { pub path: Option, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, pub generation_status: JumpHopGenerationStatus, } @@ -308,6 +312,8 @@ pub struct JumpHopWorkProfileResponse { pub character_asset: JumpHopCharacterAsset, pub tile_atlas_asset: JumpHopCharacterAsset, pub tile_assets: Vec, + #[serde(default)] + pub back_button_asset: Option, } #[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 4f3ed703..8470345b 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -706,6 +706,14 @@ fn merge_action_into_draft( { draft.cover_composite = Some(value.to_string()); } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles + ) { + if let Some(asset) = payload.back_button_asset.clone() { + draft.back_button_asset = Some(asset); + } + } if draft.work_title.trim().is_empty() { return Err(SpacetimeClientError::validation_failed( "jump-hop work_title ä¸èƒ½ä¸ºç©º", @@ -763,6 +771,11 @@ fn build_compile_input( tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?), tile_assets_json: Some(json_string(&tile_assets)?), cover_composite, + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, generation_status: Some("ready".to_string()), compiled_at_micros: now_micros, }) @@ -848,6 +861,7 @@ fn default_draft() -> JumpHopDraftResponse { tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JumpHopGenerationStatus::Draft, } } 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 6836d6e2..eec6ba97 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -159,6 +159,7 @@ fn map_jump_hop_work_snapshot( .collect(), path: Some(map_jump_hop_path(snapshot.path.clone())), cover_composite: snapshot.cover_composite.clone(), + back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset), generation_status: parse_generation_status(&snapshot.generation_status), }; let character_asset = draft @@ -201,6 +202,7 @@ fn map_jump_hop_work_snapshot( .into_iter() .map(map_tile_asset) .collect(), + back_button_asset: snapshot.back_button_asset.map(map_character_asset), }) } @@ -233,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe .collect(), path: snapshot.path.map(map_jump_hop_path), cover_composite: snapshot.cover_composite, + back_button_asset: snapshot.back_button_asset.map(map_character_asset), generation_status: parse_generation_status(&snapshot.generation_status), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs index 519e5acd..93176d49 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs @@ -31,10 +31,10 @@ pub trait get_jump_hop_leaderboard { input: JumpHopLeaderboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_jump_hop_leaderboard for super::RemoteProcedures { input: JumpHopLeaderboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs index d8f3e7f5..df9575f0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs @@ -25,6 +25,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs index cc2f6d8d..adfeae68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs @@ -28,6 +28,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, pub generation_status: String, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs index c95dc8c9..3a8b9e68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs @@ -33,6 +33,7 @@ pub struct JumpHopWorkProfileRow { pub published_at: Option<__sdk::Timestamp>, pub visible: bool, pub theme_text: Option, + pub back_button_asset_json: Option, } impl __sdk::InModule for JumpHopWorkProfileRow { @@ -69,6 +70,7 @@ pub struct JumpHopWorkProfileRowCols { pub published_at: __sdk::__query_builder::Col>, pub visible: __sdk::__query_builder::Col, pub theme_text: __sdk::__query_builder::Col>, + pub back_button_asset_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { @@ -110,6 +112,10 @@ impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), visible: __sdk::__query_builder::Col::new(table_name, "visible"), theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"), + back_button_asset_json: __sdk::__query_builder::Col::new( + table_name, + "back_button_asset_json", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs index d72083f8..969a4297 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct JumpHopWorkSnapshot { pub path: JumpHopPath, pub cover_image_src: String, pub cover_composite: Option, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 7eb34301..0ff204da 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -312,6 +312,7 @@ fn create_jump_hop_agent_session_tx( tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(), }; ctx.db @@ -391,6 +392,11 @@ fn compile_jump_hop_draft_tx( .unwrap_or_default(), path: Some(path.clone()), cover_composite: input.cover_composite.as_deref().and_then(clean_optional), + back_button_asset: input + .back_button_asset_json + .as_deref() + .map(parse_json) + .transpose()?, generation_status: input .generation_status .clone() @@ -425,6 +431,7 @@ fn compile_jump_hop_draft_tx( path_json: to_json_string(&path), cover_image_src: draft.cover_composite.clone().unwrap_or_default(), cover_composite: draft.cover_composite.clone().unwrap_or_default(), + back_button_asset_json: draft.back_button_asset.as_ref().map(to_json_string), generation_status: draft.generation_status.clone(), publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(), play_count: 0, @@ -830,6 +837,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result bool { && !row.tile_atlas_asset_json.trim().is_empty() && !row.tile_assets_json.trim().is_empty() && !row.path_json.trim().is_empty() + && row + .back_button_asset_json + .as_deref() + .and_then(clean_optional) + .is_some() } fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { @@ -1399,6 +1423,7 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow { published_at: row.published_at, visible: row.visible, theme_text: row.theme_text.clone(), + back_button_asset_json: row.back_button_asset_json.clone(), } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index 4806fbac..c5b66771 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -59,6 +59,9 @@ pub struct JumpHopWorkProfileRow { // 跳一跳生æˆä¸»é¢˜ç‹¬ç«‹äºŽä½œå“标题;旧行按 work_title 兜底。 #[default(None::)] pub(crate) theme_text: Option, + // 跳一跳左上角真实å¯ç‚¹å‡»è¿”å›žæŒ‰é’®çš„ç‹¬ç«‹é€æ˜Žèµ„产快照;旧行为空时è¿è¡Œæ€ä½¿ç”¨æ ·å¼å…œåº•。 + #[default(None::)] + pub(crate) back_button_asset_json: Option, } #[spacetimedb::table( 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 6edb7312..45441a3d 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -56,6 +56,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } @@ -248,6 +249,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, pub generation_status: String, } @@ -291,6 +293,7 @@ pub struct JumpHopWorkSnapshot { pub path: module_jump_hop::JumpHopPath, pub cover_image_src: String, pub cover_composite: Option, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index dc95fad8..dfd882fb 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1330,6 +1330,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); + if table_name == "jump_hop_work_profile" { + // 中文注释:跳一跳主题返回按钮资产晚于首版作å“表加入,旧è¿ç§»åŒ…æŒ‰æœªç”ŸæˆæŒ‰é’®å…¼å®¹ã€‚ + object + .entry("back_button_asset_json".to_string()) + .or_insert(serde_json::Value::Null); + } } } if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" { diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index 07287372..f19ec837 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -168,6 +168,7 @@ function buildProfile( tileAssets: [], path: null, coverComposite: null, + backButtonAsset: null, generationStatus: 'ready', }, path: null as never, @@ -199,5 +200,6 @@ function buildProfile( height: 0, }, tileAssets: [], + backButtonAsset: null, }; } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index e882b62e..c6332727 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -253,6 +253,70 @@ test('跳一跳è¿è¡Œæ€æ¸¸çީ䏭åªä¿ç•™å¾—分并éšè—常驻排行榜', () => expect(screen.queryByRole('button', { name: /^èµ·è·³$/ })).toBeNull(); }); +test('跳一跳è¿è¡Œæ€èƒŒæ™¯å’Œæ¸¸æˆèˆžå°è¦†ç›–全部界é¢ä¸” HUD 使用独立主题按钮和拼图顶部样å¼', () => { + const backButtonAsset = { + assetId: 'jump-hop-back-button', + imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png', + imageObjectKey: + 'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png', + assetObjectId: 'asset-back-button', + generationProvider: 'vector-engine-gpt-image-2', + prompt: '主题返回按钮', + width: 1024, + height: 1024, + } satisfies NonNullable; + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + expect(stage.className).toContain('absolute'); + expect(stage.className).toContain('inset-0'); + expect(stage.className).toContain('h-full'); + expect(stage.className).toContain('w-full'); + expect(stage.className).not.toContain('rounded-[1.5rem]'); + + const backButton = screen.getByRole('button', { name: '返回' }); + expect(backButton.className).toContain('pointer-events-auto'); + expect(backButton.className).toContain('jump-hop-runtime__back-button'); + expect(backButton.className).toContain('h-14'); + expect(backButton.className).toContain('w-14'); + expect(backButton.className).toContain('sm:h-[3.875rem]'); + expect(backButton.className).toContain('sm:w-[3.875rem]'); + expect(backButton.getAttribute('data-has-asset')).toBe('true'); + expect(backButton.textContent).toBe(''); + expect( + screen + .getByTestId('jump-hop-runtime-back-button-asset') + .getAttribute('src'), + ).toBe(backButtonAsset.imageSrc); + + const header = backButton.closest('header'); + expect(header?.className).toContain('absolute'); + expect(header?.className).toContain('top-0'); + expect(header?.className).toContain('z-[130]'); + expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy(); + const titleCard = header?.querySelector('.puzzle-runtime-level-title-card'); + expect(titleCard).toBeTruthy(); + expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card'); + expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy(); + expect(screen.getByText('得分')).toBeTruthy(); + expect(screen.queryByText('跳一跳')).toBeNull(); + + const scoreCard = screen.getByTestId('jump-hop-score-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer'); + expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card'); + expect(scoreCard.className).toContain('justify-center'); + expect(scoreCard.className).toContain('text-center'); +}); + test('跳一跳è¿è¡Œæ€å¤±è´¥åŽåœ¨å¼¹çª—中展示排行榜', () => { const runtimeRequestOptions = { runtimeGuestToken: 'runtime-guest-token', @@ -333,7 +397,7 @@ test('跳一跳角色层永远压在地å—层之上', () => { ); }); -test('跳一跳è½ç‚¹è¾…助标识会éšç€æ‹–拽方å‘å’Œè·ç¦»å®žæ—¶ç§»åЍ', async () => { +test('跳一跳拖拽时éšè—è½ç‚¹è¾…助标识但ä¿ç•™å¼¹å¼“拉线', async () => { const onJump = vi.fn().mockResolvedValue(undefined); render( @@ -354,8 +418,6 @@ test('跳一跳è½ç‚¹è¾…助标识会éšç€æ‹–拽方å‘å’Œè·ç¦»å®žæ—¶ç§»åЍ', as }); }); - expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - await act(async () => { dispatchPointerEvent(stage, 'pointermove', { pointerId: 1, @@ -364,11 +426,8 @@ test('跳一跳è½ç‚¹è¾…助标识会éšç€æ‹–拽方å‘å’Œè·ç¦»å®žæ—¶ç§»åЍ', as }); }); - const firstAssist = screen.getByTestId('jump-hop-landing-assist'); - const firstLeft = firstAssist.style.left; - const firstTop = firstAssist.style.top; - expect(firstAssist.getAttribute('data-target-index')).toBe('1'); - expect(firstLeft).not.toBe('62.288%'); + expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); await act(async () => { dispatchPointerEvent(stage, 'pointermove', { @@ -378,9 +437,8 @@ test('跳一跳è½ç‚¹è¾…助标识会éšç€æ‹–拽方å‘å’Œè·ç¦»å®žæ—¶ç§»åЍ', as }); }); - const secondAssist = screen.getByTestId('jump-hop-landing-assist'); - expect(secondAssist.style.left).not.toBe(firstLeft); - expect(secondAssist.style.top).not.toBe(firstTop); + expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); }); test('跳一跳è¿è¡Œæ€ç›´æŽ¥æ¸²æŸ“生æˆçš„地å—切片图片', () => { @@ -958,6 +1016,7 @@ function buildProfile(options: { tileAssets?: JumpHopWorkProfileResponse['tileAssets']; coverComposite?: string | null; coverImageSrc?: string | null; + backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset']; publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus']; } = {}): JumpHopWorkProfileResponse { const characterAsset = { @@ -1016,6 +1075,7 @@ function buildProfile(options: { tileAssets: options.tileAssets ?? [], path: buildRun().path, coverComposite: options.coverComposite ?? null, + backButtonAsset: options.backButtonAsset ?? null, generationStatus: 'ready', }, path: buildRun().path, @@ -1029,6 +1089,7 @@ function buildProfile(options: { characterAsset, tileAtlasAsset: characterAsset, tileAssets: options.tileAssets ?? [], + backButtonAsset: options.backButtonAsset ?? null, }; } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 86dba72d..09f47e17 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -11,6 +11,7 @@ import { useState, } from 'react'; +import jumpHopRuntimeLevelLogo from '../../../media/logo.png'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopTileAsset, @@ -622,6 +623,20 @@ export function JumpHopRuntimeShell({ refreshKey: stageBackgroundSource, }, ); + const backButtonAssetSource = + profile?.backButtonAsset?.imageSrc?.trim() || + profile?.draft.backButtonAsset?.imageSrc?.trim() || + null; + const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl( + backButtonAssetSource, + { + refreshKey: + profile?.backButtonAsset?.assetObjectId || + profile?.draft.backButtonAsset?.assetObjectId || + backButtonAssetSource || + undefined, + }, + ); useEffect(() => { activeRunRef.current = activeRun; @@ -717,8 +732,6 @@ export function JumpHopRuntimeShell({ stageRun?.path, visiblePlatforms.length, ]); - const showLandingAssist = - import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating; const characterPosition = getJumpHopCharacterVisualPosition( stageRun, visiblePlatforms, @@ -829,17 +842,6 @@ export function JumpHopRuntimeShell({ landingAssistStageSize.width, visualJump, ]); - const landingAssistPosition = showLandingAssist - ? getJumpHopLandingAssistVisualPosition( - stageRun, - visiblePlatforms, - visualCharacterPosition, - landingAssistStageSize, - dragDistance, - dragVector.x, - dragVector.y, - ) - : null; const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); const isSettled = stageRun?.status === 'failed' || stageRun?.status === 'cleared'; @@ -1308,299 +1310,344 @@ export function JumpHopRuntimeShell({ }; return ( -
-
-
+
+
void finishCharge(event)} + onPointerCancel={cancelCharge} + > + +
+ -
+ {platformRenderItems.map((item) => { + const { width, height } = getJumpHopPlatformVisualSize( + item.platform, + 1, + ); + const style = { + left: `${item.screenX}%`, + top: `${item.screenY}%`, + width, + height, + '--jump-hop-platform-scale': item.scale, + zIndex: + item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index, + } as CSSProperties; + const isCurrent = + item.advanceState !== 'exiting' && + item.index === stageRun?.currentPlatformIndex; + + return ( +
+
+ +
+ ); + })} + + {preloadTileAssets.length > 0 ? ( + + ) : null} + + {visualCharacterPosition && !isThreeCharacterLayerReady ? ( +
+
+ +
+ ) : null} +
+ + {isCharging && dragPointerPosition && characterPosition ? ( +
+ +
-
- {successfulJumpCount} +
+
+ + + 得分 + +
+
+ {successfulJumpCount} +
-
-
void finishCharge(event)} - onPointerCancel={cancelCharge} - > -
- - {error ? ( -
-
- - {error} - -
-
- ) : null} -
+ + ) : null}