Add a manual build tool
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:03 UTC
- commit
- ace548ce51cb1586dcee9e8d559204317ad01a1e
- parent
- 5531aaf964
- tree
- browse at this commit
7 files changed +264 -25
| CLAUDE.md | +4 -0 | |
| src/actions.rs | +4 -1 | |
| src/building.rs | +194 -1 | |
| src/selection.rs | +21 -7 | |
| src/ui.rs | +36 -8 | |
| src/zones.rs | +4 -4 | |
| tasks.md | +1 -4 |
CLAUDE.md +4 -0
| @@ -30,6 +30,9 @@ 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()`. 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`. | |
| 34 | +- 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. | |
| 35 | +- Camera zoom buttons: Action::ZoomIn/ZoomOut (=/-) → `button_zoom` using `CameraController::zoomed`. | |
| 33 | 36 | - `src/food.rs` spoilage: `spoil_food` (every 60 ticks) adds `spoilage(kind, roofed, ticks)` to food stacks and carried food (shelf life meat 2 d, berries 3, meals 4, vegetables 5; ×0.5 under a roof); spoil ≥ 1 removes it. `Hunger(f32)` on colonists (1 = fed): `update_hunger` drains 1/24 per game hour (half while sleeping). HUNGRY 0.35 → Eat job; STARVING 0.05 interrupts autonomous work like exhaustion, but only when unreserved food exists (`food_to_fetch`), else colonists thrashed their job every tick. `find_food` prefers Meal stacks, then raw food; `portion(kind, fed)` = units to fill up. Eat: walk to pickup, take portion into `Carrying`, eat EAT_TICKS, add nutrition. |
| 34 | 37 | - `src/plants.rs` `grow_plants` every GROWTH_INTERVAL_TICKS: plants with `ripening_ticks` (BerryBush 2 days, Crop 1.5) gain growth × `fertility(terrain)` (Soil 1, Grass .8, Sand .4, Gravel .3; wild bushes ≥ .8); writes TileChanged when `PlantLook` stage changes. |
| 35 | 38 | - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160. |
| @@ -51,6 +54,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 51 | 54 | - Shift-held drags can't be driven in the browser pane (computer-tool modifiers / synthetic Shift keydown don't reach Bevy as a held key during a real drag); test modifier logic headlessly. |
| 52 | 55 | - Browser verification: `.claude/launch.json` config `web` (python http.server :8080 serving target/web) → `preview_start name=web`, then screenshot/scroll in built-in browser. Synthetic JS pointer events are ignored by winit; real drags only via computer tool left_click_drag (left button) — temporarily bind Left to test drag code. computer tool `right_click` works for RMB clicks. Pause (Space) before clicking moving colonists. |
| 53 | 56 | - Browser pane screenshots can lag 1-2s behind the canvas (verified: rAF gaps ≤51ms while screenshot still showed old frame). Wait ≥3s after an action, or cross-check via hover text before concluding a render bug. |
| 57 | +- Repeated computer-tool clicks on the same UI button without moving the pointer register only once (pre-existing, affects all toggles); hover elsewhere between clicks or use the key. | |
| 54 | 58 | - Bevy logs in browser console; check for ERROR after load. Console log persists across reloads in the pane — count "Creating new window" lines to tell which load an error belongs to. |
| 55 | 59 | |
| 56 | 60 | ## Docs |
src/actions.rs +4 -1
| @@ -17,6 +17,7 @@ pub enum Action { | ||
| 17 | 17 | SetSpeed(SimSpeed), |
| 18 | 18 | ToggleStockpileTool, |
| 19 | 19 | ToggleFarmTool, |
| 20 | + CycleBuildTool, | |
| 20 | 21 | Cancel, |
| 21 | 22 | NewWorld, |
| 22 | 23 | ToggleUi, |
| @@ -24,7 +25,7 @@ pub enum Action { | ||
| 24 | 25 | ZoomOut, |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | -pub const KEYBINDS: [(KeyCode, Action); 12] = [ | |
| 28 | +pub const KEYBINDS: [(KeyCode, Action); 13] = [ | |
| 28 | 29 | (KeyCode::Space, Action::TogglePause), |
| 29 | 30 | (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)), |
| 30 | 31 | (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)), |
| @@ -32,6 +33,7 @@ pub const KEYBINDS: [(KeyCode, Action); 12] = [ | ||
| 32 | 33 | (KeyCode::Digit4, Action::SetSpeed(SimSpeed::Max)), |
| 33 | 34 | (KeyCode::KeyZ, Action::ToggleStockpileTool), |
| 34 | 35 | (KeyCode::KeyF, Action::ToggleFarmTool), |
| 36 | + (KeyCode::KeyB, Action::CycleBuildTool), | |
| 35 | 37 | (KeyCode::Escape, Action::Cancel), |
| 36 | 38 | (KeyCode::KeyR, Action::NewWorld), |
| 37 | 39 | (KeyCode::KeyH, Action::ToggleUi), |
| @@ -49,6 +51,7 @@ impl Action { | ||
| 49 | 51 | Action::SetSpeed(SimSpeed::Max) => "Max", |
| 50 | 52 | Action::ToggleStockpileTool => "Stockpile", |
| 51 | 53 | Action::ToggleFarmTool => "Farm", |
| 54 | + Action::CycleBuildTool => "Build", | |
| 52 | 55 | Action::Cancel => "Cancel", |
| 53 | 56 | Action::NewWorld => "New world", |
| 54 | 57 | Action::ToggleUi => "Hide UI", |
src/building.rs +194 -1
| @@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet, VecDeque}; | ||
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | + actions::Action, | |
| 6 | 7 | colonist::Colonist, |
| 7 | 8 | family::Homes, |
| 8 | 9 | items::{Item, ItemKind}, |
| 10 | + selection::{Cursor, update_cursor}, | |
| 9 | 11 | sim::{SimSystems, SimTick}, |
| 10 | 12 | structures::{Structure, StructureKind}, |
| 13 | + ui::PointerOverUi, | |
| 11 | 14 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 12 | 15 | worldgen::WorldGenerated, |
| 13 | - zones::{Zones, can_zone}, | |
| 16 | + zones::{PREVIEW_ADD_COLOR, PREVIEW_REMOVE_COLOR, Zones, can_zone, removing}, | |
| 14 | 17 | }; |
| 15 | 18 | |
| 16 | 19 | pub struct BuildingPlugin; |
| @@ -25,6 +28,13 @@ impl Plugin for BuildingPlugin { | ||
| 25 | 28 | draw_blueprints, |
| 26 | 29 | ), |
| 27 | 30 | ) |
| 31 | + .init_resource::<BuildTool>() | |
| 32 | + .add_systems( | |
| 33 | + PostUpdate, | |
| 34 | + (build_tool, draw_build_preview) | |
| 35 | + .chain() | |
| 36 | + .after(update_cursor), | |
| 37 | + ) | |
| 28 | 38 | .add_systems(FixedUpdate, plan_shelter.in_set(SimSystems::Decide)); |
| 29 | 39 | } |
| 30 | 40 | } |
| @@ -284,6 +294,152 @@ pub fn plan_shelter( | ||
| 284 | 294 | } |
| 285 | 295 | } |
| 286 | 296 | |
| 297 | +pub const BUILD_TOOL_CYCLE: [StructureKind; 5] = [ | |
| 298 | + StructureKind::Wall, | |
| 299 | + StructureKind::Door, | |
| 300 | + StructureKind::Campfire, | |
| 301 | + StructureKind::Torch, | |
| 302 | + StructureKind::Bed, | |
| 303 | +]; | |
| 304 | + | |
| 305 | +#[derive(Resource, Default, Debug)] | |
| 306 | +pub struct BuildTool { | |
| 307 | + pub kind: Option<StructureKind>, | |
| 308 | + drag_start: Option<UVec2>, | |
| 309 | +} | |
| 310 | + | |
| 311 | +impl BuildTool { | |
| 312 | + pub fn active(&self) -> bool { | |
| 313 | + self.kind.is_some() | |
| 314 | + } | |
| 315 | + | |
| 316 | + fn cycle(&mut self) { | |
| 317 | + let next = match self.kind { | |
| 318 | + None => Some(0), | |
| 319 | + Some(kind) => BUILD_TOOL_CYCLE | |
| 320 | + .iter() | |
| 321 | + .position(|k| *k == kind) | |
| 322 | + .map(|i| i + 1) | |
| 323 | + .filter(|&i| i < BUILD_TOOL_CYCLE.len()), | |
| 324 | + }; | |
| 325 | + self.kind = next.map(|i| BUILD_TOOL_CYCLE[i]); | |
| 326 | + } | |
| 327 | +} | |
| 328 | + | |
| 329 | +pub fn placements(kind: StructureKind, start: UVec2, end: UVec2) -> Vec<UVec2> { | |
| 330 | + if kind != StructureKind::Wall { | |
| 331 | + return vec![end]; | |
| 332 | + } | |
| 333 | + let (lo, hi) = (start.min(end), start.max(end)); | |
| 334 | + (lo.y..=hi.y) | |
| 335 | + .flat_map(|y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y))) | |
| 336 | + .filter(|t| t.x == lo.x || t.x == hi.x || t.y == lo.y || t.y == hi.y) | |
| 337 | + .collect() | |
| 338 | +} | |
| 339 | + | |
| 340 | +pub fn place_blueprints( | |
| 341 | + map: &WorldMap, | |
| 342 | + zones: &mut Zones, | |
| 343 | + blueprints: &mut Blueprints, | |
| 344 | + occupied: &HashSet<UVec2>, | |
| 345 | + kind: StructureKind, | |
| 346 | + tiles: &[UVec2], | |
| 347 | +) { | |
| 348 | + for &tile in tiles { | |
| 349 | + let free = can_zone(map, tile.as_ivec2()) | |
| 350 | + && map.plant(tile.as_ivec2()).is_none() | |
| 351 | + && !map.is_wall(tile.as_ivec2()) | |
| 352 | + && !occupied.contains(&tile); | |
| 353 | + if !free { | |
| 354 | + continue; | |
| 355 | + } | |
| 356 | + if matches!(kind, StructureKind::Wall | StructureKind::Door) { | |
| 357 | + zones.remove_tile(tile); | |
| 358 | + } | |
| 359 | + blueprints.0.insert(tile, BuildKind::Structure(kind)); | |
| 360 | + } | |
| 361 | +} | |
| 362 | + | |
| 363 | +#[allow(clippy::too_many_arguments)] | |
| 364 | +fn build_tool( | |
| 365 | + mut actions: MessageReader<Action>, | |
| 366 | + keys: Res<ButtonInput<KeyCode>>, | |
| 367 | + buttons: Res<ButtonInput<MouseButton>>, | |
| 368 | + over_ui: Res<PointerOverUi>, | |
| 369 | + cursor: Res<Cursor>, | |
| 370 | + map: Res<WorldMap>, | |
| 371 | + mut tool: ResMut<BuildTool>, | |
| 372 | + mut zones: ResMut<Zones>, | |
| 373 | + mut blueprints: ResMut<Blueprints>, | |
| 374 | + structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>, | |
| 375 | +) { | |
| 376 | + for action in actions.read() { | |
| 377 | + match action { | |
| 378 | + Action::CycleBuildTool => tool.cycle(), | |
| 379 | + Action::Cancel | Action::ToggleStockpileTool | Action::ToggleFarmTool => { | |
| 380 | + tool.kind = None | |
| 381 | + } | |
| 382 | + _ => continue, | |
| 383 | + } | |
| 384 | + tool.drag_start = None; | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + let Some(kind) = tool.kind else { | |
| 388 | + return; | |
| 389 | + }; | |
| 390 | + if buttons.just_pressed(MouseButton::Left) && !over_ui.0 { | |
| 391 | + tool.drag_start = cursor.tile; | |
| 392 | + } | |
| 393 | + if !buttons.just_released(MouseButton::Left) { | |
| 394 | + return; | |
| 395 | + } | |
| 396 | + let (Some(start), Some(end)) = (tool.drag_start.take(), cursor.tile) else { | |
| 397 | + return; | |
| 398 | + }; | |
| 399 | + if removing(&keys) { | |
| 400 | + let (lo, hi) = (start.min(end), start.max(end)); | |
| 401 | + blueprints | |
| 402 | + .0 | |
| 403 | + .retain(|t, _| !(t.cmpge(lo).all() && t.cmple(hi).all())); | |
| 404 | + return; | |
| 405 | + } | |
| 406 | + let occupied: HashSet<UVec2> = structures.iter().map(|p| p.0).collect(); | |
| 407 | + let tiles = placements(kind, start, end); | |
| 408 | + place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles); | |
| 409 | +} | |
| 410 | + | |
| 411 | +fn draw_build_preview( | |
| 412 | + keys: Res<ButtonInput<KeyCode>>, | |
| 413 | + cursor: Res<Cursor>, | |
| 414 | + tool: Res<BuildTool>, | |
| 415 | + mut gizmos: Gizmos, | |
| 416 | +) { | |
| 417 | + let (Some(kind), Some(end)) = (tool.kind, cursor.tile) else { | |
| 418 | + return; | |
| 419 | + }; | |
| 420 | + let start = tool.drag_start.unwrap_or(end); | |
| 421 | + let color = if removing(&keys) { | |
| 422 | + PREVIEW_REMOVE_COLOR | |
| 423 | + } else { | |
| 424 | + PREVIEW_ADD_COLOR | |
| 425 | + }; | |
| 426 | + let tiles = if removing(&keys) { | |
| 427 | + let (lo, hi) = (start.min(end), start.max(end)); | |
| 428 | + (lo.y..=hi.y) | |
| 429 | + .flat_map(|y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y))) | |
| 430 | + .collect() | |
| 431 | + } else { | |
| 432 | + placements(kind, start, end) | |
| 433 | + }; | |
| 434 | + for tile in tiles { | |
| 435 | + gizmos.rect_2d( | |
| 436 | + WorldMap::tile_center(tile), | |
| 437 | + Vec2::splat(TILE_SIZE * 0.7), | |
| 438 | + color, | |
| 439 | + ); | |
| 440 | + } | |
| 441 | +} | |
| 442 | + | |
| 287 | 443 | fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) { |
| 288 | 444 | for (tile, kind) in &blueprints.0 { |
| 289 | 445 | let color = match kind { |
| @@ -321,6 +477,43 @@ mod tests { | ||
| 321 | 477 | const DOOR: BuildKind = BuildKind::Structure(StructureKind::Door); |
| 322 | 478 | |
| 323 | 479 | #[test] |
| 480 | + fn the_build_tool_cycles_through_structures_and_off() { | |
| 481 | + let mut tool = BuildTool::default(); | |
| 482 | + for kind in BUILD_TOOL_CYCLE { | |
| 483 | + tool.cycle(); | |
| 484 | + assert_eq!(tool.kind, Some(kind)); | |
| 485 | + } | |
| 486 | + tool.cycle(); | |
| 487 | + assert_eq!(tool.kind, None); | |
| 488 | + } | |
| 489 | + | |
| 490 | + #[test] | |
| 491 | + fn dragging_walls_plans_a_room_outline_on_free_ground() { | |
| 492 | + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass); | |
| 493 | + map.set_plant(UVec2::new(2, 2), Some(crate::world::Plant::Tree)); | |
| 494 | + let mut zones = Zones::default(); | |
| 495 | + zones.add_tiles(&map, [UVec2::new(5, 2)]); | |
| 496 | + let mut blueprints = Blueprints::default(); | |
| 497 | + let occupied = HashSet::from([UVec2::new(5, 5)]); | |
| 498 | + let tiles = placements(StructureKind::Wall, UVec2::new(5, 5), UVec2::new(2, 2)); | |
| 499 | + assert_eq!(tiles.len(), 12); | |
| 500 | + place_blueprints( | |
| 501 | + &map, | |
| 502 | + &mut zones, | |
| 503 | + &mut blueprints, | |
| 504 | + &occupied, | |
| 505 | + StructureKind::Wall, | |
| 506 | + &tiles, | |
| 507 | + ); | |
| 508 | + assert_eq!(blueprints.0.len(), 10); | |
| 509 | + assert!(!zones.is_stockpile(UVec2::new(5, 2))); | |
| 510 | + assert_eq!( | |
| 511 | + placements(StructureKind::Campfire, UVec2::ZERO, UVec2::ONE), | |
| 512 | + vec![UVec2::ONE] | |
| 513 | + ); | |
| 514 | + } | |
| 515 | + | |
| 516 | + #[test] | |
| 324 | 517 | fn shelter_is_a_wall_ring_with_one_door_around_the_beds() { |
| 325 | 518 | let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); |
| 326 | 519 | let beds = [UVec2::new(10, 10), UVec2::new(11, 10)]; |
src/selection.rs +21 -7
| @@ -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, | |
| 6 | + building::{Blueprints, BuildTool}, | |
| 7 | 7 | camera::CameraController, |
| 8 | 8 | colonist::{Colonist, Path}, |
| 9 | 9 | economy::{ColonyGoals, Stock, in_colony}, |
| @@ -34,7 +34,9 @@ impl Plugin for SelectionPlugin { | ||
| 34 | 34 | PostUpdate, |
| 35 | 35 | ( |
| 36 | 36 | update_cursor, |
| 37 | - update_selection.run_if(|tool: Res<ZoneTool>| !tool.active()), | |
| 37 | + update_selection.run_if(|tool: Res<ZoneTool>, build: Res<BuildTool>| { | |
| 38 | + !tool.active() && !build.active() | |
| 39 | + }), | |
| 38 | 40 | issue_move_orders, |
| 39 | 41 | draw_highlights, |
| 40 | 42 | update_tile_info, |
| @@ -258,7 +260,7 @@ fn update_tile_info( | ||
| 258 | 260 | goals: Res<ColonyGoals>, |
| 259 | 261 | log: Res<ColonyLog>, |
| 260 | 262 | zones: Res<Zones>, |
| 261 | - tool: Res<ZoneTool>, | |
| 263 | + tools: (Res<ZoneTool>, Res<BuildTool>), | |
| 262 | 264 | clock: ( |
| 263 | 265 | Res<Time<Virtual>>, |
| 264 | 266 | Res<SimTick>, |
| @@ -357,10 +359,22 @@ fn update_tile_info( | ||
| 357 | 359 | ) |
| 358 | 360 | .collect(); |
| 359 | 361 | let events: String = log.0.iter().map(|line| format!("\n{line}")).collect(); |
| 360 | - let tool_hint = match tool.mode { | |
| 361 | - Some(ZoneKind::Stockpile) => "\nSTOCKPILE TOOL: drag to add, shift-drag to remove", | |
| 362 | - Some(ZoneKind::Farm) => "\nFARM TOOL: drag to add, shift-drag to remove", | |
| 363 | - None => "", | |
| 362 | + let (tool, build_tool) = tools; | |
| 363 | + let tool_hint = match (tool.mode, build_tool.kind) { | |
| 364 | + (Some(ZoneKind::Stockpile), _) => { | |
| 365 | + "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string() | |
| 366 | + } | |
| 367 | + (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(), | |
| 368 | + (None, Some(kind)) => format!( | |
| 369 | + "\nBUILD {}: {}, shift-drag to remove plans, B for next", | |
| 370 | + kind.name().to_uppercase(), | |
| 371 | + if kind == StructureKind::Wall { | |
| 372 | + "drag a room outline" | |
| 373 | + } else { | |
| 374 | + "click to place" | |
| 375 | + } | |
| 376 | + ), | |
| 377 | + (None, None) => String::new(), | |
| 364 | 378 | }; |
| 365 | 379 | let info = format!( |
| 366 | 380 | "{clock} {speed}\nSeed: {} Colonists: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}{events}", |
src/ui.rs +36 -8
| @@ -10,6 +10,7 @@ use bevy::{ | ||
| 10 | 10 | |
| 11 | 11 | use crate::{ |
| 12 | 12 | actions::Action, |
| 13 | + building::BuildTool, | |
| 13 | 14 | sim::SimSpeed, |
| 14 | 15 | zones::{ZoneKind, ZoneTool}, |
| 15 | 16 | }; |
| @@ -34,7 +35,7 @@ impl Plugin for HudPlugin { | ||
| 34 | 35 | } |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | -const TOOLBAR: [Action; 12] = [ | |
| 38 | +const TOOLBAR: [Action; 13] = [ | |
| 38 | 39 | Action::TogglePause, |
| 39 | 40 | Action::SetSpeed(SimSpeed::Normal), |
| 40 | 41 | Action::SetSpeed(SimSpeed::Fast), |
| @@ -42,6 +43,7 @@ const TOOLBAR: [Action; 12] = [ | ||
| 42 | 43 | Action::SetSpeed(SimSpeed::Max), |
| 43 | 44 | Action::ToggleStockpileTool, |
| 44 | 45 | Action::ToggleFarmTool, |
| 46 | + Action::CycleBuildTool, | |
| 45 | 47 | Action::Cancel, |
| 46 | 48 | Action::NewWorld, |
| 47 | 49 | Action::ZoomIn, |
| @@ -188,8 +190,15 @@ fn apply_ui_visibility( | ||
| 188 | 190 | } |
| 189 | 191 | } |
| 190 | 192 | |
| 191 | -pub fn is_active(action: Action, paused: bool, speed: SimSpeed, tool: Option<ZoneKind>) -> bool { | |
| 193 | +pub fn is_active( | |
| 194 | + action: Action, | |
| 195 | + paused: bool, | |
| 196 | + speed: SimSpeed, | |
| 197 | + tool: Option<ZoneKind>, | |
| 198 | + building: bool, | |
| 199 | +) -> bool { | |
| 192 | 200 | match action { |
| 201 | + Action::CycleBuildTool => building, | |
| 193 | 202 | Action::TogglePause => paused, |
| 194 | 203 | Action::SetSpeed(s) => !paused && s == speed, |
| 195 | 204 | Action::ToggleStockpileTool => tool == Some(ZoneKind::Stockpile), |
| @@ -202,10 +211,17 @@ fn style_buttons( | ||
| 202 | 211 | time: Res<Time<Virtual>>, |
| 203 | 212 | speed: Res<SimSpeed>, |
| 204 | 213 | tool: Res<ZoneTool>, |
| 214 | + build_tool: Res<BuildTool>, | |
| 205 | 215 | mut buttons: Query<(&ActionButton, &Hovered, Has<Pressed>, &mut BackgroundColor)>, |
| 206 | 216 | ) { |
| 207 | 217 | for (button, hovered, pressed, mut color) in &mut buttons { |
| 208 | - let active = is_active(button.0, time.is_paused(), *speed, tool.mode); | |
| 218 | + let active = is_active( | |
| 219 | + button.0, | |
| 220 | + time.is_paused(), | |
| 221 | + *speed, | |
| 222 | + tool.mode, | |
| 223 | + build_tool.active(), | |
| 224 | + ); | |
| 209 | 225 | let new = match (pressed && hovered.get(), active, hovered.get()) { |
| 210 | 226 | (true, ..) => BUTTON_PRESSED_COLOR, |
| 211 | 227 | (_, true, _) => BUTTON_ACTIVE_COLOR, |
| @@ -241,13 +257,25 @@ mod tests { | ||
| 241 | 257 | #[test] |
| 242 | 258 | fn active_buttons_reflect_simulation_state() { |
| 243 | 259 | let normal = SimSpeed::Normal; |
| 244 | - assert!(is_active(Action::TogglePause, true, normal, None)); | |
| 260 | + assert!(is_active(Action::TogglePause, true, normal, None, false)); | |
| 245 | 261 | let fast = Action::SetSpeed(SimSpeed::Fast); |
| 246 | - assert!(is_active(fast, false, SimSpeed::Fast, None)); | |
| 247 | - assert!(!is_active(fast, true, SimSpeed::Fast, None)); | |
| 262 | + assert!(is_active(fast, false, SimSpeed::Fast, None, false)); | |
| 263 | + assert!(!is_active(fast, true, SimSpeed::Fast, None, false)); | |
| 248 | 264 | let farm = Some(ZoneKind::Farm); |
| 249 | - assert!(is_active(Action::ToggleFarmTool, false, normal, farm)); | |
| 250 | - assert!(!is_active(Action::ToggleStockpileTool, false, normal, farm)); | |
| 265 | + assert!(is_active( | |
| 266 | + Action::ToggleFarmTool, | |
| 267 | + false, | |
| 268 | + normal, | |
| 269 | + farm, | |
| 270 | + false | |
| 271 | + )); | |
| 272 | + assert!(!is_active( | |
| 273 | + Action::ToggleStockpileTool, | |
| 274 | + false, | |
| 275 | + normal, | |
| 276 | + farm, | |
| 277 | + false | |
| 278 | + )); | |
| 251 | 279 | } |
| 252 | 280 | |
| 253 | 281 | #[test] |
src/zones.rs +4 -4
| @@ -28,8 +28,8 @@ impl Plugin for ZonesPlugin { | ||
| 28 | 28 | |
| 29 | 29 | pub const NEW_STOCKPILE_SIZE: u32 = 4; |
| 30 | 30 | const STOCKPILE_SITE_SEARCH_TILES: usize = 4000; |
| 31 | -const PREVIEW_ADD_COLOR: Color = Color::srgba(0.4, 0.7, 1.0, 0.9); | |
| 32 | -const PREVIEW_REMOVE_COLOR: Color = Color::srgba(1.0, 0.4, 0.4, 0.9); | |
| 31 | +pub const PREVIEW_ADD_COLOR: Color = Color::srgba(0.4, 0.7, 1.0, 0.9); | |
| 32 | +pub const PREVIEW_REMOVE_COLOR: Color = Color::srgba(1.0, 0.4, 0.4, 0.9); | |
| 33 | 33 | |
| 34 | 34 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 35 | 35 | pub enum ZoneKind { |
| @@ -242,7 +242,7 @@ pub fn find_stockpile_site(map: &WorldMap, near: UVec2) -> Option<UVec2> { | ||
| 242 | 242 | }) |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | -fn removing(keys: &ButtonInput<KeyCode>) -> bool { | |
| 245 | +pub fn removing(keys: &ButtonInput<KeyCode>) -> bool { | |
| 246 | 246 | keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) |
| 247 | 247 | } |
| 248 | 248 | |
| @@ -260,7 +260,7 @@ fn zone_tool( | ||
| 260 | 260 | match action { |
| 261 | 261 | Action::ToggleStockpileTool => tool.toggle(ZoneKind::Stockpile), |
| 262 | 262 | Action::ToggleFarmTool => tool.toggle(ZoneKind::Farm), |
| 263 | - Action::Cancel => tool.mode = None, | |
| 263 | + Action::Cancel | Action::CycleBuildTool => tool.mode = None, | |
| 264 | 264 | _ => continue, |
| 265 | 265 | } |
| 266 | 266 | tool.drag_start = None; |
tasks.md +1 -4
| @@ -27,12 +27,10 @@ | ||
| 27 | 27 | - [x] Performance: food/material searches use precollected tile sets and skip out-of-reach stacks; starving colonists without food keep working |
| 28 | 28 | - [x] Food security: stock counts only items within colony reach; harvesting, foraging and sowing take priority when food is short |
| 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 | +- [x] Refuelling fires; manual build tool (B: wall room outlines, doors, campfires, torches, beds); zoom buttons | |
| 30 | 31 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 31 | 32 | |
| 32 | 33 | ## Planned |
| 33 | -- [ ] Jobs: building (walls, floors; consumes materials) | |
| 34 | -- [ ] Refuelling fires instead of rebuilding; manual build orders for fires | |
| 35 | -- [ ] Manual wall/room building tool | |
| 36 | 34 | - [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease |
| 37 | 35 | - [ ] Animals flee from hunters; tameable animals; butchering as a separate step |
| 38 | 36 | - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone) |
| @@ -43,4 +41,3 @@ | ||
| 43 | 41 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |
| 44 | 42 | - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) |
| 45 | 43 | - [ ] Colonist panel / better info UI (info panel is plain text) |
| 46 | -- [ ] Zoom +/- buttons (camera has no buttons; WASD/drag/scroll only) | |