Add action keybinds, toolbar buttons and hide-UI toggle
- 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) | |