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