Add traffic-worn dirt paths and paving

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 19:54 UTC
commit
a71d21b9233c1a0950d0d56c4101346ef03911c3
parent
6ecdca767b
tree
browse at this commit

13 files changed +339 -13

CLAUDE.md +3 -2

@@ -14,7 +14,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
14 14 - `src/actions.rs` `Action` message enum + `KEYBINDS` table (single source of truth for keys; `keyboard_actions` in PreUpdate after `InputSystems`). All non-camera input goes through actions: consumers read `MessageReader<Action>` (sim speed/pause, worldgen NewWorld, zones tool/Cancel, selection Cancel, ui ToggleUi). New keybind = add Action variant + KEYBINDS entry + label + TOOLBAR entry (test enforces every keybind has a button).
15 15 - `src/ui.rs` `HudPlugin`: bottom toolbar of `ui_widgets::Button`s (`ActivateOnPress` — click-on-release was flaky when the pointer moves and clicks in one frame) with `ActionButton(Action)`; global `On<Activate>` observer writes the action. `style_buttons` highlights active state (`is_active`), hover, pressed. `UiVisible` + `HideableUi` marker (toolbar, info panel) toggled by H/button; `ShowUiButton` appears when hidden. `PointerOverUi` from picking `HoverMap` (any hovered entity with `Node`; layout containers are `Pickable::IGNORE`); `update_cursor` clears world/tile and selection/orders/zone tool ignore clicks while over UI.
16 16 - `src/main.rs` app wiring: DefaultPlugins (nearest sampling, canvas `#bevy`, fit_canvas_to_parent), adds plugins.
17 -- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable; Gravel = mined floor, never generated), `Plant` enum (Tree, BerryBush, Crop; separate layer with per-tile growth 0..1, adds move cost; `ripe_plant`). Worldgen scatters berry bushes (2%) with random growth. `WorldMap` resource: per-tile terrain, `Option<Plant>`, wall flag (blocks movement), roof flag, 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.
17 +- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable; Gravel = mined floor, Path/Paved from traffic, never generated), `Plant` enum (Tree, BerryBush, Crop; separate layer with per-tile growth 0..1, adds move cost; `ripe_plant`). Worldgen scatters berry bushes (2%) with random growth. `WorldMap` resource: per-tile terrain, `Option<Plant>`, wall flag (blocks movement), roof flag, plant growth, traffic wear, 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.
18 18 - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1].
19 19 - `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.
20 20 - `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`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, 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).
@@ -29,11 +29,12 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
29 29 - `src/light.rs` day/night lighting. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `light_at(kind, source, tile)` falloff from `StructureKind::light()` (None for non-emitters). `LightMap` resource = ambient + per-tile source light, recomputed in `update_light_map` (SimSystems::Decide) only when the ambient step (16 levels) changes or structures are added/removed; default (empty) = fully lit. `is_lit` = brightness ≥ LIT_BRIGHTNESS. Tilemap tints terrain/plant tiles by `LightMap::color` and refreshes all chunks on `LightMap` change (zones overlay unlit, colonists/items/structures not darkened).
30 30 - `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated.
31 31 - `src/needs.rs` `Rest(f32)` on colonists (1 = rested): `update_rest` (SimSystems::Act) drains 1/24 per game hour awake, recovers 1/7 per hour (1/5 in a bed, ×ROOF_REST_BONUS 1.25 under a roof) while `is_sleeping` (Sleep job, at spot, path empty). `wants_sleep`: rest < NAP_REST (0.3) any time, or night (21–6) and rest < BEDTIME_REST (0.9). `done_sleeping`: rest full and not night (sleep lasts until morning). EXHAUSTED_REST (0.1) makes choose_jobs drop autonomous (not ordered) jobs. `SleepContext::spot` = nearest bed not reserved as `Target::SleepSpot`, else nearest free tile (dry, no plant/stockpile/fire/item, unreserved `Target::SleepSpot`) to the nearest campfire, else home. Sleeping colonists show a `SleepMarker` Text2d "z" child.
32 -- `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home.
32 +- `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof, Pave}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no non-Pave blueprints; merges its plan into Blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home.
33 33 - `src/food.rs` `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition.
34 34 - `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes.
35 35 - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160.
36 36 - `src/farming.rs` `plan_farm` (every 600 ticks, once a stockpile exists and no farm): `FarmContext::plan` picks the FARM_SIZE² square near home (BFS corners) maximising soil − distance where every tile is farmable, plant-free and ≥ FARM_CLEARANCE from stockpile, blueprints, structures and items.
37 +- `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4.
37 38 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
38 39
39 40 ## Gotchas

src/building.rs +9 -2

@@ -34,12 +34,14 @@ const MAX_ROOM_MARGIN: i32 = 2;
34 34 const CRAMPED_SIDE_COST: i32 = 3;
35 35 const WALL_BLUEPRINT_COLOR: Color = Color::srgba(0.5, 0.75, 1.0, 0.8);
36 36 const DOOR_BLUEPRINT_COLOR: Color = Color::srgba(1.0, 0.8, 0.4, 0.8);
37 +const PAVE_BLUEPRINT_COLOR: Color = Color::srgba(0.85, 0.85, 0.85, 0.5);
37 38 const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35);
38 39
39 40 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40 41 pub enum BuildKind {
41 42 Structure(StructureKind),
42 43 Roof,
44 + Pave,
43 45 }
44 46
45 47 impl BuildKind {
@@ -47,6 +49,7 @@ impl BuildKind {
47 49 match self {
48 50 BuildKind::Structure(kind) => kind.cost(),
49 51 BuildKind::Roof => (ItemKind::Wood, 1),
52 + BuildKind::Pave => (ItemKind::Stone, 1),
50 53 }
51 54 }
52 55
@@ -58,6 +61,7 @@ impl BuildKind {
58 61 BuildKind::Structure(StructureKind::Wall) => 300,
59 62 BuildKind::Structure(StructureKind::Door) => 300,
60 63 BuildKind::Roof => 120,
64 + BuildKind::Pave => 120,
61 65 }
62 66 }
63 67
@@ -65,6 +69,7 @@ impl BuildKind {
65 69 match self {
66 70 BuildKind::Structure(kind) => kind.name(),
67 71 BuildKind::Roof => "roof",
72 + BuildKind::Pave => "paving",
68 73 }
69 74 }
70 75
@@ -234,7 +239,8 @@ pub fn plan_shelter(
234 239 items: Query<&TilePos, With<Item>>,
235 240 mut blueprints: ResMut<Blueprints>,
236 241 ) {
237 - if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || !blueprints.0.is_empty() {
242 + let shelter_pending = blueprints.0.values().any(|k| *k != BuildKind::Pave);
243 + if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || shelter_pending {
238 244 return;
239 245 }
240 246 let structures: Vec<(UVec2, StructureKind)> =
@@ -264,7 +270,7 @@ pub fn plan_shelter(
264 270 zones.remove_tile(*tile);
265 271 }
266 272 }
267 - blueprints.0 = plan;
273 + blueprints.0.extend(plan);
268 274 }
269 275 }
270 276
@@ -273,6 +279,7 @@ fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) {
273 279 let color = match kind {
274 280 BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR,
275 281 BuildKind::Roof => ROOF_BLUEPRINT_COLOR,
282 + BuildKind::Pave => PAVE_BLUEPRINT_COLOR,
276 283 _ => WALL_BLUEPRINT_COLOR,
277 284 };
278 285 gizmos.rect_2d(

src/colonist.rs +43 -3

@@ -89,10 +89,16 @@ pub struct MoveSpeed(pub f32);
89 89
90 90 pub fn follow_path(
91 91 time: Res<Time>,
92 - map: Res<WorldMap>,
93 - mut movers: Query<(&mut TilePos, &mut Path, &mut SimPos, Option<&MoveSpeed>)>,
92 + mut map: ResMut<WorldMap>,
93 + mut movers: Query<(
94 + &mut TilePos,
95 + &mut Path,
96 + &mut SimPos,
97 + Option<&MoveSpeed>,
98 + Has<Colonist>,
99 + )>,
94 100 ) {
95 - for (mut tile, mut path, mut pos, speed) in &mut movers {
101 + for (mut tile, mut path, mut pos, speed, is_colonist) in &mut movers {
96 102 let Some(&next) = path.0.front() else {
97 103 continue;
98 104 };
@@ -108,6 +114,10 @@ pub fn follow_path(
108 114 if arrived {
109 115 tile.0 = next;
110 116 path.0.pop_front();
117 + if is_colonist {
118 + let wear = map.wear(next.as_ivec2());
119 + map.set_wear(next, wear + 1.0);
120 + }
111 121 }
112 122 }
113 123 }
@@ -157,6 +167,36 @@ mod tests {
157 167 }
158 168
159 169 #[test]
170 + fn colonists_wear_down_the_ground_they_walk_on() {
171 + let mut app = App::new();
172 + app.insert_resource(WorldMap::filled(UVec2::new(10, 10), Terrain::Grass))
173 + .init_resource::<Time>()
174 + .add_systems(Update, follow_path);
175 + app.world_mut().spawn((
176 + Colonist {
177 + name: "Test".into(),
178 + },
179 + TilePos(UVec2::new(1, 1)),
180 + Path(VecDeque::from([UVec2::new(2, 1)])),
181 + sim_pos(WorldMap::tile_center(UVec2::new(1, 1))),
182 + ));
183 + app.world_mut().spawn((
184 + TilePos(UVec2::new(1, 3)),
185 + Path(VecDeque::from([UVec2::new(2, 3)])),
186 + sim_pos(WorldMap::tile_center(UVec2::new(1, 3))),
187 + ));
188 + for _ in 0..20 {
189 + app.world_mut()
190 + .resource_mut::<Time>()
191 + .advance_by(Duration::from_millis(50));
192 + app.update();
193 + }
194 + let map = app.world().resource::<WorldMap>();
195 + assert_eq!(map.wear(IVec2::new(2, 1)), 1.0);
196 + assert_eq!(map.wear(IVec2::new(2, 3)), 0.0);
197 + }
198 +
199 + #[test]
160 200 fn colonists_walk_their_path_and_update_tile_pos() {
161 201 let mut app = App::new();
162 202 app.insert_resource(WorldMap::filled(UVec2::new(10, 10), Terrain::Grass))

src/jobs/mod.rs +4 -0

@@ -1355,6 +1355,10 @@ fn run_jobs(
1355 1355 map.set_roof(site, true);
1356 1356 changed.write(TileChanged(site));
1357 1357 }
1358 + BuildKind::Pave => {
1359 + map.set(site, Terrain::Paved);
1360 + changed.write(TileChanged(site));
1361 + }
1358 1362 }
1359 1363 blueprints.0.remove(&site);
1360 1364 Step::Done

src/jobs/tests.rs +23 -0

@@ -993,3 +993,26 @@ fn builders_gather_material_from_several_piles() {
993 993 assert_eq!(beds(&mut app), 1);
994 994 assert_eq!(wood_on_map(&mut app), 20 - StructureKind::Bed.cost().1);
995 995 }
996 +
997 +#[test]
998 +fn colonists_pave_planned_paths_with_stone() {
999 + let mut app = test_app(map_with_trees(&[]));
1000 + set_goals(&mut app, &[]);
1001 + put_beds(&mut app, 1);
1002 + let tile = UVec2::new(8, 8);
1003 + app.world_mut()
1004 + .resource_mut::<WorldMap>()
1005 + .set(tile, Terrain::Path);
1006 + app.world_mut()
1007 + .resource_mut::<Blueprints>()
1008 + .0
1009 + .insert(tile, BuildKind::Pave);
1010 + put_items(&mut app, UVec2::new(2, 2), ItemKind::Stone, 5);
1011 + spawn_colonist(&mut app, UVec2::new(4, 4));
1012 + run_ticks(&mut app, 60 * 10);
1013 + assert_eq!(
1014 + app.world().resource::<WorldMap>().get(tile.as_ivec2()),
1015 + Some(Terrain::Paved)
1016 + );
1017 + assert_eq!(items_of(&mut app, ItemKind::Stone), 4);
1018 +}

src/main.rs +2 -0

@@ -11,6 +11,7 @@ mod light;
11 11 mod needs;
12 12 mod noise;
13 13 mod pathfinding;
14 +mod paths;
14 15 mod plants;
15 16 mod rng;
16 17 mod selection;
@@ -58,6 +59,7 @@ fn main() {
58 59 food::FoodPlugin,
59 60 animals::AnimalsPlugin,
60 61 farming::FarmingPlugin,
62 + paths::PathsPlugin,
61 63 plants::PlantsPlugin,
62 64 selection::SelectionPlugin,
63 65 zones::ZonesPlugin,

src/pathfinding.rs +15 -2

@@ -5,11 +5,11 @@ use std::{
5 5
6 6 use bevy::prelude::*;
7 7
8 -use crate::world::WorldMap;
8 +use crate::world::{CHEAPEST_MOVE_COST, WorldMap};
9 9
10 10 const STRAIGHT: u32 = 10;
11 11 const DIAGONAL: u32 = 14;
12 -const MIN_TERRAIN_COST: u32 = 100;
12 +const MIN_TERRAIN_COST: u32 = CHEAPEST_MOVE_COST;
13 13
14 14 const NEIGHBORS: [IVec2; 8] = [
15 15 IVec2::new(1, 0),
@@ -258,6 +258,19 @@ mod tests {
258 258 }
259 259
260 260 #[test]
261 + fn detours_along_paths_when_they_are_faster() {
262 + let mut map = map_from(&[
263 + "..........", //
264 + "..........",
265 + ]);
266 + for x in 0..10 {
267 + map.set(UVec2::new(x, 1), Terrain::Paved);
268 + }
269 + let path = find_path(&map, UVec2::new(0, 0), UVec2::new(9, 0)).unwrap();
270 + assert!(path.iter().filter(|p| p.y == 1).count() >= 6);
271 + }
272 +
273 + #[test]
261 274 fn crosses_slow_terrain_when_it_is_the_only_way() {
262 275 let map = map_from(&["..~.."]);
263 276 let path = find_path(&map, UVec2::new(0, 0), UVec2::new(4, 0)).unwrap();

src/paths.rs +183 -0

@@ -0,0 +1,183 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + building::{Blueprints, BuildKind},
5 + colonist::Colonist,
6 + sim::{SimSystems, SimTick, TICKS_PER_HOUR},
7 + structures::Structure,
8 + world::{Terrain, TileChanged, TilePos, WorldMap},
9 + zones::Zones,
10 +};
11 +
12 +pub struct PathsPlugin;
13 +
14 +impl Plugin for PathsPlugin {
15 + fn build(&self, app: &mut App) {
16 + app.add_systems(
17 + FixedUpdate,
18 + (
19 + plan_paving.in_set(SimSystems::Decide),
20 + update_paths.in_set(SimSystems::Act),
21 + ),
22 + );
23 + }
24 +}
25 +
26 +pub const PATH_WEAR: f32 = 8.0;
27 +pub const PAVE_WEAR: f32 = 20.0;
28 +pub const REGROW_WEAR: f32 = 1.0;
29 +pub const WEAR_KEPT_PER_HOUR: f32 = 0.98;
30 +pub const PAVE_BATCH: usize = 8;
31 +const UPDATE_INTERVAL_TICKS: u64 = TICKS_PER_HOUR;
32 +
33 +pub fn wears_into_path(terrain: Terrain) -> bool {
34 + matches!(terrain, Terrain::Grass | Terrain::Soil | Terrain::Sand)
35 +}
36 +
37 +pub fn evolve(map: &mut WorldMap, zones: &Zones, mut on_change: impl FnMut(UVec2)) {
38 + for pos in map.positions() {
39 + let p = pos.as_ivec2();
40 + let wear = map.wear(p) * WEAR_KEPT_PER_HOUR;
41 + map.set_wear(pos, wear);
42 + let Some(terrain) = map.get(p) else {
43 + continue;
44 + };
45 + let becomes = if wears_into_path(terrain)
46 + && wear >= PATH_WEAR
47 + && map.plant(p).is_none()
48 + && !zones.is_farm(pos)
49 + {
50 + Terrain::Path
51 + } else if terrain == Terrain::Path && wear < REGROW_WEAR {
52 + Terrain::Grass
53 + } else {
54 + continue;
55 + };
56 + map.set(pos, becomes);
57 + on_change(pos);
58 + }
59 +}
60 +
61 +fn update_paths(
62 + tick: Res<SimTick>,
63 + zones: Res<Zones>,
64 + mut map: ResMut<WorldMap>,
65 + mut changed: MessageWriter<TileChanged>,
66 +) {
67 + if !tick.0.is_multiple_of(UPDATE_INTERVAL_TICKS) {
68 + return;
69 + }
70 + evolve(&mut map, &zones, |pos| {
71 + changed.write(TileChanged(pos));
72 + });
73 +}
74 +
75 +pub fn paving_candidates(
76 + map: &WorldMap,
77 + blueprints: &Blueprints,
78 + occupied: &[UVec2],
79 +) -> Vec<UVec2> {
80 + let pending = blueprints
81 + .0
82 + .values()
83 + .filter(|k| **k == BuildKind::Pave)
84 + .count();
85 + let mut busiest: Vec<UVec2> = map
86 + .positions()
87 + .filter(|&p| {
88 + map.get(p.as_ivec2()) == Some(Terrain::Path)
89 + && map.wear(p.as_ivec2()) >= PAVE_WEAR
90 + && blueprints.get(p).is_none()
91 + && !occupied.contains(&p)
92 + })
93 + .collect();
94 + busiest.sort_by(|a, b| {
95 + map.wear(b.as_ivec2())
96 + .total_cmp(&map.wear(a.as_ivec2()))
97 + .then((a.y, a.x).cmp(&(b.y, b.x)))
98 + });
99 + busiest.truncate(PAVE_BATCH.saturating_sub(pending));
100 + busiest
101 +}
102 +
103 +fn plan_paving(
104 + tick: Res<SimTick>,
105 + map: Res<WorldMap>,
106 + structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
107 + mut blueprints: ResMut<Blueprints>,
108 +) {
109 + if !tick.0.is_multiple_of(UPDATE_INTERVAL_TICKS) {
110 + return;
111 + }
112 + let occupied: Vec<UVec2> = structures.iter().map(|p| p.0).collect();
113 + for tile in paving_candidates(&map, &blueprints, &occupied) {
114 + blueprints.0.insert(tile, BuildKind::Pave);
115 + }
116 +}
117 +
118 +#[cfg(test)]
119 +mod tests {
120 + use super::*;
121 +
122 + fn worn(terrain: Terrain, wear: f32) -> WorldMap {
123 + let mut map = WorldMap::filled(UVec2::splat(3), terrain);
124 + map.set_wear(UVec2::ONE, wear);
125 + map
126 + }
127 +
128 + #[test]
129 + fn busy_ground_wears_into_a_path() {
130 + let mut map = worn(Terrain::Grass, PATH_WEAR * 2.0);
131 + let mut changes = 0;
132 + evolve(&mut map, &Zones::default(), |_| changes += 1);
133 + assert_eq!(map.get(IVec2::ONE), Some(Terrain::Path));
134 + assert_eq!(map.get(IVec2::ZERO), Some(Terrain::Grass));
135 + assert_eq!(changes, 1);
136 + }
137 +
138 + #[test]
139 + fn farms_plants_and_rock_floors_keep_their_ground() {
140 + let mut farm_map = worn(Terrain::Soil, PATH_WEAR * 2.0);
141 + let mut zones = Zones::default();
142 + zones.add_farm_tiles(&farm_map, [UVec2::ONE]);
143 + evolve(&mut farm_map, &zones, |_| {});
144 + assert_eq!(farm_map.get(IVec2::ONE), Some(Terrain::Soil));
145 +
146 + let mut bush_map = worn(Terrain::Grass, PATH_WEAR * 2.0);
147 + bush_map.set_plant(UVec2::ONE, Some(crate::world::Plant::BerryBush));
148 + evolve(&mut bush_map, &Zones::default(), |_| {});
149 + assert_eq!(bush_map.get(IVec2::ONE), Some(Terrain::Grass));
150 +
151 + let mut gravel = worn(Terrain::Gravel, PATH_WEAR * 2.0);
152 + evolve(&mut gravel, &Zones::default(), |_| {});
153 + assert_eq!(gravel.get(IVec2::ONE), Some(Terrain::Gravel));
154 + }
155 +
156 + #[test]
157 + fn unused_paths_grow_back() {
158 + let mut map = worn(Terrain::Path, 0.5);
159 + evolve(&mut map, &Zones::default(), |_| {});
160 + assert_eq!(map.get(IVec2::ONE), Some(Terrain::Grass));
161 + }
162 +
163 + #[test]
164 + fn wear_fades_over_time() {
165 + let mut map = worn(Terrain::Grass, 10.0);
166 + evolve(&mut map, &Zones::default(), |_| {});
167 + assert!(map.wear(IVec2::ONE) < 10.0);
168 + }
169 +
170 + #[test]
171 + fn the_busiest_paths_are_paved_first_in_batches() {
172 + let mut map = WorldMap::filled(UVec2::new(20, 1), Terrain::Path);
173 + for x in 0..20 {
174 + map.set_wear(UVec2::new(x, 0), PAVE_WEAR + x as f32);
175 + }
176 + let mut blueprints = Blueprints::default();
177 + blueprints.0.insert(UVec2::new(19, 0), BuildKind::Pave);
178 + let picked = paving_candidates(&map, &blueprints, &[UVec2::new(18, 0)]);
179 + assert_eq!(picked.len(), PAVE_BATCH - 1);
180 + assert_eq!(picked[0], UVec2::new(17, 0));
181 + assert!(!picked.contains(&UVec2::new(18, 0)));
182 + }
183 +}

src/selection.rs +8 -1

@@ -358,7 +358,14 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
358 358 })
359 359 .unwrap_or_default();
360 360 let height = map.height(p).unwrap_or(0.0);
361 - format!("({}, {}) {terrain:?}{plant}{roof}, {height:.0} m", t.x, t.y)
361 + let wear = match map.wear(p) {
362 + w if w >= 1.0 => format!(", worn {w:.0}"),
363 + _ => String::new(),
364 + };
365 + format!(
366 + "({}, {}) {terrain:?}{plant}{roof}, {height:.0} m{wear}",
367 + t.x, t.y
368 + )
362 369 }
363 370
364 371 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {

src/tilemap.rs +8 -1

@@ -259,7 +259,14 @@ fn terrain_texture(terrain: Terrain) -> Texture {
259 259 .speck_color()
260 260 .filter(|_| hash2(i / 2 + i / 32, layer) % 5 == 0);
261 261 let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0;
262 - let c = speck.unwrap_or(base);
262 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
263 + let mortar = terrain == Terrain::Paved
264 + && (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0);
265 + let c = if mortar {
266 + Srgba::rgb_u8(110, 106, 100)
267 + } else {
268 + speck.unwrap_or(base)
269 + };
263 270 Srgba::new(c.red + jitter, c.green + jitter, c.blue + jitter, 1.0).to_u8_array()
264 271 })
265 272 .collect()

src/world.rs +37 -1

@@ -3,6 +3,7 @@ use bevy::prelude::*;
3 3 pub const TILE_SIZE: f32 = 16.0;
4 4 pub const MAX_HEIGHT: f32 = 100.0;
5 5 pub const UPHILL_COST_PER_METER: f32 = 20.0;
6 +pub const CHEAPEST_MOVE_COST: u32 = 60;
6 7
7 8 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
8 9 pub enum Terrain {
@@ -15,10 +16,12 @@ pub enum Terrain {
15 16 IronOre,
16 17 GoldOre,
17 18 Gravel,
19 + Path,
20 + Paved,
18 21 }
19 22
20 23 impl Terrain {
21 - pub const ALL: [Terrain; 9] = [
24 + pub const ALL: [Terrain; 11] = [
22 25 Terrain::Grass,
23 26 Terrain::Soil,
24 27 Terrain::Sand,
@@ -28,6 +31,8 @@ impl Terrain {
28 31 Terrain::IronOre,
29 32 Terrain::GoldOre,
30 33 Terrain::Gravel,
34 + Terrain::Path,
35 + Terrain::Paved,
31 36 ];
32 37
33 38 pub fn index(self) -> u16 {
@@ -36,6 +41,8 @@ impl Terrain {
36 41
37 42 pub fn move_cost(self) -> Option<u32> {
38 43 match self {
44 + Terrain::Paved => Some(CHEAPEST_MOVE_COST),
45 + Terrain::Path => Some(80),
39 46 Terrain::Grass | Terrain::Soil => Some(100),
40 47 Terrain::Sand | Terrain::Gravel => Some(130),
41 48 Terrain::ShallowWater => Some(300),
@@ -60,6 +67,8 @@ impl Terrain {
60 67 Terrain::DeepWater => Srgba::rgb_u8(36, 76, 140),
61 68 Terrain::Stone | Terrain::IronOre | Terrain::GoldOre => Srgba::rgb_u8(118, 118, 124),
62 69 Terrain::Gravel => Srgba::rgb_u8(150, 146, 138),
70 + Terrain::Path => Srgba::rgb_u8(160, 128, 86),
71 + Terrain::Paved => Srgba::rgb_u8(168, 164, 156),
63 72 }
64 73 }
65 74
@@ -67,6 +76,7 @@ impl Terrain {
67 76 match self {
68 77 Terrain::IronOre => Some(Srgba::rgb_u8(178, 104, 72)),
69 78 Terrain::GoldOre => Some(Srgba::rgb_u8(236, 196, 60)),
79 + Terrain::Path => Some(Srgba::rgb_u8(130, 104, 70)),
70 80 _ => None,
71 81 }
72 82 }
@@ -117,6 +127,7 @@ pub struct WorldMap {
117 127 walls: Vec<bool>,
118 128 roofs: Vec<bool>,
119 129 growth: Vec<f32>,
130 + wear: Vec<f32>,
120 131 }
121 132
122 133 impl WorldMap {
@@ -130,6 +141,7 @@ impl WorldMap {
130 141 walls: vec![false; n],
131 142 roofs: vec![false; n],
132 143 growth: vec![0.0; n],
144 + wear: vec![0.0; n],
133 145 }
134 146 }
135 147
@@ -194,6 +206,19 @@ impl WorldMap {
194 206 self.growth[i] = growth.clamp(0.0, 1.0);
195 207 }
196 208
209 + pub fn wear(&self, pos: IVec2) -> f32 {
210 + if self.in_bounds(pos) {
211 + self.wear[self.index(pos.as_uvec2())]
212 + } else {
213 + 0.0
214 + }
215 + }
216 +
217 + pub fn set_wear(&mut self, pos: UVec2, wear: f32) {
218 + let i = self.index(pos);
219 + self.wear[i] = wear.max(0.0);
220 + }
221 +
197 222 pub fn ripe_plant(&self, pos: IVec2) -> Option<Plant> {
198 223 self.plant(pos).filter(|p| p.is_ripe(self.growth(pos)))
199 224 }
@@ -298,6 +323,17 @@ mod tests {
298 323 assert!(!Plant::Tree.is_ripe(1.0));
299 324 }
300 325
326 + // A* uses CHEAPEST_MOVE_COST in its distance estimate; if any terrain were cheaper the
327 + // estimate would overshoot and colonists could take needlessly long routes.
328 + #[test]
329 + fn no_terrain_is_cheaper_than_the_pathfinding_estimate() {
330 + for terrain in Terrain::ALL {
331 + if let Some(cost) = terrain.move_cost() {
332 + assert!(cost >= CHEAPEST_MOVE_COST, "{terrain:?}");
333 + }
334 + }
335 + }
336 +
301 337 #[test]
302 338 fn walls_block_movement() {
303 339 let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass);

src/worldgen.rs +3 -1

@@ -374,7 +374,9 @@ mod tests {
374 374 .map(|&t| share(&map, t))
375 375 .sum();
376 376 assert!((rock - STONE_SHARE).abs() < 0.02);
377 - for terrain in Terrain::ALL.into_iter().filter(|&t| t != Terrain::Gravel) {
377 + let generated =
378 + |t: &Terrain| !matches!(t, Terrain::Gravel | Terrain::Path | Terrain::Paved);
379 + for terrain in Terrain::ALL.into_iter().filter(generated) {
378 380 assert!(share(&map, terrain) > 0.0, "seed {seed} lacks {terrain:?}");
379 381 }
380 382 }

tasks.md +1 -0

@@ -22,6 +22,7 @@
22 22 - [x] Beds: built autonomously (one per colonist, 15 wood), preferred for sleep, faster rest
23 23 - [x] Walls (stone), doors (wood), roofs: colonists plan and build a roofed shelter around their beds; roofs improve rest
24 24 - [x] Food: hunger need and eating (meals preferred), berry bushes + foraging, wild animals + hunting, farm zones (auto-planned or F tool) with sowing/growing/harvesting, cooking meals at campfires
25 +- [x] Paths: traffic wears grass into dirt paths (faster, regrow when unused); busiest paths get paved with stone
25 26 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
26 27
27 28 ## Planned