修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心 统一前端指示器飞行动画与后端顶面 footprint 判定 调整 Three.js 方块贴图与角色顶面投影表现 补充跳一跳 UV 图集切片与运行态规则文档
784 lines
28 KiB
Rust
784 lines
28 KiB
Rust
use shared_kernel::normalize_required_string;
|
|
|
|
use crate::{
|
|
JumpHopDifficulty, JumpHopError, JumpHopJumpResultKind, JumpHopLastJump, JumpHopPath,
|
|
JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType,
|
|
};
|
|
|
|
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 = 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);
|
|
let platform_count = 8usize;
|
|
let platforms = build_platforms_until(seed, difficulty, platform_count);
|
|
|
|
JumpHopPath {
|
|
seed: seed.trim().to_string(),
|
|
difficulty,
|
|
finish_index: u32::MAX,
|
|
platforms,
|
|
camera_preset: "portrait-isometric-9x16".to_string(),
|
|
scoring: JumpHopScoring {
|
|
charge_to_distance_ratio: config.charge_to_distance_ratio,
|
|
max_charge_ms: config.max_charge_ms,
|
|
hit_bonus: 20,
|
|
perfect_bonus: 60,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn start_run(
|
|
run_id: String,
|
|
owner_user_id: String,
|
|
profile_id: String,
|
|
path: JumpHopPath,
|
|
started_at_ms: u64,
|
|
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
|
let run_id = normalize_required_string(run_id).ok_or(JumpHopError::MissingRunId)?;
|
|
let owner_user_id =
|
|
normalize_required_string(owner_user_id).ok_or(JumpHopError::MissingOwnerUserId)?;
|
|
let profile_id = normalize_required_string(profile_id).ok_or(JumpHopError::MissingProfileId)?;
|
|
if path.platforms.is_empty() {
|
|
return Err(JumpHopError::EmptyPath);
|
|
}
|
|
let path = normalize_jump_hop_path_platform_size(path);
|
|
|
|
Ok(JumpHopRunSnapshot {
|
|
run_id,
|
|
profile_id,
|
|
owner_user_id,
|
|
status: JumpHopRunStatus::Playing,
|
|
current_platform_index: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
last_jump: None,
|
|
started_at_ms,
|
|
finished_at_ms: None,
|
|
path,
|
|
})
|
|
}
|
|
|
|
pub fn apply_jump(
|
|
run: &JumpHopRunSnapshot,
|
|
drag_distance: f32,
|
|
_drag_vector_x: Option<f32>,
|
|
_drag_vector_y: Option<f32>,
|
|
jumped_at_ms: u64,
|
|
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
|
if run.status != JumpHopRunStatus::Playing {
|
|
return Err(JumpHopError::RunNotPlaying);
|
|
}
|
|
let current_index = run.current_platform_index as usize;
|
|
let next_index = current_index + 1;
|
|
let path = extend_jump_hop_path(run.path.clone(), next_index + 3);
|
|
let current = run
|
|
.path
|
|
.platforms
|
|
.get(current_index)
|
|
.ok_or(JumpHopError::EmptyPath)?;
|
|
let target = path
|
|
.platforms
|
|
.get(next_index)
|
|
.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 (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 = 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();
|
|
next.path = path;
|
|
let result = if landed_on_target {
|
|
JumpHopJumpResultKind::Hit
|
|
} else {
|
|
JumpHopJumpResultKind::Miss
|
|
};
|
|
|
|
next.last_jump = Some(JumpHopLastJump {
|
|
charge_ms: capped_drag_distance.round() as u32,
|
|
jump_distance,
|
|
target_platform_index: next_index as u32,
|
|
landed_x,
|
|
landed_y,
|
|
result,
|
|
});
|
|
|
|
if result == JumpHopJumpResultKind::Miss {
|
|
next.status = JumpHopRunStatus::Failed;
|
|
next.combo = 0;
|
|
next.finished_at_ms = Some(jumped_at_ms);
|
|
return Ok(next);
|
|
}
|
|
|
|
next.current_platform_index = next_index as u32;
|
|
next.combo = 0;
|
|
next.score = next.current_platform_index;
|
|
|
|
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,
|
|
landed_y: f32,
|
|
) -> bool {
|
|
let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0);
|
|
let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0);
|
|
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 <= 1.0 + f32::EPSILON
|
|
}
|
|
|
|
pub fn restart_run(
|
|
run: &JumpHopRunSnapshot,
|
|
next_run_id: String,
|
|
restarted_at_ms: u64,
|
|
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
|
start_run(
|
|
next_run_id,
|
|
run.owner_user_id.clone(),
|
|
run.profile_id.clone(),
|
|
run.path.clone(),
|
|
restarted_at_ms,
|
|
)
|
|
}
|
|
|
|
fn normalize_jump_hop_path_platform_size(mut path: JumpHopPath) -> JumpHopPath {
|
|
let should_scale_legacy_path = path
|
|
.platforms
|
|
.iter()
|
|
.any(|platform| platform.width < 1.2 && platform.landing_radius < 0.75);
|
|
if !should_scale_legacy_path {
|
|
if (path.scoring.charge_to_distance_ratio - JUMP_HOP_CHARGE_TO_DISTANCE_RATIO).abs()
|
|
> f32::EPSILON
|
|
{
|
|
path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
for platform in &mut path.platforms {
|
|
platform.width *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
|
platform.height *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
|
platform.landing_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
|
platform.perfect_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER;
|
|
}
|
|
path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO;
|
|
path
|
|
}
|
|
|
|
struct DifficultyConfig {
|
|
min_gap: f32,
|
|
max_gap: f32,
|
|
min_width: f32,
|
|
max_width: f32,
|
|
landing_radius_factor: f32,
|
|
perfect_radius_factor: f32,
|
|
charge_to_distance_ratio: f32,
|
|
max_charge_ms: u32,
|
|
}
|
|
|
|
const JUMP_HOP_MIN_GAP_RATIO_OF_MAX: f32 = 0.55;
|
|
|
|
fn build_platforms_until(
|
|
seed: &str,
|
|
difficulty: JumpHopDifficulty,
|
|
required_count: usize,
|
|
) -> Vec<JumpHopPlatform> {
|
|
let config = difficulty_config(difficulty);
|
|
let mut platforms = Vec::with_capacity(required_count);
|
|
let mut x = 0.0f32;
|
|
let mut y = 0.0f32;
|
|
|
|
for index in 0..required_count {
|
|
platforms.push(build_platform(seed, difficulty, index, x, y, &config));
|
|
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 direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 };
|
|
x += distance * direction;
|
|
y += distance;
|
|
}
|
|
}
|
|
|
|
platforms
|
|
}
|
|
|
|
fn build_platform(
|
|
seed: &str,
|
|
difficulty: JumpHopDifficulty,
|
|
index: usize,
|
|
x: f32,
|
|
y: f32,
|
|
config: &DifficultyConfig,
|
|
) -> JumpHopPlatform {
|
|
let mut rng = DeterministicRng::new(seed, &format!("platform:{}:{index}", difficulty.as_str()));
|
|
let tile_type = if index == 0 {
|
|
JumpHopTileType::Start
|
|
} else if index % 11 == 0 {
|
|
JumpHopTileType::Bonus
|
|
} else if index % 7 == 0 {
|
|
JumpHopTileType::Accent
|
|
} else if index % 3 == 0 {
|
|
JumpHopTileType::Target
|
|
} else {
|
|
JumpHopTileType::Normal
|
|
};
|
|
let width = rng.range_f32(config.min_width, config.max_width);
|
|
let height = width * rng.range_f32(0.88, 1.06);
|
|
let landing_radius = width * config.landing_radius_factor;
|
|
|
|
JumpHopPlatform {
|
|
platform_id: format!("jump-hop-platform-{index:05}"),
|
|
tile_type,
|
|
x,
|
|
y,
|
|
width: width * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
|
height: height * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
|
landing_radius: landing_radius * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
|
perfect_radius: landing_radius
|
|
* config.perfect_radius_factor
|
|
* JUMP_HOP_PLATFORM_SIZE_MULTIPLIER,
|
|
score_value: 1,
|
|
}
|
|
}
|
|
|
|
fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHopPath {
|
|
if path.platforms.len() >= required_count {
|
|
return path;
|
|
}
|
|
path.platforms = build_platforms_until(&path.seed, path.difficulty, required_count);
|
|
path.finish_index = u32::MAX;
|
|
path
|
|
}
|
|
|
|
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
|
|
match difficulty {
|
|
JumpHopDifficulty::Easy => DifficultyConfig {
|
|
min_gap: 1.45 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
|
|
max_gap: 1.45,
|
|
min_width: 0.9,
|
|
max_width: 1.08,
|
|
landing_radius_factor: 0.62,
|
|
perfect_radius_factor: 0.32,
|
|
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
|
max_charge_ms: 700,
|
|
},
|
|
JumpHopDifficulty::Standard => DifficultyConfig {
|
|
min_gap: 1.78 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
|
|
max_gap: 1.78,
|
|
min_width: 0.82,
|
|
max_width: 1.0,
|
|
landing_radius_factor: 0.54,
|
|
perfect_radius_factor: 0.26,
|
|
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
|
max_charge_ms: 780,
|
|
},
|
|
JumpHopDifficulty::Advanced => DifficultyConfig {
|
|
min_gap: 2.05 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
|
|
max_gap: 2.05,
|
|
min_width: 0.72,
|
|
max_width: 0.94,
|
|
landing_radius_factor: 0.48,
|
|
perfect_radius_factor: 0.22,
|
|
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
|
max_charge_ms: 860,
|
|
},
|
|
JumpHopDifficulty::Challenge => DifficultyConfig {
|
|
min_gap: 2.35 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX,
|
|
max_gap: 2.35,
|
|
min_width: 0.66,
|
|
max_width: 0.88,
|
|
landing_radius_factor: 0.42,
|
|
perfect_radius_factor: 0.18,
|
|
charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO,
|
|
max_charge_ms: 950,
|
|
},
|
|
}
|
|
}
|
|
|
|
struct DeterministicRng {
|
|
state: u64,
|
|
}
|
|
|
|
impl DeterministicRng {
|
|
fn new(seed: &str, salt: &str) -> Self {
|
|
let mut state = 0xcbf2_9ce4_8422_2325u64;
|
|
for byte in seed.bytes().chain(salt.bytes()) {
|
|
state ^= u64::from(byte);
|
|
state = state.wrapping_mul(0x1000_0000_01b3);
|
|
}
|
|
Self { state }
|
|
}
|
|
|
|
fn next_u32(&mut self) -> u32 {
|
|
self.state = self
|
|
.state
|
|
.wrapping_mul(6_364_136_223_846_793_005)
|
|
.wrapping_add(1);
|
|
(self.state >> 32) as u32
|
|
}
|
|
|
|
fn range_f32(&mut self, min: f32, max: f32) -> f32 {
|
|
if max <= min {
|
|
return min;
|
|
}
|
|
let unit = self.next_u32() as f32 / u32::MAX as f32;
|
|
min + (max - min) * unit
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::{
|
|
JumpHopDifficulty, JumpHopJumpResultKind, JumpHopPlatform, JumpHopRunStatus,
|
|
JumpHopTileType, apply_jump, generate_jump_hop_path, restart_run, start_run,
|
|
};
|
|
|
|
#[test]
|
|
fn path_generation_is_seeded_and_uses_difficulty_ranges() {
|
|
let first = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard);
|
|
let second = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard);
|
|
let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge);
|
|
|
|
assert_eq!(first, second);
|
|
assert_eq!(first.platforms.len(), 8);
|
|
assert_eq!(challenge.platforms.len(), 8);
|
|
assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start");
|
|
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);
|
|
let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard);
|
|
let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced);
|
|
let challenge =
|
|
generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge);
|
|
|
|
assert_eq!(easy.scoring.charge_to_distance_ratio, 0.004);
|
|
assert_eq!(standard.scoring.charge_to_distance_ratio, 0.004);
|
|
assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.004);
|
|
assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.004);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_platforms_use_double_size_and_landing_radius() {
|
|
let path = generate_jump_hop_path("seed-size", JumpHopDifficulty::Standard);
|
|
let first_platform = path.platforms.first().expect("platform should exist");
|
|
|
|
assert!(first_platform.width >= 1.64);
|
|
assert!(first_platform.width <= 2.0);
|
|
assert!(first_platform.height >= 1.44);
|
|
assert!(first_platform.height <= 2.12);
|
|
assert!(first_platform.landing_radius >= 0.88);
|
|
assert!(first_platform.landing_radius <= 1.08);
|
|
}
|
|
|
|
#[test]
|
|
fn start_run_normalizes_legacy_single_size_platforms() {
|
|
let mut path = generate_jump_hop_path("seed-legacy", JumpHopDifficulty::Standard);
|
|
for platform in &mut path.platforms {
|
|
platform.width /= 2.0;
|
|
platform.height /= 2.0;
|
|
platform.landing_radius /= 2.0;
|
|
platform.perfect_radius /= 2.0;
|
|
}
|
|
let legacy_width = path.platforms[0].width;
|
|
let legacy_landing_radius = path.platforms[0].landing_radius;
|
|
|
|
let run = start_run(
|
|
"run-legacy".to_string(),
|
|
"user-legacy".to_string(),
|
|
"profile-legacy".to_string(),
|
|
path,
|
|
100,
|
|
)
|
|
.expect("run should start");
|
|
|
|
assert!((run.path.platforms[0].width - legacy_width * 2.0).abs() < 0.0001);
|
|
assert!(
|
|
(run.path.platforms[0].landing_radius - legacy_landing_radius * 2.0).abs() < 0.0001
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn jump_resolution_distinguishes_hit_and_miss() {
|
|
let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy);
|
|
let run = start_run(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
path,
|
|
100,
|
|
)
|
|
.expect("run should start");
|
|
let target = &run.path.platforms[1];
|
|
let distance = target.x.hypot(target.y);
|
|
let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
|
|
|
|
let hit =
|
|
apply_jump(&run, target_charge as f32, None, None, 200).expect("jump should resolve");
|
|
assert_eq!(
|
|
hit.last_jump.as_ref().unwrap().result,
|
|
JumpHopJumpResultKind::Hit
|
|
);
|
|
assert_eq!(hit.status, JumpHopRunStatus::Playing);
|
|
assert_eq!(hit.current_platform_index, 1);
|
|
|
|
let miss = apply_jump(
|
|
&run,
|
|
target_charge.saturating_add(900) as f32,
|
|
None,
|
|
None,
|
|
200,
|
|
)
|
|
.expect("jump should resolve");
|
|
assert_eq!(miss.status, JumpHopRunStatus::Failed);
|
|
assert_eq!(
|
|
miss.last_jump.as_ref().unwrap().result,
|
|
JumpHopJumpResultKind::Miss
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
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(),
|
|
"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, Some(999.0), Some(-999.0), 200)
|
|
.expect("jump should resolve");
|
|
|
|
assert_eq!(result.status, JumpHopRunStatus::Playing);
|
|
assert_eq!(
|
|
result.last_jump.as_ref().unwrap().result,
|
|
JumpHopJumpResultKind::Hit
|
|
);
|
|
assert_eq!(result.current_platform_index, 1);
|
|
}
|
|
|
|
#[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);
|
|
assert_eq!(result.current_platform_index, 1);
|
|
assert!((last_jump.landed_x - target.x).abs() < target.landing_radius);
|
|
assert!((last_jump.landed_y - target.y).abs() < target.landing_radius);
|
|
}
|
|
|
|
#[test]
|
|
fn jump_resolution_uses_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(),
|
|
"user-footprint".to_string(),
|
|
"profile-footprint".to_string(),
|
|
path,
|
|
100,
|
|
)
|
|
.expect("run should start");
|
|
|
|
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.98);
|
|
assert!(last_hit.landed_x <= 2.0);
|
|
|
|
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);
|
|
assert_eq!(
|
|
outside.last_jump.as_ref().unwrap().result,
|
|
JumpHopJumpResultKind::Miss
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
let mut run = start_run(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
path,
|
|
100,
|
|
)
|
|
.expect("run should start");
|
|
run.status = JumpHopRunStatus::Failed;
|
|
run.current_platform_index = 3;
|
|
run.score = 300;
|
|
run.combo = 2;
|
|
run.finished_at_ms = Some(200);
|
|
|
|
let restarted = restart_run(&run, "run-2".to_string(), 300).expect("run should restart");
|
|
|
|
assert_eq!(restarted.run_id, "run-2");
|
|
assert_eq!(restarted.status, JumpHopRunStatus::Playing);
|
|
assert_eq!(restarted.current_platform_index, 0);
|
|
assert_eq!(restarted.score, 0);
|
|
assert_eq!(restarted.combo, 0);
|
|
assert!(restarted.last_jump.is_none());
|
|
assert_eq!(restarted.started_at_ms, 300);
|
|
assert!(restarted.finished_at_ms.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn successful_jump_extends_infinite_path_buffer_and_counts_jumps() {
|
|
let path = generate_jump_hop_path("seed-d", JumpHopDifficulty::Easy);
|
|
let mut run = start_run(
|
|
"run-1".to_string(),
|
|
"user-1".to_string(),
|
|
"profile-1".to_string(),
|
|
path,
|
|
100,
|
|
)
|
|
.expect("run should start");
|
|
|
|
for step in 0..9 {
|
|
let current = &run.path.platforms[run.current_platform_index as usize];
|
|
let target = &run.path.platforms[run.current_platform_index as usize + 1];
|
|
let distance = (target.x - current.x).hypot(target.y - current.y);
|
|
let charge = (distance / run.path.scoring.charge_to_distance_ratio).round() as u32;
|
|
run = apply_jump(&run, charge as f32, None, None, 200 + step)
|
|
.expect("jump should resolve");
|
|
}
|
|
|
|
assert_eq!(run.status, JumpHopRunStatus::Playing);
|
|
assert_eq!(run.current_platform_index, 9);
|
|
assert_eq!(run.score, 9);
|
|
assert!(run.path.platforms.len() >= 12);
|
|
assert!(run.finished_at_ms.is_none());
|
|
}
|
|
|
|
fn test_platform(id: &str, x: f32, y: f32, width: f32, height: f32) -> JumpHopPlatform {
|
|
JumpHopPlatform {
|
|
platform_id: id.to_string(),
|
|
tile_type: JumpHopTileType::Normal,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
landing_radius: 0.2,
|
|
perfect_radius: 0.1,
|
|
score_value: 1,
|
|
}
|
|
}
|
|
}
|