Let the off hand go when a bucket, bottle or boat has no target

On a server client (single player included) remote_use_item predicted
SUCCESS for every bucket, glass bottle and boat, which ended
Minecraft.startUseItem before the off hand's turn: a Riptide trident
there never charged. BucketItem, BottleItem and BoatItem.use PASS or
FAIL with nothing to fill, empty or place, and startUseItem stops only
on SUCCESS. The client now checks the target first (a waterlogged block
counts as a water source, as SimpleWaterloggedBlock is a BucketPickup);
place_boat is split into boat_to_place (no side effects) so both share
BoatItem's checks. A powder snow bucket (SolidBucketItem, only useOn)
always passes its use.

Fixes #3147

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-29 15:31:50 +07:00
1 parent 11494e17f9
commit 53beacfd13
2 files changed
+86 -17

No files matched your search

+24 -17
View File
@@ -658,15 +658,30 @@ impl Game<'_> {
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
let Some(boat) = self.boat_to_place(stack) else {
return false;
};
if !self.boat_models.has(wood, chest) {
return false;
self.next_entity += 1;
let key = (
boat.position.x.floor() as i32 >> 4,
boat.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(key);
self.boats.push(boat);
if !self.creative {
self.consume_held();
}
let Some((eye, dir)) = self.aim() else {
return false;
};
self.swing_arm();
true
}
/// The boat `BoatItem.use` would place for `stack` (its SUCCESS), or
/// None when it aims at nothing (PASS) or the spot is taken (FAIL).
pub(super) fn boat_to_place(&self, stack: &ItemStack) -> Option<Boat> {
let (wood, chest) = any_boat_wood(&stack.item)?;
if !self.boat_models.has(wood, chest) {
return None;
}
let (eye, dir) = self.aim()?;
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
@@ -706,7 +721,7 @@ impl Game<'_> {
}
(Some(b), None) => b.0,
(None, Some(w)) => w.0,
(None, None) => return false,
(None, None) => return None,
};
let mut boat = Boat::new(
self.next_entity + 1,
@@ -730,17 +745,9 @@ impl Game<'_> {
subject: None,
};
if !scene.unobstructed(inner).unwrap_or(false) || self.boat_entity_collision(bb) {
return false;
return None;
}
self.next_entity += 1;
let key = (hit[0].floor() as i32 >> 4, hit[2].floor() as i32 >> 4);
self.world.modified.insert(key);
self.boats.push(boat);
if !self.creative {
self.consume_held();
}
self.swing_arm();
true
Some(boat)
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
+62
View File
@@ -11,6 +11,7 @@ use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
blocks::Fluid,
chunk::Chunk,
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
@@ -5049,6 +5050,12 @@ impl Game<'_> {
if info.food.is_some() && !eating {
return false;
}
// A bucket, a glass bottle or a boat with nothing to fill, empty or
// place PASSes or FAILs: startUseItem goes on to the off hand (a
// Riptide trident there charges).
if self.with_hand(hand, |g| g.use_finds_no_target(&held)) {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
@@ -5063,6 +5070,61 @@ impl Game<'_> {
item_has_use(&info)
}
/// Whether `held`'s `Item.use` needs a target this client sees none of
/// (not SUCCESS): BucketItem.use (a fluid source or powder snow to pick
/// up through `ClipContext.Fluid.SOURCE_ONLY`, a block to empty a full
/// one against, `mayUseItemAt`), BottleItem.use (water to fill from) and
/// BoatItem.use (a block or fluid surface with room for the boat).
fn use_finds_no_target(&self, held: &ItemStack) -> bool {
let id = &*held.item;
let facts = |pos: [i32; 3]| {
self.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
};
let source = |want: Option<Fluid>| {
let fluid_hit = self.pick_fluid();
// pick_fluid steps through waterlogged blocks, but their water is
// a source the SOURCE_ONLY ray stops at (and a BucketPickup).
if let Some(hit) = self.pick()
&& facts(hit.pos).waterlogged
{
let eye = self.aim().map_or(hit.point, |(eye, _)| eye);
let d = |p: [f64; 3]| (0..3).map(|a| (p[a] - eye[a]).powi(2)).sum::<f64>();
if fluid_hit
.as_ref()
.is_none_or(|fluid| d(fluid.point) >= d(hit.point))
{
return want.is_none_or(|want| want == Fluid::Water);
}
}
fluid_hit.is_some_and(|hit| {
let f = facts(hit.pos);
let fluid = f.fluid != Fluid::None && f.fluid_level == 8 && !f.fluid_falling;
match want {
Some(want) => fluid && f.fluid == want,
None => fluid || self.is_powder_snow(hit.pos),
}
})
};
// SolidBucketItem only has useOn: its use is Item.use's PASS.
if id == "minecraft:powder_snow_bucket" {
return true;
}
if id == "minecraft:bucket" {
return !self.may_build() || !source(None);
}
if horizon_client::items::bucket_contents(id).is_some() {
return !self.may_build() || self.pick().is_none();
}
if id == "minecraft:glass_bottle" {
return !source(Some(Fluid::Water));
}
if super::boats::any_boat_wood(id).is_some() {
return self.boat_to_place(held).is_none();
}
false
}
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
/// item held in use until the button lets go starts being used here, so
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and