Add walls, doors and roofs with autonomous shelter building
- 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 | |
| src/building.rs | added | +392 -0 |
| src/jobs.rs | +188 -46 | |
| src/main.rs | +2 -0 | |
| src/needs.rs | +29 -2 | |
| src/selection.rs | +14 -5 | |
| src/structures.rs | +41 -6 | |
| src/tilemap.rs | +33 -0 | |
| src/world.rs | +33 -0 | |
| src/zones.rs | +16 -6 | |
| tasks.md | +3 -1 |
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) |