diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index e64d4371..34f39319 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -10,7 +10,7 @@ 2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG; 3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG; 4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块; -5. 操作方式为长按屏幕蓄力,松手后角色朝下一块地块中心方向弹出; +5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出; 6. 只要落点未命中下一个地块,本局立即失败并冻结计时; 7. 成绩记录成功跳跃次数和游戏时长; 8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 @@ -101,18 +101,18 @@ tile-16 tile-17 tile-18 1. 用户按住当前地块或画面开始蓄力; 2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶; -3. 松手后角色朝下一块地块中心方向弹出; -4. 蓄力值决定跳跃距离,跳跃方向不受手指拖动方向影响; -5. 前端只提交蓄力值,后端基于当前地块中心到下一块地块中心的方向裁决真实落点。 +3. 松手后角色按本次输入方向弹出; +4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向; +5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心。 -手感参数固定由后端 `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` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 ### 6.3 判定 1. 目标永远是当前地块后的下一个地块; -2. 真实落点沿当前地块中心到下一块地块中心方向计算; +2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算; 3. 落点进入下一个地块可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%; 4. 落点未进入下一个地块可见顶面 footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定; 5. 失败后状态改为 `failed`,计时冻结; @@ -190,10 +190,10 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG; 4. 结果页不依赖旧角色图片槽; 5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; -6. 长按蓄力值影响落点距离,跳跃方向固定朝下一块地块中心; +6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向; 7. 未落到下一个地块立即失败; 8. 成功跳跃次数累加,失败后计时冻结; 9. 排行榜按成功跳跃次数优先排序; 10. 作品可保存、发布、分享并从公开入口启动。 -11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图按 right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。 +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` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 76e75013..7dba486f 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -184,9 +184,9 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口,HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌,以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 -运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;Three.js 平台层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,只按单一 side 等比缩放当前 / 目标 / 预览地块,并把 `tileAssets[]` 的生成切片作为主题身份方块包装贴图加载到立方体表面;单块地板保持正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转;运行态采用约 `1.3x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块基准位于屏幕中线略下方,目标和预览地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;Three.js 相机和 DOM 角色层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转,否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉;DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。 +运行态渲染分层固定为:舞台底层 `.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 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。 -跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;跳跃朝向永远由当前地块中心指向下一块地块中心,前端不再提交拖拽方向,后端即使收到旧客户端的 `dragVectorX/dragVectorY` 也必须忽略。实际落点只由蓄力时长换算出的跳跃距离决定,成功判定使用下一块地块可见顶面 footprint:后端以该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%,落点进入该视觉顶面则成功,未进入则失败;旧 `landingRadius/perfectRadius` 只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向下一块中心的轻量引导,但不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿当前地块中心到下一块地块中心方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块造成闪现。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 +跳一跳当前长按蓄力手感统一采用 `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 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `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 失败、刷新回首页。 diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index 8e7021a6..bce39499 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -64,8 +64,8 @@ pub fn start_run( pub fn apply_jump( run: &JumpHopRunSnapshot, drag_distance: f32, - _drag_vector_x: Option, - _drag_vector_y: Option, + drag_vector_x: Option, + drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { @@ -88,8 +88,12 @@ pub fn apply_jump( let vector_x = target.x - current.x; let vector_y = target.y - current.y; let target_distance = vector_x.hypot(vector_y).max(0.0001); - let unit_x = vector_x / target_distance; - let unit_y = vector_y / target_distance; + 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 landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y); @@ -138,6 +142,29 @@ fn is_landing_inside_platform_footprint( error_x.abs() <= half_width && error_y.abs() <= half_height } +fn normalize_jump_direction( + drag_vector_x: Option, + drag_vector_y: Option, + fallback_x: f32, + fallback_y: f32, +) -> (f32, f32) { + let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + // 前端提交屏幕拖拽向量:x 轴同向,y 轴向下为正;真实起跳反向弹出,世界 y 向上为正。 + let jump_x = -drag_x; + let jump_y = drag_y; + let length = jump_x.hypot(jump_y); + if length < 0.0001 { + (fallback_x, fallback_y) + } else { + (jump_x / length, jump_y / length) + } +} + pub fn restart_run( run: &JumpHopRunSnapshot, next_run_id: String, @@ -450,7 +477,7 @@ mod tests { } #[test] - fn jump_resolution_ignores_client_drag_direction_and_targets_next_center() { + fn jump_resolution_uses_client_drag_direction_for_landing() { let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); let run = start_run( "run-screen-axis".to_string(), @@ -465,9 +492,34 @@ mod tests { let target_distance = (target.x - current.x).hypot(target.y - current.y); let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; - let result = apply_jump(&run, charge as f32, Some(-999.0), Some(-999.0), 200) + 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.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Miss + ); + } + + #[test] + fn jump_resolution_falls_back_to_next_center_when_drag_direction_missing() { + let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); + let run = start_run( + "run-screen-axis".to_string(), + "user-screen-axis".to_string(), + "profile-screen-axis".to_string(), + path, + 100, + ) + .expect("run should start"); + let current = &run.path.platforms[0]; + let target = &run.path.platforms[1]; + let target_distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + + let result = apply_jump(&run, charge as f32, None, None, 200).expect("jump should resolve"); + let last_jump = result.last_jump.as_ref().expect("last jump should exist"); assert_eq!(result.status, JumpHopRunStatus::Playing); assert_eq!(last_jump.result, JumpHopJumpResultKind::Hit); diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 0fb789b3..3d5e3c68 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -13,6 +13,7 @@ import { JUMP_HOP_THREE_CAMERA_UP_Y, JumpHopRuntimeShell, getJumpHopThreeProjectedY, + getJumpHopTileTextureSignature, } from './JumpHopRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ @@ -47,7 +48,7 @@ function dispatchPointerEvent( target.dispatchEvent(event); } -test('跳一跳运行态松手时只提交长按蓄力值', async () => { +test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); @@ -90,14 +91,16 @@ test('跳一跳运行态松手时只提交长按蓄力值', async () => { expect(onJump).toHaveBeenCalledTimes(1); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeUndefined(); - expect(jumpPayload?.dragVectorY).toBeUndefined(); + 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?.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(); @@ -138,8 +141,10 @@ test('跳一跳运行态手指移动不改变提交方向', async () => { }); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(jumpPayload?.dragVectorX).toBeUndefined(); - expect(jumpPayload?.dragVectorY).toBeUndefined(); + 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?.dragDistance).toBeGreaterThanOrEqual(240); expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260); vi.useRealTimers(); @@ -585,6 +590,29 @@ test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张 ).toBe(true); }); +test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => { + const asset = buildTileAssets({ withFaceAssets: true })[0]; + const signature = getJumpHopTileTextureSignature( + { + 'p1::top': 'top-url', + 'p1::front': 'front-url', + 'p1::right': 'right-url', + 'p1::back': 'back-url', + 'p1::left': 'left-url', + 'p1::bottom': 'bottom-url', + }, + 'p1', + asset, + ); + + expect(signature).toContain('top-url'); + expect(signature).toContain('front-url'); + expect(signature).toContain('right-url'); + expect(signature).toContain('back-url'); + expect(signature).toContain('left-url'); + expect(signature).toContain('bottom-url'); +}); + test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => { render( , + renderKey: string, + asset: JumpHopTileAsset | null | undefined, +) { + if (!asset?.faceAssets) { + return textureUrls[renderKey] ?? ''; + } + + return JUMP_HOP_TILE_FACE_KEYS.map((face) => + getJumpHopTileTextureUrl(textureUrls, renderKey, face), + ).join('|'); +} + function hasJumpHopTileTexturesReady( textureUrls: Record, renderKey: string, @@ -953,7 +968,11 @@ function JumpHopThreeScene({ item.screenY.toFixed(3), item.scale.toFixed(3), cubeSide.toFixed(2), - textureUrls[item.renderKey] ?? '', + getJumpHopTileTextureSignature( + textureUrls, + item.renderKey, + item.asset, + ), item.advanceState, ].join(':'); }) @@ -2027,8 +2046,17 @@ 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, }); };