Add deconstruction

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

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

@@ -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