From 2eaf0196a5e959cf8df175e164e8bfe88c1d690f Mon Sep 17 00:00:00 2001 From: popertots Date: Sun, 22 Mar 2026 12:56:52 +0000 Subject: [PATCH] demo 1 --- src/entities/cargo/log.rs | 64 ++++++++ src/entities/cargo/mod.rs | 2 + src/entities/sentient/dorf.rs | 1 + src/entities/tasks/components.rs | 80 +++++++++- src/entities/tasks/executor.rs | 256 +++++++++++++++++++++++++++++-- src/entities/tasks/idle.rs | 22 ++- src/world/generation/forestry.rs | 18 ++- 7 files changed, 420 insertions(+), 23 deletions(-) create mode 100644 src/entities/cargo/log.rs diff --git a/src/entities/cargo/log.rs b/src/entities/cargo/log.rs new file mode 100644 index 0000000..9fc9784 --- /dev/null +++ b/src/entities/cargo/log.rs @@ -0,0 +1,64 @@ +//! Log cargo spawning — called when a tree is felled. +//! +//! Each trunk tile that was removed becomes a Cargo log entity placed on +//! that tile. Logs are Haulable — dorfs pick them up into their HaulSlot. +//! +//! The log sprite reuses the trunk sprite (texture ID 500004) as a +//! placeholder until a dedicated ground-log sprite exists. + +use bevy::prelude::*; + +use crate::constants::PIXEL_RATIO; +use crate::entities::cargo::{Cargo, Haulable}; +use crate::entities::item::constants::ITEM_Z_FIGHTING_OFFSET; +use crate::entities::item::inventory::constants::SIZE_LARGE; +use crate::world::tiles::TileMap; + +/// Weight of a single log in kg. Enough to encumber a dorf carrying one. +pub const LOG_WEIGHT_KG: u32 = 15; + +/// Spawn a Cargo log entity at `tile_pos` and register it in cargo_tiles. +/// +/// If the tile is already occupied (another log landed here), finds the +/// nearest free tile via TileMap::find_nearest_free_cargo_tile. +/// +/// Returns the spawned Entity, or None if no free tile found within radius. +pub fn spawn_log_cargo( + commands: &mut Commands, + tilemap: &mut TileMap, + tile_pos: IVec3, + log_sprite: Handle, +) -> Option { + // Find a free tile — the exact trunk position may be occupied + let drop_pos = tilemap.find_nearest_free_cargo_tile(tile_pos, 4)?; + + let entity = commands + .spawn(( + Cargo { + tile_pos: drop_pos, + name: "log", + weight: LOG_WEIGHT_KG, + size: SIZE_LARGE, + ground_sprite: log_sprite.clone(), + }, + Haulable, + Sprite { + image: log_sprite.clone(), + ..Default::default() + }, + Transform::from_translation(Vec3::new( + drop_pos.x as f32, + drop_pos.y as f32, + drop_pos.z as f32 + ITEM_Z_FIGHTING_OFFSET, + )) + .with_scale(Vec3::splat(PIXEL_RATIO)), + Visibility::Visible, + )) + .id(); + + tilemap + .place_cargo(drop_pos, entity) + .expect("place_cargo failed after find_nearest_free_cargo_tile succeeded"); + + Some(entity) +} diff --git a/src/entities/cargo/mod.rs b/src/entities/cargo/mod.rs index beaffa5..1055e6f 100644 --- a/src/entities/cargo/mod.rs +++ b/src/entities/cargo/mod.rs @@ -1,8 +1,10 @@ pub mod components; +pub mod log; pub mod systems; pub use crate::world::tiles::tilemap::CargoPlaceError; pub use components::{Cargo, CarryVisualState, HaulSlot, Haulable}; +pub use log::spawn_log_cargo; pub use systems::{any_hauling, carry_visual_system, haul_encumbrance_system}; pub use crate::plugins::cargo::CargoPlugin; diff --git a/src/entities/sentient/dorf.rs b/src/entities/sentient/dorf.rs index 2e149ea..39efb53 100644 --- a/src/entities/sentient/dorf.rs +++ b/src/entities/sentient/dorf.rs @@ -61,6 +61,7 @@ impl Dorf { sigma_world: behaviour.idle.sigma_world, state: IdleState::Picking { retry_after_tick: 0, + retry_count: 0, }, }]), }, diff --git a/src/entities/tasks/components.rs b/src/entities/tasks/components.rs index 1f9112d..32f54cb 100644 --- a/src/entities/tasks/components.rs +++ b/src/entities/tasks/components.rs @@ -14,11 +14,18 @@ use bevy::prelude::*; use std::collections::VecDeque; +/// Maximum consecutive pick failures before panic. +/// Prevents infinite retry loops when tilemap has no standable tiles. +pub const IDLE_MAX_RETRIES: u32 = 50; + /// Sub-state of Task::Idle. #[derive(Clone, Debug, PartialEq)] pub enum IdleState { /// Pick a new target. Retry cooldown prevents CPU spike when no tiles found. - Picking { retry_after_tick: u32 }, + Picking { + retry_after_tick: u32, + retry_count: u32, + }, /// Walking toward target. Moving { target: IVec3 }, /// Standing still, loitering. @@ -32,6 +39,47 @@ pub enum IdleState { }, } +/// Step state for Task::ChopTree. +#[derive(Clone, Debug, PartialEq)] +pub enum ChopStep { + /// Walking to within range of the trunk. + MovingToTree, + /// Entity is in range and actively chopping. + Chopping { ticks_remaining: u32 }, + /// Chopping complete. + Done, +} + +/// Step state for Task::HaulCargo. +#[derive(Clone, Debug, PartialEq)] +pub enum HaulStep { + /// Walking to the cargo tile. + MovingToCargo, + /// At cargo — picking up this tick. + PickingUp, + /// Cargo in HaulSlot — walking to destination. + MovingToDest, + /// At destination — dropping this tick. + Dropping, + /// Haul complete. + Done, +} + +/// Step state for Task::DropHauled. +#[derive(Clone, Debug, PartialEq)] +pub enum DropStep { + /// Walking to drop position. + Moving, + /// At drop position — dropping this tick. + Dropping, + /// Drop complete. + Done, +} + +/// Default ticks to chop a tree (~2 seconds at 60 TPS). +/// Future: replaced by skill/tool calculation. +pub const CHOP_TICKS_DEFAULT: u32 = 120; + /// A single task an entity can execute. /// /// Tasks are self-contained: they carry all data needed for execution. @@ -64,16 +112,32 @@ pub enum Task { threshold_tiles: i32, }, - /// Placeholder for Stage 3 implementations. - /// Executor should match and log "unimplemented" for now. + /// Walk to a tree trunk and fell it. Produces Cargo log entities on completion. ChopTree { - target_fixture: IVec3, + /// World position of the lowest trunk fixture tile. + trunk_pos: IVec3, + /// Ticks required to chop. Future: derived from skill + tool. + chop_ticks: u32, + step: ChopStep, }, - HaulObject { - target_cargo_tile: IVec3, + + /// Pick up a specific Cargo entity and haul it to dest. + /// Renamed from HaulObject for consistency with the Cargo type. + HaulCargo { + /// The Cargo entity to pick up. + cargo_entity: Entity, + /// Tile position of the cargo in the world (for pathfinding). + cargo_pos: IVec3, + /// Destination tile position. + dest: IVec3, + step: HaulStep, }, + + /// Drop whatever is in HaulSlot at or near pos. DropHauled { - drop_tile: IVec3, + /// Preferred drop position. Actual drop may be nearby if occupied. + pos: IVec3, + step: DropStep, }, } @@ -92,7 +156,7 @@ impl Task { Task::Idle { .. } => "Idle", Task::GoTo { .. } => "GoTo", Task::ChopTree { .. } => "ChopTree", - Task::HaulObject { .. } => "HaulObject", + Task::HaulCargo { .. } => "HaulCargo", Task::DropHauled { .. } => "DropHauled", } } diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index 6eeefdb..b4f77b2 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -9,23 +9,31 @@ //! Uses Changed + Changed to minimise queries. use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType}; +use crate::entities::cargo::HaulSlot; use crate::entities::shared_components::Ambulatory; -use crate::entities::tasks::components::{IdleState, Task, TaskQueue, TaskState}; +use crate::entities::tasks::components::{ + ChopStep, DropStep, HaulStep, IdleState, Task, TaskQueue, TaskState, +}; use crate::entities::tasks::events::{TaskClaimed, TaskCompleted, TaskFailed}; use crate::entities::tasks::idle::execute_idle; use crate::world::chunks::ChunkMap; +use crate::world::generation::forestry::{fell_tree, TreePart}; +use crate::world::tiles::tile_changed::TileChangedEvent; +use crate::world::tiles::visibility::TileOcclusionEvent; use crate::world::tiles::TileMap; use bevy::prelude::*; use bevy_rand::prelude::*; -use rand::{RngExt, SeedableRng}; /// Main task executor. Runs in FixedUpdate. pub fn task_executor_system( mut commands: Commands, tilemap: Res, + mut tilemap_mut: ResMut, chunk_map: Res, + asset_server: Res, mut rng_q: Query<&mut WyRand, With>, mut tick: Local, + tree_parts: Query<(Entity, &TreePart)>, mut query: Query<( Entity, &mut TaskQueue, @@ -34,10 +42,13 @@ pub fn task_executor_system( &Transform, &mut Sprite, &EntityType, + Option<&mut HaulSlot>, )>, mut claimed_writer: MessageWriter, mut completed_writer: MessageWriter, mut failed_writer: MessageWriter, + mut tile_changed: MessageWriter, + mut occlusion: MessageWriter, ) { let Ok(mut rng) = rng_q.single_mut() else { return; @@ -46,8 +57,16 @@ pub fn task_executor_system( *tick = tick.wrapping_add(1); let current_tick = *tick; - for (entity, mut queue, mut state, mut ambulatory, transform, mut sprite, entity_type) in - query.iter_mut() + for ( + entity, + mut queue, + mut state, + mut ambulatory, + transform, + mut sprite, + entity_type, + mut haul_slot, + ) in query.iter_mut() { let origin = transform.translation.as_ivec3(); let behaviour = EntityBehaviourRegistry::global_get(entity_type.0); @@ -58,6 +77,7 @@ pub fn task_executor_system( sigma_world: behaviour.idle.sigma_world, state: IdleState::Picking { retry_after_tick: 0, + retry_count: 0, }, }); } @@ -110,13 +130,224 @@ pub fn task_executor_system( ambulatory.current_path = None; } } - Task::ChopTree { .. } | Task::HaulObject { .. } | Task::DropHauled { .. } => { - debug!( - "Task {} unimplemented for entity {:?}", - current_task.name(), - entity - ); - *state = TaskState::Failed; + Task::ChopTree { + trunk_pos, + chop_ticks, + step, + } => match step { + ChopStep::MovingToTree => { + if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "tree already gone", + }); + *state = TaskState::Failed; + continue; + } + if ambulatory.target.is_none() { + ambulatory.target = Some(Vec3::new( + trunk_pos.x as f32, + trunk_pos.y as f32, + trunk_pos.z as f32 + 1.0, + )); + ambulatory.current_path = None; + } + let dx = transform.translation.x - trunk_pos.x as f32; + let dy = transform.translation.y - trunk_pos.y as f32; + let dist_sq = dx * dx + dy * dy; + let chop_range_sq = (crate::constants::TILE_SIZE * 2.5) + * (crate::constants::TILE_SIZE * 2.5); + if dist_sq <= chop_range_sq { + ambulatory.target = None; + ambulatory.current_path = None; + *step = ChopStep::Chopping { + ticks_remaining: *chop_ticks, + }; + } + } + ChopStep::Chopping { ticks_remaining } => { + if *ticks_remaining == 0 { + let trunk_positions = fell_tree( + *trunk_pos, + &tree_parts, + &mut commands, + &mut tilemap_mut, + &mut tile_changed, + &mut occlusion, + ); + let log_sprite = asset_server.load("tree_trunk.png"); + for &pos in trunk_positions.iter() { + crate::entities::cargo::spawn_log_cargo( + &mut commands, + &mut tilemap_mut, + pos, + log_sprite.clone(), + ); + } + *step = ChopStep::Done; + } else { + *ticks_remaining -= 1; + } + } + ChopStep::Done => { + *state = TaskState::Completed; + } + }, + Task::HaulCargo { + cargo_entity, + cargo_pos, + dest, + step, + } => { + let Some(ref mut haul) = haul_slot else { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "entity has no HaulSlot", + }); + *state = TaskState::Failed; + continue; + }; + + match step { + HaulStep::MovingToCargo => { + if !tilemap_mut.cargo_tiles.contains_key(cargo_pos) { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "cargo no longer exists", + }); + *state = TaskState::Failed; + continue; + } + if ambulatory.target.is_none() { + ambulatory.target = Some(Vec3::new( + cargo_pos.x as f32, + cargo_pos.y as f32, + cargo_pos.z as f32 + 1.0, + )); + ambulatory.current_path = None; + } + let dx = transform.translation.x - cargo_pos.x as f32; + let dy = transform.translation.y - cargo_pos.y as f32; + let dist_sq = dx * dx + dy * dy; + let pickup_range_sq = (crate::constants::TILE_SIZE * 1.5) + * (crate::constants::TILE_SIZE * 1.5); + if dist_sq <= pickup_range_sq { + ambulatory.target = None; + *step = HaulStep::PickingUp; + } + } + HaulStep::PickingUp => { + if haul.is_occupied() { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "HaulSlot already occupied", + }); + *state = TaskState::Failed; + continue; + } + if let Some(_) = tilemap_mut.remove_cargo(cargo_pos) { + haul.pick_up(*cargo_entity); + *step = HaulStep::MovingToDest; + } else { + failed_writer.write(TaskFailed { + entity, + task: current_task.clone(), + reason: "cargo vanished before pickup", + }); + *state = TaskState::Failed; + } + } + HaulStep::MovingToDest => { + if ambulatory.target.is_none() { + let drop_pos = tilemap_mut + .find_nearest_free_cargo_tile(*dest, 8) + .unwrap_or(*dest); + ambulatory.target = Some(Vec3::new( + drop_pos.x as f32, + drop_pos.y as f32, + drop_pos.z as f32 + 1.0, + )); + ambulatory.current_path = None; + } + let target_pos = ambulatory.target.unwrap_or(Vec3::ZERO); + let dx = transform.translation.x - target_pos.x; + let dy = transform.translation.y - target_pos.y; + let dist_sq = dx * dx + dy * dy; + let arrive_sq = (crate::constants::TILE_SIZE * 1.5) + * (crate::constants::TILE_SIZE * 1.5); + if dist_sq <= arrive_sq { + ambulatory.target = None; + *step = HaulStep::Dropping; + } + } + HaulStep::Dropping => { + if let Some(cargo) = haul.release() { + let drop_pos = tilemap_mut + .find_nearest_free_cargo_tile( + transform.translation.as_ivec3(), + 8, + ) + .unwrap_or(transform.translation.as_ivec3()); + let _ = tilemap_mut.place_cargo(drop_pos, cargo); + // TODO: update Cargo.tile_pos via CargoDroppedEvent + } + *step = HaulStep::Done; + } + HaulStep::Done => { + *state = TaskState::Completed; + } + } + } + Task::DropHauled { pos, step } => { + let Some(ref mut haul) = haul_slot else { + *state = TaskState::Completed; + continue; + }; + + match step { + DropStep::Moving => { + if ambulatory.target.is_none() { + let drop_pos = tilemap_mut + .find_nearest_free_cargo_tile(*pos, 8) + .unwrap_or(*pos); + ambulatory.target = Some(Vec3::new( + drop_pos.x as f32, + drop_pos.y as f32, + drop_pos.z as f32 + 1.0, + )); + ambulatory.current_path = None; + } + let target_pos = ambulatory.target.unwrap_or(Vec3::ZERO); + let dx = transform.translation.x - target_pos.x; + let dy = transform.translation.y - target_pos.y; + let dist_sq = dx * dx + dy * dy; + let arrive_sq = (crate::constants::TILE_SIZE * 1.5) + * (crate::constants::TILE_SIZE * 1.5); + if dist_sq <= arrive_sq { + ambulatory.target = None; + *step = DropStep::Dropping; + } + } + DropStep::Dropping => { + if let Some(cargo) = haul.release() { + let drop_pos = tilemap_mut + .find_nearest_free_cargo_tile( + transform.translation.as_ivec3(), + 8, + ) + .unwrap_or(transform.translation.as_ivec3()); + let _ = tilemap_mut.place_cargo(drop_pos, cargo); + } + *step = DropStep::Done; + } + DropStep::Done => { + *state = TaskState::Completed; + } + } } } } @@ -134,7 +365,7 @@ pub fn task_executor_system( failed_writer.write(TaskFailed { entity, task: completed_task.clone(), - reason: "unimplemented", + reason: completed_task.name(), }); } @@ -144,6 +375,7 @@ pub fn task_executor_system( sigma_world: behaviour.idle.sigma_world, state: IdleState::Picking { retry_after_tick: 0, + retry_count: 0, }, }); } diff --git a/src/entities/tasks/idle.rs b/src/entities/tasks/idle.rs index ae8d0a0..daf1147 100644 --- a/src/entities/tasks/idle.rs +++ b/src/entities/tasks/idle.rs @@ -1,7 +1,7 @@ use crate::constants::{ITILE_SIZE, TILE_SIZE}; use crate::entities::behaviour::IdleBehaviour; use crate::entities::shared_components::Ambulatory; -use crate::entities::tasks::components::{IdleState, Task}; +use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES}; use crate::world::chunks::ChunkMap; use crate::world::chunks::CHUNK_SIZE; use crate::world::tiles::TileMap; @@ -30,7 +30,10 @@ pub(super) fn execute_idle( }; match state { - IdleState::Picking { retry_after_tick } => { + IdleState::Picking { + retry_after_tick, + retry_count, + } => { if current_tick < *retry_after_tick { return; } @@ -47,6 +50,19 @@ pub(super) fn execute_idle( *state = IdleState::Moving { target }; } None => { + *retry_count += 1; + if *retry_count >= IDLE_MAX_RETRIES { + panic!( + "Entity stuck: pick_gaussian_target returned None {} times \ + consecutively. origin={:?} sigma_world={:.1} \ + loaded_chunks={} \ + — no standable tile found. Check tilemap state.", + retry_count, + origin, + sigma_world, + chunk_map.loaded_chunks.len(), + ); + } *retry_after_tick = current_tick.saturating_add(30); } } @@ -90,6 +106,7 @@ pub(super) fn execute_idle( } else { *state = IdleState::Picking { retry_after_tick: 0, + retry_count: 0, }; } } @@ -114,6 +131,7 @@ pub(super) fn execute_idle( sprite.flip_x = false; *state = IdleState::Picking { retry_after_tick: 0, + retry_count: 0, }; } else { *ticks_remaining -= 1; diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs index a8ba292..fad4d07 100644 --- a/src/world/generation/forestry.rs +++ b/src/world/generation/forestry.rs @@ -292,7 +292,7 @@ pub fn fell_tree( tilemap: &mut TileMap, tile_changed: &mut MessageWriter, occlusion: &mut MessageWriter, -) { +) -> SmallVec<[IVec3; 8]> { let target_chunk = world_to_chunk(trunk_pos); let trunk_x = trunk_pos.x; let trunk_y = trunk_pos.y; @@ -315,7 +315,22 @@ pub fn fell_tree( // calculate_visibility traces up arbitrarily deep, so refresh the full column. // z_total is constant per call — hoist above the loop. let z_total = crate::world::chunks::Z_BELOW as i32 + crate::world::chunks::Z_ABOVE as i32 + 1; + + // Collect trunk positions before despawning — we need is_trunk from TreePart + // while the entity still exists. This is why we DON'T immediately despawn + // in the to_remove mapping step. + let mut trunk_positions: SmallVec<[IVec3; 8]> = SmallVec::new(); + for (entity, tile_pos) in to_remove.iter() { + // Check if this was a trunk (is_trunk stored on TreePart). + // We need to look up is_trunk before the entity is despawned. + // to_remove was collected from tree_parts query, so find it again: + if let Ok((_, part)) = tree_parts.get(*entity) { + if part.is_trunk { + trunk_positions.push(*tile_pos); + } + } + tilemap.remove_fixture(tile_pos); tile_changed.write(TileChangedEvent { pos: *tile_pos }); commands.entity(*entity).despawn(); @@ -333,4 +348,5 @@ pub fn fell_tree( for pos in dirty_columns { occlusion.write(TileOcclusionEvent { tile_position: pos }); } + trunk_positions }