From fd9bf168716f195e7e6225b93dfb099aa01b1fde Mon Sep 17 00:00:00 2001 From: mat Date: Tue, 17 Jun 2025 09:30:09 +1200 Subject: implement EntityHitResult --- azalea-client/src/plugins/interact/mod.rs | 504 +++++++++++++++++++++++++++++ azalea-client/src/plugins/interact/pick.rs | 268 +++++++++++++++ 2 files changed, 772 insertions(+) create mode 100644 azalea-client/src/plugins/interact/mod.rs create mode 100644 azalea-client/src/plugins/interact/pick.rs (limited to 'azalea-client/src/plugins/interact') diff --git a/azalea-client/src/plugins/interact/mod.rs b/azalea-client/src/plugins/interact/mod.rs new file mode 100644 index 00000000..079b57f6 --- /dev/null +++ b/azalea-client/src/plugins/interact/mod.rs @@ -0,0 +1,504 @@ +pub mod pick; + +use std::collections::HashMap; + +use azalea_block::BlockState; +use azalea_core::{ + direction::Direction, + game_type::GameMode, + hit_result::{BlockHitResult, HitResult}, + position::{BlockPos, Vec3}, + tick::GameTick, +}; +use azalea_entity::{ + Attributes, LocalEntity, LookDirection, + attributes::{ + creative_block_interaction_range_modifier, creative_entity_interaction_range_modifier, + }, + clamp_look_direction, +}; +use azalea_inventory::{ItemStack, ItemStackData, components}; +use azalea_physics::PhysicsSet; +use azalea_protocol::packets::game::{ + ServerboundInteract, ServerboundUseItem, + s_interact::{self, InteractionHand}, + s_swing::ServerboundSwing, + s_use_item_on::ServerboundUseItemOn, +}; +use azalea_world::{Instance, MinecraftEntityId}; +use bevy_app::{App, Plugin, Update}; +use bevy_ecs::prelude::*; +use tracing::warn; + +use super::mining::Mining; +use crate::{ + Client, + attack::handle_attack_event, + interact::pick::{HitResultComponent, update_hit_result_component}, + inventory::{Inventory, InventorySet}, + local_player::{LocalGameMode, PermissionLevel, PlayerAbilities}, + movement::MoveEventsSet, + packet::game::SendPacketEvent, + respawn::perform_respawn, +}; + +/// A plugin that allows clients to interact with blocks in the world. +pub struct InteractPlugin; +impl Plugin for InteractPlugin { + fn build(&self, app: &mut App) { + app.add_event::() + .add_event::() + .add_systems( + Update, + ( + ( + update_attributes_for_held_item, + update_attributes_for_gamemode, + ) + .in_set(UpdateAttributesSet) + .chain(), + handle_start_use_item_event, + update_hit_result_component.after(clamp_look_direction), + handle_swing_arm_event, + ) + .after(InventorySet) + .after(MoveEventsSet) + .after(perform_respawn) + .after(handle_attack_event) + .chain(), + ) + .add_systems(GameTick, handle_start_use_item_queued.before(PhysicsSet)) + .add_observer(handle_swing_arm_trigger); + } +} + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub struct UpdateAttributesSet; + +impl Client { + /// Right-click a block. + /// + /// The behavior of this depends on the target block, + /// and it'll either place the block you're holding in your hand or use the + /// block you clicked (like toggling a lever). + /// + /// Note that this may trigger anticheats as it doesn't take into account + /// whether you're actually looking at the block. + pub fn block_interact(&self, position: BlockPos) { + self.ecs.lock().send_event(StartUseItemEvent { + entity: self.entity, + hand: InteractionHand::MainHand, + force_block: Some(position), + }); + } + + /// Right-click the currently held item. + /// + /// If the item is consumable, then it'll act as if right-click was held + /// until the item finishes being consumed. You can use this to eat food. + /// + /// If we're looking at a block or entity, then it will be clicked. Also see + /// [`Client::block_interact`]. + pub fn start_use_item(&self) { + self.ecs.lock().send_event(StartUseItemEvent { + entity: self.entity, + hand: InteractionHand::MainHand, + force_block: None, + }); + } +} + +/// A component that contains information about our local block state +/// predictions. +#[derive(Component, Clone, Debug, Default)] +pub struct BlockStatePredictionHandler { + /// The total number of changes that this client has made to blocks. + seq: u32, + server_state: HashMap, +} +#[derive(Clone, Debug)] +struct ServerVerifiedState { + seq: u32, + block_state: BlockState, + /// Used for teleporting the player back if we're colliding with the block + /// that got placed back. + #[allow(unused)] + player_pos: Vec3, +} + +impl BlockStatePredictionHandler { + /// Get the next sequence number that we're going to use and increment the + /// value. + pub fn start_predicting(&mut self) -> u32 { + self.seq += 1; + self.seq + } + + /// Should be called right before the client updates a block with its + /// prediction. + /// + /// This is used to make sure that we can rollback to this state if the + /// server acknowledges the sequence number (with + /// [`ClientboundBlockChangedAck`]) without having sent a block update. + /// + /// [`ClientboundBlockChangedAck`]: azalea_protocol::packets::game::ClientboundBlockChangedAck + pub fn retain_known_server_state( + &mut self, + pos: BlockPos, + old_state: BlockState, + player_pos: Vec3, + ) { + self.server_state + .entry(pos) + .and_modify(|s| s.seq = self.seq) + .or_insert(ServerVerifiedState { + seq: self.seq, + block_state: old_state, + player_pos, + }); + } + + /// Save this update as the correct server state so when the server sends a + /// [`ClientboundBlockChangedAck`] we don't roll back this new update. + /// + /// This should be used when we receive a block update from the server. + /// + /// [`ClientboundBlockChangedAck`]: azalea_protocol::packets::game::ClientboundBlockChangedAck + pub fn update_known_server_state(&mut self, pos: BlockPos, state: BlockState) -> bool { + if let Some(s) = self.server_state.get_mut(&pos) { + s.block_state = state; + true + } else { + false + } + } + + pub fn end_prediction_up_to(&mut self, seq: u32, world: &Instance) { + let mut to_remove = Vec::new(); + for (pos, state) in &self.server_state { + if state.seq > seq { + continue; + } + to_remove.push(*pos); + + // syncBlockState + let client_block_state = world.get_block_state(*pos).unwrap_or_default(); + let server_block_state = state.block_state; + if client_block_state == server_block_state { + continue; + } + world.set_block_state(*pos, server_block_state); + // TODO: implement these two functions + // if is_colliding(player, *pos, server_block_state) { + // abs_snap_to(state.player_pos); + // } + } + + for pos in to_remove { + self.server_state.remove(&pos); + } + } +} + +/// An event that makes one of our clients simulate a right-click. +/// +/// This event just inserts the [`StartUseItemQueued`] component on the given +/// entity. +#[doc(alias("right click"))] +#[derive(Event)] +pub struct StartUseItemEvent { + pub entity: Entity, + pub hand: InteractionHand, + /// See [`StartUseItemQueued::force_block`]. + pub force_block: Option, +} +pub fn handle_start_use_item_event( + mut commands: Commands, + mut events: EventReader, +) { + for event in events.read() { + commands.entity(event.entity).insert(StartUseItemQueued { + hand: event.hand, + force_block: event.force_block, + }); + } +} + +/// A component that makes our client simulate a right-click on the next +/// [`GameTick`]. It's removed after that tick. +/// +/// You may find it more convenient to use [`StartUseItemEvent`] instead, which +/// just inserts this component for you. +/// +/// [`GameTick`]: azalea_core::tick::GameTick +#[derive(Component, Debug)] +pub struct StartUseItemQueued { + pub hand: InteractionHand, + /// Optionally force us to send a [`ServerboundUseItemOn`] on the given + /// block. + /// + /// This is useful if you want to interact with a block without looking at + /// it, but should be avoided to stay compatible with anticheats. + pub force_block: Option, +} +#[allow(clippy::type_complexity)] +pub fn handle_start_use_item_queued( + mut commands: Commands, + query: Query<( + Entity, + &StartUseItemQueued, + &mut BlockStatePredictionHandler, + &HitResultComponent, + &LookDirection, + Option<&Mining>, + )>, + entity_id_query: Query<&MinecraftEntityId>, +) { + for (entity, start_use_item, mut prediction_handler, hit_result, look_direction, mining) in + query + { + commands.entity(entity).remove::(); + + if mining.is_some() { + warn!("Got a StartUseItemEvent for a client that was mining"); + } + + // TODO: this also skips if LocalPlayer.handsBusy is true, which is used when + // rowing a boat + + let mut hit_result = (**hit_result).clone(); + + if let Some(force_block) = start_use_item.force_block { + let hit_result_matches = if let HitResult::Block(block_hit_result) = &hit_result { + block_hit_result.block_pos == force_block + } else { + false + }; + + if !hit_result_matches { + // we're not looking at the block, so make up some numbers + hit_result = HitResult::Block(BlockHitResult { + location: force_block.center(), + direction: Direction::Up, + block_pos: force_block, + inside: false, + world_border: false, + miss: false, + }); + } + } + + match &hit_result { + HitResult::Block(r) => { + let seq = prediction_handler.start_predicting(); + if r.miss { + commands.trigger(SendPacketEvent::new( + entity, + ServerboundUseItem { + hand: start_use_item.hand, + seq, + x_rot: look_direction.x_rot, + y_rot: look_direction.y_rot, + }, + )); + } else { + commands.trigger(SendPacketEvent::new( + entity, + ServerboundUseItemOn { + hand: start_use_item.hand, + block_hit: r.into(), + seq, + }, + )); + // TODO: depending on the result of useItemOn, this might + // also need to send a SwingArmEvent. + // basically, this TODO is for simulating block + // interactions/placements on the client-side. + } + } + HitResult::Entity(r) => { + // TODO: worldborder check + + let Ok(entity_id) = entity_id_query.get(r.entity).copied() else { + warn!("tried to interact with an entity that doesn't have MinecraftEntityId"); + continue; + }; + + commands.trigger(SendPacketEvent::new( + entity, + ServerboundInteract { + entity_id, + action: s_interact::ActionType::InteractAt { + location: r.location, + hand: InteractionHand::MainHand, + }, + // TODO: sneaking + using_secondary_action: false, + }, + )); + } + } + } +} + +/// Whether we can't interact with the block, based on your gamemode. If +/// this is false, then we can interact with the block. +/// +/// Passing the inventory, block position, and instance is necessary for the +/// adventure mode check. +pub fn check_is_interaction_restricted( + instance: &Instance, + block_pos: BlockPos, + game_mode: &GameMode, + inventory: &Inventory, +) -> bool { + match game_mode { + GameMode::Adventure => { + // vanilla checks for abilities.mayBuild here but servers have no + // way of modifying that + + let held_item = inventory.held_item(); + match &held_item { + ItemStack::Present(item) => { + let block = instance.chunks.get_block_state(block_pos); + let Some(block) = block else { + // block isn't loaded so just say that it is restricted + return true; + }; + check_block_can_be_broken_by_item_in_adventure_mode(item, &block) + } + _ => true, + } + } + GameMode::Spectator => true, + _ => false, + } +} + +/// Check if the item has the `CanDestroy` tag for the block. +pub fn check_block_can_be_broken_by_item_in_adventure_mode( + item: &ItemStackData, + _block: &BlockState, +) -> bool { + // minecraft caches the last checked block but that's kind of an unnecessary + // optimization and makes the code too complicated + + if !item.components.has::() { + // no CanDestroy tag + return false; + }; + + false + + // for block_predicate in can_destroy { + // // TODO + // // defined in BlockPredicateArgument.java + // } + + // true +} + +pub fn can_use_game_master_blocks( + abilities: &PlayerAbilities, + permission_level: &PermissionLevel, +) -> bool { + abilities.instant_break && **permission_level >= 2 +} + +/// Swing your arm. This is purely a visual effect and won't interact with +/// anything in the world. +#[derive(Event, Clone, Debug)] +pub struct SwingArmEvent { + pub entity: Entity, +} +pub fn handle_swing_arm_trigger(trigger: Trigger, mut commands: Commands) { + commands.trigger(SendPacketEvent::new( + trigger.event().entity, + ServerboundSwing { + hand: InteractionHand::MainHand, + }, + )); +} +pub fn handle_swing_arm_event(mut events: EventReader, mut commands: Commands) { + for event in events.read() { + commands.trigger(event.clone()); + } +} + +#[allow(clippy::type_complexity)] +fn update_attributes_for_held_item( + mut query: Query<(&mut Attributes, &Inventory), (With, Changed)>, +) { + for (mut attributes, inventory) in &mut query { + let held_item = inventory.held_item(); + + use azalea_registry::Item; + let added_attack_speed = match held_item.kind() { + Item::WoodenSword => -2.4, + Item::WoodenShovel => -3.0, + Item::WoodenPickaxe => -2.8, + Item::WoodenAxe => -3.2, + Item::WoodenHoe => -3.0, + + Item::StoneSword => -2.4, + Item::StoneShovel => -3.0, + Item::StonePickaxe => -2.8, + Item::StoneAxe => -3.2, + Item::StoneHoe => -2.0, + + Item::GoldenSword => -2.4, + Item::GoldenShovel => -3.0, + Item::GoldenPickaxe => -2.8, + Item::GoldenAxe => -3.0, + Item::GoldenHoe => -3.0, + + Item::IronSword => -2.4, + Item::IronShovel => -3.0, + Item::IronPickaxe => -2.8, + Item::IronAxe => -3.1, + Item::IronHoe => -1.0, + + Item::DiamondSword => -2.4, + Item::DiamondShovel => -3.0, + Item::DiamondPickaxe => -2.8, + Item::DiamondAxe => -3.0, + Item::DiamondHoe => 0.0, + + Item::NetheriteSword => -2.4, + Item::NetheriteShovel => -3.0, + Item::NetheritePickaxe => -2.8, + Item::NetheriteAxe => -3.0, + Item::NetheriteHoe => 0.0, + + Item::Trident => -2.9, + _ => 0., + }; + attributes + .attack_speed + .insert(azalea_entity::attributes::base_attack_speed_modifier( + added_attack_speed, + )); + } +} + +fn update_attributes_for_gamemode( + query: Query<(&mut Attributes, &LocalGameMode), (With, Changed)>, +) { + for (mut attributes, game_mode) in query { + if game_mode.current == GameMode::Creative { + attributes + .block_interaction_range + .insert(creative_block_interaction_range_modifier()); + attributes + .entity_interaction_range + .insert(creative_entity_interaction_range_modifier()); + } else { + attributes + .block_interaction_range + .remove(&creative_block_interaction_range_modifier().id); + attributes + .entity_interaction_range + .remove(&creative_entity_interaction_range_modifier().id); + } + } +} diff --git a/azalea-client/src/plugins/interact/pick.rs b/azalea-client/src/plugins/interact/pick.rs new file mode 100644 index 00000000..5b62762c --- /dev/null +++ b/azalea-client/src/plugins/interact/pick.rs @@ -0,0 +1,268 @@ +use azalea_core::{ + aabb::AABB, + direction::Direction, + hit_result::{BlockHitResult, EntityHitResult, HitResult}, + position::Vec3, +}; +use azalea_entity::{ + Attributes, Dead, EyeHeight, LocalEntity, LookDirection, Physics, Position, + metadata::{ArmorStandMarker, Marker}, + view_vector, +}; +use azalea_physics::{ + clip::{BlockShapeType, ClipContext, FluidPickType}, + collision::entity_collisions::{PhysicsQuery, get_entities}, +}; +use azalea_world::{Instance, InstanceContainer, InstanceName}; +use bevy_ecs::prelude::*; +use derive_more::{Deref, DerefMut}; + +/// A component that contains the block or entity that the player is currently +/// looking at. +#[doc(alias("looking at", "looking at block", "crosshair"))] +#[derive(Component, Clone, Debug, Deref, DerefMut)] +pub struct HitResultComponent(HitResult); + +#[allow(clippy::type_complexity)] +pub fn update_hit_result_component( + mut commands: Commands, + mut query: Query< + ( + Entity, + Option<&mut HitResultComponent>, + &Position, + &EyeHeight, + &LookDirection, + &InstanceName, + &Physics, + &Attributes, + ), + With, + >, + instance_container: Res, + physics_query: PhysicsQuery, + pickable_query: PickableEntityQuery, +) { + for ( + entity, + hit_result_ref, + position, + eye_height, + look_direction, + world_name, + physics, + attributes, + ) in &mut query + { + let block_pick_range = attributes.block_interaction_range.calculate(); + let entity_pick_range = attributes.entity_interaction_range.calculate(); + + let eye_position = position.up(eye_height.into()); + + let Some(world_lock) = instance_container.get(world_name) else { + continue; + }; + let world = world_lock.read(); + + let aabb = &physics.bounding_box; + let hit_result = pick( + entity, + *look_direction, + eye_position, + aabb, + &world, + entity_pick_range, + block_pick_range, + &physics_query, + &pickable_query, + ); + if let Some(mut hit_result_ref) = hit_result_ref { + **hit_result_ref = hit_result; + } else { + commands + .entity(entity) + .insert(HitResultComponent(hit_result)); + } + } +} + +pub type PickableEntityQuery<'world, 'state, 'a> = Query< + 'world, + 'state, + Option<&'a ArmorStandMarker>, + (Without, Without, Without), +>; + +/// Get the block or entity that a player would be looking at if their eyes were +/// at the given direction and position. +/// +/// If you need to get the block/entity the player is looking at right now, use +/// [`HitResultComponent`]. +/// +/// Also see [`pick_block`]. +/// +/// TODO: does not currently check for entities +pub fn pick( + source_entity: Entity, + look_direction: LookDirection, + eye_position: Vec3, + aabb: &AABB, + world: &Instance, + entity_pick_range: f64, + block_pick_range: f64, + physics_query: &PhysicsQuery, + pickable_query: &PickableEntityQuery, +) -> HitResult { + // vanilla does extra math here to calculate the pick result in between ticks by + // interpolating, but since clients can still only interact on exact ticks, that + // isn't relevant for us. + + let mut max_range = entity_pick_range.max(block_pick_range); + let mut max_range_squared = max_range.powi(2); + + let block_hit_result = pick_block(look_direction, eye_position, &world.chunks, max_range); + let block_hit_result_dist_squared = block_hit_result.location.distance_squared_to(eye_position); + if !block_hit_result.miss { + max_range_squared = block_hit_result_dist_squared; + max_range = block_hit_result_dist_squared.sqrt(); + } + + let view_vector = view_vector(look_direction); + let end_position = eye_position + (view_vector * max_range); + let inflate_by = 1.; + let pick_aabb = aabb + .expand_towards(view_vector * max_range) + .inflate_all(inflate_by); + + let is_pickable = |entity: Entity| { + // TODO: ender dragon and projectiles have extra logic here. also, we shouldn't + // be able to pick spectators. + if let Ok(armor_stand_marker) = pickable_query.get(entity) { + if let Some(armor_stand_marker) = armor_stand_marker + && armor_stand_marker.0 + { + false + } else { + true + } + } else { + true + } + }; + let entity_hit_result = pick_entity( + source_entity, + eye_position, + end_position, + world, + max_range_squared, + &is_pickable, + &pick_aabb, + physics_query, + ); + + // TODO + if let Some(entity_hit_result) = entity_hit_result + && entity_hit_result.location.distance_squared_to(eye_position) + < block_hit_result_dist_squared + { + filter_hit_result( + HitResult::Entity(entity_hit_result), + eye_position, + entity_pick_range, + ) + } else { + filter_hit_result( + HitResult::Block(block_hit_result), + eye_position, + block_pick_range, + ) + } +} + +fn filter_hit_result(hit_result: HitResult, eye_position: Vec3, range: f64) -> HitResult { + let location = hit_result.location(); + if !location.closer_than(eye_position, range) { + let direction = Direction::nearest(location - eye_position); + HitResult::new_miss(location, direction, location.into()) + } else { + hit_result + } +} + +/// Get the block that a player would be looking at if their eyes were at the +/// given direction and position. +/// +/// Also see [`pick`]. +pub fn pick_block( + look_direction: LookDirection, + eye_position: Vec3, + chunks: &azalea_world::ChunkStorage, + pick_range: f64, +) -> BlockHitResult { + let view_vector = view_vector(look_direction); + let end_position = eye_position + (view_vector * pick_range); + + azalea_physics::clip::clip( + chunks, + ClipContext { + from: eye_position, + to: end_position, + block_shape_type: BlockShapeType::Outline, + fluid_pick_type: FluidPickType::None, + }, + ) +} + +// port of getEntityHitResult +fn pick_entity( + source_entity: Entity, + eye_position: Vec3, + end_position: Vec3, + world: &azalea_world::Instance, + pick_range_squared: f64, + predicate: &dyn Fn(Entity) -> bool, + aabb: &AABB, + physics_query: &PhysicsQuery, +) -> Option { + let mut picked_distance_squared = pick_range_squared; + let mut result = None; + + for (candidate, candidate_aabb) in + get_entities(world, Some(source_entity), aabb, predicate, physics_query) + { + // TODO: if the entity is "REDIRECTABLE_PROJECTILE" then this should be 1.0. + // azalea needs support for entity tags first for this to be possible. see + // getPickRadius in decompiled minecraft source + let candidate_pick_radius = 0.; + let candidate_aabb = candidate_aabb.inflate_all(candidate_pick_radius); + let clip_location = candidate_aabb.clip(eye_position, end_position); + + if candidate_aabb.contains(eye_position) { + if picked_distance_squared >= 0. { + result = Some(EntityHitResult { + location: clip_location.unwrap_or(eye_position), + entity: candidate, + }); + picked_distance_squared = 0.; + } + } else if let Some(clip_location) = clip_location { + let distance_squared = eye_position.distance_squared_to(clip_location); + if distance_squared < picked_distance_squared || picked_distance_squared == 0. { + // TODO: don't pick the entity we're riding on + // if candidate_root_vehicle == entity_root_vehicle { + // if picked_distance_squared == 0. { + // picked_entity = Some(candidate); + // picked_location = Some(clip_location); + // } + // } else { + result = Some(EntityHitResult { + location: clip_location, + entity: candidate, + }); + picked_distance_squared = distance_squared; + } + } + } + + result +} -- cgit v1.2.3