Merge remote-tracking branch 'origin/main' into optifine/ctm-chained

This commit is contained in:
some-du6e committed 2026-11-01 18:03:50 -06:00
commit ada1b8b1e4
14 files changed
+512 -75

No files matched your search

+1
View File
@@ -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 {
+54 -1
View File
@@ -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"))
+10
View File
@@ -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 };
+39 -26
View File
@@ -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;
+3
View File
@@ -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());
+23 -2
View File
@@ -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;
}
}
+116 -7
View File
@@ -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 {
+6
View File
@@ -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,
+3
View File
@@ -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),
+175
View File
@@ -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()
+32
View File
@@ -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'
+1 -1
View File
@@ -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')