diff options
| author | mat <git@matdoes.dev> | 2026-01-18 09:50:45 -1245 |
|---|---|---|
| committer | mat <git@matdoes.dev> | 2026-01-19 05:35:49 +0700 |
| commit | 268c62587e090c72b67a29e1cc42cda6c9d7340b (patch) | |
| tree | 961d0b4d0bd22d17f4ad6c8b77f02f02566b838e | |
| parent | fb92f65b3da49b6487bf6fa05010b12a3ab5d4ed (diff) | |
| download | azalea-drasl-268c62587e090c72b67a29e1cc42cda6c9d7340b.tar.xz | |
add simulation-based pathfinder execution engine
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | azalea-client/src/plugins/mining.rs | 2 | ||||
| -rw-r--r-- | azalea-client/tests/simulation/move_and_despawn_entity.rs | 6 | ||||
| -rw-r--r-- | azalea-client/tests/simulation/move_despawned_entity.rs | 3 | ||||
| -rw-r--r-- | azalea-client/tests/simulation/teleport_movement.rs | 2 | ||||
| -rw-r--r-- | azalea-core/src/position.rs | 17 | ||||
| -rw-r--r-- | azalea-entity/src/lib.rs | 4 | ||||
| -rw-r--r-- | azalea-world/src/container.rs | 2 | ||||
| -rw-r--r-- | azalea-world/src/lib.rs | 2 | ||||
| -rw-r--r-- | azalea/examples/testbot/main.rs | 22 | ||||
| -rw-r--r-- | azalea/src/pathfinder/debug.rs | 6 | ||||
| -rw-r--r-- | azalea/src/pathfinder/execute/mod.rs | 206 | ||||
| -rw-r--r-- | azalea/src/pathfinder/execute/simulation.rs | 500 | ||||
| -rw-r--r-- | azalea/src/pathfinder/mod.rs | 10 | ||||
| -rw-r--r-- | azalea/src/pathfinder/moves/mod.rs | 24 | ||||
| -rw-r--r-- | azalea/src/pathfinder/simulation.rs | 20 | ||||
| -rw-r--r-- | azalea/src/pathfinder/world.rs | 8 | ||||
| -rw-r--r-- | azalea/src/swarm/builder.rs | 12 |
18 files changed, 759 insertions, 89 deletions
@@ -18,7 +18,7 @@ _Currently supported Minecraft version: `1.21.11`._ ## Features - [Accurate physics](https://azalea.matdoes.dev/azalea_physics/) (but some features like entity pushing and elytras aren't implemented yet) -- [Pathfinder](https://azalea.matdoes.dev/azalea/pathfinder/index.html) (partially based on Baritone and several times faster) +- [Pathfinder](https://azalea.matdoes.dev/azalea/pathfinder/index.html) - [Swarms](https://azalea.matdoes.dev/azalea/swarm/index.html) - [Breaking blocks](https://azalea.matdoes.dev/azalea/struct.Client.html#method.mine) - [Block interactions & building](https://azalea.matdoes.dev/azalea/struct.Client.html#method.block_interact) (this doesn't predict the block interactions/placement on the client yet, but it's usually fine) diff --git a/azalea-client/src/plugins/mining.rs b/azalea-client/src/plugins/mining.rs index 35b01a6a..5e724071 100644 --- a/azalea-client/src/plugins/mining.rs +++ b/azalea-client/src/plugins/mining.rs @@ -8,7 +8,7 @@ use azalea_inventory::ItemStack; use azalea_physics::{PhysicsSystems, collision::BlockWithShape}; use azalea_protocol::packets::game::s_player_action::{self, ServerboundPlayerAction}; use azalea_registry::builtin::{BlockKind, ItemKind}; -use azalea_world::{Worlds, WorldName}; +use azalea_world::{WorldName, Worlds}; use bevy_app::{App, Plugin, Update}; use bevy_ecs::prelude::*; use derive_more::{Deref, DerefMut}; diff --git a/azalea-client/tests/simulation/move_and_despawn_entity.rs b/azalea-client/tests/simulation/move_and_despawn_entity.rs index f05c64f4..a30870e3 100644 --- a/azalea-client/tests/simulation/move_and_despawn_entity.rs +++ b/azalea-client/tests/simulation/move_and_despawn_entity.rs @@ -1,5 +1,8 @@ use azalea_client::test_utils::prelude::*; -use azalea_core::position::{ChunkPos, Vec3}; +use azalea_core::{ + entity_id::MinecraftEntityId, + position::{ChunkPos, Vec3}, +}; use azalea_protocol::{ common::movements::{PositionMoveRotation, RelativeMovements}, packets::{ @@ -8,7 +11,6 @@ use azalea_protocol::{ }, }; use azalea_registry::builtin::EntityKind; -use azalea_core::entity_id::MinecraftEntityId; #[test] fn test_move_and_despawn_entity() { diff --git a/azalea-client/tests/simulation/move_despawned_entity.rs b/azalea-client/tests/simulation/move_despawned_entity.rs index 48c22bf6..57ee3e37 100644 --- a/azalea-client/tests/simulation/move_despawned_entity.rs +++ b/azalea-client/tests/simulation/move_despawned_entity.rs @@ -1,9 +1,8 @@ use azalea_client::test_utils::prelude::*; -use azalea_core::position::ChunkPos; +use azalea_core::{entity_id::MinecraftEntityId, position::ChunkPos}; use azalea_entity::metadata::Cow; use azalea_protocol::packets::{ConnectionProtocol, game::ClientboundMoveEntityRot}; use azalea_registry::builtin::EntityKind; -use azalea_core::entity_id::MinecraftEntityId; use bevy_ecs::query::With; use tracing::Level; diff --git a/azalea-client/tests/simulation/teleport_movement.rs b/azalea-client/tests/simulation/teleport_movement.rs index ff06ebb5..1b740b19 100644 --- a/azalea-client/tests/simulation/teleport_movement.rs +++ b/azalea-client/tests/simulation/teleport_movement.rs @@ -1,6 +1,7 @@ use azalea_client::test_utils::prelude::*; use azalea_core::{ delta::{LpVec3, PositionDelta8}, + entity_id::MinecraftEntityId, position::{BlockPos, ChunkPos, Vec3}, }; use azalea_entity::LookDirection; @@ -16,7 +17,6 @@ use azalea_protocol::{ }, }; use azalea_registry::builtin::BlockKind; -use azalea_core::entity_id::MinecraftEntityId; #[test] fn test_teleport_movement() { diff --git a/azalea-core/src/position.rs b/azalea-core/src/position.rs index f664957f..09246423 100644 --- a/azalea-core/src/position.rs +++ b/azalea-core/src/position.rs @@ -126,6 +126,15 @@ macro_rules! vec3_impl { self.x * other.x + self.y * other.y + self.z * other.z } + #[inline] + pub fn cross(&self, other: Self) -> Self { + Self::new( + self.y * other.z - self.z * other.y, + self.z * other.x - self.x * other.z, + self.x * other.y - self.y * other.x, + ) + } + /// Make a new position with the lower coordinates for each axis. pub fn min(&self, other: Self) -> Self { Self { @@ -333,6 +342,7 @@ impl simdnbt::FromNbtTag for Vec3 { impl Vec3 { /// Get the distance of this vector to the origin by doing /// `sqrt(x^2 + y^2 + z^2)`. + #[doc(alias = "modulus")] pub fn length(&self) -> f64 { f64::sqrt(self.x * self.x + self.y * self.y + self.z * self.z) } @@ -343,6 +353,13 @@ impl Vec3 { (self - other).length() } + pub fn horizontal_distance_to(self, other: Self) -> f64 { + self.horizontal_distance_squared_to(other).sqrt() + } + pub fn horizontal_distance(self) -> f64 { + self.horizontal_distance_squared().sqrt() + } + pub fn x_rot(self, radians: f32) -> Vec3 { let x_delta = math::cos(radians); let y_delta = math::sin(radians); diff --git a/azalea-entity/src/lib.rs b/azalea-entity/src/lib.rs index 87e249c1..fab973b1 100644 --- a/azalea-entity/src/lib.rs +++ b/azalea-entity/src/lib.rs @@ -182,7 +182,7 @@ impl LookDirection { pub fn update(&mut self, new: LookDirection) { self.update_with_sensitivity(new, 1.); } - /// Update the `y_rot` to the given value, in degrees. + /// Update the `y_rot` (yaw) to the given value, in degrees. /// /// This is a shortcut for [`Self::update`] while keeping the `x_rot` the /// same. @@ -192,7 +192,7 @@ impl LookDirection { x_rot: self.x_rot, }); } - /// Update the `x_rot` to the given value, in degrees. + /// Update the `x_rot` (pitch) to the given value, in degrees. /// /// This is a shortcut for [`Self::update`] while keeping the `y_rot` the /// same. diff --git a/azalea-world/src/container.rs b/azalea-world/src/container.rs index af344dc0..c0fa2633 100644 --- a/azalea-world/src/container.rs +++ b/azalea-world/src/container.rs @@ -94,7 +94,7 @@ impl Worlds { /// If two entities share the same world name, then Azalea assumes that they're /// in the same world. #[derive(Clone, Component, Debug, Deref, DerefMut, Eq, Hash, PartialEq)] -#[doc(alias("worldname", "world name", "dimension"))] +#[doc(alias("dimension"))] pub struct WorldName(pub Identifier); impl WorldName { /// Create a new `WorldName` with the given name. diff --git a/azalea-world/src/lib.rs b/azalea-world/src/lib.rs index 325d3b43..8bb23071 100644 --- a/azalea-world/src/lib.rs +++ b/azalea-world/src/lib.rs @@ -12,7 +12,7 @@ mod world; pub use bit_storage::BitStorage; pub use chunk_storage::{Chunk, ChunkStorage, PartialChunkStorage, Section}; -pub use container::{Worlds, WorldName}; +pub use container::{WorldName, Worlds}; pub use world::*; #[deprecated = "renamed to `WorldName`."] diff --git a/azalea/examples/testbot/main.rs b/azalea/examples/testbot/main.rs index b2ad0b61..c74dcee7 100644 --- a/azalea/examples/testbot/main.rs +++ b/azalea/examples/testbot/main.rs @@ -19,6 +19,8 @@ //! /particle a ton of times to show where it's pathfinding to. You should //! only have this on if the bot has operator permissions, otherwise it'll //! just spam the server console unnecessarily. +//! - `--simulation-pathfinder`: Use the alternative simulation-based execution +//! engine for the pathfinder. mod commands; pub mod killaura; @@ -26,8 +28,14 @@ pub mod killaura; use std::{env, process, sync::Arc, thread, time::Duration}; use azalea::{ - ClientInformation, brigadier::command_dispatcher::CommandDispatcher, ecs::prelude::*, - pathfinder::debug::PathfinderDebugParticles, prelude::*, swarm::prelude::*, + ClientInformation, + brigadier::command_dispatcher::CommandDispatcher, + ecs::prelude::*, + pathfinder::{ + debug::PathfinderDebugParticles, execute::simulation::SimulationPathfinderExecutionPlugin, + }, + prelude::*, + swarm::prelude::*, }; use commands::{CommandSource, register_commands}; use parking_lot::Mutex; @@ -44,6 +52,10 @@ async fn main() -> AppExit { .set_handler(handle) .set_swarm_handler(swarm_handle); + if args.simulation_pathfinder { + builder = builder.add_plugins(SimulationPathfinderExecutionPlugin); + } + for username_or_email in &args.accounts { let account = if username_or_email.contains('@') { Account::microsoft(username_or_email).await.unwrap() @@ -210,6 +222,7 @@ pub struct Args { pub accounts: Vec<String>, pub server: String, pub pathfinder_debug_particles: bool, + pub simulation_pathfinder: bool, } fn parse_args() -> Args { @@ -217,6 +230,7 @@ fn parse_args() -> Args { let mut accounts = Vec::new(); let mut server = "localhost".to_owned(); let mut pathfinder_debug_particles = false; + let mut simulation_pathfinder = false; let mut args = env::args().skip(1); while let Some(arg) = args.next() { @@ -235,6 +249,9 @@ fn parse_args() -> Args { "--pathfinder-debug-particles" | "-P" => { pathfinder_debug_particles = true; } + "--simulation-pathfinder" => { + simulation_pathfinder = true; + } _ => { eprintln!("Unknown argument: {arg}"); process::exit(1); @@ -251,5 +268,6 @@ fn parse_args() -> Args { accounts, server, pathfinder_debug_particles, + simulation_pathfinder, } } diff --git a/azalea/src/pathfinder/debug.rs b/azalea/src/pathfinder/debug.rs index 42a64982..0a5f583d 100644 --- a/azalea/src/pathfinder/debug.rs +++ b/azalea/src/pathfinder/debug.rs @@ -3,6 +3,7 @@ use azalea_core::position::Vec3; use bevy_ecs::prelude::*; use super::ExecutingPath; +use crate::pathfinder::moves::should_mine_block_state; /// A component that makes bots run /particle commands while pathfinding to show /// where they're going. @@ -74,9 +75,8 @@ pub fn debug_render_path_with_particles( // this isn't foolproof, there might be another block that could be mined // depending on the move, but it's good enough for debugging // purposes - let is_mining = !(super::world::is_block_state_passable(target_block_state) - || super::world::is_block_state_water(target_block_state)) - || !super::world::is_block_state_passable(above_target_block_state); + let is_mining = should_mine_block_state(target_block_state) + || should_mine_block_state(above_target_block_state); let (r, g, b): (f64, f64, f64) = if i == 0 { (0., 1., 0.) diff --git a/azalea/src/pathfinder/execute/mod.rs b/azalea/src/pathfinder/execute/mod.rs index 7b5336b4..ab7a1a82 100644 --- a/azalea/src/pathfinder/execute/mod.rs +++ b/azalea/src/pathfinder/execute/mod.rs @@ -1,4 +1,5 @@ pub mod patching; +pub mod simulation; use std::{cmp, time::Duration}; @@ -8,7 +9,7 @@ use azalea_client::{ local_player::WorldHolder, mining::{Mining, MiningSystems, StartMiningBlockEvent}, }; -use azalea_core::tick::GameTick; +use azalea_core::{position::Vec3, tick::GameTick}; use azalea_entity::{Physics, Position, inventory::Inventory}; use azalea_physics::{PhysicsSystems, get_block_pos_below_that_affects_movement}; use azalea_world::{WorldName, Worlds}; @@ -29,7 +30,7 @@ use crate::{ astar::PathfinderTimeout, custom_state::CustomPathfinderState, debug::debug_render_path_with_particles, - execute, + execute::simulation::SimulatingPathState, moves::{ExecuteCtx, IsReachedCtx}, player_pos_to_block_pos, }, @@ -37,18 +38,23 @@ use crate::{ pub struct DefaultPathfinderExecutionPlugin; impl Plugin for DefaultPathfinderExecutionPlugin { - fn build(&self, app: &mut App) { + fn build(&self, _app: &mut App) {} + + fn finish(&self, app: &mut App) { + if app.is_plugin_added::<simulation::SimulationPathfinderExecutionPlugin>() { + info!("pathfinder simulation executor plugin is enabled, disabling default executor."); + return; + } + app.add_systems( - // putting systems in the GameTick schedule makes them run every Minecraft tick - // (every 50 milliseconds). GameTick, ( - execute::timeout_movement, - execute::patching::check_for_path_obstruction, - execute::check_node_reached, - execute::tick_execute_path, - execute::recalculate_near_end_of_path, - execute::recalculate_if_has_goal_but_no_path, + timeout_movement, + patching::check_for_path_obstruction, + check_node_reached, + tick_execute_path, + recalculate_near_end_of_path, + recalculate_if_has_goal_but_no_path, ) .chain() .after(PhysicsSystems) @@ -77,15 +83,8 @@ pub fn tick_execute_path( mut jump_events: MessageWriter<JumpEvent>, mut start_mining_events: MessageWriter<StartMiningBlockEvent>, ) { - for ( - entity, - mut executing_path, - position, - physics, - mining, - world_holder, - inventory_component, - ) in &mut query + for (entity, mut executing_path, position, physics, mining, world_holder, inventory) in + &mut query { executing_path.ticks_since_last_node_reached += 1; @@ -97,8 +96,9 @@ pub fn tick_execute_path( start: executing_path.last_reached_node, physics, is_currently_mining: mining.is_some(), + can_mine: true, world: world_holder.shared.clone(), - menu: inventory_component.inventory_menu.clone(), + menu: inventory.inventory_menu.clone(), commands: &mut commands, look_at_events: &mut look_at_events, @@ -146,7 +146,7 @@ pub fn check_node_reached( .clone() .into_iter() .enumerate() - .take(20) + .take(30) .rev() { let movement = edge.movement; @@ -184,7 +184,7 @@ pub fn check_node_reached( let z_predicted_offset = (z_difference_from_center + scaled_velocity.z).abs(); // this is to make sure we don't fall off immediately after finishing the path - physics.on_ground() + (physics.on_ground() || physics.is_in_water()) && player_pos_to_block_pos(**position) == movement.target // adding the delta like this isn't a perfect solution but it helps to make // sure we don't keep going if our delta is high @@ -256,6 +256,7 @@ pub fn timeout_movement( &WorldName, &Inventory, Option<&CustomPathfinderState>, + Option<&SimulatingPathState>, )>, worlds: Res<Worlds>, ) { @@ -268,8 +269,52 @@ pub fn timeout_movement( world_name, inventory, custom_state, + simulating_path_state, ) in &mut query { + if !executing_path.path.is_empty() { + let (start, end) = if let Some(SimulatingPathState::Simulated(simulating_path_state)) = + simulating_path_state + { + (simulating_path_state.start, simulating_path_state.target) + } else { + ( + executing_path.last_reached_node, + executing_path.path[0].movement.target, + ) + }; + + let (start, end) = (start.center_bottom(), end.center_bottom()); + // TODO: use an actual 2d point-line distance formula here instead of the 3d one + // lol + let xz_distance = + point_line_distance_3d(&position.with_y(0.), &(start.with_y(0.), end.with_y(0.))); + let y_distance = point_line_distance_1d(position.y, (start.y, end.y)); + + let xz_tolerance = 3.; + // longer moves have more y tolerance (in case we're climbing a hill or smth in + // a single movement) + let y_tolerance = start.horizontal_distance_to(end) / 2. + 1.5; + + if xz_distance > xz_tolerance || y_distance > y_tolerance { + warn!( + "pathfinder went too far from path (xz_distance={xz_distance}/{xz_tolerance}, y_distance={y_distance}/{y_tolerance}, line is {start} to {end}, point at {}), trying to patch!", + **position + ); + patch_path_from_timeout( + entity, + &mut executing_path, + &mut pathfinder, + &worlds, + position, + world_name, + custom_state, + inventory, + ); + continue; + } + } + // don't timeout if we're mining if let Some(mining) = mining { // also make sure we're close enough to the block that's being mined @@ -281,46 +326,77 @@ pub fn timeout_movement( } } - if executing_path.ticks_since_last_node_reached > (2 * 20) + let mut timeout = 2 * 20; + + if simulating_path_state.is_some() { + // longer timeout if we're following a simulated path from the other execution + // engine + timeout = 5 * 20; + } + + if executing_path.ticks_since_last_node_reached > timeout && !pathfinder.is_calculating && !executing_path.path.is_empty() { warn!("pathfinder timeout, trying to patch path"); - executing_path.queued_path = None; - let cur_pos = player_pos_to_block_pos(**position); - executing_path.last_reached_node = cur_pos; - - let world_lock = worlds - .get(world_name) - .expect("Entity tried to pathfind but the entity isn't in a valid world"); - let Some(opts) = pathfinder.opts.clone() else { - warn!( - "pathfinder was going to patch path because of timeout, but pathfinder.opts was None" - ); - return; - }; - let custom_state = custom_state.cloned().unwrap_or_default(); - - // try to fix the path without recalculating everything. - // (though, it'll still get fully recalculated by `recalculate_near_end_of_path` - // if the new path is too short) - patching::patch_path( - 0..=cmp::min(20, executing_path.path.len() - 1), + patch_path_from_timeout( + entity, &mut executing_path, &mut pathfinder, - inventory, - entity, - world_lock, + &worlds, + position, + world_name, custom_state, - opts, + inventory, ); - // reset last_node_reached_at so we don't immediately try to patch again - executing_path.ticks_since_last_node_reached = 0 } } } +fn patch_path_from_timeout( + entity: Entity, + executing_path: &mut ExecutingPath, + pathfinder: &mut Pathfinder, + worlds: &Worlds, + position: &Position, + world_name: &WorldName, + custom_state: Option<&CustomPathfinderState>, + inventory: &Inventory, +) { + executing_path.queued_path = None; + let cur_pos = player_pos_to_block_pos(**position); + executing_path.last_reached_node = cur_pos; + + let world_lock = worlds + .get(world_name) + .expect("Entity tried to pathfind but the entity isn't in a valid world"); + let Some(opts) = pathfinder.opts.clone() else { + warn!( + "pathfinder was going to patch path because of timeout, but pathfinder.opts was None" + ); + return; + }; + + let custom_state = custom_state.cloned().unwrap_or_default(); + + // try to fix the path without recalculating everything. + // (though, it'll still get fully recalculated by `recalculate_near_end_of_path` + // if the new path is too short) + patching::patch_path( + 0..=cmp::min(20, executing_path.path.len() - 1), + executing_path, + pathfinder, + inventory, + entity, + world_lock, + custom_state, + opts, + ); + // reset last_node_reached_at so we don't immediately try to patch again + executing_path.ticks_since_last_node_reached = 0 +} + pub fn recalculate_near_end_of_path( mut query: Query<(Entity, &mut Pathfinder, &mut ExecutingPath)>, mut walk_events: MessageWriter<StartWalkEvent>, @@ -409,3 +485,33 @@ pub fn recalculate_if_has_goal_but_no_path( } } } + +// based on https://stackoverflow.com/a/36425155 +/// Returns the distance of a point from a line. +/// +/// This is used in the pathfinder for checking if the bot is too far from the +/// current path. +pub fn point_line_distance_3d(point: &Vec3, (start, end): &(Vec3, Vec3)) -> f64 { + let start_to_end = end - start; + let start_to_point = point - start; + + if start_to_point.dot(start_to_end) <= 0. { + return start_to_point.length(); + } + + let end_to_point = point - end; + if end_to_point.dot(start_to_end) >= 0. { + return end_to_point.length(); + } + + start_to_end.cross(start_to_point).length() / start_to_end.length() +} +pub fn point_line_distance_1d(point: f64, (start, end): (f64, f64)) -> f64 { + let min = start.min(end); + let max = start.max(end); + if point < min { + min - point + } else { + point - max + } +} diff --git a/azalea/src/pathfinder/execute/simulation.rs b/azalea/src/pathfinder/execute/simulation.rs index e69de29b..5bb82753 100644 --- a/azalea/src/pathfinder/execute/simulation.rs +++ b/azalea/src/pathfinder/execute/simulation.rs @@ -0,0 +1,500 @@ +//! An alternative execution engine for the pathfinder that attempts to skip +//! nodes in the path by running simulations. +//! +//! See [`SimulationPathfinderExecutionPlugin`] for more information. + +use std::{borrow::Cow, time::Instant}; + +use azalea_client::{ + PhysicsState, SprintDirection, StartSprintEvent, StartWalkEvent, + local_player::WorldHolder, + mining::{Mining, MiningSystems, StartMiningBlockEvent}, +}; +use azalea_core::{position::BlockPos, tick::GameTick}; +use azalea_entity::{Attributes, LookDirection, Physics, Position, inventory::Inventory}; +use azalea_physics::PhysicsSystems; +use bevy_app::{App, Plugin}; +use bevy_ecs::{prelude::*, system::SystemState}; +use tracing::{debug, trace}; + +use crate::{ + WalkDirection, + bot::{JumpEvent, LookAtEvent, direction_looking_at}, + ecs::{ + entity::Entity, + system::{Commands, Query}, + }, + pathfinder::{ + ExecutingPath, + debug::debug_render_path_with_particles, + moves::{ExecuteCtx, IsReachedCtx}, + simulation::{SimulatedPlayerBundle, Simulation}, + }, +}; + +/// An alternative execution engine for the pathfinder that attempts to skip +/// nodes in the path by running simulations. +/// +/// This allows it to smooth the path and sprint-jump without failing jumps or +/// looking unnatural. However, this comes at the cost of execution being more +/// expensive and potentially less stable. +/// +/// To use it, simply add [`SimulationPathfinderExecutionPlugin`] as a plugin. +/// +/// ``` +/// use azalea::{simulation::SimulationPathfinderExecutionPlugin, swarm::prelude::*}; +/// +/// let builder = SwarmBuilder::new().add_plugins(SimulationPathfinderExecutionPlugin); +/// // ... +/// ``` +/// +/// [`DefaultPathfinderExecutionPlugin`]: super::DefaultPathfinderExecutionPlugin +pub struct SimulationPathfinderExecutionPlugin; +impl Plugin for SimulationPathfinderExecutionPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + GameTick, + ( + super::timeout_movement, + super::patching::check_for_path_obstruction, + super::check_node_reached, + tick_execute_path, + super::recalculate_near_end_of_path, + super::recalculate_if_has_goal_but_no_path, + ) + .chain() + .after(PhysicsSystems) + .after(azalea_client::movement::send_position) + .after(MiningSystems) + .after(debug_render_path_with_particles), + ); + } +} + +#[derive(Clone, Component, Debug)] +pub enum SimulatingPathState { + Fail, + Simulated(SimulatingPathOpts), +} +#[derive(Clone, Component, Debug)] +pub struct SimulatingPathOpts { + pub start: BlockPos, + pub target: BlockPos, + pub jumping: bool, + pub jump_until_target_distance: f64, + pub jump_after_start_distance: f64, + pub sprinting: bool, + pub y_rot: f32, +} + +#[allow(clippy::type_complexity)] +pub fn tick_execute_path( + mut commands: Commands, + mut query: Query<( + Entity, + &mut ExecutingPath, + &mut LookDirection, + &Position, + &Physics, + &PhysicsState, + Option<&Mining>, + &WorldHolder, + &Attributes, + &Inventory, + Option<&SimulatingPathState>, + )>, + mut look_at_events: MessageWriter<LookAtEvent>, + mut sprint_events: MessageWriter<StartSprintEvent>, + mut walk_events: MessageWriter<StartWalkEvent>, + mut jump_events: MessageWriter<JumpEvent>, + mut start_mining_events: MessageWriter<StartMiningBlockEvent>, +) { + for ( + entity, + mut executing_path, + mut look_direction, + position, + physics, + physics_state, + mining, + world_holder, + attributes, + inventory, + mut simulating_path_state, + ) in &mut query + { + executing_path.ticks_since_last_node_reached += 1; + + if executing_path.ticks_since_last_node_reached == 1 { + if let Some(SimulatingPathState::Simulated(s)) = simulating_path_state { + // only reset the state if we just reached the end of the simulation path (for + // performance) + if s.target == executing_path.last_reached_node + // or if the current simulation target isn't in the path, reset too + || !executing_path + .path + .iter() + .any(|e| e.movement.target == s.target) + { + simulating_path_state = None; + } + } else { + simulating_path_state = None; + } + } + + let simulating_path_state = if let Some(simulating_path_state) = simulating_path_state { + Cow::Borrowed(simulating_path_state) + } else { + let start = Instant::now(); + let new_state = run_simulations( + &executing_path, + world_holder, + SimulatedPlayerBundle { + position: *position, + physics: physics.clone(), + physics_state: physics_state.clone(), + look_direction: *look_direction, + attributes: attributes.clone(), + inventory: inventory.clone(), + }, + ); + debug!("found sim in {:?}: {new_state:?}", start.elapsed()); + commands.entity(entity).insert(new_state.clone()); + Cow::Owned(new_state) + }; + + match &*simulating_path_state { + SimulatingPathState::Fail => { + if let Some(edge) = executing_path.path.front() { + let mut ctx = ExecuteCtx { + entity, + target: edge.movement.target, + position: **position, + start: executing_path.last_reached_node, + physics, + is_currently_mining: mining.is_some(), + can_mine: true, + world: world_holder.shared.clone(), + menu: inventory.inventory_menu.clone(), + + commands: &mut commands, + look_at_events: &mut look_at_events, + sprint_events: &mut sprint_events, + walk_events: &mut walk_events, + jump_events: &mut jump_events, + start_mining_events: &mut start_mining_events, + }; + ctx.on_tick_start(); + trace!( + "executing move, position: {}, last_reached_node: {}", + **position, executing_path.last_reached_node + ); + (edge.movement.data.execute)(ctx); + } + } + SimulatingPathState::Simulated(SimulatingPathOpts { + start, + target, + jumping, + jump_until_target_distance, + jump_after_start_distance, + sprinting, + y_rot, + }) => { + look_direction.update(LookDirection::new(*y_rot, 0.)); + + if *sprinting { + sprint_events.write(StartSprintEvent { + entity, + direction: SprintDirection::Forward, + }); + } else { + if physics_state.was_sprinting { + walk_events.write(StartWalkEvent { + entity, + direction: WalkDirection::None, + }); + } else { + walk_events.write(StartWalkEvent { + entity, + direction: WalkDirection::Forward, + }); + } + } + if *jumping + && target.center().horizontal_distance_squared_to(**position) + > jump_until_target_distance.powi(2) + && start.center().horizontal_distance_squared_to(**position) + > jump_after_start_distance.powi(2) + { + jump_events.write(JumpEvent { entity }); + } + } + } + + // + } +} + +fn run_simulations( + executing_path: &ExecutingPath, + world_holder: &WorldHolder, + player: SimulatedPlayerBundle, +) -> SimulatingPathState { + let swimming = player.physics.is_in_water(); + + let mut sim = Simulation::new(world_holder.shared.read().chunks.clone(), player.clone()); + + for nodes_ahead in [20, 15, 10, 5, 4, 3, 2, 1, 0] { + if nodes_ahead + 1 >= executing_path.path.len() { + // don't simulate to the last node since it has stricter checks + continue; + } + + let mut results = Vec::new(); + + if let Some(simulating_to) = executing_path.path.get(nodes_ahead) { + let y_rot = + direction_looking_at(*player.position, simulating_to.movement.target.center()) + .y_rot(); + + for jump_until_target_distance in [0., 1., 3.] { + for jump_after_start_distance in [0., 0.5] { + for jumping in [true, false] { + if !jumping + && (jump_until_target_distance != 0. || jump_after_start_distance != 0.) + { + continue; + } + + // this loop is left here in case you wanna try re-enabling walking, but + // it doesn't seem that useful + for sprinting in [true] { + if !sprinting && nodes_ahead > 2 { + continue; + } + if swimming { + if !sprinting + || jump_until_target_distance > 0. + || jump_after_start_distance > 0. + { + continue; + } + } else if jump_until_target_distance == 0. { + continue; + } + + let state = SimulatingPathOpts { + start: BlockPos::from(player.position), + target: simulating_to.movement.target, + jumping, + jump_until_target_distance, + jump_after_start_distance, + sprinting, + y_rot, + }; + let sim_res = run_one_simulation( + &mut sim, + player.clone(), + state.clone(), + executing_path, + nodes_ahead, + if swimming { + (nodes_ahead * 12) + 20 + } else { + (nodes_ahead * 4) + 20 + }, + ); + if sim_res.success { + results.push((state, sim_res.ticks)); + } + } + } + } + } + } + + if !results.is_empty() { + let fastest = results.iter().min_by_key(|r| r.1).unwrap().0.clone(); + return SimulatingPathState::Simulated(fastest); + } + } + + SimulatingPathState::Fail +} + +struct SimulationResult { + success: bool, + ticks: usize, +} +fn run_one_simulation( + sim: &mut Simulation, + player: SimulatedPlayerBundle, + state: SimulatingPathOpts, + executing_path: &ExecutingPath, + nodes_ahead: usize, + timeout_ticks: usize, +) -> SimulationResult { + let simulating_to = &executing_path.path[nodes_ahead]; + + let start = BlockPos::from(player.position); + sim.reset(player); + + let simulating_to_block = simulating_to.movement.target; + + let mut success = false; + let mut total_ticks = 0; + + for ticks in 1..=timeout_ticks { + let position = sim.position(); + let ecs = sim.app.world_mut(); + + ecs.get_mut::<LookDirection>(sim.entity) + .unwrap() + .update(LookDirection::new(state.y_rot, 0.)); + + if state.sprinting { + ecs.write_message(StartSprintEvent { + entity: sim.entity, + direction: SprintDirection::Forward, + }); + } else { + if ecs + .get::<PhysicsState>(sim.entity) + .map(|p| p.trying_to_sprint) + .unwrap_or_default() + { + // have to let go for a tick to be able to start walking + ecs.write_message(StartWalkEvent { + entity: sim.entity, + direction: WalkDirection::None, + }); + } else { + ecs.write_message(StartWalkEvent { + entity: sim.entity, + direction: WalkDirection::Forward, + }); + } + } + if state.jumping + && simulating_to_block + .center() + .horizontal_distance_squared_to(position) + > state.jump_until_target_distance.powi(2) + && start.center().horizontal_distance_squared_to(position) + > state.jump_after_start_distance.powi(2) + { + ecs.write_message(JumpEvent { entity: sim.entity }); + } + + sim.tick(); + + let physics = sim.physics(); + if physics.horizontal_collision + || physics.is_in_lava() + || (physics.velocity.y < -0.7 && !physics.is_in_water()) + { + // fail + break; + } + + if (simulating_to.movement.data.is_reached)(IsReachedCtx { + target: simulating_to_block, + start, + position: sim.position(), + physics: &physics, + }) { + success = true; + total_ticks = ticks; + break; + } + } + + if success { + // now verify that the path is safe by continuing to the next node + + let mut followup_success = false; + + let next_node = &executing_path.path[nodes_ahead + 1]; + for _ in 1..=30 { + // add ticks here so if we sort by ticks later it'll be more accurate + total_ticks += 1; + + { + let mut system_state = SystemState::<( + Commands, + Query<(&Position, &Physics, Option<&Mining>, &Inventory)>, + MessageWriter<LookAtEvent>, + MessageWriter<StartSprintEvent>, + MessageWriter<StartWalkEvent>, + MessageWriter<JumpEvent>, + MessageWriter<StartMiningBlockEvent>, + )>::new(sim.app.world_mut()); + let ( + mut commands, + query, + mut look_at_events, + mut sprint_events, + mut walk_events, + mut jump_events, + mut start_mining_events, + ) = system_state.get_mut(sim.app.world_mut()); + + let (position, physics, mining, inventory) = query.get(sim.entity).unwrap(); + + if physics.horizontal_collision && physics.velocity.y < -0. { + // if the simulated move just made us hit a wall that we aren't already jumping + // from then that's bad + break; + } + if physics.velocity.y < -0.7 && !physics.is_in_water() { + break; + } + + (next_node.movement.data.execute)(ExecuteCtx { + entity: sim.entity, + target: next_node.movement.target, + start: simulating_to_block, + position: **position, + physics, + is_currently_mining: mining.is_some(), + // don't modify the world from the simulation + can_mine: false, + world: sim.world.clone(), + menu: inventory.inventory_menu.clone(), + + commands: &mut commands, + look_at_events: &mut look_at_events, + sprint_events: &mut sprint_events, + walk_events: &mut walk_events, + jump_events: &mut jump_events, + start_mining_events: &mut start_mining_events, + }); + system_state.apply(sim.app.world_mut()); + } + + sim.tick(); + + if (next_node.movement.data.is_reached)(IsReachedCtx { + target: next_node.movement.target, + start: simulating_to_block, + position: sim.position(), + physics: &sim.physics(), + }) { + followup_success = true; + break; + } + } + + if !followup_success { + debug!("followup failed"); + success = false; + } + } + + SimulationResult { + success, + ticks: total_ticks, + } +} diff --git a/azalea/src/pathfinder/mod.rs b/azalea/src/pathfinder/mod.rs index 04c1d58c..2a1a6220 100644 --- a/azalea/src/pathfinder/mod.rs +++ b/azalea/src/pathfinder/mod.rs @@ -3,7 +3,15 @@ //! For the new functions on `Client` that the pathfinder adds, see //! [`PathfinderClientExt`]. //! -//! Much of this code is based on [Baritone](https://github.com/cabaletta/baritone). +//! Note that the pathfinder is highly optimized, but it will be very slow if +//! it's not compiled with optimizations enabled. +//! +//! For more efficient and realistic path execution, also see +//! [`SimulationPathfinderExecutionPlugin`]. +//! +//! Much of the pathfinder's code is based on [Baritone](https://github.com/cabaletta/baritone). <3 +//! +//! [`SimulationPathfinderExecutionPlugin`]: execute::simulation::SimulationPathfinderExecutionPlugin pub mod astar; pub mod costs; diff --git a/azalea/src/pathfinder/moves/mod.rs b/azalea/src/pathfinder/moves/mod.rs index d977c7bd..4e621a10 100644 --- a/azalea/src/pathfinder/moves/mod.rs +++ b/azalea/src/pathfinder/moves/mod.rs @@ -14,6 +14,7 @@ use azalea_client::{ }; use azalea_core::position::{BlockPos, Vec3}; use azalea_inventory::Menu; +use azalea_registry::builtin::BlockKind; use azalea_world::World; use bevy_ecs::{entity::Entity, message::MessageWriter, system::Commands, world::EntityWorldMut}; use parking_lot::RwLock; @@ -29,7 +30,7 @@ use super::{ use crate::{ auto_tool::best_tool_in_hotbar_for_block, bot::{JumpEvent, LookAtEvent}, - pathfinder::{player_pos_to_block_pos, world::is_block_state_water}, + pathfinder::player_pos_to_block_pos, }; type Edge = astar::Edge<RelBlockPos, MoveData>; @@ -73,6 +74,7 @@ pub struct ExecuteCtx<'s, 'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'a> { pub position: Vec3, pub physics: &'a azalea_entity::Physics, pub is_currently_mining: bool, + pub can_mine: bool, pub world: Arc<RwLock<World>>, pub menu: Menu, @@ -150,18 +152,17 @@ impl ExecuteCtx<'_, '_, '_, '_, '_, '_, '_, '_> { /// Returns whether this block could be mined. pub fn should_mine(&mut self, block: BlockPos) -> bool { let block_state = self.world.read().get_block_state(block).unwrap_or_default(); - if is_block_state_passable(block_state) || is_block_state_water(block_state) { - // block is already passable, no need to mine it - return false; - } - - true + should_mine_block_state(block_state) } /// Mine the block at the given position. /// /// Returns whether the block is being mined. pub fn mine(&mut self, block: BlockPos) -> bool { + if !self.can_mine { + return false; + } + let block_state = self.world.read().get_block_state(block).unwrap_or_default(); if is_block_state_passable(block_state) { // block is already passable, no need to mine it @@ -217,6 +218,15 @@ impl ExecuteCtx<'_, '_, '_, '_, '_, '_, '_, '_> { } } +pub fn should_mine_block_state(block_state: BlockState) -> bool { + if is_block_state_passable(block_state) || BlockKind::from(block_state) == BlockKind::Water { + // block is already passable, no need to mine it + return false; + } + + true +} + pub struct IsReachedCtx<'a> { /// The node that we're trying to reach. pub target: BlockPos, diff --git a/azalea/src/pathfinder/simulation.rs b/azalea/src/pathfinder/simulation.rs index 94837f9e..a8f24480 100644 --- a/azalea/src/pathfinder/simulation.rs +++ b/azalea/src/pathfinder/simulation.rs @@ -130,18 +130,23 @@ fn create_simulation_player( pub struct Simulation { pub app: App, pub entity: Entity, - _world: Arc<RwLock<World>>, + pub world: Arc<RwLock<World>>, } impl Simulation { pub fn new(chunks: ChunkStorage, player: SimulatedPlayerBundle) -> Self { let (mut app, world) = create_simulation_world(chunks); let entity = create_simulation_player(app.world_mut(), world.clone(), player); - Self { - app, - entity, - _world: world, - } + Self { app, entity, world } + } + + /// Despawn the old simulated player and create a new one. + /// + /// This is cheaper than creating a new [`Simulation`] from scratch. + pub fn reset(&mut self, player: SimulatedPlayerBundle) { + self.app.world_mut().despawn(self.entity); + let entity = create_simulation_player(self.app.world_mut(), self.world.clone(), player); + self.entity = entity; } pub fn tick(&mut self) { @@ -157,6 +162,9 @@ impl Simulation { pub fn position(&self) -> Vec3 { *self.component::<Position>() } + pub fn physics(&self) -> Physics { + self.component::<Physics>().clone() + } pub fn is_mining(&self) -> bool { // return true if the component is present and Some self.get_component::<azalea_client::mining::MineBlockPos>() diff --git a/azalea/src/pathfinder/world.rs b/azalea/src/pathfinder/world.rs index be264889..4e9952cd 100644 --- a/azalea/src/pathfinder/world.rs +++ b/azalea/src/pathfinder/world.rs @@ -113,11 +113,9 @@ impl CachedWorld { cached_blocks: Default::default(), // this uses about 12mb of memory. it *really* helps though. cached_mining_costs: UnsafeCell::new( - vec![ - (RelBlockPos::new(i16::MAX, i32::MAX, i16::MAX), 0.); - CACHED_MINING_COSTS_SIZE - ] - .into_boxed_slice(), + (0..CACHED_MINING_COSTS_SIZE) + .map(|_| (RelBlockPos::new(i16::MAX, i32::MAX, i16::MAX), 0.)) + .collect(), ), } } diff --git a/azalea/src/swarm/builder.rs b/azalea/src/swarm/builder.rs index 560be3d8..f42b4d4a 100644 --- a/azalea/src/swarm/builder.rs +++ b/azalea/src/swarm/builder.rs @@ -90,13 +90,17 @@ impl SwarmBuilder<NoState, NoSwarmState, (), ()> { /// [`DefaultSwarmPlugins`] to this. /// /// ``` - /// # use azalea::{prelude::*, swarm::prelude::*}; + /// # use azalea::{prelude::*, swarm::{prelude::*, DefaultSwarmPlugins}, bot::DefaultBotPlugins}; /// use azalea::app::PluginGroup; /// /// let swarm_builder = SwarmBuilder::new_without_plugins() - /// .add_plugins(azalea::DefaultPlugins.build().disable::<azalea::chat_signing::ChatSigningPlugin>()) - /// .add_plugins(azalea::bot::DefaultBotPlugins) - /// .add_plugins(azalea::swarm::DefaultSwarmPlugins); + /// .add_plugins(( + /// DefaultBotPlugins, + /// DefaultSwarmPlugins, + /// azalea::DefaultPlugins + /// .build() + /// .disable::<azalea::chat_signing::ChatSigningPlugin>(), + /// )); /// # swarm_builder.set_handler(handle).set_swarm_handler(swarm_handle); /// # #[derive(Clone, Component, Default, Resource)] /// # pub struct State; |
