Merge pull request #2196 from kacpherek/fix/axolotl-squid-water-rules

🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Axolotl.handleAirSupply` (6000 air, refilled by water or rain, `dry_out` damage at -20) and `Squid.hurtServer`'s `spawnInk`. I pushed two fixes: `makeSound`'s voice pitch is drawn even when the squid is silent, so the ink's random draws stay in vanilla's order; and rain now uses the biome's height-adjusted temperature (`Biome.getTemperature` with the frozen modifier and the drop above sea level, `Level.isRainingAt`), not the base temperature, so an axolotl high up a mountain is not refilled by snow. On main with this head: clippy `-D warnings` clean and the 1186 horizon-client lib tests pass. CI parity runs the harnesses this head reaches. Thanks @kacpherek!
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-27 00:09:19 +01:00
commit 4be5c8eb53
8 files changed
+139 -3

No files matched your search

+24 -1
View File
@@ -1,7 +1,7 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -33,6 +33,8 @@ pub struct BiomeInfo {
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
/// `temperature_modifier: frozen` (the frozen oceans).
pub frozen: bool,
pub downfall: f32,
pub precipitation: bool,
pub grass: u32,
@@ -45,6 +47,26 @@ pub struct BiomeInfo {
pub fog: Option<u32>,
pub water_fog: u32,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
use horizon_world::worldgen::biome_temperature::{
BiomeTemperatureSettings, TemperatureModifier,
};
BiomeTemperatureSettings {
base_temperature: self.temperature,
modifier: if self.frozen {
TemperatureModifier::Frozen
} else {
TemperatureModifier::None
},
has_precipitation: self.precipitation,
downfall: self.downfall,
}
.height_adjusted(pos, sea_level)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
@@ -135,6 +157,7 @@ impl Biomes {
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
@@ -115,6 +115,7 @@ impl Game<'_> {
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
@@ -3524,6 +3524,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
+14 -1
View File
@@ -517,6 +517,8 @@ pub enum DamageKind {
InFire,
Lava,
Drown,
/// `dry_out`: an axolotl out of water for too long.
DryOut,
Magic,
Sting,
Cactus,
@@ -575,7 +577,11 @@ impl Damage {
pub fn causes_panic(&self) -> bool {
!matches!(
self.kind,
DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall
DamageKind::Fall
| DamageKind::Drown
| DamageKind::DryOut
| DamageKind::Generic
| DamageKind::InWall
)
}
/// DamageTypeTags.BYPASSES_ARMOR
@@ -608,6 +614,7 @@ impl Damage {
| DamageKind::InFire
| DamageKind::Lava
| DamageKind::Drown
| DamageKind::DryOut
| DamageKind::Magic
| DamageKind::Cactus
| DamageKind::Generic
@@ -1880,6 +1887,12 @@ impl Mob {
}
}
}
// Squid.hurtServer: a squid hurt after a living entity hit it squirts ink.
if matches!(self.species(), Species::Squid | Species::GlowSquid)
&& self.ai.last_hurt_by.is_some()
{
aquatic::spawn_ink(self);
}
true
}
/// LivingEntity.knockback(power, xd, zd) towards the negative direction.
+31 -1
View File
@@ -444,6 +444,34 @@ pub fn tick_swing(m: &mut Mob) {
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
}
/// `LivingEntity.baseTick` refills a breather's air by 4, then
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
/// axolotl dries out.
fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
let before = m.air;
m.air = (m.air + 4).min(super::axolotl::MAX_AIR);
if m.no_ai {
return;
}
// Entity.isInRain: rain at the block, or at the top of the box.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
if m.fluids.in_water || in_rain {
m.air = super::axolotl::MAX_AIR;
} else {
m.air = before - 1;
if m.air <= -20 {
m.air = 0;
m.damage(2.0, Damage::of(DamageKind::DryOut));
}
}
}
/// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick.
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.was_eye_in_water = m.fluids.eye_in_water;
@@ -503,7 +531,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.damage(1.0, Damage::of(DamageKind::InWall));
}
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
if m.kind.flags.needs_water {
if m.species() == Species::Axolotl {
axolotl_air_supply(m, w);
} else if m.kind.flags.needs_water {
if !m.fluids.in_water {
m.air -= 1;
if m.air == -20 {
+53
View File
@@ -24,6 +24,58 @@ const Z_BODY_ROT: usize = 3;
const TENTACLE_SPEED: usize = 4;
const ROTATE_SPEED: usize = 5;
/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under
/// the squid, turned by its body rotation.
pub fn spawn_ink(m: &mut Mob) {
let sound = if m.species() == Species::GlowSquid {
"entity.glow_squid.squirt"
} else {
"entity.squid.squirt"
};
let kind = if m.species() == Species::GlowSquid {
"glow_squid_ink"
} else {
"squid_ink"
};
// makeSound draws the voice pitch even for a silent squid.
let pitch = m.voice_pitch();
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
m.outbox.push(MobEvent::Sound(
sound,
at,
super::ai::sound_volume(m),
pitch,
));
}
// rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO).
let to_radians = (std::f64::consts::PI / 180.0) as f32;
let (x_rot, y_rot) = (
m.anim.vo[X_BODY_ROT] * to_radians,
-m.previous_body_yaw * to_radians,
);
let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot);
let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position);
let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 };
for _ in 0..30 {
let x = f64::from(m.random.next_float()) * 0.6 - 0.3;
let z = f64::from(m.random.next_float()) * 0.6 - 0.3;
let dir = rotate(Vec3::new(x, -1.0, z));
let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0));
// ServerLevel.sendParticles with a count of 0 and speed 0.1: one
// particle moving at the float offset times the float speed.
let speed = f64::from(0.1f32);
m.outbox.push(MobEvent::ParticleAt {
kind,
at: [pos.x, pos.y + 0.5, pos.z],
velocity: [
speed * f64::from(d.x as f32),
speed * f64::from(d.y as f32),
speed * f64::from(d.z as f32),
],
});
}
}
pub fn register(m: &mut Mob) {
use Species as S;
match m.species() {
@@ -36,6 +88,7 @@ pub fn register(m: &mut Mob) {
m.anim.v[TENTACLE_SPEED] = speed;
}
S::Axolotl => {
m.air = super::axolotl::MAX_AIR;
// Native controller scaling is essential: the base speed attribute is 1.0.
m.ai.mov.kind = MoveKind::SmoothSwimming {
max_turn_x: 85.0,
@@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim
use horizon_core::random::RandomSource;
pub const PLAY_DEAD_TICKS: i32 = 200;
pub const HUNTING_COOLDOWN: i64 = 2400;
/// `Axolotl.getMaxAirSupply`.
pub const MAX_AIR: i32 = 6000;
/// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown.
pub fn prey_eligible(
+13
View File
@@ -206,6 +206,19 @@ impl MobWorld<'_> {
}
self.world.light(x, y, z) >> 4 == 15
}
/// Level.isRainingAt: raining, open to the sky, nothing motion blocking
/// above, and the biome's precipitation at the position is rain, not snow
/// or none.
pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool {
if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y {
return false;
}
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
return false;
};
let biome = self.biomes.get(chunk.biome(x, y, z));
biome.precipitation && biome.temperature_at(BlockPos::new(x, y, z), self.sea_level) >= 0.15
}
/// Level.getLightLevelDependentMagicValue: the dimension's brightness curve
/// at the raw (sky darkened) light level.
pub fn brightness(&self, x: i32, y: i32, z: i32) -> f32 {