Add tile hover and selection
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 08:04 UTC
- commit
- da945f129702e09bc007ae9221d7d14e7409107d
- parent
- e2a668dc09
- tree
- browse at this commit
5 files changed +195 -3
| CLAUDE.md | +2 -1 | |
| src/main.rs | +6 -1 | |
| src/selection.rs | added | +162 -0 |
| src/world.rs | +24 -0 | |
| tasks.md | +1 -1 |
CLAUDE.md +2 -1
| @@ -13,6 +13,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 13 | 13 | - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters), `WorldMap` resource (row-major, (0,0)=bottom-left, y up), `TILE_SIZE`=16 world units, `hash2` deterministic int hash, `static_world()` handmade 192x128 map (placeholder until procgen). |
| 14 | 14 | - `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`. |
| 15 | 15 | - `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. |
| 16 | +- `src/selection.rs` `HoveredTile`/`SelectedTile` resources (Option<UVec2>). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform): cursor→world→`map.tile_at`; LMB selects hovered (off-map clears), Esc clears; gizmo `rect_2d` outlines (white hover, yellow selected); top-left UI text via `describe_tile`. Selection logic tested with headless `App` + `ButtonInput` resources. | |
| 16 | 17 | |
| 17 | 18 | ## Gotchas |
| 18 | 19 | - WebGL2 (wgpu GLES) guesses array texture view dim from layer count: 1→D2, 6→Cube, 6k→CubeArray. Tileset layer count padded via `tileset_layer_count`. Symptom: black screen + "wgpu-hal heuristics assumed ... Cube" errors. |
| @@ -23,7 +24,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 23 | 24 | - Native: `cargo run`. Tests: `cargo test`. |
| 24 | 25 | - Web: `./scripts/build-web.sh [profile]` (default `wasm-release`, ~3.5 min cold) → `target/web/`. Needs wasm-bindgen-cli matching Cargo.lock `wasm-bindgen` version (currently 0.2.129, installed in ~/.cargo/bin). |
| 25 | 26 | - 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. |
| 26 | -- Bevy logs in browser console; check for ERROR after load. | |
| 27 | +- 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. | |
| 27 | 28 | |
| 28 | 29 | ## Docs |
| 29 | 30 | - `docs/bevy/examples/` full 0.20 examples (e.g. `2d/tilemap_chunk.rs`, `camera/*`). `docs/bevy/release-content/` migration guides + release notes. |
src/main.rs +6 -1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | mod camera; |
| 2 | +mod selection; | |
| 2 | 3 | mod tilemap; |
| 3 | 4 | mod world; |
| 4 | 5 | |
| @@ -21,6 +22,10 @@ fn main() { | ||
| 21 | 22 | ) |
| 22 | 23 | .insert_resource(ClearColor(Color::srgb(0.08, 0.08, 0.1))) |
| 23 | 24 | .insert_resource(world::static_world()) |
| 24 | - .add_plugins((tilemap::TilemapPlugin, camera::CameraPlugin)) | |
| 25 | + .add_plugins(( | |
| 26 | + tilemap::TilemapPlugin, | |
| 27 | + camera::CameraPlugin, | |
| 28 | + selection::SelectionPlugin, | |
| 29 | + )) | |
| 25 | 30 | .run(); |
| 26 | 31 | } |
src/selection.rs +162 -0
| @@ -0,0 +1,162 @@ | ||
| 1 | +use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow}; | |
| 2 | + | |
| 3 | +use crate::{ | |
| 4 | + camera::CameraController, | |
| 5 | + world::{TILE_SIZE, WorldMap}, | |
| 6 | +}; | |
| 7 | + | |
| 8 | +pub struct SelectionPlugin; | |
| 9 | + | |
| 10 | +impl Plugin for SelectionPlugin { | |
| 11 | + fn build(&self, app: &mut App) { | |
| 12 | + app.init_resource::<HoveredTile>() | |
| 13 | + .init_resource::<SelectedTile>() | |
| 14 | + .add_systems(Startup, spawn_tile_info) | |
| 15 | + .add_systems( | |
| 16 | + PostUpdate, | |
| 17 | + (update_hovered, update_selected, draw_highlights, update_tile_info) | |
| 18 | + .chain() | |
| 19 | + .after(TransformSystems::Propagate), | |
| 20 | + ); | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +#[derive(Resource, Default, Debug, PartialEq)] | |
| 25 | +pub struct HoveredTile(pub Option<UVec2>); | |
| 26 | + | |
| 27 | +#[derive(Resource, Default, Debug, PartialEq)] | |
| 28 | +pub struct SelectedTile(pub Option<UVec2>); | |
| 29 | + | |
| 30 | +#[derive(Component)] | |
| 31 | +struct TileInfoText; | |
| 32 | + | |
| 33 | +const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6); | |
| 34 | +const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2); | |
| 35 | + | |
| 36 | +fn update_hovered( | |
| 37 | + map: Res<WorldMap>, | |
| 38 | + window: Single<&Window, With<PrimaryWindow>>, | |
| 39 | + camera: Single<(&Camera, &GlobalTransform), With<CameraController>>, | |
| 40 | + mut hovered: ResMut<HoveredTile>, | |
| 41 | +) { | |
| 42 | + let (camera, global) = *camera; | |
| 43 | + let tile = window | |
| 44 | + .cursor_position() | |
| 45 | + .and_then(|cursor| camera.viewport_to_world_2d(global, cursor).ok()) | |
| 46 | + .and_then(|world| map.tile_at(world)); | |
| 47 | + hovered.set_if_neq(HoveredTile(tile)); | |
| 48 | +} | |
| 49 | + | |
| 50 | +fn update_selected( | |
| 51 | + buttons: Res<ButtonInput<MouseButton>>, | |
| 52 | + keys: Res<ButtonInput<KeyCode>>, | |
| 53 | + hovered: Res<HoveredTile>, | |
| 54 | + mut selected: ResMut<SelectedTile>, | |
| 55 | +) { | |
| 56 | + if keys.just_pressed(KeyCode::Escape) { | |
| 57 | + selected.set_if_neq(SelectedTile(None)); | |
| 58 | + } else if buttons.just_pressed(MouseButton::Left) { | |
| 59 | + selected.set_if_neq(SelectedTile(hovered.0)); | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +fn draw_highlights(mut gizmos: Gizmos, hovered: Res<HoveredTile>, selected: Res<SelectedTile>) { | |
| 64 | + for (tile, color) in [(hovered.0, HOVER_COLOR), (selected.0, SELECT_COLOR)] { | |
| 65 | + if let Some(tile) = tile { | |
| 66 | + gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), color); | |
| 67 | + } | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +fn spawn_tile_info(mut commands: Commands) { | |
| 72 | + commands.spawn(( | |
| 73 | + TileInfoText, | |
| 74 | + Text::default(), | |
| 75 | + TextFont::from_font_size(14.0), | |
| 76 | + Node { | |
| 77 | + position_type: PositionType::Absolute, | |
| 78 | + top: px(8), | |
| 79 | + left: px(8), | |
| 80 | + padding: UiRect::all(px(6)), | |
| 81 | + ..default() | |
| 82 | + }, | |
| 83 | + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)), | |
| 84 | + )); | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn update_tile_info( | |
| 88 | + map: Res<WorldMap>, | |
| 89 | + hovered: Res<HoveredTile>, | |
| 90 | + selected: Res<SelectedTile>, | |
| 91 | + mut text: Single<&mut Text, With<TileInfoText>>, | |
| 92 | +) { | |
| 93 | + if !hovered.is_changed() && !selected.is_changed() { | |
| 94 | + return; | |
| 95 | + } | |
| 96 | + text.0 = format!( | |
| 97 | + "Hover: {}\nSelected: {}", | |
| 98 | + describe_tile(&map, hovered.0), | |
| 99 | + describe_tile(&map, selected.0) | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String { | |
| 104 | + match tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) { | |
| 105 | + Some((t, terrain)) => format!("({}, {}) {terrain:?}", t.x, t.y), | |
| 106 | + None => "-".into(), | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +#[cfg(test)] | |
| 111 | +mod tests { | |
| 112 | + use super::*; | |
| 113 | + use crate::world::Terrain; | |
| 114 | + | |
| 115 | + fn selection_app(hovered: Option<UVec2>, selected: Option<UVec2>) -> App { | |
| 116 | + let mut app = App::new(); | |
| 117 | + app.init_resource::<ButtonInput<MouseButton>>() | |
| 118 | + .init_resource::<ButtonInput<KeyCode>>() | |
| 119 | + .insert_resource(HoveredTile(hovered)) | |
| 120 | + .insert_resource(SelectedTile(selected)) | |
| 121 | + .add_systems(Update, update_selected); | |
| 122 | + app | |
| 123 | + } | |
| 124 | + | |
| 125 | + fn click(app: &mut App) { | |
| 126 | + app.world_mut() | |
| 127 | + .resource_mut::<ButtonInput<MouseButton>>() | |
| 128 | + .press(MouseButton::Left); | |
| 129 | + app.update(); | |
| 130 | + } | |
| 131 | + | |
| 132 | + #[test] | |
| 133 | + fn clicking_selects_the_hovered_tile() { | |
| 134 | + let mut app = selection_app(Some(UVec2::new(3, 4)), None); | |
| 135 | + click(&mut app); | |
| 136 | + assert_eq!(app.world().resource::<SelectedTile>().0, Some(UVec2::new(3, 4))); | |
| 137 | + } | |
| 138 | + | |
| 139 | + #[test] | |
| 140 | + fn clicking_outside_the_world_clears_the_selection() { | |
| 141 | + let mut app = selection_app(None, Some(UVec2::new(3, 4))); | |
| 142 | + click(&mut app); | |
| 143 | + assert_eq!(app.world().resource::<SelectedTile>().0, None); | |
| 144 | + } | |
| 145 | + | |
| 146 | + #[test] | |
| 147 | + fn escape_clears_the_selection() { | |
| 148 | + let mut app = selection_app(Some(UVec2::ONE), Some(UVec2::new(3, 4))); | |
| 149 | + app.world_mut() | |
| 150 | + .resource_mut::<ButtonInput<KeyCode>>() | |
| 151 | + .press(KeyCode::Escape); | |
| 152 | + app.update(); | |
| 153 | + assert_eq!(app.world().resource::<SelectedTile>().0, None); | |
| 154 | + } | |
| 155 | + | |
| 156 | + #[test] | |
| 157 | + fn describe_tile_shows_coordinates_and_terrain() { | |
| 158 | + let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand); | |
| 159 | + assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand"); | |
| 160 | + assert_eq!(describe_tile(&map, None), "-"); | |
| 161 | + } | |
| 162 | +} | |
src/world.rs +24 -0
| @@ -79,6 +79,15 @@ impl WorldMap { | ||
| 79 | 79 | self.size.as_vec2() * TILE_SIZE |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | + pub fn tile_center(pos: UVec2) -> Vec2 { | |
| 83 | + (pos.as_vec2() + 0.5) * TILE_SIZE | |
| 84 | + } | |
| 85 | + | |
| 86 | + pub fn tile_at(&self, world: Vec2) -> Option<UVec2> { | |
| 87 | + let pos = (world / TILE_SIZE).floor().as_ivec2(); | |
| 88 | + self.in_bounds(pos).then(|| pos.as_uvec2()) | |
| 89 | + } | |
| 90 | + | |
| 82 | 91 | } |
| 83 | 92 | |
| 84 | 93 | pub fn hash2(x: u32, y: u32) -> u32 { |
| @@ -142,6 +151,21 @@ mod tests { | ||
| 142 | 151 | } |
| 143 | 152 | |
| 144 | 153 | #[test] |
| 154 | + fn tile_at_inverts_tile_center() { | |
| 155 | + let map = static_world(); | |
| 156 | + for pos in [UVec2::ZERO, UVec2::new(17, 99), map.size - 1] { | |
| 157 | + assert_eq!(map.tile_at(WorldMap::tile_center(pos)), Some(pos)); | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + #[test] | |
| 162 | + fn tile_at_outside_world_is_none() { | |
| 163 | + let map = static_world(); | |
| 164 | + assert_eq!(map.tile_at(Vec2::new(-0.1, 0.0)), None); | |
| 165 | + assert_eq!(map.tile_at(map.world_size()), None); | |
| 166 | + } | |
| 167 | + | |
| 168 | + #[test] | |
| 145 | 169 | fn terrain_index_matches_position_in_all() { |
| 146 | 170 | for (i, t) in Terrain::ALL.iter().enumerate() { |
| 147 | 171 | assert_eq!(t.index() as usize, i); |
tasks.md +1 -1
| @@ -5,10 +5,10 @@ | ||
| 5 | 5 | - [x] Static 2D grid world (terrain types, chunked tilemap rendering) |
| 6 | 6 | - [x] Camera: WASD/arrow pan, right/middle-drag pan, scroll zoom toward cursor, clamped to world |
| 7 | 7 | - [x] WebAssembly build (`scripts/build-web.sh`) |
| 8 | +- [x] Tile hover + selection (outlines, info text, Esc to clear) | |
| 8 | 9 | |
| 9 | 10 | ## Planned |
| 10 | 11 | - [ ] Procedural world generation (noise-based terrain, seeds) |
| 11 | -- [ ] Tile hover/selection under cursor | |
| 12 | 12 | - [ ] Colonists (pawns) with movement and pathfinding |
| 13 | 13 | - [ ] Simulation tick / time controls (pause, speeds) |
| 14 | 14 | - [ ] Jobs: mining, building, hauling |