src/selection.rs 11 K · 339 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 camera::CameraController,
5 colonist::{Colonist, MoveOrder, Path, TilePos},
6 world::{TILE_SIZE, WorldMap},
7 };
8
9 pub struct SelectionPlugin;
10
11 impl Plugin for SelectionPlugin {
12 fn build(&self, app: &mut App) {
13 app.init_resource::<Cursor>()
14 .init_resource::<Selection>()
15 .add_systems(Startup, spawn_tile_info)
16 .add_systems(
17 PostUpdate,
18 (
19 update_cursor,
20 update_selection,
21 issue_move_orders,
22 draw_highlights,
23 update_tile_info,
24 )
25 .chain()
26 .after(TransformSystems::Propagate),
27 );
28 }
29 }
30
31 #[derive(Resource, Default, Debug, PartialEq)]
32 pub struct Cursor {
33 pub screen: Option<Vec2>,
34 pub world: Option<Vec2>,
35 pub tile: Option<UVec2>,
36 }
37
38 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
39 pub enum Selection {
40 #[default]
41 None,
42 Tile(UVec2),
43 Colonist(Entity),
44 }
45
46 #[derive(Component)]
47 struct TileInfoText;
48
49 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
50 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
51 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
52 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
53 const CLICK_SLOP_PX: f32 = 4.0;
54
55 fn update_cursor(
56 map: Res<WorldMap>,
57 window: Single<&Window, With<PrimaryWindow>>,
58 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
59 mut cursor: ResMut<Cursor>,
60 ) {
61 let (camera, global) = *camera;
62 let screen = window.cursor_position();
63 let world = screen.and_then(|s| camera.viewport_to_world_2d(global, s).ok());
64 let tile = world.and_then(|w| map.tile_at(w));
65 cursor.set_if_neq(Cursor { screen, world, tile });
66 }
67
68 fn update_selection(
69 buttons: Res<ButtonInput<MouseButton>>,
70 keys: Res<ButtonInput<KeyCode>>,
71 cursor: Res<Cursor>,
72 colonists: Query<(Entity, &Transform), With<Colonist>>,
73 mut selection: ResMut<Selection>,
74 ) {
75 if keys.just_pressed(KeyCode::Escape) {
76 selection.set_if_neq(Selection::None);
77 } else if buttons.just_pressed(MouseButton::Left) {
78 let picked = cursor.world.and_then(|world| {
79 pick_nearest(world, colonists.iter().map(|(e, t)| (e, t.translation.truncate())))
80 });
81 let new = match (picked, cursor.tile) {
82 (Some(entity), _) => Selection::Colonist(entity),
83 (None, Some(tile)) => Selection::Tile(tile),
84 (None, None) => Selection::None,
85 };
86 selection.set_if_neq(new);
87 }
88 }
89
90 pub fn pick_nearest(point: Vec2, candidates: impl Iterator<Item = (Entity, Vec2)>) -> Option<Entity> {
91 candidates
92 .map(|(e, pos)| (e, pos.distance(point)))
93 .filter(|&(_, d)| d <= PICK_RADIUS)
94 .min_by(|a, b| a.1.total_cmp(&b.1))
95 .map(|(e, _)| e)
96 }
97
98 fn issue_move_orders(
99 buttons: Res<ButtonInput<MouseButton>>,
100 cursor: Res<Cursor>,
101 selection: Res<Selection>,
102 mut orders: MessageWriter<MoveOrder>,
103 mut pressed_at: Local<Option<Vec2>>,
104 ) {
105 if buttons.just_pressed(MouseButton::Right) {
106 *pressed_at = cursor.screen;
107 }
108 if !buttons.just_released(MouseButton::Right) {
109 return;
110 }
111 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
112 return;
113 };
114 if let (true, Selection::Colonist(colonist), Some(goal)) =
115 (is_click(press, release), *selection, cursor.tile)
116 {
117 orders.write(MoveOrder { colonist, goal });
118 }
119 }
120
121 pub fn is_click(press: Vec2, release: Vec2) -> bool {
122 press.distance(release) <= CLICK_SLOP_PX
123 }
124
125 fn draw_highlights(
126 mut gizmos: Gizmos,
127 cursor: Res<Cursor>,
128 selection: Res<Selection>,
129 colonists: Query<(&Transform, &Path), With<Colonist>>,
130 ) {
131 if let Some(tile) = cursor.tile {
132 gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), HOVER_COLOR);
133 }
134 match *selection {
135 Selection::None => {}
136 Selection::Tile(tile) => {
137 gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), SELECT_COLOR);
138 }
139 Selection::Colonist(entity) => {
140 let Ok((transform, path)) = colonists.get(entity) else {
141 return;
142 };
143 let pos = transform.translation.truncate();
144 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
145 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
146 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
147 }
148 }
149 }
150
151 fn spawn_tile_info(mut commands: Commands) {
152 commands.spawn((
153 TileInfoText,
154 Text::default(),
155 TextFont::from_font_size(14.0),
156 Node {
157 position_type: PositionType::Absolute,
158 top: px(8),
159 left: px(8),
160 padding: UiRect::all(px(6)),
161 ..default()
162 },
163 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
164 ));
165 }
166
167 fn update_tile_info(
168 map: Res<WorldMap>,
169 cursor: Res<Cursor>,
170 selection: Res<Selection>,
171 colonists: Query<(&Colonist, &TilePos, &Path)>,
172 time: Res<Time<Virtual>>,
173 mut text: Single<&mut Text, With<TileInfoText>>,
174 ) {
175 let selected = match *selection {
176 Selection::None => "-".to_string(),
177 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
178 Selection::Colonist(entity) => colonists
179 .get(entity)
180 .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len()))
181 .unwrap_or_else(|_| "-".into()),
182 };
183 let paused = if time.is_paused() { "PAUSED\n" } else { "" };
184 let info = format!(
185 "{paused}Hover: {}\nSelected: {selected}",
186 describe_tile(&map, cursor.tile)
187 );
188 if text.0 != info {
189 text.0 = info;
190 }
191 }
192
193 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
194 match tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) {
195 Some((t, terrain)) => format!("({}, {}) {terrain:?}", t.x, t.y),
196 None => "-".into(),
197 }
198 }
199
200 pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String {
201 let activity = match steps_left {
202 0 => "idle".to_string(),
203 n => format!("moving, {n} steps left"),
204 };
205 format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y)
206 }
207
208 #[cfg(test)]
209 mod tests {
210 use super::*;
211 use crate::world::Terrain;
212
213 fn selection_app(cursor: Cursor, selection: Selection) -> App {
214 let mut app = App::new();
215 app.init_resource::<ButtonInput<MouseButton>>()
216 .init_resource::<ButtonInput<KeyCode>>()
217 .insert_resource(cursor)
218 .insert_resource(selection)
219 .add_message::<MoveOrder>()
220 .add_systems(Update, (update_selection, issue_move_orders));
221 app
222 }
223
224 fn cursor_on_tile(tile: UVec2) -> Cursor {
225 Cursor {
226 screen: Some(Vec2::new(50.0, 50.0)),
227 world: Some(WorldMap::tile_center(tile)),
228 tile: Some(tile),
229 }
230 }
231
232 fn press(app: &mut App, button: MouseButton) {
233 app.world_mut().resource_mut::<ButtonInput<MouseButton>>().press(button);
234 app.update();
235 }
236
237 fn release(app: &mut App, button: MouseButton) {
238 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
239 input.clear();
240 input.release(button);
241 app.update();
242 }
243
244 fn selection(app: &App) -> Selection {
245 *app.world().resource::<Selection>()
246 }
247
248 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
249 app.world_mut()
250 .resource_mut::<Messages<MoveOrder>>()
251 .drain()
252 .map(|o| (o.colonist, o.goal))
253 .collect()
254 }
255
256 #[test]
257 fn clicking_selects_the_hovered_tile() {
258 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
259 press(&mut app, MouseButton::Left);
260 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
261 }
262
263 #[test]
264 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
265 let tile = UVec2::new(3, 4);
266 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
267 let colonist = app
268 .world_mut()
269 .spawn((
270 Colonist { name: "Ada".into() },
271 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
272 ))
273 .id();
274 press(&mut app, MouseButton::Left);
275 assert_eq!(selection(&app), Selection::Colonist(colonist));
276 }
277
278 #[test]
279 fn clicking_outside_the_world_clears_the_selection() {
280 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
281 press(&mut app, MouseButton::Left);
282 assert_eq!(selection(&app), Selection::None);
283 }
284
285 #[test]
286 fn escape_clears_the_selection() {
287 let mut app = selection_app(cursor_on_tile(UVec2::ONE), Selection::Tile(UVec2::new(3, 4)));
288 app.world_mut()
289 .resource_mut::<ButtonInput<KeyCode>>()
290 .press(KeyCode::Escape);
291 app.update();
292 assert_eq!(selection(&app), Selection::None);
293 }
294
295 #[test]
296 fn right_click_orders_selected_colonist_to_hovered_tile() {
297 let colonist = Entity::from_raw_u32(7).unwrap();
298 let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
299 press(&mut app, MouseButton::Right);
300 release(&mut app, MouseButton::Right);
301 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
302 }
303
304 #[test]
305 fn right_drag_does_not_order_because_it_pans_the_camera() {
306 let colonist = Entity::from_raw_u32(7).unwrap();
307 let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
308 press(&mut app, MouseButton::Right);
309 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
310 release(&mut app, MouseButton::Right);
311 assert!(sent_orders(&mut app).is_empty());
312 }
313
314 #[test]
315 fn pick_nearest_ignores_far_candidates() {
316 let a = Entity::from_raw_u32(1).unwrap();
317 let b = Entity::from_raw_u32(2).unwrap();
318 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
319 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
320 assert_eq!(pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()), None);
321 }
322
323 #[test]
324 fn describe_tile_shows_coordinates_and_terrain() {
325 let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
326 assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand");
327 assert_eq!(describe_tile(&map, None), "-");
328 }
329
330 #[test]
331 fn describe_colonist_shows_activity() {
332 let ada = Colonist { name: "Ada".into() };
333 assert_eq!(describe_colonist(&ada, UVec2::new(1, 2), 0), "Ada at (1, 2), idle");
334 assert_eq!(
335 describe_colonist(&ada, UVec2::new(1, 2), 3),
336 "Ada at (1, 2), moving, 3 steps left"
337 );
338 }
339 }