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 ? (