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