Merge pull request #10034 from Ragbie/fix/mob-spear-reach

A mob's melee reach follows the spear it holds
This commit is contained in:
Kacper authored and GitHub committed 2026-11-06 09:10:35 +01:00
commit df6139520e
4 files changed
+138 -11

No files matched your search

@@ -220,6 +220,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
"RandomSwimming" => "RandomSwimmingGoal",
"LookAtPlayer" => "LookAtPlayerGoal",
"RandomLookAround" => "RandomLookAroundGoal",
"SpearUse" => "SpearUseGoal",
"HurtByTarget" => "HurtByTargetGoal",
"NearestTarget" => "NearestAttackableTargetGoal",
"Panic" => "PanicGoal",
+5 -7
View File
@@ -246,12 +246,8 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Player.touch: entities within the player's box grown by (1, 0.5, 1).
&& m.bounds().intersects(p.bounds.inflate(Vec3::new(1.0, 0.5, 1.0)))
{
let reach = 2.04f32.sqrt() as f64 - 0.6;
if m.bounds()
.inflate(Vec3::new(reach, 0.0, reach))
.intersects(p.bounds)
&& m.sees(w, Who::Player)
{
// isWithinMeleeAttackRange, with what the cube holds.
if m.melee_reaches(p.bounds) && m.sees(w, Who::Player) {
events.push(MobEvent::CubeHurtPlayer {
cube: m.id,
amount: attack_damage(m),
@@ -274,7 +270,8 @@ fn attack_damage(m: &Mob) -> f32 {
/// `AbstractCubeMob.push(entity)`: an iron golem whose `pushEntities` takes
/// this cube (its box overlaps the cube's, `EntitySelector.pushableBy`) gets
/// `dealDamage`: alive, `doTeamsAllowDamage`, `isWithinMeleeAttackRange`
/// (always true for overlapping boxes) and `hasLineOfSight`, then a
/// (true for overlapping boxes unless a held spear's minimum range refuses
/// them) and `hasLineOfSight`, then a
/// `mobAttack` hit for `getAttackDamage`.
fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// canDealDamage also needs Mob.isEffectiveAi: a NoAI cube never hurts.
@@ -292,6 +289,7 @@ fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& o.species == Species::IronGolem
&& o.alive
&& o.bounds.intersects(bounds)
&& m.melee_reaches(o.bounds)
&& crate::scoreboard::pushable_by(theirs, own, pushable)
&& crate::scoreboard::teams_allow_damage(own, theirs)
})
+116 -4
View File
@@ -655,11 +655,63 @@ impl Mob {
self.ai.seen_cache.push((who, v));
v
}
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6.
/// Mob.isWithinMeleeAttackRange: the box inflated by sqrt(2.04) - 0.6, or
/// for an active item with `attack_range` (the spears) by its
/// `effectiveMaxRange` for a mob (`max_reach * mob_factor`), and not
/// within its `effectiveMinRange` (`min_reach * mob_factor`).
pub fn within_melee_range(&self, t: &TargetInfo) -> bool {
let reach = 2.04f32.sqrt() as f64 - 0.6;
let b = self.bounds().inflate(Vec3::new(reach, 0.0, reach));
b.intersects(t.bounds)
self.melee_reaches(t.bounds)
}
/// [`Self::within_melee_range`] for a target's box (`getHitbox`).
pub fn melee_reaches(&self, target: Aabb) -> bool {
let (max, min) = match self.active_attack_range() {
Some((max, min)) => (f64::from(max), f64::from(min)),
None => (2.04f32.sqrt() as f64 - 0.6, 0.0),
};
let reaches = |range: f64| {
self.bounds()
.inflate(Vec3::new(range, 0.0, range))
.intersects(target)
};
reaches(max) && (min <= 0.0 || !reaches(min))
}
/// The active item's `attack_range` for a mob: `effectiveMaxRange` and
/// `effectiveMinRange` (`max_reach` and `min_reach` times `mob_factor`).
/// A patch on the held stack wins (`!minecraft:attack_range` removes it;
/// the codec's defaults are 0, 3 and a factor of 1); else the spears'.
fn active_attack_range(&self) -> Option<(f32, f32)> {
let item = self.active_item()?;
let patch = (self.extra.main_hand.as_deref() == Some(item))
.then(|| self.extra.main_hand_stack().and_then(|s| s.data()))
.flatten();
if patch.is_some_and(|d| d.other("!minecraft:attack_range").is_some()) {
return None;
}
if let Some(range) = patch.and_then(|d| d.other("minecraft:attack_range")) {
let float = |key: &str, default: f32| match range.get(key) {
Some(horizon_world::nbt::Tag::Float(bits)) => f32::from_bits(*bits),
Some(horizon_world::nbt::Tag::Double(bits)) => f64::from_bits(*bits) as f32,
Some(horizon_world::nbt::Tag::Int(v)) => *v as f32,
_ => default,
};
let factor = float("mob_factor", 1.0);
return Some((
float("max_reach", 3.0) * factor,
float("min_reach", 0.0) * factor,
));
}
crate::spear::Kinetic::of(item).map(|_| {
let r = &crate::spear::REACH;
(r.max * r.mob_factor, r.min * r.mob_factor)
})
}
/// `LivingEntity.getActiveItem`: the item being used, else the main hand's.
pub fn active_item(&self) -> Option<&str> {
if super::spear_use::is_using_item(self) && self.extra.using_off_hand {
self.extra.off_hand.as_deref()
} else {
self.extra.main_hand.as_deref()
}
}
/// Whether the species is a `TamableAnimal` (not the horses and llamas,
/// which are tamed another way).
@@ -3319,3 +3371,63 @@ mod breed_xp_tests {
);
}
}
#[cfg(test)]
mod melee_range_tests {
use super::*;
fn mob_holding(item: &str, components: Option<&str>) -> Mob {
let kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
let mut m = Mob::new(1, Arc::new(kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut stack = crate::items::ItemStack::new(item, 1);
if let Some(text) = components {
let tag = horizon_world::nbt::parse_snbt(text).unwrap().0;
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(&tag));
}
m.extra.set_main_hand_stack(Some(stack));
m
}
/// A one-block target whose near face is `gap` blocks east of the mob's box.
fn target(m: &Mob, gap: f64) -> Aabb {
let x = m.bounds().max_position().x + gap;
Aabb::new(Vec3::new(x, 64.0, 0.0), Vec3::new(x + 1.0, 65.0, 1.0))
}
/// `Mob.isWithinMeleeAttackRange` with `getActiveItem().get(ATTACK_RANGE)`:
/// no component reaches sqrt(2.04) - 0.6 (about 0.83); a spear 4.5 and 2.0
/// times its mob factor 0.5; a patched component with the codec's
/// defaults (`mob_factor` 1, `min_reach` 0); a removed one the default.
#[test]
fn the_active_items_attack_range_sets_the_reach() {
let sword = mob_holding("minecraft:iron_sword", None);
assert!(sword.melee_reaches(target(&sword, 0.8)));
assert!(!sword.melee_reaches(target(&sword, 0.9)));
let spear = mob_holding("minecraft:iron_spear", None);
assert!(spear.melee_reaches(target(&spear, 2.2)));
assert!(!spear.melee_reaches(target(&spear, 2.3)));
assert!(
!spear.melee_reaches(target(&spear, 0.5)),
"inside the minimum range"
);
assert!(spear.melee_reaches(target(&spear, 1.1)));
let long = mob_holding(
"minecraft:iron_sword",
Some(r#"{"minecraft:attack_range":{max_reach:6.0f}}"#),
);
assert!(long.melee_reaches(target(&long, 5.9)));
assert!(
long.melee_reaches(target(&long, 0.0)),
"no minimum by default"
);
assert!(!long.melee_reaches(target(&long, 6.1)));
let blunt = mob_holding(
"minecraft:iron_spear",
Some(r#"{"!minecraft:attack_range":{}}"#),
);
assert!(blunt.melee_reaches(target(&blunt, 0.5)));
assert!(!blunt.melee_reaches(target(&blunt, 2.0)));
}
}
+16
View File
@@ -56,6 +56,22 @@ def scenarios():
add(f'{species}_alone', 200, [
f'mob minecraft:{species} 0.5 -60 0.5 90 {seed * 7 + 1} persistent',
])
# A zombie holding a spear (Mob.isWithinMeleeAttackRange): the spear's attack_range times its
# mob_factor, 0.5 to 2.25, instead of the default reach; a player too close is out of range.
add('zombie_spear_player', 200, ['player 6.5 -60 0.5',
'mob minecraft:zombie 0.5 -60 0.5 0 361 persistent hand=iron_spear*1'],
'time=18000')
add('zombie_spear_close', 120, ['player 1.2 -60 0.5',
'mob minecraft:zombie 0.5 -60 0.5 270 362 persistent hand=wooden_spear*1'],
'time=18000')
# A vindicator has no SpearUseGoal, so its MeleeAttackGoal hits with the spear's range: it must keep
# half a block off the player and reaches 2.25 out.
add('vindicator_spear_player', 200, ['player 6.5 -60 0.5',
'mob minecraft:vindicator 0.5 -60 0.5 0 363 persistent hand=iron_spear*1'],
'time=18000')
add('vindicator_spear_close', 120, ['player 1.0 -60 0.5',
'mob minecraft:vindicator 0.5 -60 0.5 270 364 persistent hand=stone_spear*1'],
'time=18000')
# Drowned in a pool with the player on the shore and in the water.
add('drowned_pool', 200, ['fill -6 -64 -6 6 -61 6 minecraft:water',
'player 9.5 -60 0.5',