Expose dropped-item block contact to native mods

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Delete228andCodex committed 2026-11-06 14:25:58 +03:00
1 parent f4dcf8a2fb
commit c4c0e6fc6b
8 files changed
+397 -98

No files matched your search

+92 -18
View File
@@ -369,7 +369,8 @@ impl crate::entity_rules::EntityRules for ArmorStand {
#[derive(Clone, Copy)]
pub struct ItemStep {
old_velocity: Vec3,
moved: bool,
movement: Option<Vec3>,
clipped_vertical: bool,
}
#[cfg(test)]
impl ItemEntity {
@@ -585,10 +586,35 @@ impl ItemEntity {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
(water_height, lava_height): (f64, f64),
heights: (f64, f64),
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
) -> ItemStep {
let step = self.tick_move_before_block_effects(
world,
facts,
collision,
no_physics,
heights,
entity_boxes,
border,
);
self.finish_move_block_effects(world, facts, no_physics, blocks, step);
step
}
/// Resolve the item's move before the owner handles block contact and
/// resumes the normal block effects.
pub fn tick_move_before_block_effects(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
(water_height, lava_height): (f64, f64),
entity_boxes: &[Aabb],
border: Option<&BorderGeometry>,
) -> ItemStep {
self.ticks += 1;
self.previous = self.position;
@@ -686,22 +712,10 @@ impl ItemEntity {
slime,
);
}
// Entity.move's last step: soul sand and honey slow the item's
// horizontal speed. A move through blocks (noPhysics) returns
// before it. `blocks` is None only for the isolated unit tests.
if !no_physics && let Some(blocks) = blocks {
let factor =
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
self.velocity =
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
// (before air drag), matching ItemEntity.tick.
self.apply_honey_slide(world, facts);
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
return ItemStep {
old_velocity,
moved: true,
movement: Some(moved),
clipped_vertical: !no_physics && moved.y != wanted.y,
};
}
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
@@ -710,7 +724,67 @@ impl ItemEntity {
}
ItemStep {
old_velocity,
moved: false,
movement: None,
clipped_vertical: false,
}
}
/// The block at `Entity.getOnPosLegacy` for this tick's clipped move.
/// Idle ticks retain the entity's collision flags but have no contact.
pub fn vertical_contact(
&self,
world: &World,
collision: &BlockCollisionRegistry<'_>,
blocks: &horizon_world::movement_properties::BoundMovementProperties,
step: ItemStep,
) -> Option<BlockPos> {
if !step.clipped_vertical {
return None;
}
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let mut ground = horizon_world::ground_support::GroundState::default();
ground
.check_support(self.on_ground, self.bounds(), step.movement, |bounds| {
horizon_world::ground_support::find_supporting_block(
&mut scene,
horizon_world::collision_context::CollisionContext::empty(false),
self.position,
bounds,
1_000_000,
)
})
.ok()?;
Some(crate::motion::legacy_block_support_at(
world,
blocks,
self.position,
ground.supporting_block,
))
}
/// Resume the normal block effects after the collision response.
pub fn finish_move_block_effects(
&mut self,
world: &World,
facts: &BlockFacts,
no_physics: bool,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
step: ItemStep,
) {
if step.movement.is_some() {
// Entity.move applies the block's speed factor before block effects.
if !no_physics && let Some(blocks) = blocks {
let factor =
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
self.velocity =
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
self.apply_honey_slide(world, facts);
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
}
}
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
@@ -724,7 +798,7 @@ impl ItemEntity {
no_physics: bool,
step: ItemStep,
) -> bool {
if step.moved {
if step.movement.is_some() {
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
// vertical speed the bounce just left (before the air drag).
if !no_physics {
+12 -11
View File
@@ -159,6 +159,8 @@ mod integrated;
mod interaction_shapes;
mod interactions;
mod item_areas;
#[cfg(test)]
mod item_contact_tests;
mod item_draw;
mod item_particles;
mod item_use;
@@ -4441,17 +4443,11 @@ impl Game<'_> {
);
obstacles.near(item.bounds().inflate(reach), &mut nearby);
let mut entered = Vec::new();
let alive = entity::tick_item(
let (alive, discarded) = self.tick_item_with_block_contacts(
item,
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
&self.res.motion().fluids,
no_physics,
&nearby,
Some(self.border.geometry()),
Some(&self.res.motion().blocks),
false,
&mut |splash| entered.push(splash),
);
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
@@ -4459,6 +4455,11 @@ impl Game<'_> {
for splash in entered {
self.apply_item_splash(splash);
}
if discarded {
removed[i] = true;
cells.remove(i, item.previous);
continue;
}
if merging && !no_physics {
self.item_landing_event(item.id, item.previous, item.position, item.on_ground);
self.entity_powder_snow_particles(
@@ -4553,12 +4554,12 @@ impl Game<'_> {
let mut removed = removed.into_iter();
items.retain(|_| !removed.next().unwrap_or(false));
let Some(p) = self.player.as_ref() else {
self.dropped = items;
self.restore_item_batch(items);
self.spawn_destroyed_contents(destroyed);
return;
};
if p.health.dead_or_dying() || self.spectator() {
self.dropped = items;
self.restore_item_batch(items);
self.spawn_destroyed_contents(destroyed);
return;
}
@@ -4640,7 +4641,7 @@ impl Game<'_> {
}
kept.push(item);
}
self.dropped = kept.into();
self.restore_item_batch(kept.into());
self.spawn_destroyed_contents(destroyed);
}
/// `ItemUtils.onContainerDestroyed`: each stack a destroyed item held
+71 -36
View File
@@ -567,42 +567,77 @@ macro_rules! stored_motion {
};
}
/// One `ItemEntity.tick`, with the fluid push and the bubble column decided by
/// the `Entity` defaults every kind shares: fluids, move, column, settle,
/// fluids again. Returns false when the item should be removed.
pub(super) fn tick_item(
item: &mut ItemEntity,
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
facts: &horizon_client::blocks::BlockFacts,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
fluids: &horizon_client::motion::FluidResources,
no_physics: bool,
entity_boxes: &[Aabb],
border: Option<&horizon_core::geometry::border::BorderGeometry>,
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> bool {
let (water, _lava) = item.push_by_fluid(world, fluids);
// `Entity.baseTick` splashes before the item moves. The first tick does not.
item.water_entry(water > 0.0, item.ticks == 0, splash);
let step = item.tick_move(
world,
facts,
collision,
no_physics,
(water, _lava),
entity_boxes,
border,
blocks,
);
item.push_by_bubble_column(world, registry, facts, collision);
let alive = item.tick_settle(world, facts, no_physics, step);
// `ItemEntity.tick` refreshes immersion after moving and splashes if that
// move is what entered the water.
let (water, _) = item.push_by_fluid(world, fluids);
item.water_entry(water > 0.0, false, splash);
alive
impl Game<'_> {
/// Tick an item owned by the caller. The two results are ordinary survival
/// and explicit consumption by a block-contact handler.
pub(super) fn tick_item_with_block_contacts(
&mut self,
item: &mut ItemEntity,
no_physics: bool,
entity_boxes: &[Aabb],
client: bool,
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
) -> (bool, bool) {
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
// Entity.baseTick splashes before moving, except on the first tick.
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
let step = item.tick_move_before_block_effects(
&self.world,
self.res.facts(),
self.collision,
no_physics,
heights,
entity_boxes,
Some(self.border.geometry()),
);
if let Some(pos) =
item.vertical_contact(&self.world, self.collision, &self.res.motion().blocks, step)
{
let state = self.world.block(pos.x, pos.y, pos.z);
let position = item.position;
let mut discarded = false;
self.native_item_after_vertical_collision(
self.dim.id(),
client,
item.id,
state,
[pos.x, pos.y, pos.z],
[position.x, position.y, position.z],
&mut item.stack,
&mut discarded,
);
if discarded || item.stack.count == 0 {
return (false, true);
}
}
item.finish_move_block_effects(
&self.world,
self.res.facts(),
no_physics,
Some(&self.res.motion().blocks),
step,
);
item.push_by_bubble_column(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
// ItemEntity.tick refreshes immersion and splashes on water entry.
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
item.water_entry(water > 0.0, false, splash);
(alive, false)
}
/// Keep new joins after the active batch, so they tick next time.
pub(super) fn restore_item_batch(
&mut self,
mut items: horizon_client::join_seq::JoinList<ItemEntity>,
) {
items.extend(std::mem::take(&mut self.dropped));
self.dropped = items;
}
}
/// The game's lists of entities, in the order systems walk them. Hands them to
@@ -0,0 +1,129 @@
use super::*;
use crate::mod_api::{self, Flow, Hooks, Host, ItemAfterVerticalCollisionEvent};
use horizon_client::{chunk::Chunk, join_seq::Joined as _};
use std::sync::Mutex;
type Contact = (bool, u32, [i32; 3], [f64; 3], u32);
#[derive(Default)]
struct Probe {
contacts: Mutex<Vec<Contact>>,
}
impl Hooks for Arc<Probe> {
fn item_after_vertical_collision(
&self,
_: &mut dyn Host,
event: &mut ItemAfterVerticalCollisionEvent<'_>,
) -> Flow {
self.contacts.lock().unwrap().push((
event.client,
event.entity,
event.block_pos,
event.item_pos,
event.stack.count,
));
if event.stack.count == 1 {
return Flow::Cancel;
}
event.stack.count = event.stack.count.saturating_sub(2);
Flow::Continue
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and an isolated mod registry"]
fn the_active_stack_survives_a_partial_contact_and_consumption_removes_the_entity() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.options.dedicated = Some(Vec::new());
game.world = World::new(game.res.facts().air, 1);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(4, 63, 4, stone).unwrap();
// The centre of the item is outside the block, while its box overlaps it.
let landing = [3.99, 64.0, 4.5];
let mut input = ItemStack::new("minecraft:iron_ingot", 13);
input
.data_mut()
.set_other("fixture:opaque", Some(horizon_world::nbt::Tag::Long(99)));
let spawn = |game: &mut Game<'_>, count| {
game.dropped.clear();
let mut stack = input.clone();
stack.count = count;
game.spawn_item(stack, [landing[0], 64.1, landing[2]], [0.0, -0.3, 0.0], 20);
game.ticked_entities_through = game.next_entity;
game.dropped[0].clone()
};
let before = spawn(&mut game, 13);
game.tick_items();
let vanilla = game.dropped[0].clone();
assert_eq!(vanilla.id, before.id);
assert_eq!(
vanilla.position,
Vec3::new(landing[0], landing[1], landing[2])
);
assert_eq!(vanilla.velocity, Vec3::ZERO);
assert_eq!(vanilla.stack, input);
assert_eq!(vanilla.age, 1);
let probe = Arc::new(Probe::default());
assert!(
mod_api::MODS
.set(Arc::from(vec![Box::new(probe.clone()) as Box<dyn Hooks>]))
.is_ok()
);
let old = spawn(&mut game, 13);
game.tick_items();
let item = &game.dropped[0];
assert_eq!(item.id, old.id);
assert_eq!(item.join_seq(), old.join_seq());
assert_eq!(item.position, vanilla.position);
assert_eq!(item.velocity, vanilla.velocity);
let mut expected = input.clone();
expected.count = 11;
assert_eq!(item.stack, expected);
assert_eq!(
probe.contacts.lock().unwrap().as_slice(),
&[(false, old.id, [4, 63, 4], landing, 13)]
);
for count in [1, 2] {
spawn(&mut game, count);
game.tick_items();
assert!(game.dropped.is_empty(), "consumed stack of {count}");
}
}
@@ -2189,6 +2189,37 @@ fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<Strin
/// The hook points. Each does nothing in Horizon.
impl Game<'_> {
/// A dropped item's vertical move clipped. Collision response has run and
/// block effects and pickup are still ahead. The owner lends the active
/// stack and supplies the block from `Entity.getOnPosLegacy`.
/// `client` identifies prediction. `discard` consumes the current entity
/// without loot or pickup. A stack reduced to zero also consumes it.
/// @API:item_after_vertical_collision@
pub(super) fn native_item_after_vertical_collision(
&mut self,
dimension: &str,
client: bool,
entity: u32,
state: BlockStateId,
block_pos: [i32; 3],
item_pos: [f64; 3],
stack: &mut ItemStack,
discard: &mut bool,
) {
mod_api::dispatch_item_after_vertical_collision(
self,
mod_api::mods(),
dimension,
client,
entity,
state,
block_pos,
item_pos,
stack,
discard,
);
}
/// Client initialization, before any outgoing connection; a window need not exist yet.
pub(super) fn native_client_start(&mut self) {
mod_api::dispatch_client_start(self, mod_api::mods());
+15 -25
View File
@@ -7088,32 +7088,22 @@ impl Game<'_> {
}
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, registry, facts, collision, border) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
Some(self.border.geometry()),
);
let mut entered = Vec::new();
for d in self.dropped.iter_mut().filter(|_| running) {
super::entity::tick_item(
d,
world,
registry,
facts,
collision,
&self.res.motion().fluids,
false,
// The server decides what an item rests on; this copy only moves between updates.
&[],
border,
Some(&self.res.motion().blocks),
&mut |splash| entered.push(splash),
);
}
// ticks). Ordinary removal remains server-authoritative. Mods can opt
// into predicting consumption at block contact.
head_lerps.retain(|_, (_, steps)| *steps > 0);
let mut entered = Vec::new();
if running {
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
// The server decides entity collisions. This copy predicts block movement.
let (_, discarded) =
self.tick_item_with_block_contacts(item, false, &[], true, &mut |splash| {
entered.push(splash);
});
!discarded
});
self.restore_item_batch(items);
}
// Entity.doWaterSplashEffect on this window: Level.addParticle draws
// the bubbles and splashes. ClientLevel.playSeededSound drops
// Entity.playSound, so the sound stays the server's.
+31 -8
View File
@@ -3021,19 +3021,23 @@ fn fly_into_wall_damage(before: f64, after: f64) -> f32 {
/// `Entity.getBlockSpeedFactor` for a kind that keeps no motion world of its
/// own (mobs, boats): the factor of the block at `position`, else of the one
/// below it, through the same port the player's move uses.
struct MovementBlocks<'a>(&'a World);
impl MovementBlockProvider for MovementBlocks<'_> {
type Error = std::convert::Infallible;
fn movement_block_state(
&mut self,
p: BlockPos,
) -> std::result::Result<BlockStateId, Self::Error> {
Ok(self.0.block(p.x, p.y, p.z))
}
}
pub fn block_speed_factor_at(
world: &World,
properties: &BoundMovementProperties,
position: Vec3,
supporting_block: Option<BlockPos>,
) -> f32 {
struct Blocks<'a>(&'a World);
impl MovementBlockProvider for Blocks<'_> {
type Error = ();
fn movement_block_state(&mut self, p: BlockPos) -> std::result::Result<BlockStateId, ()> {
Ok(self.0.block(p.x, p.y, p.z))
}
}
let location = movement_support::MovementLocation {
position,
block_position: BlockPos::new(
@@ -3043,7 +3047,7 @@ pub fn block_speed_factor_at(
),
supporting_block,
};
let mut view = Blocks(world);
let mut view = MovementBlocks(world);
movement_support::block_speed_factor(
&mut RegistryMovementWorld {
properties,
@@ -3054,6 +3058,25 @@ pub fn block_speed_factor_at(
.unwrap_or(1.0)
}
/// `Entity.getOnPosLegacy` with the world's bound fence and wall tags.
pub(crate) fn legacy_block_support_at(
world: &World,
properties: &BoundMovementProperties,
position: Vec3,
supporting_block: Option<BlockPos>,
) -> BlockPos {
movement_support::support_position(
&mut RegistryMovementWorld {
properties,
world: &mut MovementBlocks(world),
},
position,
supporting_block,
movement_support::LEGACY_SUPPORT_OFFSET,
)
.expect("loaded world block reads are infallible")
}
#[cfg(test)]
mod tests {
/// The glide's collision damage: a third of a block a tick lost is 0.0 and
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "item_after_vertical_collision",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_after_vertical_collision",
"side": "both",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "discard"
}
}
],
"rust_summary": "dropped-item block contact after a clipped vertical move.",
"doc_summary": "`native_item_after_vertical_collision` lends a dropped item's active stack after vertical collision response, before block effects, merging and pickup. The block is the one selected by the legacy on-position query. `client` identifies prediction. Set `discard` (trait `Flow::Cancel`) or reduce the stack to zero to consume the item without loot or pickup. A movement skipped by the normal item tick sends no notification. Bundles need \"hooks\": @API:item_after_vertical_collision@."
}