mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into optifine/ctm-chained
This commit is contained in:
commit
ada1b8b1e4
14 files changed
+512
-75
No files matched your search
@@ -62,6 +62,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
|
||||
}
|
||||
|
||||
mod fluids;
|
||||
pub use fluids::update_entity_fluids;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
|
||||
@@ -26,7 +26,12 @@ struct EntityFluidWorld<'a> {
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
fn update_entity_fluids(
|
||||
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
|
||||
/// immersion heights, then the current of each fluid it sits in, added to
|
||||
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
|
||||
/// 0.0023333 slow). Items and armour stands call it for themselves; a boat
|
||||
/// calls it from its own tick, before `floatBoat` scales the velocity.
|
||||
pub fn update_entity_fluids(
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
eye_y: f64,
|
||||
@@ -287,6 +292,54 @@ mod tests {
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
|
||||
/// in water[level=1..7] gains velocity along the flow, and the same box in
|
||||
/// still source water does not. Vanilla's boat tick runs this before
|
||||
/// `floatBoat` scales the velocity, which is what `game::boats` does.
|
||||
#[test]
|
||||
fn a_boat_box_is_pushed_by_the_current_it_sits_in() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
// Boat dimensions: 1.375 wide, 0.5625 tall, eye at its top.
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let half = 1.375 / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(at.x - half, at.y, at.z - half),
|
||||
Vec3::new(at.x + half, at.y + 0.5625, at.z + half),
|
||||
);
|
||||
let mut still = Vec3::ZERO;
|
||||
update_entity_fluids(bounds, at, at.y + 0.5625, &mut still, &world, &fluids);
|
||||
assert_eq!(still.x, 0.0, "source water has no current");
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = Vec3::ZERO;
|
||||
update_entity_fluids(bounds, at, at.y + 0.5625, &mut carried, &world, &fluids);
|
||||
assert!(
|
||||
carried.x > 0.0,
|
||||
"the flow pushes the boat along +x, got {}",
|
||||
carried.x
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stands_follow_flowing_water_but_markers_and_still_water_stay_in_place() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -580,6 +580,16 @@ impl Game<'_> {
|
||||
b.paddling = [false; 2];
|
||||
b.input = [false; 4];
|
||||
}
|
||||
// `Entity.baseTick`: the boat is immersed and pushed by the current it
|
||||
// sits in (`isPushedByFluid`), before `floatBoat` scales the velocity.
|
||||
let _ = horizon_client::entities::update_entity_fluids(
|
||||
b.bounds(),
|
||||
b.position,
|
||||
b.position.y + HEIGHT,
|
||||
&mut b.velocity,
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
);
|
||||
// floatBoat
|
||||
// `-getGravity()`: none with NoGravity.
|
||||
let mut vspeed = if b.no_gravity { 0.0 } else { -0.04 };
|
||||
|
||||
@@ -278,8 +278,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Entity.getHandHoldingItemAngle(Items.FIREWORK_ROCKET)` for the local
|
||||
/// player: half a block out towards the hand holding the rocket while
|
||||
/// gliding, zero otherwise. The main arm is the right one (the only arm
|
||||
/// this client has), so a rocket only in the offhand is in the left hand.
|
||||
/// gliding, zero otherwise. A rocket only in the offhand is in the arm
|
||||
/// opposite the main one.
|
||||
fn rocket_hand_angle(&self) -> [f64; 3] {
|
||||
if !self.player.as_ref().is_some_and(|p| p.fall_flying) {
|
||||
return [0.0; 3];
|
||||
@@ -288,10 +288,44 @@ impl Game<'_> {
|
||||
|s: Option<&ItemStack>| s.is_some_and(|s| &*s.item == "minecraft:firework_rocket");
|
||||
let main = self.stack_in(self.selected);
|
||||
let offhand_only = is_rocket(self.offhand.as_ref()) && !is_rocket(main);
|
||||
let arm = if offhand_only { -80 } else { 80 };
|
||||
let right = offhand_only == self.settings.main_hand_left;
|
||||
let arm = if right { 80 } else { -80 };
|
||||
let v = view_vector(self.yaw + arm as f32, 0.0);
|
||||
[v[0] * 0.5, v[1] * 0.5, v[2] * 0.5]
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to the local player:
|
||||
/// a glide is boosted towards the look, and the rocket sits at the rocket
|
||||
/// hand with the player's motion.
|
||||
pub(super) fn follow_own_player(&mut self, r: &mut Rocket) {
|
||||
let hand = self.rocket_hand_angle();
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if p.fall_flying {
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
/// The trail (`FireworkRocketEntity.tick` on a client): a spark each tick
|
||||
/// at the rocket, drifting against its motion.
|
||||
pub(super) fn rocket_spark(&mut self, r: &Rocket) {
|
||||
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
|
||||
let v = [
|
||||
g(&mut self.world.random),
|
||||
-r.velocity.y * 0.5,
|
||||
g(&mut self.world.random),
|
||||
];
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:firework_rocket"
|
||||
@@ -350,20 +384,7 @@ impl Game<'_> {
|
||||
r.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
|
||||
}
|
||||
} else if r.attached {
|
||||
let hand = self.rocket_hand_angle();
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
self.follow_own_player(&mut r);
|
||||
hit = self.rocket_struck(&targets, &r, r.velocity, skip);
|
||||
} else {
|
||||
// getHitResultOnMoveVector before the move.
|
||||
@@ -402,15 +423,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
r.life += 1;
|
||||
// The trail: a spark each tick drifting against the rocket's motion.
|
||||
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
|
||||
let v = [
|
||||
g(&mut self.world.random),
|
||||
-r.velocity.y * 0.5,
|
||||
g(&mut self.world.random),
|
||||
];
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
|
||||
self.rocket_spark(&r);
|
||||
if r.life > r.lifetime {
|
||||
self.explode_rocket(&r);
|
||||
continue;
|
||||
|
||||
@@ -1563,11 +1563,14 @@ impl Game<'_> {
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.seat_order += 1;
|
||||
let order = host.hub.seat_order;
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
g.vehicle = Some(id);
|
||||
g.vehicle_order = order;
|
||||
g.saved_vehicle = None;
|
||||
// Pair the stationary restored entities before their passenger packet.
|
||||
g.moved = true;
|
||||
|
||||
@@ -825,12 +825,23 @@ impl Game<'_> {
|
||||
// inventory key while riding).
|
||||
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
|
||||
self.keys = host_keys;
|
||||
// `Entity.addPassenger`: a rider that gets in now sits behind the
|
||||
// players already in the vehicle, which is what tells its
|
||||
// `getControllingPassenger` apart from this one's.
|
||||
let seat_order = (guest_vehicle.is_some() && host.hub.guests[i].vehicle != guest_vehicle)
|
||||
.then(|| {
|
||||
host.hub.seat_order += 1;
|
||||
host.hub.seat_order
|
||||
});
|
||||
let g = &mut host.hub.guests[i];
|
||||
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
|
||||
if guest_vehicle.is_none() {
|
||||
g.vehicle_good = None;
|
||||
}
|
||||
g.vehicle = guest_vehicle;
|
||||
if let Some(order) = seat_order {
|
||||
g.vehicle_order = order;
|
||||
}
|
||||
let fell_asleep = bed.is_some() && g.bed.is_none();
|
||||
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
|
||||
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
|
||||
|
||||
@@ -419,7 +419,25 @@ impl Game<'_> {
|
||||
g.position = [seat.x, seat.y, seat.z];
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player drives.
|
||||
/// `Entity.getControllingPassenger` of a vehicle a server player rides: a
|
||||
/// boat has two seats, and only its first player passenger drives it. The
|
||||
/// window player sits first when it rides; of the server's players it is
|
||||
/// the one that got in first.
|
||||
pub(super) fn guest_controls_vehicle(&self, host: &LanHost, conn: u32, vehicle: u32) -> bool {
|
||||
if self.riding == Some(vehicle) {
|
||||
return false;
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.vehicle == Some(vehicle))
|
||||
.min_by_key(|g| g.vehicle_order)
|
||||
.is_some_and(|g| g.conn == conn)
|
||||
}
|
||||
|
||||
/// `handlePaddleBoat`: the paddles of the boat the player controls
|
||||
/// (`player.getControlledVehicle()`); a passenger in the second seat moves
|
||||
/// no paddle.
|
||||
pub(super) fn guest_paddle_boat(&mut self, host: &LanHost, conn: u32, paddles: [bool; 2]) {
|
||||
let vehicle = host
|
||||
.hub
|
||||
@@ -427,7 +445,10 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.and_then(|g| g.vehicle);
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| Some(b.id) == vehicle) {
|
||||
let Some(vehicle) = vehicle.filter(|&v| self.guest_controls_vehicle(host, conn, v)) else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == vehicle) {
|
||||
b.paddling = paddles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5464,6 +5464,9 @@ impl Game<'_> {
|
||||
/// One client tick while connected (Minecraft.tick with a ClientLevel).
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.tick_remote_level()?;
|
||||
// The rockets boosting this player tick after it, also while it waits
|
||||
// for chunks.
|
||||
self.tick_own_rockets();
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.native_net.to_server = 0;
|
||||
self.native_tick();
|
||||
@@ -7215,7 +7218,7 @@ impl Game<'_> {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& (self.predict_item_on_block(stack, &hit)
|
||||
&& (self.predict_item_on_block(stack, &hit, hand)
|
||||
|| self.predict_map_on_banner(stack, &hit))
|
||||
{
|
||||
// Item.useOn succeeded here (InteractionResult.SUCCESS):
|
||||
@@ -7281,9 +7284,26 @@ impl Game<'_> {
|
||||
/// `playSound(player, ...)`: the server sends them to the others only):
|
||||
/// honeycomb waxing copper, and an axe, hoe or shovel transforming a
|
||||
/// block (stripping, scraping, removing wax, tilling, making a path).
|
||||
/// A cushion succeeds where it may be placed and spawns on the server.
|
||||
/// A cushion succeeds where it may be placed and spawns on the server, a
|
||||
/// firework rocket wherever it may be used, and the server launches it.
|
||||
/// Whether the item's use succeeded, which ends the click.
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit) -> bool {
|
||||
fn predict_item_on_block(&mut self, stack: &ItemStack, hit: &super::Hit, hand: Hand) -> bool {
|
||||
// FireworkRocketItem.useOn: SUCCESS on the client too, so neither
|
||||
// UseItem nor the off hand follows. It passes while gliding (the use
|
||||
// boosts instead), cooling down (MultiPlayerGameMode.performUseItemOn
|
||||
// skips useOn) and in adventure mode off its can_place_on
|
||||
// (ItemStack.useOn).
|
||||
if &*stack.item == "minecraft:firework_rocket" {
|
||||
let cooling = self.items().get(&stack.item).is_some_and(|info| {
|
||||
let group = horizon_client::cooldowns::group(stack, info.use_cooldown.as_ref());
|
||||
self.cooldowns.is_on_cooldown(&group)
|
||||
});
|
||||
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
|
||||
return !cooling
|
||||
&& !gliding
|
||||
&& (self.may_build()
|
||||
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)));
|
||||
}
|
||||
let at = [
|
||||
f64::from(hit.pos[0]) + 0.5,
|
||||
f64::from(hit.pos[1]) + 0.5,
|
||||
@@ -7681,6 +7701,8 @@ impl Game<'_> {
|
||||
// Item.use: Equippable.swapWithEquipmentSlot runs on this client too, so
|
||||
// the armor slot and the hand change at once (the server confirms).
|
||||
if info.equip_slot.is_some() && self.with_hand(hand, |g| g.equip_from_hand(&held)) {
|
||||
// Equippable.swapWithEquipmentSlot's SUCCESS (SwingSource.CLIENT).
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
// `ItemStack.use` runs on this client too: a bucket's own ray and its
|
||||
@@ -7709,12 +7731,20 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(&name, Some(at), volume, pitch);
|
||||
}
|
||||
// BucketItem.use's SUCCESS (SwingSource.CLIENT) on this client.
|
||||
if succeeded {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
|
||||
// hit; the server alone tests LiquidBlock and the placement permission.
|
||||
if held.item.ends_with("_spawn_egg") {
|
||||
return self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
|
||||
let succeeded = self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
|
||||
if succeeded {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
// Food starts being eaten (Consumable.startConsuming). A food that
|
||||
// can't be eaten (full and not always-edible) fails the use
|
||||
@@ -7738,6 +7768,8 @@ impl Game<'_> {
|
||||
self.apply_use_cooldown(&held);
|
||||
}
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
// WritableBookItem.use's SUCCESS (SwingSource.CLIENT).
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
}
|
||||
// BowItem.use and CrossbowItem.use FAIL with nothing to shoot: no
|
||||
@@ -7746,8 +7778,23 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying);
|
||||
// FireworkRocketItem.use while gliding: SUCCESS (SwingSource.PREDICTED),
|
||||
// so startUseItem swings this hand itself; a vanilla server swings it
|
||||
// for the other players only.
|
||||
if gliding && &*held.item == "minecraft:firework_rocket" {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// A stack's own `blocks_attacks` raises it as a shield's does.
|
||||
use_ends_click(&info, gliding) || self.blocks_attacks(&held).is_some()
|
||||
let blocks = self.blocks_attacks(&held).is_some();
|
||||
let ends = use_ends_click(&info, gliding) || blocks;
|
||||
// MultiPlayerGameMode.useItem: `ItemStack.use`'s SUCCESS swings
|
||||
// (SwingSource.CLIENT); CONSUME does not — a bow, crossbow, trident
|
||||
// or spear starting its charge, and `ItemUtils.startUsingInstantly`'s
|
||||
// food, drink, spyglass, shield and goat horn.
|
||||
if ends && !blocks && remote_use_swings(&held, info.consumable.is_some()) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
ends
|
||||
}
|
||||
|
||||
/// Whether `held`'s `Item.use` needs a target this client sees none of
|
||||
@@ -9647,6 +9694,27 @@ fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) ->
|
||||
item_has_use(info) || (fall_flying && &*info.id == "minecraft:firework_rocket")
|
||||
}
|
||||
|
||||
/// Whether `Item.use` of this item answers SUCCESS on this client, which
|
||||
/// swings (`MultiPlayerGameMode.useItem`'s `result.shouldSwing()`), rather
|
||||
/// than CONSUME, which only ends the click: a bow, crossbow, trident or
|
||||
/// spear starting its charge, `ItemUtils.startUsingInstantly`'s food, drink,
|
||||
/// spyglass, shield and goat horn, and the ender eye's SUCCESS_SERVER (the
|
||||
/// server's swing source, not this client's).
|
||||
fn remote_use_swings(held: &ItemStack, consumable: bool) -> bool {
|
||||
let id = &*held.item;
|
||||
!matches!(
|
||||
id,
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:goat_horn"
|
||||
| "minecraft:ender_eye"
|
||||
| "minecraft:brush"
|
||||
) && !consumable
|
||||
&& horizon_client::spear::Kinetic::of(id).is_none()
|
||||
}
|
||||
|
||||
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
|
||||
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
|
||||
/// its hand. Tools and materials pass, and the off hand gets its turn. The
|
||||
@@ -9961,10 +10029,51 @@ fn client_millis() -> u64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{keep_client_state, synced_local_use};
|
||||
use horizon_client::containers::{Beacon, BlockEntity};
|
||||
use super::{keep_client_state, remote_use_swings, synced_local_use};
|
||||
use horizon_client::{
|
||||
containers::{Beacon, BlockEntity},
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
|
||||
/// swing, CONSUME's do not (#6425).
|
||||
#[test]
|
||||
fn a_thrown_item_swinging_on_a_server_and_a_charge_not() {
|
||||
let stack = |id: &str| ItemStack::new(id, 1);
|
||||
// SUCCESS: the throwables, a bucket, a boat, a bundle, a book, a map.
|
||||
for id in [
|
||||
"minecraft:ender_pearl",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:wind_charge",
|
||||
"minecraft:snowball",
|
||||
"minecraft:experience_bottle",
|
||||
"minecraft:bucket",
|
||||
"minecraft:oak_boat",
|
||||
"minecraft:bundle",
|
||||
"minecraft:writable_book",
|
||||
"minecraft:map",
|
||||
] {
|
||||
assert!(remote_use_swings(&stack(id), false), "{id} swings");
|
||||
}
|
||||
// CONSUME: the charges and startUsingInstantly's uses, a drinkable
|
||||
// potion among them (`consumable` set).
|
||||
for id in [
|
||||
"minecraft:bow",
|
||||
"minecraft:crossbow",
|
||||
"minecraft:trident",
|
||||
"minecraft:spyglass",
|
||||
"minecraft:goat_horn",
|
||||
"minecraft:ender_eye",
|
||||
] {
|
||||
assert!(!remote_use_swings(&stack(id), false), "{id} does not swing");
|
||||
}
|
||||
// The kinetic weapons charge like a trident.
|
||||
assert!(!remote_use_swings(&stack("minecraft:iron_spear"), false));
|
||||
// A consumable (food, a drink) starts its use without a swing.
|
||||
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
|
||||
let old = BlockEntity::Beacon(Box::new(Beacon {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! end crystals on a server's client (`ClientPacketListener.handleAddEntity`
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_client::{
|
||||
@@ -563,11 +563,7 @@ impl Game<'_> {
|
||||
/// `Entity.getEyePosition` of a projectile's owner: this game's player or
|
||||
/// another player the server sent.
|
||||
fn remote_eye(&self, id: u32) -> Option<Vec3> {
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
if me == Some(id) {
|
||||
if self.remote_player_id() == Some(id) {
|
||||
let eye = self.eye_position()?;
|
||||
return Some(Vec3::new(eye[0], eye[1], eye[2]));
|
||||
}
|
||||
@@ -579,6 +575,34 @@ impl Game<'_> {
|
||||
))
|
||||
}
|
||||
|
||||
/// This client's own player's entity id on the server.
|
||||
fn remote_player_id(&self) -> Option<u32> {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok())
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.tick` for the rockets attached to this client's
|
||||
/// own player, once the player has moved (it ticks before them in
|
||||
/// `ClientLevel.tickEntities`), each with its spark.
|
||||
pub(super) fn tick_own_rockets(&mut self) {
|
||||
let Some(me) = self.remote_player_id() else {
|
||||
return;
|
||||
};
|
||||
if !self.clock.runs_normally()
|
||||
|| self.player.is_none()
|
||||
|| !self.rockets.iter().any(|r| r.attached_to == Some(me))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in rockets.iter_mut().filter(|r| r.attached_to == Some(me)) {
|
||||
self.follow_own_player(r);
|
||||
self.rocket_spark(r);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
}
|
||||
|
||||
/// One tick of interpolation, as `tick_remote_objects`.
|
||||
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
self.tick_remote_fangs();
|
||||
@@ -647,13 +671,10 @@ impl Game<'_> {
|
||||
.collect();
|
||||
self.smoke_trails(trails);
|
||||
self.shulker_bullet_trails();
|
||||
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
|
||||
// and boosts it client-side too (attachedToEntity.isFallFlying), and
|
||||
// every rocket leaves its spark trail on a client.
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
// FireworkRocketEntity.tick: a rocket attached to another entity
|
||||
// follows it, and every rocket leaves its spark trail on a client. One
|
||||
// boosting this client's own player ticks after it (`tick_own_rockets`).
|
||||
let me = self.remote_player_id();
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
r.remote_step(&mut SceneCollisions {
|
||||
@@ -663,20 +684,9 @@ impl Game<'_> {
|
||||
});
|
||||
if let Some(target) = r.attached_to {
|
||||
if me == Some(target) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
|
||||
// Another player's motion is not known here: its step.
|
||||
r.velocity = Vec3::new(
|
||||
m.position.x - r.previous.x,
|
||||
@@ -686,17 +696,7 @@ impl Game<'_> {
|
||||
r.position = m.position;
|
||||
}
|
||||
}
|
||||
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
|
||||
let v = [
|
||||
g(&mut self.world.random),
|
||||
-r.velocity.y * 0.5,
|
||||
g(&mut self.world.random),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Firework),
|
||||
[r.position.x, r.position.y, r.position.z],
|
||||
v,
|
||||
);
|
||||
self.rocket_spark(r);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
for c in &mut self.crystals {
|
||||
|
||||
@@ -108,6 +108,11 @@ pub struct Guest {
|
||||
pub input: u8,
|
||||
/// The boat or minecart the player rides (`Entity.getVehicle`).
|
||||
pub vehicle: Option<u32>,
|
||||
/// When the player last got into its vehicle, from `Hub.seat_order`:
|
||||
/// `Entity.addPassenger` seats a rider behind the players already in the
|
||||
/// vehicle, so the smallest order on a boat is its controlling rider
|
||||
/// (`getControllingPassenger`).
|
||||
pub vehicle_order: u64,
|
||||
/// `ServerGamePacketListenerImpl.lastGoodX/Y/Z` of the vehicle it controls.
|
||||
pub vehicle_good: Option<[f64; 3]>,
|
||||
pub sprinting: bool,
|
||||
@@ -354,6 +359,7 @@ impl Guest {
|
||||
sneaking: false,
|
||||
input: 0,
|
||||
vehicle: None,
|
||||
vehicle_order: 0,
|
||||
vehicle_good: None,
|
||||
sprinting: false,
|
||||
fall_flying: false,
|
||||
|
||||
@@ -236,6 +236,8 @@ pub struct Hub {
|
||||
pub guests: Vec<Guest>,
|
||||
pub(super) sync: Arc<SyncData>,
|
||||
pub ticks: u64,
|
||||
/// The next `Guest.vehicle_order`: a rider that boards later sits behind.
|
||||
pub seat_order: u64,
|
||||
/// `Level.soundSeedGenerator`: a fresh `nextLong` per `playSound`.
|
||||
pub(super) sound_seeds: Mutex<LegacyRandom>,
|
||||
/// The `player_movement_check` and `elytra_movement_check` game rules,
|
||||
@@ -536,6 +538,7 @@ impl Hub {
|
||||
guests: Vec::new(),
|
||||
sync,
|
||||
ticks: 0,
|
||||
seat_order: 0,
|
||||
sound_seeds: Mutex::new(LegacyRandom::new(crate::sound_engine::unique_seed())),
|
||||
immediate_respawn: false,
|
||||
movement_checks: (true, true),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""A rocket boosting a server client's own player, run through the live methods.
|
||||
|
||||
Vanilla reference: FireworkRocketEntity.tick after the player's tick in ClientLevel.tickEntities;
|
||||
Entity.getHandHoldingItemAngle.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GAME = ROOT / "crates/horizon-client/src/game"
|
||||
|
||||
|
||||
def lift(path, start, end):
|
||||
source = path.read_text()
|
||||
a = source.index(start)
|
||||
return source[a : source.index(end, a)].replace("pub(super) ", "")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("rustc"), "requires Rust")
|
||||
class RemoteOwnRocketTests(unittest.TestCase):
|
||||
def test_the_spark_follows_the_moved_player_at_the_rocket_hand(self):
|
||||
methods = lift(
|
||||
GAME / "fireworks.rs",
|
||||
" /// `Entity.getHandHoldingItemAngle",
|
||||
" /// `FireworkRocketItem.use` while gliding",
|
||||
) + lift(
|
||||
GAME / "remote_projectiles.rs",
|
||||
" /// This client's own player's entity id",
|
||||
" /// One tick of interpolation",
|
||||
)
|
||||
fixture = r'''
|
||||
#![allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Vec3 { x: f64, y: f64, z: f64 }
|
||||
impl Vec3 {
|
||||
fn new(x: f64, y: f64, z: f64) -> Self { Self { x, y, z } }
|
||||
fn add_vector(self, o: Vec3) -> Self { Self::new(self.x + o.x, self.y + o.y, self.z + o.z) }
|
||||
}
|
||||
// Entity.calculateViewVector(pitch, yaw).
|
||||
fn view_vector(yaw: f32, pitch: f32) -> [f64; 3] {
|
||||
let (p, y) = (f64::from(pitch).to_radians(), f64::from(-yaw).to_radians());
|
||||
[y.sin() * p.cos(), -p.sin(), y.cos() * p.cos()]
|
||||
}
|
||||
struct ItemStack { item: String }
|
||||
struct Remote { entity_id: i32 }
|
||||
mod horizon_core {
|
||||
pub mod random {
|
||||
// Gaussians in turn from a list.
|
||||
pub struct LegacyRandom(pub Vec<f64>);
|
||||
impl LegacyRandom {
|
||||
pub fn next_gaussian(&mut self) -> f64 { self.0.remove(0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
struct World { random: horizon_core::random::LegacyRandom }
|
||||
enum ParticleType { Firework }
|
||||
struct ParticleOptions;
|
||||
impl ParticleOptions { fn simple(_: ParticleType) -> Self { Self } }
|
||||
#[derive(Default)]
|
||||
struct Particles { added: Vec<([f64; 3], [f64; 3])> }
|
||||
impl Particles {
|
||||
fn add(&mut self, _: &ParticleOptions, at: [f64; 3], v: [f64; 3]) { self.added.push((at, v)); }
|
||||
}
|
||||
struct Clock { runs: bool }
|
||||
impl Clock {
|
||||
fn runs_normally(&self) -> bool { self.runs }
|
||||
fn world_runs(&mut self) -> bool { panic!("TickRateManager.tick a second time in one client tick") }
|
||||
}
|
||||
struct Settings { main_hand_left: bool }
|
||||
struct Player { position: Vec3, velocity: Vec3, fall_flying: bool }
|
||||
#[derive(Default)]
|
||||
struct Rocket { attached_to: Option<u32>, position: Vec3, velocity: Vec3 }
|
||||
struct Game {
|
||||
remote: Option<Remote>, clock: Clock, player: Option<Player>, settings: Settings,
|
||||
hotbar: Option<ItemStack>, offhand: Option<ItemStack>, selected: usize,
|
||||
rockets: Vec<Rocket>, yaw: f32, pitch: f32, world: World, particles: Particles,
|
||||
}
|
||||
impl Game {
|
||||
fn stack_in(&self, slot: usize) -> Option<&ItemStack> { self.hotbar.as_ref().filter(|_| slot == 3) }
|
||||
METHODS
|
||||
}
|
||||
fn rocket() -> Option<ItemStack> { Some(ItemStack { item: "minecraft:firework_rocket".into() }) }
|
||||
fn game(runs: bool) -> Game {
|
||||
Game {
|
||||
remote: Some(Remote { entity_id: 7 }),
|
||||
clock: Clock { runs },
|
||||
// Where the player's own tick has just moved it, facing west.
|
||||
player: Some(Player {
|
||||
position: Vec3::new(10.0, 70.0, 20.0),
|
||||
velocity: Vec3::new(0.0, -0.2, 1.0),
|
||||
fall_flying: true,
|
||||
}),
|
||||
settings: Settings { main_hand_left: false },
|
||||
hotbar: rocket(), offhand: None, selected: 3,
|
||||
rockets: vec![
|
||||
Rocket { attached_to: Some(7), ..Rocket::default() },
|
||||
Rocket { attached_to: Some(8), position: Vec3::new(1.0, 2.0, 3.0), ..Rocket::default() },
|
||||
Rocket { attached_to: None, position: Vec3::new(4.0, 5.0, 6.0), ..Rocket::default() },
|
||||
],
|
||||
yaw: 90.0, pitch: 0.0,
|
||||
world: World { random: horizon_core::random::LegacyRandom(vec![3.0, -2.0, 3.0, -2.0]) },
|
||||
particles: Particles::default(),
|
||||
}
|
||||
}
|
||||
fn near(a: [f64; 3], b: [f64; 3]) -> bool { a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4) }
|
||||
fn at(v: Vec3) -> [f64; 3] { [v.x, v.y, v.z] }
|
||||
// The rocket's spot after one tick with this hand setup.
|
||||
fn hand(main_hand_left: bool, main: Option<ItemStack>, offhand: Option<ItemStack>) -> [f64; 3] {
|
||||
let mut g = game(true);
|
||||
g.settings.main_hand_left = main_hand_left;
|
||||
g.hotbar = main;
|
||||
g.offhand = offhand;
|
||||
g.tick_own_rockets();
|
||||
at(g.rockets[0].position)
|
||||
}
|
||||
fn main() {
|
||||
let mut g = game(true);
|
||||
g.tick_own_rockets();
|
||||
// Looking west (-x): movement + look * 0.1 + (look * 1.5 - movement) * 0.5.
|
||||
let boosted = [-0.85, -0.1, 0.5];
|
||||
assert!(near(at(g.player.as_ref().unwrap().velocity), boosted), "{:?}", g.player.as_ref().unwrap().velocity);
|
||||
assert_eq!(g.player.as_ref().unwrap().position, Vec3::new(10.0, 70.0, 20.0));
|
||||
// Facing west the right hand is north: half a block out at yaw + 80.
|
||||
let right = [10.0 - 0.0868, 70.0, 20.0 - 0.4924];
|
||||
assert!(near(at(g.rockets[0].position), right), "{:?}", g.rockets[0].position);
|
||||
assert!(near(at(g.rockets[0].velocity), boosted));
|
||||
// One firework spark at the rocket, drifting against the boosted motion
|
||||
// with a gaussian spread of 0.05 across it.
|
||||
let sparks = &g.particles.added;
|
||||
assert_eq!(sparks.len(), 1);
|
||||
assert!(near(sparks[0].0, right) && near(sparks[0].1, [0.15, 0.05, -0.1]), "{:?}", sparks);
|
||||
// Rockets of another entity, or free ones, tick with the other entities.
|
||||
assert_eq!(g.rockets[1].position, Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(g.rockets[2].position, Vec3::new(4.0, 5.0, 6.0));
|
||||
|
||||
// The other arm: a left main arm, or a rocket only in the offhand.
|
||||
let left = [10.0 - 0.0868, 70.0, 20.0 + 0.4924];
|
||||
assert!(near(hand(true, rocket(), None), left));
|
||||
assert!(near(hand(false, None, rocket()), left));
|
||||
assert!(near(hand(true, None, rocket()), right));
|
||||
assert!(near(hand(false, rocket(), rocket()), right));
|
||||
|
||||
// Not gliding: it follows the player itself, without boosting it.
|
||||
let mut g = game(true);
|
||||
g.player.as_mut().unwrap().fall_flying = false;
|
||||
g.tick_own_rockets();
|
||||
assert_eq!(g.player.as_ref().unwrap().velocity, Vec3::new(0.0, -0.2, 1.0));
|
||||
assert_eq!(g.rockets[0].position, Vec3::new(10.0, 70.0, 20.0));
|
||||
assert_eq!(g.particles.added.len(), 1);
|
||||
|
||||
// A frozen level ticks no entities.
|
||||
let mut g = game(false);
|
||||
g.tick_own_rockets();
|
||||
assert!(g.particles.added.is_empty());
|
||||
assert_eq!(g.player.as_ref().unwrap().velocity, Vec3::new(0.0, -0.2, 1.0));
|
||||
}
|
||||
'''.replace("METHODS", methods)
|
||||
with tempfile.TemporaryDirectory(prefix="remote-own-rockets-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "main.rs").write_text(fixture)
|
||||
built = subprocess.run(
|
||||
["rustc", "--edition=2024", str(path / "main.rs"), "-o", str(path / "probe")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(built.returncode, 0, built.stderr)
|
||||
result = subprocess.run([str(path / "probe")], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
Drive both paddles, stop one, stop both, resume one, then dismount without
|
||||
sending a final paddle packet. Clients must receive the false transitions.
|
||||
A boat with two players is driven by its first rider only: the second rider's
|
||||
paddle packets leave the metadata alone.
|
||||
"""
|
||||
try:
|
||||
from . import test_locator_bar_parity as net
|
||||
@@ -32,6 +34,36 @@ for kind in ['oak_boat', 'birch_chest_boat', 'bamboo_raft']:
|
||||
EXPECTED += [f'{label} {index} {str(value).lower()}' for index, value in changes]
|
||||
|
||||
|
||||
# Two players in one boat: `ServerGamePacketListenerImpl.handlePaddleBoat`
|
||||
# applies a packet only when the sender is the boat's controlling passenger
|
||||
# (`player.getControlledVehicle()`), and `Entity.addPassenger` seats the first
|
||||
# player in front, so the second rider's paddle packet changes no metadata.
|
||||
SCRIPT.append(('two-riders-setup', [
|
||||
'join Bob',
|
||||
'cmd Alice kill @e[type=!player]',
|
||||
'cmd Alice fill -5 79 -5 5 79 5 minecraft:stone',
|
||||
'cmd Alice fill -5 80 -5 5 80 5 minecraft:water',
|
||||
'cmd Alice tp @s 0.5 81 0.5',
|
||||
'cmd Alice summon minecraft:oak_boat 0.5 81 0.5',
|
||||
'cmd Alice ride @s mount @e[type=minecraft:oak_boat,limit=1]',
|
||||
'cmd Bob ride @s mount @e[type=minecraft:oak_boat,limit=1]',
|
||||
]))
|
||||
SCRIPT.append(('two-riders-both', ['paddle Alice true true']))
|
||||
SCRIPT.append(('two-riders-second-left', ['paddle Bob true false']))
|
||||
SCRIPT.append(('two-riders-second-stop', ['paddle Bob false false']))
|
||||
SCRIPT.append(('two-riders-first-right', ['paddle Alice true false']))
|
||||
# The first rider gets out, leaving the second alone in the boat: the seat it
|
||||
# took over is the one the boat's paddles follow now.
|
||||
SCRIPT.append(('two-riders-first-out', ['cmd Alice ride @s dismount']))
|
||||
SCRIPT.append(('two-riders-second-alone', ['paddle Bob true true']))
|
||||
# Each client that tracks the boat records the same metadata transition, and
|
||||
# the transcript groups a player's lines together (Alice joined first).
|
||||
EXPECTED += [f'{label} {index} {value}' for _ in ('Alice', 'Bob')
|
||||
for label, index, value in [('two-riders-both', 11, 'true'), ('two-riders-both', 12, 'true'),
|
||||
('two-riders-first-right', 12, 'false'),
|
||||
('two-riders-second-alone', 12, 'true')]]
|
||||
|
||||
|
||||
def shown(lines):
|
||||
return [f'{p[1].removeprefix("step=")} {p[3]} {p[4]}'
|
||||
for line in lines if len(p := line.split()) == 5 and p[2] == 'paddle'
|
||||
|
||||
@@ -45,7 +45,7 @@ def main():
|
||||
[inputs, build / 'client.txt'], 'client')
|
||||
# Text geometry needs neither an audio device nor controller system libraries.
|
||||
harness.run_logged(['cargo', 'build', '--locked', '--no-default-features',
|
||||
'-p', 'horizon-client', '--example', 'client_probe'], build, 'compile-rust')
|
||||
'-p', 'horizon-client-lib', '--example', 'client_probe'], build, 'compile-rust')
|
||||
harness.run_logged([lab.cargo_target() / 'debug/examples/client_probe', 'inline_object_fixture',
|
||||
inputs, build / 'rust.txt'], build, 'rust')
|
||||
matched = base.compare(build / 'client.txt', build / 'rust.txt')
|
||||
|
||||
Reference in new issue
Block a user