From fd0cb002962ed5d1ae9488b913a4329f850532e1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:58 +0100 Subject: [PATCH 1/4] Axolotls dry out on land, squid squirt ink when a living entity hurts them --- .../src/game/commands/world_cmds.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 15 +++++- crates/horizon-client/src/mobs/ai.rs | 32 +++++++++++- crates/horizon-client/src/mobs/aquatic.rs | 52 +++++++++++++++++++ crates/horizon-client/src/mobs/axolotl.rs | 2 + 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 30f019997..eac6f125f 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -108,6 +108,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, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..b784b4c98 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3272,6 +3272,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", diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..de9c7dfac 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -515,6 +515,8 @@ pub enum DamageKind { InFire, Lava, Drown, + /// `dry_out`: an axolotl out of water for too long. + DryOut, Magic, Sting, Cactus, @@ -573,7 +575,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 @@ -606,6 +612,7 @@ impl Damage { | DamageKind::InFire | DamageKind::Lava | DamageKind::Drown + | DamageKind::DryOut | DamageKind::Magic | DamageKind::Cactus | DamageKind::Generic @@ -1789,6 +1796,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. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..403f63101 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -441,6 +441,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 && (w.can_see_sky(x, y, z) || w.can_see_sky(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) { m.was_eye_in_water = m.fluids.eye_in_water; @@ -500,7 +528,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { 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 { diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index dc80c6ea4..9603d5e58 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -24,6 +24,57 @@ 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" + }; + if !m.silent { + let pitch = m.voice_pitch(); + 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 +87,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, diff --git a/crates/horizon-client/src/mobs/axolotl.rs b/crates/horizon-client/src/mobs/axolotl.rs index 55f15717c..2815418e3 100644 --- a/crates/horizon-client/src/mobs/axolotl.rs +++ b/crates/horizon-client/src/mobs/axolotl.rs @@ -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( From 75f7029d016b0ff1109f030d20c28471242ce5e1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:37 +0100 Subject: [PATCH 2/4] Squid ink: draw the squirt's voice pitch even when silent Mob.makeSound calls getVoicePitch before Entity.playSound checks isSilent, so the squid's random advances either way. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/aquatic.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index 9603d5e58..f09f7c3b3 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -37,8 +37,9 @@ pub fn spawn_ink(m: &mut Mob) { } else { "squid_ink" }; + // makeSound draws the voice pitch even for a silent squid. + let pitch = m.voice_pitch(); if !m.silent { - let pitch = m.voice_pitch(); let at = [m.position.x, m.position.y, m.position.z]; m.outbox.push(MobEvent::Sound( sound, From efd6c60a272d20f69e498a48afe3e96a759cf164 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:07:36 +0100 Subject: [PATCH 3/4] Axolotl air: refill only where it rains locally, not in deserts or snowy biomes --- crates/horizon-client/src/mobs/ai.rs | 2 +- crates/horizon-client/src/mobs/world.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 403f63101..3499ab29d 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -458,7 +458,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { m.position.z.floor() as i32, ); let top = (m.position.y + f64::from(m.height())).floor() as i32; - let in_rain = w.raining && (w.can_see_sky(x, y, z) || w.can_see_sky(x, top, z)); + 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 { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..db2557e72 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -177,6 +177,25 @@ 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 (its temperature + /// lowered above the snow level) 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)); + let snow_level = self.sea_level + 17; + let temperature = if y > snow_level { + biome.temperature - (y - snow_level) as f32 * 0.05 / 40.0 + } else { + biome.temperature + }; + biome.precipitation && temperature >= 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 { From bb9b0424b44606cd7af0bb57e4610d24c0af2a68 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:52:11 +0100 Subject: [PATCH 4/4] Axolotl rain: the biome's height-adjusted temperature Level.isRainingAt takes Biome.getHeightAdjustedTemperature, which applies the frozen oceans' temperature modifier and the height noise above the snow level; reuse horizon-world's version instead of a simplified copy. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/biome.rs | 25 ++++++++++++++++++++++++- crates/horizon-client/src/mobs/world.rs | 12 +++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 63ad004d4..9d68bfefa 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -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}; @@ -30,6 +30,8 @@ pub struct BiomeInfo { pub creature_probability: f32, pub name: String, pub temperature: f32, + /// `temperature_modifier: frozen` (the frozen oceans). + pub frozen: bool, pub downfall: f32, pub precipitation: bool, pub grass: u32, @@ -42,6 +44,26 @@ pub struct BiomeInfo { pub fog: Option, 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, pub index: HashMap, @@ -129,6 +151,7 @@ impl Biomes { .as_f64() .unwrap_or(0.1) as f32, temperature, + frozen: v["temperature_modifier"].as_str() == Some("frozen"), downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), grass: color(&effects["grass_color"]) diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index db2557e72..f893559f6 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -178,8 +178,8 @@ 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 (its temperature - /// lowered above the snow level) is rain, not snow or none. + /// 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; @@ -188,13 +188,7 @@ impl MobWorld<'_> { return false; }; let biome = self.biomes.get(chunk.biome(x, y, z)); - let snow_level = self.sea_level + 17; - let temperature = if y > snow_level { - biome.temperature - (y - snow_level) as f32 * 0.05 / 40.0 - } else { - biome.temperature - }; - biome.precipitation && temperature >= 0.15 + 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.