统一跳一跳三维地块与落点判定

修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心

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

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

补充跳一跳 UV 图集切片与运行态规则文档
This commit is contained in:
2026-06-12 22:42:39 +08:00
parent 6bdf84dc0d
commit 6ee55707e1
15 changed files with 1915 additions and 646 deletions

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);