5 Commits

Author SHA1 Message Date
3f9a2ba082 合并 master 到跳一跳分支
合入 master 最新平台公共组件与后端更新

保留跳一跳长按蓄力与视觉顶面判定规则

解决跳一跳运行态导入和决策日志冲突
2026-06-12 23:07:01 +08:00
6ee55707e1 统一跳一跳三维地块与落点判定
修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心

统一前端指示器飞行动画与后端顶面 footprint 判定

调整 Three.js 方块贴图与角色顶面投影表现

补充跳一跳 UV 图集切片与运行态规则文档
2026-06-12 22:42:39 +08:00
6bdf84dc0d optimized prompt 2026-06-10 20:34:10 +08:00
09ef80cd23 PRD补充自适应blob+gradient算法说明,保留AI prompt侧4x3 UV布局描述 2026-06-10 19:56:57 +08:00
95df62fc82 跳一跳UV面切分改用blob+gradient自适应算法
重构alpha.rs洋红去背预设参数
新增jump_hop_atlas_slicing.rs独立切图模块
修复jump_hop.rs调用链接入新切图算法
2026-06-10 19:49:39 +08:00
15 changed files with 3565 additions and 666 deletions

View File

@@ -1849,18 +1849,18 @@
- 验证方式:`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 定向前端测试。
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍
## 2026-06-01 跳一跳运行态地块视觉尺寸放大与命中 footprint 分离
- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2前端平台渲染抽成统一尺寸 helper保证单测可以直接校验放大结果。
- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2前端平台渲染抽成统一尺寸 helper保证单测可以直接校验放大结果。后续校正:正式命中只看下一块可见顶面 footprint不能让已 `2x` 归一化的 `width/height` 再把命中区二次放大;当前 footprint 使用归一化后宽度 28% / 高度 18% 的菱形,相当于旧未放大视觉规格的 56% / 36%,地块侧面、阴影和外沿不算正确落点。
- 影响范围:`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`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 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` 映射出的真实偏移,不得吸附回目标地块中心;飞行动画结束后保留约 `300ms` 落地停顿,再启动相机推进。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用`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`
@@ -1873,6 +1873,14 @@
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-12 跳一跳地块间距以当前最远视觉距离为上限随机
- 背景:跳一跳服务端路径已有随机距离雏形,但前端可见窗口把目标地块固定投影到 `47%` 屏幕高度,导致用户看到的地块间距仍像固定值,无法调出“近到远”的节奏变化。
- 决策:各难度当前 `max_gap` 保持为最大世界间距,最小间距固定为 `max_gap * 55%`,服务端按 seed 在该非零区间内随机生成下一块;前端 `buildJumpHopVisiblePlatforms` 必须用相邻地块真实世界距离缩放屏幕 X/Y 投影,最大距离沿用当前最远 45 度视觉位置,较近距离沿同一 45 度方向靠近当前块,不能再把目标块强制固定在同一屏幕坐标。
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs``src/services/jump-hop/jumpHopRuntimeModel.ts`、跳一跳运行态测试、PRD 和平台玩法链路文档。
- 验证方式:`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture``npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
@@ -2073,6 +2081,14 @@
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete``npm run check:encoding`
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-12 跳一跳判定范围必须和视觉顶面对齐
- 背景:跳一跳切到 Three.js 立方体后,曾用收缩后的顶面 footprint 做成功判定,导致指示器和角色视觉上已经落在方块顶面内,但后端仍可能判失败。
- 决策:跳一跳命中区必须严格等于当前视觉方块完整可见顶面 footprint不论何时都不得隐藏收缩或额外放宽如果后续调整方块视觉大小、顶面形状、相机角度、旋转或模型规格后端裁决、前端落点指示器和 Three.js 顶面脚点投影必须同步更新。
- 影响范围:`module-jump-hop` 后端裁决、`jumpHopRuntimeModel` 前端预测、运行态指示器、飞行动画、PRD 和平台链路文档。
- 验证方式:边缘落点只要仍在完整视觉顶面内必须判成功;超出完整视觉顶面才失败。运行 `cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture``npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Profile Wallet Delta Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。

View File

@@ -96,6 +96,14 @@
- 验证:`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`
## 跳一跳 Three.js 地块 UV 顶面要映射到 Z 轴
- 现象:跳一跳地块使用六面 UV 贴图后,看起来像贴图位置贴歪,顶面显示侧面纹理,或者旧单张地块图被拉到立方体多个面上。
- 原因:运行态以 `z` 作为立方体竖直高度和相机下压方向,但 Three.js `BoxGeometry` / `RoundedBoxGeometry` 的默认材质 group 顺序把 `+Y` 当 top如果直接按 `right / left / top / bottom / front / back` 写材质,玩法逻辑的 `top` 会贴到侧面。旧作品没有完整 `faceAssets` 时,把单张旧贴图强行作为 3D 六面 fallback 也会被误认为 UV 贴歪。
- 处理Three 平台层只在 `tileAssets[].faceAssets` 六面完整时启用;材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 映射到 `+Z` 顶面,并按每面 UV 方向做翻转校正;旧单图作品继续走 DOM 图片 / 原型兜底层。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖材质顺序、UV 翻转和旧单图不启用 Three 贴面;`cargo test -p api-server jump_hop_tile_atlas_slicing --manifest-path server-rs/Cargo.toml -- --nocapture` 应覆盖 UV 安全边裁切。
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``server-rs/crates/api-server/src/jump_hop.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
@@ -1809,10 +1817,10 @@
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 现象:按住蓄力时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;它只作为调参验证层随按下显示、松手或取消隐藏,不参与后端裁决和作品配置;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:半程蓄力时辅助点应落在当前地块和目标地块之间,完整蓄力时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`
## 跳一跳长按蓄力不能再消费拖拽方向
@@ -2098,8 +2106,8 @@
- 现象:跳一跳松手后如果后端很快返回下一帧 run地块窗口会立刻前移角色翻腾动画看起来像没播放若同时刷新图片资产还可能被误认为地块频闪。
- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。
- 处理:前端保留独立 `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 且下一跳不会被旧地块保留态阻塞。
- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run并进入约 `1440ms``platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块层和角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时已完成旧地块继续被新的相机推进自然带离屏幕超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。Three.js Sprite 角色与平台共用同一套屏幕坐标投影DOM 角色只作为 WebGL 或贴图加载失败 fallbackDOM fallback 在相机推进期间自身不能保留 `left/top` transition否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。
- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`角色 Three 帧沿真实预测落点插值并保留飞行弧线DOM fallback 角色与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x``--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态 DOM fallback 角色 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`
## 跳一跳相机推进不要让地块图片回退到原型方块
@@ -2113,19 +2121,43 @@
## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标
- 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。
- 原因Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和 DOM 角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
- 处理Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。DOM 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。
- 原因Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。
- 处理Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。Three.js Sprite 角色、DOM fallback 角色、蓄力引导、落点预测和 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`
## 跳一跳 Three.js 角色不要被地块透明排序压住
- 现象:角色已经进 Three.js 场景后,看起来像落在地块内部或只露出头,角色没有站在方块顶面上。
- 原因:地块材质如果设置 `transparent=true` 会进入 Three.js 透明物体排序队列,可能在 Sprite 角色之后绘制;同时角色脚点如果仍用固定 Z 高度,遇到标准 `1x1x1` 方块放大后的当前块时会落到顶面后方或方块体内。
- 处理:地块贴图材质只使用 `alphaTest` 裁掉透明边,不放入透明材质队列;角色 Sprite 的 `renderOrder` 必须高于平台 mesh脚点 Z 高度按最近方块半高加顶面偏移计算,确保角色站在当前方块顶面上方。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖平台材质不透明队列、角色 renderOrder 高于地块、角色脚点高度高于方块顶面。
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
## 跳一跳立方体贴图不要走透明主体切片
- 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 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 色,裁切后若仍有极少残留则转成不透明材质底色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。
- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top第 2 行 left/front/right/back第 3 行第 2 列 bottom水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端先对图集做洋红去背,再以 `jump_hop_atlas_slicing.rs` 的自适应 blob+gradient 算法检测 3x6 大单元和单元内六面区域,输出 108 张 `256x256` 不透明面贴图;固定 3x6 / 4x3 切片只作为测试对照和必要 fallback 参考,不作为优先生图切图路径。洋红 `#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`
## 跳一跳 UV 图集切片要防贴边矩形 u32 中间溢出
- 现象:跳一跳草稿在背景、返回按钮和地板图集 image2 都生成成功后前端报“执行跳一跳共创操作失败”Vite 代理日志出现 `socket hang up`,后端日志出现 `jump_hop_atlas_slicing.rs``attempt to subtract with overflow`
- 原因blob gradient 切片的 histogram 最大不透明矩形在计算顶部坐标时写成 `by0 + ly - sh + 1`。当模型输出的 UV 面内容刚好贴到 cell 顶边,数学结果本应是 0`u32` 会先执行中间步骤 `0 - 1` 并在 debug 运行时 panic。
- 处理:顶部坐标先在局部坐标内用 `ly.saturating_add(1).saturating_sub(sh)` 计算,再加 block 偏移;不要恢复成连写减法。补充贴顶两行不透明矩形回归测试,保证贴边 UV 面不会打崩共创接口。
- 验证:`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing::tests::max_opaque_rect_handles_content_touching_top_edge`;整组再跑 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop`
- 关联:`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs``server-rs/crates/api-server/src/jump_hop.rs`
## 跳一跳生图切图主路径不要绕过自适应图集切片
- 现象:拉取 `fix/jump-hop-image-gen` 后,如果又把生成链路切回旧固定坐标裁切,容易和该分支解决的 AI 图集偏移、间距不均、UV 面位置漂移问题互相抵消,导致新生图链路的实际收益无法验证。
- 原因:当前跳一跳 image2 prompt 仍要求 3x6 大单元和 4x3 UV 子网格,这是给模型和算法的结构约束;真实生产切图由自适应 `SeedRefinement + blob + gradient + max opaque rectangle` 链路消化 AI 输出偏差。固定网格切片只能验证理想图集,不适合覆盖新分支的主修复。
- 处理:生产生成链路优先调用 `slice_tile_atlas_adaptive(...)`;旧固定 `slice_jump_hop_tile_atlas(...)` 只保留为对照测试、实验和必要 fallback 参考。若自适应切图出现具体误切,应优先修正自适应模块的边界检测、主 blob、透明/安全色处理和回归测试,而不是直接全局切回固定坐标。
- 验证:新生成作品下载 `tile-01-top/front/right` 等面贴图时,单图应基本充满对应主题面内容,不应出现大块空背景、相邻面混入或纯色原型 cube同时执行 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing -- --nocapture`
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`
## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容容易误判为模型不服从提示词。

View File

@@ -2,15 +2,15 @@
## 1. 目标
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块内部再用自适应 blob+gradient 算法提取 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
首版目标:
1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG
3. 角色不再单独生图v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG
4. 运行态每屏只展示 3 个地块:当前地块、目标地块下一预览地块;
5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出;
4. 运行态每屏只展示 2 个地块:当前地块、目标地块,不再展示下一预览地块;
5. 操作方式为长按屏幕蓄力,松手后角色朝下一块地块中心方向弹出;
6. 只要落点未命中下一个地块,本局立即失败并冻结计时;
7. 成绩记录成功跳跃次数和游戏时长;
8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
@@ -21,12 +21,12 @@
- 展示名:`跳一跳`
- 工程域:`jump-hop`
- 创作入口卡:`subtitle = 主题驱动平台跳跃``imageSrc = /creation-type-references/jump-hop.webp`
- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + DOM 角色 + DOM HUD`
- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + Three.js Sprite 角色 + DOM HUD`
- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16`
- 素材策略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 不可用和测试 fallbackThree.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色必须由 DOM 透明 PNG 层渲染并保持在 Three.js 平台层之上
- 渲染分层Three.js 场景层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体统一绕玩法竖直 Z 轴自转 45°让运行态稳定露出顶面和两个侧面,不做 Y 轴偏航或 x/y/z scale 压成扁盒子;Three.js 方块模型边长在当前基础上视觉放大 1 倍,只改变模型显示尺寸,不改变平台中心点、随机间距和蓄力换算;后端命中 footprint 必须同步等于当前视觉可见顶面,不得隐藏收缩;运行态视角采用约 `1.69x` 近距相机和 45° 下压视角,每屏只保留当前地块和目标地块,当前脚下地块会根据目标方向偏向场地反侧,给下一块留出足够视野;地块从出现开始就使用自身真实规格,不再按当前 / 目标 / 远近或预览状态叠加倍率缩放,视觉远近只由相机和 Three.js 投影决定Three.js 平台层、Three.js Sprite 角色和 DOM fallback 层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallbackThree.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色主路径使用 Three.js Sprite 承载陶泥儿透明 PNGSprite 脚点必须落在当前方块顶面中心高度并且绘制顺序高于地块DOM 角色层仅在 WebGL 或角色贴图加载失败时兜底
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。
本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块永远在当前脚下地块的正 45 度或负 45 度方向随机出现。
## 3. 创作工具平台接入声明
@@ -35,10 +35,10 @@
- 单图资产槽位无独立角色图槽位v1 固定使用陶泥儿 logo 透明 PNG 角色
- 系列素材槽位:
- `batchId = jump-hop-tile-atlas`
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / 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 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理
- 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界y0/y1/y2/y3、x0/x1/x2/x3按此边界划分为 3x3 block 并保留 5 个有效 block将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位后端先对图集做洋红去背BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘
- 失败回写:生成失败时 session 保持 failed可从生成页重试
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
- API 命名空间:`/api/creation/jump-hop/*``/api/runtime/jump-hop/*`
@@ -60,11 +60,11 @@
## 5. 地板贴图图集
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体并在固定 `4列*3行` UV 网中提供六张面贴图不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体并在固定 `4列*3行` UV 网中提供六张面贴图AI prompt 侧不变);后端通过自适应 blob+gradient 算法检测面的实际位置并切图,不再依赖固定像素坐标均分。不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
图集要求:
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求prompt 侧不变)。后端切图优先使用自适应 blob+gradient 算法检测面的实际像素区域,不依赖固定像素坐标均分;固定网格切片只作为测试对照和必要 fallback 参考。
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
@@ -83,38 +83,37 @@ tile-13 tile-14 tile-15
tile-16 tile-17 tile-18
```
每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面旧作品没有 `faceAssets`运行态仍可把单张旧贴图应用到立方体所有面。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面旧作品没有完整 `faceAssets`只走 DOM 图片 / 原型兜底层,不再把单张旧贴图强行贴到 Three.js 立方体所有面,避免旧平面素材被误表现成 UV 贴歪。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。
## 6. 运行态规则
### 6.1 平台流
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见:
运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 2 个地块可见:
1. 当前地块;
2. 目标地块
3. 下一预览地块。
2. 目标地块
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径
服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。每个后续地块只能生成在当前地块的正 45 度或负 45 度方向上,世界坐标必须满足 `abs(next.x - current.x) == next.y - current.y`,左右方向由 seed 随机决定。当前版本已有的最远相邻地块间距作为各难度 `max_gap` 上限;每次新地块距离由 seed 在 `max_gap * 55%``max_gap` 之间随机,保证相对距离永远大于 0 且不会超过当前最大手感距离。前端只展示服务端快照,不自行生成正式路径;当前两块可见窗口必须按服务端真实相邻距离缩放屏幕投影,最大距离仍落在当前版本固定的左上 / 右上 45 度位置,较近距离则沿同一 45 度方向靠近当前脚下地块。当前脚下地块仍根据目标方向偏向目标反侧避免前方同时暴露两块地块。角色开局时脚点必须锚定在初始地块顶面中心Three.js 角色层通过立方体顶面高度定位脚点DOM fallback 也使用同一顶面中心屏幕锚点,不得额外把角色吸附到地块侧面或阴影中心
### 6.2 操作
1. 用户按住当前地块或画面开始蓄力;
2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶;
3. 松手后角色按本次输入方向弹出;
4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向;
5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心
3. 松手后角色从当前真实脚点出发,朝下一块地块顶面中心方向弹出;
4. 蓄力值决定跳跃距离,用户拖拽方向决定跳跃方向;
5. 前端提交 `dragDistance`,并为兼容后端契约提交由角色当前真实脚点指向下一块地块顶面中心推导出的 `dragVectorX/dragVectorY`;这些方向字段不得来自用户手指拖拽方向
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 是正式方向输入契约,不能在前端提交或后端裁决中丢弃
手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 仅用于兼容当前后端请求结构,玩法语义上不表示用户拖拽方向
松手后前端必须立即生成 `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` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
松手后前端必须立即生成 `visualJump`,用当前角色真实脚点作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前真实脚点沿下一块地块顶面中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。当前调参验证阶段,按住蓄力时允许显示一枚实时预测落点指示器,位置必须复用同一套前端预测结果:先按真实脚点到下一块顶面中心计算 `landedX/landedY`,再把该世界坐标投影到当前窗口和 Three.js 顶面脚点屏幕位置;不得用当前地块中心或屏幕线性插值替代。松手或取消时隐藏指示器,不参与后端裁决、不写入作品配置。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间显示窗口保持在本次起跳前的 2 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,成功跳跃在飞行动画结束后保留约 `300ms` 落地停顿,再进入约 `1440ms` 的相机推进过渡,避免角色刚落地就立刻拉镜头。推进过渡中,地块层和角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新目标地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块从出现开始保持自身真实尺寸,不得通过当前 / 目标 / 远近 / 预览状态附加 CSS `scale(...)` 或深度倍率;推进期只做统一相机层位移,远近变化交给相机和 Three.js 真实投影。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。
### 6.3 判定
1. 目标永远是当前地块后的下一个地块;
2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算
3. 落点进入下一个地块可见顶面 footprint则成功footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%
4. 落点未进入下一个地块可见顶面 footprint则失败`landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定;
2. 真实落点沿角色当前真实脚点到下一块地块顶面中心方向计算;开局脚点等于初始地块顶面中心,成功跳跃后脚点等于后端 `lastJump.landedX/landedY`,不得回退到当前地块中心
3. 落点进入下一个地块完整可见顶面 footprint则成功footprint 使用当前路径里该地块 `width/height` 按 45° 顶面投影得到的完整菱形区域,必须严格和当前视觉方块顶面一致,不得再额外收缩或放宽
4. 落点未进入下一个地块完整可见顶面 footprint则失败地块侧面、底面、投影阴影和旧半径范围都不算正确落点;`landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定;
5. 失败后状态改为 `failed`,计时冻结;
6. v1 没有通关状态、combo、perfect 或生命数。
@@ -153,7 +152,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
1. 陶泥儿 logo 透明角色预览;
2. 18 个地块资源池预览;
3. 首屏 3 块平台预览;
3. 首屏 2 块平台预览;
4. 试玩;
5. 发布;
6. 返回编辑;
@@ -187,13 +186,13 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
1. 创作页只显示主题输入;
2. 生成链路只调用一次地板贴图图集 image2不再调用角色生图
3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG
3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG
4. 结果页不依赖旧角色图片槽;
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;
5. 运行态为竖屏俯视角,首屏保持 2 个地块可见;
6. 长按蓄力值影响落点距离,角色初始脚点在初始地块顶面中心,跳跃方向固定朝下一块地块中心,目标地块始终位于当前脚下地块的正 45 度或负 45 度方向;
7. 未落到下一个地块立即失败;
8. 成功跳跃次数累加,失败后计时冻结;
9. 排行榜按成功跳跃次数优先排序;
10. 作品可保存、发布、分享并从公开入口启动。
11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。六面贴图通过换签或 blob 异步解析时Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。
12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。
11. 运行态 Three.js 地板必须只在 `tileAssets[].faceAssets` 六面贴图完整时启用 Three 平台层;玩法坐标把 Z 轴作为立方体竖直高度,因此材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 精确映射到 `+Z` 顶面,并按每面 UV 朝向做必要的翻转校正;六面贴图通过换签或 blob 异步解析时Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`旧作品没有完整 `faceAssets` 时使用 DOM 图片 / 原型兜底层,不使用单图 3D 贴面 fallback。立方体统一绕玩法竖直 Z 轴自转 45°让玩家稳定看到顶面和两个侧面不做 Y 轴偏航,不得把 x/y/z 缩放成扁盒子Three.js 方块模型边长视觉放大 1 倍,但平台中心点、随机间距和蓄力换算均保持原规则;后端命中 footprint 必须与当前视觉顶面完整对齐;地块材质使用 `alphaTest` 裁边但不得放进透明材质队列,避免透明排序把地块画到角色之上;角色主路径使用 Three.js Sprite 并与平台共用同一屏幕坐标投影Sprite 脚点必须按当前方块半高抬到顶面中心高度绘制顺序必须高于地块DOM 角色仅作为 WebGL 或角色贴图加载失败兜底;相机保持约 `1.69x` 近距 45° 下压视角,当前脚下地块根据目标方向偏向场地反侧,可见当前 / 目标两块地板之间的屏幕间距必须形成正负 45 度关系;所有地块从出现开始保持真实规格,不按距离、深度或预览状态做倍率缩放;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或角色层。
12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再保留约 `300ms` 落地停顿,随后看到地块窗口前移;成功落地显示必须保留真实落点偏移,且正确落点范围必须严格等于下一块地块完整可见顶面 footprint

View File

@@ -178,23 +178,25 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
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` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile
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 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮;
5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-18`,每个方块再持久化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path不能按重复的 `tileType` 复用槽位;`tileAssets[].faceAssets` 保存六面贴图,历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback运行态只有在六面 `faceAssets` 完整时才启用 Three.js 立方体贴面,旧作品没有完整 `faceAssets`只走 DOM 图片 / 原型兜底层,不再把单张贴图强行贴到立方体所有面;
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 2 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮;
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、返回按钮去绿、地板贴图图集切片和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
待解决问题(风险程度:高):跳一跳创作链路目前仍是一次 HTTP 请求内串行生成背景底图、返回按钮、地板贴图图集、切片和 OSS 写入VectorEngine image2 单步 timeout/connect 失败会在后端最多重试 5 次,而前端只有 20 分钟总等待窗口。若某次背景底图生成接近或超过 18 分钟,前端会先报“请求超时,请稍后重试”,但后端可能继续跑完并在数分钟后写入草稿;同时因为背景、返回按钮和图集等中间资产未按阶段落库,同一 session 超时后重试会重新从背景图开始生成,存在重复生图、重复计费、用户误以为失败、作品架状态短时间不一致的风险。后续应将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,并在每个素材阶段成功后写入可恢复状态;同时收口后端全局生成 deadline、前端等待策略和失败态回写确保超时、重试和最终成功不会互相打架。
生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks``paper-toy` 等工程值暴露给创作者。
运行态地块刷新规则固定由后端路径快照决定:后续地块只会出现在当前地块正负 45 度方向,当前版本已有的最远相邻地块间距作为各难度 `max_gap`,每次新地块距离按 seed 在 `max_gap * 55%``max_gap` 之间随机,距离不允许为 0。前端可见窗口不得再把目标块强制放到固定屏幕坐标而必须按服务端真实相邻距离缩放投影最大距离沿用当前固定视觉间距较近距离沿同一 45 度方向靠近当前块。
运行态规则真相必须沉到 `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`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
每屏只展示 2 个地块:当前地块目标地块,不再展示下一预览地块。平台流按同一 seed 无限生成,服务端每次补齐路径时只能把下一块生成在当前脚下块的正 45 度或负 45 度方向上,世界坐标满足 `abs(next.x - current.x) == next.y - current.y`;前端不得自行生成正式路径,只能按服务端路径方向把当前两块可见窗口投影成左上 / 右上 45 度布局,并把当前脚下地块偏向目标反侧,给目标地块留出更大视野空间。运行态 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 背景底图图片读取继续走平台资产换签没有背景时才回退到内置渐变Three.js 平台层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,只按单一 side 等比缩放当前 / 目标 / 预览地块,并把 `tileAssets[]` 的生成切片作为主题身份方块包装贴图加载到立方体表面;单块地板保持正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转;`tileAssets[].faceAssets` 存在Three.js 材质刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL任一面异步换签或 blob URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key运行态采用约 `1.3x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块基准位于屏幕中线略下方,目标和预览地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;Three.js 相机和 DOM 角色层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallbackThree.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影避免露出旧原型方块或双层闪现推进期存在旧地块退出保留时Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。
运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图图片读取继续走平台资产换签没有背景时才回退到内置渐变Three.js 场景层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,按地块自身真实规格计算当前 / 目标地块尺寸,并且只有在 `tileAssets[].faceAssets` 六面贴图完整时才把生成切片作为主题身份方块包装贴图加载到立方体表面;六面贴图通过换签或 blob 异步解析Three.js 平台 mesh 的刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL任一面 URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key玩法坐标把 Z 轴作为立方体竖直高度,运行态必须把逻辑 `top` 映射到 Three.js `+Z` 顶面,材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom` 并按每面 UV 朝向做翻转校正;单块地板统一绕玩法竖直 Z 轴自转 45°让玩家稳定看到顶面和两个侧面不做 Y 轴偏航,也不得把 x/y/z 缩放成扁盒子Three.js 方块模型边长在当前基础上视觉放大 1 倍,但只改变模型显示尺寸,不改变平台中心点、上一轮 `max_gap * 55%``max_gap` 的随机相对间距和蓄力换算;后端命中 footprint 必须严格等于当前视觉完整顶面,不论何时都不得额外收缩或放宽;地块材质使用 `alphaTest` 裁边但不得放进透明材质队列,避免透明排序把地块画到角色之上;运行态采用约 `1.69x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块根据目标方向偏向场地反侧,目标地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;地块从出现开始保持真实规格,不再按当前 / 目标 / 远近或预览状态叠加倍率缩放,视觉远近只由相机和 Three.js 投影决定Three.js 平台、Three.js Sprite 角色和 DOM fallback 层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallbackThree.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;旧作品没有完整 `faceAssets` 时不启用 Three 平台层,继续显示 DOM 图片 / 原型兜底层;推进期存在旧地块退出保留时Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。角色主路径使用 Three.js Sprite 承载 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG开局脚点必须位于初始地块顶面中心Sprite 脚点必须按当前方块半高抬到顶面中心高度且绘制顺序高于地块DOM 角色层仅在 WebGL 或角色贴图加载失败时兜底,并使用同一个顶面中心屏幕锚点,不得额外锚到地块侧面或阴影中心。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或角色层,否则会造成背景、地块和角色层频闪。
跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;前端必须提交 `dragDistance` 以及换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端正式裁决用该方向向量计算真实落点,只有旧客户端缺失方向、方向非有限数或向量长度过小时,才 fallback 到当前地块中心指向下一块地块中心。成功判定使用下一块地块可见顶面 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 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;跳跃朝向永远由角色当前脚点指向下一块地块顶面中心,前端不再提交拖拽方向,后端即使收到旧客户端的 `dragVectorX/dragVectorY` 也必须忽略。实际落点只由蓄力时长换算出的跳跃距离决定,成功判定使用下一块地块完整可见顶面 footprint后端以该地块 `width/height` 按 45° 顶面投影得到的完整菱形区域作为命中区,必须严格和视觉方块顶面效果对齐,禁止隐藏收缩命中区。落点进入该视觉顶面则成功,未进入则失败;地块侧面、底面、投影阴影和`landingRadius/perfectRadius` 半径范围都不算正确落点,旧半径字段只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向下一块顶面中心的轻量引导。当前调参验证阶段,按住蓄力时允许显示一枚实时预测落点指示器,位置必须复用同一套前端预测结果:先按真实脚点到下一块顶面中心计算 `landedX/landedY`,再把该世界坐标投影到当前窗口和 Three.js 顶面脚点屏幕位置,不得用当前地块中心或屏幕线性插值替代;松手或取消时隐藏,不参与后端裁决、不写入作品配置。松手后运行态必须立即生成 `visualJump`,用当前角色真实脚点作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿当前脚点到下一块顶面中心方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,成功跳跃在飞行动画结束后保留约 `300ms` 落地停顿,再把显示态切到后端最新 run并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块或刚落地就立刻拉镜头。推进时地块层和角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新目标地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块从出现开始保持自身真实尺寸,不得通过当前 / 目标 / 远近 / 预览状态附加 CSS `scale(...)` 或深度倍率;推进期只做统一相机层位移,远近变化交给相机和 Three.js 真实投影。相机推进期间 DOM 兜底角色自身必须禁用 `left/top` transition只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `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 失败、刷新回首页。
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'``JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地板贴图图集和路径配置,必须先补读完整 work profile 再传入运行态。结果页草稿试玩调用启动 run 时必须把 `runtimeMode=draft` 写进请求 body不能只放在请求选项里正式作品深链和推荐流使用 published 默认模式。`/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。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,951 @@
// 跳一跳图集自适应切片算法模块。
// 提供两种基于图像内容的自适应 cell 边界检测算法:
// - SeedRefinement: 种子点精修(默认),在固定网格分界线附近搜索 density 最低点。
// - ValleyDetection: 全谷检测,高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优。
// 两种算法均参数化,支持任意 rows × cols 网格配置,默认 6×3。
use axum::http::StatusCode;
use image;
use serde_json::json;
use crate::{
http_error::AppError,
jump_hop::{
JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices,
JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS,
crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label,
jump_hop_tile_type_by_index,
},
openai_image_generation::DownloadedOpenAiImage,
};
use shared_contracts::jump_hop::JumpHopTileFaceKey;
/// 默认 tile 行数
pub(crate) const DEFAULT_TILE_ROWS: u32 = 6;
/// 默认 tile 列数
pub(crate) const DEFAULT_TILE_COLS: u32 = 3;
/// 自适应切片算法类型(控制 atlas 级 6×3 cell 网格检测)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AtlasSliceAlgorithm {
/// 种子点精修:在固定网格分界线附近搜索 density 最低点(默认)
SeedRefinement,
/// 全谷检测:高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优
ValleyDetection,
}
impl Default for AtlasSliceAlgorithm {
fn default() -> Self {
Self::SeedRefinement
}
}
/// 自适应 cell 网格检测结果
#[derive(Clone, Debug)]
pub(crate) struct AdaptiveCellGrid {
/// 行边界位置 [height],长度 = rows + 1
pub row_boundaries: Vec<u32>,
/// 列边界位置 [width],长度 = cols + 1
pub col_boundaries: Vec<u32>,
/// 使用的算法
#[allow(dead_code)]
pub algorithm: AtlasSliceAlgorithm,
}
// ============================================================================
// Density 计算
// ============================================================================
/// 从 RGBA 像素计算行投影 density每行非透明像素占比
pub(crate) fn compute_row_density(pixels: &[u8], width: u32, height: u32) -> Vec<f32> {
let w = width as usize;
let h = height as usize;
let stride = w * 4;
let mut density = vec![0.0f32; h];
let total = w as f32;
for y in 0..h {
let row_start = y * stride;
let mut content = 0u32;
for x in 0..w {
if pixels[row_start + x * 4 + 3] > 0 {
content += 1;
}
}
density[y] = content as f32 / total;
}
density
}
/// 从 RGBA 像素计算列投影 density每列非透明像素占比
pub(crate) fn compute_col_density(pixels: &[u8], width: u32, height: u32) -> Vec<f32> {
let w = width as usize;
let h = height as usize;
let stride = w * 4;
let mut density = vec![0.0f32; w];
let total = h as f32;
for x in 0..w {
let mut content = 0u32;
for y in 0..h {
if pixels[y * stride + x * 4 + 3] > 0 {
content += 1;
}
}
density[x] = content as f32 / total;
}
density
}
// ============================================================================
// 共享工具
// ============================================================================
/// 在 [seed-radius, seed+radius] 范围内找 density 最小值的 index
fn find_min_density_position(density: &[f32], seed: u32, radius: u32) -> u32 {
let lo = seed.saturating_sub(radius) as usize;
let hi = (seed + radius).min(density.len().saturating_sub(1) as u32) as usize;
if lo >= density.len() || lo > hi {
return seed;
}
let mut best = seed as usize;
let mut best_val = density[best.min(density.len() - 1)];
for i in lo..=hi {
if density[i] < best_val {
best_val = density[i];
best = i;
}
}
best as u32
}
/// 保证边界单调递增(禁止交叉)
fn enforce_monotonic(boundaries: &mut [u32]) {
for i in 1..boundaries.len() {
if boundaries[i] <= boundaries[i - 1] {
boundaries[i] = boundaries[i - 1] + 1;
}
}
}
// ============================================================================
// 算法 A: 种子点精修 (Seed Refinement)
// ============================================================================
/// 种子点精修:对每条固定网格分界线,在 ±radius 搜索窗口内找 density 最低点。
///
/// * `density` - 一维投影 density 序列
/// * `seeds` - 固定网格分界线位置(不含 0 和 max
/// * `radius` - 搜索半径
///
/// 返回精修后的分界线位置(不含 0 和 max
pub(crate) fn refine_boundaries_seed(
density: &[f32],
seeds: &[u32],
radius: u32,
) -> Vec<u32> {
let mut refined = Vec::with_capacity(seeds.len());
for &seed in seeds {
let pos = find_min_density_position(density, seed, radius);
refined.push(pos);
}
enforce_monotonic(&mut refined);
refined
}
/// 种子点精修完整流程:计算 density → 生成种子 → 精修 → 组装边界
pub(crate) fn detect_cell_grid_seed(
pixels: &[u8],
width: u32,
height: u32,
rows: u32,
cols: u32,
) -> AdaptiveCellGrid {
let row_density = compute_row_density(pixels, width, height);
let col_density = compute_col_density(pixels, width, height);
let cell_height = (height / rows).max(1);
let cell_width = (width / cols).max(1);
let radius_row = (cell_height / 3).max(1);
let radius_col = (cell_width / 3).max(1);
let row_seeds: Vec<u32> = (1..rows).map(|i| i * height / rows).collect();
let col_seeds: Vec<u32> = (1..cols).map(|i| i * width / cols).collect();
let row_splits = refine_boundaries_seed(&row_density, &row_seeds, radius_row);
let col_splits = refine_boundaries_seed(&col_density, &col_seeds, radius_col);
let mut row_boundaries = vec![0u32];
row_boundaries.extend(row_splits);
row_boundaries.push(height);
let mut col_boundaries = vec![0u32];
col_boundaries.extend(col_splits);
col_boundaries.push(width);
AdaptiveCellGrid {
row_boundaries,
col_boundaries,
algorithm: AtlasSliceAlgorithm::SeedRefinement,
}
}
// ============================================================================
// 算法 B: 谷检测 (Valley Detection)
// ============================================================================
/// 一维高斯平滑核
fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec<f32> {
let n = signal.len();
if n == 0 {
return vec![];
}
let radius = (sigma * 3.0).ceil() as isize;
let mut kernel = Vec::new();
let mut kernel_sum = 0.0f32;
for i in -radius..=radius {
let w = (-(i as f32).powi(2) / (2.0 * sigma * sigma)).exp();
kernel.push(w);
kernel_sum += w;
}
for w in &mut kernel {
*w /= kernel_sum;
}
let mut result = vec![0.0f32; n];
for i in 0..n {
let mut acc = 0.0f32;
let mut w_sum = 0.0f32;
for (k, &w) in kernel.iter().enumerate() {
let idx = i as isize + k as isize - radius;
if idx >= 0 && idx < n as isize {
acc += signal[idx as usize] * w;
w_sum += w;
}
}
if w_sum > 0.0 {
result[i] = acc / w_sum;
}
}
result
}
/// 低于阈值的连续区间 → 候选谷列表
fn extract_valleys_below_threshold(
signal: &[f32],
threshold: f32,
) -> Vec<(usize, usize)> {
let n = signal.len();
let mut valleys = Vec::new();
let mut in_valley = false;
let mut start = 0usize;
for i in 0..n {
if signal[i] <= threshold {
if !in_valley {
start = i;
in_valley = true;
}
} else if in_valley {
valleys.push((start, i - 1));
in_valley = false;
}
}
if in_valley {
valleys.push((start, n - 1));
}
valleys
}
/// 合并间距 < min_gap 的相邻谷
fn merge_close_valleys(
valleys: &[(usize, usize)],
min_gap: usize,
) -> Vec<(usize, usize)> {
if valleys.is_empty() {
return vec![];
}
let mut merged = Vec::new();
let mut cur_start = valleys[0].0;
let mut cur_end = valleys[0].1;
for &(s, e) in &valleys[1..] {
if s - cur_end <= min_gap {
cur_end = e;
} else {
merged.push((cur_start, cur_end));
cur_start = s;
cur_end = e;
}
}
merged.push((cur_start, cur_end));
merged
}
/// 谷的几何中心
fn valley_centers(valleys: &[(usize, usize)]) -> Vec<u32> {
valleys.iter().map(|&(s, e)| ((s + e) / 2) as u32).collect()
}
/// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组
fn select_spaced_valleys(
centers: &[u32],
expected_spacing: f32,
target_count: usize,
) -> Vec<u32> {
if centers.len() <= target_count {
return centers.to_vec();
}
let mut best_score = f32::MAX;
let mut best_centers = vec![];
for start in 0..=centers.len() - target_count {
let window = &centers[start..start + target_count];
let mut score = 0.0f32;
for i in 1..window.len() {
let ratio = (window[i] - window[i - 1]) as f32 / expected_spacing;
score += (ratio - 1.0).powi(2);
}
if score < best_score {
best_score = score;
best_centers = window.to_vec();
}
}
best_centers
}
/// 谷检测完整流程
pub(crate) fn refine_boundaries_valley(
density: &[f32],
expected_cell_count: u32,
expected_cell_size: f32,
total_length: u32,
) -> Result<Vec<u32>, &'static str> {
if expected_cell_count <= 1 {
return Ok(vec![]);
}
let expected_valleys = (expected_cell_count - 1) as usize;
// 步骤1: 高斯平滑
let sigma = expected_cell_size / 4.0;
let smoothed = gaussian_smooth_1d(density, sigma);
// 步骤2: 自适应阈值
let peak = smoothed.iter().cloned().fold(0.0f32, f32::max);
let threshold = f32::max(peak * 0.15, 0.02);
// 步骤3: 提取候选谷
let raw_valleys = extract_valleys_below_threshold(&smoothed, threshold);
if raw_valleys.is_empty() {
return Err("未检测到候选谷");
}
// 步骤4: 合并相邻谷
let min_gap = (expected_cell_size * 0.5) as usize;
let merged = merge_close_valleys(&raw_valleys, min_gap);
// 步骤5: 过滤窄噪声谷(宽度 < 3px
let filtered: Vec<_> = merged
.into_iter()
.filter(|&(s, e)| e >= s && e - s >= 3)
.collect();
if filtered.is_empty() {
return Err("过滤后无有效谷");
}
let centers = valley_centers(&filtered);
// 步骤6: 候选太多时按谷深排序取 top
let candidates = if centers.len() > expected_valleys + 2 {
let mut scored: Vec<_> = filtered
.iter()
.map(|&(s, e)| {
let avg = smoothed[s..=e].iter().sum::<f32>() / (e - s + 1) as f32;
let depth = peak - avg;
(depth, (s + e) / 2)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(expected_valleys + 2);
let mut c: Vec<u32> = scored.into_iter().map(|(_, center)| center as u32).collect();
c.sort();
c
} else {
centers
};
// 步骤7: 滑窗选最优 expected_valleys 个
let selected = select_spaced_valleys(&candidates, expected_cell_size, expected_valleys);
// 步骤8: 校验间距合理性
let min_spacing = (expected_cell_size * 0.5) as u32;
let max_spacing = (expected_cell_size * 1.8) as u32;
for i in 1..selected.len() {
let gap = selected[i] - selected[i - 1];
if gap < min_spacing || gap > max_spacing {
return Err("谷间距异常");
}
}
// 首尾不能太靠边
let min_edge = (expected_cell_size / 3.0) as u32;
if selected[0] < min_edge || total_length - selected[selected.len() - 1] < min_edge {
return Err("谷太靠近边界");
}
Ok(selected)
}
/// 谷检测完整流程:计算 density → 谷检测 → 组装边界
pub(crate) fn detect_cell_grid_valley(
pixels: &[u8],
width: u32,
height: u32,
rows: u32,
cols: u32,
) -> Result<AdaptiveCellGrid, &'static str> {
let row_density = compute_row_density(pixels, width, height);
let col_density = compute_col_density(pixels, width, height);
let cell_height = (height / rows).max(1) as f32;
let cell_width = (width / cols).max(1) as f32;
let row_splits = refine_boundaries_valley(&row_density, rows, cell_height, height)?;
let col_splits = refine_boundaries_valley(&col_density, cols, cell_width, width)?;
let mut row_boundaries = vec![0u32];
row_boundaries.extend(row_splits);
row_boundaries.push(height);
let mut col_boundaries = vec![0u32];
col_boundaries.extend(col_splits);
col_boundaries.push(width);
Ok(AdaptiveCellGrid {
row_boundaries,
col_boundaries,
algorithm: AtlasSliceAlgorithm::ValleyDetection,
})
}
// ============================================================================
// 主入口:自适应切片
// ============================================================================
/// 使用自适应算法对洋红去背后的图集进行切片。
///
/// * `image` - 洋红去背后的图集图片
/// * `rows` - cell 行数(默认 6
/// * `cols` - cell 列数(默认 3
/// * `algorithm` - 自适应算法
pub(crate) fn slice_tile_atlas_adaptive(
image: &DownloadedOpenAiImage,
rows: u32,
cols: u32,
algorithm: AtlasSliceAlgorithm,
) -> Result<Vec<JumpHopTileAtlasSlice>, 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}"),
}))
})?
.to_rgba8();
let width = source.width();
let height = source.height();
let pixels = source.as_raw();
// 自适应检测 cell 网格
let grid = match algorithm {
AtlasSliceAlgorithm::SeedRefinement => {
detect_cell_grid_seed(pixels, width, height, rows, cols)
}
AtlasSliceAlgorithm::ValleyDetection => {
detect_cell_grid_valley(pixels, width, height, rows, cols)
.unwrap_or_else(|_| {
// 谷检测失败时回退到种子点精修
detect_cell_grid_seed(pixels, width, height, rows, cols)
})
}
};
if grid.row_boundaries.len() != (rows + 1) as usize
|| grid.col_boundaries.len() != (cols + 1) as usize
{
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!(
"自适应网格检测结果异常:期望 {}×{},实际 {}×{}",
rows + 1,
cols + 1,
grid.row_boundaries.len(),
grid.col_boundaries.len(),
),
})));
}
let tile_count = (rows * cols) as usize;
let mut slices = Vec::with_capacity(tile_count);
let mut index = 0usize;
for row in 0..rows {
for col in 0..cols {
let x0 = grid.col_boundaries[col as usize];
let x1 = grid.col_boundaries[col as usize + 1];
let y0 = grid.row_boundaries[row as usize];
let y1 = grid.row_boundaries[row as usize + 1];
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_blob(
&source,
x0,
y0,
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),
faces,
});
index += 1;
}
}
Ok(slices)
}
// ============================================================================
// Cell 内 UV 面提取(与固定网格逻辑相同,接收 cell 边界参数)
// ============================================================================
fn slice_jump_hop_tile_uv_faces_adaptive(
source: &image::RgbaImage,
tile_x: u32,
tile_y: u32,
tile_width: u32,
tile_height: u32,
atlas_row: u32,
atlas_col: u32,
) -> Result<JumpHopTileFaceSlices, AppError> {
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);
Ok(JumpHopTileFaceSlices {
top: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
)?,
front: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
)?,
right: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
)?,
back: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
)?,
left: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
)?,
bottom: slice_jump_hop_tile_uv_face_adaptive(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
)?,
})
}
#[allow(clippy::too_many_arguments)]
fn slice_jump_hop_tile_uv_face_adaptive(
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<JumpHopTileFaceSlice, AppError> {
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(),
})
}
// ============================================================================
// Blob + Gradient 驱动 UV 面切分
//
// 1. BFS 找主 blob构造仅含 blob 的 cleaned 图像
// 2. 行/列 density → 平滑 → gradient → 8 边界
// 3. 3×3 block → 5 有效块 → Block(1,2) 拆分 → 6 块
// 4. 每块 max opaque rectangle → 缩放
// ============================================================================
const BLOB_ALPHA: u8 = 48;
const MIN_BLOB_AREA: usize = 64;
const GRAD_SMOOTH: usize = 3;
// ---- 1. BFS 主 blob + 构造 cleaned 图像 ----
fn build_cleaned_tile(
source: &image::RgbaImage,
tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32,
) -> Option<image::RgbaImage> {
let pixels = source.as_raw();
let sw = source.width() as usize;
let stride = sw * 4;
let total = (tile_w * tile_h) as usize;
let mut visited = vec![false; total];
let mut queue = Vec::<usize>::new();
let mut best_comp = Vec::<usize>::new();
let idx = |lx: u32, ly: u32| (ly * tile_w + lx) as usize;
for sy in 0..tile_h {
for sx in 0..tile_w {
let si = idx(sx, sy);
if visited[si] { continue; }
let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4;
if pixels[go + 3] < BLOB_ALPHA { continue; }
queue.clear();
queue.push(si);
visited[si] = true;
let mut qi = 0;
while qi < queue.len() {
let cur = queue[qi]; qi += 1;
let cx = cur as u32 % tile_w;
let cy = cur as u32 / tile_w;
for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] {
let nx = cx as i32 + dx;
let ny = cy as i32 + dy;
if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; }
let ni = idx(nx as u32, ny as u32);
if visited[ni] { continue; }
let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4;
if pixels[ngo + 3] >= BLOB_ALPHA {
visited[ni] = true;
queue.push(ni);
}
}
}
let area = queue.len();
if area < MIN_BLOB_AREA { continue; }
if area > best_comp.len() {
best_comp = queue.clone();
}
}
}
if best_comp.is_empty() { return None; }
// 构造 cleaned 图像:仅含主 blob
let mut cleaned = image::RgbaImage::new(tile_w, tile_h);
for &pi in &best_comp {
let lx = pi as u32 % tile_w;
let ly = pi as u32 / tile_w;
let gx = tile_x + lx;
let gy = tile_y + ly;
let go = (gy as usize * sw + gx as usize) * 4;
cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]]));
}
Some(cleaned)
}
// ---- 2. density + gradient → 边界 ----
fn smooth_1d(signal: &[f32], window: usize) -> Vec<f32> {
if signal.len() <= window { return signal.to_vec(); }
let hw = window / 2;
(0..signal.len()).map(|i| {
let lo = i.saturating_sub(hw);
let hi = (i + hw).min(signal.len() - 1);
let sum: f32 = signal[lo..=hi].iter().sum();
sum / (hi - lo + 1) as f32
}).collect()
}
fn gradient(signal: &[f32]) -> Vec<f32> {
if signal.len() < 2 { return vec![]; }
(0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect()
}
/// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。
/// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。
struct GradPeak { idx: usize, val: f32 }
fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec<GradPeak> {
if grad.len() < 2 { return vec![]; }
// 取绝对值后找局部极大
let abs_grad: Vec<f32> = grad.iter().map(|&g| g.abs()).collect();
let mut peaks: Vec<GradPeak> = (1..abs_grad.len()-1)
.filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0)
.map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号
.collect();
peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal));
// 去重:间距小于 min_sep 的只保留最大者
let mut chosen = Vec::new();
for p in peaks {
if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) {
chosen.push(p);
if chosen.len() >= count { break; }
}
}
chosen
}
/// 行 density → gradient → y₀,y₁,y₂,y₃
fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> {
// 行 density
let mut row_density = Vec::with_capacity(tile_h as usize);
for y in 0..tile_h {
let mut cnt = 0u32;
for x in 0..tile_w {
if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; }
}
row_density.push(cnt as f32 / tile_w as f32);
}
let smooth = smooth_1d(&row_density, GRAD_SMOOTH);
let grad = gradient(&smooth);
let peaks = find_gradient_peaks(&grad, 4, 4);
if peaks.len() < 2 { return None; }
// 分离正负
let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect();
let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect();
if pos.len() < 1 || neg.len() < 1 { return None; }
// y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄)
let y1 = pos[0].idx as u32;
let y2 = neg[0].idx as u32;
let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32;
let y3 = (row_density.len() as u32).saturating_sub(
1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32
) + 1;
if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; }
Some((y0, y1, y2, y3))
}
/// 列高度 profile每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃
fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> {
// 每列的 blob 高度
let mut col_height = Vec::with_capacity(tile_w as usize);
for x in 0..tile_w {
let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA);
let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA);
col_height.push(
first.map_or(0.0, |f| {
let l = last.unwrap_or(f);
(l - f + 1) as f32 / (y3 - y0).max(1) as f32
})
);
}
let smooth = smooth_1d(&col_height, GRAD_SMOOTH);
let grad = gradient(&smooth);
let peaks = find_gradient_peaks(&grad, 4, 4);
if peaks.len() < 2 { return None; }
let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect();
let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect();
if pos.len() < 1 || neg.len() < 1 { return None; }
let x1 = pos[0].idx as u32;
let x2 = neg[0].idx as u32;
let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32;
let x3 = (tile_w as usize).saturating_sub(
1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0)
) as u32 + 1;
if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; }
Some((x0, x1, x2, x3))
}
// ---- 3. max opaque rectangle per block ----
/// 在 block 范围内基于 histogram 找最大全不透明矩形。
fn max_opaque_rect(
cleaned: &image::RgbaImage,
bx0: u32, by0: u32, bw: u32, bh: u32,
) -> Option<(u32, u32, u32, u32)> {
let mut heights = vec![0u32; bw as usize];
let mut best_area = 0u32;
let mut best = (0u32, 0u32, 1u32, 1u32);
for ly in 0..bh {
for lx in 0..bw {
if cleaned.get_pixel(bx0 + lx, by0 + ly).0[3] >= BLOB_ALPHA {
heights[lx as usize] += 1;
} else {
heights[lx as usize] = 0;
}
}
// histogram max rect
let mut stack: Vec<(u32, u32)> = Vec::new(); // (start_x, height)
for (x, &h) in heights.iter().enumerate() {
let x = x as u32;
let mut start = x;
while stack.last().map_or(false, |&(_, sh)| sh > h) {
let (sx, sh) = stack.pop().unwrap();
let area = sh * (x - sx);
if area > best_area {
best_area = area;
let top = by0 + ly.saturating_add(1).saturating_sub(sh);
best = (bx0 + sx, top, x - sx, sh);
}
start = sx;
}
if h > 0 && stack.last().map_or(true, |&(_, sh)| h > sh) {
stack.push((start, h));
}
}
let x = bw;
while let Some((sx, sh)) = stack.pop() {
let area = sh * (x - sx);
if area > best_area {
best_area = area;
let top = by0 + ly.saturating_add(1).saturating_sub(sh);
best = (bx0 + sx, top, x - sx, sh);
}
}
}
if best_area == 0 { None } else { Some(best) }
}
#[cfg(test)]
mod tests {
use image::{Rgba, RgbaImage};
use super::max_opaque_rect;
#[test]
fn max_opaque_rect_handles_content_touching_top_edge() {
let mut image = RgbaImage::from_pixel(4, 3, Rgba([0, 0, 0, 0]));
for y in 0..2 {
for x in 0..3 {
image.put_pixel(x, y, Rgba([255, 255, 255, 255]));
}
}
let rect = max_opaque_rect(&image, 0, 0, 4, 3).expect("应该能识别贴顶矩形");
assert_eq!(rect, (0, 0, 3, 2));
}
}
// ---- 4. 主编排 ----
fn slice_jump_hop_tile_uv_faces_blob(
source: &image::RgbaImage,
tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32,
atlas_row: u32, atlas_col: u32,
) -> Result<JumpHopTileFaceSlices, AppError> {
let fallback = || {
slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col)
};
// Step 1: BFS 主 blob → cleaned 图像
let cleaned = match build_cleaned_tile(source, tile_x, tile_y, tile_w, tile_h) {
Some(c) => c,
None => return fallback(),
};
// Step 2: gradient 边界检测
let (y0, y1, y2, y3) = match detect_row_boundaries(&cleaned, tile_w, tile_h) {
Some(v) => v,
None => return fallback(),
};
let (x0, x1, x2, x3) = match detect_col_boundaries(&cleaned, tile_w, tile_h, y0, y3) {
Some(v) => v,
None => return fallback(),
};
// Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块
// blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom
let blocks: [(u32,u32,u32,u32); 5] = [
(x1, y0, x2 - x1, y1 - y0), // Top
(x0, y1, x1 - x0, y2 - y1), // Left
(x1, y1, x2 - x1, y2 - y1), // Front
(x2, y1, x3 - x2, y2 - y1), // Right+Back
(x1, y2, x2 - x1, y3 - y2), // Bottom
];
// Step 4: max opaque rectangle per block
let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> {
max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("blob gradient: {name} 面无有效内容"),
}))
})
};
let top = rect(blocks[0], "Top")?;
let left = rect(blocks[1], "Left")?;
let front = rect(blocks[2], "Front")?;
// Right+Back → 从中点拆分
let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3];
let mid = rb_x0 + rb_w / 2;
let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?;
let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?;
let bottom = rect(blocks[4], "Bottom")?;
// Step 5: crop (tile_local → global)
let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3);
let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result<JumpHopTileFaceSlice, AppError> {
let (gx, gy, gw, gh) = global(r);
let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("跳一跳地板 UV 面贴图切割失败:{e}"),
}))
})?;
let label = jump_hop_tile_face_key_label(&face);
Ok(JumpHopTileFaceSlice {
face,
source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, label),
bytes: cursor.into_inner(),
})
};
Ok(JumpHopTileFaceSlices {
top: mk(top, JumpHopTileFaceKey::Top)?,
left: mk(left, JumpHopTileFaceKey::Left)?,
front: mk(front, JumpHopTileFaceKey::Front)?,
right: mk(right_rect, JumpHopTileFaceKey::Right)?,
back: mk(back_rect, JumpHopTileFaceKey::Back)?,
bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?,
})
}

View File

@@ -46,6 +46,7 @@ mod health;
mod http_error;
mod hyper3d_generation;
mod jump_hop;
mod jump_hop_atlas_slicing;
mod llm;
mod llm_model_routing;
mod login_options;

View File

@@ -7,8 +7,9 @@ use crate::{
const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0;
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;
// 中文注释:命中区必须与视觉顶面一致,禁止再做隐藏收缩。
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 1.0;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 1.0;
pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath {
let config = difficulty_config(difficulty);
@@ -64,8 +65,8 @@ pub fn start_run(
pub fn apply_jump(
run: &JumpHopRunSnapshot,
drag_distance: f32,
drag_vector_x: Option<f32>,
drag_vector_y: Option<f32>,
_drag_vector_x: Option<f32>,
_drag_vector_y: Option<f32>,
jumped_at_ms: u64,
) -> Result<JumpHopRunSnapshot, JumpHopError> {
if run.status != JumpHopRunStatus::Playing {
@@ -85,17 +86,15 @@ pub fn apply_jump(
.ok_or(JumpHopError::NoNextPlatform)?;
let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32);
let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio;
let vector_x = target.x - current.x;
let vector_y = target.y - current.y;
let (origin_x, origin_y) = current_jump_origin(run, current);
let vector_x = target.x - origin_x;
let vector_y = target.y - origin_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 landed_x = current.x + unit_x * jump_distance;
let landed_y = current.y + unit_y * jump_distance;
// 中文注释:规则真相只认角色当前脚点到下一块顶面中心,拖拽方向不参与裁决。
let unit_x = vector_x / target_distance;
let unit_y = vector_y / target_distance;
let landed_x = origin_x + unit_x * jump_distance;
let landed_y = origin_y + unit_y * jump_distance;
let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y);
let mut next = run.clone();
@@ -129,6 +128,22 @@ pub fn apply_jump(
Ok(next)
}
fn current_jump_origin(run: &JumpHopRunSnapshot, current: &JumpHopPlatform) -> (f32, f32) {
if let Some(last_jump) = &run.last_jump {
let landed_on_current = last_jump.target_platform_index == run.current_platform_index;
let is_successful = last_jump.result != JumpHopJumpResultKind::Miss;
if landed_on_current
&& is_successful
&& last_jump.landed_x.is_finite()
&& last_jump.landed_y.is_finite()
{
return (last_jump.landed_x, last_jump.landed_y);
}
}
(current.x, current.y)
}
fn is_landing_inside_platform_footprint(
platform: &JumpHopPlatform,
landed_x: f32,
@@ -136,33 +151,13 @@ fn is_landing_inside_platform_footprint(
) -> 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);
if half_width <= f32::EPSILON || half_height <= f32::EPSILON {
return false;
}
let error_x = landed_x - platform.x;
let error_y = landed_y - platform.y;
error_x.abs() <= half_width && error_y.abs() <= half_height
}
fn normalize_jump_direction(
drag_vector_x: Option<f32>,
drag_vector_y: Option<f32>,
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)
}
error_x.abs() / half_width + error_y.abs() / half_height <= 1.0 + f32::EPSILON
}
pub fn restart_run(
@@ -214,6 +209,8 @@ struct DifficultyConfig {
max_charge_ms: u32,
}
const JUMP_HOP_MIN_GAP_RATIO_OF_MAX: f32 = 0.55;
fn build_platforms_until(
seed: &str,
difficulty: JumpHopDifficulty,
@@ -229,9 +226,8 @@ fn build_platforms_until(
if index + 1 < required_count {
let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str()));
let distance = rng.range_f32(config.min_gap, config.max_gap);
let lane = rng.range_f32(0.42, 0.86);
let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
x += distance * lane * direction;
x += distance * direction;
y += distance;
}
}
@@ -290,7 +286,7 @@ fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHop
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
match difficulty {
JumpHopDifficulty::Easy => DifficultyConfig {
min_gap: 1.0,
min_gap: 1.45 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
max_gap: 1.45,
min_width: 0.9,
max_width: 1.08,
@@ -300,7 +296,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
max_charge_ms: 700,
},
JumpHopDifficulty::Standard => DifficultyConfig {
min_gap: 1.22,
min_gap: 1.78 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
max_gap: 1.78,
min_width: 0.82,
max_width: 1.0,
@@ -310,7 +306,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
max_charge_ms: 780,
},
JumpHopDifficulty::Advanced => DifficultyConfig {
min_gap: 1.45,
min_gap: 2.05 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
max_gap: 2.05,
min_width: 0.72,
max_width: 0.94,
@@ -320,7 +316,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
max_charge_ms: 860,
},
JumpHopDifficulty::Challenge => DifficultyConfig {
min_gap: 1.7,
min_gap: 2.35 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
max_gap: 2.35,
min_width: 0.66,
max_width: 0.88,
@@ -383,6 +379,89 @@ mod tests {
assert_eq!(first.finish_index, u32::MAX);
}
#[test]
fn generated_platforms_are_locked_to_positive_or_negative_45_degree_lanes() {
for difficulty in [
JumpHopDifficulty::Easy,
JumpHopDifficulty::Standard,
JumpHopDifficulty::Advanced,
JumpHopDifficulty::Challenge,
] {
let path = generate_jump_hop_path("seed-45-degree", difficulty);
for pair in path.platforms.windows(2) {
let current = &pair[0];
let next = &pair[1];
let dx = next.x - current.x;
let dy = next.y - current.y;
assert!(
dy > 0.0,
"next platform should always move forward for {difficulty:?}",
);
assert!(
(dx.abs() - dy).abs() < 0.0001,
"next platform should stay on a 45 degree lane for {difficulty:?}: dx={dx}, dy={dy}",
);
}
}
}
#[test]
fn generated_platform_gaps_use_current_spacing_as_max_and_nonzero_min() {
for difficulty in [
JumpHopDifficulty::Easy,
JumpHopDifficulty::Standard,
JumpHopDifficulty::Advanced,
JumpHopDifficulty::Challenge,
] {
let config = super::difficulty_config(difficulty);
let path = generate_jump_hop_path("seed-random-gap-range", difficulty);
for pair in path.platforms.windows(2) {
let current = &pair[0];
let next = &pair[1];
let gap = next.y - current.y;
assert!(
gap >= config.max_gap * super::JUMP_HOP_MIN_GAP_RATIO_OF_MAX - 0.0001,
"gap should keep a non-zero minimum for {difficulty:?}: gap={gap}, max={}",
config.max_gap,
);
assert!(
gap <= config.max_gap + 0.0001,
"gap should not exceed the current max spacing for {difficulty:?}: gap={gap}, max={}",
config.max_gap,
);
}
}
}
#[test]
fn generated_platform_centers_are_reachable_within_charge_window() {
for difficulty in [
JumpHopDifficulty::Easy,
JumpHopDifficulty::Standard,
JumpHopDifficulty::Advanced,
JumpHopDifficulty::Challenge,
] {
let path = generate_jump_hop_path("seed-reachable-centers", difficulty);
for pair in path.platforms.windows(2) {
let current = &pair[0];
let next = &pair[1];
let distance = (next.x - current.x).hypot(next.y - current.y);
let required_charge = distance / path.scoring.charge_to_distance_ratio;
assert!(
required_charge <= path.scoring.max_charge_ms as f32,
"next platform center must be reachable for {difficulty:?}: required={required_charge}, max={}",
path.scoring.max_charge_ms,
);
}
}
}
#[test]
fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() {
let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy);
@@ -477,7 +556,7 @@ mod tests {
}
#[test]
fn jump_resolution_uses_client_drag_direction_for_landing() {
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(),
@@ -495,11 +574,12 @@ mod tests {
let result = apply_jump(&run, charge as f32, Some(999.0), Some(-999.0), 200)
.expect("jump should resolve");
assert_eq!(result.status, JumpHopRunStatus::Failed);
assert_eq!(result.status, JumpHopRunStatus::Playing);
assert_eq!(
result.last_jump.as_ref().unwrap().result,
JumpHopJumpResultKind::Miss
JumpHopJumpResultKind::Hit
);
assert_eq!(result.current_platform_index, 1);
}
#[test]
@@ -528,11 +608,52 @@ mod tests {
assert!((last_jump.landed_y - target.y).abs() < target.landing_radius);
}
#[test]
fn jump_resolution_uses_previous_landing_point_as_next_origin() {
let mut path = generate_jump_hop_path("seed-foot-origin", JumpHopDifficulty::Easy);
path.platforms[0] = test_platform("p0", 0.0, 0.0, 2.0, 2.0);
path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 2.0);
path.platforms[2] = test_platform("p2", 2.0, 1.0, 2.0, 2.0);
let run = start_run(
"run-foot-origin".to_string(),
"user-foot-origin".to_string(),
"profile-foot-origin".to_string(),
path,
100,
)
.expect("run should start");
let first_charge = 0.9 / run.path.scoring.charge_to_distance_ratio;
let first =
apply_jump(&run, first_charge, None, None, 200).expect("first jump should resolve");
let first_jump = first.last_jump.as_ref().expect("first jump should exist");
assert_eq!(first.status, JumpHopRunStatus::Playing);
assert_eq!(first.current_platform_index, 1);
assert_eq!(first_jump.result, JumpHopJumpResultKind::Hit);
assert!((first_jump.landed_x - 0.9).abs() < 0.0001);
let target = &first.path.platforms[2];
let second_origin = first.last_jump.as_ref().expect("origin should exist");
let second_distance =
(target.x - second_origin.landed_x).hypot(target.y - second_origin.landed_y);
let second_charge = second_distance / first.path.scoring.charge_to_distance_ratio;
let second =
apply_jump(&first, second_charge, None, None, 300).expect("second jump should resolve");
let second_jump = second.last_jump.as_ref().expect("second jump should exist");
assert_eq!(second.status, JumpHopRunStatus::Playing);
assert_eq!(second.current_platform_index, 2);
assert_eq!(second_jump.result, JumpHopJumpResultKind::Hit);
assert!((second_jump.landed_x - target.x).abs() < 0.0001);
assert!((second_jump.landed_y - target.y).abs() < 0.0001);
}
#[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.platforms[1].landing_radius = 10.0;
path.scoring.max_charge_ms = 600;
let run = start_run(
"run-footprint".to_string(),
@@ -543,16 +664,16 @@ mod tests {
)
.expect("run should start");
let edge_hit_charge = 1.6 / run.path.scoring.charge_to_distance_ratio;
let edge_hit_charge = 1.99 / 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);
assert!(last_hit.landed_x > 1.98);
assert!(last_hit.landed_x <= 2.0);
let outside_charge = 1.8 / run.path.scoring.charge_to_distance_ratio;
let outside_charge = 2.01 / 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);
@@ -562,6 +683,33 @@ mod tests {
);
}
#[test]
fn top_face_footprint_rejects_rectangle_corners_outside_visible_top() {
let platform = test_platform("p-corner", 0.0, 0.0, 2.0, 2.0);
assert!(super::is_landing_inside_platform_footprint(
&platform, 0.5, 0.5,
));
assert!(!super::is_landing_inside_platform_footprint(
&platform, 0.7, 0.5,
));
assert!(super::is_landing_inside_platform_footprint(
&test_platform("p-diagonal", 0.8, 1.2, 2.0, 2.0),
1.3,
1.6,
));
assert!(!super::is_landing_inside_platform_footprint(
&test_platform("p-diagonal-outside", 0.8, 1.2, 2.0, 2.0),
1.4,
1.8,
));
assert!(super::is_landing_inside_platform_footprint(
&test_platform("p-side", 0.8, 1.2, 2.0, 2.0),
-0.19,
1.2,
));
}
#[test]
fn restart_returns_to_first_platform_and_playing_state() {
let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy);

View File

@@ -141,9 +141,12 @@ fn remove_generated_asset_sheet_green_screen_background(
return;
}
let alpha = pixels[pixel_index * 4 + 3];
// 中文注释:绿幕模式下 alpha 携带背景/前景信息;洋红/青色等非绿幕模式下
// 图像通常无 alpha 通道(全部为 255因此仅依赖 key_score 判断,不关 alpha。
let ignore_alpha = !options.key_color.is_green_screen();
let strong_candidate = alpha < 40
|| key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224
|| ((ignore_alpha || alpha < 224)
&& key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate {
@@ -196,13 +199,16 @@ fn remove_generated_asset_sheet_green_screen_background(
let key_score = key_scores[next_pixel_index];
let white_score = white_scores[next_pixel_index];
let hint = background_hints[next_pixel_index];
// 中文注释:非绿幕模式(洋红/青色等)下图像无 alpha 通道,不依赖 alpha 判断边界。
let ignore_alpha = !options.key_color.is_green_screen();
let reachable_soft_edge = hint > 0.08
&& alpha < 224
&& (ignore_alpha || alpha < 224)
&& (key_score > 0.04
|| (options.remove_near_white_background && white_score > 0.08)
|| alpha < 180);
let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
|| ((ignore_alpha || alpha < 224)
&& key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
if alpha < 40
|| key_background
|| (options.remove_near_white_background && white_score > 0.32)

View File

@@ -10,10 +10,23 @@ import type {
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import {
getJumpHopThreeProjectedY,
getJumpHopTileTextureSignature,
JUMP_HOP_THREE_CAMERA_UP_Y,
JUMP_HOP_THREE_CHARACTER_RENDER_ORDER,
JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET,
JUMP_HOP_THREE_MATERIAL_FACE_ORDER,
JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT,
JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE,
JUMP_HOP_THREE_PLATFORM_YAW_RAD,
JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER,
JUMP_HOP_THREE_TEXTURE_TRANSFORMS,
JumpHopRuntimeShell,
getJumpHopThreeCharacterFootZ,
getJumpHopThreeCubeSide,
getJumpHopThreeProjectedY,
getJumpHopThreeWorldYForScreenY,
getJumpHopTileTextureSignature,
hasCompleteJumpHopTileFaceAssets,
resolveJumpHopThreeCharacterFrame,
} from './JumpHopRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
@@ -91,16 +104,14 @@ test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', as
expect(onJump).toHaveBeenCalledTimes(1);
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload).not.toHaveProperty('dragVectorX');
expect(jumpPayload).not.toHaveProperty('dragVectorY');
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();
@@ -141,10 +152,8 @@ test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向
});
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload).not.toHaveProperty('dragVectorX');
expect(jumpPayload).not.toHaveProperty('dragVectorY');
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
vi.useRealTimers();
@@ -502,7 +511,86 @@ test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', ()
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
});
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
test('跳一跳 Three.js 顶面脚点投影会扣除立方体高度偏移', () => {
const projectedTopY = getJumpHopThreeProjectedY(360, 568);
const cubeTopWorldY = getJumpHopThreeWorldYForScreenY(360, 568, 48);
expect(cubeTopWorldY).toBeLessThan(projectedTopY);
expect(getJumpHopThreeWorldYForScreenY(360, 568, 0)).toBe(projectedTopY);
});
test('跳一跳 Three.js 方块只放大模型尺寸不改变屏幕间距规则', () => {
const run = buildRun();
const platform = run.path.platforms[0]!;
expect(JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER).toBe(2);
expect(getJumpHopThreeCubeSide(platform, 1)).toBeCloseTo(165.12, 2);
expect(run.path.platforms[1]!.x - run.path.platforms[0]!.x).toBeCloseTo(
1.78,
);
expect(run.path.platforms[1]!.y - run.path.platforms[0]!.y).toBeCloseTo(
1.78,
);
});
test('跳一跳 Three.js 角色帧沿真实预测落点插值并保留飞行弧线', () => {
const from = {
screenX: 50,
screenY: 64,
sceneX: 0,
sceneY: 0.84,
sceneZ: 0,
isMiss: false,
};
const to = {
screenX: 64,
screenY: 47,
sceneX: 1.1,
sceneY: 0.84,
sceneZ: 3.4,
isMiss: true,
};
const midFrame = resolveJumpHopThreeCharacterFrame({
characterPosition: null,
visualJump: { from, to },
jumpAnimationProgress: 0.5,
isJumpAnimating: true,
});
expect(midFrame?.progress).toBe(0.5);
expect(midFrame?.position.screenX).toBeGreaterThan(from.screenX);
expect(midFrame?.position.screenX).toBeLessThan(to.screenX);
expect(midFrame?.position.screenY).toBeLessThan(to.screenY);
expect(midFrame?.position.sceneZ).toBeGreaterThan(from.sceneZ);
expect(midFrame?.position.sceneZ).toBeLessThan(to.sceneZ);
expect(midFrame?.position.isMiss).toBe(true);
const landedFrame = resolveJumpHopThreeCharacterFrame({
characterPosition: from,
visualJump: { from, to },
jumpAnimationProgress: 2,
isJumpAnimating: true,
});
expect(landedFrame?.progress).toBe(1);
expect(landedFrame?.position).toMatchObject(to);
expect(
resolveJumpHopThreeCharacterFrame({
characterPosition: from,
visualJump: { from, to },
jumpAnimationProgress: 0.5,
isJumpAnimating: false,
}),
).toEqual({
position: from,
progress: 1,
});
});
test('跳一跳蓄力时实时显示松手后的预测落点', async () => {
vi.useFakeTimers();
try {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
@@ -524,27 +612,32 @@ test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async ()
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 148,
clientY: 454,
});
vi.advanceTimersByTime(240);
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
const firstAssist = screen.getByTestId('jump-hop-landing-assist');
expect(firstAssist).toBeTruthy();
expect(firstAssist.getAttribute('data-on-target')).toMatch(/^(true|false)$/);
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
const firstLeft = firstAssist.style.getPropertyValue('--jump-hop-landing-x');
const firstTop = firstAssist.style.getPropertyValue('--jump-hop-landing-y');
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 492,
});
vi.advanceTimersByTime(260);
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
expect(secondAssist.style.getPropertyValue('--jump-hop-landing-x')).not.toBe(
firstLeft,
);
expect(secondAssist.style.getPropertyValue('--jump-hop-landing-y')).not.toBe(
firstTop,
);
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
} finally {
vi.useRealTimers();
}
});
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
@@ -558,14 +651,14 @@ test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
expect(tileImages).toHaveLength(2);
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
const generatedReadUrlCalls = vi
.mocked(useResolvedAssetReadUrl)
.mock.calls.filter(([source]) =>
source?.includes('/generated-jump-hop-assets/'),
);
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(2);
for (const [, options] of generatedReadUrlCalls) {
expect(options).toEqual(
expect.objectContaining({
@@ -593,7 +686,7 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加
/>,
);
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3);
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2);
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
expect(preloadImages.length).toBeGreaterThan(0);
@@ -667,7 +760,63 @@ test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => {
expect(signature).toContain('bottom-url');
});
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
test('跳一跳 Three 立方体按玩法 Z 轴顶面映射 UV 六面贴图', () => {
expect(JUMP_HOP_THREE_PLATFORM_YAW_RAD).toBeCloseTo(Math.PI / 4);
expect(JUMP_HOP_THREE_MATERIAL_FACE_ORDER).toEqual([
'right',
'left',
'back',
'front',
'top',
'bottom',
]);
expect(JUMP_HOP_THREE_TEXTURE_TRANSFORMS).toMatchObject({
top: { flipX: true, flipY: false },
front: { flipX: true, flipY: true },
right: { flipX: false, flipY: true },
back: { flipX: false, flipY: true },
left: { flipX: true, flipY: true },
bottom: { flipX: true, flipY: true },
});
});
test('跳一跳 Three 角色绘制层级高于地块并且脚点落在顶面中心高度', () => {
expect(JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT).toBe(false);
expect(JUMP_HOP_THREE_CHARACTER_RENDER_ORDER).toBeGreaterThan(
JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE + 18,
);
expect(JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET).toBe(0);
expect(getJumpHopThreeCharacterFootZ(96, false)).toBe(48);
expect(getJumpHopThreeCharacterFootZ(96, true)).toBeLessThan(
getJumpHopThreeCharacterFootZ(96, false),
);
expect(getJumpHopThreeCharacterFootZ(40, false)).toBe(20);
});
test('跳一跳旧单图地块资源不启用 Three UV 贴面层', () => {
const oldAssets = buildTileAssets();
const uvAssets = buildTileAssets({ withFaceAssets: true });
expect(hasCompleteJumpHopTileFaceAssets(oldAssets[0])).toBe(false);
expect(hasCompleteJumpHopTileFaceAssets(uvAssets[0])).toBe(true);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: oldAssets })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
expect(
screen
.getByTestId('jump-hop-camera-layer')
.getAttribute('data-three-platform-ready'),
).toBe('false');
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2);
});
test('跳一跳运行态只展示当前块和下一块并给下一块留出视野空间', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -678,16 +827,16 @@ test('跳一跳运行态首块地块落在中下方并且后续两块向中央
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
expect(tileImages).toHaveLength(2);
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
expect(first?.style.top).toBe('64%');
expect(second?.style.top).toBe('47%');
expect(third?.style.top).toBe('30%');
expect(Number.parseFloat(first?.style.left ?? '0')).toBeLessThan(50);
expect(Number.parseFloat(second?.style.left ?? '0')).toBeGreaterThan(50);
});
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
test('跳一跳运行态地块从出现开始保持真实尺寸', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -703,7 +852,7 @@ test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸'
expect(firstTile?.style.width).toBe('116px');
expect(firstTile?.style.height).toBe('96px');
expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
'',
);
});
@@ -776,8 +925,8 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.93,
landedY: 1.4,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -841,6 +990,17 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
/>,
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'true',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
@@ -862,8 +1022,8 @@ test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向',
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0,
landedY: 1.2,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -920,8 +1080,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.93,
landedY: 1.4,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -934,8 +1094,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
chargeMs: 360,
jumpDistance: 1.44,
targetPlatformIndex: 2,
landedX: -0.2,
landedY: 2.4,
landedX: 0,
landedY: 3.56,
result: 'hit',
},
};
@@ -1012,6 +1172,17 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
await vi.advanceTimersByTimeAsync(580);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'true',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
@@ -1032,16 +1203,19 @@ 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',
'1.69',
);
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
'-17%',
);
// 下一块在反向时,当前块会切换到场地反侧锚点,横向镜头推进应被侧边视野校正抵消。
expect(
Math.abs(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
),
).toBeCloseTo(8.96, 2);
),
).toBeLessThan(1);
const styleText = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');
@@ -1082,7 +1256,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
'64%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
'',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p2',
@@ -1181,6 +1355,14 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
await vi.advanceTimersByTimeAsync(580);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
const movedOldPlatform = screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
@@ -1235,8 +1417,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p1',
tileType: 'normal',
x: 0.8,
y: 1.2,
x: 1.78,
y: 1.78,
width: 1,
height: 1,
landingRadius: 0.5,
@@ -1246,8 +1428,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p2',
tileType: 'target',
x: -0.2,
y: 2.4,
x: 0,
y: 3.56,
width: 1,
height: 1,
landingRadius: 0.5,
@@ -1293,8 +1475,8 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p3',
tileType: 'normal',
x: 0.5,
y: 3.6,
x: 1.78,
y: 5.34,
width: 1,
height: 1,
landingRadius: 0.5,

View File

@@ -28,7 +28,7 @@ import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpH
import {
buildJumpHopVisiblePlatforms,
formatJumpHopDurationLabel,
getJumpHopBackendDragVector,
getJumpHopCharacterTopFaceVisualPosition,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
@@ -48,8 +48,6 @@ import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMa
type JumpHopRuntimeJumpPayload = {
dragDistance: number;
dragVectorX: number;
dragVectorY: number;
};
type JumpHopVisualJump = {
@@ -57,6 +55,11 @@ type JumpHopVisualJump = {
to: JumpHopCharacterVisualPosition;
};
type JumpHopThreeCharacterFrame = {
position: JumpHopCharacterVisualPosition;
progress: number;
};
type JumpHopPlatformRenderItem = JumpHopVisiblePlatform & {
renderKey: string;
advanceState: 'exiting' | 'camera' | 'idle';
@@ -104,12 +107,20 @@ type JumpHopRuntimeSettlementDialogProps = {
const MAX_CHARGE_RATIO = 1;
const DEFAULT_MAX_DRAG_DISTANCE_PX = 180;
const JUMP_HOP_ANIMATION_DURATION_MS = 560;
const JUMP_HOP_POST_LANDING_CAMERA_DELAY_MS = 300;
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_CAMERA_ZOOM = 1.69;
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
'/branding/jump-hop-taonier-character.png';
const JUMP_HOP_THREE_CHARACTER_WIDTH = 72;
const JUMP_HOP_THREE_CHARACTER_HEIGHT = 84;
export const JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT = false;
export const JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE = 30;
export const JUMP_HOP_THREE_PLATFORM_YAW_RAD = Math.PI / 4;
export const JUMP_HOP_THREE_CHARACTER_RENDER_ORDER = 240;
export const JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET = 0;
const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3;
const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [
'top',
@@ -119,12 +130,32 @@ const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [
'left',
'bottom',
];
export const JUMP_HOP_THREE_MATERIAL_FACE_ORDER = [
'right',
'left',
'back',
'front',
'top',
'bottom',
] as const satisfies JumpHopTileFaceKey[];
export const JUMP_HOP_THREE_TEXTURE_TRANSFORMS = {
top: { flipX: true, flipY: false, rotation: 0 },
front: { flipX: true, flipY: true, rotation: 0 },
right: { flipX: false, flipY: true, rotation: 0 },
back: { flipX: false, flipY: true, rotation: 0 },
left: { flipX: true, flipY: true, rotation: 0 },
bottom: { flipX: true, flipY: true, rotation: 0 },
} as const satisfies Record<
JumpHopTileFaceKey,
{ flipX: boolean; flipY: boolean; rotation: number }
>;
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;
export const JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER = 2;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
@@ -173,31 +204,6 @@ function shouldAnimateJumpHopPlatformAdvance(
);
}
function buildJumpHopCharacterVisualPositionFromPlatform(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
if (isMiss) {
return {
screenX: platform.screenX + 8,
screenY: platform.screenY - 2,
sceneX: platform.sceneX + 0.7,
sceneY: platform.sceneY + 0.48,
sceneZ: platform.sceneZ - 0.4,
isMiss: true,
};
}
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneZ: platform.sceneZ,
isMiss: false,
};
}
function getJumpHopRunLandingVisualPosition({
run,
platforms,
@@ -215,12 +221,57 @@ function getJumpHopRunLandingVisualPosition({
return getJumpHopCharacterVisualPosition(run, platforms, stageSize);
}
function getJumpHopThreeCubeSide(
export function getJumpHopThreeCubeSide(
platform: JumpHopVisiblePlatform['platform'],
scale: number,
) {
const platformSize = getJumpHopPlatformVisualSize(platform, scale);
return Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86);
return (
Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86) *
JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER
);
}
export function getJumpHopThreeCharacterFootZ(
cubeSide: number | null | undefined,
isMiss = false,
) {
const safeCubeSide = Number.isFinite(cubeSide ?? NaN) ? Math.max(0, cubeSide ?? 0) : 0;
const topFaceZ = safeCubeSide / 2;
if (isMiss) {
return Math.max(0, topFaceZ * 0.72);
}
return topFaceZ + JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET;
}
function resolveJumpHopThreeCharacterFootZ(
characterPosition: JumpHopCharacterVisualPosition,
platforms: JumpHopPlatformRenderItem[],
) {
const closestPlatform = platforms.reduce<JumpHopPlatformRenderItem | null>(
(closest, item) => {
if (item.advanceState === 'exiting') {
return closest;
}
if (!closest) {
return item;
}
const itemDistance = Math.hypot(
item.sceneX - characterPosition.sceneX,
item.sceneZ - characterPosition.sceneZ,
);
const closestDistance = Math.hypot(
closest.sceneX - characterPosition.sceneX,
closest.sceneZ - characterPosition.sceneZ,
);
return itemDistance < closestDistance ? item : closest;
},
null,
);
const cubeSide = closestPlatform
? getJumpHopThreeCubeSide(closestPlatform.platform, closestPlatform.scale)
: null;
return getJumpHopThreeCharacterFootZ(cubeSide, characterPosition.isMiss);
}
export function getJumpHopThreeProjectedY(
@@ -233,6 +284,77 @@ export function getJumpHopThreeProjectedY(
);
}
// 中文注释screenY 表示顶面脚点投影Three 世界坐标需要扣掉高度投影,避免角色和指示器落到方块几何中心。
export function getJumpHopThreeWorldYForScreenY(
screenY: number,
viewportHeight: number,
worldZ = 0,
) {
return (
getJumpHopThreeProjectedY(screenY, viewportHeight) -
worldZ * Math.tan(JUMP_HOP_THREE_CAMERA_PITCH_RAD)
);
}
function clampJumpHopAnimationProgress(progress: number) {
return clamp(progress, 0, 1);
}
function easeJumpHopFlightProgress(progress: number) {
const clamped = clampJumpHopAnimationProgress(progress);
return 1 - Math.pow(1 - clamped, 3);
}
function interpolateJumpHopCharacterPosition(
from: JumpHopCharacterVisualPosition,
to: JumpHopCharacterVisualPosition,
progress: number,
): JumpHopCharacterVisualPosition {
const clamped = clampJumpHopAnimationProgress(progress);
const eased = easeJumpHopFlightProgress(clamped);
const arcLift = Math.sin(Math.PI * clamped) * 9;
return {
screenX: from.screenX + (to.screenX - from.screenX) * eased,
screenY: from.screenY + (to.screenY - from.screenY) * eased - arcLift,
sceneX: from.sceneX + (to.sceneX - from.sceneX) * eased,
sceneY: from.sceneY + (to.sceneY - from.sceneY) * eased,
sceneZ: from.sceneZ + (to.sceneZ - from.sceneZ) * eased,
isMiss: to.isMiss,
};
}
export function resolveJumpHopThreeCharacterFrame({
characterPosition,
visualJump,
jumpAnimationProgress,
isJumpAnimating,
}: {
characterPosition: JumpHopCharacterVisualPosition | null;
visualJump: JumpHopVisualJump | null;
jumpAnimationProgress: number;
isJumpAnimating: boolean;
}): JumpHopThreeCharacterFrame | null {
if (isJumpAnimating && visualJump) {
const progress = clampJumpHopAnimationProgress(jumpAnimationProgress);
return {
position: interpolateJumpHopCharacterPosition(
visualJump.from,
visualJump.to,
progress,
),
progress,
};
}
if (!characterPosition) {
return null;
}
return {
position: characterPosition,
progress: 1,
};
}
function IsometricFallbackTile({
platform,
}: {
@@ -335,6 +457,17 @@ function hasJumpHopTileTexturesReady(
);
}
export function hasCompleteJumpHopTileFaceAssets(
asset: JumpHopTileAsset | null | undefined,
) {
return Boolean(
asset?.faceAssets &&
JUMP_HOP_TILE_FACE_KEYS.every((face) =>
Boolean(asset.faceAssets?.[face]?.imageSrc?.trim()),
),
);
}
function getJumpHopActiveTextureKeys(
renderKey: string,
asset: JumpHopTileAsset | null | undefined,
@@ -672,41 +805,51 @@ function disposeJumpHopThreeObject(object: import('three').Object3D) {
}
function JumpHopThreeScene({
characterPosition,
characterFrame,
chargeRatio,
isLandingRecoilAnimating,
isJumpAnimating,
platforms,
platformCount,
renderCharacter,
renderPlatforms,
textureUrlsByRenderKey,
onCharacterLayerReadyChange,
onPlatformLayerReadyChange,
}: {
characterPosition: JumpHopCharacterVisualPosition | null;
characterFrame: JumpHopThreeCharacterFrame | null;
chargeRatio: number;
isLandingRecoilAnimating: boolean;
isJumpAnimating: boolean;
platforms: JumpHopPlatformRenderItem[];
platformCount: number;
renderCharacter: boolean;
renderPlatforms: boolean;
textureUrlsByRenderKey: Record<string, string>;
onCharacterLayerReadyChange: Dispatch<SetStateAction<boolean>>;
onPlatformLayerReadyChange: Dispatch<SetStateAction<boolean>>;
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const characterPositionRef = useRef(characterPosition);
const characterFrameRef = useRef(characterFrame);
const chargeRatioRef = useRef(chargeRatio);
const isLandingRecoilAnimatingRef = useRef(isLandingRecoilAnimating);
const isJumpAnimatingRef = useRef(isJumpAnimating);
const platformsRef = useRef(platforms);
const renderPlatformsRef = useRef(renderPlatforms);
const textureUrlsByRenderKeyRef = useRef(textureUrlsByRenderKey);
useEffect(() => {
characterPositionRef.current = characterPosition;
}, [characterPosition]);
characterFrameRef.current = characterFrame;
}, [characterFrame]);
useEffect(() => {
chargeRatioRef.current = chargeRatio;
}, [chargeRatio]);
useEffect(() => {
isLandingRecoilAnimatingRef.current = isLandingRecoilAnimating;
}, [isLandingRecoilAnimating]);
useEffect(() => {
isJumpAnimatingRef.current = isJumpAnimating;
}, [isJumpAnimating]);
@@ -715,6 +858,10 @@ function JumpHopThreeScene({
platformsRef.current = platforms;
}, [platforms]);
useEffect(() => {
renderPlatformsRef.current = renderPlatforms;
}, [renderPlatforms]);
useEffect(() => {
textureUrlsByRenderKeyRef.current = textureUrlsByRenderKey;
}, [textureUrlsByRenderKey]);
@@ -788,36 +935,6 @@ function JumpHopThreeScene({
rimLight.position.set(120, 44, 120);
scene.add(rimLight);
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 platformGroup = new three.Group();
platformGroup.renderOrder = 20;
scene.add(platformGroup);
@@ -846,9 +963,68 @@ function JumpHopThreeScene({
>();
const fallbackMaterialCache = new Map<string, import('three').Material>();
let platformSignature = '';
let isCharacterTextureLoaded = false;
let character: import('three').Sprite | null = null;
const publishCharacterLayerReady = () => {
if (!disposed) {
onCharacterLayerReadyChange(Boolean(character && isCharacterTextureLoaded));
}
};
const getTexture = (url: string) => {
const cached = textureCache.get(url);
const characterTexture = textureLoader.load(
JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC,
() => {
if (disposed) {
return;
}
isCharacterTextureLoaded = true;
renderer.render(scene, camera);
publishCharacterLayerReady();
},
undefined,
() => {
if (disposed) {
return;
}
isCharacterTextureLoaded = false;
publishCharacterLayerReady();
},
);
characterTexture.colorSpace = three.SRGBColorSpace;
const characterMaterial = new three.SpriteMaterial({
alphaTest: 0.02,
color: 0xffffff,
depthTest: false,
depthWrite: false,
map: characterTexture,
transparent: true,
});
character = renderCharacter ? new three.Sprite(characterMaterial) : null;
if (character) {
character.renderOrder = JUMP_HOP_THREE_CHARACTER_RENDER_ORDER;
character.center.set(0.5, 0.08);
character.scale.set(
JUMP_HOP_THREE_CHARACTER_WIDTH,
JUMP_HOP_THREE_CHARACTER_HEIGHT,
1,
);
character.visible = false;
scene.add(character);
}
const getTexture = (url: string, face?: JumpHopTileFaceKey) => {
const textureTransform = face
? JUMP_HOP_THREE_TEXTURE_TRANSFORMS[face]
: null;
const cacheKey = textureTransform
? [
url,
textureTransform.flipX ? 'flip-x' : 'keep-x',
textureTransform.flipY ? 'flip-y' : 'keep-y',
textureTransform.rotation,
].join('|')
: url;
const cached = textureCache.get(cacheKey);
if (cached) {
return cached;
}
@@ -860,7 +1036,19 @@ function JumpHopThreeScene({
texture.wrapS = three.ClampToEdgeWrapping;
texture.wrapT = three.ClampToEdgeWrapping;
texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 6);
textureCache.set(url, texture);
if (textureTransform) {
texture.center.set(0.5, 0.5);
texture.rotation = textureTransform.rotation;
texture.repeat.set(
textureTransform.flipX ? -1 : 1,
textureTransform.flipY ? -1 : 1,
);
texture.offset.set(
textureTransform.flipX ? 1 : 0,
textureTransform.flipY ? 1 : 0,
);
}
textureCache.set(cacheKey, texture);
return texture;
};
@@ -873,7 +1061,7 @@ function JumpHopThreeScene({
item.renderKey,
'top',
);
if (item.asset?.faceAssets && textureUrl) {
if (hasCompleteJumpHopTileFaceAssets(item.asset) && textureUrl) {
const cacheKey = JUMP_HOP_TILE_FACE_KEYS.map((face) =>
getJumpHopTileTextureUrl(textureUrls, item.renderKey, face),
).join('|');
@@ -882,27 +1070,20 @@ function JumpHopThreeScene({
return cached;
}
// 中文注释:Three.js Box/RoundedBox 材质顺序为 right, left, top, bottom, front, back
const materials = [
'right',
'left',
'top',
'bottom',
'front',
'back',
].map((face) => {
// 中文注释:玩法把 Z 轴作为立方体竖直高度,所以这里把逻辑 top 映射到 Three 的 +Z 面
const materials = JUMP_HOP_THREE_MATERIAL_FACE_ORDER.map((face) => {
const faceUrl =
getJumpHopTileTextureUrl(
textureUrls,
item.renderKey,
face as JumpHopTileFaceKey,
face,
) || textureUrl;
return new three.MeshStandardMaterial({
alphaTest: 0.04,
map: getTexture(faceUrl),
map: getTexture(faceUrl, face),
metalness: 0,
roughness: 0.76,
transparent: true,
transparent: JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT,
});
});
materialCache.set(cacheKey, materials);
@@ -920,7 +1101,7 @@ function JumpHopThreeScene({
map: getTexture(textureUrl),
metalness: 0,
roughness: 0.76,
transparent: true,
transparent: JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT,
});
materialCache.set(textureUrl, material);
return material;
@@ -968,6 +1149,7 @@ function JumpHopThreeScene({
};
const syncPlatformMeshes = () => {
platformGroup.visible = renderPlatformsRef.current;
const nextPlatforms = platformsRef.current;
const textureUrls = textureUrlsByRenderKeyRef.current;
const nextSignature = nextPlatforms
@@ -1004,9 +1186,10 @@ function JumpHopThreeScene({
const cubeSide = getJumpHopThreeCubeSide(item.platform, item.scale);
const root = new three.Group();
const rootBaseX = (item.screenX / 100) * viewportSize.width;
const rootBaseY = getJumpHopThreeProjectedY(
const rootBaseY = getJumpHopThreeWorldYForScreenY(
(item.screenY / 100) * viewportSize.height,
viewportSize.height,
cubeSide / 2,
);
root.position.set(rootBaseX, rootBaseY, 0);
root.renderOrder = 20 + item.index;
@@ -1030,9 +1213,10 @@ function JumpHopThreeScene({
getPlatformMaterial(item, textureUrls),
);
mesh.position.set(0, 0, 0);
mesh.rotation.set(0, 0, 0);
mesh.rotation.set(0, 0, JUMP_HOP_THREE_PLATFORM_YAW_RAD);
mesh.scale.setScalar(cubeSide);
mesh.renderOrder = 30 + item.index;
mesh.renderOrder =
JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE + item.index;
root.add(shadow, mesh);
platformGroup.add(root);
@@ -1057,35 +1241,50 @@ function JumpHopThreeScene({
: null;
resizeObserver?.observe(host);
resize();
onCharacterLayerReadyChange(Boolean(character));
publishCharacterLayerReady();
onPlatformLayerReadyChange(true);
const animate = () => {
platformGroup.visible = renderPlatformsRef.current;
syncPlatformMeshes();
const nextCharacterPosition = characterPositionRef.current;
if (character && nextCharacterPosition) {
const nextCharacterFrame = characterFrameRef.current;
if (character && nextCharacterFrame) {
const nextCharacterPosition = nextCharacterFrame.position;
const nextChargeRatio = chargeRatioRef.current;
const canvasPosition = resolveJumpHopCharacterCanvasPosition(
nextCharacterPosition,
viewportSize,
);
const characterFootZ = resolveJumpHopThreeCharacterFootZ(
nextCharacterPosition,
platformsRef.current,
);
character.visible = true;
character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0);
character.position.set(
canvasPosition?.x ?? 0,
getJumpHopThreeWorldYForScreenY(
canvasPosition?.y ?? 0,
viewportSize.height,
characterFootZ,
),
characterFootZ,
);
if (isJumpAnimatingRef.current) {
const now = window.performance.now();
character.rotation.z = Math.sin(now / 42) * 1.22;
character.rotation.x = Math.sin(now / 28) * 0.28;
character.rotation.y = Math.sin(now / 34) * 0.2;
character.position.y += Math.sin(now / 26) * 8 - 14;
characterMaterial.rotation =
Math.sin(now / 42) * 0.16 + nextCharacterFrame.progress * Math.PI * 1.65;
} else if (isLandingRecoilAnimatingRef.current) {
const now = window.performance.now();
characterMaterial.rotation = Math.sin(now / 52) * 0.05;
} else {
character.rotation.z = nextCharacterPosition.isMiss ? -0.32 : 0;
character.rotation.x = 0;
character.rotation.y = 0;
characterMaterial.rotation = nextCharacterPosition.isMiss ? -0.24 : 0;
}
character.scale.set(
1 + nextChargeRatio * 0.08,
1 - nextChargeRatio * 0.12,
1 + nextChargeRatio * 0.08,
JUMP_HOP_THREE_CHARACTER_WIDTH * (1 + nextChargeRatio * 0.1),
JUMP_HOP_THREE_CHARACTER_HEIGHT *
(1 - nextChargeRatio * 0.28) *
(isLandingRecoilAnimatingRef.current ? 1.04 : 1),
1,
);
} else if (character) {
character.visible = false;
@@ -1110,6 +1309,8 @@ function JumpHopThreeScene({
}
});
fallbackMaterialCache.forEach((material) => material.dispose());
characterTexture.dispose();
characterMaterial.dispose();
shadowMaterial.dispose();
platformGeometry.dispose();
shadowGeometry.dispose();
@@ -1325,6 +1526,7 @@ export function JumpHopRuntimeShell({
const landingRecoilEndTimerRef = useRef<number | null>(null);
const animationStartAtRef = useRef(0);
const hasJumpAnimationReachedTargetRef = useRef(false);
const postLandingCameraDelayTimerRef = useRef<number | null>(null);
const platformAdvanceEndTimerRef = useRef<number | null>(null);
const activeRunRef = useRef(activeRun);
const displayRunRef = useRef(displayRun);
@@ -1416,6 +1618,7 @@ export function JumpHopRuntimeShell({
() =>
isThreePlatformLayerReady &&
platformRenderItems.every((item) =>
hasCompleteJumpHopTileFaceAssets(item.asset) &&
hasJumpHopTileTexturesReady(
platformTextureUrlsByRenderKey,
item.renderKey,
@@ -1481,17 +1684,6 @@ export function JumpHopRuntimeShell({
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;
@@ -1503,12 +1695,12 @@ export function JumpHopRuntimeShell({
);
}, [stageRun, visiblePlatforms]);
const targetDirection = useMemo(() => {
const directionOrigin = currentPlatformOriginPosition ?? characterPosition;
const directionOrigin = characterPosition;
if (!directionOrigin || !jumpTargetPlatform) {
return null;
}
const targetCharacterPosition =
buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform);
getJumpHopCharacterTopFaceVisualPosition(jumpTargetPlatform);
const directionX = targetCharacterPosition.screenX - directionOrigin.screenX;
const directionY = targetCharacterPosition.screenY - directionOrigin.screenY;
const distance = Math.hypot(directionX, directionY);
@@ -1522,7 +1714,27 @@ export function JumpHopRuntimeShell({
unitScreenX: directionX / distance,
unitScreenY: directionY / distance,
};
}, [characterPosition, currentPlatformOriginPosition, jumpTargetPlatform]);
}, [characterPosition, jumpTargetPlatform]);
const landingAssistPosition = useMemo(() => {
if (!isCharging || !stageRun || !characterPosition) {
return null;
}
return getJumpHopLandingAssistVisualPosition(
stageRun,
visiblePlatforms,
characterPosition,
landingAssistStageSize,
dragDistance,
);
}, [
characterPosition,
dragDistance,
isCharging,
landingAssistStageSize,
stageRun,
visiblePlatforms,
]);
const visualCharacterPosition = useMemo(() => {
if (!characterPosition) {
return null;
@@ -1536,6 +1748,21 @@ export function JumpHopRuntimeShell({
isJumpAnimating,
visualJump,
]);
const threeCharacterFrame = useMemo(
() =>
resolveJumpHopThreeCharacterFrame({
characterPosition,
visualJump,
jumpAnimationProgress,
isJumpAnimating,
}),
[
characterPosition,
isJumpAnimating,
jumpAnimationProgress,
visualJump,
],
);
const characterMotionStyle = useMemo(() => {
const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)';
const recoilDistance = Math.hypot(dragVector.x, dragVector.y);
@@ -1701,6 +1928,13 @@ export function JumpHopRuntimeShell({
setIsLandingRecoilAnimating(false);
}, []);
const clearPostLandingCameraDelay = useCallback(() => {
if (postLandingCameraDelayTimerRef.current != null) {
window.clearTimeout(postLandingCameraDelayTimerRef.current);
postLandingCameraDelayTimerRef.current = null;
}
}, []);
const stopChargeFrame = useCallback(() => {
if (chargeFrameRef.current != null) {
window.cancelAnimationFrame(chargeFrameRef.current);
@@ -1738,7 +1972,7 @@ export function JumpHopRuntimeShell({
(item) => item.index === toRun.currentPlatformIndex,
);
return fromLandingPlatform
? buildJumpHopCharacterVisualPositionFromPlatform(
? getJumpHopCharacterTopFaceVisualPosition(
fromLandingPlatform,
)
: null;
@@ -1754,7 +1988,7 @@ export function JumpHopRuntimeShell({
(item) => item.index === toRun.currentPlatformIndex,
);
return toCurrentPlatform
? buildJumpHopCharacterVisualPositionFromPlatform(
? getJumpHopCharacterTopFaceVisualPosition(
toCurrentPlatform,
)
: null;
@@ -1850,6 +2084,32 @@ export function JumpHopRuntimeShell({
[beginPlatformAdvance, clearLandingRecoilState],
);
const resolveJumpHopFlightAnimation = useCallback(
(
fromRun: JumpHopRuntimeRunSnapshotResponse,
toRun: JumpHopRuntimeRunSnapshotResponse,
) => {
if (!shouldAnimateJumpHopPlatformAdvance(fromRun, toRun)) {
clearPostLandingCameraDelay();
finishJumpHopFlightAnimation(fromRun, toRun);
return;
}
if (postLandingCameraDelayTimerRef.current != null) {
return;
}
postLandingCameraDelayTimerRef.current = window.setTimeout(() => {
postLandingCameraDelayTimerRef.current = null;
finishJumpHopFlightAnimation(fromRun, toRun);
}, JUMP_HOP_POST_LANDING_CAMERA_DELAY_MS);
},
[
clearPostLandingCameraDelay,
finishJumpHopFlightAnimation,
],
);
useEffect(() => {
if (stageRun?.status !== 'playing') {
return undefined;
@@ -1897,6 +2157,7 @@ export function JumpHopRuntimeShell({
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
clearPostLandingCameraDelay();
clearPlatformAdvanceState();
clearLandingRecoilState();
hasJumpAnimationReachedTargetRef.current = false;
@@ -1922,6 +2183,7 @@ export function JumpHopRuntimeShell({
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
clearPostLandingCameraDelay();
clearPlatformAdvanceState();
clearLandingRecoilState();
hasJumpAnimationReachedTargetRef.current = false;
@@ -1946,7 +2208,7 @@ export function JumpHopRuntimeShell({
displayRun.runId === activeRun.runId &&
hasJumpHopRunDisplayChange(displayRun, activeRun)
) {
finishJumpHopFlightAnimation(displayRun, activeRun);
resolveJumpHopFlightAnimation(displayRun, activeRun);
}
return;
}
@@ -1960,10 +2222,11 @@ export function JumpHopRuntimeShell({
activeRun,
clearLandingRecoilState,
clearPlatformAdvanceState,
clearPostLandingCameraDelay,
displayRun,
finishJumpHopFlightAnimation,
isJumpAnimating,
jumpAnimationProgress,
resolveJumpHopFlightAnimation,
stopChargeFrame,
]);
@@ -1981,6 +2244,9 @@ export function JumpHopRuntimeShell({
if (landingRecoilEndTimerRef.current != null) {
window.clearTimeout(landingRecoilEndTimerRef.current);
}
if (postLandingCameraDelayTimerRef.current != null) {
window.clearTimeout(postLandingCameraDelayTimerRef.current);
}
if (chargeFrameRef.current != null) {
window.cancelAnimationFrame(chargeFrameRef.current);
}
@@ -2016,7 +2282,7 @@ export function JumpHopRuntimeShell({
latestDisplayRun.runId === latestActiveRun.runId &&
hasJumpHopRunDisplayChange(latestDisplayRun, latestActiveRun)
) {
finishJumpHopFlightAnimation(latestDisplayRun, latestActiveRun);
resolveJumpHopFlightAnimation(latestDisplayRun, latestActiveRun);
}
}, JUMP_HOP_ANIMATION_DURATION_MS);
const tick = (now: number) => {
@@ -2050,7 +2316,7 @@ export function JumpHopRuntimeShell({
animationEndTimerRef.current = null;
}
};
}, [finishJumpHopFlightAnimation, isJumpAnimating]);
}, [isJumpAnimating, resolveJumpHopFlightAnimation]);
const beginCharge = (event: PointerEvent<HTMLElement>) => {
if (!canJump) {
@@ -2116,21 +2382,16 @@ export function JumpHopRuntimeShell({
)
: null;
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: clamp(characterPosition.screenX + visualDeltaX, 6, 94),
screenY: clamp(characterPosition.screenY + visualDeltaY, 10, 92),
screenX: predictedLandingPosition.screenX,
screenY: predictedLandingPosition.screenY,
sceneX: predictedLandingPosition.sceneX,
sceneY: predictedLandingPosition.sceneY,
sceneZ: predictedLandingPosition.sceneZ,
isMiss: !predictedLandingPosition.isOnTargetPlatform,
}
: characterPosition,
@@ -2150,17 +2411,8 @@ export function JumpHopRuntimeShell({
x: targetDirection ? -targetDirection.unitScreenX : 0,
y: targetDirection ? -targetDirection.unitScreenY : 0,
});
const backendDragVector = getJumpHopBackendDragVector(
predictionRun ?? activeRun,
visiblePlatforms,
landingAssistStageSize,
targetDirection ? -targetDirection.unitScreenX : 0,
targetDirection ? -targetDirection.unitScreenY : 0,
);
await onJump({
dragDistance: nextDragDistance,
dragVectorX: backendDragVector.dragVectorX,
dragVectorY: backendDragVector.dragVectorY,
});
};
@@ -2227,12 +2479,14 @@ export function JumpHopRuntimeShell({
}
>
<JumpHopThreeScene
characterPosition={visualCharacterPosition}
characterFrame={threeCharacterFrame}
chargeRatio={chargeRatio}
isLandingRecoilAnimating={isLandingRecoilAnimating}
isJumpAnimating={isJumpAnimating}
platforms={platformRenderItems}
platformCount={platformRenderItems.length}
renderCharacter={false}
renderCharacter={true}
renderPlatforms={shouldUseThreePlatformLayer}
textureUrlsByRenderKey={platformTextureUrlsByRenderKey}
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
onPlatformLayerReadyChange={setIsThreePlatformLayerReady}
@@ -2248,7 +2502,6 @@ export function JumpHopRuntimeShell({
top: `${item.screenY}%`,
width,
height,
'--jump-hop-platform-scale': item.scale,
zIndex:
item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index,
} as CSSProperties;
@@ -2325,6 +2578,27 @@ export function JumpHopRuntimeShell({
/>
</div>
) : null}
{landingAssistPosition ? (
<div
aria-hidden="true"
className="jump-hop-runtime__landing-assist"
data-testid="jump-hop-landing-assist"
data-on-target={
landingAssistPosition.isOnTargetPlatform ? 'true' : 'false'
}
style={
{
'--jump-hop-landing-x': `${landingAssistPosition.screenX}%`,
'--jump-hop-landing-y': `${landingAssistPosition.screenY}%`,
'--jump-hop-charge': chargeRatio,
} as CSSProperties
}
>
<div className="jump-hop-runtime__landing-assist-ring" />
<div className="jump-hop-runtime__landing-assist-core" />
</div>
) : null}
</div>
{isCharging && characterPosition ? (
@@ -2575,14 +2849,10 @@ export function JumpHopRuntimeShell({
transition: opacity 120ms ease;
}
.jump-hop-runtime__camera-layer[data-three-platform-ready='false'] .jump-hop-runtime__three-scene {
opacity: 0;
}
.jump-hop-runtime__platform {
position: absolute;
z-index: 8;
transform: translate(-50%, -50%) scale(var(--jump-hop-platform-scale, 1));
transform: translate(-50%, -50%);
transform-origin: 50% 50%;
display: grid;
place-items: center;
@@ -2815,6 +3085,53 @@ export function JumpHopRuntimeShell({
pointer-events: none;
}
.jump-hop-runtime__landing-assist {
position: absolute;
left: var(--jump-hop-landing-x);
top: var(--jump-hop-landing-y);
z-index: 86;
width: 2.15rem;
height: 2.15rem;
pointer-events: none;
transform: translate(-50%, -50%) scale(calc(0.82 + var(--jump-hop-charge) * 0.18));
transform-origin: center;
}
.jump-hop-runtime__landing-assist-ring,
.jump-hop-runtime__landing-assist-core {
position: absolute;
left: 50%;
top: 50%;
border-radius: 999px;
transform: translate(-50%, -50%);
}
.jump-hop-runtime__landing-assist-ring {
width: 100%;
height: 100%;
border: 2px solid rgba(250, 204, 21, 0.94);
background: rgba(250, 204, 21, 0.16);
box-shadow:
0 0 0 4px rgba(255, 255, 255, 0.58),
0 10px 20px rgba(15, 23, 42, 0.2);
}
.jump-hop-runtime__landing-assist-core {
width: 0.58rem;
height: 0.58rem;
background: #facc15;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.86);
}
.jump-hop-runtime__landing-assist[data-on-target='false'] .jump-hop-runtime__landing-assist-ring {
border-color: rgba(248, 113, 113, 0.96);
background: rgba(248, 113, 113, 0.18);
}
.jump-hop-runtime__landing-assist[data-on-target='false'] .jump-hop-runtime__landing-assist-core {
background: #ef4444;
}
.jump-hop-runtime__charge-ring {
position: absolute;
left: var(--jump-hop-anchor-x);

View File

@@ -53,6 +53,25 @@ describe('jumpHopClient runtime requests', () => {
);
});
it('passes draft runtime mode in the start run request body', async () => {
await startJumpHopRuntimeRun('profile/1', {
runtimeMode: 'draft',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/jump-hop/runs',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
profileId: 'profile/1',
runtimeMode: 'draft',
}),
}),
'启动跳一跳运行态失败',
expect.anything(),
);
});
it('submits jump input with a generated client event id', async () => {
await submitJumpHopJump(
'run/1',

View File

@@ -8,6 +8,7 @@ import type {
JumpHopJumpRequest,
JumpHopLeaderboardResponse,
JumpHopRunResponse,
JumpHopStartRunRequest,
JumpHopRuntimeRunSnapshotResponse,
JumpHopSessionResponse,
JumpHopSessionSnapshotResponse,
@@ -48,7 +49,7 @@ type JumpHopStartRunOptions = JumpHopRuntimeRequestOptions & {
};
type JumpHopJumpPayload = Pick<
JumpHopJumpRequest,
'dragDistance' | 'dragVectorX' | 'dragVectorY'
'dragDistance'
>;
export type {
@@ -273,10 +274,14 @@ export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopStartRunOptions = {},
) {
const requestBody: JumpHopStartRunRequest = {
profileId,
...(options.runtimeMode ? { runtimeMode: options.runtimeMode } : {}),
};
return requestRuntimeJson<JumpHopRunResponse>({
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'),
method: 'POST',
jsonBody: { profileId },
jsonBody: requestBody,
fallbackMessage: '启动跳一跳运行态失败',
requestOptions: options,
});
@@ -289,8 +294,6 @@ export async function submitJumpHopJump(
) {
const requestPayload = {
dragDistance: payload.dragDistance,
dragVectorX: payload.dragVectorX,
dragVectorY: payload.dragVectorY,
clientEventId: `jump-${runId}-${Date.now()}`,
};

View File

@@ -6,6 +6,7 @@ import type {
} from '../../../packages/shared/src/contracts/jumpHop';
import {
buildJumpHopVisiblePlatforms,
getJumpHopCharacterTopFaceVisualPosition,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
@@ -40,7 +41,7 @@ test('跳一跳地块池按平台编号从 18 个素材中抽取而不是按类
expect(second?.imageSrc).toMatch(/^asset-/);
});
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
test('跳一跳可见平台窗口固定为当前块和下一个块并携带选中的地块素材', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -76,13 +77,12 @@ test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材',
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
expect(visible).toHaveLength(3);
expect(visible).toHaveLength(2);
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
});
test('跳一跳三块可见地块按下方中部上方展开且角色落在当前地块上', () => {
test('跳一跳当前块按目标方向偏向场地侧边且角色落在当前地块上', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -125,15 +125,103 @@ test('跳一跳三块可见地块按下方中部上方展开且角色落在当
expect(visible[0]?.screenY).toBeLessThanOrEqual(66);
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThanOrEqual(32);
expect(visible).toHaveLength(2);
expect(visible[0]?.screenX).toBeLessThan(50);
expect(visible[1]?.screenX).toBeGreaterThan(50);
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
expect(character?.screenY).toBeCloseTo((visible[0]?.screenY ?? 0) - 3, 1);
expect(character?.screenY).toBeCloseTo(visible[0]?.screenY ?? 0, 1);
});
test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
test('跳一跳目标地块始终显示在当前脚下块的正负 45 度方向', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(1.78, 1.78, 'normal'),
platform(0, 3.56, 'target'),
],
};
const stageSize = { width: 320, height: (320 * 16) / 9 };
const firstVisible = buildJumpHopVisiblePlatforms(path, 0, []);
const firstDx = Math.abs(
((firstVisible[1]!.screenX - firstVisible[0]!.screenX) / 100) *
stageSize.width,
);
const firstDy = Math.abs(
((firstVisible[1]!.screenY - firstVisible[0]!.screenY) / 100) *
stageSize.height,
);
expect(firstVisible[1]!.screenX).toBeGreaterThan(firstVisible[0]!.screenX);
expect(firstVisible[0]!.screenX).toBeLessThan(50);
expect(firstVisible[1]!.screenX).toBeGreaterThan(50);
expect(firstDx).toBeCloseTo(firstDy, 5);
const secondVisible = buildJumpHopVisiblePlatforms(path, 1, []);
const secondDx = Math.abs(
((secondVisible[1]!.screenX - secondVisible[0]!.screenX) / 100) *
stageSize.width,
);
const secondDy = Math.abs(
((secondVisible[1]!.screenY - secondVisible[0]!.screenY) / 100) *
stageSize.height,
);
expect(secondVisible[1]!.screenX).toBeLessThan(secondVisible[0]!.screenX);
expect(secondVisible[0]!.screenX).toBeGreaterThan(50);
expect(secondVisible[1]!.screenX).toBeLessThan(50);
expect(secondDx).toBeCloseTo(secondDy, 5);
});
test('跳一跳目标地块按真实距离在最小和当前最大间距之间投影', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(0.979, 0.979, 'normal'),
],
};
const stageSize = { width: 320, height: (320 * 16) / 9 };
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
const dx = Math.abs(
((visible[1]!.screenX - visible[0]!.screenX) / 100) *
stageSize.width,
);
const dy = Math.abs(
((visible[1]!.screenY - visible[0]!.screenY) / 100) *
stageSize.height,
);
expect(visible[1]!.screenY).toBeGreaterThan(47);
expect(visible[1]!.screenY).toBeLessThan(64);
expect(Math.abs(visible[1]!.screenX - visible[0]!.screenX)).toBeLessThan(
30.3,
);
expect(dx).toBeCloseTo(dy, 5);
});
test('跳一跳可见地块不再按深度做倍率缩放', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -161,18 +249,14 @@ test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
visible[1]!.platform,
visible[1]!.scale,
);
const previewSize = getJumpHopPlatformVisualSize(
visible[2]!.platform,
visible[2]!.scale,
);
expect(currentSize.width).toBeGreaterThan(targetSize.width);
expect(targetSize.width).toBeGreaterThan(previewSize.width);
expect(currentSize.height).toBeGreaterThan(targetSize.height);
expect(targetSize.height).toBeGreaterThan(previewSize.height);
expect(visible[0]?.scale).toBe(1);
expect(visible[1]?.scale).toBe(1);
expect(currentSize.width).toBe(targetSize.width);
expect(currentSize.height).toBe(targetSize.height);
});
test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起始地块', () => {
test('跳一跳三维角色画布坐标按下一块方向偏向场地侧边', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -216,17 +300,17 @@ test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起
height: 568,
});
expect(canvasPosition?.x).toBeGreaterThan(140);
expect(canvasPosition?.x).toBeLessThan(180);
expect(canvasPosition?.x).toBeGreaterThan(100);
expect(canvasPosition?.x).toBeLessThan(125);
expect(canvasPosition?.y).toBeGreaterThan(330);
expect(canvasPosition?.y).toBeLessThan(370);
});
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
test('跳一跳运行态当前地块视觉尺寸使用真实规格', () => {
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1);
expect(size.width).toBeCloseTo(125.28, 2);
expect(size.height).toBeCloseTo(103.68, 2);
expect(size.width).toBeCloseTo(116, 2);
expect(size.height).toBeCloseTo(96, 2);
});
test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () => {
@@ -290,13 +374,13 @@ test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () =>
);
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
expect(halfAssist?.screenX).toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
current.screenY + (target.screenY - current.screenY) / 2 - 3,
current.screenY + (target.screenY - current.screenY) / 2,
1,
);
});
@@ -352,26 +436,144 @@ test('跳一跳落点预测忽略旧客户端拖拽方向', () => {
character,
stageSize,
fullDragDistance,
-999,
-999,
);
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
expect(assist?.isOnTargetPlatform).toBe(true);
});
test('跳一跳落点预测用收缩后的视觉顶面 footprint 判断命中', () => {
test('跳一跳落点预测从角色真实脚点指向下一地块顶面中心', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(0.8, 1.2, 'normal'),
platform(-0.2, 2.4, 'target'),
],
};
const run = {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 1,
successfulJumpCount: 1,
durationMs: 0,
score: 1,
combo: 0,
path,
lastJump: {
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.72,
landedY: 1.16,
result: 'hit',
},
startedAtMs: 1000,
finishedAtMs: null,
} as const;
const visible = buildJumpHopVisiblePlatforms(path, 1, []);
const character = getJumpHopCharacterVisualPosition(run, visible);
const current = visible[0]!;
const target = visible[1]!;
const targetPosition = getJumpHopCharacterTopFaceVisualPosition(target);
const stageSize = { width: 320, height: 568 };
const targetWorldDistance = Math.hypot(
target.platform.x - run.lastJump.landedX,
target.platform.y - run.lastJump.landedY,
);
const fullDragDistance =
targetWorldDistance / path.scoring.chargeToDistanceRatio;
const fullAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance,
);
const halfAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance / 2,
);
expect(character?.screenX).not.toBeCloseTo(current.screenX, 1);
expect(fullAssist?.landedWorldX).toBeCloseTo(target.platform.x, 5);
expect(fullAssist?.landedWorldY).toBeCloseTo(target.platform.y, 5);
expect(fullAssist?.screenX).toBeCloseTo(targetPosition.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(targetPosition.screenY, 1);
expect(fullAssist?.isOnTargetPlatform).toBe(true);
expect(halfAssist?.landedWorldX).toBeCloseTo(
(run.lastJump.landedX + target.platform.x) / 2,
5,
);
expect(halfAssist?.landedWorldY).toBeCloseTo(
(run.lastJump.landedY + target.platform.y) / 2,
5,
);
expect(halfAssist?.screenX).toBeCloseTo(
(character!.screenX + targetPosition.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
(character!.screenY + targetPosition.screenY) / 2,
1,
);
expect(halfAssist?.screenX).not.toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
});
test('跳一跳落点预测用完整视觉顶面 footprint 判断命中', () => {
const target = {
...platform(1, 0, 'normal'),
width: 2,
height: 0.6,
landingRadius: 0.2,
landingRadius: 10,
};
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.8, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.18)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.99, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 2.01, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.29)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.31)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0.12)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.7, 0.12)).toBe(false);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
1.3,
1.6,
),
).toBe(true);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
1.4,
1.8,
),
).toBe(false);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
-0.19,
1.2,
),
).toBe(true);
});
test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块中心', () => {
@@ -407,8 +609,8 @@ test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.52,
landedY: 0.78,
landedX: 0.72,
landedY: 1.16,
result: 'hit',
},
startedAtMs: 1000,
@@ -422,9 +624,9 @@ test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块
const currentCenter = visible[0]!;
expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY - 3, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY, 1);
expect(character?.screenX).toBeLessThan(currentCenter.screenX);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY - 3);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY);
});
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {

View File

@@ -1,4 +1,5 @@
import type {
JumpHopDifficulty,
JumpHopPath,
JumpHopPlatform,
JumpHopRunStatus,
@@ -41,28 +42,47 @@ export type JumpHopPlatformVisualSize = {
export type JumpHopLandingAssistVisualPosition = {
screenX: number;
screenY: number;
sceneX: number;
sceneY: number;
sceneZ: number;
landedWorldX: number;
landedWorldY: number;
targetPlatformIndex: number;
isOnTargetPlatform: boolean;
};
export type JumpHopBackendDragVector = {
dragVectorX: number;
dragVectorY: number;
};
const JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO = 0.004;
const JUMP_HOP_DEFAULT_STAGE_SIZE: JumpHopCanvasSize = {
width: 320,
height: 568,
};
const VISIBLE_PLATFORM_COUNT = 3;
const VISIBLE_PLATFORM_COUNT = 2;
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [64, 47, 30] as const;
const JUMP_HOP_CURRENT_PLATFORM_SIDE_OFFSET_PERCENT = 15;
const JUMP_HOP_STAGE_ASPECT_HEIGHT_OVER_WIDTH = 16 / 9;
const JUMP_HOP_MIN_PLATFORM_VISUAL_GAP_RATIO = 0.55;
const JUMP_HOP_MAX_PLATFORM_WORLD_GAP_BY_DIFFICULTY: Record<
JumpHopDifficulty,
number
> = {
advanced: 2.05,
challenge: 2.35,
easy: 1.45,
standard: 1.78,
};
// 9:16 舞台里,当前与目标地块的 17% 纵向间距对应约 30% 横向间距,屏幕像素上形成 45 度。
const JUMP_HOP_SCREEN_X_45_DEGREE_STEP_PERCENT =
(JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0] -
JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]) *
JUMP_HOP_STAGE_ASPECT_HEIGHT_OVER_WIDTH;
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 11.2;
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO = 0.72;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO = 0.52;
// 中文注释:命中区必须与视觉顶面一致,禁止再做隐藏收缩。
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO = 1;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO = 1;
const JUMP_HOP_CHARACTER_TOP_FACE_SCENE_Y_OFFSET = 0.84;
const tileToneByType: Record<JumpHopTileType, string> = {
accent: '#e0f2fe',
@@ -77,6 +97,59 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getJumpHopPlatformStepDirection(
from: JumpHopPlatform | null | undefined,
to: JumpHopPlatform | null | undefined,
) {
const dx = (to?.x ?? 0) - (from?.x ?? 0);
return dx >= 0 ? 1 : -1;
}
function getJumpHopMaxPlatformWorldGap(difficulty: JumpHopDifficulty | null | undefined) {
if (!difficulty) {
return JUMP_HOP_MAX_PLATFORM_WORLD_GAP_BY_DIFFICULTY.standard;
}
return (
JUMP_HOP_MAX_PLATFORM_WORLD_GAP_BY_DIFFICULTY[difficulty] ??
JUMP_HOP_MAX_PLATFORM_WORLD_GAP_BY_DIFFICULTY.standard
);
}
function getJumpHopPlatformVisualGapRatio(
difficulty: JumpHopDifficulty | null | undefined,
from: JumpHopPlatform | null | undefined,
to: JumpHopPlatform | null | undefined,
) {
if (!from || !to) {
return 1;
}
const worldStep = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
const maxWorldStep = getJumpHopMaxPlatformWorldGap(difficulty);
if (worldStep <= 0 || maxWorldStep <= 0) {
return JUMP_HOP_MIN_PLATFORM_VISUAL_GAP_RATIO;
}
return clamp(
worldStep / maxWorldStep,
JUMP_HOP_MIN_PLATFORM_VISUAL_GAP_RATIO,
1,
);
}
function getJumpHopCurrentPlatformScreenX(
current: JumpHopPlatform,
next: JumpHopPlatform | null | undefined,
) {
if (!next) {
return 50;
}
return (
50 -
getJumpHopPlatformStepDirection(current, next) *
JUMP_HOP_CURRENT_PLATFORM_SIDE_OFFSET_PERCENT
);
}
function hashJumpHopString(value: string) {
let hash = 0x811c9dc5;
for (const character of value) {
@@ -115,9 +188,17 @@ export function buildJumpHopVisiblePlatforms(
}
const start = Math.max(0, currentPlatformIndex);
const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT);
const end = Math.min(
platforms.length,
currentPlatformIndex + VISIBLE_PLATFORM_COUNT,
);
const visible = platforms.slice(start, end);
const worldScale = 0.96;
const currentScreenX = getJumpHopCurrentPlatformScreenX(
current,
platforms[currentPlatformIndex + 1],
);
let visualOffsetX = 0;
let visualOffsetY = 0;
return visible.map((platform, offset): JumpHopVisiblePlatform => {
const index = start + offset;
@@ -130,13 +211,30 @@ export function buildJumpHopVisiblePlatforms(
index,
platform.platformId,
);
if (offset > 0) {
const gapRatio = getJumpHopPlatformVisualGapRatio(
path?.difficulty ?? null,
visible[offset - 1],
platform,
);
visualOffsetX +=
getJumpHopPlatformStepDirection(visible[offset - 1], platform) *
JUMP_HOP_SCREEN_X_45_DEGREE_STEP_PERCENT *
gapRatio;
visualOffsetY +=
(JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0] -
JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]) *
gapRatio;
}
const screenX = clamp(currentScreenX + visualOffsetX, 14, 86);
const screenY =
depth <= 0
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0]
: depth === 1
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
const screenX = clamp(50 + dx * JUMP_HOP_SCREEN_X_WORLD_PERCENT, 14, 86);
: clamp(
JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0] - visualOffsetY,
JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2],
JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0],
);
return {
platform,
@@ -146,7 +244,7 @@ export function buildJumpHopVisiblePlatforms(
sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: 0,
sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE,
scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1),
scale: 1,
asset,
};
});
@@ -196,6 +294,30 @@ function getJumpHopCurrentTargetPlatforms(
};
}
function getJumpHopCurrentFootWorldPosition(
run: JumpHopRuntimeRunSnapshotResponse,
currentPlatform: JumpHopVisiblePlatform,
) {
const lastJump = run.lastJump;
if (
lastJump &&
lastJump.result !== 'miss' &&
lastJump.targetPlatformIndex === run.currentPlatformIndex &&
Number.isFinite(lastJump.landedX) &&
Number.isFinite(lastJump.landedY)
) {
return {
x: lastJump.landedX,
y: lastJump.landedY,
};
}
return {
x: currentPlatform.platform.x,
y: currentPlatform.platform.y,
};
}
function getJumpHopCanvasPosition(
platform: JumpHopVisiblePlatform,
stageSize: JumpHopCanvasSize,
@@ -206,7 +328,7 @@ function getJumpHopCanvasPosition(
};
}
function getJumpHopCharacterVisualPositionFromPlatform(
export function getJumpHopCharacterTopFaceVisualPosition(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
@@ -223,9 +345,9 @@ function getJumpHopCharacterVisualPositionFromPlatform(
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
screenY: platform.screenY,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneY: platform.sceneY + JUMP_HOP_CHARACTER_TOP_FACE_SCENE_Y_OFFSET,
sceneZ: platform.sceneZ,
isMiss: false,
};
@@ -290,6 +412,51 @@ function getJumpHopScreenWorldScales(
};
}
function getJumpHopWorldPointVisualPosition(
currentPlatform: JumpHopVisiblePlatform,
targetPlatform: JumpHopVisiblePlatform,
stageSize: JumpHopCanvasSize,
worldX: number,
worldY: number,
isMiss = false,
): JumpHopCharacterVisualPosition | null {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
!Number.isFinite(worldX) ||
!Number.isFinite(worldY)
) {
return null;
}
const scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const worldDeltaX = worldX - currentPlatform.platform.x;
const worldDeltaY = worldY - currentPlatform.platform.y;
const landedPixelX =
scales.currentCanvasPosition.x +
worldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
worldDeltaY * scales.signedYScreenPerWorld;
const sceneDeltaX = worldDeltaX * JUMP_HOP_STAGE_WORLD_SCALE;
const sceneDeltaZ = worldDeltaY * JUMP_HOP_STAGE_FORWARD_SCALE;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
sceneX: currentPlatform.sceneX + sceneDeltaX,
sceneY:
currentPlatform.sceneY +
(isMiss ? 0.48 : JUMP_HOP_CHARACTER_TOP_FACE_SCENE_Y_OFFSET),
sceneZ: currentPlatform.sceneZ + sceneDeltaZ,
isMiss,
};
}
export function getJumpHopWorldLandingVisualPosition(
originPlatform: JumpHopVisiblePlatform | null | undefined,
scalePlatform: JumpHopVisiblePlatform | null | undefined,
@@ -309,32 +476,14 @@ export function getJumpHopWorldLandingVisualPosition(
return null;
}
const scales = getJumpHopScreenWorldScales(
return getJumpHopWorldPointVisualPosition(
originPlatform,
scalePlatform,
stageSize,
);
const worldDeltaX = landedX - originPlatform.platform.x;
const worldDeltaY = landedY - originPlatform.platform.y;
const landedPixelX =
scales.currentCanvasPosition.x +
worldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
worldDeltaY * scales.signedYScreenPerWorld;
const sceneDeltaX =
(landedX - originPlatform.platform.x) * JUMP_HOP_STAGE_WORLD_SCALE;
const sceneDeltaZ =
(landedY - originPlatform.platform.y) * JUMP_HOP_STAGE_FORWARD_SCALE;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
sceneX: originPlatform.sceneX + sceneDeltaX,
sceneY: originPlatform.sceneY + (isMiss ? 0.48 : 0.84),
sceneZ: originPlatform.sceneZ + sceneDeltaZ,
landedX,
landedY,
isMiss,
};
);
}
export function isJumpHopLandingInsidePlatformFootprint(
@@ -358,10 +507,13 @@ export function isJumpHopLandingInsidePlatformFootprint(
0,
platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO,
);
return (
Math.abs(landedX - platform.x) <= halfWidth &&
Math.abs(landedY - platform.y) <= halfHeight
);
if (halfWidth <= Number.EPSILON || halfHeight <= Number.EPSILON) {
return false;
}
const normalizedX = Math.abs(landedX - platform.x) / halfWidth;
const normalizedY = Math.abs(landedY - platform.y) / halfHeight;
return normalizedX + normalizedY <= 1 + Number.EPSILON;
}
function getJumpHopSuccessfulLandingVisualPosition(
@@ -381,89 +533,35 @@ function getJumpHopSuccessfulLandingVisualPosition(
if (!landedPlatform) {
return null;
}
const previousPlatformIndex = Math.max(0, lastJump.targetPlatformIndex - 1);
const previousWindowPlatforms = buildJumpHopVisiblePlatforms(
run.path,
previousPlatformIndex,
[],
);
const previousPlatform =
previousWindowPlatforms.find(
(item) => item.index === previousPlatformIndex,
) ?? null;
const targetPlatformInPreviousWindow =
previousWindowPlatforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
) ?? null;
const landingInPreviousWindow = getJumpHopWorldLandingVisualPosition(
platforms.find((item) => item.index === previousPlatformIndex) ?? null;
if (
previousPlatform &&
previousPlatform.index !== landedPlatform.index
) {
return getJumpHopWorldLandingVisualPosition(
previousPlatform,
targetPlatformInPreviousWindow,
landedPlatform,
stageSize,
lastJump.landedX,
lastJump.landedY,
false,
);
if (!landingInPreviousWindow || !targetPlatformInPreviousWindow) {
return null;
}
const targetCenterInPreviousWindow =
getJumpHopCharacterVisualPositionFromPlatform(
targetPlatformInPreviousWindow,
);
const landedPlatformCenter =
getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
const worldDeltaX = lastJump.landedX - landedPlatform.platform.x;
const worldDeltaY = lastJump.landedY - landedPlatform.platform.y;
const scalePlatform =
platforms.find((item) => item.index === run.currentPlatformIndex + 1) ??
landedPlatform;
return {
screenX: clamp(
landedPlatformCenter.screenX +
landingInPreviousWindow.screenX -
targetCenterInPreviousWindow.screenX,
6,
94,
),
screenY: clamp(
landedPlatformCenter.screenY +
landingInPreviousWindow.screenY -
targetCenterInPreviousWindow.screenY,
10,
92,
),
sceneX: landedPlatform.sceneX + worldDeltaX * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ + worldDeltaY * JUMP_HOP_STAGE_FORWARD_SCALE,
isMiss: false,
};
}
export function getJumpHopBackendDragVector(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize,
dragVectorX: number,
dragVectorY: number,
): JumpHopBackendDragVector {
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
if (!pair || stageSize.width <= 0 || stageSize.height <= 0) {
return {
dragVectorX,
dragVectorY,
};
}
const scales = getJumpHopScreenWorldScales(
pair.currentPlatform,
pair.targetPlatform,
return getJumpHopWorldLandingVisualPosition(
landedPlatform,
scalePlatform,
stageSize,
lastJump.landedX,
lastJump.landedY,
false,
);
return {
dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit,
dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit,
};
}
export function getJumpHopLandingAssistVisualPosition(
@@ -472,8 +570,6 @@ export function getJumpHopLandingAssistVisualPosition(
characterPosition: JumpHopCharacterVisualPosition | null,
stageSize: JumpHopCanvasSize,
dragDistance: number,
_dragVectorX?: number | null,
_dragVectorY?: number | null,
) {
if (
!run ||
@@ -492,13 +588,9 @@ export function getJumpHopLandingAssistVisualPosition(
}
const { currentPlatform, targetPlatform } = pair;
const scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const jumpWorldX = targetPlatform.platform.x - currentPlatform.platform.x;
const jumpWorldY = targetPlatform.platform.y - currentPlatform.platform.y;
const originWorld = getJumpHopCurrentFootWorldPosition(run, currentPlatform);
const jumpWorldX = targetPlatform.platform.x - originWorld.x;
const jumpWorldY = targetPlatform.platform.y - originWorld.y;
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
if (jumpWorldLength < 0.0001) {
return null;
@@ -516,18 +608,24 @@ export function getJumpHopLandingAssistVisualPosition(
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
const landedWorldDeltaY =
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
const landedWorldX = currentPlatform.platform.x + landedWorldDeltaX;
const landedWorldY = currentPlatform.platform.y + landedWorldDeltaY;
const landedPixelX =
scales.currentCanvasPosition.x +
landedWorldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
landedWorldDeltaY * scales.signedYScreenPerWorld;
const landedWorldX = originWorld.x + landedWorldDeltaX;
const landedWorldY = originWorld.y + landedWorldDeltaY;
const landedPosition = getJumpHopWorldPointVisualPosition(
currentPlatform,
targetPlatform,
stageSize,
landedWorldX,
landedWorldY,
false,
);
if (!landedPosition) {
return null;
}
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
...landedPosition,
landedWorldX,
landedWorldY,
targetPlatformIndex: targetPlatform.index,
isOnTargetPlatform: isJumpHopLandingInsidePlatformFootprint(
targetPlatform.platform,
@@ -603,7 +701,7 @@ export function getJumpHopCharacterVisualPosition(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
return getJumpHopCharacterTopFaceVisualPosition(landedPlatform);
}
if (lastJump && run.status === 'failed') {
@@ -611,7 +709,7 @@ export function getJumpHopCharacterVisualPosition(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return getJumpHopCharacterVisualPositionFromPlatform(targetPlatform, true);
return getJumpHopCharacterTopFaceVisualPosition(targetPlatform, true);
}
}