mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
52 files changed
+4737
-448
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)>,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ impl Game<'_> {
|
||||
_ => return false,
|
||||
};
|
||||
self.open_book(screen);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
fn open_book(&mut self, screen: BookScreen) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user