Merge pull request #11044 from Scholiboi/fix/remote-pearl-bubble-column

Pearls on a server's client hover in bubble columns as vanilla's do
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:19:56 +00:00
commit 1f666cfec4
3 files changed
+212 -1

No files matched your search

@@ -191,6 +191,16 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
horizon_client::motion::bubble_column_at(
self.w.world,
self.w.registry,
self.w.facts,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
)
.map(|c| (c.drag, c.surface))
}
fn solid(&self, p: [i32; 3]) -> bool {
self.w
.facts
@@ -113,7 +113,7 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
/// and a block stops it there; only the server decides what it hits.
pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
if !matches!(p.tick(w).1, Hit::None) {
if !matches!(p.tick_remote(w).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
+201
View File
@@ -298,6 +298,12 @@ pub trait ProjectileWorld {
fn ignites(&self, _p: [i32; 3]) -> bool {
false
}
/// `BubbleColumnBlock.entityInside`'s view of the block at `p`: a bubble
/// column's `drag`, and whether nothing is above it (its surface, where
/// it throws harder); None for any other block.
fn bubble_column(&self, _p: [i32; 3]) -> Option<(bool, bool)> {
None
}
}
/// `CollisionGetter.clipIncludingBorder` over a block clip's result: a ray
/// from inside the world border whose hit (or end, on a miss) lies outside it
@@ -1044,6 +1050,107 @@ impl Projectile {
pub fn tick(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
self.tick_with(w, true)
}
/// One tick of a server's projectile on its client: [`tick`](Self::tick)
/// with `ThrowableProjectile.tick`'s bubble columns, which push it on the
/// client too, so a pearl held in a stasis chamber hovers between the
/// server's updates instead of sinking and snapping back. The integrated
/// and dedicated servers push their pearls in the creature tick instead.
pub fn tick_remote(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) {
if !self.is_throwable_projectile() {
return self.tick(w);
}
// handleFirstTickBubbleColumn: before gravity, every column block in
// the box (`findBlocksIn`, BlockPos.containing of both corners),
// entered precisely.
if self.age == 0 {
let b = self.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if let Some((drag, surface)) = w.bubble_column([x, y, z]) {
self.bubble_column_push(drag, surface);
}
}
}
}
}
let from = self.position;
let result = self.tick(w);
self.bubble_columns_along(w, from);
result
}
/// A `ThrowableProjectile`: the thrown kinds but llama spit, a plain
/// `Projectile` with its own tick.
fn is_throwable_projectile(&self) -> bool {
self.is_thrown() && self.kind != ProjectileKind::LlamaSpit
}
/// `Projectile.onAboveBubbleColumn` / `onInsideBubbleColumn`: unlike
/// `Entity`'s, a projectile's pushes are not clamped.
fn bubble_column_push(&mut self, drag: bool, surface: bool) {
self.velocity.y += match (drag, surface) {
(true, _) => -0.03,
(false, true) => 0.1,
(false, false) => 0.06,
};
}
/// `applyEffectsFromBlocks`' bubble columns (`Entity.checkInsideBlocks`):
/// the blocks the box swept through from `from`, each once, pushing when
/// the move was long or the final box (deflated by 1e-5F) is inside it.
fn bubble_columns_along(&mut self, w: &impl ProjectileWorld, from: Vec3) {
let to = self.position;
let eps = f64::from(1.0e-5_f32);
let deflated = self.bounds().deflate(Vec3::new(eps, eps, eps));
let far = from.distance_squared(to) > 0.999_990_000_000_252_6f64 * 0.999_990_000_000_252_6;
let mut cells = Vec::new();
let mut used = 0;
let _ = horizon_core::geometry::sweep_intersected_blocks(
from,
to,
deflated,
1_000_000,
|p, step| {
if step >= 16 {
return false;
}
used = step;
cells.push(p);
true
},
);
// A sweep that used all 16 steps checks the end position once more.
if used + 1 >= 16 {
let _ = horizon_core::geometry::sweep_intersected_blocks(
to,
to,
deflated,
1_000_000,
|p, step| {
if step >= 1 {
return false;
}
cells.push(p);
true
},
);
}
let mut seen = std::collections::HashSet::new();
for p in cells {
if !seen.insert(p) {
continue;
}
let Some((drag, surface)) = w.bubble_column([p.x, p.y, p.z]) else {
continue;
};
let cell = Aabb::new(
Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)),
Vec3::new(f64::from(p.x + 1), f64::from(p.y + 1), f64::from(p.z + 1)),
);
if far || deflated.intersects(cell) {
self.bubble_column_push(drag, surface);
}
}
}
/// One tick that meets nothing: what `tick` would have done had its hit been
/// canceled (`ProjectileImpactEvent`). The projectile goes the whole way of its
/// motion and flies on, with no hit to act on.
@@ -1304,6 +1411,100 @@ mod tests {
&self.0
}
}
/// Water filled with one kind of bubble column, as in a stasis chamber.
struct Column {
drag: bool,
surface: bool,
}
impl ProjectileWorld for Column {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Aabb) -> bool {
true
}
fn targets(&self) -> &[(Who, Aabb)] {
&[]
}
fn bubble_column(&self, _: [i32; 3]) -> Option<(bool, bool)> {
Some((self.drag, self.surface))
}
}
/// A pearl resting in the water of a column, past its first tick.
fn resting_pearl() -> Projectile {
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(0.5, 100.2, 0.5), None);
p.age = 5;
p.touching_water = true;
p
}
#[test]
fn a_pearl_in_a_bubble_column_is_pushed_after_it_moves() {
// ThrowableProjectile.tick: gravity 0.03, the water drag 0.8F, the
// move, then applyEffectsFromBlocks' BubbleColumnBlock.entityInside:
// Projectile.onInsideBubbleColumn adds 0.06 (-0.03 dragging down) and
// onAboveBubbleColumn 0.1 at the surface, with no clamp.
let drag = f64::from(0.8_f32);
for (column, push) in [
(
Column {
drag: false,
surface: false,
},
0.06,
),
(
Column {
drag: false,
surface: true,
},
0.1,
),
(
Column {
drag: true,
surface: false,
},
-0.03,
),
(
Column {
drag: true,
surface: true,
},
-0.03,
),
] {
let mut p = resting_pearl();
p.tick_remote(&column);
assert_eq!(p.position.y, 100.2 + -0.03 * drag, "the move comes first");
assert_eq!(
p.velocity.y,
-0.03 * drag + push,
"drag {} surface {}",
column.drag,
column.surface
);
}
}
#[test]
fn a_new_pearl_takes_its_first_bubble_column_push_before_gravity() {
// ThrowableProjectile.handleFirstTickBubbleColumn on the first tick,
// before gravity, then the air drag 0.99F (not yet touching water),
// then the push after the move.
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(0.5, 100.2, 0.5), None);
p.tick_remote(&Column {
drag: false,
surface: false,
});
let air = f64::from(0.99_f32);
assert_eq!(p.velocity.y, (0.06 - 0.03) * air + 0.06);
}
#[test]
fn a_pearl_outside_any_bubble_column_only_falls() {
let mut p = resting_pearl();
p.tick_remote(&Empty(vec![]));
assert_eq!(p.velocity.y, -0.03 * f64::from(0.8_f32));
}
#[test]
fn first_tick_shoot_event_uses_base_tick_position_and_saved_flag() {
let w = Empty(vec![]);