diff --git a/crates/pumpkin/src/block/blocks/respawn_anchor.rs b/crates/pumpkin/src/block/blocks/respawn_anchor.rs index caf23833d..b084318d9 100644 --- a/crates/pumpkin/src/block/blocks/respawn_anchor.rs +++ b/crates/pumpkin/src/block/blocks/respawn_anchor.rs @@ -55,7 +55,7 @@ impl BlockBehaviour for RespawnAnchorBlock { fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(async move { let state_id = args.world.get_block_state_id(args.position); - let mut props = RespawnAnchorLikeProperties::from_state_id(state_id, args.block); + let props = RespawnAnchorLikeProperties::from_state_id(state_id, args.block); if args.world.dimension != Dimension::THE_NETHER { args.world @@ -92,15 +92,6 @@ impl BlockBehaviour for RespawnAnchorBlock { ) .await { - props.charges -= 1; - args.world - .set_block_state( - args.position, - props.to_state_id(args.block), - BlockFlags::NOTIFY_ALL, - ) - .await; - args.world.play_sound( Sound::BlockRespawnAnchorSetSpawn, SoundCategory::Blocks, diff --git a/crates/pumpkin/src/block/blocks/spawner.rs b/crates/pumpkin/src/block/blocks/spawner.rs index ec132ff98..169678d0b 100644 --- a/crates/pumpkin/src/block/blocks/spawner.rs +++ b/crates/pumpkin/src/block/blocks/spawner.rs @@ -1,18 +1,37 @@ use std::sync::Arc; use crate::block::entities::mob_spawner::MobSpawnerBlockEntity; +use crate::entity::experience_orb::ExperienceOrbEntity; use pumpkin_macros::pumpkin_block; +use pumpkin_util::GameMode; -use crate::block::{BlockBehaviour, BlockFuture, PlacedArgs}; +use crate::block::{BlockBehaviour, BlockFuture, BrokenArgs, OnSyncedBlockEventArgs, PlacedArgs}; #[pumpkin_block("minecraft:spawner")] pub struct SpawnerBlock; impl BlockBehaviour for SpawnerBlock { + fn on_synced_block_event<'a>( + &'a self, + _args: OnSyncedBlockEventArgs<'a>, + ) -> BlockFuture<'a, bool> { + Box::pin(async move { true }) + } + fn placed<'a>(&'a self, args: PlacedArgs<'a>) -> BlockFuture<'a, ()> { Box::pin(async move { - let hopper_block_entity = MobSpawnerBlockEntity::new(*args.position, None); - args.world.add_block_entity(Arc::new(hopper_block_entity)); + let spawner_block_entity = MobSpawnerBlockEntity::new(*args.position, None); + args.world.add_block_entity(Arc::new(spawner_block_entity)); + }) + } + + fn broken<'a>(&'a self, args: BrokenArgs<'a>) -> BlockFuture<'a, ()> { + Box::pin(async move { + if args.player.gamemode.load() != GameMode::Creative { + let xp_count = 15 + rand::random_range(0..15) + rand::random_range(0..15); + ExperienceOrbEntity::spawn(args.world, args.position.to_centered_f64(), xp_count) + .await; + } }) } } diff --git a/crates/pumpkin/src/block/entities/mob_spawner.rs b/crates/pumpkin/src/block/entities/mob_spawner.rs index 8294c1c35..e584dee02 100644 --- a/crates/pumpkin/src/block/entities/mob_spawner.rs +++ b/crates/pumpkin/src/block/entities/mob_spawner.rs @@ -15,7 +15,7 @@ use pumpkin_util::math::{ vector3::Vector3, }; -use crate::{block::entities::BlockEntity, world::World}; +use crate::{block::entities::BlockEntity, entity::EntityBase, world::World}; pub struct MobSpawnerBlockEntity { pub position: BlockPos, @@ -24,6 +24,8 @@ pub struct MobSpawnerBlockEntity { pub min_delay: i32, pub spawn_count: i32, pub spawn_range: i32, + pub max_nearby_entities: i32, + pub required_player_range: i32, pub entity_type: AtomicCell>, } @@ -34,6 +36,8 @@ impl MobSpawnerBlockEntity { pub const DEFAULT_MIN_SPAWN_DELAY: i32 = 200; pub const DEFAULT_SPAWN_COUNT: i32 = 4; pub const DEFAULT_SPAWN_RANGE: i32 = 4; + pub const DEFAULT_MAX_NEARBY_ENTITIES: i32 = 6; + pub const DEFAULT_REQUIRED_PLAYER_RANGE: i32 = 16; #[must_use] pub const fn new(position: BlockPos, entity_type: Option<&'static EntityType>) -> Self { @@ -44,6 +48,8 @@ impl MobSpawnerBlockEntity { min_delay: Self::DEFAULT_MIN_SPAWN_DELAY, spawn_count: Self::DEFAULT_SPAWN_COUNT, spawn_range: Self::DEFAULT_SPAWN_RANGE, + max_nearby_entities: Self::DEFAULT_MAX_NEARBY_ENTITIES, + required_player_range: Self::DEFAULT_REQUIRED_PLAYER_RANGE, entity_type: AtomicCell::new(entity_type), } } @@ -55,6 +61,14 @@ impl MobSpawnerBlockEntity { nbt.put_int("x", position.0.x); nbt.put_int("y", position.0.y); nbt.put_int("z", position.0.z); + nbt.put_short("Delay", self.delay.load(Ordering::Relaxed) as i16); + nbt.put_short("MinSpawnDelay", self.min_delay as i16); + nbt.put_short("MaxSpawnDelay", self.max_delay as i16); + nbt.put_short("SpawnCount", self.spawn_count as i16); + nbt.put_short("SpawnRange", self.spawn_range as i16); + nbt.put_short("MaxNearbyEntities", self.max_nearby_entities as i16); + nbt.put_short("RequiredPlayerRange", self.required_player_range as i16); + if let Some(entity_type) = self.entity_type.load() { let mut spawn_entry = NbtCompound::new(); @@ -101,24 +115,60 @@ impl BlockEntity for MobSpawnerBlockEntity { fn tick<'a>(&'a self, world: &'a Arc) -> Pin + Send + 'a>> { Box::pin(async move { if let Some(entity_type) = &self.entity_type.load() { - if self.delay.load(Ordering::Relaxed) == -1 { + let center = self.position.to_centered_f64(); + let max_player_dist_sq = (self.required_player_range as f64).powi(2); + let player_nearby = world.players.load().iter().any(|p| { + p.get_entity().pos.load().squared_distance_to_vec(¢er) <= max_player_dist_sq + }); + + if !player_nearby { + return; + } + + if self.delay.load(Ordering::Relaxed) < 0 { self.update_spawns(world).await; - } else { + return; + } + if self.delay.load(Ordering::Relaxed) > 0 { self.delay.fetch_sub(1, Ordering::Relaxed); return; } + + let search_radius_horiz = (self.spawn_range * 2) as f64; + let search_radius_vert = 4.0; + let nearby_count = world + .entities + .load() + .iter() + .filter(|e| { + let ent = e.get_entity(); + if ent.entity_type.id != entity_type.id { + return false; + } + let pos = ent.pos.load(); + (pos.x - center.x).abs() <= search_radius_horiz + && (pos.z - center.z).abs() <= search_radius_horiz + && (pos.y - center.y).abs() <= search_radius_vert + }) + .count(); + + if nearby_count as i32 >= self.max_nearby_entities { + self.update_spawns(world).await; + return; + } + let spawn_range = self.spawn_range; - let mut update_spawns = false; + let mut spawned_any = false; for _ in 0..self.spawn_count { let pos = self.position.0; let spawn_pos = Vector3::new( pos.x as f64 - + (rand::random::() + rand::random::()) * spawn_range as f64 + + (rand::random::() - rand::random::()) * spawn_range as f64 + 0.5, (pos.y + rand::random_range(0..3) - 1) as f64, pos.z as f64 - + (rand::random::() + rand::random::()) * spawn_range as f64 + + (rand::random::() - rand::random::()) * spawn_range as f64 + 0.5, ); // TODO: we should use getSpawnBox, but this is only modified for slimes and magma slimes @@ -140,11 +190,13 @@ impl BlockEntity for MobSpawnerBlockEntity { world, uuid::Uuid::new_v4(), ); + let yaw = rand::random::() * 360.0; + entity.get_entity().set_rotation(yaw, 0.0); world.spawn_entity(entity).await; world.sync_world_event(WorldEvent::ParticlesMobblockSpawn, self.position, 0); - update_spawns = true; + spawned_any = true; } - if update_spawns { + if spawned_any { self.update_spawns(world).await; } } @@ -155,28 +207,51 @@ impl BlockEntity for MobSpawnerBlockEntity { where Self: Sized, { - let delay = nbt.get_short("Delay").unwrap_or(Self::DEFAULT_DELAY as i16) as i32; - let min_delay = nbt - .get_int("MinSpawnDelay") - .unwrap_or(Self::DEFAULT_MIN_SPAWN_DELAY); - let max_delay = nbt - .get_int("MaxSpawnDelay") - .unwrap_or(Self::DEFAULT_MAX_SPAWN_DELAY); - let spawn_count = nbt - .get_int("SpawnCount") - .unwrap_or(Self::DEFAULT_SPAWN_COUNT); - let spawn_range = nbt - .get_int("SpawnRange") - .unwrap_or(Self::DEFAULT_SPAWN_RANGE); + let get_num = |name: &str| { + nbt.get_short(name) + .map(i32::from) + .or_else(|| nbt.get_int(name)) + .or_else(|| nbt.get_byte(name).map(i32::from)) + }; + + let delay = get_num("Delay").unwrap_or(Self::DEFAULT_DELAY); + let min_delay = get_num("MinSpawnDelay").unwrap_or(Self::DEFAULT_MIN_SPAWN_DELAY); + let max_delay = get_num("MaxSpawnDelay").unwrap_or(Self::DEFAULT_MAX_SPAWN_DELAY); + let spawn_count = get_num("SpawnCount").unwrap_or(Self::DEFAULT_SPAWN_COUNT); + let spawn_range = get_num("SpawnRange").unwrap_or(Self::DEFAULT_SPAWN_RANGE); + let max_nearby_entities = + get_num("MaxNearbyEntities").unwrap_or(Self::DEFAULT_MAX_NEARBY_ENTITIES); + let required_player_range = + get_num("RequiredPlayerRange").unwrap_or(Self::DEFAULT_REQUIRED_PLAYER_RANGE); let entity_type = nbt .get_compound("SpawnData") - .and_then(|data| data.get_compound("entity")) - .and_then(|entity| entity.get_string("id")) - .and_then(|id| { - let name = id.strip_prefix("minecraft:").unwrap_or(id); - EntityType::from_name(name) - }); + .and_then(|data| { + data.get_compound("entity") + .and_then(|entity| entity.get_string("id")) + .or_else(|| data.get_string("id")) + }) + .or_else(|| { + nbt.get_list("SpawnPotentials") + .and_then(|list| list.first()) + .and_then(|tag| tag.extract_compound()) + .and_then(|entry| { + entry + .get_compound("data") + .and_then(|data| { + data.get_compound("entity") + .and_then(|entity| entity.get_string("id")) + .or_else(|| data.get_string("id")) + }) + .or_else(|| { + entry + .get_compound("entity") + .and_then(|entity| entity.get_string("id")) + }) + }) + }) + .or_else(|| nbt.get_string("EntityId")) + .and_then(EntityType::from_name); Self { position, @@ -185,6 +260,8 @@ impl BlockEntity for MobSpawnerBlockEntity { min_delay, spawn_count, spawn_range, + max_nearby_entities, + required_player_range, entity_type: AtomicCell::new(entity_type), } } @@ -200,6 +277,14 @@ impl BlockEntity for MobSpawnerBlockEntity { fn chunk_data_nbt(&self) -> Option { let mut final_nbt = NbtCompound::new(); + final_nbt.put_short("Delay", self.delay.load(Ordering::Relaxed) as i16); + final_nbt.put_short("MinSpawnDelay", self.min_delay as i16); + final_nbt.put_short("MaxSpawnDelay", self.max_delay as i16); + final_nbt.put_short("SpawnCount", self.spawn_count as i16); + final_nbt.put_short("SpawnRange", self.spawn_range as i16); + final_nbt.put_short("MaxNearbyEntities", self.max_nearby_entities as i16); + final_nbt.put_short("RequiredPlayerRange", self.required_player_range as i16); + if let Some(entity_type) = self.entity_type.load() { let mut spawn_entry = NbtCompound::new(); diff --git a/crates/pumpkin/src/entity/ai/goal/mod.rs b/crates/pumpkin/src/entity/ai/goal/mod.rs index 21fabbf96..5c7661782 100644 --- a/crates/pumpkin/src/entity/ai/goal/mod.rs +++ b/crates/pumpkin/src/entity/ai/goal/mod.rs @@ -30,6 +30,7 @@ pub mod pathfind_to_raid; pub mod pick_up_block; pub mod place_block; pub mod ranged_attack; +pub mod ranged_crossbow_attack; pub mod revenge; pub mod step_and_destroy_block; pub mod swim; diff --git a/crates/pumpkin/src/entity/ai/goal/ranged_crossbow_attack.rs b/crates/pumpkin/src/entity/ai/goal/ranged_crossbow_attack.rs new file mode 100644 index 000000000..3eb9827a2 --- /dev/null +++ b/crates/pumpkin/src/entity/ai/goal/ranged_crossbow_attack.rs @@ -0,0 +1,298 @@ +use std::sync::Arc; + +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_util::Hand; + +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::mob::Mob; +use crate::entity::projectile::arrow::{ArrowEntity, ArrowPickup}; +use crate::entity::{Entity, EntityBase}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CrossbowState { + Uncharged, + Charging, + Charged, + ReadyToAttack, +} + +/// Ranged crossbow attack used by Pillagers, Piglins, and other mobs. +/// Mirrors vanilla `RangedCrossbowAttackGoal`. +pub struct RangedCrossbowAttackGoal { + goal_control: Controls, + speed: f64, + squared_range: f64, + state: CrossbowState, + see_time: i32, + attack_delay: i32, + update_path_delay: i32, + charge_ticks: i32, +} + +impl RangedCrossbowAttackGoal { + /// Vanilla crossbow charge duration (ticks). + const CHARGE_DURATION: i32 = 25; + /// Vanilla arrow speed for crossbow shots. + const ARROW_SPEED: f64 = 1.6; + + #[must_use] + pub fn new(speed: f64, range: f32) -> Self { + Self { + goal_control: Controls::MOVE | Controls::LOOK, + speed, + squared_range: f64::from(range * range), + state: CrossbowState::Uncharged, + see_time: 0, + attack_delay: 0, + update_path_delay: 0, + charge_ticks: 0, + } + } + + async fn is_holding_crossbow(mob: &dyn Mob) -> bool { + let equipment = mob + .get_mob_entity() + .living_entity + .entity_equipment + .lock() + .await; + equipment.get(&EquipmentSlot::MAIN_HAND).item.id == Item::CROSSBOW.id + || equipment.get(&EquipmentSlot::OFF_HAND).item.id == Item::CROSSBOW.id + } + + async fn shoot(mob: &dyn Mob, target: &Arc) { + let entity = mob.get_entity(); + let world = entity.world.load(); + + let mut event = + crate::plugin::api::events::entity::entity_shoot_bow::EntityShootBowEvent::new( + entity.entity_id, + "minecraft:crossbow".to_string(), + 1.0, + ); + if let Some(server) = world.server.upgrade() { + server.plugin_manager.fire(&server, &mut event).await; + } + if event.cancelled { + return; + } + + let mob_pos = entity.pos.load(); + let target_entity = target.get_entity(); + let target_pos = target_entity.pos.load(); + + let arrow_entity = Entity::new(world.clone(), mob_pos, &EntityType::ARROW); + let projectile = ItemStack::new(1, &Item::ARROW); + let arrow = ArrowEntity::new_shot(arrow_entity, entity, &projectile, ArrowPickup::Allowed); + + let dx = target_pos.x - mob_pos.x; + let dy = (target_pos.y + f64::from(target_entity.entity_dimension.load().height) / 3.0) + - arrow.entity.pos.load().y; + let dz = target_pos.z - mob_pos.z; + let horizontal_distance = dx.hypot(dz); + + let difficulty = world.level_info.load().difficulty as i32; + let divergence = f64::from(14 - difficulty * 4); + + arrow.set_velocity( + dx, + horizontal_distance.mul_add(0.2, dy), + dz, + Self::ARROW_SPEED, + divergence, + ); + + world.play_sound(Sound::ItemCrossbowShoot, SoundCategory::Hostile, &mob_pos); + + let arrow: Arc = Arc::new(arrow); + world.spawn_entity(arrow).await; + + if let Some(crossbow_mob) = mob.as_crossbow_attack_mob() { + crossbow_mob.on_crossbow_attack_performed(); + } + } +} + +impl Goal for RangedCrossbowAttackGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let target = mob.get_mob_entity().target.lock().await.clone(); + let Some(target) = target else { + return false; + }; + if !target.get_entity().is_alive() { + return false; + } + Self::is_holding_crossbow(mob).await + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let target = mob.get_mob_entity().target.lock().await.clone(); + let Some(target) = target else { + return false; + }; + target.get_entity().is_alive() && Self::is_holding_crossbow(mob).await + }) + } + + fn start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + self.state = CrossbowState::Uncharged; + self.see_time = 0; + self.attack_delay = 0; + self.update_path_delay = 0; + self.charge_ticks = 0; + }) + } + + fn stop<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(crossbow_mob) = mob.as_crossbow_attack_mob() { + crossbow_mob.set_charging_crossbow(false); + } + mob.get_mob_entity().living_entity.clear_active_hand().await; + self.state = CrossbowState::Uncharged; + mob.get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stop(); + }) + } + + #[expect(clippy::too_many_lines)] + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let target = mob.get_mob_entity().target.lock().await.clone(); + let Some(target) = target else { + return; + }; + + let mob_pos = mob.get_entity().pos.load(); + let target_pos = target.get_entity().pos.load(); + let distance_sq = mob_pos.squared_distance_to_vec(&target_pos); + + let has_line_of_sight = true; // In future: raycast check + if has_line_of_sight { + self.see_time += 1; + } else { + self.see_time = 0; + } + + let needs_to_move = + (distance_sq > self.squared_range || self.see_time < 5) && self.attack_delay == 0; + + if needs_to_move { + self.update_path_delay -= 1; + if self.update_path_delay <= 0 { + let move_speed = if self.state == CrossbowState::Uncharged { + self.speed + } else { + self.speed * 0.5 + }; + mob.get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_progress(NavigatorGoal { + current_progress: mob_pos, + destination: target_pos, + speed: move_speed, + }); + self.update_path_delay = 20 + rand::random_range(0..20); + } + } else { + self.update_path_delay = 0; + mob.get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stop(); + } + + mob.get_mob_entity() + .look_control + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .look_at_entity_with_range(&target, 30.0, 30.0); + + match self.state { + CrossbowState::Uncharged => { + if !needs_to_move { + let stack = mob + .get_mob_entity() + .living_entity + .entity_equipment + .lock() + .await + .get(&EquipmentSlot::MAIN_HAND); + mob.get_mob_entity() + .living_entity + .set_active_hand(Hand::Right, stack, i32::MAX) + .await; + self.state = CrossbowState::Charging; + self.charge_ticks = 0; + if let Some(crossbow_mob) = mob.as_crossbow_attack_mob() { + crossbow_mob.set_charging_crossbow(true); + } + mob.get_entity().world.load().play_sound( + Sound::ItemCrossbowLoadingStart, + SoundCategory::Hostile, + &mob_pos, + ); + } + } + CrossbowState::Charging => { + self.charge_ticks += 1; + if self.charge_ticks == 10 { + mob.get_entity().world.load().play_sound( + Sound::ItemCrossbowLoadingMiddle, + SoundCategory::Hostile, + &mob_pos, + ); + } + if self.charge_ticks >= Self::CHARGE_DURATION { + mob.get_mob_entity().living_entity.clear_active_hand().await; + self.state = CrossbowState::Charged; + self.attack_delay = 20 + rand::random_range(0..20); + if let Some(crossbow_mob) = mob.as_crossbow_attack_mob() { + crossbow_mob.set_charging_crossbow(false); + } + mob.get_entity().world.load().play_sound( + Sound::ItemCrossbowLoadingEnd, + SoundCategory::Hostile, + &mob_pos, + ); + } + } + CrossbowState::Charged => { + self.attack_delay -= 1; + if self.attack_delay <= 0 { + self.state = CrossbowState::ReadyToAttack; + } + } + CrossbowState::ReadyToAttack => { + if has_line_of_sight { + Self::shoot(mob, &target).await; + self.state = CrossbowState::Uncharged; + } + } + } + }) + } + + fn should_run_every_tick(&self) -> bool { + true + } + + fn controls(&self) -> Controls { + self.goal_control + } +} diff --git a/crates/pumpkin/src/entity/mob/bat.rs b/crates/pumpkin/src/entity/mob/bat.rs index f8a7c1b05..8fdf98c80 100644 --- a/crates/pumpkin/src/entity/mob/bat.rs +++ b/crates/pumpkin/src/entity/mob/bat.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering::Relaxed}; use pumpkin_data::damage::DamageType; -use pumpkin_data::sound::Sound; +use pumpkin_data::sound::{Sound, SoundCategory}; use pumpkin_data::tag::{self, Taggable}; +use pumpkin_data::tracked_data; use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::java::client::play::Metadata; use pumpkin_util::math::position::BlockPos; use pumpkin_util::math::vector3::Vector3; use pumpkin_world::chunk::ChunkHeightmapType; @@ -33,64 +35,197 @@ impl BatEntity { let bat = Self { mob_entity, hanging_position: Mutex::new(None), - roosting: AtomicBool::new(false), + roosting: AtomicBool::new(true), ambient_sound_chance: AtomicI32::new(MIN_AMBIENT_SOUND_DELAY), }; Arc::new(bat) } + #[must_use] pub fn check_bat_spawn_rules(world: &World, pos: &BlockPos) -> bool { if pos.0.y >= world.get_heightmap_height(ChunkHeightmapType::WorldSurface, pos.0.x, pos.0.z) { return false; } - if rand::random_bool(1.0) { + if rand::random_bool(0.5) { return false; } if world.get_max_local_raw_brightness(pos) > rand::random_range(0..4) { return false; } - if world - .get_block(pos) + let below_pos = BlockPos::new(pos.0.x, pos.0.y - 1, pos.0.z); + if !world + .get_block(&below_pos) .has_tag(&tag::Block::MINECRAFT_BATS_SPAWNABLE_ON) { return false; } - //TODO:check_mob_spawn_rules(entity_type, world, spawn_reason, pos).await true } + #[must_use] pub fn is_roosting(&self) -> bool { self.roosting.load(Relaxed) } pub fn set_roosting(&self, roosting: bool) { self.roosting.store(roosting, Relaxed); - // TODO: - // let flags = if roosting { ROOSTING_FLAG } else { 0 }; - // self.mob_entity - // .living_entity - // .entity - // .send_meta_data(&[Metadata::new( - // pumpkin_data::tracked_data::bat::ID_FLAGS, - // MetaDataType::BYTE, - // flags, - // )]) - // .await; + let flags: u8 = if roosting { ROOSTING_FLAG } else { 0 }; + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new(tracked_data::bat::DATA_ID_FLAGS, flags)], + None, + ); + } + + fn tick_ambient_sound(&self, world: &World, pos: &Vector3) { + let chance = self.ambient_sound_chance.fetch_sub(1, Relaxed); + if chance <= 0 { + self.ambient_sound_chance + .store(MIN_AMBIENT_SOUND_DELAY, Relaxed); + if !self.is_roosting() || rand::random_range(0..4) == 0 { + world.play_sound_fine( + Sound::EntityBatAmbient, + SoundCategory::Ambient, + pos, + 0.1, + 0.95, + ); + } + } + } + + fn tick_roosting(&self, world: &World, above_pos: &BlockPos, pos: &Vector3) { + let entity = &self.mob_entity.living_entity.entity; + let above_state = world.get_block_state(above_pos); + if above_state.is_solid_block() { + let rotate_head = { + let mut rng = rand::rng(); + (rng.random_range(0u32..200) == 0).then(|| rng.random_range(0i32..360) as f32) + }; + if let Some(head_yaw) = rotate_head { + entity.head_yaw.store(head_yaw); + } + + if world + .get_closest_player(*pos, CLOSE_PLAYER_DISTANCE) + .is_some() + { + self.set_roosting(false); + world.play_sound_fine( + Sound::EntityBatTakeoff, + SoundCategory::Ambient, + pos, + 0.1, + 0.95, + ); + } + } else { + self.set_roosting(false); + world.play_sound_fine( + Sound::EntityBatTakeoff, + SoundCategory::Ambient, + pos, + 0.1, + 0.95, + ); + } + } + + async fn tick_flying(&self, world: &World, above_pos: &BlockPos, pos: &Vector3) { + let entity = &self.mob_entity.living_entity.entity; + let mut hanging_pos = self.hanging_position.lock().await; + + if let Some(hp) = *hanging_pos { + let hp_state = world.get_block_state(&hp); + if !hp_state.is_air() || hp.0.y <= world.dimension.min_y { + *hanging_pos = None; + } + } + + let (should_pick_new, new_target, try_roost) = { + let mut rng = rand::rng(); + let should_pick = hanging_pos.is_none() + || rng.random_range(0u32..30) == 0 + || hanging_pos.is_some_and(|hp| { + let dx = f64::from(hp.0.x) + 0.5 - pos.x; + let dy = f64::from(hp.0.y) + 0.1 - pos.y; + let dz = f64::from(hp.0.z) + 0.5 - pos.z; + dx * dx + dy * dy + dz * dz < 4.0 + }); + let new_target = should_pick.then(|| { + BlockPos::new( + pos.x as i32 + rng.random_range(0i32..7) - rng.random_range(0i32..7), + (pos.y + f64::from(rng.random_range(0i32..6)) - 2.0) as i32, + pos.z as i32 + rng.random_range(0i32..7) - rng.random_range(0i32..7), + ) + }); + let try_roost = rng.random_range(0u32..100) == 0; + (should_pick, new_target, try_roost) + }; + + if should_pick_new { + if let Some(target) = new_target { + let target_state = world.get_block_state(&target); + if target_state.is_air() && target.0.y > world.dimension.min_y { + *hanging_pos = Some(target); + } else { + *hanging_pos = None; + } + } else { + *hanging_pos = None; + } + } + + if let Some(target) = *hanging_pos { + let d = f64::from(target.0.x) + 0.5 - pos.x; + let e = f64::from(target.0.y) + 0.1 - pos.y; + let f = f64::from(target.0.z) + 0.5 - pos.z; + + let velo = entity.velocity.load(); + let new_velo = Vector3::new( + velo.x + (d.signum() * 0.5 - velo.x) * 0.1, + velo.y + (e.signum() * 0.7 - velo.y) * 0.1, + velo.z + (f.signum() * 0.5 - velo.z) * 0.1, + ); + entity.velocity.store(new_velo); + + let yaw = (new_velo.z.atan2(new_velo.x) as f32).to_degrees() - 90.0; + let yaw_diff = pumpkin_util::math::wrap_degrees(yaw - entity.yaw.load()); + entity.yaw.store(entity.yaw.load() + yaw_diff); + } + drop(hanging_pos); + + if try_roost { + let above_state = world.get_block_state(above_pos); + if above_state.is_solid_block() { + self.set_roosting(true); + } + } } } impl Mob for BatEntity { + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + let flags: u8 = if self.is_roosting() { ROOSTING_FLAG } else { 0 }; + entity.send_meta_data( + &[Metadata::new(tracked_data::bat::DATA_ID_FLAGS, flags)], + None, + ); + }) + } + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { let flags: u8 = if self.is_roosting() { ROOSTING_FLAG } else { 0 }; - nbt.put_byte("BatFlags", flags as i8); + nbt.put_byte("BatFlags", i8::try_from(flags).unwrap_or(0)); }) } fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { - let flags = nbt.get_byte("BatFlags").unwrap_or(0) as u8; + let flags = u8::try_from(nbt.get_byte("BatFlags").unwrap_or(0)).unwrap_or(0); let roosting = (flags & ROOSTING_FLAG) != 0; self.set_roosting(roosting); }) @@ -106,110 +241,14 @@ impl Mob for BatEntity { let block_pos = entity.block_pos.load(); let above_pos = BlockPos::new(block_pos.0.x, block_pos.0.y + 1, block_pos.0.z); let world = entity.world.load(); + let pos = entity.pos.load(); - // Ambient idle sound (vanilla: MobEntity.mobTick → playAmbientSound) - let chance = self.ambient_sound_chance.fetch_sub(1, Relaxed); - if chance <= 0 { - self.ambient_sound_chance - .store(MIN_AMBIENT_SOUND_DELAY, Relaxed); - entity.play_sound(Sound::EntityBatAmbient); - } + self.tick_ambient_sound(&world, &pos); if self.is_roosting() { - let above_state = world.get_block_state(&above_pos); - if above_state.is_solid_block() { - let rotate_head = { - let mut rng = rand::rng(); - (rng.random_range(0u32..200) == 0) - .then(|| rng.random_range(0i32..360) as f32) - }; - if let Some(head_yaw) = rotate_head { - entity.head_yaw.store(head_yaw); - } - - let pos = entity.pos.load(); - if world - .get_closest_player(pos, CLOSE_PLAYER_DISTANCE) - .is_some() - { - self.set_roosting(false); - } - } else { - self.set_roosting(false); - } + self.tick_roosting(&world, &above_pos, &pos); } else { - let mut hanging_pos = self.hanging_position.lock().await; - - if let Some(hp) = *hanging_pos { - let hp_state = world.get_block_state(&hp); - if !hp_state.is_air() || hp.0.y <= world.dimension.min_y { - *hanging_pos = None; - } - } - - let (should_pick_new, new_target, try_roost) = { - let mut rng = rand::rng(); - let should_pick = hanging_pos.is_none() - || rng.random_range(0u32..30) == 0 - || hanging_pos.is_some_and(|hp| { - let pos = entity.pos.load(); - let dx = f64::from(hp.0.x) + 0.5 - pos.x; - let dy = f64::from(hp.0.y) + 0.1 - pos.y; - let dz = f64::from(hp.0.z) + 0.5 - pos.z; - dx * dx + dy * dy + dz * dz < 4.0 - }); - let new_target = should_pick.then(|| { - let pos = entity.pos.load(); - BlockPos::new( - pos.x as i32 + rng.random_range(0i32..7) - rng.random_range(0i32..7), - (pos.y + f64::from(rng.random_range(0i32..6)) - 2.0) as i32, - pos.z as i32 + rng.random_range(0i32..7) - rng.random_range(0i32..7), - ) - }); - let try_roost = rng.random_range(0u32..100) == 0; - (should_pick, new_target, try_roost) - }; - - if should_pick_new { - // Pre-validate: only accept targets in air (avoids water, lava, hazards) - if let Some(target) = new_target { - let target_state = world.get_block_state(&target); - if target_state.is_air() && target.0.y > world.dimension.min_y { - *hanging_pos = Some(target); - } else { - *hanging_pos = None; - } - } else { - *hanging_pos = None; - } - } - - if let Some(target) = *hanging_pos { - let pos = entity.pos.load(); - let d = f64::from(target.0.x) + 0.5 - pos.x; - let e = f64::from(target.0.y) + 0.1 - pos.y; - let f = f64::from(target.0.z) + 0.5 - pos.z; - - let velo = entity.velocity.load(); - let new_velo = Vector3::new( - velo.x + (d.signum() * 0.5 - velo.x) * 0.1, - velo.y + (e.signum() * 0.7 - velo.y) * 0.1, - velo.z + (f.signum() * 0.5 - velo.z) * 0.1, - ); - entity.velocity.store(new_velo); - - let yaw = (new_velo.z.atan2(new_velo.x) as f32).to_degrees() - 90.0; - let yaw_diff = pumpkin_util::math::wrap_degrees(yaw - entity.yaw.load()); - entity.yaw.store(entity.yaw.load() + yaw_diff); - } - drop(hanging_pos); - - if try_roost { - let above_state = world.get_block_state(&above_pos); - if above_state.is_solid_block() { - self.set_roosting(true); - } - } + self.tick_flying(&world, &above_pos, &pos).await; } }) } @@ -242,6 +281,15 @@ impl Mob for BatEntity { Box::pin(async move { if self.is_roosting() { self.set_roosting(false); + let entity = &self.mob_entity.living_entity.entity; + let pos = entity.pos.load(); + entity.world.load().play_sound_fine( + Sound::EntityBatTakeoff, + SoundCategory::Ambient, + &pos, + 0.1, + 0.95, + ); } }) } diff --git a/crates/pumpkin/src/entity/mob/crossbow_attack_mob.rs b/crates/pumpkin/src/entity/mob/crossbow_attack_mob.rs new file mode 100644 index 000000000..3564088f3 --- /dev/null +++ b/crates/pumpkin/src/entity/mob/crossbow_attack_mob.rs @@ -0,0 +1,5 @@ +pub trait CrossbowAttackMob: Send + Sync { + fn set_charging_crossbow(&self, is_charging: bool); + fn is_charging_crossbow(&self) -> bool; + fn on_crossbow_attack_performed(&self) {} +} diff --git a/crates/pumpkin/src/entity/mob/equipment.rs b/crates/pumpkin/src/entity/mob/equipment.rs index 43a1caa88..2a6eade26 100644 --- a/crates/pumpkin/src/entity/mob/equipment.rs +++ b/crates/pumpkin/src/entity/mob/equipment.rs @@ -753,7 +753,7 @@ fn conflicts_with(candidate: &Enchantment, applied: &[&Enchantment]) -> bool { /// enchantments by weight, resolves exclusive-set conflicts, and determines /// the level from the remaining cost. Cost is halved each iteration so /// later enchantments receive lower levels. -fn apply_vanilla_enchantments( +pub fn apply_vanilla_enchantments( stack: &mut ItemStack, slot: &EquipmentSlot, special_multiplier: f32, diff --git a/crates/pumpkin/src/entity/mob/ghast.rs b/crates/pumpkin/src/entity/mob/ghast.rs index 520d8be5e..fca2d1b60 100644 --- a/crates/pumpkin/src/entity/mob/ghast.rs +++ b/crates/pumpkin/src/entity/mob/ghast.rs @@ -1,32 +1,49 @@ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Weak}; +use pumpkin_data::damage::DamageType; +use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::tracked_data; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::java::client::play::Metadata; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::math::vector3::Vector3; +use rand::RngExt; +use tokio::sync::Mutex; + +use crate::entity::ai::goal::active_target::ActiveTargetGoal; +use crate::entity::living::LivingEntity; +use crate::entity::projectile::fireball::FireballEntity; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, NbtFuture, ai::goal::{Controls, Goal, GoalFuture}, mob::{Mob, MobEntity}, }; +use crate::world::World; pub struct GhastEntity { pub mob_entity: MobEntity, pub is_charging: AtomicBool, pub explosion_power: AtomicU8, + pub wanted_fly_target: Mutex>>, } impl GhastEntity { + pub const DEFAULT_EXPLOSION_POWER: u8 = 1; + pub const XP_REWARD: u32 = 5; + pub const FLYING_SPEED: f64 = 0.06; + pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); let ghast = Self { mob_entity, is_charging: AtomicBool::new(false), - explosion_power: AtomicU8::new(1), + explosion_power: AtomicU8::new(Self::DEFAULT_EXPLOSION_POWER), + wanted_fly_target: Mutex::new(None), }; let mob_arc = Arc::new(ghast); - let mob_weak: Weak = { - let mob_arc: Arc = mob_arc.clone(); - Arc::downgrade(&mob_arc) - }; { let mut goal_selector = mob_arc @@ -35,20 +52,83 @@ impl GhastEntity { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - goal_selector.add_goal(7, Box::new(GhastLookGoal::new(mob_weak.clone()))); + // Priority 5: Random floating around + goal_selector.add_goal( + 5, + Box::new(RandomFloatAroundGoal::new(Arc::downgrade(&mob_arc))), + ); + + // Priority 7: Face target / movement direction + goal_selector.add_goal(7, Box::new(GhastLookGoal::new())); + + // Priority 7: Shoot large fireballs at target + goal_selector.add_goal( + 7, + Box::new(GhastShootFireballGoal::new(Arc::downgrade(&mob_arc))), + ); + + let mut target_selector = mob_arc + .mob_entity + .target_selector + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + // Priority 1: Target nearest player within vertical proximity + target_selector.add_goal( + 1, + Box::new(ActiveTargetGoal::new( + &mob_arc.mob_entity, + &EntityType::PLAYER, + 10, + true, + false, + Some(|_target: Arc, _world: Arc| { + Box::pin(async move { true }) + }), + )), + ); }; mob_arc } pub fn set_charging(&self, charging: bool) { - // You would also sync this to the client via EntityMetadata here self.is_charging.store(charging, Ordering::Relaxed); + let entity = &self.mob_entity.living_entity.entity; + entity.send_meta_data( + &[Metadata::new( + tracked_data::ghast::DATA_IS_CHARGING, + charging, + )], + None, + ); } + #[must_use] pub fn is_charging(&self) -> bool { self.is_charging.load(Ordering::Relaxed) } + + #[must_use] + pub fn get_explosion_power(&self) -> u8 { + self.explosion_power.load(Ordering::Relaxed) + } + + pub fn set_explosion_power(&self, power: u8) { + self.explosion_power.store(power, Ordering::Relaxed); + } + + #[must_use] + pub fn check_ghast_spawn_rules(world: &World, pos: &BlockPos) -> bool { + if world.level_info.load().difficulty == pumpkin_util::Difficulty::Peaceful { + return false; + } + if rand::random_range(0..20) != 0 { + return false; + } + let state = world.get_block_state(pos); + state.is_air() + } } impl Mob for GhastEntity { @@ -59,21 +139,69 @@ impl Mob for GhastEntity { fn get_mob_gravity(&self) -> f64 { 0.0 // Ghasts fly, no gravity applied in standard travel } + + fn get_mob_y_velocity_drag(&self) -> Option { + Some(0.95) + } + + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + if self.is_charging() { + entity.send_meta_data( + &[Metadata::new(tracked_data::ghast::DATA_IS_CHARGING, true)], + None, + ); + } + }) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + let power = self.get_explosion_power(); + nbt.put_byte("ExplosionPower", i8::try_from(power).unwrap_or(1)); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(power) = nbt.get_byte("ExplosionPower") { + self.set_explosion_power( + u8::try_from(power).unwrap_or(Self::DEFAULT_EXPLOSION_POWER), + ); + } + }) + } + + fn modify_incoming_damage(&self, amount: f32, damage_type: DamageType) -> f32 { + if damage_type.id == DamageType::FIREBALL.id { + 1000.0 + } else { + amount + } + } + + fn get_base_experience_reward(&self) -> u32 { + Self::XP_REWARD + } } -#[expect(dead_code)] pub struct GhastLookGoal { goal_control: Controls, - mob_weak: Weak, +} + +impl Default for GhastLookGoal { + fn default() -> Self { + Self { + goal_control: Controls::LOOK, + } + } } impl GhastLookGoal { #[must_use] - pub fn new(mob_weak: Weak) -> Self { - Self { - goal_control: Controls::LOOK, - mob_weak, - } + pub fn new() -> Self { + Self::default() } } @@ -87,7 +215,7 @@ impl Goal for GhastLookGoal { } fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { - Box::pin(async { + Box::pin(async move { let mob_entity = mob.get_mob_entity(); let target_opt = mob_entity.target.lock().await.clone(); @@ -96,19 +224,18 @@ impl Goal for GhastLookGoal { let target_pos = target.get_entity().pos.load(); if mob_pos.squared_distance_to_vec(&target_pos) < 4096.0 { - let mut look_control = mob_entity - .look_control - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - look_control.look_at(mob, target_pos.x, target_pos.y, target_pos.z); + let dx = target_pos.x - mob_pos.x; + let dz = target_pos.z - mob_pos.z; + let yaw = (-f64::atan2(dx, dz).to_degrees()) as f32; + mob_entity.living_entity.entity.yaw.store(yaw); + mob_entity.living_entity.entity.head_yaw.store(yaw); } } else { - // If no target, face the movement direction let velocity = mob_entity.living_entity.entity.velocity.load(); if velocity.x != 0.0 || velocity.z != 0.0 { - let yaw = (-f64::atan2(velocity.x, velocity.z) * (180.0 / std::f64::consts::PI)) - as f32; + let yaw = (-f64::atan2(velocity.x, velocity.z).to_degrees()) as f32; mob_entity.living_entity.entity.yaw.store(yaw); + mob_entity.living_entity.entity.head_yaw.store(yaw); } } }) @@ -118,3 +245,249 @@ impl Goal for GhastLookGoal { self.goal_control } } + +pub struct GhastShootFireballGoal { + ghast: Weak, + charge_time: i32, +} + +impl GhastShootFireballGoal { + #[must_use] + pub const fn new(ghast: Weak) -> Self { + Self { + ghast, + charge_time: 0, + } + } +} + +impl Goal for GhastShootFireballGoal { + fn can_start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return false; + }; + let target = ghast.mob_entity.target.lock().await.clone(); + target.is_some_and(|t| t.get_entity().is_alive()) + }) + } + + fn should_continue<'a>(&'a self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return false; + }; + let target = ghast.mob_entity.target.lock().await.clone(); + target.is_some_and(|t| t.get_entity().is_alive()) + }) + } + + fn start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + self.charge_time = 0; + }) + } + + fn stop<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(ghast) = self.ghast.upgrade() { + ghast.set_charging(false); + } + }) + } + + fn should_run_every_tick(&self) -> bool { + true + } + + fn tick<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return; + }; + + let target_opt = ghast.mob_entity.target.lock().await.clone(); + let Some(target) = target_opt else { + return; + }; + + let entity = &ghast.mob_entity.living_entity.entity; + let ghast_pos = entity.pos.load(); + let target_pos = target.get_entity().pos.load(); + let dist_sq = ghast_pos.squared_distance_to_vec(&target_pos); + + if dist_sq < 4096.0 { + let world = entity.world.load(); + self.charge_time += 1; + + if self.charge_time == 10 { + world.play_sound_fine( + Sound::EntityGhastWarn, + SoundCategory::Hostile, + &ghast_pos, + 5.0, + 1.0, + ); + } + + if self.charge_time == 20 { + world.play_sound_fine( + Sound::EntityGhastShoot, + SoundCategory::Hostile, + &ghast_pos, + 5.0, + 1.0, + ); + + let yaw_rad = f64::from(entity.yaw.load()).to_radians(); + let pitch_rad = f64::from(entity.pitch.load()).to_radians(); + let view_x = -pitch_rad.cos() * yaw_rad.sin(); + let view_z = pitch_rad.cos() * yaw_rad.cos(); + + let spawn_pos = Vector3::new( + ghast_pos.x + view_x * 4.0, + ghast_pos.y + 2.5, + ghast_pos.z + view_z * 4.0, + ); + + let target_y = target_pos.y + target.get_entity().get_eye_height() * 0.5; + let dir_x = target_pos.x - spawn_pos.x; + let dir_y = target_y - spawn_pos.y; + let dir_z = target_pos.z - spawn_pos.z; + let direction = Vector3::new(dir_x, dir_y, dir_z); + + let fireball_base = Entity::from_uuid( + uuid::Uuid::new_v4(), + world.clone(), + spawn_pos, + &EntityType::FIREBALL, + ); + + let fireball = FireballEntity::new_shot(fireball_base, entity, direction); + fireball + .explosion_power + .store(f32::from(ghast.get_explosion_power()), Ordering::Relaxed); + + world.spawn_entity(Arc::new(fireball)).await; + self.charge_time = -40; + } + } else if self.charge_time > 0 { + self.charge_time -= 1; + } + + ghast.set_charging(self.charge_time > 10); + }) + } + + fn controls(&self) -> Controls { + Controls::empty() + } +} + +pub struct RandomFloatAroundGoal { + ghast: Weak, + float_duration: i32, +} + +impl RandomFloatAroundGoal { + #[must_use] + pub const fn new(ghast: Weak) -> Self { + Self { + ghast, + float_duration: 0, + } + } +} + +impl Goal for RandomFloatAroundGoal { + fn can_start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return false; + }; + let wanted = *ghast.wanted_fly_target.lock().await; + wanted.is_none_or(|target| { + let pos = ghast.mob_entity.living_entity.entity.pos.load(); + let dist_sq = pos.squared_distance_to_vec(&target); + dist_sq < 1.0 || dist_sq > 3600.0 + }) + }) + } + + fn start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return; + }; + let pos = ghast.mob_entity.living_entity.entity.pos.load(); + let new_target = { + let mut rng = rand::rng(); + let target_x = pos.x + (rng.random::() * 2.0 - 1.0) * 16.0; + let target_y = pos.y + (rng.random::() * 2.0 - 1.0) * 16.0; + let target_z = pos.z + (rng.random::() * 2.0 - 1.0) * 16.0; + Vector3::new(target_x, target_y, target_z) + }; + *ghast.wanted_fly_target.lock().await = Some(new_target); + }) + } + + fn should_continue<'a>(&'a self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return false; + }; + let wanted = *ghast.wanted_fly_target.lock().await; + wanted.is_some_and(|target| { + let pos = ghast.mob_entity.living_entity.entity.pos.load(); + let dist_sq = pos.squared_distance_to_vec(&target); + (1.0..=3600.0).contains(&dist_sq) + }) + }) + } + + fn should_run_every_tick(&self) -> bool { + true + } + + fn tick<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(ghast) = self.ghast.upgrade() else { + return; + }; + + let wanted = *ghast.wanted_fly_target.lock().await; + let Some(target) = wanted else { + return; + }; + + let entity = &ghast.mob_entity.living_entity.entity; + let pos = entity.pos.load(); + self.float_duration -= 1; + + if self.float_duration <= 0 { + self.float_duration = rand::random_range(2..=6); + let travel = Vector3::new(target.x - pos.x, target.y - pos.y, target.z - pos.z); + let dist = travel.length(); + if dist > 0.001 { + let move_scale = GhastEntity::FLYING_SPEED * 5.0 / 3.0; // 0.1 + let norm = travel.normalize(); + let delta = Vector3::new( + norm.x * move_scale, + norm.y * move_scale, + norm.z * move_scale, + ); + let current_vel = entity.velocity.load(); + entity.velocity.store(Vector3::new( + current_vel.x + delta.x, + current_vel.y + delta.y, + current_vel.z + delta.z, + )); + } + } + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } +} diff --git a/crates/pumpkin/src/entity/mob/mod.rs b/crates/pumpkin/src/entity/mob/mod.rs index 633dc894a..a449918bc 100644 --- a/crates/pumpkin/src/entity/mob/mod.rs +++ b/crates/pumpkin/src/entity/mob/mod.rs @@ -10,6 +10,8 @@ use crate::world::World; use crossbeam::atomic::AtomicCell; use pumpkin_data::attributes::Attributes; use pumpkin_data::damage::DamageType; +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::item::Item; use pumpkin_data::item_stack::ItemStack; use pumpkin_data::tag::{self, Taggable}; use pumpkin_data::tracked_data; @@ -36,6 +38,7 @@ pub mod breeze; pub mod cave_spider; pub mod creaking; pub mod creeper; +pub mod crossbow_attack_mob; pub mod elder_guardian; pub mod enderman; pub mod endermite; @@ -50,6 +53,7 @@ pub mod magma_cube; pub mod patrol; pub mod phantom; pub mod piglin; +pub mod piglin_ai; pub mod piglin_brute; pub mod pillager; pub mod raider; @@ -102,6 +106,65 @@ impl MobEntity { const ATTACKING_FLAG: u8 = 4; const CAN_PICK_UP_LOOT_FLAG: u8 = 8; + pub const MAX_WEARING_ARMOR_CHANCE: f32 = 0.15; + pub const WEARING_ARMOR_UPGRADE_MATERIAL_CHANCE: f32 = 0.1087; + pub const WEARING_ARMOR_UPGRADE_MATERIAL_ATTEMPTS: f32 = 3.0; + pub const MAX_PICKUP_LOOT_CHANCE: f32 = 0.55; + pub const MAX_ENCHANTED_ARMOR_CHANCE: f32 = 0.5; + pub const MAX_ENCHANTED_WEAPON_CHANCE: f32 = 0.25; + pub const EQUIPMENT_POPULATION_ORDER: [EquipmentSlot; 4] = [ + EquipmentSlot::HEAD, + EquipmentSlot::CHEST, + EquipmentSlot::LEGS, + EquipmentSlot::FEET, + ]; + + #[must_use] + pub const fn get_equipment_for_slot( + slot: &EquipmentSlot, + armor_type: i32, + ) -> Option<&'static Item> { + match slot { + EquipmentSlot::Head(_) => match armor_type { + 0 => Some(&Item::LEATHER_HELMET), + 1 => Some(&Item::COPPER_HELMET), + 2 => Some(&Item::GOLDEN_HELMET), + 3 => Some(&Item::CHAINMAIL_HELMET), + 4 => Some(&Item::IRON_HELMET), + 5 => Some(&Item::DIAMOND_HELMET), + _ => None, + }, + EquipmentSlot::Chest(_) => match armor_type { + 0 => Some(&Item::LEATHER_CHESTPLATE), + 1 => Some(&Item::COPPER_CHESTPLATE), + 2 => Some(&Item::GOLDEN_CHESTPLATE), + 3 => Some(&Item::CHAINMAIL_CHESTPLATE), + 4 => Some(&Item::IRON_CHESTPLATE), + 5 => Some(&Item::DIAMOND_CHESTPLATE), + _ => None, + }, + EquipmentSlot::Legs(_) => match armor_type { + 0 => Some(&Item::LEATHER_LEGGINGS), + 1 => Some(&Item::COPPER_LEGGINGS), + 2 => Some(&Item::GOLDEN_LEGGINGS), + 3 => Some(&Item::CHAINMAIL_LEGGINGS), + 4 => Some(&Item::IRON_LEGGINGS), + 5 => Some(&Item::DIAMOND_LEGGINGS), + _ => None, + }, + EquipmentSlot::Feet(_) => match armor_type { + 0 => Some(&Item::LEATHER_BOOTS), + 1 => Some(&Item::COPPER_BOOTS), + 2 => Some(&Item::GOLDEN_BOOTS), + 3 => Some(&Item::CHAINMAIL_BOOTS), + 4 => Some(&Item::IRON_BOOTS), + 5 => Some(&Item::DIAMOND_BOOTS), + _ => None, + }, + _ => None, + } + } + #[must_use] pub fn new(entity: Entity) -> Self { Self { @@ -654,6 +717,105 @@ pub trait Mob: EntityBase + Send + Sync { None } + fn as_crossbow_attack_mob(&self) -> Option<&dyn crossbow_attack_mob::CrossbowAttackMob> { + None + } + + fn populate_default_equipment_slots<'a>( + &'a self, + _world: &'a Arc, + difficulty: &'a crate::entity::mob::equipment::RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if rand::random::() + < MobEntity::MAX_WEARING_ARMOR_CHANCE * difficulty.special_multiplier + { + let mut armor_type = rand::random_range(0..3); + for _ in 1..=3 { + if rand::random::() < MobEntity::WEARING_ARMOR_UPGRADE_MATERIAL_CHANCE { + armor_type += 1; + } + } + + let partial_chance = if difficulty.base_difficulty == Difficulty::Hard { + 0.1f32 + } else { + 0.25f32 + }; + + let living = &self.get_mob_entity().living_entity; + let mut equipment = living.entity_equipment.lock().await; + let mut first = true; + + for slot in &MobEntity::EQUIPMENT_POPULATION_ORDER { + let current = equipment.get(slot); + if !first && rand::random::() < partial_chance { + break; + } + first = false; + if current.is_empty() + && let Some(item) = MobEntity::get_equipment_for_slot(slot, armor_type) + { + equipment.put(slot, ItemStack::new(1, item)); + } + } + } + }) + } + + fn populate_default_equipment_enchantments<'a>( + &'a self, + difficulty: &'a crate::entity::mob::equipment::RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.enchant_spawned_weapon(difficulty).await; + for slot in &MobEntity::EQUIPMENT_POPULATION_ORDER { + self.enchant_spawned_armor(slot, difficulty).await; + } + }) + } + + fn enchant_spawned_weapon<'a>( + &'a self, + difficulty: &'a crate::entity::mob::equipment::RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.enchant_spawned_equipment( + &EquipmentSlot::MAIN_HAND, + MobEntity::MAX_ENCHANTED_WEAPON_CHANCE, + difficulty, + ) + } + + fn enchant_spawned_armor<'a>( + &'a self, + slot: &'a EquipmentSlot, + difficulty: &'a crate::entity::mob::equipment::RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.enchant_spawned_equipment(slot, MobEntity::MAX_ENCHANTED_ARMOR_CHANCE, difficulty) + } + + fn enchant_spawned_equipment<'a>( + &'a self, + slot: &'a EquipmentSlot, + chance: f32, + difficulty: &'a crate::entity::mob::equipment::RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let living = &self.get_mob_entity().living_entity; + let mut equipment = living.entity_equipment.lock().await; + if let Some(stack) = equipment.equipment.get_mut(slot) + && !stack.is_empty() + && rand::random::() < chance * difficulty.special_multiplier + { + crate::entity::mob::equipment::apply_vanilla_enchantments( + stack, + slot, + difficulty.special_multiplier, + ); + } + }) + } + fn mob_write_nbt<'a>(&'a self, _nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async {}) } diff --git a/crates/pumpkin/src/entity/mob/piglin.rs b/crates/pumpkin/src/entity/mob/piglin.rs index 9545becf7..999d480ba 100644 --- a/crates/pumpkin/src/entity/mob/piglin.rs +++ b/crates/pumpkin/src/entity/mob/piglin.rs @@ -1,25 +1,91 @@ -use std::sync::{Arc, Weak}; +use std::sync::{ + Arc, Weak, + atomic::{AtomicBool, AtomicI32, Ordering}, +}; +use pumpkin_data::Block; +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::dimension::Dimension; use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::tracked_data; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use pumpkin_protocol::java::client::play::Metadata; +use pumpkin_util::math::boundingbox::EntityDimensions; +use pumpkin_util::math::position::BlockPos; +use tokio::sync::Mutex; +use crate::entity::living::LivingEntity; +use crate::entity::player::Player; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, open_door::OpenDoorGoal, - swim::SwimGoal, wander_around::WanderAroundGoal, + ranged_crossbow_attack::RangedCrossbowAttackGoal, revenge::RevengeGoal, swim::SwimGoal, + wander_around::WanderAroundGoal, + }, + mob::{ + Mob, MobEntity, crossbow_attack_mob::CrossbowAttackMob, equipment::RegionalDifficulty, + piglin_ai::PiglinAi, }, - mob::{Mob, MobEntity}, }; +use crate::world::World; pub struct PiglinEntity { pub mob_entity: MobEntity, + pub immune_to_zombification: AtomicBool, + pub time_in_overworld: AtomicI32, + pub is_baby: AtomicBool, + pub cannot_hunt: AtomicBool, + pub is_charging_crossbow: AtomicBool, + pub is_dancing: AtomicBool, + pub inventory: Mutex>, + pub admire_timer: AtomicI32, + pub admiring_disabled_timer: AtomicI32, + pub eat_cooldown_timer: AtomicI32, + pub celebration_timer: AtomicI32, + pub hunt_cooldown_timer: AtomicI32, + pub admiring_item: Mutex>, } impl PiglinEntity { + pub const CONVERSION_TIME: i32 = 300; + pub const INVENTORY_SIZE: usize = 8; + pub const XP_REWARD: u32 = 5; + + pub const ADULT_DIMENSIONS: EntityDimensions = EntityDimensions { + width: 0.6, + height: 1.95, + eye_height: 1.79, + }; + pub const BABY_DIMENSIONS: EntityDimensions = EntityDimensions { + width: 0.49, + height: 0.98, + eye_height: 0.78, + }; + pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let piglin = Self { mob_entity }; + let piglin = Self { + mob_entity, + immune_to_zombification: AtomicBool::new(false), + time_in_overworld: AtomicI32::new(0), + is_baby: AtomicBool::new(false), + cannot_hunt: AtomicBool::new(false), + is_charging_crossbow: AtomicBool::new(false), + is_dancing: AtomicBool::new(false), + inventory: Mutex::new(Vec::new()), + admire_timer: AtomicI32::new(0), + admiring_disabled_timer: AtomicI32::new(0), + eat_cooldown_timer: AtomicI32::new(0), + celebration_timer: AtomicI32::new(0), + hunt_cooldown_timer: AtomicI32::new(0), + admiring_item: Mutex::new(None), + }; let mob_arc = Arc::new(piglin); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -35,8 +101,8 @@ impl PiglinEntity { goal_selector.add_goal(0, Box::new(SwimGoal::default())); goal_selector.add_goal(1, Box::new(OpenDoorGoal::new(true))); - // Piglins use crossbows or swords, but for now we give them melee goal_selector.add_goal(2, Box::new(MeleeAttackGoal::new(1.0, true))); + goal_selector.add_goal(3, Box::new(RangedCrossbowAttackGoal::new(1.0, 8.0))); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, @@ -49,12 +115,28 @@ impl PiglinEntity { .target_selector .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - target_selector.add_goal( - 1, - ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::PLAYER, true), - ); + + // Retaliate when attacked + target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); + + // Hostile to players unless wearing safe gold armor target_selector.add_goal( 2, + Box::new(ActiveTargetGoal::new( + &mob_arc.mob_entity, + &EntityType::PLAYER, + 10, + true, + false, + Some(|target: Arc, _world: Arc| { + Box::pin(async move { !PiglinAi::is_wearing_safe_armor(&target).await }) + }), + )), + ); + + // Hostile to wither skeletons and withers + target_selector.add_goal( + 3, ActiveTargetGoal::with_default( &mob_arc.mob_entity, &EntityType::WITHER_SKELETON, @@ -62,17 +144,561 @@ impl PiglinEntity { ), ); target_selector.add_goal( - 2, + 3, ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::WITHER, true), ); + + // Adults that can hunt are hostile to hoglins + let piglin_clone = mob_arc.clone(); + target_selector.add_goal( + 4, + Box::new(ActiveTargetGoal::new( + &mob_arc.mob_entity, + &EntityType::HOGLIN, + 10, + true, + false, + Some(move |_target: Arc, _world: Arc| { + let piglin = piglin_clone.clone(); + Box::pin(async move { piglin.is_adult() && piglin.can_hunt() }) + }), + )), + ); }; mob_arc } + + #[must_use] + pub fn is_immune_to_zombification(&self) -> bool { + self.immune_to_zombification.load(Ordering::Relaxed) + } + + pub fn set_immune_to_zombification(&self, immune: bool) { + self.immune_to_zombification + .store(immune, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::piglin::DATA_IMMUNE_TO_ZOMBIFICATION, + immune, + )], + None, + ); + } + + #[must_use] + pub fn is_converting(&self, world: &World) -> bool { + !self.is_immune_to_zombification() + && !self.mob_entity.is_no_ai() + && world.dimension.minecraft_name != Dimension::THE_NETHER.minecraft_name + } + + #[must_use] + pub fn is_baby(&self) -> bool { + self.is_baby.load(Ordering::Relaxed) + } + + #[must_use] + pub fn is_adult(&self) -> bool { + !self.is_baby() + } + + pub fn set_baby(&self, baby: bool) { + self.is_baby.store(baby, Ordering::Relaxed); + let entity = &self.mob_entity.living_entity.entity; + entity.send_meta_data( + &[Metadata::new(tracked_data::piglin::DATA_BABY_ID, baby)], + None, + ); + if baby { + entity.entity_dimension.store(Self::BABY_DIMENSIONS); + } else { + entity.entity_dimension.store(Self::ADULT_DIMENSIONS); + } + } + + #[must_use] + pub fn is_charging_crossbow(&self) -> bool { + self.is_charging_crossbow.load(Ordering::Relaxed) + } + + pub fn set_charging_crossbow(&self, is_charging: bool) { + self.is_charging_crossbow + .store(is_charging, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::piglin::DATA_IS_CHARGING_CROSSBOW, + is_charging, + )], + None, + ); + } + + #[must_use] + pub fn is_dancing(&self) -> bool { + self.is_dancing.load(Ordering::Relaxed) + } + + pub fn set_dancing(&self, is_dancing: bool) { + self.is_dancing.store(is_dancing, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::piglin::DATA_IS_DANCING, + is_dancing, + )], + None, + ); + } + + #[must_use] + pub fn can_hunt(&self) -> bool { + !self.cannot_hunt.load(Ordering::Relaxed) + && self.hunt_cooldown_timer.load(Ordering::Relaxed) <= 0 + } + + pub fn set_cannot_hunt(&self, cannot_hunt: bool) { + self.cannot_hunt.store(cannot_hunt, Ordering::Relaxed); + } + + #[must_use] + pub fn is_admiring(&self) -> bool { + self.admire_timer.load(Ordering::Relaxed) > 0 + } + + #[must_use] + pub fn is_admiring_disabled(&self) -> bool { + self.admiring_disabled_timer.load(Ordering::Relaxed) > 0 + } + + #[must_use] + pub fn has_eaten_recently(&self) -> bool { + self.eat_cooldown_timer.load(Ordering::Relaxed) > 0 + } + + pub async fn start_admiring(&self, item: ItemStack) { + self.admire_timer + .store(PiglinAi::ADMIRE_DURATION, Ordering::Relaxed); + *self.admiring_item.lock().await = Some(item.clone()); + + let mut equip = self.mob_entity.living_entity.entity_equipment.lock().await; + equip.put(&EquipmentSlot::OFF_HAND, item); + + let entity = &self.mob_entity.living_entity.entity; + let pos = entity.pos.load(); + entity.world.load().play_sound( + Sound::EntityPiglinAdmiringItem, + SoundCategory::Hostile, + &pos, + ); + } + + pub async fn stop_holding_off_hand_item(&self, bartering_enabled: bool) { + let admired_item = { + let mut guard = self.admiring_item.lock().await; + guard.take() + }; + + let _ = { + let mut equip = self.mob_entity.living_entity.entity_equipment.lock().await; + equip.put(&EquipmentSlot::OFF_HAND, ItemStack::EMPTY.clone()) + }; + + let Some(item) = admired_item else { + return; + }; + + if self.is_adult() { + let is_barter = PiglinAi::is_barter_currency(&item); + if bartering_enabled && is_barter { + let outcomes = PiglinAi::get_barter_response_items(); + let entity = &self.mob_entity.living_entity.entity; + + let mut event = + crate::plugin::api::events::entity::piglin_barter::PiglinBarterEvent::new( + entity.entity_id, + item, + outcomes, + ); + if let Some(server) = entity.world.load().server.upgrade() { + server.plugin_manager.fire(&server, &mut event).await; + } + + if !event.cancelled { + PiglinAi::throw_items(self, event.outcome, None).await; + } + } else if !is_barter { + let remainder = self.add_to_inventory(item).await; + if let Some(rem) = remainder { + PiglinAi::throw_items(self, vec![rem], None).await; + } + } + } else { + let remainder = self.add_to_inventory(item).await; + if let Some(rem) = remainder { + PiglinAi::throw_items(self, vec![rem], None).await; + } + } + } + + pub async fn cancel_admiring(&self) { + if self.is_admiring() { + self.admire_timer.store(0, Ordering::Relaxed); + let item = { + let mut guard = self.admiring_item.lock().await; + guard.take() + }; + if let Some(item) = item { + let _ = { + let mut equip = self.mob_entity.living_entity.entity_equipment.lock().await; + equip.put(&EquipmentSlot::OFF_HAND, ItemStack::EMPTY.clone()) + }; + PiglinAi::throw_items(self, vec![item], None).await; + } + } + } + + pub async fn was_hurt_by(&self, attacker: Option<&dyn EntityBase>) { + self.cancel_admiring().await; + self.set_dancing(false); + self.celebration_timer.store(0, Ordering::Relaxed); + + if let Some(attacker_entity) = attacker + && attacker_entity.get_entity().entity_type.id == EntityType::PLAYER.id + { + self.admiring_disabled_timer + .store(PiglinAi::ADMIRING_DISABLED_DURATION, Ordering::Relaxed); + } + } + + pub async fn add_to_inventory(&self, item: ItemStack) -> Option { + let mut inv = self.inventory.lock().await; + if inv.len() < Self::INVENTORY_SIZE { + inv.push(item); + None + } else { + Some(item) + } + } + + pub async fn drop_inventory(&self) { + let items = { + let mut inv = self.inventory.lock().await; + std::mem::take(&mut *inv) + }; + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + let pos = entity.pos.load(); + for item in items { + if !item.is_empty() { + let item_entity = crate::entity::item::ItemEntity::new( + Entity::new(world.clone(), pos, &EntityType::ITEM), + item, + ); + world.spawn_entity(Arc::new(item_entity)).await; + } + } + } + + #[must_use] + pub fn check_piglin_spawn_rules(world: &World, pos: &BlockPos) -> bool { + let below = BlockPos::new(pos.0.x, pos.0.y - 1, pos.0.z); + let state = world.get_block_state(&below); + state.id != Block::NETHER_WART_BLOCK.default_state.id + } + + async fn convert_to_zombified(&self) { + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + let pos = entity.pos.load(); + + if world.level_info.load().difficulty != pumpkin_util::Difficulty::Peaceful { + world.play_sound( + Sound::EntityPiglinConvertedToZombified, + SoundCategory::Hostile, + &pos, + ); + } + + self.drop_inventory().await; + + let zombified = crate::entity::r#type::from_type( + &EntityType::ZOMBIFIED_PIGLIN, + pos, + &world, + uuid::Uuid::new_v4(), + ); + + let zombified_base = zombified.get_entity(); + zombified_base.set_rotation(entity.yaw.load(), entity.pitch.load()); + zombified_base.head_yaw.store(entity.head_yaw.load()); + zombified_base.velocity.store(entity.velocity.load()); + + if let Some(living) = zombified.get_living_entity() { + living.set_health(self.mob_entity.living_entity.health.load()); + } + + if let Some(custom_name) = &**entity.custom_name.load() { + zombified_base.set_custom_name(custom_name.clone()); + } + + { + let src_equip = self.mob_entity.living_entity.entity_equipment.lock().await; + if let Some(living) = zombified.get_living_entity() { + let mut dst_equip = living.entity_equipment.lock().await; + for (slot, item) in &src_equip.equipment { + dst_equip.put(slot, item.clone()); + } + } + } + + world.spawn_entity(zombified).await; + entity.remove().await; + } } impl Mob for PiglinEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn populate_default_equipment_slots<'a>( + &'a self, + _world: &'a Arc, + _difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if !self.is_baby.load(Ordering::Relaxed) { + let living = &self.mob_entity.living_entity; + let mut equipment = living.entity_equipment.lock().await; + + // Spawn weapon: 50% crossbow, 5% golden spear (10% of remaining 50%), 45% golden sword + let weapon = if rand::random::() < 0.5 { + &Item::CROSSBOW + } else if rand::random_range(0..10) == 0 { + &Item::GOLDEN_SPEAR + } else { + &Item::GOLDEN_SWORD + }; + equipment.put(&EquipmentSlot::MAIN_HAND, ItemStack::new(1, weapon)); + + // Armor: 10% chance per piece for golden armor + if rand::random::() < 0.1 { + equipment.put( + &EquipmentSlot::HEAD, + ItemStack::new(1, &Item::GOLDEN_HELMET), + ); + } + if rand::random::() < 0.1 { + equipment.put( + &EquipmentSlot::CHEST, + ItemStack::new(1, &Item::GOLDEN_CHESTPLATE), + ); + } + if rand::random::() < 0.1 { + equipment.put( + &EquipmentSlot::LEGS, + ItemStack::new(1, &Item::GOLDEN_LEGGINGS), + ); + } + if rand::random::() < 0.1 { + equipment.put(&EquipmentSlot::FEET, ItemStack::new(1, &Item::GOLDEN_BOOTS)); + } + } + }) + } + + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + let mut meta = Vec::new(); + if self.is_immune_to_zombification() { + meta.push(Metadata::new( + tracked_data::piglin::DATA_IMMUNE_TO_ZOMBIFICATION, + true, + )); + } + if self.is_baby() { + meta.push(Metadata::new(tracked_data::piglin::DATA_BABY_ID, true)); + } + if self.is_charging_crossbow() { + meta.push(Metadata::new( + tracked_data::piglin::DATA_IS_CHARGING_CROSSBOW, + true, + )); + } + if self.is_dancing() { + meta.push(Metadata::new(tracked_data::piglin::DATA_IS_DANCING, true)); + } + if !meta.is_empty() { + entity.send_meta_data(&meta, None); + } + }) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if self.is_immune_to_zombification() { + nbt.put_bool("IsImmuneToZombification", true); + } + let time_in_overworld = self.time_in_overworld.load(Ordering::Relaxed); + if time_in_overworld > 0 { + nbt.put_int("TimeInOverworld", time_in_overworld); + } + nbt.put_bool("CanPickUpLoot", true); + if self.is_baby() { + nbt.put_bool("IsBaby", true); + } + if !self.can_hunt() { + nbt.put_bool("CannotHunt", true); + } + if self.is_charging_crossbow() { + nbt.put_bool("IsChargingCrossbow", true); + } + if self.is_dancing() { + nbt.put_bool("IsDancing", true); + } + + let inv = self.inventory.lock().await; + if !inv.is_empty() { + let mut items_tag = Vec::new(); + for item in inv.iter() { + if !item.is_empty() { + let mut item_nbt = NbtCompound::new(); + item.write_item_stack(&mut item_nbt); + items_tag.push(NbtTag::Compound(item_nbt)); + } + } + if !items_tag.is_empty() { + nbt.put_list("Inventory", items_tag); + } + } + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(immune) = nbt.get_bool("IsImmuneToZombification") { + self.set_immune_to_zombification(immune); + } + if let Some(time) = nbt.get_int("TimeInOverworld") { + self.time_in_overworld.store(time, Ordering::Relaxed); + } + if let Some(baby) = nbt.get_bool("IsBaby") { + self.set_baby(baby); + } + if let Some(cannot_hunt) = nbt.get_bool("CannotHunt") { + self.set_cannot_hunt(cannot_hunt); + } + if let Some(charging) = nbt.get_bool("IsChargingCrossbow") { + self.set_charging_crossbow(charging); + } + if let Some(dancing) = nbt.get_bool("IsDancing") { + self.set_dancing(dancing); + } + if let Some(inv_list) = nbt.get_list("Inventory") { + let mut inv = self.inventory.lock().await; + inv.clear(); + for tag in inv_list { + if let Some(compound) = tag.extract_compound() + && let Some(stack) = ItemStack::read_item_stack(compound) + { + inv.push(stack); + } + } + } + }) + } + + fn mob_interact<'a>( + &'a self, + player: &'a Arc, + item_stack: &'a mut ItemStack, + ) -> EntityBaseFuture<'a, bool> { + Box::pin(async move { + if PiglinAi::can_admire(self, item_stack) { + let mut given = item_stack.clone(); + given.item_count = 1; + if player.gamemode.load() != pumpkin_util::GameMode::Creative { + item_stack.item_count -= 1; + } + self.start_admiring(given).await; + return true; + } + self.mob_entity.mob_interact(player, item_stack).await + }) + } + + fn mob_tick<'a>(&'a self, _caller: &'a Arc) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + if !entity.is_alive() { + return; + } + + let world = entity.world.load(); + if self.is_converting(&world) { + let time = self.time_in_overworld.fetch_add(1, Ordering::Relaxed) + 1; + if time > Self::CONVERSION_TIME { + self.convert_to_zombified().await; + } + } else { + self.time_in_overworld.store(0, Ordering::Relaxed); + } + + if self.admiring_disabled_timer.load(Ordering::Relaxed) > 0 { + self.admiring_disabled_timer.fetch_sub(1, Ordering::Relaxed); + } + if self.eat_cooldown_timer.load(Ordering::Relaxed) > 0 { + self.eat_cooldown_timer.fetch_sub(1, Ordering::Relaxed); + } + if self.hunt_cooldown_timer.load(Ordering::Relaxed) > 0 { + self.hunt_cooldown_timer.fetch_sub(1, Ordering::Relaxed); + } + if self.celebration_timer.load(Ordering::Relaxed) > 0 { + let remaining = self.celebration_timer.fetch_sub(1, Ordering::Relaxed) - 1; + if remaining <= 0 { + self.set_dancing(false); + } + } + + if self.admire_timer.load(Ordering::Relaxed) > 0 { + let remaining = self.admire_timer.fetch_sub(1, Ordering::Relaxed) - 1; + if remaining <= 0 { + self.stop_holding_off_hand_item(true).await; + } + } + }) + } + + fn on_damage<'a>( + &'a self, + _damage_type: pumpkin_data::damage::DamageType, + source: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if self.mob_entity.living_entity.dead.load(Ordering::Relaxed) { + self.drop_inventory().await; + } else { + self.was_hurt_by(source).await; + } + }) + } + + fn as_crossbow_attack_mob(&self) -> Option<&dyn CrossbowAttackMob> { + Some(self) + } + + fn get_base_experience_reward(&self) -> u32 { + Self::XP_REWARD + } +} + +impl CrossbowAttackMob for PiglinEntity { + fn set_charging_crossbow(&self, is_charging: bool) { + self.set_charging_crossbow(is_charging); + } + + fn is_charging_crossbow(&self) -> bool { + self.is_charging_crossbow() + } } diff --git a/crates/pumpkin/src/entity/mob/piglin_ai.rs b/crates/pumpkin/src/entity/mob/piglin_ai.rs new file mode 100644 index 000000000..bd5590ff3 --- /dev/null +++ b/crates/pumpkin/src/entity/mob/piglin_ai.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::tag::{self, Taggable}; +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::Entity; +use crate::entity::living::LivingEntity; +use crate::entity::mob::piglin::PiglinEntity; + +/// Vanilla Piglin AI logic, bartering, reactions, and utilities. +/// Mirrors `net.minecraft.world.entity.monster.piglin.PiglinAi`. +pub struct PiglinAi; + +impl PiglinAi { + pub const REPELLENT_DETECTION_RANGE_HORIZONTAL: i32 = 8; + pub const REPELLENT_DETECTION_RANGE_VERTICAL: i32 = 4; + pub const BARTERING_ITEM: &'static Item = &Item::GOLD_INGOT; + pub const PLAYER_ANGER_RANGE: f64 = 16.0; + pub const ANGER_DURATION: i32 = 600; + pub const ADMIRE_DURATION: i32 = 119; + pub const ADMIRING_DISABLED_DURATION: i32 = 400; + pub const EAT_COOLDOWN: i32 = 200; + pub const BABY_FLEE_DURATION: i32 = 100; + pub const CELEBRATION_TIME: i32 = 300; + pub const MIN_TIME_BETWEEN_HUNTS: i32 = 600; + pub const MAX_TIME_BETWEEN_HUNTS: i32 = 2400; + pub const DESIRED_DISTANCE_FROM_ZOMBIFIED: f64 = 6.0; + pub const PROBABILITY_OF_CELEBRATION_DANCE: f32 = 0.1; + + #[must_use] + pub const fn is_barter_currency(item_stack: &ItemStack) -> bool { + item_stack.item.id == Self::BARTERING_ITEM.id + } + + #[must_use] + pub fn is_loved_item(item_stack: &ItemStack) -> bool { + item_stack.item.has_tag(&tag::Item::MINECRAFT_PIGLIN_LOVED) + } + + #[must_use] + pub fn is_food(item_stack: &ItemStack) -> bool { + item_stack.item.has_tag(&tag::Item::MINECRAFT_PIGLIN_FOOD) + } + + #[must_use] + pub const fn is_zombified(entity_type: &EntityType) -> bool { + entity_type.id == EntityType::ZOMBIFIED_PIGLIN.id || entity_type.id == EntityType::ZOGLIN.id + } + + #[must_use] + pub fn wants_to_dance(killed_target_type: &EntityType) -> bool { + if killed_target_type.id != EntityType::HOGLIN.id { + return false; + } + rand::random::() < Self::PROBABILITY_OF_CELEBRATION_DANCE + } + + pub async fn is_wearing_safe_armor(target: &LivingEntity) -> bool { + let equipment = target.entity_equipment.lock().await; + [ + EquipmentSlot::HEAD, + EquipmentSlot::CHEST, + EquipmentSlot::LEGS, + EquipmentSlot::FEET, + ] + .iter() + .any(|slot| { + let stack = equipment.get(slot); + !stack.is_empty() + && (stack.item.has_tag(&tag::Item::MINECRAFT_PIGLIN_SAFE_ARMOR) + || stack.item.has_tag(&tag::Item::MINECRAFT_PIGLIN_LOVED)) + }) + } + + #[must_use] + pub fn can_admire(piglin: &PiglinEntity, item_stack: &ItemStack) -> bool { + !piglin.is_admiring_disabled() + && !piglin.is_admiring() + && piglin.is_adult() + && Self::is_barter_currency(item_stack) + } + + #[must_use] + pub fn wants_to_pickup(piglin: &PiglinEntity, item_stack: &ItemStack) -> bool { + if piglin.is_baby() + && item_stack + .item + .has_tag(&tag::Item::MINECRAFT_IGNORED_BY_PIGLIN_BABIES) + { + return false; + } + if item_stack + .item + .has_tag(&tag::Item::MINECRAFT_PIGLIN_REPELLENTS) + { + return false; + } + if piglin.is_admiring_disabled() { + return false; + } + if Self::is_barter_currency(item_stack) { + return !piglin.is_admiring(); + } + if Self::is_food(item_stack) { + return !piglin.has_eaten_recently(); + } + Self::is_loved_item(item_stack) + } + + /// Generates randomized barter drop items matching vanilla `BuiltInLootTables.PIGLIN_BARTERING`. + #[must_use] + pub fn get_barter_response_items() -> Vec { + let roll = rand::random_range(0..459); + match roll { + 0..5 => vec![ItemStack::new(1, &Item::ENCHANTED_BOOK)], + 5..13 => vec![ItemStack::new(1, &Item::IRON_BOOTS)], + 13..21 => vec![ItemStack::new(1, &Item::SPLASH_POTION)], + 21..39 => vec![ItemStack::new(1, &Item::POTION)], // Potion / Water bottle + 39..49 => vec![ItemStack::new( + rand::random_range(10..=36), + &Item::IRON_NUGGET, + )], + 49..59 => vec![ItemStack::new( + rand::random_range(2..=4), + &Item::ENDER_PEARL, + )], + 59..79 => vec![ItemStack::new(rand::random_range(3..=9), &Item::STRING)], + 79..99 => vec![ItemStack::new(rand::random_range(5..=12), &Item::QUARTZ)], + 99..139 => vec![ItemStack::new(1, &Item::OBSIDIAN)], + 139..179 => vec![ItemStack::new( + rand::random_range(1..=3), + &Item::CRYING_OBSIDIAN, + )], + 179..219 => vec![ItemStack::new(1, &Item::FIRE_CHARGE)], + 219..259 => vec![ItemStack::new(rand::random_range(2..=4), &Item::LEATHER)], + 259..299 => vec![ItemStack::new(rand::random_range(2..=8), &Item::SOUL_SAND)], + 299..339 => vec![ItemStack::new( + rand::random_range(2..=8), + &Item::NETHER_BRICK, + )], + 339..379 => vec![ItemStack::new( + rand::random_range(6..=12), + &Item::SPECTRAL_ARROW, + )], + 379..419 => vec![ItemStack::new(rand::random_range(8..=16), &Item::GRAVEL)], + _ => vec![ItemStack::new( + rand::random_range(8..=16), + &Item::BLACKSTONE, + )], + } + } + + pub async fn throw_items( + piglin: &PiglinEntity, + items: Vec, + target_pos: Option>, + ) { + let entity = &piglin.mob_entity.living_entity.entity; + let world = entity.world.load(); + let pos = entity.pos.load(); + let spawn_pos = Vector3::new(pos.x, pos.y + 1.0, pos.z); + + for item in items { + if !item.is_empty() { + let item_entity = crate::entity::item::ItemEntity::new( + Entity::new(world.clone(), spawn_pos, &EntityType::ITEM), + item, + ); + if let Some(target) = target_pos { + let vel = Vector3::new(target.x - pos.x, target.y - pos.y, target.z - pos.z) + .normalize(); + item_entity.get_entity().velocity.store(Vector3::new( + vel.x * 0.3, + 0.3, + vel.z * 0.3, + )); + } + world.spawn_entity(Arc::new(item_entity)).await; + } + } + } +} diff --git a/crates/pumpkin/src/entity/mob/piglin_brute.rs b/crates/pumpkin/src/entity/mob/piglin_brute.rs index cabab7fa9..8dec01bc5 100644 --- a/crates/pumpkin/src/entity/mob/piglin_brute.rs +++ b/crates/pumpkin/src/entity/mob/piglin_brute.rs @@ -1,25 +1,45 @@ -use std::sync::{Arc, Weak}; +use std::sync::{ + Arc, Weak, + atomic::{AtomicBool, AtomicI32, Ordering}, +}; +use pumpkin_data::Block; +use pumpkin_data::dimension::Dimension; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::tracked_data; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::java::client::play::Metadata; +use pumpkin_util::math::position::BlockPos; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, open_door::OpenDoorGoal, - swim::SwimGoal, wander_around::WanderAroundGoal, + revenge::RevengeGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, mob::{Mob, MobEntity}, }; +use crate::world::World; pub struct PiglinBruteEntity { pub mob_entity: MobEntity, + pub immune_to_zombification: AtomicBool, + pub time_in_overworld: AtomicI32, } impl PiglinBruteEntity { + pub const CONVERSION_TIME: i32 = 300; + pub const XP_REWARD: u32 = 20; + pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let piglin = Self { mob_entity }; + let piglin = Self { + mob_entity, + immune_to_zombification: AtomicBool::new(false), + time_in_overworld: AtomicI32::new(0), + }; let mob_arc = Arc::new(piglin); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -48,12 +68,14 @@ impl PiglinBruteEntity { .target_selector .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); + // Piglin brutes are always hostile to players (even with gold armor) target_selector.add_goal( - 1, + 2, ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::PLAYER, true), ); target_selector.add_goal( - 2, + 3, ActiveTargetGoal::with_default( &mob_arc.mob_entity, &EntityType::WITHER_SKELETON, @@ -61,17 +83,157 @@ impl PiglinBruteEntity { ), ); target_selector.add_goal( - 2, + 3, ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::WITHER, true), ); }; mob_arc } + + #[must_use] + pub fn is_immune_to_zombification(&self) -> bool { + self.immune_to_zombification.load(Ordering::Relaxed) + } + + pub fn set_immune_to_zombification(&self, immune: bool) { + self.immune_to_zombification + .store(immune, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::piglin_brute::DATA_IMMUNE_TO_ZOMBIFICATION, + immune, + )], + None, + ); + } + + #[must_use] + pub fn is_converting(&self, world: &World) -> bool { + !self.is_immune_to_zombification() + && !self.mob_entity.is_no_ai() + && world.dimension.minecraft_name != Dimension::THE_NETHER.minecraft_name + } + + #[must_use] + pub fn check_piglin_brute_spawn_rules(world: &World, pos: &BlockPos) -> bool { + let below = BlockPos::new(pos.0.x, pos.0.y - 1, pos.0.z); + let state = world.get_block_state(&below); + state.id != Block::NETHER_WART_BLOCK.default_state.id + } + + async fn convert_to_zombified(&self) { + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + let pos = entity.pos.load(); + + if world.level_info.load().difficulty != pumpkin_util::Difficulty::Peaceful { + world.play_sound( + Sound::EntityPiglinBruteConvertedToZombified, + SoundCategory::Hostile, + &pos, + ); + } + + let zombified = crate::entity::r#type::from_type( + &EntityType::ZOMBIFIED_PIGLIN, + pos, + &world, + uuid::Uuid::new_v4(), + ); + + let zombified_base = zombified.get_entity(); + zombified_base.set_rotation(entity.yaw.load(), entity.pitch.load()); + zombified_base.head_yaw.store(entity.head_yaw.load()); + zombified_base.velocity.store(entity.velocity.load()); + + if let Some(living) = zombified.get_living_entity() { + living.set_health(self.mob_entity.living_entity.health.load()); + } + + if let Some(custom_name) = &**entity.custom_name.load() { + zombified_base.set_custom_name(custom_name.clone()); + } + + { + let src_equip = self.mob_entity.living_entity.entity_equipment.lock().await; + if let Some(living) = zombified.get_living_entity() { + let mut dst_equip = living.entity_equipment.lock().await; + for (slot, item) in &src_equip.equipment { + dst_equip.put(slot, item.clone()); + } + } + } + + world.spawn_entity(zombified).await; + entity.remove().await; + } } impl Mob for PiglinBruteEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + if self.is_immune_to_zombification() { + entity.send_meta_data( + &[Metadata::new( + tracked_data::piglin_brute::DATA_IMMUNE_TO_ZOMBIFICATION, + true, + )], + None, + ); + } + }) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if self.is_immune_to_zombification() { + nbt.put_bool("IsImmuneToZombification", true); + } + let time_in_overworld = self.time_in_overworld.load(Ordering::Relaxed); + if time_in_overworld > 0 { + nbt.put_int("TimeInOverworld", time_in_overworld); + } + nbt.put_bool("CanPickUpLoot", true); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(immune) = nbt.get_bool("IsImmuneToZombification") { + self.set_immune_to_zombification(immune); + } + if let Some(time) = nbt.get_int("TimeInOverworld") { + self.time_in_overworld.store(time, Ordering::Relaxed); + } + }) + } + + fn mob_tick<'a>(&'a self, _caller: &'a Arc) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + if !entity.is_alive() { + return; + } + + let world = entity.world.load(); + if self.is_converting(&world) { + let time = self.time_in_overworld.fetch_add(1, Ordering::Relaxed) + 1; + if time > Self::CONVERSION_TIME { + self.convert_to_zombified().await; + } + } else { + self.time_in_overworld.store(0, Ordering::Relaxed); + } + }) + } + + fn get_base_experience_reward(&self) -> u32 { + Self::XP_REWARD + } } diff --git a/crates/pumpkin/src/entity/mob/pillager.rs b/crates/pumpkin/src/entity/mob/pillager.rs index 506f25b0d..9070eca43 100644 --- a/crates/pumpkin/src/entity/mob/pillager.rs +++ b/crates/pumpkin/src/entity/mob/pillager.rs @@ -1,18 +1,27 @@ -use std::sync::{Arc, Weak}; +use std::sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering}, +}; use pumpkin_data::entity::EntityType; +use pumpkin_data::item_stack::ItemStack; use pumpkin_data::sound::Sound; +use pumpkin_data::tracked_data; use pumpkin_nbt::compound::NbtCompound; +use pumpkin_nbt::tag::NbtTag; +use pumpkin_protocol::java::client::play::Metadata; +use tokio::sync::Mutex; use crate::entity::{ - Entity, NbtFuture, + Entity, EntityBase, EntityBaseFuture, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, - look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, swim::SwimGoal, - wander_around::WanderAroundGoal, + look_at_entity::LookAtEntityGoal, ranged_crossbow_attack::RangedCrossbowAttackGoal, + swim::SwimGoal, wander_around::WanderAroundGoal, }, mob::{ Mob, MobEntity, + crossbow_attack_mob::CrossbowAttackMob, patrol::{LongDistancePatrolGoal, PatrolData, PatrollingMonster}, raider::{ HoldGroundAttackGoal, ObtainRaidLeaderBannerGoal, PathfindToRaidGoal, Raider, @@ -24,14 +33,20 @@ use crate::entity::{ pub struct PillagerEntity { pub mob_entity: MobEntity, pub raider_data: RaiderData, + pub is_charging_crossbow: AtomicBool, + pub inventory: Mutex>, } impl PillagerEntity { + pub const INVENTORY_SIZE: usize = 5; + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_arc = Arc::new(Self { mob_entity: MobEntity::new(entity), raider_data: RaiderData::default(), + is_charging_crossbow: AtomicBool::new(false), + inventory: Mutex::new(Vec::new()), }); let mob_weak: Weak = { @@ -49,8 +64,7 @@ impl PillagerEntity { goal_selector.add_goal(0, Box::new(SwimGoal::default())); goal_selector.add_goal(1, Box::new(ObtainRaidLeaderBannerGoal)); goal_selector.add_goal(2, Box::new(HoldGroundAttackGoal::new(10.0))); - // Pillagers use crossbows, but for now we give them melee - goal_selector.add_goal(3, Box::new(MeleeAttackGoal::new(1.0, true))); + goal_selector.add_goal(3, Box::new(RangedCrossbowAttackGoal::new(1.0, 8.0))); goal_selector.add_goal(4, Box::new(LongDistancePatrolGoal::new(0.7, 0.595))); goal_selector.add_goal(4, Box::new(RaiderMoveThroughVillageGoal::new(1.05))); goal_selector.add_goal(4, Box::new(PathfindToRaidGoal::default())); @@ -83,6 +97,52 @@ impl PillagerEntity { mob_arc } + + #[must_use] + pub fn is_charging_crossbow(&self) -> bool { + self.is_charging_crossbow.load(Ordering::Relaxed) + } + + pub fn set_charging_crossbow(&self, is_charging: bool) { + self.is_charging_crossbow + .store(is_charging, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::pillager::IS_CHARGING_CROSSBOW, + is_charging, + )], + None, + ); + } + + pub async fn add_to_inventory(&self, item: ItemStack) -> Option { + let mut inv = self.inventory.lock().await; + if inv.len() < Self::INVENTORY_SIZE { + inv.push(item); + None + } else { + Some(item) + } + } + + pub async fn drop_inventory(&self) { + let items = { + let mut inv = self.inventory.lock().await; + std::mem::take(&mut *inv) + }; + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + let pos = entity.pos.load(); + for item in items { + if !item.is_empty() { + let item_entity = crate::entity::item::ItemEntity::new( + Entity::new(world.clone(), pos, &EntityType::ITEM), + item, + ); + world.spawn_entity(Arc::new(item_entity)).await; + } + } + } } impl Mob for PillagerEntity { @@ -98,15 +158,74 @@ impl Mob for PillagerEntity { Some(self) } + fn as_crossbow_attack_mob(&self) -> Option<&dyn CrossbowAttackMob> { + Some(self) + } + + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + if self.is_charging_crossbow() { + entity.send_meta_data( + &[Metadata::new( + tracked_data::pillager::IS_CHARGING_CROSSBOW, + true, + )], + None, + ); + } + }) + } + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { self.write_raider_nbt(nbt); + nbt.put_bool("CanPickUpLoot", true); + + let inv = self.inventory.lock().await; + if !inv.is_empty() { + let mut items_tag = Vec::new(); + for item in inv.iter() { + if !item.is_empty() { + let mut item_nbt = NbtCompound::new(); + item.write_item_stack(&mut item_nbt); + items_tag.push(NbtTag::Compound(item_nbt)); + } + } + if !items_tag.is_empty() { + nbt.put_list("Inventory", items_tag); + } + } }) } fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { self.read_raider_nbt(nbt); + + if let Some(inv_list) = nbt.get_list("Inventory") { + let mut inv = self.inventory.lock().await; + inv.clear(); + for tag in inv_list { + if let Some(compound) = tag.extract_compound() + && let Some(stack) = ItemStack::read_item_stack(compound) + { + inv.push(stack); + } + } + } + }) + } + + fn on_damage<'a>( + &'a self, + _damage_type: pumpkin_data::damage::DamageType, + _source: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if self.mob_entity.living_entity.dead.load(Ordering::Relaxed) { + self.drop_inventory().await; + } }) } } @@ -126,3 +245,13 @@ impl Raider for PillagerEntity { Sound::EntityPillagerCelebrate } } + +impl CrossbowAttackMob for PillagerEntity { + fn set_charging_crossbow(&self, is_charging: bool) { + self.set_charging_crossbow(is_charging); + } + + fn is_charging_crossbow(&self) -> bool { + self.is_charging_crossbow() + } +} diff --git a/crates/pumpkin/src/entity/mob/skeleton/mod.rs b/crates/pumpkin/src/entity/mob/skeleton/mod.rs index bfd24373f..0e120ffc4 100644 --- a/crates/pumpkin/src/entity/mob/skeleton/mod.rs +++ b/crates/pumpkin/src/entity/mob/skeleton/mod.rs @@ -1,17 +1,22 @@ use std::sync::{Arc, Weak}; +use pumpkin_data::data_component_impl::EquipmentSlot; use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_util::Difficulty; use crate::entity::{ - Entity, + Entity, EntityBaseFuture, ai::goal::{ active_target::ActiveTargetGoal, bow_attack::BowAttackGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, revenge::RevengeGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{Mob, MobEntity, equipment::RegionalDifficulty}, }; +use crate::world::World; pub mod bogged; pub mod parched; @@ -70,4 +75,52 @@ impl Mob for SkeletonEntityBase { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn populate_default_equipment_slots<'a>( + &'a self, + _world: &'a Arc, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + // Default armor slots (super.populateDefaultEquipmentSlots) + if rand::random::() + < MobEntity::MAX_WEARING_ARMOR_CHANCE * difficulty.special_multiplier + { + let mut armor_type = rand::random_range(0..3); + for _ in 1..=3 { + if rand::random::() < MobEntity::WEARING_ARMOR_UPGRADE_MATERIAL_CHANCE { + armor_type += 1; + } + } + + let partial_chance = if difficulty.base_difficulty == Difficulty::Hard { + 0.1f32 + } else { + 0.25f32 + }; + + let living = &self.mob_entity.living_entity; + let mut equipment = living.entity_equipment.lock().await; + let mut first = true; + + for slot in &MobEntity::EQUIPMENT_POPULATION_ORDER { + let current = equipment.get(slot); + if !first && rand::random::() < partial_chance { + break; + } + first = false; + if current.is_empty() + && let Some(item) = MobEntity::get_equipment_for_slot(slot, armor_type) + { + equipment.put(slot, ItemStack::new(1, item)); + } + } + } + + // AbstractSkeleton sets BOW on MAIN_HAND + let living = &self.mob_entity.living_entity; + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::MAIN_HAND, ItemStack::new(1, &Item::BOW)); + }) + } } diff --git a/crates/pumpkin/src/entity/mob/skeleton/skeleton.rs b/crates/pumpkin/src/entity/mob/skeleton/skeleton.rs index 8fd120186..873b19093 100644 --- a/crates/pumpkin/src/entity/mob/skeleton/skeleton.rs +++ b/crates/pumpkin/src/entity/mob/skeleton/skeleton.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use crate::entity::{ - Entity, - mob::{Mob, MobEntity, skeleton::SkeletonEntityBase}, + Entity, EntityBaseFuture, + mob::{Mob, MobEntity, equipment::RegionalDifficulty, skeleton::SkeletonEntityBase}, }; +use crate::world::World; pub struct SkeletonEntity { entity: Arc, @@ -21,4 +22,21 @@ impl Mob for SkeletonEntity { fn get_mob_entity(&self) -> &MobEntity { &self.entity.mob_entity } + + fn populate_default_equipment_slots<'a>( + &'a self, + world: &'a Arc, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.entity + .populate_default_equipment_slots(world, difficulty) + } + + fn populate_default_equipment_enchantments<'a>( + &'a self, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.entity + .populate_default_equipment_enchantments(difficulty) + } } diff --git a/crates/pumpkin/src/entity/mob/zombie/mod.rs b/crates/pumpkin/src/entity/mob/zombie/mod.rs index 78375f031..a3c83af9e 100644 --- a/crates/pumpkin/src/entity/mob/zombie/mod.rs +++ b/crates/pumpkin/src/entity/mob/zombie/mod.rs @@ -11,11 +11,19 @@ use crate::entity::{ Entity, ai::goal::{Goal, active_target::ActiveTargetGoal, look_at_entity::LookAtEntityGoal}, }; +use pumpkin_data::data_component_impl::EquipmentSlot; use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; use pumpkin_nbt::compound::NbtCompound; +use pumpkin_util::Difficulty; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; +use crate::entity::EntityBaseFuture; +use crate::entity::mob::equipment::RegionalDifficulty; +use crate::world::World; + pub mod drowned; pub mod husk; #[allow(clippy::module_inception)] @@ -127,6 +135,66 @@ impl Mob for ZombieEntityBase { &self.mob_entity } + fn populate_default_equipment_slots<'a>( + &'a self, + _world: &'a Arc, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + // Default armor slots (super.populateDefaultEquipmentSlots) + if rand::random::() + < MobEntity::MAX_WEARING_ARMOR_CHANCE * difficulty.special_multiplier + { + let mut armor_type = rand::random_range(0..3); + for _ in 1..=3 { + if rand::random::() < MobEntity::WEARING_ARMOR_UPGRADE_MATERIAL_CHANCE { + armor_type += 1; + } + } + + let partial_chance = if difficulty.base_difficulty == Difficulty::Hard { + 0.1f32 + } else { + 0.25f32 + }; + + let living = &self.mob_entity.living_entity; + let mut equipment = living.entity_equipment.lock().await; + let mut first = true; + + for slot in &MobEntity::EQUIPMENT_POPULATION_ORDER { + let current = equipment.get(slot); + if !first && rand::random::() < partial_chance { + break; + } + first = false; + if current.is_empty() + && let Some(item) = MobEntity::get_equipment_for_slot(slot, armor_type) + { + equipment.put(slot, ItemStack::new(1, item)); + } + } + } + + let weapon_chance = if difficulty.base_difficulty == Difficulty::Hard { + 0.05f32 + } else { + 0.01f32 + }; + if rand::random::() < weapon_chance { + let r = rand::random_range(0..6); + let weapon_item = match r { + 0 => &Item::IRON_SWORD, + 1 => &Item::IRON_SPEAR, + _ => &Item::IRON_SHOVEL, + }; + let living = &self.mob_entity.living_entity; + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::MAIN_HAND, ItemStack::new(1, weapon_item)); + } + }) + } + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { if self.can_break_doors() { diff --git a/crates/pumpkin/src/entity/mob/zombie/zombie.rs b/crates/pumpkin/src/entity/mob/zombie/zombie.rs index 7ab814d4d..26bfdadb6 100644 --- a/crates/pumpkin/src/entity/mob/zombie/zombie.rs +++ b/crates/pumpkin/src/entity/mob/zombie/zombie.rs @@ -1,6 +1,8 @@ +use crate::entity::mob::equipment::RegionalDifficulty; use crate::entity::mob::zombie::ZombieEntityBase; use crate::entity::mob::{Mob, MobEntity}; -use crate::entity::{Entity, NbtFuture}; +use crate::entity::{Entity, EntityBaseFuture, NbtFuture}; +use crate::world::World; use pumpkin_nbt::compound::NbtCompound; use std::sync::Arc; @@ -28,6 +30,23 @@ impl Mob for ZombieEntity { &self.entity.mob_entity } + fn populate_default_equipment_slots<'a>( + &'a self, + world: &'a Arc, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.entity + .populate_default_equipment_slots(world, difficulty) + } + + fn populate_default_equipment_enchantments<'a>( + &'a self, + difficulty: &'a RegionalDifficulty, + ) -> EntityBaseFuture<'a, ()> { + self.entity + .populate_default_equipment_enchantments(difficulty) + } + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { self.entity.mob_write_nbt(nbt).await; diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index a6cf89654..ef3190186 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -1784,8 +1784,29 @@ impl Player { let respawn_guard = self.respawn_point.lock().await; let respawn_point = respawn_guard.as_ref()?; - let world = self.world(); + let world = if self.world().dimension == respawn_point.dimension { + self.world() + } else if let Some(server) = self.world().server.upgrade() { + server.get_world_from_dimension(&respawn_point.dimension) + } else { + self.world() + }; let pos = &respawn_point.position; + + // Ensure chunks around the spawn position are fetched + let min_chunk_x = (pos.0.x - 2) >> 4; + let max_chunk_x = (pos.0.x + 2) >> 4; + let min_chunk_z = (pos.0.z - 2) >> 4; + let max_chunk_z = (pos.0.z + 2) >> 4; + for cx in min_chunk_x..=max_chunk_x { + for cz in min_chunk_z..=max_chunk_z { + world + .level + .get_or_fetch_chunk(Vector2::new(cx, cz), |_| ()) + .await; + } + } + let (block, state_id) = world.get_block_and_state_id(pos); // If force is set (from /spawnpoint command), validate position is safe diff --git a/crates/pumpkin/src/plugin/api/events/entity/piglin_barter.rs b/crates/pumpkin/src/plugin/api/events/entity/piglin_barter.rs index 86ba341b5..5a80bc293 100644 --- a/crates/pumpkin/src/plugin/api/events/entity/piglin_barter.rs +++ b/crates/pumpkin/src/plugin/api/events/entity/piglin_barter.rs @@ -14,3 +14,15 @@ pub struct PiglinBarterEvent { /// The outcome item stacks produced by the barter. pub outcome: Vec, } + +impl PiglinBarterEvent { + #[must_use] + pub const fn new(entity_id: i32, input_item: ItemStack, outcome: Vec) -> Self { + Self { + cancelled: false, + entity_id, + input_item, + outcome, + } + } +} diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 75a58a1bb..0dcf22219 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -3945,11 +3945,18 @@ impl World { let data_kept = u8::from(alive); - // Copy spawn info from level_info to avoid holding lock across await - let (spawn_x, spawn_z, spawn_yaw, spawn_pitch, keep_inventory) = { - let info = self.level_info.load(); + let server = self.server.upgrade(); + let default_world = server.as_ref().map_or_else( + || self.clone(), + |s| s.get_world_from_dimension(&Dimension::OVERWORLD), + ); + + // Copy spawn info from default world level_info to avoid holding lock across await + let (spawn_x, spawn_y, spawn_z, spawn_yaw, spawn_pitch, keep_inventory) = { + let info = default_world.level_info.load(); ( info.spawn_x, + info.spawn_y, info.spawn_z, info.spawn_yaw, info.spawn_pitch, @@ -3958,48 +3965,62 @@ impl World { }; // Get respawn position and dimension - let (position, yaw, pitch, respawn_dimension) = - if let Some(respawn) = player.calculate_respawn_point().await { - ( - respawn.position, - respawn.yaw, - respawn.pitch, - respawn.dimension, - ) - } else { - // No valid respawn point - send notification and use world spawn + let (position, yaw, pitch, respawn_dimension) = if let Some(respawn) = + player.calculate_respawn_point().await + { + ( + respawn.position, + respawn.yaw, + respawn.pitch, + respawn.dimension, + ) + } else { + // No valid respawn point - send notification if player had one set + if player.respawn_point.lock().await.is_some() { player .send_client_packet(&CGameEvent::new(GameEvent::NoRespawnBlockAvailable, 0.0)) .await; + let mut guard = player.respawn_point.lock().await; + if let Some(point) = guard.as_ref() + && !point.force + { + *guard = None; + } + } - // FIXME: This spawn position calculation is incorrect. Should use vanilla's - // proper spawn position calculation (see #1381). The y-level calculation - // needs to account for spawn radius and find a safe spawn position. - let chunk_pos = Vector2::new(spawn_x >> 4, spawn_z >> 4); - self.level.get_or_fetch_chunk(chunk_pos, |_| ()).await; - let top = self.get_top_block(Vector2::new(spawn_x, spawn_z)); - - ( - Vector3::new( - f64::from(spawn_x) + 0.5, - (top + 1).into(), - f64::from(spawn_z) + 0.5, - ), - spawn_yaw, - spawn_pitch, - self.dimension.clone(), - ) + // FIXME: This spawn position calculation is incorrect. Should use vanilla's + // proper spawn position calculation (see #1381). The y-level calculation + // needs to account for spawn radius and find a safe spawn position. + let chunk_pos = Vector2::new(spawn_x >> 4, spawn_z >> 4); + default_world + .level + .get_or_fetch_chunk(chunk_pos, |_| ()) + .await; + let top = default_world.get_top_block(Vector2::new(spawn_x, spawn_z)); + let pos_y = if top > default_world.dimension.min_y { + top + 1 + } else { + spawn_y }; + ( + Vector3::new( + f64::from(spawn_x) + 0.5, + f64::from(pos_y), + f64::from(spawn_z) + 0.5, + ), + spawn_yaw, + spawn_pitch, + default_world.dimension.clone(), + ) + }; + let mut spawn_loc_event = crate::plugin::api::events::player::player_spawn_location::PlayerSpawnLocationEvent::new( player.clone(), position, ); - if let Some(server) = self.server.upgrade() { - server - .plugin_manager - .fire(&server, &mut spawn_loc_event) - .await; + if let Some(ref s) = server { + s.plugin_manager.fire(s, &mut spawn_loc_event).await; } let position = spawn_loc_event.spawn_pos; @@ -4007,13 +4028,13 @@ impl World { let candidate_world = if respawn_dimension == self.dimension { None } else { - self.server.upgrade().map_or_else( + server.as_ref().map_or_else( || { warn!("Could not get server for cross-dimension respawn"); None }, - |server| { - let worlds = server.worlds.load(); + |s| { + let worlds = s.worlds.load(); worlds .iter() .find(|w| w.dimension == respawn_dimension) @@ -4025,7 +4046,7 @@ impl World { // Fire PlayerChangeWorldEvent (cancellable) before the transfer; it runs before // the non-cancellable PlayerRespawnEvent, which observes the resolved world. let (resolved_world, position, yaw, pitch) = if let Some(new_world) = candidate_world { - if let Some(server) = self.server.upgrade() { + if let Some(ref s) = server { let mut event = PlayerChangeWorldEvent { player: player.clone(), previous_world: self.clone(), @@ -4035,7 +4056,7 @@ impl World { pitch, cancelled: false, }; - server.plugin_manager.fire(&server, &mut event).await; + s.plugin_manager.fire(s, &mut event).await; if event.cancelled { (None, position, yaw, pitch) @@ -4087,23 +4108,10 @@ impl World { // Cancelled or unresolved cross-dimension respawns fall back to the current // world's spawn below; otherwise the resolved values from the event apply. - let (target_world, position, yaw, pitch) = if let Some(ref new_world) = resolved_world { - (new_world.clone(), position, yaw, pitch) - } else if respawn_dimension != self.dimension { - // FIXME: This spawn position calculation is incorrect. Should use vanilla's - // proper spawn position calculation (see #1381). - let chunk_pos = Vector2::new(spawn_x >> 4, spawn_z >> 4); - self.level.get_or_fetch_chunk(chunk_pos, |_| ()).await; - let top = self.get_top_block(Vector2::new(spawn_x, spawn_z)); - let fallback_pos = Vector3::new( - f64::from(spawn_x) + 0.5, - (top + 1).into(), - f64::from(spawn_z) + 0.5, - ); - (self.clone(), fallback_pos, spawn_yaw, spawn_pitch) - } else { - (self.clone(), position, yaw, pitch) - }; + let (target_world, position, yaw, pitch) = resolved_world.as_ref().map_or_else( + || (self.clone(), position, yaw, pitch), + |new_world| (new_world.clone(), position, yaw, pitch), + ); // Notify plugins that the player has respawned (non-cancellable). if let Some(server) = self.server.upgrade() {