From c3ecf11cd729130b4eddbee1c8f2bc6c2367316e Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:21:51 +0000 Subject: [PATCH 01/10] zero tiny remote entity velocities each tick --- pomme-client/src/entity/mod.rs | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 9c0933ff0..3641fd55d 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -700,6 +700,24 @@ impl LivingEntity { // `LivingEntity.aiStep` for an entity that can't simulate movement. self.velocity *= 0.98; } + // `LivingEntity.aiStep` then zeroes tiny components. + let v = &mut self.velocity; + if self.entity_type == EntityKind::Player { + if v.x * v.x + v.z * v.z < 9.0e-6 { + v.x = 0.0; + v.z = 0.0; + } + } else { + if v.x.abs() < 0.003 { + v.x = 0.0; + } + if v.z.abs() < 0.003 { + v.z = 0.0; + } + } + if v.y.abs() < 0.003 { + v.y = 0.0; + } self.prev_head_y_rot_deg = self.head_y_rot_deg; if self.interp_head_y_rot_steps > 0 { @@ -1939,6 +1957,28 @@ mod tests { assert_eq!(store.living[&1].variant, CAT_DEFAULT_VARIANT); } + /// `LivingEntity.aiStep` zeroes velocity components under 0.003 (a + /// player's horizontal pair by its squared length) after the 0.98 decay. + #[test] + fn tiny_remote_velocities_zero_each_tick() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Pig, Position::default()); + spawn(&mut store, 2, EntityKind::Player, Position::default()); + store.set_living_motion(1, DVec3::new(0.003, 0.5, -0.004)); + store.set_living_motion(2, DVec3::new(0.002, 0.002, 0.002)); + let velocity = |store: &mut EntityStore, id| { + let e = store.living.get_mut(&id).unwrap(); + e.tick_interpolation(); + e.velocity + }; + let v = velocity(&mut store, 1); + assert_eq!((v.x, v.z), (0.0, -0.004 * 0.98)); + assert!((v.y - 0.49).abs() < 1e-12); + assert_eq!(velocity(&mut store, 2), DVec3::ZERO); + store.set_living_motion(2, DVec3::new(0.0035, 0.0, 0.0)); + assert_eq!(velocity(&mut store, 2).x, 0.0035 * 0.98); + } + /// `Sniffer.onSyncedDataUpdated`: each state update restarts the one /// animation clock; idling, searching and out-of-range ids stop it. #[test] From a526a2ff2d69aebfd449cbcfd0b4da4894ac430b Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:26:28 +0000 Subject: [PATCH 02/10] add llama, trader llama and mooshroom --- pomme-client/src/app/phases/in_game.rs | 39 ++- pomme-client/src/entity/mod.rs | 44 ++- pomme-client/src/renderer/entity_model.rs | 26 +- .../src/renderer/entity_model/llama.rs | 152 +++++++++++ .../src/renderer/pipelines/entity_renderer.rs | 252 ++++++++++++++---- tests/ingame/entities/llama.toml | 106 ++++++++ tests/ingame/entities/mooshroom.toml | 64 +++++ 7 files changed, 623 insertions(+), 60 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/llama.rs create mode 100644 tests/ingame/entities/llama.toml create mode 100644 tests/ingame/entities/mooshroom.toml diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 0f65eb1a3..9086e1a05 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4119,6 +4119,7 @@ pub fn update_game( sniffer: extras.sniffer, allay: extras.allay, copper_golem: extras.copper_golem, + llama_decor: extras.llama_decor, golem_attack_ticks: extras.golem_attack_ticks, golem_offer_flower_ticks: extras.golem_offer_flower_ticks, body_transform: extras.body_transform, @@ -5002,6 +5003,7 @@ struct EntityExtras { sniffer: crate::renderer::entity_model::SnifferAnims, allay: crate::renderer::entity_model::AllayAnims, copper_golem: crate::renderer::entity_model::CopperGolemAnims, + llama_decor: Option, golem_attack_ticks: f32, golem_offer_flower_ticks: u32, } @@ -5047,7 +5049,7 @@ fn entity_extras( game_time: i64, ) -> EntityExtras { match e.entity_type { - EntityKind::Cow => EntityExtras { + EntityKind::Cow | EntityKind::Mooshroom => EntityExtras { variant_index: e.variant, ..Default::default() }, @@ -5130,6 +5132,7 @@ fn entity_extras( ..equine_extras(e, alpha) }, EntityKind::SkeletonHorse | EntityKind::ZombieHorse => equine_extras(e, alpha), + EntityKind::Llama | EntityKind::TraderLlama => llama_extras(e), EntityKind::Squid | EntityKind::GlowSquid => squid_extras(e, alpha), EntityKind::Bat => EntityExtras { bat_elapsed_secs: e.bat_anim_start.map(|s| anim_clock_secs(e, s, alpha)), @@ -5163,6 +5166,40 @@ fn entity_extras( } } +/// Vanilla `LlamaRenderer.extractRenderState` and `LlamaDecorLayer.submit`: +/// babies show neither a chest nor a carpet, and a body item with an +/// equipment asset replaces the trader decor. +fn llama_extras(e: &crate::entity::LivingEntity) -> EntityExtras { + use crate::renderer::pipelines::entity_renderer::{ + ArmorRenderInfo, LlamaDecor, equipment_render_info, + }; + let has_chest = !e.is_baby && e.has_chest; + let body = e + .equipment(EquipmentSlot::Body) + .and_then(equipment_render_info); + let equipment = match body { + Some(body) if !e.is_baby => Some(body), + // `EquipmentAssets.TRADER_LLAMA(_BABY)` with an empty stack. + _ if e.entity_type == EntityKind::TraderLlama => Some(ArmorRenderInfo { + asset_id: if e.is_baby { + "minecraft:trader_llama_baby".into() + } else { + "minecraft:trader_llama".into() + }, + dyed_color: None, + }), + _ => None, + }; + EntityExtras { + variant_index: e.variant + if has_chest { 4 } else { 0 }, + llama_decor: equipment.map(|equipment| LlamaDecor { + equipment, + has_chest, + }), + ..Default::default() + } +} + /// Vanilla `SnifferRenderer.extractRenderState`. fn sniffer_anims( e: &crate::entity::LivingEntity, diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 3641fd55d..edbd15ad6 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -193,7 +193,9 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::Sniffer | EntityKind::WanderingTrader | EntityKind::SulfurCube + | EntityKind::Mooshroom ) || is_equine(&kind) + || is_llama(kind) } /// Kinds whose entity-data index 16 is the baby flag: `AgeableMob` @@ -481,7 +483,7 @@ fn pushes_local_player(entity: &LivingEntity) -> bool { // TODO: AbstractHorse.isPushable is `!isVehicle()`; ridden equines aren't // tracked. fn accepts_reciprocal_push(entity: &LivingEntity) -> bool { - is_equine(&entity.entity_type) || entity.health > 0.0 + is_equine(&entity.entity_type) || is_llama(entity.entity_type) || entity.health > 0.0 } /// `Entity.push(Entity)` impulses as (local player, remote entity). @@ -1525,6 +1527,11 @@ impl EntityStore { entity.is_open_mouth = f & 0x40 != 0; } (EntityKind::Donkey | EntityKind::Mule, 19, Bool(b)) => entity.has_chest = b, + (k, 19, Bool(b)) if is_llama(k) => entity.has_chest = b, + // `Llama.Variant` and `MushroomCow.Variant` ids clamp (vanilla + // `ByIdMap` CLAMP). + (k, 21, Int(v)) if is_llama(k) => entity.variant = v.clamp(0, 3) as u32, + (EntityKind::Mooshroom, 18, Int(v)) => entity.variant = v.clamp(0, 1) as u32, // Horse packed variant: `color | markings << 8`, both wrapping // their id ranges (vanilla `ByIdMap` WRAP). (EntityKind::Horse, 19, Int(v)) => { @@ -1865,8 +1872,15 @@ pub fn is_equine(kind: &EntityKind) -> bool { ) } +/// Vanilla `Llama` and its `TraderLlama` subclass: `AbstractHorse`s that +/// pomme renders apart from the horse family. +pub fn is_llama(kind: EntityKind) -> bool { + matches!(kind, EntityKind::Llama | EntityKind::TraderLlama) +} + pub fn is_living_mob(kind: &EntityKind) -> bool { is_equine(kind) + || is_llama(*kind) || matches!( kind, EntityKind::Player @@ -1905,6 +1919,7 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::CopperGolem | EntityKind::WanderingTrader | EntityKind::SulfurCube + | EntityKind::Mooshroom ) } @@ -2009,6 +2024,33 @@ mod tests { assert_eq!(state(&store), (SnifferState::Idling, None)); } + /// `AbstractChestedHorse.DATA_ID_CHEST` (19), `Llama.DATA_VARIANT_ID` + /// (21) and `MushroomCow.DATA_TYPE` (18); out-of-range variant ids clamp. + #[test] + fn llama_and_mooshroom_synced_data() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::TraderLlama, Position::default()); + spawn(&mut store, 2, EntityKind::Mooshroom, Position::default()); + + store.apply_entity_data(1, 19, MetaValue::Bool(true)); + store.apply_entity_data(1, 21, MetaValue::Int(2)); + assert!(store.living[&1].has_chest); + assert_eq!(store.living[&1].variant, 2); + store.apply_entity_data(1, 21, MetaValue::Int(7)); + assert_eq!(store.living[&1].variant, 3); + store.apply_entity_data(1, 21, MetaValue::Int(-1)); + assert_eq!(store.living[&1].variant, 0); + store.apply_entity_data(1, 16, MetaValue::Bool(true)); + assert!(store.living[&1].is_baby); + + store.apply_entity_data(2, 18, MetaValue::Int(1)); + assert_eq!(store.living[&2].variant, 1); + store.apply_entity_data(2, 18, MetaValue::Int(5)); + assert_eq!(store.living[&2].variant, 1); + store.apply_entity_data(2, 16, MetaValue::Bool(true)); + assert!(store.living[&2].is_baby); + } + fn tick(store: &mut EntityStore) { store.tick_living( &ChunkStore::new(2), diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 4aeb1c8d1..0ec4ad1fc 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -4,7 +4,9 @@ use crate::renderer::chunk::mesher::pack_uv; use crate::renderer::rhi; mod copper_golem_anim; +mod llama; mod sniffer_anim; +pub use llama::*; /// An entity or block-entity model vertex: its position, UV and face normal. #[repr(C)] @@ -2634,14 +2636,14 @@ pub fn bake_snow_golem_model() -> BakedEntityModel { Vec3::new(0.0, 4.0, 0.0), vec![shrunk(vbox((0, 0), (-4.0, -8.0, -4.0), (8.0, 8.0, 8.0)))], ), - EntityPart { - default_rotation: Vec3::new(0.0, 0.0, 1.0), - ..vpart("left_arm", None, Vec3::new(5.0, 6.0, 1.0), vec![arm()]) - }, - EntityPart { - default_rotation: Vec3::new(0.0, std::f32::consts::PI, -1.0), - ..vpart("right_arm", None, Vec3::new(-5.0, 6.0, -1.0), vec![arm()]) - }, + rotated( + vpart("left_arm", None, Vec3::new(5.0, 6.0, 1.0), vec![arm()]), + Vec3::new(0.0, 0.0, 1.0), + ), + rotated( + vpart("right_arm", None, Vec3::new(-5.0, 6.0, -1.0), vec![arm()]), + Vec3::new(0.0, std::f32::consts::PI, -1.0), + ), vpart( "upper_body", None, @@ -3529,6 +3531,14 @@ fn vpart(name: &str, parent: Option, offset: Vec3, cubes: Vec) } } +/// `vpart` with a `PartPose.offsetAndRotation` rest rotation. +fn rotated(part: EntityPart, rotation: Vec3) -> EntityPart { + EntityPart { + default_rotation: rotation, + ..part + } +} + fn villager_parts() -> Vec { vec![ vpart( diff --git a/pomme-client/src/renderer/entity_model/llama.rs b/pomme-client/src/renderer/entity_model/llama.rs new file mode 100644 index 000000000..2c54c7306 --- /dev/null +++ b/pomme-client/src/renderer/entity_model/llama.rs @@ -0,0 +1,152 @@ +//! Llama and trader llama meshes: vanilla `LlamaModel` and `BabyLlamaModel`. +//! Both models' `setupAnim` is the quadruped head look and leg swing +//! (`compute_quadruped_anim`) plus the chest toggle, which is a separate +//! bake here (an empty chest part draws nothing). + +use std::f32::consts::FRAC_PI_2; + +use glam::Vec3; + +use super::{ + BakedEntityModel, EntityPart, bake_model, inflate, quadruped_legs, rotated, vbox, vpart, +}; + +/// Vanilla `LlamaModel.createBodyLayer(NONE)`, 128x64. +fn llama_parts(chest: bool) -> Vec { + let chest_part = |name: &str, x: f32, uv: (i32, i32)| { + rotated( + vpart( + name, + None, + Vec3::new(x, 3.0, 3.0), + if chest { + vec![vbox(uv, (-3.0, 0.0, 0.0), (8.0, 8.0, 3.0))] + } else { + vec![] + }, + ), + Vec3::new(0.0, FRAC_PI_2, 0.0), + ) + }; + let leg = vbox((29, 29), (-2.0, 0.0, -2.0), (4.0, 14.0, 4.0)); + let mut parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, 7.0, -6.0), + vec![ + vbox((0, 0), (-2.0, -14.0, -10.0), (4.0, 4.0, 9.0)), + // neck + vbox((0, 14), (-4.0, -16.0, -6.0), (8.0, 18.0, 6.0)), + // ears + vbox((17, 0), (-4.0, -19.0, -4.0), (3.0, 3.0, 2.0)), + vbox((17, 0), (1.0, -19.0, -4.0), (3.0, 3.0, 2.0)), + ], + ), + rotated( + vpart( + "body", + None, + Vec3::new(0.0, 5.0, 2.0), + vec![vbox((29, 0), (-6.0, -10.0, -7.0), (12.0, 18.0, 10.0))], + ), + Vec3::new(FRAC_PI_2, 0.0, 0.0), + ), + chest_part("right_chest", -8.5, (45, 28)), + chest_part("left_chest", 5.5, (45, 41)), + ]; + parts.extend(quadruped_legs(3.5, 10.0, -5.0, 6.0, leg, leg)); + parts +} + +/// Vanilla `BabyLlamaModel.createBodyLayer(NONE)`, 64x64. Its chest parts +/// are left out: `LlamaRenderer` clears `hasChest` for babies, so they never +/// show. +fn baby_llama_parts() -> Vec { + let leg = |name: &str, x: f32, z: f32, uv: (i32, i32), origin_x: f32| { + vpart( + name, + None, + Vec3::new(x, 16.5, z), + vec![vbox(uv, (origin_x, -0.5, -1.5), (3.0, 8.0, 3.0))], + ) + }; + vec![ + vpart( + "head", + None, + Vec3::new(0.0, 12.0, -4.0), + vec![ + vbox((0, 0), (-3.0, -9.0, -4.0), (6.0, 11.0, 4.0)), + vbox((0, 15), (-1.5, -7.0, -7.0), (3.0, 3.0, 3.0)), + vbox((20, 4), (0.5, -11.0, -3.0), (2.0, 2.0, 2.0)), + vbox((20, 0), (-2.5, -11.0, -3.0), (2.0, 2.0, 2.0)), + ], + ), + leg("right_hind_leg", -2.5, 4.5, (0, 45), -1.4), + leg("left_hind_leg", 2.5, 4.5, (12, 45), -1.6), + leg("right_front_leg", -2.5, -3.5, (0, 34), -1.4), + leg("left_front_leg", 2.5, -3.5, (12, 34), -1.6), + vpart( + "body", + None, + Vec3::new(0.0, 14.0, 2.5), + vec![vbox((0, 15), (-4.0, -3.0, -8.5), (8.0, 6.0, 13.0))], + ), + ] +} + +pub fn bake_llama_model(chest: bool) -> BakedEntityModel { + bake_model(llama_parts(chest), 128, 64) +} + +/// Vanilla `LLAMA_DECOR` (`LlamaModel` at `CubeDeformation(0.5)`) for +/// `LlamaDecorLayer`, whose `setupAnim` shows its chests with the base's. +pub fn bake_llama_decor_model(chest: bool) -> BakedEntityModel { + let mut parts = llama_parts(chest); + inflate(&mut parts, 0.5); + bake_model(parts, 128, 64) +} + +pub fn bake_baby_llama_model() -> BakedEntityModel { + bake_model(baby_llama_parts(), 64, 64) +} + +/// Vanilla `LLAMA_BABY_DECOR` (`BabyLlamaModel` at `CubeDeformation(0.2)`). +pub fn bake_baby_llama_decor_model() -> BakedEntityModel { + let mut parts = baby_llama_parts(); + inflate(&mut parts, 0.2); + bake_model(parts, 64, 64) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `compute_quadruped_anim` drives these parts by name, and the chest + /// toggle only empties the chest parts. + #[test] + fn llama_meshes_have_the_quadruped_parts() { + for parts in [llama_parts(false), llama_parts(true), baby_llama_parts()] { + for name in [ + "head", + "body", + "right_hind_leg", + "left_hind_leg", + "right_front_leg", + "left_front_leg", + ] { + super::super::part_index(&parts, name); + } + } + let chest_cubes = |parts: &[EntityPart]| { + parts + .iter() + .filter(|p| p.name.ends_with("_chest")) + .map(|p| p.cubes.len()) + .sum::() + }; + assert_eq!(chest_cubes(&llama_parts(false)), 0); + assert_eq!(chest_cubes(&llama_parts(true)), 2); + } +} diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 4493b6430..b2abad5f3 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -78,10 +78,16 @@ fn effective_item_component<'a, T: DataComponentTrait>(item: &'a ItemStack) -> O pub fn armor_render_info(item: Option<&ItemStack>, slot: EquipmentSlot) -> Option { let item = item?; - let equippable = effective_item_component::(item)?; - if equippable.slot != slot { + if effective_item_component::(item)?.slot != slot { return None; } + equipment_render_info(item) +} + +/// The stack's equipment asset and dye, whatever its `Equippable` slot +/// (vanilla `LlamaDecorLayer` checks only `assetId().isPresent()`). +pub fn equipment_render_info(item: &ItemStack) -> Option { + let equippable = effective_item_component::(item)?; let asset_id = equippable.asset_id.as_ref()?.to_string().into(); let dyed_color = effective_item_component::(item).map(|color| color.rgb as u32 & 0x00ff_ffff); @@ -91,6 +97,14 @@ pub fn armor_render_info(item: Option<&ItemStack>, slot: EquipmentSlot) -> Optio }) } +/// Vanilla `LlamaDecorLayer`: an equipment asset's `llama_body` layers on +/// the decor model, whose chests show with the base model's. +#[derive(Clone, Debug)] +pub struct LlamaDecor { + pub equipment: ArmorRenderInfo, + pub has_chest: bool, +} + /// Armor slots in `EntityRenderInfo::armor` order. pub const ARMOR_SLOTS: [EquipmentSlot; 4] = [ EquipmentSlot::Head, @@ -181,6 +195,8 @@ pub struct EntityRenderInfo { pub sniffer: entity_model::SnifferAnims, pub allay: entity_model::AllayAnims, pub copper_golem: entity_model::CopperGolemAnims, + /// Llama carpet or trader decor. + pub llama_decor: Option, /// Iron golem countdowns; the punch one is partial-tick adjusted. pub golem_attack_ticks: f32, pub golem_offer_flower_ticks: u32, @@ -261,6 +277,7 @@ impl Default for EntityRenderInfo { sniffer: entity_model::SnifferAnims::default(), allay: entity_model::AllayAnims::default(), copper_golem: entity_model::CopperGolemAnims::default(), + llama_decor: None, golem_attack_ticks: 0.0, golem_offer_flower_ticks: 0, base_tint: WHITE_TINT, @@ -391,6 +408,7 @@ struct ArmorLayerDef { struct ArmorAssetDef { humanoid: Vec, leggings: Vec, + llama_body: Vec, } struct ArmorTextureLayer { @@ -432,6 +450,7 @@ fn rgb_tint(rgb: u32) -> [f32; 4] { struct ArmorAsset { humanoid: Vec, leggings: Vec, + llama_body: Vec, } struct ArmorMesh { @@ -443,6 +462,9 @@ struct ArmorMeshes { humanoid: [ArmorMesh; 4], husk: [ArmorMesh; 4], zombie_villager: [ArmorMesh; 4], + /// `LLAMA_DECOR` chestless and chested; the baby never shows a chest. + llama_decor: [ArmorMesh; 2], + baby_llama_decor: ArmorMesh, } impl MobEntry { @@ -712,8 +734,15 @@ fn load_armor_asset_defs( }; let humanoid = layers("humanoid"); let leggings = layers("humanoid_leggings"); - (!humanoid.is_empty() || !leggings.is_empty()) - .then_some((id, ArmorAssetDef { humanoid, leggings })) + let llama_body = layers("llama_body"); + (!humanoid.is_empty() || !leggings.is_empty() || !llama_body.is_empty()).then_some(( + id, + ArmorAssetDef { + humanoid, + leggings, + llama_body, + }, + )) }) .collect() } @@ -747,8 +776,8 @@ fn build_armor_texture_layer( } } -/// Loads every equipment definition with humanoid layers and uploads its -/// textures. +/// Loads every equipment definition with humanoid or llama layers and +/// uploads its textures. fn load_armor_assets( device: &rhi::backend::Device, texture_layout: rhi::backend::BindGroupLayout, @@ -777,6 +806,7 @@ fn load_armor_assets( ArmorAsset { humanoid: def.humanoid.into_iter().map(build).collect(), leggings: def.leggings.into_iter().map(build).collect(), + llama_body: def.llama_body.into_iter().map(build).collect(), }, ) }) @@ -804,14 +834,20 @@ fn build_armor_meshes(device: &rhi::backend::Device) -> ArmorMeshes { humanoid: bake(entity_model::bake_humanoid_armor_model), husk: bake(entity_model::bake_husk_armor_model), zombie_villager: bake(entity_model::bake_zombie_villager_armor_model), + llama_decor: [false, true] + .map(|chest| build_armor_mesh(device, entity_model::bake_llama_decor_model(chest))), + baby_llama_decor: build_armor_mesh(device, entity_model::bake_baby_llama_decor_model()), } } fn destroy_armor_assets(device: &rhi::backend::Device, assets: &mut HashMap) { - for layer in assets - .drain() - .flat_map(|(_, asset)| asset.humanoid.into_iter().chain(asset.leggings)) - { + for layer in assets.drain().flat_map(|(_, asset)| { + asset + .humanoid + .into_iter() + .chain(asset.leggings) + .chain(asset.llama_body) + }) { layer.texture.destroy(device); layer.set.destroy(device); } @@ -1119,6 +1155,15 @@ fn mob_definitions() -> Vec { const SULFUR_CUBE_INNER_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner"); const SULFUR_CUBE_INNER_SMALL_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner_small"); + // Indexed by `Llama.Variant` id. + const LLAMA_TEX: &[&[&str]] = + tex_table!("llama" => "llama_creamy", "llama_white", "llama_brown", "llama_gray"); + const LLAMA_BABY_TEX: &[&[&str]] = tex_table!("llama" => + "llama_creamy_baby", "llama_white_baby", "llama_brown_baby", "llama_gray_baby"); + // Indexed by `MushroomCow.Variant` id. + const MOOSHROOM_TEX: &[&[&str]] = tex_table!("cow" => "mooshroom_red", "mooshroom_brown"); + const MOOSHROOM_BABY_TEX: &[&[&str]] = + tex_table!("cow" => "mooshroom_red_baby", "mooshroom_brown_baby"); const COD_TEX: &[&[&str]] = tex_table!("fish" => "cod"); const SALMON_TEX: &[&[&str]] = tex_table!("fish" => "salmon"); const PUFFERFISH_TEX: &[&[&str]] = tex_table!("fish" => "pufferfish"); @@ -1223,7 +1268,44 @@ fn mob_definitions() -> Vec { ] } + // Vanilla `LlamaRenderer`, shared by the trader llama. The variant pool + // is the four coats chestless, then chested. `LlamaDecorLayer` draws in + // the equipment pass (`EntityRenderInfo::llama_decor`). + let llama = |kind| MobDef { + kind, + anim: AnimationType::Quadruped, + adult: vec![ + opaque(entity_model::bake_llama_model(false), LLAMA_TEX, 128), + opaque(entity_model::bake_llama_model(true), LLAMA_TEX, 128), + ], + baby: Some(opaque( + entity_model::bake_baby_llama_model(), + LLAMA_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }; + vec![ + llama(EntityKind::Llama), + llama(EntityKind::TraderLlama), + // Vanilla `MushroomCowRenderer`: the cow meshes. + // TODO: `MushroomCowMushroomLayer` (three mushroom block models on + // the back and head) needs block models drawn through the entity + // pass. + MobDef { + kind: EntityKind::Mooshroom, + anim: AnimationType::Quadruped, + adult: vec![opaque(entity_model::bake_cow_model(), MOOSHROOM_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_cow_model(), + MOOSHROOM_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Pig, anim: AnimationType::Quadruped, @@ -2167,12 +2249,24 @@ impl EntityRenderer { &'a self, armor: &'a ArmorRenderInfo, slot: usize, + ) -> impl Iterator + 'a { + self.equipment_layers(armor, move |asset| asset.layers_for(slot)) + } + + /// Vanilla `EquipmentLayerRenderer.renderLayers`: the asset's layers of + /// one type, skipping those whose colour resolves to 0. + fn equipment_layers<'a>( + &'a self, + equipment: &'a ArmorRenderInfo, + layer_type: impl Fn(&'a ArmorAsset) -> &'a [ArmorTextureLayer] + 'a, ) -> impl Iterator + 'a { self.armor_assets - .get(&*armor.asset_id) + .get(&*equipment.asset_id) .into_iter() - .flat_map(move |asset| asset.layers_for(slot)) - .filter_map(move |layer| armor_layer_tint(layer, armor).map(|tint| (layer.set, tint))) + .flat_map(layer_type) + .filter_map(move |layer| { + armor_layer_tint(layer, equipment).map(|tint| (layer.set, tint)) + }) } fn collect_armor_draws( @@ -2182,6 +2276,15 @@ impl EntityRenderer { ) -> Vec { let mut records = Vec::new(); for (vi, v) in vis.iter().enumerate() { + if let Some(decor) = &v.info.llama_decor { + let mesh = if v.info.is_baby { + &self.armor_meshes.baby_llama_decor + } else { + &self.armor_meshes.llama_decor[decor.has_chest as usize] + }; + let layers = self.equipment_layers(&decor.equipment, |asset| &asset.llama_body); + self.push_equipment_draws(vi, v, mesh, layers, instances, &mut records); + } // TODO: baby armor (vanilla `HUMANOID_BABY` layer type + // `HumanoidModel.createBabyArmorMeshSet`) needs anisotropic // `CubeDeformation`, which pomme's model format lacks. @@ -2195,45 +2298,58 @@ impl EntityRenderer { let Some(mesh) = self.armor_meshes.for_entity(v.info.entity_kind, slot) else { continue; }; - // TODO: armor trims and foil (`EquipmentLayerRenderer`) need a - // trim atlas and an entity glint pass. - let mut layers = self.armor_layers(armor, slot).peekable(); - if layers.peek().is_none() { - continue; - } + let layers = self.armor_layers(armor, slot); + self.push_equipment_draws(vi, v, mesh, layers, instances, &mut records); + } + } + records + } - let anim = self.compute_anim(v.entry.anim, &mesh.model, v.info); - let part_transforms = mesh.model.compute_part_transforms(&anim); - for (texture_set, tint) in layers { - for (part, (start, part_count)) in mesh.model.part_ranges.iter().enumerate() { - if *part_count == 0 { - continue; - } - let first_instance = instances.len() as u32; - let model = v.entity_mat * part_transforms[part]; - instances.push( - vi, - EntityInstance { - model: model.to_cols_array_2d(), - tint, - // Vanilla armor is `NO_OVERLAY`: no hurt/death red. - overlay_color: NO_OVERLAY, - uv_params: [0.0, 0.0, ARMOR_ALPHA_CUTOFF, v.info.light.lane()], - }, - ); - records.push(DrawRecord { - texture_set, - vertex_buffer: mesh.vertex_buffer.handle(), - part_start: *start, - part_count: *part_count, - first_instance, - instance_count: 1, - }); - } + /// One draw per part per equipment layer, posed by the mob's animation. + // TODO: armor trims and foil (`EquipmentLayerRenderer`) need a trim atlas + // and an entity glint pass. + fn push_equipment_draws( + &self, + vi: usize, + v: &VisEntity<'_>, + mesh: &ArmorMesh, + layers: impl Iterator, + instances: &mut Instances, + records: &mut Vec, + ) { + let mut layers = layers.peekable(); + if layers.peek().is_none() { + return; + } + let anim = self.compute_anim(v.entry.anim, &mesh.model, v.info); + let part_transforms = mesh.model.compute_part_transforms(&anim); + for (texture_set, tint) in layers { + for (part, (start, part_count)) in mesh.model.part_ranges.iter().enumerate() { + if *part_count == 0 { + continue; } + let first_instance = instances.len() as u32; + let model = v.entity_mat * part_transforms[part]; + instances.push( + vi, + EntityInstance { + model: model.to_cols_array_2d(), + tint, + // Vanilla equipment is `NO_OVERLAY`: no hurt/death red. + overlay_color: NO_OVERLAY, + uv_params: [0.0, 0.0, ARMOR_ALPHA_CUTOFF, v.info.light.lane()], + }, + ); + records.push(DrawRecord { + texture_set, + vertex_buffer: mesh.vertex_buffer.handle(), + part_start: *start, + part_count: *part_count, + first_instance, + instance_count: 1, + }); } } - records } fn compute_anim( @@ -2786,6 +2902,8 @@ impl EntityRenderer { .iter_mut() .chain(self.armor_meshes.husk.iter_mut()) .chain(self.armor_meshes.zombie_villager.iter_mut()) + .chain(self.armor_meshes.llama_decor.iter_mut()) + .chain([&mut self.armor_meshes.baby_llama_decor]) { std::mem::take(&mut mesh.vertex_buffer).destroy(device); } @@ -3090,8 +3208,12 @@ const PLAYER_MODEL_SCALE: f32 = 0.9375; pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { match kind { EntityKind::Villager if is_baby => 0.63, - // An explicit `eyeHeight(1.05)`, which the 0.85 ratio would miss. - EntityKind::Sniffer if is_baby => dimensions::EntityDimensions::eye_height(kind) * 0.5, + // Explicit `BABY_DIMENSIONS` eye heights. + EntityKind::Mooshroom if is_baby => 0.69, + // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. + EntityKind::Sniffer | EntityKind::Llama | EntityKind::TraderLlama if is_baby => { + dimensions::EntityDimensions::eye_height(kind) * 0.5 + } _ if is_baby => entity_bounds(kind, is_baby).1 * dimensions::DEFAULT_EYE_HEIGHT_RATIO, _ => dimensions::EntityDimensions::eye_height(kind), } @@ -3509,6 +3631,36 @@ mod tests { armor_render_info, body_route, equipment_texture_key, layer_route, }; + /// `LlamaDecorLayer` takes any equippable asset, whatever its slot. + #[test] + fn equipment_render_info_ignores_the_equippable_slot() { + let carpet = ItemStack::new(ItemKind::LightBlueCarpet, 1); + let info = super::equipment_render_info(&carpet).expect("carpets have an asset"); + assert_eq!(&*info.asset_id, "minecraft:light_blue_carpet"); + let helmet = ItemStack::new(ItemKind::DiamondHelmet, 1); + assert_eq!( + &*super::equipment_render_info(&helmet).unwrap().asset_id, + "minecraft:diamond" + ); + assert!(super::equipment_render_info(&ItemStack::new(ItemKind::Stone, 1)).is_none()); + } + + /// The trader llama's decor resolves through its `llama_body` layer + /// like a carpet's. + #[test] + fn llama_body_layers_resolve_their_own_texture_dir() { + let info: EquipmentClientInfoJson = serde_json::from_str( + r#"{"layers":{"llama_body":[{"texture":"minecraft:trader_llama"}]}}"#, + ) + .expect("valid equipment fixture"); + let layers = armor_layer_defs(&info.layers["llama_body"], "llama_body"); + assert_eq!( + layers[0].texture_key, + "minecraft/textures/entity/equipment/llama_body/trader_llama.png" + ); + assert_eq!(layers[0].dye, ArmorDye::None); + } + fn invisible(visibility: Visibility, glowing: bool) -> EntityRenderInfo { EntityRenderInfo { visibility, diff --git a/tests/ingame/entities/llama.toml b/tests/ingame/entities/llama.toml new file mode 100644 index 000000000..f21a89fb2 --- /dev/null +++ b/tests/ingame/entities/llama.toml @@ -0,0 +1,106 @@ +# Llamas side on: the four coats, chests and carpets on adults, then trader +# llamas and babies (a baby never shows its chest or a carpet, but a baby +# trader llama keeps its own decor). + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +# Creamy, chested white, carpeted brown, chested and carpeted gray. +[[step]] +chat = "/summon minecraft:llama -3 -60 6.5 {NoAI:1b,Variant:0,Rotation:[90f,0f]}" + +[[step]] +chat = "/summon minecraft:llama -1 -60 6.5 {NoAI:1b,Variant:1,ChestedHorse:1b,Rotation:[90f,0f]}" + +[[step]] +chat = '/summon minecraft:llama 1 -60 6.5 {NoAI:1b,Variant:2,equipment:{body:{id:"minecraft:red_carpet",count:1}},Rotation:[90f,0f]}' + +[[step]] +chat = '/summon minecraft:llama 3 -60 6.5 {NoAI:1b,Variant:3,ChestedHorse:1b,equipment:{body:{id:"minecraft:light_blue_carpet",count:1}},Rotation:[90f,0f]}' + +[[step]] +expect = { entity = "llama", baby = false } + +[[step]] +look_at = [0.0, -59.0, 6.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "llamas" + +# Clear the bodies and their drops (chests, carpets, leather). +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +wait_ms = 1500 + +[[step]] +chat = "/kill @e[type=item]" + +# Trader llama, carpeted trader llama (the carpet wins), baby trader llama, +# and a chested, carpeted baby llama (neither shows). +[[step]] +chat = "/summon minecraft:trader_llama -3 -60 6.5 {NoAI:1b,Variant:0,Rotation:[90f,0f]}" + +[[step]] +chat = '/summon minecraft:trader_llama -1 -60 6.5 {NoAI:1b,Variant:2,equipment:{body:{id:"minecraft:lime_carpet",count:1}},Rotation:[90f,0f]}' + +[[step]] +chat = "/summon minecraft:trader_llama 1 -60 6.5 {NoAI:1b,Variant:1,Age:-24000,Rotation:[90f,0f]}" + +[[step]] +chat = '/summon minecraft:llama 2.5 -60 6.5 {NoAI:1b,Variant:3,Age:-24000,ChestedHorse:1b,equipment:{body:{id:"minecraft:red_carpet",count:1}},Rotation:[90f,0f]}' + +[[step]] +expect = { entity = "trader_llama", baby = true } + +[[step]] +expect = { entity = "llama", baby = true } + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "trader_llamas" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +wait_ms = 1500 + +[[step]] +chat = "/kill @e[type=item]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/mooshroom.toml b/tests/ingame/entities/mooshroom.toml new file mode 100644 index 000000000..f55b31a76 --- /dev/null +++ b/tests/ingame/entities/mooshroom.toml @@ -0,0 +1,64 @@ +# Red and brown mooshrooms with their babies, side on. Adults carry +# mushroom blocks on the back and head (MushroomCowMushroomLayer). + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = '/summon minecraft:mooshroom -2.5 -60 6.5 {NoAI:1b,Type:"red",Rotation:[90f,0f]}' + +[[step]] +chat = '/summon minecraft:mooshroom -0.5 -60 6.5 {NoAI:1b,Type:"brown",Rotation:[90f,0f]}' + +[[step]] +expect = { entity = "mooshroom", baby = false } + +[[step]] +chat = '/summon minecraft:mooshroom 1.5 -60 6.5 {NoAI:1b,Type:"red",Age:-24000,Rotation:[90f,0f]}' + +[[step]] +chat = '/summon minecraft:mooshroom 3 -60 6.5 {NoAI:1b,Type:"brown",Age:-24000,Rotation:[90f,0f]}' + +[[step]] +expect = { entity = "mooshroom", baby = true } + +[[step]] +look_at = [0.25, -59.5, 6.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "mooshrooms" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From 453d10de6852d15c03ddcc3251e2b3b86518d2e7 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:02:24 +0000 Subject: [PATCH 03/10] add dolphin and turtle --- pomme-client/src/app/phases/in_game.rs | 18 ++ pomme-client/src/entity/mod.rs | 81 +++++- pomme-client/src/renderer/entity_model.rs | 39 +++ .../src/renderer/entity_model/dolphin.rs | 201 +++++++++++++++ .../src/renderer/entity_model/turtle.rs | 240 ++++++++++++++++++ .../src/renderer/pipelines/entity_renderer.rs | 63 ++++- tests/ingame/entities/dolphin.toml | 81 ++++++ tests/ingame/entities/turtle.toml | 62 +++++ 8 files changed, 770 insertions(+), 15 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/dolphin.rs create mode 100644 pomme-client/src/renderer/entity_model/turtle.rs create mode 100644 tests/ingame/entities/dolphin.toml create mode 100644 tests/ingame/entities/turtle.toml diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 9086e1a05..148bb76f2 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4120,6 +4120,8 @@ pub fn update_game( allay: extras.allay, copper_golem: extras.copper_golem, llama_decor: extras.llama_decor, + dolphin_moving: extras.dolphin_moving, + turtle: extras.turtle, golem_attack_ticks: extras.golem_attack_ticks, golem_offer_flower_ticks: extras.golem_offer_flower_ticks, body_transform: extras.body_transform, @@ -5004,6 +5006,8 @@ struct EntityExtras { allay: crate::renderer::entity_model::AllayAnims, copper_golem: crate::renderer::entity_model::CopperGolemAnims, llama_decor: Option, + dolphin_moving: bool, + turtle: crate::renderer::entity_model::TurtleAnims, golem_attack_ticks: f32, golem_offer_flower_ticks: u32, } @@ -5143,6 +5147,20 @@ fn entity_extras( | EntityKind::TropicalFish | EntityKind::Pufferfish => fish_extras(e, alpha), EntityKind::IronGolem => golem_extras(e, alpha), + // `DolphinRenderer.extractRenderState`. + EntityKind::Dolphin => EntityExtras { + dolphin_moving: e.velocity.x * e.velocity.x + e.velocity.z * e.velocity.z > 1.0e-7, + ..Default::default() + }, + // `TurtleRenderer.extractRenderState`. + EntityKind::Turtle => EntityExtras { + turtle: crate::renderer::entity_model::TurtleAnims { + on_land: !e.is_in_water && e.on_ground, + laying_egg: e.laying_egg, + has_egg: !e.is_baby && e.has_egg, + }, + ..Default::default() + }, EntityKind::Sniffer => EntityExtras { sniffer: sniffer_anims(e, alpha), ..Default::default() diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index edbd15ad6..25891c7ba 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -194,6 +194,8 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::WanderingTrader | EntityKind::SulfurCube | EntityKind::Mooshroom + | EntityKind::Dolphin + | EntityKind::Turtle ) || is_equine(&kind) || is_llama(kind) } @@ -218,10 +220,22 @@ fn is_baby_kind(kind: EntityKind) -> bool { /// every subclass index up by one; older wire versions are lifted to the /// 26.x numbering so `apply_entity_data` matches one index per field. fn normalize_ageable_index(kind: EntityKind, index: u8) -> u8 { - if is_ageable_mob(kind) && index >= 17 && crate::version::session_protocol() < 775 { + normalize_ageable_index_at(kind, index, crate::version::session_protocol()) +} + +fn normalize_ageable_index_at(kind: EntityKind, index: u8, protocol: i32) -> u8 { + let index = if is_ageable_mob(kind) && index >= 17 && protocol < 775 { index + 1 } else { index + }; + // Before 1.21.5 (770) `Turtle.HOME_POS` was synched ahead of the egg + // flags (and the travel state after them); it shares 18 with `HAS_EGG` + // but never matches as a bool. + if kind == EntityKind::Turtle && index >= 19 && protocol < 770 { + index - 1 + } else { + index } } @@ -391,6 +405,9 @@ pub struct LivingEntity { pub copper_golem: CopperGolemAnim, /// Sulfur cube fuse ticks left, -1 while unprimed (`SulfurCube.fuse`). pub sulfur_fuse: i32, + /// `Turtle.HAS_EGG` / `LAYING_EGG`. + pub has_egg: bool, + pub laying_egg: bool, pub puff_state: u8, /// Glow squid post-hurt dim timer, synced then decremented client-side. pub dark_ticks: i32, @@ -438,6 +455,19 @@ pub struct LivingEntity { interp_head_y_rot_steps: i32, } +/// A baby's (width, height) from the adult's: vanilla `BABY_DIMENSIONS`, +/// explicit or scaled, else the generic half scale. +pub(crate) fn baby_dimensions(kind: EntityKind, width: f32, height: f32) -> (f32, f32) { + let scale = match kind { + EntityKind::Squid | EntityKind::GlowSquid => return (0.5, 0.5), + // Their `getAgeScale`. + EntityKind::Dolphin => 0.65, + EntityKind::Turtle => 0.3, + _ => 0.5, + }; + (width * scale, height * scale) +} + pub(crate) fn living_entity_dimensions(entity: &LivingEntity) -> EntityDimensions { let mut dims = EntityDimensions::of(entity.entity_type); // `Sniffer.DIGGING_DIMENSIONS`, then the same age scale. @@ -445,18 +475,7 @@ pub(crate) fn living_entity_dimensions(entity: &LivingEntity) -> EntityDimension dims.height -= 0.4; } if entity.is_baby { - // `Squid.BABY_DIMENSIONS` is an explicit 0.5x0.5, not the generic - // half scale. - if matches!( - entity.entity_type, - EntityKind::Squid | EntityKind::GlowSquid - ) { - dims.width = 0.5; - dims.height = 0.5; - } else { - dims.width *= 0.5; - dims.height *= 0.5; - } + (dims.width, dims.height) = baby_dimensions(entity.entity_type, dims.width, dims.height); } dims } @@ -613,6 +632,8 @@ impl LivingEntity { allay: AllayAnim::default(), copper_golem: CopperGolemAnim::default(), sulfur_fuse: -1, + has_egg: false, + laying_egg: false, puff_state: 0, dark_ticks: 0, golem_attack_ticks: 0, @@ -1565,6 +1586,8 @@ impl EntityStore { entity.bat_anim_start = Some(entity.age_in_ticks + 1); } } + (EntityKind::Turtle, 18, Bool(b)) => entity.has_egg = b, + (EntityKind::Turtle, 19, Bool(b)) => entity.laying_egg = b, // Salmon size ids clamp to SMALL..LARGE; a pufferfish puff state // outside 0/1 renders big (vanilla `switch` default). // `Sniffer.onSyncedDataUpdated`: every update stops all five @@ -1920,6 +1943,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::WanderingTrader | EntityKind::SulfurCube | EntityKind::Mooshroom + | EntityKind::Dolphin + | EntityKind::Turtle ) } @@ -1934,6 +1959,7 @@ fn probes_water(kind: &EntityKind) -> bool { | EntityKind::Salmon | EntityKind::TropicalFish | EntityKind::Pufferfish + | EntityKind::Turtle ) } @@ -1972,6 +1998,35 @@ mod tests { assert_eq!(store.living[&1].variant, CAT_DEFAULT_VARIANT); } + /// `Turtle.HAS_EGG`/`LAYING_EGG` land at 18/19 on every wire: 26.1 added + /// the age-locked flag ahead of them, 1.21.5 dropped `HOME_POS`. + #[test] + fn turtle_egg_flags_normalize_across_versions() { + let turtle = EntityKind::Turtle; + for (protocol, has_egg, laying_egg) in [(776, 18, 19), (770, 17, 18), (769, 18, 19)] { + assert_eq!(normalize_ageable_index_at(turtle, has_egg, protocol), 18); + assert_eq!(normalize_ageable_index_at(turtle, laying_egg, protocol), 19); + } + // Pre-1.21.5 `HOME_POS` (17) shares 18 but is a block pos. + assert_eq!(normalize_ageable_index_at(turtle, 17, 769), 18); + + let mut store = EntityStore::new(); + spawn(&mut store, 1, turtle, Position::default()); + store.apply_entity_data(1, 16, MetaValue::Bool(true)); + store.apply_entity_data(1, 18, MetaValue::Bool(true)); + store.apply_entity_data(1, 19, MetaValue::Bool(true)); + let e = &store.living[&1]; + assert!(e.is_baby && e.has_egg && e.laying_egg); + // `Turtle.BABY_DIMENSIONS` scales by 0.3, the dolphin's by 0.65. + let dims = living_entity_dimensions(e); + assert!((dims.width - 1.2 * 0.3).abs() < 1e-6); + + spawn(&mut store, 2, EntityKind::Dolphin, Position::default()); + store.apply_entity_data(2, 16, MetaValue::Bool(true)); + let dims = living_entity_dimensions(&store.living[&2]); + assert!((dims.height - 0.6 * 0.65).abs() < 1e-6); + } + /// `LivingEntity.aiStep` zeroes velocity components under 0.003 (a /// player's horizontal pair by its squared length) after the 0.98 decay. #[test] diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 0ec4ad1fc..ff756adff 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -4,9 +4,13 @@ use crate::renderer::chunk::mesher::pack_uv; use crate::renderer::rhi; mod copper_golem_anim; +mod dolphin; mod llama; mod sniffer_anim; +mod turtle; +pub use dolphin::*; pub use llama::*; +pub use turtle::*; /// An entity or block-entity model vertex: its position, UV and face normal. #[repr(C)] @@ -137,6 +141,33 @@ pub struct PartAnim { pub scale: Vec<(usize, Vec3)>, } +impl PartAnim { + /// Part `idx`'s emitted rotation, starting from its rest rotation if it + /// has none yet. + fn rotation_mut(&mut self, model: &BakedEntityModel, idx: usize) -> &mut Vec3 { + let slot = match self.rotation.iter().position(|&(i, _)| i == idx) { + Some(slot) => slot, + None => { + self.rotation.push((idx, model.parts[idx].default_rotation)); + self.rotation.len() - 1 + } + }; + &mut self.rotation[slot].1 + } +} + +#[cfg(test)] +impl PartAnim { + /// The named part's rendered rotation: its emitted one, else its rest. + fn rotation_of(&self, model: &BakedEntityModel, name: &str) -> Vec3 { + let i = part_index(&model.parts, name); + self.rotation + .iter() + .find(|&&(j, _)| j == i) + .map_or(model.parts[i].default_rotation, |r| r.1) + } +} + impl BakedEntityModel { /// Assemble baked parts, defaulting every part's scale to 1.0. pub(crate) fn new( @@ -3539,6 +3570,14 @@ fn rotated(part: EntityPart, rotation: Vec3) -> EntityPart { } } +/// `.mirror()` before `addBox`. +fn mirrored(cube: ModelCube) -> ModelCube { + ModelCube { + mirror: true, + ..cube + } +} + fn villager_parts() -> Vec { vec![ vpart( diff --git a/pomme-client/src/renderer/entity_model/dolphin.rs b/pomme-client/src/renderer/entity_model/dolphin.rs new file mode 100644 index 000000000..2ac8b5823 --- /dev/null +++ b/pomme-client/src/renderer/entity_model/dolphin.rs @@ -0,0 +1,201 @@ +//! Dolphin: `DolphinModel`, `BabyDolphinModel` and `DolphinModel.setupAnim`, +//! which both meshes share. + +use std::f32::consts::FRAC_PI_3; + +use glam::Vec3; + +use super::{BakedEntityModel, PartAnim, bake_model, mirrored, part_index, rotated, vbox, vpart}; + +/// Vanilla `DolphinModel.createBodyLayer`, 64x64. +pub fn bake_dolphin_model() -> BakedEntityModel { + // Vanilla's `1.0471976f` / `2.0943952f`. + const FIN_X_ROT: f32 = FRAC_PI_3; + const FIN_Z_ROT: f32 = 2.0 * FRAC_PI_3; + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 22.0, -5.0), + vec![vbox((22, 0), (-4.0, -7.0, 0.0), (8.0, 7.0, 13.0))], + ), + rotated( + vpart( + "back_fin", + Some(0), + Vec3::ZERO, + vec![vbox((51, 0), (-0.5, 0.0, 8.0), (1.0, 4.0, 5.0))], + ), + Vec3::new(FIN_X_ROT, 0.0, 0.0), + ), + rotated( + vpart( + "left_fin", + Some(0), + Vec3::new(2.0, -2.0, 4.0), + vec![mirrored(vbox((48, 20), (-0.5, -4.0, 0.0), (1.0, 4.0, 7.0)))], + ), + Vec3::new(FIN_X_ROT, 0.0, FIN_Z_ROT), + ), + rotated( + vpart( + "right_fin", + Some(0), + Vec3::new(-2.0, -2.0, 4.0), + vec![vbox((48, 20), (-0.5, -4.0, 0.0), (1.0, 4.0, 7.0))], + ), + Vec3::new(FIN_X_ROT, 0.0, -FIN_Z_ROT), + ), + rotated( + vpart( + "tail", + Some(0), + Vec3::new(0.0, -2.5, 11.0), + vec![vbox((0, 19), (-2.0, -2.5, 0.0), (4.0, 5.0, 11.0))], + ), + Vec3::new(-0.10471976, 0.0, 0.0), + ), + vpart( + "tail_fin", + Some(4), + Vec3::new(0.0, 0.0, 9.0), + vec![vbox((19, 20), (-5.0, -0.5, 0.0), (10.0, 1.0, 6.0))], + ), + vpart( + "head", + Some(0), + Vec3::new(0.0, -4.0, -3.0), + vec![vbox((0, 0), (-4.0, -3.0, -3.0), (8.0, 7.0, 6.0))], + ), + vpart( + "nose", + Some(6), + Vec3::ZERO, + vec![vbox((0, 13), (-1.0, 2.0, -7.0), (2.0, 2.0, 4.0))], + ), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyDolphinModel.createBodyLayer`, 64x64. `DolphinRenderer` +/// bakes this layer as-is; `DolphinModel.BABY_TRANSFORMER` is unused. +pub fn bake_baby_dolphin_model() -> BakedEntityModel { + const FIN_X_ROT: f32 = 0.8727; + const FIN_Z_ROT: f32 = 1.7017; + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 21.5, 0.0), + vec![vbox((20, 0), (-3.0, -2.5, -4.0), (6.0, 5.0, 8.0))], + ), + vpart( + "head", + Some(0), + Vec3::new(0.0, 1.0, -4.0), + vec![vbox((0, 0), (-3.0, -3.5, -4.0), (6.0, 5.0, 4.0))], + ), + vpart( + "nose", + Some(1), + Vec3::new(0.0, 0.5, -4.0), + vec![vbox((0, 9), (-1.0, -1.0, -2.0), (2.0, 2.0, 2.0))], + ), + rotated( + vpart( + "left_fin", + Some(0), + Vec3::new(1.8, 0.85, -2.6), + vec![vbox((34, 18), (-0.5, -1.5, -0.5), (1.0, 3.0, 6.0))], + ), + Vec3::new(FIN_X_ROT, 0.0, FIN_Z_ROT), + ), + rotated( + vpart( + "right_fin", + Some(0), + Vec3::new(-1.8, 0.85, -2.6), + vec![mirrored(vbox( + (48, 18), + (-0.5, -1.5, -0.5), + (1.0, 3.0, 6.0), + ))], + ), + Vec3::new(FIN_X_ROT, 0.0, -FIN_Z_ROT), + ), + vpart( + "tail", + Some(0), + Vec3::new(0.0, 1.0, 4.0), + vec![vbox((0, 13), (-2.0, -1.5, 0.0), (4.0, 3.0, 7.0))], + ), + vpart( + "tail_fin", + Some(5), + Vec3::new(0.0, 0.0, 6.0), + vec![vbox((22, 13), (-4.0, -0.5, -1.0), (8.0, 1.0, 4.0))], + ), + rotated( + vpart( + "back_fin", + Some(0), + Vec3::new(0.0, -1.0, -2.7), + vec![vbox((42, 0), (-0.5, -1.0, 1.0), (1.0, 3.0, 4.0))], + ), + Vec3::new(FIN_X_ROT, 0.0, 0.0), + ), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `DolphinModel.setupAnim`: the whole body takes the entity pitch and +/// head yaw; a swimming dolphin (`DolphinRenderState.isMoving`) also beats its +/// body and tail. +pub fn compute_dolphin_anim( + model: &BakedEntityModel, + x_rot_deg: f32, + local_head_y_rot_deg: f32, + age_in_ticks: f32, + moving: bool, +) -> PartAnim { + let mut anim = PartAnim::default(); + let mut body = Vec3::new( + x_rot_deg.to_radians(), + local_head_y_rot_deg.to_radians(), + 0.0, + ); + if moving { + let beat = (age_in_ticks * 0.3).cos(); + body.x += -0.05 - 0.05 * beat; + for (name, amplitude) in [("tail", -0.1), ("tail_fin", -0.2)] { + anim.rotation_mut(model, part_index(&model.parts, name)).x = amplitude * beat; + } + } + anim.rotation.push((part_index(&model.parts, "body"), body)); + anim +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_dolphin_keeps_its_tail_rest_pose() { + let model = bake_dolphin_model(); + let anim = compute_dolphin_anim(&model, 30.0, -10.0, 7.0, false); + assert_eq!(anim.rotation.len(), 1); + let (i, rot) = anim.rotation[0]; + assert_eq!(model.parts[i].name, "body"); + assert_eq!(rot, Vec3::new(30f32.to_radians(), -10f32.to_radians(), 0.0)); + } + + #[test] + fn swimming_dolphin_beats_body_and_tail() { + let model = bake_baby_dolphin_model(); + let anim = compute_dolphin_anim(&model, 0.0, 0.0, 0.0, true); + let rot = |name| anim.rotation_of(&model, name); + assert_eq!(rot("body").x, -0.1); + assert_eq!(rot("tail").x, -0.1); + assert_eq!(rot("tail_fin").x, -0.2); + } +} diff --git a/pomme-client/src/renderer/entity_model/turtle.rs b/pomme-client/src/renderer/entity_model/turtle.rs new file mode 100644 index 000000000..a86ffa8cc --- /dev/null +++ b/pomme-client/src/renderer/entity_model/turtle.rs @@ -0,0 +1,240 @@ +//! Turtle: `AdultTurtleModel`, `BabyTurtleModel` and the shared +//! `TurtleModel.setupAnim` (on top of `QuadrupedModel.setupAnim`). + +use std::f32::consts::FRAC_PI_2; + +use glam::Vec3; + +use super::{ + BakedEntityModel, EntityPart, PartAnim, bake_model, compute_quadruped_anim, part_index, + rotated, vbox, vpart, +}; + +/// `TurtleRenderState` flags the model reads. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct TurtleAnims { + /// `!isInWater() && onGround()`. + pub on_land: bool, + pub laying_egg: bool, + /// `!isBaby() && hasEgg()`. + pub has_egg: bool, +} + +/// `vpart` lying on its back, `PartPose.offsetAndRotation(.., PI/2, 0, 0)`. +fn flat(part: EntityPart) -> EntityPart { + rotated(part, Vec3::new(FRAC_PI_2, 0.0, 0.0)) +} + +/// Vanilla `AdultTurtleModel.createBodyLayer`, 128x64. +pub fn bake_turtle_model() -> BakedEntityModel { + let body_pivot = Vec3::new(0.0, 11.0, -10.0); + let parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, 19.0, -10.0), + vec![vbox((3, 0), (-3.0, -1.0, -3.0), (6.0, 5.0, 6.0))], + ), + flat(vpart( + "body", + None, + body_pivot, + vec![ + // "shell", then "belly". + vbox((7, 37), (-9.5, 3.0, -10.0), (19.0, 20.0, 6.0)), + vbox((31, 1), (-5.5, 3.0, -13.0), (11.0, 18.0, 3.0)), + ], + )), + flat(vpart( + "egg_belly", + None, + body_pivot, + vec![vbox((70, 33), (-4.5, 3.0, -14.0), (9.0, 18.0, 1.0))], + )), + vpart( + "right_hind_leg", + None, + Vec3::new(-3.5, 22.0, 11.0), + vec![vbox((1, 23), (-2.0, 0.0, 0.0), (4.0, 1.0, 10.0))], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(3.5, 22.0, 11.0), + vec![vbox((1, 12), (-2.0, 0.0, 0.0), (4.0, 1.0, 10.0))], + ), + vpart( + "right_front_leg", + None, + Vec3::new(-5.0, 21.0, -4.0), + vec![vbox((27, 30), (-13.0, 0.0, -2.0), (13.0, 1.0, 5.0))], + ), + vpart( + "left_front_leg", + None, + Vec3::new(5.0, 21.0, -4.0), + vec![vbox((27, 24), (0.0, 0.0, -2.0), (13.0, 1.0, 5.0))], + ), + ]; + bake_model(parts, 128, 64) +} + +/// Vanilla `BabyTurtleModel.createBodyLayer`, 16x16 (drawn backface-culled, +/// `entityCutoutCull`: the legs are zero-height planes). +pub fn bake_baby_turtle_model() -> BakedEntityModel { + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 22.9, 1.0), + vec![vbox((0, 0), (-2.0, -1.0, -2.0), (4.0, 2.0, 4.0))], + ), + vpart( + "head", + None, + Vec3::new(0.0, 22.9, -1.0), + vec![vbox((0, 6), (-1.5, -2.0, -3.0), (3.0, 3.0, 3.0))], + ), + vpart( + "right_hind_leg", + None, + Vec3::new(-2.0, 23.9, 2.5), + vec![vbox((-1, 0), (-2.0, 0.0, -0.5), (2.0, 0.0, 1.0))], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(2.0, 23.9, 2.5), + vec![vbox((-1, 1), (0.0, 0.0, -0.5), (2.0, 0.0, 1.0))], + ), + vpart( + "right_front_leg", + None, + Vec3::new(-2.0, 23.9, -0.5), + vec![vbox((8, 6), (-2.0, 0.0, -0.5), (2.0, 0.0, 1.0))], + ), + vpart( + "left_front_leg", + None, + Vec3::new(2.0, 23.9, -0.5), + vec![vbox((8, 7), (0.0, 0.0, -0.5), (2.0, 0.0, 1.0))], + ), + ]; + bake_model(parts, 16, 16) +} + +/// Vanilla `TurtleModel.setupAnim` over `QuadrupedModel.setupAnim`, plus +/// `AdultTurtleModel.setupAnim`'s egg belly. On land the legs paddle sideways +/// (yaw) on top of the quadruped stride; in water they stroke. +pub fn compute_turtle_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + t: &TurtleAnims, +) -> PartAnim { + let mut anim = compute_quadruped_anim( + model, + head_x_rot_deg, + local_head_y_rot_deg, + walk_pos, + walk_speed, + 0.0, + None, + ); + let (lay_egg, lay_egg_amplitude) = if t.laying_egg { (4.0, 2.0) } else { (1.0, 1.0) }; + let swing_pos = walk_pos * 5.0; + let front_swing = (lay_egg * swing_pos).cos(); + let hind_swing = swing_pos.cos(); + let swing = (walk_pos * 0.6662 * 0.6).cos() * (0.5 * walk_speed); + let idx = |name| part_index(&model.parts, name); + let (rh, lh, rf, lf) = ( + idx("right_hind_leg"), + idx("left_hind_leg"), + idx("right_front_leg"), + idx("left_front_leg"), + ); + if t.on_land { + anim.rotation_mut(model, rf).y = -front_swing * 8.0 * walk_speed * lay_egg_amplitude; + anim.rotation_mut(model, lf).y = front_swing * 8.0 * walk_speed * lay_egg_amplitude; + anim.rotation_mut(model, rh).y = -hind_swing * 3.0 * walk_speed; + anim.rotation_mut(model, lh).y = hind_swing * 3.0 * walk_speed; + } else { + anim.rotation_mut(model, rh).x = swing; + anim.rotation_mut(model, lh).x = -swing; + anim.rotation_mut(model, rf).z = -swing; + anim.rotation_mut(model, lf).z = swing; + } + if let Some(egg_belly) = model.parts.iter().position(|p| p.name == "egg_belly") { + if t.has_egg { + // `root.y -= 1`: every part hangs off the root. + for (i, part) in model.parts.iter().enumerate() { + if part.parent.is_none() { + anim.translation.push((i, Vec3::new(0.0, -1.0, 0.0))); + } + } + } else { + // `eggBelly.visible = false`. + anim.scale.push((egg_belly, Vec3::ZERO)); + } + } + anim +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_rot(model: &BakedEntityModel, anim: &PartAnim, name: &str, expected: Vec3) { + let rot = anim.rotation_of(model, name); + assert!( + rot.abs_diff_eq(expected, 1e-6), + "{name}: {rot} != {expected}" + ); + } + + #[test] + fn egg_belly_shows_and_lifts_only_with_an_egg() { + let model = bake_turtle_model(); + let egg = part_index(&model.parts, "egg_belly"); + let plain = compute_turtle_anim(&model, 0.0, 0.0, 0.0, 0.0, &TurtleAnims::default()); + assert_eq!(plain.scale, [(egg, Vec3::ZERO)]); + assert!(plain.translation.is_empty()); + + let gravid = TurtleAnims { + has_egg: true, + ..Default::default() + }; + let lifted = compute_turtle_anim(&model, 0.0, 0.0, 0.0, 0.0, &gravid); + assert!(lifted.scale.is_empty()); + assert_eq!(lifted.translation.len(), model.parts.len()); + + // The baby mesh has no egg belly to toggle. + let baby = bake_baby_turtle_model(); + let anim = compute_turtle_anim(&baby, 0.0, 0.0, 0.0, 0.0, &gravid); + assert!(anim.scale.is_empty() && anim.translation.is_empty()); + } + + #[test] + fn land_legs_yaw_over_the_stride_and_water_legs_stroke() { + let model = bake_turtle_model(); + let land = TurtleAnims { + on_land: true, + ..Default::default() + }; + let anim = compute_turtle_anim(&model, 0.0, 0.0, 0.0, 0.5, &land); + assert_rot(&model, &anim, "right_front_leg", Vec3::new(-0.7, -4.0, 0.0)); + assert_rot(&model, &anim, "left_hind_leg", Vec3::new(-0.7, 1.5, 0.0)); + + let anim = compute_turtle_anim(&model, 0.0, 0.0, 0.0, 0.5, &TurtleAnims::default()); + assert_rot(&model, &anim, "right_hind_leg", Vec3::new(0.25, 0.0, 0.0)); + assert_rot(&model, &anim, "left_front_leg", Vec3::new(0.7, 0.0, 0.25)); + assert_rot( + &model, + &anim, + "right_front_leg", + Vec3::new(-0.7, 0.0, -0.25), + ); + } +} diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index b2abad5f3..bc96e0e79 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -197,6 +197,9 @@ pub struct EntityRenderInfo { pub copper_golem: entity_model::CopperGolemAnims, /// Llama carpet or trader decor. pub llama_decor: Option, + /// `DolphinRenderState.isMoving`. + pub dolphin_moving: bool, + pub turtle: entity_model::TurtleAnims, /// Iron golem countdowns; the punch one is partial-tick adjusted. pub golem_attack_ticks: f32, pub golem_offer_flower_ticks: u32, @@ -278,6 +281,8 @@ impl Default for EntityRenderInfo { allay: entity_model::AllayAnims::default(), copper_golem: entity_model::CopperGolemAnims::default(), llama_decor: None, + dolphin_moving: false, + turtle: entity_model::TurtleAnims::default(), golem_attack_ticks: 0.0, golem_offer_flower_ticks: 0, base_tint: WHITE_TINT, @@ -955,6 +960,8 @@ enum AnimationType { Allay, SnowGolem, CopperGolem, + Dolphin, + Turtle, /// Cod, salmon, tropical fish, pufferfish. Fish, Golem, @@ -1155,6 +1162,10 @@ fn mob_definitions() -> Vec { const SULFUR_CUBE_INNER_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner"); const SULFUR_CUBE_INNER_SMALL_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner_small"); + const DOLPHIN_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin"); + const DOLPHIN_BABY_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin_baby"); + const TURTLE_TEX: &[&[&str]] = tex_table!("turtle" => "turtle"); + const TURTLE_BABY_TEX: &[&[&str]] = tex_table!("turtle" => "turtle_baby"); // Indexed by `Llama.Variant` id. const LLAMA_TEX: &[&[&str]] = tex_table!("llama" => "llama_creamy", "llama_white", "llama_brown", "llama_gray"); @@ -1306,6 +1317,35 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + // TODO: `DolphinCarryingItemLayer` (the item held in its mouth). + MobDef { + kind: EntityKind::Dolphin, + anim: AnimationType::Dolphin, + adult: vec![opaque(entity_model::bake_dolphin_model(), DOLPHIN_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_dolphin_model(), + DOLPHIN_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Turtle, + anim: AnimationType::Turtle, + adult: vec![opaque(entity_model::bake_turtle_model(), TURTLE_TEX, 128)], + // `BabyTurtleModel` draws `entityCutoutCull`: its legs are + // zero-height planes. + baby: Some(VariantDef { + model: entity_model::bake_baby_turtle_model(), + tex_variants: TURTLE_BABY_TEX, + tex_size: 16, + overlay_kind: OverlayKind::OpaqueCulled, + invisible_outline: false, + }), + adult_overlays: vec![], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Pig, anim: AnimationType::Quadruped, @@ -2508,6 +2548,21 @@ impl EntityRenderer { info.bat_elapsed_secs, info.bat_resting, ), + AnimationType::Dolphin => entity_model::compute_dolphin_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.age_in_ticks, + info.dolphin_moving, + ), + AnimationType::Turtle => entity_model::compute_turtle_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + &info.turtle, + ), AnimationType::Sniffer => entity_model::compute_sniffer_anim( model, info.head_x_rot_deg, @@ -3209,6 +3264,7 @@ pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { match kind { EntityKind::Villager if is_baby => 0.63, // Explicit `BABY_DIMENSIONS` eye heights. + EntityKind::Dolphin if is_baby => 0.09375, EntityKind::Mooshroom if is_baby => 0.69, // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. EntityKind::Sniffer | EntityKind::Llama | EntityKind::TraderLlama if is_baby => { @@ -3285,8 +3341,11 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { (d.width, d.height) } }; - let s = if is_baby { 0.5 } else { 1.0 }; - (w * s, h * s) + if is_baby { + crate::entity::baby_dimensions(kind, w, h) + } else { + (w, h) + } } /// Bounding-sphere frustum + distance cull. The frustum planes operate on diff --git a/tests/ingame/entities/dolphin.toml b/tests/ingame/entities/dolphin.toml new file mode 100644 index 000000000..1d10819bf --- /dev/null +++ b/tests/ingame/entities/dolphin.toml @@ -0,0 +1,81 @@ +# Dolphins in and out of water, three-quarter view from above: a pitched +# adult and a baby swimming in a pool, an adult and a baby on the grass +# beside it. The baby is its own mesh and texture, not a scaled adult. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +# A two-deep pool cut into the dirt and grass. +[[step]] +chat = "/fill -4 -62 3 3 -61 10 minecraft:water" + +[[step]] +expect = { block = "water", at = [-4, -62, 3] } + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:dolphin -1.5 -61.7 7 {NoAI:1b,Rotation:[150f,-25f]}" + +[[step]] +expect = { entity = "dolphin", baby = false } + +[[step]] +chat = "/summon minecraft:dolphin 1.5 -61.6 4.5 {NoAI:1b,Age:-24000,Rotation:[230f,0f]}" + +[[step]] +expect = { entity = "dolphin", baby = true } + +[[step]] +chat = "/summon minecraft:dolphin 6 -60 7 {NoAI:1b,Rotation:[200f,0f]}" + +[[step]] +chat = "/summon minecraft:dolphin 5.5 -60 3.5 {NoAI:1b,Age:-24000,Rotation:[120f,0f]}" + +[[step]] +look_at = [2.0, -60.5, 6.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "dolphins" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill -4 -62 3 3 -62 10 minecraft:dirt" + +[[step]] +chat = "/fill -4 -61 3 3 -61 10 minecraft:grass_block" + +[[step]] +expect = { block = "grass_block", at = [-4, -61, 3] } + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/turtle.toml b/tests/ingame/entities/turtle.toml new file mode 100644 index 000000000..859d3646a --- /dev/null +++ b/tests/ingame/entities/turtle.toml @@ -0,0 +1,62 @@ +# Turtles at rest, three-quarter view from above: a plain adult, an adult +# carrying an egg (egg belly shown, whole body lifted a pixel) and a baby, +# which is its own 16x16 mesh drawn backface-culled. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:turtle -2 -60 5.5 {NoAI:1b,Rotation:[150f,0f]}" + +[[step]] +expect = { entity = "turtle", baby = false } + +[[step]] +chat = "/summon minecraft:turtle 2 -60 6 {NoAI:1b,has_egg:1b,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:turtle 0.5 -60 3.5 {NoAI:1b,Age:-24000,Rotation:[180f,0f]}" + +[[step]] +expect = { entity = "turtle", baby = true } + +[[step]] +look_at = [0.5, -59.8, 5.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "turtles" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From 25faa85116c43e92f2723ebb36089868495ea307 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:27:33 +0000 Subject: [PATCH 04/10] add bee and parrot --- pomme-client/src/app/core.rs | 7 + pomme-client/src/app/phases/in_game.rs | 50 +++- pomme-client/src/entity/mod.rs | 161 +++++++++++- pomme-client/src/renderer/entity_model.rs | 78 +++--- pomme-client/src/renderer/entity_model/bee.rs | 242 ++++++++++++++++++ .../src/renderer/entity_model/parrot.rs | 242 ++++++++++++++++++ .../src/renderer/entity_model/turtle.rs | 6 +- .../src/renderer/pipelines/entity_renderer.rs | 58 ++++- tests/ingame/entities/bee.toml | 81 ++++++ tests/ingame/entities/parrot.toml | 85 ++++++ 10 files changed, 964 insertions(+), 46 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/bee.rs create mode 100644 pomme-client/src/renderer/entity_model/parrot.rs create mode 100644 tests/ingame/entities/bee.toml create mode 100644 tests/ingame/entities/parrot.toml diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 51ba8dc4b..70b09f7ff 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -2646,6 +2646,13 @@ impl AppCore { // The server excludes the breaking player from the // broadcast; the local break's effects come from // `predict_destroy`. TODO: the other level events. + // 1010/1011 (jukebox start/stop) only notify nearby + // entities so far. TODO: the song itself and the + // now-playing HUD line. + if event_type == 1010 || event_type == 1011 { + game.entity_store + .set_record_playing_nearby(pos, event_type == 1010); + } if event_type == 2001 && let Some(state) = game.block_registry.block_state_table().try_state(data) { diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 148bb76f2..0c5892dc5 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -37,7 +37,7 @@ use crate::renderer::chunk::mesh_columns::MeshColumns; use crate::renderer::chunk::mesh_source::MeshSource; use crate::renderer::chunk::mesher::{BiomeClimate, ChunkMeshData, MeshDispatcher}; use crate::renderer::chunk::occlusion_graph::{self, VisibilitySet}; -use crate::renderer::entity_model::triangle_wave; +use crate::renderer::entity_model::{lerp, triangle_wave}; use crate::renderer::pipelines::block_entity; use crate::renderer::pipelines::entity_renderer::{ EntityRenderInfo, MAX_OVERLAYS, PackShadow, WHITE_TINT, armor_render_infos, dye_color_tint, @@ -4120,6 +4120,8 @@ pub fn update_game( allay: extras.allay, copper_golem: extras.copper_golem, llama_decor: extras.llama_decor, + bee: extras.bee, + parrot: extras.parrot, dolphin_moving: extras.dolphin_moving, turtle: extras.turtle, golem_attack_ticks: extras.golem_attack_ticks, @@ -5006,6 +5008,8 @@ struct EntityExtras { allay: crate::renderer::entity_model::AllayAnims, copper_golem: crate::renderer::entity_model::CopperGolemAnims, llama_decor: Option, + bee: crate::renderer::entity_model::BeeAnims, + parrot: crate::renderer::entity_model::ParrotAnims, dolphin_moving: bool, turtle: crate::renderer::entity_model::TurtleAnims, golem_attack_ticks: f32, @@ -5161,6 +5165,8 @@ fn entity_extras( }, ..Default::default() }, + EntityKind::Bee => bee_extras(e, alpha, game_time), + EntityKind::Parrot => parrot_extras(e, alpha), EntityKind::Sniffer => EntityExtras { sniffer: sniffer_anims(e, alpha), ..Default::default() @@ -5218,6 +5224,48 @@ fn llama_extras(e: &crate::entity::LivingEntity) -> EntityExtras { } } +/// Vanilla `BeeRenderer.extractRenderState` and `getTextureLocation` +/// (variants: plain, nectar, angry, angry with nectar). +fn bee_extras(e: &crate::entity::LivingEntity, alpha: f32, game_time: i64) -> EntityExtras { + // `NeutralMob.isAngry`. + let is_angry = e.anger_end_time > 0 && e.anger_end_time - game_time > 0; + let has_nectar = e.bee.has_nectar(); + EntityExtras { + variant_index: u32::from(is_angry) * 2 + u32::from(has_nectar), + bee: crate::renderer::entity_model::BeeAnims { + roll_amount: lerp(alpha, e.bee.prev_roll_amount, e.bee.roll_amount), + has_stinger: !e.bee.has_stung(), + is_on_ground: e.on_ground && e.velocity.length_squared() < 1.0e-7, + is_angry, + }, + ..Default::default() + } +} + +/// Vanilla `ParrotRenderer.extractRenderState` (`ParrotModel.getPose`). +fn parrot_extras(e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { + use crate::renderer::entity_model::ParrotPose; + let flap = lerp(alpha, e.prev_flap, e.flap); + let flap_speed = lerp(alpha, e.prev_flap_speed, e.flap_speed); + let pose = if e.party_parrot { + ParrotPose::Party + } else if e.is_sitting { + ParrotPose::Sitting + } else if !e.on_ground { + ParrotPose::Flying + } else { + ParrotPose::Standing + }; + EntityExtras { + variant_index: e.variant, + parrot: crate::renderer::entity_model::ParrotAnims { + flap_angle: (flap.sin() + 1.0) * flap_speed, + pose, + }, + ..Default::default() + } +} + /// Vanilla `SnifferRenderer.extractRenderState`. fn sniffer_anims( e: &crate::entity::LivingEntity, diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 25891c7ba..9c7e10cac 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -174,6 +174,42 @@ impl CopperGolemAnim { } } +/// Vanilla `Bee` client state: the synced flags byte and its roll spring. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BeeSim { + pub flags: u8, + pub roll_amount: f32, + pub prev_roll_amount: f32, +} + +impl BeeSim { + pub fn is_rolling(&self) -> bool { + self.flags & 0x02 != 0 + } + pub fn has_stung(&self) -> bool { + self.flags & 0x04 != 0 + } + pub fn has_nectar(&self) -> bool { + self.flags & 0x08 != 0 + } + + /// `Bee.updateRollAmount`. + fn tick(&mut self) { + self.prev_roll_amount = self.roll_amount; + self.roll_amount = spring(self.roll_amount, self.is_rolling(), 0.2, 0.24); + } +} + +/// A 0..1 pose amount stepping `up` toward 1 while `on`, else `down` +/// toward 0. +fn spring(cur: f32, on: bool, up: f32, down: f32) -> f32 { + if on { + (cur + up).min(1.0) + } else { + (cur - down).max(0.0) + } +} + /// `AgeableMob` descendants on every supported version (Slime joined only /// in 26.2, so it's excluded here and special-cased where it matters). fn is_ageable_mob(kind: EntityKind) -> bool { @@ -194,6 +230,8 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::WanderingTrader | EntityKind::SulfurCube | EntityKind::Mooshroom + | EntityKind::Bee + | EntityKind::Parrot | EntityKind::Dolphin | EntityKind::Turtle ) || is_equine(&kind) @@ -399,6 +437,11 @@ pub struct LivingEntity { /// The tick the current state's animation started at. pub sniffer_state: SnifferState, pub sniffer_anim_start: Option, + pub bee: BeeSim, + /// `Parrot.partyParrot` and its `jukebox`, set by the jukebox level + /// events and cleared in `aiStep`. + pub party_parrot: bool, + pub jukebox: Option, /// Playing `SnifferSoundInstance`s, stopped once digging ends. pub sniffer_dig_sounds: Vec, pub allay: AllayAnim, @@ -628,6 +671,9 @@ impl LivingEntity { bat_anim_start: None, sniffer_state: SnifferState::Idling, sniffer_anim_start: None, + bee: BeeSim::default(), + party_parrot: false, + jukebox: None, sniffer_dig_sounds: Vec::new(), allay: AllayAnim::default(), copper_golem: CopperGolemAnim::default(), @@ -787,6 +833,27 @@ impl LivingEntity { .min(1.0) } + /// The head of vanilla `Parrot.aiStep`: the party ends once the jukebox + /// is out of reach or gone. + fn tick_party(&mut self, chunks: &ChunkStore, block_state_table: BlockStateTable) { + let in_reach = self.jukebox.is_some_and(|pos| { + let center = DVec3::new( + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 0.5, + f64::from(pos.z) + 0.5, + ); + DVec3::from(self.position).distance_squared(center) < 3.46 * 3.46 + && block_state_table + .block_state_data(chunks.get_block_state(pos.x, pos.y, pos.z)) + .id() + == "jukebox" + }); + if !in_reach { + self.party_parrot = false; + self.jukebox = None; + } + } + /// Vanilla `Chicken.aiStep` wing flap; the update order matters. fn tick_flap(&mut self) { self.prev_flap = self.flap; @@ -804,13 +871,6 @@ impl LivingEntity { /// and relax, and the rabbit hop clock (vanilla ticks these on both /// sides; only the driving flags are synced). Inert for other mobs. fn tick_tamable_anims(&mut self) { - let spring = |cur: f32, on: bool, up: f32, down: f32| { - if on { - (cur + up).min(1.0) - } else { - (cur - down).max(0.0) - } - }; self.prev_interested_angle = self.interested_angle; self.interested_angle += (if self.is_interested { 1.0 } else { 0.0 } - self.interested_angle) * 0.4; @@ -1025,7 +1085,13 @@ impl LivingEntity { /// `aiStep`); arms accrue as mobs land. fn tick_kind_anims(&mut self) { match self.entity_type { - EntityKind::Chicken => self.tick_flap(), + // `Parrot.calculateFlapping` is the chicken's flap, but it also + // folds while a passenger. TODO: passengers (no client-side + // vehicle tracking for other entities yet). + EntityKind::Chicken | EntityKind::Parrot => self.tick_flap(), + // TODO: `Bee.tick`'s falling-nectar drip particles (no drip + // particle type yet). + EntityKind::Bee => self.bee.tick(), EntityKind::Slime => self.tick_squish(), EntityKind::SulfurCube => { if self.sulfur_fuse > 0 { @@ -1560,7 +1626,7 @@ impl EntityStore { entity.variant = ((v & 0xFF) % 7) | ((((v >> 8) & 0xFF) % 5) << 8); } // Tamable flags byte: bit 0x01 = sitting, 0x04 = tame. - (EntityKind::Wolf | EntityKind::Cat, 18, Byte(f)) => { + (EntityKind::Wolf | EntityKind::Cat | EntityKind::Parrot, 18, Byte(f)) => { entity.is_sitting = f & 0x01 != 0; entity.is_tame = f & 0x04 != 0; } @@ -1572,11 +1638,16 @@ impl EntityStore { // Persistent anger: a game-time end tick since 1.21.11; on // 1.21.9-1.21.10 a remaining-tick countdown the server re-syncs // as it decrements, so any positive value means angry. - (EntityKind::Wolf, 22, Long(t)) => entity.anger_end_time = t, - (EntityKind::Wolf, 22, Int(t)) => { + (EntityKind::Wolf, 22, Long(t)) | (EntityKind::Bee, 19, Long(t)) => { + entity.anger_end_time = t + } + (EntityKind::Wolf, 22, Int(t)) | (EntityKind::Bee, 19, Int(t)) => { entity.anger_end_time = if t > 0 { i64::MAX } else { -1 } } (EntityKind::Cat, 22, Bool(b)) => entity.relax_state_one = b, + (EntityKind::Bee, 18, Byte(f)) => entity.bee.flags = f, + // `Parrot.Variant.BY_ID` clamps out-of-range ids. + (EntityKind::Parrot, 20, Int(v)) => entity.variant = v.clamp(0, 4) as u32, // Bat flags byte: bit 0x01 = resting (hanging); a flip restarts // the fly/rest animation clock. (EntityKind::Bat, 16, Byte(f)) => { @@ -1695,6 +1766,22 @@ impl EntityStore { } } + /// Vanilla `LevelEventHandler.notifyNearbyEntities`: every living + /// entity touching the jukebox's box grown by 3 hears it start or stop + /// (only parrots react, `Parrot.setRecordPlayingNearby`). + pub fn set_record_playing_nearby(&mut self, pos: BlockPos, is_playing: bool) { + let b = Aabb::block(pos.x, pos.y, pos.z); + let area = Aabb::new(b.min - DVec3::splat(3.0), b.max + DVec3::splat(3.0)); + for entity in self.living.values_mut() { + if entity.entity_type == EntityKind::Parrot + && living_entity_aabb(entity).intersects(&area) + { + entity.jukebox = Some(pos); + entity.party_parrot = is_playing; + } + } + } + pub fn set_living_motion(&mut self, id: i32, velocity: DVec3) { if let Some(entity) = self.living.get_mut(&id) { entity.velocity = velocity; @@ -1808,6 +1895,9 @@ impl EntityStore { if probes_water(&entity.entity_type) { entity.is_in_water = entity.probe_water(chunks, block_state_table); } + if entity.entity_type == EntityKind::Parrot { + entity.tick_party(chunks, block_state_table); + } entity.tick_kind_anims(); entity.tick_tamable_anims(); entity.prev_eat_anim_tick = entity.eat_anim_tick; @@ -1907,6 +1997,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { || matches!( kind, EntityKind::Player + | EntityKind::Bee + | EntityKind::Parrot | EntityKind::Pig | EntityKind::Cow | EntityKind::Sheep @@ -1998,6 +2090,53 @@ mod tests { assert_eq!(store.living[&1].variant, CAT_DEFAULT_VARIANT); } + /// Bee flags and anger drive the render state; the roll eases in by 0.2 + /// and out by 0.24 per tick. + #[test] + fn bee_flags_and_roll() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Bee, Position::default()); + store.apply_entity_data(1, 18, MetaValue::Byte(0x02 | 0x08)); + store.apply_entity_data(1, 19, MetaValue::Long(500)); + let e = &store.living[&1]; + assert!(e.bee.is_rolling() && e.bee.has_nectar() && !e.bee.has_stung()); + assert_eq!(e.anger_end_time, 500); + let tick_kind = + |store: &mut EntityStore| store.living.get_mut(&1).unwrap().tick_kind_anims(); + for _ in 0..6 { + tick_kind(&mut store); + } + assert_eq!(store.living[&1].bee.roll_amount, 1.0); + store.apply_entity_data(1, 18, MetaValue::Byte(0)); + tick_kind(&mut store); + let bee = &store.living[&1].bee; + assert_eq!((bee.prev_roll_amount, bee.roll_amount), (1.0, 0.76)); + } + + /// Parrot variants clamp; the tamable flags byte carries the sit pose; + /// a jukebox event within 3 blocks starts the party. + #[test] + fn parrot_variant_sitting_and_party() { + let mut store = EntityStore::new(); + spawn( + &mut store, + 1, + EntityKind::Parrot, + Position::new(0.5, 64.0, 0.5), + ); + store.apply_entity_data(1, 20, MetaValue::Int(9)); + store.apply_entity_data(1, 18, MetaValue::Byte(0x01)); + assert_eq!(store.living[&1].variant, 4); + assert!(store.living[&1].is_sitting); + store.set_record_playing_nearby(BlockPos::new(3, 64, 0), true); + assert!(store.living[&1].party_parrot); + store.set_record_playing_nearby(BlockPos::new(10, 64, 0), true); + assert_eq!(store.living[&1].jukebox, Some(BlockPos::new(3, 64, 0))); + // No jukebox block in an empty world: the next tick ends it. + tick(&mut store); + assert!(!store.living[&1].party_parrot); + } + /// `Turtle.HAS_EGG`/`LAYING_EGG` land at 18/19 on every wire: 26.1 added /// the age-locked flag ahead of them, 1.21.5 dropped `HOME_POS`. #[test] diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index ff756adff..3983b2452 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -3,13 +3,17 @@ use glam::{Mat4, Vec3}; use crate::renderer::chunk::mesher::pack_uv; use crate::renderer::rhi; +mod bee; mod copper_golem_anim; mod dolphin; mod llama; +mod parrot; mod sniffer_anim; mod turtle; +pub use bee::*; pub use dolphin::*; pub use llama::*; +pub use parrot::*; pub use turtle::*; /// An entity or block-entity model vertex: its position, UV and face normal. @@ -79,10 +83,12 @@ fn quadruped_legs( /// shape — positional indexing into a shared parts builder goes stale /// silently. fn part_index(parts: &[EntityPart], name: &str) -> usize { - parts - .iter() - .position(|p| p.name == name) - .unwrap_or_else(|| panic!("mesh has a {name} part")) + find_part(parts, name).unwrap_or_else(|| panic!("mesh has a {name} part")) +} + +/// Index of the named part, for parts only some meshes have. +fn find_part(parts: &[EntityPart], name: &str) -> Option { + parts.iter().position(|p| p.name == name) } /// Mirror a cube's geometry across x=0 WITHOUT flipping UVs (e.g. chicken @@ -166,6 +172,15 @@ impl PartAnim { .find(|&&(j, _)| j == i) .map_or(model.parts[i].default_rotation, |r| r.1) } + + /// The named part's emitted translation, else zero. + fn translation_of(&self, model: &BakedEntityModel, name: &str) -> Vec3 { + let i = part_index(&model.parts, name); + self.translation + .iter() + .find(|&&(j, _)| j == i) + .map_or(Vec3::ZERO, |t| t.1) + } } impl BakedEntityModel { @@ -2610,10 +2625,7 @@ pub fn bake_iron_golem_model() -> BakedEntityModel { /// Vanilla `AllayModel.createBodyLayer`. pub fn bake_allay_model() -> BakedEntityModel { - let arm = |u, v, x| ModelCube { - deformation: -0.01, - ..vbox((u, v), (x, -0.5, -1.0), (1.0, 4.0, 2.0)) - }; + let arm = |u, v, x| deformed(vbox((u, v), (x, -0.5, -1.0), (1.0, 4.0, 2.0)), -0.01); let wing = vbox((16, 14), (0.0, 1.0, 0.0), (0.0, 5.0, 8.0)); let parts = vec![ vpart("root", None, Vec3::new(0.0, 23.5, 0.0), vec![]), @@ -2629,10 +2641,7 @@ pub fn bake_allay_model() -> BakedEntityModel { Vec3::new(0.0, -4.0, 0.0), vec![ vbox((0, 10), (-1.5, 0.0, -1.0), (3.0, 4.0, 2.0)), - ModelCube { - deformation: -0.2, - ..vbox((0, 16), (-1.5, 0.0, -1.0), (3.0, 5.0, 2.0)) - }, + deformed(vbox((0, 16), (-1.5, 0.0, -1.0), (3.0, 5.0, 2.0)), -0.2), ], ), vpart( @@ -2655,10 +2664,7 @@ pub fn bake_allay_model() -> BakedEntityModel { /// Vanilla `SnowGolemModel.createBodyLayer`. pub fn bake_snow_golem_model() -> BakedEntityModel { - let shrunk = |cube: ModelCube| ModelCube { - deformation: -0.5, - ..cube - }; + let shrunk = |cube| deformed(cube, -0.5); let arm = || shrunk(vbox((32, 0), (-1.0, 0.0, -1.0), (12.0, 2.0, 2.0))); let parts = vec![ vpart( @@ -2714,19 +2720,10 @@ pub fn bake_copper_golem_model() -> BakedEntityModel { Some(0), Vec3::new(0.0, -6.0, 0.0), vec![ - ModelCube { - deformation: 0.015, - ..vbox((0, 0), (-4.0, -5.0, -5.0), (8.0, 5.0, 10.0)) - }, + deformed(vbox((0, 0), (-4.0, -5.0, -5.0), (8.0, 5.0, 10.0)), 0.015), vbox((56, 0), (-1.0, -2.0, -6.0), (2.0, 3.0, 2.0)), - ModelCube { - deformation: -0.015, - ..vbox((37, 8), (-1.0, -9.0, -1.0), (2.0, 4.0, 2.0)) - }, - ModelCube { - deformation: -0.015, - ..vbox((37, 0), (-2.0, -13.0, -2.0), (4.0, 4.0, 4.0)) - }, + deformed(vbox((37, 8), (-1.0, -9.0, -1.0), (2.0, 4.0, 2.0)), -0.015), + deformed(vbox((37, 0), (-2.0, -13.0, -2.0), (4.0, 4.0, 4.0)), -0.015), ], ), vpart( @@ -3578,6 +3575,27 @@ fn mirrored(cube: ModelCube) -> ModelCube { } } +/// A box with `CubeDeformation(g)`. +fn deformed(cube: ModelCube, g: f32) -> ModelCube { + ModelCube { + deformation: g, + ..cube + } +} + +/// Vanilla `Mth.rotLerpRad`. +fn rot_lerp_rad(a: f32, from: f32, to: f32) -> f32 { + use std::f32::consts::PI; + let mut diff = to - from; + while diff < -PI { + diff += PI * 2.0; + } + while diff >= PI { + diff -= PI * 2.0; + } + from + a * diff +} + fn villager_parts() -> Vec { vec![ vpart( @@ -4109,7 +4127,7 @@ fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { } /// Vanilla `Mth.lerp(delta, from, to)`. -fn lerp(delta: f32, from: f32, to: f32) -> f32 { +pub(crate) fn lerp(delta: f32, from: f32, to: f32) -> f32 { from + delta * (to - from) } @@ -5052,7 +5070,7 @@ fn apply_kf_scaled( elapsed_secs }; for ch in anim.channels { - let Some(idx) = model.parts.iter().position(|p| p.name == ch.part) else { + let Some(idx) = find_part(&model.parts, ch.part) else { continue; }; let v = kf_sample(ch.frames, t) * target_scale; diff --git a/pomme-client/src/renderer/entity_model/bee.rs b/pomme-client/src/renderer/entity_model/bee.rs new file mode 100644 index 000000000..b607ea31d --- /dev/null +++ b/pomme-client/src/renderer/entity_model/bee.rs @@ -0,0 +1,242 @@ +//! Bee: `AdultBeeModel`/`BabyBeeModel` meshes and `BeeModel.setupAnim`. + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use std::f32::consts::PI; + +use glam::Vec3; + +use super::{ + BakedEntityModel, PartAnim, bake_model, deformed, find_part, mirrored, part_index, + rot_lerp_rad, rotated, vbox, vpart, +}; + +/// `BeeRenderState`'s animation inputs (`BeeRenderer.extractRenderState`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BeeAnims { + /// `Bee.getRollAmount(partialTicks)`, 0..1. + pub roll_amount: f32, + /// `!Bee.hasStung()`. + pub has_stinger: bool, + /// `onGround() && getDeltaMovement().lengthSqr() < 1.0E-7`. + pub is_on_ground: bool, + pub is_angry: bool, +} + +impl Default for BeeAnims { + fn default() -> Self { + Self { + roll_amount: 0.0, + has_stinger: true, + is_on_ground: false, + is_angry: false, + } + } +} + +/// Vanilla `AdultBeeModel.createBodyLayer`, 64x64. +pub fn bake_bee_model() -> BakedEntityModel { + let wing = |origin| deformed(vbox((0, 18), origin, (9.0, 0.0, 6.0)), 0.001); + let legs = |name, z, v| { + vpart( + name, + Some(0), + Vec3::new(1.5, 3.0, z), + vec![vbox((26, v), (-5.0, 0.0, 0.0), (7.0, 2.0, 0.0))], + ) + }; + let parts = vec![ + vpart("bone", None, Vec3::new(0.0, 19.0, 0.0), vec![]), + vpart( + "body", + Some(0), + Vec3::ZERO, + vec![vbox((0, 0), (-3.5, -4.0, -5.0), (7.0, 7.0, 10.0))], + ), + vpart( + "stinger", + Some(1), + Vec3::ZERO, + vec![vbox((26, 7), (0.0, -1.0, 5.0), (0.0, 1.0, 2.0))], + ), + vpart( + "left_antenna", + Some(1), + Vec3::new(0.0, -2.0, -5.0), + vec![vbox((2, 0), (1.5, -2.0, -3.0), (1.0, 2.0, 3.0))], + ), + vpart( + "right_antenna", + Some(1), + Vec3::new(0.0, -2.0, -5.0), + vec![vbox((2, 3), (-2.5, -2.0, -3.0), (1.0, 2.0, 3.0))], + ), + rotated( + vpart( + "right_wing", + Some(0), + Vec3::new(-1.5, -4.0, -3.0), + vec![wing((-9.0, 0.0, 0.0))], + ), + Vec3::new(0.0, -0.2618, 0.0), + ), + rotated( + vpart( + "left_wing", + Some(0), + Vec3::new(1.5, -4.0, -3.0), + vec![mirrored(wing((0.0, 0.0, 0.0)))], + ), + Vec3::new(0.0, 0.2618, 0.0), + ), + legs("front_legs", -2.0, 1), + legs("middle_legs", 0.0, 3), + legs("back_legs", 2.0, 5), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyBeeModel.createBodyLayer`, 32x32 (no antennae). +pub fn bake_baby_bee_model() -> BakedEntityModel { + let legs = |name, z, v| { + vpart( + name, + Some(0), + Vec3::new(0.0, 3.3333, z), + vec![vbox((13, v), (-1.5, 0.0, 0.0), (3.0, 1.0, 0.0))], + ) + }; + let parts = vec![ + vpart( + "bone", + None, + Vec3::new(0.0, 19.6667, -1.8567), + vec![ + vbox((6, 12), (1.0, -1.6667, -2.1633), (1.0, 2.0, 2.0)), + vbox((0, 12), (-2.0, -1.6667, -2.1933), (1.0, 2.0, 2.0)), + ], + ), + vpart( + "body", + Some(0), + Vec3::new(0.0, 1.3333, 2.3567), + vec![vbox((0, 0), (-2.0, -2.0, -2.5), (4.0, 4.0, 5.0))], + ), + vpart( + "stinger", + Some(1), + Vec3::new(0.0, 0.5, 2.5), + vec![vbox((13, 2), (0.0, -0.5, 0.0), (0.0, 1.0, 1.0))], + ), + rotated( + vpart( + "right_wing", + Some(0), + Vec3::new(-1.0, -0.6667, 0.8567), + vec![vbox((3, 9), (-3.0, 0.0, 0.0), (3.0, 0.0, 3.0))], + ), + Vec3::new(0.2182, 0.3491, 0.0), + ), + rotated( + vpart( + "left_wing", + Some(0), + Vec3::new(1.0, -0.6667, 0.8567), + vec![mirrored(vbox((-3, 9), (0.0, 0.0, 0.0), (3.0, 0.0, 3.0)))], + ), + Vec3::new(0.2182, -0.3491, 0.0), + ), + legs("front_legs", 1.8567, 0), + legs("middle_legs", 2.8567, 1), + legs("back_legs", 3.8567, 2), + ]; + bake_model(parts, 32, 32) +} + +/// Vanilla `BeeModel.setupAnim` (with `AdultBeeModel.bobUpAndDown`'s +/// antennae, which the baby mesh lacks), shared by both meshes. +pub fn compute_bee_anim(model: &BakedEntityModel, age_in_ticks: f32, s: &BeeAnims) -> PartAnim { + let idx = |name: &str| part_index(&model.parts, name); + let antennae = find_part(&model.parts, "left_antenna").map(|left| (left, idx("right_antenna"))); + let (bone, stinger) = (idx("bone"), idx("stinger")); + let (right_wing, left_wing) = (idx("right_wing"), idx("left_wing")); + let (front_leg, mid_leg, back_leg) = (idx("front_legs"), idx("middle_legs"), idx("back_legs")); + + let mut rot: Vec = model.parts.iter().map(|p| p.default_rotation).collect(); + let mut bone_y = 0.0; + if !s.is_on_ground { + let speed = age_in_ticks * 120.32113 * (PI / 180.0); + rot[right_wing].y = 0.0; + rot[right_wing].z = speed.cos() * PI * 0.15; + rot[left_wing].x = rot[right_wing].x; + rot[left_wing].y = rot[right_wing].y; + rot[left_wing].z = -rot[right_wing].z; + rot[front_leg].x = 0.785_398_2; + rot[mid_leg].x = 0.785_398_2; + rot[back_leg].x = 0.785_398_2; + } + if !s.is_angry && !s.is_on_ground { + // `bobUpAndDown(speed, ageInTicks)`. + let speed = (age_in_ticks * 0.18).cos(); + rot[bone].x = 0.1 + speed * PI * 0.025; + bone_y -= speed * 0.9; + rot[front_leg].x = -speed * PI * 0.1 + 0.392_699_1; + rot[back_leg].x = -speed * PI * 0.05 + 0.785_398_2; + if let Some((left, right)) = antennae { + rot[left].x = speed * PI * 0.03; + rot[right].x = speed * PI * 0.03; + } + } + if s.roll_amount > 0.0 { + rot[bone].x = rot_lerp_rad(s.roll_amount, rot[bone].x, 3.091_592_8); + } + + PartAnim { + rotation: rot.into_iter().enumerate().collect(), + translation: vec![(bone, Vec3::new(0.0, bone_y, 0.0))], + // `stinger.visible = hasStinger`. + scale: if s.has_stinger { + Vec::new() + } else { + vec![(stinger, Vec3::ZERO)] + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Grounded bees keep the rest pose; a stung bee hides its stinger. + #[test] + fn grounded_bee_rests() { + let model = bake_bee_model(); + let s = BeeAnims { + is_on_ground: true, + has_stinger: false, + ..Default::default() + }; + let anim = compute_bee_anim(&model, 37.0, &s); + assert_eq!( + anim.rotation_of(&model, "right_wing"), + Vec3::new(0.0, -0.2618, 0.0) + ); + assert_eq!(anim.rotation_of(&model, "front_legs"), Vec3::ZERO); + let stinger = part_index(&model.parts, "stinger"); + assert_eq!(anim.scale, vec![(stinger, Vec3::ZERO)]); + } + + /// A full roll lands the bone on `3.0915928` whatever the bob angle. + #[test] + fn full_roll_flips_the_bone() { + let model = bake_baby_bee_model(); + let s = BeeAnims { + roll_amount: 1.0, + ..Default::default() + }; + let anim = compute_bee_anim(&model, 12.5, &s); + assert!((anim.rotation_of(&model, "bone").x - 3.091_592_8).abs() < 1e-5); + assert!(anim.scale.is_empty()); + } +} diff --git a/pomme-client/src/renderer/entity_model/parrot.rs b/pomme-client/src/renderer/entity_model/parrot.rs new file mode 100644 index 000000000..76aabb96c --- /dev/null +++ b/pomme-client/src/renderer/entity_model/parrot.rs @@ -0,0 +1,242 @@ +//! Parrot: `ParrotModel` mesh and `setupAnim`. + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use std::f32::consts::PI; + +use glam::Vec3; + +use super::{BakedEntityModel, PartAnim, bake_model, part_index, rotated, vbox, vpart}; + +/// Vanilla `ParrotModel.Pose`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ParrotPose { + #[default] + Flying, + Standing, + Sitting, + Party, + // TODO: `OnShoulder`, for `ParrotOnShoulderLayer` (needs the player's + // shoulder-parrot entity data). +} + +/// `ParrotRenderState`'s animation inputs +/// (`ParrotRenderer.extractRenderState`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ParrotAnims { + /// `(sin(flap) + 1) * flapSpeed`, both lerped. + pub flap_angle: f32, + pub pose: ParrotPose, +} + +/// Vanilla `ParrotModel.createBodyLayer`, 32x32. +pub fn bake_parrot_model() -> BakedEntityModel { + let part = |name, parent, offset: (f32, f32, f32), rot: (f32, f32, f32), cube| { + rotated(vpart(name, parent, offset.into(), vec![cube]), rot.into()) + }; + let wing = vbox((19, 8), (-0.5, 0.0, -1.5), (1.0, 5.0, 3.0)); + let leg = vbox((14, 18), (-0.5, 0.0, -0.5), (1.0, 2.0, 1.0)); + let parts = vec![ + part( + "body", + None, + (0.0, 16.5, -3.0), + (0.4937, 0.0, 0.0), + vbox((2, 8), (-1.5, 0.0, -1.5), (3.0, 6.0, 3.0)), + ), + part( + "tail", + None, + (0.0, 21.07, 1.16), + (1.015, 0.0, 0.0), + vbox((22, 1), (-1.5, -1.0, -1.0), (3.0, 4.0, 1.0)), + ), + part( + "left_wing", + None, + (1.5, 16.94, -2.76), + (-0.6981, -PI, 0.0), + wing, + ), + part( + "right_wing", + None, + (-1.5, 16.94, -2.76), + (-0.6981, -PI, 0.0), + wing, + ), + part( + "head", + None, + (0.0, 15.69, -2.76), + (0.0, 0.0, 0.0), + vbox((2, 2), (-1.0, -1.5, -1.0), (2.0, 3.0, 2.0)), + ), + part( + "head2", + Some(4), + (0.0, -2.0, -1.0), + (0.0, 0.0, 0.0), + vbox((10, 0), (-1.0, -0.5, -2.0), (2.0, 1.0, 4.0)), + ), + part( + "beak1", + Some(4), + (0.0, -0.5, -1.5), + (0.0, 0.0, 0.0), + vbox((11, 7), (-0.5, -1.0, -0.5), (1.0, 2.0, 1.0)), + ), + part( + "beak2", + Some(4), + (0.0, -1.75, -2.45), + (0.0, 0.0, 0.0), + vbox((16, 7), (-0.5, 0.0, -0.5), (1.0, 2.0, 1.0)), + ), + part( + "feather", + Some(4), + (0.0, -2.15, 0.15), + (-0.2214, 0.0, 0.0), + vbox((2, 18), (0.0, -4.0, -2.0), (0.0, 5.0, 4.0)), + ), + part( + "left_leg", + None, + (1.0, 22.0, -1.05), + (-0.0299, 0.0, 0.0), + leg, + ), + part( + "right_leg", + None, + (-1.0, 22.0, -1.05), + (-0.0299, 0.0, 0.0), + leg, + ), + ]; + bake_model(parts, 32, 32) +} + +/// Vanilla `ParrotModel.setupAnim` (with `prepare`). +pub fn compute_parrot_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + age_in_ticks: f32, + s: &ParrotAnims, +) -> PartAnim { + let idx = |name: &str| part_index(&model.parts, name); + let (body, tail, head) = (idx("body"), idx("tail"), idx("head")); + let (left_wing, right_wing) = (idx("left_wing"), idx("right_wing")); + let (left_leg, right_leg) = (idx("left_leg"), idx("right_leg")); + + let mut rot: Vec = model.parts.iter().map(|p| p.default_rotation).collect(); + let mut pos = vec![Vec3::ZERO; model.parts.len()]; + + // `prepare(pose)`. + match s.pose { + ParrotPose::Flying => { + rot[left_leg].x += 0.698_131_7; + rot[right_leg].x += 0.698_131_7; + } + ParrotPose::Sitting => { + pos[head].y += 1.9; + rot[tail].x += 0.523_598_8; + pos[tail].y += 1.9; + pos[body].y += 1.9; + rot[left_wing].z = -0.0873; + pos[left_wing].y += 1.9; + rot[right_wing].z = 0.0873; + pos[right_wing].y += 1.9; + pos[left_leg].y += 1.9; + pos[right_leg].y += 1.9; + rot[left_leg].x += 1.570_796_4; + rot[right_leg].x += 1.570_796_4; + } + ParrotPose::Party => { + rot[left_leg].z = -0.349_065_84; + rot[right_leg].z = 0.349_065_84; + } + ParrotPose::Standing => {} + } + + rot[head].x = head_x_rot_deg.to_radians(); + rot[head].y = local_head_y_rot_deg.to_radians(); + match s.pose { + ParrotPose::Sitting => {} + ParrotPose::Party => { + let x_pos = age_in_ticks.cos(); + let y_pos = age_in_ticks.sin(); + let shift = Vec3::new(x_pos, y_pos, 0.0); + pos[head] += shift; + rot[head] = Vec3::new(0.0, 0.0, age_in_ticks.sin() * 0.4); + pos[body] += shift; + rot[left_wing].z = -0.0873 - s.flap_angle; + pos[left_wing] += shift; + rot[right_wing].z = 0.0873 + s.flap_angle; + pos[right_wing] += shift; + pos[tail] += shift; + } + ParrotPose::Standing | ParrotPose::Flying => { + if s.pose == ParrotPose::Standing { + rot[left_leg].x += (walk_pos * 0.6662).cos() * 1.4 * walk_speed; + rot[right_leg].x += (walk_pos * 0.6662 + PI).cos() * 1.4 * walk_speed; + } + let bobbing_body = s.flap_angle * 0.3; + pos[head].y += bobbing_body; + rot[tail].x += (walk_pos * 0.6662).cos() * 0.3 * walk_speed; + pos[tail].y += bobbing_body; + pos[body].y += bobbing_body; + rot[left_wing].z = -0.0873 - s.flap_angle; + pos[left_wing].y += bobbing_body; + rot[right_wing].z = 0.0873 + s.flap_angle; + pos[right_wing].y += bobbing_body; + pos[left_leg].y += bobbing_body; + pos[right_leg].y += bobbing_body; + } + } + + PartAnim { + rotation: rot.into_iter().enumerate().collect(), + translation: pos.into_iter().enumerate().collect(), + scale: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sitting drops every root part 1.9 and folds the legs; the flap + /// amplitude doesn't reach the wings. + #[test] + fn sitting_ignores_the_flap() { + let model = bake_parrot_model(); + let s = ParrotAnims { + flap_angle: 0.8, + pose: ParrotPose::Sitting, + }; + let anim = compute_parrot_anim(&model, 0.0, 0.0, 0.0, 0.0, 10.0, &s); + assert_eq!(anim.translation_of(&model, "body").y, 1.9); + assert_eq!(anim.rotation_of(&model, "left_wing").z, -0.0873); + assert!((anim.rotation_of(&model, "left_leg").x - (1.570_796_4 - 0.0299)).abs() < 1e-6); + } + + /// Flying tucks the legs 40 degrees and bobs with the flap. + #[test] + fn flying_bobs_with_the_flap() { + let model = bake_parrot_model(); + let s = ParrotAnims { + flap_angle: 1.0, + pose: ParrotPose::Flying, + }; + let anim = compute_parrot_anim(&model, 0.0, 0.0, 0.0, 0.0, 10.0, &s); + assert!((anim.translation_of(&model, "head").y - 0.3).abs() < 1e-6); + assert!((anim.rotation_of(&model, "right_wing").z - 1.0873).abs() < 1e-6); + assert!((anim.rotation_of(&model, "right_leg").x - (0.698_131_7 - 0.0299)).abs() < 1e-6); + } +} diff --git a/pomme-client/src/renderer/entity_model/turtle.rs b/pomme-client/src/renderer/entity_model/turtle.rs index a86ffa8cc..dcca6ec8a 100644 --- a/pomme-client/src/renderer/entity_model/turtle.rs +++ b/pomme-client/src/renderer/entity_model/turtle.rs @@ -6,8 +6,8 @@ use std::f32::consts::FRAC_PI_2; use glam::Vec3; use super::{ - BakedEntityModel, EntityPart, PartAnim, bake_model, compute_quadruped_anim, part_index, - rotated, vbox, vpart, + BakedEntityModel, EntityPart, PartAnim, bake_model, compute_quadruped_anim, find_part, + part_index, rotated, vbox, vpart, }; /// `TurtleRenderState` flags the model reads. @@ -166,7 +166,7 @@ pub fn compute_turtle_anim( anim.rotation_mut(model, rf).z = -swing; anim.rotation_mut(model, lf).z = swing; } - if let Some(egg_belly) = model.parts.iter().position(|p| p.name == "egg_belly") { + if let Some(egg_belly) = find_part(&model.parts, "egg_belly") { if t.has_egg { // `root.y -= 1`: every part hangs off the root. for (i, part) in model.parts.iter().enumerate() { diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index bc96e0e79..b38381175 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -197,6 +197,8 @@ pub struct EntityRenderInfo { pub copper_golem: entity_model::CopperGolemAnims, /// Llama carpet or trader decor. pub llama_decor: Option, + pub bee: entity_model::BeeAnims, + pub parrot: entity_model::ParrotAnims, /// `DolphinRenderState.isMoving`. pub dolphin_moving: bool, pub turtle: entity_model::TurtleAnims, @@ -281,6 +283,8 @@ impl Default for EntityRenderInfo { allay: entity_model::AllayAnims::default(), copper_golem: entity_model::CopperGolemAnims::default(), llama_decor: None, + bee: entity_model::BeeAnims::default(), + parrot: entity_model::ParrotAnims::default(), dolphin_moving: false, turtle: entity_model::TurtleAnims::default(), golem_attack_ticks: 0.0, @@ -960,6 +964,8 @@ enum AnimationType { Allay, SnowGolem, CopperGolem, + Bee, + Parrot, Dolphin, Turtle, /// Cod, salmon, tropical fish, pufferfish. @@ -1163,6 +1169,14 @@ fn mob_definitions() -> Vec { const SULFUR_CUBE_INNER_SMALL_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner_small"); const DOLPHIN_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin"); + // Indexed by `angry * 2 + nectar`. + const BEE_TEX: &[&[&str]] = + tex_table!("bee" => "bee", "bee_nectar", "bee_angry", "bee_angry_nectar"); + const BEE_BABY_TEX: &[&[&str]] = tex_table!("bee" => + "bee_baby", "bee_nectar_baby", "bee_angry_baby", "bee_angry_nectar_baby"); + // Indexed by `Parrot.Variant` id. + const PARROT_TEX: &[&[&str]] = tex_table!("parrot" => + "parrot_red_blue", "parrot_blue", "parrot_green", "parrot_yellow_blue", "parrot_grey"); const DOLPHIN_BABY_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin_baby"); const TURTLE_TEX: &[&[&str]] = tex_table!("turtle" => "turtle"); const TURTLE_BABY_TEX: &[&[&str]] = tex_table!("turtle" => "turtle_baby"); @@ -1317,6 +1331,30 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + // TODO: `BeeStingerLayer` (stingers stuck in players, `BeeStingerModel`). + MobDef { + kind: EntityKind::Bee, + anim: AnimationType::Bee, + adult: vec![opaque(entity_model::bake_bee_model(), BEE_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_bee_model(), + BEE_BABY_TEX, + 32, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + // A `MobRenderer`, not an `AgeableMobRenderer`: babies draw the adult + // mesh. TODO: `ParrotOnShoulderLayer` (needs the player's + // `DATA_SHOULDER_PARROT_LEFT`/`RIGHT`). + MobDef { + kind: EntityKind::Parrot, + anim: AnimationType::Parrot, + adult: vec![opaque(entity_model::bake_parrot_model(), PARROT_TEX, 32)], + baby: None, + adult_overlays: vec![], + baby_overlays: vec![], + }, // TODO: `DolphinCarryingItemLayer` (the item held in its mouth). MobDef { kind: EntityKind::Dolphin, @@ -2548,6 +2586,18 @@ impl EntityRenderer { info.bat_elapsed_secs, info.bat_resting, ), + AnimationType::Bee => { + entity_model::compute_bee_anim(model, info.age_in_ticks, &info.bee) + } + AnimationType::Parrot => entity_model::compute_parrot_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.age_in_ticks, + &info.parrot, + ), AnimationType::Dolphin => entity_model::compute_dolphin_anim( model, info.head_x_rot_deg, @@ -3267,7 +3317,13 @@ pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { EntityKind::Dolphin if is_baby => 0.09375, EntityKind::Mooshroom if is_baby => 0.69, // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. - EntityKind::Sniffer | EntityKind::Llama | EntityKind::TraderLlama if is_baby => { + EntityKind::Sniffer + | EntityKind::Bee + | EntityKind::Parrot + | EntityKind::Llama + | EntityKind::TraderLlama + if is_baby => + { dimensions::EntityDimensions::eye_height(kind) * 0.5 } _ if is_baby => entity_bounds(kind, is_baby).1 * dimensions::DEFAULT_EYE_HEIGHT_RATIO, diff --git a/tests/ingame/entities/bee.toml b/tests/ingame/entities/bee.toml new file mode 100644 index 000000000..f2bf08603 --- /dev/null +++ b/tests/ingame/entities/bee.toml @@ -0,0 +1,81 @@ +# Bees at rest on the ground, three-quarter view from above: plain, nectar, +# angry, angry with nectar, stung (no stinger), a baby and a baby with +# nectar, plus a hovering adult and baby (flying pose: wings out, legs +# tucked, bobbing). Each texture is picked from the angry/nectar flags. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill -30 -60 0 -30 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s -29.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [-29.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:bee -32.5 -60 3.5 {NoAI:1b,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -31.5 -60 3.5 {NoAI:1b,HasNectar:1b,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -30.5 -60 3.5 {NoAI:1b,anger_end_time:2000000000L,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -29.5 -60 3.5 {NoAI:1b,HasNectar:1b,anger_end_time:2000000000L,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -28.5 -60 3.5 {NoAI:1b,HasStung:1b,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -27.5 -60 3.5 {NoAI:1b,Age:-24000,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -26.5 -60 3.5 {NoAI:1b,Age:-24000,HasNectar:1b,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -31 -59 5.5 {NoAI:1b,NoGravity:1b,Rotation:[180f,0f]}" + +[[step]] +chat = "/summon minecraft:bee -28 -59 5.5 {NoAI:1b,NoGravity:1b,Age:-24000,Rotation:[180f,0f]}" + +[[step]] +expect = { entity = "bee", baby = false } + +[[step]] +expect = { entity = "bee", baby = true } + +[[step]] +look_at = [-29.5, -59.5, 4.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "bees" + +[[step]] +chat = "/kill @e[type=minecraft:bee]" + +[[step]] +chat = "/fill -30 -60 0 -30 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/parrot.toml b/tests/ingame/entities/parrot.toml new file mode 100644 index 000000000..9e22e88b6 --- /dev/null +++ b/tests/ingame/entities/parrot.toml @@ -0,0 +1,85 @@ +# Parrots, three-quarter view from above: the five variants standing, +# one sitting, one hovering (flying pose: legs tucked, wings out) and one +# partying by a playing jukebox (the client hears the jukebox's level +# event and bobs the parrot's head and body in a circle). + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill -50 -60 0 -50 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s -49.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [-49.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:parrot -52.5 -60 3.5 {NoAI:1b,Variant:0,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -51.5 -60 3.5 {NoAI:1b,Variant:1,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -50.5 -60 3.5 {NoAI:1b,Variant:2,Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -49.5 -60 3.5 {NoAI:1b,Variant:3,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -48.5 -60 3.5 {NoAI:1b,Variant:4,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -51.5 -60 5.5 {NoAI:1b,Variant:2,Sitting:1b,Rotation:[180f,0f]}" + +[[step]] +chat = "/summon minecraft:parrot -50 -59 5.5 {NoAI:1b,NoGravity:1b,Variant:1,Rotation:[180f,0f]}" + +# The party parrot, beside a jukebox that starts playing once a disc goes in. +[[step]] +chat = "/setblock -46 -60 6 minecraft:jukebox" + +[[step]] +chat = "/summon minecraft:parrot -47.5 -60 5.5 {NoAI:1b,Variant:0,Rotation:[180f,0f]}" + +[[step]] +expect = { entity = "parrot" } + +[[step]] +chat = "/item replace block -46 -60 6 container.0 with minecraft:music_disc_cat" + +[[step]] +look_at = [-50.0, -59.5, 4.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "parrots" + +[[step]] +chat = "/kill @e[type=minecraft:parrot]" + +[[step]] +chat = "/setblock -46 -60 6 minecraft:air" + +[[step]] +chat = "/fill -50 -60 0 -50 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From 87f7c7d9860a30b6ab05d64f53251015e05088da Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:13:32 +0000 Subject: [PATCH 05/10] add fox and panda --- pomme-client/src/app/core.rs | 32 ++ pomme-client/src/app/phases/in_game.rs | 78 ++- pomme-client/src/entity/mod.rs | 280 +++++++++- pomme-client/src/particle.rs | 45 ++ pomme-client/src/renderer/entity_model.rs | 76 ++- pomme-client/src/renderer/entity_model/fox.rs | 501 ++++++++++++++++++ .../src/renderer/entity_model/panda.rs | 349 ++++++++++++ .../src/renderer/pipelines/entity_renderer.rs | 64 +++ tests/ingame/entities/fox.toml | 77 +++ tests/ingame/entities/panda.toml | 100 ++++ 10 files changed, 1592 insertions(+), 10 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/fox.rs create mode 100644 pomme-client/src/renderer/entity_model/panda.rs create mode 100644 tests/ingame/entities/fox.toml create mode 100644 tests/ingame/entities/panda.toml diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 70b09f7ff..3cbce3fb6 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -307,6 +307,34 @@ fn enqueue_edits(game: &mut GameState, sections: &[(crate::position::ChunkPos, i } } +/// The client half of `Panda.addEatingParticles`, flagged by the entity tick +/// (the sound is the server's broadcast). +fn panda_eating_particles(game: &mut GameState, camera_pos: glam::DVec3) { + for e in game.entity_store.living.values() { + if e.entity_type != azalea_registry::builtin::EntityKind::Panda || !e.panda.eat_particles { + continue; + } + let Some(azalea_inventory::ItemStack::Present(held)) = + e.equipment(azalea_inventory::components::EquipmentSlot::Mainhand) + else { + continue; + }; + let texture = format!( + "item/{}", + crate::player::inventory::item_resource_name(held.kind) + ); + game.particle_store.add_panda_eating_particles( + &texture, + e.position.into(), + glam::DVec3::from(e.eye_position(1.0)).y, + e.look_dir.x_rot_deg(), + e.look_dir.y_rot_deg(), + e.body_y_rot_deg, + camera_pos, + ); + } +} + /// A sound following an entity, skipped while it's silent (vanilla /// `EntityBoundSoundInstance.canPlaySound`); the playing instance's id. #[allow(clippy::too_many_arguments)] @@ -3194,6 +3222,7 @@ impl AppCore { self.tick_remote_entities(game); super::sniffer::tick(&mut self.audio, game, renderer.camera_render_position()); super::copper_golem::tick(&self.audio, game); + panda_eating_particles(game, renderer.camera_render_position()); } /// `LivingEntity.pushEntities` from every remote living entity's tick, @@ -3222,6 +3251,9 @@ impl AppCore { }; scoreboard.pushable_by(&pusher_name, local_name) }; + // Vanilla `Level.isThundering`. + game.entity_store.thundering = + game.environment.has_weather && f64::from(game.sky_state.thunder()) > 0.9; *game.player.velocity += game.entity_store.tick_living( &game.chunk_store, game.block_registry.block_state_table(), diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 0c5892dc5..94adabe95 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4121,6 +4121,8 @@ pub fn update_game( copper_golem: extras.copper_golem, llama_decor: extras.llama_decor, bee: extras.bee, + fox: extras.fox, + panda: extras.panda, parrot: extras.parrot, dolphin_moving: extras.dolphin_moving, turtle: extras.turtle, @@ -5009,6 +5011,8 @@ struct EntityExtras { copper_golem: crate::renderer::entity_model::CopperGolemAnims, llama_decor: Option, bee: crate::renderer::entity_model::BeeAnims, + fox: crate::renderer::entity_model::FoxAnims, + panda: crate::renderer::entity_model::PandaAnims, parrot: crate::renderer::entity_model::ParrotAnims, dolphin_moving: bool, turtle: crate::renderer::entity_model::TurtleAnims, @@ -5166,6 +5170,8 @@ fn entity_extras( ..Default::default() }, EntityKind::Bee => bee_extras(e, alpha, game_time), + EntityKind::Fox => fox_extras(e, alpha), + EntityKind::Panda => panda_extras(e, alpha), EntityKind::Parrot => parrot_extras(e, alpha), EntityKind::Sniffer => EntityExtras { sniffer: sniffer_anims(e, alpha), @@ -5224,6 +5230,73 @@ fn llama_extras(e: &crate::entity::LivingEntity) -> EntityExtras { } } +/// Vanilla `FoxRenderer.extractRenderState` and `setupRotations` (the +/// pounce / faceplant pitch). `variant_index = variant * 2 + sleeping`. +// TODO: `FoxHeldItemLayer` (the item in the fox's mouth). +fn fox_extras(e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { + let f = &e.fox; + let fox = crate::renderer::entity_model::FoxAnims { + head_roll_angle: e.interested_angle(alpha) * 0.11 * std::f32::consts::PI, + crouch_amount: lerp(alpha, f.crouch_amount_o, f.crouch_amount), + is_crouching: f.is_crouching(), + is_sleeping: f.is_sleeping(), + is_sitting: e.is_sitting, + is_faceplanted: f.is_faceplanted(), + is_pouncing: f.is_pouncing(), + leg_motion_pos: if f.is_faceplanted() { + crate::renderer::entity_model::advance_fox_leg_motion_pos(e.is_baby) + } else { + 0.0 + }, + }; + let x_rot = e.view_look(alpha).x_rot_deg(); + EntityExtras { + variant_index: e.variant * 2 + u32::from(fox.is_sleeping), + body_transform: (fox.is_pouncing || fox.is_faceplanted) + .then(|| glam::Mat4::from_rotation_x((-x_rot).to_radians())), + fox, + ..Default::default() + } +} + +/// Vanilla `PandaRenderer.extractRenderState` and `setupRotations`; +/// `variant_index` is the shown gene. +// TODO: `PandaHoldsItemLayer` (the held bamboo/cake while sitting). +fn panda_extras(e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { + let p = &e.panda; + let panda = crate::renderer::entity_model::PandaAnims { + is_unhappy: e.unhappy_counter > 0, + is_sneezing: p.is_sneezing(), + sneeze_time: p.sneeze_counter, + is_eating: p.is_eating(), + is_scared: p.is_scared, + sit_amount: lerp(alpha, p.sit_amount_o, p.sit_amount), + lie_on_back_amount: lerp(alpha, p.on_back_amount_o, p.on_back_amount), + roll_amount: if e.is_baby { + 0.0 + } else { + lerp(alpha, p.roll_amount_o, p.roll_amount) + }, + roll_time: if p.roll_counter > 0 { + p.roll_counter as f32 + alpha + } else { + 0.0 + }, + }; + let x_rot = e.view_look(alpha).x_rot_deg(); + EntityExtras { + variant_index: p.variant(), + body_transform: crate::renderer::entity_model::panda_body_transform( + &panda, + e.is_baby, + x_rot, + e.age_in_ticks as f32 + alpha, + ), + panda, + ..Default::default() + } +} + /// Vanilla `BeeRenderer.extractRenderState` and `getTextureLocation` /// (variants: plain, nectar, angry, angry with nectar). fn bee_extras(e: &crate::entity::LivingEntity, alpha: f32, game_time: i64) -> EntityExtras { @@ -5521,10 +5594,7 @@ fn wolf_extras(e: &crate::entity::LivingEntity, alpha: f32, game_time: i64) -> E overlay_tints, is_angry, tail_angle, - head_roll_angle: (e.prev_interested_angle - + (e.interested_angle - e.prev_interested_angle) * alpha) - * 0.15 - * PI, + head_roll_angle: e.interested_angle(alpha) * 0.15 * PI, shake_anim: e.prev_shake_anim + (e.shake_anim - e.prev_shake_anim) * alpha, base_tint: Some([wet, wet, wet, 1.0]), ..Default::default() diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 9c7e10cac..44d4662c6 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -174,6 +174,40 @@ impl CopperGolemAnim { } } +/// Vanilla `Fox` client state: the synced flags byte and the crouch spring +/// (`Fox.tick`). The interested head tilt reuses the wolf's spring. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FoxSim { + pub flags: u8, + pub crouch_amount: f32, + pub crouch_amount_o: f32, +} + +impl FoxSim { + pub fn is_crouching(&self) -> bool { + self.flags & 4 != 0 + } + pub fn is_pouncing(&self) -> bool { + self.flags & 16 != 0 + } + pub fn is_sleeping(&self) -> bool { + self.flags & 32 != 0 + } + pub fn is_faceplanted(&self) -> bool { + self.flags & 64 != 0 + } + + /// The crouch half of `Fox.tick`. + fn tick(&mut self) { + self.crouch_amount_o = self.crouch_amount; + if self.is_crouching() { + self.crouch_amount = (self.crouch_amount + 0.2).min(5.0); + } else { + self.crouch_amount = 0.0; + } + } +} + /// Vanilla `Bee` client state: the synced flags byte and its roll spring. #[derive(Clone, Debug, Default, PartialEq)] pub struct BeeSim { @@ -200,6 +234,133 @@ impl BeeSim { } } +/// Vanilla `Panda` client state: the synced counters, genes and flags, which +/// `Panda.tick` also advances client-side, plus its sit / on-back / roll +/// springs and roll counter. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PandaSim { + pub sneeze_counter: i32, + pub eat_counter: i32, + pub main_gene: u8, + pub hidden_gene: u8, + pub flags: u8, + pub roll_counter: i32, + pub sit_amount: f32, + pub sit_amount_o: f32, + pub on_back_amount: f32, + pub on_back_amount_o: f32, + pub roll_amount: f32, + pub roll_amount_o: f32, + /// `isScared()` as of the last tick (worried and thundering). + pub is_scared: bool, + /// The last tick's `addEatingParticles` fired (eating, counter % 5). + pub eat_particles: bool, +} + +impl PandaSim { + pub const WORRIED: u32 = 2; + + /// `Gene.byId` (signed byte, out of range = NORMAL). + fn gene(id: u8) -> u32 { + match id as i8 { + g @ 0..=6 => g as u32, + _ => 0, + } + } + + /// `Gene.getVariantFromGenes`: a recessive main gene (brown, weak) only + /// shows when the hidden gene matches. + pub fn variant(&self) -> u32 { + let (main, hidden) = (Self::gene(self.main_gene), Self::gene(self.hidden_gene)); + if matches!(main, 4 | 5) && main != hidden { + 0 + } else { + main + } + } + + pub fn is_sneezing(&self) -> bool { + self.flags & 2 != 0 + } + pub fn is_rolling(&self) -> bool { + self.flags & 4 != 0 + } + pub fn is_sitting(&self) -> bool { + self.flags & 8 != 0 + } + pub fn is_on_back(&self) -> bool { + self.flags & 16 != 0 + } + pub fn is_eating(&self) -> bool { + self.eat_counter > 0 + } + + fn set_flag(&mut self, flag: u8, value: bool) { + if value { + self.flags |= flag; + } else { + self.flags &= !flag; + } + } + + /// The client-side half of `Panda.tick` after `super.tick()` (the + /// unhappy countdown is the shared one in `tick_living`); returns whether + /// it zeroes the pitch (`isSitting` -> `setXRot(0)`). + // TODO: the `afterSneeze` SNEEZE particle; pomme has no sneeze particle + // type yet. Its sounds are server broadcasts (a client `playSound` with + // no player is a no-op), and its jumps and gift drop are server-side. + fn tick(&mut self, thundering: bool, in_water: bool, holding_item: bool) -> bool { + let worried = self.variant() == Self::WORRIED; + if worried { + if thundering && !in_water { + self.set_flag(8, true); + self.eat_counter = 0; + } else if !self.is_eating() { + self.set_flag(8, false); + } + } + if self.is_sneezing() { + self.sneeze_counter += 1; + if self.sneeze_counter > 20 { + self.set_flag(2, false); + self.sneeze_counter = 0; + } + } + if self.is_rolling() { + self.roll_counter += 1; + if self.roll_counter > 32 { + self.set_flag(4, false); + } + } else { + self.roll_counter = 0; + } + let zero_pitch = self.is_sitting(); + self.sit_amount_o = self.sit_amount; + self.sit_amount = spring(self.sit_amount, self.is_sitting(), 0.15, 0.19); + // `handleEating`; stopping after a meal is server-only. + self.is_scared = worried && thundering; + if !self.is_eating() + && self.is_sitting() + && !self.is_scared + && holding_item + && fastrand::u32(0..80) == 1 + { + self.eat_counter = 1; + } else if !holding_item || !self.is_sitting() { + self.eat_counter = 0; + } + self.eat_particles = self.is_eating() && self.eat_counter % 5 == 0; + if self.is_eating() { + self.eat_counter += 1; + } + self.on_back_amount_o = self.on_back_amount; + self.on_back_amount = spring(self.on_back_amount, self.is_on_back(), 0.15, 0.19); + self.roll_amount_o = self.roll_amount; + self.roll_amount = spring(self.roll_amount, self.is_rolling(), 0.15, 0.19); + zero_pitch + } +} + /// A 0..1 pose amount stepping `up` toward 1 while `on`, else `down` /// toward 0. fn spring(cur: f32, on: bool, up: f32, down: f32) -> f32 { @@ -230,6 +391,8 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::WanderingTrader | EntityKind::SulfurCube | EntityKind::Mooshroom + | EntityKind::Fox + | EntityKind::Panda | EntityKind::Bee | EntityKind::Parrot | EntityKind::Dolphin @@ -448,6 +611,8 @@ pub struct LivingEntity { pub copper_golem: CopperGolemAnim, /// Sulfur cube fuse ticks left, -1 while unprimed (`SulfurCube.fuse`). pub sulfur_fuse: i32, + pub fox: FoxSim, + pub panda: PandaSim, /// `Turtle.HAS_EGG` / `LAYING_EGG`. pub has_egg: bool, pub laying_egg: bool, @@ -503,6 +668,7 @@ pub struct LivingEntity { pub(crate) fn baby_dimensions(kind: EntityKind, width: f32, height: f32) -> (f32, f32) { let scale = match kind { EntityKind::Squid | EntityKind::GlowSquid => return (0.5, 0.5), + EntityKind::Fox => 0.6, // Their `getAgeScale`. EntityKind::Dolphin => 0.65, EntityKind::Turtle => 0.3, @@ -678,6 +844,8 @@ impl LivingEntity { allay: AllayAnim::default(), copper_golem: CopperGolemAnim::default(), sulfur_fuse: -1, + fox: FoxSim::default(), + panda: PandaSim::default(), has_egg: false, laying_egg: false, puff_state: 0, @@ -820,6 +988,16 @@ impl LivingEntity { + DVec3::new(0.0, f64::from(eye), 0.0) } + /// The wolf / fox interested tilt between ticks, before its + /// `getHeadRollAngle` scale. + pub fn interested_angle(&self, partial: f32) -> f32 { + crate::renderer::entity_model::lerp( + partial, + self.prev_interested_angle, + self.interested_angle, + ) + } + /// Vanilla `WalkAnimationState.position(partialTick)` (babies run the /// cycle 3x). pub fn walk_pos(&self, partial: f32) -> f32 { @@ -1092,6 +1270,7 @@ impl LivingEntity { // TODO: `Bee.tick`'s falling-nectar drip particles (no drip // particle type yet). EntityKind::Bee => self.bee.tick(), + EntityKind::Fox => self.fox.tick(), EntityKind::Slime => self.tick_squish(), EntityKind::SulfurCube => { if self.sulfur_fuse > 0 { @@ -1479,12 +1658,15 @@ fn apply_item_fluid_movement(entity: &mut ItemEntity, multiplier: f64) { pub struct EntityStore { pub living: HashMap, + /// Vanilla `Level.isThundering`, set by the caller before each tick. + pub thundering: bool, } impl EntityStore { pub fn new() -> Self { Self { living: HashMap::new(), + thundering: false, } } @@ -1594,9 +1776,23 @@ impl EntityStore { // Zombie-family underwater conversion / zombie villager curing. (EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned, 18, Bool(b)) | (EntityKind::ZombieVillager, 19, Bool(b)) => entity.is_converting = b, - (EntityKind::Villager | EntityKind::WanderingTrader, 18, Int(c)) => { - entity.unhappy_counter = c + ( + EntityKind::Villager | EntityKind::WanderingTrader | EntityKind::Panda, + 18, + Int(c), + ) => entity.unhappy_counter = c, + // `Fox.Variant` id map (ZERO out of range): 0 red, 1 snow. + (EntityKind::Fox, 18, Int(v)) => entity.variant = u32::from(v == 1), + (EntityKind::Fox, 19, Byte(f)) => { + entity.fox.flags = f; + entity.is_sitting = f & 0x01 != 0; + entity.is_interested = f & 0x08 != 0; } + (EntityKind::Panda, 19, Int(c)) => entity.panda.sneeze_counter = c, + (EntityKind::Panda, 20, Int(c)) => entity.panda.eat_counter = c, + (EntityKind::Panda, 21, Byte(g)) => entity.panda.main_gene = g, + (EntityKind::Panda, 22, Byte(g)) => entity.panda.hidden_gene = g, + (EntityKind::Panda, 23, Byte(f)) => entity.panda.flags = f, // Vanilla sparse rabbit id map: 99 = evil, unknown ids fall back // to brown. (EntityKind::Rabbit, 18, Int(v)) => { @@ -1872,6 +2068,7 @@ impl EntityStore { simulation_distance: u32, ) -> DVec3 { let mut local_push = DVec3::ZERO; + let thundering = self.thundering; for (&id, entity) in &mut self.living { entity.tick_interpolation(); entity.tick_body_rotation(); @@ -1899,6 +2096,12 @@ impl EntityStore { entity.tick_party(chunks, block_state_table); } entity.tick_kind_anims(); + if entity.entity_type == EntityKind::Panda { + let holding = entity.equipment(EquipmentSlot::Mainhand).is_some(); + if entity.panda.tick(thundering, entity.is_in_water, holding) { + entity.look_dir = LookDirection::new(entity.look_dir.y_rot_deg(), 0.0); + } + } entity.tick_tamable_anims(); entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { @@ -1915,8 +2118,11 @@ impl EntityStore { if entity.swing_time > 0 { entity.swing_time -= 1; } - // Only `Villager.tick` counts it down, not the wandering trader. - if entity.entity_type == EntityKind::Villager && entity.unhappy_counter > 0 { + // `Villager.tick` and `Panda.tick` count it down, not the + // wandering trader. + if matches!(entity.entity_type, EntityKind::Villager | EntityKind::Panda) + && entity.unhappy_counter > 0 + { entity.unhappy_counter -= 1; } entity.age_in_ticks = entity.age_in_ticks.wrapping_add(1); @@ -2034,6 +2240,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::CopperGolem | EntityKind::WanderingTrader | EntityKind::SulfurCube + | EntityKind::Fox + | EntityKind::Panda | EntityKind::Mooshroom | EntityKind::Dolphin | EntityKind::Turtle @@ -2051,6 +2259,7 @@ fn probes_water(kind: &EntityKind) -> bool { | EntityKind::Salmon | EntityKind::TropicalFish | EntityKind::Pufferfish + | EntityKind::Panda | EntityKind::Turtle ) } @@ -2090,6 +2299,69 @@ mod tests { assert_eq!(store.living[&1].variant, CAT_DEFAULT_VARIANT); } + /// Fox type (19 = flags byte, 18 = variant with ZERO fallback) and the + /// client crouch spring capped at 5. + #[test] + fn fox_flags_and_crouch_spring() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Fox, Position::default()); + store.apply_entity_data(1, 18, MetaValue::Int(1)); + assert_eq!(store.living[&1].variant, 1); + store.apply_entity_data(1, 18, MetaValue::Int(7)); + assert_eq!(store.living[&1].variant, 0); + + store.apply_entity_data(1, 19, MetaValue::Byte(0x01 | 0x04 | 0x08 | 0x20)); + let fox = &store.living[&1]; + assert!(fox.is_sitting && fox.is_interested); + assert!(fox.fox.is_crouching() && fox.fox.is_sleeping()); + assert!(!fox.fox.is_pouncing() && !fox.fox.is_faceplanted()); + for _ in 0..30 { + tick(&mut store); + } + assert_eq!(store.living[&1].fox.crouch_amount, 5.0); + store.apply_entity_data(1, 19, MetaValue::Byte(0)); + tick(&mut store); + assert_eq!(store.living[&1].fox.crouch_amount, 0.0); + } + + /// Panda genes resolve like `Gene.getVariantFromGenes`, and the sneeze + /// counter runs client-side until the flag clears itself after 20 ticks. + #[test] + fn panda_genes_and_client_sneeze() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Panda, Position::default()); + let variant = |store: &mut EntityStore, main: u8, hidden: u8| { + store.apply_entity_data(1, 21, MetaValue::Byte(main)); + store.apply_entity_data(1, 22, MetaValue::Byte(hidden)); + store.living[&1].panda.variant() + }; + assert_eq!(variant(&mut store, 1, 0), 1); + assert_eq!(variant(&mut store, 4, 0), 0); + assert_eq!(variant(&mut store, 4, 4), 4); + assert_eq!(variant(&mut store, 5, 5), 5); + assert_eq!(variant(&mut store, 0xFF, 0), 0); + + store.apply_entity_data(1, 23, MetaValue::Byte(0x02)); + store.apply_entity_data(1, 19, MetaValue::Int(0)); + for _ in 0..20 { + tick(&mut store); + } + assert!(store.living[&1].panda.is_sneezing()); + assert_eq!(store.living[&1].panda.sneeze_counter, 20); + tick(&mut store); + assert!(!store.living[&1].panda.is_sneezing()); + assert_eq!(store.living[&1].panda.sneeze_counter, 0); + + store.apply_entity_data(1, 23, MetaValue::Byte(0x08)); + tick(&mut store); + assert!((store.living[&1].panda.sit_amount - 0.15).abs() < 1e-6); + + // `Panda.tick` counts the unhappy counter down, like the villager's. + store.apply_entity_data(1, 18, MetaValue::Int(2)); + tick(&mut store); + assert_eq!(store.living[&1].unhappy_counter, 1); + } + /// Bee flags and anger drive the render state; the roll eases in by 0.2 /// and out by 0.24 per tick. #[test] diff --git a/pomme-client/src/particle.rs b/pomme-client/src/particle.rs index fcb0b0eda..225450e0c 100644 --- a/pomme-client/src/particle.rs +++ b/pomme-client/src/particle.rs @@ -502,6 +502,51 @@ impl ParticleStore { } } + /// Vanilla `Panda.addEatingParticles`: six crumbs of the held item in + /// front of the head. `ClientLevel.addParticle` culls each past 32 blocks. + #[allow(clippy::too_many_arguments)] + pub fn add_panda_eating_particles( + &mut self, + texture: &str, + pos: DVec3, + eye_y: f64, + x_rot_deg: f32, + y_rot_deg: f32, + body_y_rot_deg: f32, + camera_pos: DVec3, + ) { + if !self.uv_map.has_region(texture) { + return; + } + let region = self.uv_map.get_region(texture); + let rand = || f64::from(fastrand::f32()); + let x_rot = f64::from(-x_rot_deg.to_radians()); + let y_rot = f64::from(-y_rot_deg.to_radians()); + let body_rot = f64::from(-body_y_rot_deg.to_radians()); + for _ in 0..6 { + let v = dvec3( + (rand() - 0.5) * 0.1, + rand() * 0.1 + 0.1, + (rand() - 0.5) * 0.1, + ); + let v = rot_y(rot_x(v, x_rot), y_rot); + let p = dvec3( + (rand() - 0.5) * 0.8, + -rand() * 0.6 - 0.3, + 1.0 + (rand() - 0.5) * 0.4, + ); + let p = rot_y(p, body_rot) + dvec3(pos.x, eye_y + 1.0, pos.z); + if beyond_particle_range(camera_pos, p) { + continue; + } + self.push(Particle::breaking_item( + p, + dvec3(v.x, v.y + 0.05, v.z), + region, + )); + } + } + /// Vanilla `ClientPacketListener.handleParticleEvent`: count 0 is a /// single directional particle (velocity = dist * max_speed), otherwise a /// gaussian scatter of `count` particles around the position. diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 3983b2452..67bea86ed 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -6,13 +6,17 @@ use crate::renderer::rhi; mod bee; mod copper_golem_anim; mod dolphin; +mod fox; mod llama; +mod panda; mod parrot; mod sniffer_anim; mod turtle; pub use bee::*; pub use dolphin::*; +pub use fox::*; pub use llama::*; +pub use panda::*; pub use parrot::*; pub use turtle::*; @@ -4118,6 +4122,12 @@ fn vanilla_rot(x: f32, y: f32, z: f32) -> Vec3 { } /// Vanilla head look, `Ry(yaw)·Rx(pitch)`. +/// The walk swing vanilla models share, `cos(pos * 0.6662 + phase) * 1.4 * +/// speed`. +fn walk_swing(walk_pos: f32, walk_speed: f32, phase: f32) -> f32 { + (walk_pos * 0.6662 + phase).cos() * 1.4 * walk_speed +} + fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { vanilla_rot( head_x_rot_deg.to_radians(), @@ -4659,9 +4669,9 @@ pub fn compute_wolf_anim( let tail_wag = if inputs.is_angry { 0.0 } else { - (walk_pos * 0.6662).cos() * 1.4 * walk_speed + walk_swing(walk_pos, walk_speed, 0.0) }; - let walk = |phase: f32| (walk_pos * 0.6662 + phase).cos() * 1.4 * walk_speed; + let walk = |phase| walk_swing(walk_pos, walk_speed, phase); for (i, part) in model.parts.iter().enumerate() { let name = part.name.as_str(); @@ -5052,6 +5062,68 @@ impl KfDeltas { } } +/// A vanilla `ModelPart` pose set, mutated in `setupAnim` order (absolute +/// assignments and `+=` alike, after `resetPose`), then emitted against the +/// rest pose. Hidden parts emit a zero scale, which also hides their +/// children like vanilla's `visible = false`. +struct ModelPose { + pos: Vec, + rot: Vec, + scale: Vec, + visible: Vec, +} + +impl ModelPose { + /// Vanilla `resetPose`. + fn new(model: &BakedEntityModel) -> Self { + let n = model.parts.len(); + Self { + pos: model.parts.iter().map(|p| p.offset).collect(), + rot: model.parts.iter().map(|p| p.default_rotation).collect(), + scale: vec![Vec3::ONE; n], + visible: vec![true; n], + } + } + + /// Adds keyframe offsets (`offsetRotation`/`offsetPos`/`offsetScale`). + fn add_kf(&mut self, d: &KfDeltas) { + for i in 0..self.pos.len() { + self.rot[i] += d.rot[i]; + self.pos[i] += d.pos[i]; + self.scale[i] += d.scale[i]; + } + } + + /// The `QuadrupedModel.setupAnim` leg pattern over (right hind, left + /// hind, right front, left front): diagonal pairs in phase. + fn swing_legs(&mut self, [rh, lh, rf, lf]: [usize; 4], swing: impl Fn(f32) -> f32) { + use std::f32::consts::PI; + self.rot[rh].x = swing(0.0); + self.rot[lh].x = swing(PI); + self.rot[rf].x = swing(PI); + self.rot[lf].x = swing(0.0); + } + + fn emit(&self, model: &BakedEntityModel) -> PartAnim { + let mut anim = PartAnim::default(); + for (i, part) in model.parts.iter().enumerate() { + if self.rot[i] != part.default_rotation { + anim.rotation.push((i, self.rot[i])); + } + let t = self.pos[i] - part.offset; + if t != Vec3::ZERO { + anim.translation.push((i, t)); + } + if !self.visible[i] { + anim.scale.push((i, Vec3::ZERO)); + } else if self.scale[i] != Vec3::ONE { + anim.scale.push((i, self.scale[i])); + } + } + anim + } +} + /// Vanilla `KeyframeAnimation.apply(millisSinceStart, targetScale)`, adding /// into the deltas (vanilla `offsetRotation`/`offsetPos`/`offsetScale`; the /// position Y-negation of `KeyframeAnimations.posVec` is applied here). diff --git a/pomme-client/src/renderer/entity_model/fox.rs b/pomme-client/src/renderer/entity_model/fox.rs new file mode 100644 index 000000000..5268d7aca --- /dev/null +++ b/pomme-client/src/renderer/entity_model/fox.rs @@ -0,0 +1,501 @@ +//! Fox: `AdultFoxModel`/`BabyFoxModel` meshes, `FoxModel.setupAnim` with the +//! adult/baby pose overrides, and the `FoxBabyAnimation` walk. + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use std::sync::atomic::{AtomicU32, Ordering}; + +use glam::Vec3; + +use super::{ + BakedEntityModel, KfAnim, KfChannel, KfDeltas, KfTarget, Kfi, ModelPose, PartAnim, + apply_kf_walk, bake_model, deformed, kf, part_index, rotated, vbox, vpart, walk_swing, +}; + +/// Vanilla `AdultFoxModel.createBodyLayer`, 48x32. +pub fn bake_fox_model() -> BakedEntityModel { + let fudge = |tex| deformed(vbox(tex, (2.0, 0.5, -1.0), (2.0, 6.0, 2.0)), 0.001); + let (left_leg, right_leg) = (fudge((4, 24)), fudge((13, 24))); + let parts = vec![ + vpart( + "head", + None, + Vec3::new(-1.0, 16.5, -3.0), + vec![vbox((1, 5), (-3.0, -2.0, -5.0), (8.0, 6.0, 6.0))], + ), + vpart( + "right_ear", + Some(0), + Vec3::ZERO, + vec![vbox((8, 1), (-3.0, -4.0, -4.0), (2.0, 2.0, 1.0))], + ), + vpart( + "left_ear", + Some(0), + Vec3::ZERO, + vec![vbox((15, 1), (3.0, -4.0, -4.0), (2.0, 2.0, 1.0))], + ), + vpart( + "nose", + Some(0), + Vec3::ZERO, + vec![vbox((6, 18), (-1.0, 2.01, -8.0), (4.0, 2.0, 3.0))], + ), + rotated( + vpart( + "body", + None, + Vec3::new(0.0, 16.0, -6.0), + vec![vbox((24, 15), (-3.0, 3.999, -3.5), (6.0, 11.0, 6.0))], + ), + Vec3::new(1.5707964, 0.0, 0.0), + ), + vpart( + "right_hind_leg", + None, + Vec3::new(-5.0, 17.5, 7.0), + vec![right_leg], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(-1.0, 17.5, 7.0), + vec![left_leg], + ), + vpart( + "right_front_leg", + None, + Vec3::new(-5.0, 17.5, 0.0), + vec![right_leg], + ), + vpart( + "left_front_leg", + None, + Vec3::new(-1.0, 17.5, 0.0), + vec![left_leg], + ), + rotated( + vpart( + "tail", + Some(4), + Vec3::new(-4.0, 15.0, -1.0), + vec![vbox((30, 0), (2.0, 0.0, -1.0), (4.0, 9.0, 5.0))], + ), + Vec3::new(-0.05235988, 0.0, 0.0), + ), + ]; + bake_model(parts, 48, 32) +} + +/// Vanilla `BabyFoxModel.createBodyLayer`, 32x32. The ears are head cubes, +/// not parts. +pub fn bake_baby_fox_model() -> BakedEntityModel { + let leg = |name, tex, x: f32, z: f32| { + vpart( + name, + None, + Vec3::new(x, 22.0, z), + vec![vbox(tex, (-1.0, 0.0, -1.0), (2.0, 2.0, 2.0))], + ) + }; + let parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, 18.125, 0.125), + vec![ + vbox((0, 0), (-3.0, -2.125, -5.125), (6.0, 5.0, 5.0)), + vbox((18, 20), (-1.0, 0.875, -7.125), (2.0, 2.0, 2.0)), + vbox((22, 8), (-3.0, -4.125, -4.125), (2.0, 2.0, 1.0)), + vbox((22, 11), (1.0, -4.125, -4.125), (2.0, 2.0, 1.0)), + ], + ), + leg("right_hind_leg", (22, 4), -1.5, 4.0), + leg("left_hind_leg", (22, 0), 1.5, 4.0), + leg("right_front_leg", (22, 4), -1.5, 0.0), + leg("left_front_leg", (22, 0), 1.5, 0.0), + vpart( + "body", + None, + Vec3::new(0.0, 20.0, 2.0), + vec![vbox((0, 10), (-2.5, -2.0, -3.0), (5.0, 4.0, 6.0))], + ), + vpart( + "tail", + Some(5), + Vec3::new(0.0, -0.5, 3.0), + vec![vbox((0, 20), (-1.5, -1.48, -1.0), (3.0, 3.0, 6.0))], + ), + ]; + bake_model(parts, 32, 32) +} + +/// The `FoxRenderState` fields `FoxModel.setupAnim` reads. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct FoxAnims { + /// `getHeadRollAngle`, radians. + pub head_roll_angle: f32, + pub crouch_amount: f32, + pub is_crouching: bool, + pub is_sleeping: bool, + pub is_sitting: bool, + pub is_faceplanted: bool, + pub is_pouncing: bool, + /// `FoxModel.legMotionPos` after this fox's frame advanced it (see + /// [`advance_fox_leg_motion_pos`]); read only while faceplanted. + pub leg_motion_pos: f32, +} + +/// `FoxModel.legMotionPos`: a field on the model instance, so one counter is +/// shared by every faceplanted fox drawn with that model. +static LEG_MOTION_POS: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)]; + +/// The `legMotionPos += 0.67` of a faceplanted fox's `setupAnim`, which runs +/// once per fox per rendered frame: call it once per frame from render-state +/// extraction, not from the (possibly repeated) anim compute. +pub fn advance_fox_leg_motion_pos(is_baby: bool) -> f32 { + let cell = &LEG_MOTION_POS[is_baby as usize]; + let next = f32::from_bits(cell.load(Ordering::Relaxed)) + 0.67; + cell.store(next.to_bits(), Ordering::Relaxed); + next +} + +struct FoxParts { + head: usize, + body: usize, + tail: usize, + right_hind_leg: usize, + left_hind_leg: usize, + right_front_leg: usize, + left_front_leg: usize, +} + +/// Vanilla `FoxModel.setupAnim` with the `AdultFoxModel`/`BabyFoxModel` +/// overrides. +#[allow(clippy::too_many_arguments)] +pub fn compute_fox_anim( + model: &BakedEntityModel, + is_baby: bool, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + age_in_ticks: f32, + s: &FoxAnims, +) -> PartAnim { + let idx = |name| part_index(&model.parts, name); + let p = FoxParts { + head: idx("head"), + body: idx("body"), + tail: idx("tail"), + right_hind_leg: idx("right_hind_leg"), + left_hind_leg: idx("left_hind_leg"), + right_front_leg: idx("right_front_leg"), + left_front_leg: idx("left_front_leg"), + }; + let legs = [ + p.right_hind_leg, + p.left_hind_leg, + p.right_front_leg, + p.left_front_leg, + ]; + let age_scale = if is_baby { 0.5 } else { 1.0 }; + let mut m = ModelPose::new(model); + + // setWalkingPose + m.rot[p.head].z = s.head_roll_angle; + for leg in legs { + m.visible[leg] = true; + } + if is_baby { + let mut d = KfDeltas::new(model); + apply_kf_walk( + &FOX_BABY_WALK, + model, + walk_pos, + walk_speed, + 1.0, + 2.5, + &mut d, + ); + m.add_kf(&d); + } else { + m.swing_legs(legs, |phase| walk_swing(walk_pos, walk_speed, phase)); + } + + if s.is_crouching { + set_crouching_pose(&mut m, &p, is_baby, age_scale, age_in_ticks, s); + } else if s.is_sleeping { + for leg in legs { + m.visible[leg] = false; + } + set_sleeping_pose(&mut m, &p, is_baby); + } else if s.is_sitting { + m.rot[p.head].x = 0.0; + m.rot[p.head].y = 0.0; + set_sitting_pose(&mut m, &p, is_baby, age_scale); + } + // Only the adult overrides `setPouncingPose`. + if s.is_pouncing && !is_baby { + let crouch = s.crouch_amount / 2.0; + m.pos[p.body].y -= crouch; + m.pos[p.head].y -= crouch; + } + if !(s.is_sleeping || s.is_faceplanted || s.is_crouching) { + m.rot[p.head].x = head_x_rot_deg.to_radians(); + m.rot[p.head].y = local_head_y_rot_deg.to_radians(); + } + if s.is_sleeping { + m.rot[p.head].x = 0.0; + m.rot[p.head].y = -2.0943952; + m.rot[p.head].z = (age_in_ticks * 0.027).cos() / 22.0; + } + if s.is_faceplanted { + m.swing_legs(legs, |phase| { + (s.leg_motion_pos * 0.4662 + phase).cos() * 0.1 + }); + } + m.emit(model) +} + +fn set_crouching_pose( + m: &mut ModelPose, + p: &FoxParts, + is_baby: bool, + age_scale: f32, + age_in_ticks: f32, + s: &FoxAnims, +) { + m.rot[p.body].x += 0.10471976; + m.pos[p.head].y += s.crouch_amount * age_scale; + let wiggle = age_in_ticks.cos() * 0.05; + m.rot[p.body].y = wiggle; + m.rot[p.right_hind_leg].z = wiggle; + m.rot[p.left_hind_leg].z = wiggle; + m.rot[p.right_front_leg].z = wiggle / 2.0; + m.rot[p.left_front_leg].z = wiggle / 2.0; + m.pos[p.body].y += if is_baby { + s.crouch_amount / 6.0 + } else { + s.crouch_amount + }; +} + +fn set_sleeping_pose(m: &mut ModelPose, p: &FoxParts, is_baby: bool) { + if is_baby { + m.rot[p.body].z = -1.5707964; + m.rot[p.body].x = -0.17453292; + m.pos[p.body].y += 1.5; + m.pos[p.body].z -= 1.5; + m.pos[p.body].x -= 1.5; + // Vanilla assigns -2.6179938 first, then overwrites it. + m.rot[p.tail].x = -2.1816616; + m.pos[p.tail].x -= 0.7; + m.pos[p.tail].z += 0.6; + m.pos[p.tail].y += 0.9; + m.pos[p.head].x -= 2.0; + m.pos[p.head].y += 2.8; + m.pos[p.head].z -= 4.0; + } else { + m.rot[p.body].z = -1.5707964; + m.pos[p.body].y += 5.0; + m.rot[p.tail].x = -2.6179938; + m.pos[p.head].x += 2.0; + m.pos[p.head].y += 2.99; + } + m.rot[p.head].y = -2.0943952; + m.rot[p.head].z = 0.0; +} + +fn set_sitting_pose(m: &mut ModelPose, p: &FoxParts, is_baby: bool, age_scale: f32) { + if is_baby { + m.rot[p.body].x = -0.959931; + m.pos[p.body].z -= 4.5 * age_scale; + m.pos[p.body].y += 3.0 * age_scale; + m.pos[p.tail].y -= 0.6; + m.pos[p.tail].z -= 2.0 * age_scale; + m.rot[p.tail].x = 0.95993114; + m.pos[p.head].y -= 0.75; + m.rot[p.right_front_leg].x = -0.2617994; + m.rot[p.left_front_leg].x = -0.2617994; + m.pos[p.right_front_leg].z -= 1.5; + m.pos[p.left_front_leg].z -= 1.5; + m.pos[p.right_front_leg].x += 0.01; + m.pos[p.left_front_leg].x -= 0.01; + m.pos[p.right_hind_leg].z -= 3.75; + m.pos[p.left_hind_leg].z -= 3.75; + m.pos[p.right_hind_leg].x += 0.01; + m.pos[p.left_hind_leg].x -= 0.01; + } else { + m.rot[p.body].x = 0.5235988; + m.pos[p.body].y -= 7.0; + m.pos[p.body].z += 3.0; + m.rot[p.tail].x = 0.7853982; + m.pos[p.head].y -= 6.5; + m.pos[p.head].z += 2.75; + m.rot[p.right_front_leg].x = -0.2617994; + m.rot[p.left_front_leg].x = -0.2617994; + m.rot[p.right_hind_leg].x = -1.3089969; + m.pos[p.right_hind_leg].y += 4.0; + m.pos[p.right_hind_leg].z -= 0.25; + m.rot[p.left_hind_leg].x = -1.3089969; + m.pos[p.left_hind_leg].y += 4.0; + m.pos[p.left_hind_leg].z -= 0.25; + m.pos[p.tail].z -= 1.0; + } +} + +/// `FoxBabyAnimation.FOX_BABY_WALK` (0.5s, looping); all-zero channels +/// omitted. +static FOX_BABY_WALK: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 0.5, + looping: true, + channels: &[ + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, -1.025, 0.0, Lin)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -35.0, 0.0, 0.0, Cat), + kf(0.25, 35.0, 0.0, 0.0, Lin), + kf(0.5, -35.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.05, 0.6, -0.02, Lin), + kf(0.25, 0.05, 0.6, -0.02, Lin), + kf(0.5, 0.05, 0.6, -0.02, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.15, 0.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 35.0, 0.0, 0.0, Cat), + kf(0.25, -35.0, 0.0, 0.0, Lin), + kf(0.5, 35.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -0.05, 0.6, -0.02, Lin), + kf(0.25, -0.05, 0.6, -0.02, Lin), + kf(0.5, -0.05, 0.6, -0.02, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.15, 0.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 35.0, 0.0, 0.0, Cat), + kf(0.25, -35.0, 0.0, 0.0, Lin), + kf(0.5, 35.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.05, 0.6, -0.4, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.15, 0.0, Lin)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -35.0, 0.0, 0.0, Cat), + kf(0.25, 35.0, 0.0, 0.0, Lin), + kf(0.5, -35.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.05, 0.6, -0.4, Lin)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.15, 0.0, Lin)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[kf(0.0, -2.5, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "tail", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, -0.05, 0.0, Cat)], + }, + ], + } +}; + +#[cfg(test)] +mod tests { + use super::*; + + /// `setSleepingPose` hides the legs and rolls the body onto its side; + /// `setWalkingPose` shows them again on the next call. + #[test] + fn sleeping_hides_legs_and_rolls_the_body() { + let model = bake_fox_model(); + let sleeping = FoxAnims { + is_sleeping: true, + ..Default::default() + }; + let anim = compute_fox_anim(&model, false, 0.0, 0.0, 0.0, 0.0, 0.0, &sleeping); + let leg = part_index(&model.parts, "left_front_leg"); + assert!(anim.scale.contains(&(leg, Vec3::ZERO))); + assert_eq!( + anim.rotation_of(&model, "body"), + Vec3::new(1.5707964, 0.0, -1.5707964) + ); + assert_eq!( + anim.translation_of(&model, "body"), + Vec3::new(0.0, 5.0, 0.0) + ); + + let awake = compute_fox_anim(&model, false, 0.0, 0.0, 0.0, 0.0, 0.0, &FoxAnims::default()); + assert!(awake.scale.is_empty()); + } + + /// Sitting zeroes the head look, but the later look assignment wins + /// (sitting isn't one of its exclusions). + #[test] + fn sitting_keeps_the_head_look() { + let model = bake_baby_fox_model(); + let sitting = FoxAnims { + is_sitting: true, + ..Default::default() + }; + let anim = compute_fox_anim(&model, true, 30.0, 0.0, 0.0, 0.0, 0.0, &sitting); + assert_eq!(anim.rotation_of(&model, "head").x, 30.0f32.to_radians()); + } +} diff --git a/pomme-client/src/renderer/entity_model/panda.rs b/pomme-client/src/renderer/entity_model/panda.rs new file mode 100644 index 000000000..5ed675fe7 --- /dev/null +++ b/pomme-client/src/renderer/entity_model/panda.rs @@ -0,0 +1,349 @@ +//! Panda: `PandaModel`/`BabyPandaModel` meshes, `PandaModel.setupAnim` (on +//! top of `QuadrupedModel`'s) and `PandaRenderer.setupRotations`. + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use glam::{Mat4, Vec3}; + +use super::{ + BakedEntityModel, ModelPose, PartAnim, bake_model, lerp, part_index, rot_lerp_rad, rotated, + vbox, vpart, walk_swing, +}; + +/// Vanilla `PandaModel.createBodyLayer`, 64x64. The nose and ears are named +/// head cubes, not parts. +pub fn bake_panda_model() -> BakedEntityModel { + let leg = |name, x: f32, z: f32| { + vpart( + name, + None, + Vec3::new(x, 15.0, z), + vec![vbox((40, 0), (-3.0, 0.0, -3.0), (6.0, 9.0, 6.0))], + ) + }; + let parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, 11.5, -17.0), + vec![ + vbox((0, 6), (-6.5, -5.0, -4.0), (13.0, 10.0, 9.0)), + vbox((45, 16), (-3.5, 0.0, -6.0), (7.0, 5.0, 2.0)), + vbox((52, 25), (3.5, -8.0, -1.0), (5.0, 4.0, 1.0)), + vbox((52, 25), (-8.5, -8.0, -1.0), (5.0, 4.0, 1.0)), + ], + ), + rotated( + vpart( + "body", + None, + Vec3::new(0.0, 10.0, 0.0), + vec![vbox((0, 25), (-9.5, -13.0, -6.5), (19.0, 26.0, 13.0))], + ), + Vec3::new(1.5707964, 0.0, 0.0), + ), + leg("right_hind_leg", -5.5, 9.0), + leg("left_hind_leg", 5.5, 9.0), + leg("right_front_leg", -5.5, -9.0), + leg("left_front_leg", 5.5, -9.0), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyPandaModel.createBodyLayer`, 64x64. +pub fn bake_baby_panda_model() -> BakedEntityModel { + let leg = |name, tex, x: f32, z: f32| { + vpart( + name, + None, + Vec3::new(x, 22.0, z), + vec![vbox(tex, (-1.5, 0.0, -1.5), (3.0, 2.0, 3.0))], + ) + }; + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 18.5, 2.5), + vec![vbox((0, 11), (-4.5, -3.5, -5.5), (9.0, 7.0, 11.0))], + ), + vpart( + "head", + None, + Vec3::new(0.0, 19.0, -3.0), + vec![ + vbox((0, 0), (-3.5, -3.0, -5.0), (7.0, 6.0, 5.0)), + vbox((24, 6), (-2.0, 1.0, -6.0), (4.0, 2.0, 1.0)), + vbox((24, 0), (-4.5, -4.0, -3.5), (3.0, 3.0, 1.0)), + vbox((33, 0), (1.5, -4.0, -3.5), (3.0, 3.0, 1.0)), + ], + ), + leg("right_hind_leg", (0, 34), -3.0, 6.5), + leg("left_hind_leg", (12, 34), 3.0, 6.5), + leg("right_front_leg", (0, 29), -3.0, -1.5), + leg("left_front_leg", (12, 29), 3.0, -1.5), + ]; + bake_model(parts, 64, 64) +} + +/// The `PandaRenderState` fields `PandaModel.setupAnim` and +/// `PandaRenderer.setupRotations` read. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct PandaAnims { + pub is_unhappy: bool, + pub is_sneezing: bool, + pub sneeze_time: i32, + pub is_eating: bool, + pub is_scared: bool, + pub sit_amount: f32, + pub lie_on_back_amount: f32, + /// Always 0 for babies (`extractRenderState`). + pub roll_amount: f32, + /// `rollCounter + partialTicks` while rolling, else 0. + pub roll_time: f32, +} + +/// Vanilla `PandaModel.setupAnim` (`QuadrupedModel.setupAnim` first), with +/// `BabyPandaModel.animateSitting` for the baby. +#[allow(clippy::too_many_arguments)] +pub fn compute_panda_anim( + model: &BakedEntityModel, + is_baby: bool, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + age_in_ticks: f32, + s: &PandaAnims, +) -> PartAnim { + use std::f32::consts::FRAC_PI_2; + let idx = |name| part_index(&model.parts, name); + let (head, body) = (idx("head"), idx("body")); + let (rh, lh, rf, lf) = ( + idx("right_hind_leg"), + idx("left_hind_leg"), + idx("right_front_leg"), + idx("left_front_leg"), + ); + let age = age_in_ticks; + let mut m = ModelPose::new(model); + + // QuadrupedModel.setupAnim + m.rot[head].x = head_x_rot_deg.to_radians(); + m.rot[head].y = local_head_y_rot_deg.to_radians(); + m.swing_legs([rh, lh, rf, lf], |phase| { + walk_swing(walk_pos, walk_speed, phase) + }); + + if s.is_unhappy { + m.rot[head].y = 0.35 * (0.6 * age).sin(); + m.rot[head].z = 0.35 * (0.6 * age).sin(); + m.rot[rf].x = -0.75 * (0.3 * age).sin(); + m.rot[lf].x = 0.75 * (0.3 * age).sin(); + } else { + m.rot[head].z = 0.0; + } + if s.is_sneezing { + if s.sneeze_time < 15 { + m.rot[head].x = -0.7853982 * s.sneeze_time as f32 / 14.0; + } else if s.sneeze_time < 20 { + // Integer division in vanilla: 0 for the whole 15..19 span. + let internal_sneeze_pos = ((s.sneeze_time - 15) / 5) as f32; + m.rot[head].x = -0.7853982 + 0.7853982 * internal_sneeze_pos; + } + } + if s.sit_amount > 0.0 { + let sit = s.sit_amount; + if is_baby { + m.rot[body].x = rot_lerp_rad(sit, m.rot[body].x, 0.17453292); + m.pos[body].z = lerp(sit, m.pos[body].z, -1.5); + m.pos[head].z = lerp(sit, m.pos[head].z, -11.5); + m.pos[head].y = lerp(sit, m.pos[head].y, 17.5); + m.pos[rf].z = lerp(sit, m.pos[rf].z, -5.0); + m.pos[lf].z = lerp(sit, m.pos[lf].z, -5.0); + m.pos[rh].z = lerp(sit, m.pos[rh].z, 3.0); + m.pos[lh].z = lerp(sit, m.pos[lh].z, 3.0); + } else { + m.rot[body].x = rot_lerp_rad(sit, m.rot[body].x, 1.7407963); + m.rot[head].x = rot_lerp_rad(sit, m.rot[head].x, FRAC_PI_2); + } + m.rot[rf].z = -0.27079642; + m.rot[lf].z = 0.27079642; + m.rot[rh].z = 0.5707964; + m.rot[lh].z = -0.5707964; + if s.is_eating { + m.rot[head].x = FRAC_PI_2 + 0.2 * (age * 0.6).sin(); + m.rot[rf].x = -0.4 - 0.2 * (age * 0.6).sin(); + m.rot[lf].x = -0.4 - 0.2 * (age * 0.6).sin(); + } + if s.is_scared { + m.rot[head].x = 2.1707964; + m.rot[rf].x = -0.9; + m.rot[lf].x = -0.9; + } + } else { + m.rot[rh].z = 0.0; + m.rot[lh].z = 0.0; + m.rot[rf].z = 0.0; + m.rot[lf].z = 0.0; + } + if s.lie_on_back_amount > 0.0 { + m.rot[rh].x = -0.6 * (age * 0.15).sin(); + m.rot[lh].x = 0.6 * (age * 0.15).sin(); + m.rot[rf].x = 0.3 * (age * 0.25).sin(); + m.rot[lf].x = -0.3 * (age * 0.25).sin(); + m.rot[head].x = rot_lerp_rad(s.lie_on_back_amount, m.rot[head].x, FRAC_PI_2); + } + if s.roll_amount > 0.0 { + m.rot[head].x = rot_lerp_rad(s.roll_amount, m.rot[head].x, 2.0561945); + m.rot[rh].x = -0.5 * (age * 0.5).sin(); + m.rot[lh].x = 0.5 * (age * 0.5).sin(); + m.rot[rf].x = 0.5 * (age * 0.5).sin(); + m.rot[lf].x = -0.5 * (age * 0.5).sin(); + } + m.emit(model) +} + +/// `PandaRenderer.getAngle`. +fn roll_angle(this: f32, next: f32, next_roll_pos: i32, transition: f32, threshold: f32) -> f32 { + if (next_roll_pos as f32) < threshold { + lerp(transition, this, next) + } else { + this + } +} + +/// The `PandaRenderer.setupRotations` additions after the living-entity +/// ones: the forward roll, the sit-up pitch (and scared shake) and the +/// lie-on-back flip. `x_rot_deg` is the render state's `xRot`. `None` when +/// none applies. +pub fn panda_body_transform( + s: &PandaAnims, + is_baby: bool, + x_rot_deg: f32, + age_in_ticks: f32, +) -> Option { + let translate = |y: f32, z: f32| Mat4::from_translation(Vec3::new(0.0, y, z)); + let rot_x = |deg: f32| Mat4::from_rotation_x(deg.to_radians()); + let mut m = Mat4::IDENTITY; + let mut any = false; + if s.roll_time > 0.0 { + let transition = s.roll_time - s.roll_time.floor(); + let roll_pos = s.roll_time.floor() as i32; + let next_roll_pos = roll_pos + 1; + let y = if is_baby { 0.3 } else { 0.8 }; + let pos = roll_pos as f32; + let next = next_roll_pos as f32; + let step = if pos < 8.0 { + let angle = roll_angle( + 90.0 * pos / 7.0, + 90.0 * next / 7.0, + next_roll_pos, + transition, + 8.0, + ); + Some(((y + 0.2) * (angle / 90.0), angle)) + } else if pos < 16.0 { + let internal = (pos - 8.0) / 7.0; + let angle = roll_angle( + 90.0 + 90.0 * internal, + 90.0 + 90.0 * (next - 8.0) / 7.0, + next_roll_pos, + transition, + 16.0, + ); + Some((y + 0.2 + (y - 0.2) * (angle - 90.0) / 90.0, angle)) + } else if pos < 24.0 { + let internal = (pos - 16.0) / 7.0; + let angle = roll_angle( + 180.0 + 90.0 * internal, + 180.0 + 90.0 * (next - 16.0) / 7.0, + next_roll_pos, + transition, + 24.0, + ); + Some((y + y * (270.0 - angle) / 90.0, angle)) + } else if roll_pos < 32 { + let internal = (pos - 24.0) / 7.0; + let angle = roll_angle( + 270.0 + 90.0 * internal, + 270.0 + 90.0 * (next - 24.0) / 7.0, + next_roll_pos, + transition, + 32.0, + ); + Some((y * ((360.0 - angle) / 90.0), angle)) + } else { + None + }; + if let Some((ty, angle)) = step { + m = m * translate(ty, 0.0) * rot_x(-angle); + any = true; + } + } + let sit = s.sit_amount; + if sit > 0.0 { + m = m + * translate(0.8 * sit, 0.0) + * rot_x(lerp(sit, x_rot_deg, x_rot_deg + 90.0)) + * translate(-sit, 0.0); + if s.is_scared { + let shake_rot = (f64::from(age_in_ticks * 1.25).cos() + * std::f64::consts::PI + * f64::from(0.05f32)) as f32; + m *= Mat4::from_rotation_y(shake_rot.to_radians()); + if is_baby { + m *= translate(0.8, 0.55); + } + } + any = true; + } + let lie = s.lie_on_back_amount; + if lie > 0.0 { + let y = if is_baby { 0.5 } else { 1.3 }; + m = m * translate(y * lie, 0.0) * rot_x(lerp(lie, x_rot_deg, x_rot_deg + 180.0)); + any = true; + } + any.then_some(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sneeze ramps the head down over 14 ticks, then (vanilla's integer + /// division) holds it fully down until the flag clears. + #[test] + fn sneeze_pitches_the_head() { + let model = bake_panda_model(); + let pitch = |t| { + let s = PandaAnims { + is_sneezing: true, + sneeze_time: t, + ..Default::default() + }; + let anim = compute_panda_anim(&model, false, 0.0, 0.0, 0.0, 0.0, 0.0, &s); + anim.rotation_of(&model, "head").x + }; + assert_eq!(pitch(7), -0.7853982 * 7.0 / 14.0); + assert_eq!(pitch(18), -0.7853982); + assert_eq!(pitch(20), 0.0); + } + + /// Full sit: body pitched to 1.7407963, both rest rotations replaced. + #[test] + fn sitting_pitches_the_body_and_spreads_the_legs() { + let model = bake_panda_model(); + let s = PandaAnims { + sit_amount: 1.0, + ..Default::default() + }; + let anim = compute_panda_anim(&model, false, 0.0, 0.0, 0.0, 0.0, 0.0, &s); + let rot = |name| anim.rotation_of(&model, name); + assert!((rot("body").x - 1.7407963).abs() < 1e-6); + assert_eq!(rot("right_hind_leg").z, 0.5707964); + assert!(panda_body_transform(&s, false, 0.0, 0.0).is_some()); + assert!(panda_body_transform(&PandaAnims::default(), false, 0.0, 0.0).is_none()); + } +} diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index b38381175..c58ddeb4b 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -198,6 +198,8 @@ pub struct EntityRenderInfo { /// Llama carpet or trader decor. pub llama_decor: Option, pub bee: entity_model::BeeAnims, + pub fox: entity_model::FoxAnims, + pub panda: entity_model::PandaAnims, pub parrot: entity_model::ParrotAnims, /// `DolphinRenderState.isMoving`. pub dolphin_moving: bool, @@ -284,6 +286,8 @@ impl Default for EntityRenderInfo { copper_golem: entity_model::CopperGolemAnims::default(), llama_decor: None, bee: entity_model::BeeAnims::default(), + fox: entity_model::FoxAnims::default(), + panda: entity_model::PandaAnims::default(), parrot: entity_model::ParrotAnims::default(), dolphin_moving: false, turtle: entity_model::TurtleAnims::default(), @@ -964,6 +968,8 @@ enum AnimationType { Allay, SnowGolem, CopperGolem, + Fox, + Panda, Bee, Parrot, Dolphin, @@ -1169,6 +1175,18 @@ fn mob_definitions() -> Vec { const SULFUR_CUBE_INNER_SMALL_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner_small"); const DOLPHIN_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin"); + // Indexed by `variant * 2 + sleeping`. + const FOX_TEX: &[&[&str]] = + tex_table!("fox" => "fox", "fox_sleep", "fox_snow", "fox_snow_sleep"); + const FOX_BABY_TEX: &[&[&str]] = tex_table!("fox" => + "fox_baby", "fox_sleep_baby", "fox_snow_baby", "fox_snow_sleep_baby"); + // Indexed by `Panda.Gene` id. + const PANDA_TEX: &[&[&str]] = tex_table!("panda" => + "panda", "panda_lazy", "panda_worried", "panda_playful", "panda_brown", + "panda_weak", "panda_aggressive"); + const PANDA_BABY_TEX: &[&[&str]] = tex_table!("panda" => + "panda_baby", "lazy_panda_baby", "worried_panda_baby", "playful_panda_baby", + "brown_panda_baby", "weak_panda_baby", "aggressive_panda_baby"); // Indexed by `angry * 2 + nectar`. const BEE_TEX: &[&[&str]] = tex_table!("bee" => "bee", "bee_nectar", "bee_angry", "bee_angry_nectar"); @@ -1331,6 +1349,30 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Fox, + anim: AnimationType::Fox, + adult: vec![opaque(entity_model::bake_fox_model(), FOX_TEX, 48)], + baby: Some(opaque( + entity_model::bake_baby_fox_model(), + FOX_BABY_TEX, + 32, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Panda, + anim: AnimationType::Panda, + adult: vec![opaque(entity_model::bake_panda_model(), PANDA_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_panda_model(), + PANDA_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, // TODO: `BeeStingerLayer` (stingers stuck in players, `BeeStingerModel`). MobDef { kind: EntityKind::Bee, @@ -2586,6 +2628,26 @@ impl EntityRenderer { info.bat_elapsed_secs, info.bat_resting, ), + AnimationType::Fox => entity_model::compute_fox_anim( + model, + info.is_baby, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.age_in_ticks, + &info.fox, + ), + AnimationType::Panda => entity_model::compute_panda_anim( + model, + info.is_baby, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.age_in_ticks, + &info.panda, + ), AnimationType::Bee => { entity_model::compute_bee_anim(model, info.age_in_ticks, &info.bee) } @@ -3314,6 +3376,8 @@ pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { match kind { EntityKind::Villager if is_baby => 0.63, // Explicit `BABY_DIMENSIONS` eye heights. + EntityKind::Fox if is_baby => 0.34375, + EntityKind::Panda if is_baby => 0.28125, EntityKind::Dolphin if is_baby => 0.09375, EntityKind::Mooshroom if is_baby => 0.69, // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. diff --git a/tests/ingame/entities/fox.toml b/tests/ingame/entities/fox.toml new file mode 100644 index 000000000..dc4498634 --- /dev/null +++ b/tests/ingame/entities/fox.toml @@ -0,0 +1,77 @@ +# Foxes, three-quarter view from above: red and snow adults, then sleeping +# (legs hidden, sleep texture), sitting and crouching. Front row: babies on +# their own 32x32 mesh, standing, sleeping (snow) and sitting. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:fox -2.5 -60 6.5 {NoAI:1b,Rotation:[160f,0f]}" + +[[step]] +expect = { entity = "fox", baby = false } + +[[step]] +chat = "/summon minecraft:fox -1 -60 6.5 {NoAI:1b,Type:\"snow\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:fox 0.5 -60 6.5 {NoAI:1b,Sleeping:1b,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:fox 2 -60 6.5 {NoAI:1b,Type:\"snow\",Sitting:1b,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:fox 3.5 -60 6.5 {NoAI:1b,Crouching:1b,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:fox -1.5 -60 4.5 {NoAI:1b,Age:-24000,Rotation:[160f,0f]}" + +[[step]] +expect = { entity = "fox", baby = true } + +[[step]] +chat = "/summon minecraft:fox 0.5 -60 4.5 {NoAI:1b,Age:-24000,Type:\"snow\",Sleeping:1b,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:fox 2.5 -60 4.5 {NoAI:1b,Age:-24000,Sitting:1b,Rotation:[160f,0f]}" + +[[step]] +look_at = [0.5, -59.5, 5.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "foxes" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/panda.toml b/tests/ingame/entities/panda.toml new file mode 100644 index 000000000..5af48e121 --- /dev/null +++ b/tests/ingame/entities/panda.toml @@ -0,0 +1,100 @@ +# Pandas, three-quarter view from above: back row the seven adult genes in id +# order (normal, lazy, worried, playful, brown, weak, aggressive; recessive +# brown/weak need a matching hidden gene, a lone one shows normal), front row +# their babies on the separate 64x64 baby mesh, plus a brown main gene with a +# normal hidden gene that renders normal. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -56 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -55 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -55.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:panda -7 -60 10 {NoAI:1b,MainGene:\"normal\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +expect = { entity = "panda", baby = false } + +[[step]] +chat = "/summon minecraft:panda -4.5 -60 10 {NoAI:1b,MainGene:\"lazy\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda -2 -60 10 {NoAI:1b,MainGene:\"worried\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 0.5 -60 10 {NoAI:1b,MainGene:\"playful\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 3 -60 10 {NoAI:1b,MainGene:\"brown\",HiddenGene:\"brown\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 5.5 -60 10 {NoAI:1b,MainGene:\"weak\",HiddenGene:\"weak\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 8 -60 10 {NoAI:1b,MainGene:\"aggressive\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda -6 -60 6 {NoAI:1b,Age:-24000,MainGene:\"normal\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +expect = { entity = "panda", baby = true } + +[[step]] +chat = "/summon minecraft:panda -4 -60 6 {NoAI:1b,Age:-24000,MainGene:\"lazy\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda -2 -60 6 {NoAI:1b,Age:-24000,MainGene:\"worried\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 0 -60 6 {NoAI:1b,Age:-24000,MainGene:\"playful\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 2 -60 6 {NoAI:1b,Age:-24000,MainGene:\"brown\",HiddenGene:\"brown\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 4 -60 6 {NoAI:1b,Age:-24000,MainGene:\"weak\",HiddenGene:\"weak\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 6 -60 6 {NoAI:1b,Age:-24000,MainGene:\"aggressive\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:panda 8 -60 6 {NoAI:1b,Age:-24000,MainGene:\"brown\",HiddenGene:\"normal\",Rotation:[160f,0f]}" + +[[step]] +look_at = [0.5, -59.5, 8.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "pandas" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -56 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From b88a0b98fae582002eccb26903b0c2ed88015a43 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:52:42 +0000 Subject: [PATCH 06/10] add axolotl and polar bear --- pomme-client/src/app/phases/in_game.rs | 42 + pomme-client/src/entity/mod.rs | 216 ++- pomme-client/src/renderer/entity_model.rs | 4 + .../src/renderer/entity_model/axolotl.rs | 1301 +++++++++++++++++ .../src/renderer/entity_model/polar_bear.rs | 149 ++ .../src/renderer/pipelines/entity_renderer.rs | 70 +- tests/ingame/entities/axolotl.toml | 124 ++ tests/ingame/entities/polar_bear.toml | 63 + 8 files changed, 1964 insertions(+), 5 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/axolotl.rs create mode 100644 pomme-client/src/renderer/entity_model/polar_bear.rs create mode 100644 tests/ingame/entities/axolotl.toml create mode 100644 tests/ingame/entities/polar_bear.toml diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 94adabe95..39bc5d075 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4122,6 +4122,7 @@ pub fn update_game( llama_decor: extras.llama_decor, bee: extras.bee, fox: extras.fox, + axolotl: extras.axolotl, panda: extras.panda, parrot: extras.parrot, dolphin_moving: extras.dolphin_moving, @@ -5012,6 +5013,7 @@ struct EntityExtras { llama_decor: Option, bee: crate::renderer::entity_model::BeeAnims, fox: crate::renderer::entity_model::FoxAnims, + axolotl: crate::renderer::entity_model::AxolotlAnims, panda: crate::renderer::entity_model::PandaAnims, parrot: crate::renderer::entity_model::ParrotAnims, dolphin_moving: bool, @@ -5171,6 +5173,16 @@ fn entity_extras( }, EntityKind::Bee => bee_extras(e, alpha, game_time), EntityKind::Fox => fox_extras(e, alpha), + EntityKind::Axolotl => EntityExtras { + variant_index: e.variant, + axolotl: axolotl_anims(e, alpha), + ..Default::default() + }, + // `PolarBear.getStandingAnimationScale`. + EntityKind::PolarBear => EntityExtras { + stand_anim: lerp(alpha, e.prev_stand_anim, e.stand_anim) / 6.0, + ..Default::default() + }, EntityKind::Panda => panda_extras(e, alpha), EntityKind::Parrot => parrot_extras(e, alpha), EntityKind::Sniffer => EntityExtras { @@ -5339,6 +5351,36 @@ fn parrot_extras(e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { } } +/// Vanilla `AxolotlRenderer.extractRenderState`. +fn axolotl_anims( + e: &crate::entity::LivingEntity, + alpha: f32, +) -> crate::renderer::entity_model::AxolotlAnims { + use crate::entity::BabyAxolotlAnim as A; + let sim = &e.axolotl; + let mut anims = crate::renderer::entity_model::AxolotlAnims { + playing_dead_factor: sim.playing_dead_animator.factor(alpha), + in_water_factor: sim.in_water_animator.factor(alpha), + on_ground_factor: sim.on_ground_animator.factor(alpha), + moving_factor: sim.moving_animator.factor(alpha), + ..Default::default() + }; + if let Some((anim, start)) = sim.baby_anim { + let clock = Some(anim_clock_secs(e, start, alpha)); + match anim { + A::Swim => anims.swim = clock, + A::Walk => anims.walk = clock, + A::IdleUnderWater => anims.idle_under_water = clock, + A::IdleUnderWaterOnGround => anims.idle_under_water_on_ground = clock, + A::IdleOnGround => anims.idle_on_ground = clock, + A::PlayDead => anims.play_dead = clock, + // `BabyAxolotlModel` never reads this state. + A::WalkUnderWater => {} + } + } + anims +} + /// Vanilla `SnifferRenderer.extractRenderState`. fn sniffer_anims( e: &crate::entity::LivingEntity, diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 44d4662c6..05ab8136c 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -174,6 +174,100 @@ impl CopperGolemAnim { } } +/// Vanilla `BinaryAnimator(10, EasingType.IN_OUT_SINE)`: a tick ramp +/// toward 10 while active, back to 0 while not, eased on read. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BinaryAnimator { + ticks: u8, + ticks_old: u8, +} + +impl BinaryAnimator { + const LENGTH: u8 = 10; + + fn tick(&mut self, active: bool) { + self.ticks_old = self.ticks; + if active { + self.ticks = (self.ticks + 1).min(Self::LENGTH); + } else { + self.ticks = self.ticks.saturating_sub(1); + } + } + + /// `getFactor(partialTicks)`, `Ease.inOutSine` applied. + pub fn factor(&self, partial: f32) -> f32 { + let old = f32::from(self.ticks_old); + let x = (old + partial * (f32::from(self.ticks) - old)) / f32::from(Self::LENGTH); + -((std::f32::consts::PI * x).cos() - 1.0) / 2.0 + } +} + +/// The baby axolotl's `AnimationState`s (vanilla `Axolotl.ALL_ANIMATIONS`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BabyAxolotlAnim { + Swim, + Walk, + WalkUnderWater, + IdleUnderWater, + IdleUnderWaterOnGround, + IdleOnGround, + PlayDead, +} + +/// Client-side axolotl animation state (vanilla `Axolotl.baseTick`'s +/// `tickAdultAnimations` / `tickBabyAnimations`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AxolotlSim { + pub playing_dead: bool, + pub playing_dead_animator: BinaryAnimator, + pub in_water_animator: BinaryAnimator, + pub on_ground_animator: BinaryAnimator, + pub moving_animator: BinaryAnimator, + /// The baby's one running animation (`soloAnimation`) and its start tick. + pub baby_anim: Option<(BabyAxolotlAnim, u32)>, +} + +impl AxolotlSim { + /// `is_moving` is `walkAnimation.isMoving() || xRot != xRotO || yRot != + /// yRotO`; `tick` is the post-increment `tickCount`. + fn tick(&mut self, is_baby: bool, in_water: bool, on_ground: bool, is_moving: bool, tick: u32) { + if is_baby { + self.moving_animator.tick(is_moving); + use BabyAxolotlAnim as A; + let anim = if self.playing_dead { + A::PlayDead + } else if is_moving { + if in_water && !on_ground { + A::Swim + } else if !in_water && on_ground { + A::Walk + } else { + A::WalkUnderWater + } + } else if in_water && !on_ground { + A::IdleUnderWater + } else if in_water && on_ground { + A::IdleUnderWaterOnGround + } else { + A::IdleOnGround + }; + // `startIfStopped`: a running animation keeps its clock. + if self.baby_anim.is_none_or(|(running, _)| running != anim) { + self.baby_anim = Some((anim, tick)); + } + } else { + // `AxolotlAnimationState`: playing dead, then in water, then on + // the ground, else in the air. + let dead = self.playing_dead; + self.playing_dead_animator.tick(dead); + self.in_water_animator.tick(!dead && in_water); + self.on_ground_animator + .tick(!dead && !in_water && on_ground); + self.moving_animator.tick(is_moving); + } + } +} + /// Vanilla `Fox` client state: the synced flags byte and the crouch spring /// (`Fox.tick`). The interested head tilt reuses the wolf's spring. #[derive(Clone, Debug, Default, PartialEq)] @@ -397,6 +491,8 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::Parrot | EntityKind::Dolphin | EntityKind::Turtle + | EntityKind::Axolotl + | EntityKind::PolarBear ) || is_equine(&kind) || is_llama(kind) } @@ -567,12 +663,15 @@ pub struct LivingEntity { pub prev_relax_state_one_amount: f32, /// Tick the rabbit hop keyframe clock started at, while hopping. pub hop_anim_start: Option, - /// Equine flag-byte state (grass eating, rearing, open mouth). + /// Equine flag-byte state (grass eating, rearing, open mouth); + /// `is_standing` is also the polar bear's `DATA_STANDING_ID`. pub is_eating: bool, pub is_standing: bool, pub is_open_mouth: bool, pub eat_anim: f32, pub prev_eat_anim: f32, + /// Equine rear spring (0..1); the polar bear's + /// `clientSideStandAnimation` (0..6). pub stand_anim: f32, pub prev_stand_anim: f32, pub mouth_anim: f32, @@ -613,6 +712,7 @@ pub struct LivingEntity { pub sulfur_fuse: i32, pub fox: FoxSim, pub panda: PandaSim, + pub axolotl: AxolotlSim, /// `Turtle.HAS_EGG` / `LAYING_EGG`. pub has_egg: bool, pub laying_egg: bool, @@ -686,6 +786,7 @@ pub(crate) fn living_entity_dimensions(entity: &LivingEntity) -> EntityDimension if entity.is_baby { (dims.width, dims.height) = baby_dimensions(entity.entity_type, dims.width, dims.height); } + dims.height *= entity.stand_height_scale(); dims } @@ -846,6 +947,7 @@ impl LivingEntity { sulfur_fuse: -1, fox: FoxSim::default(), panda: PandaSim::default(), + axolotl: AxolotlSim::default(), has_egg: false, laying_egg: false, puff_state: 0, @@ -985,7 +1087,7 @@ impl LivingEntity { self.is_baby, ); self.prev_position.lerp(self.position, f64::from(partial)) - + DVec3::new(0.0, f64::from(eye), 0.0) + + DVec3::new(0.0, f64::from(eye * self.stand_height_scale()), 0.0) } /// The wolf / fox interested tilt between ticks, before its @@ -998,6 +1100,17 @@ impl LivingEntity { ) } + /// `PolarBear.getDefaultDimensions`' height stretch while standing. Its + /// `tick` refreshes the dimensions before stepping the stand counter, so + /// they hold the previous value. + fn stand_height_scale(&self) -> f32 { + if self.entity_type == EntityKind::PolarBear { + 1.0 + self.prev_stand_anim / 6.0 + } else { + 1.0 + } + } + /// Vanilla `WalkAnimationState.position(partialTick)` (babies run the /// cycle 3x). pub fn walk_pos(&self, partial: f32) -> f32 { @@ -1271,6 +1384,22 @@ impl LivingEntity { // particle type yet). EntityKind::Bee => self.bee.tick(), EntityKind::Fox => self.fox.tick(), + // `Axolotl.baseTick` runs before the interpolation step, where a + // remote entity's rotation still equals the `setOldPosAndRot` + // copy, so only the walk speed (its pre-update value) can move it. + EntityKind::Axolotl => self.axolotl.tick( + self.is_baby, + self.is_in_water, + self.on_ground, + self.prev_walk_anim_speed > 1.0e-5, + self.age_in_ticks + 1, + ), + // `PolarBear.tick`: `clientSideStandAnimation`, 0..6. + EntityKind::PolarBear => { + self.prev_stand_anim = self.stand_anim; + let step = if self.is_standing { 1.0 } else { -1.0 }; + self.stand_anim = (self.stand_anim + step).clamp(0.0, 6.0); + } EntityKind::Slime => self.tick_squish(), EntityKind::SulfurCube => { if self.sulfur_fuse > 0 { @@ -1855,6 +1984,12 @@ impl EntityStore { } (EntityKind::Turtle, 18, Bool(b)) => entity.has_egg = b, (EntityKind::Turtle, 19, Bool(b)) => entity.laying_egg = b, + // `Axolotl.Variant.byId`: out-of-range ids fall back to LUCY. + (EntityKind::Axolotl, 18, Int(v)) => { + entity.variant = if (0..=4).contains(&v) { v as u32 } else { 0 } + } + (EntityKind::Axolotl, 19, Bool(b)) => entity.axolotl.playing_dead = b, + (EntityKind::PolarBear, 18, Bool(b)) => entity.is_standing = b, // Salmon size ids clamp to SMALL..LARGE; a pufferfish puff state // outside 0/1 renders big (vanilla `switch` default). // `Sniffer.onSyncedDataUpdated`: every update stops all five @@ -2089,6 +2224,9 @@ impl EntityStore { &mut entity.prev_walk_anim_speed, ); } + // TODO: vanilla's `baseTick` reads the water state before the + // interpolation step; this probes after it, so in-water mobs + // (axolotl, fish) can lag a tick at a water edge. if probes_water(&entity.entity_type) { entity.is_in_water = entity.probe_water(chunks, block_state_table); } @@ -2245,6 +2383,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::Mooshroom | EntityKind::Dolphin | EntityKind::Turtle + | EntityKind::Axolotl + | EntityKind::PolarBear ) } @@ -2261,6 +2401,7 @@ fn probes_water(kind: &EntityKind) -> bool { | EntityKind::Pufferfish | EntityKind::Panda | EntityKind::Turtle + | EntityKind::Axolotl ) } @@ -2517,6 +2658,77 @@ mod tests { assert!(store.living[&2].is_baby); } + /// `Axolotl.DATA_VARIANT` (18) and `DATA_PLAYING_DEAD` (19): the adult + /// ramps its eased playing-dead animator, the baby solos one + /// `AnimationState` that keeps its clock while it stays selected. + #[test] + fn axolotl_synced_data_drives_its_animations() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Axolotl, Position::default()); + store.apply_entity_data(1, 18, MetaValue::Int(4)); + assert_eq!(store.living[&1].variant, 4); + store.apply_entity_data(1, 18, MetaValue::Int(5)); + assert_eq!(store.living[&1].variant, 0); + + store.apply_entity_data(1, 19, MetaValue::Bool(true)); + for _ in 0..5 { + tick(&mut store); + } + let sim = store.living[&1].axolotl; + assert_eq!(sim.playing_dead_animator.factor(1.0), 0.5); + assert_eq!(sim.in_water_animator.factor(1.0), 0.0); + assert_eq!(sim.baby_anim, None); + for _ in 0..10 { + tick(&mut store); + } + assert_eq!( + store.living[&1].axolotl.playing_dead_animator.factor(0.0), + 1.0 + ); + + store.apply_entity_data(1, 16, MetaValue::Bool(true)); + let start = store.living[&1].age_in_ticks + 1; + tick(&mut store); + tick(&mut store); + assert_eq!( + store.living[&1].axolotl.baby_anim, + Some((BabyAxolotlAnim::PlayDead, start)) + ); + store.apply_entity_data(1, 19, MetaValue::Bool(false)); + let start = store.living[&1].age_in_ticks + 1; + tick(&mut store); + assert_eq!( + store.living[&1].axolotl.baby_anim, + Some((BabyAxolotlAnim::IdleOnGround, start)) + ); + } + + /// `PolarBear.DATA_STANDING_ID` (18) steps the 0..6 stand counter; the + /// hitbox height trails it by a tick. + #[test] + fn polar_bear_standing_stretches_its_height() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::PolarBear, Position::default()); + store.apply_entity_data(1, 18, MetaValue::Bool(true)); + for _ in 0..3 { + tick(&mut store); + } + let e = &store.living[&1]; + assert_eq!((e.prev_stand_anim, e.stand_anim), (2.0, 3.0)); + assert_eq!(living_entity_dimensions(e).height, 1.4 * (1.0 + 2.0 / 6.0)); + for _ in 0..10 { + tick(&mut store); + } + assert_eq!(store.living[&1].stand_anim, 6.0); + store.apply_entity_data(1, 18, MetaValue::Bool(false)); + tick(&mut store); + assert_eq!(store.living[&1].stand_anim, 5.0); + assert_eq!( + living_entity_dimensions(&store.living[&1]).height, + 1.4 * 2.0 + ); + } + fn tick(store: &mut EntityStore) { store.tick_living( &ChunkStore::new(2), diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 67bea86ed..f531f61aa 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -3,6 +3,7 @@ use glam::{Mat4, Vec3}; use crate::renderer::chunk::mesher::pack_uv; use crate::renderer::rhi; +mod axolotl; mod bee; mod copper_golem_anim; mod dolphin; @@ -10,14 +11,17 @@ mod fox; mod llama; mod panda; mod parrot; +mod polar_bear; mod sniffer_anim; mod turtle; +pub use axolotl::*; pub use bee::*; pub use dolphin::*; pub use fox::*; pub use llama::*; pub use panda::*; pub use parrot::*; +pub use polar_bear::*; pub use turtle::*; /// An entity or block-entity model vertex: its position, UV and face normal. diff --git a/pomme-client/src/renderer/entity_model/axolotl.rs b/pomme-client/src/renderer/entity_model/axolotl.rs new file mode 100644 index 000000000..22d6b8407 --- /dev/null +++ b/pomme-client/src/renderer/entity_model/axolotl.rs @@ -0,0 +1,1301 @@ +//! Axolotl: `AdultAxolotlModel` (procedural, factor-blended poses), +//! `BabyAxolotlModel` (keyframed) and the `BabyAxolotlAnimation` tables, +//! converted mechanically from the decompiled definitions (`scaleVec` values +//! stored as `v - 1`). The unused `baby_axolotl_dash` is omitted. + +use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + +use glam::Vec3; + +use super::{ + BakedEntityModel, KfAnim, KfChannel, KfDeltas, KfTarget, Kfi, ModelCube, ModelPose, PartAnim, + apply_kf_anim, apply_kf_walk, bake_model, deformed, kf, part_index, rotated, vbox, vpart, +}; + +/// Vanilla `AdultAxolotlModel`'s `CubeDeformation(0.001f)`. +fn fudged(cube: ModelCube) -> ModelCube { + deformed(cube, 0.001) +} + +/// Vanilla `AdultAxolotlModel.createBodyLayer`, 64x64. +pub fn bake_axolotl_model() -> BakedEntityModel { + let left_leg = fudged(vbox((2, 13), (-1.0, 0.0, 0.0), (3.0, 5.0, 0.0))); + let right_leg = fudged(vbox((2, 13), (-2.0, 0.0, 0.0), (3.0, 5.0, 0.0))); + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 19.5, 5.0), + vec![ + vbox((0, 11), (-4.0, -2.0, -9.0), (8.0, 4.0, 10.0)), + vbox((2, 17), (0.0, -3.0, -8.0), (0.0, 5.0, 9.0)), + ], + ), + vpart( + "head", + Some(0), + Vec3::new(0.0, 0.0, -9.0), + vec![fudged(vbox((0, 1), (-4.0, -3.0, -5.0), (8.0, 5.0, 5.0)))], + ), + vpart( + "top_gills", + Some(1), + Vec3::new(0.0, -3.0, -1.0), + vec![fudged(vbox((3, 37), (-4.0, -3.0, 0.0), (8.0, 3.0, 0.0)))], + ), + vpart( + "left_gills", + Some(1), + Vec3::new(-4.0, 0.0, -1.0), + vec![fudged(vbox((0, 40), (-3.0, -5.0, 0.0), (3.0, 7.0, 0.0)))], + ), + vpart( + "right_gills", + Some(1), + Vec3::new(4.0, 0.0, -1.0), + vec![fudged(vbox((11, 40), (0.0, -5.0, 0.0), (3.0, 7.0, 0.0)))], + ), + vpart( + "right_hind_leg", + Some(0), + Vec3::new(-3.5, 1.0, -1.0), + vec![right_leg], + ), + vpart( + "left_hind_leg", + Some(0), + Vec3::new(3.5, 1.0, -1.0), + vec![left_leg], + ), + vpart( + "right_front_leg", + Some(0), + Vec3::new(-3.5, 1.0, -8.0), + vec![right_leg], + ), + vpart( + "left_front_leg", + Some(0), + Vec3::new(3.5, 1.0, -8.0), + vec![left_leg], + ), + vpart( + "tail", + Some(0), + Vec3::new(0.0, 0.0, 1.0), + vec![vbox((2, 19), (0.0, -3.0, 0.0), (0.0, 5.0, 12.0))], + ), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyAxolotlModel.createBodyLayer`, 32x32. The right hind leg's +/// cube hangs off a rotated `right_leg_r1` child. The `1.5708` rotations are +/// vanilla's rounded literals, not `FRAC_PI_2`. +#[allow(clippy::approx_constant)] +pub fn bake_baby_axolotl_model() -> BakedEntityModel { + let leg = |name: &str, x: f32, z: f32, uv: (i32, i32), origin_x: f32| { + vpart( + name, + Some(1), + Vec3::new(x, 0.25, z), + vec![vbox(uv, (origin_x, 0.0, -0.5), (3.0, 0.0, 1.0))], + ) + }; + let parts = vec![ + vpart("root", None, Vec3::new(0.0, 24.0, 0.0), vec![]), + vpart( + "body", + Some(0), + Vec3::new(0.0, -1.25, 1.75), + vec![ + vbox((0, 0), (-2.0, -0.75, -2.75), (4.0, 2.0, 6.0)), + vbox((0, 12), (0.0, -1.75, -2.75), (0.0, 3.0, 5.0)), + ], + ), + leg("right_front_leg", -2.0, -1.25, (20, 16), -3.0), + rotated( + vpart( + "right_hind_leg", + Some(1), + Vec3::new(-2.0, 0.25, 1.75), + vec![], + ), + Vec3::new(0.0, 1.5708, 1.5708), + ), + rotated( + vpart( + "right_leg_r1", + Some(3), + Vec3::ZERO, + vec![vbox((20, 14), (0.0, 0.0, -0.5), (3.0, 0.0, 1.0))], + ), + Vec3::new(-1.5708, 0.0, 1.5708), + ), + leg("left_front_leg", 2.0, -1.25, (20, 13), 0.0), + leg("left_hind_leg", 2.0, 1.75, (20, 14), 0.0), + vpart( + "tail", + Some(1), + Vec3::new(0.0, -0.25, 3.25), + vec![vbox((10, 9), (0.0, -1.5, -1.0), (0.0, 3.0, 8.0))], + ), + vpart( + "head", + Some(1), + Vec3::new(0.0, 0.25, -2.75), + vec![vbox((0, 8), (-3.0, -2.0, -4.0), (6.0, 3.0, 4.0))], + ), + vpart( + "left_gills", + Some(8), + Vec3::new(3.0, -0.5, -2.0), + vec![vbox((20, 8), (0.0, -3.5, 0.0), (3.0, 5.0, 0.0))], + ), + vpart( + "right_gills", + Some(8), + Vec3::new(-3.0, -0.5, -2.0), + vec![vbox((20, 3), (-3.0, -3.5, 0.0), (3.0, 5.0, 0.0))], + ), + vpart( + "top_gills", + Some(8), + Vec3::new(0.0, -2.0, -2.0), + vec![vbox((20, 0), (-3.0, -3.0, 0.0), (6.0, 3.0, 0.0))], + ), + ]; + bake_model(parts, 32, 32) +} + +/// Vanilla `AxolotlRenderState`'s animation inputs: the adult's four eased +/// `BinaryAnimator` factors and the baby's `AnimationState` clocks (seconds +/// since start; `None` = stopped). `walkUnderWaterAnimationState` is +/// extracted by vanilla but never read by `BabyAxolotlModel`, so it isn't +/// carried. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct AxolotlAnims { + pub playing_dead_factor: f32, + pub in_water_factor: f32, + pub on_ground_factor: f32, + pub moving_factor: f32, + pub swim: Option, + pub walk: Option, + pub idle_on_ground: Option, + pub idle_under_water: Option, + pub idle_under_water_on_ground: Option, + pub play_dead: Option, +} + +/// Vanilla `AdultAxolotlModel.setupAnim` / `BabyAxolotlModel.setupAnim`. +#[allow(clippy::too_many_arguments)] +pub fn compute_axolotl_anim( + model: &BakedEntityModel, + is_baby: bool, + x_rot_deg: f32, + local_head_y_rot_deg: f32, + age_in_ticks: f32, + walk_pos: f32, + walk_speed: f32, + a: &AxolotlAnims, +) -> PartAnim { + if is_baby { + return compute_baby_axolotl_anim(model, walk_pos, walk_speed, a); + } + let idx = |name| part_index(&model.parts, name); + let p = AdultParts { + body: idx("body"), + head: idx("head"), + top_gills: idx("top_gills"), + left_gills: idx("left_gills"), + right_gills: idx("right_gills"), + left_hind_leg: idx("left_hind_leg"), + right_hind_leg: idx("right_hind_leg"), + left_front_leg: idx("left_front_leg"), + right_front_leg: idx("right_front_leg"), + tail: idx("tail"), + }; + let mut m = ModelPose::new(model); + let moving = a.moving_factor; + let not_moving = 1.0 - moving; + let mirrored_legs = 1.0 - a.on_ground_factor.min(moving); + m.rot[p.body].y += local_head_y_rot_deg * (PI / 180.0); + swimming( + &mut m, + &p, + age_in_ticks, + x_rot_deg, + moving.min(a.in_water_factor), + ); + water_hovering(&mut m, &p, age_in_ticks, not_moving.min(a.in_water_factor)); + ground_crawling(&mut m, &p, age_in_ticks, moving.min(a.on_ground_factor)); + lay_still_on_ground(&mut m, &p, age_in_ticks, not_moving.min(a.on_ground_factor)); + play_dead(&mut m, &p, a.playing_dead_factor); + mirror_leg_rotations(&mut m, &p, mirrored_legs); + m.emit(model) +} + +/// `BabyAxolotlModel.setupAnim`. Vanilla applies `WALK_FLOOR_UNDERWATER` +/// on the floor walk's `AnimationState`, not its own, so it plays alongside +/// the floor walk and never under water. +fn compute_baby_axolotl_anim( + model: &BakedEntityModel, + walk_pos: f32, + walk_speed: f32, + a: &AxolotlAnims, +) -> PartAnim { + let mut d = KfDeltas::new(model); + if a.walk.is_some() { + apply_kf_walk( + &AXOLOTL_WALK_FLOOR, + model, + walk_pos, + walk_speed, + 15.0, + 30.0, + &mut d, + ); + } + apply_kf_anim(&BABY_AXOLOTL_SWIM, model, a.swim, &mut d); + apply_kf_anim(&WALK_FLOOR_UNDERWATER, model, a.walk, &mut d); + apply_kf_anim(&BABY_AXOLOTL_IDLE_FLOOR, model, a.idle_on_ground, &mut d); + apply_kf_anim(&IDLE_UNDERWATER, model, a.idle_under_water, &mut d); + apply_kf_anim( + &IDLE_FLOOR_UNDERWATER, + model, + a.idle_under_water_on_ground, + &mut d, + ); + apply_kf_anim(&BABY_AXOLOTL_PLAY_DEAD, model, a.play_dead, &mut d); + d.emit(model) +} + +/// `AdultAxolotlModel`'s posed parts. +struct AdultParts { + body: usize, + head: usize, + top_gills: usize, + left_gills: usize, + right_gills: usize, + left_hind_leg: usize, + right_hind_leg: usize, + left_front_leg: usize, + right_front_leg: usize, + tail: usize, +} + +/// The on-ground poses spread all three gills by the same angle. +fn flare_gills(m: &mut ModelPose, p: &AdultParts, gill: f32) { + m.rot[p.top_gills].x += gill; + m.rot[p.left_gills].y -= gill; + m.rot[p.right_gills].y += gill; +} + +fn lay_still_on_ground(m: &mut ModelPose, p: &AdultParts, age_in_ticks: f32, factor: f32) { + if factor <= 1.0e-5 { + return; + } + let speed = age_in_ticks * 0.09; + let sine = speed.sin(); + let cosine = speed.cos(); + let movement = sine * sine - 2.0 * sine; + let movement2 = cosine * cosine - 3.0 * sine; + m.rot[p.head].x += -0.09 * movement * factor; + m.rot[p.head].z += -0.2 * factor; + m.rot[p.tail].y += (-0.1 + 0.1 * movement) * factor; + flare_gills(m, p, (0.6 + 0.05 * movement2) * factor); + m.rot[p.left_hind_leg].x += 1.1 * factor; + m.rot[p.left_hind_leg].y += 1.0 * factor; + m.rot[p.left_front_leg].x += 0.8 * factor; + m.rot[p.left_front_leg].y += 2.3 * factor; + m.rot[p.left_front_leg].z -= 0.5 * factor; +} + +fn ground_crawling(m: &mut ModelPose, p: &AdultParts, age_in_ticks: f32, factor: f32) { + if factor <= 1.0e-5 { + return; + } + let speed = age_in_ticks * 0.11; + let cosine = speed.cos(); + let hind_leg_y_sway = (cosine * cosine - 2.0 * cosine) / 5.0; + let front_leg_y_sway = 0.7 * cosine; + let head_and_tail_y = 0.09 * cosine * factor; + m.rot[p.head].y += head_and_tail_y; + m.rot[p.tail].y += head_and_tail_y; + flare_gills( + m, + p, + (0.6 - 0.08 * (cosine * cosine + 2.0 * speed.sin())) * factor, + ); + let hind_leg_x = 0.9424779 * factor; + let front_leg_x = 1.0995574 * factor; + m.rot[p.left_hind_leg].x += hind_leg_x; + m.rot[p.left_hind_leg].y += (1.5 - hind_leg_y_sway) * factor; + m.rot[p.left_hind_leg].z += -0.1 * factor; + m.rot[p.left_front_leg].x += front_leg_x; + m.rot[p.left_front_leg].y += (FRAC_PI_2 - front_leg_y_sway) * factor; + m.rot[p.right_hind_leg].x += hind_leg_x; + m.rot[p.right_hind_leg].y += (-1.0 - hind_leg_y_sway) * factor; + m.rot[p.right_front_leg].x += front_leg_x; + m.rot[p.right_front_leg].y += (-FRAC_PI_2 - front_leg_y_sway) * factor; +} + +fn water_hovering(m: &mut ModelPose, p: &AdultParts, age_in_ticks: f32, factor: f32) { + if factor <= 1.0e-5 { + return; + } + let speed = age_in_ticks * 0.075; + let cosine = speed.cos(); + let sine = speed.sin() * 0.15; + let body_x = (-0.15 + 0.075 * cosine) * factor; + m.rot[p.body].x += body_x; + m.pos[p.body].y -= sine * factor; + m.rot[p.head].x -= body_x; + m.rot[p.top_gills].x += 0.2 * cosine * factor; + let gill_y = (-0.3 * cosine - 0.19) * factor; + m.rot[p.left_gills].y += gill_y; + m.rot[p.right_gills].y -= gill_y; + m.rot[p.left_hind_leg].x += (2.3561945 - cosine * 0.11) * factor; + m.rot[p.left_hind_leg].y += 0.47123894 * factor; + m.rot[p.left_hind_leg].z += 1.7278761 * factor; + m.rot[p.left_front_leg].x += (FRAC_PI_4 - cosine * 0.2) * factor; + m.rot[p.left_front_leg].y += 2.042035 * factor; + m.rot[p.tail].y += 0.5 * cosine * factor; +} + +fn swimming(m: &mut ModelPose, p: &AdultParts, age_in_ticks: f32, x_rot_deg: f32, factor: f32) { + if factor <= 1.0e-5 { + return; + } + let speed = age_in_ticks * 0.33; + let sine = speed.sin(); + let cosine = speed.cos(); + let body_sway = 0.13 * sine; + m.rot[p.body].x += (x_rot_deg * (PI / 180.0) + body_sway) * factor; + m.rot[p.head].x -= body_sway * 1.8 * factor; + m.pos[p.body].y -= 0.45 * cosine * factor; + m.rot[p.top_gills].x += (-0.5 * sine - 0.8) * factor; + let gill_y = (0.3 * sine + 0.9) * factor; + m.rot[p.left_gills].y += gill_y; + m.rot[p.right_gills].y -= gill_y; + m.rot[p.tail].y += 0.3 * (speed * 0.9).cos() * factor; + m.rot[p.left_hind_leg].x += 1.8849558 * factor; + m.rot[p.left_hind_leg].y += -0.4 * sine * factor; + m.rot[p.left_hind_leg].z += FRAC_PI_2 * factor; + m.rot[p.left_front_leg].x += 1.8849558 * factor; + m.rot[p.left_front_leg].y += (-0.2 * cosine - 0.1) * factor; + m.rot[p.left_front_leg].z += FRAC_PI_2 * factor; +} + +fn play_dead(m: &mut ModelPose, p: &AdultParts, factor: f32) { + if factor <= 1.0e-5 { + return; + } + m.rot[p.left_hind_leg].x += 1.4137167 * factor; + m.rot[p.left_hind_leg].y += 1.0995574 * factor; + m.rot[p.left_hind_leg].z += FRAC_PI_4 * factor; + m.rot[p.left_front_leg].x += FRAC_PI_4 * factor; + m.rot[p.left_front_leg].y += 2.042035 * factor; + m.rot[p.body].x += -0.15 * factor; + m.rot[p.body].z += 0.35 * factor; +} + +/// The right legs take on the left legs' pose, y and z negated. +fn mirror_leg_rotations(m: &mut ModelPose, p: &AdultParts, factor: f32) { + if factor <= 1.0e-5 { + return; + } + let mirror = Vec3::new(1.0, -1.0, -1.0) * factor; + for (right, left) in [ + (p.right_hind_leg, p.left_hind_leg), + (p.right_front_leg, p.left_front_leg), + ] { + let mirrored = m.rot[left] * mirror; + m.rot[right] += mirrored; + } +} + +static BABY_AXOLOTL_IDLE_FLOOR: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 2.88, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Cat), kf(2.8, 0.0, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 160.0, -10.0, -37.5, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.2, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[kf(1.72, 360.0, 0.0, -45.0, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 160.0, 10.0, 37.5, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.2, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 37.5, Cat)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 10.0, 0.0, Cat), + kf(0.72, 0.0, -14.0, 0.0, Lin), + kf(0.96, 0.0, -21.0, 0.0, Lin), + kf(1.4, 0.0, -25.0, 0.0, Cat), + kf(2.04, 0.0, -16.75, 0.0, Cat), + kf(2.84, 0.0, 10.0, 0.0, Cat), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.04, 0.0, 0.0, -6.0, Cat), + kf(1.32, -6.45, -1.45, -6.5, Lin), + kf(1.48, -6.45, -1.45, -6.5, Lin), + kf(2.84, 0.0, 0.0, -6.0, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 38.0, 0.0, Cat), + kf(1.28, 0.0, 45.5, 0.0, Cat), + kf(1.64, 0.0, 45.5, 0.0, Cat), + kf(2.8, 0.0, 38.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.04, 0.0, -50.0, 0.0, Cat), + kf(1.32, 0.0, -59.0, 0.0, Cat), + kf(1.6, 0.0, -59.0, 0.0, Cat), + kf(2.84, 0.0, -50.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 33.0, 0.0, 0.0, Cat), + kf(1.28, 47.4, 0.0, 0.0, Cat), + kf(1.64, 47.4, 0.0, 0.0, Cat), + kf(2.8, 33.0, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static IDLE_FLOOR_UNDERWATER: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 5.12, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Cat), kf(5.12, 0.0, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 1.5, 0.0, Cat), kf(5.12, 0.0, 1.5, 0.0, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[kf(5.04, 190.0, -15.0, -30.0, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.3, 0.0, 0.5, Cat), kf(5.12, 0.3, 0.0, 0.5, Cat)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 360.0, 0.0, -30.0, Cat)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.18, 0.0, 0.0, Lin), kf(5.12, 0.18, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[kf(5.04, 190.0, 18.0, 30.0, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.1, 0.0, 0.5, Cat), kf(5.12, -0.1, 0.0, 0.5, Cat)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[kf(5.04, 180.0, 0.0, 30.0, Cat)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.2, 0.0, 0.0, Lin), kf(5.12, -0.2, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.04, 0.0, -12.0, 0.0, Cat), + kf(2.56, 0.0, 12.0, 0.0, Cat), + kf(5.12, 0.0, -12.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 10.0, 0.0, Cat), + kf(3.12, 0.0, -16.0, 0.0, Cat), + kf(5.12, 0.0, 10.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -16.0, 0.0, Cat), + kf(2.08, 0.0, 12.0, 0.0, Cat), + kf(5.12, 0.0, -16.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 10.0, 0.0, 0.0, Cat), + kf(2.56, -16.0, 0.0, 0.0, Cat), + kf(5.12, 10.0, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static IDLE_UNDERWATER: KfAnim = { + use Kfi::Cat; + KfAnim { + length: 5.2, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -8.9, 0.0, 0.0, Cat), + kf(2.6, -2.6, 0.0, 0.0, Cat), + kf(5.2, -8.9, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(2.6, 0.0, -0.5, 0.0, Cat), + kf(5.2, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -5.0, -20.0, -55.0, Cat), + kf(2.6, 1.0, -20.0, -65.0, Cat), + kf(5.2, -5.0, -20.0, -55.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.15, -0.3391, 0.0876, Cat)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 60.0, -60.0, 15.0, Cat), + kf(2.6, 75.0, -60.0, 0.0, Cat), + kf(5.2, 60.0, -60.0, 15.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(5.2, 0.0, -0.4, 0.0, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 20.0, 50.0, Cat), + kf(2.6, 0.0, 20.0, 60.0, Cat), + kf(5.2, 0.0, 20.0, 50.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(2.68, -0.15, -0.4, 0.3, Cat)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -135.0, -50.0, -330.0, Cat), + kf(2.6, -160.0, -60.0, -295.0, Cat), + kf(5.2, -135.0, -50.0, -330.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(5.2, 0.0, -0.5, 0.0, Cat)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 15.0, 0.0, Cat), + kf(2.6, 0.0, -32.0, 0.0, Cat), + kf(5.2, 0.0, 15.0, 0.0, Cat), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 11.7, 0.0, 0.0, Cat), + kf(2.6, 2.4, 0.0, 0.0, Cat), + kf(5.2, 11.7, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -25.6, 0.0, Cat), + kf(3.12, 0.0, 16.0, 0.0, Cat), + kf(5.2, 0.0, -25.6, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 23.0, 0.0, Cat), + kf(2.08, 0.0, -26.0, 0.0, Cat), + kf(5.2, 0.0, 23.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -19.2, 0.0, 0.0, Cat), + kf(2.6, 12.3, 0.0, 0.0, Cat), + kf(5.2, -19.2, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static BABY_AXOLOTL_SWIM: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 1.0, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 8.0, 0.0, 0.0, Cat), + kf(0.24, 12.0, 0.0, 0.0, Cat), + kf(0.68, -7.5, 0.0, 0.0, Cat), + kf(1.0, 8.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.52, 0.0, -1.0, 0.0, Cat), + kf(0.96, 0.0, -0.05, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 310.0, 70.0, 400.0, Cat), + kf(0.24, 330.0, 70.0, 420.0, Cat), + kf(0.52, 290.0, 65.0, 384.0, Cat), + kf(0.76, 290.0, 70.0, 380.0, Cat), + kf(0.96, 310.0, 70.0, 400.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 105.0, -95.0, 180.0, Cat), + kf(0.28, 105.0, -85.0, 180.0, Cat), + kf(0.52, 105.0, -80.0, 180.0, Cat), + kf(0.76, 105.0, -95.0, 180.0, Cat), + kf(0.96, 105.0, -95.0, 180.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -50.0, -70.0, -40.0, Cat), + kf(0.52, -60.0, -68.0, -30.0, Cat), + kf(0.76, -55.0, -70.0, -30.0, Cat), + kf(0.96, -50.0, -70.0, -40.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 70.0, -80.0, 15.0, Cat), + kf(0.28, 130.0, -80.0, -45.0, Cat), + kf(0.52, 120.0, -70.0, -35.0, Cat), + kf(0.76, 80.0, -70.0, 5.0, Cat), + kf(0.96, 70.0, -80.0, 15.0, Cat), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 15.0, 0.0, Cat), + kf(0.52, 0.0, -15.0, 0.0, Cat), + kf(0.96, 0.0, 13.36, 0.0, Cat), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -7.5, 0.0, 0.0, Cat), + kf(0.28, -13.9, 0.0, 0.0, Cat), + kf(0.72, 14.23, 0.0, 0.0, Cat), + kf(1.0, -7.5, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -72.0, 0.0, Cat), + kf(0.2, 0.0, -79.9, 0.0, Cat), + kf(0.64, 0.0, -38.1, 0.0, Cat), + kf(0.96, 0.0, -72.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 72.0, 0.0, Cat), + kf(0.2, 0.0, 86.5, 0.0, Cat), + kf(0.64, 0.0, 26.7, 0.0, Cat), + kf(0.96, 0.0, 72.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -57.0, 0.0, 0.0, Cat), + kf(0.2, -68.7, 0.0, 0.0, Cat), + kf(0.64, -24.2, 0.0, 0.0, Cat), + kf(0.96, -57.0, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static AXOLOTL_WALK_FLOOR: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 2.72, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin), kf(2.72, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 2.5, 10.0, -30.0, Cat), + kf(1.32, 2.5, -22.5, -30.0, Cat), + kf(2.72, 2.5, 10.0, -30.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(1.92, 0.5, 0.0, 0.8, Cat)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 260.0, -10.0, 230.0, Cat), + kf(1.32, 90.0, -20.0, 50.0, Cat), + kf(2.72, 260.0, -10.0, 230.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.5, 0.0, 0.0, Cat), kf(2.72, 0.5, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -2.5, 32.5, 30.0, Cat), + kf(1.32, -2.5, -12.0, 30.0, Cat), + kf(2.72, -2.5, 32.5, 30.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.5, 0.0, 0.7, Cat)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -2.5, -30.0, 30.0, Cat), + kf(1.32, 2.5, 12.0, 30.0, Cat), + kf(2.72, -2.5, -30.0, 30.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.5, 0.0, 0.0, Cat), kf(2.72, -0.5, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -10.0, 0.0, Cat), + kf(1.32, 0.0, 10.0, 0.0, Cat), + kf(2.72, 0.0, -10.0, 0.0, Cat), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -7.5, 0.0, Cat), + kf(1.32, 0.0, 6.7, 0.0, Cat), + kf(2.72, 0.0, -7.5, 0.0, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 38.1, 0.0, Cat), + kf(1.08, 0.0, 45.6, 0.0, Cat), + kf(1.44, 0.0, 45.6, 0.0, Cat), + kf(2.72, 0.0, 38.1, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -50.0, 0.0, Cat), + kf(1.08, 0.0, -59.0, 0.0, Cat), + kf(1.36, 0.0, -59.0, 0.0, Cat), + kf(2.72, 0.0, -50.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 33.0, 0.0, 0.0, Cat), + kf(1.08, 47.4, 0.0, 0.0, Cat), + kf(1.44, 47.4, 0.0, 0.0, Cat), + kf(2.72, 33.0, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static WALK_FLOOR_UNDERWATER: KfAnim = { + use Kfi::Cat; + KfAnim { + length: 2.04, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -5.0, 6.0, Cat), + kf(1.04, 0.0, 5.0, -4.0, Cat), + kf(2.04, 0.0, -5.0, 6.0, Cat), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 1.5, 0.0, Cat), kf(2.04, 0.0, 1.5, 0.0, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -15.0, -30.0, Cat), + kf(0.72, 0.0, 9.0, -30.0, Cat), + kf(1.12, 0.0, 13.0, -24.0, Cat), + kf(1.44, 0.0, 9.0, -30.0, Cat), + kf(2.04, 0.0, -15.0, -30.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.96, 0.4, 0.0, 0.0, Cat), + kf(2.04, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 95.0, -20.0, 70.0, Cat), + kf(0.68, 255.0, -20.0, 230.0, Cat), + kf(1.12, 280.0, -25.0, 255.0, Cat), + kf(1.52, 255.0, -20.0, 230.0, Cat), + kf(2.04, 95.0, -20.0, 70.0, Cat), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.5, 0.0, 0.0, Cat), + kf(0.96, 0.5, 0.0, 0.0, Cat), + kf(2.04, 0.5, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -5.0, -20.0, 30.0, Cat), + kf(0.72, 0.0, 10.0, 25.0, Cat), + kf(1.44, 0.0, 10.0, 25.0, Cat), + kf(2.04, -5.0, -20.0, 30.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -0.5, 0.0, 0.0, Cat), + kf(0.96, 0.0, 0.0, 0.0, Cat), + kf(2.04, -0.5, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 5.0, 15.0, 25.0, Cat), + kf(0.72, -10.0, -20.0, 35.0, Cat), + kf(1.44, -10.0, -20.0, 35.0, Cat), + kf(2.04, 5.0, 15.0, 25.0, Cat), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -0.7, 0.0, 0.0, Cat), + kf(0.96, -0.4, 0.0, 0.0, Cat), + kf(2.04, -0.7, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 16.5, 0.0, Cat), + kf(0.56, 0.0, 1.12, 0.0, Cat), + kf(1.44, 0.0, 17.5, 0.0, Cat), + kf(2.04, 0.0, 16.5, 0.0, Cat), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 4.0, -5.8, Cat), + kf(1.04, 0.0, -4.0, 5.0, Cat), + kf(2.04, 0.0, 4.0, -5.8, Cat), + ], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, -60.0, 0.0, Cat), + kf(1.24, 0.0, -45.0, 0.0, Cat), + kf(2.04, 0.0, -60.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 38.0, 0.0, Cat), + kf(0.64, 0.0, 53.0, 0.0, Cat), + kf(2.04, 0.0, 38.0, 0.0, Cat), + ], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -34.0, 0.0, 0.0, Cat), + kf(1.04, -41.5, 0.0, 0.0, Cat), + kf(2.04, -34.0, 0.0, 0.0, Cat), + ], + }, + ], + } +}; + +static BABY_AXOLOTL_PLAY_DEAD: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 0.04, + looping: false, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 30.0, Cat)], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "body", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 202.35, -30.33, -40.82, Cat)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.2, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 418.0, -50.0, 35.8, Cat)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 177.0, 22.76, 15.9, Cat)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, -0.2, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 16.4287, -37.6467, 16.9822, Cat)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, -18.67, 0.0, Lin)], + }, + KfChannel { + part: "tail", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "tail", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[kf(0.0, -4.21, -0.95, -6.33, Lin)], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "head", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 43.21, 0.0, Cat)], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_gills", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, -55.87, 0.0, Cat)], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_gills", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Rot, + frames: &[kf(0.0, 43.0, 0.0, 0.0, Cat)], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "top_gills", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "root", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "root", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "root", + target: KfTarget::Scale, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin)], + }, + ], + } +}; diff --git a/pomme-client/src/renderer/entity_model/polar_bear.rs b/pomme-client/src/renderer/entity_model/polar_bear.rs new file mode 100644 index 000000000..62b4348af --- /dev/null +++ b/pomme-client/src/renderer/entity_model/polar_bear.rs @@ -0,0 +1,149 @@ +//! Polar bear: `PolarBearModel` (adult, `MeshTransformer.scaling(1.2)`) and +//! the `BabyPolarBearModel` mesh, which `PolarBearRenderer` wraps in a plain +//! `PolarBearModel`, so both run the same `setupAnim`. + +use std::f32::consts::PI; + +use glam::Vec3; + +use super::{ + BakedEntityModel, PartAnim, bake_model, bake_root_scaled, compute_quadruped_anim, mirrored, + rotated, vbox, vpart, +}; + +/// Vanilla `PolarBearModel.createBodyLayer`, 128x64, scaled 1.2 from the +/// root. +pub fn bake_polar_bear_model() -> BakedEntityModel { + let hind_leg = vbox((50, 22), (-2.0, 0.0, -2.0), (4.0, 10.0, 8.0)); + let front_leg = vbox((50, 40), (-2.0, 0.0, -2.0), (4.0, 10.0, 6.0)); + let parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, 10.0, -16.0), + vec![ + vbox((0, 0), (-3.5, -3.0, -3.0), (7.0, 7.0, 7.0)), + // mouth, right_ear, left_ear + vbox((0, 44), (-2.5, 1.0, -6.0), (5.0, 3.0, 3.0)), + vbox((26, 0), (-4.5, -4.0, -1.0), (2.0, 2.0, 1.0)), + mirrored(vbox((26, 0), (2.5, -4.0, -1.0), (2.0, 2.0, 1.0))), + ], + ), + rotated( + vpart( + "body", + None, + Vec3::new(-2.0, 9.0, 12.0), + vec![ + vbox((0, 19), (-5.0, -13.0, -7.0), (14.0, 14.0, 11.0)), + vbox((39, 0), (-4.0, -25.0, -7.0), (12.0, 12.0, 10.0)), + ], + ), + Vec3::new(PI / 2.0, 0.0, 0.0), + ), + vpart( + "right_hind_leg", + None, + Vec3::new(-4.5, 14.0, 6.0), + vec![hind_leg], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(4.5, 14.0, 6.0), + vec![hind_leg], + ), + vpart( + "right_front_leg", + None, + Vec3::new(-3.5, 14.0, -8.0), + vec![front_leg], + ), + vpart( + "left_front_leg", + None, + Vec3::new(3.5, 14.0, -8.0), + vec![front_leg], + ), + ]; + bake_root_scaled(parts, 1.2, 128, 64) +} + +/// Vanilla `BabyPolarBearModel.createBodyLayer`, 64x64, unscaled. +pub fn bake_baby_polar_bear_model() -> BakedEntityModel { + let leg = |name: &str, x: f32, z: f32, uv: (i32, i32)| { + vpart( + name, + None, + Vec3::new(x, 21.5, z), + vec![vbox(uv, (-1.5, -0.5, -1.5), (3.0, 3.0, 3.0))], + ) + }; + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 17.5, 0.0), + vec![vbox((0, 9), (-4.0, -3.5, -6.0), (8.0, 7.0, 12.0))], + ), + vpart( + "head", + None, + Vec3::new(0.0, 18.625, -5.75), + vec![ + vbox((0, 0), (-3.0, -2.625, -4.25), (6.0, 5.0, 4.0)), + vbox((20, 3), (-2.0, 0.375, -6.25), (4.0, 2.0, 2.0)), + vbox((20, 0), (-4.0, -3.625, -2.75), (2.0, 2.0, 1.0)), + vbox((26, 0), (2.0, -3.625, -2.75), (2.0, 2.0, 1.0)), + ], + ), + leg("right_hind_leg", -2.5, 4.5, (0, 34)), + leg("left_hind_leg", 2.5, 4.5, (12, 34)), + leg("right_front_leg", -2.5, -4.5, (0, 28)), + leg("left_front_leg", 2.5, -4.5, (12, 28)), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `PolarBearModel.setupAnim`: the quadruped look and leg swing, then +/// the stand-up pose scaled by `standScale²`. `stand_scale` is +/// `PolarBearRenderState.standScale` (0..1), `age_scale` its `ageScale`. +pub fn compute_polar_bear_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + stand_scale: f32, + age_scale: f32, +) -> PartAnim { + let mut anim = compute_quadruped_anim( + model, + head_x_rot_deg, + local_head_y_rot_deg, + walk_pos, + walk_speed, + 0.0, + None, + ); + let s = stand_scale * stand_scale; + if s == 0.0 { + return anim; + } + for (i, part) in model.parts.iter().enumerate() { + // (xRot delta, offset delta); the left front leg copies the right's + // y/z, which share their rest values, so the deltas match. + let (x_rot, offset) = match part.name.as_str() { + "body" => (-s * PI * 0.35, Vec3::new(0.0, s * age_scale * 2.0, 0.0)), + "right_front_leg" | "left_front_leg" => ( + -s * PI * 0.45, + Vec3::new(0.0, -s * age_scale * 20.0, s * age_scale * 4.0), + ), + "head" => (s * PI * 0.15, Vec3::new(0.0, -s * 24.0, s * 13.0)), + _ => continue, + }; + anim.rotation_mut(model, i).x += x_rot; + anim.translation.push((i, offset)); + } + anim +} diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index c58ddeb4b..a38fec95e 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -199,6 +199,8 @@ pub struct EntityRenderInfo { pub llama_decor: Option, pub bee: entity_model::BeeAnims, pub fox: entity_model::FoxAnims, + /// Axolotl animator factors and baby animation clocks. + pub axolotl: entity_model::AxolotlAnims, pub panda: entity_model::PandaAnims, pub parrot: entity_model::ParrotAnims, /// `DolphinRenderState.isMoving`. @@ -287,6 +289,7 @@ impl Default for EntityRenderInfo { llama_decor: None, bee: entity_model::BeeAnims::default(), fox: entity_model::FoxAnims::default(), + axolotl: entity_model::AxolotlAnims::default(), panda: entity_model::PandaAnims::default(), parrot: entity_model::ParrotAnims::default(), dolphin_moving: false, @@ -968,6 +971,8 @@ enum AnimationType { Allay, SnowGolem, CopperGolem, + Axolotl, + PolarBear, Fox, Panda, Bee, @@ -1175,6 +1180,14 @@ fn mob_definitions() -> Vec { const SULFUR_CUBE_INNER_SMALL_TEX: &[&[&str]] = tex_table!("sulfur_cube" => "sulfur_cube_inner_small"); const DOLPHIN_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin"); + // Indexed by `Axolotl.Variant` id. + const AXOLOTL_TEX: &[&[&str]] = tex_table!("axolotl" => + "axolotl_lucy", "axolotl_wild", "axolotl_gold", "axolotl_cyan", "axolotl_blue"); + const AXOLOTL_BABY_TEX: &[&[&str]] = tex_table!("axolotl" => + "axolotl_lucy_baby", "axolotl_wild_baby", "axolotl_gold_baby", "axolotl_cyan_baby", + "axolotl_blue_baby"); + const POLAR_BEAR_TEX: &[&[&str]] = tex_table!("bear" => "polarbear"); + const POLAR_BEAR_BABY_TEX: &[&[&str]] = tex_table!("bear" => "polarbear_baby"); // Indexed by `variant * 2 + sleeping`. const FOX_TEX: &[&[&str]] = tex_table!("fox" => "fox", "fox_sleep", "fox_snow", "fox_snow_sleep"); @@ -1349,6 +1362,34 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Axolotl, + anim: AnimationType::Axolotl, + adult: vec![opaque(entity_model::bake_axolotl_model(), AXOLOTL_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_axolotl_model(), + AXOLOTL_BABY_TEX, + 32, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::PolarBear, + anim: AnimationType::PolarBear, + adult: vec![opaque( + entity_model::bake_polar_bear_model(), + POLAR_BEAR_TEX, + 128, + )], + baby: Some(opaque( + entity_model::bake_baby_polar_bear_model(), + POLAR_BEAR_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Fox, anim: AnimationType::Fox, @@ -2628,6 +2669,25 @@ impl EntityRenderer { info.bat_elapsed_secs, info.bat_resting, ), + AnimationType::Axolotl => entity_model::compute_axolotl_anim( + model, + info.is_baby, + info.head_x_rot_deg, + local_head_y, + info.age_in_ticks, + info.walk_anim_pos, + info.walk_anim_speed, + &info.axolotl, + ), + AnimationType::PolarBear => entity_model::compute_polar_bear_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.stand_anim, + if info.is_baby { 0.5 } else { 1.0 }, + ), AnimationType::Fox => entity_model::compute_fox_anim( model, info.is_baby, @@ -3376,9 +3436,9 @@ pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { match kind { EntityKind::Villager if is_baby => 0.63, // Explicit `BABY_DIMENSIONS` eye heights. - EntityKind::Fox if is_baby => 0.34375, + EntityKind::Fox | EntityKind::PolarBear if is_baby => 0.34375, EntityKind::Panda if is_baby => 0.28125, - EntityKind::Dolphin if is_baby => 0.09375, + EntityKind::Axolotl | EntityKind::Dolphin if is_baby => 0.09375, EntityKind::Mooshroom if is_baby => 0.69, // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. EntityKind::Sniffer @@ -3477,7 +3537,11 @@ fn entity_visible( eye: glam::DVec3, cull_dist: f32, ) -> bool { - let (w, h) = entity_bounds(info.entity_kind, info.is_baby); + let (w, mut h) = entity_bounds(info.entity_kind, info.is_baby); + // A standing polar bear's hitbox stretches up to double height. + if info.entity_kind == EntityKind::PolarBear { + h *= 1.0 + info.stand_anim; + } // Renderer `getBoundingBoxForCulling().inflate(..)` overrides grow the // box on every side around the same center (the sniffer's head reaches // well past its hitbox). diff --git a/tests/ingame/entities/axolotl.toml b/tests/ingame/entities/axolotl.toml new file mode 100644 index 000000000..28677f434 --- /dev/null +++ b/tests/ingame/entities/axolotl.toml @@ -0,0 +1,124 @@ +# Axolotls, three-quarter view from above: the five variants as adults +# (back row) and babies (front row) on the grass, then an adult and a baby +# hovering in a pool. The baby is its own keyframed 32x32 mesh, not a scaled +# adult. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:axolotl -2.5 -60 6.5 {NoAI:1b,Variant:0,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl -1 -60 6.5 {NoAI:1b,Variant:1,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 0.5 -60 6.5 {NoAI:1b,Variant:2,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 2 -60 6.5 {NoAI:1b,Variant:3,Rotation:[160f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 3.5 -60 6.5 {NoAI:1b,Variant:4,Rotation:[160f,0f]}" + +[[step]] +expect = { entity = "axolotl", baby = false } + +[[step]] +chat = "/summon minecraft:axolotl -2.5 -60 4 {NoAI:1b,Variant:0,Age:-24000,Rotation:[200f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl -1 -60 4 {NoAI:1b,Variant:1,Age:-24000,Rotation:[200f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 0.5 -60 4 {NoAI:1b,Variant:2,Age:-24000,Rotation:[200f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 2 -60 4 {NoAI:1b,Variant:3,Age:-24000,Rotation:[200f,0f]}" + +[[step]] +chat = "/summon minecraft:axolotl 3.5 -60 4 {NoAI:1b,Variant:4,Age:-24000,Rotation:[200f,0f]}" + +[[step]] +expect = { entity = "axolotl", baby = true } + +[[step]] +look_at = [0.5, -60.0, 5.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "on_ground" + +[[step]] +chat = "/kill @e[type=!player]" + +# A two-deep pool cut into the dirt and grass. +[[step]] +chat = "/fill -2 -62 3 3 -61 7 minecraft:water" + +[[step]] +expect = { block = "water", at = [-2, -62, 3] } + +[[step]] +chat = "/summon minecraft:axolotl -0.5 -61.6 5.5 {NoAI:1b,Variant:0,Rotation:[150f,0f]}" + +[[step]] +expect = { entity = "axolotl", baby = false } + +[[step]] +chat = "/summon minecraft:axolotl 2 -61.6 4.5 {NoAI:1b,Variant:4,Age:-24000,Rotation:[230f,0f]}" + +[[step]] +expect = { entity = "axolotl", baby = true } + +[[step]] +look_at = [0.5, -61.5, 5.0] + +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "in_water" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill -2 -62 3 3 -62 7 minecraft:dirt" + +[[step]] +chat = "/fill -2 -61 3 3 -61 7 minecraft:grass_block" + +[[step]] +expect = { block = "grass_block", at = [-2, -61, 3] } + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/polar_bear.toml b/tests/ingame/entities/polar_bear.toml new file mode 100644 index 000000000..5ca637b70 --- /dev/null +++ b/tests/ingame/entities/polar_bear.toml @@ -0,0 +1,63 @@ +# Polar bear and cub at rest, three-quarter view from above. The adult mesh +# is scaled 1.2 from its root; the cub is its own 64x64 mesh, not a scaled +# adult. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:polar_bear -1.5 -60 6.5 {NoAI:1b,Rotation:[150f,0f]}" + +[[step]] +expect = { entity = "polar_bear", baby = false } + +[[step]] +chat = "/summon minecraft:polar_bear 2.5 -60 5 {NoAI:1b,Age:-24000,Rotation:[210f,0f]}" + +[[step]] +expect = { entity = "polar_bear", baby = true } + +[[step]] +look_at = [0.5, -59.5, 6.0] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "polar_bears" + +[[step]] +chat = "/kill @e[type=!player]" + +# The adult drops fish. +[[step]] +chat = "/kill @e[type=item]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" From 27b441fe2abc3d4ebec574cab05b989fa2c09dc1 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:08:12 +0000 Subject: [PATCH 07/10] add armadillo and goat --- pomme-client/src/app/core.rs | 16 + pomme-client/src/app/phases/in_game.rs | 36 + pomme-client/src/entity/mod.rs | 256 ++- pomme-client/src/net/entity_data.rs | 3 + pomme-client/src/net/entity_wire.rs | 6 + pomme-client/src/net/mod.rs | 9 + pomme-client/src/renderer/entity_model.rs | 6 +- .../src/renderer/entity_model/armadillo.rs | 1737 +++++++++++++++++ .../src/renderer/entity_model/goat.rs | 258 +++ .../src/renderer/pipelines/entity_renderer.rs | 58 + .../src/data/codec-fixtures-26.2.json | 1 + pomme-protocol/src/entity_data.rs | 22 +- tests/ingame/entities/armadillo.toml | 65 + tests/ingame/entities/goat.toml | 62 + tools/codec-oracle/CodecOracle.java | 5 + 15 files changed, 2535 insertions(+), 5 deletions(-) create mode 100644 pomme-client/src/renderer/entity_model/armadillo.rs create mode 100644 pomme-client/src/renderer/entity_model/goat.rs create mode 100644 tests/ingame/entities/armadillo.toml create mode 100644 tests/ingame/entities/goat.toml diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 3cbce3fb6..7d3b73b4a 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -2823,6 +2823,22 @@ impl AppCore { NetworkEvent::SnifferDigging { id } => { super::sniffer::start_digging_sound(&mut self.audio, game, id); } + // `Armadillo.handleEntityEvent`: a local sound at its position. + NetworkEvent::ArmadilloPeek { id } => { + if let Some(pos) = game.entity_store.armadillo_peek(id) { + self.audio.play_world_sound( + &crate::audio::SoundRef::event("entity.armadillo.peek"), + crate::audio::CATEGORY_NEUTRAL, + pos, + 1.0, + 1.0, + fastrand::u64(..), + ); + } + } + NetworkEvent::GoatRam { id, lowering } => { + game.entity_store.set_goat_lowering_head(id, lowering); + } NetworkEvent::SquidTentacleReset { id } => { game.entity_store.squid_tentacle_reset(id); } diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 39bc5d075..b1c1beecf 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -4124,6 +4124,8 @@ pub fn update_game( fox: extras.fox, axolotl: extras.axolotl, panda: extras.panda, + armadillo: extras.armadillo, + goat: extras.goat, parrot: extras.parrot, dolphin_moving: extras.dolphin_moving, turtle: extras.turtle, @@ -5015,6 +5017,8 @@ struct EntityExtras { fox: crate::renderer::entity_model::FoxAnims, axolotl: crate::renderer::entity_model::AxolotlAnims, panda: crate::renderer::entity_model::PandaAnims, + armadillo: crate::renderer::entity_model::ArmadilloAnims, + goat: crate::renderer::entity_model::GoatAnims, parrot: crate::renderer::entity_model::ParrotAnims, dolphin_moving: bool, turtle: crate::renderer::entity_model::TurtleAnims, @@ -5157,6 +5161,19 @@ fn entity_extras( | EntityKind::TropicalFish | EntityKind::Pufferfish => fish_extras(e, alpha), EntityKind::IronGolem => golem_extras(e, alpha), + EntityKind::Armadillo => EntityExtras { + armadillo: armadillo_anims(e, alpha), + ..Default::default() + }, + // `GoatRenderer.extractRenderState`. + EntityKind::Goat => EntityExtras { + goat: crate::renderer::entity_model::GoatAnims { + has_left_horn: e.goat.has_left_horn, + has_right_horn: e.goat.has_right_horn, + ramming_x_head_rot: e.goat.ramming_x_head_rot(e.is_baby), + }, + ..Default::default() + }, // `DolphinRenderer.extractRenderState`. EntityKind::Dolphin => EntityExtras { dolphin_moving: e.velocity.x * e.velocity.x + e.velocity.z * e.velocity.z > 1.0e-7, @@ -5381,6 +5398,25 @@ fn axolotl_anims( anims } +/// Vanilla `ArmadilloRenderer.extractRenderState`. +fn armadillo_anims( + e: &crate::entity::LivingEntity, + alpha: f32, +) -> crate::renderer::entity_model::ArmadilloAnims { + let a = &e.armadillo; + // `AnimationState.getTimeInMillis`, whose start may sit before tick 0 + // after a `fastForward`. + let clock = |start: Option| { + start.map(|s| ((e.age_in_ticks as f32 + alpha - s as f32) * 50.0) as i64 as f32 / 1000.0) + }; + crate::renderer::entity_model::ArmadilloAnims { + hiding_in_shell: a.hiding_in_shell(), + roll_out: clock(a.roll_out_start), + roll_up: clock(a.roll_up_start), + peek: clock(a.peek_start), + } +} + /// Vanilla `SnifferRenderer.extractRenderState`. fn sniffer_anims( e: &crate::entity::LivingEntity, diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 05ab8136c..9bd5443ea 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -268,6 +268,41 @@ impl AxolotlSim { } } +/// Vanilla `Armadillo.ArmadilloState`, in id order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ArmadilloState { + #[default] + Idle, + Rolling, + Scared, + Unrolling, +} + +impl ArmadilloState { + /// `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`. + fn from_id(id: i32) -> Self { + match id { + 1 => Self::Rolling, + 2 => Self::Scared, + 3 => Self::Unrolling, + _ => Self::Idle, + } + } + + /// `shouldHideInShell`. + fn hides_in_shell(self, ticks_in_state: u64) -> bool { + match self { + Self::Idle => false, + Self::Rolling => ticks_in_state > 5, + Self::Scared => true, + Self::Unrolling => ticks_in_state < 26, + } + } +} + +/// `ArmadilloState.SCARED.animationDuration()`. +const ARMADILLO_SCARED_ANIMATION_TICKS: i64 = 50; + /// Vanilla `Fox` client state: the synced flags byte and the crouch spring /// (`Fox.tick`). The interested head tilt reuses the wolf's spring. #[derive(Clone, Debug, Default, PartialEq)] @@ -455,6 +490,104 @@ impl PandaSim { } } +/// The client half of vanilla `Armadillo`: the synced state, the ticks spent +/// in it, and the start ticks of its three `AnimationState`s. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ArmadilloSim { + pub state: ArmadilloState, + in_state_ticks: u64, + pub roll_out_start: Option, + pub roll_up_start: Option, + pub peek_start: Option, + peek_received: bool, +} + +impl ArmadilloSim { + /// `onSyncedDataUpdated`. + fn set_state(&mut self, id: i32) { + self.state = ArmadilloState::from_id(id); + self.in_state_ticks = 0; + } + + /// `isScared`. + pub fn is_scared(&self) -> bool { + self.state != ArmadilloState::Idle + } + + /// `shouldHideInShell`. + pub fn hiding_in_shell(&self) -> bool { + self.state.hides_in_shell(self.in_state_ticks) + } + + /// `setupAnimationStates` then `++inStateTicks`, at vanilla's + /// post-increment `tickCount`. + fn tick(&mut self, tick_count: i64) { + use ArmadilloState as S; + let (roll_out, roll_up) = (self.state == S::Unrolling, self.state == S::Rolling); + for (clock, on) in [ + (&mut self.roll_out_start, roll_out), + (&mut self.roll_up_start, roll_up), + ] { + if !on { + *clock = None; + } else if clock.is_none() { + *clock = Some(tick_count); + } + } + if self.state != S::Scared { + self.peek_start = None; + } else { + if self.peek_received { + self.peek_start = None; + self.peek_received = false; + } + if self.in_state_ticks == 0 { + // `start` then `fastForward(animationDuration, 1)`: entering + // the state shows the peek's last frame. + self.peek_start = Some(tick_count - ARMADILLO_SCARED_ANIMATION_TICKS); + } else if self.peek_start.is_none() { + self.peek_start = Some(tick_count); + } + } + self.in_state_ticks += 1; + } +} + +/// The client half of vanilla `Goat`: horn flags and the ram's head-lowering +/// counter. +#[derive(Clone, Debug, PartialEq)] +pub struct GoatSim { + pub has_left_horn: bool, + pub has_right_horn: bool, + lowering_head: bool, + lower_head_tick: i32, +} + +impl Default for GoatSim { + fn default() -> Self { + Self { + has_left_horn: true, + has_right_horn: true, + lowering_head: false, + lower_head_tick: 0, + } + } +} + +impl GoatSim { + /// `Goat.aiStep`'s head-lowering counter. + fn tick(&mut self) { + let step = if self.lowering_head { 1 } else { -2 }; + self.lower_head_tick = (self.lower_head_tick + step).clamp(0, 20); + } + + /// `Goat.getRammingXHeadRot`, radians. + pub fn ramming_x_head_rot(&self, is_baby: bool) -> f32 { + let max = if is_baby { 52.5 } else { 30.0 }; + self.lower_head_tick as f32 / 20.0 * max * (std::f32::consts::PI / 180.0) + } +} + /// A 0..1 pose amount stepping `up` toward 1 while `on`, else `down` /// toward 0. fn spring(cur: f32, on: bool, up: f32, down: f32) -> f32 { @@ -485,6 +618,8 @@ fn is_ageable_mob(kind: EntityKind) -> bool { | EntityKind::WanderingTrader | EntityKind::SulfurCube | EntityKind::Mooshroom + | EntityKind::Armadillo + | EntityKind::Goat | EntityKind::Fox | EntityKind::Panda | EntityKind::Bee @@ -713,6 +848,8 @@ pub struct LivingEntity { pub fox: FoxSim, pub panda: PandaSim, pub axolotl: AxolotlSim, + pub armadillo: ArmadilloSim, + pub goat: GoatSim, /// `Turtle.HAS_EGG` / `LAYING_EGG`. pub has_egg: bool, pub laying_egg: bool, @@ -768,7 +905,8 @@ pub struct LivingEntity { pub(crate) fn baby_dimensions(kind: EntityKind, width: f32, height: f32) -> (f32, f32) { let scale = match kind { EntityKind::Squid | EntityKind::GlowSquid => return (0.5, 0.5), - EntityKind::Fox => 0.6, + EntityKind::Goat => return (0.45, 0.65), + EntityKind::Armadillo | EntityKind::Fox => 0.6, // Their `getAgeScale`. EntityKind::Dolphin => 0.65, EntityKind::Turtle => 0.3, @@ -787,6 +925,8 @@ pub(crate) fn living_entity_dimensions(entity: &LivingEntity) -> EntityDimension (dims.width, dims.height) = baby_dimensions(entity.entity_type, dims.width, dims.height); } dims.height *= entity.stand_height_scale(); + // TODO: `Goat.getDefaultDimensions` scales by 0.7 in `Pose.LONG_JUMPING`; + // pomme tracks only the crouching pose. dims } @@ -948,6 +1088,8 @@ impl LivingEntity { fox: FoxSim::default(), panda: PandaSim::default(), axolotl: AxolotlSim::default(), + armadillo: ArmadilloSim::default(), + goat: GoatSim::default(), has_egg: false, laying_egg: false, puff_state: 0, @@ -1383,6 +1525,15 @@ impl LivingEntity { // TODO: `Bee.tick`'s falling-nectar drip particles (no drip // particle type yet). EntityKind::Bee => self.bee.tick(), + // `Armadillo.tick`; a scared armadillo's `getMaxHeadYRot` is 0, + // so `clampHeadRotationToBody` pins the head to the body. + EntityKind::Armadillo => { + self.armadillo.tick(i64::from(self.age_in_ticks) + 1); + if self.armadillo.is_scared() { + self.head_y_rot_deg += wrap_degrees(self.body_y_rot_deg - self.head_y_rot_deg); + } + } + EntityKind::Goat => self.goat.tick(), EntityKind::Fox => self.fox.tick(), // `Axolotl.baseTick` runs before the interpolation step, where a // remote entity's rotation still equals the `setOldPosAndRot` @@ -1982,6 +2133,9 @@ impl EntityStore { entity.bat_anim_start = Some(entity.age_in_ticks + 1); } } + (EntityKind::Armadillo, 18, Int(id)) => entity.armadillo.set_state(id), + (EntityKind::Goat, 19, Bool(b)) => entity.goat.has_left_horn = b, + (EntityKind::Goat, 20, Bool(b)) => entity.goat.has_right_horn = b, (EntityKind::Turtle, 18, Bool(b)) => entity.has_egg = b, (EntityKind::Turtle, 19, Bool(b)) => entity.laying_egg = b, // `Axolotl.Variant.byId`: out-of-range ids fall back to LUCY. @@ -2131,6 +2285,26 @@ impl EntityStore { } } + /// `Armadillo.handleEntityEvent` 64: restart the peek animation. Returns + /// where to play the peek sound. + pub fn armadillo_peek(&mut self, id: i32) -> Option { + let entity = self.living.get_mut(&id)?; + if entity.entity_type != EntityKind::Armadillo { + return None; + } + entity.armadillo.peek_received = true; + Some(entity.position) + } + + /// Goat entity events 58 / 59: start / stop lowering the head to ram. + pub fn set_goat_lowering_head(&mut self, id: i32, lowering: bool) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Goat + { + entity.goat.lowering_head = lowering; + } + } + /// Iron golem punch (entity event 4): vanilla runs a 10-tick swing. pub fn golem_punch(&mut self, id: i32) { if let Some(entity) = self.living.get_mut(&id) @@ -2206,7 +2380,10 @@ impl EntityStore { let thundering = self.thundering; for (&id, entity) in &mut self.living { entity.tick_interpolation(); - entity.tick_body_rotation(); + // A scared armadillo's body control skips its client tick. + if !(entity.entity_type == EntityKind::Armadillo && entity.armadillo.is_scared()) { + entity.tick_body_rotation(); + } let dx = entity.position.x - entity.prev_position.x; let dz = entity.position.z - entity.prev_position.z; if entity.health <= 0.0 { @@ -2381,6 +2558,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::Fox | EntityKind::Panda | EntityKind::Mooshroom + | EntityKind::Armadillo + | EntityKind::Goat | EntityKind::Dolphin | EntityKind::Turtle | EntityKind::Axolotl @@ -2658,6 +2837,79 @@ mod tests { assert!(store.living[&2].is_baby); } + /// `Armadillo.setupAnimationStates`: each state runs its one clock; + /// entering SCARED fast-forwards the peek to its end, and a peek event + /// restarts it on the next tick. + #[test] + fn armadillo_state_drives_shell_and_clocks() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Armadillo, Position::default()); + store.living.get_mut(&1).unwrap().age_in_ticks = 100; + let sim = |store: &EntityStore| store.living[&1].armadillo.clone(); + + store.apply_entity_data(1, 18, MetaValue::Int(2)); + assert!(sim(&store).hiding_in_shell()); + tick(&mut store); + assert_eq!(sim(&store).peek_start, Some(101 - 50)); + assert!(store.armadillo_peek(1).is_some()); + tick(&mut store); + assert_eq!(sim(&store).peek_start, Some(102)); + + store.apply_entity_data(1, 18, MetaValue::Int(3)); + tick(&mut store); + let s = sim(&store); + assert_eq!( + (s.roll_out_start, s.roll_up_start, s.peek_start), + (Some(103), None, None) + ); + assert!(s.hiding_in_shell()); + for _ in 0..25 { + tick(&mut store); + } + assert!(!sim(&store).hiding_in_shell()); + + store.apply_entity_data(1, 18, MetaValue::Int(1)); + for _ in 0..5 { + tick(&mut store); + } + let s = sim(&store); + assert_eq!((s.roll_out_start, s.roll_up_start), (None, Some(129))); + assert!(!s.hiding_in_shell()); + tick(&mut store); + assert!(sim(&store).hiding_in_shell()); + + store.apply_entity_data(1, 18, MetaValue::Int(7)); + tick(&mut store); + assert_eq!( + sim(&store), + ArmadilloSim { + in_state_ticks: 1, + ..Default::default() + } + ); + } + + /// `Goat.aiStep`: the ram counter climbs one a tick to 20 and falls two. + #[test] + fn goat_ram_events_lower_the_head() { + let mut store = EntityStore::new(); + spawn(&mut store, 1, EntityKind::Goat, Position::default()); + store.set_goat_lowering_head(1, true); + for _ in 0..25 { + tick(&mut store); + } + let goat = &store.living[&1].goat; + assert_eq!(goat.lower_head_tick, 20); + assert_eq!(goat.ramming_x_head_rot(false), 30f32.to_radians()); + store.set_goat_lowering_head(1, false); + tick(&mut store); + assert_eq!(store.living[&1].goat.lower_head_tick, 18); + + store.apply_entity_data(1, 19, MetaValue::Bool(false)); + assert!(!store.living[&1].goat.has_left_horn); + assert!(store.living[&1].goat.has_right_horn); + } + /// `Axolotl.DATA_VARIANT` (18) and `DATA_PLAYING_DEAD` (19): the adult /// ramps its eased playing-dead animator, the baby solos one /// `AnimationState` that keeps its clock while it stays selected. diff --git a/pomme-client/src/net/entity_data.rs b/pomme-client/src/net/entity_data.rs index cd284d7aa..c1fcb176b 100644 --- a/pomme-client/src/net/entity_data.rs +++ b/pomme-client/src/net/entity_data.rs @@ -242,6 +242,9 @@ pub(super) fn from_azalea(value: &EntityDataValue) -> Option { EntityDataValue::SnifferState(state) => Value::Int(*state as i32), EntityDataValue::CopperGolemState(state) => Value::Int(*state as i32), EntityDataValue::WeatheringCopperState(state) => Value::Int(*state as i32), + // TODO: azalea's `ArmadilloStateKind` lacks UNROLLING (id 3), so a + // translated session fails to decode that state. + EntityDataValue::ArmadilloState(state) => Value::Int(*state as i32), _ => return None, }) } diff --git a/pomme-client/src/net/entity_wire.rs b/pomme-client/src/net/entity_wire.rs index 05f7c708e..39b4d4c7a 100644 --- a/pomme-client/src/net/entity_wire.rs +++ b/pomme-client/src/net/entity_wire.rs @@ -170,7 +170,13 @@ fn entity_event(id: i32, event: i8) -> Option { offering: false, }, 56 => NetworkEvent::WolfShaking { id, shaking: false }, + 58 => NetworkEvent::GoatRam { id, lowering: true }, + 59 => NetworkEvent::GoatRam { + id, + lowering: false, + }, 63 => NetworkEvent::SnifferDigging { id }, + 64 => NetworkEvent::ArmadilloPeek { id }, _ => return None, }) } diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 777552e47..02f50ea21 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -563,6 +563,15 @@ pub enum NetworkEvent { SnifferDigging { id: i32, }, + /// Entity event 64: an armadillo peeks out of its shell. + ArmadilloPeek { + id: i32, + }, + /// Entity events 58 / 59: a goat starts / stops lowering its head to ram. + GoatRam { + id: i32, + lowering: bool, + }, SquidTentacleReset { id: i32, }, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index f531f61aa..b27fb99e3 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -3,21 +3,25 @@ use glam::{Mat4, Vec3}; use crate::renderer::chunk::mesher::pack_uv; use crate::renderer::rhi; +mod armadillo; mod axolotl; mod bee; mod copper_golem_anim; mod dolphin; mod fox; +mod goat; mod llama; mod panda; mod parrot; mod polar_bear; mod sniffer_anim; mod turtle; +pub use armadillo::*; pub use axolotl::*; pub use bee::*; pub use dolphin::*; pub use fox::*; +pub use goat::*; pub use llama::*; pub use panda::*; pub use parrot::*; @@ -4125,13 +4129,13 @@ fn vanilla_rot(x: f32, y: f32, z: f32) -> Vec3 { Vec3::new(x, y, z) } -/// Vanilla head look, `Ry(yaw)·Rx(pitch)`. /// The walk swing vanilla models share, `cos(pos * 0.6662 + phase) * 1.4 * /// speed`. fn walk_swing(walk_pos: f32, walk_speed: f32, phase: f32) -> f32 { (walk_pos * 0.6662 + phase).cos() * 1.4 * walk_speed } +/// Vanilla head look, `Ry(yaw)·Rx(pitch)`. fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { vanilla_rot( head_x_rot_deg.to_radians(), diff --git a/pomme-client/src/renderer/entity_model/armadillo.rs b/pomme-client/src/renderer/entity_model/armadillo.rs new file mode 100644 index 000000000..7b06df733 --- /dev/null +++ b/pomme-client/src/renderer/entity_model/armadillo.rs @@ -0,0 +1,1737 @@ +//! Armadillo: `AdultArmadilloModel`/`BabyArmadilloModel` meshes, +//! `ArmadilloModel.setupAnim`, and the `ArmadilloAnimation` / +//! `BabyArmadilloAnimation` keyframe tables (converted mechanically from the +//! decompiled definitions; all-zero channels omitted). + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use glam::Vec3; + +use super::{ + BakedEntityModel, KfAnim, KfChannel, KfDeltas, KfTarget, Kfi, PartAnim, apply_kf_anim, + apply_kf_walk, bake_model, deformed, head_rotation, kf, mirrored, part_index, rotated, vbox, + vpart, +}; + +/// The body's own cubes, split off as a child at the body's pivot so +/// `body.skipDraw` can hide them while the head (also a body child) still +/// draws. Pomme-only; no keyframe channel targets it. +const BODY_SHELL: &str = "body_shell"; + +/// Vanilla `AdultArmadilloModel.createBodyLayer`, 64x64. +pub fn bake_armadillo_model() -> BakedEntityModel { + let leg = |name: &str, x: f32, z: f32, uv: (i32, i32)| { + vpart( + name, + None, + Vec3::new(x, 21.0, z), + vec![vbox(uv, (-1.0, 0.0, -1.0), (2.0, 3.0, 2.0))], + ) + }; + let parts = vec![ + vpart("body", None, Vec3::new(0.0, 21.0, 4.0), vec![]), + vpart( + BODY_SHELL, + Some(0), + Vec3::ZERO, + vec![ + deformed(vbox((0, 20), (-4.0, -7.0, -10.0), (8.0, 8.0, 12.0)), 0.3), + vbox((0, 40), (-4.0, -7.0, -10.0), (8.0, 8.0, 12.0)), + ], + ), + rotated( + vpart( + "tail", + Some(0), + Vec3::new(0.0, -3.0, 1.0), + vec![vbox((44, 53), (-0.5, -0.0865, 0.0933), (1.0, 6.0, 1.0))], + ), + Vec3::new(0.5061, 0.0, 0.0), + ), + vpart("head", Some(0), Vec3::new(0.0, -2.0, -11.0), vec![]), + rotated( + vpart( + "head_cube", + Some(3), + Vec3::ZERO, + vec![vbox((43, 15), (-1.5, -1.0, -1.0), (3.0, 5.0, 2.0))], + ), + Vec3::new(-0.3927, 0.0, 0.0), + ), + vpart("right_ear", Some(3), Vec3::new(-1.0, -1.0, 0.0), vec![]), + rotated( + vpart( + "right_ear_cube", + Some(5), + Vec3::new(-0.5, 0.0, -0.6), + vec![vbox((43, 10), (-2.0, -3.0, 0.0), (2.0, 5.0, 0.0))], + ), + Vec3::new(0.1886, -0.3864, -0.0718), + ), + vpart("left_ear", Some(3), Vec3::new(1.0, -2.0, 0.0), vec![]), + rotated( + vpart( + "left_ear_cube", + Some(7), + Vec3::new(0.5, 1.0, -0.6), + vec![vbox((47, 10), (0.0, -3.0, 0.0), (2.0, 5.0, 0.0))], + ), + Vec3::new(0.1886, 0.3864, 0.0718), + ), + leg("right_hind_leg", -2.0, 4.0, (51, 31)), + leg("left_hind_leg", 2.0, 4.0, (42, 31)), + leg("right_front_leg", -2.0, -4.0, (51, 43)), + leg("left_front_leg", 2.0, -4.0, (42, 43)), + vpart( + "cube", + None, + Vec3::new(0.0, 24.0, 0.0), + vec![vbox((0, 0), (-5.0, -10.0, -6.0), (10.0, 10.0, 10.0))], + ), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyArmadilloModel.createBodyLayer`, 64x64. The names are +/// vanilla's: the tail cube is called `right_ear_cube`, the ears hang off +/// `head_cube`, and the right front leg sits at +x. +pub fn bake_baby_armadillo_model() -> BakedEntityModel { + let parts = vec![ + vpart("body", None, Vec3::new(0.0, 20.0, 0.5), vec![]), + vpart( + BODY_SHELL, + Some(0), + Vec3::ZERO, + vec![ + deformed(vbox((0, 0), (-2.5, -2.0, -3.5), (5.0, 4.0, 7.0)), 0.3), + vbox((0, 11), (-2.5, -2.0, -3.0), (5.0, 4.0, 6.0)), + ], + ), + vpart("tail", Some(0), Vec3::new(0.0, 0.0, 3.4), vec![]), + rotated( + vpart( + "right_ear_cube", + Some(2), + Vec3::new(0.0, 1.5, 1.0), + vec![vbox((22, 11), (-0.5, -0.5, -2.0), (1.0, 1.0, 4.0))], + ), + Vec3::new(-1.0472, 0.0, 0.0), + ), + vpart("head", Some(0), Vec3::new(0.0, 0.0, -3.2), vec![]), + rotated( + vpart( + "head_cube", + Some(4), + Vec3::ZERO, + vec![vbox((20, 17), (-1.0, -2.0, -4.0), (2.0, 2.0, 4.0))], + ), + Vec3::new(0.7417649, 0.0, 0.0), + ), + rotated( + vpart( + "right_ear", + Some(5), + Vec3::new(-1.0, -2.0, -0.3), + vec![mirrored(vbox((28, 8), (-1.8, -2.0, 0.0), (2.0, 3.0, 0.0)))], + ), + Vec3::new(-0.4363, -0.1134, 0.0524), + ), + rotated( + vpart( + "left_ear", + Some(5), + Vec3::new(1.0, -2.0, -0.3), + vec![vbox((28, 8), (-0.2, -2.0, 0.0), (2.0, 3.0, 0.0))], + ), + Vec3::new(-0.4363, 0.1134, -0.0524), + ), + vpart( + "right_hind_leg", + None, + Vec3::new(-1.5, 22.0, 2.5), + vec![mirrored(vbox((20, 27), (-1.0, 0.0, -1.0), (2.0, 2.0, 2.0)))], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(1.5, 22.0, 2.5), + vec![vbox((20, 27), (-1.0, 0.0, -1.0), (2.0, 2.0, 2.0))], + ), + vpart( + "right_front_leg", + None, + Vec3::new(1.5, 22.0, -1.5), + vec![vbox((20, 23), (-1.0, 0.0, -1.0), (2.0, 2.0, 2.0))], + ), + vpart( + "left_front_leg", + None, + Vec3::new(-1.5, 22.0, -1.5), + vec![mirrored(vbox((24, 0), (-1.0, 0.0, -1.0), (2.0, 2.0, 2.0)))], + ), + vpart( + "cube", + None, + Vec3::new(0.0, 20.7, 0.5), + vec![deformed( + vbox((0, 25), (-3.0, -3.0, -3.0), (6.0, 6.0, 6.0)), + 0.3, + )], + ), + ]; + bake_model(parts, 64, 64) +} + +/// `ArmadilloRenderState`: the shell flag and the three `AnimationState` +/// clocks (seconds since start; `None` = stopped). +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct ArmadilloAnims { + pub hiding_in_shell: bool, + pub roll_out: Option, + pub roll_up: Option, + pub peek: Option, +} + +/// One mesh's `ArmadilloModel` constructor arguments. +struct ArmadilloTables { + walk: &'static KfAnim, + roll_out: &'static KfAnim, + roll_up: &'static KfAnim, + peek: &'static KfAnim, +} + +static ADULT: ArmadilloTables = ArmadilloTables { + walk: &ARMADILLO_WALK, + roll_out: &ARMADILLO_ROLL_OUT, + roll_up: &ARMADILLO_ROLL_UP, + peek: &ARMADILLO_PEEK, +}; + +static BABY: ArmadilloTables = ArmadilloTables { + walk: &ARMADILLO_BABY_WALK, + roll_out: &ARMADILLO_BABY_ROLL_OUT, + roll_up: &ARMADILLO_BABY_ROLL_UP, + peek: &ARMADILLO_BABY_PEEK, +}; + +/// Vanilla `ArmadilloModel.setupAnim`, shared by the adult and baby meshes. +/// Hidden parts (`visible = false`, `skipDraw`) collapse to a zero scale. +pub fn compute_armadillo_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + is_baby: bool, + a: &ArmadilloAnims, +) -> PartAnim { + let t = if is_baby { &BABY } else { &ADULT }; + let mut d = KfDeltas::new(model); + if !a.hiding_in_shell { + d.set_rot( + model, + part_index(&model.parts, "head"), + head_rotation( + head_x_rot_deg.clamp(-22.5, 25.0), + local_head_y_rot_deg.clamp(-32.5, 32.5), + ), + ); + apply_kf_walk(t.walk, model, walk_pos, walk_speed, 16.5, 2.5, &mut d); + } + apply_kf_anim(t.roll_out, model, a.roll_out, &mut d); + apply_kf_anim(t.roll_up, model, a.roll_up, &mut d); + apply_kf_anim(t.peek, model, a.peek, &mut d); + let mut anim = d.emit(model); + let hidden: &[&str] = if a.hiding_in_shell { + &[BODY_SHELL, "left_hind_leg", "right_hind_leg", "tail"] + } else { + &["cube"] + }; + for name in hidden { + anim.scale + .push((part_index(&model.parts, name), Vec3::ZERO)); + } + anim +} + +static ARMADILLO_ROLL_UP: KfAnim = { + use Kfi::Lin; + KfAnim { + length: 0.5, + looping: false, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 5.0, 0.0, Lin), + kf(0.2083, 0.0, 6.0, -1.0, Lin), + kf(0.25, 0.0, 6.0, -1.0, Lin), + kf(0.375, 0.0, -1.0, -1.0, Lin), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 0.0, 0.0, Lin), + kf(0.2083, 0.0, 0.0, -2.0, Lin), + kf(0.25, 0.0, 0.0, -2.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 17.5, 0.0, 0.0, Lin), + kf(0.25, -72.5, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, -1.0, 0.0, Lin), + kf(0.2083, 0.0, 2.0, 1.0, Lin), + kf(0.25, 0.0, 2.0, 1.0, Lin), + kf(0.2917, 0.0, 2.0, 6.0, Lin), + kf(0.375, 0.0, 2.0, 7.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, -35.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 5.0, -2.0, Lin), + kf(0.1875, 0.0, 8.0, -3.0, Lin), + kf(0.2083, 0.0, 8.0, -2.0, Lin), + kf(0.25, 0.0, 8.0, -2.0, Lin), + kf(0.375, 1.0, 3.0, -6.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, -45.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 5.0, -2.0, Lin), + kf(0.1875, 0.0, 8.0, -3.0, Lin), + kf(0.2083, 0.0, 8.0, -2.0, Lin), + kf(0.25, 0.0, 8.0, -2.0, Lin), + kf(0.375, -1.0, 3.0, -6.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, -27.5, 0.0, 0.0, Lin), + kf(0.2083, -32.5, 0.0, 0.0, Lin), + kf(0.25, -85.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 5.0, 0.0, Lin), + kf(0.1875, -0.5, 11.5, 0.5, Lin), + kf(0.2083, -1.0, 9.0, -1.0, Lin), + kf(0.25, -1.0, 9.0, -1.0, Lin), + kf(0.375, -1.0, 2.0, 3.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, -12.5, 0.0, 0.0, Lin), + kf(0.2083, -35.0, 0.0, 0.0, Lin), + kf(0.25, -85.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 5.0, 0.0, Lin), + kf(0.1875, 0.5, 11.5, 0.5, Lin), + kf(0.2083, 1.0, 9.0, -1.0, Lin), + kf(0.25, 1.0, 9.0, -1.0, Lin), + kf(0.375, 1.0, 2.0, 3.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.1667, 0.0, 0.0, 0.0, Lin), + kf(0.4167, -2.5, 0.0, 0.0, Lin), + kf(0.4583, 5.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 3.0, 0.0, Lin), + kf(0.1667, 0.0, 8.0, 0.0, Lin), + kf(0.2083, 0.0, 7.0, 0.0, Lin), + kf(0.25, 0.0, 7.0, 0.0, Lin), + kf(0.375, 0.0, 0.0, 0.0, Lin), + kf(0.4167, 0.0, 1.0, 0.0, Lin), + kf(0.4583, 0.0, 0.6, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_WALK: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 1.4583, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.25, 0.0, 0.0, 4.6, Cat), + kf(0.2917, 0.0, 0.0, 6.81, Cat), + kf(0.5, 0.0, 0.0, 0.0, Cat), + kf(0.7083, 0.0, 0.0, 0.0, Cat), + kf(0.9583, 0.0, 0.0, -4.6, Cat), + kf(1.0, 0.0, 0.0, -6.89, Cat), + kf(1.25, 0.0, 0.0, 0.0, Cat), + kf(1.4583, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.25, 0.0, -0.2, 0.0, Cat), + kf(0.5, 0.0, 0.0, 0.0, Cat), + kf(0.7083, 0.0, 0.0, 0.0, Cat), + kf(0.9583, 0.0, -0.2, 0.0, Cat), + kf(1.25, 0.0, 0.0, 0.0, Cat), + kf(1.4583, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.5, -9.17, 0.0, 0.0, Lin), + kf(0.75, 5.0, 0.0, 0.0, Lin), + kf(1.2083, -8.24, 0.0, 0.0, Lin), + kf(1.4583, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -50.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.75, 50.0, 0.0, 0.0, Lin), + kf(1.0, 50.0, 0.0, 0.0, Lin), + kf(1.2917, -20.0, 0.0, 0.0, Lin), + kf(1.4583, -50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, -0.5, Lin), + kf(1.2917, 0.0, 1.0, -0.18, Lin), + kf(1.4583, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 50.0, 0.0, 0.0, Lin), + kf(0.25, 50.0, 0.0, 0.0, Lin), + kf(0.5417, -20.0, 0.0, 0.0, Lin), + kf(0.7083, -50.0, 0.0, 0.0, Lin), + kf(0.9583, 0.0, 0.0, 0.0, Lin), + kf(1.2083, 0.0, 0.0, 0.0, Lin), + kf(1.4583, 50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, -0.25, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5417, 0.0, 1.0, -0.18, Lin), + kf(0.7083, 0.0, 0.0, 0.0, Lin), + kf(0.9583, 0.0, 0.0, -0.5, Lin), + kf(1.2083, 0.0, 0.0, 0.0, Lin), + kf(1.4583, 0.0, 0.0, -0.25, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 50.0, 0.0, 0.0, Lin), + kf(0.2917, 50.0, 0.0, 0.0, Lin), + kf(0.5417, -20.0, 0.0, 0.0, Lin), + kf(0.7083, -50.0, 0.0, 0.0, Lin), + kf(0.9583, 0.0, 0.0, 0.0, Lin), + kf(1.2083, 0.0, 0.0, 0.0, Lin), + kf(1.4583, 50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, -0.25, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5417, 0.0, 1.0, -0.18, Lin), + kf(0.7083, 0.0, 0.0, 0.0, Lin), + kf(0.9583, 0.0, 0.0, -0.5, Lin), + kf(1.2083, 0.0, 0.0, 0.0, Lin), + kf(1.4583, 0.0, 0.0, -0.25, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -50.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.75, 50.0, 0.0, 0.0, Lin), + kf(1.0, 50.0, 0.0, 0.0, Lin), + kf(1.2917, -20.0, 0.0, 0.0, Lin), + kf(1.4583, -50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, -0.5, Lin), + kf(1.2917, 0.0, 1.0, -0.18, Lin), + kf(1.4583, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -2.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.7083, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, 2.5, Lin), + kf(1.25, 0.0, 0.0, 0.0, Lin), + kf(1.4583, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_PEEK: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 2.5, + looping: false, + channels: &[ + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -70.0, 0.0, 0.0, Lin), + kf(0.15, -65.0, 0.0, 0.0, Lin), + kf(0.4, -50.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.9, -7.5, 0.0, 45.0, Cat), + kf(1.15, -7.5, 0.0, 45.0, Lin), + kf(1.3, -0.8639, -1.4959, -39.1287, Cat), + kf(1.6, -0.8639, -1.4959, -39.1287, Lin), + kf(1.75, 0.0, 0.0, 0.0, Lin), + kf(1.8, -25.0, 0.0, 0.0, Lin), + kf(1.85, -70.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 1.0, 7.0, Lin), + kf(0.05, 0.0, 1.0, 4.0, Lin), + kf(0.15, 0.0, 1.0, 4.0, Lin), + kf(0.25, 0.0, 1.0, 5.0, Lin), + kf(0.35, 0.0, 1.0, 0.2, Lin), + kf(0.4, 0.0, 1.0, 0.2, Lin), + kf(0.5, 0.0, 2.1, 1.2, Lin), + kf(0.6, 0.0, 1.1, 0.2, Lin), + kf(0.7, 0.0, 1.1, 0.2, Lin), + kf(1.75, 0.0, 1.1, 0.2, Lin), + kf(1.8, 0.0, 0.1, 0.2, Lin), + kf(1.95, 0.0, 0.1, 5.2, Lin), + kf(2.0, 0.0, 0.1, 7.2, Lin), + kf(2.15, 0.0, 0.1, 8.2, Lin), + kf(2.3, 0.0, 0.1, 5.2, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 3.0, -2.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 3.0, -2.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Lin), + kf(0.5833, -90.0, 0.0, 0.0, Cat), + kf(0.8333, -45.0, 0.0, 0.0, Cat), + kf(1.0, -45.0, 0.0, 0.0, Cat), + kf(1.75, -45.0, 0.0, 0.0, Cat), + kf(1.8, -90.0, 0.0, 0.0, Cat), + kf(1.95, -90.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -1.0, 2.0, 2.0, Lin), + kf(0.5833, -1.0, 2.0, 2.0, Cat), + kf(0.6667, -1.0, 2.0, -2.0, Cat), + kf(0.8333, -1.0, 2.0, -1.0, Cat), + kf(1.0, -1.0, 2.0, -1.0, Cat), + kf(1.75, -1.0, 2.0, -1.0, Cat), + kf(1.95, -1.0, 2.0, -1.0, Cat), + kf(2.0, -1.0, 2.0, 3.0, Cat), + kf(2.15, -1.0, 3.0, 4.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Lin), + kf(0.6667, -90.0, 0.0, 0.0, Cat), + kf(0.8333, -45.0, 0.0, 0.0, Cat), + kf(1.0, -45.0, 0.0, 0.0, Cat), + kf(1.75, -45.0, 0.0, 0.0, Cat), + kf(1.8, -90.0, 0.0, 0.0, Cat), + kf(1.95, -90.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 1.0, 2.0, 2.0, Lin), + kf(0.6667, 1.0, 2.0, 2.0, Cat), + kf(0.75, 1.0, 2.0, -2.0, Cat), + kf(0.8333, 1.0, 2.0, -1.0, Cat), + kf(1.0, 1.0, 2.0, -1.0, Cat), + kf(1.75, 1.0, 2.0, -1.0, Cat), + kf(1.95, 1.0, 2.0, -1.0, Cat), + kf(2.0, 1.0, 2.0, 3.0, Cat), + kf(2.15, 1.0, 3.0, 4.0, Cat), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.35, 15.0, 0.0, 0.0, Lin), + kf(0.5, -7.5, 0.0, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(2.0, 0.0, 0.0, 0.0, Lin), + kf(2.05, -17.5, 0.0, 0.0, Lin), + kf(2.15, -25.0, 0.0, 0.0, Lin), + kf(2.25, 0.0, 0.0, 0.0, Lin), + kf(2.3, 12.5, 0.0, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.35, 0.0, 1.6, 0.0, Lin), + kf(0.5, 0.0, 0.5, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(2.0, 0.0, 0.0, 0.0, Lin), + kf(2.05, 0.0, 1.2, 0.0, Lin), + kf(2.15, 0.0, 1.7, 0.0, Lin), + kf(2.25, 0.0, 0.0, 0.0, Lin), + kf(2.3, 0.0, 1.3, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_ROLL_OUT: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 1.5, + looping: false, + channels: &[ + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.1, -50.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, 0.0, 0.0, Lin), + kf(0.65, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.85, -2.5, 0.0, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, -7.5, 0.0, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.1, 7.5, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 1.0, 5.0, Lin), + kf(0.05, 0.0, 1.0, 0.2, Lin), + kf(0.1, 0.0, 1.0, 0.2, Lin), + kf(0.15, 0.0, 2.1, 1.2, Lin), + kf(0.25, 0.0, 1.03, 0.13, Lin), + kf(0.4, 0.0, 1.03, 0.13, Lin), + kf(0.65, 0.0, 1.03, 0.13, Lin), + kf(0.7, 0.0, 1.1, 0.2, Lin), + kf(0.75, 0.0, 4.1, 2.2, Lin), + kf(0.85, 0.0, 5.1, 3.2, Lin), + kf(0.9, 0.0, 0.1, 0.2, Lin), + kf(0.95, 0.0, 0.9, -0.8, Lin), + kf(1.05, 0.0, 0.9, 0.0, Lin), + kf(1.1, 0.0, 2.6, 0.2, Lin), + kf(1.15, 0.0, 2.4, 0.2, Lin), + kf(1.2, 0.0, 0.0, 0.2, Lin), + kf(1.25, 0.0, 0.0, 0.2, Lin), + kf(1.3, 0.0, 0.0, 0.2, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.3, 0.0, 0.0, 30.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 3.0, -2.0, Lin), + kf(1.2, 0.0, 8.0, -2.0, Lin), + kf(1.3, -1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.3, 0.0, 0.0, -30.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 3.0, -2.0, Lin), + kf(1.2, 0.0, 8.0, -2.0, Lin), + kf(1.3, 1.0, 3.0, 0.0, Lin), + kf(1.35, 1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Cat), + kf(0.05, -90.0, 0.0, 0.0, Cat), + kf(0.25, -45.0, 0.0, 0.0, Lin), + kf(0.55, -45.0, 0.0, 0.0, Lin), + kf(0.6, -92.5, 0.0, 0.0, Cat), + kf(1.1, -90.0, 0.0, 0.0, Cat), + kf(1.3, 0.0, 0.0, 30.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -1.0, 2.0, 2.0, Lin), + kf(0.05, -1.0, 2.0, 2.0, Cat), + kf(0.25, -1.0, 2.0, -1.0, Lin), + kf(0.55, -1.0, 2.0, -1.0, Lin), + kf(0.7, -1.0, 2.0, 2.63, Cat), + kf(1.1, -1.0, 2.0, 2.0, Lin), + kf(1.2, -1.0, 7.0, 2.0, Lin), + kf(1.3, -1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Cat), + kf(0.05, -90.0, 0.0, 0.0, Cat), + kf(0.25, -45.0, 0.0, 0.0, Lin), + kf(0.55, -45.0, 0.0, 0.0, Lin), + kf(0.6, -87.5, 0.0, 0.0, Cat), + kf(1.1, -90.0, 0.0, 0.0, Cat), + kf(1.3, 0.0, 0.0, -30.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 1.0, 2.0, 2.0, Cat), + kf(0.05, 1.0, 2.0, 2.0, Cat), + kf(0.15, 1.0, 2.0, 0.0, Cat), + kf(0.25, 1.0, 2.0, -1.0, Lin), + kf(0.55, 1.0, 2.0, -1.0, Lin), + kf(0.7, 1.0, 2.0, 1.88, Cat), + kf(0.75, 1.0, 2.0, 2.67, Cat), + kf(1.1, 1.0, 2.0, 2.0, Cat), + kf(1.2, 1.0, 8.0, 2.0, Cat), + kf(1.25, 1.06, 5.06, 1.0, Cat), + kf(1.3, 1.0, 3.0, 0.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.05, 15.0, 0.0, 0.0, Lin), + kf(0.15, -7.5, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.75, -17.5, 0.0, 0.0, Lin), + kf(0.85, -25.0, 0.0, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, 12.5, 0.0, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.1, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.05, 0.0, 1.6, 0.0, Lin), + kf(0.15, 0.0, 0.5, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.75, 0.0, 1.2, 0.0, Lin), + kf(0.85, 0.0, 1.7, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 1.3, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 5.0, 0.0, Lin), + kf(1.25, 0.0, 8.0, 0.0, Lin), + kf(1.5, 0.0, 1.0, 0.0, Lin), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 4.0, 0.0, Lin), + kf(1.25, 0.0, 5.0, 0.0, Lin), + kf(1.3, 0.0, 4.0, 0.0, Lin), + kf(1.4, 0.0, -1.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_BABY_ROLL_UP: KfAnim = { + use Kfi::Lin; + KfAnim { + length: 0.5, + looping: false, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 5.0, 0.0, Lin), + kf(0.2, 0.0, 6.0, -1.0, Lin), + kf(0.25, 0.0, 6.0, -1.0, Lin), + kf(0.4, 0.0, -1.0, -1.0, Lin), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 0.0, 0.0, Lin), + kf(0.2, 0.0, 1.0, -3.0, Lin), + kf(0.25, 0.0, 1.0, -3.0, Lin), + kf(0.4, 0.0, 2.0, -3.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 17.5, 0.0, 0.0, Lin), + kf(0.25, -72.5, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, -1.0, 0.0, Lin), + kf(0.2, 0.0, 0.0, 1.0, Lin), + kf(0.25, 0.0, 0.0, 1.0, Lin), + kf(0.3, 0.0, 0.0, 5.0, Lin), + kf(0.4, 0.0, 0.0, 5.0, Lin), + kf(0.5, 0.0, -0.25, 5.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin), kf(0.15, -35.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 5.0, -2.0, Lin), + kf(0.2, 0.0, 8.0, -2.0, Lin), + kf(0.25, 0.0, 8.0, -2.0, Lin), + kf(0.4, 1.0, 3.0, -2.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[kf(0.0, 0.0, 0.0, 0.0, Lin), kf(0.15, -45.0, 0.0, 0.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 5.0, -2.0, Lin), + kf(0.2, 0.0, 8.0, -2.0, Lin), + kf(0.25, 0.0, 8.0, -2.0, Lin), + kf(0.4, -1.0, 3.0, -3.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, -27.5, 0.0, 0.0, Lin), + kf(0.2, -32.5, 0.0, 0.0, Lin), + kf(0.25, -85.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 5.0, 0.0, Lin), + kf(0.2, -1.0, 8.0, -1.0, Lin), + kf(0.25, -1.0, 9.0, -1.0, Lin), + kf(0.4, -1.0, 2.0, 3.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, -12.5, 0.0, 0.0, Lin), + kf(0.2, -35.0, 0.0, 0.0, Lin), + kf(0.25, -85.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 5.0, 0.0, Lin), + kf(0.2, 1.0, 8.0, -1.0, Lin), + kf(0.25, 1.0, 9.0, -1.0, Lin), + kf(0.4, 1.0, 2.0, 3.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 0.0, 0.0, Lin), + kf(0.4, -2.5, 0.0, 0.0, Lin), + kf(0.45, 5.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 3.0, 0.0, Lin), + kf(0.15, 0.0, 8.0, 0.0, Lin), + kf(0.2, 0.0, 7.0, 0.0, Lin), + kf(0.25, 0.0, 7.0, 0.0, Lin), + kf(0.4, 0.0, 1.0, 0.0, Lin), + kf(0.45, 0.0, 0.6, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_BABY_WALK: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 1.4583, + looping: true, + channels: &[ + KfChannel { + part: "body", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.25, 0.0, 0.0, 4.6, Cat), + kf(0.3, 0.0, 0.0, 6.81, Cat), + kf(0.5, 0.0, 0.0, 0.0, Cat), + kf(0.7, 0.0, 0.0, 0.0, Cat), + kf(0.95, 0.0, 0.0, -4.6, Cat), + kf(1.0, 0.0, 0.0, -6.89, Cat), + kf(1.25, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Cat), + kf(0.25, 0.0, -0.2, 0.0, Cat), + kf(0.5, 0.0, 0.0, 0.0, Cat), + kf(0.7, 0.0, 0.0, 0.0, Cat), + kf(0.95, 0.0, -0.2, 0.0, Cat), + kf(1.25, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "tail", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.5, -9.17, 0.0, 0.0, Lin), + kf(0.75, 5.0, 0.0, 0.0, Lin), + kf(1.2, -8.24, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -50.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.75, 50.0, 0.0, 0.0, Lin), + kf(1.0, 50.0, 0.0, 0.0, Lin), + kf(1.3, -20.0, 0.0, 0.0, Lin), + kf(1.45, -50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, -0.5, Lin), + kf(1.3, 0.0, 1.0, -0.18, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 50.0, 0.0, 0.0, Lin), + kf(0.25, 50.0, 0.0, 0.0, Lin), + kf(0.55, -20.0, 0.0, 0.0, Lin), + kf(0.7, -50.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 0.0, 0.0, Lin), + kf(1.45, 50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, -0.25, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.55, 0.0, 1.0, -0.18, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 0.0, -0.5, Lin), + kf(1.2, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, -0.25, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 50.0, 0.0, 0.0, Lin), + kf(0.3, 50.0, 0.0, 0.0, Lin), + kf(0.55, -20.0, 0.0, 0.0, Lin), + kf(0.7, -50.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 0.0, 0.0, Lin), + kf(1.45, 50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, -0.25, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.55, 0.0, 1.0, -0.18, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 0.0, -0.5, Lin), + kf(1.2, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, -0.25, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -50.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.75, 50.0, 0.0, 0.0, Lin), + kf(1.0, 50.0, 0.0, 0.0, Lin), + kf(1.3, -20.0, 0.0, 0.0, Lin), + kf(1.45, -50.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -0.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, -0.5, Lin), + kf(1.3, 0.0, 1.0, -0.18, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, -2.5, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(1.0, 0.0, 0.0, 2.5, Lin), + kf(1.25, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_BABY_ROLL_OUT: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 1.5, + looping: false, + channels: &[ + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.1, -50.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, 0.0, 0.0, Lin), + kf(0.65, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.85, -2.5, 0.0, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, -7.5, 0.0, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.1, 7.5, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, -1.9, 5.0, Lin), + kf(0.05, 0.0, -1.0, 0.2, Lin), + kf(0.1, 0.0, 0.0, 0.2, Lin), + kf(0.15, 0.0, 2.1, 1.2, Lin), + kf(0.25, 0.0, 0.03, 0.13, Lin), + kf(0.4, 0.0, 0.03, 0.13, Lin), + kf(0.65, 0.0, 0.03, 0.13, Lin), + kf(0.7, 0.0, 0.1, 0.2, Lin), + kf(0.75, 0.0, 3.1, 2.2, Lin), + kf(0.85, 0.0, 4.1, 2.2, Lin), + kf(0.9, 0.0, 0.1, 0.2, Lin), + kf(0.95, 0.0, 0.9, -0.8, Lin), + kf(1.05, 0.0, 0.9, 0.0, Lin), + kf(1.1, 0.0, 2.6, 0.2, Lin), + kf(1.15, 0.0, 2.4, 0.2, Lin), + kf(1.2, 0.0, 0.0, 0.2, Lin), + kf(1.25, 0.0, 0.0, 0.2, Lin), + kf(1.3, 0.0, 0.0, 0.2, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.3, 0.0, 0.0, 30.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 3.0, -2.0, Lin), + kf(1.2, 0.0, 8.0, -2.0, Lin), + kf(1.3, -1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Rot, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.3, 0.0, 0.0, -30.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 3.0, -2.0, Lin), + kf(1.2, 0.0, 8.0, -2.0, Lin), + kf(1.3, 1.0, 3.0, 0.0, Lin), + kf(1.35, 1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Cat), + kf(0.05, -90.0, 0.0, 0.0, Cat), + kf(0.25, -45.0, 0.0, 0.0, Lin), + kf(0.55, -45.0, 0.0, 0.0, Lin), + kf(0.6, -92.5, 0.0, 0.0, Cat), + kf(1.1, -90.0, 0.0, 0.0, Cat), + kf(1.3, 0.0, 0.0, 30.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -1.0, 2.0, 2.0, Lin), + kf(0.05, -1.0, 2.0, 2.0, Cat), + kf(0.25, 0.0, 2.0, -1.0, Lin), + kf(0.55, 0.0, 2.0, -1.0, Lin), + kf(0.7, -1.0, 2.0, 2.63, Cat), + kf(1.1, -1.0, 2.0, 2.0, Lin), + kf(1.2, -1.0, 7.0, 2.0, Lin), + kf(1.3, -1.0, 3.0, 0.0, Lin), + kf(1.4, 0.0, 0.0, 0.0, Lin), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Cat), + kf(0.05, -90.0, 0.0, 0.0, Cat), + kf(0.25, -45.0, 0.0, 0.0, Lin), + kf(0.55, -45.0, 0.0, 0.0, Lin), + kf(0.6, -87.5, 0.0, 0.0, Cat), + kf(1.1, -90.0, 0.0, 0.0, Cat), + kf(1.3, 0.0, 0.0, -30.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 1.0, 2.0, 2.0, Cat), + kf(0.05, 1.0, 2.0, 2.0, Cat), + kf(0.15, 1.0, 2.0, 0.0, Cat), + kf(0.25, 0.0, 2.0, -1.0, Lin), + kf(0.55, 0.0, 2.0, -1.0, Lin), + kf(0.7, 1.0, 2.0, 1.88, Cat), + kf(0.75, 1.0, 2.0, 2.67, Cat), + kf(1.1, 1.0, 2.0, 2.0, Cat), + kf(1.2, 1.0, 8.0, 2.0, Cat), + kf(1.25, 1.06, 5.06, 1.0, Cat), + kf(1.3, 1.0, 3.0, 0.0, Cat), + kf(1.4, 0.0, 0.0, 0.0, Cat), + kf(1.45, 0.0, 0.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "body", + target: KfTarget::Pos, + frames: &[ + kf(1.1, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 4.0, 0.0, Lin), + kf(1.25, 0.0, 5.0, 0.0, Lin), + kf(1.3, 0.0, 4.0, 0.0, Lin), + kf(1.4, 0.0, -1.0, 0.0, Lin), + kf(1.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_ear", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, -1.4129, 0.0617, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_ear", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.15, 0.0, -1.4129, 0.0617, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.05, 15.0, 0.0, 0.0, Lin), + kf(0.15, -7.5, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.75, -17.5, 0.0, 0.0, Lin), + kf(0.85, -25.0, 0.0, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, 12.5, 0.0, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.1, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.05, 0.0, 1.6, 0.0, Lin), + kf(0.15, 0.0, 0.5, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.75, 0.0, 1.2, 0.0, Lin), + kf(0.85, 0.0, 1.7, 0.0, Lin), + kf(0.9, 0.0, 0.0, 0.0, Lin), + kf(0.95, 0.0, 1.3, 0.0, Lin), + kf(1.05, 0.0, 0.0, 0.0, Lin), + kf(1.2, 0.0, 5.0, 0.0, Lin), + kf(1.25, 0.0, 8.0, 0.0, Lin), + kf(1.5, 0.0, 1.0, 0.0, Lin), + ], + }, + ], + } +}; + +static ARMADILLO_BABY_PEEK: KfAnim = { + use Kfi::{Cat, Lin}; + KfAnim { + length: 2.5, + looping: false, + channels: &[ + KfChannel { + part: "head", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -70.0, 0.0, 0.0, Lin), + kf(0.15, -65.0, 0.0, 0.0, Lin), + kf(0.4, -50.0, 0.0, 0.0, Lin), + kf(0.5, 0.0, 0.0, 0.0, Lin), + kf(0.7, 0.0, 0.0, 0.0, Lin), + kf(0.9, -7.5, 0.0, 45.0, Cat), + kf(1.15, -7.5, 0.0, 45.0, Lin), + kf(1.3, -0.8639, -1.4959, -39.1287, Cat), + kf(1.6, -0.8639, -1.4959, -39.1287, Lin), + kf(1.75, 0.0, 0.0, 0.0, Lin), + kf(1.8, -25.0, 0.0, 0.0, Lin), + kf(1.85, -70.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "head", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, -1.25, 4.0, Lin), + kf(0.1, 0.0, -1.0, 4.0, Lin), + kf(0.15, 0.0, -1.0, 4.0, Lin), + kf(0.25, 0.0, -1.0, 5.0, Lin), + kf(0.35, 0.0, 0.0, 0.2, Lin), + kf(0.4, 0.0, 0.0, 0.2, Lin), + kf(0.45, 0.0, 0.05, 0.7, Lin), + kf(0.5, 0.0, 1.1, 1.2, Lin), + kf(0.6, 0.0, 0.1, 0.8, Lin), + kf(0.7, 0.0, 0.1, 0.7, Lin), + kf(1.75, 0.0, 0.1, 0.7, Lin), + kf(1.8, 0.0, 0.1, 0.2, Lin), + kf(1.9, 0.0, -1.2, 3.53, Lin), + kf(1.95, 0.0, -1.2, 3.53, Lin), + kf(2.0, 0.0, -1.2, 3.53, Lin), + kf(2.15, 0.0, -0.9, 4.2, Lin), + kf(2.3, 0.0, -0.9, 3.4, Lin), + kf(2.5, 0.0, -1.9, 4.1, Lin), + ], + }, + KfChannel { + part: "right_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 3.0, -2.0, Lin)], + }, + KfChannel { + part: "left_hind_leg", + target: KfTarget::Pos, + frames: &[kf(0.0, 0.0, 3.0, -2.0, Lin)], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Lin), + kf(0.6, -90.0, 0.0, 0.0, Cat), + kf(0.85, -45.0, 0.0, 0.0, Cat), + kf(1.0, -45.0, 0.0, 0.0, Cat), + kf(1.75, -45.0, 0.0, 0.0, Cat), + kf(1.8, -90.0, 0.0, 0.0, Cat), + kf(1.95, -90.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "right_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, -1.0, 2.0, 2.0, Lin), + kf(0.6, -1.0, 2.0, 2.0, Cat), + kf(0.65, -1.0, 2.0, 1.0, Cat), + kf(0.85, 0.0, 1.0, -0.5, Cat), + kf(1.0, 0.0, 1.0, -0.5, Cat), + kf(1.75, 0.0, 1.0, -0.5, Cat), + kf(1.95, 0.0, 1.0, 3.0, Cat), + kf(2.0, -1.0, 2.0, 3.0, Cat), + kf(2.15, -1.0, 3.0, 4.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Rot, + frames: &[ + kf(0.0, -90.0, 0.0, 0.0, Lin), + kf(0.65, -90.0, 0.0, 0.0, Cat), + kf(0.85, -45.0, 0.0, 0.0, Cat), + kf(1.0, -45.0, 0.0, 0.0, Cat), + kf(1.75, -45.0, 0.0, 0.0, Cat), + kf(1.8, -90.0, 0.0, 0.0, Cat), + kf(1.95, -90.0, 0.0, 0.0, Cat), + ], + }, + KfChannel { + part: "left_front_leg", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 1.0, 2.0, 2.0, Lin), + kf(0.65, 1.0, 2.0, 2.0, Cat), + kf(0.85, 0.0, 1.0, -0.4, Cat), + kf(1.0, 0.0, 1.0, -0.4, Cat), + kf(1.75, 0.0, 1.0, -0.4, Cat), + kf(1.95, 1.0, 1.0, 2.0, Cat), + kf(2.0, 1.0, 2.0, 3.0, Cat), + kf(2.15, 1.0, 3.0, 4.0, Cat), + ], + }, + KfChannel { + part: "left_ear", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, 0.0, 0.0, Lin), + kf(0.5, -15.0, 0.0, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(1.8, 0.0, 0.0, 0.0, Lin), + kf(1.85, -10.0, 0.0, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "left_ear", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, -1.13, 1.05, Lin), + kf(0.5, 0.0, -1.4129, 0.0617, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_ear", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, 0.0, 0.0, Lin), + kf(0.5, -15.0, 0.0, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(1.8, 0.0, 0.0, 0.0, Lin), + kf(1.85, -10.0, 0.0, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "right_ear", + target: KfTarget::Pos, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.4, 0.0, -1.13, 1.05, Lin), + kf(0.5, 0.0, -1.4129, 0.0617, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Rot, + frames: &[ + kf(0.0, 0.0, 0.0, 0.0, Lin), + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.35, 15.0, 0.0, 0.0, Lin), + kf(0.5, -7.5, 0.0, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(2.0, 0.0, 0.0, 0.0, Lin), + kf(2.05, -17.5, 0.0, 0.0, Lin), + kf(2.15, -25.0, 0.0, 0.0, Lin), + kf(2.25, 0.0, 0.0, 0.0, Lin), + kf(2.3, 12.5, 0.0, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + KfChannel { + part: "cube", + target: KfTarget::Pos, + frames: &[ + kf(0.25, 0.0, 0.0, 0.0, Lin), + kf(0.35, 0.0, 1.6, 0.0, Lin), + kf(0.5, 0.0, 0.5, 0.0, Lin), + kf(0.6, 0.0, 0.0, 0.0, Lin), + kf(2.0, 0.0, 0.0, 0.0, Lin), + kf(2.05, 0.0, 1.2, 0.0, Lin), + kf(2.15, 0.0, 1.7, 0.0, Lin), + kf(2.25, 0.0, 0.0, 0.0, Lin), + kf(2.3, 0.0, 1.3, 0.0, Lin), + kf(2.5, 0.0, 0.0, 0.0, Lin), + ], + }, + ], + } +}; + +#[cfg(test)] +mod tests { + use super::*; + + /// `KeyframeAnimation.bake` throws on a channel naming a part the model + /// lacks; every table must resolve against its own mesh. + #[test] + fn every_channel_names_a_part_of_its_mesh() { + for (model, t) in [ + (bake_armadillo_model(), &ADULT), + (bake_baby_armadillo_model(), &BABY), + ] { + for anim in [t.walk, t.roll_out, t.roll_up, t.peek] { + for ch in anim.channels { + part_index(&model.parts, ch.part); + } + } + } + } + + /// In the shell the cube replaces the body's own cubes, hind legs and + /// tail; out of it only the cube hides. + #[test] + fn shell_swaps_body_for_cube() { + let model = bake_armadillo_model(); + let hidden = |hiding_in_shell| { + let a = ArmadilloAnims { + hiding_in_shell, + ..Default::default() + }; + let anim = compute_armadillo_anim(&model, 0.0, 0.0, 0.0, 0.0, false, &a); + let mut names: Vec<_> = anim + .scale + .iter() + .filter(|(_, s)| *s == Vec3::ZERO) + .map(|&(i, _)| model.parts[i].name.as_str()) + .collect(); + names.sort_unstable(); + names + }; + assert_eq!( + hidden(true), + [BODY_SHELL, "left_hind_leg", "right_hind_leg", "tail"] + ); + assert_eq!(hidden(false), ["cube"]); + } +} diff --git a/pomme-client/src/renderer/entity_model/goat.rs b/pomme-client/src/renderer/entity_model/goat.rs new file mode 100644 index 000000000..f280083ca --- /dev/null +++ b/pomme-client/src/renderer/entity_model/goat.rs @@ -0,0 +1,258 @@ +//! Goat: `GoatModel`/`BabyGoatModel` meshes and their `setupAnim` over +//! `QuadrupedModel`'s. + +// Vanilla's radian literals are kept verbatim, not swapped for `consts`. +#![allow(clippy::approx_constant)] + +use glam::Vec3; + +use super::{ + BakedEntityModel, PartAnim, bake_model, compute_quadruped_anim, mirrored, part_index, rotated, + vbox, vpart, +}; + +/// Vanilla `GoatModel.createBodyLayer`, 64x64. The head's `.mirror()` carries +/// over to the goatee (`CubeListBuilder` keeps the flag). +pub fn bake_goat_model() -> BakedEntityModel { + let parts = vec![ + vpart( + "head", + None, + Vec3::new(1.0, 14.0, 0.0), + vec![ + vbox((2, 61), (-6.0, -11.0, -10.0), (3.0, 2.0, 1.0)), + mirrored(vbox((2, 61), (2.0, -11.0, -10.0), (3.0, 2.0, 1.0))), + mirrored(vbox((23, 52), (-0.5, -3.0, -14.0), (0.0, 7.0, 5.0))), + ], + ), + vpart( + "left_horn", + Some(0), + Vec3::ZERO, + vec![vbox((12, 55), (-0.01, -16.0, -10.0), (2.0, 7.0, 2.0))], + ), + vpart( + "right_horn", + Some(0), + Vec3::ZERO, + vec![vbox((12, 55), (-2.99, -16.0, -10.0), (2.0, 7.0, 2.0))], + ), + rotated( + vpart( + "nose", + Some(0), + Vec3::new(0.0, -8.0, -8.0), + vec![vbox((34, 46), (-3.0, -4.0, -8.0), (5.0, 7.0, 10.0))], + ), + Vec3::new(0.9599, 0.0, 0.0), + ), + vpart( + "body", + None, + Vec3::new(0.0, 24.0, 0.0), + vec![ + vbox((1, 1), (-4.0, -17.0, -7.0), (9.0, 11.0, 16.0)), + vbox((0, 28), (-5.0, -18.0, -8.0), (11.0, 14.0, 11.0)), + ], + ), + vpart( + "left_hind_leg", + None, + Vec3::new(1.0, 14.0, 4.0), + vec![vbox((36, 29), (0.0, 4.0, 0.0), (3.0, 6.0, 3.0))], + ), + vpart( + "right_hind_leg", + None, + Vec3::new(-3.0, 14.0, 4.0), + vec![vbox((49, 29), (0.0, 4.0, 0.0), (3.0, 6.0, 3.0))], + ), + vpart( + "left_front_leg", + None, + Vec3::new(1.0, 14.0, -6.0), + vec![vbox((49, 2), (0.0, 0.0, 0.0), (3.0, 10.0, 3.0))], + ), + vpart( + "right_front_leg", + None, + Vec3::new(-3.0, 14.0, -6.0), + vec![vbox((35, 2), (0.0, 0.0, 0.0), (3.0, 10.0, 3.0))], + ), + ]; + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyGoatModel.createBodyLayer`, 64x64. +pub fn bake_baby_goat_model() -> BakedEntityModel { + let leg = |name: &str, x: f32, z: f32, tex: (i32, i32)| { + vpart( + name, + None, + Vec3::new(x, 19.5, z), + vec![vbox(tex, (-1.0, -0.5, -1.0), (2.0, 5.0, 2.0))], + ) + }; + let horn_rot = Vec3::new(-0.3926991, 0.0, 0.0); + let parts = vec![ + leg("left_hind_leg", 1.5, 3.0, (29, 12)), + leg("right_hind_leg", -1.5, 3.0, (21, 12)), + leg("right_front_leg", -1.5, -2.0, (21, 5)), + leg("left_front_leg", 1.5, -2.0, (29, 5)), + vpart( + "body", + None, + Vec3::new(0.0, 17.8, 0.0), + vec![ + vbox((0, 10), (-3.0, -2.3, -4.5), (6.0, 5.0, 9.0)), + vbox((0, 24), (-2.5, -2.2, -4.0), (5.0, 4.0, 8.0)), + ], + ), + rotated( + vpart( + "head", + None, + Vec3::new(0.0, 15.5, -3.0), + vec![vbox((0, 0), (-2.0, -3.8126, -5.1548), (4.0, 4.0, 6.0))], + ), + Vec3::new(0.4363, 0.0, 0.0), + ), + rotated( + vpart( + "right_horn", + Some(5), + Vec3::new(-1.5, -1.5, -1.0), + vec![mirrored(vbox((24, 0), (0.0, -4.5, 0.0), (1.0, 2.0, 1.0)))], + ), + horn_rot, + ), + rotated( + vpart( + "left_horn", + Some(5), + Vec3::new(-1.5, -1.5, -1.0), + vec![mirrored(vbox((24, 0), (2.0, -4.5, 0.0), (1.0, 2.0, 1.0)))], + ), + horn_rot, + ), + rotated( + vpart( + "right_ear", + Some(5), + Vec3::new(-1.7, -2.3126, 0.1452), + vec![mirrored(vbox((0, 12), (-2.0, -0.5, -0.5), (2.0, 1.0, 1.0)))], + ), + Vec3::new(0.0, -0.5236, 0.0), + ), + rotated( + vpart( + "left_ear", + Some(5), + Vec3::new(1.7, -2.3126, 0.1452), + vec![vbox((0, 12), (0.0, -0.5, -0.5), (2.0, 1.0, 1.0))], + ), + Vec3::new(0.0, 0.5236, 0.0), + ), + vpart( + "HeadMain", + Some(5), + Vec3::new(0.0, -1.3126, -1.1548), + vec![vbox((0, 0), (-2.0, -2.5, -4.0), (4.0, 4.0, 6.0))], + ), + ]; + bake_model(parts, 64, 64) +} + +/// `GoatRenderState` (vanilla defaults: both horns present). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GoatAnims { + pub has_left_horn: bool, + pub has_right_horn: bool, + /// `Goat.getRammingXHeadRot`, radians. + pub ramming_x_head_rot: f32, +} + +impl Default for GoatAnims { + fn default() -> Self { + Self { + has_left_horn: true, + has_right_horn: true, + ramming_x_head_rot: 0.0, + } + } +} + +/// `BabyGoatModel.setupAnim`'s head pitch while not ramming (22.5 degrees). +const BABY_HEAD_X_ROT: f32 = 0.3926991; + +/// Vanilla `GoatModel.setupAnim` (and `BabyGoatModel`'s override) over +/// `QuadrupedModel.setupAnim`. A missing horn collapses to a zero scale. +pub fn compute_goat_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + is_baby: bool, + g: &GoatAnims, +) -> PartAnim { + let mut anim = compute_quadruped_anim( + model, + head_x_rot_deg, + local_head_y_rot_deg, + walk_pos, + walk_speed, + 0.0, + None, + ); + let head_x = if g.ramming_x_head_rot != 0.0 { + Some(g.ramming_x_head_rot) + } else if is_baby { + Some(BABY_HEAD_X_ROT) + } else { + None + }; + if let Some(head_x) = head_x { + let head = part_index(&model.parts, "head"); + anim.rotation_mut(model, head).x = head_x; + } + for (name, present) in [ + ("left_horn", g.has_left_horn), + ("right_horn", g.has_right_horn), + ] { + if !present { + anim.scale + .push((part_index(&model.parts, name), Vec3::ZERO)); + } + } + anim +} + +#[cfg(test)] +mod tests { + use super::*; + + fn head_x(is_baby: bool, ramming: f32) -> f32 { + let model = if is_baby { + bake_baby_goat_model() + } else { + bake_goat_model() + }; + let g = GoatAnims { + ramming_x_head_rot: ramming, + ..Default::default() + }; + let anim = compute_goat_anim(&model, 10.0, 0.0, 0.0, 0.0, is_baby, &g); + anim.rotation_of(&model, "head").x + } + + /// Ramming overrides the look pitch; a baby not ramming pitches a fixed + /// 22.5 degrees whatever it looks at. + #[test] + fn ramming_and_baby_override_head_pitch() { + assert_eq!(head_x(false, 0.0), 10f32.to_radians()); + assert_eq!(head_x(false, 0.3), 0.3); + assert_eq!(head_x(true, 0.0), BABY_HEAD_X_ROT); + assert_eq!(head_x(true, 0.6), 0.6); + } +} diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index a38fec95e..a8dfa6380 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -202,6 +202,8 @@ pub struct EntityRenderInfo { /// Axolotl animator factors and baby animation clocks. pub axolotl: entity_model::AxolotlAnims, pub panda: entity_model::PandaAnims, + pub armadillo: entity_model::ArmadilloAnims, + pub goat: entity_model::GoatAnims, pub parrot: entity_model::ParrotAnims, /// `DolphinRenderState.isMoving`. pub dolphin_moving: bool, @@ -291,6 +293,8 @@ impl Default for EntityRenderInfo { fox: entity_model::FoxAnims::default(), axolotl: entity_model::AxolotlAnims::default(), panda: entity_model::PandaAnims::default(), + armadillo: entity_model::ArmadilloAnims::default(), + goat: entity_model::GoatAnims::default(), parrot: entity_model::ParrotAnims::default(), dolphin_moving: false, turtle: entity_model::TurtleAnims::default(), @@ -975,6 +979,8 @@ enum AnimationType { PolarBear, Fox, Panda, + Armadillo, + Goat, Bee, Parrot, Dolphin, @@ -1211,6 +1217,10 @@ fn mob_definitions() -> Vec { const DOLPHIN_BABY_TEX: &[&[&str]] = tex_table!("dolphin" => "dolphin_baby"); const TURTLE_TEX: &[&[&str]] = tex_table!("turtle" => "turtle"); const TURTLE_BABY_TEX: &[&[&str]] = tex_table!("turtle" => "turtle_baby"); + const ARMADILLO_TEX: &[&[&str]] = tex_table!("armadillo" => "armadillo"); + const ARMADILLO_BABY_TEX: &[&[&str]] = tex_table!("armadillo" => "armadillo_baby"); + const GOAT_TEX: &[&[&str]] = tex_table!("goat" => "goat"); + const GOAT_BABY_TEX: &[&[&str]] = tex_table!("goat" => "goat_baby"); // Indexed by `Llama.Variant` id. const LLAMA_TEX: &[&[&str]] = tex_table!("llama" => "llama_creamy", "llama_white", "llama_brown", "llama_gray"); @@ -1362,6 +1372,34 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Armadillo, + anim: AnimationType::Armadillo, + adult: vec![opaque( + entity_model::bake_armadillo_model(), + ARMADILLO_TEX, + 64, + )], + baby: Some(opaque( + entity_model::bake_baby_armadillo_model(), + ARMADILLO_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Goat, + anim: AnimationType::Goat, + adult: vec![opaque(entity_model::bake_goat_model(), GOAT_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_goat_model(), + GOAT_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Axolotl, anim: AnimationType::Axolotl, @@ -2669,6 +2707,24 @@ impl EntityRenderer { info.bat_elapsed_secs, info.bat_resting, ), + AnimationType::Armadillo => entity_model::compute_armadillo_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.is_baby, + &info.armadillo, + ), + AnimationType::Goat => entity_model::compute_goat_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.is_baby, + &info.goat, + ), AnimationType::Axolotl => entity_model::compute_axolotl_anim( model, info.is_baby, @@ -3438,6 +3494,8 @@ pub(crate) fn standing_eye_height(kind: EntityKind, is_baby: bool) -> f32 { // Explicit `BABY_DIMENSIONS` eye heights. EntityKind::Fox | EntityKind::PolarBear if is_baby => 0.34375, EntityKind::Panda if is_baby => 0.28125, + EntityKind::Armadillo if is_baby => 0.21875, + EntityKind::Goat if is_baby => 0.59375, EntityKind::Axolotl | EntityKind::Dolphin if is_baby => 0.09375, EntityKind::Mooshroom if is_baby => 0.69, // Explicit `eyeHeight`s, halved; the 0.85 ratio would miss them. diff --git a/pomme-protocol/src/data/codec-fixtures-26.2.json b/pomme-protocol/src/data/codec-fixtures-26.2.json index 2bd44b00f..9b8bd1a81 100644 --- a/pomme-protocol/src/data/codec-fixtures-26.2.json +++ b/pomme-protocol/src/data/codec-fixtures-26.2.json @@ -26,6 +26,7 @@ "player_position/mixed_relative": "033ff80000000000004050000000000000c00000000000000000000000000000003fe0000000000000000000000000000042b400004120000000000112", "player_rotation/relative_yaw": "4234000001c1f0000000", "respawn/keep_all": "03146d696e6563726166743a7468655f6e6574686572fffffffffffffff90002010001146d696e6563726166743a7468655f6e65746865720000017fffffd046002003", + "set_entity_data/armadillo_state": "07122403ff", "set_entity_data/copper_golem": "07102602112503ff", "set_entity_data/sniffer_state": "07122305ff", "set_simulation_distance/twelve": "0c", diff --git a/pomme-protocol/src/entity_data.rs b/pomme-protocol/src/entity_data.rs index 15edf8635..8f732b085 100644 --- a/pomme-protocol/src/entity_data.rs +++ b/pomme-protocol/src/entity_data.rs @@ -142,8 +142,8 @@ macro_rules! serializers_26_2 { ]))), // SNIFFER_STATE: an idMapper VarInt, read like INT. S::Int, - // ARMADILLO_STATE - S::Skip(ENUM), + // ARMADILLO_STATE: an idMapper VarInt, read like INT. + S::Int, // COPPER_GOLEM_STATE and WEATHERING_COPPER_STATE: idMapper VarInts. S::Int, S::Int, @@ -335,6 +335,24 @@ mod tests { ); } + /// `Armadillo.ARMADILLO_STATE` (index 18) set to UNROLLING by vanilla's + /// encoder. + #[test] + fn armadillo_state_matches_vanilla_encoder() { + let payload = crate::vanilla_fixtures::case("set_entity_data/armadillo_state"); + let mut r = Reader::new(&payload); + assert_eq!(r.var_i32().unwrap(), 7); + let values = codec().read_entity_data(&mut r).expect("valid entity data"); + r.ensure_eof().unwrap(); + assert_eq!( + values, + [DataValue { + index: 18, + value: Value::Int(3), + }] + ); + } + #[test] fn decodes_an_item_stack() { let stone = id(ClientRegistry::Item, "stone"); diff --git a/tests/ingame/entities/armadillo.toml b/tests/ingame/entities/armadillo.toml new file mode 100644 index 000000000..70907f12b --- /dev/null +++ b/tests/ingame/entities/armadillo.toml @@ -0,0 +1,65 @@ +# Armadillos at rest, three-quarter view from above: an adult and a baby +# (its own mesh) standing, then an adult and a baby rolled up in the scared +# state, where the shell cube replaces the body and hind legs. + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -59 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -58 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -58.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:armadillo -1.5 -60 3.5 {NoAI:1b,Rotation:[150f,0f]}" + +[[step]] +expect = { entity = "armadillo", baby = false } + +[[step]] +chat = "/summon minecraft:armadillo -0.25 -60 3.5 {NoAI:1b,Age:-24000,Rotation:[210f,0f]}" + +[[step]] +expect = { entity = "armadillo", baby = true } + +[[step]] +chat = "/summon minecraft:armadillo 1.25 -60 3.5 {NoAI:1b,state:\"scared\",Rotation:[150f,0f]}" + +[[step]] +chat = "/summon minecraft:armadillo 2.5 -60 3.5 {NoAI:1b,Age:-24000,state:\"scared\",Rotation:[210f,0f]}" + +[[step]] +look_at = [0.5, -59.8, 3.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "armadillos" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -59 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tests/ingame/entities/goat.toml b/tests/ingame/entities/goat.toml new file mode 100644 index 000000000..a0cb19fa3 --- /dev/null +++ b/tests/ingame/entities/goat.toml @@ -0,0 +1,62 @@ +# Goats at rest, three-quarter view from above: an adult with both horns, an +# adult that lost its left horn, and a baby (its own mesh, head pitched down +# 22.5 degrees). + +[[step]] +wait_ms = 2000 + +[[step]] +chat = "/gamemode creative" + +[[step]] +chat = "/gamerule advance_time false" + +[[step]] +chat = "/time set 6000" + +[[step]] +deterministic = true + +# A pillar to look down from. +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:stone" + +[[step]] +chat = "/tp @s 0.5 -57 0.5 0 40" + +[[step]] +expect = { player = { position = [0.5, -57.0, 0.5] } } + +[[step]] +chat = "/summon minecraft:goat -1.5 -60 5.5 {NoAI:1b,Rotation:[150f,0f]}" + +[[step]] +expect = { entity = "goat", baby = false } + +[[step]] +chat = "/summon minecraft:goat 0.5 -60 6.5 {NoAI:1b,HasLeftHorn:0b,Rotation:[210f,0f]}" + +[[step]] +chat = "/summon minecraft:goat 2.5 -60 5 {NoAI:1b,Age:-24000,Rotation:[210f,0f]}" + +[[step]] +expect = { entity = "goat", baby = true } + +[[step]] +look_at = [0.5, -59.5, 5.5] + +# Chat fades after 10 seconds. +[[step]] +wait_ms = 11000 + +[[step]] +screenshot = "goats" + +[[step]] +chat = "/kill @e[type=!player]" + +[[step]] +chat = "/fill 0 -60 0 0 -58 0 minecraft:air" + +[[step]] +chat = "/gamerule advance_time true" diff --git a/tools/codec-oracle/CodecOracle.java b/tools/codec-oracle/CodecOracle.java index a5feff208..5c9216e24 100644 --- a/tools/codec-oracle/CodecOracle.java +++ b/tools/codec-oracle/CodecOracle.java @@ -53,6 +53,7 @@ import net.minecraft.world.clock.WorldClocks; import net.minecraft.world.entity.PositionMoveRotation; import net.minecraft.world.entity.Relative; +import net.minecraft.world.entity.animal.armadillo.Armadillo; import net.minecraft.world.entity.animal.golem.CopperGolemState; import net.minecraft.world.entity.animal.sniffer.Sniffer; import net.minecraft.world.item.equipment.trim.TrimPattern; @@ -109,6 +110,10 @@ public static void main(String[] args) throws Exception { EntityDataSerializers.WEATHERING_COPPER_STATE), WeatheringCopper.WeatherState.WEATHERED), SynchedEntityData.DataValue.create(new EntityDataAccessor<>(17, EntityDataSerializers.COPPER_GOLEM_STATE), CopperGolemState.DROPPING_ITEM)))); + encode("set_entity_data/armadillo_state", ClientboundSetEntityDataPacket.STREAM_CODEC::encode, + new ClientboundSetEntityDataPacket(7, List.of(SynchedEntityData.DataValue.create( + new EntityDataAccessor<>(18, EntityDataSerializers.ARMADILLO_STATE), + Armadillo.ArmadilloState.UNROLLING)))); JsonObject out = new JsonObject(); out.addProperty("version", SharedConstants.getCurrentVersion().name()); From 5669699030c4195f5ef2b63522cdc6acecea887d Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:37:11 +0000 Subject: [PATCH 08/10] start spawned mobs' bodies at their head yaw --- pomme-client/src/app/core.rs | 2 -- pomme-client/src/entity/mod.rs | 33 ++++++++++------------------- pomme-client/src/net/entity_wire.rs | 3 +-- pomme-client/src/net/mod.rs | 1 - 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 7d3b73b4a..f86c30601 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -2560,7 +2560,6 @@ impl AppCore { entity_type, position, velocity, - y_rot_deg, x_rot_deg, head_y_rot_deg, } => { @@ -2572,7 +2571,6 @@ impl AppCore { entity_type, position, LookDirection::new(head_y_rot_deg, x_rot_deg), - y_rot_deg, uuid, ); if entity_type == azalea_registry::builtin::EntityKind::Player { diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 9bd5443ea..c19ed8879 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -973,14 +973,15 @@ fn living_push_impulses(local: Position, remote: Position) -> Option<(DVec3, DVe } impl LivingEntity { + /// Vanilla `LivingEntity.recreateFromPacket`: the head and body both + /// start at the packet's head yaw, which `look_dir` carries. pub fn new( entity_type: EntityKind, position: Position, look_dir: LookDirection, - head_y_rot_deg: f32, - body_y_rot_deg: f32, uuid: uuid::Uuid, ) -> Self { + let y_rot_deg = look_dir.y_rot_deg(); let default_health = if entity_type == EntityKind::IronGolem { 100.0 } else { @@ -991,10 +992,10 @@ impl LivingEntity { prev_position: position, look_dir, prev_look_dir: look_dir, - head_y_rot_deg, - prev_head_y_rot_deg: head_y_rot_deg, - body_y_rot_deg, - prev_body_y_rot_deg: body_y_rot_deg, + head_y_rot_deg: y_rot_deg, + prev_head_y_rot_deg: y_rot_deg, + body_y_rot_deg: y_rot_deg, + prev_body_y_rot_deg: y_rot_deg, entity_type, uuid, player_uuid: (entity_type == EntityKind::Player).then_some(uuid), @@ -1126,7 +1127,7 @@ impl LivingEntity { interp_target: position, interp_look_dir: look_dir, interp_steps: 0, - interp_head_y_rot_deg: head_y_rot_deg, + interp_head_y_rot_deg: y_rot_deg, interp_head_y_rot_steps: 0, } } @@ -1956,20 +1957,10 @@ impl EntityStore { entity_type: EntityKind, position: Position, look_dir: LookDirection, - body_y_rot_deg: f32, uuid: uuid::Uuid, ) { - self.living.insert( - id, - LivingEntity::new( - entity_type, - position, - look_dir, - look_dir.y_rot_deg(), - body_y_rot_deg, - uuid, - ), - ); + self.living + .insert(id, LivingEntity::new(entity_type, position, look_dir, uuid)); } pub fn move_living_delta(&mut self, id: i32, dx: f64, dy: f64, dz: f64, on_ground: bool) { @@ -2607,7 +2598,7 @@ mod tests { fn spawn(store: &mut EntityStore, id: i32, kind: EntityKind, position: Position) { let uuid = uuid::Uuid::from_u128(id as u128); - store.spawn_living(id, kind, position, LookDirection::default(), 0.0, uuid); + store.spawn_living(id, kind, position, LookDirection::default(), uuid); } #[test] @@ -2998,8 +2989,6 @@ mod tests { EntityKind::Player, Position::default(), LookDirection::default(), - 0.0, - 0.0, uuid::Uuid::nil(), ); let helmet = ItemStack::new(ItemKind::DiamondHelmet, 1); diff --git a/pomme-client/src/net/entity_wire.rs b/pomme-client/src/net/entity_wire.rs index 39b4d4c7a..61f10f12c 100644 --- a/pomme-client/src/net/entity_wire.rs +++ b/pomme-client/src/net/entity_wire.rs @@ -41,7 +41,7 @@ fn event(packet: EntityPacket) -> Option { position, movement, x_rot, - y_rot, + y_rot: _, y_head_rot, data: _, } => { @@ -55,7 +55,6 @@ fn event(packet: EntityPacket) -> Option { entity_type, position: position.into(), velocity: movement, - y_rot_deg: y_rot, x_rot_deg: x_rot, head_y_rot_deg: y_head_rot, } diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 02f50ea21..e522b09ae 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -433,7 +433,6 @@ pub enum NetworkEvent { entity_type: EntityKind, position: Position, velocity: DVec3, - y_rot_deg: f32, x_rot_deg: f32, head_y_rot_deg: f32, }, From e6b7b8459d2bee0258c95327ec3075d870b2e17d Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:51:12 +0000 Subject: [PATCH 09/10] keep the axolotl script's land row on the ground --- tests/ingame/entities/axolotl.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/ingame/entities/axolotl.toml b/tests/ingame/entities/axolotl.toml index 28677f434..3f2ec9b26 100644 --- a/tests/ingame/entities/axolotl.toml +++ b/tests/ingame/entities/axolotl.toml @@ -29,37 +29,37 @@ chat = "/tp @s 0.5 -57 0.5 0 40" expect = { player = { position = [0.5, -57.0, 0.5] } } [[step]] -chat = "/summon minecraft:axolotl -2.5 -60 6.5 {NoAI:1b,Variant:0,Rotation:[160f,0f]}" +chat = "/summon minecraft:axolotl -2.5 -60 6.5 {NoAI:1b,OnGround:1b,Variant:0,Rotation:[160f,0f]}" [[step]] -chat = "/summon minecraft:axolotl -1 -60 6.5 {NoAI:1b,Variant:1,Rotation:[160f,0f]}" +chat = "/summon minecraft:axolotl -1 -60 6.5 {NoAI:1b,OnGround:1b,Variant:1,Rotation:[160f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 0.5 -60 6.5 {NoAI:1b,Variant:2,Rotation:[160f,0f]}" +chat = "/summon minecraft:axolotl 0.5 -60 6.5 {NoAI:1b,OnGround:1b,Variant:2,Rotation:[160f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 2 -60 6.5 {NoAI:1b,Variant:3,Rotation:[160f,0f]}" +chat = "/summon minecraft:axolotl 2 -60 6.5 {NoAI:1b,OnGround:1b,Variant:3,Rotation:[160f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 3.5 -60 6.5 {NoAI:1b,Variant:4,Rotation:[160f,0f]}" +chat = "/summon minecraft:axolotl 3.5 -60 6.5 {NoAI:1b,OnGround:1b,Variant:4,Rotation:[160f,0f]}" [[step]] expect = { entity = "axolotl", baby = false } [[step]] -chat = "/summon minecraft:axolotl -2.5 -60 4 {NoAI:1b,Variant:0,Age:-24000,Rotation:[200f,0f]}" +chat = "/summon minecraft:axolotl -2.5 -60 4 {NoAI:1b,OnGround:1b,Variant:0,Age:-24000,Rotation:[200f,0f]}" [[step]] -chat = "/summon minecraft:axolotl -1 -60 4 {NoAI:1b,Variant:1,Age:-24000,Rotation:[200f,0f]}" +chat = "/summon minecraft:axolotl -1 -60 4 {NoAI:1b,OnGround:1b,Variant:1,Age:-24000,Rotation:[200f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 0.5 -60 4 {NoAI:1b,Variant:2,Age:-24000,Rotation:[200f,0f]}" +chat = "/summon minecraft:axolotl 0.5 -60 4 {NoAI:1b,OnGround:1b,Variant:2,Age:-24000,Rotation:[200f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 2 -60 4 {NoAI:1b,Variant:3,Age:-24000,Rotation:[200f,0f]}" +chat = "/summon minecraft:axolotl 2 -60 4 {NoAI:1b,OnGround:1b,Variant:3,Age:-24000,Rotation:[200f,0f]}" [[step]] -chat = "/summon minecraft:axolotl 3.5 -60 4 {NoAI:1b,Variant:4,Age:-24000,Rotation:[200f,0f]}" +chat = "/summon minecraft:axolotl 3.5 -60 4 {NoAI:1b,OnGround:1b,Variant:4,Age:-24000,Rotation:[200f,0f]}" [[step]] expect = { entity = "axolotl", baby = true } From f0e6cf8e5f2008b18d88195c5b29dce1e6c8a06f Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:06:39 +0000 Subject: [PATCH 10/10] note mobs' BodyRotationControl turning --- pomme-client/src/entity/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 73eacd415..f62728716 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -1589,6 +1589,11 @@ impl LivingEntity { } } + /// Vanilla `LivingEntity.tickHeadTurn`. + // TODO: a `Mob` turns its body through `BodyRotationControl.clientTick` + // instead: the body snaps to its yaw while moving, follows the head past + // `getMaxHeadYRot` (75 by default, 15 for a goat, 1 for an axolotl), and + // turns to face the head after it holds still for 10 ticks. pub fn tick_body_rotation(&mut self) { let dx = self.position.x - self.prev_position.x; let dz = self.position.z - self.prev_position.z;