haulage system WIP

This commit is contained in:
2026-03-21 23:39:58 +00:00
parent dcdfb375f0
commit c776b91a44
8 changed files with 277 additions and 1 deletions
+133
View File
@@ -0,0 +1,133 @@
//! Cargo — loose world objects that exist on tiles and can be hauled by entities.
//!
//! # Cargo vs Fixture vs Item
//!
//! | Type | Storage | Standability | Haulable | Examples |
//! |---------|------------------------|--------------|----------|-----------------------|
//! | Fixture | fixture_tiles HashMap | Affects | No | Tree trunk, wall, door |
//! | Cargo | cargo_tiles HashMap | Unaffected | Yes* | Felled log, mined rock |
//! | Item | PersonalInventory | Unaffected | No | Coin, food, tool |
//!
//! *All Cargo is Haulable by default. The Haulable component marks this explicitly
//! and will allow future per-cargo override (e.g. a pinned ritual object).
//!
//! # One per tile
//! TileMap enforces one Cargo entity per tile position via cargo_tiles: FxHashMap<IVec3, Entity>.
//! Placing Cargo on an occupied tile returns Err(CargoPlaceError::TileOccupied).
//! The caller must find a free tile first — use TileMap::find_nearest_free_cargo_tile.
//!
//! # HaulSlot
//! Entities that can haul Cargo get a HaulSlot component. Occupying the slot sets
//! Ambulatory.walk_speed to base_walk_speed * ENCUMBERED_SPEED_MULTIPLIER unconditionally,
//! regardless of weight. Emptying it restores walk_speed via InventoryChangedEvent.
//!
//! # CarryVisualState
//! While hauling, the carrier cycles between its normal sprite and the cargo sprite
//! using a fast timer. This mimics ItemRotationState but is carrier-side, not tile-side.
use bevy::prelude::*;
/// Marks an entity as a loose world object sitting on a tile.
/// Cargo entities are non-solid — they do not affect is_standable.
/// Registered in TileMap::cargo_tiles for O(1) tile lookup.
///
/// Cargo is created when fixtures are destroyed (felling a tree creates log Cargo)
/// or when items are dropped as world objects. It ceases to exist when:
/// - Picked up into a HaulSlot (entity still exists, removed from cargo_tiles)
/// - Placed as a new fixture (entity despawned, fixture inserted)
/// - Consumed by a crafting task (entity despawned)
#[derive(Component, Debug)]
pub struct Cargo {
/// World position this Cargo occupies. Kept in sync with Transform.
/// Redundant with Transform but avoids a query when looking up tile position.
pub tile_pos: IVec3,
/// Display name for UI and log messages.
pub name: &'static str,
/// Weight in kg, used when determining if a carrier can haul this.
pub weight: u32,
/// Size (use SIZE_* constants from inventory). Determines what can hold this.
pub size: u8,
/// Sprite handle for rendering when on the ground.
pub ground_sprite: Handle<Image>,
}
/// Marks a Cargo entity as haulable by entities with a HaulSlot.
/// All Cargo is Haulable by default. Remove this component to pin an object
/// in place (future: ritual objects, planted crops treated as Cargo).
#[derive(Component, Debug, Default)]
pub struct Haulable;
/// The haul slot of a carrying entity. At most one Cargo can be hauled at a time.
/// Occupying this slot sets the entity encumbered unconditionally — two hands full
/// means movement penalty regardless of cargo weight.
///
/// Entities that can haul must have this component. Add it to Dorf bundles.
/// Do NOT add to Rabbit or Pig — they use PersonalInventory's single slot instead.
#[derive(Component, Debug, Default)]
pub struct HaulSlot {
/// The Cargo entity currently being hauled. None = empty, hands free.
pub contents: Option<Entity>,
}
impl HaulSlot {
#[inline]
pub fn is_empty(&self) -> bool {
self.contents.is_none()
}
#[inline]
pub fn is_occupied(&self) -> bool {
self.contents.is_some()
}
/// Pick up a Cargo entity into this slot. Returns false if slot is occupied.
#[inline]
pub fn pick_up(&mut self, cargo: Entity) -> bool {
if self.contents.is_some() {
return false;
}
self.contents = Some(cargo);
true
}
/// Release the hauled Cargo. Returns the entity that was held, or None.
#[inline]
pub fn release(&mut self) -> Option<Entity> {
self.contents.take()
}
}
/// Controls the carry visual on the hauling entity.
/// While HaulSlot is occupied, cycles between the entity's normal sprite
/// and the cargo sprite on a fast timer, giving a visual "carrying" impression.
///
/// Add to entities that have HaulSlot. The system reads HaulSlot to decide
/// whether to cycle or show only the normal sprite.
#[derive(Component, Debug)]
pub struct CarryVisualState {
/// Timer controlling the sprite cycle rate while hauling.
pub timer: Timer,
/// Whether the cargo sprite is currently showing (vs entity normal sprite).
pub showing_cargo: bool,
/// Sprite handle for the "carrying" visual (e.g. log over shoulder).
/// Swapped in/out of the entity's Sprite component while hauling.
pub carry_sprite: Handle<Image>,
/// The entity's original sprite, restored when not hauling.
pub normal_sprite: Handle<Image>,
}
impl CarryVisualState {
/// Creates a new CarryVisualState.
///
/// `normal` — the entity's default sprite handle.
/// `carry` — the sprite handle shown while carrying (may be same as normal for testing).
pub fn new(normal: Handle<Image>, carry: Handle<Image>) -> Self {
Self {
// Cycle every 0.4s — fast enough to be noticeable, slow enough to read.
timer: Timer::from_seconds(0.4, TimerMode::Repeating),
showing_cargo: false,
carry_sprite: carry,
normal_sprite: normal,
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod components;
pub mod systems;
pub use components::{CarryVisualState, HaulSlot, Haulable};
pub use systems::{carry_visual_system, haul_encumbrance_system};
+55
View File
@@ -0,0 +1,55 @@
//! Cargo systems: carry visual cycling and haul encumbrance.
use bevy::prelude::*;
use crate::entities::cargo::components::{CarryVisualState, HaulSlot};
use crate::entities::item::inventory::constants::ENCUMBERED_SPEED_MULTIPLIER;
use crate::entities::item::inventory::InventoryChangedEvent;
use crate::entities::shared_components::Ambulatory;
/// Cycle the carry visual sprite while the entity's HaulSlot is occupied.
/// When empty, ensure the normal sprite is restored.
///
/// Runs in Update (visual only, no game state change).
pub fn carry_visual_system(
time: Res<Time>,
mut query: Query<(&HaulSlot, &mut CarryVisualState, &mut Sprite)>,
) {
for (haul_slot, mut visual, mut sprite) in query.iter_mut() {
if haul_slot.is_empty() {
if visual.showing_cargo {
sprite.image = visual.normal_sprite.clone();
visual.showing_cargo = false;
}
continue;
}
visual.timer.tick(time.delta());
if visual.timer.just_finished() {
visual.showing_cargo = !visual.showing_cargo;
sprite.image = if visual.showing_cargo {
visual.carry_sprite.clone()
} else {
visual.normal_sprite.clone()
};
}
}
}
/// Apply encumbrance when HaulSlot is occupied, remove when empty.
/// Fires InventoryChangedEvent so update_encumbrance recalculates walk_speed.
///
/// Uses Changed<HaulSlot> so this only runs for entities whose slot changed this frame.
pub fn haul_encumbrance_system(
mut query: Query<(Entity, &HaulSlot, &mut Ambulatory), Changed<HaulSlot>>,
mut events: MessageWriter<InventoryChangedEvent>,
) {
for (entity, haul_slot, mut ambulatory) in query.iter_mut() {
if haul_slot.is_occupied() {
ambulatory.walk_speed = ambulatory.base_walk_speed * ENCUMBERED_SPEED_MULTIPLIER;
} else {
ambulatory.walk_speed = ambulatory.base_walk_speed;
}
events.write(InventoryChangedEvent { carrier: entity });
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod cargo;
pub mod item;
pub mod livestock;
pub mod sentient;
+12 -1
View File
@@ -1,7 +1,9 @@
use crate::config::GameConfig;
use crate::constants::TILE_SIZE;
use crate::constants::*;
use crate::entities::cargo::{CarryVisualState, HaulSlot};
use crate::entities::shared_components::Ambulatory;
use crate::entities::shared_systems::digging::Digger;
use crate::game::SpawnDelay;
use crate::world::VisibleGameEntity;
use bevy::prelude::*;
@@ -14,12 +16,18 @@ pub struct Dorf {
sprite: Sprite,
transform: Transform,
visibility: Visibility,
digger: Digger,
haul_slot: HaulSlot,
carry_visual: CarryVisualState,
}
impl Dorf {
pub fn new(asset_server: &Res<AssetServer>, position: Vec3) -> Self {
let walk_speed = 5.;
let run_speed = 6.;
let normal_sprite = asset_server.load("dorf.png");
let carry_sprite = asset_server.load("dorf.png");
Dorf {
ambulatory: Ambulatory {
walk_speed,
@@ -34,11 +42,14 @@ impl Dorf {
step_history: [0i16; 4],
},
sprite: Sprite {
image: asset_server.load("dorf.png"),
image: normal_sprite.clone(),
..Default::default()
},
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
visibility: Visibility::Hidden,
digger: Digger::new(3.0),
haul_slot: HaulSlot::default(),
carry_visual: CarryVisualState::new(normal_sprite, carry_sprite),
}
}
}
@@ -63,6 +63,7 @@ use crate::constants::{
ITILE_SIZE, PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS, PATHFINDER_MAX_NODES,
PATHFINDER_PROVISIONAL_NODE_LIMIT, PIXEL_RATIO, TILE_SIZE,
};
use crate::entities::cargo::haul_encumbrance_system;
use crate::entities::item::inventory::{update_encumbrance, InventoryChangedEvent};
use crate::entities::shared_systems::constants::{
CONVOY_DOT_THRESHOLD, ES_DIRECTION_THRESHOLD, HEAD_ON_DOT_THRESHOLD, OCCUPANCY_CROWD_THRESHOLD,
@@ -230,6 +231,7 @@ impl Plugin for PathfindingPlugin {
FixedUpdate,
(
rebuild_tile_occupancy,
haul_encumbrance_system,
update_encumbrance,
collect_pathfinding_dirty_chunks,
invalidate_paths_on_tile_change,
+2
View File
@@ -2,6 +2,7 @@ use bevy::prelude::*;
use bevy_rand::prelude::*;
use crate::entities::{
cargo::carry_visual_system,
item::{initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer},
shared_systems::digging::dig_system,
};
@@ -70,5 +71,6 @@ fn main() {
item_tile_management_system.after(initialize_item_rotation_state),
)
.add_systems(Update, debug::entity_dump::dump_entity_positions)
.add_systems(Update, carry_visual_system)
.run();
}
+67
View File
@@ -188,6 +188,13 @@ impl FixtureTileData {
}
/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access.
/// Error type for cargo placement failures.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CargoPlaceError {
/// A Cargo entity already occupies this tile.
TileOccupied,
}
#[derive(Resource, Default)]
pub struct TileMap {
/// O(1) standability lookups via bitsets (~2KB per chunk).
@@ -198,6 +205,9 @@ pub struct TileMap {
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
/// Entity references per tile position.
pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
/// One Cargo entity per tile position. Enforces single-occupancy.
/// Cargo does not affect standability — purely for lookup and placement validation.
pub cargo_tiles: FxHashMap<IVec3, Entity>,
}
impl TileMap {
@@ -419,9 +429,66 @@ impl TileMap {
self.floor_tiles.remove(&pos);
self.fixture_tiles.remove(&pos);
self.item_tiles.remove(&pos);
self.cargo_tiles.remove(&pos);
}
}
}
self.chunks.remove(&chunk_pos);
}
/// Place a Cargo entity at a tile position.
/// Returns Err if the tile is already occupied by another Cargo.
/// Does NOT check standability — Cargo can sit on any tile including mid-air.
pub fn place_cargo(&mut self, pos: IVec3, entity: Entity) -> Result<(), CargoPlaceError> {
if self.cargo_tiles.contains_key(&pos) {
return Err(CargoPlaceError::TileOccupied);
}
self.cargo_tiles.insert(pos, entity);
Ok(())
}
/// Remove a Cargo entity from a tile. Returns the entity if one existed.
#[inline]
pub fn remove_cargo(&mut self, pos: &IVec3) -> Option<Entity> {
self.cargo_tiles.remove(pos)
}
/// Check if a tile has Cargo on it.
#[inline]
pub fn has_cargo(&self, pos: &IVec3) -> bool {
self.cargo_tiles.contains_key(pos)
}
/// Find the nearest free tile to `origin` that:
/// - Has no Cargo on it
/// - Is standable (entity can reach it)
/// - Is within `max_radius` tiles (Chebyshev distance)
///
/// Uses a spiral outward search — O(radius²) worst case but returns immediately
/// on first free tile found. Searches only at origin.z (same z-level).
/// Returns None if no free tile found within radius.
pub fn find_nearest_free_cargo_tile(&self, origin: IVec3, max_radius: i32) -> Option<IVec3> {
if !self.cargo_tiles.contains_key(&origin) && self.is_standable(origin) {
return Some(origin);
}
for r in 1..=max_radius {
for dx in -r..=r {
for dy in -r..=r {
if dx.abs() != r && dy.abs() != r {
continue;
}
let candidate = IVec3::new(
origin.x + dx * ITILE_SIZE,
origin.y + dy * ITILE_SIZE,
origin.z,
);
if !self.cargo_tiles.contains_key(&candidate) && self.is_standable(candidate) {
return Some(candidate);
}
}
}
}
None
}
}