mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Merge pull request #9993 from MSedgeMC/fix/prediction-snap
A refused block change that comes back inside the player snaps it out
This commit is contained in:
2 files changed
+172
-18
No files matched your search
@@ -548,6 +548,9 @@ pub(super) struct Remote {
|
||||
/// `BlockStatePredictionHandler.currentSequenceNr`: the level's, so a new level (a login,
|
||||
/// another dimension) counts from 0 again.
|
||||
sequence: i32,
|
||||
/// `BlockStatePredictionHandler.lastTeleportSequence`: the sequence of the last
|
||||
/// teleport, so a prediction made before it keeps its block but not its snap.
|
||||
last_teleport_sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
last_input: u8,
|
||||
was_sprinting: bool,
|
||||
@@ -573,10 +576,10 @@ pub(super) struct Remote {
|
||||
/// again when a view distance, Chat, skin or Main Hand option changes).
|
||||
sent_info: horizon_protocol::packets::configuration::ClientInformation,
|
||||
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
|
||||
/// with the sequence of the last action that changed them and the state the server
|
||||
/// last said they have. Until the server acknowledges that sequence its block updates
|
||||
/// only replace the remembered state; then the block takes it.
|
||||
predictions: HashMap<[i32; 3], (i32, i32)>,
|
||||
/// with what it remembers of each of them. Until the server acknowledges the
|
||||
/// sequence its block updates only replace the remembered state; then the block
|
||||
/// takes it.
|
||||
predictions: HashMap<[i32; 3], Prediction>,
|
||||
/// Open container: (window id, menu name).
|
||||
pub(super) menu: Option<(i32, String)>,
|
||||
/// The crafting result slot of the open menu as the server last set it
|
||||
@@ -1117,6 +1120,7 @@ impl Game<'_> {
|
||||
view_distance: self.settings.render_distance,
|
||||
simulation_distance: self.settings.simulation_distance,
|
||||
sequence: 0,
|
||||
last_teleport_sequence: -1,
|
||||
position_sender: Default::default(),
|
||||
last_input: 0,
|
||||
was_sprinting: false,
|
||||
@@ -2620,12 +2624,12 @@ impl Game<'_> {
|
||||
/// predicted state until the server acknowledges the action; the server's state is
|
||||
/// remembered for then.
|
||||
fn server_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
if let Some((_, known)) = self
|
||||
if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.predictions.get_mut(&pos))
|
||||
{
|
||||
*known = state;
|
||||
p.state = state;
|
||||
return;
|
||||
}
|
||||
self.set_block(pos, state);
|
||||
@@ -2637,21 +2641,82 @@ impl Game<'_> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let last_teleport = r.last_teleport_sequence;
|
||||
let settled: Vec<_> = r
|
||||
.predictions
|
||||
.iter()
|
||||
.filter(|(_, (s, _))| *s <= sequence)
|
||||
.map(|(&pos, &(_, state))| (pos, state))
|
||||
.filter(|(_, p)| p.sequence <= sequence)
|
||||
.map(|(&pos, &p)| (pos, p))
|
||||
.collect();
|
||||
for (pos, state) in settled {
|
||||
for (pos, prediction) in settled {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions.remove(&pos);
|
||||
}
|
||||
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
|
||||
self.set_block(pos, state);
|
||||
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 == prediction.state {
|
||||
continue;
|
||||
}
|
||||
self.set_block(pos, prediction.state);
|
||||
// ClientLevel.syncBlockState: the block that comes back may now hold
|
||||
// the player, and then it goes to the position the prediction
|
||||
// remembered.
|
||||
let meets = self.block_meets_player(pos, prediction.state);
|
||||
if let Some(at) = snap_target(prediction.player_pos, last_teleport, sequence, meets) {
|
||||
self.snap_player_to(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The player's position, for `retainKnownServerState`'s `player.position()`.
|
||||
fn player_position(&self) -> [f64; 3] {
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| p.position)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
[at.x, at.y, at.z]
|
||||
}
|
||||
/// `Entity.isColliding(pos, state)` for the local player: the state's own
|
||||
/// collision shape, at the player's pose, against the player's box.
|
||||
fn block_meets_player(&self, pos: [i32; 3], state: i32) -> bool {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let entity = player.bounds();
|
||||
let context = horizon_world::collision_context::CollisionContext::for_entity(
|
||||
horizon_world::collision_context::CollisionEntity {
|
||||
identity: horizon_world::entity_obstruction::EntityIdentity(0),
|
||||
y: player.position.y,
|
||||
descending: player.shift_key_down,
|
||||
main_hand: None,
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
match self.collision.collision_shape_with_context(
|
||||
&mut scene,
|
||||
BlockStateId(state.max(0) as u32),
|
||||
at,
|
||||
&context,
|
||||
) {
|
||||
Ok(shape) => shape_meets(&shape, pos, entity),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
/// `Entity.absSnapTo`: the remembered point, clamped to the level's edge.
|
||||
/// The velocity is left alone, and the player has no separate previous
|
||||
/// position to reset (its box is drawn from `position`).
|
||||
fn snap_player_to(&mut self, at: [f64; 3]) {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let x = at[0].clamp(-3.0e7, 3.0e7);
|
||||
let z = at[2].clamp(-3.0e7, 3.0e7);
|
||||
player.position = Vec3::new(x, at[1], z);
|
||||
}
|
||||
fn set_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
// A state we don't have (a server with mods we lack) reads as air, as in its chunks.
|
||||
let state = state.max(0) as u32;
|
||||
@@ -5984,6 +6049,11 @@ impl Game<'_> {
|
||||
(yaw, pitch),
|
||||
p,
|
||||
);
|
||||
// BlockStatePredictionHandler.onTeleport: a prediction made before this
|
||||
// one keeps its block but not the player position it remembered.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.last_teleport_sequence = r.sequence;
|
||||
}
|
||||
match self.player.as_mut() {
|
||||
Some(player) => {
|
||||
player.position = position;
|
||||
@@ -7685,11 +7755,16 @@ impl Game<'_> {
|
||||
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
|
||||
self.tutorial_destroy(state, 1.0);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
let here = self.player_position();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|p| p.0 = sequence)
|
||||
.or_insert((sequence, state.0 as i32));
|
||||
.and_modify(|p| p.sequence = sequence)
|
||||
.or_insert(Prediction {
|
||||
sequence,
|
||||
state: state.0 as i32,
|
||||
player_pos: here,
|
||||
});
|
||||
}
|
||||
// Block.playerWillDestroy's destroy event (particles and the break
|
||||
// sound), which BaseFireBlock skips: the server's extinguish sound
|
||||
@@ -8430,11 +8505,16 @@ impl Game<'_> {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
let here = self.player_position();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
.entry(pos)
|
||||
.and_modify(|e| e.0 = seq)
|
||||
.or_insert((seq, before.0 as i32));
|
||||
.and_modify(|e| e.sequence = seq)
|
||||
.or_insert(Prediction {
|
||||
sequence: seq,
|
||||
state: before.0 as i32,
|
||||
player_pos: here,
|
||||
});
|
||||
}
|
||||
self.set_block(pos, state.0 as i32);
|
||||
}
|
||||
@@ -11113,6 +11193,45 @@ fn hold_server_fire(fire_ticks: i32, server_on_fire: bool) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockStatePredictionHandler.ServerVerifiedState`: the sequence of the action
|
||||
/// that last changed the block, the state the server last said it has, and where
|
||||
/// the player stood when this client first predicted a change there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Prediction {
|
||||
pub sequence: i32,
|
||||
pub state: i32,
|
||||
pub player_pos: [f64; 3],
|
||||
}
|
||||
|
||||
/// `Entity.isColliding`: whether the collision `shape` of a block at `pos`
|
||||
/// meets `entity`'s box. Shapes are block-local, so they move onto the block
|
||||
/// first (`VoxelShape.move`), and any box that overlaps is a collision
|
||||
/// (`Shapes.joinIsNotEmpty` with `BooleanOp.AND`).
|
||||
pub(super) fn shape_meets(
|
||||
shape: &horizon_core::geometry::VoxelShape,
|
||||
pos: [i32; 3],
|
||||
entity: horizon_core::geometry::Aabb,
|
||||
) -> bool {
|
||||
let at = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
shape
|
||||
.boxes()
|
||||
.iter()
|
||||
.any(|b| b.move_by(at).intersects(entity))
|
||||
}
|
||||
|
||||
/// `ClientLevel.syncBlockState`'s snap: a restored block that meets the player
|
||||
/// sends it back to the position the prediction remembered, unless the player
|
||||
/// was teleported after that prediction was made
|
||||
/// (`lastTeleportSequence < sequence`: the remembered position is stale).
|
||||
pub(super) fn snap_target(
|
||||
recorded: [f64; 3],
|
||||
last_teleport_sequence: i32,
|
||||
sequence: i32,
|
||||
meets: bool,
|
||||
) -> Option<[f64; 3]> {
|
||||
(meets && last_teleport_sequence < sequence).then_some(recorded)
|
||||
}
|
||||
|
||||
/// [`Remote::has_level`]: logged in (a Login arrived) and no reconfiguration
|
||||
/// since (`reconfiguring` is set by StartConfiguration, cleared by the next
|
||||
/// Login).
|
||||
@@ -11124,8 +11243,8 @@ fn client_has_level(logged_in: bool, reconfiguring: Option<u64>) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
boat_interact_passes, client_has_level, hold_server_fire, item_frame_interact_passes,
|
||||
keep_client_state, open_hash_order, remote_object_swings, remote_use_swings,
|
||||
synced_local_use,
|
||||
keep_client_state, open_hash_order, remote_object_swings, remote_use_swings, shape_meets,
|
||||
snap_target, synced_local_use,
|
||||
};
|
||||
|
||||
/// The vanilla 26.3 client's ServerboundContainerClickPacket for a shift-click from hotbar slot 37
|
||||
@@ -11368,6 +11487,41 @@ mod tests {
|
||||
assert!(game.native_send_chat("/warp spawn").is_err());
|
||||
}
|
||||
|
||||
/// `Entity.isColliding` and the snap `ClientLevel.syncBlockState` makes: a
|
||||
/// block the server puts back where the player stands sends it to the
|
||||
/// position the prediction remembered, and a block that comes back clear of
|
||||
/// the player, an empty one, or one the player has been teleported since
|
||||
/// leaves it where it is.
|
||||
#[test]
|
||||
fn a_block_the_server_puts_back_snaps_the_player_out_of_it() {
|
||||
use horizon_core::geometry::{Aabb, Vec3, VoxelShape};
|
||||
// A player standing in block (0, 64, 0): 0.6 wide, 1.8 tall.
|
||||
let player = Aabb::new(Vec3::new(0.2, 64.0, 0.2), Vec3::new(0.8, 65.8, 0.8));
|
||||
let cube = VoxelShape::block();
|
||||
assert!(
|
||||
shape_meets(&cube, [0, 64, 0], player),
|
||||
"the block the player is standing in"
|
||||
);
|
||||
assert!(!shape_meets(&cube, [0, 66, 0], player), "above its head");
|
||||
assert!(
|
||||
!shape_meets(&cube, [1, 64, 0], player),
|
||||
"the block beside it"
|
||||
);
|
||||
assert!(!shape_meets(&VoxelShape::empty(), [0, 64, 0], player));
|
||||
|
||||
// The snap itself: the remembered position, unless a teleport came after
|
||||
// the prediction (`lastTeleportSequence < sequence`).
|
||||
let at = [0.5, 64.0, 0.5];
|
||||
assert_eq!(snap_target(at, -1, 3, true), Some(at));
|
||||
assert_eq!(snap_target(at, -1, 3, false), None, "a block it clears");
|
||||
assert_eq!(snap_target(at, 3, 3, true), None, "teleported at 3");
|
||||
assert_eq!(
|
||||
snap_target(at, 2, 3, true),
|
||||
Some(at),
|
||||
"teleported before it"
|
||||
);
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
|
||||
/// swing, CONSUME's do not (#6425).
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
|
||||
Entity.get port crates/horizon-client/src/game/advancements.rs::Game::mob_entity mob predicate projection: live custom_name Text and the existing type-variant component; gap: custom_data is not projected
|
||||
Entity.getMovementEmission port crates/horizon-client/src/mobs/ai.rs::movement_sounds EVENTS for silverfish, endermite, bats, squids, glow squids, guardians, elder guardians and breezes (game events, no sounds), NONE for a shulker (ai.rs::emits_movement), ALL for every other type
|
||||
Entity.isColliding missing minor: a block change that a server refuses and that comes back inside the player does not snap the player to the position of the prediction (end_predictions only restores the block)
|
||||
Entity.isColliding port crates/horizon-client/src/game/remote.rs::Game::block_meets_player the state's collision shape at the player's pose against the player's box; `end_predictions` asks it for a restored block and snaps the player to the position the prediction remembered (`snap_target`, guarded by `onTeleport`)
|
||||
Entity.canInteractWithLevel inline crates/horizon-client/src/mobs/leash.rs::tick gap: minor: only the leashed mob's life is tested (a dead one drops its lead); a dead or spectating holder is not tested
|
||||
Entity.unRide inline crates/horizon-client/src/game/consume.rs::Game::stop_riding gap: minor: the player's half (boat, minecart or mob mount); riders of a vehicle are let go by sync_riders and eject_boat_mobs
|
||||
Entity.addTag port crates/horizon-client/src/game/commands/data.rs::Game::cmd_tag the tag.add branch, with the 1024 tag limit
|
||||
|
||||
|
Reference in new issue
Block a user