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 /azalea/src/pathfinder/execute/simulation.rs | |
| parent | fb92f65b3da49b6487bf6fa05010b12a3ab5d4ed (diff) | |
| download | azalea-drasl-268c62587e090c72b67a29e1cc42cda6c9d7340b.tar.xz | |
add simulation-based pathfinder execution engine
Diffstat (limited to 'azalea/src/pathfinder/execute/simulation.rs')
| -rw-r--r-- | azalea/src/pathfinder/execute/simulation.rs | 500 |
1 files changed, 500 insertions, 0 deletions
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, + } +} |
