From 9e64813374afb904201baa4945a49c614669d09b Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Wed, 11 Mar 2026 19:48:43 +0100 Subject: [PATCH 01/14] feat : add pathfindings basics but sometime pig sadly noclip --- Cargo.toml | 1 + src/game_systems/Cargo.toml | 1 + src/game_systems/src/background/src/lib.rs | 2 + .../src/background/src/send_particles.rs | 33 +++ src/game_systems/src/mobs/Cargo.toml | 9 +- src/game_systems/src/mobs/src/lib.rs | 1 + src/game_systems/src/mobs/src/pig.rs | 161 ++++++++++++++- src/game_systems/src/pathfinding/Cargo.toml | 11 + src/game_systems/src/pathfinding/src/astar.rs | 190 ++++++++++++++++++ src/game_systems/src/pathfinding/src/cost.rs | 134 ++++++++++++ src/game_systems/src/pathfinding/src/lib.rs | 4 + .../src/physics/src/collisions.rs | 105 ++++++---- 12 files changed, 614 insertions(+), 38 deletions(-) create mode 100644 src/game_systems/src/background/src/send_particles.rs create mode 100644 src/game_systems/src/pathfinding/Cargo.toml create mode 100644 src/game_systems/src/pathfinding/src/astar.rs create mode 100644 src/game_systems/src/pathfinding/src/cost.rs create mode 100644 src/game_systems/src/pathfinding/src/lib.rs 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/game_systems/Cargo.toml b/src/game_systems/Cargo.toml index bb3a872c..3c4c2072 100644 --- a/src/game_systems/Cargo.toml +++ b/src/game_systems/Cargo.toml @@ -6,6 +6,7 @@ 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" } diff --git a/src/game_systems/src/background/src/lib.rs b/src/game_systems/src/background/src/lib.rs index 2f283f88..6d0c7a8a 100644 --- a/src/game_systems/src/background/src/lib.rs +++ b/src/game_systems/src/background/src/lib.rs @@ -6,6 +6,7 @@ pub mod keep_alive_system; pub mod lan_pinger; pub mod mq; pub mod send_entity_updates; +pub mod send_particles; pub mod server_command; pub mod world_sync; @@ -15,5 +16,6 @@ pub fn register_background_systems(schedule: &mut bevy_ecs::prelude::Schedule) { schedule.add_systems(day_cycle::tick_daylight_cycle); schedule.add_systems(mq::process); schedule.add_systems(send_entity_updates::handle); + schedule.add_systems(send_particles::handle); schedule.add_systems(server_command::handle); } diff --git a/src/game_systems/src/background/src/send_particles.rs b/src/game_systems/src/background/src/send_particles.rs new file mode 100644 index 00000000..8118fdf3 --- /dev/null +++ b/src/game_systems/src/background/src/send_particles.rs @@ -0,0 +1,33 @@ +use bevy_ecs::prelude::{MessageReader, Query}; +use temper_components::player::position::Position; +use temper_messages::particle::SendParticle; +use temper_net_runtime::connection::StreamWriter; +use temper_protocol::outgoing::particle::Particle; +use tracing::warn; + +pub fn handle(mut reader: MessageReader, writers: Query<(&Position, &StreamWriter)>) { + for msg in reader.read() { + let packet = Particle { + long_distance: false, + always_visible: false, + x: msg.position.x as f64, + y: msg.position.y as f64, + z: msg.position.z as f64, + offset_x: msg.offset.x, + offset_y: msg.offset.y, + offset_z: msg.offset.z, + max_speed: msg.speed, + count: msg.count, + particle_type: msg.particle_type.clone(), + }; + + for (pos, writer) in writers.iter() { + let distance_sq = pos.as_vec3a().distance_squared(msg.position); + if distance_sq <= 256.0 * 256.0 { + if let Err(e) = writer.send_packet_ref(&packet) { + warn!("Failed to send particle packet: {:?}", e); + } + } + } + } +} 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..cde315de 100644 --- a/src/game_systems/src/mobs/src/lib.rs +++ b/src/game_systems/src/mobs/src/lib.rs @@ -3,4 +3,5 @@ use bevy_ecs::prelude::Schedule; mod pig; pub fn register_mob_systems(schedule: &mut Schedule) { schedule.add_systems(pig::tick_pig); + 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..fa5eacce 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -1,11 +1,166 @@ -use bevy_ecs::prelude::{Query, With}; +use bevy_ecs::prelude::*; +use bevy_math::Vec3A; +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_core::pos::BlockPos; use temper_entities::markers::entity_types::Pig; +use temper_messages::particle::SendParticle; +use temper_particles::ParticleType; +use temper_state::GlobalStateResource; + +/// Pig walk speed in blocks per tick. +const PIG_WALK_SPEED: f32 = 0.1; + +/// Jump impulse matching Minecraft's standard jump velocity (blocks/tick). +/// With GRAVITY_ACCELERATION = -0.08 blocks/tick², this peaks at ~1.1 blocks. +const JUMP_IMPULSE: f32 = 0.42; + +/// Recompute the path every N ticks. +const REPATH_INTERVAL: u32 = 40; + +/// Max A* node expansions per repath. +const MAX_PATH_NODES: usize = 100; + +/// Per-pig AI state: cached path and repath cooldown. +#[derive(Component, Default)] +pub struct PigAI { + path: Vec, + waypoint: usize, + repath_cooldown: u32, +} -#[expect(unused_variables)] pub fn tick_pig( - query: Query<&Position, With>, + mut commands: Commands, + mut pigs: Query< + ( + Entity, + &Position, + &mut Velocity, + &OnGround, + Option<&mut PigAI>, + ), + With, + >, players: Query<&Position, With>, + state: Res, ) { + for (entity, pig_pos, mut velocity, grounded, ai) in pigs.iter_mut() { + let mut ai = match ai { + Some(ai) => ai, + None => { + commands.entity(entity).insert(PigAI::default()); + continue; + } + }; + + ai.repath_cooldown = ai.repath_cooldown.saturating_sub(1); + + let current_block = pos_to_block(pig_pos); + + // Advance waypoint when the pig reaches it (same X/Z block) + if let Some(next) = ai.path.get(ai.waypoint) { + if next.pos.x == current_block.pos.x && next.pos.z == current_block.pos.z { + ai.waypoint += 1; + } + } + + // Recompute path if cooldown expired or path exhausted + if ai.repath_cooldown == 0 || ai.waypoint >= ai.path.len() { + let Some(target_pos) = players + .iter() + .min_by_key(|p| ordered_float(pig_pos.coords.distance_squared(p.coords))) + else { + stop(&mut velocity); + continue; + }; + + let goal = pos_to_block(target_pos); + ai.path = pathfinding::find_path(&state.0.world, current_block, goal, MAX_PATH_NODES) + .map(|p| p.nodes) + .unwrap_or_default(); + ai.waypoint = 1; // node 0 is the current position + ai.repath_cooldown = REPATH_INTERVAL; + } + + let Some(next) = ai.path.get(ai.waypoint) else { + stop(&mut velocity); + continue; + }; + + // Jump if the next waypoint is 1 block above and the pig is on the ground. + // We rely on OnGround (set/reset by the collision system each tick) rather than + // a fractional-Y heuristic, which would fire mid-air and cause infinite flying. + 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()); + // Only spawn particles if a player is within 256 blocks + if distance_sq > 16.0 * 256.0 { + continue; + } + // Spawn end rod particles from the pig to the player + let steps = temper_utils::maths::step::step_between( + pos.1.as_vec3a(), + player_pos.coords.as_vec3a(), + 0.5, + ); + // Limit to 32 particles to avoid spamming (16 blocks with a 0.5 step) + 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; +} + +fn pos_to_block(pos: &Position) -> BlockPos { + // Add a small epsilon before flooring to avoid floating-point edge cases where + // the entity is at exactly the block surface (e.g. y=64.9999... instead of 65.0), + // which would incorrectly place the entity one block too low. + const EPSILON: f64 = 1e-4; + BlockPos::of( + pos.x.floor() as i32, + (pos.y + EPSILON).floor() as i32, + pos.z.floor() as i32, + ) +} + +fn ordered_float(v: f64) -> u64 { + v.to_bits() } diff --git a/src/game_systems/src/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml new file mode 100644 index 00000000..08e72459 --- /dev/null +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pathfinding" +version = "0.1.0" +edition = "2024" + +[dependencies] +temper-core = { workspace = true } +temper-world = { 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..5cba33b8 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -0,0 +1,190 @@ +use std::collections::{BinaryHeap, HashMap}; + +use temper_core::block_state_id::BlockStateId; +use temper_core::pos::BlockPos; +use temper_world::Dimension; + +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: (i32, i32, i32), +} + +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)) + } +} + +/// Find a path for a 1-block-tall land mob using weighted A*. +/// +/// `start` and `goal` are the block positions of the mob's feet. +/// 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, +) -> Option { + let start_key = to_key(start); + let goal_key = to_key(goal); + + if start_key == goal_key { + return Some(Path { nodes: vec![goal] }); + } + + let mut open: BinaryHeap = BinaryHeap::new(); + let mut g_score: HashMap<(i32, i32, i32), i32> = HashMap::new(); + let mut came_from: HashMap<(i32, i32, i32), (i32, i32, i32)> = HashMap::new(); + + g_score.insert(start_key, 0); + open.push(Candidate { + estimated_cost: heuristic(start, goal), + real_cost: 0, + pos: start_key, + }); + + let mut iterations = 0; + while let Some(Candidate { real_cost, pos, .. }) = open.pop() { + if iterations >= max_nodes { + break; + } + iterations += 1; + + if pos == goal_key { + return Some(reconstruct_path(came_from, pos, start_key)); + } + + if real_cost > *g_score.get(&pos).unwrap_or(&i32::MAX) { + continue; + } + + let current = from_key(pos); + for (neighbor, move_cost) in neighbors(world, current) { + let neighbor_key = to_key(neighbor); + let tentative_g = real_cost + move_cost; + + if g_score + .get(&neighbor_key) + .is_none_or(|&best| tentative_g < best) + { + g_score.insert(neighbor_key, tentative_g); + came_from.insert(neighbor_key, pos); + open.push(Candidate { + estimated_cost: tentative_g + heuristic(neighbor, goal), + real_cost: tentative_g, + pos: neighbor_key, + }); + } + } + } + + None +} + +fn to_key(pos: BlockPos) -> (i32, i32, i32) { + (pos.pos.x, pos.pos.y, pos.pos.z) +} + +fn from_key((x, y, z): (i32, i32, i32)) -> BlockPos { + BlockPos::of(x, y, z) +} + +fn heuristic(a: BlockPos, b: BlockPos) -> i32 { + (a.pos.x - b.pos.x).abs() + (a.pos.y - b.pos.y).abs() + (a.pos.z - b.pos.z).abs() +} + +fn reconstruct_path( + came_from: HashMap<(i32, i32, i32), (i32, i32, i32)>, + target: (i32, i32, i32), + start: (i32, i32, i32), +) -> Path { + let mut current = target; + let mut nodes = vec![from_key(current)]; + while current != start { + current = came_from[¤t]; + nodes.push(from_key(current)); + } + nodes.reverse(); + Path { nodes } +} + +const CARDINALS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; + +/// Generate passable neighbors for a 1-block-tall land mob (e.g. pig, height=0.9). +/// Handles flat walking, stepping up 1 block, and stepping down 1 block. +fn neighbors(world: &temper_world::World, pos: BlockPos) -> Vec<(BlockPos, i32)> { + let mut result = Vec::with_capacity(5); + + for (dx, dz) in CARDINALS { + let nx = pos.pos.x + dx; + let nz = pos.pos.z + dz; + + // Walk flat + if let Some(cost) = can_stand_at(world, nx, pos.pos.y, nz) { + result.push((BlockPos::of(nx, pos.pos.y, nz), cost + 1)); + continue; + } + + // Step up 1 block — need the block directly above current feet to be clear + if block_penalty(get_block(world, pos.pos.x, pos.pos.y + 1, pos.pos.z)) != IMPASSABLE { + if let Some(cost) = can_stand_at(world, nx, pos.pos.y + 1, nz) { + result.push((BlockPos::of(nx, pos.pos.y + 1, nz), cost + 2)); + continue; + } + } + + // Step down 1 block — neighbor column must be open at current height + if block_penalty(get_block(world, nx, pos.pos.y, nz)) != IMPASSABLE { + if let Some(cost) = can_stand_at(world, nx, pos.pos.y - 1, nz) { + result.push((BlockPos::of(nx, pos.pos.y - 1, nz), cost + 1)); + } + } + } + + result +} + +/// Check if a 1-block-tall mob can stand with feet at (x, y, z): +/// - solid block at (x, y-1, z) as floor +/// - passable at (x, y, z) for the body +/// +/// Returns `Some(terrain_cost)` if valid, `None` if not. +fn can_stand_at(world: &temper_world::World, x: i32, y: i32, z: i32) -> Option { + if block_penalty(get_block(world, x, y - 1, z)) != IMPASSABLE { + return None; // no solid floor + } + + let body_penalty = block_penalty(get_block(world, x, y, z)); + if body_penalty == IMPASSABLE { + return None; + } + + Some(body_penalty.max(0)) +} + +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; pig won't path there (no solid floor) +} 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..7f290d88 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/cost.rs @@ -0,0 +1,134 @@ +use temper_core::block_state_id::BlockStateId; + +/// Sentinel value meaning the block cannot be traversed. +pub const IMPASSABLE: i32 = i32::MIN; + +/// 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 +pub fn block_penalty(id: BlockStateId) -> i32 { + if id.raw() == 0 { + return 0; // air + } + + let Some(data) = id.to_block_data() else { + return IMPASSABLE; + }; + + let name = data.name.trim_start_matches("minecraft:"); + + 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")) + .map(|v| v == "true") + .unwrap_or(false); + 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")) + .map(|v| v == "true") + .unwrap_or(false); + 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") +} 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..a7365588 --- /dev/null +++ b/src/game_systems/src/pathfinding/src/lib.rs @@ -0,0 +1,4 @@ +mod astar; +mod cost; + +pub use astar::{Path, find_path}; diff --git a/src/game_systems/src/physics/src/collisions.rs b/src/game_systems/src/physics/src/collisions.rs index 79720c43..eb4b06b0 100644 --- a/src/game_systems/src/physics/src/collisions.rs +++ b/src/game_systems/src/physics/src/collisions.rs @@ -1,8 +1,8 @@ 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; @@ -37,6 +37,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 +75,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 +91,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)); From 2e6b22ac8b0219b208ad7b5236a8c67aa877462e Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 00:22:58 +0100 Subject: [PATCH 02/14] fix : replace OG hashmap by rustc FX hashmap --- src/game_systems/src/pathfinding/Cargo.toml | 1 + src/game_systems/src/pathfinding/src/astar.rs | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/game_systems/src/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml index 08e72459..47029440 100644 --- a/src/game_systems/src/pathfinding/Cargo.toml +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] temper-core = { workspace = true } temper-world = { workspace = true } +rustc-hash = "2" [lints] workspace = true diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index 5cba33b8..fc2527cf 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -1,11 +1,16 @@ -use std::collections::{BinaryHeap, HashMap}; +use std::collections::BinaryHeap; +use rustc_hash::FxHashMap; use temper_core::block_state_id::BlockStateId; use temper_core::pos::BlockPos; use temper_world::Dimension; use crate::cost::{IMPASSABLE, block_penalty}; +/// Position key for pathfinding maps. +type PosKey = (i32, i32, i32); +type PosMap = FxHashMap; + /// A path from start to goal, expressed as block positions (feet position). pub struct Path { pub nodes: Vec, @@ -50,8 +55,8 @@ pub fn find_path( } let mut open: BinaryHeap = BinaryHeap::new(); - let mut g_score: HashMap<(i32, i32, i32), i32> = HashMap::new(); - let mut came_from: HashMap<(i32, i32, i32), (i32, i32, i32)> = HashMap::new(); + let mut g_score: PosMap = FxHashMap::default(); + let mut came_from: PosMap = FxHashMap::default(); g_score.insert(start_key, 0); open.push(Candidate { @@ -110,11 +115,7 @@ fn heuristic(a: BlockPos, b: BlockPos) -> i32 { (a.pos.x - b.pos.x).abs() + (a.pos.y - b.pos.y).abs() + (a.pos.z - b.pos.z).abs() } -fn reconstruct_path( - came_from: HashMap<(i32, i32, i32), (i32, i32, i32)>, - target: (i32, i32, i32), - start: (i32, i32, i32), -) -> Path { +fn reconstruct_path(came_from: PosMap, target: PosKey, start: PosKey) -> Path { let mut current = target; let mut nodes = vec![from_key(current)]; while current != start { From ab4ff649899bbc81f1bb0ed357842be3d67b3ab0 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 00:36:12 +0100 Subject: [PATCH 03/14] fix : replace string matching with #[inline] O1 array --- src/game_systems/src/pathfinding/src/cost.rs | 29 ++++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/game_systems/src/pathfinding/src/cost.rs b/src/game_systems/src/pathfinding/src/cost.rs index 7f290d88..85de4291 100644 --- a/src/game_systems/src/pathfinding/src/cost.rs +++ b/src/game_systems/src/pathfinding/src/cost.rs @@ -1,24 +1,31 @@ -use temper_core::block_state_id::BlockStateId; +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 { - if id.raw() == 0 { - return 0; // air - } - - let Some(data) = id.to_block_data() else { - return IMPASSABLE; - }; + 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; } @@ -58,8 +65,7 @@ pub fn block_penalty(id: BlockStateId) -> i32 { .properties .as_ref() .and_then(|p| p.get("open")) - .map(|v| v == "true") - .unwrap_or(false); + .is_some_and(|v| v == "true"); return if open { 0 } else { IMPASSABLE }; } @@ -69,8 +75,7 @@ pub fn block_penalty(id: BlockStateId) -> i32 { .properties .as_ref() .and_then(|p| p.get("open")) - .map(|v| v == "true") - .unwrap_or(false); + .is_some_and(|v| v == "true"); return if open { 0 } else { IMPASSABLE }; } From 4f6246979f2c49ab6089436c15a2bd0b2203253f Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 00:41:39 +0100 Subject: [PATCH 04/14] fix : use arrayvec for neighbour compute --- src/game_systems/src/pathfinding/Cargo.toml | 1 + src/game_systems/src/pathfinding/src/astar.rs | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/game_systems/src/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml index 47029440..e75e98e6 100644 --- a/src/game_systems/src/pathfinding/Cargo.toml +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" temper-core = { workspace = true } temper-world = { workspace = true } rustc-hash = "2" +arrayvec = "0.7" [lints] workspace = true diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index fc2527cf..78871eb1 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -1,5 +1,6 @@ use std::collections::BinaryHeap; +use arrayvec::ArrayVec; use rustc_hash::FxHashMap; use temper_core::block_state_id::BlockStateId; use temper_core::pos::BlockPos; @@ -128,10 +129,16 @@ fn reconstruct_path(came_from: PosMap, target: PosKey, start: PosKey) -> const CARDINALS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; +/// Maximum number of neighbors per node (one per cardinal direction). +const MAX_NEIGHBORS: usize = 4; + /// Generate passable neighbors for a 1-block-tall land mob (e.g. pig, height=0.9). /// Handles flat walking, stepping up 1 block, and stepping down 1 block. -fn neighbors(world: &temper_world::World, pos: BlockPos) -> Vec<(BlockPos, i32)> { - let mut result = Vec::with_capacity(5); +fn neighbors( + world: &temper_world::World, + pos: BlockPos, +) -> ArrayVec<(BlockPos, i32), MAX_NEIGHBORS> { + let mut result = ArrayVec::new(); for (dx, dz) in CARDINALS { let nx = pos.pos.x + dx; From a079fb750e540995f3bb152403e5fd7cf03aeb10 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 00:46:59 +0100 Subject: [PATCH 05/14] feat: add 4 others neighbour (yes now the pig can go diagonal thanks @Pooka) pig now farm aura --- src/game_systems/src/pathfinding/src/astar.rs | 104 ++++++++++++++---- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index 78871eb1..aa4b4cba 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -112,8 +112,20 @@ fn from_key((x, y, z): (i32, i32, i32)) -> BlockPos { BlockPos::of(x, y, z) } +/// 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 { - (a.pos.x - b.pos.x).abs() + (a.pos.y - b.pos.y).abs() + (a.pos.z - b.pos.z).abs() + 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: PosMap, target: PosKey, start: PosKey) -> Path { @@ -127,46 +139,96 @@ fn reconstruct_path(came_from: PosMap, target: PosKey, start: PosKey) -> Path { nodes } } +/// Cardinal directions (cost multiplier: 10). const CARDINALS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; -/// Maximum number of neighbors per node (one per cardinal direction). -const MAX_NEIGHBORS: usize = 4; +/// 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 1-block-tall land mob (e.g. pig, height=0.9). /// 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, ) -> ArrayVec<(BlockPos, i32), MAX_NEIGHBORS> { let mut result = ArrayVec::new(); + // Cardinal directions for (dx, dz) in CARDINALS { - let nx = pos.pos.x + dx; - let nz = pos.pos.z + dz; - - // Walk flat - if let Some(cost) = can_stand_at(world, nx, pos.pos.y, nz) { - result.push((BlockPos::of(nx, pos.pos.y, nz), cost + 1)); - continue; + if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_CARDINAL) { + result.push((dest, cost)); } + } - // Step up 1 block — need the block directly above current feet to be clear - if block_penalty(get_block(world, pos.pos.x, pos.pos.y + 1, pos.pos.z)) != IMPASSABLE { - if let Some(cost) = can_stand_at(world, nx, pos.pos.y + 1, nz) { - result.push((BlockPos::of(nx, pos.pos.y + 1, nz), cost + 2)); - continue; + // 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 { + if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL) { + result.push((dest, 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 { - if let Some(cost) = can_stand_at(world, nx, pos.pos.y - 1, nz) { - result.push((BlockPos::of(nx, pos.pos.y - 1, nz), cost + 1)); - } + 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, +) -> 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) { + return Some((BlockPos::of(nx, pos.pos.y, nz), base_cost + terrain_cost)); + } + + // Step up 1 block — need the block directly above current feet to be clear + if block_penalty(get_block(world, pos.pos.x, pos.pos.y + 1, pos.pos.z)) != IMPASSABLE { + if let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y + 1, nz) { + return Some(( + BlockPos::of(nx, pos.pos.y + 1, nz), + base_cost + COST_STEP_UP + terrain_cost, + )); } } - result + // Step down 1 block — neighbor column must be open at current height + if block_penalty(get_block(world, nx, pos.pos.y, nz)) != IMPASSABLE { + if let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y - 1, nz) { + return Some(( + BlockPos::of(nx, pos.pos.y - 1, nz), + base_cost + terrain_cost, + )); + } + } + + None } /// Check if a 1-block-tall mob can stand with feet at (x, y, z): From 51f78ab9d145134c862340be53bdb33e7da3e555 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 00:50:55 +0100 Subject: [PATCH 06/14] fix: documenting my skills issues --- src/game_systems/src/mobs/src/pig.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/game_systems/src/mobs/src/pig.rs b/src/game_systems/src/mobs/src/pig.rs index fa5eacce..dde53db2 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -150,9 +150,14 @@ fn stop(velocity: &mut Velocity) { } fn pos_to_block(pos: &Position) -> BlockPos { - // Add a small epsilon before flooring to avoid floating-point edge cases where - // the entity is at exactly the block surface (e.g. y=64.9999... instead of 65.0), - // which would incorrectly place the entity one block too low. + // TODO(collision): This epsilon is a workaround for imprecise collision resolution. + // When an entity lands on a block, the MTV (Minimum Translation Vector) in + // `physics/collisions.rs` sometimes leaves the entity at y=64.9999... instead of + // exactly y=65.0. Without this epsilon, floor() would return 64 instead of 65, + // causing the pathfinding to think the entity is one block lower than it actually is. + // + // The proper fix is to ensure the collision system snaps entities to exact block + // surfaces when resolving vertical collisions (see `handle()` in collisions.rs). const EPSILON: f64 = 1e-4; BlockPos::of( pos.x.floor() as i32, From bbe3dace359059969eb3a6008c9aba79233dbfd8 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 01:06:59 +0100 Subject: [PATCH 07/14] feat : add Physical Rigistries reference in find_path to extand scope to all entities --- src/game_systems/src/mobs/src/pig.rs | 22 ++++- src/game_systems/src/pathfinding/Cargo.toml | 1 + src/game_systems/src/pathfinding/src/astar.rs | 87 +++++++++++++++---- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/game_systems/src/mobs/src/pig.rs b/src/game_systems/src/mobs/src/pig.rs index dde53db2..2a28da17 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -5,6 +5,8 @@ use temper_components::player::player_identity::PlayerIdentity; use temper_components::player::position::Position; use temper_components::player::velocity::Velocity; use temper_core::pos::BlockPos; +use temper_entities::PhysicalRegistry; +use temper_entities::components::EntityMetadata; use temper_entities::markers::entity_types::Pig; use temper_messages::particle::SendParticle; use temper_particles::ParticleType; @@ -39,14 +41,20 @@ pub fn tick_pig( &Position, &mut Velocity, &OnGround, + &EntityMetadata, Option<&mut PigAI>, ), With, >, players: Query<&Position, With>, state: Res, + registry: Res, ) { - for (entity, pig_pos, mut velocity, grounded, ai) in pigs.iter_mut() { + for (entity, pig_pos, mut velocity, grounded, metadata, ai) in pigs.iter_mut() { + let Some(physical) = registry.get(metadata.protocol_id(), false) else { + continue; + }; + let mut ai = match ai { Some(ai) => ai, None => { @@ -77,9 +85,15 @@ pub fn tick_pig( }; let goal = pos_to_block(target_pos); - ai.path = pathfinding::find_path(&state.0.world, current_block, goal, MAX_PATH_NODES) - .map(|p| p.nodes) - .unwrap_or_default(); + ai.path = pathfinding::find_path( + &state.0.world, + current_block, + goal, + MAX_PATH_NODES, + physical, + ) + .map(|p| p.nodes) + .unwrap_or_default(); ai.waypoint = 1; // node 0 is the current position ai.repath_cooldown = REPATH_INTERVAL; } diff --git a/src/game_systems/src/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml index e75e98e6..cecd37c1 100644 --- a/src/game_systems/src/pathfinding/Cargo.toml +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] temper-core = { workspace = true } temper-world = { workspace = true } +temper-components = { workspace = true } rustc-hash = "2" arrayvec = "0.7" diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index aa4b4cba..9a36b0c0 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -2,6 +2,7 @@ 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; @@ -38,16 +39,41 @@ impl PartialOrd for Candidate { } } -/// Find a path for a 1-block-tall land mob using weighted A*. +/// Entity dimensions for pathfinding, computed from PhysicalProperties. +#[derive(Clone, Copy)] +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, + } + } +} + +/// 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 { + let dims = EntityDimensions::from_physical(physical); let start_key = to_key(start); let goal_key = to_key(goal); @@ -82,7 +108,7 @@ pub fn find_path( } let current = from_key(pos); - for (neighbor, move_cost) in neighbors(world, current) { + for (neighbor, move_cost) in neighbors(world, current, dims) { let neighbor_key = to_key(neighbor); let tentative_g = real_cost + move_cost; @@ -157,18 +183,19 @@ 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 1-block-tall land mob (e.g. pig, height=0.9). +/// 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) { + if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_CARDINAL, dims) { result.push((dest, cost)); } } @@ -182,7 +209,7 @@ fn neighbors( block_penalty(get_block(world, pos.pos.x, pos.pos.y, pos.pos.z + dz)) != IMPASSABLE; if side1_passable && side2_passable { - if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL) { + if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL, dims) { result.push((dest, cost)); } } @@ -199,18 +226,19 @@ fn try_move( 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) { + 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 the block directly above current feet to be clear - if block_penalty(get_block(world, pos.pos.x, pos.pos.y + 1, pos.pos.z)) != IMPASSABLE { - if let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y + 1, nz) { + // 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) { + if 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, @@ -220,7 +248,7 @@ fn try_move( // Step down 1 block — neighbor column must be open at current height if block_penalty(get_block(world, nx, pos.pos.y, nz)) != IMPASSABLE { - if let Some(terrain_cost) = can_stand_at(world, nx, pos.pos.y - 1, nz) { + if 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, @@ -231,22 +259,45 @@ fn try_move( None } -/// Check if a 1-block-tall mob can stand with feet at (x, y, z): +/// Check if an entity can stand with feet at (x, y, z): /// - solid block at (x, y-1, z) as floor -/// - passable at (x, y, z) for the body +/// - 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) -> Option { +fn can_stand_at( + world: &temper_world::World, + x: i32, + y: i32, + z: i32, + dims: EntityDimensions, +) -> Option { + // Check for solid floor if block_penalty(get_block(world, x, y - 1, z)) != IMPASSABLE { return None; // no solid floor } - let body_penalty = block_penalty(get_block(world, x, y, z)); - if body_penalty == IMPASSABLE { - return None; + // Check all blocks occupied by the body + let mut total_penalty = 0; + for dy in 0..dims.height_blocks as i32 { + 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(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..=height as i32 { + 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 { @@ -256,5 +307,5 @@ fn get_block(world: &temper_world::World, x: i32, y: i32, z: i32) -> BlockStateI .get_cache() .get(&(pos.chunk(), Dimension::Overworld)) .map(|chunk| chunk.get_block(pos.chunk_block_pos())) - .unwrap_or_default() // unloaded chunk = air; pig won't path there (no solid floor) + .unwrap_or_default() // unloaded chunk = air; mob won't path there (no solid floor) } From 6165d491016b6c6a4ecdeef2e6448b8194ef9cfb Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 01:13:56 +0100 Subject: [PATCH 08/14] fix : sorry just forgot that BlockPos derive Hash ... --- src/game_systems/src/pathfinding/src/astar.rs | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index 9a36b0c0..bcb42492 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -9,10 +9,6 @@ use temper_world::Dimension; use crate::cost::{IMPASSABLE, block_penalty}; -/// Position key for pathfinding maps. -type PosKey = (i32, i32, i32); -type PosMap = FxHashMap; - /// A path from start to goal, expressed as block positions (feet position). pub struct Path { pub nodes: Vec, @@ -24,7 +20,7 @@ pub struct Path { struct Candidate { estimated_cost: i32, // f = g + h real_cost: i32, // g - pos: (i32, i32, i32), + pos: BlockPos, } impl Ord for Candidate { @@ -74,22 +70,20 @@ pub fn find_path( physical: &PhysicalProperties, ) -> Option { let dims = EntityDimensions::from_physical(physical); - let start_key = to_key(start); - let goal_key = to_key(goal); - if start_key == goal_key { + if start == goal { return Some(Path { nodes: vec![goal] }); } let mut open: BinaryHeap = BinaryHeap::new(); - let mut g_score: PosMap = FxHashMap::default(); - let mut came_from: PosMap = FxHashMap::default(); + let mut g_score: FxHashMap = FxHashMap::default(); + let mut came_from: FxHashMap = FxHashMap::default(); - g_score.insert(start_key, 0); + g_score.insert(start, 0); open.push(Candidate { estimated_cost: heuristic(start, goal), real_cost: 0, - pos: start_key, + pos: start, }); let mut iterations = 0; @@ -99,29 +93,27 @@ pub fn find_path( } iterations += 1; - if pos == goal_key { - return Some(reconstruct_path(came_from, pos, start_key)); + if pos == goal { + return Some(reconstruct_path(came_from, pos, start)); } if real_cost > *g_score.get(&pos).unwrap_or(&i32::MAX) { continue; } - let current = from_key(pos); - for (neighbor, move_cost) in neighbors(world, current, dims) { - let neighbor_key = to_key(neighbor); + for (neighbor, move_cost) in neighbors(world, pos, dims) { let tentative_g = real_cost + move_cost; if g_score - .get(&neighbor_key) + .get(&neighbor) .is_none_or(|&best| tentative_g < best) { - g_score.insert(neighbor_key, tentative_g); - came_from.insert(neighbor_key, pos); + g_score.insert(neighbor, tentative_g); + came_from.insert(neighbor, pos); open.push(Candidate { estimated_cost: tentative_g + heuristic(neighbor, goal), real_cost: tentative_g, - pos: neighbor_key, + pos: neighbor, }); } } @@ -130,14 +122,6 @@ pub fn find_path( None } -fn to_key(pos: BlockPos) -> (i32, i32, i32) { - (pos.pos.x, pos.pos.y, pos.pos.z) -} - -fn from_key((x, y, z): (i32, i32, i32)) -> BlockPos { - BlockPos::of(x, y, z) -} - /// 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 { @@ -154,12 +138,16 @@ fn heuristic(a: BlockPos, b: BlockPos) -> i32 { min_xz * COST_DIAGONAL + (max_xz - min_xz) * COST_CARDINAL + dy * COST_CARDINAL } -fn reconstruct_path(came_from: PosMap, target: PosKey, start: PosKey) -> Path { +fn reconstruct_path( + came_from: FxHashMap, + target: BlockPos, + start: BlockPos, +) -> Path { let mut current = target; - let mut nodes = vec![from_key(current)]; + let mut nodes = vec![current]; while current != start { current = came_from[¤t]; - nodes.push(from_key(current)); + nodes.push(current); } nodes.reverse(); Path { nodes } From 0c22cc0296e5d94f2f822846315406ed3ec2e30a Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 14 Mar 2026 01:33:54 +0100 Subject: [PATCH 09/14] fix clippy warning --- .../src/background/src/send_particles.rs | 8 ++-- src/game_systems/src/mobs/src/pig.rs | 30 +++++++------- src/game_systems/src/pathfinding/src/astar.rs | 41 ++++++++++--------- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/game_systems/src/background/src/send_particles.rs b/src/game_systems/src/background/src/send_particles.rs index 8118fdf3..84e3c8f3 100644 --- a/src/game_systems/src/background/src/send_particles.rs +++ b/src/game_systems/src/background/src/send_particles.rs @@ -23,10 +23,10 @@ pub fn handle(mut reader: MessageReader, writers: Query<(&Position for (pos, writer) in writers.iter() { let distance_sq = pos.as_vec3a().distance_squared(msg.position); - if distance_sq <= 256.0 * 256.0 { - if let Err(e) = writer.send_packet_ref(&packet) { - warn!("Failed to send particle packet: {:?}", e); - } + if distance_sq <= 256.0 * 256.0 + && let Err(e) = writer.send_packet_ref(&packet) + { + warn!("Failed to send particle packet: {:?}", e); } } } diff --git a/src/game_systems/src/mobs/src/pig.rs b/src/game_systems/src/mobs/src/pig.rs index 2a28da17..e020e6bb 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -33,19 +33,18 @@ pub struct PigAI { repath_cooldown: u32, } +type PigQuery<'a> = ( + Entity, + &'a Position, + &'a mut Velocity, + &'a OnGround, + &'a EntityMetadata, + Option<&'a mut PigAI>, +); + pub fn tick_pig( mut commands: Commands, - mut pigs: Query< - ( - Entity, - &Position, - &mut Velocity, - &OnGround, - &EntityMetadata, - Option<&mut PigAI>, - ), - With, - >, + mut pigs: Query>, players: Query<&Position, With>, state: Res, registry: Res, @@ -68,10 +67,11 @@ pub fn tick_pig( let current_block = pos_to_block(pig_pos); // Advance waypoint when the pig reaches it (same X/Z block) - if let Some(next) = ai.path.get(ai.waypoint) { - if next.pos.x == current_block.pos.x && next.pos.z == current_block.pos.z { - ai.waypoint += 1; - } + if let Some(next) = ai.path.get(ai.waypoint) + && next.pos.x == current_block.pos.x + && next.pos.z == current_block.pos.z + { + ai.waypoint += 1; } // Recompute path if cooldown expired or path exhausted diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index bcb42492..867e150f 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -196,10 +196,11 @@ fn neighbors( let side2_passable = block_penalty(get_block(world, pos.pos.x, pos.pos.y, pos.pos.z + dz)) != IMPASSABLE; - if side1_passable && side2_passable { - if let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL, dims) { - result.push((dest, cost)); - } + if side1_passable + && side2_passable + && let Some((dest, cost)) = try_move(world, pos, dx, dz, COST_DIAGONAL, dims) + { + result.push((dest, cost)); } } @@ -225,23 +226,23 @@ fn try_move( } // 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) { - if 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, - )); - } + 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 { - if 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, - )); - } + 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 @@ -266,7 +267,7 @@ fn can_stand_at( // Check all blocks occupied by the body let mut total_penalty = 0; - for dy in 0..dims.height_blocks as i32 { + 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; @@ -280,7 +281,7 @@ fn can_stand_at( /// 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..=height as i32 { + for dy in 1..=i32::from(height) { if block_penalty(get_block(world, x, y + dy, z)) == IMPASSABLE { return false; } From ad6a49509735093c8fa1af28f346f9e8fc925c0b Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Mon, 16 Mar 2026 09:44:51 +0100 Subject: [PATCH 10/14] feat: add bettery tests for astars and costs --- src/game_systems/Cargo.toml | 2 +- src/game_systems/src/pathfinding/Cargo.toml | 6 + src/game_systems/src/pathfinding/src/astar.rs | 255 ++++++++++++++++++ src/game_systems/src/pathfinding/src/cost.rs | 78 ++++++ 4 files changed, 340 insertions(+), 1 deletion(-) diff --git a/src/game_systems/Cargo.toml b/src/game_systems/Cargo.toml index 3c4c2072..634804e5 100644 --- a/src/game_systems/Cargo.toml +++ b/src/game_systems/Cargo.toml @@ -13,7 +13,7 @@ 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/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml index cecd37c1..05e9f221 100644 --- a/src/game_systems/src/pathfinding/Cargo.toml +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -10,5 +10,11 @@ temper-components = { 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 index 867e150f..0d7adc55 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -298,3 +298,258 @@ fn get_block(world: &temper_world::World, x: i32, y: i32, z: i32) -> BlockStateI .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 index 85de4291..ac02ece4 100644 --- a/src/game_systems/src/pathfinding/src/cost.rs +++ b/src/game_systems/src/pathfinding/src/cost.rs @@ -137,3 +137,81 @@ fn is_non_solid(name: &str) -> bool { || 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)", + ); + } +} From ba672c9cc6331c34f168512f8fc4c8628c5429b3 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Mon, 16 Mar 2026 10:06:35 +0100 Subject: [PATCH 11/14] refacto: unify block solidity into a shared lookup table in temper-core, used by both pathfinding and collision systems --- src/core/src/block_properties.rs | 120 ++++++++++++++++++ src/core/src/lib.rs | 1 + src/game_systems/src/pathfinding/src/astar.rs | 8 +- .../src/physics/src/collisions.rs | 8 +- 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 src/core/src/block_properties.rs 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/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index 0d7adc55..fbff835c 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -7,6 +7,8 @@ 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). @@ -249,7 +251,7 @@ fn try_move( } /// Check if an entity can stand with feet at (x, y, z): -/// - solid block at (x, y-1, z) as floor +/// - 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. @@ -260,8 +262,8 @@ fn can_stand_at( z: i32, dims: EntityDimensions, ) -> Option { - // Check for solid floor - if block_penalty(get_block(world, x, y - 1, z)) != IMPASSABLE { + // 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 } diff --git a/src/game_systems/src/physics/src/collisions.rs b/src/game_systems/src/physics/src/collisions.rs index eb4b06b0..430d9676 100644 --- a/src/game_systems/src/physics/src/collisions.rs +++ b/src/game_systems/src/physics/src/collisions.rs @@ -6,14 +6,13 @@ use bevy_math::bounding::{Aabb3d, BoundingVolume}; 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}; @@ -169,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) } From 13eb51900879b8a29ed260315769cc8d5424564e Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Mon, 16 Mar 2026 10:16:43 +0100 Subject: [PATCH 12/14] fix : replace ordered_float with total_cmp to handle negative values --- src/game_systems/src/mobs/src/pig.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/game_systems/src/mobs/src/pig.rs b/src/game_systems/src/mobs/src/pig.rs index e020e6bb..dc16ad12 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -76,10 +76,12 @@ pub fn tick_pig( // Recompute path if cooldown expired or path exhausted if ai.repath_cooldown == 0 || ai.waypoint >= ai.path.len() { - let Some(target_pos) = players - .iter() - .min_by_key(|p| ordered_float(pig_pos.coords.distance_squared(p.coords))) - else { + let Some(target_pos) = players.iter().min_by(|a, b| { + pig_pos + .coords + .distance_squared(a.coords) + .total_cmp(&pig_pos.coords.distance_squared(b.coords)) + }) else { stop(&mut velocity); continue; }; @@ -179,7 +181,3 @@ fn pos_to_block(pos: &Position) -> BlockPos { pos.z.floor() as i32, ) } - -fn ordered_float(v: f64) -> u64 { - v.to_bits() -} From 80009f2b75a133b56443a8d5f650a0ed81610272 Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Fri, 27 Mar 2026 20:56:22 +0100 Subject: [PATCH 13/14] fix : remove duplicate particle system --- src/game_systems/src/background/src/lib.rs | 2 -- .../src/background/src/send_particles.rs | 33 ------------------- 2 files changed, 35 deletions(-) delete mode 100644 src/game_systems/src/background/src/send_particles.rs diff --git a/src/game_systems/src/background/src/lib.rs b/src/game_systems/src/background/src/lib.rs index 6d0c7a8a..2f283f88 100644 --- a/src/game_systems/src/background/src/lib.rs +++ b/src/game_systems/src/background/src/lib.rs @@ -6,7 +6,6 @@ pub mod keep_alive_system; pub mod lan_pinger; pub mod mq; pub mod send_entity_updates; -pub mod send_particles; pub mod server_command; pub mod world_sync; @@ -16,6 +15,5 @@ pub fn register_background_systems(schedule: &mut bevy_ecs::prelude::Schedule) { schedule.add_systems(day_cycle::tick_daylight_cycle); schedule.add_systems(mq::process); schedule.add_systems(send_entity_updates::handle); - schedule.add_systems(send_particles::handle); schedule.add_systems(server_command::handle); } diff --git a/src/game_systems/src/background/src/send_particles.rs b/src/game_systems/src/background/src/send_particles.rs deleted file mode 100644 index 84e3c8f3..00000000 --- a/src/game_systems/src/background/src/send_particles.rs +++ /dev/null @@ -1,33 +0,0 @@ -use bevy_ecs::prelude::{MessageReader, Query}; -use temper_components::player::position::Position; -use temper_messages::particle::SendParticle; -use temper_net_runtime::connection::StreamWriter; -use temper_protocol::outgoing::particle::Particle; -use tracing::warn; - -pub fn handle(mut reader: MessageReader, writers: Query<(&Position, &StreamWriter)>) { - for msg in reader.read() { - let packet = Particle { - long_distance: false, - always_visible: false, - x: msg.position.x as f64, - y: msg.position.y as f64, - z: msg.position.z as f64, - offset_x: msg.offset.x, - offset_y: msg.offset.y, - offset_z: msg.offset.z, - max_speed: msg.speed, - count: msg.count, - particle_type: msg.particle_type.clone(), - }; - - for (pos, writer) in writers.iter() { - let distance_sq = pos.as_vec3a().distance_squared(msg.position); - if distance_sq <= 256.0 * 256.0 - && let Err(e) = writer.send_packet_ref(&packet) - { - warn!("Failed to send particle packet: {:?}", e); - } - } - } -} From ed9a26c65b1bf3d8cce31ef323550f359bbf335c Mon Sep 17 00:00:00 2001 From: Tonguechaude Date: Sat, 28 Mar 2026 08:16:51 +0100 Subject: [PATCH 14/14] feat : create pathfinding component --- src/game_systems/src/mobs/src/lib.rs | 5 +- src/game_systems/src/mobs/src/pig.rs | 115 ++++--------- src/game_systems/src/pathfinding/Cargo.toml | 3 + src/game_systems/src/pathfinding/src/astar.rs | 156 ++++++++++++------ src/game_systems/src/pathfinding/src/lib.rs | 2 + .../src/pathfinding/src/pathfinder.rs | 143 ++++++++++++++++ 6 files changed, 293 insertions(+), 131 deletions(-) create mode 100644 src/game_systems/src/pathfinding/src/pathfinder.rs diff --git a/src/game_systems/src/mobs/src/lib.rs b/src/game_systems/src/mobs/src/lib.rs index cde315de..4f98e12e 100644 --- a/src/game_systems/src/mobs/src/lib.rs +++ b/src/game_systems/src/mobs/src/lib.rs @@ -1,7 +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 dc16ad12..3617a91b 100644 --- a/src/game_systems/src/mobs/src/pig.rs +++ b/src/game_systems/src/mobs/src/pig.rs @@ -1,35 +1,26 @@ 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_core::pos::BlockPos; -use temper_entities::PhysicalRegistry; -use temper_entities::components::EntityMetadata; use temper_entities::markers::entity_types::Pig; use temper_messages::particle::SendParticle; use temper_particles::ParticleType; -use temper_state::GlobalStateResource; /// Pig walk speed in blocks per tick. const PIG_WALK_SPEED: f32 = 0.1; /// Jump impulse matching Minecraft's standard jump velocity (blocks/tick). -/// With GRAVITY_ACCELERATION = -0.08 blocks/tick², this peaks at ~1.1 blocks. const JUMP_IMPULSE: f32 = 0.42; -/// Recompute the path every N ticks. +/// How often to update the pathfinding target (ticks). const REPATH_INTERVAL: u32 = 40; -/// Max A* node expansions per repath. -const MAX_PATH_NODES: usize = 100; - -/// Per-pig AI state: cached path and repath cooldown. +/// Per-pig AI state. #[derive(Component, Default)] pub struct PigAI { - path: Vec, - waypoint: usize, repath_cooldown: u32, } @@ -38,81 +29,65 @@ type PigQuery<'a> = ( &'a Position, &'a mut Velocity, &'a OnGround, - &'a EntityMetadata, Option<&'a mut PigAI>, + Option<&'a mut Pathfinder>, ); pub fn tick_pig( mut commands: Commands, mut pigs: Query>, players: Query<&Position, With>, - state: Res, - registry: Res, ) { - for (entity, pig_pos, mut velocity, grounded, metadata, ai) in pigs.iter_mut() { - let Some(physical) = registry.get(metadata.protocol_id(), false) else { + 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; }; - let mut ai = match ai { - Some(ai) => ai, - None => { - commands.entity(entity).insert(PigAI::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(next) = ai.path.get(ai.waypoint) - && next.pos.x == current_block.pos.x - && next.pos.z == current_block.pos.z + // 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 { - ai.waypoint += 1; + pathfinder.advance_waypoint(); } - // Recompute path if cooldown expired or path exhausted - if ai.repath_cooldown == 0 || ai.waypoint >= ai.path.len() { - let Some(target_pos) = players.iter().min_by(|a, b| { - pig_pos - .coords - .distance_squared(a.coords) - .total_cmp(&pig_pos.coords.distance_squared(b.coords)) - }) else { - stop(&mut velocity); - continue; - }; - - let goal = pos_to_block(target_pos); - ai.path = pathfinding::find_path( - &state.0.world, - current_block, - goal, - MAX_PATH_NODES, - physical, - ) - .map(|p| p.nodes) - .unwrap_or_default(); - ai.waypoint = 1; // node 0 is the current position - ai.repath_cooldown = REPATH_INTERVAL; - } - - let Some(next) = ai.path.get(ai.waypoint) else { + 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. - // We rely on OnGround (set/reset by the collision system each tick) rather than - // a fractional-Y heuristic, which would fire mid-air and cause infinite flying. 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 + // 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(); @@ -135,17 +110,14 @@ pub fn tick_pig_particles( for pos in pigs.iter() { for player_pos in players.iter() { let distance_sq = player_pos.as_vec3a().distance_squared(pos.1.as_vec3a()); - // Only spawn particles if a player is within 256 blocks if distance_sq > 16.0 * 256.0 { continue; } - // Spawn end rod particles from the pig to the player let steps = temper_utils::maths::step::step_between( pos.1.as_vec3a(), player_pos.coords.as_vec3a(), 0.5, ); - // Limit to 32 particles to avoid spamming (16 blocks with a 0.5 step) for step_pos in steps.iter().take(32) { let particle_message = SendParticle { particle_type: ParticleType::EndRod, @@ -164,20 +136,3 @@ fn stop(velocity: &mut Velocity) { velocity.vec.x = 0.0; velocity.vec.z = 0.0; } - -fn pos_to_block(pos: &Position) -> BlockPos { - // TODO(collision): This epsilon is a workaround for imprecise collision resolution. - // When an entity lands on a block, the MTV (Minimum Translation Vector) in - // `physics/collisions.rs` sometimes leaves the entity at y=64.9999... instead of - // exactly y=65.0. Without this epsilon, floor() would return 64 instead of 65, - // causing the pathfinding to think the entity is one block lower than it actually is. - // - // The proper fix is to ensure the collision system snaps entities to exact block - // surfaces when resolving vertical collisions (see `handle()` in collisions.rs). - 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/pathfinding/Cargo.toml b/src/game_systems/src/pathfinding/Cargo.toml index 05e9f221..bc2f0307 100644 --- a/src/game_systems/src/pathfinding/Cargo.toml +++ b/src/game_systems/src/pathfinding/Cargo.toml @@ -7,6 +7,9 @@ edition = "2024" 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" diff --git a/src/game_systems/src/pathfinding/src/astar.rs b/src/game_systems/src/pathfinding/src/astar.rs index fbff835c..c5dec21b 100644 --- a/src/game_systems/src/pathfinding/src/astar.rs +++ b/src/game_systems/src/pathfinding/src/astar.rs @@ -39,7 +39,7 @@ impl PartialOrd for Candidate { /// Entity dimensions for pathfinding, computed from PhysicalProperties. #[derive(Clone, Copy)] -struct EntityDimensions { +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. @@ -59,6 +59,104 @@ impl EntityDimensions { } } +/// 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. @@ -71,57 +169,15 @@ pub fn find_path( max_nodes: usize, physical: &PhysicalProperties, ) -> Option { - let dims = EntityDimensions::from_physical(physical); - if start == goal { return Some(Path { nodes: vec![goal] }); } - let mut open: BinaryHeap = BinaryHeap::new(); - let mut g_score: FxHashMap = FxHashMap::default(); - let mut came_from: FxHashMap = FxHashMap::default(); - - g_score.insert(start, 0); - open.push(Candidate { - estimated_cost: heuristic(start, goal), - real_cost: 0, - pos: start, - }); - - let mut iterations = 0; - while let Some(Candidate { real_cost, pos, .. }) = open.pop() { - if iterations >= max_nodes { - break; - } - iterations += 1; - - if pos == goal { - return Some(reconstruct_path(came_from, pos, start)); - } - - if real_cost > *g_score.get(&pos).unwrap_or(&i32::MAX) { - continue; - } - - for (neighbor, move_cost) in neighbors(world, pos, dims) { - let tentative_g = real_cost + move_cost; - - if g_score - .get(&neighbor) - .is_none_or(|&best| tentative_g < best) - { - g_score.insert(neighbor, tentative_g); - came_from.insert(neighbor, pos); - open.push(Candidate { - estimated_cost: tentative_g + heuristic(neighbor, goal), - real_cost: tentative_g, - pos: neighbor, - }); - } - } + 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, } - - None } /// Heuristic using octile distance (accounts for diagonal movement). @@ -141,10 +197,10 @@ fn heuristic(a: BlockPos, b: BlockPos) -> i32 { } fn reconstruct_path( - came_from: FxHashMap, + came_from: &FxHashMap, target: BlockPos, start: BlockPos, -) -> Path { +) -> Vec { let mut current = target; let mut nodes = vec![current]; while current != start { @@ -152,7 +208,7 @@ fn reconstruct_path( nodes.push(current); } nodes.reverse(); - Path { nodes } + nodes } /// Cardinal directions (cost multiplier: 10). diff --git a/src/game_systems/src/pathfinding/src/lib.rs b/src/game_systems/src/pathfinding/src/lib.rs index a7365588..39faf721 100644 --- a/src/game_systems/src/pathfinding/src/lib.rs +++ b/src/game_systems/src/pathfinding/src/lib.rs @@ -1,4 +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, + ) +}