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