haulage system WIP
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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,3 +1,4 @@
|
|||||||
|
pub mod cargo;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod livestock;
|
pub mod livestock;
|
||||||
pub mod sentient;
|
pub mod sentient;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::config::GameConfig;
|
use crate::config::GameConfig;
|
||||||
use crate::constants::TILE_SIZE;
|
use crate::constants::TILE_SIZE;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
|
use crate::entities::cargo::{CarryVisualState, HaulSlot};
|
||||||
use crate::entities::shared_components::Ambulatory;
|
use crate::entities::shared_components::Ambulatory;
|
||||||
|
use crate::entities::shared_systems::digging::Digger;
|
||||||
use crate::game::SpawnDelay;
|
use crate::game::SpawnDelay;
|
||||||
use crate::world::VisibleGameEntity;
|
use crate::world::VisibleGameEntity;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -14,12 +16,18 @@ pub struct Dorf {
|
|||||||
sprite: Sprite,
|
sprite: Sprite,
|
||||||
transform: Transform,
|
transform: Transform,
|
||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
|
digger: Digger,
|
||||||
|
haul_slot: HaulSlot,
|
||||||
|
carry_visual: CarryVisualState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dorf {
|
impl Dorf {
|
||||||
pub fn new(asset_server: &Res<AssetServer>, position: Vec3) -> Self {
|
pub fn new(asset_server: &Res<AssetServer>, position: Vec3) -> Self {
|
||||||
let walk_speed = 5.;
|
let walk_speed = 5.;
|
||||||
let run_speed = 6.;
|
let run_speed = 6.;
|
||||||
|
let normal_sprite = asset_server.load("dorf.png");
|
||||||
|
let carry_sprite = asset_server.load("dorf.png");
|
||||||
|
|
||||||
Dorf {
|
Dorf {
|
||||||
ambulatory: Ambulatory {
|
ambulatory: Ambulatory {
|
||||||
walk_speed,
|
walk_speed,
|
||||||
@@ -34,11 +42,14 @@ impl Dorf {
|
|||||||
step_history: [0i16; 4],
|
step_history: [0i16; 4],
|
||||||
},
|
},
|
||||||
sprite: Sprite {
|
sprite: Sprite {
|
||||||
image: asset_server.load("dorf.png"),
|
image: normal_sprite.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
||||||
visibility: Visibility::Hidden,
|
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,
|
ITILE_SIZE, PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS, PATHFINDER_MAX_NODES,
|
||||||
PATHFINDER_PROVISIONAL_NODE_LIMIT, PIXEL_RATIO, TILE_SIZE,
|
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::item::inventory::{update_encumbrance, InventoryChangedEvent};
|
||||||
use crate::entities::shared_systems::constants::{
|
use crate::entities::shared_systems::constants::{
|
||||||
CONVOY_DOT_THRESHOLD, ES_DIRECTION_THRESHOLD, HEAD_ON_DOT_THRESHOLD, OCCUPANCY_CROWD_THRESHOLD,
|
CONVOY_DOT_THRESHOLD, ES_DIRECTION_THRESHOLD, HEAD_ON_DOT_THRESHOLD, OCCUPANCY_CROWD_THRESHOLD,
|
||||||
@@ -230,6 +231,7 @@ impl Plugin for PathfindingPlugin {
|
|||||||
FixedUpdate,
|
FixedUpdate,
|
||||||
(
|
(
|
||||||
rebuild_tile_occupancy,
|
rebuild_tile_occupancy,
|
||||||
|
haul_encumbrance_system,
|
||||||
update_encumbrance,
|
update_encumbrance,
|
||||||
collect_pathfinding_dirty_chunks,
|
collect_pathfinding_dirty_chunks,
|
||||||
invalidate_paths_on_tile_change,
|
invalidate_paths_on_tile_change,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use bevy::prelude::*;
|
|||||||
use bevy_rand::prelude::*;
|
use bevy_rand::prelude::*;
|
||||||
|
|
||||||
use crate::entities::{
|
use crate::entities::{
|
||||||
|
cargo::carry_visual_system,
|
||||||
item::{initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer},
|
item::{initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer},
|
||||||
shared_systems::digging::dig_system,
|
shared_systems::digging::dig_system,
|
||||||
};
|
};
|
||||||
@@ -70,5 +71,6 @@ fn main() {
|
|||||||
item_tile_management_system.after(initialize_item_rotation_state),
|
item_tile_management_system.after(initialize_item_rotation_state),
|
||||||
)
|
)
|
||||||
.add_systems(Update, debug::entity_dump::dump_entity_positions)
|
.add_systems(Update, debug::entity_dump::dump_entity_positions)
|
||||||
|
.add_systems(Update, carry_visual_system)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ impl FixtureTileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access.
|
/// 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)]
|
#[derive(Resource, Default)]
|
||||||
pub struct TileMap {
|
pub struct TileMap {
|
||||||
/// O(1) standability lookups via bitsets (~2KB per chunk).
|
/// O(1) standability lookups via bitsets (~2KB per chunk).
|
||||||
@@ -198,6 +205,9 @@ pub struct TileMap {
|
|||||||
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
|
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
|
||||||
/// Entity references per tile position.
|
/// Entity references per tile position.
|
||||||
pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
|
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 {
|
impl TileMap {
|
||||||
@@ -419,9 +429,66 @@ impl TileMap {
|
|||||||
self.floor_tiles.remove(&pos);
|
self.floor_tiles.remove(&pos);
|
||||||
self.fixture_tiles.remove(&pos);
|
self.fixture_tiles.remove(&pos);
|
||||||
self.item_tiles.remove(&pos);
|
self.item_tiles.remove(&pos);
|
||||||
|
self.cargo_tiles.remove(&pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.chunks.remove(&chunk_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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user