src/selection.rs 15.2 K · 491 lines · raw · history

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