diff options
| author | mat <27899617+mat-1@users.noreply.github.com> | 2023-12-15 11:26:40 -0600 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-12-15 11:26:40 -0600 |
| commit | a707e2eb82b74994a16083b31fa4576332cf1995 (patch) | |
| tree | db6c2ac94dd73590befd68a9b1b0ef960410b0df /azalea/src | |
| parent | 59e140ddd655c7dc6e35109b91286118c51bcc06 (diff) | |
| download | azalea-drasl-a707e2eb82b74994a16083b31fa4576332cf1995.tar.xz | |
Add mining to the pathfinder (#122)
* basic pathfinder mining poc
* mining descending and autotool
* pathfinder mining descending
* pathfinder fixes
* allow disabling pathfinder miner and other fixes
* small optimization to avoid chunk vec iter lookup sometimes
* seeded rng in pathfinder bench
* consistently use f32::INFINITY
this brings performance much closer to how it was before
* astar heuristic optimization from baritone
* add downward_move
* fix downward move execute
* avoid liquids and falling blocks when mining
* fix COST_HEURISTIC
* fix to not path through flowing liquids
* only reset pathfinder timeout while mining if the block is close enough
* cache mining costs of block positions
* fix mine_while_at_start and move PathfinderDebugParticles to its own module
* add ReachBlockPosGoal
in other news: azalea's sin/cos functions were broken this whole time and i never noticed
* clippy and add things that i accidentally didn't commit
* improve wording on doc for azalea::pathfinder
Diffstat (limited to 'azalea/src')
| -rw-r--r-- | azalea/src/auto_tool.rs | 51 | ||||
| -rw-r--r-- | azalea/src/bot.rs | 18 | ||||
| -rw-r--r-- | azalea/src/pathfinder/astar.rs | 6 | ||||
| -rw-r--r-- | azalea/src/pathfinder/costs.rs | 9 | ||||
| -rw-r--r-- | azalea/src/pathfinder/debug.rs | 113 | ||||
| -rw-r--r-- | azalea/src/pathfinder/goals.rs | 96 | ||||
| -rw-r--r-- | azalea/src/pathfinder/mining.rs | 99 | ||||
| -rw-r--r-- | azalea/src/pathfinder/mod.rs | 213 | ||||
| -rw-r--r-- | azalea/src/pathfinder/moves/basic.rs | 141 | ||||
| -rw-r--r-- | azalea/src/pathfinder/moves/mod.rs | 108 | ||||
| -rw-r--r-- | azalea/src/pathfinder/world.rs | 240 |
11 files changed, 922 insertions, 172 deletions
diff --git a/azalea/src/auto_tool.rs b/azalea/src/auto_tool.rs index 2cf53085..bc9bb474 100644 --- a/azalea/src/auto_tool.rs +++ b/azalea/src/auto_tool.rs @@ -4,6 +4,7 @@ use azalea_entity::{FluidOnEyes, Physics}; use azalea_inventory::{ItemSlot, Menu}; use azalea_registry::Fluid; +#[derive(Debug)] pub struct BestToolResult { pub index: usize, pub percentage_per_tick: f32, @@ -62,7 +63,57 @@ pub fn accurate_best_tool_in_hotbar_for_block( let mut best_slot = None; let block = Box::<dyn Block>::from(block); + let registry_block = block.as_registry_block(); + if matches!( + registry_block, + azalea_registry::Block::Water | azalea_registry::Block::Lava + ) { + // can't mine fluids + return BestToolResult { + index: 0, + percentage_per_tick: 0., + }; + } + + // find the first slot that has an item without durability + for (i, item_slot) in hotbar_slots.iter().enumerate() { + let this_item_speed; + match item_slot { + ItemSlot::Empty => { + this_item_speed = Some(azalea_entity::mining::get_mine_progress( + block.as_ref(), + azalea_registry::Item::Air, + menu, + fluid_on_eyes, + physics, + )); + } + ItemSlot::Present(item_slot) => { + // lazy way to avoid checking durability since azalea doesn't have durability + // data yet + if item_slot.nbt.is_none() { + this_item_speed = Some(azalea_entity::mining::get_mine_progress( + block.as_ref(), + item_slot.kind, + menu, + fluid_on_eyes, + physics, + )); + } else { + this_item_speed = None; + } + } + } + if let Some(this_item_speed) = this_item_speed { + if this_item_speed > best_speed { + best_slot = Some(i); + best_speed = this_item_speed; + } + } + } + + // now check every item for (i, item_slot) in hotbar_slots.iter().enumerate() { if let ItemSlot::Present(item_slot) = item_slot { let this_item_speed = azalea_entity::mining::get_mine_progress( diff --git a/azalea/src/bot.rs b/azalea/src/bot.rs index ccc016e6..529bb251 100644 --- a/azalea/src/bot.rs +++ b/azalea/src/bot.rs @@ -170,27 +170,35 @@ fn look_at_listener( ) { for event in events.read() { if let Ok((position, eye_height, mut look_direction)) = query.get_mut(event.entity) { - let (y_rot, x_rot) = + let new_look_direction = direction_looking_at(&position.up(eye_height.into()), &event.position); trace!( "look at {:?} (currently at {:?})", event.position, **position ); - (look_direction.y_rot, look_direction.x_rot) = (y_rot, x_rot); + *look_direction = new_look_direction; } } } -/// Return the (`y_rot`, `x_rot`) that would make a client at `current` be +/// Return the look direction that would make a client at `current` be /// looking at `target`. -fn direction_looking_at(current: &Vec3, target: &Vec3) -> (f32, f32) { +pub fn direction_looking_at(current: &Vec3, target: &Vec3) -> LookDirection { // borrowed from mineflayer's Bot.lookAt because i didn't want to do math let delta = target - current; let y_rot = (PI - f64::atan2(-delta.x, -delta.z)) * (180.0 / PI); let ground_distance = f64::sqrt(delta.x * delta.x + delta.z * delta.z); let x_rot = f64::atan2(delta.y, ground_distance) * -(180.0 / PI); - (y_rot as f32, x_rot as f32) + + // clamp + let y_rot = y_rot.rem_euclid(360.0); + let x_rot = x_rot.clamp(-90.0, 90.0) % 360.0; + + LookDirection { + x_rot: x_rot as f32, + y_rot: y_rot as f32, + } } /// A [`PluginGroup`] for the plugins that add extra bot functionality to the diff --git a/azalea/src/pathfinder/astar.rs b/azalea/src/pathfinder/astar.rs index 163189af..cc1e2242 100644 --- a/azalea/src/pathfinder/astar.rs +++ b/azalea/src/pathfinder/astar.rs @@ -48,7 +48,7 @@ where movement_data: None, came_from: None, g_score: f32::default(), - f_score: f32::MAX, + f_score: f32::INFINITY, }, ); @@ -70,14 +70,14 @@ where let current_g_score = nodes .get(¤t_node) .map(|n| n.g_score) - .unwrap_or(f32::MAX); + .unwrap_or(f32::INFINITY); for neighbor in successors(current_node) { let tentative_g_score = current_g_score + neighbor.cost; let neighbor_g_score = nodes .get(&neighbor.movement.target) .map(|n| n.g_score) - .unwrap_or(f32::MAX); + .unwrap_or(f32::INFINITY); if tentative_g_score - neighbor_g_score < MIN_IMPROVEMENT { let heuristic = heuristic(neighbor.movement.target); let f_score = tentative_g_score + heuristic; diff --git a/azalea/src/pathfinder/costs.rs b/azalea/src/pathfinder/costs.rs index 5c72b73a..f9b67e5f 100644 --- a/azalea/src/pathfinder/costs.rs +++ b/azalea/src/pathfinder/costs.rs @@ -10,6 +10,15 @@ pub const SPRINT_MULTIPLIER: f32 = SPRINT_ONE_BLOCK_COST / WALK_ONE_BLOCK_COST; pub const JUMP_PENALTY: f32 = 2.; pub const CENTER_AFTER_FALL_COST: f32 = WALK_ONE_BLOCK_COST - WALK_OFF_BLOCK_COST; // 0.927 +// explanation here: +// https://github.com/cabaletta/baritone/blob/f147519a5c291015d4f18c94558a3f1bdcdb9588/src/api/java/baritone/api/Settings.java#L405 +// it's basically just the heuristic multiplier +pub const COST_HEURISTIC: f32 = 3.563; + +// this one is also from baritone, it's helpful as a tiebreaker to avoid +// breaking blocks if it can be avoided +pub const BLOCK_BREAK_ADDITIONAL_PENALTY: f32 = 2.; + pub static FALL_1_25_BLOCKS_COST: LazyLock<f32> = LazyLock::new(|| distance_to_ticks(1.25)); pub static FALL_0_25_BLOCKS_COST: LazyLock<f32> = LazyLock::new(|| distance_to_ticks(0.25)); pub static JUMP_ONE_BLOCK_COST: LazyLock<f32> = diff --git a/azalea/src/pathfinder/debug.rs b/azalea/src/pathfinder/debug.rs new file mode 100644 index 00000000..201803c9 --- /dev/null +++ b/azalea/src/pathfinder/debug.rs @@ -0,0 +1,113 @@ +use azalea_client::{chat::SendChatEvent, InstanceHolder}; +use azalea_core::position::Vec3; +use bevy_ecs::prelude::*; + +use super::ExecutingPath; + +/// A component that makes bots run /particle commands while pathfinding to show +/// where they're going. This requires the bots to have server operator +/// permissions, and it'll make them spam *a lot* of commands. +/// +/// ``` +/// # use azalea::prelude::*; +/// # use azalea::pathfinder::PathfinderDebugParticles; +/// # #[derive(Component, Clone, Default)] +/// # pub struct State; +/// +/// async fn handle(mut bot: Client, event: azalea::Event, state: State) -> anyhow::Result<()> { +/// match event { +/// azalea::Event::Init => { +/// bot.ecs +/// .lock() +/// .entity_mut(bot.entity) +/// .insert(PathfinderDebugParticles); +/// } +/// _ => {} +/// } +/// Ok(()) +/// } +/// ``` +#[derive(Component)] +pub struct PathfinderDebugParticles; + +pub fn debug_render_path_with_particles( + mut query: Query<(Entity, &ExecutingPath, &InstanceHolder), With<PathfinderDebugParticles>>, + // chat_events is Option because the tests don't have SendChatEvent + // and we have to use ResMut<Events> because bevy doesn't support Option<EventWriter> + chat_events: Option<ResMut<Events<SendChatEvent>>>, + mut tick_count: Local<usize>, +) { + let Some(mut chat_events) = chat_events else { + return; + }; + if *tick_count >= 2 { + *tick_count = 0; + } else { + *tick_count += 1; + return; + } + for (entity, executing_path, instance_holder) in &mut query { + if executing_path.path.is_empty() { + continue; + } + + let chunks = &instance_holder.instance.read().chunks; + + let mut start = executing_path.last_reached_node; + for (i, movement) in executing_path.path.iter().enumerate() { + // /particle dust 0 1 1 1 ~ ~ ~ 0 0 0.2 0 100 + + let end = movement.target; + + let start_vec3 = start.center(); + let end_vec3 = end.center(); + + let step_count = (start_vec3.distance_to_sqr(&end_vec3).sqrt() * 4.0) as usize; + + let target_block_state = chunks.get_block_state(&movement.target).unwrap_or_default(); + let above_target_block_state = chunks + .get_block_state(&movement.target.up(1)) + .unwrap_or_default(); + // 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_passable(above_target_block_state); + + let (r, g, b): (f64, f64, f64) = if i == 0 { + (0., 1., 0.) + } else if is_mining { + (1., 0., 0.) + } else { + (0., 1., 1.) + }; + + // interpolate between the start and end positions + for i in 0..step_count { + let percent = i as f64 / step_count as f64; + let pos = Vec3 { + x: start_vec3.x + (end_vec3.x - start_vec3.x) * percent, + y: start_vec3.y + (end_vec3.y - start_vec3.y) * percent, + z: start_vec3.z + (end_vec3.z - start_vec3.z) * percent, + }; + let particle_command = format!( + "/particle dust {r} {g} {b} {size} {start_x} {start_y} {start_z} {delta_x} {delta_y} {delta_z} 0 {count}", + size = 1, + start_x = pos.x, + start_y = pos.y, + start_z = pos.z, + delta_x = 0, + delta_y = 0, + delta_z = 0, + count = 1 + ); + chat_events.send(SendChatEvent { + entity, + content: particle_command, + }); + } + + start = movement.target; + } + } +} diff --git a/azalea/src/pathfinder/goals.rs b/azalea/src/pathfinder/goals.rs index 9c01d486..3f8c7993 100644 --- a/azalea/src/pathfinder/goals.rs +++ b/azalea/src/pathfinder/goals.rs @@ -1,12 +1,21 @@ +//! The goals that a pathfinder can try to reach. + use std::f32::consts::SQRT_2; use azalea_core::position::{BlockPos, Vec3}; +use azalea_world::ChunkStorage; + +use super::costs::{COST_HEURISTIC, FALL_N_BLOCKS_COST, JUMP_ONE_BLOCK_COST}; -use super::{ - costs::{FALL_N_BLOCKS_COST, JUMP_ONE_BLOCK_COST}, - Goal, -}; +pub trait Goal { + #[must_use] + fn heuristic(&self, n: BlockPos) -> f32; + #[must_use] + fn success(&self, n: BlockPos) -> bool; +} +/// Move to the given block position. +#[derive(Debug)] pub struct BlockPosGoal(pub BlockPos); impl Goal for BlockPosGoal { fn heuristic(&self, n: BlockPos) -> f32 { @@ -36,9 +45,11 @@ fn xz_heuristic(dx: f32, dz: f32) -> f32 { diagonal = z; } - diagonal * SQRT_2 + straight + (diagonal * SQRT_2 + straight) * COST_HEURISTIC } +/// Move to the given block position, ignoring the y axis. +#[derive(Debug)] pub struct XZGoal { pub x: i32, pub z: i32, @@ -62,6 +73,8 @@ fn y_heuristic(dy: f32) -> f32 { } } +/// Move to the given y coordinate. +#[derive(Debug)] pub struct YGoal { pub y: i32, } @@ -75,6 +88,8 @@ impl Goal for YGoal { } } +/// Get within the given radius of the given position. +#[derive(Debug)] pub struct RadiusGoal { pub pos: Vec3, pub radius: f32, @@ -96,6 +111,8 @@ impl Goal for RadiusGoal { } } +/// Do the opposite of the given goal. +#[derive(Debug)] pub struct InverseGoal<T: Goal>(pub T); impl<T: Goal> Goal for InverseGoal<T> { fn heuristic(&self, n: BlockPos) -> f32 { @@ -106,6 +123,8 @@ impl<T: Goal> Goal for InverseGoal<T> { } } +/// Do either of the given goals, whichever is closer. +#[derive(Debug)] pub struct OrGoal<T: Goal, U: Goal>(pub T, pub U); impl<T: Goal, U: Goal> Goal for OrGoal<T, U> { fn heuristic(&self, n: BlockPos) -> f32 { @@ -116,6 +135,24 @@ impl<T: Goal, U: Goal> Goal for OrGoal<T, U> { } } +/// Do any of the given goals, whichever is closest. +#[derive(Debug)] +pub struct OrGoals<T: Goal>(pub Vec<T>); +impl<T: Goal> Goal for OrGoals<T> { + fn heuristic(&self, n: BlockPos) -> f32 { + self.0 + .iter() + .map(|goal| goal.heuristic(n)) + .min_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(f32::INFINITY) + } + fn success(&self, n: BlockPos) -> bool { + self.0.iter().any(|goal| goal.success(n)) + } +} + +/// Try to reach both of the given goals. +#[derive(Debug)] pub struct AndGoal<T: Goal, U: Goal>(pub T, pub U); impl<T: Goal, U: Goal> Goal for AndGoal<T, U> { fn heuristic(&self, n: BlockPos) -> f32 { @@ -125,3 +162,52 @@ impl<T: Goal, U: Goal> Goal for AndGoal<T, U> { self.0.success(n) && self.1.success(n) } } + +/// Try to reach all of the given goals. +#[derive(Debug)] +pub struct AndGoals<T: Goal>(pub Vec<T>); +impl<T: Goal> Goal for AndGoals<T> { + fn heuristic(&self, n: BlockPos) -> f32 { + self.0 + .iter() + .map(|goal| goal.heuristic(n)) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(f32::INFINITY) + } + fn success(&self, n: BlockPos) -> bool { + self.0.iter().all(|goal| goal.success(n)) + } +} + +/// Move to a position where we can reach the given block. +#[derive(Debug)] +pub struct ReachBlockPosGoal { + pub pos: BlockPos, + pub chunk_storage: ChunkStorage, +} +impl Goal for ReachBlockPosGoal { + fn heuristic(&self, n: BlockPos) -> f32 { + BlockPosGoal(self.pos).heuristic(n) + } + fn success(&self, n: BlockPos) -> bool { + // only do the expensive check if we're close enough + let max_pick_range = 6; + let actual_pick_range = 4.5; + + let distance = (self.pos - n).length_sqr(); + if distance > max_pick_range * max_pick_range { + return false; + } + + let eye_position = n.to_vec3_floored() + Vec3::new(0.5, 1.62, 0.5); + let look_direction = crate::direction_looking_at(&eye_position, &self.pos.center()); + let block_hit_result = azalea_client::interact::pick( + &look_direction, + &eye_position, + &self.chunk_storage, + actual_pick_range, + ); + + block_hit_result.block_pos == self.pos + } +} diff --git a/azalea/src/pathfinder/mining.rs b/azalea/src/pathfinder/mining.rs index d5977973..1bc08c43 100644 --- a/azalea/src/pathfinder/mining.rs +++ b/azalea/src/pathfinder/mining.rs @@ -1,30 +1,109 @@ -use azalea_block::BlockState; +use std::{cell::UnsafeCell, ops::RangeInclusive}; + +use azalea_block::{BlockState, BlockStates}; use azalea_inventory::Menu; use nohash_hasher::IntMap; use crate::auto_tool::best_tool_in_hotbar_for_block; +use super::costs::BLOCK_BREAK_ADDITIONAL_PENALTY; + pub struct MiningCache { - block_state_id_costs: IntMap<u32, f32>, - inventory_menu: Menu, + block_state_id_costs: UnsafeCell<IntMap<u32, f32>>, + inventory_menu: Option<Menu>, + + water_block_state_range: RangeInclusive<u32>, + lava_block_state_range: RangeInclusive<u32>, + + falling_blocks: Vec<BlockState>, } impl MiningCache { - pub fn new(inventory_menu: Menu) -> Self { + pub fn new(inventory_menu: Option<Menu>) -> Self { + let water_block_states = BlockStates::from(azalea_registry::Block::Water); + let lava_block_states = BlockStates::from(azalea_registry::Block::Lava); + + let mut water_block_state_range_min = u32::MAX; + let mut water_block_state_range_max = u32::MIN; + for state in water_block_states { + water_block_state_range_min = water_block_state_range_min.min(state.id); + water_block_state_range_max = water_block_state_range_max.max(state.id); + } + let water_block_state_range = water_block_state_range_min..=water_block_state_range_max; + + let mut lava_block_state_range_min = u32::MAX; + let mut lava_block_state_range_max = u32::MIN; + for state in lava_block_states { + lava_block_state_range_min = lava_block_state_range_min.min(state.id); + lava_block_state_range_max = lava_block_state_range_max.max(state.id); + } + let lava_block_state_range = lava_block_state_range_min..=lava_block_state_range_max; + + let mut falling_blocks: Vec<BlockState> = vec![ + azalea_registry::Block::Sand.into(), + azalea_registry::Block::RedSand.into(), + azalea_registry::Block::Gravel.into(), + azalea_registry::Block::Anvil.into(), + azalea_registry::Block::ChippedAnvil.into(), + azalea_registry::Block::DamagedAnvil.into(), + // concrete powders + azalea_registry::Block::WhiteConcretePowder.into(), + azalea_registry::Block::OrangeConcretePowder.into(), + azalea_registry::Block::MagentaConcretePowder.into(), + azalea_registry::Block::LightBlueConcretePowder.into(), + azalea_registry::Block::YellowConcretePowder.into(), + azalea_registry::Block::LimeConcretePowder.into(), + azalea_registry::Block::PinkConcretePowder.into(), + azalea_registry::Block::GrayConcretePowder.into(), + azalea_registry::Block::LightGrayConcretePowder.into(), + azalea_registry::Block::CyanConcretePowder.into(), + azalea_registry::Block::PurpleConcretePowder.into(), + azalea_registry::Block::BlueConcretePowder.into(), + azalea_registry::Block::BrownConcretePowder.into(), + azalea_registry::Block::GreenConcretePowder.into(), + azalea_registry::Block::RedConcretePowder.into(), + azalea_registry::Block::BlackConcretePowder.into(), + ]; + falling_blocks.sort_unstable_by_key(|block| block.id); + Self { - block_state_id_costs: IntMap::default(), + block_state_id_costs: UnsafeCell::new(IntMap::default()), inventory_menu, + water_block_state_range, + lava_block_state_range, + falling_blocks, } } - pub fn cost_for(&mut self, block: BlockState) -> f32 { - if let Some(cost) = self.block_state_id_costs.get(&block.id) { + pub fn cost_for(&self, block: BlockState) -> f32 { + let Some(inventory_menu) = &self.inventory_menu else { + return f32::INFINITY; + }; + + // SAFETY: mining is single-threaded, so this is safe + let block_state_id_costs = unsafe { &mut *self.block_state_id_costs.get() }; + + if let Some(cost) = block_state_id_costs.get(&block.id) { *cost } else { - let best_tool_result = best_tool_in_hotbar_for_block(block, &self.inventory_menu); - let cost = 1. / best_tool_result.percentage_per_tick; - self.block_state_id_costs.insert(block.id, cost); + let best_tool_result = best_tool_in_hotbar_for_block(block, inventory_menu); + let mut cost = 1. / best_tool_result.percentage_per_tick; + + cost += BLOCK_BREAK_ADDITIONAL_PENALTY; + + block_state_id_costs.insert(block.id, cost); cost } } + + pub fn is_liquid(&self, block: BlockState) -> bool { + self.water_block_state_range.contains(&block.id) + || self.lava_block_state_range.contains(&block.id) + } + + pub fn is_falling_block(&self, block: BlockState) -> bool { + self.falling_blocks + .binary_search_by_key(&block.id, |block| block.id) + .is_ok() + } } diff --git a/azalea/src/pathfinder/mod.rs b/azalea/src/pathfinder/mod.rs index 525f982d..9fd769e6 100644 --- a/azalea/src/pathfinder/mod.rs +++ b/azalea/src/pathfinder/mod.rs @@ -1,8 +1,10 @@ -//! A pathfinding plugin to make bots navigate the world. A lot of this code is -//! based on [Baritone](https://github.com/cabaletta/baritone). +//! A pathfinding plugin to make bots able to traverse the world. +//! +//! Much of this code is based on [Baritone](https://github.com/cabaletta/baritone). pub mod astar; pub mod costs; +mod debug; pub mod goals; pub mod mining; pub mod moves; @@ -23,11 +25,11 @@ use crate::ecs::{ }; use crate::pathfinder::moves::PathfinderCtx; use crate::pathfinder::world::CachedWorld; -use azalea_client::chat::SendChatEvent; -use azalea_client::inventory::{InventoryComponent, InventorySet}; +use azalea_client::inventory::{InventoryComponent, InventorySet, SetSelectedHotbarSlotEvent}; +use azalea_client::mining::{Mining, StartMiningBlockEvent}; use azalea_client::movement::MoveEventsSet; -use azalea_client::{StartSprintEvent, StartWalkEvent}; -use azalea_core::position::{BlockPos, Vec3}; +use azalea_client::{InstanceHolder, StartSprintEvent, StartWalkEvent}; +use azalea_core::position::BlockPos; use azalea_core::tick::GameTick; use azalea_entity::metadata::Player; use azalea_entity::LocalEntity; @@ -35,11 +37,9 @@ use azalea_entity::{Physics, Position}; use azalea_physics::PhysicsSet; use azalea_world::{InstanceContainer, InstanceName}; use bevy_app::{PreUpdate, Update}; -use bevy_ecs::event::Events; use bevy_ecs::prelude::Event; use bevy_ecs::query::Changed; use bevy_ecs::schedule::IntoSystemConfigs; -use bevy_ecs::system::{Local, ResMut}; use bevy_tasks::{AsyncComputeTaskPool, Task}; use futures_lite::future; use std::collections::VecDeque; @@ -48,6 +48,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tracing::{debug, error, info, trace, warn}; +use self::debug::debug_render_path_with_particles; +pub use self::debug::PathfinderDebugParticles; +use self::goals::Goal; use self::mining::MiningCache; use self::moves::{ExecuteCtx, IsReachedCtx, SuccessorsFn}; @@ -93,11 +96,12 @@ impl Plugin for PathfinderPlugin { } /// A component that makes this client able to pathfind. -#[derive(Component, Default)] +#[derive(Component, Default, Clone)] pub struct Pathfinder { pub goal: Option<Arc<dyn Goal + Send + Sync>>, pub successors_fn: Option<SuccessorsFn>, pub is_calculating: bool, + pub allow_mining: bool, pub goto_id: Arc<AtomicUsize>, } @@ -120,6 +124,9 @@ pub struct GotoEvent { /// The function that's used for checking what moves are possible. Usually /// `pathfinder::moves::default_move` pub successors_fn: SuccessorsFn, + + /// Whether the bot is allowed to break blocks while pathfinding. + pub allow_mining: bool, } #[derive(Event, Clone)] pub struct PathFoundEvent { @@ -128,6 +135,7 @@ pub struct PathFoundEvent { pub path: Option<VecDeque<astar::Movement<BlockPos, moves::MoveData>>>, pub is_partial: bool, pub successors_fn: SuccessorsFn, + pub allow_mining: bool, } #[allow(clippy::type_complexity)] @@ -142,6 +150,7 @@ fn add_default_pathfinder( pub trait PathfinderClientExt { fn goto(&self, goal: impl Goal + Send + Sync + 'static); + fn goto_without_mining(&self, goal: impl Goal + Send + Sync + 'static); fn stop_pathfinding(&self); } @@ -158,6 +167,18 @@ impl PathfinderClientExt for azalea_client::Client { entity: self.entity, goal: Arc::new(goal), successors_fn: moves::default_move, + allow_mining: true, + }); + } + + /// Same as [`goto`](Self::goto). but the bot won't break any blocks while + /// executing the path. + fn goto_without_mining(&self, goal: impl Goal + Send + Sync + 'static) { + self.ecs.lock().send_event(GotoEvent { + entity: self.entity, + goal: Arc::new(goal), + successors_fn: moves::default_move, + allow_mining: false, }); } @@ -191,10 +212,19 @@ fn goto_listener( .get_mut(event.entity) .expect("Called goto on an entity that's not in the world"); + if event.goal.success(BlockPos::from(position)) { + // we're already at the goal, nothing to do + pathfinder.goal = None; + pathfinder.successors_fn = None; + pathfinder.is_calculating = false; + continue; + } + // we store the goal so it can be recalculated later if necessary pathfinder.goal = Some(event.goal.clone()); pathfinder.successors_fn = Some(event.successors_fn); pathfinder.is_calculating = true; + pathfinder.allow_mining = event.allow_mining; let start = if let Some(executing_path) = executing_path && let Some(final_node) = executing_path.path.back() @@ -220,7 +250,13 @@ fn goto_listener( let goto_id_atomic = pathfinder.goto_id.clone(); let goto_id = goto_id_atomic.fetch_add(1, atomic::Ordering::Relaxed) + 1; - let mining_cache = MiningCache::new(inventory.inventory_menu.clone()); + + let allow_mining = event.allow_mining; + let mining_cache = MiningCache::new(if allow_mining { + Some(inventory.inventory_menu.clone()) + } else { + None + }); let task = thread_pool.spawn(async move { debug!("start: {start:?}"); @@ -248,7 +284,11 @@ fn goto_listener( debug!("partial: {partial:?}"); let duration = end_time - start_time; if partial { - info!("Pathfinder took {duration:?} (incomplete path)"); + if movements.is_empty() { + info!("Pathfinder took {duration:?} (empty path)"); + } else { + info!("Pathfinder took {duration:?} (incomplete path)"); + } // wait a bit so it's not a busy loop std::thread::sleep(Duration::from_millis(100)); } else { @@ -289,6 +329,7 @@ fn goto_listener( path: Some(path), is_partial, successors_fn, + allow_mining, }) }); @@ -342,7 +383,11 @@ fn path_found_listener( .expect("Entity tried to pathfind but the entity isn't in a valid world"); let successors_fn: moves::SuccessorsFn = event.successors_fn; let cached_world = CachedWorld::new(world_lock); - let mining_cache = MiningCache::new(inventory.inventory_menu.clone()); + let mining_cache = MiningCache::new(if event.allow_mining { + Some(inventory.inventory_menu.clone()) + } else { + None + }); let successors = |pos: BlockPos| { call_successors_fn(&cached_world, &mining_cache, successors_fn, pos) }; @@ -399,8 +444,21 @@ fn path_found_listener( } } -fn timeout_movement(mut query: Query<(&Pathfinder, &mut ExecutingPath, &Position)>) { - for (pathfinder, mut executing_path, position) in &mut query { +fn timeout_movement( + mut query: Query<(&Pathfinder, &mut ExecutingPath, &Position, Option<&Mining>)>, +) { + for (pathfinder, mut executing_path, position, mining) in &mut query { + // 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 + if mining.pos.distance_to_sqr(&BlockPos::from(position)) < 6_i32.pow(2) { + // also reset the last_node_reached_at so we don't timeout after we finish + // mining + executing_path.last_node_reached_at = Instant::now(); + continue; + } + } + if executing_path.last_node_reached_at.elapsed() > Duration::from_secs(2) && !pathfinder.is_calculating && !executing_path.path.is_empty() @@ -535,7 +593,11 @@ fn check_for_path_obstruction( // obstruction check (the path we're executing isn't possible anymore) let cached_world = CachedWorld::new(world_lock); - let mining_cache = MiningCache::new(inventory.inventory_menu.clone()); + let mining_cache = MiningCache::new(if pathfinder.allow_mining { + Some(inventory.inventory_menu.clone()) + } else { + None + }); let successors = |pos: BlockPos| call_successors_fn(&cached_world, &mining_cache, successors_fn, pos); @@ -580,6 +642,7 @@ fn recalculate_near_end_of_path( entity, goal, successors_fn, + allow_mining: pathfinder.allow_mining, }); pathfinder.is_calculating = true; @@ -614,14 +677,27 @@ fn recalculate_near_end_of_path( } } +#[allow(clippy::type_complexity)] fn tick_execute_path( - mut query: Query<(Entity, &mut ExecutingPath, &Position, &Physics)>, + mut query: Query<( + Entity, + &mut ExecutingPath, + &Position, + &Physics, + Option<&Mining>, + &InstanceHolder, + &InventoryComponent, + )>, mut look_at_events: EventWriter<LookAtEvent>, mut sprint_events: EventWriter<StartSprintEvent>, mut walk_events: EventWriter<StartWalkEvent>, mut jump_events: EventWriter<JumpEvent>, + mut start_mining_events: EventWriter<StartMiningBlockEvent>, + mut set_selected_hotbar_slot_events: EventWriter<SetSelectedHotbarSlotEvent>, ) { - for (entity, executing_path, position, physics) in &mut query { + for (entity, executing_path, position, physics, mining, instance_holder, inventory_component) in + &mut query + { if let Some(movement) = executing_path.path.front() { let ctx = ExecuteCtx { entity, @@ -629,10 +705,16 @@ fn tick_execute_path( position: **position, start: executing_path.last_reached_node, physics, + is_currently_mining: mining.is_some(), + instance: instance_holder.instance.clone(), + menu: inventory_component.inventory_menu.clone(), + 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, + set_selected_hotbar_slot_events: &mut set_selected_hotbar_slot_events, }; trace!("executing move"); (movement.data.execute)(ctx); @@ -652,6 +734,7 @@ fn recalculate_if_has_goal_but_no_path( entity, goal, successors_fn: pathfinder.successors_fn.unwrap(), + allow_mining: pathfinder.allow_mining, }); pathfinder.is_calculating = true; } @@ -718,101 +801,6 @@ fn stop_pathfinding_on_instance_change( } } -/// A component that makes bots run /particle commands while pathfinding to show -/// where they're going. This requires the bots to have server operator -/// permissions, and it'll make them spam *a lot* of commands. -/// -/// ``` -/// # use azalea::prelude::*; -/// # use azalea::pathfinder::PathfinderDebugParticles; -/// # #[derive(Component, Clone, Default)] -/// # pub struct State; -/// -/// async fn handle(mut bot: Client, event: azalea::Event, state: State) -> anyhow::Result<()> { -/// match event { -/// azalea::Event::Init => { -/// bot.ecs -/// .lock() -/// .entity_mut(bot.entity) -/// .insert(PathfinderDebugParticles); -/// } -/// _ => {} -/// } -/// Ok(()) -/// } -/// ``` -#[derive(Component)] -pub struct PathfinderDebugParticles; - -fn debug_render_path_with_particles( - mut query: Query<(Entity, &ExecutingPath), With<PathfinderDebugParticles>>, - // chat_events is Option because the tests don't have SendChatEvent - // and we have to use ResMut<Events> because bevy doesn't support Option<EventWriter> - chat_events: Option<ResMut<Events<SendChatEvent>>>, - mut tick_count: Local<usize>, -) { - let Some(mut chat_events) = chat_events else { - return; - }; - if *tick_count >= 2 { - *tick_count = 0; - } else { - *tick_count += 1; - return; - } - for (entity, executing_path) in &mut query { - if executing_path.path.is_empty() { - continue; - } - - let mut start = executing_path.last_reached_node; - for (i, movement) in executing_path.path.iter().enumerate() { - // /particle dust 0 1 1 1 ~ ~ ~ 0 0 0.2 0 100 - - let end = movement.target; - - let start_vec3 = start.center(); - let end_vec3 = end.center(); - - let step_count = (start_vec3.distance_to_sqr(&end_vec3).sqrt() * 4.0) as usize; - - let (r, g, b): (f64, f64, f64) = if i == 0 { (0., 1., 0.) } else { (0., 1., 1.) }; - - // interpolate between the start and end positions - for i in 0..step_count { - let percent = i as f64 / step_count as f64; - let pos = Vec3 { - x: start_vec3.x + (end_vec3.x - start_vec3.x) * percent, - y: start_vec3.y + (end_vec3.y - start_vec3.y) * percent, - z: start_vec3.z + (end_vec3.z - start_vec3.z) * percent, - }; - let particle_command = format!( - "/particle dust {r} {g} {b} {size} {start_x} {start_y} {start_z} {delta_x} {delta_y} {delta_z} 0 {count}", - size = 1, - start_x = pos.x, - start_y = pos.y, - start_z = pos.z, - delta_x = 0, - delta_y = 0, - delta_z = 0, - count = 1 - ); - chat_events.send(SendChatEvent { - entity, - content: particle_command, - }); - } - - start = movement.target; - } - } -} - -pub trait Goal { - fn heuristic(&self, n: BlockPos) -> f32; - fn success(&self, n: BlockPos) -> bool; -} - /// Checks whether the path has been obstructed, and returns Some(index) if it /// has been. The index is of the first obstructed node. fn check_path_obstructed<SuccessorsFn>( @@ -911,6 +899,7 @@ mod tests { entity: simulation.entity, goal: Arc::new(BlockPosGoal(end_pos)), successors_fn: moves::default_move, + allow_mining: false, }); simulation } diff --git a/azalea/src/pathfinder/moves/basic.rs b/azalea/src/pathfinder/moves/basic.rs index 957e24c6..54a6dc6a 100644 --- a/azalea/src/pathfinder/moves/basic.rs +++ b/azalea/src/pathfinder/moves/basic.rs @@ -16,17 +16,20 @@ pub fn basic_move(ctx: &mut PathfinderCtx, node: BlockPos) { descend_move(ctx, node); diagonal_move(ctx, node); descend_forward_1_move(ctx, node); + downward_move(ctx, node); } fn forward_move(ctx: &mut PathfinderCtx, pos: BlockPos) { for dir in CardinalDirection::iter() { let offset = BlockPos::new(dir.x(), 0, dir.z()); - if !ctx.world.is_standable(pos + offset) { + let mut cost = SPRINT_ONE_BLOCK_COST; + + let break_cost = ctx.world.cost_for_standing(pos + offset, ctx.mining_cache); + if break_cost == f32::INFINITY { continue; } - - let cost = SPRINT_ONE_BLOCK_COST; + cost += break_cost; ctx.edges.push(Edge { movement: astar::Movement { @@ -43,6 +46,14 @@ fn forward_move(ctx: &mut PathfinderCtx, pos: BlockPos) { fn execute_forward_move(mut ctx: ExecuteCtx) { let center = ctx.target.center(); + + if ctx.mine_while_at_start(ctx.target.up(1)) { + return; + } + if ctx.mine_while_at_start(ctx.target) { + return; + } + ctx.look_at(center); ctx.sprint(SprintDirection::Forward); } @@ -51,14 +62,22 @@ fn ascend_move(ctx: &mut PathfinderCtx, pos: BlockPos) { for dir in CardinalDirection::iter() { let offset = BlockPos::new(dir.x(), 1, dir.z()); - if !ctx.world.is_block_passable(pos.up(2)) { + let break_cost_1 = ctx + .world + .cost_for_breaking_block(pos.up(2), ctx.mining_cache); + if break_cost_1 == f32::INFINITY { continue; } - if !ctx.world.is_standable(pos + offset) { + let break_cost_2 = ctx.world.cost_for_standing(pos + offset, ctx.mining_cache); + if break_cost_2 == f32::INFINITY { continue; } - let cost = SPRINT_ONE_BLOCK_COST + JUMP_PENALTY + *JUMP_ONE_BLOCK_COST; + let cost = SPRINT_ONE_BLOCK_COST + + JUMP_PENALTY + + *JUMP_ONE_BLOCK_COST + + break_cost_1 + + break_cost_2; ctx.edges.push(Edge { movement: astar::Movement { @@ -81,6 +100,16 @@ fn execute_ascend_move(mut ctx: ExecuteCtx) { .. } = ctx; + if ctx.mine_while_at_start(start.up(2)) { + return; + } + if ctx.mine_while_at_start(target) { + return; + } + if ctx.mine_while_at_start(target.up(1)) { + return; + } + let target_center = target.center(); ctx.look_at(target_center); @@ -123,19 +152,39 @@ fn descend_move(ctx: &mut PathfinderCtx, pos: BlockPos) { for dir in CardinalDirection::iter() { let dir_delta = BlockPos::new(dir.x(), 0, dir.z()); let new_horizontal_position = pos + dir_delta; - let fall_distance = ctx.world.fall_distance(new_horizontal_position); - if fall_distance == 0 || fall_distance > 3 { + + let break_cost_1 = ctx + .world + .cost_for_passing(new_horizontal_position, ctx.mining_cache); + if break_cost_1 == f32::INFINITY { continue; } - let new_position = new_horizontal_position.down(fall_distance as i32); - // check whether 3 blocks vertically forward are passable - if !ctx.world.is_passable(new_horizontal_position) { + let mut fall_distance = ctx.world.fall_distance(new_horizontal_position); + if fall_distance > 3 { continue; } - // check whether we can stand on the target position - if !ctx.world.is_standable(new_position) { - continue; + + if fall_distance == 0 { + // if the fall distance is 0, set it to 1 so we try mining + fall_distance = 1 + } + + let new_position = new_horizontal_position.down(fall_distance as i32); + + // only mine if we're descending 1 block + let break_cost_2; + if fall_distance == 1 { + break_cost_2 = ctx.world.cost_for_standing(new_position, ctx.mining_cache); + if break_cost_2 == f32::INFINITY { + continue; + } + } else { + // check whether we can stand on the target position + if !ctx.world.is_standable(new_position) { + continue; + } + break_cost_2 = 0.; } let cost = WALK_OFF_BLOCK_COST @@ -145,9 +194,11 @@ fn descend_move(ctx: &mut PathfinderCtx, pos: BlockPos) { .copied() // avoid panicking if we fall more than the size of FALL_N_BLOCKS_COST // probably not possible but just in case - .unwrap_or(f32::MAX), + .unwrap_or(f32::INFINITY), CENTER_AFTER_FALL_COST, - ); + ) + + break_cost_1 + + break_cost_2; ctx.edges.push(Edge { movement: astar::Movement { @@ -169,6 +220,12 @@ fn execute_descend_move(mut ctx: ExecuteCtx) { .. } = ctx; + for i in (0..=(start.y - target.y + 1)).rev() { + if ctx.mine_while_at_start(target.up(i)) { + return; + } + } + let start_center = start.center(); let center = target.center(); @@ -249,7 +306,7 @@ fn descend_forward_1_move(ctx: &mut PathfinderCtx, pos: BlockPos) { .copied() // avoid panicking if we fall more than the size of FALL_N_BLOCKS_COST // probably not possible but just in case - .unwrap_or(f32::MAX), + .unwrap_or(f32::INFINITY), CENTER_AFTER_FALL_COST, ); @@ -310,3 +367,53 @@ fn execute_diagonal_move(mut ctx: ExecuteCtx) { ctx.look_at(target_center); ctx.sprint(SprintDirection::Forward); } + +/// Go directly down, usually by mining. +fn downward_move(ctx: &mut PathfinderCtx, pos: BlockPos) { + // make sure we land on a solid block after breaking the one below us + if !ctx.world.is_block_solid(pos.down(2)) { + return; + } + + let break_cost = ctx + .world + .cost_for_breaking_block(pos.down(1), ctx.mining_cache); + if break_cost == f32::INFINITY { + return; + } + + let cost = FALL_N_BLOCKS_COST[1] + break_cost; + + ctx.edges.push(Edge { + movement: astar::Movement { + target: pos.down(1), + data: MoveData { + execute: &execute_downward_move, + is_reached: &default_is_reached, + }, + }, + cost, + }) +} +fn execute_downward_move(mut ctx: ExecuteCtx) { + let ExecuteCtx { + target, position, .. + } = ctx; + + let target_center = target.center(); + + let horizontal_distance_from_target = + (target_center - position).horizontal_distance_sqr().sqrt(); + + if horizontal_distance_from_target > 0.25 { + ctx.look_at(target_center); + ctx.walk(WalkDirection::Forward); + } else if ctx.mine_while_at_start(target) { + ctx.walk(WalkDirection::None); + } else if BlockPos::from(position) != target { + ctx.look_at(target_center); + ctx.walk(WalkDirection::Forward); + } else { + ctx.walk(WalkDirection::None); + } +} diff --git a/azalea/src/pathfinder/moves/mod.rs b/azalea/src/pathfinder/moves/mod.rs index e5b837ea..bb10b192 100644 --- a/azalea/src/pathfinder/moves/mod.rs +++ b/azalea/src/pathfinder/moves/mod.rs @@ -1,14 +1,24 @@ pub mod basic; pub mod parkour; -use std::fmt::Debug; - -use crate::{JumpEvent, LookAtEvent}; - -use super::{astar, mining::MiningCache, world::CachedWorld}; -use azalea_client::{SprintDirection, StartSprintEvent, StartWalkEvent, WalkDirection}; +use std::{fmt::Debug, sync::Arc}; + +use crate::{auto_tool::best_tool_in_hotbar_for_block, JumpEvent, LookAtEvent}; + +use super::{ + astar, + mining::MiningCache, + world::{is_block_state_passable, CachedWorld}, +}; +use azalea_client::{ + inventory::SetSelectedHotbarSlotEvent, mining::StartMiningBlockEvent, SprintDirection, + StartSprintEvent, StartWalkEvent, WalkDirection, +}; use azalea_core::position::{BlockPos, Vec3}; +use azalea_inventory::Menu; +use azalea_world::Instance; use bevy_ecs::{entity::Entity, event::EventWriter}; +use parking_lot::RwLock; type Edge = astar::Edge<BlockPos, MoveData>; @@ -35,7 +45,7 @@ impl Debug for MoveData { } } -pub struct ExecuteCtx<'w1, 'w2, 'w3, 'w4, 'a> { +pub struct ExecuteCtx<'w1, 'w2, 'w3, 'w4, 'w5, 'w6, 'a> { pub entity: Entity, /// The node that we're trying to reach. pub target: BlockPos, @@ -43,14 +53,19 @@ pub struct ExecuteCtx<'w1, 'w2, 'w3, 'w4, 'a> { pub start: BlockPos, pub position: Vec3, pub physics: &'a azalea_entity::Physics, + pub is_currently_mining: bool, + pub instance: Arc<RwLock<Instance>>, + pub menu: Menu, pub look_at_events: &'a mut EventWriter<'w1, LookAtEvent>, pub sprint_events: &'a mut EventWriter<'w2, StartSprintEvent>, pub walk_events: &'a mut EventWriter<'w3, StartWalkEvent>, pub jump_events: &'a mut EventWriter<'w4, JumpEvent>, + pub start_mining_events: &'a mut EventWriter<'w5, StartMiningBlockEvent>, + pub set_selected_hotbar_slot_events: &'a mut EventWriter<'w6, SetSelectedHotbarSlotEvent>, } -impl ExecuteCtx<'_, '_, '_, '_, '_> { +impl ExecuteCtx<'_, '_, '_, '_, '_, '_, '_> { pub fn look_at(&mut self, position: Vec3) { self.look_at_events.send(LookAtEvent { entity: self.entity, @@ -63,6 +78,13 @@ impl ExecuteCtx<'_, '_, '_, '_, '_> { }); } + pub fn look_at_exact(&mut self, position: Vec3) { + self.look_at_events.send(LookAtEvent { + entity: self.entity, + position, + }); + } + pub fn sprint(&mut self, direction: SprintDirection) { self.sprint_events.send(StartSprintEvent { entity: self.entity, @@ -82,6 +104,76 @@ impl ExecuteCtx<'_, '_, '_, '_, '_> { entity: self.entity, }); } + + /// Returns whether this block could be mined. + pub fn should_mine(&mut self, block: BlockPos) -> bool { + let block_state = self + .instance + .read() + .get_block_state(&block) + .unwrap_or_default(); + if is_block_state_passable(block_state) { + // block is already passable, no need to mine it + return false; + } + + true + } + + /// Mine the block at the given position. Returns whether the block is being + /// mined. + pub fn mine(&mut self, block: BlockPos) -> bool { + let block_state = self + .instance + .read() + .get_block_state(&block) + .unwrap_or_default(); + if is_block_state_passable(block_state) { + // block is already passable, no need to mine it + return false; + } + + let best_tool_result = best_tool_in_hotbar_for_block(block_state, &self.menu); + + self.set_selected_hotbar_slot_events + .send(SetSelectedHotbarSlotEvent { + entity: self.entity, + slot: best_tool_result.index as u8, + }); + + self.is_currently_mining = true; + + self.walk(WalkDirection::None); + self.look_at_exact(block.center()); + self.start_mining_events.send(StartMiningBlockEvent { + entity: self.entity, + position: block, + }); + + true + } + + /// Mine the given block, but make sure the player is standing at the start + /// of the current node first. + pub fn mine_while_at_start(&mut self, block: BlockPos) -> bool { + let horizontal_distance_from_start = (self.start.center() - self.position) + .horizontal_distance_sqr() + .sqrt(); + let at_start_position = + BlockPos::from(self.position) == self.start && horizontal_distance_from_start < 0.25; + + if self.should_mine(block) { + if at_start_position { + self.mine(block); + } else { + self.look_at(self.start.center()); + self.walk(WalkDirection::Forward); + } + true + } else { + false + } + } } pub struct IsReachedCtx<'a> { diff --git a/azalea/src/pathfinder/world.rs b/azalea/src/pathfinder/world.rs index 4b48f921..a5a273fb 100644 --- a/azalea/src/pathfinder/world.rs +++ b/azalea/src/pathfinder/world.rs @@ -11,6 +11,9 @@ use azalea_core::{ use azalea_physics::collision::BlockWithShape; use azalea_world::Instance; use parking_lot::RwLock; +use rustc_hash::FxHashMap; + +use super::mining::MiningCache; /// An efficient representation of the world used for the pathfinder. pub struct CachedWorld { @@ -20,8 +23,11 @@ pub struct CachedWorld { // we store `PalettedContainer`s instead of `Chunk`s or `Section`s because it doesn't contain // any unnecessary data like heightmaps or biomes. cached_chunks: RefCell<Vec<(ChunkPos, Vec<azalea_world::palette::PalettedContainer>)>>, + last_chunk_cache_index: RefCell<Option<usize>>, cached_blocks: UnsafeCell<CachedSections>, + + cached_mining_costs: RefCell<FxHashMap<BlockPos, f32>>, } #[derive(Default)] @@ -82,7 +88,9 @@ impl CachedWorld { min_y, world_lock, cached_chunks: Default::default(), + last_chunk_cache_index: Default::default(), cached_blocks: Default::default(), + cached_mining_costs: Default::default(), } } @@ -100,24 +108,50 @@ impl CachedWorld { section_pos: ChunkSectionPos, f: impl FnOnce(&azalea_world::palette::PalettedContainer) -> T, ) -> Option<T> { + if section_pos.y * 16 < self.min_y { + // y position is out of bounds + return None; + } + let chunk_pos = ChunkPos::from(section_pos); let section_index = azalea_world::chunk_storage::section_index(section_pos.y * 16, self.min_y) as usize; let mut cached_chunks = self.cached_chunks.borrow_mut(); - // get section from cache - if let Some(sections) = cached_chunks.iter().find_map(|(pos, sections)| { - if *pos == chunk_pos { - Some(sections) - } else { - None + // optimization: avoid doing the iter lookup if the last chunk we looked up is + // the same + if let Some(last_chunk_cache_index) = *self.last_chunk_cache_index.borrow() { + if cached_chunks[last_chunk_cache_index].0 == chunk_pos { + // don't bother with the iter lookup + let sections = &cached_chunks[last_chunk_cache_index].1; + if section_index >= sections.len() { + // y position is out of bounds + return None; + }; + let section: &azalea_world::palette::PalettedContainer = §ions[section_index]; + return Some(f(section)); } - }) { + } + + // get section from cache + if let Some((chunk_index, sections)) = + cached_chunks + .iter() + .enumerate() + .find_map(|(i, (pos, sections))| { + if *pos == chunk_pos { + Some((i, sections)) + } else { + None + } + }) + { if section_index >= sections.len() { // y position is out of bounds return None; }; + *self.last_chunk_cache_index.borrow_mut() = Some(chunk_index); let section: &azalea_world::palette::PalettedContainer = §ions[section_index]; return Some(f(section)); } @@ -206,18 +240,189 @@ impl CachedWorld { solid } + /// Returns how much it costs to break this block. Returns 0 if the block is + /// already passable. + pub fn cost_for_breaking_block(&self, pos: BlockPos, mining_cache: &MiningCache) -> f32 { + let mut cached_mining_costs = self.cached_mining_costs.borrow_mut(); + + if let Some(&cost) = cached_mining_costs.get(&pos) { + return cost; + } + + let cost = self.uncached_cost_for_breaking_block(pos, mining_cache); + cached_mining_costs.insert(pos, cost); + cost + } + + fn uncached_cost_for_breaking_block(&self, pos: BlockPos, mining_cache: &MiningCache) -> f32 { + if self.is_block_passable(pos) { + // if the block is passable then it doesn't need to be broken + return 0.; + } + + let (section_pos, section_block_pos) = + (ChunkSectionPos::from(pos), ChunkSectionBlockPos::from(pos)); + + // we use this as an optimization to avoid getting the section again if the + // block is in the same section + let up_is_in_same_section = section_block_pos.y != 15; + let north_is_in_same_section = section_block_pos.z != 0; + let east_is_in_same_section = section_block_pos.x != 15; + let south_is_in_same_section = section_block_pos.z != 15; + let west_is_in_same_section = section_block_pos.x != 0; + + let Some(mining_cost) = self.with_section(section_pos, |section| { + let block_state = + BlockState::try_from(section.get_at_index(u16::from(section_block_pos) as usize)) + .unwrap_or_default(); + let mining_cost = mining_cache.cost_for(block_state); + + if mining_cost == f32::INFINITY { + // the block is unbreakable + return f32::INFINITY; + } + + // if there's a falling block or liquid above this block, abort + if up_is_in_same_section { + let up_block = BlockState::try_from( + section.get_at_index(u16::from(section_block_pos.up(1)) as usize), + ) + .unwrap_or_default(); + if mining_cache.is_liquid(up_block) || mining_cache.is_falling_block(up_block) { + return f32::INFINITY; + } + } + + // if there's a liquid to the north of this block, abort + if north_is_in_same_section { + let north_block = BlockState::try_from( + section.get_at_index(u16::from(section_block_pos.north(1)) as usize), + ) + .unwrap_or_default(); + if mining_cache.is_liquid(north_block) { + return f32::INFINITY; + } + } + + // liquid to the east + if east_is_in_same_section { + let east_block = BlockState::try_from( + section.get_at_index(u16::from(section_block_pos.east(1)) as usize), + ) + .unwrap_or_default(); + if mining_cache.is_liquid(east_block) { + return f32::INFINITY; + } + } + + // liquid to the south + if south_is_in_same_section { + let south_block = BlockState::try_from( + section.get_at_index(u16::from(section_block_pos.south(1)) as usize), + ) + .unwrap_or_default(); + if mining_cache.is_liquid(south_block) { + return f32::INFINITY; + } + } + + // liquid to the west + if west_is_in_same_section { + let west_block = BlockState::try_from( + section.get_at_index(u16::from(section_block_pos.west(1)) as usize), + ) + .unwrap_or_default(); + if mining_cache.is_liquid(west_block) { + return f32::INFINITY; + } + } + + // the block is probably safe to break, we'll have to check the adjacent blocks + // that weren't in the same section next though + mining_cost + }) else { + // the chunk isn't loaded + let cost = if self.is_block_solid(pos) { + // assume it's unbreakable if it's solid and out of render distance + f32::INFINITY + } else { + 0. + }; + return cost; + }; + + if mining_cost == f32::INFINITY { + // the block is unbreakable + return f32::INFINITY; + } + + let check_should_avoid_this_block = |pos: BlockPos, check: &dyn Fn(BlockState) -> bool| { + let block_state = self + .with_section(ChunkSectionPos::from(pos), |section| { + BlockState::try_from( + section.get_at_index(u16::from(ChunkSectionBlockPos::from(pos)) as usize), + ) + .unwrap_or_default() + }) + .unwrap_or_default(); + check(block_state) + }; + + // check the adjacent blocks that weren't in the same section + if !up_is_in_same_section + && check_should_avoid_this_block(pos.up(1), &|b| { + mining_cache.is_liquid(b) || mining_cache.is_falling_block(b) + }) + { + return f32::INFINITY; + } + if !north_is_in_same_section + && check_should_avoid_this_block(pos.north(1), &|b| mining_cache.is_liquid(b)) + { + return f32::INFINITY; + } + if !east_is_in_same_section + && check_should_avoid_this_block(pos.east(1), &|b| mining_cache.is_liquid(b)) + { + return f32::INFINITY; + } + if !south_is_in_same_section + && check_should_avoid_this_block(pos.south(1), &|b| mining_cache.is_liquid(b)) + { + return f32::INFINITY; + } + if !west_is_in_same_section + && check_should_avoid_this_block(pos.west(1), &|b| mining_cache.is_liquid(b)) + { + return f32::INFINITY; + } + + mining_cost + } + /// Whether this block and the block above are passable pub fn is_passable(&self, pos: BlockPos) -> bool { self.is_block_passable(pos) && self.is_block_passable(pos.up(1)) } + pub fn cost_for_passing(&self, pos: BlockPos, mining_cache: &MiningCache) -> f32 { + self.cost_for_breaking_block(pos, mining_cache) + + self.cost_for_breaking_block(pos.up(1), mining_cache) + } + /// Whether we can stand in this position. Checks if the block below is /// solid, and that the two blocks above that are passable. - pub fn is_standable(&self, pos: BlockPos) -> bool { self.is_block_solid(pos.down(1)) && self.is_passable(pos) } + pub fn cost_for_standing(&self, pos: BlockPos, mining_cache: &MiningCache) -> f32 { + if !self.is_block_solid(pos.down(1)) { + return f32::INFINITY; + } + self.cost_for_passing(pos, mining_cache) + } + /// Get the amount of air blocks until the next solid block below this one. pub fn fall_distance(&self, pos: BlockPos) -> u32 { let mut distance = 0; @@ -235,7 +440,10 @@ impl CachedWorld { } /// whether this block is passable -fn is_block_state_passable(block: BlockState) -> bool { +pub fn is_block_state_passable(block: BlockState) -> bool { + // i already tried optimizing this by having it cache in an IntMap/FxHashMap but + // it wasn't measurably faster + if block.is_air() { // fast path return true; @@ -243,7 +451,8 @@ fn is_block_state_passable(block: BlockState) -> bool { if !block.is_shape_empty() { return false; } - if block == azalea_registry::Block::Water.into() { + let registry_block = azalea_registry::Block::from(block); + if registry_block == azalea_registry::Block::Water { return false; } if block @@ -252,7 +461,7 @@ fn is_block_state_passable(block: BlockState) -> bool { { return false; } - if block == azalea_registry::Block::Lava.into() { + if registry_block == azalea_registry::Block::Lava { return false; } // block.waterlogged currently doesn't account for seagrass and some other water @@ -261,11 +470,18 @@ fn is_block_state_passable(block: BlockState) -> bool { return false; } + // don't walk into fire + if registry_block == azalea_registry::Block::Fire + || registry_block == azalea_registry::Block::SoulFire + { + return false; + } + true } /// whether this block has a solid hitbox (i.e. we can stand on it) -fn is_block_state_solid(block: BlockState) -> bool { +pub fn is_block_state_solid(block: BlockState) -> bool { if block.is_air() { // fast path return false; |
