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