diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index c8c15e44e..46c1e16ca 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}; @@ -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, 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, @@ -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"]) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 687eb3f88..1ce11755b 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -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, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f6961ab13..e895cb7f7 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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", diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index f4086216d..0d7fb4b54 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -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. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 258c1ccc7..3406c09d7 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -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) { 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) { 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 3f86d8edc..f63a42239 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -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, 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( diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index aa665decc..bce324228 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -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 {