Add action keybinds, toolbar buttons and hide-UI toggle

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 09:40 UTC
commit
b72352167a2965a05f2572da35219be1d877a79c
parent
c5927789b1
tree
browse at this commit

9 files changed +493 -64

CLAUDE.md +3 -1
src/actions.rs added +117 -0
src/main.rs +4 -0
src/selection.rs +35 -13
src/sim.rs +20 -36
src/ui.rs added +293 -0
src/worldgen.rs +5 -4
src/zones.rs +13 -7
tasks.md +3 -3

CLAUDE.md +3 -1

@@ -11,13 +11,15 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
11 11 - This file: dense notes for Claude, not user facing.
12 12
13 13 ## Layout
14 +- `src/actions.rs` `Action` message enum + `KEYBINDS` table (single source of truth for keys; `keyboard_actions` in PreUpdate after `InputSystems`). All non-camera input goes through actions: consumers read `MessageReader<Action>` (sim speed/pause, worldgen NewWorld, zones tool/Cancel, selection Cancel, ui ToggleUi). New keybind = add Action variant + KEYBINDS entry + label + TOOLBAR entry (test enforces every keybind has a button).
15 +- `src/ui.rs` `HudPlugin`: bottom toolbar of `ui_widgets::Button`s (`ActivateOnPress` — click-on-release was flaky when the pointer moves and clicks in one frame) with `ActionButton(Action)`; global `On<Activate>` observer writes the action. `style_buttons` highlights active state (`is_active`), hover, pressed. `UiVisible` + `HideableUi` marker (toolbar, info panel) toggled by H/button; `ShowUiButton` appears when hidden. `PointerOverUi` from picking `HoverMap` (any hovered entity with `Node`; layout containers are `Pickable::IGNORE`); `update_cursor` clears world/tile and selection/orders/zone tool ignore clicks while over UI.
14 16 - `src/main.rs` app wiring: DefaultPlugins (nearest sampling, canvas `#bevy`, fit_canvas_to_parent), adds plugins.
15 17 - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash.
16 18 - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1].
17 19 - `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear.
18 20 - `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
19 21 - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect.
20 -- `src/selection.rs` `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, wood stock/goal + stored, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
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, wood stock/goal + stored, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
21 23 - `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal.
22 24 - `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front.
23 25 - `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Chop{tree, progress}, Haul{pickup: Option<tile>, to}}`. `Reservations` keyed by `Target::{Tree, Pickup, Storage}(tile)` → entity, so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide), for idle colonists (no job, empty path, IdleTimer done), in priority order: deliver what they're carrying (or drop it if no storage) → chop if `wood_wanted` (stock incl. carried + trees being chopped × WOOD_PER_TREE) → haul nearest loose item (not on a stockpile) to nearest stockpile tile with room (`find_haul`/`find_storage`) → wander. `run_jobs` (SimSystems::Act) per-task state machine returning `Step::{Continue, Done}`; Done releases all reservations and restarts the idle timer (short after work, 10 s after an ordered move). Chop walks adjacent, ticks to CHOP_TICKS, removes tree, `TileChanged`, drops wood. Haul walks onto the pickup tile, `take_items` up to min(CARRY_CAPACITY, room at dest), walks to dest, drops what fits (remainder stays in `Carrying` and gets delivered by the next choice). `Order{colonist, tile}` (Update, applies while paused): tree → ordered Chop; loose item → ordered Haul; else ordered MoveTo. Orders cancel the job but keep `Carrying`. Adding a job = Task variant + reserve_task arm + demand check in choose_jobs + run_jobs arm.

src/actions.rs +117 -0

@@ -0,0 +1,117 @@
1 +use bevy::{input::InputSystems, prelude::*};
2 +
3 +use crate::sim::SimSpeed;
4 +
5 +pub struct ActionsPlugin;
6 +
7 +impl Plugin for ActionsPlugin {
8 + fn build(&self, app: &mut App) {
9 + app.add_message::<Action>()
10 + .add_systems(PreUpdate, keyboard_actions.after(InputSystems));
11 + }
12 +}
13 +
14 +#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
15 +pub enum Action {
16 + TogglePause,
17 + SetSpeed(SimSpeed),
18 + ToggleStockpileTool,
19 + Cancel,
20 + NewWorld,
21 + ToggleUi,
22 +}
23 +
24 +pub const KEYBINDS: [(KeyCode, Action); 8] = [
25 + (KeyCode::Space, Action::TogglePause),
26 + (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)),
27 + (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)),
28 + (KeyCode::Digit3, Action::SetSpeed(SimSpeed::Fastest)),
29 + (KeyCode::KeyZ, Action::ToggleStockpileTool),
30 + (KeyCode::Escape, Action::Cancel),
31 + (KeyCode::KeyR, Action::NewWorld),
32 + (KeyCode::KeyH, Action::ToggleUi),
33 +];
34 +
35 +impl Action {
36 + pub fn label(self) -> &'static str {
37 + match self {
38 + Action::TogglePause => "Pause",
39 + Action::SetSpeed(SimSpeed::Normal) => "1x",
40 + Action::SetSpeed(SimSpeed::Fast) => "3x",
41 + Action::SetSpeed(SimSpeed::Fastest) => "6x",
42 + Action::ToggleStockpileTool => "Stockpile",
43 + Action::Cancel => "Cancel",
44 + Action::NewWorld => "New world",
45 + Action::ToggleUi => "Hide UI",
46 + }
47 + }
48 +
49 + pub fn key(self) -> Option<KeyCode> {
50 + KEYBINDS
51 + .iter()
52 + .find(|(_, action)| *action == self)
53 + .map(|(key, _)| *key)
54 + }
55 +
56 + pub fn key_label(self) -> String {
57 + match self.key() {
58 + Some(KeyCode::Space) => "Space".into(),
59 + Some(KeyCode::Escape) => "Esc".into(),
60 + Some(key) => format!("{key:?}")
61 + .trim_start_matches("Digit")
62 + .trim_start_matches("Key")
63 + .to_string(),
64 + None => String::new(),
65 + }
66 + }
67 +}
68 +
69 +fn keyboard_actions(keys: Res<ButtonInput<KeyCode>>, mut actions: MessageWriter<Action>) {
70 + for (key, action) in KEYBINDS {
71 + if keys.just_pressed(key) {
72 + actions.write(action);
73 + }
74 + }
75 +}
76 +
77 +#[cfg(test)]
78 +mod tests {
79 + use super::*;
80 +
81 + #[test]
82 + fn pressing_a_bound_key_triggers_its_action() {
83 + let mut app = App::new();
84 + app.init_resource::<ButtonInput<KeyCode>>()
85 + .add_message::<Action>()
86 + .add_systems(Update, keyboard_actions);
87 + app.world_mut()
88 + .resource_mut::<ButtonInput<KeyCode>>()
89 + .press(KeyCode::Digit3);
90 + app.update();
91 + let actions: Vec<Action> = app
92 + .world_mut()
93 + .resource_mut::<Messages<Action>>()
94 + .drain()
95 + .collect();
96 + assert_eq!(actions, vec![Action::SetSpeed(SimSpeed::Fastest)]);
97 + }
98 +
99 + #[test]
100 + fn every_action_has_exactly_one_key() {
101 + for (_, action) in KEYBINDS {
102 + let bound = KEYBINDS.iter().filter(|(_, a)| *a == action).count();
103 + assert_eq!(bound, 1, "{action:?}");
104 + }
105 + let mut keys: Vec<_> = KEYBINDS.iter().map(|(k, _)| format!("{k:?}")).collect();
106 + keys.sort();
107 + keys.dedup();
108 + assert_eq!(keys.len(), KEYBINDS.len());
109 + }
110 +
111 + #[test]
112 + fn key_labels_are_short() {
113 + assert_eq!(Action::SetSpeed(SimSpeed::Fast).key_label(), "2");
114 + assert_eq!(Action::ToggleStockpileTool.key_label(), "Z");
115 + assert_eq!(Action::Cancel.key_label(), "Esc");
116 + }
117 +}

src/main.rs +4 -0

@@ -1,3 +1,4 @@
1 +mod actions;
1 2 mod camera;
2 3 mod colonist;
3 4 mod items;
@@ -8,6 +9,7 @@ mod rng;
8 9 mod selection;
9 10 mod sim;
10 11 mod tilemap;
12 +mod ui;
11 13 mod world;
12 14 mod worldgen;
13 15 mod zones;
@@ -31,6 +33,7 @@ fn main() {
31 33 )
32 34 .insert_resource(ClearColor(Color::srgb(0.08, 0.08, 0.1)))
33 35 .add_plugins((
36 + actions::ActionsPlugin,
34 37 sim::SimPlugin,
35 38 worldgen::WorldGenPlugin,
36 39 tilemap::TilemapPlugin,
@@ -40,6 +43,7 @@ fn main() {
40 43 jobs::JobsPlugin,
41 44 selection::SelectionPlugin,
42 45 zones::ZonesPlugin,
46 + ui::HudPlugin,
43 47 ))
44 48 .run();
45 49 }

src/selection.rs +35 -13

@@ -1,11 +1,13 @@
1 1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2 2
3 3 use crate::{
4 + actions::Action,
4 5 camera::CameraController,
5 6 colonist::{Colonist, Path},
6 7 items::{Carrying, Item, ItemKind, stock},
7 8 jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task},
8 9 sim::{SimSpeed, SimTick, format_clock},
10 + ui::{HideableUi, PointerOverUi},
9 11 world::{TILE_SIZE, TilePos, WorldMap},
10 12 worldgen::{WorldGenerated, WorldSeed},
11 13 zones::{ZoneTool, Zones},
@@ -64,11 +66,14 @@ pub fn update_cursor(
64 66 map: Res<WorldMap>,
65 67 window: Single<&Window, With<PrimaryWindow>>,
66 68 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
69 + over_ui: Res<PointerOverUi>,
67 70 mut cursor: ResMut<Cursor>,
68 71 ) {
69 72 let (camera, global) = *camera;
70 73 let screen = window.cursor_position();
71 - let world = screen.and_then(|s| camera.viewport_to_world_2d(global, s).ok());
74 + let world = screen
75 + .filter(|_| !over_ui.0)
76 + .and_then(|s| camera.viewport_to_world_2d(global, s).ok());
72 77 let tile = world.and_then(|w| map.tile_at(w));
73 78 cursor.set_if_neq(Cursor {
74 79 screen,
@@ -82,15 +87,16 @@ fn clear_selection(mut selection: ResMut<Selection>) {
82 87 }
83 88
84 89 fn update_selection(
90 + mut actions: MessageReader<Action>,
85 91 buttons: Res<ButtonInput<MouseButton>>,
86 - keys: Res<ButtonInput<KeyCode>>,
92 + over_ui: Res<PointerOverUi>,
87 93 cursor: Res<Cursor>,
88 94 colonists: Query<(Entity, &Transform), With<Colonist>>,
89 95 mut selection: ResMut<Selection>,
90 96 ) {
91 - if keys.just_pressed(KeyCode::Escape) {
97 + if actions.read().any(|a| *a == Action::Cancel) {
92 98 selection.set_if_neq(Selection::None);
93 - } else if buttons.just_pressed(MouseButton::Left) {
99 + } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
94 100 let picked = cursor.world.and_then(|world| {
95 101 pick_nearest(
96 102 world,
@@ -121,11 +127,12 @@ fn issue_move_orders(
121 127 buttons: Res<ButtonInput<MouseButton>>,
122 128 cursor: Res<Cursor>,
123 129 selection: Res<Selection>,
130 + over_ui: Res<PointerOverUi>,
124 131 mut orders: MessageWriter<Order>,
125 132 mut pressed_at: Local<Option<Vec2>>,
126 133 ) {
127 134 if buttons.just_pressed(MouseButton::Right) {
128 - *pressed_at = cursor.screen;
135 + *pressed_at = cursor.screen.filter(|_| !over_ui.0);
129 136 }
130 137 if !buttons.just_released(MouseButton::Right) {
131 138 return;
@@ -207,6 +214,7 @@ fn draw_highlights(
207 214 fn spawn_tile_info(mut commands: Commands) {
208 215 commands.spawn((
209 216 TileInfoText,
217 + HideableUi,
210 218 Text::default(),
211 219 TextFont::from_font_size(14.0),
212 220 Node {
@@ -269,12 +277,12 @@ fn update_tile_info(
269 277 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
270 278 .collect();
271 279 let tool_hint = if tool.active {
272 - "STOCKPILE TOOL: drag to add, shift-drag to remove, Z/Esc to exit"
280 + "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
273 281 } else {
274 - "Z: stockpile tool"
282 + ""
275 283 };
276 284 let info = format!(
277 - "{clock} {speed}\nSeed: {} (R: new world)\nWood: {wood} / {} ({stored} stored)\n{tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
285 + "{clock} {speed}\nSeed: {}\nWood: {wood} / {} ({stored} stored){tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
278 286 seed.0,
279 287 goals.wood,
280 288 describe_tile(&map, cursor.tile)
@@ -351,10 +359,11 @@ mod tests {
351 359 fn selection_app(cursor: Cursor, selection: Selection) -> App {
352 360 let mut app = App::new();
353 361 app.init_resource::<ButtonInput<MouseButton>>()
354 - .init_resource::<ButtonInput<KeyCode>>()
362 + .init_resource::<PointerOverUi>()
355 363 .insert_resource(cursor)
356 364 .insert_resource(selection)
357 365 .add_message::<Order>()
366 + .add_message::<Action>()
358 367 .add_systems(Update, (update_selection, issue_move_orders));
359 368 app
360 369 }
@@ -423,19 +432,32 @@ mod tests {
423 432 }
424 433
425 434 #[test]
426 - fn escape_clears_the_selection() {
435 + fn cancel_clears_the_selection() {
427 436 let mut app = selection_app(
428 437 cursor_on_tile(UVec2::ONE),
429 438 Selection::Tile(UVec2::new(3, 4)),
430 439 );
431 - app.world_mut()
432 - .resource_mut::<ButtonInput<KeyCode>>()
433 - .press(KeyCode::Escape);
440 + app.world_mut().write_message(Action::Cancel);
434 441 app.update();
435 442 assert_eq!(selection(&app), Selection::None);
436 443 }
437 444
438 445 #[test]
446 + fn clicks_on_the_ui_do_not_reach_the_map() {
447 + let colonist = Entity::from_raw_u32(7).unwrap();
448 + let mut app = selection_app(
449 + cursor_on_tile(UVec2::new(5, 6)),
450 + Selection::Colonist(colonist),
451 + );
452 + app.insert_resource(PointerOverUi(true));
453 + press(&mut app, MouseButton::Left);
454 + assert_eq!(selection(&app), Selection::Colonist(colonist));
455 + press(&mut app, MouseButton::Right);
456 + release(&mut app, MouseButton::Right);
457 + assert!(sent_orders(&mut app).is_empty());
458 + }
459 +
460 + #[test]
439 461 fn right_click_orders_selected_colonist_to_hovered_tile() {
440 462 let colonist = Entity::from_raw_u32(7).unwrap();
441 463 let mut app = selection_app(

src/sim.rs +20 -36

@@ -1,5 +1,7 @@
1 1 use bevy::{app::RunFixedMainLoopSystems, prelude::*};
2 2
3 +use crate::actions::Action;
4 +
3 5 pub const TICKS_PER_SECOND: f64 = 60.0;
4 6 pub const TICKS_PER_HOUR: u64 = 600;
5 7 pub const HOURS_PER_DAY: u64 = 24;
@@ -85,26 +87,20 @@ fn advance_tick(mut tick: ResMut<SimTick>) {
85 87 }
86 88
87 89 fn speed_controls(
88 - keys: Res<ButtonInput<KeyCode>>,
90 + mut actions: MessageReader<Action>,
89 91 mut speed: ResMut<SimSpeed>,
90 92 mut time: ResMut<Time<Virtual>>,
91 93 ) {
92 - if keys.just_pressed(KeyCode::Space) {
93 - if time.is_paused() {
94 - time.unpause();
95 - } else {
96 - time.pause();
97 - }
98 - }
99 - for (key, new_speed) in [
100 - (KeyCode::Digit1, SimSpeed::Normal),
101 - (KeyCode::Digit2, SimSpeed::Fast),
102 - (KeyCode::Digit3, SimSpeed::Fastest),
103 - ] {
104 - if keys.just_pressed(key) {
105 - *speed = new_speed;
106 - time.set_relative_speed(new_speed.multiplier());
107 - time.unpause();
94 + for action in actions.read() {
95 + match *action {
96 + Action::TogglePause if time.is_paused() => time.unpause(),
97 + Action::TogglePause => time.pause(),
98 + Action::SetSpeed(new_speed) => {
99 + *speed = new_speed;
100 + time.set_relative_speed(new_speed.multiplier());
101 + time.unpause();
102 + }
103 + _ => {}
108 104 }
109 105 }
110 106 }
@@ -163,34 +159,22 @@ mod tests {
163 159
164 160 fn controls_app() -> App {
165 161 let mut app = App::new();
166 - app.init_resource::<ButtonInput<KeyCode>>()
162 + app.add_message::<Action>()
167 163 .init_resource::<Time<Virtual>>()
168 164 .init_resource::<SimSpeed>()
169 - .configure_sets(
170 - FixedUpdate,
171 - (SimSystems::Decide, SimSystems::Act, SimSystems::Move).chain(),
172 - )
173 165 .add_systems(Update, speed_controls);
174 166 app
175 167 }
176 168
177 - fn tap(app: &mut App, key: KeyCode) {
178 - app.world_mut()
179 - .resource_mut::<ButtonInput<KeyCode>>()
180 - .press(key);
169 + fn trigger(app: &mut App, action: Action) {
170 + app.world_mut().write_message(action);
181 171 app.update();
182 - app.world_mut()
183 - .resource_mut::<ButtonInput<KeyCode>>()
184 - .clear();
185 - app.world_mut()
186 - .resource_mut::<ButtonInput<KeyCode>>()
187 - .release(key);
188 172 }
189 173
190 174 #[test]
191 - fn number_keys_set_speed_and_scale_virtual_time() {
175 + fn speed_actions_scale_virtual_time() {
192 176 let mut app = controls_app();
193 - tap(&mut app, KeyCode::Digit3);
177 + trigger(&mut app, Action::SetSpeed(SimSpeed::Fastest));
194 178 assert_eq!(*app.world().resource::<SimSpeed>(), SimSpeed::Fastest);
195 179 assert_eq!(
196 180 app.world().resource::<Time<Virtual>>().relative_speed(),
@@ -201,9 +185,9 @@ mod tests {
201 185 #[test]
202 186 fn choosing_a_speed_while_paused_resumes() {
203 187 let mut app = controls_app();
204 - tap(&mut app, KeyCode::Space);
188 + trigger(&mut app, Action::TogglePause);
205 189 assert!(app.world().resource::<Time<Virtual>>().is_paused());
206 - tap(&mut app, KeyCode::Digit2);
190 + trigger(&mut app, Action::SetSpeed(SimSpeed::Fast));
207 191 assert!(!app.world().resource::<Time<Virtual>>().is_paused());
208 192 }
209 193 }

src/ui.rs +293 -0

@@ -0,0 +1,293 @@
1 +use bevy::{
2 + picking::{
3 + hover::{HoverMap, Hovered},
4 + pointer::PointerId,
5 + },
6 + prelude::*,
7 + ui::Pressed,
8 + ui_widgets::{Activate, ActivateOnPress, Button},
9 +};
10 +
11 +use crate::{actions::Action, sim::SimSpeed, zones::ZoneTool};
12 +
13 +pub struct HudPlugin;
14 +
15 +impl Plugin for HudPlugin {
16 + fn build(&self, app: &mut App) {
17 + app.init_resource::<PointerOverUi>()
18 + .init_resource::<UiVisible>()
19 + .add_systems(Startup, spawn_toolbar)
20 + .add_systems(
21 + Update,
22 + (
23 + detect_pointer_over_ui,
24 + toggle_ui,
25 + apply_ui_visibility.run_if(resource_changed::<UiVisible>),
26 + style_buttons,
27 + ),
28 + )
29 + .add_observer(on_button_activated);
30 + }
31 +}
32 +
33 +const TOOLBAR: [Action; 8] = [
34 + Action::TogglePause,
35 + Action::SetSpeed(SimSpeed::Normal),
36 + Action::SetSpeed(SimSpeed::Fast),
37 + Action::SetSpeed(SimSpeed::Fastest),
38 + Action::ToggleStockpileTool,
39 + Action::Cancel,
40 + Action::NewWorld,
41 + Action::ToggleUi,
42 +];
43 +
44 +const BUTTON_COLOR: Color = Color::srgba(0.12, 0.12, 0.14, 0.9);
45 +const BUTTON_HOVER_COLOR: Color = Color::srgba(0.22, 0.22, 0.26, 0.95);
46 +const BUTTON_ACTIVE_COLOR: Color = Color::srgba(0.2, 0.45, 0.75, 0.95);
47 +const BUTTON_PRESSED_COLOR: Color = Color::srgba(0.3, 0.6, 0.9, 1.0);
48 +const KEY_HINT_COLOR: Color = Color::srgb(0.65, 0.65, 0.7);
49 +
50 +#[derive(Resource, Default, Debug, Clone, Copy, PartialEq)]
51 +pub struct PointerOverUi(pub bool);
52 +
53 +#[derive(Resource, Debug, Clone, Copy, PartialEq)]
54 +pub struct UiVisible(pub bool);
55 +
56 +impl Default for UiVisible {
57 + fn default() -> Self {
58 + Self(true)
59 + }
60 +}
61 +
62 +#[derive(Component)]
63 +pub struct HideableUi;
64 +
65 +#[derive(Component)]
66 +struct ShowUiButton;
67 +
68 +#[derive(Component, Debug, Clone, Copy)]
69 +pub struct ActionButton(pub Action);
70 +
71 +fn action_button(action: Action, label: &str) -> impl Bundle {
72 + (
73 + Button,
74 + ActivateOnPress,
75 + Hovered::default(),
76 + ActionButton(action),
77 + Node {
78 + flex_direction: FlexDirection::Column,
79 + align_items: AlignItems::Center,
80 + min_width: px(52),
81 + padding: UiRect::axes(px(8), px(4)),
82 + border_radius: BorderRadius::all(px(4)),
83 + ..default()
84 + },
85 + BackgroundColor(BUTTON_COLOR),
86 + children![
87 + (Text::new(label), TextFont::from_font_size(14.0)),
88 + (
89 + Text::new(action.key_label()),
90 + TextFont::from_font_size(10.0),
91 + TextColor(KEY_HINT_COLOR),
92 + ),
93 + ],
94 + )
95 +}
96 +
97 +fn spawn_toolbar(mut commands: Commands) {
98 + let toolbar = commands
99 + .spawn((
100 + HideableUi,
101 + Pickable::IGNORE,
102 + Node {
103 + position_type: PositionType::Absolute,
104 + bottom: px(8),
105 + width: percent(100),
106 + justify_content: JustifyContent::Center,
107 + ..default()
108 + },
109 + ))
110 + .id();
111 + let row = commands
112 + .spawn((
113 + Node {
114 + column_gap: px(4),
115 + padding: UiRect::all(px(4)),
116 + border_radius: BorderRadius::all(px(6)),
117 + ..default()
118 + },
119 + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.4)),
120 + ChildOf(toolbar),
121 + ))
122 + .id();
123 + for action in TOOLBAR {
124 + commands.spawn((action_button(action, action.label()), ChildOf(row)));
125 + }
126 + commands
127 + .spawn((
128 + ShowUiButton,
129 + action_button(Action::ToggleUi, "Show UI"),
130 + Visibility::Hidden,
131 + ))
132 + .entry::<Node>()
133 + .and_modify(|mut node| {
134 + node.position_type = PositionType::Absolute;
135 + node.bottom = px(8);
136 + node.right = px(8);
137 + });
138 +}
139 +
140 +fn on_button_activated(
141 + activate: On<Activate>,
142 + buttons: Query<&ActionButton>,
143 + mut actions: MessageWriter<Action>,
144 +) {
145 + if let Ok(button) = buttons.get(activate.entity) {
146 + actions.write(button.0);
147 + }
148 +}
149 +
150 +fn detect_pointer_over_ui(
151 + hover_map: Res<HoverMap>,
152 + nodes: Query<(), With<Node>>,
153 + mut over_ui: ResMut<PointerOverUi>,
154 +) {
155 + let over = hover_map
156 + .get(&PointerId::Mouse)
157 + .is_some_and(|hits| hits.keys().any(|&entity| nodes.contains(entity)));
158 + over_ui.set_if_neq(PointerOverUi(over));
159 +}
160 +
161 +fn toggle_ui(mut actions: MessageReader<Action>, mut visible: ResMut<UiVisible>) {
162 + for action in actions.read() {
163 + if *action == Action::ToggleUi {
164 + visible.0 = !visible.0;
165 + }
166 + }
167 +}
168 +
169 +fn apply_ui_visibility(
170 + visible: Res<UiVisible>,
171 + mut hideable: Query<&mut Visibility, (With<HideableUi>, Without<ShowUiButton>)>,
172 + mut show_button: Query<&mut Visibility, With<ShowUiButton>>,
173 +) {
174 + let (shown, hidden) = (Visibility::Inherited, Visibility::Hidden);
175 + for mut visibility in &mut hideable {
176 + *visibility = if visible.0 { shown } else { hidden };
177 + }
178 + for mut visibility in &mut show_button {
179 + *visibility = if visible.0 { hidden } else { shown };
180 + }
181 +}
182 +
183 +pub fn is_active(action: Action, paused: bool, speed: SimSpeed, stockpile_tool: bool) -> bool {
184 + match action {
185 + Action::TogglePause => paused,
186 + Action::SetSpeed(s) => !paused && s == speed,
187 + Action::ToggleStockpileTool => stockpile_tool,
188 + _ => false,
189 + }
190 +}
191 +
192 +fn style_buttons(
193 + time: Res<Time<Virtual>>,
194 + speed: Res<SimSpeed>,
195 + tool: Res<ZoneTool>,
196 + mut buttons: Query<(&ActionButton, &Hovered, Has<Pressed>, &mut BackgroundColor)>,
197 +) {
198 + for (button, hovered, pressed, mut color) in &mut buttons {
199 + let active = is_active(button.0, time.is_paused(), *speed, tool.active);
200 + let new = match (pressed && hovered.get(), active, hovered.get()) {
201 + (true, ..) => BUTTON_PRESSED_COLOR,
202 + (_, true, _) => BUTTON_ACTIVE_COLOR,
203 + (_, _, true) => BUTTON_HOVER_COLOR,
204 + _ => BUTTON_COLOR,
205 + };
206 + color.set_if_neq(BackgroundColor(new));
207 + }
208 +}
209 +
210 +#[cfg(test)]
211 +mod tests {
212 + use super::*;
213 + use crate::actions::KEYBINDS;
214 +
215 + #[test]
216 + fn toolbar_spawns_one_button_per_action_plus_restore_button() {
217 + let mut app = App::new();
218 + app.add_systems(Startup, spawn_toolbar);
219 + app.update();
220 + let world = app.world_mut();
221 + let buttons = world.query::<&ActionButton>().iter(world).count();
222 + assert_eq!(buttons, TOOLBAR.len() + 1);
223 + }
224 +
225 + #[test]
226 + fn every_keybind_has_a_toolbar_button() {
227 + for (key, action) in KEYBINDS {
228 + assert!(TOOLBAR.contains(&action), "{key:?} has no button");
229 + }
230 + }
231 +
232 + #[test]
233 + fn active_buttons_reflect_simulation_state() {
234 + assert!(is_active(
235 + Action::TogglePause,
236 + true,
237 + SimSpeed::Normal,
238 + false
239 + ));
240 + assert!(is_active(
241 + Action::SetSpeed(SimSpeed::Fast),
242 + false,
243 + SimSpeed::Fast,
244 + false
245 + ));
246 + assert!(!is_active(
247 + Action::SetSpeed(SimSpeed::Fast),
248 + true,
249 + SimSpeed::Fast,
250 + false
251 + ));
252 + assert!(is_active(
253 + Action::ToggleStockpileTool,
254 + false,
255 + SimSpeed::Normal,
256 + true
257 + ));
258 + }
259 +
260 + #[test]
261 + fn toggling_hides_the_ui_and_shows_the_restore_button() {
262 + let mut app = App::new();
263 + app.init_resource::<UiVisible>()
264 + .add_message::<Action>()
265 + .add_systems(
266 + Update,
267 + (
268 + toggle_ui,
269 + apply_ui_visibility.run_if(resource_changed::<UiVisible>),
270 + )
271 + .chain(),
272 + );
273 + let panel = app
274 + .world_mut()
275 + .spawn((HideableUi, Visibility::Inherited))
276 + .id();
277 + let restore = app
278 + .world_mut()
279 + .spawn((ShowUiButton, Visibility::Hidden))
280 + .id();
281 + app.update();
282 + app.world_mut().write_message(Action::ToggleUi);
283 + app.update();
284 + assert_eq!(
285 + app.world().get::<Visibility>(panel),
286 + Some(&Visibility::Hidden)
287 + );
288 + assert_eq!(
289 + app.world().get::<Visibility>(restore),
290 + Some(&Visibility::Inherited)
291 + );
292 + }
293 +}

src/worldgen.rs +5 -4

@@ -6,6 +6,7 @@ use std::{
6 6 use bevy::prelude::*;
7 7
8 8 use crate::{
9 + actions::Action,
9 10 noise::Noise,
10 11 rng::SimRng,
11 12 world::{MAX_HEIGHT, Plant, Terrain, WorldMap, hash2},
@@ -42,7 +43,7 @@ impl Plugin for WorldGenPlugin {
42 43 .add_systems(Startup, |mut generated: MessageWriter<WorldGenerated>| {
43 44 generated.write(WorldGenerated);
44 45 })
45 - .add_systems(Update, regenerate_on_key);
46 + .add_systems(Update, regenerate);
46 47 }
47 48 }
48 49
@@ -52,13 +53,13 @@ pub struct WorldSeed(pub u32);
52 53 #[derive(Message, Debug, Clone, Copy)]
53 54 pub struct WorldGenerated;
54 55
55 -fn regenerate_on_key(
56 - keys: Res<ButtonInput<KeyCode>>,
56 +fn regenerate(
57 + mut actions: MessageReader<Action>,
57 58 mut seed: ResMut<WorldSeed>,
58 59 mut map: ResMut<WorldMap>,
59 60 mut generated: MessageWriter<WorldGenerated>,
60 61 ) {
61 - if keys.just_pressed(KeyCode::KeyR) {
62 + if actions.read().any(|a| *a == Action::NewWorld) {
62 63 seed.0 = seed.0.wrapping_add(1);
63 64 *map = generate(map.size, seed.0);
64 65 generated.write(WorldGenerated);

src/zones.rs +13 -7

@@ -3,7 +3,9 @@ use std::collections::HashSet;
3 3 use bevy::prelude::*;
4 4
5 5 use crate::{
6 + actions::Action,
6 7 selection::{Cursor, update_cursor},
8 + ui::PointerOverUi,
7 9 world::{TILE_SIZE, WorldMap},
8 10 worldgen::{WorldGenerated, spawn_tiles},
9 11 };
@@ -90,26 +92,28 @@ fn removing(keys: &ButtonInput<KeyCode>) -> bool {
90 92 }
91 93
92 94 fn zone_tool(
95 + mut actions: MessageReader<Action>,
93 96 keys: Res<ButtonInput<KeyCode>>,
94 97 buttons: Res<ButtonInput<MouseButton>>,
98 + over_ui: Res<PointerOverUi>,
95 99 cursor: Res<Cursor>,
96 100 map: Res<WorldMap>,
97 101 mut tool: ResMut<ZoneTool>,
98 102 mut zones: ResMut<Zones>,
99 103 ) {
100 - if keys.just_pressed(KeyCode::KeyZ) {
101 - tool.active = !tool.active;
104 + for action in actions.read() {
105 + match action {
106 + Action::ToggleStockpileTool => tool.active = !tool.active,
107 + Action::Cancel => tool.active = false,
108 + _ => continue,
109 + }
102 110 tool.drag_start = None;
103 111 return;
104 112 }
105 - if keys.just_pressed(KeyCode::Escape) {
106 - tool.active = false;
107 - tool.drag_start = None;
108 - }
109 113 if !tool.active {
110 114 return;
111 115 }
112 - if buttons.just_pressed(MouseButton::Left) {
116 + if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
113 117 tool.drag_start = cursor.tile;
114 118 }
115 119 if !buttons.just_released(MouseButton::Left) {
@@ -183,6 +187,8 @@ mod tests {
183 187 drag_start: None,
184 188 })
185 189 .init_resource::<Cursor>()
190 + .init_resource::<PointerOverUi>()
191 + .add_message::<Action>()
186 192 .add_systems(Update, zone_tool);
187 193 app
188 194 }

tasks.md +3 -3

@@ -15,13 +15,13 @@
15 15 - [x] Job system: autonomous job choice from colony demand, reservations, manual orders override
16 16 - [x] Woodcutting (auto when wood < goal, or right-click a tree), wood item piles
17 17 - [x] Stockpile zones (Z tool, starting stockpile) and hauling (auto, or right-click loose items)
18 +- [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
18 19
19 20 ## Planned
20 -- [ ] Speed/pause buttons in UI
21 -- [ ] Ignore map clicks over UI
22 21 - [ ] Jobs: mining (stone/ore), building
23 22 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
24 23 - [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > chop > haul > wander)
25 24 - [ ] UI to edit colony goals (e.g. wood target)
26 25 - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky)
27 -- [ ] Basic UI (tile info, colonist panel)
26 +- [ ] Colonist panel / better info UI (info panel is plain text)
27 +- [ ] Zoom +/- buttons (camera has no buttons; WASD/drag/scroll only)