Add job system with autonomous and ordered woodcutting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:11 UTC
commit
ad48ec64efc6e9c0a27c7fc655dc207d2d28b897
parent
312e46fde3
tree
browse at this commit

10 files changed +795 -106

CLAUDE.md +8 -5
src/colonist.rs +9 -76
src/items.rs added +155 -0
src/jobs.rs added +463 -0
src/main.rs +4 -0
src/selection.rs +96 -21
src/sim.rs +15 -0
src/tilemap.rs +34 -3
src/world.rs +6 -0
tasks.md +5 -1

CLAUDE.md +8 -5

@@ -3,6 +3,7 @@
3 3 DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — check `docs/bevy/` before guessing APIs).
4 4
5 5 ## User rules
6 +- Vision: fully simulated colony. Every job colonists can be ordered to do, they must also be able to decide to do autonomously (driven by colony demand); manual orders are a bonus/override.
6 7 - Code is `cargo fmt`ed; run it before committing.
7 8 - Commit after each logical chunk; short commit msgs.
8 9 - Minimal code comments. "Why" explanations go into a test asserting the behaviour, comment lives in the test.
@@ -14,12 +15,14 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
14 15 - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash.
15 16 - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1].
16 17 - `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear.
17 -- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
18 +- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
18 19 - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect.
19 -- `src/selection.rs` `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, hover, selection). Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
20 -- `src/pathfinding.rs` `find_path(map, start, goal) -> Option<Vec<UVec2>>` A*, 8-dir, no corner cutting, cost = (10|14) * `Terrain::move_cost` of entered tile (100 = normal, None = impassable), octile heuristic. Path excludes start, includes goal; start==goal → empty.
21 -- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (last reached tile), `Path(VecDeque)`, `Wander(Timer)`. `handle_move_orders` in Update (orders apply instantly even while paused; `repath` keeps tile being entered at front). FixedUpdate chain: `wander` (idle + timer → random goal within radius), `follow_path` (moves `SimPos`, speed / terrain cost, `step_towards`). Camera uses `Time<Real>` so it works while paused. Rendered as Mesh2d circles z=1.
22 -- `src/sim.rs` `SimPlugin`: fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly.
20 +- `src/selection.rs` `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, wood stock/goal, hover incl. items, selection incl. job). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
21 +- `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal.
22 +- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front.
23 +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Chop{tree, progress}}`. `Reservations` (tile → entity) so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide): idle (no job, empty path, IdleTimer done) → first work type with demand + reachable target (woodcutting: `wood_wanted` counts stock + trees being chopped × WOOD_PER_TREE; `find_tree` nearest unreserved), else wander. `run_jobs` (SimSystems::Act): per task state machine; Chop walks adjacent (`find_path_adjacent`), ticks progress to CHOP_TICKS, removes tree, writes `TileChanged`, drops wood; abandons if tree gone/reservation lost. `Order{colonist, tile}` (Update, applies while paused): tree tile → ordered Chop (steals reservation, ignores demand), else ordered MoveTo; ordered move completion → 10 s idle before autonomy resumes. Adding a job = new Task variant + demand check in choose_jobs + arm in run_jobs.
24 +- `src/items.rs` `Item{kind, count}` + `TilePos` entities (Mesh2d rect, z=0.7), `drop_items` stacks same kind on a tile, `stock()` sums; cleared on WorldGenerated. Camera uses `Time<Real>` so it works while paused. Rendered as Mesh2d circles z=1.
25 +- `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly.
23 26 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
24 27
25 28 ## Gotchas

src/colonist.rs +9 -76

@@ -1,12 +1,12 @@
1 -use std::{collections::VecDeque, time::Duration};
1 +use std::collections::VecDeque;
2 2
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + jobs::{CurrentJob, IdleTimer},
6 7 pathfinding::find_path,
7 - rng::SimRng,
8 - sim::{SimPos, sim_pos},
9 - world::{TILE_SIZE, WorldMap},
8 + sim::{SimPos, SimSystems, sim_pos},
9 + world::{TILE_SIZE, TilePos, WorldMap},
10 10 worldgen::{WorldGenerated, spawn_tiles},
11 11 };
12 12
@@ -14,17 +14,12 @@ pub struct ColonistPlugin;
14 14
15 15 impl Plugin for ColonistPlugin {
16 16 fn build(&self, app: &mut App) {
17 - app.insert_resource(SimRng::new(0x5EED))
18 - .add_message::<MoveOrder>()
19 - .add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>))
20 - .add_systems(Update, handle_move_orders)
21 - .add_systems(FixedUpdate, (wander, follow_path).chain());
17 + app.add_systems(Update, spawn_colonists.run_if(on_message::<WorldGenerated>))
18 + .add_systems(FixedUpdate, follow_path.in_set(SimSystems::Move));
22 19 }
23 20 }
24 21
25 22 const TILES_PER_SECOND: f32 = 4.0;
26 -const WANDER_RADIUS: i32 = 8;
27 -const WANDER_PAUSE_SECS: std::ops::Range<f32> = 1.0..4.0;
28 23
29 24 const STARTING_COLONISTS: [(&str, Color); 3] = [
30 25 ("Ada", Color::srgb(0.9, 0.3, 0.3)),
@@ -33,35 +28,14 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [
33 28 ];
34 29
35 30 #[derive(Component, Debug)]
36 -#[require(Transform, SimPos, TilePos, Path, Wander)]
31 +#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer)]
37 32 pub struct Colonist {
38 33 pub name: String,
39 34 }
40 35
41 -#[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
42 -pub struct TilePos(pub UVec2);
43 -
44 36 #[derive(Component, Default, Debug)]
45 37 pub struct Path(pub VecDeque<UVec2>);
46 38
47 -#[derive(Component, Debug)]
48 -pub struct Wander(Timer);
49 -
50 -impl Default for Wander {
51 - fn default() -> Self {
52 - Self(Timer::from_seconds(
53 - WANDER_PAUSE_SECS.start,
54 - TimerMode::Once,
55 - ))
56 - }
57 -}
58 -
59 -#[derive(Message, Debug, Clone, Copy)]
60 -pub struct MoveOrder {
61 - pub colonist: Entity,
62 - pub goal: UVec2,
63 -}
64 -
65 39 fn spawn_colonists(
66 40 mut commands: Commands,
67 41 map: Res<WorldMap>,
@@ -87,21 +61,6 @@ fn spawn_colonists(
87 61 }
88 62 }
89 63
90 -fn handle_move_orders(
91 - mut orders: MessageReader<MoveOrder>,
92 - map: Res<WorldMap>,
93 - mut colonists: Query<(&TilePos, &mut Path)>,
94 -) {
95 - for order in orders.read() {
96 - let Ok((tile, mut path)) = colonists.get_mut(order.colonist) else {
97 - continue;
98 - };
99 - if let Some(new_path) = repath(&map, tile.0, &path.0, order.goal) {
100 - path.0 = new_path;
101 - }
102 - }
103 -}
104 -
105 64 pub fn repath(
106 65 map: &WorldMap,
107 66 tile: UVec2,
@@ -118,34 +77,7 @@ pub fn repath(
118 77 }
119 78 }
120 79
121 -fn wander(
122 - time: Res<Time>,
123 - map: Res<WorldMap>,
124 - mut rng: ResMut<SimRng>,
125 - mut colonists: Query<(&TilePos, &mut Path, &mut Wander)>,
126 -) {
127 - for (tile, mut path, mut wander) in &mut colonists {
128 - if !path.0.is_empty() || !wander.0.tick(time.delta()).is_finished() {
129 - continue;
130 - }
131 - let offset = IVec2::new(
132 - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
133 - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
134 - );
135 - let goal = tile.0.as_ivec2() + offset;
136 - if !map.in_bounds(goal) {
137 - continue;
138 - }
139 - if let Some(new_path) = find_path(&map, tile.0, goal.as_uvec2()) {
140 - path.0 = new_path.into();
141 - let pause = rng.range_f32(WANDER_PAUSE_SECS);
142 - wander.0.set_duration(Duration::from_secs_f32(pause));
143 - wander.0.reset();
144 - }
145 - }
146 -}
147 -
148 -fn follow_path(
80 +pub fn follow_path(
149 81 time: Res<Time>,
150 82 map: Res<WorldMap>,
151 83 mut colonists: Query<(&mut TilePos, &mut Path, &mut SimPos)>,
@@ -182,6 +114,7 @@ pub fn step_towards(pos: Vec2, target: Vec2, max_distance: f32) -> (Vec2, bool)
182 114 mod tests {
183 115 use super::*;
184 116 use crate::world::Terrain;
117 + use std::time::Duration;
185 118
186 119 #[test]
187 120 fn step_towards_stops_exactly_on_target() {

src/items.rs +155 -0

@@ -0,0 +1,155 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + world::{TILE_SIZE, TilePos, WorldMap},
5 + worldgen::WorldGenerated,
6 +};
7 +
8 +pub struct ItemsPlugin;
9 +
10 +impl Plugin for ItemsPlugin {
11 + fn build(&self, app: &mut App) {
12 + app.add_systems(Startup, create_item_assets)
13 + .add_systems(Update, clear_items.run_if(on_message::<WorldGenerated>));
14 + }
15 +}
16 +
17 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
18 +pub enum ItemKind {
19 + Wood,
20 +}
21 +
22 +impl ItemKind {
23 + pub const ALL: [ItemKind; 1] = [ItemKind::Wood];
24 +
25 + fn color(self) -> Color {
26 + match self {
27 + ItemKind::Wood => Color::srgb_u8(150, 104, 62),
28 + }
29 + }
30 +}
31 +
32 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
33 +#[require(Transform)]
34 +pub struct Item {
35 + pub kind: ItemKind,
36 + pub count: u32,
37 +}
38 +
39 +#[derive(Resource, Default)]
40 +pub struct ItemAssets {
41 + mesh: Handle<Mesh>,
42 + materials: Vec<(ItemKind, Handle<ColorMaterial>)>,
43 +}
44 +
45 +impl ItemAssets {
46 + fn material(&self, kind: ItemKind) -> Handle<ColorMaterial> {
47 + self.materials
48 + .iter()
49 + .find(|(k, _)| *k == kind)
50 + .map(|(_, m)| m.clone())
51 + .unwrap_or_default()
52 + }
53 +}
54 +
55 +fn create_item_assets(
56 + mut commands: Commands,
57 + mut meshes: ResMut<Assets<Mesh>>,
58 + mut materials: ResMut<Assets<ColorMaterial>>,
59 +) {
60 + commands.insert_resource(ItemAssets {
61 + mesh: meshes.add(Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.4)),
62 + materials: ItemKind::ALL
63 + .iter()
64 + .map(|&kind| (kind, materials.add(kind.color())))
65 + .collect(),
66 + });
67 +}
68 +
69 +fn clear_items(mut commands: Commands, items: Query<Entity, With<Item>>) {
70 + for entity in &items {
71 + commands.entity(entity).despawn();
72 + }
73 +}
74 +
75 +pub fn drop_items(
76 + commands: &mut Commands,
77 + items: &mut Query<(&mut Item, &TilePos)>,
78 + assets: &ItemAssets,
79 + tile: UVec2,
80 + kind: ItemKind,
81 + count: u32,
82 +) {
83 + if let Some((mut item, _)) = items
84 + .iter_mut()
85 + .find(|(item, pos)| pos.0 == tile && item.kind == kind)
86 + {
87 + item.count += count;
88 + return;
89 + }
90 + commands.spawn((
91 + Name::new(format!("{kind:?}")),
92 + Item { kind, count },
93 + TilePos(tile),
94 + Mesh2d(assets.mesh.clone()),
95 + MeshMaterial2d(assets.material(kind)),
96 + Transform::from_translation(WorldMap::tile_center(tile).extend(0.7)),
97 + ));
98 +}
99 +
100 +pub fn stock<'a>(items: impl Iterator<Item = &'a Item>, kind: ItemKind) -> u32 {
101 + items.filter(|i| i.kind == kind).map(|i| i.count).sum()
102 +}
103 +
104 +#[cfg(test)]
105 +mod tests {
106 + use super::*;
107 +
108 + fn drop_system(mut commands: Commands, mut items: Query<(&mut Item, &TilePos)>) {
109 + let assets = ItemAssets::default();
110 + drop_items(
111 + &mut commands,
112 + &mut items,
113 + &assets,
114 + UVec2::ONE,
115 + ItemKind::Wood,
116 + 5,
117 + );
118 + }
119 +
120 + #[test]
121 + fn dropping_on_a_tile_with_the_same_item_stacks() {
122 + let mut app = App::new();
123 + app.add_systems(Update, drop_system);
124 + app.update();
125 + app.update();
126 + let items: Vec<Item> = app
127 + .world_mut()
128 + .query::<&Item>()
129 + .iter(app.world())
130 + .copied()
131 + .collect();
132 + assert_eq!(
133 + items,
134 + vec![Item {
135 + kind: ItemKind::Wood,
136 + count: 10
137 + }]
138 + );
139 + }
140 +
141 + #[test]
142 + fn stock_sums_counts_of_one_kind() {
143 + let items = [
144 + Item {
145 + kind: ItemKind::Wood,
146 + count: 3,
147 + },
148 + Item {
149 + kind: ItemKind::Wood,
150 + count: 4,
151 + },
152 + ];
153 + assert_eq!(stock(items.iter(), ItemKind::Wood), 7);
154 + }
155 +}

src/jobs.rs +463 -0

@@ -0,0 +1,463 @@
1 +use std::{collections::HashMap, ops::Range, time::Duration};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + colonist::{Colonist, Path, repath},
7 + items::{Item, ItemAssets, ItemKind, drop_items, stock},
8 + pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
9 + rng::SimRng,
10 + sim::SimSystems,
11 + world::{Plant, TileChanged, TilePos, WorldMap},
12 + worldgen::WorldGenerated,
13 +};
14 +
15 +pub struct JobsPlugin;
16 +
17 +impl Plugin for JobsPlugin {
18 + fn build(&self, app: &mut App) {
19 + app.insert_resource(SimRng::new(0x5EED))
20 + .init_resource::<Reservations>()
21 + .init_resource::<ColonyGoals>()
22 + .add_message::<Order>()
23 + .add_message::<TileChanged>()
24 + .add_systems(
25 + Update,
26 + (
27 + clear_reservations.run_if(on_message::<WorldGenerated>),
28 + handle_orders,
29 + ),
30 + )
31 + .add_systems(FixedUpdate, choose_jobs.in_set(SimSystems::Decide))
32 + .add_systems(FixedUpdate, run_jobs.in_set(SimSystems::Act));
33 + }
34 +}
35 +
36 +pub const CHOP_TICKS: u32 = 240;
37 +pub const WOOD_PER_TREE: u32 = 10;
38 +const TREE_SEARCH_TILES: usize = 6000;
39 +const WANDER_RADIUS: i32 = 8;
40 +const IDLE_SECS: Range<f32> = 1.0..4.0;
41 +const IDLE_AFTER_ORDER_SECS: f32 = 10.0;
42 +
43 +#[derive(Component, Default, Debug, Clone, PartialEq)]
44 +pub struct CurrentJob(pub Option<Job>);
45 +
46 +#[derive(Debug, Clone, Copy, PartialEq)]
47 +pub struct Job {
48 + pub task: Task,
49 + pub ordered: bool,
50 +}
51 +
52 +#[derive(Debug, Clone, Copy, PartialEq)]
53 +pub enum Task {
54 + MoveTo(UVec2),
55 + Chop { tree: UVec2, progress: u32 },
56 +}
57 +
58 +#[derive(Component, Debug)]
59 +pub struct IdleTimer(Timer);
60 +
61 +impl Default for IdleTimer {
62 + fn default() -> Self {
63 + Self(Timer::from_seconds(IDLE_SECS.start, TimerMode::Once))
64 + }
65 +}
66 +
67 +impl IdleTimer {
68 + fn restart(&mut self, secs: f32) {
69 + self.0.set_duration(Duration::from_secs_f32(secs));
70 + self.0.reset();
71 + }
72 +}
73 +
74 +#[derive(Resource, Default, Debug)]
75 +pub struct Reservations(HashMap<UVec2, Entity>);
76 +
77 +impl Reservations {
78 + pub fn reserve(&mut self, tile: UVec2, by: Entity) {
79 + self.0.insert(tile, by);
80 + }
81 +
82 + pub fn holder(&self, tile: UVec2) -> Option<Entity> {
83 + self.0.get(&tile).copied()
84 + }
85 +
86 + pub fn release_all(&mut self, by: Entity) {
87 + self.0.retain(|_, holder| *holder != by);
88 + }
89 +}
90 +
91 +#[derive(Resource, Debug)]
92 +pub struct ColonyGoals {
93 + pub wood: u32,
94 +}
95 +
96 +impl Default for ColonyGoals {
97 + fn default() -> Self {
98 + Self { wood: 50 }
99 + }
100 +}
101 +
102 +#[derive(Message, Debug, Clone, Copy)]
103 +pub struct Order {
104 + pub colonist: Entity,
105 + pub tile: UVec2,
106 +}
107 +
108 +fn clear_reservations(mut reservations: ResMut<Reservations>) {
109 + reservations.0.clear();
110 +}
111 +
112 +fn handle_orders(
113 + mut orders: MessageReader<Order>,
114 + map: Res<WorldMap>,
115 + mut reservations: ResMut<Reservations>,
116 + mut colonists: Query<(&TilePos, &mut Path, &mut CurrentJob)>,
117 +) {
118 + for order in orders.read() {
119 + let Ok((tile, mut path, mut job)) = colonists.get_mut(order.colonist) else {
120 + continue;
121 + };
122 + if map.plant(order.tile.as_ivec2()) == Some(Plant::Tree) {
123 + reservations.release_all(order.colonist);
124 + reservations.reserve(order.tile, order.colonist);
125 + path.0.truncate(1);
126 + job.0 = Some(Job {
127 + task: Task::Chop {
128 + tree: order.tile,
129 + progress: 0,
130 + },
131 + ordered: true,
132 + });
133 + } else if let Some(new_path) = repath(&map, tile.0, &path.0, order.tile) {
134 + reservations.release_all(order.colonist);
135 + path.0 = new_path;
136 + job.0 = Some(Job {
137 + task: Task::MoveTo(order.tile),
138 + ordered: true,
139 + });
140 + }
141 + }
142 +}
143 +
144 +pub fn wood_wanted(goals: &ColonyGoals, in_stock: u32, trees_being_chopped: u32) -> bool {
145 + in_stock + trees_being_chopped * WOOD_PER_TREE < goals.wood
146 +}
147 +
148 +pub fn find_tree(
149 + map: &WorldMap,
150 + reservations: &Reservations,
151 + from: UVec2,
152 +) -> Option<(UVec2, Vec<UVec2>)> {
153 + let tree = nearest_tile(map, from, TREE_SEARCH_TILES, |p| {
154 + map.plant(p.as_ivec2()) == Some(Plant::Tree) && reservations.holder(p).is_none()
155 + })?;
156 + let path = find_path_adjacent(map, from, tree)?;
157 + Some((tree, path))
158 +}
159 +
160 +#[allow(clippy::too_many_arguments)]
161 +fn choose_jobs(
162 + time: Res<Time>,
163 + map: Res<WorldMap>,
164 + goals: Res<ColonyGoals>,
165 + mut rng: ResMut<SimRng>,
166 + mut reservations: ResMut<Reservations>,
167 + items: Query<&Item>,
168 + mut colonists: Query<
169 + (Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer),
170 + With<Colonist>,
171 + >,
172 +) {
173 + let wood = stock(items.iter(), ItemKind::Wood);
174 + let mut chopping = colonists
175 + .iter()
176 + .filter(|(.., job, _)| {
177 + matches!(
178 + job.0,
179 + Some(Job {
180 + task: Task::Chop { .. },
181 + ..
182 + })
183 + )
184 + })
185 + .count() as u32;
186 + for (entity, tile, mut path, mut job, mut idle) in &mut colonists {
187 + if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() {
188 + continue;
189 + }
190 + if wood_wanted(&goals, wood, chopping)
191 + && let Some((tree, tree_path)) = find_tree(&map, &reservations, tile.0)
192 + {
193 + reservations.reserve(tree, entity);
194 + path.0 = tree_path.into();
195 + job.0 = Some(Job {
196 + task: Task::Chop { tree, progress: 0 },
197 + ordered: false,
198 + });
199 + chopping += 1;
200 + continue;
201 + }
202 + let goal = tile.0.as_ivec2()
203 + + IVec2::new(
204 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
205 + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
206 + );
207 + if map.in_bounds(goal)
208 + && let Some(wander_path) = find_path(&map, tile.0, goal.as_uvec2())
209 + {
210 + path.0 = wander_path.into();
211 + idle.restart(rng.range_f32(IDLE_SECS));
212 + }
213 + }
214 +}
215 +
216 +#[allow(clippy::too_many_arguments)]
217 +fn run_jobs(
218 + mut commands: Commands,
219 + mut map: ResMut<WorldMap>,
220 + mut reservations: ResMut<Reservations>,
221 + mut rng: ResMut<SimRng>,
222 + item_assets: Res<ItemAssets>,
223 + mut items: Query<(&mut Item, &TilePos)>,
224 + mut changed: MessageWriter<TileChanged>,
225 + mut colonists: Query<(Entity, &TilePos, &mut Path, &mut CurrentJob, &mut IdleTimer)>,
226 +) {
227 + for (entity, tile, mut path, mut current, mut idle) in &mut colonists {
228 + let Some(mut job) = current.0 else {
229 + continue;
230 + };
231 + if !path.0.is_empty() {
232 + continue;
233 + }
234 + let done = match &mut job.task {
235 + Task::MoveTo(_) => true,
236 + Task::Chop { tree, progress } => {
237 + let tree = *tree;
238 + let still_ours = map.plant(tree.as_ivec2()) == Some(Plant::Tree)
239 + && reservations.holder(tree) == Some(entity);
240 + if !still_ours {
241 + true
242 + } else if !is_adjacent(tile.0.as_ivec2(), tree.as_ivec2()) {
243 + match find_path_adjacent(&map, tile.0, tree) {
244 + Some(p) if !p.is_empty() => {
245 + path.0 = p.into();
246 + false
247 + }
248 + _ => true,
249 + }
250 + } else {
251 + *progress += 1;
252 + if *progress >= CHOP_TICKS {
253 + map.set_plant(tree, None);
254 + changed.write(TileChanged(tree));
255 + drop_items(
256 + &mut commands,
257 + &mut items,
258 + &item_assets,
259 + tree,
260 + ItemKind::Wood,
261 + WOOD_PER_TREE,
262 + );
263 + }
264 + *progress >= CHOP_TICKS
265 + }
266 + }
267 + };
268 + if done {
269 + reservations.release_all(entity);
270 + current.0 = None;
271 + let ordered_move = matches!(
272 + job,
273 + Job {
274 + task: Task::MoveTo(_),
275 + ordered: true
276 + }
277 + );
278 + idle.restart(if ordered_move {
279 + IDLE_AFTER_ORDER_SECS
280 + } else {
281 + rng.range_f32(IDLE_SECS)
282 + });
283 + } else {
284 + current.0 = Some(job);
285 + }
286 + }
287 +}
288 +
289 +#[cfg(test)]
290 +mod tests {
291 + use super::*;
292 + use crate::{
293 + colonist::follow_path,
294 + sim::{SimPos, sim_pos},
295 + world::Terrain,
296 + };
297 +
298 + const TICK: Duration = Duration::from_nanos(1_000_000_000 / 60);
299 +
300 + fn test_app(map: WorldMap) -> App {
301 + let mut app = App::new();
302 + app.insert_resource(map)
303 + .insert_resource(SimRng::new(1))
304 + .init_resource::<Time>()
305 + .init_resource::<Reservations>()
306 + .init_resource::<ColonyGoals>()
307 + .init_resource::<ItemAssets>()
308 + .add_message::<Order>()
309 + .add_message::<TileChanged>()
310 + .add_systems(
311 + Update,
312 + (handle_orders, choose_jobs, run_jobs, follow_path).chain(),
313 + );
314 + app
315 + }
316 +
317 + fn spawn_colonist(app: &mut App, tile: UVec2) -> Entity {
318 + app.world_mut()
319 + .spawn((
320 + Colonist {
321 + name: "Test".into(),
322 + },
323 + TilePos(tile),
324 + sim_pos(WorldMap::tile_center(tile)),
325 + ))
326 + .id()
327 + }
328 +
329 + fn run_ticks(app: &mut App, ticks: u32) {
330 + for _ in 0..ticks {
331 + app.world_mut().resource_mut::<Time>().advance_by(TICK);
332 + app.update();
333 + }
334 + }
335 +
336 + fn map_with_trees(trees: &[UVec2]) -> WorldMap {
337 + let mut map = WorldMap::filled(UVec2::new(20, 20), Terrain::Grass);
338 + for &t in trees {
339 + map.set_plant(t, Some(Plant::Tree));
340 + }
341 + map
342 + }
343 +
344 + fn wood_on_map(app: &mut App) -> u32 {
345 + let world = app.world_mut();
346 + let items: Vec<Item> = world.query::<&Item>().iter(world).copied().collect();
347 + stock(items.iter(), ItemKind::Wood)
348 + }
349 +
350 + fn job(app: &App, colonist: Entity) -> Option<Job> {
351 + app.world().get::<CurrentJob>(colonist).unwrap().0
352 + }
353 +
354 + #[test]
355 + fn idle_colonists_cut_trees_when_the_colony_needs_wood() {
356 + let tree = UVec2::new(8, 5);
357 + let mut app = test_app(map_with_trees(&[tree]));
358 + spawn_colonist(&mut app, UVec2::new(2, 5));
359 + run_ticks(&mut app, 60 * 15);
360 + assert_eq!(
361 + app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
362 + None
363 + );
364 + assert_eq!(wood_on_map(&mut app), WOOD_PER_TREE);
365 + assert!(app.world().resource::<Reservations>().0.is_empty());
366 + }
367 +
368 + #[test]
369 + fn colonists_leave_trees_alone_when_there_is_enough_wood() {
370 + let tree = UVec2::new(8, 5);
371 + let mut app = test_app(map_with_trees(&[tree]));
372 + app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
373 + spawn_colonist(&mut app, UVec2::new(2, 5));
374 + run_ticks(&mut app, 60 * 15);
375 + assert_eq!(
376 + app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
377 + Some(Plant::Tree)
378 + );
379 + }
380 +
381 + #[test]
382 + fn colonists_never_pick_the_same_tree() {
383 + let trees = [UVec2::new(10, 10), UVec2::new(10, 11)];
384 + let mut app = test_app(map_with_trees(&trees));
385 + let a = spawn_colonist(&mut app, UVec2::new(5, 10));
386 + let b = spawn_colonist(&mut app, UVec2::new(5, 11));
387 + run_ticks(&mut app, 61);
388 + let target = |c| match job(&app, c) {
389 + Some(Job {
390 + task: Task::Chop { tree, .. },
391 + ..
392 + }) => tree,
393 + other => panic!("expected chop job, got {other:?}"),
394 + };
395 + assert_ne!(target(a), target(b));
396 + }
397 +
398 + // Each chopped tree will add WOOD_PER_TREE; counting trees already being chopped as future
399 + // wood keeps the whole colony from felling a forest when only a little more wood is needed.
400 + #[test]
401 + fn trees_already_being_cut_count_towards_the_wood_goal() {
402 + let goals = ColonyGoals { wood: 25 };
403 + assert!(wood_wanted(&goals, 10, 1));
404 + assert!(!wood_wanted(&goals, 10, 2));
405 + assert!(!wood_wanted(&goals, 30, 0));
406 + }
407 +
408 + #[test]
409 + fn ordered_chop_ignores_the_wood_goal() {
410 + let tree = UVec2::new(8, 5);
411 + let mut app = test_app(map_with_trees(&[tree]));
412 + app.world_mut().resource_mut::<ColonyGoals>().wood = 0;
413 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
414 + app.world_mut().write_message(Order {
415 + colonist,
416 + tile: tree,
417 + });
418 + run_ticks(&mut app, 60 * 15);
419 + assert_eq!(
420 + app.world().resource::<WorldMap>().plant(tree.as_ivec2()),
421 + None
422 + );
423 + }
424 +
425 + #[test]
426 + fn ordering_a_move_cancels_the_current_job_and_frees_its_tree() {
427 + let tree = UVec2::new(15, 5);
428 + let mut app = test_app(map_with_trees(&[tree]));
429 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
430 + run_ticks(&mut app, 61);
431 + assert!(matches!(
432 + job(&app, colonist),
433 + Some(Job {
434 + task: Task::Chop { .. },
435 + ..
436 + })
437 + ));
438 + app.world_mut().write_message(Order {
439 + colonist,
440 + tile: UVec2::new(2, 15),
441 + });
442 + run_ticks(&mut app, 1);
443 + assert_eq!(
444 + job(&app, colonist).map(|j| j.task),
445 + Some(Task::MoveTo(UVec2::new(2, 15)))
446 + );
447 + assert_eq!(app.world().resource::<Reservations>().holder(tree), None);
448 + }
449 +
450 + #[test]
451 + fn colonists_chop_from_a_neighbouring_tile() {
452 + let tree = UVec2::new(8, 5);
453 + let mut app = test_app(map_with_trees(&[tree]));
454 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5));
455 + run_ticks(&mut app, 60 * 5);
456 + let tile = app.world().get::<TilePos>(colonist).unwrap().0;
457 + assert!(is_adjacent(tile.as_ivec2(), tree.as_ivec2()));
458 + assert_eq!(
459 + app.world().get::<SimPos>(colonist).unwrap().0,
460 + WorldMap::tile_center(tile)
461 + );
462 + }
463 +}

src/main.rs +4 -0

@@ -1,5 +1,7 @@
1 1 mod camera;
2 2 mod colonist;
3 +mod items;
4 +mod jobs;
3 5 mod noise;
4 6 mod pathfinding;
5 7 mod rng;
@@ -33,6 +35,8 @@ fn main() {
33 35 tilemap::TilemapPlugin,
34 36 camera::CameraPlugin,
35 37 colonist::ColonistPlugin,
38 + items::ItemsPlugin,
39 + jobs::JobsPlugin,
36 40 selection::SelectionPlugin,
37 41 ))
38 42 .run();

src/selection.rs +96 -21

@@ -2,9 +2,11 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2 2
3 3 use crate::{
4 4 camera::CameraController,
5 - colonist::{Colonist, MoveOrder, Path, TilePos},
5 + colonist::{Colonist, Path},
6 + items::{Item, ItemKind, stock},
7 + jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task},
6 8 sim::{SimSpeed, SimTick, format_clock},
7 - world::{TILE_SIZE, WorldMap},
9 + world::{TILE_SIZE, TilePos, WorldMap},
8 10 worldgen::{WorldGenerated, WorldSeed},
9 11 };
10 12
@@ -51,6 +53,8 @@ struct TileInfoText;
51 53
52 54 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
53 55 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
56 +const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4);
57 +const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6);
54 58 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
55 59 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
56 60 const CLICK_SLOP_PX: f32 = 4.0;
@@ -116,7 +120,7 @@ fn issue_move_orders(
116 120 buttons: Res<ButtonInput<MouseButton>>,
117 121 cursor: Res<Cursor>,
118 122 selection: Res<Selection>,
119 - mut orders: MessageWriter<MoveOrder>,
123 + mut orders: MessageWriter<Order>,
120 124 mut pressed_at: Local<Option<Vec2>>,
121 125 ) {
122 126 if buttons.just_pressed(MouseButton::Right) {
@@ -128,10 +132,10 @@ fn issue_move_orders(
128 132 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
129 133 return;
130 134 };
131 - if let (true, Selection::Colonist(colonist), Some(goal)) =
135 + if let (true, Selection::Colonist(colonist), Some(tile)) =
132 136 (is_click(press, release), *selection, cursor.tile)
133 137 {
134 - orders.write(MoveOrder { colonist, goal });
138 + orders.write(Order { colonist, tile });
135 139 }
136 140 }
137 141
@@ -143,8 +147,23 @@ fn draw_highlights(
143 147 mut gizmos: Gizmos,
144 148 cursor: Res<Cursor>,
145 149 selection: Res<Selection>,
146 - colonists: Query<(&Transform, &Path), With<Colonist>>,
150 + colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
147 151 ) {
152 + for (_, transform, _, job) in &colonists {
153 + if let Some(Job {
154 + task: Task::Chop { progress, .. },
155 + ..
156 + }) = job.0
157 + && progress > 0
158 + {
159 + let left =
160 + transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
161 + let full = TILE_SIZE * 0.8;
162 + let done = full * progress as f32 / CHOP_TICKS as f32;
163 + gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
164 + gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
165 + }
166 + }
148 167 if let Some(tile) = cursor.tile {
149 168 gizmos.rect_2d(
150 169 WorldMap::tile_center(tile),
@@ -162,9 +181,20 @@ fn draw_highlights(
162 181 );
163 182 }
164 183 Selection::Colonist(entity) => {
165 - let Ok((transform, path)) = colonists.get(entity) else {
184 + let Ok((_, transform, path, job)) = colonists.get(entity) else {
166 185 return;
167 186 };
187 + if let Some(Job {
188 + task: Task::Chop { tree, .. },
189 + ..
190 + }) = job.0
191 + {
192 + gizmos.rect_2d(
193 + WorldMap::tile_center(tree),
194 + Vec2::splat(TILE_SIZE),
195 + PATH_COLOR,
196 + );
197 + }
168 198 let pos = transform.translation.truncate();
169 199 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
170 200 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
@@ -193,7 +223,9 @@ fn update_tile_info(
193 223 map: Res<WorldMap>,
194 224 cursor: Res<Cursor>,
195 225 selection: Res<Selection>,
196 - colonists: Query<(&Colonist, &TilePos, &Path)>,
226 + colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob)>,
227 + items: Query<(&Item, &TilePos)>,
228 + goals: Res<ColonyGoals>,
197 229 time: Res<Time<Virtual>>,
198 230 tick: Res<SimTick>,
199 231 speed: Res<SimSpeed>,
@@ -205,14 +237,23 @@ fn update_tile_info(
205 237 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
206 238 Selection::Colonist(entity) => colonists
207 239 .get(entity)
208 - .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len()))
240 + .map(|(colonist, tile, path, job)| {
241 + describe_colonist(colonist, tile.0, job.0, path.0.len())
242 + })
209 243 .unwrap_or_else(|_| "-".into()),
210 244 };
211 245 let clock = format_clock(*tick);
212 246 let speed = describe_speed(*speed, time.is_paused());
247 + let wood = stock(items.iter().map(|(item, _)| item), ItemKind::Wood);
248 + let hovered_items: String = items
249 + .iter()
250 + .filter(|(_, pos)| Some(pos.0) == cursor.tile)
251 + .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
252 + .collect();
213 253 let info = format!(
214 - "{clock} {speed}\nSeed: {} (R: new world)\nHover: {}\nSelected: {selected}",
254 + "{clock} {speed}\nSeed: {} (R: new world)\nWood: {wood} / {}\nHover: {}{hovered_items}\nSelected: {selected}",
215 255 seed.0,
256 + goals.wood,
216 257 describe_tile(&map, cursor.tile)
217 258 );
218 259 if text.0 != info {
@@ -242,12 +283,30 @@ pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
242 283 }
243 284 }
244 285
245 -pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String {
246 - let activity = match steps_left {
247 - 0 => "idle".to_string(),
248 - n => format!("moving, {n} steps left"),
286 +pub fn describe_colonist(
287 + colonist: &Colonist,
288 + tile: UVec2,
289 + job: Option<Job>,
290 + steps_left: usize,
291 +) -> String {
292 + let activity = match (job.map(|j| j.task), steps_left) {
293 + (None, 0) => "idle".to_string(),
294 + (None, _) => "wandering".to_string(),
295 + (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
296 + (Some(Task::Chop { .. }), n) if n > 0 => "walking to a tree".to_string(),
297 + (Some(Task::Chop { progress, .. }), _) => {
298 + format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS)
299 + }
300 + };
301 + let ordered = if job.is_some_and(|j| j.ordered) {
302 + " (ordered)"
303 + } else {
304 + ""
249 305 };
250 - format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y)
306 + format!(
307 + "{} at ({}, {}), {activity}{ordered}",
308 + colonist.name, tile.x, tile.y
309 + )
251 310 }
252 311
253 312 #[cfg(test)]
@@ -261,7 +320,7 @@ mod tests {
261 320 .init_resource::<ButtonInput<KeyCode>>()
262 321 .insert_resource(cursor)
263 322 .insert_resource(selection)
264 - .add_message::<MoveOrder>()
323 + .add_message::<Order>()
265 324 .add_systems(Update, (update_selection, issue_move_orders));
266 325 app
267 326 }
@@ -294,9 +353,9 @@ mod tests {
294 353
295 354 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
296 355 app.world_mut()
297 - .resource_mut::<Messages<MoveOrder>>()
356 + .resource_mut::<Messages<Order>>()
298 357 .drain()
299 - .map(|o| (o.colonist, o.goal))
358 + .map(|o| (o.colonist, o.tile))
300 359 .collect()
301 360 }
302 361
@@ -404,13 +463,29 @@ mod tests {
404 463 #[test]
405 464 fn describe_colonist_shows_activity() {
406 465 let ada = Colonist { name: "Ada".into() };
466 + let tile = UVec2::new(1, 2);
407 467 assert_eq!(
408 - describe_colonist(&ada, UVec2::new(1, 2), 0),
468 + describe_colonist(&ada, tile, None, 0),
409 469 "Ada at (1, 2), idle"
410 470 );
471 + let ordered_move = Job {
472 + task: Task::MoveTo(UVec2::ZERO),
473 + ordered: true,
474 + };
475 + assert_eq!(
476 + describe_colonist(&ada, tile, Some(ordered_move), 3),
477 + "Ada at (1, 2), moving, 3 steps left (ordered)"
478 + );
479 + let chop = Job {
480 + task: Task::Chop {
481 + tree: UVec2::ZERO,
482 + progress: CHOP_TICKS / 2,
483 + },
484 + ordered: false,
485 + };
411 486 assert_eq!(
412 - describe_colonist(&ada, UVec2::new(1, 2), 3),
413 - "Ada at (1, 2), moving, 3 steps left"
487 + describe_colonist(&ada, tile, Some(chop), 0),
488 + "Ada at (1, 2), chopping a tree, 50%"
414 489 );
415 490 }
416 491 }

src/sim.rs +15 -0

@@ -12,6 +12,10 @@ impl Plugin for SimPlugin {
12 12 app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND))
13 13 .init_resource::<SimTick>()
14 14 .init_resource::<SimSpeed>()
15 + .configure_sets(
16 + FixedUpdate,
17 + (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(),
18 + )
15 19 .add_systems(Update, speed_controls)
16 20 .add_systems(FixedFirst, (advance_tick, remember_positions))
17 21 .add_systems(
@@ -21,6 +25,13 @@ impl Plugin for SimPlugin {
21 25 }
22 26 }
23 27
28 +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
29 +pub enum SimSystems {
30 + Decide,
31 + Act,
32 + Move,
33 +}
34 +
24 35 #[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
25 36 #[require(PrevSimPos)]
26 37 pub struct SimPos(pub Vec2);
@@ -155,6 +166,10 @@ mod tests {
155 166 app.init_resource::<ButtonInput<KeyCode>>()
156 167 .init_resource::<Time<Virtual>>()
157 168 .init_resource::<SimSpeed>()
169 + .configure_sets(
170 + FixedUpdate,
171 + (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(),
172 + )
158 173 .add_systems(Update, speed_controls);
159 174 app
160 175 }

src/tilemap.rs +34 -3

@@ -6,7 +6,7 @@ use bevy::{
6 6 };
7 7
8 8 use crate::{
9 - world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, WorldMap, hash2},
9 + world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2},
10 10 worldgen::WorldGenerated,
11 11 };
12 12
@@ -21,8 +21,13 @@ pub struct TilemapPlugin;
21 21
22 22 impl Plugin for TilemapPlugin {
23 23 fn build(&self, app: &mut App) {
24 - app.add_systems(Startup, spawn_chunks)
25 - .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
24 + app.add_systems(Startup, spawn_chunks).add_systems(
25 + Update,
26 + (
27 + refresh_chunks.run_if(on_message::<WorldGenerated>),
28 + refresh_changed_chunks,
29 + ),
30 + );
26 31 }
27 32 }
28 33
@@ -90,6 +95,26 @@ fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChu
90 95 }
91 96 }
92 97
98 +fn refresh_changed_chunks(
99 + map: Res<WorldMap>,
100 + mut changes: MessageReader<TileChanged>,
101 + mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
102 +) {
103 + let origins: Vec<UVec2> = changes
104 + .read()
105 + .map(|TileChanged(tile)| chunk_origin(*tile))
106 + .collect();
107 + for (chunk, mut data) in &mut chunks {
108 + if origins.contains(&chunk.origin) {
109 + data.0 = chunk.layer.tiles(&map, chunk.origin);
110 + }
111 + }
112 +}
113 +
114 +pub fn chunk_origin(tile: UVec2) -> UVec2 {
115 + tile / CHUNK_SIZE * CHUNK_SIZE
116 +}
117 +
93 118 fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> {
94 119 (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
95 120 }
@@ -273,6 +298,12 @@ mod tests {
273 298 }
274 299
275 300 #[test]
301 + fn chunk_origin_rounds_down_to_chunk_grid() {
302 + assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0));
303 + assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128));
304 + }
305 +
306 + #[test]
276 307 fn tileset_has_a_layer_for_every_texture() {
277 308 let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect();
278 309 let image = stack_tileset(textures);

src/world.rs +6 -0

@@ -88,6 +88,12 @@ impl Plant {
88 88 }
89 89 }
90 90
91 +#[derive(Component, Default, Debug, Clone, Copy, PartialEq)]
92 +pub struct TilePos(pub UVec2);
93 +
94 +#[derive(Message, Debug, Clone, Copy, PartialEq)]
95 +pub struct TileChanged(pub UVec2);
96 +
91 97 #[derive(Resource, Clone, Debug)]
92 98 pub struct WorldMap {
93 99 pub size: UVec2,

tasks.md +5 -1

@@ -12,10 +12,14 @@
12 12 - [x] Procedural world generation (noise elevation/fertility, seeds, R to regenerate, spawn in largest walkable region)
13 13 - [x] Heightmap: hill shading, uphill movement cost, height in tile info
14 14 - [x] Rivers (flow field), iron/gold ore veins, trees
15 +- [x] Job system: autonomous job choice from colony demand, reservations, manual orders override
16 +- [x] Woodcutting (auto when wood < goal, or right-click a tree), wood item piles
15 17
16 18 ## Planned
17 19 - [ ] Speed/pause buttons in UI
18 20 - [ ] Ignore map clicks over UI
19 -- [ ] Jobs: mining (stone/ore), woodcutting, building, hauling
21 +- [ ] Jobs: mining (stone/ore), building, hauling (wood to stockpile)
22 +- [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities
23 +- [ ] UI to edit colony goals (e.g. wood target)
20 24 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
21 25 - [ ] Basic UI (tile info, colonist panel)