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 });
}
}