Add walls, doors and roofs with autonomous shelter building

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 10:55 UTC
commit
8c7d843711301b7947aa2717ee404b892b49117d
parent
bdeba7cc88
tree
browse at this commit

11 files changed +758 -72

CLAUDE.md +7 -6

@@ -14,21 +14,22 @@ 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; 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.
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; separate layer, adds move cost). `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.
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 -- `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).
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, `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).
21 21 - `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.
22 22 - `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `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, per-material stock/goal, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile/Build (`progress_fraction`). Clock shows day/dawn/dusk/night; hover shows structures with remaining fuel. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
23 23 - `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.
24 24 - `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.
25 -- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted ones skip the idle timer), priority: sleep if `wants_sleep` → deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → `find_bed_site` (one bed per colonist incl. planned; next to nearest bed, else campfire, else home) → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work).
25 +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted ones skip the idle timer), priority: sleep if `wants_sleep` → deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → `find_bed_site` (one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile tiles so a wall ring fits) → `find_blueprint_work` (nearest unreserved blueprint with material available) → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work).
26 26 - `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold}, count}` + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated.
27 -- `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion` grows one tile at a time next to stockpile/planned tiles, preferring tree-free tiles near the stockpile centroid (deterministic tiebreak). `find_stockpile_site` = BFS for nearest fully open (zonable, tree-free) square, fallback any zonable tile. `ZoneTool` (Z/button toggles, Cancel exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`). Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`.
27 +- `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion(map, zones, excluded, count)` (excluded = blueprint tiles) grows one tile at a time next to stockpile/planned tiles, preferring tree-free tiles near the stockpile centroid (deterministic tiebreak). `find_stockpile_site` = BFS for nearest fully open (zonable, tree-free) square, fallback any zonable tile. `ZoneTool` (Z/button toggles, Cancel exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`). Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`.
28 28 - `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.
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 -- `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed}}` + 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: wood_cost 10/2/15, `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; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated.
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) 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.
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 +- `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 33 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
33 34
34 35 ## Gotchas

src/building.rs +392 -0

@@ -0,0 +1,392 @@
1 +use std::collections::{HashMap, HashSet, VecDeque};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + colonist::Colonist,
7 + items::{Item, ItemKind},
8 + sim::{SimSystems, SimTick},
9 + structures::{Structure, StructureKind},
10 + world::{TILE_SIZE, TilePos, WorldMap},
11 + worldgen::WorldGenerated,
12 + zones::{Zones, can_zone},
13 +};
14 +
15 +pub struct BuildingPlugin;
16 +
17 +impl Plugin for BuildingPlugin {
18 + fn build(&self, app: &mut App) {
19 + app.init_resource::<Blueprints>()
20 + .add_systems(
21 + Update,
22 + (
23 + clear_blueprints.run_if(on_message::<WorldGenerated>),
24 + draw_blueprints,
25 + ),
26 + )
27 + .add_systems(FixedUpdate, plan_shelter.in_set(SimSystems::Decide));
28 + }
29 +}
30 +
31 +pub const MAX_ROOM_TILES: usize = 120;
32 +const PLAN_INTERVAL_TICKS: u64 = 300;
33 +const MAX_ROOM_MARGIN: i32 = 2;
34 +const CRAMPED_SIDE_COST: i32 = 3;
35 +const WALL_BLUEPRINT_COLOR: Color = Color::srgba(0.5, 0.75, 1.0, 0.8);
36 +const DOOR_BLUEPRINT_COLOR: Color = Color::srgba(1.0, 0.8, 0.4, 0.8);
37 +const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35);
38 +
39 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40 +pub enum BuildKind {
41 + Structure(StructureKind),
42 + Roof,
43 +}
44 +
45 +impl BuildKind {
46 + pub fn cost(self) -> (ItemKind, u32) {
47 + match self {
48 + BuildKind::Structure(kind) => kind.cost(),
49 + BuildKind::Roof => (ItemKind::Wood, 1),
50 + }
51 + }
52 +
53 + pub fn work(self) -> u32 {
54 + match self {
55 + BuildKind::Structure(StructureKind::Campfire) => 360,
56 + BuildKind::Structure(StructureKind::Torch) => 120,
57 + BuildKind::Structure(StructureKind::Bed) => 600,
58 + BuildKind::Structure(StructureKind::Wall) => 300,
59 + BuildKind::Structure(StructureKind::Door) => 300,
60 + BuildKind::Roof => 120,
61 + }
62 + }
63 +
64 + pub fn name(self) -> &'static str {
65 + match self {
66 + BuildKind::Structure(kind) => kind.name(),
67 + BuildKind::Roof => "roof",
68 + }
69 + }
70 +
71 + pub fn blocks_tile(self) -> bool {
72 + self == BuildKind::Structure(StructureKind::Wall)
73 + }
74 +}
75 +
76 +#[derive(Resource, Default, Debug, Clone)]
77 +pub struct Blueprints(pub HashMap<UVec2, BuildKind>);
78 +
79 +impl Blueprints {
80 + pub fn get(&self, tile: UVec2) -> Option<BuildKind> {
81 + self.0.get(&tile).copied()
82 + }
83 +
84 + pub fn tiles(&self) -> HashSet<UVec2> {
85 + self.0.keys().copied().collect()
86 + }
87 +}
88 +
89 +fn clear_blueprints(mut blueprints: ResMut<Blueprints>) {
90 + blueprints.0.clear();
91 +}
92 +
93 +pub fn enclosed_room(map: &WorldMap, doors: &HashSet<UVec2>, start: UVec2) -> Option<Vec<UVec2>> {
94 + let mut seen = HashSet::from([start]);
95 + let mut queue = VecDeque::from([start]);
96 + while let Some(tile) = queue.pop_front() {
97 + if seen.len() > MAX_ROOM_TILES {
98 + return None;
99 + }
100 + for step in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] {
101 + let next = tile.as_ivec2() + step;
102 + if !map.in_bounds(next) {
103 + return None;
104 + }
105 + let next = next.as_uvec2();
106 + let barrier = map.is_wall(next.as_ivec2())
107 + || doors.contains(&next)
108 + || map.get(next.as_ivec2()).is_some_and(|t| t.is_rock());
109 + if !barrier && seen.insert(next) {
110 + queue.push_back(next);
111 + }
112 + }
113 + }
114 + let mut room: Vec<UVec2> = seen.into_iter().collect();
115 + room.sort_by_key(|t| (t.y, t.x));
116 + Some(room)
117 +}
118 +
119 +pub struct ShelterContext<'a> {
120 + pub map: &'a WorldMap,
121 + pub structures: &'a [(UVec2, StructureKind)],
122 + pub items: &'a [UVec2],
123 +}
124 +
125 +impl ShelterContext<'_> {
126 + fn doors(&self) -> HashSet<UVec2> {
127 + self.structures
128 + .iter()
129 + .filter(|(_, kind)| *kind == StructureKind::Door)
130 + .map(|(pos, _)| *pos)
131 + .collect()
132 + }
133 +
134 + fn wall_buildable(&self, tile: IVec2) -> bool {
135 + can_zone(self.map, tile)
136 + && self.map.plant(tile).is_none()
137 + && !self.items.contains(&tile.as_uvec2())
138 + && !self
139 + .structures
140 + .iter()
141 + .any(|(pos, _)| *pos == tile.as_uvec2())
142 + }
143 +
144 + fn already_closed(&self, tile: IVec2, doors: &HashSet<UVec2>) -> bool {
145 + self.map.is_wall(tile)
146 + || doors.contains(&tile.as_uvec2())
147 + || self.map.get(tile).is_some_and(|t| t.is_rock())
148 + }
149 +
150 + pub fn plan(&self, beds: &[UVec2], toward: UVec2) -> Option<HashMap<UVec2, BuildKind>> {
151 + let doors = self.doors();
152 + if let Some(&first) = beds.first()
153 + && let Some(room) = enclosed_room(self.map, &doors, first)
154 + {
155 + let roofs: HashMap<UVec2, BuildKind> = room
156 + .into_iter()
157 + .filter(|t| !self.map.is_roofed(t.as_ivec2()))
158 + .map(|t| (t, BuildKind::Roof))
159 + .collect();
160 + return (!roofs.is_empty()).then_some(roofs);
161 + }
162 + let min = beds.iter().copied().reduce(|a, b| a.min(b))?.as_ivec2();
163 + let max = beds.iter().copied().reduce(|a, b| a.max(b))?.as_ivec2();
164 + let margins = 0..=MAX_ROOM_MARGIN;
165 + let shapes = margins.clone().flat_map(|l| {
166 + let margins = margins.clone();
167 + margins.clone().flat_map(move |r| {
168 + let margins = margins.clone();
169 + margins
170 + .clone()
171 + .flat_map(move |d| margins.clone().map(move |u| IVec4::new(l, r, d, u)))
172 + })
173 + });
174 + shapes
175 + .filter_map(|m| {
176 + let lo = min - IVec2::new(m.x, m.z) - 1;
177 + let hi = max + IVec2::new(m.y, m.w) + 1;
178 + let plan = self.walls_around(lo, hi, toward, &doors)?;
179 + let cramped = m.to_array().iter().filter(|&&side| side == 0).count() as i32;
180 + let cost = plan.len() as i32 + CRAMPED_SIDE_COST * cramped;
181 + Some((cost, m.to_array(), plan))
182 + })
183 + .min_by_key(|(cost, margins, _)| (*cost, *margins))
184 + .map(|(_, _, plan)| plan)
185 + }
186 +
187 + fn walls_around(
188 + &self,
189 + lo: IVec2,
190 + hi: IVec2,
191 + toward: UVec2,
192 + doors: &HashSet<UVec2>,
193 + ) -> Option<HashMap<UVec2, BuildKind>> {
194 + let ring: Vec<IVec2> = (lo.y..=hi.y)
195 + .flat_map(|y| (lo.x..=hi.x).map(move |x| IVec2::new(x, y)))
196 + .filter(|t| t.x == lo.x || t.x == hi.x || t.y == lo.y || t.y == hi.y)
197 + .collect();
198 + let mut plan = HashMap::new();
199 + for &tile in &ring {
200 + if !self.map.in_bounds(tile) {
201 + return None;
202 + }
203 + if self.already_closed(tile, doors) {
204 + continue;
205 + }
206 + if !self.wall_buildable(tile) {
207 + return None;
208 + }
209 + plan.insert(tile.as_uvec2(), BuildKind::Structure(StructureKind::Wall));
210 + }
211 + let has_door = ring.iter().any(|t| doors.contains(&t.as_uvec2()));
212 + if !has_door {
213 + let corner = |t: &UVec2| {
214 + let t = t.as_ivec2();
215 + (t.x == lo.x || t.x == hi.x) && (t.y == lo.y || t.y == hi.y)
216 + };
217 + let door = plan.keys().filter(|t| !corner(t)).copied().min_by(|a, b| {
218 + a.as_vec2()
219 + .distance_squared(toward.as_vec2())
220 + .total_cmp(&b.as_vec2().distance_squared(toward.as_vec2()))
221 + .then((a.y, a.x).cmp(&(b.y, b.x)))
222 + })?;
223 + plan.insert(door, BuildKind::Structure(StructureKind::Door));
224 + }
225 + Some(plan)
226 + }
227 +}
228 +
229 +pub fn plan_shelter(
230 + tick: Res<SimTick>,
231 + map: Res<WorldMap>,
232 + mut zones: ResMut<Zones>,
233 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
234 + items: Query<&TilePos, With<Item>>,
235 + mut blueprints: ResMut<Blueprints>,
236 +) {
237 + if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || !blueprints.0.is_empty() {
238 + return;
239 + }
240 + let structures: Vec<(UVec2, StructureKind)> =
241 + structures.iter().map(|(pos, s)| (pos.0, s.kind)).collect();
242 + let beds: Vec<UVec2> = structures
243 + .iter()
244 + .filter(|(pos, kind)| *kind == StructureKind::Bed && !map.is_roofed(pos.as_ivec2()))
245 + .map(|(pos, _)| *pos)
246 + .collect();
247 + if beds.is_empty() {
248 + return;
249 + }
250 + let items: Vec<UVec2> = items.iter().map(|pos| pos.0).collect();
251 + let ctx = ShelterContext {
252 + map: &map,
253 + structures: &structures,
254 + items: &items,
255 + };
256 + let toward = structures
257 + .iter()
258 + .find(|(_, kind)| *kind == StructureKind::Campfire)
259 + .map(|(pos, _)| *pos)
260 + .unwrap_or_else(|| zones.home(beds[0]));
261 + if let Some(plan) = ctx.plan(&beds, toward) {
262 + for (tile, kind) in &plan {
263 + if *kind != BuildKind::Roof {
264 + zones.remove_tile(*tile);
265 + }
266 + }
267 + blueprints.0 = plan;
268 + }
269 +}
270 +
271 +fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) {
272 + for (tile, kind) in &blueprints.0 {
273 + let color = match kind {
274 + BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR,
275 + BuildKind::Roof => ROOF_BLUEPRINT_COLOR,
276 + _ => WALL_BLUEPRINT_COLOR,
277 + };
278 + gizmos.rect_2d(
279 + WorldMap::tile_center(*tile),
280 + Vec2::splat(TILE_SIZE * 0.85),
281 + color,
282 + );
283 + }
284 +}
285 +
286 +#[cfg(test)]
287 +mod tests {
288 + use super::*;
289 + use crate::world::Terrain;
290 +
291 + fn ctx<'a>(map: &'a WorldMap, structures: &'a [(UVec2, StructureKind)]) -> ShelterContext<'a> {
292 + ShelterContext {
293 + map,
294 + structures,
295 + items: &[],
296 + }
297 + }
298 +
299 + fn count(plan: &HashMap<UVec2, BuildKind>, kind: BuildKind) -> usize {
300 + plan.values().filter(|k| **k == kind).count()
301 + }
302 +
303 + const WALL: BuildKind = BuildKind::Structure(StructureKind::Wall);
304 + const DOOR: BuildKind = BuildKind::Structure(StructureKind::Door);
305 +
306 + #[test]
307 + fn shelter_is_a_wall_ring_with_one_door_around_the_beds() {
308 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
309 + let beds = [UVec2::new(10, 10), UVec2::new(11, 10)];
310 + let plan = ctx(&map, &[]).plan(&beds, UVec2::new(10, 2)).unwrap();
311 + assert_eq!(plan.len(), 6 * 5 - 4 * 3);
312 + assert_eq!(count(&plan, DOOR), 1);
313 + let door = plan.iter().find(|(_, k)| **k == DOOR).unwrap().0;
314 + assert_eq!(door.y, 8);
315 + }
316 +
317 + // Mountains are free walls: a shelter dug against a cliff only needs walls on the open sides.
318 + #[test]
319 + fn rock_counts_as_wall() {
320 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
321 + for y in 0..20 {
322 + map.set(UVec2::new(12, y), Terrain::Stone);
323 + }
324 + let plan = ctx(&map, &[])
325 + .plan(&[UVec2::new(10, 10)], UVec2::new(10, 2))
326 + .unwrap();
327 + assert!(plan.keys().all(|t| t.x != 12));
328 + assert_eq!(count(&plan, WALL) + count(&plan, DOOR), 16 - 5);
329 + }
330 +
331 + #[test]
332 + fn shelters_route_around_unbuildable_tiles() {
333 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
334 + map.set(UVec2::new(10, 8), Terrain::ShallowWater);
335 + let plan = ctx(&map, &[])
336 + .plan(&[UVec2::new(10, 10)], UVec2::new(10, 2))
337 + .unwrap();
338 + assert!(!plan.contains_key(&UVec2::new(10, 8)));
339 + assert_eq!(
340 + plan.values()
341 + .filter(|k| **k == BuildKind::Structure(StructureKind::Door))
342 + .count(),
343 + 1
344 + );
345 + }
346 +
347 + // Beds often end up right next to the stockpile; walls may take over empty stockpile tiles
348 + // (which then stop being storage) instead of the shelter never getting planned at all.
349 + #[test]
350 + fn walls_may_replace_empty_stockpile_tiles_but_not_stored_items() {
351 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
352 + let mut zones = Zones::default();
353 + zones.add_stockpile(&map, IVec2::new(12, 5), IVec2::new(15, 15));
354 + let items = [UVec2::new(13, 10)];
355 + let ctx = ShelterContext {
356 + map: &map,
357 + structures: &[],
358 + items: &items,
359 + };
360 + let plan = ctx.plan(&[UVec2::new(11, 10)], UVec2::new(5, 10)).unwrap();
361 + assert!(plan.keys().any(|t| zones.is_stockpile(*t)));
362 + assert!(!plan.contains_key(&UVec2::new(13, 10)));
363 + }
364 +
365 + #[test]
366 + fn enclosed_rooms_get_roof_blueprints() {
367 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
368 + for i in 3..=6 {
369 + for t in [
370 + UVec2::new(i, 3),
371 + UVec2::new(i, 6),
372 + UVec2::new(3, i),
373 + UVec2::new(6, i),
374 + ] {
375 + map.set_wall(t, true);
376 + }
377 + }
378 + map.set_wall(UVec2::new(4, 3), false);
379 + let structures = [(UVec2::new(4, 3), StructureKind::Door)];
380 + let plan = ctx(&map, &structures)
381 + .plan(&[UVec2::new(4, 4)], UVec2::ZERO)
382 + .unwrap();
383 + assert_eq!(plan.len(), 4);
384 + assert!(plan.values().all(|k| *k == BuildKind::Roof));
385 + }
386 +
387 + #[test]
388 + fn open_areas_are_not_rooms() {
389 + let map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
390 + assert!(enclosed_room(&map, &HashSet::new(), UVec2::new(15, 15)).is_none());
391 + }
392 +}

src/jobs.rs +188 -46

@@ -7,6 +7,7 @@ use std::{
7 7 use bevy::prelude::*;
8 8
9 9 use crate::{
10 + building::{Blueprints, BuildKind},
10 11 colonist::{Colonist, Path, repath},
11 12 items::{
12 13 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
@@ -47,6 +48,7 @@ impl Plugin for JobsPlugin {
47 48 pub const EXPAND_WORK: u32 = 240;
48 49 pub const CAMPFIRE_MIN_FUEL: u32 = 3 * crate::sim::TICKS_PER_HOUR as u32;
49 50 pub const HOME_RADIUS: f32 = 10.0;
51 +pub const BED_STOCKPILE_CLEARANCE: i32 = 3;
50 52 const LIT_WORK_RATE: u32 = 2;
51 53 const DARK_WORK_RATE: u32 = 1;
52 54 pub const EXPANSION_TILES: usize = 8;
@@ -94,7 +96,7 @@ pub enum Task {
94 96 },
95 97 Build {
96 98 site: UVec2,
97 - kind: StructureKind,
99 + kind: BuildKind,
98 100 pickup: Option<UVec2>,
99 101 progress: u32,
100 102 },
@@ -103,14 +105,6 @@ pub enum Task {
103 105 },
104 106 }
105 107
106 -pub fn build_work(kind: StructureKind) -> u32 {
107 - match kind {
108 - StructureKind::Campfire => 360,
109 - StructureKind::Torch => 120,
110 - StructureKind::Bed => 600,
111 - }
112 -}
113 -
114 108 pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
115 109 if light.is_lit(tile) {
116 110 LIT_WORK_RATE
@@ -400,6 +394,7 @@ pub fn find_expansion(
400 394 zones: &Zones,
401 395 reservations: &Reservations,
402 396 items: &[(Item, TilePos)],
397 + blueprints: &Blueprints,
403 398 ) -> Option<Task> {
404 399 if !reservations.is_free(Target::StockpileExpansion) {
405 400 return None;
@@ -408,7 +403,7 @@ pub fn find_expansion(
408 403 if !stockpile_too_full(zones, items) {
409 404 return None;
410 405 }
411 - *plan_expansion(map, zones, EXPANSION_TILES).first()?
406 + *plan_expansion(map, zones, &blueprints.tiles(), EXPANSION_TILES).first()?
412 407 } else {
413 408 find_stockpile_site(map, central_item_tile(items)?)?
414 409 };
@@ -432,6 +427,7 @@ pub struct BuildContext<'a> {
432 427 pub items: &'a [(Item, TilePos)],
433 428 pub structures: &'a [(UVec2, StructureKind, u32)],
434 429 pub planned: &'a [(UVec2, StructureKind)],
430 + pub blueprints: &'a Blueprints,
435 431 }
436 432
437 433 impl BuildContext<'_> {
@@ -441,6 +437,7 @@ impl BuildContext<'_> {
441 437 && self.map.plant(tile.as_ivec2()).is_none()
442 438 && !self.structures.iter().any(|(pos, ..)| *pos == tile)
443 439 && !self.planned.iter().any(|(pos, _)| *pos == tile)
440 + && self.blueprints.get(tile).is_none()
444 441 && !self.items.iter().any(|(_, pos)| pos.0 == tile)
445 442 && self.reservations.is_free(Target::Site(tile))
446 443 }
@@ -478,8 +475,25 @@ impl BuildContext<'_> {
478 475 }
479 476
480 477 fn site_near(&self, target: UVec2, max_distance: f32) -> Option<UVec2> {
481 - nearest_tile(self.map, target, SEARCH_TILES, |p| self.buildable(p))
482 - .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance)
478 + self.site_near_where(target, max_distance, |_| true)
479 + }
480 +
481 + fn site_near_where(
482 + &self,
483 + target: UVec2,
484 + max_distance: f32,
485 + extra: impl Fn(UVec2) -> bool,
486 + ) -> Option<UVec2> {
487 + nearest_tile(self.map, target, SEARCH_TILES, |p| {
488 + self.buildable(p) && extra(p)
489 + })
490 + .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance)
491 + }
492 +
493 + fn clear_of_stockpile(&self, tile: UVec2) -> bool {
494 + self.zones.stockpile_tiles().all(|s| {
495 + (s.as_ivec2() - tile.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE
496 + })
483 497 }
484 498
485 499 fn count(&self, kind: StructureKind) -> usize {
@@ -507,12 +521,23 @@ impl BuildContext<'_> {
507 521 .unwrap_or(home)
508 522 }
509 523
510 - fn wood_source(&self, from: UVec2, amount: u32) -> Option<UVec2> {
524 + fn material_source(&self, from: UVec2, (kind, amount): (ItemKind, u32)) -> Option<UVec2> {
511 525 nearest_tile(self.map, from, SEARCH_TILES, |p| {
512 526 self.reservations.is_free(Target::Pickup(p))
513 - && self.items.iter().any(|(item, pos)| {
514 - pos.0 == p && item.kind == ItemKind::Wood && item.count >= amount
515 - })
527 + && self
528 + .items
529 + .iter()
530 + .any(|(item, pos)| pos.0 == p && item.kind == kind && item.count >= amount)
531 + })
532 + }
533 +
534 + fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option<Task> {
535 + let pickup = self.material_source(from, kind.cost())?;
536 + Some(Task::Build {
537 + site,
538 + kind,
539 + pickup: Some(pickup),
540 + progress: 0,
516 541 })
517 542 }
518 543 }
@@ -526,13 +551,7 @@ pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
526 551 let reach = StructureKind::Torch.radius() * 0.6;
527 552 (StructureKind::Torch, ctx.site_near(dark, reach)?)
528 553 };
529 - let pickup = ctx.wood_source(from, kind.wood_cost())?;
530 - Some(Task::Build {
531 - site,
532 - kind,
533 - pickup: Some(pickup),
534 - progress: 0,
535 - })
554 + ctx.build_task(from, site, BuildKind::Structure(kind))
536 555 }
537 556
538 557 pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Option<Task> {
@@ -540,14 +559,23 @@ pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Optio
540 559 return None;
541 560 }
542 561 let anchor = ctx.bed_anchor(ctx.zones.home(from));
543 - let site = ctx.site_near(anchor, HOME_RADIUS)?;
544 - let pickup = ctx.wood_source(from, StructureKind::Bed.wood_cost())?;
545 - Some(Task::Build {
546 - site,
547 - kind: StructureKind::Bed,
548 - pickup: Some(pickup),
549 - progress: 0,
550 - })
562 + let site = ctx.site_near_where(anchor, HOME_RADIUS * 1.5, |p| ctx.clear_of_stockpile(p))?;
563 + ctx.build_task(from, site, BuildKind::Structure(StructureKind::Bed))
564 +}
565 +
566 +pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> {
567 + let mut found = None;
568 + nearest_tile(ctx.map, from, SEARCH_TILES, |p| {
569 + let Some(kind) = ctx.blueprints.get(p) else {
570 + return false;
571 + };
572 + if !ctx.reservations.is_free(Target::Site(p)) {
573 + return false;
574 + }
575 + found = ctx.build_task(from, p, kind);
576 + found.is_some()
577 + })?;
578 + found
551 579 }
552 580
553 581 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
@@ -571,6 +599,7 @@ fn choose_jobs(
571 599 goals: Res<ColonyGoals>,
572 600 item_assets: Res<ItemAssets>,
573 601 tick: Res<SimTick>,
602 + blueprints: Res<Blueprints>,
574 603 mut rng: ResMut<SimRng>,
575 604 mut reservations: ResMut<Reservations>,
576 605 mut items: ItemQuery,
@@ -596,7 +625,11 @@ fn choose_jobs(
596 625 let mut planned: Vec<(UVec2, StructureKind)> = colonists
597 626 .iter()
598 627 .filter_map(|c| match c.3.0?.task {
599 - Task::Build { site, kind, .. } => Some((site, kind)),
628 + Task::Build {
629 + site,
630 + kind: BuildKind::Structure(kind),
631 + ..
632 + } => Some((site, kind)),
600 633 _ => None,
601 634 })
602 635 .collect();
@@ -642,6 +675,7 @@ fn choose_jobs(
642 675 reservations: &reservations,
643 676 items: &snapshot,
644 677 structures: &structures_list,
678 + blueprints: &blueprints,
645 679 };
646 680 let task = if wants_sleep(rest.0, hour) {
647 681 Some(Task::Sleep {
@@ -669,12 +703,14 @@ fn choose_jobs(
669 703 items: &snapshot,
670 704 structures: &structures_list,
671 705 planned: &planned,
706 + blueprints: &blueprints,
672 707 };
673 - find_expansion(&map, &zones, &reservations, &snapshot)
708 + find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
674 709 .or_else(|| dark.then(|| find_lighting(&build, tile.0)).flatten())
675 710 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
676 711 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
677 712 .or_else(|| find_bed_site(&build, colonist_count, tile.0))
713 + .or_else(|| find_blueprint_work(&build, tile.0))
678 714 };
679 715 if let Some(task) = task {
680 716 if let Task::Work { site, .. } = task
@@ -682,7 +718,12 @@ fn choose_jobs(
682 718 {
683 719 *pending.entry(work.item).or_insert(0) += work.count;
684 720 }
685 - if let Task::Build { site, kind, .. } = task {
721 + if let Task::Build {
722 + site,
723 + kind: BuildKind::Structure(kind),
724 + ..
725 + } = task
726 + {
686 727 planned.push((site, kind));
687 728 }
688 729 reserve_task(&mut reservations, task, entity);
@@ -732,6 +773,7 @@ fn run_jobs(
732 773 structure_assets: Res<StructureAssets>,
733 774 light: Res<LightMap>,
734 775 tick: Res<SimTick>,
776 + mut blueprints: ResMut<Blueprints>,
735 777 mut items: ItemQuery,
736 778 structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
737 779 mut changed: MessageWriter<TileChanged>,
@@ -749,6 +791,7 @@ fn run_jobs(
749 791 >,
750 792 ) {
751 793 let hour = hour_of_day(*tick);
794 + let occupied: Vec<UVec2> = colonists.iter().map(|c| c.1.0).collect();
752 795 for (entity, tile, mut path, mut current, mut idle, mut carrying, rest) in &mut colonists {
753 796 let Some(mut job) = current.0 else {
754 797 continue;
@@ -822,7 +865,8 @@ fn run_jobs(
822 865 *progress += work_rate(&light, tile.0);
823 866 if *progress >= EXPAND_WORK {
824 867 if zones.has_stockpile() {
825 - let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
868 + let excluded = blueprints.tiles();
869 + let tiles = plan_expansion(&map, &zones, &excluded, EXPANSION_TILES);
826 870 zones.add_tiles(&map, tiles);
827 871 } else {
828 872 zones.add_tiles(&map, new_stockpile_tiles(*site));
@@ -841,10 +885,16 @@ fn run_jobs(
841 885 } => {
842 886 let (site, kind) = (*site, *kind);
843 887 let ours = reservations.holder(Target::Site(site)) == Some(entity);
888 + let planned = match kind {
889 + BuildKind::Structure(
890 + StructureKind::Campfire | StructureKind::Torch | StructureKind::Bed,
891 + ) => true,
892 + _ => blueprints.get(site) == Some(kind),
893 + };
844 894 let blocked = map.plant(site.as_ivec2()).is_some()
845 - || structures.iter().any(|pos| pos.0 == site);
846 - let cost = kind.wood_cost();
847 - if !ours || blocked {
895 + || (kind != BuildKind::Roof && structures.iter().any(|pos| pos.0 == site));
896 + let (material, cost) = kind.cost();
897 + if !ours || blocked || !planned {
848 898 Step::Done
849 899 } else if let Some(from) = *pickup {
850 900 if reservations.holder(Target::Pickup(from)) != Some(entity) {
@@ -853,7 +903,7 @@ fn run_jobs(
853 903 walk_to(&map, tile.0, from, &mut path)
854 904 } else {
855 905 let load = take_items(&mut commands, &mut items, from, cost)
856 - .filter(|load| load.kind == ItemKind::Wood && load.count > 0);
906 + .filter(|load| load.kind == material && load.count > 0);
857 907 carrying.0 = load;
858 908 match load {
859 909 Some(load) if load.count >= cost => {
@@ -866,7 +916,7 @@ fn run_jobs(
866 916 }
867 917 } else if !carrying
868 918 .0
869 - .is_some_and(|load| load.kind == ItemKind::Wood && load.count >= cost)
919 + .is_some_and(|load| load.kind == material && load.count >= cost)
870 920 {
871 921 Step::Done
872 922 } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
@@ -878,8 +928,9 @@ fn run_jobs(
878 928 _ => Step::Done,
879 929 }
880 930 } else {
881 - *progress += work_rate(&light, tile.0);
882 - if *progress < build_work(kind) {
931 + *progress = (*progress + work_rate(&light, tile.0)).min(kind.work());
932 + let someone_in_the_way = kind.blocks_tile() && occupied.contains(&site);
933 + if *progress < kind.work() || someone_in_the_way {
883 934 Step::Continue
884 935 } else {
885 936 carrying.0 = carrying.0.and_then(|load| {
@@ -888,7 +939,20 @@ fn run_jobs(
888 939 ..load
889 940 })
890 941 });
891 - spawn_structure(&mut commands, &structure_assets, site, kind);
942 + match kind {
943 + BuildKind::Structure(structure) => {
944 + spawn_structure(&mut commands, &structure_assets, site, structure);
945 + if kind.blocks_tile() {
946 + map.set_wall(site, true);
947 + changed.write(TileChanged(site));
948 + }
949 + }
950 + BuildKind::Roof => {
951 + map.set_roof(site, true);
952 + changed.write(TileChanged(site));
953 + }
954 + }
955 + blueprints.0.remove(&site);
892 956 Step::Done
893 957 }
894 958 }
@@ -995,6 +1059,7 @@ mod tests {
995 1059 .init_resource::<ColonyGoals>()
996 1060 .init_resource::<ItemAssets>()
997 1061 .init_resource::<StructureAssets>()
1062 + .init_resource::<Blueprints>()
998 1063 .init_resource::<LightMap>()
999 1064 .init_resource::<SimTick>()
1000 1065 .init_resource::<Zones>()
@@ -1642,10 +1707,33 @@ mod tests {
1642 1707 spawn_colonist(&mut app, UVec2::new(11, 10));
1643 1708 run_ticks(&mut app, 60 * 40);
1644 1709 assert_eq!(beds(&mut app), 2);
1645 - assert_eq!(
1646 - wood_on_map(&mut app),
1647 - 100 - 2 * StructureKind::Bed.wood_cost()
1710 + assert_eq!(wood_on_map(&mut app), 100 - 2 * StructureKind::Bed.cost().1);
1711 + }
1712 +
1713 + // A shelter needs a wall ring with walking space around the beds; beds hugging the stockpile
1714 + // would leave stored items where the walls must go and the room could never be planned.
1715 + #[test]
1716 + fn beds_keep_clear_of_the_stockpile() {
1717 + let mut app = test_app(map_with_trees(&[]));
1718 + set_goals(&mut app, &[]);
1719 + let map = app.world().resource::<WorldMap>().clone();
1720 + app.world_mut().resource_mut::<Zones>().add_stockpile(
1721 + &map,
1722 + IVec2::new(8, 8),
1723 + IVec2::new(11, 11),
1648 1724 );
1725 + put_wood(&mut app, UVec2::new(8, 8), 50);
1726 + spawn_colonist(&mut app, UVec2::new(10, 4));
1727 + run_ticks(&mut app, 60 * 20);
1728 + let built = structures(&mut app);
1729 + let bed = built
1730 + .iter()
1731 + .find(|(_, kind)| *kind == StructureKind::Bed)
1732 + .expect("a bed");
1733 + let zones = app.world().resource::<Zones>();
1734 + assert!(zones.stockpile_tiles().all(|s| {
1735 + (s.as_ivec2() - bed.0.as_ivec2()).abs().max_element() >= BED_STOCKPILE_CLEARANCE
1736 + }));
1649 1737 }
1650 1738
1651 1739 #[test]
@@ -1664,6 +1752,60 @@ mod tests {
1664 1752 assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1665 1753 }
1666 1754
1755 + fn put_items(app: &mut App, tile: UVec2, kind: ItemKind, count: u32) {
1756 + app.world_mut().spawn((Item { kind, count }, TilePos(tile)));
1757 + }
1758 +
1759 + #[test]
1760 + fn blueprints_are_built_with_the_right_material() {
1761 + let mut app = test_app(map_with_trees(&[]));
1762 + set_goals(&mut app, &[]);
1763 + put_beds(&mut app, 1);
1764 + put_items(&mut app, UVec2::new(2, 2), ItemKind::Stone, 10);
1765 + let wall = UVec2::new(8, 8);
1766 + app.world_mut()
1767 + .resource_mut::<Blueprints>()
1768 + .0
1769 + .insert(wall, BuildKind::Structure(StructureKind::Wall));
1770 + spawn_colonist(&mut app, UVec2::new(5, 5));
1771 + run_ticks(&mut app, 60 * 10);
1772 + assert!(app.world().resource::<WorldMap>().is_wall(wall.as_ivec2()));
1773 + assert!(app.world().resource::<Blueprints>().0.is_empty());
1774 + assert_eq!(items_of(&mut app, ItemKind::Stone), 8);
1775 + }
1776 +
1777 + #[test]
1778 + fn colonists_build_a_roofed_shelter_around_their_beds() {
1779 + let mut app = test_app(map_with_trees(&[]));
1780 + app.add_systems(Update, crate::building::plan_shelter.before(choose_jobs));
1781 + set_goals(&mut app, &[]);
1782 + for x in [10, 11] {
1783 + app.world_mut().spawn((
1784 + Structure {
1785 + kind: StructureKind::Bed,
1786 + },
1787 + TilePos(UVec2::new(x, 10)),
1788 + ));
1789 + }
1790 + put_items(&mut app, UVec2::new(2, 2), ItemKind::Stone, 50);
1791 + put_items(&mut app, UVec2::new(3, 2), ItemKind::Wood, 50);
1792 + spawn_colonist(&mut app, UVec2::new(4, 4));
1793 + spawn_colonist(&mut app, UVec2::new(5, 4));
1794 + run_ticks(&mut app, 60 * 150);
1795 + let map = app.world().resource::<WorldMap>();
1796 + assert!(map.is_roofed(IVec2::new(10, 10)) && map.is_roofed(IVec2::new(11, 10)));
1797 + let walls = map
1798 + .positions()
1799 + .filter(|p| map.is_wall(p.as_ivec2()))
1800 + .count();
1801 + assert_eq!(walls, 17);
1802 + assert!(
1803 + structures(&mut app)
1804 + .iter()
1805 + .any(|(_, kind)| *kind == StructureKind::Door)
1806 + );
1807 + }
1808 +
1667 1809 #[test]
1668 1810 fn colonists_chop_from_a_neighbouring_tile() {
1669 1811 let tree = UVec2::new(8, 5);

src/main.rs +2 -0

@@ -1,4 +1,5 @@
1 1 mod actions;
2 +mod building;
2 3 mod camera;
3 4 mod colonist;
4 5 mod items;
@@ -46,6 +47,7 @@ fn main() {
46 47 jobs::JobsPlugin,
47 48 light::LightPlugin,
48 49 structures::StructuresPlugin,
50 + building::BuildingPlugin,
49 51 needs::NeedsPlugin,
50 52 selection::SelectionPlugin,
51 53 zones::ZonesPlugin,

src/needs.rs +29 -2

@@ -3,6 +3,7 @@ use std::collections::HashSet;
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + building::Blueprints,
6 7 colonist::{Colonist, Path},
7 8 items::Item,
8 9 jobs::{CurrentJob, Job, Reservations, Target, Task},
@@ -25,6 +26,7 @@ impl Plugin for NeedsPlugin {
25 26 pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
26 27 pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32);
27 28 pub const BED_REST_RECOVERY_PER_TICK: f32 = 1.0 / (5.0 * TICKS_PER_HOUR as f32);
29 +pub const ROOF_REST_BONUS: f32 = 1.25;
28 30 pub const BEDTIME_REST: f32 = 0.9;
29 31 pub const NAP_REST: f32 = 0.3;
30 32 pub const EXHAUSTED_REST: f32 = 0.1;
@@ -62,6 +64,7 @@ pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool {
62 64 }
63 65
64 66 pub fn update_rest(
67 + map: Res<WorldMap>,
65 68 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
66 69 mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path), With<Colonist>>,
67 70 ) {
@@ -76,6 +79,11 @@ pub fn update_rest(
76 79 (true, false) => REST_RECOVERY_PER_TICK,
77 80 (false, _) => -REST_DRAIN_PER_TICK,
78 81 };
82 + let change = if change > 0.0 && map.is_roofed(tile.0.as_ivec2()) {
83 + change * ROOF_REST_BONUS
84 + } else {
85 + change
86 + };
79 87 rest.0 = (rest.0 + change).clamp(0.0, 1.0);
80 88 }
81 89 }
@@ -86,6 +94,7 @@ pub struct SleepContext<'a> {
86 94 pub reservations: &'a Reservations,
87 95 pub items: &'a [(Item, TilePos)],
88 96 pub structures: &'a [(UVec2, StructureKind, u32)],
97 + pub blueprints: &'a Blueprints,
89 98 }
90 99
91 100 impl SleepContext<'_> {
@@ -95,6 +104,7 @@ impl SleepContext<'_> {
95 104 && !self.zones.is_stockpile(tile)
96 105 && !self.structures.iter().any(|(pos, ..)| *pos == tile)
97 106 && !self.items.iter().any(|(_, pos)| pos.0 == tile)
107 + && self.blueprints.get(tile).is_none()
98 108 && self.reservations.is_free(Target::SleepSpot(tile))
99 109 }
100 110
@@ -182,7 +192,8 @@ mod tests {
182 192 #[test]
183 193 fn rest_drains_awake_and_recovers_asleep() {
184 194 let mut app = App::new();
185 - app.add_systems(Update, update_rest);
195 + app.insert_resource(WorldMap::filled(UVec2::splat(10), Terrain::Grass))
196 + .add_systems(Update, update_rest);
186 197 let tile = UVec2::new(3, 3);
187 198 let awake = app
188 199 .world_mut()
@@ -236,7 +247,8 @@ mod tests {
236 247 #[test]
237 248 fn beds_restore_rest_faster_than_the_ground() {
238 249 let mut app = App::new();
239 - app.add_systems(Update, update_rest);
250 + app.insert_resource(WorldMap::filled(UVec2::splat(10), Terrain::Grass))
251 + .add_systems(Update, update_rest);
240 252 let bed = UVec2::new(1, 1);
241 253 app.world_mut().spawn((
242 254 Structure {
@@ -252,6 +264,19 @@ mod tests {
252 264 }
253 265
254 266 #[test]
267 + fn sleeping_under_a_roof_restores_more_rest() {
268 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
269 + map.set_roof(UVec2::new(1, 1), true);
270 + let mut app = App::new();
271 + app.insert_resource(map).add_systems(Update, update_rest);
272 + let sheltered = sleeper(&mut app, UVec2::new(1, 1));
273 + let outside = sleeper(&mut app, UVec2::new(5, 5));
274 + app.update();
275 + let rest = |e| app.world().get::<Rest>(e).unwrap().0;
276 + assert!(rest(sheltered) > rest(outside));
277 + }
278 +
279 + #[test]
255 280 fn sleepers_take_a_free_bed_over_the_campfire() {
256 281 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
257 282 let zones = Zones::default();
@@ -268,6 +293,7 @@ mod tests {
268 293 reservations: &reservations,
269 294 items: &[],
270 295 structures: &structures,
296 + blueprints: &Blueprints::default(),
271 297 };
272 298 assert_eq!(ctx.spot(UVec2::new(2, 2)), UVec2::new(16, 15));
273 299 }
@@ -284,6 +310,7 @@ mod tests {
284 310 reservations: &reservations,
285 311 items: &[],
286 312 structures: &fires,
313 + blueprints: &Blueprints::default(),
287 314 };
288 315 let spot = ctx.spot(UVec2::new(2, 2));
289 316 assert!(spot.as_vec2().distance(Vec2::splat(15.0)) < 2.0);

src/selection.rs +14 -5

@@ -2,10 +2,11 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2 2
3 3 use crate::{
4 4 actions::Action,
5 + building::Blueprints,
5 6 camera::CameraController,
6 7 colonist::{Colonist, Path},
7 8 items::{Carrying, Item, ItemKind, stock},
8 - jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind, build_work},
9 + jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
9 10 light::daylight,
10 11 needs::Rest,
11 12 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
@@ -238,6 +239,7 @@ fn update_tile_info(
238 239 colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>,
239 240 items: Query<(&Item, &TilePos)>,
240 241 structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
242 + blueprints: Res<Blueprints>,
241 243 goals: Res<ColonyGoals>,
242 244 zones: Res<Zones>,
243 245 tool: Res<ZoneTool>,
@@ -288,6 +290,12 @@ fn update_tile_info(
288 290 .iter()
289 291 .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
290 292 .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0)))
293 + .chain(
294 + cursor
295 + .tile
296 + .and_then(|t| blueprints.get(t))
297 + .map(|kind| format!(", planned {}", kind.name())),
298 + )
291 299 .collect();
292 300 let tool_hint = if tool.active {
293 301 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
@@ -310,12 +318,13 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
310 318 return "-".into();
311 319 };
312 320 let p = t.as_ivec2();
321 + let roof = if map.is_roofed(p) { ", Roofed" } else { "" };
313 322 let plant = map
314 323 .plant(p)
315 324 .map(|plant| format!(", {plant:?}"))
316 325 .unwrap_or_default();
317 326 let height = map.height(p).unwrap_or(0.0);
318 - format!("({}, {}) {terrain:?}{plant}, {height:.0} m", t.x, t.y)
327 + format!("({}, {}) {terrain:?}{plant}{roof}, {height:.0} m", t.x, t.y)
319 328 }
320 329
321 330 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
@@ -349,7 +358,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> {
349 358 match task {
350 359 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
351 360 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
352 - Task::Build { kind, progress, .. } => Some(progress as f32 / build_work(kind) as f32),
361 + Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
353 362 _ => None,
354 363 }
355 364 }
@@ -411,9 +420,9 @@ pub fn describe_colonist(
411 420 ..
412 421 }),
413 422 _,
414 - ) => format!("fetching wood for a {}", kind.name()),
423 + ) => format!("fetching {:?} for a {}", kind.cost().0, kind.name()).to_lowercase(),
415 424 (Some(Task::Build { kind, .. }), n) if n > 0 => {
416 - format!("carrying wood to build a {}", kind.name())
425 + format!("carrying {:?} to build a {}", kind.cost().0, kind.name()).to_lowercase()
417 426 }
418 427 (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
419 428 (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),

src/structures.rs +41 -6

@@ -1,6 +1,7 @@
1 1 use bevy::prelude::*;
2 2
3 3 use crate::{
4 + items::ItemKind,
4 5 sim::{SimSystems, TICKS_PER_HOUR},
5 6 world::{TILE_SIZE, TilePos, WorldMap},
6 7 worldgen::WorldGenerated,
@@ -24,14 +25,18 @@ pub enum StructureKind {
24 25 Campfire,
25 26 Torch,
26 27 Bed,
28 + Wall,
29 + Door,
27 30 }
28 31
29 32 impl StructureKind {
30 - pub fn wood_cost(self) -> u32 {
33 + pub fn cost(self) -> (ItemKind, u32) {
31 34 match self {
32 - StructureKind::Campfire => 10,
33 - StructureKind::Torch => 2,
34 - StructureKind::Bed => 15,
35 + StructureKind::Campfire => (ItemKind::Wood, 10),
36 + StructureKind::Torch => (ItemKind::Wood, 2),
37 + StructureKind::Bed => (ItemKind::Wood, 15),
38 + StructureKind::Wall => (ItemKind::Stone, 2),
39 + StructureKind::Door => (ItemKind::Wood, 5),
35 40 }
36 41 }
37 42
@@ -39,7 +44,7 @@ impl StructureKind {
39 44 let hours = match self {
40 45 StructureKind::Campfire => 12,
41 46 StructureKind::Torch => 8,
42 - StructureKind::Bed => return None,
47 + StructureKind::Bed | StructureKind::Wall | StructureKind::Door => return None,
43 48 };
44 49 Some(hours * TICKS_PER_HOUR as u32)
45 50 }
@@ -48,7 +53,7 @@ impl StructureKind {
48 53 match self {
49 54 StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))),
50 55 StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))),
51 - StructureKind::Bed => None,
56 + StructureKind::Bed | StructureKind::Wall | StructureKind::Door => None,
52 57 }
53 58 }
54 59
@@ -61,6 +66,8 @@ impl StructureKind {
61 66 StructureKind::Campfire => "campfire",
62 67 StructureKind::Torch => "torch",
63 68 StructureKind::Bed => "bed",
69 + StructureKind::Wall => "wall",
70 + StructureKind::Door => "door",
64 71 }
65 72 }
66 73 }
@@ -101,6 +108,10 @@ pub struct StructureAssets {
101 108 bed_frame: Part,
102 109 blanket: Part,
103 110 pillow: Part,
111 + wall: Part,
112 + wall_top: Part,
113 + door: Part,
114 + door_knob: Part,
104 115 }
105 116
106 117 const CAMPFIRE_STONES: usize = 7;
@@ -153,6 +164,22 @@ fn create_structure_assets(
153 164 Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.18).into(),
154 165 Color::srgb_u8(230, 228, 220),
155 166 ),
167 + wall: part(
168 + Rectangle::new(TILE_SIZE, TILE_SIZE).into(),
169 + Color::srgb_u8(84, 82, 80),
170 + ),
171 + wall_top: part(
172 + Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.8).into(),
173 + Color::srgb_u8(132, 128, 122),
174 + ),
175 + door: part(
176 + Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.9).into(),
177 + Color::srgb_u8(140, 96, 54),
178 + ),
179 + door_knob: part(
180 + Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(),
181 + Color::srgb_u8(60, 44, 30),
182 + ),
156 183 });
157 184 }
158 185
@@ -199,6 +226,14 @@ pub fn spawn_structure(
199 226 c.spawn(part_bundle(&assets.blanket, at(0.0, -0.13, 0.01)));
200 227 c.spawn(part_bundle(&assets.pillow, at(0.0, 0.3, 0.01)));
201 228 }),
229 + StructureKind::Wall => entity.with_children(|c| {
230 + c.spawn(part_bundle(&assets.wall, at(0.0, 0.0, 0.0)));
231 + c.spawn(part_bundle(&assets.wall_top, at(0.0, 0.0, 0.01)));
232 + }),
233 + StructureKind::Door => entity.with_children(|c| {
234 + c.spawn(part_bundle(&assets.door, at(0.0, 0.0, 0.0)));
235 + c.spawn(part_bundle(&assets.door_knob, at(0.25, 0.0, 0.01)));
236 + }),
202 237 StructureKind::Torch => entity.with_children(|c| {
203 238 c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
204 239 let small = Vec3::splat(0.6);

src/tilemap.rs +33 -0

@@ -38,6 +38,7 @@ impl Plugin for TilemapPlugin {
38 38 enum Layer {
39 39 Terrain,
40 40 Zones,
41 + Roofs,
41 42 Plants,
42 43 }
43 44
@@ -46,6 +47,7 @@ impl Layer {
46 47 match self {
47 48 Layer::Terrain => 0.0,
48 49 Layer::Zones => 0.3,
50 + Layer::Roofs => 0.85,
49 51 Layer::Plants => 0.5,
50 52 }
51 53 }
@@ -60,6 +62,7 @@ impl Layer {
60 62 match self {
61 63 Layer::Terrain => chunk_tiles(map, light, origin),
62 64 Layer::Zones => zone_tiles(zones, origin),
65 + Layer::Roofs => roof_tiles(map, origin),
63 66 Layer::Plants => plant_tiles(map, light, origin),
64 67 }
65 68 }
@@ -85,11 +88,13 @@ fn spawn_chunks(
85 88 Plant::ALL.iter().map(|&p| plant_texture(p)).collect(),
86 89 ));
87 90 let zone = images.add(stack_tileset(vec![zone_texture()]));
91 + let roof = images.add(stack_tileset(vec![roof_texture()]));
88 92 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
89 93 for (layer, tileset) in [
90 94 (Layer::Terrain, terrain),
91 95 (Layer::Zones, zone),
92 96 (Layer::Plants, plants),
97 + (Layer::Roofs, roof),
93 98 ] {
94 99 for cy in 0..chunks.y {
95 100 for cx in 0..chunks.x {
@@ -191,6 +196,15 @@ pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
191 196 .collect()
192 197 }
193 198
199 +pub fn roof_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
200 + chunk_positions(origin)
201 + .map(|pos| {
202 + map.is_roofed(pos.as_ivec2())
203 + .then(|| TileData::from_tileset_index(0))
204 + })
205 + .collect()
206 +}
207 +
194 208 pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
195 209 chunk_positions(origin)
196 210 .map(|pos| {
@@ -258,6 +272,16 @@ fn zone_texture() -> Texture {
258 272 .collect()
259 273 }
260 274
275 +fn roof_texture() -> Texture {
276 + (0..TEXTURE_SIZE * TEXTURE_SIZE)
277 + .map(|i| {
278 + let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
279 + let stripe = (x + y) % 4 == 0;
280 + [110, 80, 50, if stripe { 90 } else { 45 }]
281 + })
282 + .collect()
283 +}
284 +
261 285 fn plant_texture(plant: Plant) -> Texture {
262 286 match plant {
263 287 Plant::Tree => tree_texture(),
@@ -389,6 +413,15 @@ mod tests {
389 413 }
390 414
391 415 #[test]
416 + fn roof_layer_covers_roofed_tiles_only() {
417 + let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
418 + map.set_roof(UVec2::new(2, 1), true);
419 + let tiles = roof_tiles(&map, UVec2::ZERO);
420 + assert!(tiles[(CHUNK_SIZE + 2) as usize].is_some());
421 + assert_eq!(tiles.iter().filter(|t| t.is_some()).count(), 1);
422 + }
423 +
424 + #[test]
392 425 fn chunk_origin_rounds_down_to_chunk_grid() {
393 426 assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0));
394 427 assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128));

src/world.rs +33 -0

@@ -103,6 +103,8 @@ pub struct WorldMap {
103 103 tiles: Vec<Terrain>,
104 104 plants: Vec<Option<Plant>>,
105 105 heights: Vec<f32>,
106 + walls: Vec<bool>,
107 + roofs: Vec<bool>,
106 108 }
107 109
108 110 impl WorldMap {
@@ -113,6 +115,8 @@ impl WorldMap {
113 115 tiles: vec![terrain; n],
114 116 plants: vec![None; n],
115 117 heights: vec![0.0; n],
118 + walls: vec![false; n],
119 + roofs: vec![false; n],
116 120 }
117 121 }
118 122
@@ -155,7 +159,28 @@ impl WorldMap {
155 159 self.heights[i] = height;
156 160 }
157 161
162 + pub fn is_wall(&self, pos: IVec2) -> bool {
163 + self.in_bounds(pos) && self.walls[self.index(pos.as_uvec2())]
164 + }
165 +
166 + pub fn set_wall(&mut self, pos: UVec2, wall: bool) {
167 + let i = self.index(pos);
168 + self.walls[i] = wall;
169 + }
170 +
171 + pub fn is_roofed(&self, pos: IVec2) -> bool {
172 + self.in_bounds(pos) && self.roofs[self.index(pos.as_uvec2())]
173 + }
174 +
175 + pub fn set_roof(&mut self, pos: UVec2, roof: bool) {
176 + let i = self.index(pos);
177 + self.roofs[i] = roof;
178 + }
179 +
158 180 pub fn move_cost(&self, pos: IVec2) -> Option<u32> {
181 + if self.is_wall(pos) {
182 + return None;
183 + }
159 184 let terrain = self.get(pos)?.move_cost()?;
160 185 Some(terrain + self.plant(pos).map_or(0, Plant::extra_move_cost))
161 186 }
@@ -237,6 +262,14 @@ mod tests {
237 262 }
238 263
239 264 #[test]
265 + fn walls_block_movement() {
266 + let mut map = WorldMap::filled(UVec2::new(2, 1), Terrain::Grass);
267 + map.set_wall(UVec2::ZERO, true);
268 + assert_eq!(map.move_cost(IVec2::ZERO), None);
269 + assert!(map.move_cost(IVec2::X).is_some());
270 + }
271 +
272 + #[test]
240 273 fn walking_uphill_costs_more_than_downhill_or_flat() {
241 274 let mut map = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass);
242 275 map.set_height(UVec2::new(1, 0), 5.0);

src/zones.rs +16 -6

@@ -59,6 +59,10 @@ impl Zones {
59 59 .unwrap_or(fallback)
60 60 }
61 61
62 + pub fn remove_tile(&mut self, tile: UVec2) {
63 + self.stockpile.remove(&tile);
64 + }
65 +
62 66 pub fn has_stockpile(&self) -> bool {
63 67 !self.stockpile.is_empty()
64 68 }
@@ -88,7 +92,12 @@ impl Zones {
88 92 }
89 93 }
90 94
91 -pub fn plan_expansion(map: &WorldMap, zones: &Zones, count: usize) -> Vec<UVec2> {
95 +pub fn plan_expansion(
96 + map: &WorldMap,
97 + zones: &Zones,
98 + excluded: &HashSet<UVec2>,
99 + count: usize,
100 +) -> Vec<UVec2> {
92 101 if zones.stockpile.is_empty() {
93 102 return Vec::new();
94 103 }
@@ -104,7 +113,8 @@ pub fn plan_expansion(map: &WorldMap, zones: &Zones, count: usize) -> Vec<UVec2>
104 113 };
105 114 let mut planned: Vec<UVec2> = Vec::new();
106 115 while planned.len() < count {
107 - let taken = |t: UVec2| zones.is_stockpile(t) || planned.contains(&t);
116 + let taken =
117 + |t: UVec2| zones.is_stockpile(t) || planned.contains(&t) || excluded.contains(&t);
108 118 let best = zones
109 119 .stockpile
110 120 .iter()
@@ -251,10 +261,10 @@ mod tests {
251 261 let map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
252 262 let mut zones = Zones::default();
253 263 zones.add_stockpile(&map, IVec2::new(4, 4), IVec2::new(5, 5));
254 - let plan = plan_expansion(&map, &zones, 8);
264 + let plan = plan_expansion(&map, &zones, &HashSet::new(), 8);
255 265 assert_eq!(plan.len(), 8);
256 266 assert!(plan.iter().all(|t| !zones.is_stockpile(*t)));
257 - let beyond_one_ring = plan_expansion(&map, &zones, 20);
267 + let beyond_one_ring = plan_expansion(&map, &zones, &HashSet::new(), 20);
258 268 assert_eq!(beyond_one_ring.len(), 20);
259 269 assert!(
260 270 plan.iter()
@@ -271,7 +281,7 @@ mod tests {
271 281 map.set_plant(UVec2::new(5, 4), Some(crate::world::Plant::Tree));
272 282 let mut zones = Zones::default();
273 283 zones.add_stockpile(&map, IVec2::new(4, 4), IVec2::new(4, 4));
274 - let plan = plan_expansion(&map, &zones, 2);
284 + let plan = plan_expansion(&map, &zones, &HashSet::new(), 2);
275 285 assert_eq!(plan.len(), 2);
276 286 assert!(!plan.contains(&UVec2::new(3, 4)));
277 287 assert!(!plan.contains(&UVec2::new(5, 4)));
@@ -280,7 +290,7 @@ mod tests {
280 290 #[test]
281 291 fn no_expansion_without_a_stockpile() {
282 292 let map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
283 - assert!(plan_expansion(&map, &Zones::default(), 8).is_empty());
293 + assert!(plan_expansion(&map, &Zones::default(), &HashSet::new(), 8).is_empty());
284 294 }
285 295
286 296 #[test]

tasks.md +3 -1

@@ -20,12 +20,14 @@
20 20 - [x] Day/night cycle with per-tile lighting; colonists build campfires and torches (wood, burn out); work is slower in the dark
21 21 - [x] Sleep: rest need, bedtime/naps/exhaustion, colonists sleep by the campfire until morning
22 22 - [x] Beds: built autonomously (one per colonist, 15 wood), preferred for sleep, faster rest
23 +- [x] Walls (stone), doors (wood), roofs: colonists plan and build a roofed shelter around their beds; roofs improve rest
23 24 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
24 25
25 26 ## Planned
26 27 - [ ] Jobs: building (walls, floors; consumes materials)
27 28 - [ ] Refuelling fires instead of rebuilding; manual build orders for fires
28 -- [ ] Shelter/rooms (walls, roofs); bed ownership; more needs (food)
29 +- [ ] Manual wall/room building tool; bed ownership; more needs (food)
30 +- [ ] Walls block light; indoor darkness; deconstruction
29 31 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
30 32 - [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > found/expand stockpile > chop/mine > haul > wander)
31 33 - [ ] UI to edit colony goals (material targets)