diff --git a/Cargo.toml b/Cargo.toml index e4e4d5b0..30a6f454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "src/game_systems", "src/game_systems/src/background", "src/game_systems/src/mobs", + "src/game_systems/src/pathfinding", "src/game_systems/src/interactions", "src/game_systems/src/packets", "src/game_systems/src/physics", diff --git a/src/core/src/block_properties.rs b/src/core/src/block_properties.rs new file mode 100644 index 00000000..4f78828b --- /dev/null +++ b/src/core/src/block_properties.rs @@ -0,0 +1,120 @@ +use std::sync::LazyLock; + +use crate::block_data::BlockData; +use crate::block_state_id::{BlockStateId, ID2BLOCK}; + +/// Precomputed solidity for all block states. +/// A block is solid if it has a full collision box that entities cannot walk through. +/// Indexed by `BlockStateId::raw()`. +static SOLID_BLOCKS: LazyLock> = + LazyLock::new(|| ID2BLOCK.iter().map(compute_solid).collect()); + +/// Returns whether a block is solid (has a full collision box). +/// +/// This is the single source of truth for solidity, used by both the collision +/// system and the pathfinding system. +#[inline] +pub fn is_solid(id: BlockStateId) -> bool { + SOLID_BLOCKS[id.raw() as usize] +} + +/// Determine whether a block data entry represents a solid block. +fn compute_solid(data: &BlockData) -> bool { + let name = data.name.trim_start_matches("minecraft:"); + + // Air variants are never solid + if name.ends_with("air") { + return false; + } + + // Liquids + if matches!(name, "water" | "lava" | "bubble_column") { + return false; + } + + // Fire + if matches!(name, "fire" | "soul_fire") { + return false; + } + if name.ends_with("_campfire") { + // Campfires are solid blocks you can stand on, but lit ones deal damage + return true; + } + + // Doors, fence gates, trapdoors: solid only when closed + if name.ends_with("_door") || name.ends_with("_fence_gate") || name.ends_with("_trapdoor") { + let open = data + .properties + .as_ref() + .and_then(|p| p.get("open")) + .is_some_and(|v| v == "true"); + return !open; + } + + // Non-solid vegetation and decorations + if is_non_solid_decoration(name) { + return false; + } + + // Default: solid + true +} + +/// Returns true for blocks that have no collision box (decorative, vegetation, etc.) +fn is_non_solid_decoration(name: &str) -> bool { + if matches!( + name, + "grass" + | "short_grass" + | "tall_grass" + | "fern" + | "large_fern" + | "dead_bush" + | "snow" + | "string" + | "nether_portal" + | "spore_blossom" + | "glow_lichen" + | "dandelion" + | "poppy" + | "blue_orchid" + | "allium" + | "azure_bluet" + | "oxeye_daisy" + | "cornflower" + | "lily_of_the_valley" + | "wither_rose" + | "sunflower" + | "lilac" + | "rose_bush" + | "peony" + | "torchflower" + | "pitcher_plant" + | "pitcher_pod" + | "sweet_berry_bush" + | "cobweb" + | "powder_snow" + | "redstone_wire" + | "rail" + | "powered_rail" + | "detector_rail" + | "activator_rail" + | "tripwire" + | "tripwire_hook" + | "structure_void" + ) { + return true; + } + + name.ends_with("_button") + || name.ends_with("_pressure_plate") + || name.ends_with("_sign") + || name.ends_with("_banner") + || name.ends_with("_carpet") + || name.ends_with("_torch") + || name.ends_with("_sapling") + || name.ends_with("_mushroom") + || name.ends_with("_flower") + || name.ends_with("_vine") + || name.ends_with("_roots") +} diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs index af38f11f..073b9312 100644 --- a/src/core/src/lib.rs +++ b/src/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod errors; // Core structs/types. Usually used in ECS Components. pub mod block_data; +pub mod block_properties; pub mod block_state_id; pub mod color; pub mod dimension; diff --git a/src/game_systems/Cargo.toml b/src/game_systems/Cargo.toml index bb3a872c..634804e5 100644 --- a/src/game_systems/Cargo.toml +++ b/src/game_systems/Cargo.toml @@ -6,13 +6,14 @@ version.workspace = true [dependencies] background = { path = "./src/background" } mobs = { path = "./src/mobs" } +pathfinding = { path = "./src/pathfinding" } packets = { path = "./src/packets" } physics = { path = "./src/physics" } player = { path = "./src/player" } shutdown = { path = "./src/shutdown" } world = { path = "./src/world" } interactions = { path = "./src/interactions" } - +tempfile = { workspace = true } bevy_ecs = { workspace = true } [lints] diff --git a/src/game_systems/src/mobs/Cargo.toml b/src/game_systems/src/mobs/Cargo.toml index 6121c98f..751e615a 100644 --- a/src/game_systems/src/mobs/Cargo.toml +++ b/src/game_systems/src/mobs/Cargo.toml @@ -5,5 +5,12 @@ edition = "2024" [dependencies] bevy_ecs = { workspace = true } +bevy_math = { workspace = true } +pathfinding = { path = "../pathfinding" } temper-components = { workspace = true } -temper-entities = { workspace = true } \ No newline at end of file +temper-core = { workspace = true } +temper-entities = { workspace = true } +temper-state = { workspace = true } +temper-messages = { workspace = true } +temper-particles = { workspace = true } +temper-utils = { workspace = true } diff --git a/src/game_systems/src/mobs/src/lib.rs b/src/game_systems/src/mobs/src/lib.rs index 9ae3afb7..4f98e12e 100644 --- a/src/game_systems/src/mobs/src/lib.rs +++ b/src/game_systems/src/mobs/src/lib.rs @@ -1,6 +1,10 @@ use bevy_ecs::prelude::Schedule; +use bevy_ecs::schedule::IntoScheduleConfigs; mod pig; + pub fn register_mob_systems(schedule: &mut Schedule) { - schedule.add_systems(pig::tick_pig); + schedule.add_systems(pathfinding::tick_pathfinder); + schedule.add_systems(pig::tick_pig.after(pathfinding::tick_pathfinder)); + schedule.add_systems(pig::tick_pig_particles); } diff --git a/src/game_systems/src/mobs/src/pig.rs b/src/game_systems/src/mobs/src/pig.rs index bf273356..3617a91b 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -1,11 +1,138 @@ -use bevy_ecs::prelude::{Query, With}; +use bevy_ecs::prelude::*; +use bevy_math::Vec3A; +use pathfinding::{Pathfinder, pos_to_block}; +use temper_components::player::grounded::OnGround; use temper_components::player::player_identity::PlayerIdentity; use temper_components::player::position::Position; +use temper_components::player::velocity::Velocity; use temper_entities::markers::entity_types::Pig; +use temper_messages::particle::SendParticle; +use temper_particles::ParticleType; + +/// Pig walk speed in blocks per tick. +const PIG_WALK_SPEED: f32 = 0.1; + +/// Jump impulse matching Minecraft's standard jump velocity (blocks/tick). +const JUMP_IMPULSE: f32 = 0.42; + +/// How often to update the pathfinding target (ticks). +const REPATH_INTERVAL: u32 = 40; + +/// Per-pig AI state. +#[derive(Component, Default)] +pub struct PigAI { + repath_cooldown: u32, +} + +type PigQuery<'a> = ( + Entity, + &'a Position, + &'a mut Velocity, + &'a OnGround, + Option<&'a mut PigAI>, + Option<&'a mut Pathfinder>, +); -#[expect(unused_variables)] pub fn tick_pig( - query: Query<&Position, With>, + mut commands: Commands, + mut pigs: Query>, players: Query<&Position, With>, ) { + for (entity, pig_pos, mut velocity, grounded, ai_opt, pf_opt) in pigs.iter_mut() { + let (Some(mut ai), Some(mut pathfinder)) = (ai_opt, pf_opt) else { + commands + .entity(entity) + .insert((PigAI::default(), Pathfinder::default())); + continue; + }; + + ai.repath_cooldown = ai.repath_cooldown.saturating_sub(1); + + // Repath when the cooldown expires OR when the pig has followed a path + // to its end (but not when pathfinding simply failed to find a route). + let path_reached_end = + !pathfinder.has_path() && !pathfinder.path.is_empty() && !pathfinder.is_searching(); + + if ai.repath_cooldown == 0 || path_reached_end { + pathfinder.target = players + .iter() + .min_by(|a, b| { + pig_pos + .coords + .distance_squared(a.coords) + .total_cmp(&pig_pos.coords.distance_squared(b.coords)) + }) + .map(pos_to_block); + pathfinder.request_repath(); + ai.repath_cooldown = REPATH_INTERVAL; + } + + let current_block = pos_to_block(pig_pos); + + // Advance waypoint when the pig reaches it (same X/Z block). + if let Some(wp) = pathfinder.current_waypoint() + && wp.pos.x == current_block.pos.x + && wp.pos.z == current_block.pos.z + { + pathfinder.advance_waypoint(); + } + + let Some(next) = pathfinder.current_waypoint() else { + stop(&mut velocity); + continue; + }; + + // Jump if the next waypoint is 1 block above and the pig is on the ground. + if next.pos.y > current_block.pos.y && grounded.0 { + velocity.vec.y = JUMP_IMPULSE; + } + + // Steer horizontally toward the center of the next waypoint block. + let dx = (next.pos.x as f64 + 0.5 - pig_pos.x) as f32; + let dz = (next.pos.z as f64 + 0.5 - pig_pos.z) as f32; + let len = (dx * dx + dz * dz).sqrt(); + + if len > 0.1 { + velocity.vec.x = (dx / len) * PIG_WALK_SPEED; + velocity.vec.z = (dz / len) * PIG_WALK_SPEED; + } else { + velocity.vec.x = 0.0; + velocity.vec.z = 0.0; + } + } +} + +pub fn tick_pig_particles( + pigs: Query<(Entity, &Position), With>, + players: Query<&Position, With>, + mut msgs: MessageWriter, +) { + for pos in pigs.iter() { + for player_pos in players.iter() { + let distance_sq = player_pos.as_vec3a().distance_squared(pos.1.as_vec3a()); + if distance_sq > 16.0 * 256.0 { + continue; + } + let steps = temper_utils::maths::step::step_between( + pos.1.as_vec3a(), + player_pos.coords.as_vec3a(), + 0.5, + ); + for step_pos in steps.iter().take(32) { + let particle_message = SendParticle { + particle_type: ParticleType::EndRod, + position: *step_pos, + offset: Vec3A::new(0.0, 0.0, 0.0), + speed: 0.0, + count: 1, + }; + msgs.write(particle_message); + } + } + } +} + +fn stop(velocity: &mut Velocity) { + velocity.vec.x = 0.0; + velocity.vec.z = 0.0; } diff --git a/src/game_systems/src/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml new file mode 100644 index 00000000..bc2f0307 --- /dev/null +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "pathfinding" +version = "0.1.0" +edition = "2024" + +[dependencies] +temper-core = { workspace = true } +temper-world = { workspace = true } +temper-components = { workspace = true } +bevy_ecs = { workspace = true } +temper-entities = { workspace = true } +temper-state = { workspace = true } +rustc-hash = "2" +arrayvec = "0.7" + +[dev-dependencies] +temper-state = { workspace = true } +temper-macros = { workspace = true } +temper-data = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs new file mode 100644 index 00000000..c5dec21b --- /dev/null +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -0,0 +1,613 @@ +use std::collections::BinaryHeap; + +use arrayvec::ArrayVec; +use rustc_hash::FxHashMap; +use temper_components::physical::PhysicalProperties; +use temper_core::block_state_id::BlockStateId; +use temper_core::pos::BlockPos; +use temper_world::Dimension; + +use temper_core::block_properties; + +use crate::cost::{IMPASSABLE, block_penalty}; + +/// A path from start to goal, expressed as block positions (feet position). +pub struct Path { + pub nodes: Vec, +} + +// Internal node in the priority queue. +// Ord is inverted so BinaryHeap acts as a min-heap on estimated_cost. +#[derive(Eq, PartialEq)] +struct Candidate { + estimated_cost: i32, // f = g + h + real_cost: i32, // g + pos: BlockPos, +} + +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + other.estimated_cost.cmp(&self.estimated_cost) + } +} + +impl PartialOrd for Candidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Entity dimensions for pathfinding, computed from PhysicalProperties. +#[derive(Clone, Copy)] +pub(crate) struct EntityDimensions { + /// Height in blocks (rounded up). E.g. pig=1, zombie=2, enderman=3. + height_blocks: u8, + /// Half-width in blocks (rounded up). E.g. 0.45 -> 1 block. + /// TODO: Use this for wider entities like spiders that occupy multiple blocks horizontally. + #[allow(dead_code)] + half_width_blocks: u8, + // TODO: Use fire_immune from PhysicalProperties to avoid lava/fire penalties + // for entities like blazes, striders, etc. +} + +impl EntityDimensions { + fn from_physical(props: &PhysicalProperties) -> Self { + Self { + height_blocks: props.bounding_box.height().ceil() as u8, + half_width_blocks: (props.bounding_box.width() / 2.0).ceil() as u8, + } + } +} + +/// Result of a single incremental A* step. +pub(crate) enum SearchStep { + /// A path was found; contains the nodes from start to goal. + Found(Vec), + /// No path exists (open set exhausted or node limit reached). + NoPath, + /// Budget exhausted; call `step` again next tick. + Continue, +} + +/// Incremental A* search state. Call `step` each tick with a node budget. +pub(crate) struct AStarSearch { + open: BinaryHeap, + g_score: FxHashMap, + came_from: FxHashMap, + start: BlockPos, + goal: BlockPos, + dims: EntityDimensions, + pub nodes_expanded: usize, + pub max_nodes: usize, +} + +impl AStarSearch { + pub(crate) fn new( + start: BlockPos, + goal: BlockPos, + max_nodes: usize, + physical: &PhysicalProperties, + ) -> Self { + let dims = EntityDimensions::from_physical(physical); + let mut open = BinaryHeap::new(); + let mut g_score = FxHashMap::default(); + g_score.insert(start, 0); + open.push(Candidate { + estimated_cost: heuristic(start, goal), + real_cost: 0, + pos: start, + }); + Self { + open, + g_score, + came_from: FxHashMap::default(), + start, + goal, + dims, + nodes_expanded: 0, + max_nodes, + } + } + + /// Advance the search by up to `budget` node expansions. + pub(crate) fn step(&mut self, world: &temper_world::World, budget: usize) -> SearchStep { + let mut expanded = 0; + while let Some(Candidate { real_cost, pos, .. }) = self.open.pop() { + if self.nodes_expanded >= self.max_nodes { + return SearchStep::NoPath; + } + self.nodes_expanded += 1; + expanded += 1; + + if pos == self.goal { + let nodes = reconstruct_path(&self.came_from, pos, self.start); + return SearchStep::Found(nodes); + } + + if real_cost > *self.g_score.get(&pos).unwrap_or(&i32::MAX) { + if expanded >= budget { + return SearchStep::Continue; + } + continue; + } + + for (neighbor, move_cost) in neighbors(world, pos, self.dims) { + let tentative_g = real_cost + move_cost; + if self + .g_score + .get(&neighbor) + .is_none_or(|&best| tentative_g < best) + { + self.g_score.insert(neighbor, tentative_g); + self.came_from.insert(neighbor, pos); + self.open.push(Candidate { + estimated_cost: tentative_g + heuristic(neighbor, self.goal), + real_cost: tentative_g, + pos: neighbor, + }); + } + } + + if expanded >= budget { + return SearchStep::Continue; + } + } + // Open set exhausted + SearchStep::NoPath + } +} + +/// Find a path for a land mob using weighted A*. +/// +/// `start` and `goal` are the block positions of the mob's feet. +/// `physical` provides the entity's dimensions for collision checking. +/// Returns `None` if no path is found within `max_nodes` node expansions. +pub fn find_path( + world: &temper_world::World, + start: BlockPos, + goal: BlockPos, + max_nodes: usize, + physical: &PhysicalProperties, +) -> Option { + if start == goal { + return Some(Path { nodes: vec![goal] }); + } + + let mut search = AStarSearch::new(start, goal, max_nodes, physical); + match search.step(world, usize::MAX) { + SearchStep::Found(nodes) => Some(Path { nodes }), + SearchStep::NoPath | SearchStep::Continue => None, + } +} + +/// Heuristic using octile distance (accounts for diagonal movement). +/// Returns cost estimate scaled to match movement costs (cardinal=10, diagonal=14). +fn heuristic(a: BlockPos, b: BlockPos) -> i32 { + let dx = (a.pos.x - b.pos.x).abs(); + let dy = (a.pos.y - b.pos.y).abs(); + let dz = (a.pos.z - b.pos.z).abs(); + + // Octile distance on XZ plane + vertical distance + let min_xz = dx.min(dz); + let max_xz = dx.max(dz); + + // Diagonal moves cost 14, cardinal moves cost 10 + // min_xz diagonals + (max_xz - min_xz) cardinals + dy vertical + min_xz * COST_DIAGONAL + (max_xz - min_xz) * COST_CARDINAL + dy * COST_CARDINAL +} + +fn reconstruct_path( + came_from: &FxHashMap, + target: BlockPos, + start: BlockPos, +) -> Vec { + let mut current = target; + let mut nodes = vec![current]; + while current != start { + current = came_from[¤t]; + nodes.push(current); + } + nodes.reverse(); + nodes +} + +/// Cardinal directions (cost multiplier: 10). +const CARDINALS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + +/// Diagonal directions (cost multiplier: 14, approximation of 10 * sqrt(2)). +const DIAGONALS: [(i32, i32); 4] = [(1, 1), (1, -1), (-1, 1), (-1, -1)]; + +/// Base movement cost for cardinal directions. +const COST_CARDINAL: i32 = 10; + +/// Base movement cost for diagonal directions (approx. 10 * sqrt(2)). +const COST_DIAGONAL: i32 = 14; + +/// Extra cost for stepping up one block. +const COST_STEP_UP: i32 = 10; + +/// Maximum number of neighbors per node (4 cardinal + 4 diagonal). +const MAX_NEIGHBORS: usize = 8; + +/// Generate passable neighbors for a land mob. +/// Handles flat walking, stepping up 1 block, and stepping down 1 block. +/// Supports both cardinal and diagonal movement. +fn neighbors( + world: &temper_world::World, + pos: BlockPos, + dims: EntityDimensions, +) -> ArrayVec<(BlockPos, i32), MAX_NEIGHBORS> { + let mut result = ArrayVec::new(); + + // Cardinal directions + for (dx, dz) in CARDINALS { + if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_CARDINAL, dims) { + result.push((dest, cost)); + } + } + + // Diagonal directions (require both adjacent cardinal directions to be passable) + for (dx, dz) in DIAGONALS { + // Check corner-cutting: both adjacent cells must be passable at feet level + let side1_passable = + block_penalty(get_block(world, pos.pos.x + dx, pos.pos.y, pos.pos.z)) != IMPASSABLE; + let side2_passable = + block_penalty(get_block(world, pos.pos.x, pos.pos.y, pos.pos.z + dz)) != IMPASSABLE; + + if side1_passable + && side2_passable + && let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL, dims) + { + result.push((dest, cost)); + } + } + + result +} + +/// Try to move from `pos` in direction `(dx, dz)` with base cost `base_cost`. +/// Returns the destination and total movement cost if the move is valid. +fn try_move( + world: &temper_world::World, + pos: BlockPos, + dx: i32, + dz: i32, + base_cost: i32, + dims: EntityDimensions, +) -> Option<(BlockPos, i32)> { + let nx = pos.pos.x + dx; + let nz = pos.pos.z + dz; + + // Walk flat + if let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y, nz, dims) { + return Some((BlockPos::of(nx, pos.pos.y, nz), base_cost + terrain_cost)); + } + + // Step up 1 block — need space above current position for the full entity height + if is_clear_above(world, pos.pos.x, pos.pos.y, pos.pos.z, dims.height_blocks) + && let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y + 1, nz, dims) + { + return Some(( + BlockPos::of(nx, pos.pos.y + 1, nz), + base_cost + COST_STEP_UP + terrain_cost, + )); + } + + // Step down 1 block — neighbor column must be open at current height + if block_penalty(get_block(world, nx, pos.pos.y, nz)) != IMPASSABLE + && let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y - 1, nz, dims) + { + return Some(( + BlockPos::of(nx, pos.pos.y - 1, nz), + base_cost + terrain_cost, + )); + } + + None +} + +/// Check if an entity can stand with feet at (x, y, z): +/// - solid block at (x, y-1, z) as floor (uses `block_properties::is_solid`) +/// - passable blocks for the full body height at (x, y, z) to (x, y+height-1, z) +/// +/// Returns `Some(terrain_cost)` if valid, `None` if not. +fn can_stand_at( + world: &temper_world::World, + x: i32, + y: i32, + z: i32, + dims: EntityDimensions, +) -> Option { + // Check for solid floor (shared definition with the collision system) + if !block_properties::is_solid(get_block(world, x, y - 1, z)) { + return None; // no solid floor + } + + // Check all blocks occupied by the body + let mut total_penalty = 0; + for dy in 0..i32::from(dims.height_blocks) { + let body_penalty = block_penalty(get_block(world, x, y + dy, z)); + if body_penalty == IMPASSABLE { + return None; + } + total_penalty += body_penalty.max(0); + } + + Some(total_penalty) +} + +/// Check if there's enough vertical clearance above a position. +/// Used for step-up checks where the entity needs headroom. +fn is_clear_above(world: &temper_world::World, x: i32, y: i32, z: i32, height: u8) -> bool { + for dy in 1..=i32::from(height) { + if block_penalty(get_block(world, x, y + dy, z)) == IMPASSABLE { + return false; + } + } + true +} + +fn get_block(world: &temper_world::World, x: i32, y: i32, z: i32) -> BlockStateId { + let pos = BlockPos::of(x, y, z); + // Only read from cache — never generate chunks during pathfinding + world + .get_cache() + .get(&(pos.chunk(), Dimension::Overworld)) + .map(|chunk| chunk.get_block(pos.chunk_block_pos())) + .unwrap_or_default() // unloaded chunk = air; mob won't path there (no solid floor) +} + +#[cfg(test)] +mod tests { + use super::*; + use temper_core::pos::ChunkPos; + use temper_macros::block; + use temper_state::create_test_state; + use tempfile::TempDir; + + fn pig() -> PhysicalProperties { + PhysicalProperties::from_vanilla(&temper_data::generated::entities::EntityType::PIG) + } + + fn zombie() -> PhysicalProperties { + PhysicalProperties::from_vanilla(&temper_data::generated::entities::EntityType::ZOMBIE) + } + + /// Test helper that provides a flat world (stone floor at y=64) and + /// convenience methods for placing blocks and running pathfinding. + struct TestEnv { + state: temper_state::GlobalStateResource, + _temp_dir: TempDir, + } + + impl TestEnv { + /// Creates a new test environment with a stone floor at y=64. + fn new() -> Self { + let (state, _temp_dir) = create_test_state(); + let env = Self { state, _temp_dir }; + env.fill_floor(64); + env + } + + /// Fills an entire chunk layer at the given y with stone. + fn fill_floor(&self, y: i32) { + let chunk_pos = ChunkPos::new(0, 0); + let mut chunk = self + .state + .0 + .world + .get_or_generate_mut(chunk_pos, Dimension::Overworld) + .expect("Failed to get chunk"); + for x in 0u8..16 { + for z in 0u8..16 { + chunk.set_block( + BlockPos::of(i32::from(x), y, i32::from(z)).chunk_block_pos(), + block!("stone"), + ); + } + } + } + + /// Places a single block in the world. + fn set_block(&self, x: i32, y: i32, z: i32, id: BlockStateId) { + let pos = BlockPos::of(x, y, z); + let mut chunk = self + .state + .0 + .world + .get_or_generate_mut(pos.chunk(), Dimension::Overworld) + .expect("Failed to get chunk"); + chunk.set_block(pos.chunk_block_pos(), id); + } + + /// Places a vertical wall (stone) along the Z axis at the given x and y. + fn wall_z(&self, x: i32, y: i32, z_range: std::ops::Range) { + for z in z_range { + self.set_block(x, y, z, block!("stone")); + } + } + + /// Fills a rectangular area at the given y with stone. + fn fill_rect(&self, x_range: std::ops::Range, y: i32, z_range: std::ops::Range) { + for x in x_range { + for z in z_range.clone() { + self.set_block(x, y, z, block!("stone")); + } + } + } + + /// Surrounds a position with stone walls at the given heights. + fn cage(&self, center_x: i32, center_z: i32, y_range: std::ops::Range) { + for dx in -1..=1 { + for dz in -1..=1 { + if dx != 0 || dz != 0 { + for y in y_range.clone() { + self.set_block(center_x + dx, y, center_z + dz, block!("stone")); + } + } + } + } + } + + /// Runs pathfinding with a generous node budget. + fn find( + &self, + start: BlockPos, + goal: BlockPos, + physical: &PhysicalProperties, + ) -> Option { + find_path(&self.state.0.world, start, goal, 1000, physical) + } + + /// Runs pathfinding with a specific node budget. + fn find_with_budget( + &self, + start: BlockPos, + goal: BlockPos, + max_nodes: usize, + physical: &PhysicalProperties, + ) -> Option { + find_path(&self.state.0.world, start, goal, max_nodes, physical) + } + } + #[test] + fn test_straight_line_path() { + let env = TestEnv::new(); + let start = BlockPos::of(0, 65, 0); + let goal = BlockPos::of(3, 65, 0); + + let path = env + .find(start, goal, &pig()) + .expect("Should find a path on flat ground"); + + assert_eq!(path.nodes.first(), Some(&start)); + assert_eq!(path.nodes.last(), Some(&goal)); + } + + #[test] + fn test_diagonal_path() { + let env = TestEnv::new(); + let start = BlockPos::of(0, 65, 0); + let goal = BlockPos::of(3, 65, 3); + + let path = env + .find(start, goal, &pig()) + .expect("Should find a diagonal path"); + + // With diagonals: (0,0) -> (1,1) -> (2,2) -> (3,3) = 4 nodes + assert!( + path.nodes.len() <= 5, + "Diagonal path should be efficient, got {} nodes", + path.nodes.len() + ); + } + + #[test] + fn test_path_around_wall() { + let env = TestEnv::new(); + env.wall_z(5, 65, 0..16); + + let start = BlockPos::of(0, 65, 5); + let goal = BlockPos::of(10, 65, 5); + + let path = env + .find(start, goal, &pig()) + .expect("Should find a path around the wall"); + + assert!(path.nodes.len() > 10, "Path should be longer due to wall"); + } + + #[test] + fn test_step_up() { + let env = TestEnv::new(); + env.set_block(5, 65, 5, block!("stone")); // Step + + let start = BlockPos::of(4, 65, 5); + let goal = BlockPos::of(5, 66, 5); + + let path = env + .find(start, goal, &pig()) + .expect("Should find a path stepping up"); + + assert_eq!(path.nodes.last(), Some(&goal)); + } + + #[test] + fn test_step_down() { + let env = TestEnv::new(); + env.set_block(5, 65, 5, block!("stone")); // Step + + let start = BlockPos::of(5, 66, 5); + let goal = BlockPos::of(4, 65, 5); + + let path = env + .find(start, goal, &pig()) + .expect("Should find a path stepping down"); + + assert_eq!(path.nodes.last(), Some(&goal)); + } + + #[test] + fn test_no_path_blocked() { + let env = TestEnv::new(); + env.cage(2, 2, 65..67); // 2 blocks high cage + + let start = BlockPos::of(2, 65, 2); + let goal = BlockPos::of(10, 65, 10); + + let path = env.find(start, goal, &pig()); + + assert!( + path.is_none(), + "Should not find a path when completely blocked" + ); + } + + #[test] + fn test_same_start_and_goal() { + let env = TestEnv::new(); + let pos = BlockPos::of(5, 65, 5); + + let path = env + .find(pos, pos, &pig()) + .expect("Should return a path for same start and goal"); + + assert_eq!(path.nodes.len(), 1); + assert_eq!(path.nodes[0], pos); + } + + #[test] + fn test_tall_entity_blocked_by_low_ceiling() { + let env = TestEnv::new(); + env.fill_rect(3..8, 66, 0..16); // Low ceiling at y=66 + + let start = BlockPos::of(0, 65, 5); + let goal = BlockPos::of(10, 65, 5); + + // Pig (height < 1 block) should pass + assert!( + env.find(start, goal, &pig()).is_some(), + "Pig should fit under low ceiling" + ); + + // Zombie (height ~2 blocks) should not pass + assert!( + env.find(start, goal, &zombie()).is_none(), + "Zombie should not fit under low ceiling" + ); + } + + #[test] + fn test_max_nodes_limit() { + let env = TestEnv::new(); + let start = BlockPos::of(0, 65, 0); + let goal = BlockPos::of(15, 65, 15); + + let path = env.find_with_budget(start, goal, 5, &pig()); + + assert!( + path.is_none(), + "Should not find path with very limited node budget" + ); + } +} diff --git a/src/game_systems/src/pathfinding/src/cost.rs b/src/game_systems/src/pathfinding/src/cost.rs new file mode 100644 index 00000000..ac02ece4 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/cost.rs @@ -0,0 +1,217 @@ +use std::sync::LazyLock; + +use temper_core::block_data::BlockData; +use temper_core::block_state_id::{BlockStateId, ID2BLOCK}; + +/// Sentinel value meaning the block cannot be traversed. +pub const IMPASSABLE: i32 = i32::MIN; + +/// Precomputed pathfinding costs for all block states. +/// Indexed by `BlockStateId::raw()`. +static PATHFINDING_COSTS: LazyLock> = + LazyLock::new(|| ID2BLOCK.iter().map(compute_cost).collect()); + +/// Returns the pathfinding penalty for a block, following the Minecraft wiki penalty system: +/// - IMPASSABLE: solid blocks, fences, walls, closed doors, cactus, lava, etc. +/// - 0 : air, open trapdoors, lily pads, vegetation +/// - 8 : water, honey blocks, danger zones (near fire/cactus) +/// - 16 : fire, lava, magma, lit campfire +#[inline] +pub fn block_penalty(id: BlockStateId) -> i32 { + PATHFINDING_COSTS[id.raw() as usize] +} + +/// Compute the pathfinding cost for a single block data entry. +fn compute_cost(data: &BlockData) -> i32 { + let name = data.name.trim_start_matches("minecraft:"); + + // Air variants + if name.ends_with("air") { + return 0; + } + + // Damage blocks (penalty: 16) + if matches!(name, "fire" | "soul_fire" | "magma_block") { + return 16; + } + if name.ends_with("_campfire") { + return 16; + } + + // Liquids + if name == "lava" { + return IMPASSABLE; + } + if name == "water" || name == "bubble_column" { + return 8; + } + + // Impassable hazards + if matches!( + name, + "cactus" | "sweet_berry_bush" | "cobweb" | "powder_snow" + ) { + return IMPASSABLE; + } + + // Fences, walls + if name.ends_with("_fence") || name.ends_with("_wall") { + return IMPASSABLE; + } + + // Doors and fence gates: passable only when open + if name.ends_with("_door") || name.ends_with("_fence_gate") { + let open = data + .properties + .as_ref() + .and_then(|p| p.get("open")) + .is_some_and(|v| v == "true"); + return if open { 0 } else { IMPASSABLE }; + } + + // Trapdoors: passable only when open + if name.ends_with("_trapdoor") { + let open = data + .properties + .as_ref() + .and_then(|p| p.get("open")) + .is_some_and(|v| v == "true"); + return if open { 0 } else { IMPASSABLE }; + } + + // Known non-solid blocks + if is_non_solid(name) { + return 0; + } + + // Default: solid/impassable + IMPASSABLE +} + +fn is_non_solid(name: &str) -> bool { + if matches!( + name, + "grass" + | "short_grass" + | "tall_grass" + | "fern" + | "large_fern" + | "dead_bush" + | "lily_pad" + | "big_dripleaf" + | "small_dripleaf" + | "snow" + | "string" + | "nether_portal" + | "spore_blossom" + | "glow_lichen" + | "dandelion" + | "poppy" + | "blue_orchid" + | "allium" + | "azure_bluet" + | "oxeye_daisy" + | "cornflower" + | "lily_of_the_valley" + | "wither_rose" + | "sunflower" + | "lilac" + | "rose_bush" + | "peony" + | "torchflower" + | "pitcher_plant" + | "pitcher_pod" + ) { + return true; + } + + name.ends_with("_button") + || name.ends_with("_pressure_plate") + || name.ends_with("_sign") + || name.ends_with("_banner") + || name.ends_with("_carpet") + || name.ends_with("_torch") + || name.ends_with("_sapling") + || name.ends_with("_mushroom") + || name.ends_with("_flower") + || name.ends_with("_vine") + || name.ends_with("_roots") +} + +#[cfg(test)] +mod tests { + use super::*; + use temper_macros::block; + + /// Helper to assert a block has the expected penalty. + fn assert_penalty(id: BlockStateId, expected: i32, name: &str) { + assert_eq!( + block_penalty(id), + expected, + "{name} should have penalty {expected}" + ); + } + + #[test] + fn passable_blocks_have_zero_cost() { + assert_penalty(block!("air"), 0, "air"); + assert_penalty(block!("short_grass"), 0, "short_grass"); + assert_penalty(block!("wall_torch", { facing: "north" }), 0, "wall_torch"); + assert_penalty(block!("red_carpet"), 0, "red_carpet"); + } + + #[test] + fn water_has_medium_penalty() { + assert_penalty(block!("water", { level: 0 }), 8, "water"); + } + + #[test] + fn damage_blocks_have_high_penalty() { + assert_penalty( + block!("fire", { age: 0, east: false, north: false, south: false, up: false, west: false }), + 16, + "fire", + ); + assert_penalty(block!("magma_block"), 16, "magma_block"); + } + + #[test] + fn solid_and_hazard_blocks_are_impassable() { + assert_penalty(block!("stone"), IMPASSABLE, "stone"); + assert_penalty(block!("lava", { level: 0 }), IMPASSABLE, "lava"); + assert_penalty(block!("cactus", { age: 0 }), IMPASSABLE, "cactus"); + assert_penalty( + block!("oak_fence", { east: false, north: false, south: false, waterlogged: false, west: false }), + IMPASSABLE, + "oak_fence", + ); + } + + #[test] + fn doors_depend_on_open_property() { + assert_penalty( + block!("oak_door", { open: true, half: "lower", facing: "north", hinge: "left", powered: false }), + 0, + "oak_door (open)", + ); + assert_penalty( + block!("oak_door", { open: false, half: "lower", facing: "north", hinge: "left", powered: false }), + IMPASSABLE, + "oak_door (closed)", + ); + } + + #[test] + fn trapdoors_depend_on_open_property() { + assert_penalty( + block!("oak_trapdoor", { open: true, half: "bottom", facing: "north", powered: false, waterlogged: false }), + 0, + "oak_trapdoor (open)", + ); + assert_penalty( + block!("oak_trapdoor", { open: false, half: "bottom", facing: "north", powered: false, waterlogged: false }), + IMPASSABLE, + "oak_trapdoor (closed)", + ); + } +} diff --git a/src/game_systems/src/pathfinding/src/lib.rs b/src/game_systems/src/pathfinding/src/lib.rs new file mode 100644 index 00000000..39faf721 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/lib.rs @@ -0,0 +1,6 @@ +mod astar; +mod cost; +pub mod pathfinder; + +pub use astar::{Path, find_path}; +pub use pathfinder::{Pathfinder, pos_to_block, tick_pathfinder}; diff --git a/src/game_systems/src/pathfinding/src/pathfinder.rs b/src/game_systems/src/pathfinding/src/pathfinder.rs new file mode 100644 index 00000000..e291b032 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/pathfinder.rs @@ -0,0 +1,143 @@ +use bevy_ecs::prelude::*; +use temper_components::player::position::Position; +use temper_core::pos::BlockPos; +use temper_entities::PhysicalRegistry; +use temper_entities::components::{Baby, EntityMetadata}; +use temper_state::GlobalStateResource; + +use crate::astar::{AStarSearch, SearchStep}; + +const DEFAULT_BUDGET_PER_TICK: usize = 20; +const DEFAULT_MAX_NODES: usize = 500; + +/// Pathfinding component for land mob entities. +/// Set `target` each tick (or periodically) from mob AI. The system `tick_pathfinder` +/// advances the A* search incrementally (budget_per_tick nodes/tick) and updates `path`. +/// The mob AI reads `current_waypoint()` and calls `advance_waypoint()` when it arrives. +#[derive(Component)] +pub struct Pathfinder { + /// Target block to navigate to. Changing this restarts the search. + pub target: Option, + /// Current computed path (nodes from start to goal). + pub path: Vec, + /// Index of the waypoint the mob is currently heading toward. + pub waypoint: usize, + /// A* node expansions allowed per tick. + pub budget_per_tick: usize, + /// Maximum total A* expansions before giving up. + pub max_nodes: usize, + // private + search: Option, + last_target: Option, +} + +impl Pathfinder { + pub fn new(budget_per_tick: usize, max_nodes: usize) -> Self { + Self { + target: None, + path: Vec::new(), + waypoint: 0, + budget_per_tick, + max_nodes, + search: None, + last_target: None, + } + } + + /// The block the mob should currently move toward. + pub fn current_waypoint(&self) -> Option { + self.path.get(self.waypoint).copied() + } + + /// Move to the next waypoint after reaching the current one. + pub fn advance_waypoint(&mut self) { + self.waypoint += 1; + } + + /// True if a path is available and not yet exhausted. + pub fn has_path(&self) -> bool { + self.waypoint < self.path.len() + } + + /// True if a search is currently in progress. + pub fn is_searching(&self) -> bool { + self.search.is_some() + } + + /// Force a new search on the next tick, even if the target hasn't changed. + /// Use this when you want to repath to the same block (e.g. the player didn't move). + pub fn request_repath(&mut self) { + self.last_target = None; + } +} + +impl Default for Pathfinder { + fn default() -> Self { + Self::new(DEFAULT_BUDGET_PER_TICK, DEFAULT_MAX_NODES) + } +} + +/// Advances incremental A* searches for all entities with a Pathfinder component. +/// Must run before mob AI systems so the updated path is available each tick. +pub fn tick_pathfinder( + mut query: Query<(&mut Pathfinder, &Position, &EntityMetadata, Has)>, + state: Res, + registry: Res, +) { + let world = &state.0.world; + + for (mut pf, pos, metadata, is_baby) in &mut query { + let Some(props) = registry.get(metadata.protocol_id(), is_baby) else { + continue; + }; + + // Restart search when target changes, but keep the old path so the mob + // keeps moving while the new path is being computed. + if pf.target != pf.last_target { + pf.last_target = pf.target; + pf.search = None; + + if let Some(goal) = pf.target { + let start = pos_to_block(pos); + if start == goal { + pf.path = vec![goal]; + pf.waypoint = 0; + } else { + pf.search = Some(AStarSearch::new(start, goal, pf.max_nodes, props)); + } + } + } + + // Advance the in-progress search by up to budget_per_tick expansions. + let budget = pf.budget_per_tick; + if let Some(ref mut search) = pf.search { + match search.step(world, budget) { + SearchStep::Found(nodes) => { + pf.path = nodes; + pf.waypoint = 1; // node 0 is the start position + pf.search = None; + } + SearchStep::NoPath => { + pf.path.clear(); + pf.waypoint = 0; + pf.search = None; + } + SearchStep::Continue => {} + } + } + } +} + +/// Convert a world-space position to a block position. +/// +/// The small Y epsilon compensates for imprecise collision resolution +/// (see TODO in physics/collisions.rs) that can leave entities at e.g. +/// y=64.9999 instead of exactly y=65.0. +pub fn pos_to_block(pos: &Position) -> BlockPos { + const EPSILON: f64 = 1e-4; + BlockPos::of( + pos.x.floor() as i32, + (pos.y + EPSILON).floor() as i32, + pos.z.floor() as i32, + ) +} diff --git a/src/game_systems/src/physics/src/collisions.rs b/src/game_systems/src/physics/src/collisions.rs index 79720c43..430d9676 100644 --- a/src/game_systems/src/physics/src/collisions.rs +++ b/src/game_systems/src/physics/src/collisions.rs @@ -1,19 +1,18 @@ use bevy_ecs::message::MessageWriter; use bevy_ecs::prelude::{DetectChanges, Entity, Has, Query, Res, With}; use bevy_ecs::world::Mut; +use bevy_math::IVec3; use bevy_math::bounding::{Aabb3d, BoundingVolume}; -use bevy_math::{IVec3, Vec3A}; use temper_components::player::grounded::OnGround; use temper_components::player::position::Position; use temper_components::player::velocity::Velocity; -use temper_core::block_state_id::BlockStateId; +use temper_core::block_properties; use temper_core::dimension::Dimension; use temper_core::pos::{ChunkBlockPos, ChunkPos}; use temper_entities::PhysicalRegistry; use temper_entities::components::Baby; use temper_entities::components::EntityMetadata; use temper_entities::markers::HasCollisions; -use temper_macros::match_block; use temper_messages::entity_update::SendEntityUpdate; use temper_state::{GlobalState, GlobalStateResource}; @@ -37,6 +36,12 @@ pub fn handle( continue; }; if pos.is_changed() || vel.is_changed() { + // Reset grounded only when the entity is actually moving. + // When grounded and at rest, gravity is skipped → vel/pos unchanged → this block + // is skipped → grounded keeps its true value, preventing spurious falling. + // When the entity jumps or falls, vel/pos change → grounded resets to false here, + // then gets set back to true only when the MTV Y-resolution detects a landing. + grounded.0 = false; // Figure out where the entity is going to be next tick let next_pos = pos.coords.as_vec3a() + **vel; let mut collided = false; @@ -69,17 +74,15 @@ pub fn handle( if is_solid_block(&state.0, block_pos) { collided = true; hit_blocks.push(block_pos); - if is_solid_block(&state.0, IVec3::new(x, y - 1, z)) && vel.y <= 0.0 { - grounded.0 = true; - } } } } } - // If a collision is detected, stop the entity's movement + // Resolve collisions using Minimum Translation Vector (MTV): + // compute the penetration depth on each axis and push out along the + // smallest one, zeroing only that velocity component. This preserves + // jump velocity when hitting a wall horizontally. if collided { - vel.vec = Vec3A::ZERO; - // Find the closest hit block to the entity's position hit_blocks.sort_by(|a, b| { let dist_a = (a.as_dvec3() - pos.coords).length_squared(); let dist_b = (b.as_dvec3() - pos.coords).length_squared(); @@ -87,36 +90,69 @@ pub fn handle( }); let first_hit = hit_blocks.first().expect("At least one hit block expected"); - let block_aabb = Aabb3d { - min: first_hit.as_vec3a(), - max: (first_hit + IVec3::ONE).as_vec3a(), - }; - - let translated_bounding_box = Aabb3d { - min: physical.bounding_box.min + pos.coords.as_vec3a(), - max: physical.bounding_box.max + pos.coords.as_vec3a(), - }; - - // Get the closest point on the entity's bounding box to the block's AABB - let entity_collide_point = translated_bounding_box - .closest_point(block_aabb.center().as_dvec3().as_vec3a()); - - if entity_collide_point == block_aabb.center().as_dvec3().as_vec3a() { - continue; + let entity_min = physical.bounding_box.min + pos.coords.as_vec3a(); + let entity_max = physical.bounding_box.max + pos.coords.as_vec3a(); + let block_min = first_hit.as_vec3a(); + let block_max = (first_hit + IVec3::ONE).as_vec3a(); + + // Penetration depth on each axis from both sides + let ox_pos = entity_max.x - block_min.x; // entity entering from -X + let ox_neg = block_max.x - entity_min.x; // entity entering from +X + let oy_pos = entity_max.y - block_min.y; // entity entering from below + let oy_neg = block_max.y - entity_min.y; // entity entering from above + let oz_pos = entity_max.z - block_min.z; // entity entering from -Z + let oz_neg = block_max.z - entity_min.z; // entity entering from +Z + + // Only resolve if there is real penetration on all three axes + if ox_pos > 0.0 + && ox_neg > 0.0 + && oy_pos > 0.0 + && oy_neg > 0.0 + && oz_pos > 0.0 + && oz_neg > 0.0 + { + let mx = ox_pos.min(ox_neg); + let my = oy_pos.min(oy_neg); + let mz = oz_pos.min(oz_neg); + + if mx <= my && mx <= mz { + let push = if ox_pos < ox_neg { -ox_pos } else { ox_neg }; + pos.coords.x += push as f64; + vel.vec.x = 0.0; + } else if my <= mx && my <= mz { + let push = if oy_pos < oy_neg { -oy_pos } else { oy_neg }; + pos.coords.y += push as f64; + vel.vec.y = 0.0; + if oy_neg <= oy_pos { + // Entity came from above: it's landing on the block + grounded.0 = true; + } + } else { + let push = if oz_pos < oz_neg { -oz_pos } else { oz_neg }; + pos.coords.z += push as f64; + vel.vec.z = 0.0; + } } + } - // Then we get the closest point on the block's AABB to the entity's collide point - let block_collide_point = block_aabb.closest_point(entity_collide_point); - - if block_collide_point == entity_collide_point { - continue; + // Floor contact check: catches the "exactly at surface" case that the MTV + // misses when vel.y = 0. This happens when the entity moves horizontally + // while standing: the merged hitbox uses floor(65.0) = 65, so block y=64 + // is excluded, no collision fires, and grounded stays false. We check the + // block just below the entity's feet explicitly. + if !grounded.0 && vel.vec.y <= 0.0 { + let feet_y = physical.bounding_box.min.y as f64 + pos.coords.y; + let floor_block_y = (feet_y - 1e-3).floor() as i32; + let cx = pos.coords.x.floor() as i32; + let cz = pos.coords.z.floor() as i32; + if is_solid_block(&state.0, IVec3::new(cx, floor_block_y, cz)) { + let surface_y = (floor_block_y + 1) as f64; + if (feet_y - surface_y).abs() < 0.05 { + pos.coords.y = surface_y - physical.bounding_box.min.y as f64; + vel.vec.y = 0.0; + grounded.0 = true; + } } - - // The difference between these two points tells us how far apart the 2 colliding objects are - let collision_difference = entity_collide_point - block_collide_point; - - // We use this to nudge the entity out of the block along the smallest axis - pos.coords -= collision_difference.as_dvec3(); } writer.write(SendEntityUpdate(eid)); @@ -132,8 +168,5 @@ pub fn is_solid_block(state: &GlobalState, pos: IVec3) -> bool { .expect("Failed to load or generate chunk") .get_block(ChunkBlockPos::from(pos)); - !match_block!("air", block_state) - && !match_block!("void_air", block_state) - && !match_block!("water", block_state) - && !match_block!("air", block_state) + block_properties::is_solid(block_state) }