Codex/tiaoyitiao #68

Merged
kdletters merged 6 commits from codex/tiaoyitiao into master 2026-06-26 21:46:13 +08:00
15 changed files with 3517 additions and 657 deletions
@@ -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 承载陶泥儿透明 PNG,Sprite 脚点必须落在当前方块顶面中心高度并且绘制顺序高于地块,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
@@ -2393,18 +2393,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`
@@ -2417,6 +2417,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 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
@@ -2617,6 +2625,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 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。
+40 -8
View File
@@ -528,6 +528,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` 和“去完成”。
@@ -2277,10 +2285,10 @@
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 现象:按住蓄力时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;它只作为调参验证层随按下显示、松手或取消隐藏,不参与后端裁决和作品配置;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:半程蓄力时辅助点应落在当前地块和目标地块之间,完整蓄力时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`
## 跳一跳长按蓄力不能再消费拖拽方向
@@ -2566,8 +2574,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`
## 跳一跳相机推进不要让地块图片回退到原型方块
@@ -2581,19 +2589,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、建筑海报或其它随机内容,容易误判为模型不服从提示词。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1
View File
@@ -56,6 +56,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;
@@ -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);
@@ -144,25 +144,27 @@ fn remove_generated_asset_sheet_green_screen_background(
background_key_score.max(white_score).max(transparency_hint);
}
let seed_background_pixel = |pixel_index: usize,
background_mask: &mut [u8],
queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let alpha = pixels[pixel_index * 4 + 3];
let strong_candidate = alpha < 40
|| background_key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224
&& background_key_scores[pixel_index]
> GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate {
return;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
};
let seed_background_pixel =
|pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec<usize>| {
if background_mask[pixel_index] != 0 {
return;
}
let alpha = pixels[pixel_index * 4 + 3];
// 中文注释:绿幕模式下 alpha 携带背景/前景信息;洋红/青色等非绿幕模式下
// 图像通常无 alpha 通道(全部为 255),因此仅依赖 key_score 判断,不关 alpha。
let ignore_alpha = !options.key_color.is_green_screen();
let key_score = background_key_scores[pixel_index];
let strong_candidate = alpha < 40
|| key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| ((ignore_alpha || alpha < 224)
&& key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate {
return;
}
background_mask[pixel_index] = 1;
queue.push(pixel_index);
};
for x in 0..width {
seed_background_pixel(x, &mut background_mask, &mut queue);
@@ -207,13 +209,16 @@ fn remove_generated_asset_sheet_green_screen_background(
let key_score = background_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)
@@ -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,49 +511,133 @@ test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', ()
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
});
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
test('跳一跳 Three.js 顶面脚点投影会扣除立方体高度偏移', () => {
const projectedTopY = getJumpHopThreeProjectedY(360, 568);
const cubeTopWorldY = getJumpHopThreeWorldYForScreenY(360, 568, 48);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={onJump}
onRestart={() => {}}
/>,
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,
);
});
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
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,
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 148,
clientY: 454,
});
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(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 492,
});
expect(
resolveJumpHopThreeCharacterFrame({
characterPosition: from,
visualJump: { from, to },
jumpAnimationProgress: 0.5,
isJumpAnimating: false,
}),
).toEqual({
position: from,
progress: 1,
});
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
test('跳一跳蓄力时实时显示松手后的预测落点', async () => {
vi.useFakeTimers();
try {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
vi.advanceTimersByTime(240);
});
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 () => {
vi.advanceTimersByTime(260);
});
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(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
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,
File diff suppressed because it is too large Load Diff
@@ -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',
+7 -4
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()}`,
};
+240 -38
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 和通关语义', () => {
File diff suppressed because it is too large Load Diff