mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Predict fishing bobber block hits before client movement
This commit is contained in:
1 parent
9f8f6842db
commit
36c0d3f938
3 files changed
+102
-36
No files matched your search
@@ -349,6 +349,34 @@ impl Game<'_> {
|
||||
"vz": hook.velocity.z.to_bits() as i64, "nibble": hook.nibble, "next": hook.random.next_long(),
|
||||
}));
|
||||
}
|
||||
// The client must take the same block hit before moving as the server;
|
||||
// otherwise it lands farther away and slides back on the next update.
|
||||
let stone = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
self.level_set(block, stone).unwrap();
|
||||
for (case, motion) in [
|
||||
Vec3::new(0.2, -0.4, 0.1),
|
||||
Vec3::new(-0.2, -0.4, 0.1),
|
||||
Vec3::new(0.0, -0.6, -0.2),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
for tick in 0..5 {
|
||||
state = self.client_hook_step(state, &mut random);
|
||||
rows.push(serde_json::json!({
|
||||
"case": case, "tick": tick,
|
||||
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
|
||||
"motion": [state.1.x, state.1.y, state.1.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
self.hook = old_hook;
|
||||
self.offhand = old_offhand;
|
||||
self.player.as_mut().unwrap().position = old_position;
|
||||
@@ -874,27 +902,8 @@ impl Game<'_> {
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let entity_end = block_hit.map_or(to, |hit| hit.location);
|
||||
let block_hit = self.hook_block_hit(h.position, to);
|
||||
let entity_end = block_hit.unwrap_or(to);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
let hit = first_entity_hit(
|
||||
@@ -968,9 +977,9 @@ impl Game<'_> {
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[hit.location.x, hit.location.y, hit.location.z],
|
||||
[hit.x, hit.y, hit.z],
|
||||
);
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
h.velocity = rebound(h.velocity, hit, h.position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
@@ -1060,7 +1069,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !h.on_ground {
|
||||
h.velocity.y -= 0.03;
|
||||
h.velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -1584,6 +1593,28 @@ impl Game<'_> {
|
||||
self.remote_hooks = hooks;
|
||||
}
|
||||
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
self.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?
|
||||
.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|hit| hit.location)
|
||||
}
|
||||
|
||||
/// `FishingHook.tick`: one tick of movement without the fishing (the
|
||||
/// server's): position, motion, state and whether it rests on the ground.
|
||||
fn client_hook_step(
|
||||
@@ -1608,7 +1639,11 @@ impl Game<'_> {
|
||||
State::Flying if velocity == Vec3::ZERO && on_ground => {
|
||||
return (position, velocity, state, on_ground);
|
||||
}
|
||||
State::Flying => {}
|
||||
State::Flying => {
|
||||
if let Some(hit) = self.hook_block_hit(position, position.add_vector(velocity)) {
|
||||
velocity = rebound(velocity, hit, position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: at 0.8 of the hooked entity's height.
|
||||
let hooked = if self
|
||||
@@ -1661,7 +1696,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !on_ground {
|
||||
velocity.y -= 0.03;
|
||||
velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
|
||||
@@ -345,6 +345,31 @@ public final class VanillaCommandExecOracle {
|
||||
bobs.add(row);
|
||||
hook.discard();
|
||||
}
|
||||
// Dry block hits run on both sides: compare the client's movement
|
||||
// prediction with the real hook before and after touching the floor.
|
||||
level.setBlockAndUpdate(waterPos, net.minecraft.world.level.block.Blocks.STONE.defaultBlockState());
|
||||
double[][] motions = {{0.2, -0.4, 0.1}, {-0.2, -0.4, 0.1}, {0.0, -0.6, -0.2}};
|
||||
for (int c = 0; c < motions.length; c++) {
|
||||
var hook = new net.minecraft.world.entity.projectile.FishingHook(player, level, 0, 0);
|
||||
hook.setPos(0.5, 301.1, 0.5);
|
||||
hook.setDeltaMovement(motions[c][0], motions[c][1], motions[c][2]);
|
||||
for (int tick = 0; tick < 5; tick++) {
|
||||
hook.tick();
|
||||
var at = hook.position();
|
||||
var motion = hook.getDeltaMovement();
|
||||
JsonObject row = new JsonObject();
|
||||
row.addProperty("case", c);
|
||||
row.addProperty("tick", tick);
|
||||
JsonArray position = new JsonArray();
|
||||
position.add(at.x); position.add(at.y); position.add(at.z);
|
||||
JsonArray movement = new JsonArray();
|
||||
movement.add(motion.x); movement.add(motion.y); movement.add(motion.z);
|
||||
row.add("position", position);
|
||||
row.add("motion", movement);
|
||||
bobs.add(row);
|
||||
}
|
||||
hook.discard();
|
||||
}
|
||||
player.snapTo(oldPos.x, oldPos.y, oldPos.z, originalYaw, originalPitch);
|
||||
level.setBlockAndUpdate(waterPos, oldBlock);
|
||||
Files.writeString(output.resolveSibling("fishing-bobbing-reference.json"), bobs.toString());
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare fishing retrieval against a real 26.3 server and player.
|
||||
"""Compare fishing movement and retrieval against a real 26.3 server and player.
|
||||
|
||||
The Java side calls FishingHook.retrieve with seeded hook and table sequences.
|
||||
The Rust side compares seeded cast velocity, a bobbing tick, catch-timer
|
||||
The Rust side compares seeded cast velocity, bobbing and dry-ground ticks, catch-timer
|
||||
transitions and the rod's fallback loot path, plus named-sequence loot and XP.
|
||||
It does not yet cover the UUID-to-seed relationship.
|
||||
"""
|
||||
@@ -49,13 +49,19 @@ def main():
|
||||
raise SystemExit(f"fishing tick record count differs: {len(reference)} vs {len(actual)}")
|
||||
expected_bobbing = json.loads((work / "fishing-bobbing-reference.json").read_text())
|
||||
actual_bobbing = json.loads((work / "rust-fishing-bobbing.json").read_text())
|
||||
if len(expected_bobbing) != 5 or len(actual_bobbing) != 5:
|
||||
raise SystemExit(f"expected 5 bobbing cases: {len(expected_bobbing)} vs {len(actual_bobbing)}")
|
||||
if expected_bobbing != actual_bobbing:
|
||||
for expected, actual in zip(expected_bobbing, actual_bobbing):
|
||||
if expected != actual:
|
||||
raise SystemExit(f"fishing bobbing differs: vanilla {expected}; Horizon {actual}")
|
||||
raise SystemExit(f"fishing bobbing count differs: {len(expected_bobbing)} vs {len(actual_bobbing)}")
|
||||
if len(expected_bobbing) != 20 or len(actual_bobbing) != 20:
|
||||
raise SystemExit(f"expected 20 bobbing and ground cases: {len(expected_bobbing)} vs {len(actual_bobbing)}")
|
||||
for expected, actual in zip(expected_bobbing, actual_bobbing):
|
||||
if "case" in expected:
|
||||
# Collision clipping can differ below a picometre; retain exact
|
||||
# case/tick matching and compare all three positions and velocities.
|
||||
if expected["case"] != actual.get("case") or expected["tick"] != actual.get("tick"):
|
||||
raise SystemExit(f"fishing ground case differs: {expected}; {actual}")
|
||||
for field in ("position", "motion"):
|
||||
if len(actual[field]) != 3 or any(not abs(a - b) <= 1e-12 for a, b in zip(expected[field], actual[field])):
|
||||
raise SystemExit(f"fishing ground differs: vanilla {expected}; Horizon {actual}")
|
||||
elif expected != actual:
|
||||
raise SystemExit(f"fishing bobbing differs: vanilla {expected}; Horizon {actual}")
|
||||
fallback = work / "fallback"
|
||||
fallback_env = env.copy()
|
||||
fallback_env.pop("HORIZON_FISHING_TICK_REFERENCE", None)
|
||||
@@ -81,7 +87,7 @@ def main():
|
||||
subprocess.run(["cargo", "test", "--profile", "fast", "-p", "horizon-client", "--lib",
|
||||
"game::fishing::tests::fishing_retrieve_reference", "--", "--exact", "--ignored"],
|
||||
cwd=lab.ROOT, env=env, check=True)
|
||||
print("fishing: seeded catch timers, loot and XP match vanilla")
|
||||
print("fishing: ground prediction, bobbing, seeded catch timers, loot and XP match vanilla")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user