src/selection.rs 18.7 K · 594 lines · raw · history

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