From 97fdea50d2b54adad6bd3ce5e63e896502a565ef Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 24 Aug 2026 13:24:47 +0200 Subject: [PATCH] feat: add pillager raids --- .../src/bedrock/client/boss_event.rs | 275 +++- .../pumpkin/src/block/blocks/wither_skull.rs | 1 + crates/pumpkin/src/command/args/mod.rs | 2 +- .../src/command/args/resource_location.rs | 34 +- .../pumpkin/src/command/commands/bossbar.rs | 179 ++- crates/pumpkin/src/command/commands/mod.rs | 2 + crates/pumpkin/src/command/commands/raid.rs | 339 +++++ crates/pumpkin/src/command/commands/time.rs | 16 +- .../pumpkin/src/command/commands/waypoint.rs | 5 +- crates/pumpkin/src/entity/ai/goal/mod.rs | 2 + .../src/entity/ai/goal/pathfind_to_raid.rs | 204 +++ .../src/entity/ai/goal/ranged_attack.rs | 178 +++ crates/pumpkin/src/entity/boss/wither.rs | 679 ++++++++- crates/pumpkin/src/entity/effect/hunger.rs | 29 + crates/pumpkin/src/entity/effect/infested.rs | 73 + crates/pumpkin/src/entity/effect/mod.rs | 96 +- crates/pumpkin/src/entity/effect/oozing.rs | 47 + crates/pumpkin/src/entity/effect/poison.rs | 44 + crates/pumpkin/src/entity/effect/raid_omen.rs | 32 + .../pumpkin/src/entity/effect/regeneration.rs | 32 + .../pumpkin/src/entity/effect/saturation.rs | 29 + crates/pumpkin/src/entity/effect/weaving.rs | 69 + .../pumpkin/src/entity/effect/wind_charged.rs | 44 + crates/pumpkin/src/entity/effect/wither.rs | 37 + crates/pumpkin/src/entity/living.rs | 197 +-- crates/pumpkin/src/entity/mob/evoker.rs | 41 +- crates/pumpkin/src/entity/mob/illusioner.rs | 64 +- crates/pumpkin/src/entity/mob/mod.rs | 26 +- crates/pumpkin/src/entity/mob/patrol.rs | 227 +++ crates/pumpkin/src/entity/mob/pillager.rs | 69 +- crates/pumpkin/src/entity/mob/raider.rs | 418 ++++++ crates/pumpkin/src/entity/mob/ravager.rs | 67 +- crates/pumpkin/src/entity/mob/vindicator.rs | 66 +- crates/pumpkin/src/entity/mob/witch.rs | 306 +++- crates/pumpkin/src/entity/passive/llama.rs | 77 +- .../pumpkin/src/entity/passive/snow_golem.rs | 59 +- .../src/entity/passive/trader_llama.rs | 77 +- .../src/entity/passive/villager/mod.rs | 8 +- .../src/entity/passive/wandering_trader.rs | 1266 ++++++++++++++++- crates/pumpkin/src/entity/player.rs | 62 + .../src/entity/projectile/llama_spit.rs | 114 ++ crates/pumpkin/src/entity/projectile/mod.rs | 4 + .../src/entity/projectile/wither_skull.rs | 176 +++ crates/pumpkin/src/entity/type.rs | 4 + crates/pumpkin/src/world/bossbar.rs | 116 +- crates/pumpkin/src/world/custom_bossbar.rs | 92 +- crates/pumpkin/src/world/mod.rs | 21 +- crates/pumpkin/src/world/raid.rs | 765 ++++++++++ crates/pumpkin/src/world/villager_poi.rs | 18 + 49 files changed, 6318 insertions(+), 470 deletions(-) create mode 100644 crates/pumpkin/src/command/commands/raid.rs create mode 100644 crates/pumpkin/src/entity/ai/goal/pathfind_to_raid.rs create mode 100644 crates/pumpkin/src/entity/ai/goal/ranged_attack.rs create mode 100644 crates/pumpkin/src/entity/effect/hunger.rs create mode 100644 crates/pumpkin/src/entity/effect/infested.rs create mode 100644 crates/pumpkin/src/entity/effect/oozing.rs create mode 100644 crates/pumpkin/src/entity/effect/poison.rs create mode 100644 crates/pumpkin/src/entity/effect/raid_omen.rs create mode 100644 crates/pumpkin/src/entity/effect/regeneration.rs create mode 100644 crates/pumpkin/src/entity/effect/saturation.rs create mode 100644 crates/pumpkin/src/entity/effect/weaving.rs create mode 100644 crates/pumpkin/src/entity/effect/wind_charged.rs create mode 100644 crates/pumpkin/src/entity/effect/wither.rs create mode 100644 crates/pumpkin/src/entity/mob/patrol.rs create mode 100644 crates/pumpkin/src/entity/mob/raider.rs create mode 100644 crates/pumpkin/src/entity/projectile/llama_spit.rs create mode 100644 crates/pumpkin/src/entity/projectile/wither_skull.rs create mode 100644 crates/pumpkin/src/world/raid.rs diff --git a/crates/pumpkin-protocol/src/bedrock/client/boss_event.rs b/crates/pumpkin-protocol/src/bedrock/client/boss_event.rs index f087a2e15..4d4361dd2 100644 --- a/crates/pumpkin-protocol/src/bedrock/client/boss_event.rs +++ b/crates/pumpkin-protocol/src/bedrock/client/boss_event.rs @@ -1,85 +1,216 @@ -use std::io::{Error, Write}; - -use crate::{ - codec::{var_int::VarInt, var_long::VarLong}, - serial::PacketWrite, -}; use pumpkin_macros::packet; +use crate::{ + codec::var_long::VarLong, + serial::{PacketRead, PacketWrite}, +}; + +pub const BOSS_EVENT_SHOW: u8 = 0; +pub const BOSS_EVENT_REGISTER_PLAYER: u8 = 1; +pub const BOSS_EVENT_HIDE: u8 = 2; +pub const BOSS_EVENT_UNREGISTER_PLAYER: u8 = 3; +pub const BOSS_EVENT_HEALTH_PERCENTAGE: u8 = 4; +pub const BOSS_EVENT_TITLE: u8 = 5; +pub const BOSS_EVENT_APPEARANCE_PROPERTIES: u8 = 6; +pub const BOSS_EVENT_TEXTURE: u8 = 7; +pub const BOSS_EVENT_REQUEST: u8 = 8; + +pub const BOSS_EVENT_COLOUR_PINK: u8 = 0; +pub const BOSS_EVENT_COLOUR_BLUE: u8 = 1; +pub const BOSS_EVENT_COLOUR_RED: u8 = 2; +pub const BOSS_EVENT_COLOUR_GREEN: u8 = 3; +pub const BOSS_EVENT_COLOUR_YELLOW: u8 = 4; +pub const BOSS_EVENT_COLOUR_PURPLE: u8 = 5; +pub const BOSS_EVENT_COLOUR_REBECCA_PURPLE: u8 = 6; +pub const BOSS_EVENT_COLOUR_WHITE: u8 = 7; + +pub const BOSS_EVENT_OVERLAY_PROGRESS: u8 = 0; +pub const BOSS_EVENT_OVERLAY_NOTCHED_6: u8 = 1; +pub const BOSS_EVENT_OVERLAY_NOTCHED_10: u8 = 2; +pub const BOSS_EVENT_OVERLAY_NOTCHED_12: u8 = 3; +pub const BOSS_EVENT_OVERLAY_NOTCHED_20: u8 = 4; + +/// Sent by the server to make a specific 'boss event' occur in the world. +/// +/// Packet ID: `74` +#[derive(PacketWrite, PacketRead, Clone, Debug, PartialEq)] #[packet(74)] pub struct CBossEvent { + /// The unique ID of the boss entity that the boss event sent involves. pub boss_entity_id: VarLong, - pub action: BossEventAction, + /// The unique ID of the player that is registered to or unregistered from the boss fight. + pub player_entity_id: VarLong, + /// The type of the event (one of `BOSS_EVENT_*`). + pub event_type: u8, + /// The title shown above the boss bar. + pub title: String, + /// Filtered version of `title` with profanity removed. + pub filtered_title: String, + /// The percentage of health shown in the boss bar (0.0 - 1.0). + pub health_percentage: f32, + /// The colour of the boss bar (one of `BOSS_EVENT_COLOUR_*`). + pub color: u8, + /// The overlay of the boss bar (one of `BOSS_EVENT_OVERLAY_*`). + pub overlay: u8, } -pub enum BossEventAction { - Add { +impl CBossEvent { + #[allow(clippy::too_many_arguments)] + #[must_use] + pub const fn new( + boss_entity_id: VarLong, + player_entity_id: VarLong, + event_type: u8, title: String, - health_percent: f32, - color: VarInt, - overlay: VarInt, - }, - Remove, - UpdateHealth(f32), - UpdateTitle(String), - UpdateProperties { - color: VarInt, - overlay: VarInt, - }, -} - -impl PacketWrite for CBossEvent { - fn write(&self, writer: &mut W) -> Result<(), Error> { - self.boss_entity_id.write(writer)?; - VarLong(0).write(writer)?; // player_entity_id - - let event_type: u8; - let mut title = String::new(); - let mut health_percent = 0.0f32; - let mut color: u8 = 0; - let mut overlay: u8 = 0; - - match &self.action { - BossEventAction::Add { - title: t, - health_percent: hp, - color: c, - overlay: o, - } => { - event_type = 0; - title.clone_from(t); - health_percent = *hp; - color = c.0 as u8; - overlay = o.0 as u8; - } - BossEventAction::Remove => { - event_type = 2; - } - BossEventAction::UpdateHealth(health) => { - event_type = 3; - health_percent = *health; - } - BossEventAction::UpdateTitle(t) => { - event_type = 4; - title.clone_from(t); - } - BossEventAction::UpdateProperties { - color: c, - overlay: o, - } => { - event_type = 5; - color = c.0 as u8; - overlay = o.0 as u8; - } + filtered_title: String, + health_percentage: f32, + color: u8, + overlay: u8, + ) -> Self { + Self { + boss_entity_id, + player_entity_id, + event_type, + title, + filtered_title, + health_percentage, + color, + overlay, } + } - event_type.write(writer)?; - title.write(writer)?; // title - title.write(writer)?; // filtered_title - health_percent.write(writer)?; - color.write(writer)?; - overlay.write(writer)?; + #[must_use] + pub fn show( + boss_entity_id: VarLong, + player_entity_id: VarLong, + title: impl Into, + health_percentage: f32, + color: u8, + overlay: u8, + ) -> Self { + let title = title.into(); + Self { + boss_entity_id, + player_entity_id, + event_type: BOSS_EVENT_SHOW, + filtered_title: title.clone(), + title, + health_percentage, + color, + overlay, + } + } - Ok(()) + #[must_use] + pub const fn register_player(boss_entity_id: VarLong, player_entity_id: VarLong) -> Self { + Self { + boss_entity_id, + player_entity_id, + event_type: BOSS_EVENT_REGISTER_PLAYER, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color: 0, + overlay: 0, + } + } + + #[must_use] + pub const fn hide(boss_entity_id: VarLong) -> Self { + Self { + boss_entity_id, + player_entity_id: VarLong(0), + event_type: BOSS_EVENT_HIDE, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color: 0, + overlay: 0, + } + } + + #[must_use] + pub const fn unregister_player(boss_entity_id: VarLong, player_entity_id: VarLong) -> Self { + Self { + boss_entity_id, + player_entity_id, + event_type: BOSS_EVENT_UNREGISTER_PLAYER, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color: 0, + overlay: 0, + } + } + + #[must_use] + pub const fn update_health(boss_entity_id: VarLong, health_percentage: f32) -> Self { + Self { + boss_entity_id, + player_entity_id: VarLong(0), + event_type: BOSS_EVENT_HEALTH_PERCENTAGE, + title: String::new(), + filtered_title: String::new(), + health_percentage, + color: 0, + overlay: 0, + } + } + + #[must_use] + pub fn update_title(boss_entity_id: VarLong, title: impl Into) -> Self { + let title = title.into(); + Self { + boss_entity_id, + player_entity_id: VarLong(0), + event_type: BOSS_EVENT_TITLE, + filtered_title: title.clone(), + title, + health_percentage: 0.0, + color: 0, + overlay: 0, + } + } + + #[must_use] + pub const fn update_properties(boss_entity_id: VarLong, color: u8, overlay: u8) -> Self { + Self { + boss_entity_id, + player_entity_id: VarLong(0), + event_type: BOSS_EVENT_APPEARANCE_PROPERTIES, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color, + overlay, + } + } + + #[must_use] + pub const fn update_texture(boss_entity_id: VarLong, color: u8, overlay: u8) -> Self { + Self { + boss_entity_id, + player_entity_id: VarLong(0), + event_type: BOSS_EVENT_TEXTURE, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color, + overlay, + } + } + + #[must_use] + pub const fn request(boss_entity_id: VarLong, player_entity_id: VarLong) -> Self { + Self { + boss_entity_id, + player_entity_id, + event_type: BOSS_EVENT_REQUEST, + title: String::new(), + filtered_title: String::new(), + health_percentage: 0.0, + color: 0, + overlay: 0, + } } } diff --git a/crates/pumpkin/src/block/blocks/wither_skull.rs b/crates/pumpkin/src/block/blocks/wither_skull.rs index b078f465d..c9b9e5125 100644 --- a/crates/pumpkin/src/block/blocks/wither_skull.rs +++ b/crates/pumpkin/src/block/blocks/wither_skull.rs @@ -88,6 +88,7 @@ impl BlockBehaviour for WitherSkeletonSkullBlock { &EntityType::WITHER, ); let wither = WitherEntity::new(entity); + wither.make_invulnerable(); world.spawn_entity(wither).await; return; } diff --git a/crates/pumpkin/src/command/args/mod.rs b/crates/pumpkin/src/command/args/mod.rs index e0f50e6f6..ddff623f6 100644 --- a/crates/pumpkin/src/command/args/mod.rs +++ b/crates/pumpkin/src/command/args/mod.rs @@ -125,7 +125,7 @@ pub enum Arg<'a> { CommandTree(CommandTree), Item(&'a str), ItemPredicate(&'a str), - ResourceLocation(&'a str), + ResourceLocation(pumpkin_util::identifier::Identifier), Block(&'a str), BlockPredicate(&'a str), BossbarColor(BossbarColor), diff --git a/crates/pumpkin/src/command/args/resource_location.rs b/crates/pumpkin/src/command/args/resource_location.rs index 378243bf5..580b9c27b 100644 --- a/crates/pumpkin/src/command/args/resource_location.rs +++ b/crates/pumpkin/src/command/args/resource_location.rs @@ -6,6 +6,7 @@ use crate::command::dispatcher::CommandError; use crate::command::tree::RawArgs; use crate::server::Server; use pumpkin_protocol::java::client::play::{ArgumentType, SuggestionProviders}; +use pumpkin_util::identifier::Identifier; // TODO: Add proper autocomplete pub struct ResourceLocationArgumentConsumer; @@ -21,39 +22,16 @@ impl GetClientSideArgParser for ResourceLocationArgumentConsumer { } impl ArgumentConsumer for ResourceLocationArgumentConsumer { - fn consume<'a, 'b>( + fn consume<'a>( &'a self, _sender: &'a CommandSender, _server: &'a Server, - args: &'b mut RawArgs<'a>, + args: &mut RawArgs<'a>, ) -> ConsumeResult<'a> { - let s_opt: Option<&'a str> = args.pop().map(|arg| arg.value); + let identifier = args.pop().and_then(|arg| Identifier::parse(arg.value).ok()); - Box::pin(async move { s_opt.map(Arg::ResourceLocation) }) + Box::pin(async move { identifier.map(Arg::ResourceLocation) }) } - - // async fn suggest<'a>( - // &'a self, - // _sender: &CommandSender, - // _server: &'a Server, - // _input: &'a str, - // ) -> Result>, CommandError> { - // if !self.autocomplete { - // return Ok(None); - // } - // // TODO - - // // let suggestions = server - // // .bossbars - // // .lock() - // // .await - // // .custom_bossbars - // // .keys() - // // .map(|suggestion| CommandSuggestion::new(suggestion, None)) - // // .collect(); - - // Ok(None) - // } } impl DefaultNameArgConsumer for ResourceLocationArgumentConsumer { @@ -63,7 +41,7 @@ impl DefaultNameArgConsumer for ResourceLocationArgumentConsumer { } impl<'a> FindArg<'a> for ResourceLocationArgumentConsumer { - type Data = &'a str; + type Data = &'a Identifier; fn find_arg(args: &'a super::ConsumedArgs, name: &str) -> Result { match args.get(name) { diff --git a/crates/pumpkin/src/command/commands/bossbar.rs b/crates/pumpkin/src/command/commands/bossbar.rs index 903cac214..ed3ea58b1 100644 --- a/crates/pumpkin/src/command/commands/bossbar.rs +++ b/crates/pumpkin/src/command/commands/bossbar.rs @@ -9,9 +9,11 @@ use crate::command::args::{ConsumedArgs, FindArg, FindArgDefaultName}; use crate::command::args::textcomponent::TextComponentArgConsumer; use crate::command::dispatcher::CommandError; -use crate::command::tree::CommandTree; +use crate::command::suggestion::suggestions::SuggestionsBuilder; use crate::command::tree::builder::{argument, argument_default_name, literal}; +use crate::command::tree::{CommandSuggestionProvider, CommandSuggestionResult, CommandTree}; use crate::command::{CommandExecutor, CommandResult, CommandSender}; +use crate::server::Server; use crate::world::bossbar::Bossbar; use crate::world::custom_bossbar::BossbarUpdateError; use pumpkin_data::translation; @@ -20,17 +22,41 @@ use pumpkin_util::text::hover::HoverEvent; use uuid::Uuid; const NAMES: [&str; 1] = ["bossbar"]; -const DESCRIPTION: &str = "Display bossbar"; +const DESCRIPTION: &str = "Creates and modifies boss bars"; const ARG_NAME: &str = "name"; const ARG_VISIBLE: &str = "visible"; const fn autocomplete_consumer() -> ResourceLocationArgumentConsumer { - // TODO: Add autocompletion when implemented properly ResourceLocationArgumentConsumer } +struct BossbarSuggestionProvider; + +impl CommandSuggestionProvider for BossbarSuggestionProvider { + fn suggest<'a>( + &'a self, + _src: &'a CommandSender, + server: &'a Server, + input: &'a str, + start: usize, + _end: usize, + ) -> CommandSuggestionResult<'a> { + Box::pin(async move { + let mut builder = SuggestionsBuilder::new(input, start); + let bossbars = server.bossbars.lock().await; + let remaining = builder.remaining_lowercase().to_string(); + for key in bossbars.custom_bossbars.keys() { + if key.to_lowercase().starts_with(&remaining) { + builder = builder.suggest(key.clone()); + } + } + builder.build() + }) + } +} + enum CommandValueGet { Max, Players, @@ -58,12 +84,9 @@ impl CommandExecutor for AddExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let mut namespace = autocomplete_consumer() + let namespace = autocomplete_consumer() .find_arg_default_name(args)? .to_string(); - if !namespace.contains(':') { - namespace = format!("minecraft:{namespace}"); - } let text_component = TextComponentArgConsumer::find_arg(args, ARG_NAME)?; @@ -98,6 +121,7 @@ impl CommandExecutor for AddExecutor { struct GetExecutor(CommandValueGet); impl CommandExecutor for GetExecutor { + #[expect(clippy::too_many_lines)] fn execute<'a>( &'a self, sender: &'a CommandSender, @@ -132,7 +156,48 @@ impl CommandExecutor for GetExecutor { .await; Ok(bossbar.max) } - CommandValueGet::Players => Ok(bossbar.players.len() as i32), + CommandValueGet::Players => { + let online_players: Vec = server + .get_all_players() + .iter() + .filter(|player| bossbar.players.contains(&player.gameprofile.id)) + .map(|player| player.gameprofile.name.clone()) + .collect(); + let count = online_players.len() as i32; + + if count == 0 { + sender + .send_message(TextComponent::translate_cross( + translation::java::COMMANDS_BOSSBAR_GET_PLAYERS_NONE, + translation::bedrock::COMMANDS_BOSSBAR_GET_PLAYERS_NONE, + [bossbar_prefix( + bossbar.bossbar_data.title.clone(), + namespace.clone(), + )], + )) + .await; + } else { + sender + .send_message(TextComponent::translate_cross( + translation::java::COMMANDS_BOSSBAR_GET_PLAYERS_SOME, + if count == 1 { + translation::bedrock::COMMANDS_BOSSBAR_GET_PLAYERS_ONE + } else { + translation::bedrock::COMMANDS_BOSSBAR_GET_PLAYERS + }, + [ + bossbar_prefix( + bossbar.bossbar_data.title.clone(), + namespace.clone(), + ), + TextComponent::text(count.to_string()), + TextComponent::text(online_players.join(", ")), + ], + )) + .await; + } + Ok(count) + } CommandValueGet::Value => { sender .send_message(TextComponent::translate_cross( @@ -152,12 +217,12 @@ impl CommandExecutor for GetExecutor { CommandValueGet::Visible => { let (java_key, bedrock_key) = if bossbar.visible { ( - translation::java::COMMANDS_BOSSBAR_SET_VISIBLE_SUCCESS_VISIBLE, + translation::java::COMMANDS_BOSSBAR_GET_VISIBLE_VISIBLE, translation::bedrock::COMMANDS_BOSSBAR_GET_VISIBLE_TRUE, ) } else { ( - translation::java::COMMANDS_BOSSBAR_SET_VISIBLE_SUCCESS_HIDDEN, + translation::java::COMMANDS_BOSSBAR_GET_VISIBLE_HIDDEN, translation::bedrock::COMMANDS_BOSSBAR_GET_VISIBLE_FALSE, ) }; @@ -270,7 +335,7 @@ impl CommandExecutor for RemoveExecutor { .bossbars .lock() .await - .remove_bossbar(server, namespace.clone()) + .remove_bossbar(server, namespace) .await { Ok(()) => return Ok(server.bossbars.lock().await.get_bossbars_len() as i32), @@ -294,11 +359,13 @@ impl CommandExecutor for SetExecutor { args: &'a ConsumedArgs<'a>, ) -> CommandResult<'a> { Box::pin(async move { - let namespace = autocomplete_consumer().find_arg_default_name(args)?; + let namespace = autocomplete_consumer() + .find_arg_default_name(args)? + .to_string(); - let Some(bossbar) = server.bossbars.lock().await.get_bossbar(namespace) else { + let Some(bossbar) = server.bossbars.lock().await.get_bossbar(&namespace) else { return Err(handle_bossbar_error( - BossbarUpdateError::InvalidResourceLocation(namespace.to_string()), + BossbarUpdateError::InvalidResourceLocation(namespace), )); }; @@ -310,7 +377,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_color(server, namespace.to_string(), *color) + .update_color(server, namespace.clone(), *color) .await { Ok(()) => {} @@ -321,11 +388,11 @@ impl CommandExecutor for SetExecutor { sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.color.success", - "commands.bossbar.set.color.success", + translation::java::COMMANDS_BOSSBAR_SET_COLOR_SUCCESS, + translation::java::COMMANDS_BOSSBAR_SET_COLOR_SUCCESS, [bossbar_prefix( bossbar.bossbar_data.title.clone(), - namespace.to_string(), + namespace, )], )) .await; @@ -345,7 +412,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_health(server, namespace.to_string(), max_value, bossbar.value) + .update_max(server, namespace.clone(), max_value) .await { Ok(()) => {} @@ -356,13 +423,10 @@ impl CommandExecutor for SetExecutor { sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.max.success", - "commands.bossbar.set.max.success", + translation::java::COMMANDS_BOSSBAR_SET_MAX_SUCCESS, + translation::java::COMMANDS_BOSSBAR_SET_MAX_SUCCESS, [ - bossbar_prefix( - bossbar.bossbar_data.title.clone(), - namespace.to_string(), - ), + bossbar_prefix(bossbar.bossbar_data.title.clone(), namespace), TextComponent::text(max_value.to_string()), ], )) @@ -376,7 +440,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_name(server, namespace, text_component.clone()) + .update_name(server, &namespace, text_component.clone()) .await { Ok(()) => {} @@ -387,9 +451,9 @@ impl CommandExecutor for SetExecutor { sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.name.success", - "commands.bossbar.set.name.success", - [bossbar_prefix(text_component, namespace.to_string())], + translation::java::COMMANDS_BOSSBAR_SET_NAME_SUCCESS, + translation::java::COMMANDS_BOSSBAR_SET_NAME_SUCCESS, + [bossbar_prefix(text_component, namespace)], )) .await; @@ -401,7 +465,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_players(server, namespace.to_string(), vec![]) + .update_players(server, namespace.clone(), vec![]) .await { Ok(()) => {} @@ -411,11 +475,11 @@ impl CommandExecutor for SetExecutor { } sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.players.success.none", - "commands.bossbar.set.players.success.none", + translation::java::COMMANDS_BOSSBAR_SET_PLAYERS_SUCCESS_NONE, + translation::java::COMMANDS_BOSSBAR_SET_PLAYERS_SUCCESS_NONE, [bossbar_prefix( bossbar.bossbar_data.title.clone(), - namespace.to_string(), + namespace, )], )) .await; @@ -432,7 +496,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_players(server, namespace.to_string(), players) + .update_players(server, namespace.clone(), players) .await { Ok(()) => {} @@ -449,13 +513,10 @@ impl CommandExecutor for SetExecutor { sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.players.success.some", - "commands.bossbar.set.players.success.some", + translation::java::COMMANDS_BOSSBAR_SET_PLAYERS_SUCCESS_SOME, + translation::java::COMMANDS_BOSSBAR_SET_PLAYERS_SUCCESS_SOME, [ - bossbar_prefix( - bossbar.bossbar_data.title.clone(), - namespace.to_string(), - ), + bossbar_prefix(bossbar.bossbar_data.title.clone(), namespace), TextComponent::text(count.to_string()), TextComponent::text(player_names), ], @@ -470,7 +531,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_division(server, namespace.to_string(), *style) + .update_division(server, namespace.clone(), *style) .await { Ok(()) => {} @@ -480,11 +541,11 @@ impl CommandExecutor for SetExecutor { } sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.style.success", - "commands.bossbar.set.style.success", + translation::java::COMMANDS_BOSSBAR_SET_STYLE_SUCCESS, + translation::java::COMMANDS_BOSSBAR_SET_STYLE_SUCCESS, [bossbar_prefix( bossbar.bossbar_data.title.clone(), - namespace.to_string(), + namespace, )], )) .await; @@ -503,7 +564,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_health(server, namespace.to_string(), bossbar.max, value) + .update_value(server, namespace.clone(), value) .await { Ok(()) => {} @@ -514,13 +575,10 @@ impl CommandExecutor for SetExecutor { sender .send_message(TextComponent::translate_cross( - "commands.bossbar.set.value.success", - "commands.bossbar.set.value.success", + translation::java::COMMANDS_BOSSBAR_SET_VALUE_SUCCESS, + translation::java::COMMANDS_BOSSBAR_SET_VALUE_SUCCESS, [ - bossbar_prefix( - bossbar.bossbar_data.title.clone(), - namespace.to_string(), - ), + bossbar_prefix(bossbar.bossbar_data.title.clone(), namespace), TextComponent::text(value.to_string()), ], )) @@ -535,7 +593,7 @@ impl CommandExecutor for SetExecutor { .bossbars .lock() .await - .update_visibility(server, namespace.to_string(), visibility) + .update_visibility(server, namespace.clone(), visibility) .await { Ok(()) => {} @@ -545,9 +603,9 @@ impl CommandExecutor for SetExecutor { } let state = if visibility { - "commands.bossbar.set.visible.success.visible" + translation::java::COMMANDS_BOSSBAR_SET_VISIBLE_SUCCESS_VISIBLE } else { - "commands.bossbar.set.visible.success.hidden" + translation::java::COMMANDS_BOSSBAR_SET_VISIBLE_SUCCESS_HIDDEN }; sender @@ -556,12 +614,12 @@ impl CommandExecutor for SetExecutor { state, [bossbar_prefix( bossbar.bossbar_data.title.clone(), - namespace.to_string(), + namespace, )], )) .await; - Ok(visibility as i32) + Ok(0) } } }) @@ -587,6 +645,7 @@ pub fn init_command_tree() -> CommandTree { .then( literal("get").then( argument_default_name(autocomplete_consumer()) + .suggests(BossbarSuggestionProvider) .then(literal("max").execute(GetExecutor(CommandValueGet::Max))) .then(literal("players").execute(GetExecutor(CommandValueGet::Players))) .then(literal("value").execute(GetExecutor(CommandValueGet::Value))) @@ -595,12 +654,16 @@ pub fn init_command_tree() -> CommandTree { ) .then(literal("list").execute(ListExecutor)) .then( - literal("remove") - .then(argument_default_name(autocomplete_consumer()).execute(RemoveExecutor)), + literal("remove").then( + argument_default_name(autocomplete_consumer()) + .suggests(BossbarSuggestionProvider) + .execute(RemoveExecutor), + ), ) .then( literal("set").then( argument_default_name(autocomplete_consumer()) + .suggests(BossbarSuggestionProvider) .then( literal("color").then( argument_default_name(BossbarColorArgumentConsumer) diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index 979ec0206..9537db4ab 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -53,6 +53,7 @@ mod playsound; mod plugin; mod plugins; mod pumpkin; +mod raid; mod random; mod recipe; mod reload; @@ -157,6 +158,7 @@ pub fn default_dispatcher( dispatcher.register(spectate::init_command_tree(), "minecraft:command.spectate"); dispatcher.register(data::init_command_tree(), "minecraft:command.data"); dispatcher.register(waypoint::init_command_tree(), "minecraft:command.waypoint"); + dispatcher.register(raid::init_command_tree(), "minecraft:command.raid"); // Three dispatcher.register(deop::init_command_tree(), "minecraft:command.deop"); dispatcher.register(kick::init_command_tree(), "minecraft:command.kick"); diff --git a/crates/pumpkin/src/command/commands/raid.rs b/crates/pumpkin/src/command/commands/raid.rs new file mode 100644 index 000000000..db8bd8261 --- /dev/null +++ b/crates/pumpkin/src/command/commands/raid.rs @@ -0,0 +1,339 @@ +use uuid::Uuid; + +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::effect::StatusEffect; +use pumpkin_data::entity::EntityType; +use pumpkin_data::potion::Effect; +use pumpkin_data::sound::Sound; +use pumpkin_util::text::TextComponent; + +use crate::command::args::bounded_num::BoundedNumArgumentConsumer; +use crate::command::args::{ConsumedArgs, FindArg}; +use crate::command::dispatcher::CommandError; +use crate::command::tree::CommandTree; +use crate::command::tree::builder::{argument, literal}; +use crate::command::{CommandExecutor, CommandResult, CommandSender}; +use crate::entity::EntityBase; +use crate::entity::mob::raider::create_ominous_banner; +use crate::entity::r#type::from_type; +use crate::server::Server; + +const NAMES: [&str; 1] = ["raid"]; +const DESCRIPTION: &str = "Controls or queries village raids."; + +const ARG_OMEN_LVL: &str = "omenlvl"; +const ARG_LEVEL: &str = "level"; + +struct StartExecutor { + has_omen_lvl: bool, +} + +impl CommandExecutor for StartExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.block_pos.load(); + let world = entity.world.load(); + + let mut raids = world.raids.lock().await; + if raids.get_raid_at(&pos).is_some() { + sender + .send_message(TextComponent::text("Raid already started close by")) + .await; + return Ok(0); + } + + let omen_lvl = if self.has_omen_lvl { + BoundedNumArgumentConsumer::::find_arg(args, ARG_OMEN_LVL) + .ok() + .and_then(Result::ok) + .unwrap_or(1) + } else { + 1 + }; + + let raid_id = raids.create_or_extend_raid(&player, pos, &world); + if let Some(id) = raid_id { + if let Some(raid) = raids.get_mut(id) { + raid.set_raid_omen_level(omen_lvl); + } + sender + .send_message(TextComponent::text("Created a raid in your local village")) + .await; + Ok(1) + } else { + sender + .send_message(TextComponent::text( + "Failed to create a raid in your local village", + )) + .await; + Ok(0) + } + }) + } +} + +struct StopExecutor; + +impl CommandExecutor for StopExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.block_pos.load(); + let world = entity.world.load(); + + let mut raids = world.raids.lock().await; + if let Some(raid) = raids.get_raid_at_mut(&pos) { + raid.stop(&world).await; + sender + .send_message(TextComponent::text("Stopped raid")) + .await; + Ok(1) + } else { + sender + .send_message(TextComponent::text("No raid here")) + .await; + Ok(0) + } + }) + } +} + +struct CheckExecutor; + +impl CommandExecutor for CheckExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.block_pos.load(); + let world = entity.world.load(); + + let raids = world.raids.lock().await; + if let Some(raid) = raids.get_raid_at(&pos) { + sender + .send_message(TextComponent::text("Found a started raid!")) + .await; + let alive = raid.get_total_raiders_alive(); + let living_health = raid.get_health_of_living_raiders(&world); + let msg = format!( + "Num groups spawned: {} Raid omen level: {} Num mobs: {} Raid health: {} / {}", + raid.get_groups_spawned(), + raid.get_raid_omen_level(), + alive, + living_health, + raid.total_health + ); + sender.send_message(TextComponent::text(msg)).await; + Ok(1) + } else { + sender + .send_message(TextComponent::text("Found no started raids")) + .await; + Ok(0) + } + }) + } +} + +struct SoundExecutor; + +impl CommandExecutor for SoundExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.pos.load(); + let world = entity.world.load(); + + let sound_pos = pos.add_raw(5.0, 0.0, 0.0); + world.play_sound( + Sound::EventRaidHorn, + pumpkin_data::sound::SoundCategory::Neutral, + &sound_pos, + ); + Ok(1) + }) + } +} + +struct SpawnLeaderExecutor; + +impl CommandExecutor for SpawnLeaderExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.pos.load(); + let world = entity.world.load(); + + let raider_uuid = Uuid::new_v4(); + let raider_entity = from_type(&EntityType::PILLAGER, pos, &world, raider_uuid); + + if let Some(mob) = raider_entity.get_mob() + && let Some(raider) = mob.as_raider() + { + raider.set_patrol_leader(true); + let banner = create_ominous_banner(); + let living = &mob.get_mob_entity().living_entity; + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::HEAD, banner.clone()); + drop(equipment); + living.send_equipment_changes(&[(EquipmentSlot::HEAD, banner)]); + } + + world.spawn_entity(raider_entity).await; + sender + .send_message(TextComponent::text("Spawned a raid captain")) + .await; + Ok(1) + }) + } +} + +struct SetOmenExecutor; + +impl CommandExecutor for SetOmenExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.block_pos.load(); + let world = entity.world.load(); + + let level = BoundedNumArgumentConsumer::::find_arg(args, ARG_LEVEL) + .ok() + .and_then(Result::ok) + .unwrap_or(1); + + let mut raids = world.raids.lock().await; + if let Some(raid) = raids.get_raid_at_mut(&pos) { + if level > 5 { + sender + .send_message(TextComponent::text( + "Sorry, the max raid omen level you can set is 5", + )) + .await; + return Ok(0); + } + let before = raid.get_raid_omen_level(); + raid.set_raid_omen_level(level); + sender + .send_message(TextComponent::text(format!( + "Changed village's raid omen level from {before} to {level}" + ))) + .await; + Ok(1) + } else { + sender + .send_message(TextComponent::text("No raid found here")) + .await; + Ok(0) + } + }) + } +} + +struct GlowExecutor; + +impl CommandExecutor for GlowExecutor { + fn execute<'a>( + &'a self, + sender: &'a CommandSender, + _server: &'a Server, + _args: &'a ConsumedArgs<'a>, + ) -> CommandResult<'a> { + Box::pin(async move { + let player = sender.as_player().ok_or(CommandError::InvalidRequirement)?; + let entity = player.get_entity(); + let pos = entity.block_pos.load(); + let world = entity.world.load(); + + let raids = world.raids.lock().await; + if let Some(raid) = raids.get_raid_at(&pos) { + let effect = Effect { + effect_type: &StatusEffect::GLOWING, + duration: 1000, + amplifier: 1, + ambient: false, + show_particles: false, + show_icon: true, + blend: true, + }; + for raider_uuid in raid.get_all_raiders() { + if let Some(e) = world.get_entity_by_uuid(raider_uuid) + && let Some(living) = e.get_living_entity() + { + living.add_effect(effect.clone()).await; + } + } + Ok(1) + } else { + sender + .send_message(TextComponent::text("No raid found here")) + .await; + Ok(0) + } + }) + } +} + +pub fn init_command_tree() -> CommandTree { + CommandTree::new(NAMES, DESCRIPTION) + .then( + literal("start") + .execute(StartExecutor { + has_omen_lvl: false, + }) + .then( + argument( + ARG_OMEN_LVL, + BoundedNumArgumentConsumer::::new().min(0), + ) + .execute(StartExecutor { has_omen_lvl: true }), + ), + ) + .then(literal("stop").execute(StopExecutor)) + .then(literal("check").execute(CheckExecutor)) + .then(literal("sound").execute(SoundExecutor)) + .then(literal("spawnleader").execute(SpawnLeaderExecutor)) + .then( + literal("setomen").then( + argument(ARG_LEVEL, BoundedNumArgumentConsumer::::new().min(0)) + .execute(SetOmenExecutor), + ), + ) + .then(literal("glow").execute(GlowExecutor)) +} diff --git a/crates/pumpkin/src/command/commands/time.rs b/crates/pumpkin/src/command/commands/time.rs index 61ab30a2d..21deb86ae 100644 --- a/crates/pumpkin/src/command/commands/time.rs +++ b/crates/pumpkin/src/command/commands/time.rs @@ -69,7 +69,7 @@ impl CommandExecutor for QueryExecutor { ) -> CommandResult<'a> { Box::pin(async move { let clock_name = ResourceLocationArgumentConsumer::find_arg(args, ARG_CLOCK) - .unwrap_or(DEFAULT_CLOCK); + .map_or_else(|_| DEFAULT_CLOCK.to_string(), ToString::to_string); let mode = self.0; let worlds = server.worlds.load(); let world = worlds.first().ok_or(CommandError::InvalidRequirement)?; @@ -93,7 +93,7 @@ impl CommandExecutor for QueryExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_QUERY_ABSOLUTE, translation::bedrock::COMMANDS_TIME_QUERY_DAYTIME, - TextComponent::text(clock_name.to_string()), + TextComponent::text(clock_name.clone()), TextComponent::text(total_ticks.to_string()) )) .await; @@ -137,7 +137,7 @@ impl CommandExecutor for ActionExecutor { ) -> CommandResult<'a> { Box::pin(async move { let clock_name = ResourceLocationArgumentConsumer::find_arg(args, ARG_CLOCK) - .unwrap_or(DEFAULT_CLOCK); + .map_or_else(|_| DEFAULT_CLOCK.to_string(), ToString::to_string); let action = self.0; let worlds = server.worlds.load(); let world = worlds.first().ok_or(CommandError::InvalidRequirement)?; @@ -156,7 +156,7 @@ impl CommandExecutor for ActionExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_SET_ABSOLUTE, translation::bedrock::COMMANDS_TIME_SET, - TextComponent::text(clock_name.to_string()), + TextComponent::text(clock_name.clone()), TextComponent::text(time_count.to_string()) )) .await; @@ -171,7 +171,7 @@ impl CommandExecutor for ActionExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_SET_ABSOLUTE, translation::bedrock::COMMANDS_TIME_ADDED, - TextComponent::text(clock_name.to_string()), + TextComponent::text(clock_name.clone()), TextComponent::text(total_ticks.to_string()) )) .await; @@ -184,7 +184,7 @@ impl CommandExecutor for ActionExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_PAUSE, translation::bedrock::COMMANDS_TIME_STOP, - TextComponent::text(clock_name.to_string()) + TextComponent::text(clock_name.clone()) )) .await; Ok(1) @@ -196,7 +196,7 @@ impl CommandExecutor for ActionExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_RESUME, translation::bedrock::COMMANDS_TIME_SET, - TextComponent::text(clock_name.to_string()) + TextComponent::text(clock_name.clone()) )) .await; Ok(1) @@ -213,7 +213,7 @@ impl CommandExecutor for ActionExecutor { .send_message(pumpkin_macros::translate_cross!( translation::java::COMMANDS_TIME_RATE, translation::bedrock::COMMANDS_TIME_SET, - TextComponent::text(clock_name.to_string()), + TextComponent::text(clock_name.clone()), TextComponent::text(rate.to_string()) )) .await; diff --git a/crates/pumpkin/src/command/commands/waypoint.rs b/crates/pumpkin/src/command/commands/waypoint.rs index 3bfbe8d93..fccac1c97 100644 --- a/crates/pumpkin/src/command/commands/waypoint.rs +++ b/crates/pumpkin/src/command/commands/waypoint.rs @@ -155,13 +155,14 @@ impl CommandExecutor for StyleExecutor { ); let uuid = entity.entity_uuid; - let style_str = match self.0 { + let style_owned = match self.0 { StyleAction::Set => { let style = ResourceLocationArgumentConsumer::find_arg(args, ARG_STYLE)?; - Some(style) + Some(style.to_string()) } StyleAction::Reset => None, }; + let style_str = style_owned.as_deref(); let packet = CWaypoint::update_position( uuid, diff --git a/crates/pumpkin/src/entity/ai/goal/mod.rs b/crates/pumpkin/src/entity/ai/goal/mod.rs index 0738a8e3d..159cad014 100644 --- a/crates/pumpkin/src/entity/ai/goal/mod.rs +++ b/crates/pumpkin/src/entity/ai/goal/mod.rs @@ -22,8 +22,10 @@ pub mod melee_attack; pub mod move_to_target_pos; pub mod owner_hurt_by_target; pub mod owner_hurt_target; +pub mod pathfind_to_raid; pub mod pick_up_block; pub mod place_block; +pub mod ranged_attack; pub mod revenge; pub mod step_and_destroy_block; pub mod swim; diff --git a/crates/pumpkin/src/entity/ai/goal/pathfind_to_raid.rs b/crates/pumpkin/src/entity/ai/goal/pathfind_to_raid.rs new file mode 100644 index 000000000..d198ecff8 --- /dev/null +++ b/crates/pumpkin/src/entity/ai/goal/pathfind_to_raid.rs @@ -0,0 +1,204 @@ +use std::sync::atomic::AtomicI32; + +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::mob::Mob; + +pub struct PathfindToRaidGoal { + recruitment_tick: AtomicI32, + speed_modifier: f64, +} + +impl Default for PathfindToRaidGoal { + fn default() -> Self { + Self::new(1.0) + } +} + +impl PathfindToRaidGoal { + #[must_use] + pub const fn new(speed_modifier: f64) -> Self { + Self { + recruitment_tick: AtomicI32::new(0), + speed_modifier, + } + } +} + +impl Goal for PathfindToRaidGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + + let target = mob.get_mob_entity().target.lock().await.clone(); + if target.is_some() || !raider.has_active_raid() { + return false; + } + + let Some(raid_id) = raider.get_raider_data().raid_id.load() else { + return false; + }; + + let world = mob.get_entity().world.load(); + let raids = world.raids.lock().await; + let Some(raid) = raids.get(raid_id) else { + return false; + }; + + if raid.is_over() { + return false; + } + + let pos = mob.get_entity().block_pos.load(); + let is_village = world + .villager_poi + .lock() + .await + .get_nearest_job_site(pos, 32) + .is_some(); + + !is_village + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + + let target = mob.get_mob_entity().target.lock().await.clone(); + if target.is_some() || !raider.has_active_raid() { + return false; + } + + let Some(raid_id) = raider.get_raider_data().raid_id.load() else { + return false; + }; + + let world = mob.get_entity().world.load(); + let raids = world.raids.lock().await; + let Some(raid) = raids.get(raid_id) else { + return false; + }; + + if raid.is_over() { + return false; + } + + let pos = mob.get_entity().block_pos.load(); + let is_village = world + .villager_poi + .lock() + .await + .get_nearest_job_site(pos, 32) + .is_some(); + + !is_village + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return; + }; + + let Some(raid_id) = raider.get_raider_data().raid_id.load() else { + return; + }; + + let entity = mob.get_entity(); + let world = entity.world.load(); + let current_age = entity.age.load(std::sync::atomic::Ordering::Relaxed); + + let raid_center = { + let raids = world.raids.lock().await; + let Some(raid) = raids.get(raid_id) else { + return; + }; + if raid.is_over() { + return; + } + raid.center + }; + + // Periodic recruitment of nearby raiders + let next_recruit = self + .recruitment_tick + .load(std::sync::atomic::Ordering::Relaxed); + if current_age >= next_recruit { + self.recruitment_tick + .store(current_age + 20, std::sync::atomic::Ordering::Relaxed); + + let bb = entity.bounding_box.load().expand(16.0, 16.0, 16.0); + let nearby = world.get_entities_at_box(&bb); + + for cand in nearby { + if cand.get_entity().entity_id != entity.entity_id + && let Some(cand_mob) = cand.get_mob() + && let Some(cand_raider) = cand_mob.as_raider() + && !cand_raider.has_active_raid() + && cand_raider.can_join_raid() + { + cand_raider.get_raider_data().raid_id.store(Some(raid_id)); + } + } + } + + // Pathfind towards raid center if idle + let pos = entity.pos.load(); + let mut nav = mob + .get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if nav.is_idle() { + // Generate a point towards the raid center + let center_vec = Vector3::new( + f64::from(raid_center.0.x) + 0.5, + f64::from(raid_center.0.y), + f64::from(raid_center.0.z) + 0.5, + ); + let dir = center_vec - pos; + let dir_len = dir.x.hypot(dir.z); + + let step_dist = 15.0f64.min(dir_len); + let norm_dir_x = if dir_len > 0.001 { + dir.x / dir_len + } else { + 0.0 + }; + let norm_dir_z = if dir_len > 0.001 { + dir.z / dir_len + } else { + 0.0 + }; + + // Add slight random offset (-45 to +45 degrees) + let angle = (rand::random::() - 0.5) * std::f64::consts::FRAC_PI_2; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + let rx = norm_dir_x * cos_a - norm_dir_z * sin_a; + let rz = norm_dir_x * sin_a + norm_dir_z * cos_a; + + let dest = Vector3::new(pos.x + rx * step_dist, pos.y, pos.z + rz * step_dist); + + nav.set_progress(NavigatorGoal { + current_progress: pos, + destination: dest, + speed: self.speed_modifier, + }); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/ai/goal/ranged_attack.rs b/crates/pumpkin/src/entity/ai/goal/ranged_attack.rs new file mode 100644 index 000000000..659924457 --- /dev/null +++ b/crates/pumpkin/src/entity/ai/goal/ranged_attack.rs @@ -0,0 +1,178 @@ +use std::sync::{Arc, Weak}; + +use crate::entity::EntityBase; +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::mob::{Mob, RangedAttackMob}; + +/// Generic ranged attack goal for mobs implementing [`RangedAttackMob`]. +/// +/// Mirrors vanilla `RangedAttackGoal`. The mob maintains distance from its target, +/// points towards it, and fires ranged attacks periodically with calculated power. +pub struct RangedAttackGoal { + mob: Weak, + speed_modifier: f64, + attack_interval_min: i32, + attack_interval_max: i32, + attack_radius: f32, + attack_radius_sqr: f64, + see_time: i32, + attack_time: i32, + target: Option>, +} + +impl RangedAttackGoal { + #[must_use] + pub const fn new( + mob: Weak, + speed_modifier: f64, + attack_interval: i32, + attack_radius: f32, + ) -> Self { + Self::new_with_range( + mob, + speed_modifier, + attack_interval, + attack_interval, + attack_radius, + ) + } + + #[must_use] + pub const fn new_with_range( + mob: Weak, + speed_modifier: f64, + attack_interval_min: i32, + attack_interval_max: i32, + attack_radius: f32, + ) -> Self { + Self { + mob, + speed_modifier, + attack_interval_min, + attack_interval_max, + attack_radius, + attack_radius_sqr: (attack_radius * attack_radius) as f64, + see_time: 0, + attack_time: -1, + target: None, + } + } +} + +impl Goal for RangedAttackGoal { + 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(); + if let Some(target) = target + && target.get_entity().is_alive() + { + self.target = Some(target); + true + } else { + false + } + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + if let Some(target) = &self.target { + if !target.get_entity().is_alive() { + return false; + } + let current_target = mob.get_mob_entity().target.lock().await.clone(); + current_target.is_some() + } else { + false + } + }) + } + + fn stop<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + self.target = None; + self.see_time = 0; + self.attack_time = -1; + mob.get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stop(); + }) + } + + fn should_run_every_tick(&self) -> bool { + true + } + + fn controls(&self) -> Controls { + Controls::MOVE | Controls::LOOK + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(target) = self.target.clone() else { + return; + }; + + let Some(ranged_mob) = self.mob.upgrade() else { + return; + }; + + let mob_pos = mob.get_entity().pos.load(); + let target_pos = target.get_entity().pos.load(); + let target_dist_sq = mob_pos.squared_distance_to_vec(&target_pos); + + let has_line_of_sight = true; + if has_line_of_sight { + self.see_time += 1; + } else { + self.see_time = 0; + } + + { + let mut navigator = mob + .get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if target_dist_sq <= self.attack_radius_sqr && self.see_time >= 5 { + navigator.stop(); + } else { + navigator.set_progress(NavigatorGoal { + current_progress: mob_pos, + destination: target_pos, + speed: self.speed_modifier, + }); + } + } + + 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); + + self.attack_time -= 1; + if self.attack_time == 0 { + if !has_line_of_sight { + return; + } + + let dist = (target_dist_sq.sqrt() as f32) / self.attack_radius; + let power = dist.clamp(0.1, 1.0); + ranged_mob.perform_ranged_attack(&target, power).await; + + let min = self.attack_interval_min as f32; + let max = self.attack_interval_max as f32; + self.attack_time = (dist.mul_add(max - min, min)).floor() as i32; + } else if self.attack_time < 0 { + let ratio = (target_dist_sq.sqrt() as f32) / self.attack_radius; + let min = self.attack_interval_min as f32; + let max = self.attack_interval_max as f32; + self.attack_time = (ratio.mul_add(max - min, min)).floor() as i32; + } + }) + } +} diff --git a/crates/pumpkin/src/entity/boss/wither.rs b/crates/pumpkin/src/entity/boss/wither.rs index 89f223ee6..8ef4f60b6 100644 --- a/crates/pumpkin/src/entity/boss/wither.rs +++ b/crates/pumpkin/src/entity/boss/wither.rs @@ -1,20 +1,75 @@ -use pumpkin_data::entity::EntityType; -use std::sync::{Arc, Weak}; - -use crate::entity::{ - Entity, - ai::goal::{look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal}, - mob::{Mob, MobEntity}, +use std::sync::{ + Arc, Weak, + atomic::{AtomicBool, AtomicI32, Ordering}, }; +use tokio::sync::Mutex; +use uuid::Uuid; + +use pumpkin_data::{ + Block, + damage::DamageType, + entity::EntityType, + item::Item, + item_stack::ItemStack, + tag::{self, Taggable}, + tracked_data, + world::WorldEvent, +}; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::{codec::var_int::VarInt, java::client::play::Metadata}; +use pumpkin_util::{ + Difficulty, + math::{position::BlockPos, vector3::Vector3}, + text::TextComponent, +}; +use pumpkin_world::world::BlockFlags; + +use crate::{ + entity::{ + Entity, EntityBase, EntityBaseFuture, NbtFuture, + ai::goal::{ + look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, + revenge::RevengeGoal, + }, + mob::{Mob, MobEntity}, + projectile::wither_skull::WitherSkullEntity, + }, + world::{ + ExplosionInteraction, + bossbar::{Bossbar, BossbarColor, BossbarDivisions, BossbarFlags}, + }, +}; + +const INVULNERABLE_TICKS: i32 = 220; pub struct WitherEntity { pub mob_entity: MobEntity, + pub invulnerable_ticks: AtomicI32, + pub destroy_blocks_tick: AtomicI32, + pub next_head_update: [AtomicI32; 2], + pub idle_head_updates: [AtomicI32; 2], + pub alternative_targets: [AtomicI32; 3], + pub main_attack_timer: AtomicI32, + pub bossbar_uuid: Uuid, + pub bossbar_players: Mutex>, + pub dropped_loot: AtomicBool, } impl WitherEntity { pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let wither = Self { mob_entity }; + let wither = Self { + mob_entity, + invulnerable_ticks: AtomicI32::new(0), + destroy_blocks_tick: AtomicI32::new(0), + next_head_update: [AtomicI32::new(0), AtomicI32::new(0)], + idle_head_updates: [AtomicI32::new(0), AtomicI32::new(0)], + alternative_targets: [AtomicI32::new(0), AtomicI32::new(0), AtomicI32::new(0)], + main_attack_timer: AtomicI32::new(0), + bossbar_uuid: Uuid::new_v4(), + bossbar_players: Mutex::new(Vec::new()), + dropped_loot: AtomicBool::new(false), + }; let mob_arc = Arc::new(wither); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -27,21 +82,623 @@ impl WitherEntity { .goals_selector .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut target_selector = mob_arc + .mob_entity + .target_selector + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); - // TODO goal_selector.add_goal( - 8, + 6, LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); - goal_selector.add_goal(8, Box::new(RandomLookAroundGoal::default())); + goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default())); + + target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); }; mob_arc } + + #[must_use] + pub fn get_invulnerable_ticks(&self) -> i32 { + self.invulnerable_ticks.load(Ordering::Relaxed) + } + + pub fn set_invulnerable_ticks(&self, ticks: i32) { + self.invulnerable_ticks.store(ticks, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::wither::DATA_ID_INV, + VarInt(ticks), + )], + None, + ); + } + + #[must_use] + pub fn get_alternative_target(&self, head: usize) -> i32 { + if head < 3 { + self.alternative_targets[head].load(Ordering::Relaxed) + } else { + 0 + } + } + + pub fn set_alternative_target(&self, head: usize, entity_id: i32) { + if head < 3 { + let old = self.alternative_targets[head].swap(entity_id, Ordering::Relaxed); + if old != entity_id { + let tracker_id = match head { + 0 => tracked_data::wither::DATA_TARGET_A, + 1 => tracked_data::wither::DATA_TARGET_B, + _ => tracked_data::wither::DATA_TARGET_C, + }; + self.mob_entity + .living_entity + .entity + .send_meta_data(&[Metadata::new(tracker_id, VarInt(entity_id))], None); + } + } + } + + #[must_use] + pub fn is_powered(&self) -> bool { + let living = &self.mob_entity.living_entity; + living.health.load() <= living.get_max_health() / 2.0 + } + + pub fn make_invulnerable(&self) { + self.set_invulnerable_ticks(INVULNERABLE_TICKS); + self.mob_entity + .living_entity + .set_health(self.mob_entity.living_entity.get_max_health() / 3.0); + } + + #[must_use] + pub fn can_destroy(block: &Block) -> bool { + block != &Block::AIR && !block.has_tag(&tag::Block::MINECRAFT_WITHER_IMMUNE) + } + + #[must_use] + pub fn get_head_x(&self, head: usize) -> f64 { + if head == 0 { + return self.mob_entity.living_entity.entity.pos.load().x; + } + let yaw = self.mob_entity.living_entity.entity.yaw.load(); + let angle = (yaw + 180.0 * (head as f32 - 1.0)).to_radians(); + self.mob_entity.living_entity.entity.pos.load().x + (angle.cos() as f64) * 1.3 + } + + #[must_use] + pub fn get_head_y(&self, head: usize) -> f64 { + let base_y = self.mob_entity.living_entity.entity.pos.load().y; + if head == 0 { + base_y + 3.0 + } else { + base_y + 2.2 + } + } + + #[must_use] + pub fn get_head_z(&self, head: usize) -> f64 { + if head == 0 { + return self.mob_entity.living_entity.entity.pos.load().z; + } + let yaw = self.mob_entity.living_entity.entity.yaw.load(); + let angle = (yaw + 180.0 * (head as f32 - 1.0)).to_radians(); + self.mob_entity.living_entity.entity.pos.load().z + (angle.sin() as f64) * 1.3 + } + + pub async fn perform_ranged_attack( + &self, + head: usize, + target_x: f64, + target_y: f64, + target_z: f64, + dangerous: bool, + ) { + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + + if !entity.silent.load(Ordering::Relaxed) { + world.sync_world_event(WorldEvent::SoundWitherBossShoot, entity.block_pos.load(), 0); + } + + let hx = self.get_head_x(head); + let hy = self.get_head_y(head); + let hz = self.get_head_z(head); + let head_pos = Vector3::new(hx, hy, hz); + + let dir = Vector3::new(target_x - hx, target_y - hy, target_z - hz); + let normalized_dir = if dir.length_squared() > 1e-6 { + dir.normalize() + } else { + Vector3::new(0.0, 0.0, 1.0) + }; + + let skull_entity = Entity::from_uuid( + Uuid::new_v4(), + (*world).clone(), + head_pos, + &EntityType::WITHER_SKULL, + ); + let skull = Arc::new(WitherSkullEntity::new_shot( + skull_entity, + entity, + dangerous, + normalized_dir, + )); + world.spawn_entity(skull).await; + } + + fn make_bossbar(&self) -> Bossbar { + let title = self + .mob_entity + .living_entity + .entity + .custom_name + .load() + .as_ref() + .clone() + .unwrap_or_else(|| { + TextComponent::translate_cross( + "entity.minecraft.wither", + "entity.minecraft.wither", + [], + ) + }); + + Bossbar { + uuid: self.bossbar_uuid, + title, + health: 1.0, + color: BossbarColor::Purple, + division: BossbarDivisions::NoDivision, + flags: BossbarFlags::DARKEN_SKY, + } + } + + async fn update_bossbar(&self, world: &Arc, progress: f32) { + let pos = self.mob_entity.living_entity.entity.pos.load(); + let tracking_radius_sq = 50.0 * 50.0; + let players = world.players.load(); + + let current: Vec = players + .iter() + .filter(|p| { + let p_pos = p.living_entity.entity.pos.load(); + (p_pos - pos).length_squared() < tracking_radius_sq + }) + .map(|p| p.gameprofile.id) + .collect(); + + let mut bossbar_players = self.bossbar_players.lock().await; + + for &uid in ¤t { + if !bossbar_players.contains(&uid) { + if let Some(p) = players.iter().find(|p| p.gameprofile.id == uid) { + let mut bar = self.make_bossbar(); + bar.health = progress; + p.send_bossbar(&bar).await; + } + bossbar_players.push(uid); + } + } + + let to_remove: Vec = bossbar_players + .iter() + .filter(|uid| !current.contains(uid)) + .copied() + .collect(); + + for uid in &to_remove { + if let Some(p) = players.iter().find(|p| &p.gameprofile.id == uid) { + p.remove_bossbar(self.bossbar_uuid).await; + } + bossbar_players.retain(|u| u != uid); + } + + for player in players.iter() { + if bossbar_players.contains(&player.gameprofile.id) { + player + .update_bossbar_health(&self.bossbar_uuid, progress) + .await; + } + } + } + + async fn remove_all_bossbar(&self, world: &Arc) { + let mut bossbar_players = self.bossbar_players.lock().await; + let players = world.players.load(); + for player in players.iter() { + if bossbar_players.contains(&player.gameprofile.id) { + player.remove_bossbar(self.bossbar_uuid).await; + } + } + bossbar_players.clear(); + } } impl Mob for WitherEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn mob_init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + entity.send_meta_data( + &[ + Metadata::new( + tracked_data::wither::DATA_TARGET_A, + VarInt(self.get_alternative_target(0)), + ), + Metadata::new( + tracked_data::wither::DATA_TARGET_B, + VarInt(self.get_alternative_target(1)), + ), + Metadata::new( + tracked_data::wither::DATA_TARGET_C, + VarInt(self.get_alternative_target(2)), + ), + Metadata::new( + tracked_data::wither::DATA_ID_INV, + VarInt(self.get_invulnerable_ticks()), + ), + ], + None, + ); + }) + } + + #[expect(clippy::too_many_lines)] + fn mob_tick<'a>(&'a self, _caller: &'a Arc) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + + if world.level_info.load().difficulty == Difficulty::Peaceful { + self.remove_all_bossbar(&world).await; + entity.remove().await; + return; + } + + if !entity.is_alive() || self.mob_entity.living_entity.health.load() <= 0.0 { + self.remove_all_bossbar(&world).await; + if !self.dropped_loot.swap(true, Ordering::SeqCst) { + let pos = entity.block_pos.load(); + world + .drop_stack(&pos, ItemStack::new(1, &Item::NETHER_STAR)) + .await; + } + return; + } + + let invul = self.get_invulnerable_ticks(); + let tick_count = entity.age.load(Ordering::Relaxed); + + if invul > 0 { + let new_count = invul - 1; + let progress = (1.0 - (new_count as f32) / 220.0).clamp(0.0, 1.0); + self.update_bossbar(&world, progress).await; + + if new_count <= 0 { + let pos = entity.pos.load(); + let eye_y = pos.y + entity.get_eye_height(); + world + .explode( + Vector3::new(pos.x, eye_y, pos.z), + 7.0, + ExplosionInteraction::Mob, + ) + .await; + + if !entity.silent.load(Ordering::Relaxed) { + world.sync_world_event( + WorldEvent::SoundWitherBossSpawn, + entity.block_pos.load(), + 0, + ); + } + } + + self.set_invulnerable_ticks(new_count); + if tick_count % 10 == 0 { + self.mob_entity.living_entity.heal(10.0); + } + } else { + let living = &self.mob_entity.living_entity; + let max_health = living.get_max_health(); + let health = living.health.load(); + let progress = if max_health > 0.0 { + (health / max_health).clamp(0.0, 1.0) + } else { + 0.0 + }; + self.update_bossbar(&world, progress).await; + + if tick_count % 20 == 0 { + living.heal(1.0); + } + + // AI step - movement towards main target + let mut delta_movement = entity.velocity.load().multiply(1.0, 0.6, 1.0); + let target_opt = self.mob_entity.get_target().await; + + if let Some(ref target) = target_opt { + if target.get_entity().is_alive() { + let target_pos = target.get_entity().pos.load(); + let wither_pos = entity.pos.load(); + let mut yd = delta_movement.y; + + if wither_pos.y < target_pos.y + || (!self.is_powered() && wither_pos.y < target_pos.y + 5.0) + { + yd = yd.max(0.0); + yd += 0.3 - yd * 0.6; + } + + delta_movement.y = yd; + let delta = Vector3::new( + target_pos.x - wither_pos.x, + 0.0, + target_pos.z - wither_pos.z, + ); + + if delta.horizontal_length_squared() > 9.0 { + let scale = delta.normalize(); + delta_movement.x += scale.x * 0.3 - delta_movement.x * 0.6; + delta_movement.z += scale.z * 0.3 - delta_movement.z * 0.6; + } + + self.set_alternative_target(0, target.get_entity().entity_id); + + // Main head attack + let dist_sq = (wither_pos - target_pos).length_squared(); + if dist_sq <= 400.0 { + let attack_timer = self.main_attack_timer.load(Ordering::Relaxed); + if attack_timer <= 0 { + self.main_attack_timer.store(40, Ordering::Relaxed); + let dangerous = rand::random_range(0.0..1.0) < 0.001; + let eye_h = target.get_entity().get_eye_height(); + self.perform_ranged_attack( + 0, + target_pos.x, + target_pos.y + eye_h * 0.5, + target_pos.z, + dangerous, + ) + .await; + } else { + self.main_attack_timer + .store(attack_timer - 1, Ordering::Relaxed); + } + } + } else { + self.set_alternative_target(0, 0); + self.mob_entity.set_target(None).await; + } + } else { + self.set_alternative_target(0, 0); + } + + entity.velocity.store(delta_movement); + if delta_movement.horizontal_length_squared() > 0.05 { + let yaw = (delta_movement.z.atan2(delta_movement.x).to_degrees() as f32) - 90.0; + entity.set_rotation(yaw, entity.pitch.load()); + } + + // Side heads attack logic + let difficulty = world.level_info.load().difficulty; + let wither_pos = entity.pos.load(); + + for i in 1..=2 { + let next_update = self.next_head_update[i - 1].load(Ordering::Relaxed); + if tick_count >= next_update { + let rand_delay = rand::random_range(0..10); + self.next_head_update[i - 1] + .store(tick_count + 10 + rand_delay, Ordering::Relaxed); + + if (difficulty == Difficulty::Normal || difficulty == Difficulty::Hard) + && self.idle_head_updates[i - 1].fetch_add(1, Ordering::Relaxed) > 15 + { + let (xt, yt, zt) = ( + wither_pos.x + rand::random_range(-10.0..10.0), + wither_pos.y + rand::random_range(-5.0..5.0), + wither_pos.z + rand::random_range(-10.0..10.0), + ); + self.perform_ranged_attack(i, xt, yt, zt, true).await; + self.idle_head_updates[i - 1].store(0, Ordering::Relaxed); + } + + let head_target_id = self.get_alternative_target(i); + if head_target_id > 0 { + let head_target = world.get_entity_by_id(head_target_id); + if let Some(target) = head_target { + let t_pos = target.get_entity().pos.load(); + if target.get_entity().is_alive() + && (wither_pos - t_pos).length_squared() <= 900.0 + { + let eye_h = target.get_entity().get_eye_height(); + self.perform_ranged_attack( + i, + t_pos.x, + t_pos.y + eye_h * 0.5, + t_pos.z, + false, + ) + .await; + let next_delay = 40 + rand::random_range(0..20); + self.next_head_update[i - 1] + .store(tick_count + next_delay, Ordering::Relaxed); + self.idle_head_updates[i - 1].store(0, Ordering::Relaxed); + } else { + self.set_alternative_target(i, 0); + } + } else { + self.set_alternative_target(i, 0); + } + } else { + let search_box = entity.bounding_box.load().expand(20.0, 8.0, 20.0); + let entities = world.get_entities_at_box(&search_box); + let candidates: Vec<_> = entities + .into_iter() + .filter(|e| { + e.get_entity().entity_id != entity.entity_id + && e.get_living_entity().is_some() + && e.get_entity().is_alive() + && !e + .get_entity() + .entity_type + .has_tag(&tag::EntityType::MINECRAFT_WITHER_FRIENDS) + }) + .collect(); + + if !candidates.is_empty() { + let idx = rand::random_range(0..candidates.len()); + self.set_alternative_target( + i, + candidates[idx].get_entity().entity_id, + ); + } + } + } + } + + // Block destruction + let destroy_tick = self.destroy_blocks_tick.load(Ordering::Relaxed); + if destroy_tick > 0 { + let next_destroy = destroy_tick - 1; + self.destroy_blocks_tick + .store(next_destroy, Ordering::Relaxed); + + if next_destroy == 0 && world.level_info.load().game_rules.mob_griefing { + let bb = entity.bounding_box.load(); + let bb_width = bb.max.x - bb.min.x; + let bb_height = bb.max.y - bb.min.y; + let width = (bb_width as f32 / 2.0 + 1.0).floor() as i32; + let height = (bb_height as f32).floor() as i32; + let min_pos = entity.block_pos.load(); + let mut destroyed = false; + + for dx in -width..=width { + for dy in 0..=height { + for dz in -width..=width { + let bpos = BlockPos::new( + min_pos.0.x + dx, + min_pos.0.y + dy, + min_pos.0.z + dz, + ); + let block = world.get_block(&bpos); + if Self::can_destroy(block) { + world + .set_block_state( + &bpos, + Block::AIR.default_state.id, + BlockFlags::NOTIFY_ALL, + ) + .await; + destroyed = true; + } + } + } + } + + if destroyed && !entity.silent.load(Ordering::Relaxed) { + world.sync_world_event( + WorldEvent::SoundWitherBlockBreak, + entity.block_pos.load(), + 0, + ); + } + } + } + } + }) + } + + fn pre_damage<'a>( + &'a self, + damage_type: DamageType, + source: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, bool> { + Box::pin(async move { + if damage_type.has_tag(&tag::DamageType::MINECRAFT_WITHER_IMMUNE_TO) { + return false; + } + + if let Some(src) = source { + let src_type = src.get_entity().entity_type; + if src_type == &EntityType::WITHER { + return false; + } + if src_type.has_tag(&tag::EntityType::MINECRAFT_WITHER_FRIENDS) { + return false; + } + if self.is_powered() + && (src_type == &EntityType::ARROW + || src_type == &EntityType::SPECTRAL_ARROW + || src_type == &EntityType::WIND_CHARGE + || src_type == &EntityType::BREEZE_WIND_CHARGE) + { + return false; + } + } + + if self.get_invulnerable_ticks() > 0 + && !damage_type.has_tag(&tag::DamageType::MINECRAFT_BYPASSES_INVULNERABILITY) + { + return false; + } + + true + }) + } + + fn on_damage<'a>( + &'a self, + _damage_type: DamageType, + _source: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if self.destroy_blocks_tick.load(Ordering::Relaxed) <= 0 { + self.destroy_blocks_tick.store(20, Ordering::Relaxed); + } + + for idle in &self.idle_head_updates { + idle.fetch_add(3, Ordering::Relaxed); + } + }) + } + + fn post_tick(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + if !entity.is_alive() || self.mob_entity.living_entity.health.load() <= 0.0 { + let world = entity.world.load(); + self.remove_all_bossbar(&world).await; + if !self.dropped_loot.swap(true, Ordering::SeqCst) { + let pos = entity.block_pos.load(); + world + .drop_stack(&pos, ItemStack::new(1, &Item::NETHER_STAR)) + .await; + } + } + }) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + nbt.put_int("Invul", self.get_invulnerable_ticks()); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(invul) = nbt.get_int("Invul") { + self.set_invulnerable_ticks(invul); + } + }) + } } diff --git a/crates/pumpkin/src/entity/effect/hunger.rs b/crates/pumpkin/src/entity/effect/hunger.rs new file mode 100644 index 000000000..29968cae4 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/hunger.rs @@ -0,0 +1,29 @@ +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct HungerMobEffect; + +impl MobEffect for HungerMobEffect { + fn should_apply_effect_tick(&self, duration: i32, _amplifier: u8) -> bool { + if duration <= 0 { + return false; + } + (duration as u32).is_multiple_of(20) + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let world = living.entity.world.load(); + if let Some(entity) = world.get_entity_by_id(living.entity.entity_id) + && let Some(player) = entity.get_player() + { + let exhaustion = 0.1 * (f32::from(amplifier) + 1.0); + player.hunger_manager.add_exhaustion(exhaustion); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/infested.rs b/crates/pumpkin/src/entity/effect/infested.rs new file mode 100644 index 000000000..a5c086676 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/infested.rs @@ -0,0 +1,73 @@ +use uuid::Uuid; + +use pumpkin_data::damage::DamageType; +use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; +use crate::entity::r#type::from_type; + +pub struct InfestedMobEffect; + +impl MobEffect for InfestedMobEffect { + fn on_mob_hurt<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + _damage_amount: f32, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + // Wither, ender dragon and silverfish are immune + if living.entity.entity_type == &EntityType::WITHER + || living.entity.entity_type == &EntityType::ENDER_DRAGON + || living.entity.entity_type == &EntityType::SILVERFISH + { + return; + } + + let world = living.entity.world.load(); + + // 10% chance to spawn + if rand::random::() <= 0.1 { + let count = rand::random::() % 2 + 1; + let bbox = living.entity.bounding_box.load(); + let center = Vector3::new( + f64::midpoint(bbox.min.x, bbox.max.x), + f64::midpoint(bbox.min.y, bbox.max.y), + f64::midpoint(bbox.min.z, bbox.max.z), + ); + + let rot = living.entity.rotation(); + let vx = rot.x * 0.3; + let vy = rot.y * 0.45; + let vz = rot.z * 0.3; + + for _ in 0..count { + let random_angle = (rand::random::() - 0.5) * std::f32::consts::PI; + let cos_a = random_angle.cos(); + let sin_a = random_angle.sin(); + let rx = vx * cos_a + vz * sin_a; + let rz = -vx * sin_a + vz * cos_a; + + let silver = from_type(&EntityType::SILVERFISH, center, &world, Uuid::new_v4()); + + let entity = silver.get_entity(); + entity.set_pos(center); + entity.yaw.store(rand::random::() * 360.0); + entity.pitch.store(0.0); + entity.velocity.store(Vector3::new( + f64::from(rx), + f64::from(vy), + f64::from(rz), + )); + + world.spawn_entity(silver).await; + world.play_sound(Sound::EntitySilverfishHurt, SoundCategory::Hostile, ¢er); + } + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/mod.rs b/crates/pumpkin/src/entity/effect/mod.rs index c7a8f0c70..e3f9dc3c9 100644 --- a/crates/pumpkin/src/entity/effect/mod.rs +++ b/crates/pumpkin/src/entity/effect/mod.rs @@ -1,9 +1,103 @@ -use crate::entity::{NBTInitFuture, NBTStorage, NBTStorageInit, NbtFuture}; +pub mod hunger; +pub mod infested; +pub mod oozing; +pub mod poison; +pub mod raid_omen; +pub mod regeneration; +pub mod saturation; +pub mod weaving; +pub mod wind_charged; +pub mod wither; + +use std::future::Future; +use std::pin::Pin; + +use pumpkin_data::damage::DamageType; use pumpkin_data::effect::StatusEffect; use pumpkin_nbt::compound::NbtCompound; use pumpkin_nbt::tag::NbtTag; use tracing::warn; +use crate::entity::living::LivingEntity; +use crate::entity::{NBTInitFuture, NBTStorage, NBTStorageInit, NbtFuture}; + +pub type EffectFuture<'a, T = ()> = Pin + Send + 'a>>; + +pub trait MobEffect: Send + Sync { + /// Returns true if `apply_effect_tick` should be called for the current tick and duration. + fn should_apply_effect_tick(&self, _duration: i32, _amplifier: u8) -> bool { + false + } + + /// Applies periodic/tick-based effect logic on a living entity. + fn apply_effect_tick<'a>( + &'a self, + _living: &'a LivingEntity, + _amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move {}) + } + + /// Called when an entity carrying this effect is hurt. + fn on_mob_hurt<'a>( + &'a self, + _living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + _damage_amount: f32, + ) -> EffectFuture<'a, ()> { + Box::pin(async move {}) + } + + /// Called when an entity carrying this effect dies. + fn on_mob_death<'a>( + &'a self, + _living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + ) -> EffectFuture<'a, ()> { + Box::pin(async move {}) + } +} + +pub static REGENERATION: regeneration::RegenerationMobEffect = regeneration::RegenerationMobEffect; +pub static POISON: poison::PoisonMobEffect = poison::PoisonMobEffect; +pub static WITHER: wither::WitherMobEffect = wither::WitherMobEffect; +pub static HUNGER: hunger::HungerMobEffect = hunger::HungerMobEffect; +pub static SATURATION: saturation::SaturationMobEffect = saturation::SaturationMobEffect; +pub static RAID_OMEN: raid_omen::RaidOmenMobEffect = raid_omen::RaidOmenMobEffect; +pub static INFESTED: infested::InfestedMobEffect = infested::InfestedMobEffect; +pub static OOZING: oozing::OozingMobEffect = oozing::OozingMobEffect; +pub static WEAVING: weaving::WeavingMobEffect = weaving::WeavingMobEffect; +pub static WIND_CHARGED: wind_charged::WindChargedMobEffect = wind_charged::WindChargedMobEffect; + +#[must_use] +pub fn get_mob_effect(effect: &'static StatusEffect) -> Option<&'static dyn MobEffect> { + if effect == &StatusEffect::REGENERATION { + Some(®ENERATION) + } else if effect == &StatusEffect::POISON { + Some(&POISON) + } else if effect == &StatusEffect::WITHER { + Some(&WITHER) + } else if effect == &StatusEffect::HUNGER { + Some(&HUNGER) + } else if effect == &StatusEffect::SATURATION { + Some(&SATURATION) + } else if effect == &StatusEffect::RAID_OMEN { + Some(&RAID_OMEN) + } else if effect == &StatusEffect::INFESTED { + Some(&INFESTED) + } else if effect == &StatusEffect::OOZING { + Some(&OOZING) + } else if effect == &StatusEffect::WEAVING { + Some(&WEAVING) + } else if effect == &StatusEffect::WIND_CHARGED { + Some(&WIND_CHARGED) + } else { + None + } +} + impl NBTStorage for pumpkin_data::potion::Effect { fn write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async { diff --git a/crates/pumpkin/src/entity/effect/oozing.rs b/crates/pumpkin/src/entity/effect/oozing.rs new file mode 100644 index 000000000..2a9037dc3 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/oozing.rs @@ -0,0 +1,47 @@ +use uuid::Uuid; + +use pumpkin_data::damage::DamageType; +use pumpkin_data::entity::EntityType; +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; +use crate::entity::mob::slime::SlimeEntity; +use crate::entity::r#type::from_type; + +pub struct OozingMobEffect; + +impl MobEffect for OozingMobEffect { + fn on_mob_death<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + // Slimes are immune + if living.entity.entity_type == &EntityType::SLIME { + return; + } + + let world = living.entity.world.load(); + let pos = living.entity.pos.load(); + let spawn_pos = Vector3::new(pos.x, pos.y + 0.5, pos.z); + + // Spawns 2 slimes of size 2 (medium slimes) + for _ in 0..2 { + let entity_arc = from_type(&EntityType::SLIME, spawn_pos, &world, Uuid::new_v4()); + let entity = entity_arc.get_entity(); + entity.set_pos(spawn_pos); + entity.yaw.store(rand::random::() * 360.0); + entity.pitch.store(0.0); + + if let Some(slime) = entity_arc.cast_any().downcast_ref::() { + slime.set_size(2, true); + } + + world.spawn_entity(entity_arc).await; + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/poison.rs b/crates/pumpkin/src/entity/effect/poison.rs new file mode 100644 index 000000000..deb7da758 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/poison.rs @@ -0,0 +1,44 @@ +use pumpkin_data::damage::DamageType; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct PoisonMobEffect; + +impl MobEffect for PoisonMobEffect { + fn should_apply_effect_tick(&self, duration: i32, amplifier: u8) -> bool { + if duration <= 0 { + return false; + } + let tick_rate = 25 >> amplifier.min(4); + if tick_rate > 0 { + (duration as u32).is_multiple_of(tick_rate as u32) + } else { + true + } + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let current_health = living.health.load(); + if current_health > 1.0 + && let Some(dyn_self) = living + .entity + .world + .load() + .get_entity_by_id(living.entity.entity_id) + { + let damage_amount = (current_health - 1.0).min(1.0); + if damage_amount > 0.0 { + dyn_self + .damage(&*dyn_self, damage_amount, DamageType::MAGIC) + .await; + } + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/raid_omen.rs b/crates/pumpkin/src/entity/effect/raid_omen.rs new file mode 100644 index 000000000..598e5b6b4 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/raid_omen.rs @@ -0,0 +1,32 @@ +use crate::entity::EntityBase; +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct RaidOmenMobEffect; + +impl MobEffect for RaidOmenMobEffect { + fn should_apply_effect_tick(&self, duration: i32, _amplifier: u8) -> bool { + duration == 1 + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let world = living.entity.world.load(); + if let Some(entity) = world.get_entity_by_id(living.entity.entity_id) + && let Some(player) = entity.get_player() + && !player.is_spectator() + { + let raid_pos = player + .get_raid_omen_position() + .unwrap_or_else(|| living.entity.block_pos.load()); + let mut raids = world.raids.lock().await; + raids.create_or_extend_raid(player, raid_pos, &world); + player.clear_raid_omen_position(); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/regeneration.rs b/crates/pumpkin/src/entity/effect/regeneration.rs new file mode 100644 index 000000000..b20903704 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/regeneration.rs @@ -0,0 +1,32 @@ +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct RegenerationMobEffect; + +impl MobEffect for RegenerationMobEffect { + fn should_apply_effect_tick(&self, duration: i32, amplifier: u8) -> bool { + if duration <= 0 { + return false; + } + let tick_rate = 50 >> amplifier.min(4); + if tick_rate > 0 { + (duration as u32).is_multiple_of(tick_rate as u32) + } else { + true + } + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let current_health = living.health.load(); + let max_health = living.get_max_health(); + if current_health < max_health && current_health > 0.0 { + living.heal(1.0); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/saturation.rs b/crates/pumpkin/src/entity/effect/saturation.rs new file mode 100644 index 000000000..ca879e80b --- /dev/null +++ b/crates/pumpkin/src/entity/effect/saturation.rs @@ -0,0 +1,29 @@ +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct SaturationMobEffect; + +impl MobEffect for SaturationMobEffect { + fn should_apply_effect_tick(&self, _duration: i32, _amplifier: u8) -> bool { + true + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let world = living.entity.world.load(); + if let Some(entity) = world.get_entity_by_id(living.entity.entity_id) + && let Some(player) = entity.get_player() + { + let hunger = amplifier + 1; + player.hunger_manager.add_hunger(hunger); + player + .hunger_manager + .add_saturation(f32::from(hunger) * 2.0); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/weaving.rs b/crates/pumpkin/src/entity/effect/weaving.rs new file mode 100644 index 000000000..ff82a2292 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/weaving.rs @@ -0,0 +1,69 @@ +use std::collections::HashSet; + +use pumpkin_data::Block; +use pumpkin_data::damage::DamageType; +use pumpkin_data::world::WorldEvent; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::math::vector3::Vector3; +use pumpkin_world::world::BlockFlags; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct WeavingMobEffect; + +impl MobEffect for WeavingMobEffect { + fn on_mob_death<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let world = living.entity.world.load(); + + // Check if mob griefing is enabled or if player + let mob_griefing = world.level_info.load().game_rules.mob_griefing; + if !living.is_player() && !mob_griefing { + return; + } + + let center_pos = living.entity.block_pos.load(); + let cobweb_count = (rand::random::() % 2 + 2) as usize; // 2 to 3 cobwebs + + let mut positions_to_transform = HashSet::new(); + + // Sample up to 15 random positions in a cube of radius 1 + for _ in 0..15 { + let dx = (rand::random::() % 3) as i32 - 1; + let dy = (rand::random::() % 3) as i32 - 1; + let dz = (rand::random::() % 3) as i32 - 1; + + let target_pos = BlockPos(center_pos.0 + Vector3::new(dx, dy, dz)); + let below_pos = BlockPos(target_pos.0 + Vector3::new(0, -1, 0)); + + let target_state = world.get_block_state(&target_pos); + let below_state = world.get_block_state(&below_pos); + + if target_state.is_air() + && !below_state.is_air() + && positions_to_transform.insert(target_pos) + && positions_to_transform.len() >= cobweb_count + { + break; + } + } + + for target_pos in positions_to_transform { + world + .set_block_state( + &target_pos, + Block::COBWEB.default_state.id, + BlockFlags::NOTIFY_ALL, + ) + .await; + world.sync_world_event(WorldEvent::AnimationSpawnCobweb, target_pos, 0); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/wind_charged.rs b/crates/pumpkin/src/entity/effect/wind_charged.rs new file mode 100644 index 000000000..2e3a9e224 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/wind_charged.rs @@ -0,0 +1,44 @@ +use pumpkin_data::damage::DamageType; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; +use crate::entity::projectile::wind_charge::BREEZE_WIND_CHARGE_EXPLOSION_DAMAGE_CALCULATOR; +use crate::world::explosion::ExplosionInteraction; + +pub struct WindChargedMobEffect; + +impl MobEffect for WindChargedMobEffect { + fn on_mob_death<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + _damage_type: &'a DamageType, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let world = living.entity.world.load(); + let pos = living.entity.pos.load(); + let height = living.entity.height(); + let center = Vector3::new(pos.x, pos.y + f64::from(height) / 2.0, pos.z); + + // gustStrength = 3.0 + random * 2.0 + let gust_strength = 3.0 + rand::random::() * 2.0; + + world + .explode_with_calculator( + center, + gust_strength, + ExplosionInteraction::Trigger, + Some(BREEZE_WIND_CHARGE_EXPLOSION_DAMAGE_CALCULATOR.clone()), + ) + .await; + + world.play_sound( + Sound::EntityBreezeWindBurst, + SoundCategory::Neutral, + ¢er, + ); + }) + } +} diff --git a/crates/pumpkin/src/entity/effect/wither.rs b/crates/pumpkin/src/entity/effect/wither.rs new file mode 100644 index 000000000..e0d023c22 --- /dev/null +++ b/crates/pumpkin/src/entity/effect/wither.rs @@ -0,0 +1,37 @@ +use pumpkin_data::damage::DamageType; + +use crate::entity::effect::{EffectFuture, MobEffect}; +use crate::entity::living::LivingEntity; + +pub struct WitherMobEffect; + +impl MobEffect for WitherMobEffect { + fn should_apply_effect_tick(&self, duration: i32, amplifier: u8) -> bool { + if duration <= 0 { + return false; + } + let tick_rate = 40 >> amplifier.min(4); + if tick_rate > 0 { + (duration as u32).is_multiple_of(tick_rate as u32) + } else { + true + } + } + + fn apply_effect_tick<'a>( + &'a self, + living: &'a LivingEntity, + _amplifier: u8, + ) -> EffectFuture<'a, ()> { + Box::pin(async move { + let dyn_self = living + .entity + .world + .load() + .get_entity_by_id(living.entity.entity_id); + if let Some(dyn_self) = dyn_self { + dyn_self.damage(&*dyn_self, 1.0, DamageType::WITHER).await; + } + }) + } +} diff --git a/crates/pumpkin/src/entity/living.rs b/crates/pumpkin/src/entity/living.rs index 8722e4b57..3bdd2da7a 100644 --- a/crates/pumpkin/src/entity/living.rs +++ b/crates/pumpkin/src/entity/living.rs @@ -66,7 +66,6 @@ use pumpkin_util::text::TextComponent; use rand::RngExt; use std::sync::RwLock; use tokio::sync::Mutex; -use uuid::Uuid; /// Represents a living entity within the game world. /// @@ -1611,6 +1610,20 @@ impl LivingEntity { self.broadcast_death_message(&*dyn_self, damage_type, source, cause) .await; + // Trigger on_mob_death for active status effects + let active_effects_vec: Vec<_> = { + let effects = self.active_effects.lock().await; + effects + .values() + .map(|e| (e.effect_type, e.amplifier)) + .collect() + }; + for (effect_type, amplifier) in active_effects_vec { + if let Some(mob_effect) = crate::entity::effect::get_mob_effect(effect_type) { + mob_effect.on_mob_death(self, amplifier, &damage_type).await; + } + } + self.reset_effects_and_attributes().await; } } @@ -1790,8 +1803,10 @@ impl LivingEntity { effect.duration }; - if Self::should_apply_effect_tick(effect, tick_duration) { - effects_to_apply.push((effect.effect_type, effect.amplifier)); + if let Some(mob_effect) = crate::entity::effect::get_mob_effect(effect.effect_type) + && mob_effect.should_apply_effect_tick(tick_duration, effect.amplifier) + { + effects_to_apply.push((mob_effect, effect.amplifier)); } if effect.duration != -1 { @@ -1801,110 +1816,12 @@ impl LivingEntity { } // Call the central removal function for each expired effect - // This will now trigger your logs and absorption resets! for effect_type in effects_to_remove { self.remove_effect(effect_type).await; } - for (effect_type, amplifier) in effects_to_apply { - self.apply_effect_tick(effect_type, amplifier).await; - } - } - - /// Determines if an effect should apply its tick effect this frame - /// Based on vanilla Minecraft's effect tick frequencies - /// - /// TODO: villager, beacon, and other effects. - fn should_apply_effect_tick(effect: &pumpkin_data::potion::Effect, duration: i32) -> bool { - let effect_type = effect.effect_type; - - if effect_type == &StatusEffect::REGENERATION { - if duration <= 0 { - return false; - } - let tick_rate = 50 >> effect.amplifier.min(4); - duration % tick_rate == 0 - } else if effect_type == &StatusEffect::POISON { - if duration <= 0 { - return false; - } - let tick_rate = 25 >> effect.amplifier.min(4); - duration % tick_rate == 0 - } else if effect_type == &StatusEffect::WITHER { - if duration <= 0 { - return false; - } - let tick_rate = 40 >> effect.amplifier.min(4); - duration % tick_rate == 0 - } else if effect_type == &StatusEffect::HUNGER { - // Hunger every 20 ticks - duration % 20 == 0 - } else if effect_type == &StatusEffect::SATURATION { - // Saturation every tick - true - } else { - // Other effects that don't tick - false - } - } - - /// Applies the actual effect to the entity - /// This is called by `tick_effects` when an effect should trigger this tick - async fn apply_effect_tick(&self, effect_type: &'static StatusEffect, amplifier: u8) { - if effect_type == &StatusEffect::REGENERATION { - let current_health = self.health.load(); - let max_health = self.get_max_health(); - if current_health < max_health && current_health > 0.0 { - self.heal(1.0); - } - } else if effect_type == &StatusEffect::POISON { - let current_health = self.health.load(); - if current_health > 1.0 - && let Some(dyn_self) = self - .entity - .world - .load() - .get_entity_by_id(self.entity.entity_id) - { - let damage_amount = (current_health - 1.0).min(1.0); - if damage_amount > 0.0 { - dyn_self - .damage(&*dyn_self, damage_amount, DamageType::MAGIC) - .await; - } - } - } else if effect_type == &StatusEffect::WITHER { - let damage_amount = 1.0; - let dyn_self = self - .entity - .world - .load() - .get_entity_by_id(self.entity.entity_id); - if let Some(dyn_self) = dyn_self { - dyn_self - .damage(&*dyn_self, damage_amount, DamageType::WITHER) - .await; - } - } else if effect_type == &StatusEffect::HUNGER { - let world = self.entity.world.load(); - if let Some(entity) = world.get_entity_by_id(self.entity.entity_id) - && let Some(player) = entity.get_player() - { - // Add exhaustion to trigger hunger decrease - let exhaustion = 0.1 * (amplifier as f32 + 1.0); - player.hunger_manager.add_exhaustion(exhaustion); - } - drop(world); - } else if effect_type == &StatusEffect::SATURATION { - let world = self.entity.world.load(); - if let Some(entity) = world.get_entity_by_id(self.entity.entity_id) - && let Some(player) = entity.get_player() - { - // Add hunger and saturation - let hunger = amplifier + 1; - player.hunger_manager.add_hunger(hunger); - player.hunger_manager.add_saturation(hunger as f32 * 2.0); - } + for (mob_effect, amplifier) in effects_to_apply { + mob_effect.apply_effect_tick(self, amplifier).await; } } @@ -2146,63 +2063,6 @@ impl LivingEntity { self.dead.store(false, Relaxed); } - /// Try to spawn silverfish when this entity is infested and hurt. - async fn try_spawn_infested_silverfish(&self) { - if !self.has_effect(&StatusEffect::INFESTED).await { - return; - } - - // Wither, ender dragon and silverfish are immune - if self.entity.entity_type == &EntityType::WITHER - || self.entity.entity_type == &EntityType::ENDER_DRAGON - || self.entity.entity_type == &EntityType::SILVERFISH - { - return; - } - - let world = self.entity.world.load(); - - // 10% chance - if rand::rng().random::() <= 0.1 { - let count = rand::rng().random_range(1..3); - for _ in 0..count { - // Spawn at center of entity - let bbox = self.entity.bounding_box.load(); - let center = Vector3::new( - f64::midpoint(bbox.min.x, bbox.max.x), - f64::midpoint(bbox.min.y, bbox.max.y), - f64::midpoint(bbox.min.z, bbox.max.z), - ); - - // Random direction - let yaw_rad = self.entity.yaw.load().to_radians() as f64; - let random_angle = rand::rng().random::() * std::f64::consts::PI - - std::f64::consts::FRAC_PI_2; - let angle = yaw_rad + random_angle; - let speed = 0.3f64; - let dx = -angle.sin() * speed; - let dz = angle.cos() * speed; - let dy = 0.1f64; - - // Spawn - let silver = crate::entity::r#type::from_type( - &EntityType::SILVERFISH, - center, - &world, - Uuid::new_v4(), - ); - - silver.get_entity().set_pos(center); - silver.get_entity().velocity.store(Vector3::new(dx, dy, dz)); - - world.spawn_entity(silver).await; - - // Play sound - world.play_sound(Sound::EntitySilverfishHurt, SoundCategory::Players, ¢er); - } - } - } - pub fn is_player(&self) -> bool { let world = self.entity.world.load(); world.get_player_by_id(self.entity.entity_id).is_some() @@ -2660,8 +2520,21 @@ impl EntityBase for LivingEntity { position, )); - // Try to spawn infested silverfish - self.try_spawn_infested_silverfish().await; + // Trigger on_mob_hurt for active status effects + let active_effects_vec: Vec<_> = { + let effects = self.active_effects.lock().await; + effects + .values() + .map(|e| (e.effect_type, e.amplifier)) + .collect() + }; + for (effect_type, amplifier) in active_effects_vec { + if let Some(mob_effect) = crate::entity::effect::get_mob_effect(effect_type) { + mob_effect + .on_mob_hurt(self, amplifier, &damage_type, amount) + .await; + } + } if play_sound { world.play_sound( diff --git a/crates/pumpkin/src/entity/mob/evoker.rs b/crates/pumpkin/src/entity/mob/evoker.rs index 6ef30a143..d2dab4a18 100644 --- a/crates/pumpkin/src/entity/mob/evoker.rs +++ b/crates/pumpkin/src/entity/mob/evoker.rs @@ -16,7 +16,14 @@ use crate::entity::{ look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, + patrol::{PatrolData, PatrollingMonster}, + raider::{ + ObtainRaidLeaderBannerGoal, PathfindToRaidGoal, Raider, RaiderCelebrationGoal, + RaiderData, RaiderMoveThroughVillageGoal, + }, + }, projectile::evoker_fangs::EvokerFangsEntity, r#type::from_type, }; @@ -48,6 +55,7 @@ impl IllagerSpell { pub struct EvokerEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, spell_casting_tick_count: AtomicI32, current_spell: AtomicU8, wololo_target_id: Arc>>, @@ -58,6 +66,7 @@ impl EvokerEntity { let mob_entity = MobEntity::new(entity); let evoker = Self { mob_entity, + raider_data: RaiderData::default(), spell_casting_tick_count: AtomicI32::new(0), current_spell: AtomicU8::new(IllagerSpell::None as u8), wololo_target_id: Arc::new(Mutex::new(None)), @@ -78,9 +87,13 @@ impl EvokerEntity { goal_selector.add_goal(0, Box::new(SwimGoal::default())); goal_selector.add_goal(1, Box::new(EvokerCastingSpellGoal::new(mob_weak.clone()))); + goal_selector.add_goal(2, Box::new(ObtainRaidLeaderBannerGoal)); + goal_selector.add_goal(3, Box::new(RaiderMoveThroughVillageGoal::new(1.05))); + goal_selector.add_goal(3, Box::new(PathfindToRaidGoal::default())); goal_selector.add_goal(4, Box::new(EvokerSummonSpellGoal::new(mob_weak.clone()))); goal_selector.add_goal(5, Box::new(EvokerAttackSpellGoal::new(mob_weak.clone()))); goal_selector.add_goal(6, Box::new(EvokerWololoSpellGoal::new(mob_weak))); + goal_selector.add_goal(7, Box::new(RaiderCelebrationGoal)); goal_selector.add_goal(8, Box::new(WanderAroundGoal::new(0.6))); goal_selector.add_goal( 9, @@ -141,14 +154,24 @@ impl EvokerEntity { } impl Mob for EvokerEntity { + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { + self.write_raider_nbt(nbt); nbt.put_int("SpellTicks", self.get_spell_casting_time()); }) } fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async move { + self.read_raider_nbt(nbt); if let Some(ticks) = nbt.get_int("SpellTicks") { self.set_spell_casting_time(ticks); } @@ -170,6 +193,22 @@ impl Mob for EvokerEntity { } } +impl PatrollingMonster for EvokerEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } +} + +impl Raider for EvokerEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityEvokerCelebrate + } +} + pub struct EvokerCastingSpellGoal { evoker: Weak, } diff --git a/crates/pumpkin/src/entity/mob/illusioner.rs b/crates/pumpkin/src/entity/mob/illusioner.rs index 92b07107a..a540d49ed 100644 --- a/crates/pumpkin/src/entity/mob/illusioner.rs +++ b/crates/pumpkin/src/entity/mob/illusioner.rs @@ -1,24 +1,38 @@ use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::Sound; +use pumpkin_nbt::compound::NbtCompound; use crate::entity::{ - Entity, + Entity, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, + patrol::{LongDistancePatrolGoal, PatrolData, PatrollingMonster}, + raider::{ + HoldGroundAttackGoal, ObtainRaidLeaderBannerGoal, PathfindToRaidGoal, Raider, + RaiderCelebrationGoal, RaiderData, RaiderMoveThroughVillageGoal, + }, + }, }; pub struct IllusionerEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, } impl IllusionerEntity { + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let illusioner = Self { mob_entity }; + let illusioner = Self { + mob_entity, + raider_data: RaiderData::default(), + }; let mob_arc = Arc::new(illusioner); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -33,10 +47,16 @@ impl IllusionerEntity { .unwrap_or_else(std::sync::PoisonError::into_inner); 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))); + 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())); + goal_selector.add_goal(5, Box::new(RaiderCelebrationGoal)); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, - LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0), + LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default())); @@ -67,4 +87,40 @@ impl Mob for IllusionerEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.write_raider_nbt(nbt); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.read_raider_nbt(nbt); + }) + } +} + +impl PatrollingMonster for IllusionerEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } +} + +impl Raider for IllusionerEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityEvokerCelebrate + } } diff --git a/crates/pumpkin/src/entity/mob/mod.rs b/crates/pumpkin/src/entity/mob/mod.rs index 120b883c8..bc40dc0ce 100644 --- a/crates/pumpkin/src/entity/mob/mod.rs +++ b/crates/pumpkin/src/entity/mob/mod.rs @@ -47,10 +47,12 @@ pub mod guardian; pub mod hoglin; pub mod illusioner; pub mod magma_cube; +pub mod patrol; pub mod phantom; pub mod piglin; pub mod piglin_brute; pub mod pillager; +pub mod raider; pub mod ravager; pub mod shulker; pub mod silverfish; @@ -91,8 +93,8 @@ pub struct MobEntity { /// /// TODO: Replace with `EnvironmentAttributes::MONSTERS_BURN` lookup once the /// `EnvironmentAttributeSystem` is implemented in `pumpkin-data`. -const NIGHT_START: i64 = 12542; -const NIGHT_END: i64 = 23459; +pub(crate) const NIGHT_START: i64 = 12542; +pub(crate) const NIGHT_END: i64 = 23459; impl MobEntity { const AI_DISABLED_FLAG: u8 = 1; @@ -122,6 +124,10 @@ impl MobEntity { } } + pub fn has_position_target(&self) -> bool { + self.position_target_range.load(Relaxed) != -1 + } + pub fn is_in_position_target_range(&self) -> bool { self.is_in_position_target_range_pos(&self.living_entity.entity.block_pos.load()) } @@ -636,6 +642,14 @@ pub trait Mob: EntityBase + Send + Sync { None } + fn as_patrolling_monster(&self) -> Option<&dyn patrol::PatrollingMonster> { + None + } + + fn as_raider(&self) -> Option<&dyn raider::Raider> { + None + } + fn mob_write_nbt<'a>(&'a self, _nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { Box::pin(async {}) } @@ -1246,3 +1260,11 @@ pub trait PathAwareEntity: Mob + Send + Sync { 1.0 } } + +pub trait RangedAttackMob: Mob + Send + Sync { + fn perform_ranged_attack<'a>( + &'a self, + target: &'a Arc, + power: f32, + ) -> EntityBaseFuture<'a, ()>; +} diff --git a/crates/pumpkin/src/entity/mob/patrol.rs b/crates/pumpkin/src/entity/mob/patrol.rs new file mode 100644 index 000000000..1ad17184b --- /dev/null +++ b/crates/pumpkin/src/entity/mob/patrol.rs @@ -0,0 +1,227 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crossbeam::atomic::AtomicCell; +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::math::vector3::Vector3; + +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::mob::Mob; +use crate::entity::mob::raider::create_ominous_banner; + +pub struct PatrolData { + pub patrol_target: AtomicCell>, + pub patrol_leader: AtomicBool, + pub patrolling: AtomicBool, +} + +impl Default for PatrolData { + fn default() -> Self { + Self { + patrol_target: AtomicCell::new(None), + patrol_leader: AtomicBool::new(false), + patrolling: AtomicBool::new(false), + } + } +} + +pub trait PatrollingMonster: Mob { + fn get_patrol_data(&self) -> &PatrolData; + + fn can_be_leader(&self) -> bool { + true + } + + fn is_patrol_leader(&self) -> bool { + self.get_patrol_data().patrol_leader.load(Ordering::Relaxed) + } + + fn set_patrol_leader(&self, is_leader: bool) { + self.get_patrol_data() + .patrol_leader + .store(is_leader, Ordering::Relaxed); + self.set_patrolling(true); + } + + fn is_patrolling(&self) -> bool { + self.get_patrol_data().patrolling.load(Ordering::Relaxed) + } + + fn set_patrolling(&self, patrolling: bool) { + self.get_patrol_data() + .patrolling + .store(patrolling, Ordering::Relaxed); + } + + fn get_patrol_target(&self) -> Option { + self.get_patrol_data().patrol_target.load() + } + + fn set_patrol_target(&self, target: BlockPos) { + self.get_patrol_data().patrol_target.store(Some(target)); + self.set_patrolling(true); + } + + fn has_patrol_target(&self) -> bool { + self.get_patrol_data().patrol_target.load().is_some() + } + + fn can_join_patrol(&self) -> bool { + true + } + + fn find_patrol_target(&self) { + let block_pos = self.get_mob_entity().living_entity.entity.block_pos.load(); + let dx: i32 = rand::random::().rem_euclid(1000) - 500; + let dz: i32 = rand::random::().rem_euclid(1000) - 500; + let target = BlockPos(Vector3::new( + block_pos.0.x + dx, + block_pos.0.y, + block_pos.0.z + dz, + )); + self.set_patrol_target(target); + } + + fn finalize_patrol_spawn(&self, is_patrol_spawn: bool) { + if !is_patrol_spawn && self.can_be_leader() { + let r: f32 = rand::random(); + if r < 0.06 { + self.set_patrol_leader(true); + } + } + + if self.is_patrol_leader() { + let banner = create_ominous_banner(); + let living = &self.get_mob_entity().living_entity; + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::HEAD, banner.clone()); + drop(equipment); + living.send_equipment_changes(&[(EquipmentSlot::HEAD, banner)]); + }); + }); + } + + if is_patrol_spawn { + self.set_patrolling(true); + } + } + + fn write_patrol_nbt(&self, nbt: &mut NbtCompound) { + let data = self.get_patrol_data(); + if let Some(target) = data.patrol_target.load() { + nbt.put_int("PatrolTargetX", target.0.x); + nbt.put_int("PatrolTargetY", target.0.y); + nbt.put_int("PatrolTargetZ", target.0.z); + } + nbt.put_bool("PatrolLeader", data.patrol_leader.load(Ordering::Relaxed)); + nbt.put_bool("Patrolling", data.patrolling.load(Ordering::Relaxed)); + } + + fn read_patrol_nbt(&self, nbt: &NbtCompound) { + let data = self.get_patrol_data(); + if let (Some(x), Some(y), Some(z)) = ( + nbt.get_int("PatrolTargetX"), + nbt.get_int("PatrolTargetY"), + nbt.get_int("PatrolTargetZ"), + ) { + data.patrol_target + .store(Some(BlockPos(Vector3::new(x, y, z)))); + } + if let Some(leader) = nbt.get_bool("PatrolLeader") { + data.patrol_leader.store(leader, Ordering::Relaxed); + } + if let Some(patrolling) = nbt.get_bool("Patrolling") { + data.patrolling.store(patrolling, Ordering::Relaxed); + } + } +} + +pub struct LongDistancePatrolGoal { + speed_modifier: f64, + leader_speed_modifier: f64, + cooldown_until: i64, +} + +impl LongDistancePatrolGoal { + #[must_use] + pub const fn new(speed_modifier: f64, leader_speed_modifier: f64) -> Self { + Self { + speed_modifier, + leader_speed_modifier, + cooldown_until: -1, + } + } +} + +impl Goal for LongDistancePatrolGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(patrol) = mob.as_patrolling_monster() else { + return false; + }; + let world = mob.get_entity().world.load(); + let game_time = world.level_time.lock().await.query_daytime(); + let is_on_cooldown = game_time < self.cooldown_until; + + let target = mob.get_mob_entity().target.lock().await.clone(); + patrol.is_patrolling() + && target.is_none() + && patrol.has_patrol_target() + && !is_on_cooldown + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(patrol) = mob.as_patrolling_monster() else { + return false; + }; + let target = mob.get_mob_entity().target.lock().await.clone(); + patrol.is_patrolling() && target.is_none() && patrol.has_patrol_target() + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(patrol) = mob.as_patrolling_monster() else { + return; + }; + let is_leader = patrol.is_patrol_leader(); + let entity = mob.get_entity(); + let pos = entity.pos.load(); + + let Some(patrol_target) = patrol.get_patrol_target() else { + return; + }; + + let dist_sq = pos.squared_distance_to_vec(&patrol_target.to_f64()); + if is_leader && dist_sq < 100.0 { + patrol.find_patrol_target(); + } else { + let speed = if is_leader { + self.leader_speed_modifier + } else { + self.speed_modifier + }; + let mut nav = mob + .get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + nav.set_progress(NavigatorGoal { + current_progress: pos, + destination: patrol_target.to_f64(), + speed, + }); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/mob/pillager.rs b/crates/pumpkin/src/entity/mob/pillager.rs index 599f87e00..506f25b0d 100644 --- a/crates/pumpkin/src/entity/mob/pillager.rs +++ b/crates/pumpkin/src/entity/mob/pillager.rs @@ -1,26 +1,39 @@ use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::Sound; +use pumpkin_nbt::compound::NbtCompound; use crate::entity::{ - Entity, + Entity, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, + patrol::{LongDistancePatrolGoal, PatrolData, PatrollingMonster}, + raider::{ + HoldGroundAttackGoal, ObtainRaidLeaderBannerGoal, PathfindToRaidGoal, Raider, + RaiderCelebrationGoal, RaiderData, RaiderMoveThroughVillageGoal, + }, + }, }; pub struct PillagerEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, } impl PillagerEntity { + #[must_use] pub fn new(entity: Entity) -> Arc { - let mob_entity = MobEntity::new(entity); - let pillager = Self { mob_entity }; - let mob_arc = Arc::new(pillager); + let mob_arc = Arc::new(Self { + mob_entity: MobEntity::new(entity), + raider_data: RaiderData::default(), + }); + let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) @@ -34,12 +47,18 @@ impl PillagerEntity { .unwrap_or_else(std::sync::PoisonError::into_inner); 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(2, Box::new(MeleeAttackGoal::new(1.0, true))); + goal_selector.add_goal(3, Box::new(MeleeAttackGoal::new(1.0, true))); + 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())); + goal_selector.add_goal(5, Box::new(RaiderCelebrationGoal)); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, - LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0), + LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default())); @@ -70,4 +89,40 @@ impl Mob for PillagerEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.write_raider_nbt(nbt); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.read_raider_nbt(nbt); + }) + } +} + +impl PatrollingMonster for PillagerEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } +} + +impl Raider for PillagerEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityPillagerCelebrate + } } diff --git a/crates/pumpkin/src/entity/mob/raider.rs b/crates/pumpkin/src/entity/mob/raider.rs new file mode 100644 index 000000000..910ef4ae4 --- /dev/null +++ b/crates/pumpkin/src/entity/mob/raider.rs @@ -0,0 +1,418 @@ +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; + +use crossbeam::atomic::AtomicCell; +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; +use pumpkin_data::tracked_data; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::java::client::play::Metadata; +use pumpkin_util::math::vector3::Vector3; +use pumpkin_util::text::TextComponent; + +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::mob::Mob; +use crate::entity::mob::patrol::{PatrolData, PatrollingMonster}; + +#[must_use] +pub fn create_ominous_banner() -> ItemStack { + use pumpkin_data::data_component::DataComponent; + use pumpkin_data::data_component_impl::{CustomNameImpl, DataComponentImpl}; + let mut stack = ItemStack::new(1, &Item::WHITE_BANNER); + stack.patch.push(( + DataComponent::CustomName, + Some( + CustomNameImpl { + name: TextComponent::translate("block.minecraft.ominous_banner", []), + } + .to_dyn(), + ), + )); + stack +} + +#[must_use] +pub const fn is_ominous_banner(stack: &ItemStack) -> bool { + stack.item.id == Item::WHITE_BANNER.id +} + +pub struct RaiderData { + pub patrol_data: PatrolData, + pub wave: AtomicI32, + pub can_join_raid: AtomicBool, + pub ticks_outside_raid: AtomicI32, + pub is_celebrating: AtomicBool, + pub raid_id: AtomicCell>, +} + +impl Default for RaiderData { + fn default() -> Self { + Self { + patrol_data: PatrolData::default(), + wave: AtomicI32::new(0), + can_join_raid: AtomicBool::new(false), + ticks_outside_raid: AtomicI32::new(0), + is_celebrating: AtomicBool::new(false), + raid_id: AtomicCell::new(None), + } + } +} + +pub trait Raider: PatrollingMonster { + fn get_raider_data(&self) -> &RaiderData; + + fn can_join_raid(&self) -> bool { + self.get_raider_data().can_join_raid.load(Ordering::Relaxed) + } + + fn set_can_join_raid(&self, can_join: bool) { + self.get_raider_data() + .can_join_raid + .store(can_join, Ordering::Relaxed); + } + + fn get_wave(&self) -> i32 { + self.get_raider_data().wave.load(Ordering::Relaxed) + } + + fn set_wave(&self, wave: i32) { + self.get_raider_data().wave.store(wave, Ordering::Relaxed); + } + + fn is_celebrating(&self) -> bool { + self.get_raider_data() + .is_celebrating + .load(Ordering::Relaxed) + } + + fn set_celebrating(&self, celebrating: bool) { + self.get_raider_data() + .is_celebrating + .store(celebrating, Ordering::Relaxed); + let entity = &self.get_mob_entity().living_entity.entity; + entity.send_meta_data( + &[Metadata::new( + tracked_data::pillager::IS_CELEBRATING, + celebrating, + )], + None, + ); + } + + fn has_active_raid(&self) -> bool { + self.get_raider_data().raid_id.load().is_some() + } + + fn is_captain(&self) -> bool { + self.is_patrol_leader() + } + + fn get_celebrate_sound(&self) -> Sound; + + fn apply_raid_buffs(&self, _wave: i32, _is_captain: bool) {} + + fn write_raider_nbt(&self, nbt: &mut NbtCompound) { + self.write_patrol_nbt(nbt); + let data = self.get_raider_data(); + nbt.put_int("Wave", data.wave.load(Ordering::Relaxed)); + nbt.put_bool("CanJoinRaid", data.can_join_raid.load(Ordering::Relaxed)); + if let Some(raid_id) = data.raid_id.load() { + nbt.put_int("RaidId", raid_id); + } + } + + fn read_raider_nbt(&self, nbt: &NbtCompound) { + self.read_patrol_nbt(nbt); + let data = self.get_raider_data(); + if let Some(wave) = nbt.get_int("Wave") { + data.wave.store(wave, Ordering::Relaxed); + } + if let Some(can_join) = nbt.get_bool("CanJoinRaid") { + data.can_join_raid.store(can_join, Ordering::Relaxed); + } + if let Some(raid_id) = nbt.get_int("RaidId") { + data.raid_id.store(Some(raid_id)); + } + } +} + +/// Goal for raiders holding ground during patrols when spotting distant targets, alerting nearby raiders. +pub struct HoldGroundAttackGoal { + hostile_radius_sqr: f64, +} + +impl HoldGroundAttackGoal { + #[must_use] + pub fn new(hostile_radius: f32) -> Self { + Self { + hostile_radius_sqr: f64::from(hostile_radius * hostile_radius), + } + } +} + +impl Goal for HoldGroundAttackGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + if raider.has_active_raid() || !raider.is_patrolling() { + return false; + } + let target = mob.get_mob_entity().target.lock().await.clone(); + target.is_some() + }) + } + + 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(); + target.is_some() + }) + } + + fn start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + mob.get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stop(); + + let target = mob.get_mob_entity().target.lock().await.clone(); + if let Some(target) = target { + let entity = mob.get_entity(); + let world = entity.world.load(); + let bb = entity.bounding_box.load().expand(8.0, 8.0, 8.0); + let nearby = world.get_entities_at_box(&bb); + + for cand in nearby { + if cand.get_entity().entity_id != entity.entity_id + && let Some(cand_mob) = cand.get_mob() + && cand_mob.as_raider().is_some() + { + *cand_mob.get_mob_entity().target.lock().await = Some(target.clone()); + } + } + } + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE | Controls::LOOK + } + + 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(); + if let Some(target) = target { + let mob_pos = mob.get_entity().pos.load(); + let target_pos = target.get_entity().pos.load(); + let dist_sq = mob_pos.squared_distance_to_vec(&target_pos); + + if dist_sq > self.hostile_radius_sqr { + 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); + } + } + }) + } +} + +/// Goal for raiders to pick up dropped ominous banners and become raid / patrol leaders. +pub struct ObtainRaidLeaderBannerGoal; + +impl Goal for ObtainRaidLeaderBannerGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + if !raider.can_be_leader() || raider.is_patrol_leader() { + return false; + } + // Check if dropped banner nearby + let entity = mob.get_entity(); + let world = entity.world.load(); + let bb = entity.bounding_box.load().expand(16.0, 4.0, 16.0); + let nearby = world.get_entities_at_box(&bb); + + nearby + .iter() + .any(|e| *e.get_entity().entity_type == EntityType::ITEM) + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return; + }; + if !raider.can_be_leader() || raider.is_patrol_leader() { + return; + } + + let entity = mob.get_entity(); + let pos = entity.pos.load(); + let world = entity.world.load(); + let bb = entity.bounding_box.load().expand(16.0, 4.0, 16.0); + let nearby = world.get_entities_at_box(&bb); + + for cand in nearby { + if *cand.get_entity().entity_type == EntityType::ITEM { + let cand_pos = cand.get_entity().pos.load(); + let dist = pos.squared_distance_to_vec(&cand_pos); + if dist < 2.0 { + raider.set_patrol_leader(true); + let banner = create_ominous_banner(); + let living = &mob.get_mob_entity().living_entity; + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::HEAD, banner.clone()); + drop(equipment); + living.send_equipment_changes(&[(EquipmentSlot::HEAD, banner)]); + cand.get_entity().remove().await; + break; + } + let mut nav = mob + .get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + nav.set_progress(NavigatorGoal { + current_progress: pos, + destination: cand_pos, + speed: 1.15, + }); + break; + } + } + }) + } +} + +/// Goal for raiders celebrating victory. +pub struct RaiderCelebrationGoal; + +impl Goal for RaiderCelebrationGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + let target = mob.get_mob_entity().target.lock().await.clone(); + target.is_none() && raider.is_celebrating() + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + let target = mob.get_mob_entity().target.lock().await.clone(); + target.is_none() && raider.is_celebrating() + }) + } + + fn start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(raider) = mob.as_raider() { + raider.set_celebrating(true); + } + }) + } + + fn stop<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(raider) = mob.as_raider() { + raider.set_celebrating(false); + } + }) + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return; + }; + let entity = mob.get_entity(); + let pos = entity.pos.load(); + let world = entity.world.load(); + + let r: f32 = rand::random(); + if r < 0.02 && !entity.silent.load(Ordering::Relaxed) { + world.play_sound( + raider.get_celebrate_sound(), + pumpkin_data::sound::SoundCategory::Hostile, + &pos, + ); + } + }) + } +} + +/// Goal for moving through village homes during active raids. +pub struct RaiderMoveThroughVillageGoal { + speed_modifier: f64, +} + +impl RaiderMoveThroughVillageGoal { + #[must_use] + pub const fn new(speed_modifier: f64) -> Self { + Self { speed_modifier } + } +} + +impl Goal for RaiderMoveThroughVillageGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(raider) = mob.as_raider() else { + return false; + }; + if !raider.has_active_raid() { + return false; + } + let target = mob.get_mob_entity().target.lock().await.clone(); + target.is_none() + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let entity = mob.get_entity(); + let pos = entity.pos.load(); + let mut nav = mob + .get_mob_entity() + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if nav.is_idle() { + let dx: f64 = (rand::random::() - 0.5) * 32.0; + let dz: f64 = (rand::random::() - 0.5) * 32.0; + let dest = Vector3::new(pos.x + dx, pos.y, pos.z + dz); + nav.set_progress(NavigatorGoal { + current_progress: pos, + destination: dest, + speed: self.speed_modifier, + }); + } + }) + } +} + +pub use crate::entity::ai::goal::pathfind_to_raid::PathfindToRaidGoal; diff --git a/crates/pumpkin/src/entity/mob/ravager.rs b/crates/pumpkin/src/entity/mob/ravager.rs index 3c8d7cd36..eaab3fcfa 100644 --- a/crates/pumpkin/src/entity/mob/ravager.rs +++ b/crates/pumpkin/src/entity/mob/ravager.rs @@ -1,25 +1,39 @@ use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::Sound; +use pumpkin_nbt::compound::NbtCompound; use crate::entity::{ - Entity, + Entity, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, + patrol::{LongDistancePatrolGoal, PatrolData, PatrollingMonster}, + raider::{ + HoldGroundAttackGoal, PathfindToRaidGoal, Raider, RaiderCelebrationGoal, RaiderData, + RaiderMoveThroughVillageGoal, + }, + }, }; pub struct RavagerEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, } impl RavagerEntity { + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let ravager = Self { mob_entity }; + let ravager = Self { + mob_entity, + raider_data: RaiderData::default(), + }; let mob_arc = Arc::new(ravager); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -34,11 +48,16 @@ impl RavagerEntity { .unwrap_or_else(std::sync::PoisonError::into_inner); goal_selector.add_goal(0, Box::new(SwimGoal::default())); + goal_selector.add_goal(2, Box::new(HoldGroundAttackGoal::new(10.0))); goal_selector.add_goal(4, Box::new(MeleeAttackGoal::new(1.0, true))); + 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())); + goal_selector.add_goal(5, Box::new(RaiderCelebrationGoal)); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, - LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0), + LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default())); @@ -69,4 +88,44 @@ impl Mob for RavagerEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.write_raider_nbt(nbt); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.read_raider_nbt(nbt); + }) + } +} + +impl PatrollingMonster for RavagerEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } + + fn can_be_leader(&self) -> bool { + false + } +} + +impl Raider for RavagerEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityRavagerCelebrate + } } diff --git a/crates/pumpkin/src/entity/mob/vindicator.rs b/crates/pumpkin/src/entity/mob/vindicator.rs index 5da745857..f3024c847 100644 --- a/crates/pumpkin/src/entity/mob/vindicator.rs +++ b/crates/pumpkin/src/entity/mob/vindicator.rs @@ -1,25 +1,39 @@ use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::Sound; +use pumpkin_nbt::compound::NbtCompound; use crate::entity::{ - Entity, + Entity, NbtFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, melee_attack::MeleeAttackGoal, swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, + patrol::{LongDistancePatrolGoal, PatrolData, PatrollingMonster}, + raider::{ + HoldGroundAttackGoal, ObtainRaidLeaderBannerGoal, PathfindToRaidGoal, Raider, + RaiderCelebrationGoal, RaiderData, RaiderMoveThroughVillageGoal, + }, + }, }; pub struct VindicatorEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, } impl VindicatorEntity { + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let vindicator = Self { mob_entity }; + let vindicator = Self { + mob_entity, + raider_data: RaiderData::default(), + }; let mob_arc = Arc::new(vindicator); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); @@ -34,11 +48,17 @@ impl VindicatorEntity { .unwrap_or_else(std::sync::PoisonError::into_inner); goal_selector.add_goal(0, Box::new(SwimGoal::default())); - goal_selector.add_goal(2, Box::new(MeleeAttackGoal::new(1.0, true))); + goal_selector.add_goal(1, Box::new(ObtainRaidLeaderBannerGoal)); + goal_selector.add_goal(2, Box::new(HoldGroundAttackGoal::new(10.0))); + goal_selector.add_goal(3, Box::new(MeleeAttackGoal::new(1.0, true))); + 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())); + goal_selector.add_goal(5, Box::new(RaiderCelebrationGoal)); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, - LookAtEntityGoal::with_default(mob_weak.clone(), &EntityType::PLAYER, 8.0), + LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); goal_selector.add_goal(7, Box::new(RandomLookAroundGoal::default())); @@ -69,4 +89,40 @@ impl Mob for VindicatorEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.write_raider_nbt(nbt); + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + self.read_raider_nbt(nbt); + }) + } +} + +impl PatrollingMonster for VindicatorEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } +} + +impl Raider for VindicatorEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityVindicatorCelebrate + } } diff --git a/crates/pumpkin/src/entity/mob/witch.rs b/crates/pumpkin/src/entity/mob/witch.rs index 7b12867db..b35bb9f7c 100644 --- a/crates/pumpkin/src/entity/mob/witch.rs +++ b/crates/pumpkin/src/entity/mob/witch.rs @@ -1,30 +1,83 @@ +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, Weak}; +use pumpkin_data::damage::DamageType; +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::effect::StatusEffect; use pumpkin_data::entity::EntityType; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::potion::Potion; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::tracked_data; +use pumpkin_protocol::java::client::play::Metadata; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, - look_at_entity::LookAtEntityGoal, revenge::RevengeGoal, swim::SwimGoal, - wander_around::WanderAroundGoal, + look_at_entity::LookAtEntityGoal, ranged_attack::RangedAttackGoal, revenge::RevengeGoal, + swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{ + Mob, MobEntity, RangedAttackMob, + patrol::{PatrolData, PatrollingMonster}, + raider::{ + PathfindToRaidGoal, Raider, RaiderCelebrationGoal, RaiderData, + RaiderMoveThroughVillageGoal, + }, + }, + projectile::splash_potion::SplashPotionEntity, }; +fn create_potion_stack(item: &'static Item, potion: &'static Potion) -> ItemStack { + use pumpkin_data::data_component::DataComponent; + use pumpkin_data::data_component_impl::{DataComponentImpl, PotionContentsImpl}; + let mut stack = ItemStack::new(1, item); + stack.patch.push(( + DataComponent::PotionContents, + Some( + PotionContentsImpl { + potion_id: Some(i32::from(potion.id)), + custom_color: None, + custom_effects: Vec::new(), + custom_name: None, + } + .to_dyn(), + ), + )); + stack +} + +/// Represents a Witch, a hostile ranged mob that throws splash potions and drinks restorative potions. +/// +/// Wiki: pub struct WitchEntity { pub mob_entity: MobEntity, + pub raider_data: RaiderData, + drinking_potion: AtomicBool, + using_time: AtomicI32, } impl WitchEntity { + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let witch = Self { mob_entity }; + let witch = Self { + mob_entity, + raider_data: RaiderData::default(), + drinking_potion: AtomicBool::new(false), + using_time: AtomicI32::new(0), + }; let mob_arc = Arc::new(witch); let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) }; + let ranged_weak: Weak = { + let ranged_arc: Arc = mob_arc.clone(); + Arc::downgrade(&ranged_arc) + }; { let mut goal_selector = mob_arc @@ -39,13 +92,19 @@ impl WitchEntity { .unwrap_or_else(std::sync::PoisonError::into_inner); goal_selector.add_goal(1, Box::new(SwimGoal::default())); - // TODO: WitchAttackGoal (potions) - goal_selector.add_goal(2, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( - 3, + 2, + Box::new(RangedAttackGoal::new(ranged_weak, 1.0, 60, 10.0)), + ); + goal_selector.add_goal(3, Box::new(RaiderMoveThroughVillageGoal::new(1.05))); + goal_selector.add_goal(3, Box::new(PathfindToRaidGoal::default())); + goal_selector.add_goal(4, Box::new(RaiderCelebrationGoal)); + goal_selector.add_goal(4, Box::new(WanderAroundGoal::new(1.0))); + goal_selector.add_goal( + 5, LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); - goal_selector.add_goal(3, Box::new(RandomLookAroundGoal::default())); + goal_selector.add_goal(6, Box::new(RandomLookAroundGoal::default())); target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); target_selector.add_goal( @@ -56,10 +115,239 @@ impl WitchEntity { mob_arc } + + pub fn set_drinking_potion(&self, drinking: bool) { + self.drinking_potion.store(drinking, Ordering::Relaxed); + self.mob_entity.living_entity.entity.send_meta_data( + &[Metadata::new( + tracked_data::witch::DATA_USING_ITEM, + drinking, + )], + None, + ); + } + + #[must_use] + pub fn is_drinking_potion(&self) -> bool { + self.drinking_potion.load(Ordering::Relaxed) + } + + pub async fn throw_potion(&self, target: &Arc) { + if self.is_drinking_potion() { + return; + } + + let entity = &self.mob_entity.living_entity.entity; + let world = entity.world.load(); + + let target_entity = target.get_entity(); + let target_pos = target_entity.pos.load(); + let target_vel = target_entity.velocity.load(); + let witch_pos = entity.pos.load(); + + let xd = target_pos.x + target_vel.x - witch_pos.x; + let yd = target_pos.y + target_entity.get_eye_height() - 1.1 - witch_pos.y; + let zd = target_pos.z + target_vel.z - witch_pos.z; + let dist = xd.hypot(zd); + + let mut potion = &Potion::HARMING; + + if let Some(target_living) = target.get_living_entity() { + let r: f32 = rand::random(); + if dist >= 8.0 && !target_living.has_effect(&StatusEffect::SLOWNESS).await { + potion = &Potion::SLOWNESS; + } else if target_living.health.load() >= 8.0 + && !target_living.has_effect(&StatusEffect::POISON).await + { + potion = &Potion::POISON; + } else if dist <= 3.0 + && !target_living.has_effect(&StatusEffect::WEAKNESS).await + && r < 0.25 + { + potion = &Potion::WEAKNESS; + } + } + + let potion_stack = create_potion_stack(&Item::SPLASH_POTION, potion); + + let splash_entity = Entity::new(world.clone(), witch_pos, &EntityType::SPLASH_POTION); + let splash = SplashPotionEntity::new_shot(splash_entity, entity); + splash.set_item_stack(potion_stack).await; + + let speed = if dist <= 2.0 { 0.45 } else { 0.75 }; + let yo = dist * 0.2; + + splash.thrown.set_velocity(xd, yd + yo, zd, speed, 8.0); + + if !entity.silent.load(Ordering::Relaxed) { + world.play_sound(Sound::EntityWitchThrow, SoundCategory::Hostile, &witch_pos); + } + + let splash_arc: Arc = Arc::new(splash); + world.spawn_entity(splash_arc).await; + } } impl Mob for WitchEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn as_patrolling_monster(&self) -> Option<&dyn PatrollingMonster> { + Some(self) + } + + fn as_raider(&self) -> Option<&dyn Raider> { + Some(self) + } + + fn mob_write_nbt<'a>( + &'a self, + nbt: &'a mut pumpkin_nbt::compound::NbtCompound, + ) -> crate::entity::NbtFuture<'a, ()> { + Box::pin(async move { + self.write_raider_nbt(nbt); + }) + } + + fn mob_read_nbt<'a>( + &'a self, + nbt: &'a pumpkin_nbt::compound::NbtCompound, + ) -> crate::entity::NbtFuture<'a, ()> { + Box::pin(async move { + self.read_raider_nbt(nbt); + }) + } + + fn pre_damage<'a>( + &'a self, + _damage_type: DamageType, + source: Option<&'a dyn EntityBase>, + ) -> EntityBaseFuture<'a, bool> { + Box::pin(async move { + if let Some(src) = source + && src.get_entity().entity_id == self.mob_entity.living_entity.entity.entity_id + { + return false; + } + true + }) + } + + fn modify_incoming_damage(&self, mut amount: f32, damage_type: DamageType) -> f32 { + if damage_type == DamageType::MAGIC + || damage_type == DamageType::INDIRECT_MAGIC + || damage_type == DamageType::THORNS + || damage_type == DamageType::WITHER + { + amount *= 0.15; + } + amount + } + + fn mob_tick<'a>(&'a self, _caller: &'a Arc) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + let entity = &self.mob_entity.living_entity.entity; + let living = &self.mob_entity.living_entity; + let world = entity.world.load(); + + if self.is_drinking_potion() { + let remaining = self.using_time.fetch_sub(1, Ordering::Relaxed) - 1; + if remaining <= 0 { + self.set_drinking_potion(false); + let mut equipment = living.entity_equipment.lock().await; + let stack = equipment.get(&EquipmentSlot::MAIN_HAND); + equipment.put(&EquipmentSlot::MAIN_HAND, ItemStack::EMPTY.clone()); + drop(equipment); + living.send_equipment_changes(&[( + EquipmentSlot::MAIN_HAND, + ItemStack::EMPTY.clone(), + )]); + + let effects = crate::item::potion::PotionContents::read_potion_effects(&stack); + crate::item::potion::PotionContents::apply_effects_to( + living, + effects, + 1.0, + crate::item::potion::PotionApplicationSource::Normal, + ) + .await; + } + } else { + let mut potion: Option<&'static Potion> = None; + let r: f32 = rand::random(); + + if r < 0.15 + && entity.touching_water.load(Ordering::Relaxed) + && !living.has_effect(&StatusEffect::WATER_BREATHING).await + { + potion = Some(&Potion::WATER_BREATHING); + } else if r < 0.15 + && entity.fire_ticks.load(Ordering::Relaxed) > 0 + && !living.has_effect(&StatusEffect::FIRE_RESISTANCE).await + { + potion = Some(&Potion::FIRE_RESISTANCE); + } else if r < 0.05 && living.health.load() < living.get_max_health() { + potion = Some(&Potion::HEALING); + } else if r < 0.5 + && let Some(target) = self.mob_entity.target.lock().await.as_ref() + && !living.has_effect(&StatusEffect::SPEED).await + { + let target_pos = target.get_entity().pos.load(); + let self_pos = entity.pos.load(); + if self_pos.squared_distance_to_vec(&target_pos) > 121.0 { + potion = Some(&Potion::SWIFTNESS); + } + } + + if let Some(potion) = potion { + let stack = create_potion_stack(&Item::POTION, potion); + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::MAIN_HAND, stack.clone()); + drop(equipment); + living.send_equipment_changes(&[(EquipmentSlot::MAIN_HAND, stack)]); + + self.using_time.store(32, Ordering::Relaxed); + self.set_drinking_potion(true); + + if !entity.silent.load(Ordering::Relaxed) { + let pos = entity.pos.load(); + world.play_sound(Sound::EntityWitchDrink, SoundCategory::Hostile, &pos); + } + } + } + }) + } +} + +impl RangedAttackMob for WitchEntity { + fn perform_ranged_attack<'a>( + &'a self, + target: &'a Arc, + _power: f32, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.throw_potion(target).await; + }) + } +} + +impl PatrollingMonster for WitchEntity { + fn get_patrol_data(&self) -> &PatrolData { + &self.raider_data.patrol_data + } + + fn can_be_leader(&self) -> bool { + false + } +} + +impl Raider for WitchEntity { + fn get_raider_data(&self) -> &RaiderData { + &self.raider_data + } + + fn get_celebrate_sound(&self) -> Sound { + Sound::EntityWitchCelebrate + } } diff --git a/crates/pumpkin/src/entity/passive/llama.rs b/crates/pumpkin/src/entity/passive/llama.rs index e7523728b..857583f3d 100644 --- a/crates/pumpkin/src/entity/passive/llama.rs +++ b/crates/pumpkin/src/entity/passive/llama.rs @@ -1,14 +1,18 @@ +use std::sync::atomic::Ordering; use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, ai::goal::{ - look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal, - wander_around::WanderAroundGoal, + active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, + look_at_entity::LookAtEntityGoal, ranged_attack::RangedAttackGoal, revenge::RevengeGoal, + swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{Mob, MobEntity, RangedAttackMob}, + projectile::llama_spit::LlamaSpitEntity, }; /// Represents a Llama, a neutral mob that can be used for carrying items and spits at enemies. @@ -27,6 +31,10 @@ impl LlamaEntity { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) }; + let ranged_weak: Weak = { + let ranged_arc: Arc = mob_arc.clone(); + Arc::downgrade(&ranged_arc) + }; { let mut goal_selector = mob_arc @@ -34,18 +42,61 @@ impl LlamaEntity { .goals_selector .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut target_selector = mob_arc + .mob_entity + .target_selector + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); goal_selector.add_goal(0, Box::new(SwimGoal::default())); - goal_selector.add_goal(1, Box::new(WanderAroundGoal::new(0.7))); goal_selector.add_goal( - 2, + 3, + Box::new(RangedAttackGoal::new(ranged_weak, 1.25, 40, 20.0)), + ); + goal_selector.add_goal(7, Box::new(WanderAroundGoal::new(0.7))); + goal_selector.add_goal( + 8, LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 6.0), ); - goal_selector.add_goal(3, Box::new(RandomLookAroundGoal::default())); + goal_selector.add_goal(9, Box::new(RandomLookAroundGoal::default())); + + target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); + target_selector.add_goal( + 2, + ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::WOLF, true), + ); }; mob_arc } + + pub async fn spit(&self, target: &Arc) { + let entity = self.get_entity(); + let world = entity.world.load(); + + let spit_entity = Entity::new(world.clone(), entity.pos.load(), &EntityType::LLAMA_SPIT); + let spit = LlamaSpitEntity::new_shot(spit_entity, entity); + + let mob_pos = entity.pos.load(); + let target_entity = target.get_entity(); + let target_pos = target_entity.pos.load(); + let target_height = f64::from(target_entity.entity_dimension.load().height); + + let dx = target_pos.x - mob_pos.x; + let dy = (target_pos.y + target_height / 3.0) - spit.get_entity().pos.load().y; + let dz = target_pos.z - mob_pos.z; + let horizontal_distance = dx.hypot(dz); + let yo = horizontal_distance * 0.2; + + spit.thrown.set_velocity(dx, dy + yo, dz, 1.5, 10.0); + + if !entity.silent.load(Ordering::Relaxed) { + world.play_sound(Sound::EntityLlamaSpit, SoundCategory::Neutral, &mob_pos); + } + + let spit_arc: Arc = Arc::new(spit); + world.spawn_entity(spit_arc).await; + } } impl Mob for LlamaEntity { @@ -53,3 +104,15 @@ impl Mob for LlamaEntity { &self.mob_entity } } + +impl RangedAttackMob for LlamaEntity { + fn perform_ranged_attack<'a>( + &'a self, + target: &'a Arc, + _power: f32, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.spit(target).await; + }) + } +} diff --git a/crates/pumpkin/src/entity/passive/snow_golem.rs b/crates/pumpkin/src/entity/passive/snow_golem.rs index 87dedc703..7fec37b27 100644 --- a/crates/pumpkin/src/entity/passive/snow_golem.rs +++ b/crates/pumpkin/src/entity/passive/snow_golem.rs @@ -1,14 +1,18 @@ +use std::sync::atomic::Ordering; use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, ai::goal::{ active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, - look_at_entity::LookAtEntityGoal, wander_around::WanderAroundGoal, + look_at_entity::LookAtEntityGoal, ranged_attack::RangedAttackGoal, + wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{Mob, MobEntity, RangedAttackMob}, + projectile::snowball::SnowballEntity, }; pub struct SnowGolemEntity { @@ -24,6 +28,10 @@ impl SnowGolemEntity { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) }; + let ranged_weak: Weak = { + let ranged_arc: Arc = mob_arc.clone(); + Arc::downgrade(&ranged_arc) + }; { let mut goal_selector = mob_arc @@ -37,7 +45,10 @@ impl SnowGolemEntity { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - // TODO: SnowballAttackGoal + goal_selector.add_goal( + 1, + Box::new(RangedAttackGoal::new(ranged_weak, 1.25, 20, 10.0)), + ); goal_selector.add_goal(5, Box::new(WanderAroundGoal::new(1.0))); goal_selector.add_goal( 6, @@ -53,6 +64,34 @@ impl SnowGolemEntity { mob_arc } + + pub async fn throw_snowball(&self, target: &Arc) { + let entity = self.get_entity(); + let world = entity.world.load(); + + let snowball_entity = Entity::new(world.clone(), entity.pos.load(), &EntityType::SNOWBALL); + let snowball = SnowballEntity::new_shot(snowball_entity, entity); + + let mob_pos = entity.pos.load(); + let target_entity = target.get_entity(); + let target_pos = target_entity.pos.load(); + let target_height = f64::from(target_entity.entity_dimension.load().height); + + let dx = target_pos.x - mob_pos.x; + let dy = (target_pos.y + target_height / 3.0) - snowball.get_entity().pos.load().y; + let dz = target_pos.z - mob_pos.z; + let horizontal_distance = dx.hypot(dz); + let yo = horizontal_distance * 0.2; + + snowball.thrown.set_velocity(dx, dy + yo, dz, 1.6, 12.0); + + if !entity.silent.load(Ordering::Relaxed) { + world.play_sound(Sound::EntitySnowballThrow, SoundCategory::Neutral, &mob_pos); + } + + let snowball_arc: Arc = Arc::new(snowball); + world.spawn_entity(snowball_arc).await; + } } impl Mob for SnowGolemEntity { @@ -60,3 +99,15 @@ impl Mob for SnowGolemEntity { &self.mob_entity } } + +impl RangedAttackMob for SnowGolemEntity { + fn perform_ranged_attack<'a>( + &'a self, + target: &'a Arc, + _power: f32, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.throw_snowball(target).await; + }) + } +} diff --git a/crates/pumpkin/src/entity/passive/trader_llama.rs b/crates/pumpkin/src/entity/passive/trader_llama.rs index afade4316..33d2c1d5f 100644 --- a/crates/pumpkin/src/entity/passive/trader_llama.rs +++ b/crates/pumpkin/src/entity/passive/trader_llama.rs @@ -1,14 +1,18 @@ +use std::sync::atomic::Ordering; use std::sync::{Arc, Weak}; use pumpkin_data::entity::EntityType; +use pumpkin_data::sound::{Sound, SoundCategory}; use crate::entity::{ - Entity, + Entity, EntityBase, EntityBaseFuture, ai::goal::{ - look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal, - wander_around::WanderAroundGoal, + active_target::ActiveTargetGoal, look_around::RandomLookAroundGoal, + look_at_entity::LookAtEntityGoal, ranged_attack::RangedAttackGoal, revenge::RevengeGoal, + swim::SwimGoal, wander_around::WanderAroundGoal, }, - mob::{Mob, MobEntity}, + mob::{Mob, MobEntity, RangedAttackMob}, + projectile::llama_spit::LlamaSpitEntity, }; pub struct TraderLlamaEntity { @@ -24,6 +28,10 @@ impl TraderLlamaEntity { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) }; + let ranged_weak: Weak = { + let ranged_arc: Arc = mob_arc.clone(); + Arc::downgrade(&ranged_arc) + }; { let mut goal_selector = mob_arc @@ -31,18 +39,61 @@ impl TraderLlamaEntity { .goals_selector .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut target_selector = mob_arc + .mob_entity + .target_selector + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); goal_selector.add_goal(0, Box::new(SwimGoal::default())); - goal_selector.add_goal(1, Box::new(WanderAroundGoal::new(0.7))); goal_selector.add_goal( - 2, + 3, + Box::new(RangedAttackGoal::new(ranged_weak, 1.25, 40, 20.0)), + ); + goal_selector.add_goal(7, Box::new(WanderAroundGoal::new(0.7))); + goal_selector.add_goal( + 8, LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 6.0), ); - goal_selector.add_goal(3, Box::new(RandomLookAroundGoal::default())); + goal_selector.add_goal(9, Box::new(RandomLookAroundGoal::default())); + + target_selector.add_goal(1, Box::new(RevengeGoal::new(true))); + target_selector.add_goal( + 2, + ActiveTargetGoal::with_default(&mob_arc.mob_entity, &EntityType::WOLF, true), + ); }; mob_arc } + + pub async fn spit(&self, target: &Arc) { + let entity = self.get_entity(); + let world = entity.world.load(); + + let spit_entity = Entity::new(world.clone(), entity.pos.load(), &EntityType::LLAMA_SPIT); + let spit = LlamaSpitEntity::new_shot(spit_entity, entity); + + let mob_pos = entity.pos.load(); + let target_entity = target.get_entity(); + let target_pos = target_entity.pos.load(); + let target_height = f64::from(target_entity.entity_dimension.load().height); + + let dx = target_pos.x - mob_pos.x; + let dy = (target_pos.y + target_height / 3.0) - spit.get_entity().pos.load().y; + let dz = target_pos.z - mob_pos.z; + let horizontal_distance = dx.hypot(dz); + let yo = horizontal_distance * 0.2; + + spit.thrown.set_velocity(dx, dy + yo, dz, 1.5, 10.0); + + if !entity.silent.load(Ordering::Relaxed) { + world.play_sound(Sound::EntityLlamaSpit, SoundCategory::Neutral, &mob_pos); + } + + let spit_arc: Arc = Arc::new(spit); + world.spawn_entity(spit_arc).await; + } } impl Mob for TraderLlamaEntity { @@ -50,3 +101,15 @@ impl Mob for TraderLlamaEntity { &self.mob_entity } } + +impl RangedAttackMob for TraderLlamaEntity { + fn perform_ranged_attack<'a>( + &'a self, + target: &'a Arc, + _power: f32, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.spit(target).await; + }) + } +} diff --git a/crates/pumpkin/src/entity/passive/villager/mod.rs b/crates/pumpkin/src/entity/passive/villager/mod.rs index d9f0a848e..2a5e5dcab 100644 --- a/crates/pumpkin/src/entity/passive/villager/mod.rs +++ b/crates/pumpkin/src/entity/passive/villager/mod.rs @@ -57,7 +57,7 @@ pub use data::{ get_food_points, }; -async fn trigger_trade_advancement(player: &Player) { +pub(crate) async fn trigger_trade_advancement(player: &Player) { player .trigger_advancement( crate::entity::player::advancement::trigger::AdvancementTrigger::TradedWithVillager, @@ -166,7 +166,7 @@ fn enchant_trade_item( Some((stack, additional_cost)) } -fn apply_random_dye(rng: &mut impl rand::Rng, stack: &mut ItemStack) { +pub(crate) fn apply_random_dye(rng: &mut impl rand::Rng, stack: &mut ItemStack) { use pumpkin_data::data_component::DataComponent; use pumpkin_data::data_component_impl::{DataComponentImpl, DyedColorImpl}; use rand::RngExt; @@ -199,7 +199,7 @@ fn apply_random_dye(rng: &mut impl rand::Rng, stack: &mut ItemStack) { )); } -fn apply_random_stew_effect(rng: &mut impl rand::Rng, stack: &mut ItemStack) { +pub(crate) fn apply_random_stew_effect(rng: &mut impl rand::Rng, stack: &mut ItemStack) { use pumpkin_data::data_component::DataComponent; use pumpkin_data::data_component_impl::{ DataComponentImpl, SuspiciousStewEffect, SuspiciousStewEffectsImpl, @@ -230,7 +230,7 @@ fn apply_random_stew_effect(rng: &mut impl rand::Rng, stack: &mut ItemStack) { )); } -fn apply_potion(stack: &mut ItemStack, potion_name: &str) { +pub(crate) fn apply_potion(stack: &mut ItemStack, potion_name: &str) { use pumpkin_data::data_component::DataComponent; use pumpkin_data::data_component_impl::{DataComponentImpl, PotionContentsImpl}; diff --git a/crates/pumpkin/src/entity/passive/wandering_trader.rs b/crates/pumpkin/src/entity/passive/wandering_trader.rs index 366450858..efb72a92c 100644 --- a/crates/pumpkin/src/entity/passive/wandering_trader.rs +++ b/crates/pumpkin/src/entity/passive/wandering_trader.rs @@ -1,29 +1,156 @@ +use std::borrow::Cow; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::{Arc, Weak}; +use tokio::sync::Mutex; +use uuid::Uuid; +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::effect::StatusEffect; use pumpkin_data::entity::EntityType; - -use crate::entity::{ - Entity, - ai::goal::{ - look_around::RandomLookAroundGoal, look_at_entity::LookAtEntityGoal, swim::SwimGoal, - wander_around::WanderAroundGoal, - }, - mob::{Mob, MobEntity}, +use pumpkin_data::item::{Item, JavaToBedrockItemMapping}; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::potion::Effect; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::statistic::{CustomStatistic, StatisticCategory}; +use pumpkin_data::villager::{ + TRADES_WANDERING_TRADER_BUYING, TRADES_WANDERING_TRADER_COMMON, + TRADES_WANDERING_TRADER_UNCOMMON, VillagerTrade, VillagerTradeModifier, }; +use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler; +use pumpkin_inventory::screen_handler::{ + BoxFuture, InventoryPlayer, ScreenHandlerFactory, SharedScreenHandler, +}; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::codec::item_stack_seralizer::ItemStackSerializer; +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::java::client::play::CMerchantOffers; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::math::vector3::Vector3; +use pumpkin_util::text::TextComponent; +use pumpkin_world::inventory::SimpleInventory; +use rand::RngExt; +use rand::seq::IndexedRandom; + +use super::villager::{ + apply_potion, apply_random_dye, apply_random_stew_effect, trigger_trade_advancement, +}; +use crate::entity::ageable::{AgeableData, AgeableMob}; +use crate::entity::ai::goal::avoid_entity::AvoidEntityGoal; +use crate::entity::ai::goal::escape_danger::EscapeDangerGoal; +use crate::entity::ai::goal::look_at_entity::LookAtEntityGoal; +use crate::entity::ai::goal::swim::SwimGoal; +use crate::entity::ai::goal::trade_with_player::TradeWithPlayerGoal; +use crate::entity::ai::goal::wander_around::WanderAroundGoal; +use crate::entity::ai::goal::{Controls, Goal, GoalFuture}; +use crate::entity::ai::pathfinder::NavigatorGoal; +use crate::entity::experience_orb::ExperienceOrbEntity; +use crate::entity::mob::{Mob, MobEntity, NIGHT_END, NIGHT_START}; +use crate::entity::player::Player; +use crate::entity::{Entity, EntityBase, EntityBaseFuture, NbtFuture}; +use crate::world::World; + +const DEFAULT_DESPAWN_DELAY: i32 = 0; + +fn create_invisibility_potion() -> ItemStack { + let mut stack = ItemStack::new(1, &Item::POTION); + apply_potion(&mut stack, "invisibility"); + stack +} + +fn add_offers_from_trade_set( + offers: &mut Vec, + trade_pool: &'static [VillagerTrade], + amount: usize, + rng: &mut impl rand::Rng, +) { + let mut remaining = trade_pool.iter().collect::>(); + let mut added = 0; + while added < amount && !remaining.is_empty() { + let index = rng.random_range(0..remaining.len()); + let trade = remaining.remove(index); + + let base_cost_a = ItemStack::new(trade.wants.count as u8, trade.wants.item); + let mut output = ItemStack::new(trade.gives.count as u8, trade.gives.item); + let cost_b = trade + .wants_b + .as_ref() + .map(|b| ItemStack::new(b.count as u8, b.item)); + + match trade.modifier { + VillagerTradeModifier::RandomDyes => apply_random_dye(rng, &mut output), + VillagerTradeModifier::RandomPotion => { + if let Some(potion_name) = + pumpkin_data::tag::Potion::MINECRAFT_TRADEABLE.0.choose(rng) + { + apply_potion(&mut output, potion_name); + } + } + VillagerTradeModifier::SuspiciousStew => { + apply_random_stew_effect(rng, &mut output); + } + VillagerTradeModifier::Potion(potion) => apply_potion(&mut output, potion), + _ => {} + } + + offers.push(pumpkin_protocol::java::client::play::MerchantOffer { + base_cost_a: ItemStackSerializer(Cow::Owned(base_cost_a)), + output: ItemStackSerializer(Cow::Owned(output)), + cost_b: cost_b.map(|stack| ItemStackSerializer(Cow::Owned(stack))), + reward_exp: true, + uses: 0, + max_uses: trade.max_uses, + xp: trade.xp, + special_price: 0, + price_multiplier: trade.price_multiplier, + demand: 0, + }); + added += 1; + } +} pub struct WanderingTraderEntity { pub mob_entity: MobEntity, + pub despawn_delay: AtomicI32, + pub wander_target: Mutex>, + pub offers: Mutex>, + pub merchant_inventory: Arc, + pub trading_player: std::sync::Mutex>, + pub is_trading: AtomicBool, + pub trade_sound_cooldown: AtomicI32, + pub ambient_sound_timer: AtomicI32, + pub ageable_data: AgeableData, + self_weak: std::sync::Mutex>>, } impl WanderingTraderEntity { + #[allow(clippy::too_many_lines)] + #[must_use] pub fn new(entity: Entity) -> Arc { let mob_entity = MobEntity::new(entity); - let trader = Self { mob_entity }; + let trader = Self { + mob_entity, + despawn_delay: AtomicI32::new(DEFAULT_DESPAWN_DELAY), + wander_target: Mutex::new(None), + offers: Mutex::new(Vec::new()), + merchant_inventory: Arc::new(SimpleInventory::new(3)), + trading_player: std::sync::Mutex::new(None), + is_trading: AtomicBool::new(false), + trade_sound_cooldown: AtomicI32::new(0), + ambient_sound_timer: AtomicI32::new(120), + ageable_data: AgeableData::default(), + self_weak: std::sync::Mutex::new(None), + }; let mob_arc = Arc::new(trader); + *mob_arc + .self_weak + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::downgrade(&mob_arc)); + let mob_weak: Weak = { let mob_arc: Arc = mob_arc.clone(); Arc::downgrade(&mob_arc) }; + let trader_weak = Arc::downgrade(&mob_arc); { let mut goal_selector = mob_arc @@ -32,21 +159,1138 @@ impl WanderingTraderEntity { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + // Priority 0: FloatGoal goal_selector.add_goal(0, Box::new(SwimGoal::default())); - goal_selector.add_goal(1, Box::new(WanderAroundGoal::new(0.6))); + + // Priority 0: UseItemGoal (Invisibility potion at night / Milk bucket in daylight) + goal_selector.add_goal( + 0, + Box::new(WanderingTraderUseItemGoal::new(trader_weak.clone())), + ); + + // Priority 1: TradeWithPlayerGoal + goal_selector.add_goal(1, Box::new(TradeWithPlayerGoal::new(0.5))); + + // Priority 1: AvoidEntityGoals + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::ZOMBIE, 8.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::DROWNED, 8.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::HUSK, 8.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new( + &EntityType::ZOMBIE_VILLAGER, + 8.0, + 0.5, + 0.5, + )), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::EVOKER, 12.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::VINDICATOR, 8.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::VEX, 8.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::PILLAGER, 15.0, 0.5, 0.5)), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new( + &EntityType::ILLUSIONER, + 12.0, + 0.5, + 0.5, + )), + ); + goal_selector.add_goal( + 1, + Box::new(AvoidEntityGoal::new(&EntityType::ZOGLIN, 10.0, 0.5, 0.5)), + ); + + // Priority 1: PanicGoal (EscapeDangerGoal) + goal_selector.add_goal(1, EscapeDangerGoal::new(0.5)); + + // Priority 1: LookAtTradingPlayerGoal + goal_selector.add_goal(1, Box::new(LookAtTradingPlayerGoal::new(8.0))); + + // Priority 2: WanderToPositionGoal goal_selector.add_goal( 2, + Box::new(WanderToPositionGoal::new(trader_weak, 2.0, 0.35)), + ); + + // Priority 4: MoveTowardsRestrictionGoal + goal_selector.add_goal(4, Box::new(MoveTowardsRestrictionGoal::new(0.35))); + + // Priority 8: WaterAvoidingRandomStrollGoal (WanderAroundGoal) + goal_selector.add_goal(8, Box::new(WanderAroundGoal::new(0.35))); + + // Priority 9: InteractGoal (Player.class, 3.0F, 1.0F) + goal_selector.add_goal( + 9, + Box::new(LookAtEntityGoal::new( + mob_weak.clone(), + &EntityType::PLAYER, + 3.0, + 1.0, + false, + )), + ); + + // Priority 10: LookAtPlayerGoal (Mob.class, 8.0F) + goal_selector.add_goal( + 10, LookAtEntityGoal::with_default(mob_weak, &EntityType::PLAYER, 8.0), ); - goal_selector.add_goal(3, Box::new(RandomLookAroundGoal::default())); }; mob_arc } + + #[must_use] + pub fn get_despawn_delay(&self) -> i32 { + self.despawn_delay.load(Ordering::Relaxed) + } + + pub fn set_despawn_delay(&self, delay: i32) { + self.despawn_delay.store(delay, Ordering::Relaxed); + } + + pub async fn get_wander_target(&self) -> Option { + *self.wander_target.lock().await + } + + pub async fn set_wander_target(&self, target: Option) { + *self.wander_target.lock().await = target; + } + + pub async fn generate_trades(&self) { + let mut offers = self.offers.lock().await; + offers.clear(); + let mut rng = rand::rng(); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_BUYING, 2, &mut rng); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_UNCOMMON, 2, &mut rng); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_COMMON, 5, &mut rng); + } + + pub async fn open_trading_screen(&self, player: &Arc) { + if let Some(sync_id) = player.open_handled_screen(self, None).await { + let offers = self.offers.lock().await.clone(); + self.send_trade_offers(player, sync_id, offers).await; + } + } + + fn bedrock_trade_item(stack: &ItemStack, count: u8) -> NbtCompound { + let mut item = NbtCompound::new(); + if stack.is_empty() { + return item; + } + let Some(mapping) = JavaToBedrockItemMapping::from_java_item_id(stack.item.id) else { + return item; + }; + item.put_byte("Count", count as i8); + item.put_short("Damage", mapping.bedrock_data as i16); + item.put_string("Name", mapping.bedrock_item.registry_key.to_owned()); + item + } + + fn bedrock_trade_data( + offers: &[pumpkin_protocol::java::client::play::MerchantOffer], + ) -> NbtCompound { + use pumpkin_nbt::tag::NbtTag; + + let mut recipes = Vec::with_capacity(offers.len()); + for (index, offer) in offers.iter().enumerate() { + let base_cost = &offer.base_cost_a.0; + let demand_bonus = (i32::from(base_cost.item_count).saturating_mul(offer.demand) as f32 + * offer.price_multiplier) + .floor() + .max(0.0) as i32; + let adjusted_count = i32::from(base_cost.item_count) + .saturating_add(demand_bonus) + .saturating_add(offer.special_price) + .clamp(1, i32::from(base_cost.get_max_stack_size())) + as u8; + + let mut recipe = NbtCompound::new(); + recipe.put_int("netId", index as i32 + 1); + recipe.put_int( + "maxUses", + if offer.is_out_of_stock() { + 0 + } else { + offer.max_uses + }, + ); + recipe.put_int("traderExp", offer.xp); + recipe.put_float("priceMultiplierA", offer.price_multiplier); + recipe.put_float("priceMultiplierB", 0.0); + recipe.put_compound( + "sell", + Self::bedrock_trade_item(&offer.output.0, offer.output.0.item_count), + ); + recipe.put_int("buyCountA", i32::from(base_cost.item_count)); + recipe.put_int( + "buyCountB", + offer + .cost_b + .as_ref() + .map_or(0, |cost| i32::from(cost.0.item_count)), + ); + recipe.put_int("demand", offer.demand); + recipe.put_int("tier", 0); + recipe.put_compound("buyA", Self::bedrock_trade_item(base_cost, adjusted_count)); + recipe.put_compound( + "buyB", + offer.cost_b.as_ref().map_or_else(NbtCompound::new, |cost| { + Self::bedrock_trade_item(&cost.0, cost.0.item_count) + }), + ); + recipe.put_int("uses", offer.uses); + recipe.put_byte("rewardExp", i8::from(offer.reward_exp)); + recipes.push(NbtTag::Compound(recipe)); + } + + let mut data = NbtCompound::new(); + data.put_list("Recipes", recipes); + data.put_list( + "TierExpRequirements", + std::iter::once(0) + .enumerate() + .map(|(tier, xp)| { + let mut requirement = NbtCompound::new(); + requirement.put_int(&tier.to_string(), xp); + NbtTag::Compound(requirement) + }) + .collect(), + ); + data + } + + async fn send_trade_offers( + &self, + player: &Player, + sync_id: u8, + offers: Vec, + ) { + use pumpkin_protocol::{bedrock::client::CUpdateTrade, codec::var_long::VarLong}; + + let java = CMerchantOffers::new( + VarInt(i32::from(sync_id)), + offers.clone(), + VarInt(1), + VarInt(0), + false, + false, + ); + let bedrock = CUpdateTrade { + container_id: sync_id, + r#type: 15, + size: VarInt(0), + trader_tier: VarInt(0), + entity_unique_id: VarLong(i64::from(self.get_entity().entity_id)), + last_trading_player: VarLong(i64::from(player.entity_id())), + display_name: ScreenHandlerFactory::get_display_name(self).to_pretty_console(), + use_new_trade_screen: true, + using_economy_trade: true, + data: Self::bedrock_trade_data(&offers), + }; + player + .client + .enqueue_packet_editioned(&java, &bedrock) + .await; + } + + fn can_continue_trading( + &self, + inventory_player: &dyn InventoryPlayer, + player_uuid: Uuid, + sync_id: u8, + ) -> bool { + let Some(player) = inventory_player.as_any().downcast_ref::() else { + return false; + }; + let entity = self.get_entity(); + let range = player + .living_entity + .get_attribute_value(&pumpkin_data::attributes::Attributes::ENTITY_INTERACTION_RANGE) + + 4.0; + entity.is_alive() + && self.mob_entity.living_entity.health.load() > 0.0 + && self + .trading_player + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|(uuid, id)| *uuid == player_uuid && *id == sync_id) + && entity + .bounding_box + .load() + .squared_magnitude(player.eye_position()) + < range * range + } + + async fn complete_trade(&self, offer_index: usize, world: &Arc, player_uuid: Uuid) { + let (reward_exp, reward_amount) = { + let mut offers = self.offers.lock().await; + let Some(offer) = offers.get_mut(offer_index) else { + return; + }; + offer.uses += 1; + let reward_xp = rand::rng().random_range(3..=6); + (offer.reward_exp, reward_xp) + }; + + self.get_entity() + .play_sound(Sound::EntityWanderingTraderYes); + self.trade_sound_cooldown.store(20, Ordering::Relaxed); + + if reward_exp { + let position = self.get_entity().pos.load().add_raw(0.0, 0.5, 0.0); + ExperienceOrbEntity::spawn(world, position, reward_amount).await; + } + + if let Some(player) = world.get_player_by_uuid(player_uuid) { + trigger_trade_advancement(&player).await; + } + } +} + +impl ScreenHandlerFactory for WanderingTraderEntity { + fn create_screen_handler<'a>( + &'a self, + sync_id: u8, + player_inventory: &'a Arc, + player: &'a dyn InventoryPlayer, + ) -> BoxFuture<'a, Option> { + Box::pin(async move { + let self_weak = self + .self_weak + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone()?; + let server_player = player.as_any().downcast_ref::(); + let player_uuid = + server_player.map_or_else(uuid::Uuid::nil, |p| p.get_entity().entity_uuid); + + let offers = self.offers.lock().await.clone(); + let world = self.get_entity().world.load().clone(); + + let mut handler = MerchantScreenHandler::new( + sync_id, + player_inventory, + self.merchant_inventory.clone(), + offers, + ) + .await; + + self.is_trading.store(true, Ordering::Relaxed); + *self + .trading_player + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((player_uuid, sync_id)); + + let validity_weak = self_weak.clone(); + handler.validity_check = Some(Box::new(move |inventory_player| { + validity_weak.upgrade().is_some_and(|trader| { + trader.can_continue_trading(inventory_player, player_uuid, sync_id) + }) + })); + + let update_weak = self_weak.clone(); + handler.on_trade_updated = Some(Box::new(move |has_result| { + let Some(trader) = update_weak.upgrade() else { + return; + }; + if trader + .trade_sound_cooldown + .compare_exchange(0, 20, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + trader.get_entity().play_sound(if has_result { + Sound::EntityWanderingTraderYes + } else { + Sound::EntityWanderingTraderNo + }); + } + })); + + let close_weak = self_weak.clone(); + handler.on_close = Some(Box::new(move || { + let close_weak = close_weak.clone(); + Box::pin(async move { + if let Some(trader) = close_weak.upgrade() { + trader.is_trading.store(false, Ordering::Relaxed); + *trader + .trading_player + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + }) + })); + + let trade_weak = self_weak.clone(); + handler.on_trade = Some(Box::new(move |offer_index| { + let trade_weak = trade_weak.clone(); + let world = world.clone(); + Box::pin(async move { + if let Some(trader) = trade_weak.upgrade() { + trader + .complete_trade(offer_index, &world, player_uuid) + .await; + } + }) + })); + + Some(Arc::new(tokio::sync::Mutex::new(handler)) as SharedScreenHandler) + }) + } + + fn get_display_name(&self) -> TextComponent { + TextComponent::translate("entity.minecraft.wandering_trader", []) + } +} + +impl AgeableMob for WanderingTraderEntity { + fn get_ageable_data(&self) -> &AgeableData { + &self.ageable_data + } + + fn can_be_a_baby(&self) -> bool { + false + } } impl Mob for WanderingTraderEntity { fn get_mob_entity(&self) -> &MobEntity { &self.mob_entity } + + fn get_trading_player(&self) -> Option> { + let trading = self + .trading_player + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|(uuid, _)| *uuid)?; + self.mob_entity + .living_entity + .entity + .world + .load() + .get_player_by_uuid(trading) + } + + fn as_ageable(&self) -> Option<&dyn AgeableMob> { + Some(self) + } + + fn mob_write_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + nbt.put_int("DespawnDelay", self.despawn_delay.load(Ordering::Relaxed)); + let wander_target = *self.wander_target.lock().await; + if let Some(target) = wander_target { + nbt.put( + "wander_target", + pumpkin_nbt::tag::NbtTag::IntArray(vec![target.0.x, target.0.y, target.0.z]), + ); + } + + let offers = self.offers.lock().await; + if !offers.is_empty() { + let mut recipes = Vec::with_capacity(offers.len()); + for offer in offers.iter() { + let mut recipe = NbtCompound::new(); + let mut buy = NbtCompound::new(); + offer.base_cost_a.0.write_item_stack(&mut buy); + recipe.put_compound("buy", buy); + + if let Some(cost_b) = &offer.cost_b { + let mut buy_b = NbtCompound::new(); + cost_b.0.write_item_stack(&mut buy_b); + recipe.put_compound("buyB", buy_b); + } + + let mut sell_item = NbtCompound::new(); + offer.output.0.write_item_stack(&mut sell_item); + recipe.put_compound("sell", sell_item); + + recipe.put_int("uses", offer.uses); + recipe.put_int("maxUses", offer.max_uses); + recipe.put_bool("rewardExp", offer.reward_exp); + recipe.put_int("xp", offer.xp); + recipe.put_float("priceMultiplier", offer.price_multiplier); + recipe.put_int("specialPrice", offer.special_price); + recipe.put_int("demand", offer.demand); + + recipes.push(pumpkin_nbt::tag::NbtTag::Compound(recipe)); + } + let mut offers_compound = NbtCompound::new(); + offers_compound.put("Recipes", pumpkin_nbt::tag::NbtTag::List(recipes)); + nbt.put_compound("Offers", offers_compound); + } + }) + } + + fn mob_read_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(delay) = nbt.get_int("DespawnDelay") { + self.despawn_delay.store(delay, Ordering::Relaxed); + } + if let Some(target_arr) = nbt.get_int_array("wander_target") + && target_arr.len() >= 3 + { + *self.wander_target.lock().await = + Some(BlockPos::new(target_arr[0], target_arr[1], target_arr[2])); + } else if let (Some(x), Some(y), Some(z)) = ( + nbt.get_int("wander_target_x"), + nbt.get_int("wander_target_y"), + nbt.get_int("wander_target_z"), + ) { + *self.wander_target.lock().await = Some(BlockPos::new(x, y, z)); + } + + if let Some(offers_compound) = nbt.get_compound("Offers") + && let Some(recipes) = offers_compound.get_list("Recipes") + { + let mut offers = self.offers.lock().await; + offers.clear(); + for tag in recipes { + if let Some(recipe) = tag.extract_compound() { + let buy = recipe + .get_compound("buy") + .and_then(ItemStack::read_item_stack); + let buy_b = recipe + .get_compound("buyB") + .and_then(ItemStack::read_item_stack); + let sell_item = recipe + .get_compound("sell") + .and_then(ItemStack::read_item_stack); + + if let (Some(buy), Some(sell_item)) = (buy, sell_item) + && !buy.is_empty() + && !sell_item.is_empty() + && buy_b.as_ref().is_none_or(|stack| !stack.is_empty()) + { + let uses = recipe.get_int("uses").unwrap_or(0); + let max_uses = recipe.get_int("maxUses").unwrap_or(12); + let reward_exp = recipe.get_bool("rewardExp").unwrap_or(true); + let xp = recipe.get_int("xp").unwrap_or(2); + let price_multiplier = + recipe.get_float("priceMultiplier").unwrap_or(0.05); + let special_price = recipe.get_int("specialPrice").unwrap_or(0); + let demand = recipe.get_int("demand").unwrap_or(0); + + offers.push(pumpkin_protocol::java::client::play::MerchantOffer { + base_cost_a: buy.into(), + output: sell_item.into(), + cost_b: buy_b.map(Into::into), + reward_exp, + uses, + max_uses, + xp, + special_price, + price_multiplier, + demand, + }); + } + } + } + } + + let current_age = self.get_age(); + if current_age < 0 { + self.set_age(0); + } + }) + } + + fn mob_interact<'a>( + &'a self, + player: &'a Arc, + item_stack: &'a mut ItemStack, + ) -> EntityBaseFuture<'a, bool> { + let player = player.clone(); + Box::pin(async move { + if item_stack.item == &Item::VILLAGER_SPAWN_EGG + || self.mob_entity.living_entity.health.load() <= 0.0 + || self.is_trading.load(Ordering::Relaxed) + || self.is_baby() + { + return false; + } + + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::TalkedToVillager as i32, + 1, + ) + .await; + + let mut offers = self.offers.lock().await; + if offers.is_empty() { + drop(offers); + self.generate_trades().await; + offers = self.offers.lock().await; + } + + if offers.is_empty() { + return true; + } + drop(offers); + + self.open_trading_screen(&player).await; + true + }) + } + + fn mob_tick<'a>(&'a self, _caller: &'a Arc) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + // Despawn delay handling (vanilla aiStep / maybeDespawn) + if !self.is_trading.load(Ordering::Relaxed) { + let delay = self.despawn_delay.load(Ordering::Relaxed); + if delay > 0 { + let new_delay = delay - 1; + self.despawn_delay.store(new_delay, Ordering::Relaxed); + if new_delay == 0 { + self.mob_entity.living_entity.entity.remove().await; + return; + } + } + } + + // Trade sound cooldown + let cooldown = self.trade_sound_cooldown.load(Ordering::Relaxed); + if cooldown > 0 { + self.trade_sound_cooldown + .store(cooldown - 1, Ordering::Relaxed); + } + + // Ambient sound handling + if self.ambient_sound_timer.fetch_sub(1, Ordering::Relaxed) <= 0 { + let mut rng = rand::rng(); + self.ambient_sound_timer + .store(rng.random_range(80..=160), Ordering::Relaxed); + let sound = if self.is_trading.load(Ordering::Relaxed) { + Sound::EntityWanderingTraderTrade + } else { + Sound::EntityWanderingTraderAmbient + }; + self.mob_entity + .living_entity + .entity + .world + .load() + .play_sound( + sound, + SoundCategory::Neutral, + &self.mob_entity.living_entity.entity.pos.load(), + ); + } + }) + } +} + +// ---------------- AI Goals ---------------- + +pub struct LookAtTradingPlayerGoal { + range: f64, +} + +impl LookAtTradingPlayerGoal { + #[must_use] + pub const fn new(range: f64) -> Self { + Self { range } + } +} + +impl Goal for LookAtTradingPlayerGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(player) = mob.get_trading_player() else { + return false; + }; + let mob_pos = mob.get_mob_entity().living_entity.entity.pos.load(); + let player_pos = player.get_entity().pos.load(); + mob_pos.squared_distance_to_vec(&player_pos) <= self.range * self.range + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(player) = mob.get_trading_player() else { + return false; + }; + let mob_pos = mob.get_mob_entity().living_entity.entity.pos.load(); + let player_pos = player.get_entity().pos.load(); + mob_pos.squared_distance_to_vec(&player_pos) <= self.range * self.range + }) + } + + fn tick<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(player) = mob.get_trading_player() { + let player_pos = player.get_entity().pos.load(); + mob.get_mob_entity() + .look_control + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .look_at( + mob, + player_pos.x, + player.get_entity().get_eye_y(), + player_pos.z, + ); + } + }) + } + + fn should_run_every_tick(&self) -> bool { + true + } + + fn controls(&self) -> Controls { + Controls::LOOK + } +} + +pub struct WanderToPositionGoal { + trader: Weak, + stop_distance: f64, + speed_modifier: f64, +} + +impl WanderToPositionGoal { + #[must_use] + pub const fn new( + trader: Weak, + stop_distance: f64, + speed_modifier: f64, + ) -> Self { + Self { + trader, + stop_distance, + speed_modifier, + } + } + + fn is_too_far_away(pos: &BlockPos, trader_pos: &Vector3, distance: f64) -> bool { + let center = Vector3::new( + pos.0.x as f64 + 0.5, + pos.0.y as f64 + 0.5, + pos.0.z as f64 + 0.5, + ); + center.squared_distance_to_vec(trader_pos) >= distance * distance + } +} + +impl Goal for WanderToPositionGoal { + fn can_start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return false; + }; + let wander_target = *trader.wander_target.lock().await; + let Some(wander_pos) = wander_target else { + return false; + }; + let entity_pos = trader.mob_entity.living_entity.entity.pos.load(); + Self::is_too_far_away(&wander_pos, &entity_pos, self.stop_distance) + }) + } + + fn should_continue<'a>(&'a self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return false; + }; + let wander_target = *trader.wander_target.lock().await; + let Some(wander_pos) = wander_target else { + return false; + }; + let entity_pos = trader.mob_entity.living_entity.entity.pos.load(); + Self::is_too_far_away(&wander_pos, &entity_pos, self.stop_distance) + }) + } + + fn stop<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(trader) = self.trader.upgrade() { + *trader.wander_target.lock().await = None; + trader + .mob_entity + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stop(); + } + }) + } + + fn tick<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return; + }; + let wander_target = *trader.wander_target.lock().await; + let Some(wander_pos) = wander_target else { + return; + }; + let is_idle = trader + .mob_entity + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_idle(); + if is_idle { + let entity_pos = trader.mob_entity.living_entity.entity.pos.load(); + let center = Vector3::new( + wander_pos.0.x as f64 + 0.5, + wander_pos.0.y as f64 + 0.5, + wander_pos.0.z as f64 + 0.5, + ); + let target_pos = if Self::is_too_far_away(&wander_pos, &entity_pos, 10.0) { + let dx = center.x - entity_pos.x; + let dy = center.y - entity_pos.y; + let dz = center.z - entity_pos.z; + let len = (dx * dx + dy * dy + dz * dz).sqrt(); + if len > 0.0 { + Vector3::new( + entity_pos.x + (dx / len) * 10.0, + entity_pos.y + (dy / len) * 10.0, + entity_pos.z + (dz / len) * 10.0, + ) + } else { + center + } + } else { + center + }; + trader + .mob_entity + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_progress(NavigatorGoal::new( + entity_pos, + target_pos, + self.speed_modifier, + )); + } + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } +} + +pub struct MoveTowardsRestrictionGoal { + speed: f64, +} + +impl MoveTowardsRestrictionGoal { + #[must_use] + pub const fn new(speed: f64) -> Self { + Self { speed } + } +} + +impl Goal for MoveTowardsRestrictionGoal { + fn can_start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let mob_entity = mob.get_mob_entity(); + mob_entity.has_position_target() && !mob_entity.is_in_position_target_range() + }) + } + + fn should_continue<'a>(&'a self, mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let mob_entity = mob.get_mob_entity(); + !mob_entity + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_idle() + && mob_entity.has_position_target() + && !mob_entity.is_in_position_target_range() + }) + } + + fn start<'a>(&'a mut self, mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let mob_entity = mob.get_mob_entity(); + let target = mob_entity.position_target.load(); + let entity_pos = mob_entity.living_entity.entity.pos.load(); + let dest = Vector3::new( + target.0.x as f64 + 0.5, + target.0.y as f64, + target.0.z as f64 + 0.5, + ); + mob_entity + .navigator + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .set_progress(NavigatorGoal::new(entity_pos, dest, self.speed)); + }) + } + + fn controls(&self) -> Controls { + Controls::MOVE + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PotionGoalType { + Invisibility, + Milk, +} + +struct WanderingTraderUseItemGoal { + trader: Weak, + goal_type: Option, + timer: i32, +} + +impl WanderingTraderUseItemGoal { + pub const fn new(trader: Weak) -> Self { + Self { + trader, + goal_type: None, + timer: 0, + } + } +} + +impl Goal for WanderingTraderUseItemGoal { + fn can_start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return false; + }; + if !trader.mob_entity.living_entity.entity.is_alive() + || trader.is_trading.load(Ordering::Relaxed) + { + return false; + } + let world = trader.mob_entity.living_entity.entity.world.load(); + let day_time = world.get_time_of_day().await % 24000; + let is_dark = (NIGHT_START..=NIGHT_END).contains(&day_time); + let is_invisible = trader + .mob_entity + .living_entity + .get_effect(&StatusEffect::INVISIBILITY) + .await + .is_some(); + + if is_dark && !is_invisible { + self.goal_type = Some(PotionGoalType::Invisibility); + return true; + } + if !is_dark && is_invisible { + self.goal_type = Some(PotionGoalType::Milk); + return true; + } + false + }) + } + + fn should_continue<'a>(&'a self, _mob: &'a dyn Mob) -> GoalFuture<'a, bool> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return false; + }; + self.timer > 0 + && trader.mob_entity.living_entity.entity.is_alive() + && !trader.is_trading.load(Ordering::Relaxed) + }) + } + + fn start<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return; + }; + self.timer = 32; + let stack = match self.goal_type { + Some(PotionGoalType::Invisibility) => create_invisibility_potion(), + Some(PotionGoalType::Milk) => ItemStack::new(1, &Item::MILK_BUCKET), + None => return, + }; + let mut equip = trader + .mob_entity + .living_entity + .entity_equipment + .lock() + .await; + equip.put(&EquipmentSlot::MAIN_HAND, stack.clone()); + drop(equip); + trader + .mob_entity + .living_entity + .send_equipment_changes(&[(EquipmentSlot::MAIN_HAND, stack)]); + }) + } + + fn tick<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + let Some(trader) = self.trader.upgrade() else { + return; + }; + self.timer -= 1; + if self.timer > 0 && self.timer % 4 == 0 { + let sound = match self.goal_type { + Some(PotionGoalType::Invisibility) => Sound::EntityWanderingTraderDrinkPotion, + Some(PotionGoalType::Milk) => Sound::EntityWanderingTraderDrinkMilk, + None => return, + }; + trader + .mob_entity + .living_entity + .entity + .world + .load() + .play_sound( + sound, + SoundCategory::Neutral, + &trader.mob_entity.living_entity.entity.pos.load(), + ); + } + if self.timer == 0 { + match self.goal_type { + Some(PotionGoalType::Invisibility) => { + trader + .mob_entity + .living_entity + .add_effect(Effect { + effect_type: &StatusEffect::INVISIBILITY, + duration: 6000, + amplifier: 0, + ambient: false, + show_particles: true, + show_icon: true, + blend: false, + }) + .await; + trader + .mob_entity + .living_entity + .entity + .world + .load() + .play_sound( + Sound::EntityWanderingTraderDisappeared, + SoundCategory::Neutral, + &trader.mob_entity.living_entity.entity.pos.load(), + ); + } + Some(PotionGoalType::Milk) => { + trader + .mob_entity + .living_entity + .remove_effect(&StatusEffect::INVISIBILITY) + .await; + trader + .mob_entity + .living_entity + .entity + .world + .load() + .play_sound( + Sound::EntityWanderingTraderReappeared, + SoundCategory::Neutral, + &trader.mob_entity.living_entity.entity.pos.load(), + ); + } + None => {} + } + } + }) + } + + fn stop<'a>(&'a mut self, _mob: &'a dyn Mob) -> GoalFuture<'a, ()> { + Box::pin(async move { + if let Some(trader) = self.trader.upgrade() { + let empty = ItemStack::EMPTY; + let mut equip = trader + .mob_entity + .living_entity + .entity_equipment + .lock() + .await; + equip.put(&EquipmentSlot::MAIN_HAND, empty.clone()); + drop(equip); + trader + .mob_entity + .living_entity + .send_equipment_changes(&[(EquipmentSlot::MAIN_HAND, empty.clone())]); + } + self.goal_type = None; + self.timer = 0; + }) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn default_wandering_trader_despawn_delay_is_zero() { + assert_eq!(DEFAULT_DESPAWN_DELAY, 0); + } + + #[tokio::test] + async fn generate_trades_creates_expected_trade_count() { + let mut offers = Vec::new(); + let mut rng = rand::rng(); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_BUYING, 2, &mut rng); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_UNCOMMON, 2, &mut rng); + add_offers_from_trade_set(&mut offers, TRADES_WANDERING_TRADER_COMMON, 5, &mut rng); + + assert_eq!(offers.len(), 9); + } + + #[test] + fn wander_target_distance_check() { + let target = BlockPos::new(10, 64, 10); + let trader_pos = Vector3::new(10.0, 64.0, 10.0); + // Center is (10.5, 64.5, 10.5), dist ~ 0.866 + assert!(!WanderToPositionGoal::is_too_far_away( + &target, + &trader_pos, + 2.0 + )); + assert!(WanderToPositionGoal::is_too_far_away( + &target, + &trader_pos, + 0.5 + )); + } + + #[test] + fn invisibility_potion_item_stack_creation() { + let potion = create_invisibility_potion(); + assert!(potion.item == &Item::POTION); + assert_eq!(potion.item_count, 1); + assert!(!potion.patch.is_empty()); + } } diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index 2703bef55..a6cf89654 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -712,6 +712,8 @@ pub struct Player { pub open_container: AtomicCell>, /// The block position of the currently open container screen (if any). pub open_container_pos: AtomicCell>, + /// The village position where Raid Omen was triggered. + pub raid_omen_position: AtomicCell>, /// The item currently being held by the player. pub carried_item: Mutex>, /// The player's abilities and special powers. @@ -978,6 +980,7 @@ impl Player { enchantment_seed: AtomicI32::new(rand::random()), open_container: AtomicCell::new(None), open_container_pos: AtomicCell::new(None), + raid_omen_position: AtomicCell::new(None), tick_counter: AtomicI32::new(0), start_mining_time: AtomicI32::new(0), last_input: AtomicI8::new(0), @@ -2503,6 +2506,7 @@ impl Player { // experience handling self.tick_experience().await; self.tick_health().await; + self.tick_raid_omen().await; self.tick_maps(server).await; // Anti-spam counter decay @@ -3771,6 +3775,51 @@ impl Player { } } + pub async fn tick_raid_omen(&self) { + if self.is_spectator() { + return; + } + + if let Some(bad_omen) = self.get_effect(&StatusEffect::BAD_OMEN).await + && !self.has_effect(&StatusEffect::RAID_OMEN).await + { + let world = self.world(); + let player_pos = self.living_entity.entity.block_pos.load(); + let pos_f64 = self.living_entity.entity.pos.load(); + + let village_pos = world + .villager_poi + .lock() + .await + .get_nearest_job_site(player_pos, 64) + .or_else(|| { + world.raids.try_lock().ok().and_then(|raids| { + raids + .get_nearby_raid(&player_pos, 64.0 * 64.0) + .map(|r| r.center) + }) + }); + + if let Some(pos) = village_pos { + self.living_entity + .remove_effect(&StatusEffect::BAD_OMEN) + .await; + self.set_raid_omen_position(pos); + let effect = Effect { + effect_type: &StatusEffect::RAID_OMEN, + duration: 600, + amplifier: bad_omen.amplifier, + ambient: false, + show_particles: true, + show_icon: true, + blend: true, + }; + self.add_effect(effect).await; + world.play_sound(Sound::BlockBellResonate, SoundCategory::Neutral, &pos_f64); + } + } + } + pub async fn set_health(&self, health: f32) { self.living_entity.set_health(health); self.send_health().await; @@ -4532,6 +4581,19 @@ impl Player { effects.values().cloned().collect() } + #[must_use] + pub fn get_raid_omen_position(&self) -> Option { + self.raid_omen_position.load() + } + + pub fn set_raid_omen_position(&self, pos: BlockPos) { + self.raid_omen_position.store(Some(pos)); + } + + pub fn clear_raid_omen_position(&self) { + self.raid_omen_position.store(None); + } + pub async fn send_active_effects(&self) { let effects = self.living_entity.active_effects.lock().await; for effect in effects.values() { diff --git a/crates/pumpkin/src/entity/projectile/llama_spit.rs b/crates/pumpkin/src/entity/projectile/llama_spit.rs new file mode 100644 index 000000000..d84d07b80 --- /dev/null +++ b/crates/pumpkin/src/entity/projectile/llama_spit.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use pumpkin_data::damage::DamageType; +use pumpkin_util::math::vector3::Vector3; + +use crate::{ + entity::{ + Entity, EntityBase, EntityBaseFuture, + living::LivingEntity, + projectile::{ProjectileHit, ThrownItemEntity}, + }, + server::Server, +}; + +pub const LLAMA_SPIT_GRAVITY: f64 = 0.06; + +pub struct LlamaSpitEntity { + pub thrown: ThrownItemEntity, +} + +impl LlamaSpitEntity { + #[must_use] + pub const fn new(entity: Entity) -> Self { + let thrown = ThrownItemEntity { + entity, + owner_id: None, + collides_with_projectiles: false, + has_hit: AtomicBool::new(false), + gravity: LLAMA_SPIT_GRAVITY, + }; + + Self { thrown } + } + + #[must_use] + pub fn new_shot(entity: Entity, shooter: &Entity) -> Self { + let owner_pos = shooter.pos.load(); + let body_yaw_rad = f64::from(shooter.body_yaw.load()).to_radians(); + let bb_width = f64::from(shooter.entity_dimension.load().width); + let offset = f64::midpoint(bb_width, 1.0); + let x = owner_pos.x - offset * body_yaw_rad.sin(); + let y = owner_pos.y + shooter.get_eye_height() - 0.1; + let z = owner_pos.z + offset * body_yaw_rad.cos(); + entity.pos.store(Vector3::new(x, y, z)); + + let thrown = ThrownItemEntity { + entity, + owner_id: Some(shooter.entity_id), + collides_with_projectiles: false, + has_hit: AtomicBool::new(false), + gravity: LLAMA_SPIT_GRAVITY, + }; + + Self { thrown } + } +} + +impl EntityBase for LlamaSpitEntity { + fn tick<'a>( + &'a self, + caller: &'a Arc, + server: &'a Server, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + if self.get_entity().touching_water.load(Ordering::Relaxed) { + self.get_entity().remove().await; + return; + } + self.thrown.process_tick(caller, server).await; + }) + } + + fn get_entity(&self) -> &Entity { + self.thrown.get_entity() + } + + fn get_living_entity(&self) -> Option<&LivingEntity> { + None + } + + fn cast_any(&self) -> &dyn std::any::Any { + self + } + + fn on_hit(&self, hit: ProjectileHit) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + if let ProjectileHit::Entity { + ref entity, + hit_pos, + .. + } = hit + { + let entity_clone = entity.clone(); + let world = self.get_entity().world.load(); + let owner_id = self.thrown.owner_id; + let owner = owner_id.and_then(|id| world.get_entity_by_id(id)); + + tokio::spawn(async move { + let _ = entity_clone + .damage_with_context( + entity_clone.as_ref(), + 1.0, + DamageType::SPIT, + Some(hit_pos), + None, + owner.as_deref(), + ) + .await; + }); + } + }) + } +} diff --git a/crates/pumpkin/src/entity/projectile/mod.rs b/crates/pumpkin/src/entity/projectile/mod.rs index 9d3d784d4..8e41cadfe 100644 --- a/crates/pumpkin/src/entity/projectile/mod.rs +++ b/crates/pumpkin/src/entity/projectile/mod.rs @@ -18,12 +18,14 @@ pub mod fireball; pub mod firework_rocket; pub mod fishing_bobber; pub mod lingering_potion; +pub mod llama_spit; pub mod shulker_bullet; pub mod small_fireball; pub mod snowball; pub mod splash_potion; pub mod trident; pub mod wind_charge; +pub mod wither_skull; #[must_use] pub fn is_projectile(entity_type: &EntityType) -> bool { @@ -40,6 +42,8 @@ pub fn is_projectile(entity_type: &EntityType) -> bool { || *entity_type == EntityType::FIREBALL || *entity_type == EntityType::SMALL_FIREBALL || *entity_type == EntityType::FISHING_BOBBER + || *entity_type == EntityType::WITHER_SKULL + || *entity_type == EntityType::LLAMA_SPIT } pub struct ThrownItemEntity { diff --git a/crates/pumpkin/src/entity/projectile/wither_skull.rs b/crates/pumpkin/src/entity/projectile/wither_skull.rs new file mode 100644 index 000000000..80655345b --- /dev/null +++ b/crates/pumpkin/src/entity/projectile/wither_skull.rs @@ -0,0 +1,176 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use pumpkin_data::{damage::DamageType, effect::StatusEffect, potion::Effect, tracked_data}; +use pumpkin_nbt::compound::NbtCompound; +use pumpkin_protocol::java::client::play::Metadata; +use pumpkin_util::{Difficulty, math::vector3::Vector3}; + +use crate::{ + entity::{ + Entity, EntityBase, EntityBaseFuture, NbtFuture, + projectile::{ProjectileHit, ThrownItemEntity}, + }, + server::Server, + world::ExplosionInteraction, +}; + +const GRAVITY: f64 = 0.0; + +pub struct WitherSkullEntity { + pub thrown: ThrownItemEntity, + pub dangerous: AtomicBool, +} + +impl WitherSkullEntity { + #[must_use] + pub const fn new(entity: Entity) -> Self { + let thrown = ThrownItemEntity { + entity, + owner_id: None, + collides_with_projectiles: false, + has_hit: AtomicBool::new(false), + gravity: GRAVITY, + }; + + Self { + thrown, + dangerous: AtomicBool::new(false), + } + } + + #[must_use] + pub fn new_shot( + entity: Entity, + shooter: &Entity, + dangerous: bool, + direction: Vector3, + ) -> Self { + let thrown = ThrownItemEntity::new(entity, shooter, GRAVITY); + let speed = 0.95; + let vel = direction.normalize().multiply(speed, speed, speed); + thrown.entity.velocity.store(vel); + + let len = vel.horizontal_length(); + thrown.entity.set_rotation( + vel.x.atan2(vel.z) as f32 * 57.295_776, + vel.y.atan2(len) as f32 * 57.295_776, + ); + + Self { + thrown, + dangerous: AtomicBool::new(dangerous), + } + } + + #[must_use] + pub fn is_dangerous(&self) -> bool { + self.dangerous.load(Ordering::Relaxed) + } + + pub fn set_dangerous(&self, dangerous: bool) { + self.dangerous.store(dangerous, Ordering::Relaxed); + self.thrown.entity.send_meta_data( + &[Metadata::new( + tracked_data::wither_skull::DATA_DANGEROUS, + dangerous, + )], + None, + ); + } +} + +impl EntityBase for WitherSkullEntity { + fn write_custom_nbt<'a>(&'a self, nbt: &'a mut NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + nbt.put_bool("dangerous", self.is_dangerous()); + }) + } + + fn read_custom_nbt<'a>(&'a self, nbt: &'a NbtCompound) -> NbtFuture<'a, ()> { + Box::pin(async move { + if let Some(dangerous) = nbt.get_bool("dangerous") { + self.dangerous.store(dangerous, Ordering::Relaxed); + } + }) + } + + fn init_data_tracker(&self) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let entity = self.get_entity(); + entity.send_meta_data( + &[Metadata::new( + tracked_data::wither_skull::DATA_DANGEROUS, + self.is_dangerous(), + )], + None, + ); + }) + } + + fn tick<'a>( + &'a self, + caller: &'a Arc, + server: &'a Server, + ) -> EntityBaseFuture<'a, ()> { + Box::pin(async move { + self.thrown.process_tick(caller, server).await; + }) + } + + fn get_entity(&self) -> &Entity { + self.thrown.get_entity() + } + + fn get_living_entity(&self) -> Option<&crate::entity::living::LivingEntity> { + None + } + + fn cast_any(&self) -> &dyn std::any::Any { + self + } + + fn on_hit(&self, hit: ProjectileHit) -> EntityBaseFuture<'_, ()> { + Box::pin(async move { + let world = self.get_entity().world.load(); + + if let ProjectileHit::Entity { ref entity, .. } = hit { + let entity_clone = entity.clone(); + let difficulty = world.level_info.load().difficulty; + + tokio::spawn(async move { + let _ = entity_clone + .damage(entity_clone.as_ref(), 8.0, DamageType::WITHER_SKULL) + .await; + + if let Some(living) = entity_clone.get_living_entity() { + let duration = match difficulty { + Difficulty::Hard => 800, // 40 seconds + Difficulty::Normal => 200, // 10 seconds + Difficulty::Easy | Difficulty::Peaceful => 0, + }; + + if duration > 0 { + let effect = Effect { + effect_type: &StatusEffect::WITHER, + duration, + amplifier: 1, + ambient: false, + show_particles: true, + show_icon: true, + blend: true, + }; + if let Some(player) = entity_clone.get_player() { + player.send_effect(effect.clone()).await; + } + living.add_effect(effect).await; + } + } + }); + } + + let hit_pos = hit.hit_pos(); + world.explode(hit_pos, 1.0, ExplosionInteraction::Mob).await; + }) + } +} diff --git a/crates/pumpkin/src/entity/type.rs b/crates/pumpkin/src/entity/type.rs index 0feb204f4..25186ab95 100644 --- a/crates/pumpkin/src/entity/type.rs +++ b/crates/pumpkin/src/entity/type.rs @@ -111,12 +111,14 @@ use crate::entity::projectile::eye_of_ender::EyeOfEnder; use crate::entity::projectile::fireball::FireballEntity; use crate::entity::projectile::firework_rocket::FireworkRocketEntity; use crate::entity::projectile::lingering_potion::LingeringPotionEntity; +use crate::entity::projectile::llama_spit::LlamaSpitEntity; use crate::entity::projectile::shulker_bullet::ShulkerBulletEntity; use crate::entity::projectile::small_fireball::SmallFireballEntity; use crate::entity::projectile::snowball::SnowballEntity; use crate::entity::projectile::splash_potion::SplashPotionEntity; use crate::entity::projectile::trident::TridentEntity; use crate::entity::projectile::wind_charge::{WIND_CHARGE_GRAVITY, WindChargeEntity}; +use crate::entity::projectile::wither_skull::WitherSkullEntity; use crate::entity::tnt::TNTEntity; use crate::entity::vehicle::boat::BoatEntity; use crate::entity::vehicle::minecart::MinecartEntity; @@ -275,6 +277,7 @@ pub fn from_type( } id if id == EntityType::FIREBALL.id => Arc::new(FireballEntity::new(entity)), id if id == EntityType::SMALL_FIREBALL.id => Arc::new(SmallFireballEntity::new(entity)), + id if id == EntityType::WITHER_SKULL.id => Arc::new(WitherSkullEntity::new(entity)), id if id == EntityType::WIND_CHARGE.id => { let thrown = ThrownItemEntity { entity, @@ -301,6 +304,7 @@ pub fn from_type( id if id == EntityType::FIREWORK_ROCKET.id => Arc::new(FireworkRocketEntity::new(entity)), id if id == EntityType::SPLASH_POTION.id => Arc::new(SplashPotionEntity::new(entity)), id if id == EntityType::LINGERING_POTION.id => Arc::new(LingeringPotionEntity::new(entity)), + id if id == EntityType::LLAMA_SPIT.id => Arc::new(LlamaSpitEntity::new(entity)), id if id == EntityType::EYE_OF_ENDER.id => Arc::new(EyeOfEnder::new(entity)), id if id == EntityType::ACACIA_BOAT.id || id == EntityType::ACACIA_CHEST_BOAT.id diff --git a/crates/pumpkin/src/world/bossbar.rs b/crates/pumpkin/src/world/bossbar.rs index c7a2ad116..a8f468fcb 100644 --- a/crates/pumpkin/src/world/bossbar.rs +++ b/crates/pumpkin/src/world/bossbar.rs @@ -1,8 +1,13 @@ use crate::entity::player::Player; use crate::net::ClientPlatform; use bitflags::bitflags; -use pumpkin_protocol::bedrock::client::boss_event::{BossEventAction, CBossEvent as BBossEvent}; -use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::bedrock::client::boss_event::{ + BOSS_EVENT_COLOUR_BLUE, BOSS_EVENT_COLOUR_GREEN, BOSS_EVENT_COLOUR_PINK, + BOSS_EVENT_COLOUR_PURPLE, BOSS_EVENT_COLOUR_RED, BOSS_EVENT_COLOUR_WHITE, + BOSS_EVENT_COLOUR_YELLOW, BOSS_EVENT_OVERLAY_NOTCHED_6, BOSS_EVENT_OVERLAY_NOTCHED_10, + BOSS_EVENT_OVERLAY_NOTCHED_12, BOSS_EVENT_OVERLAY_NOTCHED_20, BOSS_EVENT_OVERLAY_PROGRESS, + CBossEvent as BBossEvent, +}; use pumpkin_protocol::codec::var_long::VarLong; use pumpkin_protocol::java::client::play::{BosseventAction, CBossEvent}; use pumpkin_util::text::TextComponent; @@ -21,15 +26,15 @@ pub enum BossbarColor { impl BossbarColor { #[must_use] - pub const fn to_bedrock(self) -> VarInt { + pub const fn to_bedrock(self) -> u8 { match self { - Self::Pink => VarInt(0), - Self::Blue => VarInt(1), - Self::Red => VarInt(2), - Self::Green => VarInt(3), - Self::Yellow => VarInt(4), - Self::Purple => VarInt(5), - Self::White => VarInt(6), + Self::Pink => BOSS_EVENT_COLOUR_PINK, + Self::Blue => BOSS_EVENT_COLOUR_BLUE, + Self::Red => BOSS_EVENT_COLOUR_RED, + Self::Green => BOSS_EVENT_COLOUR_GREEN, + Self::Yellow => BOSS_EVENT_COLOUR_YELLOW, + Self::Purple => BOSS_EVENT_COLOUR_PURPLE, + Self::White => BOSS_EVENT_COLOUR_WHITE, } } } @@ -45,13 +50,13 @@ pub enum BossbarDivisions { impl BossbarDivisions { #[must_use] - pub const fn to_bedrock(self) -> VarInt { + pub const fn to_bedrock(self) -> u8 { match self { - Self::NoDivision => VarInt(0), - Self::Notches6 => VarInt(1), - Self::Notches10 => VarInt(2), - Self::Notches12 => VarInt(3), - Self::Notches20 => VarInt(4), + Self::NoDivision => BOSS_EVENT_OVERLAY_PROGRESS, + Self::Notches6 => BOSS_EVENT_OVERLAY_NOTCHED_6, + Self::Notches10 => BOSS_EVENT_OVERLAY_NOTCHED_10, + Self::Notches12 => BOSS_EVENT_OVERLAY_NOTCHED_12, + Self::Notches20 => BOSS_EVENT_OVERLAY_NOTCHED_20, } } } @@ -91,6 +96,14 @@ impl Bossbar { } } +#[inline] +#[must_use] +pub const fn bossbar_bedrock_id(uuid: &Uuid) -> VarLong { + let (high, low) = uuid.as_u64_pair(); + let id = (high ^ low) & 0x7FFF_FFFF_FFFF_FFFF; + VarLong(id as i64) +} + /// Extra methods for [`Player`] to send and manage the bossbar. impl Player { pub async fn send_bossbar(&self, bossbar: &Bossbar) { @@ -108,16 +121,20 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - let packet = BBossEvent { - boss_entity_id: VarLong(bossbar.uuid.as_u128() as i64), - action: BossEventAction::Add { - title: bossbar.title.clone().get_text(), - health_percent: bossbar.health, - color: bossbar.color.to_bedrock(), - overlay: bossbar.division.to_bedrock(), - }, - }; + let boss_id = bossbar_bedrock_id(&bossbar.uuid); + let player_id = VarLong(self.entity_id() as i64); + let packet = BBossEvent::show( + boss_id, + player_id, + bossbar.title.clone().get_text(), + bossbar.health, + bossbar.color.to_bedrock(), + bossbar.division.to_bedrock(), + ); bedrock.send_packet(&packet).await; + + let register_packet = BBossEvent::register_player(boss_id, player_id); + bedrock.send_packet(®ister_packet).await; } } } @@ -131,10 +148,12 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - let packet = BBossEvent { - boss_entity_id: VarLong(uuid.as_u128() as i64), - action: BossEventAction::Remove, - }; + let boss_id = bossbar_bedrock_id(&uuid); + let player_id = VarLong(self.entity_id() as i64); + let unregister_packet = BBossEvent::unregister_player(boss_id, player_id); + bedrock.send_packet(&unregister_packet).await; + + let packet = BBossEvent::hide(boss_id); bedrock.send_packet(&packet).await; } } @@ -149,10 +168,8 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - let packet = BBossEvent { - boss_entity_id: VarLong(uuid.as_u128() as i64), - action: BossEventAction::UpdateHealth(health), - }; + let boss_id = bossbar_bedrock_id(uuid); + let packet = BBossEvent::update_health(boss_id, health); bedrock.send_packet(&packet).await; } } @@ -167,10 +184,8 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - let packet = BBossEvent { - boss_entity_id: VarLong(uuid.as_u128() as i64), - action: BossEventAction::UpdateTitle(title.get_text()), - }; + let boss_id = bossbar_bedrock_id(uuid); + let packet = BBossEvent::update_title(boss_id, title.get_text()); bedrock.send_packet(&packet).await; } } @@ -194,13 +209,12 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - let packet = BBossEvent { - boss_entity_id: VarLong(uuid.as_u128() as i64), - action: BossEventAction::UpdateProperties { - color: color.to_bedrock(), - overlay: dividers.to_bedrock(), - }, - }; + let boss_id = bossbar_bedrock_id(uuid); + let packet = BBossEvent::update_properties( + boss_id, + color.to_bedrock(), + dividers.to_bedrock(), + ); bedrock.send_packet(&packet).await; } } @@ -215,18 +229,8 @@ impl Player { java.enqueue_client_packet(&packet).await; } ClientPlatform::Bedrock(bedrock) => { - // For Bedrock, flags are part of properties (screen darken) - // We don't have color and dividers here, so we might need more info or just skip if not critical - // Actually, properties includes screen_darken, color, and overlay. - // Since this method only has flags, we might need to store the current color/division on the player - // or retrieve them from the bossbar if we had a reference to it. - let packet = BBossEvent { - boss_entity_id: VarLong(uuid.as_u128() as i64), - action: BossEventAction::UpdateProperties { - color: VarInt(0), - overlay: VarInt(0), - }, - }; + let boss_id = bossbar_bedrock_id(uuid); + let packet = BBossEvent::update_properties(boss_id, 0, 0); bedrock.send_packet(&packet).await; } } diff --git a/crates/pumpkin/src/world/custom_bossbar.rs b/crates/pumpkin/src/world/custom_bossbar.rs index ba93c227a..642855605 100644 --- a/crates/pumpkin/src/world/custom_bossbar.rs +++ b/crates/pumpkin/src/world/custom_bossbar.rs @@ -64,7 +64,7 @@ impl CustomBossbars { pub fn get_player_bars(&self, uuid: &Uuid) -> Option> { let mut player_bars: Vec<&Bossbar> = Vec::new(); for bossbar in &self.custom_bossbars { - if bossbar.1.players.contains(uuid) { + if bossbar.1.visible && bossbar.1.players.contains(uuid) { player_bars.push(&bossbar.1.bossbar_data); } } @@ -141,6 +141,96 @@ impl CustomBossbars { self.custom_bossbars.contains_key(resource_location) } + pub async fn update_value( + &mut self, + server: &Server, + resource_location: String, + value: i32, + ) -> Result<(), BossbarUpdateError> { + let bossbar = self.custom_bossbars.get_mut(&resource_location); + if let Some(bossbar) = bossbar { + if bossbar.value == value { + return Err(BossbarUpdateError::NoChanges("value", None)); + } + + let ratio = f64::from(value) / f64::from(bossbar.max); + let health: f32 = if ratio < 0.0 { + 0.0 + } else if ratio > 1.0 { + 1.0 + } else { + ratio as f32 + }; + + bossbar.value = value; + bossbar.bossbar_data.health = health; + + if !bossbar.visible { + return Ok(()); + } + + let players: Vec> = server.get_all_players(); + let matching_players = players + .iter() + .filter(|player| bossbar.players.contains(&player.gameprofile.id)); + for player in matching_players { + player + .update_bossbar_health(&bossbar.bossbar_data.uuid, bossbar.bossbar_data.health) + .await; + } + + return Ok(()); + } + Err(BossbarUpdateError::InvalidResourceLocation( + resource_location, + )) + } + + pub async fn update_max( + &mut self, + server: &Server, + resource_location: String, + max_value: i32, + ) -> Result<(), BossbarUpdateError> { + let bossbar = self.custom_bossbars.get_mut(&resource_location); + if let Some(bossbar) = bossbar { + if bossbar.max == max_value { + return Err(BossbarUpdateError::NoChanges("max", None)); + } + + let ratio = f64::from(bossbar.value) / f64::from(max_value); + let health: f32 = if ratio < 0.0 { + 0.0 + } else if ratio > 1.0 { + 1.0 + } else { + ratio as f32 + }; + + bossbar.max = max_value; + bossbar.bossbar_data.health = health; + + if !bossbar.visible { + return Ok(()); + } + + let players: Vec> = server.get_all_players(); + let matching_players = players + .iter() + .filter(|player| bossbar.players.contains(&player.gameprofile.id)); + for player in matching_players { + player + .update_bossbar_health(&bossbar.bossbar_data.uuid, bossbar.bossbar_data.health) + .await; + } + + return Ok(()); + } + Err(BossbarUpdateError::InvalidResourceLocation( + resource_location, + )) + } + pub async fn update_health( &mut self, server: &Server, diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 92c10d57c..20ded47f9 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -24,6 +24,7 @@ pub mod explosion; pub mod loot; pub mod map; pub mod portal; +pub mod raid; pub mod time; pub mod villager_poi; @@ -274,6 +275,8 @@ pub struct World { pub portal_poi: Mutex, /// Villager job sites and their current owners. pub villager_poi: Mutex, + /// Active raids in this world. + pub raids: Mutex, /// End Dragon fight manager (only present in `THE_END` dimension). pub dragon_fight: Option>, pub spawn_state: ArcSwap, @@ -393,6 +396,7 @@ impl World { unsent_block_changes: Mutex::new(HashMap::new()), portal_poi: Mutex::new(portal_poi), villager_poi: Mutex::new(villager_poi::VillagerPoiStorage::default()), + raids: Mutex::new(raid::Raids::default()), dragon_fight, spawn_state: ArcSwap::new(Arc::new(SpawnState::empty())), active_chunks: ArcSwap::new(Arc::new(FxHashSet::default())), @@ -1203,6 +1207,7 @@ impl World { self.flush_synced_block_events().await; self.update_active_chunks(); self.tick_environment().await; + self.raids.lock().await.tick(self).await; let world_for_chunks = self.clone(); let chunk_future = async move { @@ -3668,11 +3673,17 @@ impl World { .await; } - // if let Some(bossbars) = self..lock().get_player_bars(&player.gameprofile.id) { - // for bossbar in bossbars { - // player.send_bossbar(bossbar); - // } - // } + let player_bossbars = server + .bossbars + .lock() + .await + .get_player_bars(&player.gameprofile.id) + .map(|bars| bars.into_iter().cloned().collect::>()); + if let Some(bossbars) = player_bossbars { + for bossbar in &bossbars { + player.send_bossbar(bossbar).await; + } + } player.has_played_before.store(true, Ordering::Relaxed); player diff --git a/crates/pumpkin/src/world/raid.rs b/crates/pumpkin/src/world/raid.rs new file mode 100644 index 000000000..e468c7e02 --- /dev/null +++ b/crates/pumpkin/src/world/raid.rs @@ -0,0 +1,765 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use uuid::Uuid; + +use pumpkin_data::data_component_impl::EquipmentSlot; +use pumpkin_data::effect::StatusEffect; +use pumpkin_data::entity::EntityType; +use pumpkin_data::potion::Effect; +use pumpkin_data::sound::Sound; +use pumpkin_util::difficulty::Difficulty; +use pumpkin_util::math::position::BlockPos; +use pumpkin_util::math::vector3::Vector3; +use pumpkin_util::text::TextComponent; + +use crate::entity::EntityBase; +use crate::entity::mob::raider::create_ominous_banner; +use crate::entity::player::Player; +use crate::entity::r#type::from_type; +use crate::world::World; +use crate::world::bossbar::{Bossbar, BossbarColor, BossbarDivisions}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RaidStatus { + Ongoing, + Victory, + Loss, + Stopped, +} + +impl RaidStatus { + #[must_use] + pub const fn get_serialized_name(self) -> &'static str { + match self { + Self::Ongoing => "ongoing", + Self::Victory => "victory", + Self::Loss => "loss", + Self::Stopped => "stopped", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RaiderType { + Vindicator, + Evoker, + Pillager, + Witch, + Ravager, +} + +impl RaiderType { + pub const VALUES: [Self; 5] = [ + Self::Vindicator, + Self::Evoker, + Self::Pillager, + Self::Witch, + Self::Ravager, + ]; + + #[must_use] + pub const fn entity_type(self) -> &'static EntityType { + match self { + Self::Vindicator => &EntityType::VINDICATOR, + Self::Evoker => &EntityType::EVOKER, + Self::Pillager => &EntityType::PILLAGER, + Self::Witch => &EntityType::WITCH, + Self::Ravager => &EntityType::RAVAGER, + } + } + + #[must_use] + pub const fn spawns_per_wave(self) -> &'static [i32; 8] { + match self { + Self::Vindicator => &[0, 0, 2, 0, 1, 4, 2, 5], + Self::Evoker => &[0, 0, 0, 0, 0, 1, 1, 2], + Self::Pillager => &[0, 4, 3, 3, 4, 4, 4, 2], + Self::Witch => &[0, 0, 0, 0, 3, 0, 0, 1], + Self::Ravager => &[0, 0, 0, 1, 0, 1, 0, 2], + } + } +} + +pub struct Raid { + pub id: i32, + pub center: BlockPos, + pub status: RaidStatus, + pub active: bool, + pub started: bool, + pub ticks_active: u64, + pub raid_omen_level: i32, + pub groups_spawned: i32, + pub num_groups: i32, + pub raid_cooldown_ticks: i32, + pub post_raid_ticks: i32, + pub celebration_ticks: u32, + pub total_health: f32, + pub bossbar: Bossbar, + pub group_to_leader_map: HashMap, + pub group_raider_map: HashMap>, + pub heroes_of_the_village: HashSet, + pub players_in_raid: HashSet, + pub wave_spawn_pos: Option, +} + +impl Raid { + pub const VALID_RAID_RADIUS: f64 = 96.0; + pub const VALID_RAID_RADIUS_SQR: f64 = 9216.0; + pub const RAID_REMOVAL_THRESHOLD_SQR: f64 = 12544.0; + pub const MAX_RAID_OMEN_LEVEL: i32 = 5; + + #[must_use] + pub fn new(id: i32, center: BlockPos, difficulty: Difficulty) -> Self { + let mut bossbar = Bossbar::new(TextComponent::translate("event.minecraft.raid", [])); + bossbar.color = BossbarColor::Red; + bossbar.division = BossbarDivisions::Notches10; + bossbar.health = 0.0; + + let num_groups = Self::get_num_groups(difficulty); + + Self { + id, + center, + status: RaidStatus::Ongoing, + active: true, + started: false, + ticks_active: 0, + raid_omen_level: 1, + groups_spawned: 0, + num_groups, + raid_cooldown_ticks: 300, + post_raid_ticks: 0, + celebration_ticks: 0, + total_health: 0.0, + bossbar, + group_to_leader_map: HashMap::new(), + group_raider_map: HashMap::new(), + heroes_of_the_village: HashSet::new(), + players_in_raid: HashSet::new(), + wave_spawn_pos: None, + } + } + + #[must_use] + pub const fn get_num_groups(difficulty: Difficulty) -> i32 { + match difficulty { + Difficulty::Peaceful => 0, + Difficulty::Easy => 3, + Difficulty::Normal => 5, + Difficulty::Hard => 7, + } + } + + #[must_use] + pub const fn is_over(&self) -> bool { + self.is_victory() || self.is_loss() + } + + #[must_use] + pub const fn is_stopped(&self) -> bool { + matches!(self.status, RaidStatus::Stopped) + } + + #[must_use] + pub const fn is_victory(&self) -> bool { + matches!(self.status, RaidStatus::Victory) + } + + #[must_use] + pub const fn is_loss(&self) -> bool { + matches!(self.status, RaidStatus::Loss) + } + + #[must_use] + pub const fn is_started(&self) -> bool { + self.started + } + + #[must_use] + pub const fn is_active(&self) -> bool { + self.active + } + + #[must_use] + pub const fn get_groups_spawned(&self) -> i32 { + self.groups_spawned + } + + #[must_use] + pub const fn get_raid_omen_level(&self) -> i32 { + self.raid_omen_level + } + + pub fn set_raid_omen_level(&mut self, level: i32) { + self.raid_omen_level = level.clamp(0, Self::MAX_RAID_OMEN_LEVEL); + } + + #[must_use] + pub const fn has_more_waves(&self) -> bool { + if self.has_bonus_wave() { + !self.has_spawned_bonus_wave() + } else { + !self.is_final_wave() + } + } + + #[must_use] + pub const fn is_final_wave(&self) -> bool { + self.groups_spawned == self.num_groups + } + + #[must_use] + pub const fn has_bonus_wave(&self) -> bool { + self.raid_omen_level > 1 + } + + #[must_use] + pub const fn has_spawned_bonus_wave(&self) -> bool { + self.groups_spawned > self.num_groups + } + + #[must_use] + pub fn should_spawn_bonus_group(&self) -> bool { + self.is_final_wave() && self.get_total_raiders_alive() == 0 && self.has_bonus_wave() + } + + #[must_use] + pub fn should_spawn_group(&self) -> bool { + self.raid_cooldown_ticks == 0 + && (self.groups_spawned < self.num_groups || self.should_spawn_bonus_group()) + && self.get_total_raiders_alive() == 0 + } + + #[must_use] + pub fn get_total_raiders_alive(&self) -> usize { + self.group_raider_map.values().map(HashSet::len).sum() + } + + #[must_use] + pub fn get_all_raiders(&self) -> HashSet { + let mut set = HashSet::new(); + for wave_set in self.group_raider_map.values() { + set.extend(wave_set); + } + set + } + + pub async fn stop(&mut self, world: &World) { + self.active = false; + self.status = RaidStatus::Stopped; + self.remove_all_players(world).await; + } + + pub async fn remove_all_players(&mut self, world: &World) { + let players = world.players.load(); + for player in players.iter() { + if self.players_in_raid.contains(&player.gameprofile.id) { + player.remove_bossbar(self.bossbar.uuid).await; + } + } + self.players_in_raid.clear(); + } + + pub fn absorb_raid_omen(&mut self, _player: &Player) -> bool { + self.raid_omen_level = (self.raid_omen_level + 1).clamp(1, Self::MAX_RAID_OMEN_LEVEL); + true + } + + pub fn add_hero_of_the_village(&mut self, killer_uuid: Uuid) { + self.heroes_of_the_village.insert(killer_uuid); + } + + const fn get_default_num_spawns( + &self, + raider_type: RaiderType, + wave: i32, + is_bonus_wave: bool, + ) -> i32 { + let spawns = raider_type.spawns_per_wave(); + if is_bonus_wave { + spawns[self.num_groups as usize] + } else { + spawns[wave as usize] + } + } + + fn get_potential_bonus_spawns( + raider_type: RaiderType, + wave: i32, + difficulty: Difficulty, + is_bonus_wave: bool, + ) -> i32 { + let is_easy = difficulty == Difficulty::Easy; + let is_normal = difficulty == Difficulty::Normal; + let bonus_spawns = match raider_type { + RaiderType::Vindicator | RaiderType::Pillager => { + if is_easy { + rand::random::() % 2 + } else if is_normal { + 1 + } else { + 2 + } + } + RaiderType::Evoker => 0, + RaiderType::Witch => u32::from(!(is_easy || wave <= 2 || wave == 4)), + RaiderType::Ravager => u32::from(!is_easy && is_bonus_wave), + }; + if bonus_spawns > 0 { + (rand::random::() % (bonus_spawns + 1)) as i32 + } else { + 0 + } + } + + pub async fn spawn_group(&mut self, world: &Arc, pos: BlockPos) { + let mut leader_set = false; + let group_number = self.groups_spawned + 1; + self.total_health = 0.0; + let is_bonus_group = self.should_spawn_bonus_group(); + + let difficulty = world.level_info.load().difficulty; + + for raider_type in RaiderType::VALUES { + let num_spawns = self.get_default_num_spawns(raider_type, group_number, is_bonus_group) + + Self::get_potential_bonus_spawns( + raider_type, + group_number, + difficulty, + is_bonus_group, + ); + let mut ravagers_spawned = 0; + + for _ in 0..num_spawns { + let uuid = Uuid::new_v4(); + let spawn_pos = Vector3::new( + f64::from(pos.0.x) + 0.5, + f64::from(pos.0.y) + 1.0, + f64::from(pos.0.z) + 0.5, + ); + let entity_base = from_type(raider_type.entity_type(), spawn_pos, world, uuid); + + if let Some(mob) = entity_base.get_mob() + && let Some(raider) = mob.as_raider() + { + if !leader_set && raider.can_be_leader() { + raider.set_patrol_leader(true); + let banner = create_ominous_banner(); + let living = &mob.get_mob_entity().living_entity; + let mut equipment = living.entity_equipment.lock().await; + equipment.put(&EquipmentSlot::HEAD, banner.clone()); + drop(equipment); + living.send_equipment_changes(&[(EquipmentSlot::HEAD, banner)]); + self.group_to_leader_map.insert(group_number, uuid); + leader_set = true; + } + raider.set_wave(group_number); + raider.set_can_join_raid(true); + raider.apply_raid_buffs(group_number, false); + } + + self.join_raid(group_number, uuid, &entity_base); + world.spawn_entity(entity_base.clone()).await; + + if *raider_type.entity_type() == EntityType::RAVAGER { + let mut riding_type: Option<&'static EntityType> = None; + if group_number == Self::get_num_groups(Difficulty::Normal) { + riding_type = Some(&EntityType::PILLAGER); + } else if group_number >= Self::get_num_groups(Difficulty::Hard) { + if ravagers_spawned == 0 { + riding_type = Some(&EntityType::EVOKER); + } else { + riding_type = Some(&EntityType::VINDICATOR); + } + } + ravagers_spawned += 1; + + if let Some(riding_type) = riding_type { + let rider_uuid = Uuid::new_v4(); + let rider_base = from_type(riding_type, spawn_pos, world, rider_uuid); + if let Some(mob) = rider_base.get_mob() + && let Some(raider) = mob.as_raider() + { + raider.set_wave(group_number); + raider.set_can_join_raid(true); + raider.apply_raid_buffs(group_number, false); + } + self.join_raid(group_number, rider_uuid, &rider_base); + world.spawn_entity(rider_base).await; + } + } + } + } + + self.wave_spawn_pos = None; + self.groups_spawned += 1; + self.update_bossbar(world).await; + } + + pub fn join_raid(&mut self, wave: i32, uuid: Uuid, entity_base: &Arc) { + self.group_raider_map.entry(wave).or_default().insert(uuid); + + if let Some(living) = entity_base.get_living_entity() { + self.total_health += living.health.load(); + } + } + + #[must_use] + pub fn find_random_spawn_pos(&self, _world: &World, max_tries: usize) -> Option { + let seconds_remaining = self.raid_cooldown_ticks / 20; + let how_far = 0.22 * (seconds_remaining as f32) - 0.24; + let start_angle = rand::random::() * std::f32::consts::PI * 2.0; + + for i in 0..max_tries { + let angle = start_angle + std::f32::consts::PI * (i as f32) / 8.0; + let spawn_x = self.center.0.x + + ((angle.cos() * 32.0 * how_far) as i32) + + (rand::random::().rem_euclid(3)) * (how_far as i32); + let spawn_z = self.center.0.z + + ((angle.sin() * 32.0 * how_far) as i32) + + (rand::random::().rem_euclid(3)) * (how_far as i32); + let spawn_y = self.center.0.y; + + if (spawn_y - self.center.0.y).abs() <= 96 { + let spawn_pos = BlockPos(Vector3::new(spawn_x, spawn_y, spawn_z)); + return Some(spawn_pos); + } + } + None + } + + pub fn play_sound(&self, world: &World, sound_origin: BlockPos) { + let raid_loc = sound_origin.to_f64(); + let players = world.players.load(); + for player in players.iter() { + let player_loc = player.get_entity().pos.load(); + let dx = raid_loc.x - player_loc.x; + let dz = raid_loc.z - player_loc.z; + let dist = dx.hypot(dz); + let sound_pos = if dist > 0.001 { + Vector3::new( + player_loc.x + (13.0 / dist) * dx, + player_loc.y, + player_loc.z + (13.0 / dist) * dz, + ) + } else { + player_loc + }; + if dist <= 64.0 || self.players_in_raid.contains(&player.gameprofile.id) { + world.play_sound( + Sound::EventRaidHorn, + pumpkin_data::sound::SoundCategory::Neutral, + &sound_pos, + ); + } + } + } + + pub async fn update_players(&mut self, world: &World) { + let center_f64 = self.center.to_f64(); + let players = world.players.load(); + let mut current_nearby = HashSet::new(); + + for player in players.iter() { + let pos = player.get_entity().pos.load(); + let dist_sq = pos.squared_distance_to_vec(¢er_f64); + if dist_sq <= Self::VALID_RAID_RADIUS_SQR && player.living_entity.health.load() > 0.0 { + current_nearby.insert(player.gameprofile.id); + if self.players_in_raid.insert(player.gameprofile.id) { + player.send_bossbar(&self.bossbar).await; + } + } + } + + let mut to_remove = Vec::new(); + for player_uuid in &self.players_in_raid { + if !current_nearby.contains(player_uuid) { + to_remove.push(*player_uuid); + } + } + + for player_uuid in to_remove { + self.players_in_raid.remove(&player_uuid); + if let Some(player) = players.iter().find(|p| p.gameprofile.id == player_uuid) { + player.remove_bossbar(self.bossbar.uuid).await; + } + } + } + + pub fn update_raiders(&mut self, world: &World) { + let center_f64 = self.center.to_f64(); + + for raiders in self.group_raider_map.values_mut() { + let mut wave_dead = Vec::new(); + for &raider_uuid in raiders.iter() { + let entity = world.get_entity_by_uuid(raider_uuid); + match entity { + Some(e) => { + let is_dead = e.get_living_entity().is_none_or(|l| l.health.load() <= 0.0); + let pos = e.get_entity().pos.load(); + let dist_sq = pos.squared_distance_to_vec(¢er_f64); + if is_dead || dist_sq >= Self::RAID_REMOVAL_THRESHOLD_SQR { + wave_dead.push(raider_uuid); + } + } + None => { + wave_dead.push(raider_uuid); + } + } + } + + for uuid in wave_dead { + raiders.remove(&uuid); + } + } + } + + pub async fn update_bossbar(&mut self, world: &World) { + let living_health = self.get_health_of_living_raiders(world); + let progress = if self.total_health > 0.0 { + (living_health / self.total_health).clamp(0.0, 1.0) + } else { + 0.0 + }; + self.bossbar.health = progress; + + let players = world.players.load(); + for player in players.iter() { + if self.players_in_raid.contains(&player.gameprofile.id) { + player + .update_bossbar_health(&self.bossbar.uuid, self.bossbar.health) + .await; + player + .update_bossbar_title(&self.bossbar.uuid, self.bossbar.title.clone()) + .await; + } + } + } + + #[must_use] + pub fn get_health_of_living_raiders(&self, world: &World) -> f32 { + let mut health = 0.0; + for raiders in self.group_raider_map.values() { + for &uuid in raiders { + if let Some(e) = world.get_entity_by_uuid(uuid) + && let Some(living) = e.get_living_entity() + { + health += living.health.load(); + } + } + } + health + } + + pub async fn tick(&mut self, world: &Arc) { + if self.is_stopped() { + return; + } + + if self.status == RaidStatus::Ongoing { + if world.level_info.load().difficulty == Difficulty::Peaceful { + self.stop(world).await; + return; + } + + self.ticks_active += 1; + if self.ticks_active >= 48000 { + self.stop(world).await; + return; + } + + let raiders_alive = self.get_total_raiders_alive(); + if raiders_alive == 0 && self.has_more_waves() { + if self.raid_cooldown_ticks <= 0 { + if self.groups_spawned > 0 { + self.raid_cooldown_ticks = 300; + self.bossbar.title = TextComponent::translate("event.minecraft.raid", []); + } + } else { + if self.wave_spawn_pos.is_none() && self.raid_cooldown_ticks % 5 == 0 { + self.wave_spawn_pos = self.find_random_spawn_pos(world, 8); + } + + if self.raid_cooldown_ticks == 300 || self.raid_cooldown_ticks % 20 == 0 { + self.update_players(world).await; + } + + self.raid_cooldown_ticks -= 1; + self.bossbar.health = + ((300 - self.raid_cooldown_ticks) as f32 / 300.0).clamp(0.0, 1.0); + } + } + + if self.ticks_active.is_multiple_of(20) { + self.update_players(world).await; + self.update_raiders(world); + let alive = self.get_total_raiders_alive(); + if alive > 0 && alive <= 2 { + self.bossbar.title = TextComponent::translate( + "event.minecraft.raid.raiders_remaining", + [TextComponent::text(alive.to_string())], + ); + } else { + self.bossbar.title = TextComponent::translate("event.minecraft.raid", []); + } + self.update_bossbar(world).await; + } + + while self.should_spawn_group() { + let spawn_pos = self + .wave_spawn_pos + .or_else(|| self.find_random_spawn_pos(world, 20)) + .unwrap_or(self.center); + + self.started = true; + self.spawn_group(world, spawn_pos).await; + self.play_sound(world, spawn_pos); + } + + let raiders_alive = self.get_total_raiders_alive(); + if self.is_started() && !self.has_more_waves() && raiders_alive == 0 { + if self.post_raid_ticks < 40 { + self.post_raid_ticks += 1; + } else { + self.status = RaidStatus::Victory; + let effect = Effect { + effect_type: &StatusEffect::HERO_OF_THE_VILLAGE, + duration: 48000, + amplifier: (self.raid_omen_level - 1).max(0) as u8, + ambient: false, + show_particles: false, + show_icon: true, + blend: true, + }; + let players = world.players.load(); + for player in players.iter() { + if self.heroes_of_the_village.contains(&player.gameprofile.id) { + player.add_effect(effect.clone()).await; + } + } + } + } + } else if self.is_over() { + self.celebration_ticks += 1; + if self.celebration_ticks >= 600 { + self.stop(world).await; + return; + } + + if self.celebration_ticks.is_multiple_of(20) { + self.update_players(world).await; + if self.is_victory() { + self.bossbar.health = 0.0; + self.bossbar.title = + TextComponent::translate("event.minecraft.raid.victory.full", []); + } else { + self.bossbar.title = + TextComponent::translate("event.minecraft.raid.defeat.full", []); + } + self.update_bossbar(world).await; + } + } + } +} + +#[derive(Default)] +pub struct Raids { + pub raid_map: HashMap, + pub next_id: i32, + pub tick_counter: u64, +} + +impl Raids { + #[must_use] + pub fn get(&self, raid_id: i32) -> Option<&Raid> { + self.raid_map.get(&raid_id) + } + + pub fn get_mut(&mut self, raid_id: i32) -> Option<&mut Raid> { + self.raid_map.get_mut(&raid_id) + } + + #[must_use] + pub fn get_raid_at(&self, pos: &BlockPos) -> Option<&Raid> { + let pos_f64 = pos.to_f64(); + self.raid_map.values().find(|r| { + r.is_active() + && r.center.to_f64().squared_distance_to_vec(&pos_f64) + <= Raid::VALID_RAID_RADIUS_SQR + }) + } + + pub fn get_raid_at_mut(&mut self, pos: &BlockPos) -> Option<&mut Raid> { + let pos_f64 = pos.to_f64(); + self.raid_map.values_mut().find(|r| { + r.is_active() + && r.center.to_f64().squared_distance_to_vec(&pos_f64) + <= Raid::VALID_RAID_RADIUS_SQR + }) + } + + #[must_use] + pub fn get_nearby_raid(&self, pos: &BlockPos, max_dist_sqr: f64) -> Option<&Raid> { + let pos_f64 = pos.to_f64(); + let mut closest = None; + let mut closest_dist = max_dist_sqr; + + for raid in self.raid_map.values() { + let dist = raid.center.to_f64().squared_distance_to_vec(&pos_f64); + if raid.is_active() && dist < closest_dist { + closest = Some(raid); + closest_dist = dist; + } + } + closest + } + + pub fn create_or_extend_raid( + &mut self, + player: &Player, + raid_position: BlockPos, + world: &Arc, + ) -> Option { + let raid_center_pos = raid_position; + + let existing_id = self.raid_map.iter().find_map(|(&id, r)| { + let dist = r + .center + .to_f64() + .squared_distance_to_vec(&raid_center_pos.to_f64()); + (r.is_active() && dist <= Raid::VALID_RAID_RADIUS_SQR).then_some(id) + }); + + if let Some(id) = existing_id { + if let Some(raid) = self.raid_map.get_mut(&id) { + raid.absorb_raid_omen(player); + } + Some(id) + } else { + self.next_id += 1; + let id = self.next_id; + let mut raid = Raid::new(id, raid_center_pos, world.level_info.load().difficulty); + raid.absorb_raid_omen(player); + self.raid_map.insert(id, raid); + Some(id) + } + } + + pub async fn tick(&mut self, world: &Arc) { + self.tick_counter += 1; + + let mut stopped_ids = Vec::new(); + for (&id, raid) in &mut self.raid_map { + raid.tick(world).await; + if raid.is_stopped() { + stopped_ids.push(id); + } + } + + for id in stopped_ids { + if let Some(mut raid) = self.raid_map.remove(&id) { + raid.remove_all_players(world).await; + } + } + } +} diff --git a/crates/pumpkin/src/world/villager_poi.rs b/crates/pumpkin/src/world/villager_poi.rs index 813dba859..973bbae13 100644 --- a/crates/pumpkin/src/world/villager_poi.rs +++ b/crates/pumpkin/src/world/villager_poi.rs @@ -102,6 +102,24 @@ impl VillagerPoiStorage { sites.sort_unstable_by_key(|(distance, _)| *distance); sites.into_iter().map(|(_, position)| position).collect() } + + #[must_use] + pub fn get_nearest_job_site(&self, origin: BlockPos, radius: i32) -> Option { + let radius_squared = i64::from(radius).pow(2); + let mut closest = None; + let mut closest_dist = radius_squared; + + for position in self.job_sites.keys() { + let delta = position.0 - origin.0; + let distance_squared = + i64::from(delta.x).pow(2) + i64::from(delta.y).pow(2) + i64::from(delta.z).pow(2); + if distance_squared <= closest_dist { + closest = Some(*position); + closest_dist = distance_squared; + } + } + closest + } } #[must_use]