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