Merge pull request #59 from Online5277/fix/vanilla-item-use-animations

Fix animations, axolotls, fluids, fire and world progress isolation
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-15 01:36:40 +02:00
commit e96e1571e9
52 files changed
+4737 -448

No files matched your search

+19
View File
@@ -594,6 +594,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
w,
)
}
"minecraft:match_block" => ctx.block_pos.is_some_and(|b| block(v, b, w)),
"minecraft:block_state_property" => ctx.block_pos.is_some_and(|b| {
let mut pred = serde_json::Map::new();
pred.insert("blocks".into(), v["block"].clone());
@@ -952,6 +953,24 @@ mod tests {
}
}
#[test]
fn bucket_axolotl_block_advancements_require_the_placed_block() {
let ctx = Entities {
this: None,
origin: [0.0; 3],
tool: None,
block_pos: Some([0; 3]),
};
let stone = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:stone"});
let wheat = serde_json::json!({"type":"minecraft:match_block","blocks":"minecraft:wheat"});
assert!(conditions(&stone, &ctx, &Flat));
assert!(!conditions(&wheat, &ctx, &Flat));
let absent = Entities {
block_pos: None,
..ctx
};
assert!(!conditions(&stone, &absent, &Flat));
}
#[test]
fn predicates_match_tags_ranges_and_locations() {
let w = Flat;
let stone = ItemView {
+2
View File
@@ -55,6 +55,7 @@ pub struct Assets {
pub properties: Properties,
pub material_states: [BlockStateId; 6],
pub sprite_size: u32,
pub sprite_minimum_size: u32,
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
@@ -239,6 +240,7 @@ impl Assets {
properties,
material_states,
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.layers.len())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
+4
View File
@@ -283,9 +283,11 @@ impl PlayerEffects {
levitation: self.amplifier("levitation"),
resistance: self.amplifier("resistance"),
fire_resistance: self.get("fire_resistance").is_some(),
dolphins_grace: self.get("dolphins_grace").is_some(),
weaving: self.get("weaving").is_some(),
water_breathing: self.get("water_breathing").is_some()
|| self.get("conduit_power").is_some(),
breath_of_the_nautilus: self.get("breath_of_the_nautilus").is_some(),
protection: Vec::new(),
}
}
@@ -379,9 +381,11 @@ pub struct StatusFlags {
pub levitation: Option<i32>,
pub resistance: Option<i32>,
pub fire_resistance: bool,
pub dolphins_grace: bool,
/// Weaving: cobwebs hold half as much (`WebBlock.entityInside`).
pub weaving: bool,
pub water_breathing: bool,
pub breath_of_the_nautilus: bool,
/// Armor enchantment protection per damage type id (`EnchantmentHelper.getDamageProtection`).
pub protection: Vec<(String, f32)>,
}
+228
View File
@@ -12,6 +12,8 @@ use horizon_world::block_collision::BlockCollisionRegistry;
#[derive(Clone, Debug)]
pub struct ItemEntity {
pub health: i32,
pub fire_ticks: i32,
pub id: u32,
pub stack: ItemStack,
pub position: Vec3,
@@ -22,6 +24,17 @@ pub struct ItemEntity {
pub pickup_delay: u32,
pub bob_offset: f32,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
pub struct ItemEnvironmentTick {
pub alive: bool,
pub lava_hurt: bool,
}
impl ItemEnvironmentTick {
pub fn should_play_lava_hurt_sound(&self, tick: u32) -> bool {
self.lava_hurt && (!self.alive || tick.is_multiple_of(10))
}
}
impl ItemEntity {
pub const WIDTH: f64 = 0.25;
pub fn bounds(&self) -> Aabb {
@@ -35,6 +48,52 @@ impl ItemEntity {
),
)
}
pub fn environment_tick(
&mut self,
world: &World,
facts: &BlockFacts,
registry: &horizon_world::registry::BlockRegistry,
items: &crate::items::Items,
) -> ItemEnvironmentTick {
let at = world.block(
self.position.x.floor() as i32,
(self.position.y + 0.1).floor() as i32,
self.position.z.floor() as i32,
);
let fluid = facts.get(at).fluid;
let fire_immune = items.resists_fire(&self.stack, "minecraft:in_fire");
if fluid == Fluid::Water || fire_immune {
self.fire_ticks = 0;
}
if self.fire_ticks > 0 {
if self.fire_ticks % 20 == 0
&& fluid != Fluid::Lava
&& !items.resists_fire(&self.stack, "minecraft:on_fire")
{
self.health -= 1;
}
self.fire_ticks -= 1;
}
let mut lava_hurt = false;
if fluid == Fluid::Lava && !fire_immune {
self.fire_ticks = self.fire_ticks.max(300);
if !items.resists_fire(&self.stack, "minecraft:lava") {
self.health -= 4;
lava_hurt = true;
}
}
let fire_damage = crate::motion::environment::fire_contact(world, registry, self.bounds());
if fire_damage > 0.0 && !fire_immune {
self.fire_ticks = self.fire_ticks.max(160);
if !items.resists_fire(&self.stack, "minecraft:in_fire") {
self.health -= fire_damage as i32;
}
}
ItemEnvironmentTick {
alive: self.health > 0,
lava_hurt,
}
}
/// Returns false when the item should be removed.
pub fn tick(
&mut self,
@@ -59,6 +118,12 @@ impl ItemEntity {
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
} else if facts.get(eye).fluid == Fluid::Lava {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.95,
);
} else {
self.velocity.y -= 0.04;
}
@@ -359,6 +424,8 @@ impl ItemFrame {
/// An armor stand (`ArmorStand`).
#[derive(Clone, Debug)]
pub struct ArmorStand {
pub fire_ticks: i32,
pub health: f32,
pub id: u32,
pub position: Vec3,
pub velocity: Vec3,
@@ -378,6 +445,8 @@ impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
health: 20.0,
id,
position,
velocity: Vec3::ZERO,
@@ -392,6 +461,53 @@ impl ArmorStand {
last_hit: i64::MIN / 2,
}
}
pub fn environment_tick(
&mut self,
world: &World,
facts: &BlockFacts,
registry: &horizon_world::registry::BlockRegistry,
) -> bool {
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
let mut water = false;
let mut lava = false;
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..hi.y.ceil() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
match facts.get(world.block(x, y, z)).fluid {
Fluid::Water => water = true,
Fluid::Lava => lava = true,
_ => {}
}
}
}
}
if water {
self.fire_ticks = 0;
}
if self.fire_ticks > 0 {
if self.fire_ticks % 20 == 0
&& !lava
&& !self.invisible
&& !self.marker
&& self.health > 0.5
{
self.health -= 4.0;
}
self.fire_ticks -= 1;
}
if lava {
self.fire_ticks = self.fire_ticks.max(300);
}
let fire = crate::motion::environment::fire_contact(world, registry, self.bounds()) > 0.0;
if fire && !self.invisible && !self.marker {
if self.fire_ticks > 0 {
self.health -= 0.15;
} else {
self.fire_ticks = 100;
}
}
self.health > 0.5
}
/// EntityType.ARMOR_STAND dimensions (0.5 x 1.975, half for small).
pub fn bounds(&self) -> Aabb {
let s = if self.small { 0.5 } else { 1.0 };
@@ -423,3 +539,115 @@ impl ArmorStand {
self.velocity = v;
}
}
#[cfg(test)]
mod fire_environment_tests {
use super::*;
#[test]
fn ordinary_items_burn_but_native_netherite_resists_lava() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let mut world = World::test_scene(&assets, 3).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
world
.set(
20,
4,
20,
assets.registry.default_state("minecraft:lava").unwrap(),
)
.unwrap();
let make = |id: &str| ItemEntity {
id: 1,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
let first = wood.environment_tick(&world, &facts, assets.registry, &items);
assert!(first.alive && first.lava_hurt);
assert!(
!first.should_play_lava_hurt_sound(1),
"ordinary entry damage must not repeat the burn sound"
);
assert!(first.should_play_lava_hurt_sound(10));
assert!(
wood.fire_ticks > 0,
"lava contact must ignite dropped items"
);
let last = wood.environment_tick(&world, &facts, assets.registry, &items);
assert!(
!last.alive && last.lava_hurt,
"lethal lava damage still emits burn sound"
);
assert!(
last.should_play_lava_hurt_sound(2),
"the lethal hit must always play the sound"
);
for _ in 0..40 {
let result = metal.environment_tick(&world, &facts, assets.registry, &items);
assert!(result.alive && !result.lava_hurt);
assert!(!result.should_play_lava_hurt_sound(10));
}
assert_eq!(metal.health, 5);
assert_eq!(metal.fire_ticks, 0, "fire-resistant items must not ignite");
world
.set(
20,
4,
20,
assets.registry.default_state("minecraft:fire").unwrap(),
)
.unwrap();
let mut burning = make("minecraft:oak_planks");
let result = burning.environment_tick(&world, &facts, assets.registry, &items);
assert!(result.alive && !result.lava_hurt);
assert!(burning.fire_ticks > 0);
world
.set(
20,
4,
20,
assets
.registry
.default_state("minecraft:soul_fire")
.unwrap(),
)
.unwrap();
let mut soul = make("minecraft:oak_planks");
soul.environment_tick(&world, &facts, assets.registry, &items);
assert_eq!(soul.health, 3);
let mut stand = ArmorStand::new(2, at, 0.0);
world
.set(
20,
4,
20,
assets.registry.default_state("minecraft:lava").unwrap(),
)
.unwrap();
for _ in 0..150 {
assert!(stand.environment_tick(&world, &facts, assets.registry));
}
assert_eq!(stand.health, 20.0, "lava does not hurt armor stands");
}
}
+354 -10
View File
@@ -100,6 +100,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
};
let mut tag = base.tag(raw);
for (key, value) in [
(
"Fire",
Tag::Short(m.fire_ticks.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16),
),
("Air", Tag::Short(m.air as i16)),
("Health", Tag::Float(m.health.max(0.0).to_bits())),
(
"HurtTime",
@@ -131,6 +136,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
for (key, on) in [
("NoAI", m.no_ai),
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
@@ -139,6 +146,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
map.remove(&horizon_world::nbt::JavaString::from("TicksFrozen"));
}
for (key, value) in [
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
@@ -324,6 +336,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -584,7 +597,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.main_hand = None;
}
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
m.persistent = flag(tag, "PersistenceRequired");
m.persistent |= flag(tag, "PersistenceRequired");
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
if m.kind.category == "creature" {
@@ -594,9 +607,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
m.no_gravity = m.no_gravity || flag(tag, "NoGravity");
if m.species() == crate::mobs::Species::Creeper {
m.extra.charged = flag(tag, "powered");
// Creeper.readAdditionalSaveData: `ignited` lights the fuse.
@@ -713,6 +728,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
crate::mobs::zombie_villager::read_nbt(&mut m, tag);
}
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
if let Some(Tag::List(inv)) = tag.get("Inventory")
&& let Some(first) = inv.first()
@@ -839,6 +859,8 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
let mut tag = base.tag(raw);
for (key, value) in [
("Item", item_to_nbt(&e.stack)),
("Health", Tag::Short(e.health as i16)),
("Fire", Tag::Short(e.fire_ticks as i16)),
("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)),
(
"PickupDelay",
@@ -847,15 +869,14 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
] {
put(&mut tag, key, value);
}
if let Tag::Compound(map) = &mut tag {
map.entry("Health".into()).or_insert(Tag::Short(5));
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
let stack = item_from_nbt(tag.get("Item")?)?;
let position = position(tag)?;
Some(ItemEntity {
health: number(tag.get("Health")).unwrap_or(5) as i32,
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i32,
id,
stack,
position,
@@ -1178,11 +1199,8 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Marker".into());
}
if let Tag::Compound(map) = &tag
&& !map.contains_key(&horizon_world::nbt::JavaString::from("Health"))
{
put(&mut tag, "Health", Tag::Float(20f32.to_bits()));
}
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
if let Tag::Compound(map) = &mut tag {
for (key, value) in [
@@ -1207,6 +1225,8 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
let mut a = ArmorStand::new(id, position(tag)?, rotation(tag)[0]);
a.velocity = motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
if let Some(eq) = tag.get("equipment") {
for (i, slot) in ArmorStand::SLOTS.iter().enumerate() {
a.equipment[i] = eq.get(slot).and_then(item_from_nbt);
@@ -1458,6 +1478,8 @@ mod tests {
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
health: 2,
fire_ticks: 73,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
@@ -1478,6 +1500,7 @@ mod tests {
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
let back = item_entity_from_nbt(&tag, 9, 0.0).unwrap();
assert_eq!(back.stack.damage, 5);
assert_eq!((back.health, back.fire_ticks), (2, 73));
assert_eq!(
(back.age, back.pickup_delay, back.on_ground),
(120, 10, true)
@@ -1517,3 +1540,324 @@ mod tests {
assert_eq!(back[0].value, 7);
}
}
fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(brain, Tag::Compound(_)) {
*brain = Tag::Compound(Compound::new());
}
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(memories, Tag::Compound(_)) {
*memories = Tag::Compound(Compound::new());
}
let Tag::Compound(memories) = memories else {
return;
};
let mm = m.extra.animal_brain.as_deref().map(|b| &b.mem);
let hunting = crate::mobs::animal_brain::hunting_cooldown(m)
.map(|ttl| Tag::compound([("value", Tag::Byte(1)), ("ttl", Tag::Long(ttl))]));
let play_dead = mm
.and_then(|m| m.play_dead_ticks.value())
.map(|v| Tag::compound([("value", Tag::Int(v))]));
let temptation = mm
.and_then(|m| m.temptation_cooldown_ticks.value())
.map(|v| Tag::compound([("value", Tag::Int(v))]));
for (key, value) in [
("minecraft:has_hunting_cooldown", hunting),
("minecraft:play_dead_ticks", play_dead),
("minecraft:temptation_cooldown_ticks", temptation),
] {
if let Some(value) = value {
memories.insert(key.into(), value);
} else {
memories.remove(&key.into());
}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
.and_then(|b| b.get("minecraft:has_hunting_cooldown"))
.and_then(|v| number(v.get("ttl")))
.unwrap_or(0);
crate::mobs::animal_brain::set_hunting_cooldown(m, ttl);
if let Some(b) = m.extra.animal_brain.as_mut() {
if let Some(v) = memories
.and_then(|b| b.get("minecraft:play_dead_ticks"))
.and_then(|v| number(v.get("value")))
{
b.mem.play_dead_ticks.set(v as i32);
}
if let Some(v) = memories
.and_then(|b| b.get("minecraft:temptation_cooldown_ticks"))
.and_then(|v| number(v.get("value")))
{
b.mem.temptation_cooldown_ticks.set(v as i32);
}
}
}
const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"];
pub fn bucket_data(m: &Mob) -> Tag {
let mut data = Tag::compound([
("Age", Tag::Int(m.extra.age)),
("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))),
("Health", Tag::Float(m.health.to_bits())),
]);
for (key, on) in [
("NoAI", m.no_ai),
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
("PersistenceRequired", m.persistent),
] {
if on {
put(&mut data, key, Tag::Byte(1));
}
}
if let Some(ttl) = crate::mobs::animal_brain::hunting_cooldown(m) {
put(&mut data, "HuntingCooldown", Tag::Long(ttl));
}
data
}
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
let mut data = crate::item_data::StackData {
custom_name: m.custom_name.clone(),
..Default::default()
};
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
if m.species() == crate::mobs::Species::Axolotl {
let variant = AXOLOTL_VARIANTS
.get(usize::from(m.extra.variant))
.unwrap_or(&"lucy");
data.set_other("minecraft:axolotl/variant", Some(string(variant)));
}
data
}
pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) {
let Some(components) = stack.data() else {
return;
};
if let Some(name) = &components.custom_name {
m.custom_name = Some(name.clone());
}
if let Some(data) = components.other("minecraft:bucket_entity_data") {
if let Some(v) = number(data.get("Age")) {
m.extra.age = v as i32;
}
if let Some(v) = number(data.get("AgeLocked")) {
m.extra.age_locked = v != 0;
}
// Keep old Horizon buckets readable; the modern component below takes precedence.
if let Some(v) = number(data.get("Variant")) {
m.extra.variant = v as u8;
}
if let Some(v) = float(data.get("Health")) {
m.health = v as f32;
}
for (key, flag) in [
("NoAI", &mut m.no_ai),
("Silent", &mut m.silent),
("NoGravity", &mut m.no_gravity),
("Glowing", &mut m.glowing),
("Invulnerable", &mut m.invulnerable_flag),
] {
if let Some(v) = number(data.get(key)) {
*flag = v != 0;
}
}
m.persistent |= flag(data, "PersistenceRequired");
if m.species() == crate::mobs::Species::Axolotl {
crate::mobs::animal_brain::set_hunting_cooldown(
m,
number(data.get("HuntingCooldown")).unwrap_or(0),
);
}
}
if m.species() == crate::mobs::Species::Axolotl
&& let Some(name) = text(components.other("minecraft:axolotl/variant"))
&& let Some(id) = AXOLOTL_VARIANTS
.iter()
.position(|&v| v == name.trim_start_matches("minecraft:"))
{
m.extra.variant = id as u8;
}
}
#[cfg(test)]
mod axolotl_nbt_tests {
use super::*;
#[test]
fn bucket_cooldown_and_brain_memories_survive_and_expired_cooldown_is_removed() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let kind = Arc::new(data.types.get("minecraft:axolotl").unwrap().clone());
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 123);
m.extra.variant = 4;
m.extra.age = -24000;
m.extra.age_locked = true;
m.no_ai = true;
m.silent = true;
m.no_gravity = true;
m.glowing = true;
m.invulnerable_flag = true;
m.persistent = true;
m.custom_name = Some("Bucket friend".into());
m.health = 5.0;
{
let mm = &mut m.extra.animal_brain.as_mut().unwrap().mem;
mm.play_dead_ticks.set(78);
mm.temptation_cooldown_ticks.set(42);
}
let raw = Tag::compound([(
"Brain",
Tag::compound([(
"memories",
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
)]),
)]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
assert!(memories.get("test:unrelated").is_some());
assert_eq!(
number(
memories
.get("minecraft:play_dead_ticks")
.unwrap()
.get("value")
),
Some(78)
);
assert_eq!(
number(
memories
.get("minecraft:temptation_cooldown_ticks")
.unwrap()
.get("value")
),
Some(42)
);
let mut stack = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
stack.extra = Some(Box::new(bucket_components(&m)));
assert_eq!(
text(stack.data().unwrap().other("minecraft:axolotl/variant")),
Some("blue".into())
);
assert!(bucket_data(&m).get("Variant").is_none());
let stack = item_from_nbt(&item_to_nbt(&stack)).unwrap();
let mut restored = Mob::new(2, kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
load_bucket_data(&mut restored, &stack);
assert_eq!(
crate::mobs::animal_brain::hunting_cooldown(&restored),
Some(123)
);
assert_eq!(
(
restored.extra.variant,
restored.extra.age,
restored.extra.age_locked
),
(4, -24000, true)
);
assert!(
restored.no_ai
&& restored.silent
&& restored.no_gravity
&& restored.glowing
&& restored.invulnerable_flag
&& restored.persistent
);
assert_eq!(restored.custom_name.as_deref(), Some("Bucket friend"));
assert_eq!(restored.health, 5.0);
m.glowing = false;
m.add_effect("glowing", 200, 0);
assert!(m.has_effect("glowing"));
assert!(bucket_data(&m).get("Glowing").is_none());
m.glowing = true;
assert_eq!(number(bucket_data(&m).get("Glowing")), Some(1));
for (persistent, expected) in [(None, false), (Some(0), false), (Some(1), true)] {
let mut data = Tag::Compound(Compound::new());
if let Some(value) = persistent {
put(&mut data, "PersistenceRequired", Tag::Byte(value));
}
let mut bucket = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
bucket
.data_mut()
.set_other("minecraft:bucket_entity_data", Some(data));
restored.persistent = false;
load_bucket_data(&mut restored, &bucket);
assert_eq!(restored.persistent, expected);
restored.persistent = true;
load_bucket_data(&mut restored, &bucket);
assert!(restored.persistent, "bucket data cannot clear persistence");
}
restored.ticks_frozen = 140;
restored.ignite_for_ticks(160);
assert_eq!(restored.ticks_frozen, 0);
restored.ticks_frozen = 140;
restored.ignite_for_ticks(1);
assert_eq!(
restored.ticks_frozen, 0,
"even an ignition that does not extend burning clears freeze"
);
for (id, name) in AXOLOTL_VARIANTS.iter().enumerate() {
let mut vanilla = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
vanilla.extra = Some(Box::new(crate::item_data::StackData {
other: vec![("minecraft:axolotl/variant".into(), string(name))],
..Default::default()
}));
load_bucket_data(&mut restored, &vanilla);
assert_eq!(
usize::from(restored.extra.variant),
id,
"component-only vanilla buckets work"
);
}
let mut legacy = crate::items::ItemStack::new("minecraft:axolotl_bucket", 1);
legacy.extra = Some(Box::new(crate::item_data::StackData {
other: vec![(
"minecraft:bucket_entity_data".into(),
Tag::compound([("Variant", Tag::Int(3))]),
)],
..Default::default()
}));
load_bucket_data(&mut restored, &legacy);
assert_eq!(restored.extra.variant, 3);
legacy
.data_mut()
.set_other("minecraft:axolotl/variant", Some(string("gold")));
load_bucket_data(&mut restored, &legacy);
assert_eq!(
restored.extra.variant, 2,
"component wins over old Horizon data"
);
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag));
assert!(
saved
.get("Brain")
.unwrap()
.get("memories")
.unwrap()
.get("minecraft:has_hunting_cooldown")
.is_none()
);
restore_axolotl_memories(&mut restored, &saved);
let mm = &restored.extra.animal_brain.as_ref().unwrap().mem;
assert_eq!(mm.play_dead_ticks.value(), Some(78));
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Animated burning-entity flames; the first-person overlay uses screen_effects.
use crate::{hud::Rect, mesher::Vertex};
use std::collections::HashMap;
#[allow(clippy::too_many_arguments)]
pub fn flames(
position: [f64; 3],
width: f32,
height: f32,
camera: glam::DVec3,
yaw: f32,
sprites: &HashMap<String, Rect>,
scene_light: u8,
out: &mut Vec<Vertex>,
) {
let (Some(a), Some(b)) = (sprites.get("block/fire_0"), sprites.get("block/fire_1")) else {
return;
};
let scale = width * 1.4;
if scale <= 0.0 {
return;
}
let origin = glam::DVec3::from(position) - camera;
let rotation = glam::Mat3::from_rotation_y(std::f32::consts::PI - yaw.to_radians());
let mut remaining = height / scale;
let mut half = 0.5f32;
let mut y = 0.0f32;
let mut z = 0.3 - (remaining as i32) as f32 * 0.02;
let mut index = 0;
while remaining > 0.0 {
let r = if index % 2 == 0 { a } else { b };
let (u0, u1) = if (index / 2) % 2 == 0 {
(r.u0, r.u1)
} else {
(r.u1, r.u0)
};
for (p, uv) in [
([-half, y, z], [u0, r.v1]),
([half, y, z], [u1, r.v1]),
([half, y + 1.4, z], [u1, r.v0]),
([-half, y + 1.4, z], [u0, r.v0]),
] {
let p = origin.as_vec3() + rotation * (glam::Vec3::from(p) * scale);
out.push(Vertex {
pos: p.to_array(),
uv,
layer: 0,
color: 0xffff_ffff,
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
normal: [0; 4],
});
}
remaining -= 0.45;
y += 0.45;
half *= 0.9;
z -= 0.03;
index += 1;
}
}
#[cfg(test)]
mod tests {
#[test]
fn flames_cover_entity_height_and_alternate_animated_sprites() {
let rect = |u0| super::Rect {
u0,
v0: 0.0,
u1: u0 + 0.1,
v1: 0.1,
w: 16,
h: 16,
};
let sprites = super::HashMap::from([
("block/fire_0".into(), rect(0.0)),
("block/fire_1".into(), rect(0.5)),
]);
let mut out = Vec::new();
super::flames(
[0.0; 3],
0.6,
1.8,
glam::DVec3::ZERO,
0.0,
&sprites,
0,
&mut out,
);
assert_eq!(out.len() % 4, 0);
assert!(
out[0].pos[2] < 0.0,
"billboard depth bias must face the camera"
);
assert!(
out.iter().all(|v| v.light == 0xf000),
"fire forces block light to 15 but preserves scene sky light"
);
assert!(out.len() >= 20);
assert!(out.iter().any(|v| v.pos[1] >= 1.8));
assert!(
out.iter().all(|v| v.layer == 0),
"flames must not use the breaking-cracks UV-remap material"
);
assert!(out[..4].iter().all(|v| v.uv[0] <= 0.1));
assert!(out[4..8].iter().all(|v| v.uv[0] >= 0.5));
}
}
+289
View File
@@ -8,6 +8,125 @@
//!
//! What those read and do in the world is behind [`HandWorld`].
/// The ranged item state read by the first-person hand renderer.
#[derive(Clone, Copy, Debug)]
pub enum ItemPose {
Swing,
Using,
Eat {
remaining: f32,
duration: f32,
partial: f32,
},
Trident {
ticks_used: f32,
},
Bow {
ticks_used: f32,
},
Crossbow {
ticks_used: Option<f32>,
charge_duration: f32,
charged: bool,
},
}
/// `submitArmWithItem`: the main-hand item pose, before the model transform.
pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let invert = if left { -1.0 } else { 1.0 };
let pi = std::f32::consts::PI;
let mut m = Mat4::from_translation(Vec3::new(
invert * 0.56,
-0.52 - inverse_height * 0.6,
-0.72,
));
if let ItemPose::Eat {
remaining,
duration,
partial,
} = pose
{
let time = remaining - partial + 1.0;
let fraction = time / duration;
let mut eat = Mat4::IDENTITY;
if fraction < 0.8 {
eat *=
Mat4::from_translation(Vec3::new(0.0, ((time / 4.0 * pi).mcos() * 0.1).abs(), 0.0));
}
let jiggle = 1.0 - horizon_core::jmath::pow(f64::from(fraction), 27.0) as f32;
eat *= Mat4::from_translation(Vec3::new(jiggle * 0.6 * invert, jiggle * -0.5, 0.0));
eat *= Mat4::from_rotation_y((invert * jiggle * 90.0).to_radians());
eat *= Mat4::from_rotation_x((jiggle * 10.0).to_radians());
eat *= Mat4::from_rotation_z((invert * jiggle * 30.0).to_radians());
return eat * m;
}
if matches!(pose, ItemPose::Using) {
return m;
}
let charging = match pose {
ItemPose::Bow { ticks_used } => {
let power = ticks_used / 20.0;
Some((
ticks_used,
((power * power + power * 2.0) / 3.0).min(1.0),
false,
))
}
ItemPose::Crossbow {
ticks_used: Some(ticks),
charge_duration,
charged: false,
} => Some((ticks, (ticks / charge_duration).min(1.0), true)),
ItemPose::Trident { ticks_used } => Some((ticks_used, (ticks_used / 10.0).min(1.0), false)),
_ => None,
};
if let Some((ticks, power, crossbow)) = charging {
let trident = matches!(pose, ItemPose::Trident { .. });
let (x, y, z, pitch, yaw) = if trident {
(-0.5, 0.7, 0.1, -55.0f32, 35.3f32)
} else if crossbow {
(-0.4785682, -0.094387, 0.05731531, -11.935f32, 65.3f32)
} else {
(-0.2785682, 0.18344387, 0.15731531, -13.935f32, 35.3f32)
};
m *= Mat4::from_translation(Vec3::new(invert * x, y, z));
m *= Mat4::from_rotation_x(pitch.to_radians());
m *= Mat4::from_rotation_y((invert * yaw).to_radians());
m *= Mat4::from_rotation_z((invert * -9.785f32).to_radians());
if power > 0.1 {
let shake = ((ticks - 0.1) * 1.3).msin() * (power - 0.1);
m *= Mat4::from_translation(Vec3::new(0.0, shake * 0.004, 0.0));
}
m *= Mat4::from_translation(Vec3::new(
0.0,
0.0,
power * if trident { 0.2 } else { 0.04 },
));
m *= Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0 + power * 0.2));
m *= Mat4::from_rotation_y((invert * -45.0f32).to_radians());
} else {
let root = attack.sqrt();
m *= Mat4::from_translation(Vec3::new(
invert * -0.4 * (root * pi).msin(),
0.2 * (root * std::f32::consts::TAU).msin(),
-0.2 * (attack * pi).msin(),
));
m *= Mat4::from_rotation_y(
(invert * (45.0 - (attack * attack * pi).msin() * 20.0)).to_radians(),
);
m *= Mat4::from_rotation_z((invert * (root * pi).msin() * -20.0).to_radians());
m *= Mat4::from_rotation_x(((root * pi).msin() * -80.0).to_radians());
m *= Mat4::from_rotation_y((invert * -45.0f32).to_radians());
if matches!(pose, ItemPose::Crossbow { charged: true, .. }) && attack < 0.001 {
m *= Mat4::from_translation(Vec3::new(invert * -0.641864, 0.0, 0.0));
m *= Mat4::from_rotation_y((invert * 10.0f32).to_radians());
}
}
m
}
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
@@ -240,6 +359,23 @@ pub enum UseResult {
Fail,
}
impl UseResult {
pub fn should_swing(self, before: u32, after: u32) -> bool {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
@@ -628,3 +764,156 @@ mod tests {
assert_eq!(swing_duration(d, None, Some(2)), 12);
}
}
#[cfg(test)]
mod item_pose_tests {
use super::*;
#[test]
fn ranged_draw_uses_native_charge_curves_and_caps() {
for (ticks, stretch) in [
(0.0, 1.0),
(10.0, 1.0 + 0.2 * 1.25 / 3.0),
(20.0, 1.2),
(40.0, 1.2),
] {
let m = item_pose(false, 0.0, 0.0, ItemPose::Bow { ticks_used: ticks });
assert!((m.determinant() - stretch).abs() < 0.000001);
}
for duration in [25.0, 20.0, 15.0, 10.0] {
let m = item_pose(
false,
0.0,
0.0,
ItemPose::Crossbow {
ticks_used: Some(duration),
charge_duration: duration,
charged: false,
},
);
assert!((m.determinant() - 1.2).abs() < 0.000001);
}
}
#[test]
fn left_handed_ranged_poses_mirror_the_right_hand() {
let reflect = glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0));
for pose in [
ItemPose::Swing,
ItemPose::Bow { ticks_used: 10.0 },
ItemPose::Crossbow {
ticks_used: Some(10.0),
charge_duration: 25.0,
charged: false,
},
ItemPose::Crossbow {
ticks_used: None,
charge_duration: 25.0,
charged: true,
},
] {
let left = item_pose(true, 0.2, 0.0, pose).to_cols_array();
let right = (reflect * item_pose(false, 0.2, 0.0, pose) * reflect).to_cols_array();
for (a, b) in left.into_iter().zip(right) {
assert!((a - b).abs() < 0.000001);
}
}
}
#[test]
fn drawing_bow_has_its_own_pose() {
assert_ne!(
item_pose(false, 0.0, 0.0, ItemPose::Bow { ticks_used: 10.0 }),
item_pose(false, 0.0, 0.0, ItemPose::Swing)
);
}
#[test]
fn charging_crossbow_has_its_own_pose() {
assert_ne!(
item_pose(
false,
0.0,
0.0,
ItemPose::Crossbow {
ticks_used: Some(10.0),
charge_duration: 25.0,
charged: false
}
),
item_pose(false, 0.0, 0.0, ItemPose::Swing)
);
}
#[test]
fn charged_crossbow_is_aimed_while_idle() {
assert_ne!(
item_pose(
false,
0.0,
0.0,
ItemPose::Crossbow {
ticks_used: None,
charge_duration: 25.0,
charged: true
}
),
item_pose(false, 0.0, 0.0, ItemPose::Swing)
);
}
}
#[cfg(test)]
mod continuous_use_tests {
use super::*;
#[test]
fn eating_and_throwing_have_use_poses_not_attack_poses() {
let swing = item_pose(false, 0.0, 0.3, ItemPose::Swing);
assert_ne!(
swing,
item_pose(
false,
0.0,
0.3,
ItemPose::Eat {
remaining: 10.0,
duration: 32.0,
partial: 0.5
}
)
);
assert_ne!(
swing,
item_pose(false, 0.0, 0.3, ItemPose::Trident { ticks_used: 5.0 })
);
assert_ne!(swing, item_pose(false, 0.0, 0.3, ItemPose::Using));
}
}
#[cfg(test)]
mod action_result_tests {
use super::*;
#[test]
fn only_successful_actions_swing_once() {
assert!(UseResult::Success { swing: true }.should_swing(2, 2));
for result in [
UseResult::Fail,
UseResult::Pass,
UseResult::Success { swing: false },
] {
assert!(!result.should_swing(2, 2));
}
assert!(!UseResult::Success { swing: true }.should_swing(2, 3));
}
#[test]
fn consumption_pose_requires_eat_or_drink_animation() {
use horizon_world::consumable::UseAnimation::*;
assert_eq!(consumable_animation(&serde_json::json!({})), Eat);
assert_eq!(
consumable_animation(&serde_json::json!({"animation":"drink"})),
Drink
);
for animation in ["none", "block", "bow", "brush"] {
assert_eq!(
consumable_animation(&serde_json::json!({"animation":animation})),
None
);
}
}
}
+364 -126
View File
@@ -895,6 +895,7 @@ struct Game<'a> {
/// Monster spawners' display mobs (`game/spawner.rs`).
spawners: spawner::SpawnerState,
trident_model: Option<horizon_client::mobs::Part>,
arrow_model: Option<horizon_client::mobs::Part>,
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
creative_screen: Creative,
@@ -970,6 +971,7 @@ struct Game<'a> {
tab_list: horizon_client::tab_list::TabList,
subtitles: subtitle_hud::SubtitleState,
adv: advancements::AdvState,
restore_player_environment: bool,
stats_ui: stats_screen::StatsUi,
adv_ui: advancements_screen::AdvUi,
camera_type: perspective::CameraType,
@@ -1285,6 +1287,8 @@ impl Game<'_> {
// ItemEntity.bobOffs comes from the entity's own random, not the level's.
let bob = self.particles.random().next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
health: 5,
fire_ticks: 0,
id: self.next_entity,
stack,
position: p,
@@ -1336,19 +1340,13 @@ impl Game<'_> {
if self.eye_position().is_none() {
return;
}
let Some(stack) = self.inventory[self.selected].as_mut() else {
let Some(thrown) =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all)
else {
return;
};
let count = if all { stack.count } else { 1 };
let thrown = ItemStack {
count,
..stack.clone()
};
stack.count -= count;
if stack.count == 0 {
self.inventory[self.selected] = None;
}
self.player_drop(thrown);
self.swing_arm();
}
/// `Player.drop`: the statistics and a thrown item entity in front of the player.
fn player_drop(&mut self, thrown: ItemStack) {
@@ -1377,10 +1375,32 @@ impl Game<'_> {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
items.retain_mut(|item| {
!ticking(
if !ticking(
item.position.x.floor() as i32,
item.position.z.floor() as i32,
) || item.tick(&self.world, &self.res.facts, self.collision)
) {
return true;
}
if !item.tick(&self.world, &self.res.facts, self.collision) {
return false;
}
let environment = item.environment_tick(
&self.world,
&self.res.facts,
self.res.assets.registry,
&self.res.items,
);
// shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity.
if environment.should_play_lava_hurt_sound(item.age) {
let pitch = 2.0 + self.world.random.next_float() * 0.4;
self.audio.play(
"minecraft:entity.generic.burn",
Some([item.position.x, item.position.y, item.position.z]),
0.4,
pitch,
);
}
environment.alive
});
let Some(p) = self.player.as_ref() else {
self.dropped = items;
@@ -1722,7 +1742,13 @@ impl Game<'_> {
_ => None,
};
if let Some((name, volume)) = sound {
self.audio.play(name, c, volume, 1.0);
let pitch = if id == 1009 {
2.6 + (self.world.random.next_float() - self.world.random.next_float())
* 0.8
} else {
1.0
};
self.audio.play(name, c, volume, pitch);
}
}
LevelEvent::Sound {
@@ -2276,6 +2302,21 @@ impl Game<'_> {
let Some(info) = self.res.items.get(&stack.item).cloned() else {
return Ok(());
};
if self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)? {
return Ok(());
}
use horizon_client::first_person::UseResult;
let swings = self.swings_started;
match self.use_tool(&stack)? {
UseResult::Success { swing } => {
if (UseResult::Success { swing }).should_swing(swings, self.swings_started) {
self.swing_arm();
}
return Ok(());
}
UseResult::Fail => return Ok(()),
UseResult::Pass => {}
}
if self.use_potion_item(&stack)? || self.throw_item(&stack)? {
return Ok(());
}
@@ -2297,23 +2338,15 @@ impl Game<'_> {
return Ok(());
}
if &*stack.item == "minecraft:bow" {
if self.using.is_none() {
if self.using.is_none() && (self.creative || self.arrow_slot().is_some()) {
self.using = Some((self.selected, 0));
}
return Ok(());
}
// Food that is also a block item (berries, carrots, potatoes) aimed at
// a block tries to place first: BlockItem.useOn eats only when the
// placement fails.
// Block foods attempt placement before starting consumption.
if !(info.block.is_some() && self.pick().is_some()) && self.start_eating(&info) {
return Ok(());
}
if self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)? {
return Ok(());
}
if self.use_tool(&stack)? {
return Ok(());
}
if self.place_boat(&stack) {
return Ok(());
}
@@ -2419,7 +2452,8 @@ impl Game<'_> {
/// `Consumable.startConsuming` for held food the player can eat
/// (`Player.canEat`): the right click is held for `consume_seconds`.
fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
if info.food.is_none()
if !info.consumable
|| info.food.is_none()
|| !self
.player
.as_ref()
@@ -2748,10 +2782,17 @@ impl Game<'_> {
};
let mut player = PlayerMotion::new(self.motion, feet)?;
player.yaw = self.yaw;
player.load_environment_once(
&mut self.restore_player_environment,
self.level.player_extra.as_ref(),
);
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
player.may_fly = self.creative || spectator;
player.invulnerable = self.creative || spectator;
player.spectator = spectator;
player.respiration = self.armor[0]
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
player.pitch = self.pitch;
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
player.glider = self.armor[1].as_ref().is_some_and(|s| {
@@ -2859,6 +2900,9 @@ impl Game<'_> {
player.auto_jump_enabled = self.settings.auto_jump;
player.invulnerable = self.creative || spectator;
player.spectator = spectator;
player.respiration = self.armor[0]
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
player.pitch = self.pitch;
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
player.glider = self.armor[1].as_ref().is_some_and(|s| {
@@ -2931,6 +2975,7 @@ impl Game<'_> {
self.record_combat(&events);
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
position, state, ..
} if !crouching => {
@@ -2974,11 +3019,6 @@ impl Game<'_> {
b.notify_written(level, ticks, position, level_flags::UPDATE_ALL)
});
}
MotionEvent::Health(horizon_client::health::HealthEvent::DamageBroadcast {
source,
}) => {
self.player_hurt_sound(source, feet);
}
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
@@ -3076,7 +3116,6 @@ impl Game<'_> {
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
horizon_client::spans::time("tick_effects", || self.tick_effects());
horizon_client::spans::time("tick_burning", || self.tick_burning());
horizon_client::spans::time("tick_using", || self.tick_using());
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
@@ -3477,8 +3516,7 @@ impl Game<'_> {
hunger: effects.get("hunger").is_some(),
hardcore: self.level.hardcore,
damage_cooldown: p.health.hurt.cooldown > 0,
// Horizon does not track the player's air supply yet.
air: 300,
air: p.air,
max_air: 300,
under_water,
vehicle_hearts,
@@ -4826,6 +4864,94 @@ impl Game<'_> {
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
fn entity_geometry(&mut self, camera: glam::DVec3, alpha: f64) -> Vec<Vertex> {
let mut out = Vec::new();
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
self.yaw + 180.0
} else {
self.yaw
};
for m in &self.mobs {
if m.fire_ticks > 0 && !m.removed {
let at = m.previous.lerp(m.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
m.width(),
m.height(),
camera,
fire_yaw,
&self.res.atlas.sprites,
self.world.light(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
),
&mut out,
);
}
}
if !self.camera_type.first_person()
&& let Some(p) = &self.player
&& p.fire_ticks > 0
&& !p.spectator
{
let feet = self.previous_position.lerp(p.position, alpha);
horizon_client::fire_render::flames(
[feet.x, feet.y, feet.z],
p.dimensions.width,
p.dimensions.height,
camera,
fire_yaw,
&self.res.atlas.sprites,
self.world.light(
feet.x.floor() as i32,
feet.y.floor() as i32,
feet.z.floor() as i32,
),
&mut out,
);
}
for item in &self.dropped {
if item.fire_ticks > 0
&& !self
.res
.items
.resists_fire(&item.stack, "minecraft:in_fire")
{
let at = item.previous.lerp(item.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
0.25,
0.25,
camera,
fire_yaw,
&self.res.atlas.sprites,
self.world.light(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
),
&mut out,
);
}
}
for stand in &self.armor_stands {
if stand.fire_ticks > 0 && !stand.marker {
let scale = if stand.small { 0.5 } else { 1.0 };
horizon_client::fire_render::flames(
[stand.position.x, stand.position.y, stand.position.z],
0.5 * scale,
1.975 * scale,
camera,
fire_yaw,
&self.res.atlas.sprites,
self.world.light(
stand.position.x.floor() as i32,
stand.position.y.floor() as i32,
stand.position.z.floor() as i32,
),
&mut out,
);
}
}
// `ItemEntityRenderer`.
self.dropped_item_geometry(camera, alpha, &mut out);
// Mobs and their projectiles.
@@ -5591,6 +5717,8 @@ impl Game<'_> {
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
reset_world_progress(&mut self.adv, &mut self.stats);
self.restore_player_environment = true;
// RandomSequences: the world's data/minecraft/random_sequences.dat.
self.rand_sequences =
horizon_client::save::read_dat(&save_root.join("data/minecraft/random_sequences.dat"))
@@ -5624,6 +5752,7 @@ impl Game<'_> {
let (mut streaming, save) = self.make_streaming(&save_root, dim, level.seed)?;
streaming.radius = self.settings.render_distance;
let mut world = World::new(self.res.facts.air, level.seed);
world.fast_lava = dim == Dim::Nether;
world.game_time = level.time;
world.day_time = self.options.time.unwrap_or(level.day_time);
world.weather = level.weather;
@@ -5869,6 +5998,7 @@ impl Game<'_> {
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
let mut world = World::new(self.res.facts.air, self.level.seed);
world.fast_lava = target == Dim::Nether;
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
world.weather = self.world.weather;
@@ -6380,8 +6510,9 @@ impl Game<'_> {
}
}
}
/// Item use on blocks for tools, buckets and fire starters; returns true if handled.
fn use_tool(&mut self, stack: &ItemStack) -> Result<bool> {
/// Block tool use: distinguish success, failure and fall-through for hand feedback.
fn use_tool(&mut self, stack: &ItemStack) -> Result<horizon_client::first_person::UseResult> {
use horizon_client::first_person::UseResult;
let item = stack.item.trim_start_matches("minecraft:").to_owned();
let at = |p: [i32; 3]| {
[
@@ -6393,7 +6524,7 @@ impl Game<'_> {
if let Some(hit) = self.pick()
&& self.use_on_cauldron(hit.pos)?
{
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
if item == "bucket" {
// getPlayerPOVHitResult(SOURCE_ONLY): powder snow's outline before
@@ -6410,23 +6541,28 @@ impl Game<'_> {
.is_none_or(|fluid| d(fluid.point) >= d(hit.point))
{
self.pick_up_powder_snow(hit.pos)?;
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
}
let Some(hit) = self.pick_fluid() else {
return Ok(false);
return Ok(UseResult::Pass);
};
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let f = self.res.facts.get(state).clone();
if f.fluid == Fluid::None || f.fluid_level != 8 || f.fluid_falling {
return Ok(false);
return Ok(UseResult::Pass);
}
let (filled, sound) = match f.fluid {
Fluid::Water => ("minecraft:water_bucket", "minecraft:item.bucket.fill"),
_ => ("minecraft:lava_bucket", "minecraft:item.bucket.fill_lava"),
let sound = match f.fluid {
Fluid::Water => "minecraft:item.bucket.fill",
_ => "minecraft:item.bucket.fill_lava",
};
self.world
.set(hit.pos[0], hit.pos[1], hit.pos[2], self.world.air)?;
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
let Some(filled) =
self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, pos))
else {
return Ok(UseResult::Pass);
};
self.process_level_events()?;
self.audio.play(sound, Some(at(hit.pos)), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &ItemStack::new(filled, 1));
if !self.creative {
@@ -6445,13 +6581,16 @@ impl Game<'_> {
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
}
}
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
let Some(hit) = self.pick() else {
return Ok(false);
return Ok(UseResult::Pass);
};
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if item == "water_bucket" || item == "lava_bucket" {
if let Some((content, sound, mob)) = horizon_client::items::bucket_contents(&stack.item) {
if !self.may_build() {
return Ok(UseResult::Fail);
}
let target = if self.res.facts.get(clicked).replaceable {
hit.pos
} else {
@@ -6461,34 +6600,35 @@ impl Game<'_> {
hit.pos[2] + hit.face[2],
]
};
let existing = self.world.block(target[0], target[1], target[2]);
if !self.res.facts.get(existing).replaceable && !self.res.facts.is_air(existing) {
return Ok(true);
let pos = BlockPos::new(target[0], target[1], target[2]);
let emptied = self
.with_level(|b, level, ticks| b.bucket_empty(level, ticks, pos, content, sound));
self.process_level_events()?;
if !emptied {
return Ok(UseResult::Fail);
}
let fluid = if item == "water_bucket" {
"minecraft:water"
} else {
"minecraft:lava"
};
self.set_state_named(target, fluid, &[])?;
let sound = if item == "water_bucket" {
"minecraft:item.bucket.empty"
} else {
"minecraft:item.bucket.empty_lava"
};
self.audio.play(sound, Some(at(target)), 1.0, 1.0);
if let Some(kind) = mob {
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::load_bucket_data(m, stack);
}
self.mob_placed(id)?;
}
self.post_block_game_event(
"minecraft:entity_place",
target,
self.sculk_actor(),
None,
);
}
self.trigger_block("minecraft:placed_block", target, stack);
if !self.creative {
self.inventory[self.selected] = Some(ItemStack {
item: Arc::from("minecraft:bucket"),
count: 1,
damage: 0,
extra: None,
});
self.inventory[self.selected] = Some(ItemStack::new("minecraft:bucket", 1));
}
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
if self.use_block_transformer(stack, hit.pos, hit.face)? {
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
if item == "honeycomb" {
let registry = self.res.assets.registry;
@@ -6507,22 +6647,50 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
}
if item == "flint_and_steel" || item == "fire_charge" {
if !self.may_build() {
return Ok(UseResult::Fail);
}
let name = self.res.facts.get(clicked).name.clone();
if self.can_light(clicked, &name) {
let registry = self.res.assets.registry;
let mut state = registry
.state(clicked)
.cloned()
.ok_or("missing lit block state")?;
state.properties.insert("lit".into(), "true".into());
self.level_set(hit.pos, registry.resolve(&state)?)?;
self.audio.play(
if item == "fire_charge" {
"minecraft:item.firecharge.use"
} else {
"minecraft:item.flintandsteel.use"
},
Some(at(hit.pos)),
1.0,
horizon_client::items::fire_starter_pitch(
item == "fire_charge",
&mut self.world.random,
),
);
if item == "fire_charge" {
self.consume_held();
} else {
self.damage_held(1);
}
return Ok(UseResult::Success { swing: true });
}
let target = [
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
];
if self
.res
.facts
.is_air(self.world.block(target[0], target[1], target[2]))
{
if self.can_place_used_fire(target) {
if !self.try_light_portal(target)? {
self.set_state_named(target, "minecraft:fire", &[])?;
self.place_used_fire(target)?;
}
let sound = horizon_client::items::fire_starter_sound(item == "fire_charge");
let pitch = horizon_client::items::fire_starter_pitch(
@@ -6535,36 +6703,11 @@ impl Game<'_> {
} else {
self.damage_held(1);
}
return Ok(true);
return Ok(UseResult::Success { swing: true });
}
return Ok(UseResult::Fail);
}
// Equippable items swap into their armor slot (Equippable.swapWithEquipmentSlot);
// block items aimed at a block are placed instead.
if let Some(slot) = self
.res
.items
.get(&stack.item)
.filter(|i| i.block.is_none())
.and_then(|i| i.equip_slot.clone())
{
let index = match slot.as_str() {
"head" => 0,
"chest" => 1,
"legs" => 2,
"feet" => 3,
_ => return Ok(false),
};
let held = self.inventory[self.selected].take();
self.inventory[self.selected] = self.armor[index].take();
self.armor[index] = held;
if let Some(eye) = self.eye_position() {
self.audio
.play("minecraft:item.armor.equip_generic", Some(eye), 1.0, 1.0);
}
self.apply_equipment();
return Ok(true);
}
Ok(false)
Ok(UseResult::Pass)
}
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
/// `tryMergeToExisting` compares from the level random, then moves with
@@ -6596,6 +6739,21 @@ impl Game<'_> {
on_ground: false,
});
}
fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool {
let pos = BlockPos::new(target[0], target[1], target[2]);
let direction = match ((self.yaw / 90.0 + 0.5).floor() as i32).rem_euclid(4) {
0 => Direction::South,
1 => Direction::West,
2 => Direction::North,
_ => Direction::East,
};
self.with_level(|b, level, ticks| b.fire_can_be_placed_at(level, ticks, pos, direction))
}
fn place_used_fire(&mut self, target: [i32; 3]) -> Result<()> {
let pos = BlockPos::new(target[0], target[1], target[2]);
let state = self.with_level(|b, level, ticks| b.fire_state_for(level, ticks, pos));
self.level_set(target, state)
}
/// DropExperienceBlock / RedStoneOreBlock / SculkBlock experience ranges.
fn block_experience(&mut self, name: &str) -> u32 {
let (min, max) = match name.trim_start_matches("minecraft:") {
@@ -6877,27 +7035,57 @@ impl Game<'_> {
);
}
Some(stack) => {
// applyItemArmTransform + swingArm (applyItemArmAttackTransform)
let x = -0.4 * (sqrt_attack * std::f32::consts::PI).sin();
let y = 0.2 * (sqrt_attack * std::f32::consts::TAU).sin();
let z = -0.2 * (attack * std::f32::consts::PI).sin();
let y_swing_rot = (attack * attack * std::f32::consts::PI).sin();
let xz_swing_rot = (sqrt_attack * std::f32::consts::PI).sin();
let m = to_world
* glam::Mat4::from_translation(glam::Vec3::new(
0.56,
-0.52 + inverse_height * -0.6,
-0.72,
))
* glam::Mat4::from_translation(glam::Vec3::new(x, y, z))
* glam::Mat4::from_rotation_y(deg(45.0 + y_swing_rot * -20.0))
* glam::Mat4::from_rotation_z(deg(xz_swing_rot * -20.0))
* glam::Mat4::from_rotation_x(deg(xz_swing_rot * -80.0))
* glam::Mat4::from_rotation_y(deg(-45.0));
// `ItemInHandRenderer.renderItem`: the player as owner, seeded by
// its id plus the display context ordinal.
let owner = self.player_owner(Some(&stack));
let context = horizon_assets::item_model::DisplayContext::FirstPersonRightHand;
let using = owner.using_this_item && owner.use_item_remaining_ticks > 0;
let ticks_used =
owner.use_duration as f32 - (owner.use_item_remaining_ticks as f32 - a + 1.0);
use horizon_client::first_person::ItemPose;
let pose = match &*stack.item {
"minecraft:bow" if using => ItemPose::Bow { ticks_used },
"minecraft:crossbow" => {
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
ItemPose::Crossbow {
ticks_used: (using && !charged).then_some(ticks_used),
charge_duration: owner.crossbow_charge_duration as f32,
charged,
}
}
"minecraft:trident" if using => ItemPose::Trident { ticks_used },
_ if using => {
let components = self
.res
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(&stack));
let animation = components
.get("minecraft:consumable")
.map(horizon_client::first_person::consumable_animation);
if matches!(
animation,
Some(
horizon_world::consumable::UseAnimation::Eat
| horizon_world::consumable::UseAnimation::Drink
)
) {
ItemPose::Eat {
remaining: owner.use_item_remaining_ticks as f32,
duration: owner.use_duration as f32,
partial: a,
}
} else {
ItemPose::Using
}
}
_ => ItemPose::Swing,
};
let left = self.settings.main_hand_left;
let m = to_world
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
let context = if left {
horizon_assets::item_model::DisplayContext::FirstPersonLeftHand
} else {
horizon_assets::item_model::DisplayContext::FirstPersonRightHand
};
let state = self.resolve_item(&stack, context, Some(&owner), 4);
let light = self.world.light(
eye[0].floor() as i32,
@@ -6918,11 +7106,12 @@ impl Game<'_> {
/// atlas and world border texture.
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
let assets = &self.res.assets;
pollster::block_on(Renderer::new(
let mut renderer = pollster::block_on(Renderer::new(
window,
self.options.offscreen,
&assets.pixels,
assets.sprite_size,
assets.sprite_minimum_size,
assets.texture_layers,
&assets.sprite_mips,
&assets.animations,
@@ -6933,7 +7122,15 @@ impl Game<'_> {
),
(&self.res.forcefield.2, self.res.forcefield.0),
&self.res.panorama,
))
))?;
renderer.hud.fire_animations = self
.res
.atlas
.fire_animations
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
.collect();
Ok(renderer)
}
fn apply_settings(&mut self) {
if let Some(r) = &self.renderer {
@@ -7516,6 +7713,19 @@ impl Game<'_> {
if let Some(p) = &self.player {
level.position = Some([p.position.x, p.position.y, p.position.z]);
level.health = p.health.health();
let extra = level
.player_extra
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
if let horizon_world::nbt::Tag::Compound(map) = extra {
map.insert(
"Fire".into(),
horizon_world::nbt::Tag::Short(
p.fire_ticks.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16,
),
);
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
}
level.food = p.health.food.food;
level.saturation = p.health.food.saturation;
}
@@ -8770,6 +8980,7 @@ impl ApplicationHandler for Game<'_> {
}
_ => {}
}
Ok(())
})();
if let Err(e) = result {
@@ -9180,6 +9391,7 @@ pub fn run() -> Result<()> {
creaking: Default::default(),
spawners: Default::default(),
trident_model: horizon_client::mobs::load_model(&root, "minecraft:trident#main"),
arrow_model: horizon_client::mobs::load_model(&root, "minecraft:arrow#main"),
tnts: Vec::new(),
hoppers: HashMap::new(),
creative_screen: Creative::load(&game_root)?,
@@ -9229,6 +9441,7 @@ pub fn run() -> Result<()> {
tab_list: Default::default(),
subtitles: Default::default(),
adv: Default::default(),
restore_player_environment: true,
stats_ui: Default::default(),
adv_ui: Default::default(),
camera_type: Default::default(),
@@ -9303,6 +9516,31 @@ pub fn run() -> Result<()> {
Ok(())
}
fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Stats) {
*adv = Default::default();
*stats = Default::default();
}
#[cfg(test)]
mod runtime_reset_tests {
use super::*;
#[test]
fn world_entry_invalidates_loaded_progress() {
let mut adv = advancements::AdvState::default();
let mut stats = stats::Stats::default();
adv.loaded = true;
stats.loaded = true;
adv.progress
.criteria
.insert("minecraft:story/root".into(), Default::default());
stats.add("mined", "minecraft:stone", 4);
reset_world_progress(&mut adv, &mut stats);
assert!(!adv.loaded && !stats.loaded);
assert!(adv.progress.criteria.is_empty());
assert!(stats.values.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -45,7 +45,7 @@ impl Toast {
pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
pub(super) progress: Progress,
loaded: bool,
pub(super) loaded: bool,
pub(super) toasts: VecDeque<Toast>,
/// Awards skip the toast (`/advancement grant ... everything`).
pub(super) quiet: bool,
+54 -2
View File
@@ -41,6 +41,9 @@ impl ArmorStandAssets {
}
}
}
if let Some(part) = load("minecraft:elytra#main") {
player.insert("elytra", part);
}
let mut equipment = HashMap::new();
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
for e in entries.flatten() {
@@ -55,7 +58,7 @@ impl ArmorStandAssets {
};
let Ok(v) = v else { continue };
let mut kinds = HashMap::new();
for kind in ["humanoid", "humanoid_leggings"] {
for kind in ["humanoid", "humanoid_leggings", "wings"] {
if let Some(layer) = v["layers"][kind].get(0) {
let tex = layer["texture"]
.as_str()
@@ -365,8 +368,23 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
for a in &mut self.armor_stands {
let mut dead = Vec::new();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.tick(&mut scene);
if !a.environment_tick(&self.world, &self.res.facts, self.res.assets.registry) {
dead.push(i);
}
}
for i in dead.into_iter().rev() {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = burnt_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
@@ -684,3 +702,37 @@ fn skull_kind(item: &str) -> Option<&'static str> {
_ => return None,
})
}
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
let pos = [
a.position.x.floor() as i32,
a.position.y.floor() as i32 + 1,
a.position.z.floor() as i32,
];
let drops = a
.equipment
.into_iter()
.flatten()
.filter(|stack| stack.enchantment_level("minecraft:vanishing_curse") == 0)
.collect();
(pos, drops)
}
#[cfg(test)]
mod burn_drop_tests {
use super::*;
#[test]
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
let kept = ItemStack::new("minecraft:diamond_boots", 1);
let mut vanished = ItemStack::new("minecraft:diamond_helmet", 1);
vanished
.data_mut()
.enchantments
.push(("minecraft:vanishing_curse".into(), 1));
stand.equipment[0] = Some(kept.clone());
stand.equipment[1] = Some(vanished);
let (pos, drops) = burnt_armor_stand_equipment(stand);
assert_eq!(pos, [-1, 5, 0]);
assert_eq!(drops, vec![kept]);
}
}
+1
View File
@@ -79,6 +79,7 @@ impl Game<'_> {
_ => return false,
};
self.open_book(screen);
self.swing_arm();
true
}
fn open_book(&mut self, screen: BookScreen) {
+1 -9
View File
@@ -56,15 +56,7 @@ impl Game<'_> {
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(
&mut self,
source: horizon_world::damage_source::DamageTypeId,
at: [f64; 3],
) {
let Some(d) = self.motion.health.damage.definition(source) else {
return;
};
let sound = horizon_client::health::hurt_sound(&d.effects);
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
}
+86 -68
View File
@@ -40,7 +40,6 @@ fn arrow_impact_sound(
pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Player status effects applied by mobs: (effect, ticks left, amplifier).
pub player_fire: i32,
pub pearl_cooldown: u32,
/// The wind charge's use cooldown (ticks).
pub wind_cooldown: u32,
@@ -821,8 +820,10 @@ impl Game<'_> {
source,
} => {
let mut killed = false;
let mut hurt = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
@@ -839,6 +840,20 @@ impl Game<'_> {
}
}
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
{
self.audio.play(
"minecraft:entity.axolotl.attack",
Some([m.position.x, m.position.y, m.position.z]),
1.0,
1.0,
);
}
if killed && let Some(Who::Mob(killer)) = source.attacker {
self.zombie_killed(killer, target);
}
@@ -993,6 +1008,26 @@ impl Game<'_> {
}
}
MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount),
MobEvent::AxolotlSupportPlayer => {
let previous = self
.potions
.effects
.get("regeneration")
.map_or(0, |e| e.duration);
if (0..=2399).contains(&previous) {
self.potions
.effects
.add(horizon_client::effects::EffectInstance::new(
"regeneration",
(previous + 100).min(2400),
0,
));
}
self.potions
.effects
.list
.retain(|e| e.id != "mining_fatigue");
}
MobEvent::EffectPlayer {
effect,
ticks,
@@ -1002,14 +1037,15 @@ impl Game<'_> {
}
MobEvent::Ignite { target, seconds } => match target {
Who::Player => {
if !self.creative {
self.creatures.player_fire =
self.creatures.player_fire.max(seconds * 20);
if !self.creative
&& let Some(p) = self.player.as_mut()
{
p.ignite_for_ticks(seconds * 20)?;
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.fire_ticks = m.fire_ticks.max(seconds * 20);
m.ignite_for_ticks(seconds * 20);
}
}
},
@@ -1374,24 +1410,6 @@ impl Game<'_> {
}
/// Burning applied to the player by mobs.
fn tick_player_effects(&mut self) {
let in_water = self.player.as_ref().is_some_and(|p| {
let (x, y, z) = (
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
);
self.res.facts.get(self.world.block(x, y, z)).fluid == Fluid::Water
});
if self.creatures.player_fire > 0 {
if in_water {
self.creatures.player_fire = 0;
} else {
if self.creatures.player_fire % 20 == 0 {
self.hurt_player_by("minecraft:on_fire", 1.0);
}
self.creatures.player_fire -= 1;
}
}
self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1);
if self.creatures.pearl_cooldown > 0 {
self.creatures.pearl_cooldown -= 1;
@@ -1846,7 +1864,9 @@ impl Game<'_> {
Hit::Entity { who, .. } => match who {
Who::Player => {
if !self.creative {
self.creatures.player_fire = self.creatures.player_fire.max(100);
if let Some(p) = self.player.as_mut() {
p.ignite_for_ticks(100)?;
}
self.mob_hits_player(
5.0,
[p.previous.x, p.previous.y, p.previous.z],
@@ -1857,7 +1877,7 @@ impl Game<'_> {
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
if !m.kind.flags.fire_immune {
m.fire_ticks = m.fire_ticks.max(100);
m.ignite_for_ticks(100);
}
m.damage(5.0, source);
}
@@ -2075,6 +2095,32 @@ impl Game<'_> {
.as_ref()
.map(|h| h.item.to_string())
.unwrap_or_default();
if item == "minecraft:water_bucket"
&& self.mobs[index].species() == Species::Axolotl
&& self.mobs[index].alive()
{
let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1);
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(
&self.mobs[index],
)));
self.audio.play(
"minecraft:item.bucket.fill_axolotl",
Some(self.mobs[index].center()),
1.0,
1.0,
);
self.trigger_item("minecraft:filled_bucket", &filled);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
self.spawn_item(rest, self.eye_position().unwrap_or([0.0; 3]), [0.0; 3], 40);
}
} else {
self.inventory[self.selected] = Some(filled);
}
self.swing_arm();
return Ok(true);
}
let creative = self.creative;
let selected = self.selected;
if self.interact_zombie_villager(index, &item) {
@@ -2668,7 +2714,7 @@ impl Game<'_> {
}
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: arrows as crossed quads, the rest as item billboards.
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = glam::Vec3::new(
-yaw.sin() * pitch.cos(),
@@ -2703,52 +2749,24 @@ impl Game<'_> {
packed = (15 * 16) | ((15 * 16) << 8);
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/projectiles/arrow").copied() else {
continue;
};
let dir = glam::Vec3::new(
a.velocity.x as f32,
a.velocity.y as f32,
a.velocity.z as f32,
)
.normalize_or(glam::Vec3::X);
let dir = if a.stuck {
glam::Vec3::new(
-a.yaw.to_radians().sin() * a.pitch.to_radians().cos(),
a.pitch.to_radians().sin(),
a.yaw.to_radians().cos() * a.pitch.to_radians().cos(),
)
.normalize_or(dir)
} else {
dir
};
let side = dir.cross(glam::Vec3::Y).normalize_or(glam::Vec3::Z) * 0.08;
let upv = dir.cross(side).normalize_or(glam::Vec3::Y) * 0.08;
let (u1, v1) = (
r.u0 + (r.u1 - r.u0) * 0.5,
r.v0 + (r.v1 - r.v0) * (5.0 / 32.0),
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(a.pitch.to_radians());
horizon_client::mobs::render_model(
model,
m,
&|part| part.pose,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
for off in [side, upv] {
let q = [
c - dir * 0.25 - off,
c + dir * 0.25 - off,
c + dir * 0.25 + off,
c - dir * 0.25 + off,
];
for (v, uv) in q
.iter()
.zip([[r.u0, v1], [u1, v1], [u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
});
}
}
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
+2 -2
View File
@@ -13,7 +13,7 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
const CROSSBOW: &str = "minecraft:crossbow";
impl Game<'_> {
fn charge_ticks(stack: &ItemStack) -> u32 {
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
let seconds = 1.25 - 0.25 * stack.enchantment_level("minecraft:quick_charge") as f32;
(seconds.max(0.0) * 20.0).floor() as u32
@@ -42,7 +42,7 @@ impl Game<'_> {
],
}
}
fn arrow_slot(&self) -> Option<usize> {
pub(super) fn arrow_slot(&self) -> Option<usize> {
self.inventory
.iter()
.position(|s| s.as_ref().is_some_and(|s| &*s.item == "minecraft:arrow"))
+8 -45
View File
@@ -742,7 +742,7 @@ impl Game<'_> {
1.0 + amount
}
/// `EntityType.spawn(.., pos, reason, tryMoveDown, false)` for a mob.
fn spawn_mob_at(&mut self, kind: &str, p: Pos, try_move_down: bool) -> Option<u32> {
pub(super) fn spawn_mob_at(&mut self, kind: &str, p: Pos, try_move_down: bool) -> Option<u32> {
let name = if kind.contains(':') {
kind.to_owned()
} else {
@@ -869,7 +869,7 @@ impl Game<'_> {
self.handle_mob_events(events)
}
/// A mob a bucket emptied: `setFromBucket` keeps it, then it calls out.
fn mob_placed(&mut self, id: u32) -> Result<()> {
pub(super) fn mob_placed(&mut self, id: u32) -> Result<()> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.persistent = true;
}
@@ -1137,48 +1137,8 @@ impl Game<'_> {
) -> Result<Dispensed> {
let item = stack.item.to_string();
let target = offset(pos, facing);
let (content, sound, mob) = match item.as_str() {
"minecraft:lava_bucket" => (
BucketContent::Fluid(FluidKind::Lava),
"minecraft:item.bucket.empty_lava",
None,
),
"minecraft:water_bucket" => (
BucketContent::Fluid(FluidKind::Water),
"minecraft:item.bucket.empty",
None,
),
"minecraft:powder_snow_bucket" => (
BucketContent::Powder,
"minecraft:item.bucket.empty_powder_snow",
None,
),
"minecraft:axolotl_bucket" => (
BucketContent::MobWater,
"minecraft:item.bucket.empty_axolotl",
Some("axolotl"),
),
"minecraft:tadpole_bucket" => (
BucketContent::MobWater,
"minecraft:item.bucket.empty_tadpole",
Some("tadpole"),
),
"minecraft:sulfur_cube_bucket" => (
BucketContent::Nothing,
"minecraft:item.bucket.empty_sulfur_cube",
Some("sulfur_cube"),
),
other => (
BucketContent::MobWater,
"minecraft:item.bucket.empty_fish",
Some(match other {
"minecraft:salmon_bucket" => "salmon",
"minecraft:cod_bucket" => "cod",
"minecraft:pufferfish_bucket" => "pufferfish",
_ => "tropical_fish",
}),
),
};
let (content, sound, mob) =
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self
.with_level(|b, level, ticks| b.bucket_empty(level, ticks, target_pos, content, sound));
@@ -1213,6 +1173,9 @@ impl Game<'_> {
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
// persistent) calls out, then ENTITY_PLACE.
if let Some(id) = self.spawn_mob_at(kind, target, true) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::load_bucket_data(m, &stack);
}
self.mob_placed(id)?;
}
self.post_block_game_event(
@@ -1333,7 +1296,7 @@ impl Game<'_> {
Ok((Some(stack), event))
}
/// `CampfireBlock.canLight || CandleBlock.canLight || CandleCakeBlock.canLight`.
fn can_light(&self, state: BlockStateId, name: &str) -> bool {
pub(super) fn can_light(&self, state: BlockStateId, name: &str) -> bool {
let lit = self.prop(state, "lit");
let waterlogged = self.prop(state, "waterlogged");
let dry_unlit = lit.as_deref() == Some("false") && waterlogged.as_deref() == Some("false");
@@ -159,11 +159,11 @@ impl Game<'_> {
}
}
}
if ignite > 0.0 {
// Entity.igniteForSeconds; mobs burn for 1 damage per second.
let id = self.mobs[mob].id;
self.potions.burning.retain(|(m, _)| *m != id);
self.potions.burning.push((id, (ignite * 20.0) as i32));
if ignite > 0.0
&& let Some(m) = self.mobs.get_mut(mob)
&& !m.kind.flags.fire_immune
{
m.ignite_for_ticks((ignite * 20.0) as i32);
}
self.post_attack_bursts(&held, &kind);
}
@@ -229,25 +229,6 @@ impl Game<'_> {
let _ = self.wind_burst(feet, radius, knockback, false, false);
}
}
/// Burning mobs (fire aspect): one damage every 20 ticks, flame particles.
pub(super) fn tick_burning(&mut self) {
let mut burning = std::mem::take(&mut self.potions.burning);
burning.retain_mut(|(id, ticks)| {
*ticks -= 1;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id && m.alive()) else {
return false;
};
if *ticks % 20 == 0 {
let at = [m.position.x, m.position.y, m.position.z];
if m.hurt(1.0, None, &mut self.world.random) {
self.last_hurt_by_player.insert(*id);
let _ = at;
}
}
*ticks > 0
});
self.potions.burning = burning;
}
/// Thorns on worn armor when a mob at `from` hits the player.
pub(super) fn thorns(&mut self, from: [f64; 3]) {
let attacker = self
@@ -102,6 +102,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.swing_arm();
true
}
pub(super) fn tick_rockets(&mut self) {
+1 -1
View File
@@ -220,7 +220,7 @@ impl Game<'_> {
owner.using_this_item = true;
owner.use_duration = duration;
owner.use_item_remaining_ticks = duration - ticks as i32;
owner.crossbow_charge_duration = 25;
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
}
owner.is_selected =
stack.is_some() && self.inventory.get(self.selected).and_then(Option::as_ref) == stack;
+13 -7
View File
@@ -316,11 +316,11 @@ impl Game<'_> {
_ => {}
}
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
m.fire_ticks = if m.fire_ticks + 1 == 0 {
160
} else {
m.fire_ticks + 1
};
m.fire_ticks += 1;
if m.fire_ticks == 0 {
m.ignite_for_ticks(160);
}
m.damage(5.0, damage);
}
if !events.is_empty() {
@@ -329,8 +329,14 @@ impl Game<'_> {
if let Some(p) = self.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
{
let fire = &mut self.creatures.player_fire;
*fire = if *fire + 1 == 0 { 160 } else { *fire + 1 };
if let Some(p) = self.player.as_mut() {
p.fire_ticks += 1;
if p.fire_ticks == 0
&& let Err(error) = p.ignite_for_ticks(160)
{
self.error = Some(error);
}
}
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
+161 -25
View File
@@ -298,6 +298,14 @@ impl Game<'_> {
}
let crouching = p.pose == Pose::Crouching;
let (position, flying) = (p.position, p.flying || p.fall_flying);
let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity);
let using = self.using.map(|(_, ticks)| ticks as i32 + 1).unwrap_or(0);
let charged = self.inventory[self.selected]
.as_ref()
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
let charge_duration = self.inventory[self.selected]
.as_ref()
.map_or(25, |s| Self::charge_ticks(s).max(1)) as i32;
let attack = (self.swing.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
let (yaw, pitch) = (self.yaw, self.pitch);
@@ -357,6 +365,14 @@ impl Game<'_> {
m.extra.off_hand = off;
m.extra.crouching = crouching;
m.extra.passenger = riding;
m.velocity = velocity;
m.extra.fall_flying = fall_flying;
m.extra.fall_fly_ticks = fly_ticks;
m.extra.main_hand_left = self.settings.main_hand_left;
m.extra.using_item = using;
m.extra.charged = charged;
m.extra.ints[5] = charge_duration;
m.extra.elytra.tick(fall_flying, crouching, velocity);
m.hurt_time = hurt;
}
/// Whether the player's own model shows in the world view.
@@ -401,6 +417,7 @@ impl Game<'_> {
&self.player_armor_layers(),
out,
);
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
@@ -455,6 +472,7 @@ impl Game<'_> {
&self.player_armor_layers(),
&mut verts,
);
self.player_elytra_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
// PictureInPictureRenderer: the box centre, (size, size, -size);
@@ -464,31 +482,12 @@ impl Game<'_> {
* glam::Mat4::from_translation(glam::Vec3::new(0.0, height / 2.0 + offset_y, 0.0))
* glam::Mat4::from_rotation_z(std::f32::consts::PI)
* glam::Mat4::from_rotation_x((y_angle * 20.0).to_radians());
let mut quads: Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
for q in verts.as_chunks::<4>().0 {
let p: [glam::Vec3; 4] =
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
let n = (p[1] - p[0]).cross(p[2] - p[0]);
// The winding gives inward normals: faces with n.z >= 0 point away.
if n.z >= 0.0 {
continue;
}
let n = n.normalize_or_zero();
// Lighting.Entry.ENTITY_IN_UI, roughly: lit from the front and above.
let shade = (0.55 + 0.45 * -n.z - 0.15 * n.y).clamp(0.4, 1.0);
let c = q[0].color;
let ch = |s: u32| ((((c >> s) & 255) as f32 * shade) as u32) << s;
let color = (c & 0xFF00_0000) | ch(0) | ch(8) | ch(16);
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
quads.push((
depth,
p.map(|v| [v.x, v.y]),
std::array::from_fn(|i| q[i].uv),
color,
));
}
// Far (low z) first: the viewer is on the +z side.
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
let fire = self
.player
.as_ref()
.filter(|p| p.fire_ticks > 0 && !p.spectator)
.map(|p| (p.dimensions.width, p.dimensions.height));
let quads = player_preview_quads(verts, gui, fire, &atlas.sprites);
let start = batch.vertices.len();
for (_, p, uv, color) in quads {
batch.quad(p, uv, color);
@@ -510,3 +509,140 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
fn player_elytra_geometry(
&self,
m: &Mob,
camera: glam::DVec3,
partial: f32,
light: u32,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(asset) = self.armor[1]
.as_ref()
.and_then(|s| self.res.items.get(&s.item))
.and_then(|i| i.equip_asset.as_ref())
else {
return;
};
let assets = &self.armor_stand_assets;
let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else {
return;
};
let (Some(model), Some(rect)) = (
assets.player.get("elytra"),
self.res.atlas.sprites.get(texture),
) else {
return;
};
let base = horizon_client::mobs::render::base_transform(m, partial, camera)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0, 0.125));
let angles = m.extra.elytra.angles(partial);
horizon_client::mobs::render_model(
model,
base,
&|part| {
let mut p = part.pose;
if part.name == "left_wing" || part.name == "right_wing" {
let invert = if part.name == "left_wing" { 1.0 } else { -1.0 };
p[1] = if m.extra.crouching { 3.0 } else { 0.0 };
p[3] = angles[0];
p[4] = invert * angles[1];
p[5] = invert * angles[2];
}
p
},
[rect.u0, rect.v0, rect.u1, rect.v1],
light,
0,
out,
);
}
}
fn player_preview_quads(
mut verts: Vec<horizon_client::mesher::Vertex>,
gui: glam::Mat4,
fire: Option<(f32, f32)>,
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
) -> Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> {
let flame_start = verts.len();
if let Some((width, height)) = fire {
horizon_client::fire_render::flames(
[0.0; 3],
width,
height,
glam::DVec3::ZERO,
0.0,
sprites,
0xff,
&mut verts,
);
}
let mut quads: Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
for (index, q) in verts.as_chunks::<4>().0.iter().enumerate() {
let p: [glam::Vec3; 4] =
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
let n = (p[1] - p[0]).cross(p[2] - p[0]);
// The winding gives inward normals: faces with n.z >= 0 point away.
if n.z >= 0.0 {
continue;
}
let n = n.normalize_or_zero();
// Lighting.Entry.ENTITY_IN_UI, roughly: lit from the front and above.
let shade = if index * 4 >= flame_start {
1.0
} else {
(0.55 + 0.45 * -n.z - 0.15 * n.y).clamp(0.4, 1.0)
};
let c = q[0].color;
let ch = |s: u32| ((((c >> s) & 255) as f32 * shade) as u32) << s;
let color = (c & 0xFF00_0000) | ch(0) | ch(8) | ch(16);
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
quads.push((
depth,
p.map(|v| [v.x, v.y]),
std::array::from_fn(|i| q[i].uv),
color,
));
}
// Far (low z) first: the viewer is on the +z side.
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
quads
}
#[cfg(test)]
mod preview_tests {
use super::*;
#[test]
fn burning_inventory_preview_keeps_visible_fullbright_flames() {
let rect = |u0| horizon_client::hud::Rect {
u0,
v0: 0.0,
u1: u0 + 0.1,
v1: 0.1,
w: 16,
h: 16,
};
let sprites = std::collections::HashMap::from([
("block/fire_0".into(), rect(0.0)),
("block/fire_1".into(), rect(0.5)),
]);
for pitch in [-30.0f32, 0.0, 30.0] {
let gui = glam::Mat4::from_scale(glam::Vec3::new(30.0, 30.0, -30.0))
* glam::Mat4::from_rotation_z(std::f32::consts::PI)
* glam::Mat4::from_rotation_x(pitch.to_radians());
assert!(player_preview_quads(Vec::new(), gui, None, &sprites).is_empty());
let quads = player_preview_quads(Vec::new(), gui, Some((0.6, 1.8)), &sprites);
assert!(
!quads.is_empty(),
"inventory projection must not cull all flames"
);
assert!(quads.iter().all(|q| q.3 == 0xffff_ffff));
assert!(quads.iter().any(|q| q.2[0][0] <= 0.1));
assert!(quads.iter().any(|q| q.2[0][0] >= 0.5));
assert!(quads.windows(2).all(|q| q[0].0 <= q[1].0));
}
}
}
+1 -2
View File
@@ -57,8 +57,7 @@ pub(super) struct PotionState {
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
/// Armor the protection table was computed for.
protection_for: Option<Vec<Option<ItemStack>>>,
/// Mobs set on fire by fire aspect: (mob id, ticks left).
pub(super) burning: Vec<(u32, i32)>,
/// Enchantment attribute modifiers on the player: (attribute, id, amount, operation).
pub(super) enchant_modifiers: Vec<(String, String, f64, String)>,
}
@@ -42,7 +42,7 @@ impl Game<'_> {
};
let entered = std::mem::take(&mut player.powder_snow_entered);
let at = [player.position.x, player.position.y, player.position.z];
if entered.is_empty() || self.creatures.player_fire <= 0 {
if entered.is_empty() || player.fire_ticks <= 0 {
return Ok(());
}
for pos in entered {
@@ -50,7 +50,9 @@ impl Game<'_> {
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
}
self.process_level_events()?;
self.creatures.player_fire = 0;
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
// Entity.playEntityOnFireExtinguishedSound
let r = self.particles.random();
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
+2
View File
@@ -1571,6 +1571,8 @@ impl Game<'_> {
let at = Vec3::new(p[0], p[1], p[2]);
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
self.dropped.push(ItemEntity {
health: 5,
fire_ticks: 0,
id: id as u32,
stack,
position: at,
@@ -179,7 +179,9 @@ impl Game<'_> {
partial,
block_overlay,
water,
on_fire: false,
on_fire: self.camera_type.first_person()
&& !self.spectator()
&& self.player.as_ref().is_some_and(|p| p.fire_ticks > 0),
gui_overlays: &gui_overlays,
})
}
+1 -1
View File
@@ -10,7 +10,7 @@ use std::collections::BTreeMap;
#[derive(Default)]
pub(super) struct Stats {
pub(super) values: BTreeMap<String, BTreeMap<String, i32>>,
loaded: bool,
pub(super) loaded: bool,
/// Ticks until the phantom spawner runs again.
phantom_tick: i32,
/// Statistic changes (type, key, amount; `None` = reset) for the
+305 -35
View File
@@ -31,6 +31,78 @@ impl PrimedTnt {
),
)
}
fn tick(
&mut self,
world: &horizon_client::world::World,
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
assets: &horizon_client::assets::Assets,
facts: &horizon_client::blocks::BlockFacts,
) -> Result<()> {
self.previous = self.position;
self.velocity.y -= 0.04;
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let wanted = self.velocity;
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
if moved.z != wanted.z {
self.velocity.z = 0.0;
}
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
self.velocity = Vec3::new(
self.velocity.x * 0.98,
self.velocity.y * 0.98,
self.velocity.z * 0.98,
);
if self.on_ground {
self.velocity = Vec3::new(
self.velocity.x * 0.7,
self.velocity.y * -0.5,
self.velocity.z * 0.7,
);
}
self.fuse -= 1;
if self.fuse > 0 {
// PrimedTnt updates immersion after moving and decrementing its fuse.
let mut fluids =
horizon_world::entity_fluid::EntityFluidInteraction::new([WATER, LAVA]);
let mut fluid_world = TntFluidWorld {
tnt: self,
world,
assets,
facts,
};
fluids
.update(&mut fluid_world, false, 4096)
.map_err(|e| std::io::Error::other(e.to_string()))?;
fluids.apply_current(&mut fluid_world, WATER, 0.014)?;
fluids.apply_current(
&mut fluid_world,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
)?;
}
Ok(())
}
}
impl Game<'_> {
@@ -83,41 +155,12 @@ impl Game<'_> {
let mut i = 0;
while i < count {
let t = &mut self.tnts[i];
t.previous = t.position;
t.velocity.y -= 0.04;
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let wanted = t.velocity;
let moved = scene
.resolve(t.bounds(), wanted, 0.0, t.on_ground)
.unwrap_or(Vec3::ZERO);
t.position = Vec3::new(
t.position.x + moved.x,
t.position.y + moved.y,
t.position.z + moved.z,
);
t.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
if moved.x != wanted.x {
t.velocity.x = 0.0;
}
if moved.z != wanted.z {
t.velocity.z = 0.0;
}
if moved.y != wanted.y {
t.velocity.y = 0.0;
}
t.velocity = Vec3::new(
t.velocity.x * 0.98,
t.velocity.y * 0.98,
t.velocity.z * 0.98,
);
if t.on_ground {
t.velocity = Vec3::new(t.velocity.x * 0.7, t.velocity.y * -0.5, t.velocity.z * 0.7);
}
t.fuse -= 1;
t.tick(
&self.world,
self.collision,
&self.res.assets,
&self.res.facts,
)?;
if t.fuse <= 0 {
let center = [
t.position.x,
@@ -269,3 +312,230 @@ impl Game<'_> {
}
}
}
use horizon_world::{
entity_fluid::{
CurrentTarget, FluidInteractionWorld, FluidMembership, FluidSections, FluidTagId,
},
fluid::{FluidHeightWorld, FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
};
const WATER: FluidTagId = FluidTagId(0);
const LAVA: FluidTagId = FluidTagId(1);
struct TntFluidWorld<'a> {
tnt: &'a mut PrimedTnt,
world: &'a horizon_client::world::World,
assets: &'a horizon_client::assets::Assets,
facts: &'a horizon_client::blocks::BlockFacts,
}
impl TntFluidWorld<'_> {
fn state(&self, p: BlockPos) -> horizon_world::registry::BlockStateId {
self.world.block(p.x, p.y, p.z)
}
fn fluid_at(&self, p: BlockPos) -> std::io::Result<FluidState> {
FluidState::from_block(
self.assets
.registry
.state(self.state(p))
.ok_or_else(|| std::io::Error::other("missing TNT fluid state"))?,
)
}
}
impl FluidMembership for TntFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
match tag {
WATER => matches!(kind, FluidKind::Water | FluidKind::FlowingWater),
LAVA => matches!(kind, FluidKind::Lava | FluidKind::FlowingLava),
_ => false,
}
}
}
impl CurrentTarget for TntFluidWorld<'_> {
type Error = std::io::Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> std::io::Result<Vec3> {
Ok(self.tnt.velocity)
}
fn add_delta_movement(&mut self, v: Vec3) -> std::io::Result<()> {
self.tnt.velocity = Vec3::new(
self.tnt.velocity.x + v.x,
self.tnt.velocity.y + v.y,
self.tnt.velocity.z + v.z,
);
Ok(())
}
}
impl FluidInteractionWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
type Chunk = (i32, i32);
fn interaction_box(&mut self) -> std::io::Result<Option<Aabb>> {
Ok(Some(
self.tnt.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> std::io::Result<Option<Self::Chunk>> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some((x, z)))
}
fn chunk_sections(&mut self, _: Self::Chunk) -> std::io::Result<FluidSections<'_>> {
static SECTIONS: [bool; horizon_client::chunk::SECTIONS] =
[true; horizon_client::chunk::SECTIONS];
Ok(FluidSections {
minimum_section_y: horizon_client::chunk::MIN_Y / 16,
has_fluid: &SECTIONS,
})
}
fn bounding_box_min_y(&self) -> f64 {
self.tnt.position.y
}
fn block_x(&self) -> i32 {
self.tnt.position.x.floor() as i32
}
fn block_z(&self) -> i32 {
self.tnt.position.z.floor() as i32
}
fn eye_y(&mut self) -> std::io::Result<f64> {
Ok(self.tnt.position.y + f64::from(0.98f32 * 0.85f32))
}
fn fluid_state(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
self.fluid_at(p)
}
fn fluid_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
s.height_at(self, [p.x, p.y, p.z])
}
fn camera_height(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<f32> {
s.camera_height_at(self, [p.x, p.y, p.z])
}
fn flow(&mut self, s: FluidState, p: BlockPos) -> std::io::Result<Vec3> {
flow_at(self, s, p)
}
}
impl FluidHeightWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
fn fluid(&mut self, p: [i32; 3]) -> std::io::Result<FluidState> {
self.fluid_at(BlockPos::new(p[0], p[1], p[2]))
}
fn sturdy_down(&mut self, p: [i32; 3]) -> std::io::Result<bool> {
Ok(self
.facts
.get(self.state(BlockPos::new(p[0], p[1], p[2])))
.sturdy_full
& 1
!= 0)
}
}
impl FluidFlowWorld for TntFluidWorld<'_> {
type Error = std::io::Error;
type Block = horizon_world::registry::BlockStateId;
fn fluid(&mut self, p: BlockPos) -> std::io::Result<FluidState> {
self.fluid_at(p)
}
fn block(&mut self, p: BlockPos) -> std::io::Result<Self::Block> {
Ok(self.state(p))
}
fn blocks_fluid_flow(&self, s: Self::Block) -> bool {
self.assets
.blocks_fluid_flow
.contains(&self.assets.registry.state(s).unwrap().name)
}
fn is_ice_class(&self, s: Self::Block) -> bool {
self.facts
.get(s)
.classes
.iter()
.any(|c| c.as_ref() == "IceBlock")
}
fn is_face_sturdy(
&mut self,
s: Self::Block,
_: BlockPos,
d: horizon_core::geometry::Direction,
) -> std::io::Result<bool> {
Ok(self.facts.get(s).sturdy_full & (1 << d as u8) != 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primed_tnt_follows_water_currents_after_moving() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=22 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
}
}
world.set(20, 4, 20, water).unwrap();
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
let mut tnt = PrimedTnt {
position: at,
previous: at,
velocity: Vec3::ZERO,
fuse: 80,
on_ground: false,
};
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert_eq!(
tnt.position.x, at.x,
"current is applied after motion, not before"
);
assert!(
(tnt.velocity.x - 0.014).abs() < 1e-12,
"water must push primed TNT east: {:?}",
tnt.velocity
);
assert_eq!(tnt.fuse, 79);
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
assert!(tnt.on_ground);
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 1;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert_eq!(tnt.fuse, 0);
assert_eq!(
tnt.velocity.x, 0.0,
"expired TNT must not receive a current"
);
world.set(21, 4, 20, water).unwrap();
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 80;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert_eq!(
tnt.velocity.x, 0.0,
"still water must not add horizontal motion"
);
world
.set(
20,
3,
20,
assets.registry.default_state("minecraft:air").unwrap(),
)
.unwrap();
tnt.on_ground = false;
tnt.velocity = Vec3::ZERO;
tnt.tick(&world, &collision, &assets, &facts).unwrap();
assert!(
tnt.position.y < at.y && tnt.velocity.y < 0.0,
"TNT sinks; water does not add buoyancy"
);
}
}
+85 -23
View File
@@ -279,6 +279,7 @@ pub struct Renderer {
sprite_mips: Vec<crate::mipmap::Settings>,
/// Edge length of the block sprite array's layers.
sprite_size: u32,
sprite_minimum_size: u32,
indices: wgpu::Buffer,
index_quads: u32,
instances: wgpu::Buffer,
@@ -398,6 +399,7 @@ impl Renderer {
offscreen: bool,
sprite_pixels: &[u8],
sprite_size: u32,
sprite_minimum_size: u32,
layers: u32,
sprite_mips: &[crate::mipmap::Settings],
animations: &[SpriteAnimation],
@@ -1424,6 +1426,7 @@ impl Renderer {
animations: animations.iter().map(|a| (a.clone(), usize::MAX)).collect(),
sprite_mips: sprite_mips.to_vec(),
sprite_size,
sprite_minimum_size,
indices,
index_quads,
instances,
@@ -1668,7 +1671,12 @@ impl Renderer {
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = levels.min(self.mip_levels - 1);
let levels = sprite_mipmap_limit(
levels,
self.sprite_texture.width(),
self.sprite_minimum_size,
self.mip_levels,
);
if levels == self.mipmaps {
return;
}
@@ -1795,23 +1803,13 @@ impl Renderer {
);
}
fn animate(&mut self, tick: u64) {
for (animation, previous) in &mut self.animations {
let frame = animation.frame(tick);
if frame == *previous {
continue;
}
*previous = frame;
let (mips, levels) = mip_chain(
animation.pixels(frame),
self.sprite_size,
&self.sprite_mips,
animation.layer,
1,
);
let mut offset = 0;
for level in 0..levels.min(self.mip_levels) {
let size = self.sprite_size >> level;
let bytes = (size * size * 4) as usize;
animate_sprites(
&mut self.animations,
tick,
self.sprite_size,
self.mip_levels,
&self.sprite_mips,
|layer, level, size, pixels| {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.sprite_texture,
@@ -1819,11 +1817,11 @@ impl Renderer {
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: animation.layer,
z: layer,
},
aspect: wgpu::TextureAspect::All,
},
&mips[offset..offset + bytes],
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size * 4),
@@ -1835,9 +1833,8 @@ impl Renderer {
depth_or_array_layers: 1,
},
);
offset += bytes;
}
}
},
);
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
if self.suspended {
@@ -1876,6 +1873,7 @@ impl Renderer {
};
if self.animated_textures {
self.animate(frame.ticks);
self.hud.animate_fire(&self.queue, frame.ticks);
}
self.update_lightmap(
&frame.sky,
@@ -2713,3 +2711,67 @@ mod tests {
assert!(super::outline_vertices(&[], glam::DVec3::ZERO).is_empty());
}
}
pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 {
// Only the atlas native minimum determines how many resampling-only mips to skip.
let upscale = (size / minimum.max(1)).max(1).ilog2();
requested.saturating_add(upscale).min(available - 1)
}
fn animate_sprites(
animations: &mut [(crate::sprites::SpriteAnimation, usize)],
tick: u64,
size: u32,
max_levels: u32,
settings: &[crate::mipmap::Settings],
mut write: impl FnMut(u32, u32, u32, &[u8]),
) {
for (animation, previous) in animations {
let frame = animation.frame(tick);
if frame == *previous {
continue;
}
*previous = frame;
let (mips, levels) = mip_chain(animation.pixels(frame), size, settings, animation.layer, 1);
let mut offset = 0;
for level in 0..levels.min(max_levels) {
let edge = size >> level;
let bytes = (edge * edge * 4) as usize;
write(animation.layer, level, edge, &mips[offset..offset + bytes]);
offset += bytes;
}
}
}
#[cfg(test)]
mod sprite_upload_tests {
use super::*;
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
}
}
#[test]
fn animation_updates_entire_layer_every_mip_once() {
for size in [16, 32] {
let animation = crate::sprites::test_animation(size, 7);
let pixels = animation.pixels(animation.frame(1)).to_vec();
let settings = [];
let (expected, levels) = mip_chain(&pixels, size, &settings, 7, 1);
let mut animations = vec![(animation, 0)];
let mut writes = Vec::new();
let mut record = |layer, level, edge, data: &[u8]| {
assert_eq!(layer, 7);
assert_eq!(edge, size >> level);
assert_eq!(data.len(), (edge * edge * 4) as usize);
writes.push(data.to_vec());
};
animate_sprites(&mut animations, 0, size, levels, &settings, &mut record);
animate_sprites(&mut animations, 1, size, levels, &settings, &mut record);
animate_sprites(&mut animations, 1, size, levels, &settings, &mut record);
assert_eq!(writes.len(), levels as usize);
assert_eq!(writes[0], pixels);
assert_eq!(writes.concat(), expected);
}
}
}
+4 -1
View File
@@ -617,7 +617,10 @@ impl HurtWorld for Environment<'_> {
self.phase("death-request")
}
fn hurt_sound(&mut self, _: &mut State) -> io::Result<()> {
self.phase("hurt-sound")
self.phase("hurt-sound")?;
let sound = hurt_sound(&self.source.definition().effects);
self.events.push(MotionEvent::HurtSound(sound));
Ok(())
}
fn game_time(&mut self, _: &mut State) -> io::Result<i64> {
Ok(self.game_time)
+69
View File
@@ -23,6 +23,7 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
pub font: crate::font::Font,
white: Rect,
@@ -261,6 +262,35 @@ impl HudAtlas {
},
);
}
let mut fire_sprites = crate::sprites::Sprites::default();
let mut fire_animations = Vec::new();
for name in [
"block/fire_0",
"block/fire_1",
"block/soul_fire_0",
"block/soul_fire_1",
] {
if let Some(rect) = sprites.get(name).copied() {
let layer = fire_sprites.insert(root, &format!("minecraft:{name}"))?;
if let Some(animation) = fire_sprites
.animations
.iter()
.find(|a| a.layer == layer)
.cloned()
{
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
let x = (rect.u0 * width as f32).round() as usize;
let y = (rect.v0 * height as f32).round() as usize;
for row in 0..rect.h as usize {
let dst = ((y + row) * width as usize + x) * 4;
let src = row * rect.w as usize * 4;
pixels[dst..dst + rect.w as usize * 4]
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
}
fire_animations.push((rect, animation));
}
}
}
let white = sprites["horizon/white"];
let font = crate::font::Font::load(
root,
@@ -275,6 +305,7 @@ impl HudAtlas {
width,
height,
sprites,
fire_animations,
font,
white,
})
@@ -638,6 +669,7 @@ struct HudUniform {
size: [f32; 4],
}
pub struct HudRenderer {
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
/// The shared atlas texture, for run-time glyph uploads.
pub atlas_texture: Option<wgpu::Texture>,
pipeline: wgpu::RenderPipeline,
@@ -782,6 +814,7 @@ impl HudRenderer {
});
Ok(Self {
atlas_texture: None,
fire_animations: Vec::new(),
pipeline,
invert_pipeline,
group,
@@ -790,6 +823,42 @@ impl HudRenderer {
capacity,
})
}
pub fn animate_fire(&mut self, queue: &wgpu::Queue, tick: u64) {
let Some(texture) = &self.atlas_texture else {
return;
};
for (rect, animation, previous) in &mut self.fire_animations {
let frame = animation.frame(tick);
if frame == *previous {
continue;
}
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: (rect.u0 * texture.width() as f32).round() as u32,
y: (rect.v0 * texture.height() as f32).round() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.w * 4),
rows_per_image: Some(rect.h),
},
wgpu::Extent3d {
width: rect.w,
height: rect.h,
depth_or_array_layers: 1,
},
);
*previous = frame;
}
}
pub fn draw(
&mut self,
device: &wgpu::Device,
+143
View File
@@ -41,6 +41,7 @@ pub struct ToolRule {
}
#[derive(Clone, Debug)]
pub struct ItemInfo {
pub fire_resistant: bool,
pub id: Arc<str>,
pub max_stack: u32,
pub max_damage: u32,
@@ -48,6 +49,7 @@ pub struct ItemInfo {
pub tool: Vec<ToolRule>,
pub tool_default_speed: f32,
pub food: Option<(u32, f32)>,
pub consumable: bool,
pub model: String,
/// Main-hand `attack_damage` / `attack_speed` modifier amounts.
pub attack_damage: f32,
@@ -331,6 +333,26 @@ fn entity_spec(tags: &HashMap<String, HashSet<String>>, spec: &str) -> HashSet<S
}
}
impl Items {
pub fn resists_fire(&self, stack: &ItemStack, source: &str) -> bool {
if let Some(data) = stack.data()
&& let Some((_, v)) = data
.other
.iter()
.find(|(key, _)| key == "minecraft:damage_resistant")
{
return match v.get("types") {
Some(horizon_world::nbt::Tag::String(tag)) => {
tag.to_string().is_ok_and(|id|id=="#minecraft:is_fire" || id==source)
}
Some(horizon_world::nbt::Tag::List(tags)) => tags.iter().any(
|t| matches!(t,horizon_world::nbt::Tag::String(id) if id.to_string().is_ok_and(|id|id==source)),
),
_ => false,
};
}
self.get(&stack.item).is_some_and(|i| i.fire_resistant)
}
pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> Result<Self> {
let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new();
for (i, s) in facts.states.iter().enumerate() {
@@ -394,6 +416,8 @@ impl Items {
items.insert(
id.clone(),
ItemInfo {
fire_resistant: c["minecraft:damage_resistant"]["types"].as_str()
== Some("#minecraft:is_fire"),
id: id.clone(),
max_stack: c["minecraft:max_stack_size"].as_u64().unwrap_or(64) as u32,
max_damage: c["minecraft:max_damage"].as_u64().unwrap_or(0) as u32,
@@ -403,6 +427,7 @@ impl Items {
.as_f64()
.unwrap_or(1.0) as f32,
food,
consumable: c.get("minecraft:consumable").is_some(),
model: c["minecraft:item_model"]
.as_str()
.unwrap_or(name)
@@ -1264,6 +1289,94 @@ impl LootContext<'_> {
}
}
/// `DispensibleContainerItem`: shared contents for hand use and dispensers.
pub fn bucket_contents(
item: &str,
) -> Option<(horizon_world::level::BucketContent, &str, Option<&str>)> {
use horizon_world::{fluid::FluidKind, level::BucketContent as C};
Some(match item {
"minecraft:water_bucket" => (
C::Fluid(FluidKind::Water),
"minecraft:item.bucket.empty",
None,
),
"minecraft:lava_bucket" => (
C::Fluid(FluidKind::Lava),
"minecraft:item.bucket.empty_lava",
None,
),
"minecraft:powder_snow_bucket" => {
(C::Powder, "minecraft:item.bucket.empty_powder_snow", None)
}
"minecraft:cod_bucket" => (C::MobWater, "minecraft:item.bucket.empty_fish", Some("cod")),
"minecraft:salmon_bucket" => (
C::MobWater,
"minecraft:item.bucket.empty_fish",
Some("salmon"),
),
"minecraft:pufferfish_bucket" => (
C::MobWater,
"minecraft:item.bucket.empty_fish",
Some("pufferfish"),
),
"minecraft:tropical_fish_bucket" => (
C::MobWater,
"minecraft:item.bucket.empty_fish",
Some("tropical_fish"),
),
"minecraft:axolotl_bucket" => (
C::MobWater,
"minecraft:item.bucket.empty_axolotl",
Some("axolotl"),
),
"minecraft:tadpole_bucket" => (
C::MobWater,
"minecraft:item.bucket.empty_tadpole",
Some("tadpole"),
),
"minecraft:sulfur_cube_bucket" => (
C::Nothing,
"minecraft:item.bucket.empty_sulfur_cube",
Some("sulfur_cube"),
),
_ => return None,
})
}
#[cfg(test)]
mod bucket_use_tests {
use super::*;
#[test]
fn every_filled_bucket_has_the_native_content_and_sound() {
use horizon_world::level::BucketContent as C;
assert_eq!(
bucket_contents("minecraft:powder_snow_bucket"),
Some((C::Powder, "minecraft:item.bucket.empty_powder_snow", None))
);
assert_eq!(
bucket_contents("minecraft:tadpole_bucket"),
Some((
C::MobWater,
"minecraft:item.bucket.empty_tadpole",
Some("tadpole")
))
);
assert_eq!(
bucket_contents("minecraft:sulfur_cube_bucket"),
Some((
C::Nothing,
"minecraft:item.bucket.empty_sulfur_cube",
Some("sulfur_cube")
))
);
for id in ["cod", "salmon", "pufferfish", "tropical_fish", "axolotl"] {
assert!(bucket_contents(&format!("minecraft:{id}_bucket")).is_some());
}
assert!(bucket_contents("minecraft:bucket").is_none());
assert!(bucket_contents("minecraft:milk_bucket").is_none());
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1358,3 +1471,33 @@ mod tests {
);
}
}
pub fn take_held_drop(slot: &mut Option<ItemStack>, all: bool) -> Option<ItemStack> {
let stack = slot.as_mut()?;
if stack.count == 0 {
return None;
}
let count = if all { stack.count } else { 1 };
let thrown = ItemStack {
count,
..stack.clone()
};
stack.count -= count;
if stack.count == 0 {
*slot = None;
}
Some(thrown)
}
#[cfg(test)]
mod held_drop_tests {
use super::*;
#[test]
fn one_all_and_empty_drop() {
let mut slot = Some(ItemStack::new("minecraft:stone", 4));
assert_eq!(take_held_drop(&mut slot, false).unwrap().count, 1);
assert_eq!(slot.as_ref().unwrap().count, 3);
assert_eq!(take_held_drop(&mut slot, true).unwrap().count, 3);
assert!(slot.is_none());
assert!(take_held_drop(&mut slot, false).is_none());
}
}
+1
View File
@@ -53,6 +53,7 @@ pub mod extras;
pub mod extras_hud;
pub mod fast_hash;
pub mod fastutil_order;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod fog;
+93
View File
@@ -10,6 +10,7 @@ pub mod allay;
pub mod anim;
pub mod animal_brain;
pub mod aquatic;
pub mod axolotl;
pub mod body_anim;
pub mod brain;
pub mod breeze;
@@ -677,6 +678,10 @@ pub struct Extra {
pub legs: Option<Arc<str>>,
pub feet: Option<Arc<str>>,
pub using_item: i32,
pub fall_flying: bool,
pub fall_fly_ticks: i32,
pub main_hand_left: bool,
pub elytra: ElytraAnimation,
pub flag: bool,
pub flag2: bool,
/// Species scratch values (the wither's head targets and clocks).
@@ -714,6 +719,7 @@ pub struct Extra {
pub villager: Option<Box<villager::Ext>>,
/// The sniffer's and the ghastling's brain.
pub animal_brain: Option<Box<animal_brain::Brain>>,
pub axolotl: axolotl::Animation,
/// The mob this one rides, and the mob riding it (`getVehicle`, `getFirstPassenger`).
pub mount: Option<u32>,
pub rider: Option<u32>,
@@ -800,6 +806,8 @@ pub struct Mob {
pub invulnerable_flag: bool,
pub climbing: bool,
pub fire_ticks: i32,
pub ticks_frozen: i32,
pub glowing: bool,
pub air: i32,
pub last_hurt: f32,
pub last_damage: Option<(Damage, i64)>,
@@ -968,6 +976,8 @@ pub enum MobEvent {
at: [f64; 3],
amount: u32,
},
/// Regeneration support after an axolotl-assisted kill.
AxolotlSupportPlayer,
/// Apply an effect to the player (witch potions, bee stings, cave spider poison).
EffectPlayer {
effect: &'static str,
@@ -1125,6 +1135,9 @@ pub struct SpawnSetup {
/// A snapshot of another entity mobs can see this tick.
#[derive(Clone, Debug)]
pub struct Seen {
pub in_water: bool,
pub playing_dead: bool,
pub last_damage: Option<Damage>,
pub id: u32,
pub species: Species,
pub position: Vec3,
@@ -1177,12 +1190,30 @@ pub struct Seen {
pub stats: [f64; 3],
}
impl Mob {
pub fn burning_time(&self) -> f64 {
let base = finalize::value(self, finalize::Attr::BurningTime);
let protection: u32 = gear::ARMOR
.into_iter()
.map(|slot| gear::level(self, slot, "minecraft:fire_protection"))
.sum();
(base - 0.15 * f64::from(protection)).max(0.0)
}
pub fn ignite_for_ticks(&mut self, ticks: i32) {
self.fire_ticks = self
.fire_ticks
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
self.ticks_frozen = 0;
}
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by).
pub fn seen(&self, player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
Seen {
in_water: self.fluids.in_water,
playing_dead: axolotl::is_playing_dead(self),
last_damage: self.last_damage.map(|(d, _)| d),
id: m.id,
species: m.species(),
position: m.position,
@@ -1435,6 +1466,8 @@ impl Mob {
invulnerable_flag: false,
climbing: false,
fire_ticks: 0,
ticks_frozen: 0,
glowing: false,
air: 300,
last_hurt: 0.0,
last_damage: None,
@@ -1545,6 +1578,7 @@ impl Mob {
source: Damage,
armor_effectiveness: Option<&mut dyn FnMut(f32) -> f32>,
) -> bool {
axolotl::before_damage(self, amount, &source);
raider::note_hurt(self);
if !self.alive() || self.removed || self.invulnerable_flag {
return false;
@@ -1936,3 +1970,62 @@ mod lighting_tests {
);
}
}
/// `LivingEntity.elytraAnimationState`: eased wing angles and render interpolation.
#[derive(Clone, Copy, Debug, Default)]
pub struct ElytraAnimation {
pub old: [f32; 3],
pub angles: [f32; 3],
}
impl ElytraAnimation {
pub fn tick(&mut self, flying: bool, crouching: bool, velocity: horizon_core::geometry::Vec3) {
use std::f32::consts::PI;
self.old = self.angles;
let target = if flying {
let length =
(velocity.x * velocity.x + velocity.y * velocity.y + velocity.z * velocity.z)
.sqrt();
let ratio = if velocity.y < 0.0 && length > 0.0 {
1.0 - horizon_core::jmath::pow(-velocity.y / length, 1.5) as f32
} else {
1.0
};
[
PI / 12.0 + (PI / 9.0 - PI / 12.0) * ratio,
0.0,
-PI / 12.0 + (-PI / 2.0 + PI / 12.0) * ratio,
]
} else if crouching {
[PI * 2.0 / 9.0, 0.08726646, -PI / 4.0]
} else {
[PI / 12.0, 0.0, -PI / 12.0]
};
for (angle, target) in self.angles.iter_mut().zip(target) {
*angle += (target - *angle) * 0.3;
}
}
pub fn angles(&self, partial: f32) -> [f32; 3] {
std::array::from_fn(|i| self.old[i] + (self.angles[i] - self.old[i]) * partial)
}
}
#[cfg(test)]
mod elytra_tests {
use super::*;
#[test]
fn wings_ease_between_ground_flight_and_crouch() {
let mut wings = ElytraAnimation::default();
wings.tick(false, false, Vec3::ZERO);
assert!((wings.angles[0] - std::f32::consts::PI / 12.0 * 0.3).abs() < 0.000001);
assert_eq!(wings.angles(0.0), wings.old);
assert_eq!(wings.angles(1.0), wings.angles);
for _ in 0..40 {
wings.tick(true, false, Vec3::new(1.0, 0.0, 0.0));
}
assert!((wings.angles[2] + std::f32::consts::FRAC_PI_2).abs() < 0.00001);
for _ in 0..40 {
wings.tick(false, true, Vec3::ZERO);
}
assert!((wings.angles[1] - 0.08726646).abs() < 0.00001);
}
}
+37 -6
View File
@@ -126,7 +126,8 @@ pub fn family(s: Species) -> Family {
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Dolphin => Family::Aquatic,
| S::Dolphin
| S::Axolotl => Family::Aquatic,
S::Giant => Family::Inert,
S::Witch => Family::Witch,
S::Wolf | S::Cat => Family::Tame,
@@ -381,6 +382,7 @@ pub fn tick(
}
events.append(&mut m.outbox);
m.update_walk_animation();
super::axolotl::tick_animation(m);
m.anim.end_tick(m.ai.swinging);
tick_swing(m);
if m.species() == Species::CopperGolem {
@@ -409,6 +411,20 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.fluids.in_water {
m.fall_distance = 0.0;
}
let fire = crate::motion::environment::fire_contact(w.world, w.registry, m.bounds());
if fire > 0.0 {
m.ticks_frozen = 0;
}
if fire > 0.0 && !m.kind.flags.fire_immune {
let multiplier = m.burning_time();
m.fire_ticks = crate::motion::environment::ignite_contact(
m.fire_ticks,
false,
&mut m.random,
multiplier,
);
m.damage(fire, Damage::of(DamageKind::InFire));
}
// Fire: water extinguishes, lava ignites, burning hurts every second.
if m.fluids.in_water && m.fire_ticks > 0 {
m.fire_ticks = 0;
@@ -420,12 +436,12 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
));
}
if m.fluids.in_lava && !m.kind.flags.fire_immune {
m.fire_ticks = m.fire_ticks.max(300);
m.ignite_for_ticks(300);
m.damage(4.0, Damage::of(DamageKind::Lava));
}
if m.fire_ticks > 0 {
if m.kind.flags.fire_immune {
m.fire_ticks = (m.fire_ticks - 4).max(0);
m.fire_ticks = 0;
} else {
if m.fire_ticks % 20 == 0 && !m.fluids.in_lava {
m.damage(1.0, Damage::of(DamageKind::OnFire));
@@ -508,6 +524,9 @@ pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
play_ambient(m, w, events);
}
fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if super::axolotl::is_playing_dead(m) {
return;
}
let sound = match m.species() {
// Warden.getAmbientSound: by anger, silent while roaring or digging.
Species::Warden => match super::warden::ambient_sound(m) {
@@ -686,7 +705,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
}
// Mob.aiStep: undead burn in daylight.
if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() {
m.fire_ticks = m.fire_ticks.max(160);
m.ignite_for_ticks(160);
}
species_ai_step(m, w, events);
}
@@ -844,6 +863,7 @@ fn server_ai_step(
if m.species() == Species::Sniffer
|| m.species() == Species::HappyGhast && m.is_baby()
|| m.species() == Species::CopperGolem
|| m.species() == Species::Axolotl
{
super::animal_brain::tick(m, w, level_random, events);
}
@@ -855,8 +875,10 @@ fn server_ai_step(
m.ai.jumping = false;
return;
}
m.move_control_tick(w, events);
m.look_control_tick();
if !super::axolotl::is_playing_dead(m) {
m.move_control_tick(w, events);
m.look_control_tick();
}
m.jump_control_tick();
}
fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
@@ -1474,3 +1496,12 @@ pub fn swim_sound(m: &Mob) -> &'static str {
_ => "entity.generic.swim",
}
}
#[cfg(test)]
mod bucket_axolotl_tests {
use super::*;
#[test]
fn bucket_axolotl_uses_aquatic_not_generic_land_ai() {
assert_eq!(family(Species::Axolotl), Family::Aquatic);
}
}
+83 -2
View File
@@ -266,6 +266,8 @@ pub struct Hum {
pub left: ArmPose,
pub crouching: bool,
pub passenger: bool,
pub fall_flying: bool,
pub fly_speed_scale: f32,
/// `Some(aggressive)` for `ZombieModel`-style `animateZombieArms`.
pub zombie: Option<bool>,
/// The main hand holds something (`getMainHandItemStack() != EMPTY`).
@@ -282,6 +284,8 @@ impl Hum {
left,
crouching: false,
passenger: false,
fall_flying: false,
fly_speed_scale: 1.0,
zombie: None,
holding: false,
charge: (0.0, 0.0),
@@ -353,7 +357,10 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
/// `HumanoidModel.setupAnim` (and `ZombieModel.setupAttackAnimation`).
pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) {
head(p, s);
let (pos, sp) = (s.pos, s.speed);
if h.fall_flying {
p.xrot("head", -std::f32::consts::FRAC_PI_4);
}
let (pos, sp) = (s.pos, s.speed / h.fly_speed_scale);
p.xrot("right_arm", (pos * 0.6662 + PI).mcos() * 2.0 * sp * 0.5);
p.xrot("left_arm", (pos * 0.6662).mcos() * 2.0 * sp * 0.5);
p.xrot("right_leg", (pos * 0.6662).mcos() * 1.4 * sp);
@@ -539,7 +546,38 @@ pub fn setup(
} else {
ArmPose::Empty
};
let mut h = Hum::new(right, ArmPose::Empty);
let pose = if player {
match m.extra.main_hand.as_deref() {
Some("minecraft:bow") if m.extra.using_item > 0 => ArmPose::Bow,
Some("minecraft:crossbow") if m.extra.using_item > 0 && !m.extra.charged => {
ArmPose::CrossbowCharge
}
Some("minecraft:crossbow") if m.extra.charged && s.swing == 0.0 => {
ArmPose::CrossbowHold
}
Some(_) => ArmPose::Item,
None => ArmPose::Empty,
}
} else {
right
};
let off = if player && m.extra.off_hand.is_some() && !pose.two_handed() {
ArmPose::Item
} else {
ArmPose::Empty
};
let mut h = if m.extra.main_hand_left {
Hum::new(off, pose)
} else {
Hum::new(pose, off)
};
h.charge = (m.extra.ints[5] as f32, m.extra.using_item as f32);
h.fall_flying = m.extra.fall_flying;
if h.fall_flying {
let v = m.velocity;
h.fly_speed_scale =
(((v.x * v.x + v.y * v.y + v.z * v.z) as f32 / 0.2).powi(3)).max(1.0);
}
h.crouching = m.extra.crouching;
h.passenger = m.extra.passenger;
humanoid(p, s, &h);
@@ -630,3 +668,46 @@ pub fn setup(
}
}
}
#[cfg(test)]
mod flight_pose_tests {
use super::*;
#[test]
fn flight_slows_limbs_and_sets_the_head_pose() {
let state = State {
pos: 0.0,
speed: 1.0,
age: 0.0,
y_rot: 0.0,
x_rot: 0.0,
swing: 0.0,
baby: false,
partial: 0.0,
};
let make = || Poses {
map: [
"head",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
"body",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let mut ground = make();
let mut flight = make();
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
humanoid(&mut ground, &state, &h);
h.fall_flying = true;
h.fly_speed_scale = 8.0;
humanoid(&mut flight, &state, &h);
assert_eq!(flight.get("head")[3], -std::f32::consts::FRAC_PI_4);
assert_eq!(flight.get("right_leg")[3], ground.get("right_leg")[3] / 8.0);
}
}
+696 -26
View File
@@ -194,11 +194,21 @@ pub struct Memories {
pub sniff_cooldown: Slot<()>,
pub gaze_cooldown_ticks: Slot<i32>,
pub transport_items_cooldown_ticks: Slot<i32>,
pub nearest_attackable: Slot<Who>,
pub attack_target: Slot<Who>,
pub attack_cooling_down: Slot<bool>,
pub hunting_cooldown: Slot<bool>,
pub play_dead_ticks: Slot<i32>,
pub visited_block_positions: Slot<Vec<[i32; 3]>>,
pub unreachable_transport_block_positions: Slot<Vec<[i32; 3]>>,
}
impl Memories {
fn tick(&mut self) {
self.nearest_attackable.tick();
self.attack_target.tick();
self.attack_cooling_down.tick();
self.hunting_cooldown.tick();
self.play_dead_ticks.tick();
self.mobs.tick();
self.visible_mobs.tick();
self.nearest_players.tick();
@@ -231,6 +241,8 @@ impl Memories {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Act {
Core,
Fight,
PlayDead,
Idle,
Sniff,
Dig,
@@ -240,6 +252,8 @@ impl Act {
pub fn name(self) -> &'static str {
match self {
Act::Core => "core",
Act::Fight => "fight",
Act::PlayDead => "play_dead",
Act::Idle => "idle",
Act::Sniff => "sniff",
Act::Dig => "dig",
@@ -251,6 +265,7 @@ impl Act {
/// Which brain.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Axolotl,
Sniffer,
/// A happy ghast (the brain ticks while it is a ghastling).
Ghastling,
@@ -261,6 +276,8 @@ pub enum Kind {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Sensor {
Living,
AxolotlAttackables,
AdultSameType,
HurtBy,
Players,
Tempting,
@@ -270,6 +287,13 @@ impl Kind {
/// `BRAIN_PROVIDER`'s sensors, in order (all scan every 20 ticks).
fn sensors(self) -> &'static [Sensor] {
match self {
Kind::Axolotl => &[
Sensor::Living,
Sensor::AdultSameType,
Sensor::HurtBy,
Sensor::AxolotlAttackables,
Sensor::Tempting,
],
Kind::Sniffer => &[
Sensor::Living,
Sensor::HurtBy,
@@ -322,7 +346,11 @@ pub(super) fn mem_ref(m: &Mob) -> Option<&Memories> {
/// The brain kind a mob has, if any.
pub fn kind_of(m: &Mob) -> Option<Kind> {
match m.species() {
kind_for_species(m.species())
}
fn kind_for_species(species: Species) -> Option<Kind> {
match species {
Species::Axolotl => Some(Kind::Axolotl),
Species::Sniffer => Some(Kind::Sniffer),
// Every happy ghast has the brain; only the ghastling's ticks.
Species::HappyGhast => Some(Kind::Ghastling),
@@ -353,6 +381,7 @@ pub fn init(m: &mut Mob, shuffle_seed: i64) {
list: Vec::new(),
};
b.list = match kind {
Kind::Axolotl => axolotl_package(shuffle_seed),
Kind::Sniffer => sniffer_package(shuffle_seed),
Kind::Ghastling => ghastling_package(shuffle_seed),
Kind::CopperGolem => copper_golem_package(shuffle_seed),
@@ -399,7 +428,16 @@ pub fn set_breed_partner(m: &mut Mob, partner: u32) {
}
let who = Who::Mob(partner);
mem(m).breed_target.set(who);
lock_gaze_and_walk(m, who, 1.0, 2);
lock_gaze_and_walk(
m,
who,
if m.species() == Species::Axolotl {
0.2
} else {
1.0
},
2,
);
}
/// `AnimalMakeLove.tick` after the birth: the partner forgets this mob.
@@ -413,7 +451,16 @@ pub fn lock_gaze(m: &mut Mob, partner: u32) {
if m.extra.animal_brain.is_none() {
return;
}
lock_gaze_and_walk(m, Who::Mob(partner), 1.0, 2);
lock_gaze_and_walk(
m,
Who::Mob(partner),
if m.species() == Species::Axolotl {
0.2
} else {
1.0
},
2,
);
}
/// What behaviours read and emit during a brain tick.
@@ -457,6 +504,13 @@ pub fn tick(m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom, events: &mut Vec<M
brain_mut(m).list = list;
update_activity(m);
m.ai.panicking = is_panicking(m);
if m.species() == Species::Axolotl {
m.extra.axolotl.playing_dead = mem(m)
.play_dead_ticks
.value()
.is_some_and(|ticks| ticks > 0);
m.ai.target = mem(m).attack_target.value();
}
}
/// `SnifferAi.updateActivity` / `HappyGhastAi.updateActivity`:
@@ -464,10 +518,15 @@ pub fn tick(m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom, events: &mut Vec<M
fn update_activity(m: &mut Mob) {
let b = brain_mut(m);
let mm = &b.mem;
if b.kind == Kind::Axolotl {
update_axolotl_activity(b);
return;
}
let order: &[Act] = match b.kind {
Kind::Sniffer => &[Act::Dig, Act::Sniff, Act::Idle],
Kind::Ghastling => &[Act::Panic, Act::Idle],
Kind::CopperGolem => &[Act::Idle],
Kind::Axolotl => unreachable!(),
};
for &a in order {
let ok = match a {
@@ -484,6 +543,7 @@ fn update_activity(m: &mut Mob) {
Act::Idle => b.kind != Kind::Sniffer || !mm.sniffer_digging.present(),
Act::Panic => mm.is_panicking.present(),
Act::Core => true,
Act::Fight | Act::PlayDead => false,
};
if ok {
b.activity = a;
@@ -523,6 +583,16 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
}
match sensor {
Sensor::Living => sense_living(m, w),
Sensor::AxolotlAttackables => sense_axolotl_attackables(m, w),
Sensor::AdultSameType => {
let found = find_closest(m, w, |m, who| match who {
Who::Mob(id) => w
.seen(id)
.is_some_and(|s| !s.baby && s.species == m.species()),
_ => false,
});
mem(m).nearest_visible_adult.set_opt(found);
}
Sensor::HurtBy => sense_hurt(m, w),
Sensor::Players => sense_players(m, w),
Sensor::Tempting => sense_tempting(m, w),
@@ -759,6 +829,8 @@ impl Timed {
#[derive(Clone, Debug)]
struct Gate {
running: bool,
shuffle: bool,
try_all: bool,
random: LegacyRandom,
kids: Vec<(B, i32, f64)>,
/// The gate's memory requirements.
@@ -768,6 +840,8 @@ impl Gate {
fn run_one(seed: i64, kids: Vec<(B, i32)>) -> Self {
Self {
running: false,
shuffle: true,
try_all: false,
random: LegacyRandom::new(seed),
kids: kids.into_iter().map(|(b, w)| (b, w, 0.0)).collect(),
when: None,
@@ -786,13 +860,15 @@ impl Gate {
self.running = true;
// ShufflingList.shuffle: a fresh weight for each entry in the
// current order, then a stable sort.
for k in &mut self.kids {
let r = self.random.next_float();
k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32));
if self.shuffle {
for k in &mut self.kids {
let r = self.random.next_float();
k.2 = -horizon_core::jmath::pow(f64::from(r), f64::from(1.0_f32 / k.1 as f32));
}
self.kids.sort_by(|a, b| a.2.total_cmp(&b.2));
}
self.kids.sort_by(|a, b| a.2.total_cmp(&b.2));
for k in &mut self.kids {
if !k.0.running() && k.0.try_start(m, cx) {
if !k.0.running() && k.0.try_start(m, cx) && !self.try_all {
break;
}
}
@@ -820,6 +896,12 @@ impl Gate {
#[derive(Clone, Debug)]
enum B {
AxolotlAction {
action: AxolotlAction,
running: bool,
until: i64,
},
PlayDead(Timed),
Swim(Timed),
/// `AnimalPanic`: flying (`AirAndWaterRandomPos`) or on land, and
/// whether the sniffer's override resets its sniffing first.
@@ -890,7 +972,8 @@ enum B {
impl B {
fn running(&self) -> bool {
match self {
B::Swim(t)
B::PlayDead(t)
| B::Swim(t)
| B::AnimalPanic { t, .. }
| B::MoveToTargetSink { t, .. }
| B::CountDownTemptation(t)
@@ -908,7 +991,8 @@ impl B {
| B::CountDownTransport(t) => t.running,
B::Transport(x) => x.running,
B::LookAtPlayerSometimes(running, _) => *running,
B::WalkToLookTarget { running, .. }
B::AxolotlAction { running, .. }
| B::WalkToLookTarget { running, .. }
| B::LookAtPlayer(running)
| B::Stroll { running, .. }
| B::FollowAdult { running, .. } => *running,
@@ -919,7 +1003,8 @@ impl B {
/// cleared with the rest).
fn reset(&mut self) {
match self {
B::Swim(t)
B::PlayDead(t)
| B::Swim(t)
| B::AnimalPanic { t, .. }
| B::CountDownTemptation(t)
| B::MakeLove { t, .. }
@@ -952,7 +1037,8 @@ impl B {
fn one_shot(&self) -> bool {
matches!(
self,
B::WalkToLookTarget { .. }
B::AxolotlAction { .. }
| B::WalkToLookTarget { .. }
| B::LookAtPlayer(_)
| B::LookAtPlayerSometimes(..)
| B::Stroll { .. }
@@ -961,7 +1047,8 @@ impl B {
}
fn set_one_shot(&mut self, on: bool) {
match self {
B::WalkToLookTarget { running, .. }
B::AxolotlAction { running, .. }
| B::WalkToLookTarget { running, .. }
| B::LookAtPlayer(running)
| B::LookAtPlayerSometimes(running, _)
| B::Stroll { running, .. }
@@ -982,6 +1069,19 @@ impl B {
let now = cx.now;
match self {
B::Gate(g) => g.try_start(m, cx),
B::PlayDead(t) => {
if !m.fluids.in_water
|| !mem(m).play_dead_ticks.present()
|| !mem(m).hurt_by_entity.present()
{
return false;
}
t.begin(cx);
mem(m).walk_target.clear();
mem(m).look_target.clear();
m.add_effect("minecraft:regeneration", 200, 0);
true
}
B::Swim(t) => {
if !swim_condition(m) {
return false;
@@ -1082,7 +1182,12 @@ impl B {
partner: m.id,
});
}
lock_gaze_and_walk(m, partner, 1.0, 2);
let speed = if m.species() == Species::Axolotl {
0.2
} else {
1.0
};
lock_gaze_and_walk(m, partner, speed, 2);
*spawn_at = now + 60 + i64::from(m.random.next_int_bound(50).unwrap_or(0));
true
}
@@ -1196,7 +1301,8 @@ impl B {
t.begin(cx);
true
}
B::WalkToLookTarget { .. }
B::AxolotlAction { .. }
| B::WalkToLookTarget { .. }
| B::LookAtPlayer(_)
| B::LookAtPlayerSometimes(..)
| B::Stroll { .. }
@@ -1207,6 +1313,11 @@ impl B {
/// A one-shot's trigger.
fn trigger(&mut self, m: &mut Mob, cx: &mut Bx) -> bool {
match *self {
B::AxolotlAction {
action,
ref mut until,
..
} => axolotl_action(m, cx, action, until),
B::WalkToLookTarget { speed, close, .. } => {
let mm = mem(m);
if mm.walk_target.present() {
@@ -1252,7 +1363,11 @@ impl B {
let Some(t) = found else { return false };
// Ticker.tickDownAndCheck (UniformInt 40..80 on the level random).
if *ticks == 0 {
*ticks = 40 + cx.lr.next_int_bound(41).unwrap_or(0) - 1;
*ticks = if m.species() == Species::Axolotl {
30 + cx.lr.next_int_bound(31).unwrap_or(0) - 1
} else {
40 + cx.lr.next_int_bound(41).unwrap_or(0) - 1
};
return false;
}
*ticks -= 1;
@@ -1265,6 +1380,9 @@ impl B {
B::Stroll {
fly, speed, range, ..
} => {
if m.species() == Species::Axolotl && m.fluids.in_water {
return false;
}
if mem(m).walk_target.present() {
return false;
}
@@ -1295,23 +1413,39 @@ impl B {
};
// closerThan(adult, 17) && !closerThan(adult, 3)
let d = m.distance_sqr(at.position);
if !(3.0 * 3.0..17.0 * 17.0).contains(&d) {
let min = if m.species() == Species::Axolotl {
5.0
} else {
3.0
};
if !(min * min..17.0 * 17.0).contains(&d) {
return false;
}
let speed = if m.species() == Species::Axolotl {
axolotl_speed(m, true)
} else {
1.1
};
let close = if m.species() == Species::Axolotl {
4
} else {
2
};
let eyes = m.species() != Species::Axolotl;
let mm = mem(m);
mm.walk_target.set(WalkTarget {
target: Tracker::Entity {
who: adult,
eye: true,
block_eye: true,
eye: eyes,
block_eye: eyes,
},
speed: 1.1,
close: 2,
speed,
close,
});
mm.look_target.set(Tracker::Entity {
who: adult,
eye: true,
block_eye: true,
block_eye: eyes,
});
true
}
@@ -1327,6 +1461,12 @@ impl B {
}
let now = cx.now;
match self {
B::AxolotlAction { .. } => {}
B::PlayDead(t) => {
if t.timed_out(now) || !m.fluids.in_water || !mem(m).play_dead_ticks.present() {
self.do_stop(m, cx);
}
}
B::Gate(g) => g.tick_or_stop(m, cx),
B::Swim(t) => {
if t.timed_out(now) || !swim_condition(m) {
@@ -1419,7 +1559,16 @@ impl B {
self.do_stop(m, cx);
return;
}
lock_gaze_and_walk(m, partner, 1.0, 2);
lock_gaze_and_walk(
m,
partner,
if m.species() == Species::Axolotl {
0.2
} else {
1.0
},
2,
);
if let Who::Mob(id) = partner {
cx.events.push(MobEvent::LockGaze {
mob: id,
@@ -1476,7 +1625,11 @@ impl B {
let near =
cx.w.target_info(player)
.is_some_and(|t| m.distance_sqr(t.position) < close * close);
let speed = 1.25;
let speed = if m.species() == Species::Axolotl {
axolotl_speed(m, false)
} else {
1.25
};
let mm = mem(m);
if near {
mm.walk_target.clear();
@@ -1560,6 +1713,8 @@ impl B {
}
let now = cx.now;
match self {
B::AxolotlAction { .. } => {}
B::PlayDead(t) => t.running = false,
B::Gate(g) => g.do_stop(m, cx),
B::Swim(t) | B::DoNothing(t) => t.running = false,
B::AnimalPanic { t, .. } => {
@@ -1788,6 +1943,7 @@ fn follow_temptation_close(m: &mut Mob) -> f64 {
3.5
}
}
Kind::Axolotl => 2.5,
Kind::Ghastling | Kind::CopperGolem => 3.0,
}
}
@@ -1797,8 +1953,8 @@ fn can_mate_with(m: &Mob, w: &MobWorld, partner: Who) -> bool {
s.species == m.species()
&& s.in_love
&& m.extra.in_love > 0
&& sniffer::can_mate_state(sniffer::state(m))
&& sniffer::can_mate_state(s.variant)
&& (m.species() != Species::Sniffer
|| sniffer::can_mate_state(sniffer::state(m)) && sniffer::can_mate_state(s.variant))
}
fn partner_panicking(_w: &MobWorld, _partner: Who) -> bool {
false
@@ -2093,6 +2249,438 @@ fn move_to_target_sink(min: i32, max: i32) -> B {
}
}
// ---- AxolotlAi -----------------------------------------------------------
#[derive(Clone, Copy, Debug)]
enum AxolotlAction {
ValidateDead,
StartAttack,
StopAttack,
Chase,
Bite,
EraseAttackBreeding,
EraseDeadBreeding,
FindLiquid,
SwimStroll,
WalkFromLook,
StayWater,
StayGround,
}
fn axolotl_speed(m: &Mob, chase: bool) -> f32 {
if m.fluids.in_water {
if chase { 0.6 } else { 0.5 }
} else {
0.15
}
}
pub fn set_play_dead(m: &mut Mob) {
if m.extra.animal_brain.is_none() {
init(m, i64::from(m.id));
}
mem(m).play_dead_ticks.set(super::axolotl::PLAY_DEAD_TICKS);
}
pub fn hunting_cooldown(m: &Mob) -> Option<i64> {
mem_ref(m).and_then(|mm| {
mm.hunting_cooldown
.get()
.and_then(|_| (mm.hunting_cooldown.ttl > 0).then_some(mm.hunting_cooldown.ttl))
})
}
pub fn set_hunting_cooldown(m: &mut Mob, ticks: i64) {
if m.extra.animal_brain.is_none() {
init(m, i64::from(m.id));
}
if ticks > 0 {
mem(m).hunting_cooldown.set_ttl(true, ticks);
} else {
mem(m).hunting_cooldown.clear();
}
}
fn validate_play_dead(b: &mut Brain) -> bool {
let Some(ticks) = b.mem.play_dead_ticks.value() else {
return false;
};
if ticks <= 0 {
b.mem.play_dead_ticks.clear();
b.mem.hurt_by_entity.clear();
b.activity = Act::Idle;
} else {
b.mem.play_dead_ticks.set(ticks - 1);
}
true
}
fn update_axolotl_activity(b: &mut Brain) {
let old = b.activity;
// PLAY_DEAD remains active until ValidatePlayDead resets to default IDLE.
if old == Act::PlayDead {
return;
}
b.activity = if b.mem.play_dead_ticks.present() {
Act::PlayDead
} else if b.mem.attack_target.present() {
Act::Fight
} else {
Act::Idle
};
if old != b.activity && old == Act::Fight {
b.mem
.hunting_cooldown
.set_ttl(true, super::axolotl::HUNTING_COOLDOWN);
b.mem.attack_target.clear();
}
}
fn sense_axolotl_attackables(m: &mut Mob, w: &MobWorld) {
let cooldown = mem(m).hunting_cooldown.present();
let target = find_closest(m, w, |m, who| {
let Who::Mob(id) = who else { return false };
let Some(s) = w.seen(id) else { return false };
let id = format!("minecraft:{}", super::species::def(s.species).name);
super::axolotl::prey_eligible(
m.distance_sqr(s.position),
s.in_water,
w.data.entity_has("minecraft:axolotl_always_hostiles", &id),
w.data.entity_has("minecraft:axolotl_hunt_targets", &id),
cooldown,
s.alive && !s.playing_dead,
)
});
mem(m).nearest_attackable.set_opt(target);
}
fn axolotl_action(m: &mut Mob, cx: &mut Bx, action: AxolotlAction, until: &mut i64) -> bool {
use AxolotlAction::*;
match action {
ValidateDead => validate_play_dead(brain_mut(m)),
StartAttack => {
let mm = mem(m);
if mm.attack_target.present() || mm.breed_target.present() {
return false;
}
let Some(target) = mm.nearest_attackable.value() else {
return false;
};
if !cx
.w
.target_info(target)
.is_some_and(|t| t.alive && !t.creative)
{
return false;
}
mem(m).attack_target.set(target);
mem(m).cant_reach_walk_target_since.clear();
m.ai.target = Some(target);
true
}
StopAttack => {
let Some(target) = mem(m).attack_target.value() else {
return false;
};
let tired = mem(m)
.cant_reach_walk_target_since
.value()
.is_some_and(|t| cx.now - t > 200);
let valid =
cx.w.target_info(target)
.is_some_and(|t| t.alive && !t.creative)
&& match target {
Who::Mob(id) => cx.w.seen(id).is_some_and(|s| !s.playing_dead),
Who::Player => true,
};
if tired || !valid {
super::axolotl::on_stop_attacking(m, cx.w, target, cx.events);
mem(m).attack_target.clear();
m.ai.target = None;
}
true
}
Chase => {
let Some(target) = mem(m).attack_target.value() else {
return false;
};
let speed = axolotl_speed(m, true);
if cx
.w
.target_info(target)
.is_some_and(|t| m.within_melee_range(&t))
&& visible_contains(m, cx.w, target)
{
mem(m).walk_target.clear();
} else {
let tracker = Tracker::entity(target, true);
mem(m).look_target.set(tracker);
mem(m).walk_target.set(WalkTarget {
target: Tracker::entity(target, false),
speed,
close: 0,
});
}
true
}
Bite => {
let mm = mem(m);
if mm.attack_cooling_down.present() {
return false;
}
let Some(target) = mm.attack_target.value() else {
return false;
};
if !visible_contains(m, cx.w, target)
|| !cx
.w
.target_info(target)
.is_some_and(|t| m.within_melee_range(&t))
{
return false;
}
mem(m).look_target.set(Tracker::entity(target, true));
m.swing();
super::ai::do_hurt_target(
m,
target,
&mut super::goals::Cx {
w: cx.w,
events: cx.events,
},
);
mem(m).attack_cooling_down.set_ttl(true, 20);
true
}
EraseAttackBreeding | EraseDeadBreeding => {
if !mem(m).breed_target.present() {
return false;
}
if matches!(action, EraseAttackBreeding) {
mem(m).attack_target.clear();
} else {
mem(m).play_dead_ticks.clear();
}
true
}
FindLiquid => {
if mem(m).walk_target.present() || mem(m).attack_target.present() {
return false;
}
let p = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if cx.w.fluid(p[0], p[1], p[2]) == crate::blocks::Fluid::Water {
return false;
}
if cx.now < *until {
*until = cx.now + 22;
return true;
}
let mut positions = Vec::new();
// BlockPos.withinManhattan: distance, x, y, then positive/negative z.
for distance in 0..=18i32 {
for x in -6..=6i32 {
for y in -6..=6i32 {
let z = distance - x.abs() - y.abs();
if !(0..=6).contains(&z) {
continue;
}
positions.push([p[0] + x, p[1] + y, p[2] + z]);
if z != 0 {
positions.push([p[0] + x, p[1] + y, p[2] - z]);
}
}
}
}
let mut found = None;
for q in positions {
if (q[0] == p[0] && q[2] == p[2])
|| cx.w.fluid(q[0], q[1], q[2]) != crate::blocks::Fluid::Water
{
continue;
}
let name = cx.w.block_name(q[0], q[1], q[2]);
if name != "minecraft:water" {
continue;
}
if cx.w.block_name(q[0], q[1] + 1, q[2]).ends_with("air") {
found = Some(q);
break;
}
let v = Vec3::new(
f64::from(q[0]) + 0.5,
f64::from(q[1]) + 0.5,
f64::from(q[2]) + 0.5,
);
if found.is_none() && m.distance_sqr(v) >= 2.25 {
found = Some(q);
}
}
if let Some(q) = found {
mem(m).look_target.set(Tracker::Block(q));
mem(m).walk_target.set(WalkTarget {
target: Tracker::Block(q),
speed: 0.15,
close: 0,
});
}
*until = cx.now + 40;
true
}
SwimStroll => {
if mem(m).walk_target.present() || !m.fluids.in_water {
return false;
}
let mut fallback: Option<Vec3> = None;
for (h, v) in [(1, 1), (3, 3), (5, 5), (6, 5), (7, 7), (10, 7)] {
let point = if let Some(p) = fallback {
let delta =
Vec3::new(p.x - m.position.x, p.y - m.position.y, p.z - m.position.z);
let length = (delta.x * delta.x + delta.y * delta.y + delta.z * delta.z).sqrt();
let n = if length < 1.0e-5 {
Vec3::ZERO
} else {
Vec3::new(delta.x / length, delta.y / length, delta.z / length)
};
Some(Vec3::new(
m.position.x + n.x * f64::from(h),
m.position.y + n.y * f64::from(v),
m.position.z + n.z * f64::from(h),
))
} else {
let mut p = m.default_random_pos(cx.w, h, v);
let mut count = 0;
while let Some(q) = p {
let state = cx.w.world.block(
q[0].floor() as i32,
q[1].floor() as i32,
q[2].floor() as i32,
);
if cx.w.data.state(state).flags & super::data::WATER != 0 || count >= 10 {
break;
}
count += 1;
p = m.default_random_pos(cx.w, h, v);
}
p.map(|q| Vec3::new(q[0], q[1], q[2]))
};
let Some(point) = point else { break };
let q = [
point.x.floor() as i32,
point.y.floor() as i32,
point.z.floor() as i32,
];
let restricted = m.restricted(f64::from(h), q);
if cx.w.fluid(q[0], q[1], q[2]) == crate::blocks::Fluid::None || restricted {
break;
}
fallback = Some(point);
}
mem(m)
.walk_target
.set_opt(fallback.map(|p| WalkTarget::point([p.x, p.y, p.z], 0.5, 0)));
true
}
WalkFromLook => {
if mem(m).walk_target.present() {
return false;
}
let Some(t) = mem(m).look_target.value() else {
return false;
};
let Some(p) = t.block(cx.w) else { return false };
if (cx.w.fluid(p[0], p[1], p[2]) == crate::blocks::Fluid::Water) != m.fluids.in_water {
return false;
}
let speed = axolotl_speed(m, false);
mem(m).walk_target.set(WalkTarget {
target: t,
speed,
close: 3,
});
true
}
StayWater => m.fluids.in_water,
StayGround => m.on_ground,
}
}
fn axolotl_package(seed: i64) -> Vec<Entry> {
use Act::*;
use AxolotlAction::*;
let e = |act, b| Entry { act, b };
let action = |action| B::AxolotlAction {
action,
running: false,
until: 0,
};
vec![
// Native activity slots, merged by behavior priority.
e(Core, B::LookAtTargetSink(Timed::new(45, 90))),
e(Idle, B::LookAtPlayerSometimes(false, 0)),
e(Fight, action(StopAttack)),
e(PlayDead, B::PlayDead(Timed::new(200, 200))),
e(Core, move_to_target_sink(150, 250)),
e(
Idle,
B::MakeLove {
t: Timed::new(110, 110),
spawn_at: 0,
},
),
e(Fight, action(Chase)),
e(PlayDead, action(EraseDeadBreeding)),
e(Core, action(ValidateDead)),
e(
Idle,
B::Gate(Box::new(Gate::run_one(
seed,
vec![
(
B::FollowTemptation {
t: Timed::new(60, 60),
look_in_the_eyes: false,
},
1,
),
(
B::FollowAdult {
running: false,
player: false,
},
1,
),
],
))),
),
e(Fight, action(Bite)),
e(Core, B::CountDownTemptation(Timed::new(60, 60))),
e(Idle, action(StartAttack)),
e(Idle, action(FindLiquid)),
e(Fight, action(EraseAttackBreeding)),
e(
Idle,
B::Gate(Box::new(Gate {
running: false,
shuffle: false,
try_all: true,
random: horizon_core::random::LegacyRandom::new(seed),
kids: vec![
(action(SwimStroll), 2, 0.0),
(
B::Stroll {
running: false,
fly: false,
speed: 0.15,
range: (10, 7),
},
2,
0.0,
),
(action(WalkFromLook), 3, 0.0),
(action(StayWater), 5, 0.0),
(action(StayGround), 5, 0.0),
],
when: Some(|mm| !mm.walk_target.present()),
})),
),
]
}
// ---- Debug output (the mob AI harness) ------------------------------------
fn pos(p: [i32; 3]) -> String {
@@ -2134,6 +2722,16 @@ pub fn describe(m: &Mob) -> Option<(String, String)> {
};
}
let n = |l: &Vec<Who>| format!("n{}", l.len());
slot!("nearest_attackable", mm.nearest_attackable, |v: &Who| who(
*v
));
slot!("attack_target", mm.attack_target, |v: &Who| who(*v));
slot!("attack_cooling_down", mm.attack_cooling_down, |v: &bool| v
.to_string());
slot!("has_hunting_cooldown", mm.hunting_cooldown, |v: &bool| v
.to_string());
slot!("play_dead_ticks", mm.play_dead_ticks, |v: &i32| v
.to_string());
slot!("mobs", mm.mobs, n);
slot!("visible_mobs", mm.visible_mobs, |_: &Visible| {
"NearestVisibleLivingEntities".to_string()
@@ -2219,3 +2817,75 @@ pub fn describe(m: &Mob) -> Option<(String, String)> {
out.sort();
Some((acts.join("|"), out.join(" ")))
}
#[cfg(test)]
mod axolotl_parity_tests {
use super::*;
fn empty_brain() -> Brain {
Brain {
kind: Kind::Axolotl,
mem: Memories::default(),
sensors: Vec::new(),
activity: Act::Idle,
list: Vec::new(),
}
}
#[test]
fn fight_exit_sets_hunting_cooldown_and_play_dead_has_priority() {
let mut b = empty_brain();
b.mem.attack_target.set(Who::Mob(1));
update_axolotl_activity(&mut b);
assert_eq!(b.activity, Act::Fight);
b.mem.play_dead_ticks.set(200);
update_axolotl_activity(&mut b);
assert_eq!(b.activity, Act::PlayDead);
assert_eq!(b.mem.hunting_cooldown.ttl, 2400);
assert!(!b.mem.attack_target.present());
for _ in 0..2400 {
b.mem.hunting_cooldown.tick();
}
assert!(b.mem.hunting_cooldown.present());
b.mem.hunting_cooldown.tick();
assert!(!b.mem.hunting_cooldown.present());
}
#[test]
fn play_dead_flag_ends_at_zero_before_memory_is_erased() {
let mut b = empty_brain();
b.activity = Act::PlayDead;
b.mem.play_dead_ticks.set(200);
b.mem.hurt_by_entity.set(Who::Mob(7));
for _ in 0..200 {
assert!(validate_play_dead(&mut b));
}
assert_eq!(b.mem.play_dead_ticks.value(), Some(0));
assert_eq!(b.activity, Act::PlayDead);
assert!(validate_play_dead(&mut b));
assert!(!b.mem.play_dead_ticks.present());
assert!(!b.mem.hurt_by_entity.present());
assert_eq!(b.activity, Act::Idle);
}
#[test]
fn axolotl_has_native_activity_brain_not_fallback_goals() {
assert!(kind_for_species(Species::Axolotl).is_some());
}
}
#[cfg(test)]
mod breeding_duration_tests {
use super::*;
#[test]
fn axolotl_breeding_uses_native_110_tick_timeout() {
let package = axolotl_package(1);
let timed = package
.iter()
.find_map(|e| {
if let B::MakeLove { t, .. } = &e.b {
Some(t)
} else {
None
}
})
.unwrap();
assert_eq!((timed.min, timed.max), (110, 110));
}
}
+20
View File
@@ -35,6 +35,19 @@ pub fn register(m: &mut Mob) {
let speed = 1.0 / (m.random.next_float() + 1.0) * 0.2;
m.anim.v[TENTACLE_SPEED] = speed;
}
S::Axolotl => {
// Native controller scaling is essential: the base speed attribute is 1.0.
m.ai.mov.kind = MoveKind::SmoothSwimming {
max_turn_x: 85.0,
max_turn_y: 10.0,
in_water: 0.1,
outside: 0.5,
gravity: false,
};
m.ai.look.kind = super::control::LookKind::SmoothSwimming(20.0);
m.ai.nav.kind = NavKind::Amphibious;
super::animal_brain::init(m, i64::from(m.id));
}
S::Tadpole => {
// TadpoleAi: panic, swim around and look about.
let g = &mut m.ai.goals;
@@ -267,6 +280,13 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
m.do_move(w, events);
true
}
// Axolotl.travelInWater: no fish sinking or generic water gravity.
S::Axolotl if m.fluids.in_water => {
m.move_relative(m.ai.speed, input);
m.do_move(w, events);
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
true
}
// AbstractFish.travelInWater / Dolphin.travelInWater.
S::Cod | S::Salmon | S::TropicalFish | S::Pufferfish | S::Dolphin if m.fluids.in_water => {
let speed = if m.species() == S::Dolphin {
+205
View File
@@ -0,0 +1,205 @@
//! Axolotl-specific damage, animation and prey rules; activities live in animal_brain.
use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::AnimationState};
use horizon_core::random::RandomSource;
pub const PLAY_DEAD_TICKS: i32 = 200;
pub const HUNTING_COOLDOWN: i64 = 2400;
/// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown.
pub fn prey_eligible(
distance: f64,
water: bool,
hostile: bool,
hunt: bool,
cooldown: bool,
attackable: bool,
) -> bool {
distance <= 64.0 && water && (hostile || hunt && !cooldown) && attackable
}
/// Preserve the ordered random draws in Axolotl.hurtServer, even out of water.
fn roll_play_dead(
random: &mut impl RandomSource,
no_ai: bool,
health: f32,
max: f32,
damage: f32,
water: bool,
entity_source: bool,
already: bool,
) -> bool {
!no_ai
&& random.next_int_bound(3).unwrap_or(0) == 0
&& ((random.next_int_bound(3).unwrap_or(0) as f32) < damage || health / max < 0.5)
&& damage < health
&& water
&& entity_source
&& !already
}
pub fn before_damage(m: &mut Mob, damage: f32, source: &super::Damage) {
if m.species() != Species::Axolotl {
return;
}
let playing = m.extra.axolotl.playing_dead;
if roll_play_dead(
&mut m.random,
m.no_ai,
m.health,
m.attrs.max_health,
damage,
m.fluids.in_water,
source.attacker.is_some() || source.is_projectile(),
playing,
) {
animal_brain::set_play_dead(m);
}
}
pub fn is_playing_dead(m: &Mob) -> bool {
m.species() == Species::Axolotl && m.extra.axolotl.playing_dead
}
/// Axolotl.onStopAttacking: help the player whose last damage killed the target.
pub fn on_stop_attacking(m: &Mob, w: &MobWorld, target: Who, events: &mut Vec<MobEvent>) {
if let Who::Mob(id) = target
&& let Some(s) = w.seen(id)
&& !s.alive
&& s.last_damage
.is_some_and(|d| d.attacker == Some(Who::Player))
&& w.player.as_ref().is_some_and(|p| {
m.bounds()
.inflate(horizon_core::geometry::Vec3::new(20.0, 20.0, 20.0))
.intersects(p.bounds)
})
{
events.push(MobEvent::AxolotlSupportPlayer);
}
}
/// BinaryAnimator(10, IN_OUT_SINE), plus the seven exclusive baby animation states.
#[derive(Clone, Debug, Default)]
pub struct Animation {
pub playing_dead: bool,
old: [u8; 4],
value: [u8; 4],
pub states: [AnimationState; 7],
}
impl Animation {
pub fn tick(&mut self, baby: bool, water: bool, ground: bool, moving: bool, age: f32) {
self.old = self.value;
let flags = [
self.playing_dead,
!self.playing_dead && water,
!self.playing_dead && !water && ground,
moving,
];
for (v, on) in self.value.iter_mut().zip(flags) {
*v = if on {
(*v + 1).min(10)
} else {
v.saturating_sub(1)
};
}
if baby {
let selected = if self.playing_dead {
6
} else if moving {
if water && !ground {
1
} else if !water && ground {
0
} else {
2
}
} else if water && !ground {
4
} else if water && ground {
5
} else {
3
};
for (i, state) in self.states.iter_mut().enumerate() {
if i == selected {
state.start_if_stopped(age);
} else {
state.stop();
}
}
}
}
pub fn factor(&self, i: usize, partial: f32) -> f32 {
let x = (f32::from(self.old[i])
+ (f32::from(self.value[i]) - f32::from(self.old[i])) * partial)
/ 10.0;
((1.0 - horizon_core::jmath::cos(f64::from(x) * std::f64::consts::PI)) / 2.0) as f32
}
}
pub fn tick_animation(m: &mut Mob) {
if m.species() != Species::Axolotl {
return;
}
let moving =
m.limb_speed > 1.0e-5 || m.yaw != m.previous_yaw || m.head_pitch != m.previous_head_pitch;
let baby = m.is_baby();
m.extra
.axolotl
.tick(baby, m.fluids.in_water, m.on_ground, moving, m.age as f32);
}
#[cfg(test)]
mod axolotl_parity_tests {
use super::*;
use horizon_core::random::LegacyRandom;
#[test]
fn prey_water_range_and_cooldown_match_native() {
assert!(prey_eligible(64.0, true, false, true, false, true));
assert!(!prey_eligible(64.01, true, false, true, false, true));
assert!(!prey_eligible(4.0, false, true, true, false, true));
assert!(!prey_eligible(4.0, true, false, true, true, true));
assert!(prey_eligible(4.0, true, true, false, true, true));
assert!(!prey_eligible(4.0, true, false, false, false, true));
assert!(!prey_eligible(4.0, true, true, true, false, false));
}
#[test]
fn damage_roll_matches_native_draw_order_and_gates() {
for seed in 0..100 {
for (water, entity, already, no_ai) in [
(true, true, false, false),
(false, true, false, false),
(true, false, false, false),
(true, true, true, false),
(true, true, false, true),
] {
let mut expected = LegacyRandom::new(seed);
let mut actual = LegacyRandom::new(seed);
let result = !no_ai
&& expected.next_int_bound(3).unwrap() == 0
&& ((expected.next_int_bound(3).unwrap() as f32) < 2.0 || 4.0f32 / 14.0 < 0.5)
&& 2.0 < 4.0
&& water
&& entity
&& !already;
assert_eq!(
roll_play_dead(&mut actual, no_ai, 4.0, 14.0, 2.0, water, entity, already),
result
);
assert_eq!(actual.next_int(), expected.next_int());
}
}
}
#[test]
fn playing_dead_eases_and_selects_exclusive_baby_pose() {
let mut x = Animation {
playing_dead: true,
..Animation::default()
};
for _ in 0..10 {
x.tick(true, true, true, true, 20.0);
}
assert_eq!(x.factor(0, 1.0), 1.0);
assert_eq!(x.factor(1, 1.0), 0.0);
assert_eq!(x.states.iter().filter(|s| s.started.is_some()).count(), 1);
assert!(x.states[6].started.is_some());
x.playing_dead = false;
for _ in 0..10 {
x.tick(true, true, false, true, 30.0);
}
assert_eq!(x.factor(0, 1.0), 0.0);
assert_eq!(x.factor(1, 1.0), 1.0);
assert!(x.states[1].started.is_some());
}
}
+14 -8
View File
@@ -1011,7 +1011,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
},
a,
),
Species::Axolotl => axolotl(p, s, &Axolotl::from_mob(m), a),
Species::Axolotl => axolotl(p, s, &Axolotl::from_mob(m, s.partial), a),
Species::Frog => frog(
p,
s,
@@ -1031,14 +1031,20 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
true
}
impl Axolotl {
pub fn from_mob(m: &Mob) -> Self {
let in_water = m.fluids.in_water;
pub fn from_mob(m: &Mob, partial: f32) -> Self {
let x = &m.extra.axolotl;
Self {
in_water: if in_water { 1.0 } else { 0.0 },
on_ground: if m.on_ground { 1.0 } else { 0.0 },
walk: m.anim.states[0],
swim: m.anim.states[1],
..Self::default()
playing_dead: x.factor(0, partial),
in_water: x.factor(1, partial),
on_ground: x.factor(2, partial),
moving: x.factor(3, partial),
// Native baby model deliberately reads walkAnimationState, not its underwater counterpart.
walk: x.states[0],
swim: x.states[1],
idle_on_ground: x.states[3],
idle_underwater: x.states[4],
idle_underwater_on_ground: x.states[5],
play_dead: x.states[6],
}
}
}
+7 -3
View File
@@ -404,9 +404,10 @@ pub enum Attr {
SpawnReinforcements,
JumpStrength,
Armor,
BurningTime,
}
impl Attr {
pub const ALL: [Self; 8] = [
pub const ALL: [Self; 9] = [
Self::MaxHealth,
Self::MovementSpeed,
Self::FollowRange,
@@ -415,6 +416,7 @@ impl Attr {
Self::SpawnReinforcements,
Self::JumpStrength,
Self::Armor,
Self::BurningTime,
];
pub fn name(self) -> &'static str {
match self {
@@ -426,6 +428,7 @@ impl Attr {
Self::SpawnReinforcements => "spawn_reinforcements",
Self::JumpStrength => "jump_strength",
Self::Armor => "armor",
Self::BurningTime => "burning_time",
}
}
/// `RangedAttribute` bounds.
@@ -438,6 +441,7 @@ impl Attr {
Self::KnockbackResistance | Self::SpawnReinforcements => (0.0, 1.0),
Self::JumpStrength => (0.0, 32.0),
Self::Armor => (0.0, 30.0),
Self::BurningTime => (0.0, 1024.0),
}
}
}
@@ -476,7 +480,7 @@ pub fn base_value(m: &Mob, a: Attr) -> f64 {
.as_ref()
.and_then(|s| s.bases.iter().find(|(x, _)| *x == a).map(|(_, v)| *v))
.or_else(|| default_value(m, a))
.unwrap_or(0.0)
.unwrap_or(if a == Attr::BurningTime { 1.0 } else { 0.0 })
}
/// The transient modifiers of the mob's state (`SPEED_MODIFIER_BABY`).
fn transient(m: &Mob, a: Attr) -> Option<(f64, Op)> {
@@ -560,7 +564,7 @@ fn refresh(m: &mut Mob, a: Attr) {
Attr::KnockbackResistance => m.attrs.knockback_resistance = value(m, a),
Attr::SpawnReinforcements => m.attrs.spawn_reinforcements = value(m, a),
Attr::JumpStrength => m.attrs.jump_strength = value(m, a),
Attr::Armor => {}
Attr::Armor | Attr::BurningTime => {}
}
}
/// The value without the transient baby modifier (`speed_bonus` applies
+3 -2
View File
@@ -1229,7 +1229,7 @@ pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &m
let Some(t) = cx.w.target_info(who) else {
return false;
};
if !t.alive || t.is_player && t.creative {
if !t.alive || !t.can_be_seen_as_enemy || t.is_player && t.creative {
return false;
}
let follow = m.attrs.follow_range * st.follow_scale;
@@ -1287,6 +1287,7 @@ impl Goal for NearestTarget {
let candidates: Vec<(u32, Vec3)> =
cx.w.nearby(pos, range, move |s| {
s.id != id
&& !s.playing_dead
&& match prey {
Prey::Species(list) => list.contains(&s.species),
Prey::Filter(f) => f(s),
@@ -1381,7 +1382,7 @@ impl Goal for HurtByTarget {
let Some(t) = cx.w.target_info(by) else {
return false;
};
t.alive && !(t.is_player && t.creative)
t.alive && t.can_be_seen_as_enemy && !(t.is_player && t.creative)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
target_continue(&mut self.st, true, m, cx)
@@ -364,7 +364,9 @@ fn can_attack(m: &Mob, w: &MobWorld, who: Who) -> bool {
.is_some_and(|p| p.alive && !p.creative && !p.spectator),
Who::Mob(id) => {
let Some(s) = w.seen(id) else { return false };
s.alive && (m.species() != Species::Breeze || s.species == Species::IronGolem)
s.alive
&& !s.playing_dead
&& (m.species() != Species::Breeze || s.species == Species::IronGolem)
}
}
}
+22
View File
@@ -747,6 +747,28 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
.unwrap_or_else(|| glam::Mat4::from_rotation_y((180.0 - body).to_radians())),
};
let mut base = origin * rotation;
if m.extra.fall_flying {
let ticks = m.extra.fall_fly_ticks as f32 + alpha;
let scale = (ticks * ticks / 100.0).min(1.0);
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
base *= glam::Mat4::from_rotation_x((scale * (-90.0 - pitch)).to_radians());
let yaw = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha;
let (look_x, look_z) = (-(yaw.to_radians()).msin(), (yaw.to_radians()).mcos());
let v = m.velocity;
let view_scale = (pitch.to_radians()).mcos();
let (look_x, look_z) = (look_x * view_scale, look_z * view_scale);
let view_length = f64::from(look_x * look_x + look_z * look_z);
let speed = v.x * v.x + v.z * v.z;
if speed > f64::from(1.0e-5f32) && view_length > f64::from(1.0e-5f32) {
let dot =
(v.x * f64::from(look_x) + v.z * f64::from(look_z)) / (speed * view_length).sqrt();
let sign = (v.x * f64::from(look_z) - v.z * f64::from(look_x)).signum();
base *= glam::Mat4::from_rotation_y(
(sign * horizon_core::jmath::acos(dot.abs().min(1.0))) as f32,
);
}
}
if m.death_time > 0 && !super::creaking::tearing_down(m) {
let f = ((m.death_time as f32 + alpha - 1.0) / 20.0 * 1.6)
.sqrt()
+3
View File
@@ -15,6 +15,7 @@ pub struct TargetInfo {
pub eye_y: f64,
pub bounds: Aabb,
pub alive: bool,
pub can_be_seen_as_enemy: bool,
pub is_player: bool,
pub creative: bool,
}
@@ -205,6 +206,7 @@ impl MobWorld<'_> {
eye_y: p.eye[1],
bounds: p.bounds,
alive: p.alive,
can_be_seen_as_enemy: true,
is_player: true,
creative: p.creative,
}),
@@ -213,6 +215,7 @@ impl MobWorld<'_> {
eye_y: s.eye_y,
bounds: s.bounds,
alive: s.alive,
can_be_seen_as_enemy: !s.playing_dead,
is_player: false,
creative: false,
}),
+685 -11
View File
@@ -43,6 +43,7 @@ use std::{
sync::Arc,
};
pub(crate) mod environment;
mod fluids;
pub use fluids::{FluidResources, LAVA, PlayerFluids, WATER};
@@ -112,6 +113,7 @@ pub enum MotionEvent {
enabled: bool,
},
Health(crate::health::HealthEvent),
HurtSound(&'static str),
Fall(crate::landing::FallEvent),
Recorded {
mover: MoverType,
@@ -172,6 +174,11 @@ pub enum MotionEvent {
SplashEvent,
}
pub struct PlayerMotion {
pub swimming: bool,
pub fire_ticks: i32,
pub air: i32,
pub respiration: i32,
pub position: Vec3,
pub velocity: Vec3,
pub ground: GroundState,
@@ -257,6 +264,34 @@ fn attribute_error(error: impl std::fmt::Debug) -> io::Error {
io::Error::other(format!("movement attribute: {error:?}"))
}
impl PlayerMotion {
pub fn load_environment_once(
&mut self,
pending: &mut bool,
extra: Option<&horizon_world::nbt::Tag>,
) {
if std::mem::take(pending) {
self.fire_ticks = extra
.and_then(|t| crate::save::number(t.get("Fire")))
.unwrap_or(-20) as i32;
self.air = extra
.and_then(|t| crate::save::number(t.get("Air")))
.unwrap_or(300) as i32;
}
}
pub fn set_remaining_fire_ticks(&mut self, ticks: i32) {
self.fire_ticks = if self.invulnerable {
ticks.min(1)
} else {
ticks
};
}
pub fn ignite_for_ticks(&mut self, ticks: i32) -> Result<()> {
let scaled = (f64::from(ticks) * self.attribute("minecraft:burning_time")?).ceil() as i32;
self.set_remaining_fire_ticks(self.fire_ticks.max(scaled));
self.ticks_frozen = 0;
Ok(())
}
pub fn new(resources: &MotionResources, position: Vec3) -> Result<Self> {
// Process-owned identities determine native collection callback order.
// Differential fixtures can replay a reference process's identities.
@@ -324,6 +359,10 @@ impl PlayerMotion {
local_crouching: false,
pose: crate::pose::Pose::Standing,
dimensions: crate::pose::Pose::Standing.dimensions(1.0),
swimming: false,
fire_ticks: -20,
air: 300,
respiration: 0,
speed: 0.0,
item_use_speed: None,
last_climbable: None,
@@ -486,6 +525,9 @@ impl PlayerMotion {
horizontal_collision: self.collision.horizontal,
minor_horizontal_collision: self.collision.minor,
moving_slowly: self.moving_slowly(),
in_water: self.fluids.was_touching_water,
under_water: self.is_under_water(),
swimming: self.swimming,
..Default::default()
};
// The sprint setter edits attributes and flags, independent of local
@@ -611,10 +653,17 @@ impl MotionWorld<'_, '_, '_> {
/// caller applies LocalPlayer input preparation once. Empty inventory,
/// equipment and nearby-entity sets are the current scene domain.
pub fn player_movement_tick(&mut self, direction: [f32; 2], jumping: bool) -> Result<()> {
self.fluid_base_tick()?;
self.player_movement_tick_with_fluids(direction, jumping)
}
fn player_movement_tick_with_fluids(
&mut self,
direction: [f32; 2],
jumping: bool,
) -> Result<()> {
// Entity.baseTick
self.player.was_in_powder_snow = self.player.in_powder_snow;
self.player.in_powder_snow = false;
self.fluid_base_tick()?;
self.player.old_position = self.player.position;
if self.player.jump_trigger_time > 0 {
self.player.jump_trigger_time -= 1;
@@ -641,14 +690,22 @@ impl MotionWorld<'_, '_, '_> {
}
let toggled = flying_before != self.player.flying;
// Player.tryToStartFallFlying (canGlide: airborne, not flying, a glider).
let can_glide = self.player.glider && !self.player.ground.on_ground && !self.player.flying;
let can_glide = self.player.glider
&& !self.player.ground.on_ground
&& !self.player.flying
&& !self.player.fluids.was_touching_water
&& !self.player.is_in_lava();
if jumping && !toggled && !was_jumping && !self.player.fall_flying && can_glide {
self.player.fall_flying = true;
}
// LivingEntity.tick fallFlyTicks, then updateFallFlying.
if self.player.fall_flying {
self.player.fall_fly_ticks += 1;
let still = self.player.glider && !self.player.ground.on_ground && !self.player.flying;
let still = self.player.glider
&& !self.player.ground.on_ground
&& !self.player.flying
&& !self.player.fluids.was_touching_water
&& !self.player.is_in_lava();
if !still {
self.player.fall_flying = false;
} else if (self.player.fall_fly_ticks + 1) % 10 == 0
@@ -688,8 +745,14 @@ impl MotionWorld<'_, '_, '_> {
/// services are still incomplete; this composes clocks, movement and hunger.
pub fn simulation_tick(&mut self, direction: [f32; 2], jumping: bool) -> Result<()> {
self.player.health.advance_clock();
self.fluid_base_tick()?;
self.simulation_tick_with_fluids(direction, jumping)
}
fn simulation_tick_with_fluids(&mut self, direction: [f32; 2], jumping: bool) -> Result<()> {
self.tick_environment_damage()?;
let previous = self.player.position;
self.player_movement_tick(direction, jumping)?;
self.player_movement_tick_with_fluids(direction, jumping)?;
self.apply_fire_contact()?;
self.player.refresh_attributes()?;
self.update_player_pose()?;
// The resident scene accepts the resolved local movement directly.
@@ -708,6 +771,8 @@ impl MotionWorld<'_, '_, '_> {
self.player.auto_jump_time -= 1;
keys.jump = true;
}
self.player.health.advance_clock();
self.fluid_base_tick()?;
self.player.sprint_input_tick(keys)?;
self.player.shift_key_down = keys.shift;
let direction = keys.direction();
@@ -719,7 +784,7 @@ impl MotionWorld<'_, '_, '_> {
} else {
None
};
self.simulation_tick(
self.simulation_tick_with_fluids(
crate::input::modify_input(direction, self.player.item_use_speed, sneak),
keys.jump,
)
@@ -756,14 +821,44 @@ impl MotionWorld<'_, '_, '_> {
input.x = 0.0;
input.z = 0.0;
}
if self.player.shift_key_down
&& !self.player.flying
&& self.player.fluids.was_touching_water
{
self.player.velocity.y -= f64::from(0.04f32);
}
if self.player.jumping {
if self.player.ground.on_ground && self.player.no_jump_delay == 0 {
let in_lava = self.player.is_in_lava();
let height = self.player.fluid_height(if in_lava { LAVA } else { WATER });
let water_jump = self.player.fluids.was_touching_water
&& height > 0.0
&& (!self.player.ground.on_ground || height > 0.4);
let lava_jump = in_lava && (!self.player.ground.on_ground || height > 0.4);
if !self.player.flying && (water_jump || lava_jump) {
self.player.velocity.y += f64::from(0.04f32);
} else if self.player.ground.on_ground && self.player.no_jump_delay == 0 {
self.jump_from_ground()?;
self.player.no_jump_delay = 10;
}
} else {
self.player.no_jump_delay = 0;
}
if self.player.swimming {
let look_y =
horizon_world::view_direction::view_vector(self.player.pitch, self.player.yaw).y;
let near_surface = BlockPos::containing(Vec3::new(
self.player.position.x,
self.player.position.y + 0.9,
self.player.position.z,
));
if look_y <= 0.0
|| self.player.jumping
|| self.fluid_at(near_surface).kind() != horizon_world::fluid::FluidKind::Empty
{
let blend = if look_y < -0.2 { 0.085 } else { 0.06 };
self.player.velocity.y += (look_y - self.player.velocity.y) * blend;
}
}
if self.player.fall_flying {
self.travel_fall_flying()?;
} else if self.player.flying {
@@ -771,6 +866,10 @@ impl MotionWorld<'_, '_, '_> {
let y = self.player.velocity.y;
self.travel_in_air(input)?;
self.player.velocity.y = y * 0.6;
} else if !self.player.flying
&& (self.player.fluids.was_touching_water || self.player.is_in_lava())
{
horizon_world::living_fluid::travel_in_fluid(self, input)?;
} else {
self.travel_in_air(input)?;
}
@@ -1229,13 +1328,13 @@ impl MovementStatisticsWorld for MotionWorld<'_, '_, '_> {
Ok(false)
}
fn swimming(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.swimming)
}
fn eyes_in_water(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.is_eye_in_fluid(WATER))
}
fn in_water(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.fluids.was_touching_water)
}
fn on_climbable(&mut self) -> Result<bool> {
self.climbable()
@@ -1560,10 +1659,10 @@ impl MovementEmissionWorld for MotionWorld<'_, '_, '_> {
Ok(false)
}
fn swimming(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.swimming)
}
fn in_water(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.fluids.was_touching_water)
}
fn walking_step_sound(&mut self, position: BlockPos, state: BlockStateId) -> Result<()> {
self.player
@@ -1838,9 +1937,584 @@ impl LivingMotionWorld for MotionWorld<'_, '_, '_> {
mod tests {
use super::*;
#[test]
fn player_fire_damage_queues_hurt_sound_only_for_fresh_hits() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for name in ["on_fire", "in_fire", "lava"] {
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let source = resources
.health
.damage
.id(&format!("minecraft:{name}"))
.unwrap();
assert!(
player
.hurt(&resources.health, Default::default(), source, 1.0)
.unwrap()
);
assert_eq!(
player
.events
.iter()
.filter(|e| matches!(
e,
MotionEvent::HurtSound("minecraft:entity.player.hurt_on_fire")
))
.count(),
1
);
player.events.clear();
assert!(
!player
.hurt(&resources.health, Default::default(), source, 1.0)
.unwrap()
);
assert!(
!player
.events
.iter()
.any(|e| matches!(e, MotionEvent::HurtSound(_)))
);
for _ in 0..20 {
player.health.advance_clock();
}
player.invulnerable = true;
player.events.clear();
assert!(
!player
.hurt(&resources.health, Default::default(), source, 1.0)
.unwrap()
);
assert!(
!player
.events
.iter()
.any(|e| matches!(e, MotionEvent::HurtSound(_)))
);
}
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let source = resources.health.damage.id("minecraft:mob_attack").unwrap();
assert!(
player
.hurt_blocking(&resources.health, Default::default(), source, 2.0, None)
.unwrap()
.hurt
);
assert_eq!(
player
.events
.iter()
.filter(|e| matches!(e, MotionEvent::HurtSound("minecraft:entity.player.hurt")))
.count(),
1
);
}
/// Footsteps on the prepared vanilla resources, when they are present:
/// none while standing (gravity still moves the player every tick), one
/// per `nextStep` metre while walking.
#[test]
fn resident_water_landing_cancels_a_preset_fall() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for wet in [false, true] {
let mut scene = World::test_scene(&assets, 2).unwrap();
if wet {
let water = assets.registry.default_state("minecraft:water").unwrap();
for x in 19..=21 {
for z in 19..=21 {
scene.set(x, 3, z, water).unwrap();
}
}
}
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 20.0, 20.5)).unwrap();
player.fall_distance = 30.0;
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.move_self(Vec3::new(0.0, -30.0, 0.0)).unwrap();
assert!(w.player.ground.on_ground);
assert_eq!(w.player.fluids.was_touching_water, wet);
if wet {
assert_eq!(w.player.health.health(), 20.0);
assert_eq!(w.player.fall_distance, 0.0);
} else {
assert!(w.player.health.health() < 20.0);
}
}
}
#[test]
fn resident_water_and_lava_are_not_treated_as_air() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, 3).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
for x in 19..=21 {
for y in 3..=7 {
for z in 19..=21 {
scene.set(x, y, z, water).unwrap();
}
}
}
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.controlled_simulation_tick(Default::default()).unwrap();
assert!(
MovementStatisticsWorld::in_water(&mut w).unwrap(),
"resident water must reach the player movement adapter"
);
assert!(MovementStatisticsWorld::eyes_in_water(&mut w).unwrap());
assert!(
!w.player.fluids.was_eye_in_water,
"the first controlled tick must run only one fluid base phase"
);
assert!(
(w.player.velocity.y + 0.005).abs() < 1e-5,
"water drag/gravity must replace air travel: {:?}",
w.player.velocity
);
let lava = assets.registry.default_state("minecraft:lava").unwrap();
for x in 19..=21 {
for y in 3..=7 {
for z in 19..=21 {
w.scene.set(x, y, z, lava).unwrap();
}
}
}
w.controlled_simulation_tick(Default::default()).unwrap();
assert!(
w.player.health.health() < 20.0,
"lava must damage a survival player"
);
// Water extinguishes, swimming is a real pose, and deep water consumes air.
for x in 19..=21 {
for y in 3..=7 {
for z in 19..=21 {
w.scene.set(x, y, z, water).unwrap();
}
}
}
w.player.fire_ticks = 160;
w.controlled_simulation_tick(Default::default()).unwrap();
assert!(w.player.fire_ticks <= 0);
w.player.air = -19;
w.player.health.hurt.cooldown = 0;
let hp = w.player.health.health();
w.controlled_simulation_tick(Default::default()).unwrap();
assert_eq!(w.player.air, 0);
assert!(w.player.health.health() < hp, "drowning must hurt");
w.player.invulnerable = true;
let hp = w.player.health.health();
w.player.air = -19;
w.controlled_simulation_tick(Default::default()).unwrap();
assert_eq!(w.player.health.health(), hp);
w.player.invulnerable = false;
w.player.air = 300;
w.controlled_simulation_tick(crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
})
.unwrap();
assert!(w.player.sprinting);
assert!(
!w.player.swimming,
"sprint input follows the swimming phase"
);
w.controlled_simulation_tick(crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
})
.unwrap();
assert!(w.player.swimming);
assert_eq!(w.player.pose, crate::pose::Pose::Swimming);
// Direct fire contact ignites; resistance prevents damage but does not hide flames.
let fire = assets.registry.default_state("minecraft:fire").unwrap();
for x in 19..=21 {
for y in 3..=7 {
for z in 19..=21 {
w.scene.set(x, y, z, fire).unwrap();
}
}
}
w.player.position = Vec3::new(20.5, 4.0, 20.5);
w.player.velocity = Vec3::ZERO;
w.player.fire_ticks = -20;
w.player.health.hurt.cooldown = 0;
w.player.ticks_frozen = 140;
let hp = w.player.health.health();
w.controlled_simulation_tick(Default::default()).unwrap();
assert!(
w.player.fire_ticks < 0,
"direct fire hurts during the immunity delay"
);
assert_eq!(
w.player.ticks_frozen, 0,
"fire contact clears freeze during the delay"
);
assert!(w.player.health.health() < hp);
w.player.status.fire_resistance = true;
w.player.health.hurt.cooldown = 0;
let hp = w.player.health.health();
w.controlled_simulation_tick(Default::default()).unwrap();
assert_eq!(w.player.health.health(), hp);
w.player.invulnerable = true;
w.player.fire_ticks = -20;
w.player.ticks_frozen = 140;
w.controlled_simulation_tick(Default::default()).unwrap();
assert_eq!(w.player.ticks_frozen, 0);
assert_eq!(
w.player.fire_ticks, -19,
"creative preserves the fire-contact delay"
);
for _ in 0..18 {
w.apply_fire_contact().unwrap();
}
assert_eq!(w.player.fire_ticks, -1);
w.apply_fire_contact().unwrap();
assert_eq!(
w.player.fire_ticks, 1,
"creative clamps only after ignition"
);
w.player.ticks_frozen = 140;
w.player.ignite_for_ticks(160).unwrap();
assert_eq!(w.player.ticks_frozen, 0);
assert_eq!(w.player.fire_ticks, 1);
w.player.invulnerable = false;
// Sprint swimming must keep full input, not the dry crawl/sneak multiplier.
for x in 19..=21 {
for y in 2..=7 {
for z in 19..=30 {
w.scene.set(x, y, z, water).unwrap();
}
}
}
w.player.position = Vec3::new(20.5, 4.0, 20.5);
w.player.velocity = Vec3::ZERO;
w.player.local_crouching = false;
w.player.swimming = true;
w.player.sprinting = true;
w.player.set_pose(crate::pose::Pose::Swimming).unwrap();
for _ in 0..30 {
w.controlled_simulation_tick(crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
})
.unwrap();
}
assert!(
w.player.velocity.z > 0.165,
"native sprint swim speed must not receive crawl slowdown: {:?}",
w.player.velocity
);
assert!(!w.player.moving_slowly());
w.player.position = Vec3::new(20.5, 4.0, 20.5);
w.player.velocity = Vec3::ZERO;
w.player.pitch = 45.0;
w.controlled_simulation_tick(crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
})
.unwrap();
assert!(
w.player.velocity.y < -0.04,
"look down must steer a swimming player downward"
);
w.player.position = Vec3::new(20.5, 4.0, 20.5);
w.player.velocity = Vec3::ZERO;
w.player.pitch = -45.0;
w.controlled_simulation_tick(crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
})
.unwrap();
assert!(
w.player.velocity.y > 0.02,
"look up must steer a submerged swimmer upward"
);
let extra = horizon_world::nbt::Tag::compound([
("Fire", horizon_world::nbt::Tag::Short(160)),
("Air", horizon_world::nbt::Tag::Short(8)),
]);
let mut pending = true;
w.player.load_environment_once(&mut pending, Some(&extra));
assert_eq!((w.player.fire_ticks, w.player.air), (160, 8));
let mut respawn = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
respawn.load_environment_once(&mut pending, Some(&extra));
assert_eq!((respawn.fire_ticks, respawn.air), (-20, 300));
w.player.fluids.was_touching_water = false;
assert!(
w.player.moving_slowly(),
"dry crawling must still slow input"
);
}
#[test]
fn resident_swimming_uses_previous_eyes_and_requires_water_at_the_feet() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
for y in 4..=6 {
scene.set(20, y, 20, water).unwrap();
}
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.player.local_crouching = true;
let sprint = crate::sprint::Keys {
forward: true,
sprint: true,
..Default::default()
};
w.fluid_base_tick().unwrap();
assert!(w.player.is_eye_in_fluid(WATER));
assert!(!w.player.is_under_water());
w.player.sprint_input_tick(sprint).unwrap();
assert!(
!w.player.sprinting,
"new eye immersion cannot bypass crouching"
);
w.fluid_base_tick().unwrap();
assert!(w.player.is_under_water());
w.player.sprint_input_tick(sprint).unwrap();
assert!(w.player.sprinting);
assert!(!w.player.swimming, "sprint starts after the base tick");
w.fluid_base_tick().unwrap();
assert!(w.player.swimming);
let air = assets.registry.default_state("minecraft:air").unwrap();
w.scene.set(20, 4, 20, air).unwrap();
w.player.swimming = false;
w.fluid_base_tick().unwrap();
assert!(w.player.is_under_water());
assert!(
!w.player.swimming,
"starting a swim requires water in the feet cell"
);
w.player.swimming = true;
w.fluid_base_tick().unwrap();
assert!(
w.player.swimming,
"an existing swim only requires touching water"
);
w.player.flying = true;
w.fluid_base_tick().unwrap();
assert!(!w.player.swimming);
w.player.flying = false;
w.player.spectator = true;
w.scene.set(20, 4, 20, water).unwrap();
w.fluid_base_tick().unwrap();
assert!(
w.player.swimming,
"spectator flight is applied later in aiStep"
);
}
#[test]
fn resident_fire_precedes_first_tick_lava_and_water_extinguishing() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for name in ["minecraft:lava", "minecraft:water"] {
let mut scene = World::test_scene(&assets, 3).unwrap();
let fluid = assets.registry.default_state(name).unwrap();
scene.set(20, 4, 20, fluid).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
player.fire_ticks = 160;
player.fall_distance = 8.0;
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.fluid_base_tick().unwrap();
assert_eq!(
w.player.health.health(),
19.0,
"base on-fire damage: {name}"
);
if name == "minecraft:lava" {
assert_eq!(w.player.fire_ticks, 159);
assert_eq!(w.player.fall_distance, 8.0, "first tick excludes lava");
w.fluid_base_tick().unwrap();
assert_eq!(
w.player.fall_distance, 4.0,
"later ticks halve exactly once"
);
} else {
assert_eq!(w.player.fire_ticks, 0, "water extinguishes after the hurt");
assert_eq!(w.player.fall_distance, 0.0);
w.player.fire_ticks = -7;
w.fluid_base_tick().unwrap();
assert_eq!(
w.player.fire_ticks, -7,
"clearing fire retains negative delay"
);
}
}
}
#[test]
fn resident_bubble_columns_and_nautilus_preserve_their_air_rules() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
scene.set(20, 4, 20, water).unwrap();
scene.set(20, 5, 20, water).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap();
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
for (extra_effect, expected_air) in [
(None, -19),
(Some("water_breathing"), -15),
(Some("conduit_power"), -15),
] {
let mut effects = crate::effects::PlayerEffects::default();
effects.add(crate::effects::EffectInstance::new(
"breath_of_the_nautilus",
200,
0,
));
if let Some(id) = extra_effect {
effects.add(crate::effects::EffectInstance::new(id, 200, 0));
}
w.player.status = effects.status();
assert!(w.player.status.breath_of_the_nautilus);
w.player.air = -19;
w.fluid_base_tick().unwrap();
w.tick_environment_damage().unwrap();
assert_eq!(w.player.air, expected_air);
assert_eq!(w.player.health.health(), 20.0);
}
w.player.status.water_breathing = false;
for (name, nautilus) in [
("minecraft:bubble_column", false),
("minecraft:bubble_column", true),
("minecraft:air", true),
] {
w.scene
.set(20, 5, 20, assets.registry.default_state(name).unwrap())
.unwrap();
w.player.status.breath_of_the_nautilus = nautilus;
w.player.air = -19;
w.fluid_base_tick().unwrap();
w.tick_environment_damage().unwrap();
assert_eq!(w.player.air, -15, "{name}, nautilus={nautilus}");
assert_eq!(w.player.health.health(), 20.0);
}
w.scene.set(20, 5, 20, water).unwrap();
w.player.status.breath_of_the_nautilus = false;
w.player.air = -19;
w.fluid_base_tick().unwrap();
w.tick_environment_damage().unwrap();
assert_eq!(w.player.air, 0);
assert_eq!(w.player.health.health(), 18.0);
}
#[test]
fn resident_mixed_fluids_use_lava_height_for_ground_jumps() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for (water_level, lava_level, ground_jump) in [(0, 6, true), (6, 0, false)] {
let mut scene = World::test_scene(&assets, 3).unwrap();
for (name, level, x) in [
("minecraft:water", water_level, 20),
("minecraft:lava", lava_level, 21),
] {
let mut state = assets
.registry
.state(assets.registry.default_state(name).unwrap())
.unwrap()
.clone();
state.properties.insert("level".into(), level.to_string());
scene
.set(x, 4, 20, assets.registry.id(&state).unwrap())
.unwrap();
}
let mut player = PlayerMotion::new(&resources, Vec3::new(21.0, 4.0, 20.5)).unwrap();
player.ground.on_ground = true;
let mut w = MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.fluid_base_tick().unwrap();
assert!(w.player.fluids.was_touching_water && w.player.is_in_lava());
assert_eq!(w.player.fluid_height(LAVA) <= 0.4, ground_jump);
assert_eq!(w.player.fluid_height(WATER) > 0.4, ground_jump);
w.dry_tick(TickInput::PreparedLocal {
direction: [0.0; 2],
jumping: true,
})
.unwrap();
assert_eq!(w.player.no_jump_delay == 10, ground_jump);
}
}
#[test]
fn footsteps_follow_the_walked_distance_not_the_tick() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
@@ -0,0 +1,217 @@
//! Player liquid travel and environmental damage over the shared fluid state.
use super::*;
use horizon_core::random::RandomSource;
use horizon_world::{
fluid::FluidKind,
living_fluid::{LivingFluidWorld, TravelFluidTag},
};
impl MotionWorld<'_, '_, '_> {
fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
if self.player.invulnerable || self.player.spectator {
return Ok(());
}
let id = self
.resources
.health
.damage
.id(source)
.ok_or("missing environment damage type")?;
self.player
.hurt(&self.resources.health, self.scene.health_rules, id, damage)?;
Ok(())
}
pub(super) fn tick_remaining_fire(&mut self) -> Result<()> {
if self.player.spectator {
self.player.fire_ticks = 0;
}
if self.player.invulnerable && self.player.fire_ticks > 1 {
self.player.fire_ticks = 1;
}
if self.player.fire_ticks > 0 {
if self.player.fire_ticks % 20 == 0 && !self.player.is_in_lava() {
self.environment_hurt("minecraft:on_fire", 1.0)?;
}
self.player.fire_ticks -= 1;
}
if self.player.fluids.was_touching_water {
self.player.fire_ticks = self.player.fire_ticks.min(0);
}
Ok(())
}
pub(super) fn tick_environment_damage(&mut self) -> Result<()> {
let eye = BlockPos::containing(self.player.position.add_vector(Vec3::new(
0.0,
f64::from(self.player.dimensions.eye_height),
0.0,
)));
let bubble_column = self
.assets
.registry
.state(self.block(eye))
.is_some_and(|state| state.name == "minecraft:bubble_column");
let submerged = self.player.is_eye_in_fluid(WATER) && !bubble_column;
if submerged
&& !self.player.status.water_breathing
&& !self.player.status.breath_of_the_nautilus
&& !self.player.invulnerable
&& !self.player.spectator
{
let oxygen = self
.player
.attribute("minecraft:oxygen_bonus")?
.max(f64::from(self.player.respiration));
if oxygen <= 0.0 || self.scene.random.next_double() < 1.0 / (oxygen + 1.0) {
self.player.air -= 1;
}
if self.player.air <= -20 {
self.player.air = 0;
self.environment_hurt("minecraft:drown", 2.0)?;
}
} else if !submerged
|| !self.player.status.breath_of_the_nautilus
|| self.player.status.water_breathing
{
self.player.air = (self.player.air + 4).min(300);
}
if self.player.is_in_lava() {
self.player.ignite_for_ticks(300)?;
self.environment_hurt("minecraft:lava", 4.0)?;
}
Ok(())
}
pub(super) fn apply_fire_contact(&mut self) -> Result<()> {
if self.player.spectator {
self.player.fire_ticks = 0;
return Ok(());
}
let damage = fire_contact(self.scene, self.assets.registry, self.player.bounds());
if damage > 0.0 {
self.player.ticks_frozen = 0;
if !self.player.fluids.was_touching_water {
let multiplier = self.player.attribute("minecraft:burning_time")?;
let ticks = ignite_contact(
self.player.fire_ticks,
true,
&mut self.scene.random,
multiplier,
);
self.player.set_remaining_fire_ticks(ticks);
}
self.environment_hurt("minecraft:in_fire", damage)?;
} else if self.player.fire_ticks <= 0 {
self.player.fire_ticks = -20;
}
Ok(())
}
}
pub(crate) fn fire_contact(
world: &World,
registry: &horizon_world::registry::BlockRegistry,
bounds: Aabb,
) -> f32 {
let b = bounds.deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let mut damage = 0.0f32;
for x in b.min_position().x.floor() as i32..=b.max_position().x.floor() as i32 {
for y in b.min_position().y.floor() as i32..=b.max_position().y.floor() as i32 {
for z in b.min_position().z.floor() as i32..=b.max_position().z.floor() as i32 {
damage = damage.max(
match registry
.state(world.block(x, y, z))
.map(|s| s.name.as_str())
{
Some("minecraft:fire") => 1.0,
Some("minecraft:soul_fire") => 2.0,
_ => 0.0,
},
);
}
}
}
damage
}
impl LivingFluidWorld for MotionWorld<'_, '_, '_> {
fn in_water(&mut self) -> Result<bool> {
Ok(self.player.fluids.was_touching_water)
}
fn sprinting(&mut self) -> Result<bool> {
Ok(self.player.sprinting)
}
fn water_slow_down(&mut self) -> Result<f32> {
Ok(0.8)
}
fn water_movement_efficiency(&mut self) -> Result<f64> {
self.player.attribute("minecraft:water_movement_efficiency")
}
fn dolphins_grace(&mut self) -> Result<bool> {
Ok(self.player.status.dolphins_grace)
}
fn fluid_height(&mut self, tag: TravelFluidTag) -> Result<f64> {
Ok(self.player.fluid_height(if tag == TravelFluidTag::Lava {
LAVA
} else {
WATER
}))
}
fn fluid_jump_threshold(&mut self) -> Result<f64> {
Ok(0.4)
}
fn is_free(&mut self, v: Vec3) -> Result<bool> {
let b = self.player.bounds().move_by(v);
Ok(self.no_player_collision(b)? && !self.has_liquid(b)?)
}
fn is_vehicle(&mut self) -> Result<bool> {
Ok(false)
}
fn can_float_while_ridden(&mut self) -> Result<bool> {
Ok(false)
}
}
impl MotionWorld<'_, '_, '_> {
fn has_liquid(&self, b: Aabb) -> Result<bool> {
for x in b.min_position().x.floor() as i32..=b.max_position().x.floor() as i32 {
for y in b.min_position().y.floor() as i32..=b.max_position().y.floor() as i32 {
for z in b.min_position().z.floor() as i32..=b.max_position().z.floor() as i32 {
if self.fluid_at(BlockPos::new(x, y, z)).kind() != FluidKind::Empty {
return Ok(true);
}
}
}
}
Ok(false)
}
}
pub(crate) fn ignite_contact(
ticks: i32,
player: bool,
random: &mut impl RandomSource,
multiplier: f64,
) -> i32 {
let ticks = if ticks < 0 {
ticks + 1
} else if player {
ticks + 1 + random.next_int_bound(2).unwrap_or(0)
} else {
ticks
};
if ticks >= 0 {
ticks.max((160.0 * multiplier).ceil() as i32)
} else {
ticks
}
}
#[cfg(test)]
mod fire_ignition_tests {
use super::*;
#[test]
fn fire_hurts_before_twenty_tick_ignition_and_scales_duration() {
let mut r = horizon_core::random::LegacyRandom::new(3);
let mut ticks = -20;
for _ in 0..19 {
ticks = ignite_contact(ticks, true, &mut r, 0.4);
assert!(ticks < 0);
}
assert_eq!(ignite_contact(ticks, true, &mut r, 0.4), 64);
}
}
+19 -2
View File
@@ -109,6 +109,10 @@ impl PlayerMotion {
pub fn is_eye_in_fluid(&self, tag: FluidTagId) -> bool {
self.fluids.interaction.is_eye_in_fluid(&Membership, tag)
}
/// `Player.isUnderWater`: the eye sample from the start of the base tick.
pub fn is_under_water(&self) -> bool {
self.fluids.was_eye_in_water && self.fluids.was_touching_water
}
/// `Entity.isInLava`.
pub fn is_in_lava(&self) -> bool {
!self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&Membership, LAVA)
@@ -121,14 +125,27 @@ impl FluidMembership for Membership {
}
}
impl MotionWorld<'_, '_, '_> {
fn fluid_at(&self, p: BlockPos) -> FluidState {
pub(super) fn fluid_at(&self, p: BlockPos) -> FluidState {
self.resources.fluids.fluid(self.block(p))
}
/// `Entity.baseTick`'s fluid phase: `wasEyeInWater`, `updateFluidInteraction`,
/// the lava's fall halving, and the end of the first tick.
/// swimming and remaining fire, lava's fall halving, then the first-tick clear.
pub fn fluid_base_tick(&mut self) -> Result<()> {
self.player.fluids.was_eye_in_water = self.player.is_eye_in_fluid(WATER);
self.update_fluids()?;
self.player.swimming = !self.player.flying
&& self.player.sprinting
&& if self.player.swimming {
self.player.fluids.was_touching_water
} else {
self.player.is_under_water()
&& is_tagged(
self.fluid_at(BlockPos::containing(self.player.position))
.kind(),
WATER,
)
};
self.tick_remaining_fire()?;
if self.player.is_in_lava() {
self.player.fall_distance *= 0.5;
}
+4 -1
View File
@@ -72,7 +72,7 @@ impl PlayerMotion {
Ok(())
}
pub fn moving_slowly(&self) -> bool {
self.local_crouching || self.pose == Pose::Swimming // dry scene: not in water
self.local_crouching || (self.pose == Pose::Swimming && !self.fluids.was_touching_water)
}
}
impl MotionWorld<'_, '_, '_> {
@@ -87,6 +87,7 @@ impl MotionWorld<'_, '_, '_> {
/// the old shift input. This is distinct from Entity's current pose.
pub fn update_local_crouching(&mut self) -> Result<()> {
self.player.local_crouching = !self.player.flying
&& !self.player.swimming
&& self.can_fit_pose(Pose::Crouching)?
&& (self.player.shift_key_down || !self.can_fit_pose(Pose::Standing)?);
Ok(())
@@ -99,6 +100,8 @@ impl MotionWorld<'_, '_, '_> {
}
let desired = if self.player.fall_flying {
Pose::FallFlying
} else if self.player.swimming {
Pose::Swimming
} else if self.player.shift_key_down && !self.player.flying {
Pose::Crouching
} else {
+85
View File
@@ -175,6 +175,41 @@ impl Projectile {
*critical = value;
}
}
/// `AbstractArrow.tick`: critical streaks and submerged bubble trails.
pub fn arrow_trail(
&self,
water: bool,
) -> Vec<(crate::particles::ParticleType, [f64; 3], [f64; 3])> {
use crate::particles::ParticleType;
let mut trail = Vec::new();
if self.stuck || !self.is_arrow() {
return trail;
}
let (p, v) = (self.position, self.velocity);
if water {
let drag = f64::from(0.6f32);
let speed = [v.x * drag, v.y * drag, v.z * drag];
let at = [
p.x - speed[0] * 0.25,
p.y - speed[1] * 0.25,
p.z - speed[2] * 0.25,
];
for _ in 0..4 {
trail.push((ParticleType::Bubble, at, speed));
}
}
if matches!(self.kind, ProjectileKind::Arrow { critical: true, .. }) {
for i in 0..4 {
let t = i as f64 / 4.0;
trail.push((
ParticleType::Crit,
[p.x + v.x * t, p.y + v.y * t, p.z + v.z * t],
[-v.x, -v.y + 0.2, -v.z],
));
}
}
trail
}
pub fn is_arrow(&self) -> bool {
matches!(self.kind, ProjectileKind::Arrow { .. })
}
@@ -743,3 +778,53 @@ mod tests {
assert!((p.velocity.x - 0.19).abs() < 1e-9);
}
}
#[cfg(test)]
mod trail_tests {
use super::*;
use crate::particles::ParticleType;
fn arrow(critical: bool) -> Projectile {
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical,
effect: None,
},
Vec3::new(1.0, 2.0, 3.0),
None,
);
p.velocity = Vec3::new(4.0, 2.0, -1.0);
p
}
#[test]
fn critical_arrow_emits_four_native_streak_samples() {
let trail = arrow(true).arrow_trail(false);
assert_eq!(trail.len(), 4);
for (i, (ty, pos, speed)) in trail.iter().enumerate() {
assert_eq!(*ty, ParticleType::Crit);
assert_eq!(
*pos,
[1.0 + i as f64, 2.0 + i as f64 * 0.5, 3.0 - i as f64 * 0.25]
);
assert_eq!(*speed, [-4.0, -1.8, 1.0]);
}
}
#[test]
fn submerged_arrow_emits_four_bubbles_after_water_inertia() {
let trail = arrow(false).arrow_trail(true);
assert_eq!(trail.len(), 4);
let drag = f64::from(0.6f32);
for (ty, pos, speed) in trail {
assert_eq!(ty, ParticleType::Bubble);
assert_eq!(speed, [4.0 * drag, 2.0 * drag, -drag]);
assert_eq!(pos, [1.0 - drag, 2.0 - drag * 0.5, 3.0 + drag * 0.25]);
}
}
#[test]
fn ordinary_and_stuck_arrows_do_not_emit_critical_trails() {
assert!(arrow(false).arrow_trail(false).is_empty());
let mut p = arrow(true);
p.stuck = true;
assert!(p.arrow_trail(true).is_empty());
}
}
+146
View File
@@ -19,6 +19,18 @@ impl SpriteAnimation {
pub fn frame(&self, tick: u64) -> usize {
self.clip.sample(tick).frame as usize
}
pub fn resized_pixels(&self, frame: usize, width: u32, height: u32) -> Vec<u8> {
let source = self.pixels(frame);
let side = (source.len() / 4).isqrt() as u32;
let mut out = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
let offset = ((y * side / height * side + x * side / width) * 4) as usize;
out.extend_from_slice(&source[offset..offset + 4]);
}
}
out
}
pub fn pixels(&self, frame: usize) -> &[u8] {
&self.frames[frame]
}
@@ -29,6 +41,8 @@ pub struct Sprites {
/// Edge length of every array layer and the layers' current frames, set
/// by [`Sprites::finish`].
pub size: u32,
/// Smallest native layer before array-size resampling.
pub minimum_size: u32,
pub pixels: Vec<u8>,
pub layers: BTreeMap<String, u32>,
pub animations: Vec<SpriteAnimation>,
@@ -68,6 +82,7 @@ impl Sprites {
pub fn finish(&mut self) {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
@@ -237,9 +252,140 @@ impl Sprites {
}
}
#[cfg(test)]
pub(crate) fn test_animation(size: u32, layer: u32) -> SpriteAnimation {
let frame = |blue: u8| {
(0..size * size)
.flat_map(|i| [(i % size) as u8, (i / size) as u8, blue, 255])
.collect()
};
let metadata = AnimationMetadata::from_json(r#"{"frametime":1,"frames":[0,1]}"#).unwrap();
SpriteAnimation {
layer,
frames: Arc::new(vec![frame(0), frame(99)]),
clip: Arc::new(metadata.clip(2).unwrap().unwrap()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
let mut sprites = Sprites {
native: vec![
(32, Arc::new(vec![vec![255; 32 * 32 * 4]]), 0),
(32, Arc::new(vec![vec![128; 32 * 32 * 4]]), 0),
],
..Default::default()
};
sprites.finish();
assert_eq!((sprites.size, sprites.minimum_size), (32, 32));
for level in 0..=4 {
assert_eq!(
crate::gfx::sprite_mipmap_limit(level, sprites.size, sprites.minimum_size, 6),
level
);
}
sprites
.native
.push((16, Arc::new(vec![vec![255; 16 * 16 * 4]]), 0));
sprites.finish();
assert_eq!((sprites.size, sprites.minimum_size), (32, 16));
assert_eq!(
crate::gfx::sprite_mipmap_limit(0, sprites.size, sprites.minimum_size, 6),
1
);
}
#[test]
fn hud_animation_resampling_uses_the_actual_frame_size() {
for size in [16, 32, 64] {
let animation = test_animation(size, 0);
let resized = animation.resized_pixels(1, 16, 16);
let last = (15 * size / 16) as u8;
assert_eq!(&resized[15 * 4..16 * 4], &[last, 0, 99, 255]);
assert_eq!(&resized[255 * 4..256 * 4], &[last, last, 99, 255]);
}
}
#[test]
fn native_fire_sprites_register_full_animation_sequences() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root
.join("assets/minecraft/textures/block/fire_0.png")
.exists()
{
return;
}
let mut sprites = Sprites::default();
for name in ["fire_0", "fire_1", "soul_fire_0", "soul_fire_1"] {
let layer = sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
let animation = sprites
.animations
.iter()
.find(|a| a.layer == layer)
.expect("fire must be animated");
assert_ne!(
animation.pixels(animation.frame(0)),
animation.pixels(animation.frame(1))
);
assert_ne!(
animation.resized_pixels(animation.frame(0), 16, 16),
animation.resized_pixels(animation.frame(1), 16, 16)
);
assert_eq!(
animation.pixels(animation.frame(0)).len(),
(SPRITE_SIZE * SPRITE_SIZE * 4) as usize
);
}
}
#[test]
fn native_32_pixel_animation_frames_keep_every_texel() {
let root = std::env::temp_dir().join(format!(
"horizon-sprite-detail-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let directory = root.join("assets/test/textures");
fs::create_dir_all(&directory).unwrap();
let path = directory.join("flow.png");
let mut pixels = Vec::new();
for y in 0..64u8 {
for x in 0..32u8 {
pixels.extend([x, y, 0, 255]);
}
}
let mut encoder = png::Encoder::new(File::create(&path).unwrap(), 32, 64);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
fs::write(path.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
let mut sprites = Sprites::default();
sprites.insert(&root, "test:flow").unwrap();
sprites.finish();
let size = sprites.size;
let initial = sprites.pixels.clone();
let animation = sprites.animations[0].clone();
fs::remove_dir_all(root).unwrap();
assert_eq!(
size, 32,
"half a flowing-water frame must retain 16 texels, not 8"
);
assert_eq!(initial, pixels[..32 * 32 * 4]);
assert_eq!(animation.pixels(animation.frame(1)), &pixels[32 * 32 * 4..]);
}
#[test]
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
let animation = SpriteAnimation {
+2
View File
@@ -9,6 +9,7 @@ use std::{
};
pub struct World {
pub fast_lava: bool,
pub chunks: crate::fast_hash::FxHashMap<(i32, i32), Arc<Chunk>>,
pub random: LegacyRandom,
pub revision: u64,
@@ -113,6 +114,7 @@ impl World {
revision: 0,
health_rules: crate::health::HealthRules::default(),
air,
fast_lava: false,
dirty_sections: Default::default(),
dirty_light: Default::default(),
light_edits: Vec::new(),
+6 -1
View File
@@ -424,7 +424,12 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
if !creative && data.is(Class::Tnt) && b.table.bool(state, p.unstable) {
self.prime_tnt(pos);
}
self.level_event(2001, pos, state.0 as i32);
// BaseFireBlock emits extinguish and suppresses generic destroy particles/sound.
if data.is(Class::BaseFire) {
self.level_event(1009, pos, 0);
} else {
self.level_event(2001, pos, state.0 as i32);
}
self.game_event(pos, "minecraft:block_destroy");
self.remove_block(pos, false)
}
+69
View File
@@ -367,6 +367,40 @@ fn water_spreads_on_a_floor() {
assert_eq!(w.prop(BlockPos::new(2, 1, 2), "level"), "4");
}
#[test]
fn bucket_source_removal_drains_spread_water() {
let mut w = world!();
w.floor(0, 10, "stone");
let at = BlockPos::new(0, 1, 0);
assert!(w.b().bucket_empty(
&mut w.level,
&mut w.ticks,
at,
BucketContent::Fluid(crate::fluid::FluidKind::Water),
"minecraft:item.bucket.empty"
));
for _ in 0..60 {
w.tick();
}
assert_ne!(w.get(BlockPos::new(1, 1, 0)), w.b().air());
assert_eq!(
w.b().bucket_pickup(&mut w.level, &mut w.ticks, at),
Some("minecraft:water_bucket")
);
for _ in 0..120 {
w.tick();
}
for x in -8..=8 {
for z in -8..=8 {
assert_eq!(
w.get(BlockPos::new(x, 1, z)),
w.b().air(),
"water remains at {x}, {z}"
);
}
}
}
#[test]
fn water_falls_and_makes_sources() {
let mut w = world!();
@@ -1192,3 +1226,38 @@ fn creaking_heart_follows_its_logs_and_the_night() {
}
assert_eq!(w.prop(heart_at, "creaking_heart_state"), "uprooted");
}
#[test]
fn player_breaks_fire_with_extinguish_not_generic_break_event() {
for name in ["fire", "soul_fire", "white_wool"] {
let mut w = world!();
let pos = BlockPos::new(0, 1, 0);
let support = w.id("soul_soil", &[]);
w.put(BlockPos::new(0, 0, 0), support);
let state = w.id(name, &[]);
w.put(pos, state);
w.level.events.clear();
assert!(
w.f.behaviours
.player_destroy_block(&mut w.level, &mut w.ticks, pos, false)
);
let is_fire = name != "white_wool";
assert_eq!(
w.level
.events
.iter()
.any(|e| matches!(e,LevelEvent::LevelEvent {id:1009,pos:p,..} if *p==pos)),
is_fire,
"extinguish for {name}"
);
assert_eq!(
w.level
.events
.iter()
.any(|e| matches!(e,LevelEvent::LevelEvent {id:2001,pos:p,..} if *p==pos)),
!is_fire,
"generic break for {name}"
);
assert_eq!(w.get(pos), w.b().air());
}
}