Working gather-pickup-store work system

master
hheik 2025-04-08 23:41:13 +03:00
parent bf50c19ba0
commit d9319bfd5c
13 changed files with 471 additions and 102 deletions

1
Cargo.lock generated
View File

@ -2424,6 +2424,7 @@ dependencies = [
"bevy_mod_debugdump",
"bevy_prototype_lyon",
"bevy_rapier2d",
"fastrand",
"num-traits",
]

View File

@ -11,6 +11,7 @@ bevy-inspector-egui = "0.28.1"
bevy_mod_debugdump = "0.12.1"
bevy_prototype_lyon = "0.13.0"
bevy_rapier2d = "0.28.0"
fastrand = "2.3.0"
num-traits = "0.2.19"
# Enable a small amount of optimization in debug mode

View File

@ -19,7 +19,7 @@ impl Plugin for DebugPlugin {
app.add_plugins((
bevy_inspector_egui::quick::WorldInspectorPlugin::new().run_if(is_debug_enabled),
bevy_rapier2d::prelude::RapierDebugRenderPlugin::default(),
// bevy_rapier2d::prelude::RapierDebugRenderPlugin::default(),
));
app.register_type::<DebugCanvas>()

View File

@ -26,14 +26,22 @@ pub fn init(app: &mut App) {
.register_type::<creature::Mover>()
.register_type::<prefab::Glorb>()
.register_type::<prefab::Tree>()
.add_event::<work::OnTaskFinish>()
.add_event::<item::SpawnItem>()
.add_systems(Startup, systems::setup_2d)
.add_systems(
Update,
(
(
systems::demo_2d,
systems::work_select,
systems::worker_movement,
systems::spawn_items,
),
(systems::do_work,),
(systems::apply_task_result,),
)
.chain(),
)
.add_systems(PostUpdate, item::update_item_sprite)
.add_systems(Update, (systems::draw_job_targets).in_set(debug::DebugSet));

View File

@ -1,5 +1,7 @@
use bevy::prelude::*;
use crate::util::Kilograms;
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Component, Reflect)]
#[reflect(Component)]
#[require(Sprite)]
@ -7,44 +9,78 @@ pub enum Item {
Wood,
}
impl Item {
// TODO: implement
fn stack_max_size(&self) -> Option<u32> {
match self {
_ => Some(100),
}
}
// TODO: implement
fn weight(&self) -> Kilograms {
match self {
Self::Wood => 1.0, // Paper birch, height 30cm, diameter 18cm ~= 6 kg, chopped in 6 pieces ~= 1kg
}
}
}
#[derive(Copy, Clone, Debug, Reflect)]
pub struct ItemStack {
pub item: Item,
pub count: u32,
}
impl Default for ItemStack {
fn default() -> Self {
Self {
item: Item::Wood,
count: 1,
}
impl From<Item> for ItemStack {
fn from(item: Item) -> Self {
Self { item, count: 1 }
}
}
#[derive(Clone, Debug, Default, Component, Reflect)]
#[reflect(Component)]
pub struct ItemSource(pub ItemStack);
pub struct ItemSource {
/// What items are spawned when gathered
pub drops: Vec<ItemStack>,
/// How many times this entity can be gathered before it despawns
pub gather_limit: Option<u32>,
}
#[derive(Clone, Debug, Default, Component, Reflect)]
#[reflect(Component)]
pub struct Inventory {
pub capacity: Option<usize>,
// TODO: Create a "Reserved" item stack type, that indicates that someone has reserved a slot
// for some new item type in the inventory
pub items: Vec<ItemStack>,
}
pub enum InsertResult {
/// Combine to an existing stack. Contains (`index`, `new_count`)
Combine(usize, u32),
/// No existing stackable stacks, push a new one.
Push(ItemStack),
#[derive(Clone, Debug)]
pub enum InventoryError {
IndexNotFound {
index: usize,
inventory: Inventory,
info: Option<String>,
},
}
/// Result for trying to insert an item to inventory. Should be used and discarded immediately, as
/// it might not be valid after mutating inventory. In future, It could contain a hash value for
/// the valid inventory state that it aplies to!
#[derive(Debug)]
pub enum InsertResult {
/// Combine to an existing stack. Contains (`index`, `new_count`)
Combine { index: usize, new_count: u32 },
/// No existing stackable stacks, push a new one.
Push { stack: ItemStack },
}
#[derive(Debug)]
pub enum RemoveResult {
/// The stack will get depleted, so it can be removed. Contains the `index` of empty stack.
Empty(usize),
Empty { index: usize },
/// The stack will get only partially depleted. Contains (`index`, `new_count`)
Partial(usize, u32),
Partial { index: usize, new_count: u32 },
}
impl Inventory {
@ -55,26 +91,30 @@ impl Inventory {
}
}
pub fn is_empty(&self) -> bool {
self.items.iter().all(|stack| stack.count == 0)
pub fn weight(&self) -> Kilograms {
self.items
.iter()
.map(|stack| stack.item.weight() * stack.count as f32)
.sum()
}
pub fn try_insert(&self, item_stack: &ItemStack) -> Option<InsertResult> {
pub fn try_insert(&self, item_stack: ItemStack) -> Option<InsertResult> {
match self
.items
.iter()
.enumerate()
.find(|(_, stack)| stack.item == item_stack.item)
{
Some((index, stack)) => {
Some(InsertResult::Combine(index, stack.count + item_stack.count))
}
Some((index, stack)) => Some(InsertResult::Combine {
index,
new_count: stack.count + item_stack.count,
}),
None => {
if self
.capacity
.map_or(true, |capacity| self.items.len() < capacity)
{
Some(InsertResult::Push(*item_stack))
Some(InsertResult::Push { stack: item_stack })
} else {
None
}
@ -82,7 +122,7 @@ impl Inventory {
}
}
pub fn try_remove(&self, item_stack: &ItemStack) -> Option<RemoveResult> {
pub fn try_remove(&self, item_stack: ItemStack) -> Option<RemoveResult> {
match self
.items
.iter()
@ -92,9 +132,9 @@ impl Inventory {
Some((index, stack)) => match stack.count.checked_sub(item_stack.count) {
Some(new_count) => {
if new_count == 0 {
Some(RemoveResult::Empty(index))
Some(RemoveResult::Empty { index })
} else {
Some(RemoveResult::Partial(index, new_count))
Some(RemoveResult::Partial { index, new_count })
}
}
// Not enough items
@ -108,7 +148,7 @@ impl Inventory {
pub fn try_transfer(
&self,
to: &Self,
item_stack: &ItemStack,
item_stack: ItemStack,
) -> Option<(RemoveResult, InsertResult)> {
match (self.try_remove(item_stack), to.try_insert(item_stack)) {
(Some(remove_result), Some(insert_result)) => Some((remove_result, insert_result)),
@ -116,6 +156,58 @@ impl Inventory {
}
}
pub fn apply_remove(&mut self, remove_result: RemoveResult) -> Result<(), InventoryError> {
match remove_result {
RemoveResult::Empty { index } => {
if index >= self.items.len() {
Err(InventoryError::IndexNotFound {
index,
inventory: self.clone(),
info: Some("RemoveResult::Empty".to_string()),
})
} else {
self.items.remove(index);
self.sanitize();
Ok(())
}
}
RemoveResult::Partial { index, new_count } => match self.items.get_mut(index) {
Some(stack) => {
stack.count = new_count;
self.sanitize();
Ok(())
}
None => Err(InventoryError::IndexNotFound {
index,
inventory: self.clone(),
info: Some("RemoveResult::Partial".to_string()),
}),
},
}
}
pub fn apply_insert(&mut self, insert_result: InsertResult) -> Result<(), InventoryError> {
match insert_result {
InsertResult::Push { stack } => {
self.items.push(stack);
self.sanitize();
Ok(())
}
InsertResult::Combine { index, new_count } => match self.items.get_mut(index) {
Some(stack) => {
stack.count = new_count;
self.sanitize();
Ok(())
}
None => Err(InventoryError::IndexNotFound {
index,
inventory: self.clone(),
info: Some("InsertResult::Combine".to_string()),
}),
},
}
}
pub fn sanitize(&mut self) {
if let Some(capacity) = self.capacity {
self.items.truncate(capacity);
@ -139,3 +231,10 @@ pub fn update_item_sprite(
});
}
}
#[derive(Event, Clone, Debug)]
pub struct SpawnItem {
pub item: Item,
pub to: Vec2,
pub velocity: Vec2,
}

View File

@ -32,7 +32,7 @@ pub struct Glorb;
..default()
}),
SpriteLoader(|| SpriteLoader::from("sprites/tree.png")),
ItemSource(|| ItemSource(ItemStack { item: Item::Wood, count: 1 })),
ItemSource(|| ItemSource { drops: vec![ItemStack { item: Item::Wood, count: 1 }], gather_limit: None }),
WorkDuration(|| WorkDuration(5.0))
)]
pub struct Tree;

View File

@ -1,7 +1,9 @@
mod creature;
mod demo;
mod item;
mod worker;
pub use creature::*;
pub use demo::*;
pub use item::*;
pub use worker::*;

View File

@ -1,21 +1,35 @@
use bevy::prelude::*;
use crate::game::{creature::Mover, work::Worker};
use crate::{
game::{creature::Mover, item::Inventory, work::Worker},
util::{inverse_lerp, lerp},
};
pub fn worker_movement(
mut worker_query: Query<(Entity, &Mover, &Worker, &mut Transform)>,
mut worker_query: Query<(Entity, &Mover, &Worker, &mut Transform, Option<&Inventory>)>,
global_query: Query<&GlobalTransform>,
time: Res<Time>,
) {
for (entity, mover, worker, mut transform) in worker_query.iter_mut() {
let Some(task) = worker.0 else { continue };
for (entity, mover, worker, mut transform, inventory) in worker_query.iter_mut() {
let Some(task) = worker.task.as_ref() else {
continue;
};
let (Ok(from), Ok(to)) = (global_query.get(entity), global_query.get(task.target)) else {
continue;
};
let diff = (to.translation() - from.translation()).xy();
let dist = diff.length().max(0.);
let dir = diff.normalize_or_zero();
let movement = dir.extend(0.) * mover.speed * time.delta_secs();
let movement = dir.extend(0.)
* mover.speed
* inventory.map_or(1.0, |inventory| {
lerp(
1.0,
0.2,
inverse_lerp(0.0, 10.0, inventory.weight()).min(1.0),
)
})
* time.delta_secs();
transform.translation += movement.clamp_length_max(dist);
}
}

27
src/game/systems/item.rs Normal file
View File

@ -0,0 +1,27 @@
use bevy::prelude::*;
use bevy_rapier2d::prelude::{Damping, RigidBody, Velocity};
use crate::game::{item::SpawnItem, work::WorkDuration};
pub fn spawn_items(mut spawn_item_events: EventReader<SpawnItem>, mut commands: Commands) {
for event in spawn_item_events.read() {
commands.spawn((
Name::new(format!("{:?}", event.item)),
event.item,
WorkDuration(0.1),
Transform {
translation: event.to.extend(1.0),
..default()
},
RigidBody::Dynamic,
Damping {
linear_damping: 4.0,
angular_damping: 1.0,
},
Velocity {
linvel: event.velocity,
..default()
},
));
}
}

View File

@ -1,44 +1,104 @@
use bevy::{color::palettes::css, prelude::*};
use bevy::{color::palettes::css, prelude::*, utils::HashMap};
use crate::{
debug::{ColoredShape, DebugDraw, Shape},
game::{
item::{Inventory, ItemSource, Stockpile},
work::{Task, WorkType, Worker},
item::{Inventory, Item, ItemSource, ItemStack, SpawnItem, Stockpile},
work::{OnTaskFinish, Task, WorkDuration, WorkType, Worker},
},
util::random_vec2,
};
pub fn work_select(
mut worker_query: Query<(&mut Worker, &Inventory, &GlobalTransform)>,
workers: Query<(Entity, &Inventory, &GlobalTransform), With<Worker>>,
mut worker_query: Query<&mut Worker>,
pickup_query: Query<(Entity, &Item, &GlobalTransform)>,
store_query: Query<(Entity, &Inventory, &GlobalTransform), With<Stockpile>>,
gather_query: Query<(Entity, &ItemSource, &GlobalTransform)>,
gather_query: Query<(Entity, &GlobalTransform), With<ItemSource>>,
) {
for (mut worker, worker_inventory, worker_transform) in worker_query.iter_mut() {
// What tasks are already targeting given entity
let mut target_tasks_map: HashMap<Entity, Vec<Task>> = HashMap::new();
fn add_to_task_map(map: &mut HashMap<Entity, Vec<Task>>, task: Task) {
match map.get_mut(&task.target) {
Some(tasks) => {
tasks.push(task);
}
None => {
map.insert(task.target, vec![task]);
}
};
}
for task in worker_query.iter().filter_map(|worker| worker.task.clone()) {
add_to_task_map(&mut target_tasks_map, task);
}
for (worker_entity, worker_inventory, worker_transform) in workers.iter() {
let mut worker = worker_query.get_mut(worker_entity).unwrap();
// Skip if worker already has a job
if worker.0.is_some() {
if worker.task.is_some() {
continue;
}
let mut task_by_distance: Option<(Task, f32)> = None;
for (task_entity, item, item_transform) in pickup_query.iter() {
// Skip items that are already targeted by any task
if target_tasks_map.contains_key(&task_entity) {
continue;
}
// Check if the item fits into workers inventory
if worker_inventory
.try_insert(ItemStack::from(*item))
.is_some()
{
let dist_squared = worker_transform
.translation()
.distance_squared(item_transform.translation());
if task_by_distance
.as_ref()
.is_none_or(|(_, closest_task_dist)| dist_squared < *closest_task_dist)
{
task_by_distance = Some((
Task {
target: task_entity,
work_type: WorkType::Pickup,
progress: 0.0,
},
dist_squared,
))
}
}
}
if let Some((task, _)) = task_by_distance {
worker.task = Some(task.clone());
add_to_task_map(&mut target_tasks_map, task);
continue;
}
for (stockpile_entity, stockpile_inventory, stockpile_transform) in store_query.iter() {
for worker_stack in worker_inventory.items.iter() {
if let Some((_, _)) =
worker_inventory.try_transfer(stockpile_inventory, worker_stack)
if worker_inventory
.try_transfer(stockpile_inventory, *worker_stack)
.is_some()
{
let stockpile_dist_squared = worker_transform
let dist_squared = worker_transform
.translation()
.distance_squared(stockpile_transform.translation());
if task_by_distance.is_none_or(|(_, closest_task_dist)| {
stockpile_dist_squared < closest_task_dist
}) {
if task_by_distance
.as_ref()
.is_none_or(|(_, closest_task_dist)| dist_squared < *closest_task_dist)
{
task_by_distance = Some((
Task {
target: stockpile_entity,
work_type: WorkType::Store(*worker_stack),
progress: 0.0,
},
stockpile_dist_squared,
dist_squared,
))
}
}
@ -46,17 +106,18 @@ pub fn work_select(
}
if let Some((task, _)) = task_by_distance {
worker.0 = Some(task);
worker.task = Some(task.clone());
add_to_task_map(&mut target_tasks_map, task);
continue;
}
for (item_source_entity, item_source, item_source_transform) in gather_query.iter() {
if worker_inventory.try_insert(&item_source.0).is_some() {
let source_dist_squared = worker_transform
for (item_source_entity, item_source_transform) in gather_query.iter() {
let dist_squared = worker_transform
.translation()
.distance_squared(item_source_transform.translation());
if task_by_distance
.is_none_or(|(_, closest_task_dist)| source_dist_squared < closest_task_dist)
.as_ref()
.is_none_or(|(_, closest_task_dist)| dist_squared < *closest_task_dist)
{
task_by_distance = Some((
Task {
@ -64,13 +125,155 @@ pub fn work_select(
work_type: WorkType::Gather,
progress: 0.0,
},
source_dist_squared,
dist_squared,
))
}
}
// worker.task = task_by_distance.map(|(task, _)| task.clone());
if let Some((task, _)) = task_by_distance {
worker.task = Some(task.clone());
add_to_task_map(&mut target_tasks_map, task);
} else {
worker.task = None
}
}
}
worker.0 = task_by_distance.map(|(task, _)| task);
pub fn do_work(
mut worker_query: Query<(Entity, &mut Worker)>,
mut on_task_finish: EventWriter<OnTaskFinish>,
global_query: Query<&GlobalTransform>,
duration_query: Query<&WorkDuration>,
time: Res<Time>,
) {
for (entity, mut worker) in worker_query.iter_mut() {
// Skip if worker already has a job
let task = match worker.task.as_mut() {
Some(task) => task,
None => continue,
};
const DISTANCE_THRESHOLD_SQUARED: f32 = 32.0 * 32.0;
match (global_query.get(entity), global_query.get(task.target)) {
(Ok(a), Ok(b)) => {
if a.translation().xy().distance_squared(b.translation().xy())
> DISTANCE_THRESHOLD_SQUARED
{
continue;
}
}
(worker_global, target_global) => {
warn!("Either worker or target doesn't have a GlobalTransform component! Stopping the task. worker: {worker_global:?} target: {target_global:?}");
worker.task = None;
continue;
}
}
let finished_tasks = match duration_query.get(task.target) {
Ok(duration) => task.add_progress(duration, time.delta_secs()),
Err(_) => 1,
};
if finished_tasks == 0 {
continue;
}
let task = worker.task.take().unwrap();
for _ in 0..finished_tasks {
debug!("{entity} task done: {task:?}");
on_task_finish.send(OnTaskFinish {
task: task.clone(),
worker: entity,
});
}
}
}
pub fn apply_task_result(
mut commands: Commands,
mut spawn_item: EventWriter<SpawnItem>,
mut on_task_finish: EventReader<OnTaskFinish>,
mut inventory_query: Query<&mut Inventory>,
item_query: Query<(Entity, &Item)>,
gather_query: Query<(&ItemSource, &GlobalTransform)>,
) {
for OnTaskFinish { worker, task } in on_task_finish.read() {
let target = task.target;
match task.work_type {
WorkType::Pickup => {
let mut inventory = match inventory_query.get_mut(*worker) {
Ok(inventory) => inventory,
Err(err) => {
error!("Pickup: can't get Inventory component from {worker}: {err:?}");
continue;
}
};
let (item_entity, item) = match item_query.get(target) {
Ok(item) => item,
Err(err) => {
error!("Pickup: can't get Item component from {target}: {err:?}");
continue;
}
};
match inventory.try_insert(ItemStack::from(*item)) {
Some(insert) => {
inventory
.apply_insert(insert)
.expect("Worker picks up an item");
commands
.get_entity(item_entity)
.expect("Getting item entity to despawn it")
.despawn_recursive();
}
None => {
// Not fatal, something could have filled the inventory on the way.
warn!("Worker {worker} could not pickup {item:?} possibly due to full inventory");
}
}
}
WorkType::Gather => {
let (item_source, source_transform) = match gather_query.get(target) {
Ok(item_source) => item_source,
Err(err) => {
error!("Gather: can't get ItemSource component from {target}: {err:?}",);
continue;
}
};
for stack in item_source.drops.iter() {
for _ in 0..stack.count {
spawn_item.send(SpawnItem {
item: stack.item,
to: source_transform.translation().xy(),
velocity: random_vec2(250.0, 250.0),
});
}
}
}
WorkType::Store(stack) => {
let [mut worker_inventory, mut stockpile_inventory] =
match inventory_query.get_many_mut([*worker, target]) {
Ok(inventory) => inventory,
Err(err) => {
error!("Store: can't get Inventory components: {err:?}");
continue;
}
};
match worker_inventory.try_transfer(&stockpile_inventory, stack) {
Some((remove, insert)) => {
worker_inventory
.apply_remove(remove)
.expect("Worker stores an item");
stockpile_inventory
.apply_insert(insert)
.expect("Stockpile takes an item");
}
None => {
warn!("Worker {worker} could not store {stack:?} into {target}");
}
}
}
}
}
}
@ -81,44 +284,39 @@ pub fn draw_job_targets(
global_query: Query<&GlobalTransform>,
) {
for (worker_entity, worker) in worker_query.iter() {
let worker_global = global_query.get(worker_entity).unwrap();
let colored_shapes = match worker.0 {
Some(task) => match task.work_type {
WorkType::Gather => vec![
let worker_global = match global_query.get(worker_entity) {
Ok(global) => global,
Err(_) => continue,
};
let colored_shapes = match worker.task.as_ref() {
Some(task) => {
let target_global = match global_query.get(task.target) {
Ok(global) => global,
Err(_) => continue,
};
let color = match task.work_type {
WorkType::Pickup => css::AQUA,
WorkType::Gather => css::GREEN,
WorkType::Store(_) => css::YELLOW,
};
vec![
ColoredShape {
shape: Shape::Polygon {
center: worker_global.translation().xy(),
sides: 3,
radius: 16.,
},
color: css::GREEN,
color,
},
ColoredShape {
shape: Shape::Line {
from: worker_global.translation().xy(),
to: global_query.get(task.target).unwrap().translation().xy(),
to: target_global.translation().xy(),
},
color: css::GREEN,
},
],
WorkType::Store(_) => vec![
ColoredShape {
shape: Shape::Polygon {
center: worker_global.translation().xy(),
sides: 3,
radius: 16.,
},
color: css::YELLOW,
},
ColoredShape {
shape: Shape::Line {
from: worker_global.translation().xy(),
to: global_query.get(task.target).unwrap().translation().xy(),
},
color: css::YELLOW,
},
],
color,
},
]
}
None => vec![ColoredShape {
shape: Shape::Polygon {
center: worker_global.translation().xy(),

View File

@ -5,7 +5,7 @@ use crate::util::Seconds;
use super::item::ItemStack;
/// Total time it takes to finish a task
#[derive(Copy, Clone, Debug, Default, Component, Reflect)]
#[derive(Clone, Debug, Default, Component, Reflect)]
#[reflect(Component)]
pub struct WorkDuration(pub Seconds);
@ -20,13 +20,14 @@ impl WorkDuration {
}
}
#[derive(Copy, Clone, Debug, Reflect)]
#[derive(Clone, Debug, Reflect)]
pub enum WorkType {
Pickup,
Gather,
Store(ItemStack),
}
#[derive(Copy, Clone, Debug, Reflect)]
#[derive(Clone, Debug, Reflect)]
pub struct Task {
pub progress: Seconds,
pub work_type: WorkType,
@ -36,12 +37,25 @@ pub struct Task {
impl Task {
pub fn add_progress(&mut self, work_duration: &WorkDuration, progress: Seconds) -> u32 {
match work_duration.safe_duration() {
Some(duration) => (self.progress + progress).div_euclid(duration).max(0.0) as u32,
Some(duration) => {
self.progress += progress;
let times_completed = self.progress.div_euclid(duration).max(0.0) as u32;
self.progress = self.progress.rem_euclid(duration);
times_completed
}
None => 1,
}
}
}
#[derive(Copy, Clone, Debug, Default, Component, Reflect)]
#[derive(Event, Clone, Debug)]
pub struct OnTaskFinish {
pub worker: Entity,
pub task: Task,
}
#[derive(Clone, Debug, Default, Component, Reflect)]
#[reflect(Component)]
pub struct Worker(pub Option<Task>);
pub struct Worker {
pub task: Option<Task>,
}

View File

@ -1,7 +1,7 @@
use bevy::prelude::*;
use bevy_mod_debugdump::{render_graph, render_graph_dot};
use core::{fmt, ops};
use std::{fs, path::Path};
use std::{f32::consts::PI, fs, path::Path};
mod basis;
mod plugin;
@ -153,6 +153,10 @@ pub fn loop_value(from: f32, to: f32, value: f32) -> f32 {
value - inverse_lerp(from, to, value).floor() * range
}
pub fn random_vec2(min_length: f32, max_length: f32) -> Vec2 {
Vec2::from_angle(fastrand::f32() * PI * 2.0) * lerp(min_length, max_length, fastrand::f32())
}
pub fn create_app_graphs(app: &mut App) {
// TODO: Figure out how to list schedules under the new interned ScheduleLabel system
println!("Writing render graph");

View File

@ -1 +1,2 @@
pub type Seconds = f32;
pub type Kilograms = f32;