diff --git a/steel-core/src/behavior/items/egg.rs b/steel-core/src/behavior/items/egg.rs new file mode 100644 index 00000000000..c9882ad5214 --- /dev/null +++ b/steel-core/src/behavior/items/egg.rs @@ -0,0 +1,76 @@ +//! Egg item behavior (`EggItem`). +//! +//! Throwing an egg spawns a [`ThrownEggEntity`] from the player's eye, shot +//! along their look direction, and consumes one egg (creative-mode count +//! restoration is handled by the caller). Mirrors vanilla `EggItem.use`. + +use std::sync::Arc; + +use steel_macros::item_behavior; +use steel_protocol::packets::game::SoundSource; +use steel_registry::stat::vanilla_stat_types; +use steel_registry::{sound_events, vanilla_entities}; + +use crate::behavior::context::{InteractionResult, UseItemContext}; +use crate::behavior::item::ItemBehavior; +use crate::entity::entities::ThrownEggEntity; +use crate::entity::{Entity, next_entity_id, spawn_throwable_item_projectile}; + +/// Vanilla `EggItem.PROJECTILE_SHOOT_POWER`. +const SHOOT_POWER: f32 = 1.5; +/// Vanilla `EggItem.use` throw sound volume. +const THROW_SOUND_VOLUME: f32 = 0.5; +/// Vanilla `EggItem.use` throw pitch jitter scale: `0.4 / (random * 0.4 + 0.8)`. +const THROW_PITCH_JITTER_SCALE: f32 = 0.4; +/// Vanilla `EggItem.use` throw pitch jitter base. +const THROW_PITCH_JITTER_BASE: f32 = 0.8; +/// Vanilla `EggItem.use` throw uncertainty (`spawnProjectileFromRotation`). +const THROW_UNCERTAINTY: f32 = 1.0; + +/// Behavior for the egg item. +#[item_behavior(class = "EggItem")] +pub struct EggItem; + +impl ItemBehavior for EggItem { + fn use_item(&self, context: &mut UseItemContext) -> InteractionResult { + let player = context.player; + let world = context.world; + + let pitch = THROW_PITCH_JITTER_SCALE + / (rand::random::() * THROW_PITCH_JITTER_SCALE + THROW_PITCH_JITTER_BASE); + world.play_sound_at( + &sound_events::ENTITY_EGG_THROW, + SoundSource::Players, + player.position(), + THROW_SOUND_VOLUME, + pitch, + None, + ); + + let mut thrown_item = context.inv.with_item(|item| item.clone()); + let Some(_egg) = spawn_throwable_item_projectile( + world, + player, + &mut thrown_item, + SHOOT_POWER, + THROW_UNCERTAINTY, + |spawn_pos| { + ThrownEggEntity::new( + &vanilla_entities::EGG, + next_entity_id(), + spawn_pos, + Arc::downgrade(world), + ) + }, + ) else { + return InteractionResult::Fail; + }; + + player.award_stat(&vanilla_stat_types::ITEM_USED, thrown_item.item); + if !player.has_infinite_materials() { + context.inv.with_item(|item| item.shrink(1)); + } + + InteractionResult::Success + } +} diff --git a/steel-core/src/behavior/items/ender_pearl.rs b/steel-core/src/behavior/items/ender_pearl.rs index 0eff997d434..caf503c0674 100644 --- a/steel-core/src/behavior/items/ender_pearl.rs +++ b/steel-core/src/behavior/items/ender_pearl.rs @@ -6,7 +6,6 @@ use std::sync::Arc; -use glam::DVec3; use steel_macros::item_behavior; use steel_protocol::packets::game::SoundSource; use steel_registry::stat::vanilla_stat_types; @@ -15,10 +14,12 @@ use steel_registry::{sound_events, vanilla_entities}; use crate::behavior::context::{InteractionResult, UseItemContext}; use crate::behavior::item::ItemBehavior; use crate::entity::entities::EnderPearlEntity; -use crate::entity::{Entity, Projectile, SharedEntity, ThrowableItemProjectile, next_entity_id}; +use crate::entity::{Entity, next_entity_id, spawn_throwable_item_projectile}; /// Vanilla `EnderpearlItem.PROJECTILE_SHOOT_POWER`. const SHOOT_POWER: f32 = 1.5; +/// Vanilla `EnderpearlItem.use` throw uncertainty (`spawnProjectileFromRotation`). +const THROW_UNCERTAINTY: f32 = 1.0; /// Behavior for the ender pearl item. #[item_behavior(class = "EnderpearlItem")] @@ -39,41 +40,30 @@ impl ItemBehavior for EnderPearlItem { None, ); - let thrown_item = context.inv.with_item(|item| item.clone()); - - // Vanilla `ThrowableItemProjectile` spawns at the shooter's eye minus 0.1. - let player_pos = player.position(); - let spawn_pos = DVec3::new(player_pos.x, player.get_eye_y() - 0.1, player_pos.z); - - let pearl = Arc::new(EnderPearlEntity::new( - &vanilla_entities::ENDER_PEARL, - next_entity_id(), - spawn_pos, - Arc::downgrade(world), - )); - if let Some(owner) = world.players.get_by_uuid(&player.gameprofile.id) { - let owner: SharedEntity = owner; - pearl.set_owner_entity(Some(&owner)); - } else { - pearl.set_owner_uuid(Some(player.gameprofile.id)); - } - pearl.set_item_clamped(thrown_item); - - let (yaw, player_pitch) = player.rotation(); - pearl.shoot_from_rotation(player, player_pitch, yaw, 0.0, SHOOT_POWER, 1.0); - - let entity: SharedEntity = pearl; - if let Err(error) = world.try_add_entity(entity.clone()) { - log::debug!("failed to spawn ender pearl: {error}"); + let mut thrown_item = context.inv.with_item(|item| item.clone()); + let Some(entity) = spawn_throwable_item_projectile( + world, + player, + &mut thrown_item, + SHOOT_POWER, + THROW_UNCERTAINTY, + |spawn_pos| { + EnderPearlEntity::new( + &vanilla_entities::ENDER_PEARL, + next_entity_id(), + spawn_pos, + Arc::downgrade(world), + ) + }, + ) else { return InteractionResult::Fail; - } + }; player.register_ender_pearl(&entity); - context.inv.with_item(|itemstack| { - let item = itemstack.item(); - itemstack.shrink(1); - player.award_stat(&vanilla_stat_types::ITEM_USED, item); - }); + player.award_stat(&vanilla_stat_types::ITEM_USED, thrown_item.item); + if !player.has_infinite_materials() { + context.inv.with_item(|item| item.shrink(1)); + } InteractionResult::Success } diff --git a/steel-core/src/behavior/items/mod.rs b/steel-core/src/behavior/items/mod.rs index 663aefad3f9..093e2ea636f 100644 --- a/steel-core/src/behavior/items/mod.rs +++ b/steel-core/src/behavior/items/mod.rs @@ -15,6 +15,7 @@ mod copper_chest_events; mod default; mod dye; mod dynamic_name; +mod egg; mod ender_eye; mod ender_pearl; mod firework_rocket; @@ -48,6 +49,7 @@ pub use bucket::BucketItem; pub use compass::CompassItem; pub use default::DefaultItemBehavior; pub use dye::DyeItem; +pub use egg::EggItem; pub use ender_eye::EnderEyeItem; pub use ender_pearl::EnderPearlItem; pub use firework_rocket::FireworkRocketItem; diff --git a/steel-core/src/entity/entities/mobs/passive/chicken/mod.rs b/steel-core/src/entity/entities/mobs/passive/chicken/mod.rs new file mode 100644 index 00000000000..07c8ebb282e --- /dev/null +++ b/steel-core/src/entity/entities/mobs/passive/chicken/mod.rs @@ -0,0 +1,670 @@ +//! Vanilla Chicken entity with variant + sound-variant parity, wing-flap +//! slow-fall, and periodic egg-laying behavior. + +use std::str::FromStr; +use std::sync::{Arc, Weak}; + +use glam::DVec3; +use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView; +use simdnbt::owned::NbtCompound; +use steel_macros::entity_behavior; +use steel_registry::chicken_sound_variant::{ChickenAge, ChickenSoundVariantRef}; +use steel_registry::chicken_variant::ChickenVariantRef; +use steel_registry::entity_type::{ + EntityAttachmentPoint, EntityAttachments, EntityDimensions, EntityTypeRef, +}; +use steel_registry::item_stack::ItemStack; +use steel_registry::loot_table::{LootContext, LootTableRef}; +use steel_registry::sound_event::SoundEventRef; +use steel_registry::vanilla_entity_data::ChickenEntityData; +use steel_registry::vanilla_item_tags::ItemTag; +use steel_registry::vanilla_loot_tables; +use steel_registry::{ + REGISTRY, RegistryExt, RegistryReference, TaggedRegistryExt, sound_events, vanilla_game_events, +}; +use steel_utils::locks::SyncMutex; +use steel_utils::random::legacy_random::LegacyRandom; +use steel_utils::types::InteractionHand; +use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey, Identifier}; + +use crate::behavior::InteractionResult; +use crate::entity::ai::goal::{ + BreedGoal, FloatGoal, FollowParentGoal, LookAtPlayerGoal, PanicGoal, RandomLookAroundGoal, + TemptGoal, WaterAvoidingRandomStrollGoal, +}; +use crate::entity::ai::path::PathType; +use crate::entity::damage::DamageSource; +use crate::entity::{ + AgeableMob, AgeableMobBase, Animal, AnimalBase, Entity, EntityBase, EntityBaseLoad, EntityPose, + EntitySpawnReason, EntitySyncedData, LivingEntity, LivingEntityBase, Mob, MobBase, + PathfinderMob, SpawnGroupData, entity_loot_ref, position_rider_default, +}; +use crate::physics::MoveResult; +use crate::player::Player; +use crate::world::World; + +const CHICKEN_BABY_PASSENGER_ATTACHMENTS: [EntityAttachmentPoint; 1] = + [EntityAttachmentPoint::new(0.0, 0.375, 0.0)]; +const CHICKEN_BABY_WIDTH: f32 = 0.3; +const CHICKEN_BABY_HEIGHT: f32 = 0.4; +const CHICKEN_BABY_EYE_HEIGHT: f32 = 0.28125; + +const CHICKEN_BABY_DIMENSIONS: EntityDimensions = EntityDimensions::new_with_attachments( + CHICKEN_BABY_WIDTH, + CHICKEN_BABY_HEIGHT, + CHICKEN_BABY_EYE_HEIGHT, + EntityAttachments::new(&CHICKEN_BABY_PASSENGER_ATTACHMENTS, &[], &[], &[]), +); + +/// Flap speed gained per tick while airborne (vanilla `Chicken.aiStep`). +const FLAP_SPEED_AIR_GAIN: f32 = 4.0; +/// Flap speed lost per tick while grounded (vanilla `Chicken.aiStep`). +const FLAP_SPEED_GROUND_LOSS: f32 = 1.0; +/// Scales the per-tick flap-speed adjustment (vanilla `Chicken.aiStep`). +const FLAP_SPEED_ADJUST_SCALE: f32 = 0.3; +const FLAP_SPEED_MIN: f32 = 0.0; +const FLAP_SPEED_MAX: f32 = 1.0; +/// Flapping strength restored whenever the chicken is airborne. +const MIN_FLAPPING_STRENGTH: f32 = 1.0; +/// Multiplicative flapping-strength decay per tick. +const FLAPPING_STRENGTH_DECAY: f32 = 0.9; +/// Scales accumulated flap rotation (vanilla `Chicken.aiStep`). +const FLAP_ROTATION_SCALE: f32 = 2.0; +/// Downward velocity multiplier while airborne, producing the chicken slow-fall. +const FALL_DRAG_Y: f32 = 0.6; +/// Divisor converting flap speed into the next flap distance threshold. +const NEXT_FLAP_SPEED_DIVISOR: f32 = 2.0; + +/// Minimum ticks between egg lays (vanilla `nextInt(6000) + 6000`). +const EGG_LAY_MIN_DELAY_TICKS: i32 = 6000; +/// Range of the randomized egg-lay delay in ticks. +const EGG_LAY_RANDOM_RANGE_TICKS: i32 = 6000; +const EGG_LAY_SOUND_VOLUME: f32 = 1.0; +const EGG_LAY_SOUND_BASE_PITCH: f32 = 1.0; +/// Pitch jitter around the base egg-lay sound pitch. +const EGG_LAY_SOUND_PITCH_JITTER: f32 = 0.2; +/// Offset at which laid eggs spawn above the chicken's feet. +const EGG_LAY_SPAWN_Y_OFFSET: f64 = 0.0; + +/// Experience rewarded for a chicken jockey (vanilla `Chicken.getBaseExperienceReward`). +const CHICKEN_JOCKEY_EXPERIENCE_REWARD: i32 = 10; + +/// Runtime state unique to chickens: wing-flap animation, egg timer, and jockey flag. +#[derive(Debug, Clone, Copy)] +struct ChickenState { + flap: f32, + flap_speed: f32, + o_flap: f32, + o_flap_speed: f32, + flapping: f32, + next_flap: f32, + egg_time: i32, + is_chicken_jockey: bool, +} + +impl ChickenState { + const fn new() -> Self { + Self { + flap: 0.0, + flap_speed: 0.0, + o_flap: 0.0, + o_flap_speed: 0.0, + flapping: 1.0, + next_flap: 1.0, + egg_time: 0, + is_chicken_jockey: false, + } + } +} + +/// Vanilla chicken entity. +#[entity_behavior(class = "Chicken")] +pub struct ChickenEntity { + base: EntityBase, + entity_type: EntityTypeRef, + living_base: LivingEntityBase, + mob_base: MobBase, + ageable_base: AgeableMobBase, + animal_base: AnimalBase, + chicken_state: SyncMutex, + entity_data: SyncMutex, +} + +// SAFETY: This key is owned by Steel and uniquely identifies `ChickenEntity`. +unsafe impl DowncastType for ChickenEntity { + const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/chicken"); +} + +impl ChickenEntity { + /// Creates a new chicken at runtime. + #[must_use] + pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak) -> Self { + Self::new_with_base( + EntityBase::new(id, position, entity_type.dimensions, world), + entity_type, + ) + } + + /// Reconstructs a chicken from persisted base entity state. + #[must_use] + pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self { + Self::new_with_base( + EntityBase::from_load(load, entity_type.dimensions), + entity_type, + ) + } + + fn new_with_base(base: EntityBase, entity_type: EntityTypeRef) -> Self { + let living_base = LivingEntityBase::new(entity_type); + let mob_base = MobBase::new(); + let ageable_base = AgeableMobBase::new(); + let animal_base = AnimalBase::new(); + AnimalBase::initialize_pathfinding_malus(&mob_base); + // Chickens do not avoid water (`setPathfindingMalus(PathType.WATER, 0.0F)`). + mob_base + .pathfinding_malus() + .lock() + .set(PathType::Water, 0.0); + let mut chicken_state = ChickenState::new(); + chicken_state.egg_time = Self::roll_egg_lay_time(); + let mut entity_data = ChickenEntityData::new(); + living_base.initialize_synced_data(&mut entity_data); + + { + // Keep vanilla Chicken goal priorities and speeds in the same order. + let mut goal_selector = mob_base.goal_selector().lock(); + goal_selector.add_goal(0, FloatGoal::new(&mob_base)); + goal_selector.add_goal(1, PanicGoal::new(1.4)); + goal_selector.add_goal(2, BreedGoal::new(1.0)); + goal_selector.add_goal( + 3, + TemptGoal::new( + 1.0, + |item_stack| { + REGISTRY + .items + .is_in_tag(item_stack.item(), &ItemTag::CHICKEN_FOOD) + }, + false, + ), + ); + goal_selector.add_goal(4, FollowParentGoal::new(1.1)); + goal_selector.add_goal(5, WaterAvoidingRandomStrollGoal::new(1.0)); + goal_selector.add_goal(6, LookAtPlayerGoal::new(6.0)); + goal_selector.add_goal(7, RandomLookAroundGoal::new()); + } + + Self { + base, + entity_type, + living_base, + mob_base, + ageable_base, + animal_base, + chicken_state: SyncMutex::new(chicken_state), + entity_data: SyncMutex::new(entity_data), + } + } + + /// Sets the active chicken variant by registry entry. + pub fn set_variant(&self, variant: ChickenVariantRef) { + self.entity_data + .lock() + .variant + .set(RegistryReference::new(variant)); + } + + /// Returns the active chicken variant, falling back to temperate when invalid. + #[must_use] + pub fn variant(&self) -> ChickenVariantRef { + self.entity_data.lock().variant.get().value() + } + + /// Sets the active chicken sound variant by registry entry. + pub fn set_sound_variant(&self, sound_variant: ChickenSoundVariantRef) { + self.entity_data + .lock() + .sound_variant + .set(RegistryReference::new(sound_variant)); + } + + /// Returns the active chicken sound variant, falling back to classic when invalid. + #[must_use] + pub fn sound_variant(&self) -> ChickenSoundVariantRef { + self.entity_data.lock().sound_variant.get().value() + } + + fn set_variant_by_key(&self, key: &Identifier) -> bool { + let Some(variant) = REGISTRY.chicken_variants.by_key(key) else { + return false; + }; + self.set_variant(variant); + true + } + + fn set_sound_variant_by_key(&self, key: &Identifier) { + if let Some(sound_variant) = REGISTRY.chicken_sound_variants.by_key(key) { + self.set_sound_variant(sound_variant); + } + } + + /// Returns the sound set for this chicken's current age (vanilla `Chicken.getSoundSet`). + fn current_sound_set(&self) -> &'static ChickenAge { + let sound_variant = self.sound_variant(); + if AgeableMob::is_baby(self) { + &sound_variant.baby_sounds + } else { + &sound_variant.adult_sounds + } + } + + fn update_dirty_mob_effect_entity_data(&self) { + if !self.living_base.take_effects_dirty() { + return; + } + + let display = self.living_base.mob_effect_display_state(); + + { + let mut entity_data = self.entity_data.lock(); + let living = entity_data.living_entity_mut(); + living.effect_particles.set(display.particles); + living.effect_ambience.set(display.ambient); + } + + self.entity_data.set_base_invisible_flag(display.invisible); + self.entity_data + .set_base_glowing_flag(self.has_glowing_tag() || display.glowing); + } + + /// Returns whether an item stack matches the vanilla chicken food tag. + #[must_use] + pub fn is_food(item_stack: &ItemStack) -> bool { + REGISTRY + .items + .is_in_tag(item_stack.item(), &ItemTag::CHICKEN_FOOD) + } + + /// Returns vanilla `Chicken.isChickenJockey`. + #[must_use] + pub fn is_chicken_jockey(&self) -> bool { + self.chicken_state.lock().is_chicken_jockey + } + + /// Sets vanilla `Chicken.isChickenJockey`. + pub fn set_chicken_jockey(&self, is_chicken_jockey: bool) { + self.chicken_state.lock().is_chicken_jockey = is_chicken_jockey; + } + + /// Returns the remaining ticks before the next egg is laid. + fn egg_time(&self) -> i32 { + self.chicken_state.lock().egg_time + } + + /// Sets the remaining ticks before the next egg is laid. + fn set_egg_time(&self, egg_time: i32) { + self.chicken_state.lock().egg_time = egg_time; + } + + /// Rolls the vanilla randomized delay before the next egg lay. + fn roll_egg_lay_time() -> i32 { + EGG_LAY_MIN_DELAY_TICKS + rand::random_range(0..EGG_LAY_RANDOM_RANGE_TICKS) + } + + /// Runs vanilla `Chicken.aiStep` wing-flap and slow-fall side effects. + fn tick_flapping(&self) { + let on_ground = self.on_ground(); + let velocity = self.velocity(); + + { + let mut state = self.chicken_state.lock(); + state.o_flap = state.flap; + state.o_flap_speed = state.flap_speed; + let adjustment = if on_ground { + -FLAP_SPEED_GROUND_LOSS + } else { + FLAP_SPEED_AIR_GAIN + }; + state.flap_speed = (state.flap_speed + adjustment * FLAP_SPEED_ADJUST_SCALE) + .clamp(FLAP_SPEED_MIN, FLAP_SPEED_MAX); + if !on_ground && state.flapping < MIN_FLAPPING_STRENGTH { + state.flapping = MIN_FLAPPING_STRENGTH; + } + state.flapping *= FLAPPING_STRENGTH_DECAY; + state.flap += state.flapping * FLAP_ROTATION_SCALE; + } + + if !on_ground && velocity.y < 0.0 { + self.set_velocity(DVec3::new( + velocity.x, + velocity.y * f64::from(FALL_DRAG_Y), + velocity.z, + )); + } + } + + /// Runs vanilla `Chicken.aiStep` egg-laying side effects. + fn tick_egg_laying(&self) { + if self.level().is_none() || !Entity::is_alive(self) || AgeableMob::is_baby(self) { + return; + } + + let should_lay_egg = { + let mut state = self.chicken_state.lock(); + // Vanilla gates egg laying on `!isChickenJockey()`. + if state.is_chicken_jockey { + return; + } + state.egg_time -= 1; + state.egg_time <= 0 + }; + + if should_lay_egg { + if self.drop_gift_loot_table(&vanilla_loot_tables::GAMEPLAY_CHICKEN_LAY) { + let pitch = EGG_LAY_SOUND_BASE_PITCH + + (rand::random::() - rand::random::()) * EGG_LAY_SOUND_PITCH_JITTER; + self.play_sound( + &sound_events::ENTITY_CHICKEN_EGG, + EGG_LAY_SOUND_VOLUME, + pitch, + ); + self.game_event(&vanilla_game_events::ENTITY_PLACE); + } + self.set_egg_time(Self::roll_egg_lay_time()); + } + } + + /// Rolls a gift loot table at this entity's position and drops each result. + /// + /// Mirrors vanilla `LivingEntity.dropFromGiftLootTable` for the + /// `gameplay/chicken_lay` table, returning whether any item was dropped. + fn drop_gift_loot_table(&self, loot_table: LootTableRef) -> bool { + let position = self.position(); + let mut rng = rand::rng(); + // Vanilla `LootContextParamSets.GIFT` carries only ORIGIN and THIS_ENTITY. + let mut context = LootContext::new(&mut rng) + .with_origin(position.x, position.y, position.z) + .with_this_entity(entity_loot_ref(self)); + + let items = loot_table.get_random_items(&mut context); + let dropped_any = !items.is_empty(); + for item in items { + self.spawn_at_location(item, EGG_LAY_SPAWN_Y_OFFSET); + } + dropped_any + } +} + +impl Entity for ChickenEntity { + fn base(&self) -> &EntityBase { + &self.base + } + + fn entity_type(&self) -> EntityTypeRef { + self.entity_type + } + + fn base_tick(&self) { + Mob::base_tick_mob(self); + } + + fn dimensions_for_pose(&self, _pose: EntityPose) -> EntityDimensions { + let scale = LivingEntity::get_scale(self); + if AgeableMob::is_baby(self) { + CHICKEN_BABY_DIMENSIONS.scale(scale) + } else if self.entity_type.fixed { + self.entity_type.dimensions + } else { + self.entity_type.dimensions.scale(scale) + } + } + + fn synced_data(&self) -> Option<&dyn EntitySyncedData> { + Some(&self.entity_data) + } + + fn update_data_before_sync(&self) { + self.update_dirty_mob_effect_entity_data(); + } + + fn play_step_sound(&self, _pos: BlockPos, _block_state: BlockStateId) { + self.play_sound(self.current_sound_set().step_sound, 0.15, 1.0); + } + + fn is_flapping(&self) -> bool { + let fly_dist = self.base().movement_progress().fly_dist(); + fly_dist > self.chicken_state.lock().next_flap + } + + fn on_flap(&self) { + let fly_dist = self.base().movement_progress().fly_dist(); + let mut state = self.chicken_state.lock(); + state.next_flap = fly_dist + state.flap_speed / NEXT_FLAP_SPEED_DIVISOR; + } + + fn position_rider(&self, passenger: &dyn Entity) { + position_rider_default(self, passenger); + if let Some(living) = passenger.as_living_entity() { + living.set_y_body_rot(self.y_body_rot()); + } + } + + fn save_additional(&self, nbt: &mut NbtCompound) { + self.save_mob(nbt); + self.save_ageable_mob(nbt); + self.save_animal(nbt); + nbt.insert("IsChickenJockey", i8::from(self.is_chicken_jockey())); + nbt.insert("EggLayTime", self.egg_time()); + nbt.insert("variant", self.variant().key.to_string()); + nbt.insert("sound_variant", self.sound_variant().key.to_string()); + } + + fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) { + self.load_mob(nbt); + self.load_ageable_mob(nbt); + self.load_animal(nbt); + + self.set_chicken_jockey(nbt.byte("IsChickenJockey").is_some_and(|value| value != 0)); + if let Some(egg_time) = nbt.int("EggLayTime") { + self.set_egg_time(egg_time); + } + if let Some(variant) = nbt.string("variant") + && let Ok(key) = Identifier::from_str(variant.to_str().as_ref()) + { + self.set_variant_by_key(&key); + } + if let Some(sound_variant) = nbt.string("sound_variant") + && let Ok(key) = Identifier::from_str(sound_variant.to_str().as_ref()) + { + self.set_sound_variant_by_key(&key); + } + } +} + +impl LivingEntity for ChickenEntity { + fn living_base(&self) -> &LivingEntityBase { + &self.living_base + } + + fn get_health(&self) -> f32 { + *self.entity_data.lock().living_entity().health.get() + } + + fn set_health(&self, health: f32) { + let max_health = self.get_max_health(); + let clamped = health.clamp(0.0, max_health); + self.entity_data + .lock() + .living_entity_mut() + .health + .set(clamped); + } + + fn hurt_sound(&self, _source: &DamageSource) -> Option { + Some(self.current_sound_set().hurt_sound) + } + + fn death_sound(&self) -> Option { + Some(self.current_sound_set().death_sound) + } + + fn chicken_loot_variant(&self) -> Option<&Identifier> { + Some(&self.variant().key) + } + + fn base_experience_reward(&self) -> i32 { + if self.is_chicken_jockey() { + CHICKEN_JOCKEY_EXPERIENCE_REWARD + } else { + Animal::base_experience_reward_animal(self) + } + } + + fn server_ai_step(&self) { + Mob::mob_server_ai_step(self); + } + + fn ai_step(&self) -> Option { + let result = self.default_ai_step(); + + AgeableMob::tick_ageable_mob(self); + Animal::tick_animal_love(self); + self.tick_flapping(); + self.tick_egg_laying(); + result + } +} + +impl AgeableMob for ChickenEntity { + fn ageable_base(&self) -> &AgeableMobBase { + &self.ageable_base + } + + fn is_age_locked(&self) -> bool { + *self.entity_data.lock().ageable_mob().age_locked.get() + } + + fn set_age_locked(&self, age_locked: bool) { + self.entity_data + .lock() + .ageable_mob_mut() + .age_locked + .set(age_locked); + } + + fn set_synced_baby(&self, baby: bool) { + self.entity_data.lock().ageable_mob_mut().baby.set(baby); + } + + fn age_boundary_changed(&self, _baby: bool) { + self.refresh_dimensions(); + } +} + +impl Animal for ChickenEntity { + fn animal_base(&self) -> &AnimalBase { + &self.animal_base + } + + fn is_food(&self, item_stack: &ItemStack) -> bool { + ChickenEntity::is_food(item_stack) + } + + fn breed_variant_key(&self) -> Option<&Identifier> { + Some(&self.variant().key) + } + + fn set_breed_variant_key(&self, key: &Identifier) -> bool { + self.set_variant_by_key(key) + } + + fn initialize_breed_offspring(&self, partner: &dyn Animal, offspring: &dyn Animal) { + let use_self_variant = rand::random::(); + let variant_key = if use_self_variant { + self.breed_variant_key() + } else { + partner.breed_variant_key() + }; + let Some(variant_key) = variant_key else { + return; + }; + + if !offspring.set_breed_variant_key(variant_key) { + log::error!("chicken offspring could not inherit breeding variant {variant_key}"); + } + } +} + +impl Mob for ChickenEntity { + fn mob_base(&self) -> &MobBase { + &self.mob_base + } + + fn tick_goal_selectors(&self) { + PathfinderMob::tick_pathfinder_goal_selectors(self); + } + + fn tick_path_navigation(&self) { + PathfinderMob::tick_pathfinder_path_navigation(self); + } + + fn custom_server_ai_step(&self) { + Animal::custom_server_ai_step_animal(self); + } + + fn ambient_sound(&self) -> Option { + Some(self.current_sound_set().ambient_sound) + } + + fn remove_when_far_away(&self, _dist_sqr: f64) -> bool { + // Chicken jockeys persist like their rider (vanilla `Chicken.removeWhenFarAway`). + self.is_chicken_jockey() + } + + fn finalize_spawn( + &self, + world: &Arc, + spawn_reason: EntitySpawnReason, + group_data: Option, + ) -> Option { + let biome = world.biome_at(self.block_position()); + // Mirrors the cow/pig convention. + let (variant, sound_variant) = { + let mut random = LegacyRandom::from_seed(rand::random()); + let variant = biome.and_then(|biome| { + REGISTRY + .chicken_variants + .select_spawn_variant(biome, &mut random) + }); + let sound_variant = REGISTRY.chicken_sound_variants.pick_random(&mut random); + (variant, sound_variant) + }; + + if let Some(variant) = variant { + self.set_variant(variant); + } + + if let Some(sound_variant) = sound_variant { + self.set_sound_variant(sound_variant); + } + + self.finalize_spawn_ageable_mob(world, spawn_reason, group_data) + } + + fn mob_interact(&self, player: &Player, hand: InteractionHand) -> InteractionResult { + Animal::mob_interact_animal(self, player, hand) + } + + fn mob_flags(&self) -> i8 { + *self.entity_data.lock().mob().mob_flags.get() + } + + fn set_mob_flags(&self, flags: i8) { + self.entity_data.lock().mob_mut().mob_flags.set(flags); + } +} + +impl PathfinderMob for ChickenEntity {} + +#[cfg(test)] +mod tests; diff --git a/steel-core/src/entity/entities/mobs/passive/chicken/tests/core.rs b/steel-core/src/entity/entities/mobs/passive/chicken/tests/core.rs new file mode 100644 index 00000000000..721a5b35640 --- /dev/null +++ b/steel-core/src/entity/entities/mobs/passive/chicken/tests/core.rs @@ -0,0 +1,226 @@ +use super::*; +use steel_registry::init_vanilla_registry; + +#[test] +fn chicken_initializes_vanilla_living_attributes_and_health() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + + assert_eq!(chicken.get_health().to_bits(), 4.0_f32.to_bits()); + let attributes = chicken.attributes().lock(); + assert_eq!( + attributes + .required_value(vanilla_attributes::MAX_HEALTH) + .to_bits(), + 4.0_f64.to_bits() + ); + assert!( + (attributes.required_value(vanilla_attributes::MOVEMENT_SPEED) - f64::from(0.25_f32)).abs() + < 1e-12 + ); +} + +#[test] +fn chicken_uses_vanilla_chicken_food_tag() { + init_vanilla_registry(); + + assert!(ChickenEntity::is_food(&ItemStack::new( + &vanilla_items::WHEAT_SEEDS + ))); + assert!(!ChickenEntity::is_food(&ItemStack::new( + &vanilla_items::STONE + ))); +} + +#[test] +fn chicken_sound_methods_follow_selected_sound_variant() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + let source = DamageSource::environment(&vanilla_damage_types::GENERIC); + + assert_eq!( + Mob::ambient_sound(&chicken).map(|sound| &sound.key), + Some(&sound_events::ENTITY_CHICKEN_AMBIENT.key) + ); + + chicken.set_sound_variant(&vanilla_chicken_sound_variants::PICKY); + + assert_eq!( + Mob::ambient_sound(&chicken).map(|sound| &sound.key), + Some(&chicken.sound_variant().adult_sounds.ambient_sound.key) + ); + assert_eq!( + LivingEntity::hurt_sound(&chicken, &source).map(|sound| &sound.key), + Some(&chicken.sound_variant().adult_sounds.hurt_sound.key) + ); + assert_eq!( + LivingEntity::death_sound(&chicken).map(|sound| &sound.key), + Some(&chicken.sound_variant().adult_sounds.death_sound.key) + ); +} + +#[test] +fn chicken_sounds_switch_to_baby_set_for_babies() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + chicken.set_baby(true); + + assert_eq!( + Mob::ambient_sound(&chicken).map(|sound| &sound.key), + Some(&sound_events::ENTITY_BABY_CHICKEN_AMBIENT.key) + ); +} + +#[test] +fn chicken_jockey_rewards_ten_experience() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + + let normal_reward = LivingEntity::base_experience_reward(&chicken); + assert!( + (1..=3).contains(&normal_reward), + "adult chicken reward should be 1..=3, got {normal_reward}" + ); + + chicken.set_chicken_jockey(true); + assert_eq!( + LivingEntity::base_experience_reward(&chicken), + CHICKEN_JOCKEY_EXPERIENCE_REWARD + ); +} + +#[test] +fn chicken_jockey_does_not_despawn_far_away() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + + assert!(!Mob::remove_when_far_away(&chicken, f64::MAX)); + + chicken.set_chicken_jockey(true); + assert!(Mob::remove_when_far_away(&chicken, f64::MAX)); +} + +#[test] +fn chicken_breeding_offspring_inherits_parent_variant() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + let partner = ChickenEntity::new(&vanilla_entities::CHICKEN, 2, DVec3::ZERO, Weak::new()); + let offspring = ChickenEntity::new(&vanilla_entities::CHICKEN, 3, DVec3::ZERO, Weak::new()); + + chicken.set_variant(&vanilla_chicken_variants::WARM); + partner.set_variant(&vanilla_chicken_variants::COLD); + offspring.set_variant(&vanilla_chicken_variants::TEMPERATE); + + chicken.initialize_breed_offspring(&partner, &offspring); + + let variant_key = &offspring.variant().key; + assert!( + variant_key == &vanilla_chicken_variants::WARM.key + || variant_key == &vanilla_chicken_variants::COLD.key + ); +} + +#[test] +fn chicken_finalize_spawn_assigns_registered_variant_and_sound_variant() { + init_vanilla_registry(); + + let world = fresh_test_world("chicken_finalize_spawn"); + let chicken = ChickenEntity::new( + &vanilla_entities::CHICKEN, + 1, + DVec3::new(0.0, 80.0, 0.0), + Arc::downgrade(&world), + ); + + let _ = Mob::finalize_spawn(&chicken, &world, EntitySpawnReason::Natural, None); + + assert!( + REGISTRY + .chicken_variants + .id_from_key(&chicken.variant().key) + .is_some() + ); + assert!( + REGISTRY + .chicken_sound_variants + .id_from_key(&chicken.sound_variant().key) + .is_some() + ); +} + +#[test] +fn chicken_egg_timer_decrements_only_for_adult_non_jockeys() { + init_vanilla_registry(); + + let world = fresh_test_world("chicken_egg_timer"); + let chicken = ChickenEntity::new( + &vanilla_entities::CHICKEN, + 1, + DVec3::new(0.0, 80.0, 0.0), + Arc::downgrade(&world), + ); + + chicken.set_egg_time(2); + chicken.tick_egg_laying(); + assert_eq!(chicken.egg_time(), 1); + + chicken.set_baby(true); + chicken.set_egg_time(2); + chicken.tick_egg_laying(); + assert_eq!(chicken.egg_time(), 2); + + chicken.set_baby(false); + chicken.set_chicken_jockey(true); + chicken.set_egg_time(2); + chicken.tick_egg_laying(); + assert_eq!(chicken.egg_time(), 2); +} + +#[test] +fn chicken_resets_egg_timer_within_vanilla_range_after_laying() { + init_vanilla_registry(); + + let world = fresh_test_world("chicken_egg_lay"); + let chicken = ChickenEntity::new( + &vanilla_entities::CHICKEN, + 1, + DVec3::new(0.0, 80.0, 0.0), + Arc::downgrade(&world), + ); + + chicken.set_egg_time(1); + chicken.tick_egg_laying(); + + let egg_time = chicken.egg_time(); + assert!( + (EGG_LAY_MIN_DELAY_TICKS..EGG_LAY_MIN_DELAY_TICKS + EGG_LAY_RANDOM_RANGE_TICKS) + .contains(&egg_time), + "egg timer should reset to the vanilla delay range, got {egg_time}" + ); +} + +#[test] +fn chicken_slow_fall_reduces_downward_velocity() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + + chicken.set_on_ground(false); + let velocity_input = DVec3::new(1.0, -2.0, 3.0); + chicken.set_velocity(velocity_input); + chicken.tick_flapping(); + + let velocity = chicken.velocity(); + assert_eq!(velocity.x.to_bits(), 1.0_f64.to_bits()); + assert!( + (velocity.y - velocity_input.y * f64::from(FALL_DRAG_Y)).abs() < 1e-6, + "fall drag should scale the downward velocity by the vanilla constant" + ); + assert_eq!(velocity.z.to_bits(), 3.0_f64.to_bits()); +} diff --git a/steel-core/src/entity/entities/mobs/passive/chicken/tests/mod.rs b/steel-core/src/entity/entities/mobs/passive/chicken/tests/mod.rs new file mode 100644 index 00000000000..72f0858cc6e --- /dev/null +++ b/steel-core/src/entity/entities/mobs/passive/chicken/tests/mod.rs @@ -0,0 +1,18 @@ +use std::io::Cursor; +use std::sync::Weak; + +use glam::DVec3; +use simdnbt::borrow::read_compound as read_borrowed_compound; +use steel_registry::{ + sound_events, vanilla_attributes, vanilla_chicken_sound_variants, vanilla_chicken_variants, + vanilla_damage_types, vanilla_entities, vanilla_items, +}; + +use crate::entity::damage::DamageSource; +use crate::entity::{Animal, Entity, LivingEntity, Mob}; +use crate::test_support::fresh_test_world; + +use super::*; + +mod core; +mod persistence; diff --git a/steel-core/src/entity/entities/mobs/passive/chicken/tests/persistence.rs b/steel-core/src/entity/entities/mobs/passive/chicken/tests/persistence.rs new file mode 100644 index 00000000000..a21e745aef3 --- /dev/null +++ b/steel-core/src/entity/entities/mobs/passive/chicken/tests/persistence.rs @@ -0,0 +1,82 @@ +use super::*; +use steel_registry::init_vanilla_registry; + +#[test] +fn chicken_saves_vanilla_state_and_variant_data() { + init_vanilla_registry(); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + chicken.set_age(-24_000); + chicken.set_variant(&vanilla_chicken_variants::WARM); + chicken.set_sound_variant(&vanilla_chicken_sound_variants::PICKY); + chicken.set_chicken_jockey(true); + chicken.set_egg_time(1234); + + let mut nbt = NbtCompound::new(); + chicken.save_additional(&mut nbt); + + assert_eq!(nbt.int("Age"), Some(-24_000)); + assert_eq!(nbt.byte("IsChickenJockey"), Some(1)); + assert_eq!(nbt.int("EggLayTime"), Some(1234)); + assert_eq!( + nbt.string("variant").map(ToString::to_string), + Some("minecraft:warm".to_owned()) + ); + assert_eq!( + nbt.string("sound_variant").map(ToString::to_string), + Some("minecraft:picky".to_owned()) + ); +} + +#[test] +fn chicken_loads_vanilla_state_and_variant_data() { + init_vanilla_registry(); + + let mut nbt = NbtCompound::new(); + nbt.insert("Age", -24_000_i32); + nbt.insert("IsChickenJockey", 1_i8); + nbt.insert("EggLayTime", 5678_i32); + nbt.insert("variant", "minecraft:cold"); + nbt.insert("sound_variant", "minecraft:picky"); + + let mut bytes = Vec::new(); + nbt.write(&mut bytes); + let borrowed = read_borrowed_compound(&mut Cursor::new(&bytes)) + .unwrap_or_else(|error| panic!("test nbt should reborrow: {error}")); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + chicken.load_additional((&borrowed).into()); + + assert_eq!(chicken.get_age(), -24_000); + assert!(chicken.is_chicken_jockey()); + assert_eq!(chicken.egg_time(), 5678); + assert_eq!(chicken.variant().key, vanilla_chicken_variants::COLD.key); + assert_eq!( + chicken.sound_variant().key, + vanilla_chicken_sound_variants::PICKY.key + ); +} + +#[test] +fn chicken_load_defaults_jockey_and_keeps_random_egg_time_when_absent() { + init_vanilla_registry(); + + let mut nbt = NbtCompound::new(); + nbt.insert("variant", "minecraft:temperate"); + nbt.insert("sound_variant", "minecraft:classic"); + + let mut bytes = Vec::new(); + nbt.write(&mut bytes); + let borrowed = read_borrowed_compound(&mut Cursor::new(&bytes)) + .unwrap_or_else(|error| panic!("test nbt should reborrow: {error}")); + + let chicken = ChickenEntity::new(&vanilla_entities::CHICKEN, 1, DVec3::ZERO, Weak::new()); + chicken.load_additional((&borrowed).into()); + + assert!(!chicken.is_chicken_jockey()); + assert!( + (EGG_LAY_MIN_DELAY_TICKS..EGG_LAY_MIN_DELAY_TICKS + EGG_LAY_RANDOM_RANGE_TICKS) + .contains(&chicken.egg_time()), + "constructor-initialized egg time should stay in the vanilla range" + ); +} diff --git a/steel-core/src/entity/entities/mobs/passive/mod.rs b/steel-core/src/entity/entities/mobs/passive/mod.rs index 6e360c3abf8..e3e613400cc 100644 --- a/steel-core/src/entity/entities/mobs/passive/mod.rs +++ b/steel-core/src/entity/entities/mobs/passive/mod.rs @@ -1,9 +1,11 @@ //! Passive entity implementations. /// Those mobs are passive creatures that run away when attacked by a player. +mod chicken; mod cow; mod pig; mod sheep; +pub use chicken::ChickenEntity; pub use cow::CowEntity; pub use pig::PigEntity; pub use sheep::SheepEntity; diff --git a/steel-core/src/entity/entities/mod.rs b/steel-core/src/entity/entities/mod.rs index c1874fd81c2..7eff58b8cbc 100644 --- a/steel-core/src/entity/entities/mod.rs +++ b/steel-core/src/entity/entities/mod.rs @@ -4,10 +4,10 @@ pub mod mobs; pub mod objects; mod raw; -pub use mobs::passive::{CowEntity, PigEntity, SheepEntity}; +pub use mobs::passive::{ChickenEntity, CowEntity, PigEntity, SheepEntity}; pub use objects::display_ui::{BlockDisplayEntity, ItemFrameEntity, LeashFenceKnotEntity}; pub use objects::explosives::EndCrystalEntity; pub use objects::items::{ExperienceOrbEntity, FallingBlockEntity, ItemEntity}; -pub use objects::projectiles::{EnderPearlEntity, FireworkRocketEntity}; +pub use objects::projectiles::{EnderPearlEntity, FireworkRocketEntity, ThrownEggEntity}; pub use objects::vehicles::ChestMinecartEntity; pub use raw::RawEntity; diff --git a/steel-core/src/entity/entities/objects/display_ui/leash_fence_knot.rs b/steel-core/src/entity/entities/objects/display_ui/leash_fence_knot.rs index c85dd468f56..0c6e1e00d26 100644 --- a/steel-core/src/entity/entities/objects/display_ui/leash_fence_knot.rs +++ b/steel-core/src/entity/entities/objects/display_ui/leash_fence_knot.rs @@ -301,7 +301,7 @@ impl Entity for LeashFenceKnotEntity { } fn hurt(&self, world: &World, source: &DamageSource, _amount: f32) -> bool { - if self.default_is_invulnerable_to(source) { + if self.is_invulnerable_to_base(source) { return false; } diff --git a/steel-core/src/entity/entities/objects/items/experience_orb.rs b/steel-core/src/entity/entities/objects/items/experience_orb.rs index d481e76ba6e..8d7306cadf3 100644 --- a/steel-core/src/entity/entities/objects/items/experience_orb.rs +++ b/steel-core/src/entity/entities/objects/items/experience_orb.rs @@ -10,8 +10,8 @@ use steel_protocol::packets::game::{CTakeItemEntity, SoundSource}; use steel_registry::blocks::block_state_ext::BlockStateExt as _; use steel_registry::entity_type::EntityTypeRef; use steel_registry::fluid::FluidStateExt as _; +use steel_registry::vanilla_entities; use steel_registry::vanilla_entity_data::ExperienceOrbEntityData; -use steel_registry::{vanilla_damage_type_tags, vanilla_entities}; use steel_utils::locks::SyncMutex; use steel_utils::{BlockPos, ChunkPos, Downcast as _, DowncastType, DowncastTypeKey, WorldAabb}; @@ -355,14 +355,6 @@ impl ExperienceOrbEntity { self.set_velocity(velocity); } - fn is_base_invulnerable_to(&self, source: &DamageSource) -> bool { - self.is_removed() - || self.is_invulnerable() && !source.bypasses_invulnerability() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL) - && self.is_fall_damage_immune() - } - /// Attempts to have a player pick up this experience orb. pub fn try_pickup(&self, player: &Arc) -> bool { if player.take_xp_delay() != 0 { @@ -495,7 +487,7 @@ impl Entity for ExperienceOrbEntity { } fn hurt(&self, _world: &World, source: &DamageSource, amount: f32) -> bool { - if self.is_base_invulnerable_to(source) { + if self.is_invulnerable_to_base(source) { return false; } diff --git a/steel-core/src/entity/entities/objects/items/falling_block.rs b/steel-core/src/entity/entities/objects/items/falling_block.rs index 4ae6942e508..d4644d76ac2 100644 --- a/steel-core/src/entity/entities/objects/items/falling_block.rs +++ b/steel-core/src/entity/entities/objects/items/falling_block.rs @@ -18,8 +18,7 @@ use steel_registry::vanilla_block_tags::BlockTag; use steel_registry::vanilla_entity_data::FallingBlockEntityData; use steel_registry::vanilla_game_rules::ENTITY_DROPS; use steel_registry::{ - REGISTRY, vanilla_blocks, vanilla_damage_type_tags, vanilla_damage_types, vanilla_entities, - vanilla_fluids, + REGISTRY, vanilla_blocks, vanilla_damage_types, vanilla_entities, vanilla_fluids, }; use steel_utils::locks::SyncMutex; use steel_utils::{BlockPos, BlockStateId, DowncastType, DowncastTypeKey, types::UpdateFlags}; @@ -380,14 +379,6 @@ impl FallingBlockEntity { } self.try_place_carried_block(world, pos, current_state, is_stuck_in_water, block); } - - fn is_base_invulnerable_to(&self, source: &DamageSource) -> bool { - self.is_removed() - || self.is_invulnerable() && !source.bypasses_invulnerability() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL) - && self.is_fall_damage_immune() - } } impl Entity for FallingBlockEntity { @@ -452,7 +443,7 @@ impl Entity for FallingBlockEntity { } fn hurt(&self, _world: &World, source: &DamageSource, _amount: f32) -> bool { - if !self.is_base_invulnerable_to(source) { + if !self.is_invulnerable_to_base(source) { self.mark_hurt(); } false diff --git a/steel-core/src/entity/entities/objects/projectiles/ender_pearl.rs b/steel-core/src/entity/entities/objects/projectiles/ender_pearl.rs index a6ad921f045..37e430eebac 100644 --- a/steel-core/src/entity/entities/objects/projectiles/ender_pearl.rs +++ b/steel-core/src/entity/entities/objects/projectiles/ender_pearl.rs @@ -273,11 +273,6 @@ impl Entity for EnderPearlEntity { Some(&self.entity_data) } - fn hurt(&self, _world: &World, _source: &DamageSource, _amount: f32) -> bool { - // Vanilla `Projectile.hurtServer` marks hurt but never takes damage. - false - } - fn save_additional(&self, nbt: &mut NbtCompound) { self.save_projectile(nbt); self.save_throwable_item(nbt); diff --git a/steel-core/src/entity/entities/objects/projectiles/firework_rocket.rs b/steel-core/src/entity/entities/objects/projectiles/firework_rocket.rs index 7b2aa300ca0..0a37ae35672 100644 --- a/steel-core/src/entity/entities/objects/projectiles/firework_rocket.rs +++ b/steel-core/src/entity/entities/objects/projectiles/firework_rocket.rs @@ -16,10 +16,7 @@ use steel_registry::data_components::vanilla_components::FIREWORKS; use steel_registry::entity_type::EntityTypeRef; use steel_registry::item_stack::ItemStack; use steel_registry::vanilla_entity_data::FireworkRocketEntityData; -use steel_registry::{ - sound_events, vanilla_damage_type_tags, vanilla_damage_types, vanilla_game_events, - vanilla_items, -}; +use steel_registry::{sound_events, vanilla_damage_types, vanilla_game_events, vanilla_items}; use steel_utils::entity_events::EntityStatus; use steel_utils::locks::SyncMutex; use steel_utils::{DowncastType, DowncastTypeKey}; @@ -99,14 +96,6 @@ impl FireworkRocketEntity { } } - fn is_base_invulnerable_to(&self, source: &DamageSource) -> bool { - self.is_removed() - || self.is_invulnerable() && !source.bypasses_invulnerability() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL) - && self.is_fall_damage_immune() - } - /// Creates a normally launched rocket at an exact position. #[must_use] pub fn launched( @@ -422,13 +411,6 @@ impl Entity for FireworkRocketEntity { Some(&self.entity_data) } - fn hurt(&self, _world: &World, source: &DamageSource, _amount: f32) -> bool { - if !self.is_base_invulnerable_to(source) { - self.mark_hurt(); - } - false - } - fn save_additional(&self, nbt: &mut NbtCompound) { self.save_projectile(nbt); let state = self.state.lock(); @@ -577,12 +559,12 @@ mod tests { ); let source = DamageSource::environment(&vanilla_damage_types::GENERIC); - assert!(!rocket.hurt(test_world(), &source, 1.0)); + assert!(!Entity::hurt(&rocket, test_world(), &source, 1.0)); assert!(rocket.hurt_marked()); rocket.clear_hurt_mark(); rocket.set_invulnerable(true); - assert!(!rocket.hurt(test_world(), &source, 1.0)); + assert!(!Entity::hurt(&rocket, test_world(), &source, 1.0)); assert!(!rocket.hurt_marked()); } diff --git a/steel-core/src/entity/entities/objects/projectiles/mod.rs b/steel-core/src/entity/entities/objects/projectiles/mod.rs index c727fa0b027..c482cc9c647 100644 --- a/steel-core/src/entity/entities/objects/projectiles/mod.rs +++ b/steel-core/src/entity/entities/objects/projectiles/mod.rs @@ -2,6 +2,8 @@ mod ender_pearl; mod firework_rocket; +mod thrown_egg; pub use ender_pearl::EnderPearlEntity; pub use firework_rocket::FireworkRocketEntity; +pub use thrown_egg::ThrownEggEntity; diff --git a/steel-core/src/entity/entities/objects/projectiles/thrown_egg.rs b/steel-core/src/entity/entities/objects/projectiles/thrown_egg.rs new file mode 100644 index 00000000000..9a5905f1e61 --- /dev/null +++ b/steel-core/src/entity/entities/objects/projectiles/thrown_egg.rs @@ -0,0 +1,463 @@ +//! Thrown egg projectile entity (`ThrownEgg`). +//! +//! Mirrors vanilla `ThrownEgg` (yarn `EggEntity`) on the Steel +//! `Projectile → ThrowableProjectile → ThrowableItemProjectile` trait stack. +//! On impact it may hatch one chick (or four with a rarer roll), each born as a +//! baby that inherits the egg stack's `chicken/variant` component when present. +//! The egg then broadcasts the item-break entity event and discards itself. + +use std::sync::{Arc, Weak}; + +use glam::DVec3; +use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView; +use simdnbt::owned::NbtCompound; +use steel_macros::entity_behavior; +use steel_protocol::packets::game::SoundSource; +use steel_registry::data_components::vanilla_components::CHICKEN_VARIANT; +use steel_registry::entity_type::{EntityDimensions, EntityTypeRef}; +use steel_registry::item_stack::ItemStack; +use steel_registry::items::ItemRef; +use steel_registry::vanilla_entity_data::EggEntityData; +use steel_registry::{vanilla_damage_types, vanilla_entities, vanilla_items}; +use steel_utils::entity_events::EntityStatus; +use steel_utils::locks::SyncMutex; +use steel_utils::{DowncastType, DowncastTypeKey}; + +use crate::entity::damage::DamageSource; +use crate::entity::entities::ChickenEntity; +use crate::entity::{ + AgeableMob, Entity, EntityBase, EntityBaseLoad, EntitySyncedData, Projectile, ProjectileBase, + ProjectileHit, RemovalReason, SharedEntity, ThrowableItemProjectile, ThrowableProjectile, + next_entity_id, +}; +use crate::world::World; + +/// Baby chicken start age applied to hatched chicks (vanilla `getBabyStartAge`). +const BABY_CHICK_AGE: i32 = -24000; + +/// Denomator of the one-in-eight hatch chance (vanilla `ThrownEgg.onHit`). +const HATCH_ROLL_DENOMINATOR: u32 = 8; +/// Denominator of the one-in-thirty-two quadruple-hatch chance. +const QUAD_HATCH_ROLL_DENOMINATOR: u32 = 32; +/// Chicks born when the quadruple roll succeeds. +const QUAD_HATCH_COUNT: usize = 4; + +/// Zero-sized dimensions used as the pre-hatch footprint (vanilla +/// `ThrownEgg.ZERO_SIZED_DIMENSIONS`). +const ZERO_SIZED_DIMENSIONS: EntityDimensions = EntityDimensions::new(0.0, 0.0, 0.0); + +/// A thrown egg. +#[entity_behavior(class = "ThrownEgg")] +pub struct ThrownEggEntity { + /// Common entity fields (id, uuid, position, etc.). + base: EntityBase, + /// Vanilla entity type registered for this implementation. + entity_type: EntityTypeRef, + /// Synced data carrying the rendered item stack. + entity_data: SyncMutex, + /// Shared `Projectile` state (owner / left-owner / has-been-shot). + projectile_base: ProjectileBase, +} + +// SAFETY: This key is owned by Steel and uniquely identifies `ThrownEggEntity`. +unsafe impl DowncastType for ThrownEggEntity { + const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/thrown_egg"); +} + +impl ThrownEggEntity { + /// Creates a new thrown egg with no owner and the default rendered item. + #[must_use] + pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak) -> Self { + Self { + base: EntityBase::new(id, position, entity_type.dimensions, world), + entity_type, + entity_data: SyncMutex::new(EggEntityData::new()), + projectile_base: ProjectileBase::new(), + } + } + + /// Creates a thrown egg from saved base data. + #[must_use] + pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self { + Self { + base: EntityBase::from_load(load, entity_type.dimensions), + entity_type, + entity_data: SyncMutex::new(EggEntityData::new()), + projectile_base: ProjectileBase::new(), + } + } + + /// Returns the number of chicks to hatch from the two vanilla rolls. + /// + /// Vanilla rolls lazily (the second roll only happens when the first + /// succeeds); rolling both up front preserves the exact distribution. + const fn hatch_count(eighth_roll: u32, thirty_second_roll: u32) -> usize { + if eighth_roll != 0 { + 0 + } else if thirty_second_roll == 0 { + QUAD_HATCH_COUNT + } else { + 1 + } + } + + /// Applies the egg stack's `chicken/variant` component to a hatched chick + /// (vanilla `ThrownEgg.onHit` component inheritance). + fn apply_hatchling_variant(item_stack: &ItemStack, chicken: &ChickenEntity) { + if let Some(variant) = item_stack.get(CHICKEN_VARIANT) { + chicken.set_variant(variant.value()); + } + } + + /// Creates and adds one hatched chick, returning whether it was placed. + /// + /// Mirrors the per-chick body of vanilla `ThrownEgg.onHit`: the chick is + /// born at the egg's position with the egg's yaw, aged into a baby, then + /// fudged into the nearest free spot. Returns `false` when the chick does + /// not fit, aborting the remaining hatch loop like vanilla. + fn spawn_hatchling(&self, world: &Arc) -> bool { + let position = self.position(); + let chicken = Arc::new(ChickenEntity::new( + &vanilla_entities::CHICKEN, + next_entity_id(), + position, + Arc::downgrade(world), + )); + + AgeableMob::set_age(chicken.as_ref(), BABY_CHICK_AGE); + let (yaw, _) = self.rotation(); + chicken.set_rotation((yaw, 0.0)); + Self::apply_hatchling_variant(&self.get_item(), &chicken); + + if !chicken.fudge_position_after_size_change(ZERO_SIZED_DIMENSIONS) { + return false; + } + + let entity: SharedEntity = chicken; + match world.try_add_entity(entity) { + Ok(()) => true, + Err(error) => { + // Vanilla `addFreshEntity` drops the chick silently if the + // destination chunk is not loaded; mirror that by continuing. + log::debug!("failed to add hatched chick: {error}"); + true + } + } + } +} + +impl Entity for ThrownEggEntity { + fn base(&self) -> &EntityBase { + &self.base + } + + fn entity_type(&self) -> EntityTypeRef { + self.entity_type + } + + fn tick(&self) { + self.throwable_projectile_tick(); + } + + fn get_default_gravity(&self) -> f64 { + self.throwable_default_gravity() + } + + fn sound_source(&self) -> SoundSource { + SoundSource::Neutral + } + + fn spawn_data(&self) -> i32 { + self.get_owner().map_or(0, |owner| owner.id()) + } + + fn restore_owner_reference(&self, owner: &SharedEntity) { + self.cache_owner_entity(owner); + } + + fn projectile_owner_uuid(&self) -> Option { + self.owner_uuid() + } + + fn projectile_owner(&self) -> Option { + self.get_owner() + } + + fn attackable(&self) -> bool { + false + } + + fn synced_data(&self) -> Option<&dyn EntitySyncedData> { + Some(&self.entity_data) + } + + fn save_additional(&self, nbt: &mut NbtCompound) { + self.save_projectile(nbt); + self.save_throwable_item(nbt); + } + + fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) { + self.load_projectile(nbt); + self.load_throwable_item(nbt); + } +} + +impl Projectile for ThrownEggEntity { + fn projectile_base(&self) -> &ProjectileBase { + &self.projectile_base + } + + fn on_hit_entity(&self, entity: &SharedEntity, _location: DVec3) { + // Vanilla `ThrownEgg.onHitEntity`: super.onHitEntity() (no-op), then + // deal 0 damage with a `thrown` source so the hit registers the impact. + let mut damage = + DamageSource::environment(&vanilla_damage_types::THROWN).with_direct_entity(self.id()); + if let Some(owner) = self.get_owner() { + damage = damage.with_causing_entity(owner.id()); + } + if let Some(world) = entity.level() { + entity.hurt(&world, &damage, 0.0); + } + } + + fn on_hit(&self, hit: &ProjectileHit) { + // Vanilla `ThrownEgg.onHit`: super.onHit() then the server-side hatch. + self.projectile_on_hit(hit); + + let Some(world) = self.level() else { + return; + }; + + let count = Self::hatch_count( + rand::random_range(0..HATCH_ROLL_DENOMINATOR), + rand::random_range(0..QUAD_HATCH_ROLL_DENOMINATOR), + ); + for _ in 0..count { + if !self.spawn_hatchling(&world) { + break; + } + } + + // VANILLA CLIENT-LOCAL: entity event 3 renders the egg break particles + // on clients via `ThrownEgg.handleEntityEvent`; the server only relays + // the event. The shared `EntityStatus::Death` variant carries byte 3. + self.broadcast_entity_event(EntityStatus::Death); + self.set_removed(RemovalReason::Discarded); + } +} + +impl ThrowableProjectile for ThrownEggEntity {} + +impl ThrowableItemProjectile for ThrownEggEntity { + fn get_default_item(&self) -> ItemRef { + &vanilla_items::EGG + } + + fn set_item(&self, item: ItemStack) { + self.entity_data + .lock() + .throwable_item_projectile + .item_stack + .set(item); + } + + fn get_item(&self) -> ItemStack { + self.entity_data + .lock() + .throwable_item_projectile + .item_stack + .get() + .clone() + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Weak}; + + use glam::DVec3; + use steel_registry::{ + RegistryReference, init_vanilla_registry, vanilla_blocks, vanilla_chicken_variants, + vanilla_damage_types, vanilla_entities, vanilla_items, + }; + use steel_utils::types::UpdateFlags; + use steel_utils::{BlockPos, ChunkPos, Downcast}; + + use crate::behavior::init_behaviors; + use crate::entity::damage::DamageSource; + use crate::entity::entities::ChickenEntity; + use crate::entity::{AgeableMob, Entity, ThrowableItemProjectile, WorldAabb}; + use crate::test_support::{fresh_test_world, insert_ready_full_chunk, test_world}; + + use super::*; + + #[test] + fn hurt_marks_egg_unless_base_invulnerable_and_always_returns_false() { + init_vanilla_registry(); + + let egg = + ThrownEggEntity::new(&vanilla_entities::EGG, 1, DVec3::ZERO, Weak::::new()); + let source = DamageSource::environment(&vanilla_damage_types::GENERIC); + + assert!(!Entity::hurt(&egg, test_world(), &source, 1.0)); + assert!(egg.hurt_marked()); + + egg.clear_hurt_mark(); + egg.set_invulnerable(true); + assert!(!Entity::hurt(&egg, test_world(), &source, 1.0)); + assert!(!egg.hurt_marked()); + } + + #[test] + fn hatch_count_matches_vanilla_odds() { + assert_eq!(ThrownEggEntity::hatch_count(1, 0), 0); + assert_eq!(ThrownEggEntity::hatch_count(7, 5), 0); + assert_eq!(ThrownEggEntity::hatch_count(0, 3), 1); + assert_eq!(ThrownEggEntity::hatch_count(0, 0), QUAD_HATCH_COUNT); + } + + #[test] + fn hatchling_inherits_egg_variant_component() { + init_vanilla_registry(); + + let egg = + ThrownEggEntity::new(&vanilla_entities::EGG, 1, DVec3::ZERO, Weak::::new()); + let mut stack = ItemStack::new(&vanilla_items::EGG); + stack.set( + CHICKEN_VARIANT, + RegistryReference::new(&vanilla_chicken_variants::COLD), + ); + egg.set_item(stack); + + let chicken = ChickenEntity::new( + &vanilla_entities::CHICKEN, + 2, + DVec3::ZERO, + Weak::::new(), + ); + ThrownEggEntity::apply_hatchling_variant(&egg.get_item(), &chicken); + + assert_eq!(chicken.variant().key, vanilla_chicken_variants::COLD.key); + } + + #[test] + fn hatchling_without_variant_keeps_default() { + init_vanilla_registry(); + + let egg = + ThrownEggEntity::new(&vanilla_entities::EGG, 1, DVec3::ZERO, Weak::::new()); + egg.set_item(ItemStack::new(&vanilla_items::EGG)); + + let chicken = ChickenEntity::new( + &vanilla_entities::CHICKEN, + 2, + DVec3::ZERO, + Weak::::new(), + ); + ThrownEggEntity::apply_hatchling_variant(&egg.get_item(), &chicken); + + assert_eq!( + chicken.variant().key, + vanilla_chicken_variants::TEMPERATE.key + ); + } + + #[test] + fn hatchling_spawns_baby_chicken_at_egg_position() { + init_vanilla_registry(); + + let world = fresh_test_world("thrown_egg_hatchling"); + let pos = DVec3::new(0.5, 80.0, 0.5); + insert_ready_full_chunk(&world, ChunkPos::from_entity_pos(pos)); + let egg = Arc::new(ThrownEggEntity::new( + &vanilla_entities::EGG, + 1, + pos, + Arc::downgrade(&world), + )); + egg.set_item(ItemStack::new(&vanilla_items::EGG)); + + assert!(egg.spawn_hatchling(&world)); + + let chicks = world.get_entities_in_aabb(&WorldAabb::of_size(pos, 1.0, 2.0, 1.0)); + let chick = chicks + .iter() + .find(|entity| entity.entity_type() == &vanilla_entities::CHICKEN) + .expect("hatched chick should be in the world"); + let chicken = chick + .as_ref() + .downcast_ref::() + .expect("chick should be a chicken"); + assert!(AgeableMob::is_baby(chicken)); + assert!((chicken.position() - pos).length() < 1.0); + } + + #[test] + fn hatchling_does_not_spawn_fully_enclosed_in_solid_block() { + init_vanilla_registry(); + init_behaviors(); + + let world = fresh_test_world("thrown_egg_enclosed"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + assert!(world.set_block( + BlockPos::new(0, 80, 0), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_NONE, + )); + + // The egg lands at the center of the stone block, where the chick box + // cannot fit; the fudge finds no free position and the hatch aborts. + let egg = Arc::new(ThrownEggEntity::new( + &vanilla_entities::EGG, + 1, + DVec3::new(0.5, 80.5, 0.5), + Arc::downgrade(&world), + )); + egg.set_item(ItemStack::new(&vanilla_items::EGG)); + + assert!(!egg.spawn_hatchling(&world)); + assert!( + world + .get_entities_in_aabb(&WorldAabb::of_size( + DVec3::new(0.5, 80.5, 0.5), + 1.0, + 2.0, + 1.0, + )) + .iter() + .all(|entity| entity.entity_type() != &vanilla_entities::CHICKEN) + ); + } + + #[test] + fn hatchling_fits_when_egg_lands_on_top_of_solid_block() { + init_vanilla_registry(); + init_behaviors(); + + let world = fresh_test_world("thrown_egg_on_block"); + insert_ready_full_chunk(&world, ChunkPos::new(0, 0)); + assert!(world.set_block( + BlockPos::new(0, 80, 0), + vanilla_blocks::STONE.default_state(), + UpdateFlags::UPDATE_NONE, + )); + + // The egg rests on the block surface, so the chick fits right there. + let pos = DVec3::new(0.5, 81.0, 0.5); + let egg = Arc::new(ThrownEggEntity::new( + &vanilla_entities::EGG, + 1, + pos, + Arc::downgrade(&world), + )); + egg.set_item(ItemStack::new(&vanilla_items::EGG)); + + assert!(egg.spawn_hatchling(&world)); + assert!( + world + .get_entities_in_aabb(&WorldAabb::of_size(pos, 1.0, 2.0, 1.0)) + .iter() + .any(|entity| entity.entity_type() == &vanilla_entities::CHICKEN) + ); + } +} diff --git a/steel-core/src/entity/entity/mod.rs b/steel-core/src/entity/entity/mod.rs index 6424673f045..d492afd368b 100644 --- a/steel-core/src/entity/entity/mod.rs +++ b/steel-core/src/entity/entity/mod.rs @@ -1,6 +1,12 @@ use super::*; use crate::entity::leash::Leashable; +/// Vanilla `Entity.refreshDimensions` small-entity limit: only entities at most +/// this wide and tall (in blocks) get their position fudged after growing. +const FUDGE_SMALL_DIMENSION_LIMIT: f32 = 4.0; +/// Vanilla `Entity.fudgePositionAfterSizeChange` epsilon padding (vanilla `1.0E-6`). +const FUDGE_POSITION_EPSILON: f64 = 1.0e-6; + /// Final state accepted from a client-authored movement packet. #[derive(Debug, Clone, Copy, PartialEq)] pub struct AcceptedClientMovement { @@ -120,15 +126,6 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { .insertion(self.uuid().to_string()) } - /// Returns vanilla base living-entity invulnerability. - fn default_is_invulnerable_to(&self, source: &DamageSource) -> bool { - self.is_removed() - || self.is_invulnerable() && !source.bypasses_invulnerability() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune() - || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL) - && self.is_fall_damage_immune() - } - /// Returns this entity's plain vanilla name. /// /// Custom names are resolved as text components; otherwise entities use @@ -699,19 +696,7 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { /// /// Mirrors vanilla `Entity.positionRider`. fn position_rider(&self, passenger: &dyn Entity) { - if !self.has_passenger(passenger) { - return; - } - - let riding_position = self.passenger_riding_position(passenger); - let vehicle_attachment = passenger.vehicle_attachment_point(self.as_entity_event_source()); - if let Err(error) = passenger.try_set_position(riding_position - vehicle_attachment) { - log::debug!( - "Failed to position passenger {} riding entity {}: {error}", - passenger.id(), - self.id() - ); - } + position_rider_default(self, passenger); } /// Returns this entity's root vehicle ID, or this entity's ID when it is not riding. @@ -2607,9 +2592,103 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { /// Refreshes dimensions for the current physical pose. fn refresh_dimensions(&self) { let pose = self.pose(); - self.base() - .set_pose_and_dimensions(pose, self.dimensions_for_pose(pose)); - // TODO: Fudge position after growth once free-position probing exists. + let old_dimensions = self.base().dimensions(); + let new_dimensions = self.dimensions_for_pose(pose); + self.base().set_pose_and_dimensions(pose, new_dimensions); + + // Vanilla fudges the position when growth would push the entity into + // neighboring blocks, and only for small, non-player entities. Vanilla + // also requires the entity to have ticked once (`!firstTick`), which + // Steel does not track; no current entity grows on its first tick, so + // the omission is not observable. + let is_small = new_dimensions.width <= FUDGE_SMALL_DIMENSION_LIMIT + && new_dimensions.height <= FUDGE_SMALL_DIMENSION_LIMIT; + if self.level().is_some() + && !self.no_physics() + && is_small + && (new_dimensions.width > old_dimensions.width + || new_dimensions.height > old_dimensions.height) + && self.as_player().is_none() + { + self.fudge_position_after_size_change(old_dimensions); + } + } + + /// Vanilla `Entity.fudgePositionAfterSizeChange`. + /// + /// Moves the entity to the closest free position for its new dimensions + /// within the previous dimensions' footprint, returning whether a valid + /// position was found. Vanilla runs this after growing in a confined space; + /// Steel's `ThrownEgg` hatchlings also use it to fit a newborn chick at the + /// egg's impact point. + fn fudge_position_after_size_change(&self, previous_dimensions: EntityDimensions) -> bool { + let new_dimensions = self.dimensions_for_pose(self.pose()); + let old_center = + self.position() + DVec3::new(0.0, f64::from(previous_dimensions.height) / 2.0, 0.0); + let width_delta = f64::from((new_dimensions.width - previous_dimensions.width).max(0.0)) + + FUDGE_POSITION_EPSILON; + let height_delta = f64::from((new_dimensions.height - previous_dimensions.height).max(0.0)) + + FUDGE_POSITION_EPSILON; + let allowed_centers = [WorldAabb::of_size( + old_center, + width_delta, + height_delta, + width_delta, + )]; + + let Some(world) = self.level() else { + return false; + }; + let provider = WorldCollisionProvider::for_entity(&world, self.as_entity_event_source()); + if let Some(free_center) = provider.find_free_position( + &allowed_centers, + old_center, + f64::from(new_dimensions.width), + f64::from(new_dimensions.height), + f64::from(new_dimensions.width), + ) { + let new_position = + free_center + DVec3::new(0.0, -f64::from(new_dimensions.height) / 2.0, 0.0); + match self.try_set_position(new_position) { + Ok(()) => return true, + Err(error) => { + log::warn!( + "failed to fudge entity {} position after size change: {error}", + self.id() + ); + } + } + } + + // Vanilla retries ignoring the vertical axis when both dimensions grow, + // allowing the entity to keep its previous footprint horizontally. + if new_dimensions.width > previous_dimensions.width + && new_dimensions.height > previous_dimensions.height + { + let allowed_centers_ignoring_y = [WorldAabb::of_size( + old_center, + width_delta, + FUDGE_POSITION_EPSILON, + width_delta, + )]; + if let Some(free_center) = provider.find_free_position( + &allowed_centers_ignoring_y, + old_center, + f64::from(new_dimensions.width), + f64::from(previous_dimensions.height), + f64::from(new_dimensions.width), + ) { + let new_position = free_center + + DVec3::new( + 0.0, + -f64::from(previous_dimensions.height) / 2.0 + FUDGE_POSITION_EPSILON, + 0.0, + ); + return self.try_set_position(new_position).is_ok(); + } + } + + false } /// Sets the physical pose and synchronized pose metadata. @@ -3490,8 +3569,41 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { /// Mirrors vanilla's `Entity.readAdditionalSaveData()`. fn load_additional(&self, _nbt: BorrowedNbtCompoundView<'_, '_>) {} + /// Returns vanilla `Entity.isInvulnerableToBase`. + fn is_invulnerable_to_base(&self, source: &DamageSource) -> bool { + self.is_removed() + || self.is_invulnerable() + && !source.bypasses_invulnerability() + && !self.source_is_creative_player(source) + || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune() + || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL) + && self.is_fall_damage_immune() + } + + /// Returns vanilla `DamageSource.isCreativePlayer`: whether the damage's + /// causing entity is a player with infinite materials. + fn source_is_creative_player(&self, source: &DamageSource) -> bool { + let Some(causing_entity_id) = source.causing_entity_id else { + return false; + }; + let Some(world) = self.level() else { + return false; + }; + world + .get_entity_by_id(causing_entity_id) + .is_some_and(|entity| { + entity + .as_player() + .is_some_and(Player::has_infinite_materials) + }) + } + /// Applies damage to this entity. fn hurt(&self, world: &World, source: &DamageSource, amount: f32) -> bool { + // Vanilla `Projectile.hurtServer` overrides the entity default. + if let Some(projectile) = self.as_projectile() { + return Projectile::hurt(projectile, world, source, amount); + } let Some(living) = self.as_living_entity() else { return false; }; @@ -3499,6 +3611,26 @@ pub trait Entity: EntityEventSource + ErasedType + Send + Sync + 'static { } } +/// Repositions a direct passenger from the vehicle's attachment point. +/// +/// Shared between the [`Entity::position_rider`] default and entity overrides that +/// extend it (e.g. `Chicken` mirroring the rider's body yaw). +pub(crate) fn position_rider_default(entity: &E, passenger: &dyn Entity) { + if !entity.has_passenger(passenger) { + return; + } + + let riding_position = entity.passenger_riding_position(passenger); + let vehicle_attachment = passenger.vehicle_attachment_point(entity.as_entity_event_source()); + if let Err(error) = passenger.try_set_position(riding_position - vehicle_attachment) { + log::debug!( + "Failed to position passenger {} riding entity {}: {error}", + passenger.id(), + entity.id() + ); + } +} + pub(crate) fn apply_entity_look_at(entity: &dyn Entity, from_anchor: EntityAnchor, target: DVec3) { let rotation = look_at_rotation(from_anchor.position(entity), target); entity.set_rotation(rotation); diff --git a/steel-core/src/entity/living_entity.rs b/steel-core/src/entity/living_entity.rs index 245ccbe5308..18d680b2bdf 100644 --- a/steel-core/src/entity/living_entity.rs +++ b/steel-core/src/entity/living_entity.rs @@ -293,6 +293,14 @@ pub trait LivingEntity: Entity { None } + /// Returns this entity's `minecraft:components.chicken/variant` key for the + /// entity loot context, when it is a chicken. + /// + /// Mirrors `Chicken.get(DataComponents.CHICKEN_VARIANT)` for the loot predicate. + fn chicken_loot_variant(&self) -> Option<&Identifier> { + None + } + /// Returns vanilla `LivingEntity.getSoundVolume`. fn sound_volume(&self) -> f32 { 1.0 @@ -567,6 +575,11 @@ pub trait LivingEntity: Entity { world.clip(start, end, block_shape, fluid).is_miss() } + /// Returns vanilla base living-entity invulnerability. + fn default_is_invulnerable_to(&self, source: &DamageSource) -> bool { + self.is_invulnerable_to_base(source) + } + /// Returns whether this living entity ignores a damage source. fn is_invulnerable_to(&self, world: &World, source: &DamageSource) -> bool { self.default_is_invulnerable_to(source) @@ -2953,6 +2966,7 @@ fn living_entity_loot_ref(entity: &E) -> EntityRef<'_> custom_name: None, sheep_color: sheep.map(|(color, _)| color), sheep_sheared: sheep.map(|(_, sheared)| sheared), + chicken_variant: entity.chicken_loot_variant(), } } diff --git a/steel-core/src/entity/mod.rs b/steel-core/src/entity/mod.rs index 1c08aa1c381..7a2766bc975 100644 --- a/steel-core/src/entity/mod.rs +++ b/steel-core/src/entity/mod.rs @@ -781,6 +781,7 @@ pub use callback::{ PlayerEntityCallback, RemovalReason, }; pub(crate) use entity::apply_entity_look_at; +pub(crate) use entity::position_rider_default; pub use entity::{ AcceptedClientMovement, AcceptedClientMovementOutcome, Entity, EntityEventSource, }; @@ -811,7 +812,7 @@ pub use movement_sync::{ pub use projectile::{ EntityHitResult, Projectile, ProjectileBase, ProjectileDeflection, ProjectileEventSource, ProjectileHit, ThrowableItemProjectile, ThrowableProjectile, ViewVectorHitResult, - compute_margin, get_hit_result_on_view_vector, + compute_margin, get_hit_result_on_view_vector, spawn_throwable_item_projectile, }; pub use registry::{ENTITIES, EntityLoadRequest, EntityRegistry, init_entities}; pub(crate) use spawn::{AgeableMobGroupData, EntitySpawnReason, SpawnGroupData}; @@ -1335,6 +1336,7 @@ pub(crate) fn entity_loot_ref(entity: &dyn Entity) -> EntityRef<'_> { custom_name: None, sheep_color: sheep.map(|(color, _)| color), sheep_sheared: sheep.map(|(_, sheared)| sheared), + chicken_variant: living_entity.and_then(LivingEntity::chicken_loot_variant), } } diff --git a/steel-core/src/entity/projectile/mod.rs b/steel-core/src/entity/projectile/mod.rs index e3c2729f738..f2b060a160a 100644 --- a/steel-core/src/entity/projectile/mod.rs +++ b/steel-core/src/entity/projectile/mod.rs @@ -19,6 +19,7 @@ use glam::DVec3; use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView; use simdnbt::owned::{NbtCompound, NbtTag}; use steel_registry::blocks::block_state_ext::BlockStateExt as _; +use steel_registry::item_stack::ItemStack; use steel_registry::vanilla_entity_type_tags::EntityTypeTag; use steel_registry::vanilla_game_rules::{MOB_GRIEFING, PROJECTILES_CAN_BREAK_BLOCKS}; use steel_registry::{REGISTRY, TaggedRegistryExt as _, vanilla_game_events}; @@ -28,8 +29,10 @@ use steel_utils::{UuidExt, WorldAabb}; use uuid::Uuid; use crate::behavior::BLOCK_BEHAVIORS; +use crate::enchantment_helper; use crate::entity::damage::DamageSource; use crate::entity::{Entity, LivingEntity, SharedEntity}; +use crate::player::Player; use crate::world::game_event::GameEventContext; use crate::world::{ClipBlockShape, ClipFluid, ClipHitResult, World}; @@ -42,6 +45,9 @@ const SHOOT_INACCURACY_SCALE: f64 = 0.0172_275; /// Vanilla `ProjectileUtil.DEFAULT_ENTITY_HIT_RESULT_MARGIN`. const MAX_ENTITY_HIT_MARGIN: f64 = 0.3; +/// Vanilla `ThrowableItemProjectile` spawn offset below the shooter's eye. +const THROWN_ITEM_SPAWN_EYE_OFFSET: f64 = 0.1; + /// Mirrors vanilla `RandomSource.triangle(mode, deviation)`. fn triangle_random(mode: f64, deviation: f64) -> f64 { mode + deviation * (rand::random::() - rand::random::()) @@ -591,6 +597,15 @@ pub trait Projectile: Entity + ProjectileEventSource { .on_projectile_hit(state, &world, hit, projectile); } + /// Vanilla `Projectile.hurtServer`: marks hurt unless invulnerable to the + /// base checks, but never takes damage. + fn hurt(&self, _world: &World, source: &DamageSource, _amount: f32) -> bool { + if !self.is_invulnerable_to_base(source) { + self.mark_hurt(); + } + false + } + /// Vanilla `Projectile.tick` (the `super.tick()` reached from subclasses). fn projectile_base_tick(&self) { if !self.has_been_shot() { @@ -634,6 +649,56 @@ pub trait Projectile: Entity + ProjectileEventSource { } } +/// Spawns a throwable-item projectile from the source's eye along its look +/// direction and registers it in the world. +/// +/// Mirrors vanilla `Projectile.spawnProjectileFromRotation` + `spawnProjectile`: +/// create the projectile at the eye minus 0.1, set its owner, shoot it with +/// `shootFromRotation`, add it to the world, then run `applyOnProjectileSpawned` +/// (the `minecraft:projectile_spawned` enchantment effects). `create` receives +/// the spawn position and must return the concrete projectile type. Returns +/// `None` when the world rejects the projectile so the caller can fail the use. +#[must_use] +pub fn spawn_throwable_item_projectile( + world: &Arc, + player: &Player, + item_stack: &mut ItemStack, + power: f32, + uncertainty: f32, + create: impl FnOnce(DVec3) -> E, +) -> Option +where + E: Projectile + ThrowableItemProjectile + Entity + 'static, +{ + let player_pos = player.position(); + let spawn_pos = DVec3::new( + player_pos.x, + player.get_eye_y() - THROWN_ITEM_SPAWN_EYE_OFFSET, + player_pos.z, + ); + + let entity = create(spawn_pos); + if let Some(owner) = world.players.get_by_uuid(&player.gameprofile.id) { + let owner: SharedEntity = owner; + entity.set_owner_entity(Some(&owner)); + } else { + entity.set_owner_uuid(Some(player.gameprofile.id)); + } + entity.set_item_clamped(item_stack.clone()); + + let (yaw, player_pitch) = player.rotation(); + entity.shoot_from_rotation(player, player_pitch, yaw, 0.0, power, uncertainty); + + let entity: SharedEntity = Arc::new(entity); + if let Err(error) = world.try_add_entity(Arc::clone(&entity)) { + log::debug!("failed to spawn throwable item projectile: {error}"); + return None; + } + enchantment_helper::on_projectile_spawned(world, item_stack, entity.as_ref(), Some(player)); + + Some(entity) +} + /// Vanilla `ProjectileUtil.computeMargin`: ramps the entity hit margin from 0 to /// 0.3 over the first ticks of flight. #[must_use] diff --git a/steel-registry/build/loot_tables/conditions.rs b/steel-registry/build/loot_tables/conditions.rs index 041fd4a893c..798353e9f4d 100644 --- a/steel-registry/build/loot_tables/conditions.rs +++ b/steel-registry/build/loot_tables/conditions.rs @@ -159,6 +159,7 @@ pub(super) fn generate_condition(condition: &LootConditionJson) -> TokenStream { equipment: None, sheep_color: None, sheep_sheared: None, + chicken_variant: None, } } } @@ -170,6 +171,7 @@ pub(super) fn generate_condition(condition: &LootConditionJson) -> TokenStream { equipment: None, sheep_color: None, sheep_sheared: None, + chicken_variant: None, } } }; diff --git a/steel-registry/build/loot_tables/mod.rs b/steel-registry/build/loot_tables/mod.rs index 6ab0d3e96b9..3878247cb83 100644 --- a/steel-registry/build/loot_tables/mod.rs +++ b/steel-registry/build/loot_tables/mod.rs @@ -285,7 +285,8 @@ struct EntityPredicateJson { flags: Option, #[serde(alias = "minecraft:equipment", default)] equipment: Option, - /// Entity data components (`minecraft:components`). Only `sheep/color` is modeled. + /// Entity data components (`minecraft:components`). Only `sheep/color` and + /// `chicken/variant` are modeled. #[serde(rename = "minecraft:components", default)] components: Option, /// Type-specific predicates. `minecraft:type_specific/sheep` is a single @@ -299,6 +300,8 @@ struct EntityPredicateJson { struct EntityComponentsJson { #[serde(rename = "minecraft:sheep/color", default)] sheep_color: Option, + #[serde(rename = "minecraft:chicken/variant", default)] + chicken_variant: Option, } /// Type-specific entity predicates (`minecraft:type_specific/sheep`). diff --git a/steel-registry/build/loot_tables/values.rs b/steel-registry/build/loot_tables/values.rs index e511461748d..749d21cc207 100644 --- a/steel-registry/build/loot_tables/values.rs +++ b/steel-registry/build/loot_tables/values.rs @@ -351,6 +351,17 @@ pub(super) fn generate_entity_predicate(predicate: &EntityPredicateJson) -> Toke quote! { Some(#color) } }, ); + let chicken_variant = predicate + .components + .as_ref() + .and_then(|components| components.chicken_variant.as_deref()) + .map_or_else( + || quote! { None }, + |variant| { + let variant = variant.strip_prefix("minecraft:").unwrap_or(variant); + quote! { Some(Identifier::vanilla_static(#variant)) } + }, + ); let sheep_sheared = predicate .sheep_type_specific .as_ref() @@ -364,6 +375,7 @@ pub(super) fn generate_entity_predicate(predicate: &EntityPredicateJson) -> Toke equipment: #equipment, sheep_color: #sheep_color, sheep_sheared: #sheep_sheared, + chicken_variant: #chicken_variant, } } } diff --git a/steel-registry/src/chicken_sound_variant.rs b/steel-registry/src/chicken_sound_variant.rs index cf481f98176..fc77fb087ae 100644 --- a/steel-registry/src/chicken_sound_variant.rs +++ b/steel-registry/src/chicken_sound_variant.rs @@ -2,7 +2,9 @@ use rustc_hash::FxHashMap; use simdnbt::ToNbtTag; use simdnbt::owned::NbtTag; use steel_utils::Identifier; +use steel_utils::random::Random; +use crate::RegistryExt; use crate::sound_event::SoundEventRef; /// Represents a set of sounds for a chicken variant from a data pack JSON file. @@ -63,6 +65,17 @@ impl ChickenSoundVariantRegistry { allows_registering: true, } } + + #[must_use] + pub fn pick_random(&self, random: &mut impl Random) -> Option { + // Vanilla picks a random sound-set holder from the chicken sound variant registry. + let bound = i32::try_from(self.len()).ok()?; + if bound == 0 { + return None; + } + + self.by_id(random.next_i32_bounded(bound) as usize) + } } crate::impl_standard_methods!( diff --git a/steel-registry/src/chicken_variant.rs b/steel-registry/src/chicken_variant.rs index 164f14fbe6e..64741195c17 100644 --- a/steel-registry/src/chicken_variant.rs +++ b/steel-registry/src/chicken_variant.rs @@ -1,8 +1,11 @@ +use crate::biome::BiomeRef; +use crate::shared_structs::pick_spawn_conditioned_entry; use crate::shared_structs::{SpawnConditionEntry, insert_spawn_conditions}; use rustc_hash::FxHashMap; use simdnbt::ToNbtTag; use simdnbt::owned::NbtTag; use steel_utils::Identifier; +use steel_utils::random::Random; /// Represents a full chicken variant definition from a data pack JSON file. #[derive(Debug)] @@ -57,6 +60,21 @@ impl ChickenVariantRegistry { allows_registering: true, } } + + #[must_use] + pub fn select_spawn_variant( + &self, + biome: BiomeRef, + random: &mut impl Random, + ) -> Option { + // Mirrors vanilla conditioned variant selection against the spawn biome. + pick_spawn_conditioned_entry( + self.iter().map(|(_, variant)| variant), + |variant| variant.spawn_conditions, + biome, + random, + ) + } } crate::impl_standard_methods!( diff --git a/steel-registry/src/loot_table/conditions.rs b/steel-registry/src/loot_table/conditions.rs index 7509a2eeb46..bcf55487283 100644 --- a/steel-registry/src/loot_table/conditions.rs +++ b/steel-registry/src/loot_table/conditions.rs @@ -143,6 +143,8 @@ pub struct EntityPredicate { pub sheep_color: Option, /// Vanilla `minecraft:type_specific/sheep.sheared` check. pub sheep_sheared: Option, + /// Vanilla `minecraft:components.chicken/variant` entity data component check. + pub chicken_variant: Option, } /// Entity flags (`is_on_fire`, `is_sneaking`, etc.) @@ -396,6 +398,12 @@ impl EntityPredicate { return false; } + if let Some(expected_variant) = &self.chicken_variant + && entity.chicken_variant != Some(expected_variant) + { + return false; + } + true } } diff --git a/steel-registry/src/loot_table/context.rs b/steel-registry/src/loot_table/context.rs index e833477b88e..c831470bfdf 100644 --- a/steel-registry/src/loot_table/context.rs +++ b/steel-registry/src/loot_table/context.rs @@ -294,6 +294,8 @@ pub struct EntityRef<'a> { /// Vanilla `minecraft:type_specific/sheep.sheared`. `None` when the entity is /// not a sheep, matching vanilla `SheepPredicate.matches`' non-sheep rejection. pub sheep_sheared: Option, + /// Vanilla `minecraft:components.chicken/variant` entity data component key. + pub chicken_variant: Option<&'a Identifier>, } /// Entity flags for predicate checking. diff --git a/steel-registry/src/loot_table/tests.rs b/steel-registry/src/loot_table/tests.rs index 334d6b60c68..20ee3dfdb07 100644 --- a/steel-registry/src/loot_table/tests.rs +++ b/steel-registry/src/loot_table/tests.rs @@ -107,6 +107,7 @@ fn test_pig_loot_drops_raw_porkchop_when_not_on_fire() { custom_name: None, sheep_color: None, sheep_sheared: None, + chicken_variant: None, }; let mut ctx = LootContext::new(&mut rng).with_this_entity(pig); @@ -159,6 +160,78 @@ fn shearing_sheep_table_parses_the_flat_type_specific_sheep_key() { ); } +#[test] +fn chicken_lay_branches_carry_the_chicken_variant_component() { + init_test_registries(); + + let table = &vanilla_loot_tables::GAMEPLAY_CHICKEN_LAY; + let pool = table.pools.first().expect("chicken_lay should have a pool"); + let LootEntry::Alternatives { children, .. } = pool + .entries + .first() + .expect("chicken_lay should start with alternatives") + else { + panic!("chicken_lay should use an alternatives entry"); + }; + + let variants = children + .iter() + .filter_map(|child| { + let LootEntry::Item { conditions, .. } = child else { + return None; + }; + let Some(LootCondition::EntityProperties { predicate, .. }) = conditions.first() else { + return None; + }; + predicate + .chicken_variant + .as_ref() + .map(|id| id.path.as_ref()) + }) + .collect::>(); + assert_eq!( + variants, + ["temperate", "warm", "cold"], + "each egg branch should match its chicken variant" + ); +} + +#[test] +fn chicken_lay_drops_the_egg_matching_the_chicken_variant() { + init_test_registries(); + + for (variant, expected_egg) in [ + (&crate::vanilla_chicken_variants::TEMPERATE.key, "egg"), + (&crate::vanilla_chicken_variants::WARM.key, "brown_egg"), + (&crate::vanilla_chicken_variants::COLD.key, "blue_egg"), + ] { + let mut rng = test_rng(); + let chicken = EntityRef { + entity_type: Some(&Identifier::vanilla_static("chicken")), + flags: EntityRefFlags::default(), + equipment: None, + custom_name: None, + sheep_color: None, + sheep_sheared: None, + chicken_variant: Some(variant), + }; + + let mut ctx = LootContext::new(&mut rng).with_this_entity(chicken); + let items = vanilla_loot_tables::GAMEPLAY_CHICKEN_LAY.get_random_items(&mut ctx); + + assert_eq!( + items.len(), + 1, + "a {variant} chicken should lay exactly one egg" + ); + assert_eq!( + items[0].item.key, + Identifier::vanilla_static(expected_egg), + "a {variant} chicken should lay {expected_egg}" + ); + } +} + #[test] fn sheared_predicate_rejects_non_sheep_entities() { init_test_registries(); @@ -171,6 +244,7 @@ fn sheared_predicate_rejects_non_sheep_entities() { custom_name: None, sheep_color: None, sheep_sheared: None, + chicken_variant: None, }; let mut ctx = LootContext::new(&mut rng).with_this_entity(pig); @@ -182,6 +256,7 @@ fn sheared_predicate_rejects_non_sheep_entities() { equipment: None, sheep_color: None, sheep_sheared: Some(false), + chicken_variant: None, }, }; assert!( @@ -205,6 +280,7 @@ fn test_pig_loot_smelt_condition_uses_entity_fire_flag() { custom_name: None, sheep_color: None, sheep_sheared: None, + chicken_variant: None, }; let mut ctx = LootContext::new(&mut rng).with_this_entity(pig);