MVP checks / mvp (push) Canceled after 0s
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
625 lines
17 KiB
Rust
625 lines
17 KiB
Rust
use shacraft_physics::{
|
|
Aabb, CollisionBlock, Controls, PhysicsBody, PhysicsSettings, PhysicsWorld, Pose, step,
|
|
};
|
|
|
|
fn block(pos: [i32; 3], state: &str, collision: Vec<Aabb>) -> CollisionBlock {
|
|
CollisionBlock {
|
|
pos,
|
|
state: format!("minecraft:{state}"),
|
|
collision,
|
|
}
|
|
}
|
|
fn floor(state: &str) -> PhysicsWorld {
|
|
let mut world = PhysicsWorld::default();
|
|
for x in -12..=12 {
|
|
for z in -60..=60 {
|
|
world
|
|
.blocks
|
|
.push(block([x, -1, z], state, vec![Aabb::unit()]));
|
|
}
|
|
}
|
|
world
|
|
}
|
|
fn player() -> PhysicsBody {
|
|
PhysicsBody {
|
|
position: [0.5, 0., 0.5],
|
|
velocity: [0., -0.0784000015258789, 0.],
|
|
on_ground: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
fn run(body: &mut PhysicsBody, input: &Controls, world: &PhysicsWorld, ticks: usize) {
|
|
for _ in 0..ticks {
|
|
step(body, input, world, &PhysicsSettings::default());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn original_java26_2_travel_kernel_trajectories() {
|
|
let fixture: serde_json::Value =
|
|
serde_json::from_str(include_str!("fixtures/java26.2.json")).unwrap();
|
|
for (case, trajectory) in fixture["travel_kernel_trajectories"].as_object().unwrap() {
|
|
let medium = trajectory["medium"].as_str().unwrap_or("air");
|
|
// A fixed shallow-fluid depth is a kernel-only harness condition;
|
|
// the integrated world correctly changes depth as a player falls.
|
|
if case == "lava_shallow_move_stop" {
|
|
continue;
|
|
}
|
|
let world = if medium == "air" {
|
|
floor(trajectory["surface"].as_str().unwrap())
|
|
} else {
|
|
let mut world = liquid_world(&format!("{medium}[level=0]"));
|
|
for block in &mut world.blocks {
|
|
block.pos[1] += 10;
|
|
}
|
|
world
|
|
};
|
|
let mut body = PhysicsBody {
|
|
position: serde_json::from_value(trajectory["initial_position"].clone()).unwrap(),
|
|
velocity: serde_json::from_value(trajectory["initial_velocity"].clone()).unwrap(),
|
|
flying: trajectory["flying"].as_bool().unwrap_or(false),
|
|
on_ground: medium == "air" && !trajectory["flying"].as_bool().unwrap_or(false),
|
|
..Default::default()
|
|
};
|
|
let input_ticks = trajectory["input_ticks"].as_u64().unwrap();
|
|
for sample in trajectory["samples"].as_array().unwrap() {
|
|
let tick = sample["tick"].as_u64().unwrap();
|
|
// Kernel fixture keeps sprint asserted after input release; the
|
|
// integrated player controller ends swimming at release instead.
|
|
if medium == "water" && trajectory["sprint"].as_bool().unwrap() && tick > input_ticks {
|
|
break;
|
|
}
|
|
let input = Controls {
|
|
forward: if tick <= input_ticks { 1. } else { 0. },
|
|
sprint: trajectory["sprint"].as_bool().unwrap(),
|
|
jump: tick == 1 && trajectory["jump_first_tick"].as_bool().unwrap(),
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut body,
|
|
&input,
|
|
&world,
|
|
&PhysicsSettings {
|
|
allow_flight: true,
|
|
..Default::default()
|
|
},
|
|
);
|
|
// Java yaw zero faces +Z; Shacraft yaw zero faces -Z.
|
|
for (field, actual) in [("position", body.position), ("velocity", body.velocity)] {
|
|
for axis in 0..3 {
|
|
let expected =
|
|
sample[field][axis].as_f64().unwrap() * if axis == 2 { -1. } else { 1. };
|
|
assert!(
|
|
(actual[axis] - expected).abs() < 2.0e-6,
|
|
"{case} tick {tick}: {field}[{axis}] actual {} expected {expected}",
|
|
actual[axis]
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
body.on_ground,
|
|
sample["on_ground"].as_bool().unwrap(),
|
|
"{case} tick {tick}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_cube_stops_player_but_half_slab_is_stepped_without_jump() {
|
|
let mut wall = floor("stone");
|
|
wall.blocks
|
|
.push(block([0, 0, -1], "stone", vec![Aabb::unit()]));
|
|
let mut blocked = player();
|
|
run(
|
|
&mut blocked,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&wall,
|
|
20,
|
|
);
|
|
assert!((blocked.position[2] - blocked.width() / 2.).abs() < 1.0e-6);
|
|
assert_eq!(blocked.position[1], 0.);
|
|
wall.blocks.last_mut().unwrap().collision[0].max[1] = 0.5;
|
|
let mut climbed = player();
|
|
run(
|
|
&mut climbed,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&wall,
|
|
5,
|
|
);
|
|
assert!(climbed.position[2] < 0. && climbed.position[1] >= 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn step_up_preserves_clearance_under_low_ceiling() {
|
|
let mut world = floor("stone");
|
|
world.blocks.push(block(
|
|
[0, 0, -1],
|
|
"stone_slab[type=bottom]",
|
|
vec![Aabb::new([0.; 3], [1., 0.5, 1.])],
|
|
));
|
|
world
|
|
.blocks
|
|
.push(block([0, 2, -1], "stone", vec![Aabb::unit()]));
|
|
let mut body = player();
|
|
run(
|
|
&mut body,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
12,
|
|
);
|
|
assert!(
|
|
body.position[2] >= body.width() / 2. - 1.0e-6,
|
|
"standing body must not step into a ceiling"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sneaking_keeps_support_at_straight_and_diagonal_edges() {
|
|
let world = PhysicsWorld {
|
|
blocks: vec![block([0, -1, 0], "stone", vec![Aabb::unit()])],
|
|
};
|
|
for strafe in [0., 1.] {
|
|
let mut body = player();
|
|
run(
|
|
&mut body,
|
|
&Controls {
|
|
forward: 1.,
|
|
strafe,
|
|
sneak: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
120,
|
|
);
|
|
assert_eq!(body.position[1], 0.);
|
|
assert!(body.position[2] > -body.width() / 2.);
|
|
assert!(body.position[0] < 1. + body.width() / 2.);
|
|
assert_eq!(body.pose, Pose::Crouching);
|
|
}
|
|
let mut walking = player();
|
|
run(
|
|
&mut walking,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
20,
|
|
);
|
|
assert!(walking.position[1] < -1.);
|
|
}
|
|
|
|
#[test]
|
|
fn crawl_pose_persists_until_there_is_headroom() {
|
|
let mut world = floor("stone");
|
|
world
|
|
.blocks
|
|
.push(block([0, 1, 0], "stone", vec![Aabb::unit()]));
|
|
let mut body = PhysicsBody {
|
|
pose: Pose::Swimming,
|
|
..player()
|
|
};
|
|
step(
|
|
&mut body,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert_eq!(body.pose, Pose::Swimming);
|
|
world.blocks.pop();
|
|
step(
|
|
&mut body,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert_eq!(body.pose, Pose::Standing);
|
|
}
|
|
|
|
#[test]
|
|
fn diagonal_input_is_normalized_and_air_control_is_weaker() {
|
|
let world = floor("stone");
|
|
let mut straight = player();
|
|
let mut diagonal = player();
|
|
run(
|
|
&mut straight,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
20,
|
|
);
|
|
run(
|
|
&mut diagonal,
|
|
&Controls {
|
|
forward: 1.,
|
|
strafe: 1.,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
20,
|
|
);
|
|
let a = (straight.position[0] - 0.5).hypot(straight.position[2] - 0.5);
|
|
let b = (diagonal.position[0] - 0.5).hypot(diagonal.position[2] - 0.5);
|
|
assert!(
|
|
(b / a - 1. / 0.98).abs() < 1.0e-6,
|
|
"vanilla normalizes a diagonal after multiplying input by .98"
|
|
);
|
|
let mut air = PhysicsBody {
|
|
position: [0., 20., 0.],
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut air,
|
|
&Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!((air.position[2] + 0.019600000381469584).abs() < 1.0e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn held_jump_has_ten_tick_cooldown_and_release_rearms() {
|
|
let world = floor("stone");
|
|
let mut body = player();
|
|
let jump = Controls {
|
|
jump: true,
|
|
..Default::default()
|
|
};
|
|
step(&mut body, &jump, &world, &PhysicsSettings::default());
|
|
assert_eq!(body.jump_cooldown, 10);
|
|
body.position[1] = 0.;
|
|
body.velocity = [0., -0.08, 0.];
|
|
body.on_ground = true;
|
|
step(&mut body, &jump, &world, &PhysicsSettings::default());
|
|
assert_eq!(body.position[1], 0.);
|
|
step(
|
|
&mut body,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
step(&mut body, &jump, &world, &PhysicsSettings::default());
|
|
assert!(body.position[1] > 0.4);
|
|
}
|
|
|
|
fn liquid_world(state: &str) -> PhysicsWorld {
|
|
let mut world = PhysicsWorld::default();
|
|
for x in -3..=3 {
|
|
for z in -12..=3 {
|
|
for y in -4..=4 {
|
|
world.blocks.push(block([x, y, z], state, vec![]));
|
|
}
|
|
}
|
|
}
|
|
world
|
|
}
|
|
#[test]
|
|
fn water_swimming_lava_and_waterlogged_cells_apply_distinct_motion() {
|
|
let mut water = PhysicsBody {
|
|
position: [0.5, 0., 0.5],
|
|
..Default::default()
|
|
};
|
|
let mut lava = water.clone();
|
|
let input = Controls {
|
|
forward: 1.,
|
|
..Default::default()
|
|
};
|
|
run(&mut water, &input, &liquid_world("water[level=0]"), 20);
|
|
run(&mut lava, &input, &liquid_world("lava[level=0]"), 20);
|
|
assert!(water.in_water && !water.in_lava && lava.in_lava);
|
|
assert!(water.position[2] < lava.position[2]);
|
|
assert!(water.velocity[1] > lava.velocity[1]);
|
|
let mut swimmer = PhysicsBody {
|
|
position: [0.5, 0., 0.5],
|
|
..Default::default()
|
|
};
|
|
run(
|
|
&mut swimmer,
|
|
&Controls {
|
|
sprint: true,
|
|
forward: 1.,
|
|
pitch: 0.6,
|
|
..Default::default()
|
|
},
|
|
&liquid_world("water[level=0]"),
|
|
10,
|
|
);
|
|
assert!(swimmer.swimming && swimmer.pose == Pose::Swimming);
|
|
assert!(swimmer.position[1] > 0.);
|
|
let mut logged = PhysicsBody {
|
|
position: [0.5, 0., 0.5],
|
|
..Default::default()
|
|
};
|
|
run(
|
|
&mut logged,
|
|
&input,
|
|
&liquid_world("oak_sign[waterlogged=true]"),
|
|
2,
|
|
);
|
|
assert!(logged.in_water);
|
|
}
|
|
#[test]
|
|
fn climbable_clamps_fall_and_jump_climbs() {
|
|
let world = liquid_world("ladder[facing=north]");
|
|
let mut body = PhysicsBody {
|
|
position: [0.5, 2., 0.5],
|
|
velocity: [0., -2., 0.],
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut body,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!(body.on_climbable && body.position[1] > 1.84);
|
|
let height = body.position[1];
|
|
run(
|
|
&mut body,
|
|
&Controls {
|
|
jump: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
5,
|
|
);
|
|
assert!(body.position[1] > height);
|
|
let height = body.position[1];
|
|
body.velocity[1] = -0.1;
|
|
run(
|
|
&mut body,
|
|
&Controls {
|
|
sneak: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
5,
|
|
);
|
|
assert!((body.position[1] - height).abs() < 1.0e-6);
|
|
}
|
|
#[test]
|
|
fn slime_bounces_without_sneak_and_honey_limits_jump() {
|
|
let world = floor("slime_block");
|
|
let mut body = PhysicsBody {
|
|
position: [0.5, 0.1, 0.5],
|
|
velocity: [0., -0.9, 0.],
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut body,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!(body.velocity[1] > 0.7);
|
|
let mut sneaking = PhysicsBody {
|
|
position: [0.5, 0.1, 0.5],
|
|
velocity: [0., -0.9, 0.],
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut sneaking,
|
|
&Controls {
|
|
sneak: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!(sneaking.velocity[1] < 0.);
|
|
let mut honey = player();
|
|
step(
|
|
&mut honey,
|
|
&Controls {
|
|
jump: true,
|
|
..Default::default()
|
|
},
|
|
&floor("honey_block"),
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!((honey.position[1] - 0.21).abs() < 1.0e-6);
|
|
}
|
|
#[test]
|
|
fn cobweb_slows_falling_and_bubble_column_lifts() {
|
|
let mut web = PhysicsBody {
|
|
position: [0.5, 2., 0.5],
|
|
velocity: [0., -1., 0.],
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut web,
|
|
&Controls::default(),
|
|
&liquid_world("cobweb"),
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!((web.position[1] - 1.95).abs() < 1.0e-6);
|
|
let mut bubble = PhysicsBody {
|
|
position: [0.5, 0., 0.5],
|
|
..Default::default()
|
|
};
|
|
run(
|
|
&mut bubble,
|
|
&Controls::default(),
|
|
&liquid_world("bubble_column[drag=false]"),
|
|
10,
|
|
);
|
|
assert!(bubble.position[1] > 0.5 && bubble.velocity[1] > 0.);
|
|
}
|
|
#[test]
|
|
fn powder_snow_and_scaffolding_have_entity_sensitive_support() {
|
|
for material in ["powder_snow", "scaffolding"] {
|
|
let world = PhysicsWorld {
|
|
blocks: vec![block([0, -1, 0], material, vec![Aabb::unit()])],
|
|
};
|
|
let mut body = player();
|
|
let settings = PhysicsSettings {
|
|
leather_boots: true,
|
|
..Default::default()
|
|
};
|
|
step(&mut body, &Controls::default(), &world, &settings);
|
|
assert_eq!(body.position[1], 0., "{material}");
|
|
run(
|
|
&mut body,
|
|
&Controls {
|
|
sneak: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
5,
|
|
);
|
|
assert!(body.position[1] < 0., "{material}");
|
|
}
|
|
}
|
|
#[test]
|
|
fn flight_is_authorized_and_frozen_body_does_not_move() {
|
|
let mut denied = player();
|
|
step(
|
|
&mut denied,
|
|
&Controls {
|
|
fly_toggle: true,
|
|
..Default::default()
|
|
},
|
|
&PhysicsWorld::default(),
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!(!denied.flying);
|
|
let mut flight = player();
|
|
let settings = PhysicsSettings {
|
|
allow_flight: true,
|
|
..Default::default()
|
|
};
|
|
step(
|
|
&mut flight,
|
|
&Controls {
|
|
fly_toggle: true,
|
|
jump: true,
|
|
..Default::default()
|
|
},
|
|
&PhysicsWorld::default(),
|
|
&settings,
|
|
);
|
|
assert!(flight.flying && flight.position[1] > 0.);
|
|
let position = flight.position;
|
|
step(
|
|
&mut flight,
|
|
&Controls {
|
|
jump: true,
|
|
forward: 1.,
|
|
..Default::default()
|
|
},
|
|
&PhysicsWorld::default(),
|
|
&PhysicsSettings {
|
|
frozen: true,
|
|
..settings
|
|
},
|
|
);
|
|
assert_eq!(flight.position, position);
|
|
assert_eq!(flight.velocity, [0.; 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn fluid_and_callback_results_do_not_depend_on_block_serialization_order() {
|
|
let mut world = PhysicsWorld {
|
|
blocks: vec![
|
|
block([0, 0, 0], "water[level=5]", vec![]),
|
|
block([1, 0, 0], "water[level=7]", vec![]),
|
|
block([0, 0, 1], "water[level=2]", vec![]),
|
|
block([1, 0, 1], "water[level=6]", vec![]),
|
|
block([-1, 0, 0], "water[level=0]", vec![]),
|
|
],
|
|
};
|
|
let start = PhysicsBody {
|
|
position: [0.95, 0., 0.95],
|
|
..Default::default()
|
|
};
|
|
let mut forward = start.clone();
|
|
step(
|
|
&mut forward,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
world.blocks.reverse();
|
|
let mut reversed = start;
|
|
step(
|
|
&mut reversed,
|
|
&Controls::default(),
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert_eq!(forward.position, reversed.position);
|
|
assert_eq!(forward.velocity, reversed.velocity);
|
|
assert!(
|
|
forward.velocity[0].abs() + forward.velocity[2].abs() > 0.,
|
|
"fixture must exercise current pushing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shallow_grounded_water_jump_uses_the_normal_jump_impulse() {
|
|
let mut world = floor("stone");
|
|
world
|
|
.blocks
|
|
.push(block([0, 0, 0], "water[level=7]", vec![]));
|
|
let mut body = player();
|
|
step(
|
|
&mut body,
|
|
&Controls {
|
|
jump: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
&PhysicsSettings::default(),
|
|
);
|
|
assert!((body.position[1] - 0.41999998688697815).abs() < 1.0e-12);
|
|
assert_eq!(body.jump_cooldown, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn ground_flight_shortcut_hovers_and_landing_still_cancels_flight() {
|
|
let world = floor("stone");
|
|
let settings = PhysicsSettings {
|
|
allow_flight: true,
|
|
..Default::default()
|
|
};
|
|
let mut body = player();
|
|
step(&mut body, &Controls::default(), &world, &settings);
|
|
assert!(body.on_ground && body.velocity[1] < 0.);
|
|
step(
|
|
&mut body,
|
|
&Controls {
|
|
fly_toggle: true,
|
|
..Default::default()
|
|
},
|
|
&world,
|
|
&settings,
|
|
);
|
|
assert!(body.flying && !body.on_ground);
|
|
for _ in 0..5 {
|
|
step(&mut body, &Controls::default(), &world, &settings);
|
|
assert!(body.flying && !body.on_ground);
|
|
assert_eq!(body.position[1], 0.);
|
|
assert_eq!(body.velocity[1], 0.);
|
|
}
|
|
body.position[1] = 0.1;
|
|
body.velocity[1] = -0.2;
|
|
step(&mut body, &Controls::default(), &world, &settings);
|
|
assert!(body.on_ground && !body.flying);
|
|
assert_eq!(body.position[1], 0.);
|
|
}
|