Add deconstruction
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:09 UTC
- commit
- 1bacc518e6cc5cb608f05645ecb4b78bd98042f0
- parent
- aa47d4522e
- tree
- browse at this commit
6 files changed +159 -52
| CLAUDE.md | +3 -3 | |
| src/building.rs | +67 -28 | |
| src/jobs/mod.rs | +54 -17 | |
| src/jobs/tests.rs | +27 -0 | |
| src/selection.rs | +7 -3 | |
| tasks.md | +1 -1 |
CLAUDE.md +3 -3
| @@ -22,15 +22,15 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 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, colonist count, material and food stock-group/learned-goal lines, recent colony events, 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, plant growth, farm, animals. `describe_colonist(&ColonistView)` includes rest and food. 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)` (start may itself be blocked so movers can leave a tile that became a wall; `follow_path` also drops a route whose next tile became impassable), 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 (bitmap visited set). `nearest_of(start, max_tiles, &HashSet targets)` = nearest_tile over a precollected target set, returning None at once when no target is within `search_reach(max_tiles)` Chebyshev — use it instead of predicates that scan all items per visited tile (that was the dominant per-tick cost). Paths exclude start, include goal. |
| 24 | 24 | - `src/colonist.rs` `Colonist{name}` (`spawn_colonist(index)` picks name/colour; 3 spawned 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/mod.rs` job system (tests in `src/jobs/tests.rs`). `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` (economy.rs) = learned demand per stock group. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted/starving ones skip the idle timer), priority: sleep if exhausted → eat if hungry (drops carried load) → 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) → cooking if meals wanted and a raw stack ≥ COOK_INPUT exists (`find_cooking` at a campfire with ≥ COOK_MIN_FUEL, else `find_campfire`) → if food wanted: `find_work` restricted to food yields (harvest/forage) then `find_sowing` → `find_bed_site` → `find_blueprint_work` → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work, hunts and cooks in progress) → `find_hunt` if meat wanted → `find_sowing` → `find_haul` → wander. `find_bed_site`: one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile and farm tiles so a wall ring fits. `find_blueprint_work`: nearest unreserved blueprint with material available. Cook: fetch COOK_INPUT raw units of one kind, cook COOK_WORK next to the campfire (`Target::Station`), drop 1 Meal (0.9 nutrition vs 0.6 raw). Orders: animal → ordered Hunt (checked first), work site → Work, loose item → Haul, else MoveTo. `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/mod.rs` job system (tests in `src/jobs/tests.rs`). `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` (economy.rs) = learned demand per stock group. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted/starving ones skip the idle timer), priority: sleep if exhausted → eat if hungry (drops carried load) → 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) → cooking if meals wanted and a raw stack ≥ COOK_INPUT exists (`find_cooking` at a campfire with ≥ COOK_MIN_FUEL, else `find_campfire`) → if food wanted: `find_work` restricted to food yields (harvest/forage) then `find_sowing` → `find_bed_site` → `find_blueprint_work` → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work, hunts and cooks in progress) → `find_hunt` if meat wanted → `find_sowing` → `find_haul` → wander. `find_bed_site`: one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile and farm tiles so a wall ring fits. `find_blueprint_work`: nearest unreserved blueprint with material available (checks the reached tile and impassable neighbours, so built walls can be deconstructed). Cook: fetch COOK_INPUT raw units of one kind, cook COOK_WORK next to the campfire (`Target::Station`), drop 1 Meal (0.9 nutrition vs 0.6 raw). Orders: animal → ordered Hunt (checked first), work site → Work, loose item → Haul, else MoveTo. `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, Berries, Meat, Vegetables, Meal}, count, spoil}` (`Item::new`, `merge` averages spoil by count, `with_count`; `drop_items` takes a whole Item) (`nutrition`, `is_food`, `is_raw_food`) + `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 | 27 | - `src/zones.rs` `Zones` resource: stockpile and farm tile sets (`ZoneKind`; stockpile = walkable dry land, farm = `can_farm` fertile; never overlap; farm rendered as zone layer index 1), 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{mode: Option<ZoneKind>}` (Z stockpile / F farm toggle, 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/Max = 1x/3x/6x/adaptive via `Time<Virtual>::set_relative_speed`; keys 1/2/3/4 set speed + unpause, Space toggles pause. Max: `SimClock` times the fixed loop (start/stop systems around RunFixedMainLoop) and whole frames; `adapt_max_speed` (`next_max_speed`) aims for ticks filling `MAX_SPEED_BUDGET_SHARE` of a 30 fps frame from measured cost per tick, backs off proportionally on slow frames, smoothed, rise ≤1.5x/frame, clamped `MAX_SPEED_LIMITS`; `max_delta` = frame budget × speed in Max (prevents death spiral), 250 ms otherwise. Info panel shows `Max (Nx)`. 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 | -- `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). | |
| 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 (roofed tiles get ROOF_DAYLIGHT × daylight) + per-tile source light blocked by walls (`line_of_sight`), recomputed in `update_light_map` (SimSystems::Decide) only when the ambient step (16 levels) changes, structures are added/removed or tiles change, and only replaced when different (a replace refreshes every tilemap chunk); default (empty) = fully lit. `is_lit` = brightness ≥ LIT_BRIGHTNESS. Tilemap tints terrain/plant tiles by `LightMap::color` and refreshes all chunks on `LightMap` change (zones overlay unlit, colonists/items/structures not darkened). | |
| 30 | 30 | - `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated. |
| 31 | 31 | - `src/needs.rs` `Rest(f32)` on colonists (1 = rested): `update_rest` (SimSystems::Act) drains 1/24 per game hour awake, recovers 1/7 per hour (1/5 in a bed, ×ROOF_REST_BONUS 1.25 under a roof) while `is_sleeping` (Sleep job, at spot, path empty). `wants_sleep`: rest < NAP_REST (0.3) any time, or night (21–6) and rest < BEDTIME_REST (0.9). `done_sleeping`: rest full and not night (sleep lasts until morning). EXHAUSTED_REST (0.1) makes choose_jobs drop autonomous (not ordered) jobs. `SleepContext::spot` = nearest bed not reserved as `Target::SleepSpot`, else nearest free tile (dry, no plant/stockpile/fire/item, unreserved `Target::SleepSpot`) to the nearest campfire, else home. Sleeping colonists show a `SleepMarker` Text2d "z" child. |
| 32 | 32 | - `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof, Pave}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no non-Pave blueprints; merges its plan into Blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home. |
| 33 | -- Build tool (building.rs): `BuildTool{kind}` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, off); Wall drag = room outline (`placements`), others place at the release tile; `place_blueprints` skips plants/walls/structures/water/rock and clears stockpile under walls/doors; shift-drag removes blueprints in the rect. Zone tool and build tool cancel each other; selection ignores clicks while either is active. Colonists build manual blueprints via `find_blueprint_work`. | |
| 33 | +- Build tool (building.rs): `BuildTool{kind}` `mode: Option<BuildMode::{Place(kind), Deconstruct}>` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, Deconstruct, off); Deconstruct drag marks structure tiles as `BuildKind::Deconstruct` blueprints (cost 0 → Build task without pickup; completion despawns the structure, clears wall flag, refunds half its cost); Wall drag = room outline (`placements`), others place at the release tile; `place_blueprints` skips plants/walls/structures/water/rock and clears stockpile under walls/doors; shift-drag removes blueprints in the rect. Zone tool and build tool cancel each other; selection ignores clicks while either is active. Colonists build manual blueprints via `find_blueprint_work`. | |
| 34 | 34 | - `src/designations.rs` `Designations(HashSet<tile>)` of trees/rock (`designatable` = site_work Chop/Mine) marked with the Chop/Mine tool (X; drag adds, shift-drag clears; yellow X gizmos); `prune_designations` drops worked-out tiles. choose_jobs works designated sites (find_work with a site predicate) right after food gathering, regardless of stock goals. Tools (zone, build, designate) cancel each other. |
| 35 | 35 | - Refuel: `BuildKind::Refuel(kind)` (cost = kind.cost(), 60 work) reuses the Build task; `find_refuel` (before find_lighting) picks the lowest-fuel fire within 2×HOME_RADIUS of home below 1/REFUEL_BELOW_SHARE of full; completion resets Fuel to full. |
| 36 | 36 | - Camera zoom buttons: Action::ZoomIn/ZoomOut (=/-) → `button_zoom` using `CameraController::zoomed`. |
src/building.rs +67 -28
| @@ -46,6 +46,7 @@ const CRAMPED_SIDE_COST: i32 = 3; | ||
| 46 | 46 | const WALL_BLUEPRINT_COLOR: Color = Color::srgba(0.5, 0.75, 1.0, 0.8); |
| 47 | 47 | const DOOR_BLUEPRINT_COLOR: Color = Color::srgba(1.0, 0.8, 0.4, 0.8); |
| 48 | 48 | const PAVE_BLUEPRINT_COLOR: Color = Color::srgba(0.85, 0.85, 0.85, 0.5); |
| 49 | +const DECONSTRUCT_COLOR: Color = Color::srgba(1.0, 0.3, 0.25, 0.9); | |
| 49 | 50 | const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35); |
| 50 | 51 | |
| 51 | 52 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| @@ -54,6 +55,7 @@ pub enum BuildKind { | ||
| 54 | 55 | Roof, |
| 55 | 56 | Pave, |
| 56 | 57 | Refuel(StructureKind), |
| 58 | + Deconstruct, | |
| 57 | 59 | } |
| 58 | 60 | |
| 59 | 61 | impl BuildKind { |
| @@ -63,6 +65,7 @@ impl BuildKind { | ||
| 63 | 65 | BuildKind::Roof => (ItemKind::Wood, 1), |
| 64 | 66 | BuildKind::Pave => (ItemKind::Stone, 1), |
| 65 | 67 | BuildKind::Refuel(kind) => kind.cost(), |
| 68 | + BuildKind::Deconstruct => (ItemKind::Wood, 0), | |
| 66 | 69 | } |
| 67 | 70 | } |
| 68 | 71 | |
| @@ -76,6 +79,7 @@ impl BuildKind { | ||
| 76 | 79 | BuildKind::Roof => 120, |
| 77 | 80 | BuildKind::Pave => 120, |
| 78 | 81 | BuildKind::Refuel(_) => 60, |
| 82 | + BuildKind::Deconstruct => 200, | |
| 79 | 83 | } |
| 80 | 84 | } |
| 81 | 85 | |
| @@ -85,6 +89,7 @@ impl BuildKind { | ||
| 85 | 89 | BuildKind::Roof => "roof", |
| 86 | 90 | BuildKind::Pave => "paving", |
| 87 | 91 | BuildKind::Refuel(_) => "refuelling", |
| 92 | + BuildKind::Deconstruct => "deconstruction", | |
| 88 | 93 | } |
| 89 | 94 | } |
| 90 | 95 | |
| @@ -294,35 +299,58 @@ pub fn plan_shelter( | ||
| 294 | 299 | } |
| 295 | 300 | } |
| 296 | 301 | |
| 297 | -pub const BUILD_TOOL_CYCLE: [StructureKind; 5] = [ | |
| 298 | - StructureKind::Wall, | |
| 299 | - StructureKind::Door, | |
| 300 | - StructureKind::Campfire, | |
| 301 | - StructureKind::Torch, | |
| 302 | - StructureKind::Bed, | |
| 302 | +#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 303 | +pub enum BuildMode { | |
| 304 | + Place(StructureKind), | |
| 305 | + Deconstruct, | |
| 306 | +} | |
| 307 | + | |
| 308 | +pub const BUILD_TOOL_CYCLE: [BuildMode; 6] = [ | |
| 309 | + BuildMode::Place(StructureKind::Wall), | |
| 310 | + BuildMode::Place(StructureKind::Door), | |
| 311 | + BuildMode::Place(StructureKind::Campfire), | |
| 312 | + BuildMode::Place(StructureKind::Torch), | |
| 313 | + BuildMode::Place(StructureKind::Bed), | |
| 314 | + BuildMode::Deconstruct, | |
| 303 | 315 | ]; |
| 304 | 316 | |
| 305 | 317 | #[derive(Resource, Default, Debug)] |
| 306 | 318 | pub struct BuildTool { |
| 307 | - pub kind: Option<StructureKind>, | |
| 319 | + pub mode: Option<BuildMode>, | |
| 308 | 320 | drag_start: Option<UVec2>, |
| 309 | 321 | } |
| 310 | 322 | |
| 311 | 323 | impl BuildTool { |
| 312 | 324 | pub fn active(&self) -> bool { |
| 313 | - self.kind.is_some() | |
| 325 | + self.mode.is_some() | |
| 314 | 326 | } |
| 315 | 327 | |
| 316 | 328 | fn cycle(&mut self) { |
| 317 | - let next = match self.kind { | |
| 329 | + let next = match self.mode { | |
| 318 | 330 | None => Some(0), |
| 319 | - Some(kind) => BUILD_TOOL_CYCLE | |
| 331 | + Some(mode) => BUILD_TOOL_CYCLE | |
| 320 | 332 | .iter() |
| 321 | - .position(|k| *k == kind) | |
| 333 | + .position(|m| *m == mode) | |
| 322 | 334 | .map(|i| i + 1) |
| 323 | 335 | .filter(|&i| i < BUILD_TOOL_CYCLE.len()), |
| 324 | 336 | }; |
| 325 | - self.kind = next.map(|i| BUILD_TOOL_CYCLE[i]); | |
| 337 | + self.mode = next.map(|i| BUILD_TOOL_CYCLE[i]); | |
| 338 | + } | |
| 339 | +} | |
| 340 | + | |
| 341 | +fn rect(start: UVec2, end: UVec2) -> impl Iterator<Item = UVec2> { | |
| 342 | + let (lo, hi) = (start.min(end), start.max(end)); | |
| 343 | + (lo.y..=hi.y).flat_map(move |y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y))) | |
| 344 | +} | |
| 345 | + | |
| 346 | +pub fn mark_deconstruction( | |
| 347 | + blueprints: &mut Blueprints, | |
| 348 | + occupied: &HashSet<UVec2>, | |
| 349 | + start: UVec2, | |
| 350 | + end: UVec2, | |
| 351 | +) { | |
| 352 | + for tile in rect(start, end).filter(|t| occupied.contains(t)) { | |
| 353 | + blueprints.0.insert(tile, BuildKind::Deconstruct); | |
| 326 | 354 | } |
| 327 | 355 | } |
| 328 | 356 | |
| @@ -379,13 +407,13 @@ fn build_tool( | ||
| 379 | 407 | Action::Cancel |
| 380 | 408 | | Action::ToggleStockpileTool |
| 381 | 409 | | Action::ToggleFarmTool |
| 382 | - | Action::ToggleDesignateTool => tool.kind = None, | |
| 410 | + | Action::ToggleDesignateTool => tool.mode = None, | |
| 383 | 411 | _ => continue, |
| 384 | 412 | } |
| 385 | 413 | tool.drag_start = None; |
| 386 | 414 | return; |
| 387 | 415 | } |
| 388 | - let Some(kind) = tool.kind else { | |
| 416 | + let Some(mode) = tool.mode else { | |
| 389 | 417 | return; |
| 390 | 418 | }; |
| 391 | 419 | if buttons.just_pressed(MouseButton::Left) && !over_ui.0 { |
| @@ -405,8 +433,13 @@ fn build_tool( | ||
| 405 | 433 | return; |
| 406 | 434 | } |
| 407 | 435 | let occupied: HashSet<UVec2> = structures.iter().map(|p| p.0).collect(); |
| 408 | - let tiles = placements(kind, start, end); | |
| 409 | - place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles); | |
| 436 | + match mode { | |
| 437 | + BuildMode::Place(kind) => { | |
| 438 | + let tiles = placements(kind, start, end); | |
| 439 | + place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles); | |
| 440 | + } | |
| 441 | + BuildMode::Deconstruct => mark_deconstruction(&mut blueprints, &occupied, start, end), | |
| 442 | + } | |
| 410 | 443 | } |
| 411 | 444 | |
| 412 | 445 | fn draw_build_preview( |
| @@ -415,22 +448,18 @@ fn draw_build_preview( | ||
| 415 | 448 | tool: Res<BuildTool>, |
| 416 | 449 | mut gizmos: Gizmos, |
| 417 | 450 | ) { |
| 418 | - let (Some(kind), Some(end)) = (tool.kind, cursor.tile) else { | |
| 451 | + let (Some(mode), Some(end)) = (tool.mode, cursor.tile) else { | |
| 419 | 452 | return; |
| 420 | 453 | }; |
| 421 | 454 | let start = tool.drag_start.unwrap_or(end); |
| 422 | - let color = if removing(&keys) { | |
| 455 | + let color = if removing(&keys) || mode == BuildMode::Deconstruct { | |
| 423 | 456 | PREVIEW_REMOVE_COLOR |
| 424 | 457 | } else { |
| 425 | 458 | PREVIEW_ADD_COLOR |
| 426 | 459 | }; |
| 427 | - let tiles = if removing(&keys) { | |
| 428 | - let (lo, hi) = (start.min(end), start.max(end)); | |
| 429 | - (lo.y..=hi.y) | |
| 430 | - .flat_map(|y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y))) | |
| 431 | - .collect() | |
| 432 | - } else { | |
| 433 | - placements(kind, start, end) | |
| 460 | + let tiles: Vec<UVec2> = match mode { | |
| 461 | + BuildMode::Place(kind) if !removing(&keys) => placements(kind, start, end), | |
| 462 | + _ => rect(start, end).collect(), | |
| 434 | 463 | }; |
| 435 | 464 | for tile in tiles { |
| 436 | 465 | gizmos.rect_2d( |
| @@ -447,6 +476,7 @@ fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) { | ||
| 447 | 476 | BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR, |
| 448 | 477 | BuildKind::Roof => ROOF_BLUEPRINT_COLOR, |
| 449 | 478 | BuildKind::Pave => PAVE_BLUEPRINT_COLOR, |
| 479 | + BuildKind::Deconstruct => DECONSTRUCT_COLOR, | |
| 450 | 480 | _ => WALL_BLUEPRINT_COLOR, |
| 451 | 481 | }; |
| 452 | 482 | gizmos.rect_2d( |
| @@ -480,12 +510,21 @@ mod tests { | ||
| 480 | 510 | #[test] |
| 481 | 511 | fn the_build_tool_cycles_through_structures_and_off() { |
| 482 | 512 | let mut tool = BuildTool::default(); |
| 483 | - for kind in BUILD_TOOL_CYCLE { | |
| 513 | + for mode in BUILD_TOOL_CYCLE { | |
| 484 | 514 | tool.cycle(); |
| 485 | - assert_eq!(tool.kind, Some(kind)); | |
| 515 | + assert_eq!(tool.mode, Some(mode)); | |
| 486 | 516 | } |
| 487 | 517 | tool.cycle(); |
| 488 | - assert_eq!(tool.kind, None); | |
| 518 | + assert_eq!(tool.mode, None); | |
| 519 | + } | |
| 520 | + | |
| 521 | + #[test] | |
| 522 | + fn deconstruction_marks_only_structures() { | |
| 523 | + let mut blueprints = Blueprints::default(); | |
| 524 | + let occupied = HashSet::from([UVec2::new(1, 1), UVec2::new(9, 9)]); | |
| 525 | + mark_deconstruction(&mut blueprints, &occupied, UVec2::ZERO, UVec2::splat(3)); | |
| 526 | + assert_eq!(blueprints.0.len(), 1); | |
| 527 | + assert_eq!(blueprints.get(UVec2::ONE), Some(BuildKind::Deconstruct)); | |
| 489 | 528 | } |
| 490 | 529 | |
| 491 | 530 | #[test] |
src/jobs/mod.rs +54 -17
| @@ -643,11 +643,14 @@ impl BuildContext<'_> { | ||
| 643 | 643 | } |
| 644 | 644 | |
| 645 | 645 | fn build_task(&self, from: UVec2, site: UVec2, kind: BuildKind) -> Option<Task> { |
| 646 | - let pickup = self.material_source(from, kind.cost())?; | |
| 646 | + let pickup = match kind.cost() { | |
| 647 | + (_, 0) => None, | |
| 648 | + cost => Some(self.material_source(from, cost)?), | |
| 649 | + }; | |
| 647 | 650 | Some(Task::Build { |
| 648 | 651 | site, |
| 649 | 652 | kind, |
| 650 | - pickup: Some(pickup), | |
| 653 | + pickup, | |
| 651 | 654 | progress: 0, |
| 652 | 655 | }) |
| 653 | 656 | } |
| @@ -746,15 +749,25 @@ pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Optio | ||
| 746 | 749 | } |
| 747 | 750 | |
| 748 | 751 | pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> { |
| 752 | + if ctx.blueprints.0.is_empty() { | |
| 753 | + return None; | |
| 754 | + } | |
| 749 | 755 | let mut found = None; |
| 750 | 756 | nearest_tile(ctx.map, from, SEARCH_TILES, |p| { |
| 751 | - let Some(kind) = ctx.blueprints.get(p) else { | |
| 752 | - return false; | |
| 753 | - }; | |
| 754 | - if !ctx.reservations.is_free(Target::Site(p)) { | |
| 755 | - return false; | |
| 756 | - } | |
| 757 | - found = ctx.build_task(from, p, kind); | |
| 757 | + found = std::iter::once(IVec2::ZERO) | |
| 758 | + .chain(NEIGHBORS) | |
| 759 | + .filter_map(|d| { | |
| 760 | + let site = p.as_ivec2() + d; | |
| 761 | + (site.min_element() >= 0).then(|| site.as_uvec2()) | |
| 762 | + }) | |
| 763 | + .filter(|&site| site == p || ctx.map.move_cost(site.as_ivec2()).is_none()) | |
| 764 | + .find_map(|site| { | |
| 765 | + let kind = ctx.blueprints.get(site)?; | |
| 766 | + if !ctx.reservations.is_free(Target::Site(site)) { | |
| 767 | + return None; | |
| 768 | + } | |
| 769 | + ctx.build_task(from, site, kind) | |
| 770 | + }); | |
| 758 | 771 | found.is_some() |
| 759 | 772 | })?; |
| 760 | 773 | found |
| @@ -1034,7 +1047,7 @@ fn run_jobs( | ||
| 1034 | 1047 | mut blueprints: ResMut<Blueprints>, |
| 1035 | 1048 | mut goals: ResMut<ColonyGoals>, |
| 1036 | 1049 | mut items: ItemQuery, |
| 1037 | - mut structures: Query<(&TilePos, &Structure, Option<&mut Fuel>), Without<Colonist>>, | |
| 1050 | + mut structures: Query<(Entity, &TilePos, &Structure, Option<&mut Fuel>), Without<Colonist>>, | |
| 1038 | 1051 | animals: Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>, |
| 1039 | 1052 | mut changed: MessageWriter<TileChanged>, |
| 1040 | 1053 | mut colonists: Query< |
| @@ -1081,7 +1094,7 @@ fn run_jobs( | ||
| 1081 | 1094 | let station = *station; |
| 1082 | 1095 | let fire = structures |
| 1083 | 1096 | .iter() |
| 1084 | - .any(|(pos, s, _)| pos.0 == station && s.kind == StructureKind::Campfire); | |
| 1097 | + .any(|(_, pos, s, _)| pos.0 == station && s.kind == StructureKind::Campfire); | |
| 1085 | 1098 | let ours = reservations.holder(Target::Station(station)) == Some(entity); |
| 1086 | 1099 | if !fire || !ours { |
| 1087 | 1100 | Step::Done |
| @@ -1304,13 +1317,14 @@ fn run_jobs( | ||
| 1304 | 1317 | | BuildKind::Refuel(_) => true, |
| 1305 | 1318 | _ => blueprints.get(site) == Some(kind), |
| 1306 | 1319 | }; |
| 1307 | - let structure_there = structures.iter().any(|(pos, ..)| pos.0 == site); | |
| 1320 | + let structure_there = structures.iter().any(|(_, pos, ..)| pos.0 == site); | |
| 1308 | 1321 | let blocked = map.plant(site.as_ivec2()).is_some() |
| 1309 | 1322 | || match kind { |
| 1310 | 1323 | BuildKind::Roof => false, |
| 1311 | 1324 | BuildKind::Refuel(fire) => !structures |
| 1312 | 1325 | .iter() |
| 1313 | - .any(|(pos, s, _)| pos.0 == site && s.kind == fire), | |
| 1326 | + .any(|(_, pos, s, _)| pos.0 == site && s.kind == fire), | |
| 1327 | + BuildKind::Deconstruct => !structure_there, | |
| 1314 | 1328 | _ => structure_there, |
| 1315 | 1329 | }; |
| 1316 | 1330 | let (material, cost) = kind.cost(); |
| @@ -1350,9 +1364,10 @@ fn run_jobs( | ||
| 1350 | 1364 | None => Step::Done, |
| 1351 | 1365 | } |
| 1352 | 1366 | } |
| 1353 | - } else if !carrying | |
| 1354 | - .0 | |
| 1355 | - .is_some_and(|load| load.kind == material && load.count >= cost) | |
| 1367 | + } else if cost > 0 | |
| 1368 | + && !carrying | |
| 1369 | + .0 | |
| 1370 | + .is_some_and(|load| load.kind == material && load.count >= cost) | |
| 1356 | 1371 | { |
| 1357 | 1372 | Step::Done |
| 1358 | 1373 | } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) { |
| @@ -1392,8 +1407,30 @@ fn run_jobs( | ||
| 1392 | 1407 | map.set(site, Terrain::Paved); |
| 1393 | 1408 | changed.write(TileChanged(site)); |
| 1394 | 1409 | } |
| 1410 | + BuildKind::Deconstruct => { | |
| 1411 | + for (entity, pos, structure, _) in &structures { | |
| 1412 | + if pos.0 != site { | |
| 1413 | + continue; | |
| 1414 | + } | |
| 1415 | + commands.entity(entity).despawn(); | |
| 1416 | + let (refund, amount) = structure.kind.cost(); | |
| 1417 | + if amount / 2 > 0 { | |
| 1418 | + drop_items( | |
| 1419 | + &mut commands, | |
| 1420 | + &mut items, | |
| 1421 | + &item_assets, | |
| 1422 | + site, | |
| 1423 | + Item::new(refund, amount / 2), | |
| 1424 | + ); | |
| 1425 | + } | |
| 1426 | + } | |
| 1427 | + if map.is_wall(site.as_ivec2()) { | |
| 1428 | + map.set_wall(site, false); | |
| 1429 | + changed.write(TileChanged(site)); | |
| 1430 | + } | |
| 1431 | + } | |
| 1395 | 1432 | BuildKind::Refuel(fire) => { |
| 1396 | - for (pos, _, fuel) in &mut structures { | |
| 1433 | + for (_, pos, _, fuel) in &mut structures { | |
| 1397 | 1434 | if pos.0 == site |
| 1398 | 1435 | && let Some(mut fuel) = fuel |
| 1399 | 1436 | { |
src/jobs/tests.rs +27 -0
| @@ -1197,3 +1197,30 @@ fn designated_trees_are_cut_even_when_wood_is_not_wanted() { | ||
| 1197 | 1197 | assert!(map.plant(marked.as_ivec2()).is_none()); |
| 1198 | 1198 | assert_eq!(map.plant(IVec2::new(4, 5)), Some(Plant::Tree)); |
| 1199 | 1199 | } |
| 1200 | + | |
| 1201 | +#[test] | |
| 1202 | +fn colonists_deconstruct_marked_structures_and_recover_some_material() { | |
| 1203 | + let mut app = test_app(map_with_trees(&[])); | |
| 1204 | + set_goals(&mut app, &[]); | |
| 1205 | + let wall = UVec2::new(8, 5); | |
| 1206 | + app.world_mut().spawn(( | |
| 1207 | + Structure { | |
| 1208 | + kind: StructureKind::Wall, | |
| 1209 | + }, | |
| 1210 | + TilePos(wall), | |
| 1211 | + )); | |
| 1212 | + app.world_mut() | |
| 1213 | + .resource_mut::<WorldMap>() | |
| 1214 | + .set_wall(wall, true); | |
| 1215 | + app.world_mut() | |
| 1216 | + .resource_mut::<Blueprints>() | |
| 1217 | + .0 | |
| 1218 | + .insert(wall, BuildKind::Deconstruct); | |
| 1219 | + spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 1220 | + run_ticks(&mut app, 60 * 10); | |
| 1221 | + assert!(!app.world().resource::<WorldMap>().is_wall(wall.as_ivec2())); | |
| 1222 | + let world = app.world_mut(); | |
| 1223 | + assert_eq!(world.query::<&Structure>().iter(world).count(), 0); | |
| 1224 | + assert!(app.world().resource::<Blueprints>().0.is_empty()); | |
| 1225 | + assert_eq!(items_of(&mut app, ItemKind::Stone), 1); | |
| 1226 | +} | |
src/selection.rs +7 -3
| @@ -3,7 +3,7 @@ use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; | ||
| 3 | 3 | use crate::{ |
| 4 | 4 | actions::Action, |
| 5 | 5 | animals::Animal, |
| 6 | - building::{Blueprints, BuildTool}, | |
| 6 | + building::{Blueprints, BuildMode, BuildTool}, | |
| 7 | 7 | camera::CameraController, |
| 8 | 8 | colonist::{Colonist, Path}, |
| 9 | 9 | designations::DesignateTool, |
| @@ -363,7 +363,7 @@ fn update_tile_info( | ||
| 363 | 363 | .collect(); |
| 364 | 364 | let events: String = log.0.iter().map(|line| format!("\n{line}")).collect(); |
| 365 | 365 | let (tool, build_tool, designate_tool) = tools; |
| 366 | - let tool_hint = match (tool.mode, build_tool.kind) { | |
| 366 | + let tool_hint = match (tool.mode, build_tool.mode) { | |
| 367 | 367 | _ if designate_tool.active => { |
| 368 | 368 | "\nCHOP/MINE: drag over trees and rock, shift-drag to clear".to_string() |
| 369 | 369 | } |
| @@ -371,7 +371,11 @@ fn update_tile_info( | ||
| 371 | 371 | "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string() |
| 372 | 372 | } |
| 373 | 373 | (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(), |
| 374 | - (None, Some(kind)) => format!( | |
| 374 | + (None, Some(BuildMode::Deconstruct)) => { | |
| 375 | + "\nDECONSTRUCT: drag over structures, shift-drag to remove plans, B for next" | |
| 376 | + .to_string() | |
| 377 | + } | |
| 378 | + (None, Some(BuildMode::Place(kind))) => format!( | |
| 375 | 379 | "\nBUILD {}: {}, shift-drag to remove plans, B for next", |
| 376 | 380 | kind.name().to_uppercase(), |
| 377 | 381 | if kind == StructureKind::Wall { |
tasks.md +1 -1
| @@ -29,13 +29,13 @@ | ||
| 29 | 29 | - [x] Families: colonists who spend time together become partners, plan and build their own walled, roofed home with two beds, and have children who grow up after 4 days |
| 30 | 30 | - [x] Refuelling fires; manual build tool (B: wall room outlines, doors, campfires, torches, beds); zoom buttons |
| 31 | 31 | - [x] Designations: Chop/Mine tool (X) marks trees and rock for anyone to work |
| 32 | +- [x] Walls cast shadows, roofs keep daylight out; deconstruction (build tool mode, refunds half) | |
| 32 | 33 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 33 | 34 | |
| 34 | 35 | ## Planned |
| 35 | 36 | - [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease |
| 36 | 37 | - [ ] Animals flee from hunters; tameable animals; butchering as a separate step |
| 37 | 38 | - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone) |
| 38 | -- [ ] Walls block light; indoor darkness; deconstruction | |
| 39 | 39 | - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority |
| 40 | 40 | - [ ] UI to view/override learned stock targets |
| 41 | 41 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |