Add tile hover and selection

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

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