Add a Demolish tool for structures and roofs

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 14:45 UTC
commit
d1f6a4bb12f4a1fd52711b449ce77ba5a5d53f5c
parent
9448289802
tree
browse at this commit

10 files changed +107 -31

CLAUDE.md +1 -1

@@ -30,7 +30,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
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()`. Walls connect: `join_walls` (Update; reruns when the wall/door tile set changes or a wall/door is added) gives each wall `WallJointPiece` children from `wall_joints` (top arm toward each wall/door neighbour, corner fill when both sides and the diagonal are solid), so the light top is continuous and only outer edges show the dark base. 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}` `mode: Option<BuildMode::{Place(kind), Deconstruct}>` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, Workbench, Statue, Floor (drag fills a rect via `place_floors`), 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`.
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, Workbench, Statue, Hearth, Floor (drag fills a rect via `place_floors`), off). Demolish is its own tool: Action::ToggleDemolishTool (Q, "Demolish" button) toggles `BuildMode::Deconstruct` (B from it goes to the first build mode); drag marks structure and roofed tiles as `BuildKind::Deconstruct` blueprints (cost 0 → Build task without pickup; blocked only if neither structure nor roof is left; completion despawns the structure, refunds half its cost, clears wall and roof flags). plan_homes forgets homes that are `torn_down` (fewer than half of the ring tiles wall/door, no ring blueprints pending); 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/actions.rs +4 -1

@@ -26,9 +26,10 @@ pub enum Action {
26 26 ZoomOut,
27 27 Save,
28 28 Load,
29 + ToggleDemolishTool,
29 30 }
30 31
31 -pub const KEYBINDS: [(KeyCode, Action); 16] = [
32 +pub const KEYBINDS: [(KeyCode, Action); 17] = [
32 33 (KeyCode::Space, Action::TogglePause),
33 34 (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)),
34 35 (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)),
@@ -38,6 +39,7 @@ pub const KEYBINDS: [(KeyCode, Action); 16] = [
38 39 (KeyCode::KeyF, Action::ToggleFarmTool),
39 40 (KeyCode::KeyB, Action::CycleBuildTool),
40 41 (KeyCode::KeyX, Action::ToggleDesignateTool),
42 + (KeyCode::KeyQ, Action::ToggleDemolishTool),
41 43 (KeyCode::Escape, Action::Cancel),
42 44 (KeyCode::KeyR, Action::NewWorld),
43 45 (KeyCode::KeyH, Action::ToggleUi),
@@ -59,6 +61,7 @@ impl Action {
59 61 Action::ToggleFarmTool => "Farm",
60 62 Action::CycleBuildTool => "Build",
61 63 Action::ToggleDesignateTool => "Mark",
64 + Action::ToggleDemolishTool => "Demolish",
62 65 Action::Cancel => "Cancel",
63 66 Action::NewWorld => "New map",
64 67 Action::ToggleUi => "Hide",

src/building.rs +27 -12

@@ -316,7 +316,7 @@ pub enum BuildMode {
316 316 Deconstruct,
317 317 }
318 318
319 -pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [
319 +pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [
320 320 BuildMode::Place(StructureKind::Wall),
321 321 BuildMode::Place(StructureKind::Door),
322 322 BuildMode::Place(StructureKind::Campfire),
@@ -326,7 +326,6 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [
326 326 BuildMode::Place(StructureKind::Statue),
327 327 BuildMode::Place(StructureKind::Hearth),
328 328 BuildMode::Floor,
329 - BuildMode::Deconstruct,
330 329 ];
331 330
332 331 #[derive(Resource, Default, Debug)]
@@ -343,11 +342,10 @@ impl BuildTool {
343 342 fn cycle(&mut self) {
344 343 let next = match self.mode {
345 344 None => Some(0),
346 - Some(mode) => BUILD_TOOL_CYCLE
347 - .iter()
348 - .position(|m| *m == mode)
349 - .map(|i| i + 1)
350 - .filter(|&i| i < BUILD_TOOL_CYCLE.len()),
345 + Some(mode) => match BUILD_TOOL_CYCLE.iter().position(|m| *m == mode) {
346 + Some(i) => Some(i + 1).filter(|&i| i < BUILD_TOOL_CYCLE.len()),
347 + None => Some(0),
348 + },
351 349 };
352 350 self.mode = next.map(|i| BUILD_TOOL_CYCLE[i]);
353 351 }
@@ -359,12 +357,13 @@ fn rect(start: UVec2, end: UVec2) -> impl Iterator<Item = UVec2> {
359 357 }
360 358
361 359 pub fn mark_deconstruction(
360 + map: &WorldMap,
362 361 blueprints: &mut Blueprints,
363 362 occupied: &HashSet<UVec2>,
364 363 start: UVec2,
365 364 end: UVec2,
366 365 ) {
367 - for tile in rect(start, end).filter(|t| occupied.contains(t)) {
366 + for tile in rect(start, end).filter(|t| occupied.contains(t) || map.is_roofed(t.as_ivec2())) {
368 367 blueprints.0.insert(tile, BuildKind::Deconstruct);
369 368 }
370 369 }
@@ -432,6 +431,10 @@ fn build_tool(
432 431 for action in actions.read() {
433 432 match action {
434 433 Action::CycleBuildTool => tool.cycle(),
434 + Action::ToggleDemolishTool => {
435 + tool.mode =
436 + (tool.mode != Some(BuildMode::Deconstruct)).then_some(BuildMode::Deconstruct);
437 + }
435 438 Action::Cancel
436 439 | Action::ToggleStockpileTool
437 440 | Action::ToggleFarmTool
@@ -467,7 +470,7 @@ fn build_tool(
467 470 place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles);
468 471 }
469 472 BuildMode::Floor => place_floors(&map, &mut blueprints, start, end),
470 - BuildMode::Deconstruct => mark_deconstruction(&mut blueprints, &occupied, start, end),
473 + BuildMode::Deconstruct => mark_deconstruction(&map, &mut blueprints, &occupied, start, end),
471 474 }
472 475 }
473 476
@@ -549,12 +552,24 @@ mod tests {
549 552 }
550 553
551 554 #[test]
552 - fn deconstruction_marks_only_structures() {
555 + fn demolition_marks_structures_and_roofs_in_the_area() {
556 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
557 + map.set_roof(UVec2::new(2, 3), true);
553 558 let mut blueprints = Blueprints::default();
554 559 let occupied = HashSet::from([UVec2::new(1, 1), UVec2::new(9, 9)]);
555 - mark_deconstruction(&mut blueprints, &occupied, UVec2::ZERO, UVec2::splat(3));
556 - assert_eq!(blueprints.0.len(), 1);
560 + mark_deconstruction(
561 + &map,
562 + &mut blueprints,
563 + &occupied,
564 + UVec2::ZERO,
565 + UVec2::splat(3),
566 + );
567 + assert_eq!(blueprints.0.len(), 2);
557 568 assert_eq!(blueprints.get(UVec2::ONE), Some(BuildKind::Deconstruct));
569 + assert_eq!(
570 + blueprints.get(UVec2::new(2, 3)),
571 + Some(BuildKind::Deconstruct)
572 + );
558 573 }
559 574
560 575 #[test]

src/designations.rs +2 -1

@@ -96,7 +96,8 @@ fn designate_tool(
96 96 Action::Cancel
97 97 | Action::ToggleStockpileTool
98 98 | Action::ToggleFarmTool
99 - | Action::CycleBuildTool => tool.active = false,
99 + | Action::CycleBuildTool
100 + | Action::ToggleDemolishTool => tool.active = false,
100 101 _ => continue,
101 102 }
102 103 tool.drag_start = None;

src/family.rs +49 -0

@@ -399,6 +399,15 @@ pub fn floored(map: &WorldMap, tile: UVec2) -> bool {
399 399 map.get(tile.as_ivec2()) == Some(Terrain::WoodFloor)
400 400 }
401 401
402 +pub fn torn_down(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> bool {
403 + let ring: Vec<UVec2> = home.ring().collect();
404 + let intact = ring
405 + .iter()
406 + .filter(|t| map.is_wall(t.as_ivec2()) || doors.contains(t))
407 + .count();
408 + intact * 2 < ring.len()
409 +}
410 +
402 411 pub fn abandoned_home(map: &WorldMap, doors: &HashSet<UVec2>, bed: UVec2) -> Option<Home> {
403 412 let room = enclosed_room(map, doors, bed)?;
404 413 let lo = room.iter().fold(UVec2::MAX, |a, t| a.min(*t));
@@ -472,6 +481,12 @@ fn plan_homes(
472 481 .collect()
473 482 };
474 483 let doors = of_kind(StructureKind::Door);
484 + let standing = |home: &Home| {
485 + let planned = home.ring().any(|t| blueprints.get(t).is_some());
486 + planned || !torn_down(&map, &doors, home)
487 + };
488 + homes.by_family.retain(|_, home| standing(home));
489 + homes.vacant.retain(standing);
475 490 let statues = of_kind(StructureKind::Statue);
476 491 let hearths = of_kind(StructureKind::Hearth);
477 492 let beds = of_kind(StructureKind::Bed);
@@ -1019,4 +1034,38 @@ mod tests {
1019 1034 .any(|k| *k == BuildKind::Structure(StructureKind::Wall))
1020 1035 );
1021 1036 }
1037 +
1038 + // When the player demolishes a home, the family stops treating it as theirs (and plans a new
1039 + // one) once most of its walls are gone; otherwise colonists kept putting doors in the gaps.
1040 + #[test]
1041 + fn torn_down_homes_are_forgotten() {
1042 + let map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
1043 + let home = Home::at(UVec2::new(5, 5));
1044 + let mut standing = map.clone();
1045 + for tile in home.ring() {
1046 + standing.set_wall(tile, true);
1047 + }
1048 + let mut ruin = map.clone();
1049 + for tile in home.ring().take(4) {
1050 + ruin.set_wall(tile, true);
1051 + }
1052 + let doors = HashSet::new();
1053 + assert!(!torn_down(&standing, &doors, &home));
1054 + assert!(torn_down(&ruin, &doors, &home));
1055 + let mut app = App::new();
1056 + app.insert_resource(ruin)
1057 + .init_resource::<SimTick>()
1058 + .init_resource::<Zones>()
1059 + .init_resource::<Blueprints>()
1060 + .init_resource::<ColonyReach>()
1061 + .insert_resource(Homes {
1062 + by_family: HashMap::from([(1, home)]),
1063 + ..default()
1064 + })
1065 + .add_systems(Update, plan_homes);
1066 + app.world_mut()
1067 + .spawn((Colonist { name: "Ada".into() }, Family(1)));
1068 + app.update();
1069 + assert!(app.world().resource::<Homes>().of(Some(Family(1))) != Some(&home));
1070 + }
1022 1071 }

src/jobs/mod.rs +5 -2

@@ -2159,7 +2159,9 @@ fn run_jobs(
2159 2159 BuildKind::Refuel(fire) => !structures
2160 2160 .iter()
2161 2161 .any(|(_, pos, s, _)| pos.0 == site && s.kind == fire),
2162 - BuildKind::Deconstruct => !structure_there,
2162 + BuildKind::Deconstruct => {
2163 + !structure_there && !map.is_roofed(site.as_ivec2())
2164 + }
2163 2165 _ => structure_there,
2164 2166 };
2165 2167 let (material, cost) = kind.cost();
@@ -2263,8 +2265,9 @@ fn run_jobs(
2263 2265 );
2264 2266 }
2265 2267 }
2266 - if map.is_wall(site.as_ivec2()) {
2268 + if map.is_wall(site.as_ivec2()) || map.is_roofed(site.as_ivec2()) {
2267 2269 map.set_wall(site, false);
2270 + map.set_roof(site, false);
2268 2271 changed.write(TileChanged(site));
2269 2272 }
2270 2273 }

src/selection.rs +1 -1

@@ -438,7 +438,7 @@ fn update_tile_info(
438 438 "\nBUILD FLOOR: drag an area, shift-drag to remove plans, B for next".to_string()
439 439 }
440 440 (None, Some(BuildMode::Deconstruct)) => {
441 - "\nDECONSTRUCT: drag over structures, shift-drag to remove plans, B for next"
441 + "\nDEMOLISH: drag over structures and roofs to take them down, shift-drag to cancel"
442 442 .to_string()
443 443 }
444 444 (None, Some(BuildMode::Place(kind))) => format!(

src/ui.rs +13 -10

@@ -10,7 +10,7 @@ use bevy::{
10 10
11 11 use crate::{
12 12 actions::Action,
13 - building::BuildTool,
13 + building::{BuildMode, BuildTool},
14 14 designations::DesignateTool,
15 15 sim::SimSpeed,
16 16 zones::{ZoneKind, ZoneTool},
@@ -36,7 +36,7 @@ impl Plugin for HudPlugin {
36 36 }
37 37 }
38 38
39 -const TOOLBAR: [Action; 16] = [
39 +const TOOLBAR: [Action; 17] = [
40 40 Action::TogglePause,
41 41 Action::SetSpeed(SimSpeed::Normal),
42 42 Action::SetSpeed(SimSpeed::Fast),
@@ -46,6 +46,7 @@ const TOOLBAR: [Action; 16] = [
46 46 Action::ToggleFarmTool,
47 47 Action::CycleBuildTool,
48 48 Action::ToggleDesignateTool,
49 + Action::ToggleDemolishTool,
49 50 Action::Cancel,
50 51 Action::NewWorld,
51 52 Action::Save,
@@ -203,11 +204,13 @@ pub fn is_active(
203 204 paused: bool,
204 205 speed: SimSpeed,
205 206 tool: Option<ZoneKind>,
206 - building: bool,
207 + build_mode: Option<BuildMode>,
207 208 designating: bool,
208 209 ) -> bool {
210 + let demolishing = build_mode == Some(BuildMode::Deconstruct);
209 211 match action {
210 - Action::CycleBuildTool => building,
212 + Action::CycleBuildTool => build_mode.is_some() && !demolishing,
213 + Action::ToggleDemolishTool => demolishing,
211 214 Action::ToggleDesignateTool => designating,
212 215 Action::TogglePause => paused,
213 216 Action::SetSpeed(s) => !paused && s == speed,
@@ -231,7 +234,7 @@ fn style_buttons(
231 234 time.is_paused(),
232 235 *speed,
233 236 tool.mode,
234 - build_tool.active(),
237 + build_tool.mode,
235 238 designate_tool.active,
236 239 );
237 240 let new = match (pressed && hovered.get(), active, hovered.get()) {
@@ -274,19 +277,19 @@ mod tests {
274 277 true,
275 278 normal,
276 279 None,
277 - false,
280 + None,
278 281 false
279 282 ));
280 283 let fast = Action::SetSpeed(SimSpeed::Fast);
281 - assert!(is_active(fast, false, SimSpeed::Fast, None, false, false));
282 - assert!(!is_active(fast, true, SimSpeed::Fast, None, false, false));
284 + assert!(is_active(fast, false, SimSpeed::Fast, None, None, false));
285 + assert!(!is_active(fast, true, SimSpeed::Fast, None, None, false));
283 286 let farm = Some(ZoneKind::Farm);
284 287 assert!(is_active(
285 288 Action::ToggleFarmTool,
286 289 false,
287 290 normal,
288 291 farm,
289 - false,
292 + None,
290 293 false
291 294 ));
292 295 assert!(!is_active(
@@ -294,7 +297,7 @@ mod tests {
294 297 false,
295 298 normal,
296 299 farm,
297 - false,
300 + None,
298 301 false
299 302 ));
300 303 }

src/zones.rs +4 -3

@@ -390,9 +390,10 @@ fn zone_tool(
390 390 match action {
391 391 Action::ToggleStockpileTool => tool.toggle(ZoneKind::Stockpile),
392 392 Action::ToggleFarmTool => tool.toggle(ZoneKind::Farm),
393 - Action::Cancel | Action::CycleBuildTool | Action::ToggleDesignateTool => {
394 - tool.mode = None
395 - }
393 + Action::Cancel
394 + | Action::CycleBuildTool
395 + | Action::ToggleDesignateTool
396 + | Action::ToggleDemolishTool => tool.mode = None,
396 397 _ => continue,
397 398 }
398 399 tool.drag_start = None;

tasks.md +1 -0

@@ -50,6 +50,7 @@
50 50 - [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode
51 51 - [x] Colonist cap removed (numbered names past the list); job search pass for 40+ colonists (reach-based nearest targets, cheaper cooking/blueprint checks)
52 52 - [x] Family hearths: every home gets a hearth where the family cooks (meals stay home) and which heats the home; growth needs 3 days of food per colonist; failed food searches back off
53 +- [x] Demolish tool (Q / toolbar): drag to have colonists take down structures and roofs; torn-down homes are forgotten
53 54 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
54 55
55 56 ## Planned