src/selection.rs 27.7 K · 829 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 actions::Action,
5 animals::{Animal, Tame},
6 building::{Blueprints, BuildMode, BuildTool},
7 camera::CameraController,
8 colonist::{Colonist, Path},
9 designations::DesignateTool,
10 economy::{ColonyGoals, Stock, in_colony},
11 family::{Child, Partner},
12 food::Hunger,
13 items::{Carrying, Item, stock},
14 jobs::{BUTCHER_WORK, COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
15 light::daylight,
16 needs::Rest,
17 population::ColonyLog,
18 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
19 structures::{Fuel, Structure, StructureKind},
20 ui::{HideableUi, PointerOverUi},
21 world::{TILE_SIZE, TilePos, WorldMap},
22 worldgen::{WorldGenerated, WorldSeed},
23 zones::{ZoneKind, ZoneTool, Zones},
24 };
25
26 pub struct SelectionPlugin;
27
28 impl Plugin for SelectionPlugin {
29 fn build(&self, app: &mut App) {
30 app.init_resource::<Cursor>()
31 .init_resource::<Selection>()
32 .add_systems(Startup, spawn_tile_info)
33 .add_systems(Update, clear_selection.run_if(on_message::<WorldGenerated>))
34 .add_systems(
35 PostUpdate,
36 (
37 update_cursor,
38 update_selection.run_if(
39 |tool: Res<ZoneTool>, build: Res<BuildTool>, mark: Res<DesignateTool>| {
40 !tool.active() && !build.active() && !mark.active
41 },
42 ),
43 issue_move_orders,
44 draw_highlights,
45 update_tile_info,
46 )
47 .chain()
48 .after(TransformSystems::Propagate),
49 );
50 }
51 }
52
53 #[derive(Resource, Default, Debug, PartialEq)]
54 pub struct Cursor {
55 pub screen: Option<Vec2>,
56 pub world: Option<Vec2>,
57 pub tile: Option<UVec2>,
58 }
59
60 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
61 pub enum Selection {
62 #[default]
63 None,
64 Tile(UVec2),
65 Colonist(Entity),
66 }
67
68 #[derive(Component)]
69 struct TileInfoText;
70
71 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
72 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
73 const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4);
74 const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6);
75 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
76 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
77 const CLICK_SLOP_PX: f32 = 4.0;
78
79 pub fn update_cursor(
80 map: Res<WorldMap>,
81 window: Single<&Window, With<PrimaryWindow>>,
82 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
83 over_ui: Res<PointerOverUi>,
84 mut cursor: ResMut<Cursor>,
85 ) {
86 let (camera, global) = *camera;
87 let screen = window.cursor_position();
88 let world = screen
89 .filter(|_| !over_ui.0)
90 .and_then(|s| camera.viewport_to_world_2d(global, s).ok());
91 let tile = world.and_then(|w| map.tile_at(w));
92 cursor.set_if_neq(Cursor {
93 screen,
94 world,
95 tile,
96 });
97 }
98
99 fn clear_selection(mut selection: ResMut<Selection>) {
100 *selection = Selection::None;
101 }
102
103 fn update_selection(
104 mut actions: MessageReader<Action>,
105 buttons: Res<ButtonInput<MouseButton>>,
106 over_ui: Res<PointerOverUi>,
107 cursor: Res<Cursor>,
108 colonists: Query<(Entity, &Transform), With<Colonist>>,
109 mut selection: ResMut<Selection>,
110 ) {
111 if actions.read().any(|a| *a == Action::Cancel) {
112 selection.set_if_neq(Selection::None);
113 } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
114 let picked = cursor.world.and_then(|world| {
115 pick_nearest(
116 world,
117 colonists.iter().map(|(e, t)| (e, t.translation.truncate())),
118 )
119 });
120 let new = match (picked, cursor.tile) {
121 (Some(entity), _) => Selection::Colonist(entity),
122 (None, Some(tile)) => Selection::Tile(tile),
123 (None, None) => Selection::None,
124 };
125 selection.set_if_neq(new);
126 }
127 }
128
129 pub fn pick_nearest(
130 point: Vec2,
131 candidates: impl Iterator<Item = (Entity, Vec2)>,
132 ) -> Option<Entity> {
133 candidates
134 .map(|(e, pos)| (e, pos.distance(point)))
135 .filter(|&(_, d)| d <= PICK_RADIUS)
136 .min_by(|a, b| a.1.total_cmp(&b.1))
137 .map(|(e, _)| e)
138 }
139
140 fn issue_move_orders(
141 buttons: Res<ButtonInput<MouseButton>>,
142 cursor: Res<Cursor>,
143 selection: Res<Selection>,
144 over_ui: Res<PointerOverUi>,
145 mut orders: MessageWriter<Order>,
146 mut pressed_at: Local<Option<Vec2>>,
147 ) {
148 if buttons.just_pressed(MouseButton::Right) {
149 *pressed_at = cursor.screen.filter(|_| !over_ui.0);
150 }
151 if !buttons.just_released(MouseButton::Right) {
152 return;
153 }
154 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
155 return;
156 };
157 if let (true, Selection::Colonist(colonist), Some(tile)) =
158 (is_click(press, release), *selection, cursor.tile)
159 {
160 orders.write(Order { colonist, tile });
161 }
162 }
163
164 pub fn is_click(press: Vec2, release: Vec2) -> bool {
165 press.distance(release) <= CLICK_SLOP_PX
166 }
167
168 fn draw_highlights(
169 mut gizmos: Gizmos,
170 cursor: Res<Cursor>,
171 selection: Res<Selection>,
172 colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
173 ) {
174 for (_, transform, _, job) in &colonists {
175 if let Some(fraction) = job.0.and_then(|j| progress_fraction(j.task))
176 && fraction > 0.0
177 {
178 let left =
179 transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
180 let full = TILE_SIZE * 0.8;
181 let done = full * fraction;
182 gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
183 gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
184 }
185 }
186 if let Some(tile) = cursor.tile {
187 gizmos.rect_2d(
188 WorldMap::tile_center(tile),
189 Vec2::splat(TILE_SIZE),
190 HOVER_COLOR,
191 );
192 }
193 match *selection {
194 Selection::None => {}
195 Selection::Tile(tile) => {
196 gizmos.rect_2d(
197 WorldMap::tile_center(tile),
198 Vec2::splat(TILE_SIZE),
199 SELECT_COLOR,
200 );
201 }
202 Selection::Colonist(entity) => {
203 let Ok((_, transform, path, job)) = colonists.get(entity) else {
204 return;
205 };
206 if let Some(Job {
207 task:
208 Task::Work { site, .. }
209 | Task::ExpandStockpile { site, .. }
210 | Task::Build { site, .. },
211 ..
212 }) = job.0
213 {
214 gizmos.rect_2d(
215 WorldMap::tile_center(site),
216 Vec2::splat(TILE_SIZE),
217 PATH_COLOR,
218 );
219 }
220 let pos = transform.translation.truncate();
221 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
222 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
223 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
224 }
225 }
226 }
227
228 fn spawn_tile_info(mut commands: Commands) {
229 commands.spawn((
230 TileInfoText,
231 HideableUi,
232 Text::default(),
233 TextFont::from_font_size(14.0),
234 Node {
235 position_type: PositionType::Absolute,
236 top: px(8),
237 left: px(8),
238 padding: UiRect::all(px(6)),
239 ..default()
240 },
241 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
242 ));
243 }
244
245 #[allow(clippy::too_many_arguments)]
246 fn update_tile_info(
247 map: Res<WorldMap>,
248 cursor: Res<Cursor>,
249 selection: Res<Selection>,
250 colonists: Query<(
251 &Colonist,
252 &TilePos,
253 &Path,
254 &CurrentJob,
255 &Carrying,
256 &Rest,
257 &Hunger,
258 )>,
259 items: Query<(&Item, &TilePos)>,
260 structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
261 animals: Query<(&Animal, &TilePos, Has<Tame>)>,
262 blueprints: Res<Blueprints>,
263 goals: Res<ColonyGoals>,
264 log: Res<ColonyLog>,
265 zones: Res<Zones>,
266 tools: (Res<ZoneTool>, Res<BuildTool>, Res<DesignateTool>),
267 clock: (
268 Res<Time<Virtual>>,
269 Res<SimTick>,
270 Res<SimSpeed>,
271 Res<WorldSeed>,
272 ),
273 kin: Query<(Option<&Partner>, Option<&Child>)>,
274 mut text: Single<&mut Text, With<TileInfoText>>,
275 ) {
276 let (time, tick, speed, seed) = clock;
277 let selected = match *selection {
278 Selection::None => "-".to_string(),
279 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
280 Selection::Colonist(entity) => colonists
281 .get(entity)
282 .map(|(colonist, tile, path, job, carrying, rest, hunger)| {
283 describe_colonist(&ColonistView {
284 name: &colonist.name,
285 tile: tile.0,
286 job: job.0,
287 steps_left: path.0.len(),
288 carrying: carrying.0,
289 rest: rest.0,
290 fed: hunger.0,
291 }) + &kin
292 .get(entity)
293 .map(|(partner, child)| {
294 let partner = partner
295 .and_then(|p| colonists.get(p.0).ok())
296 .map(|p| format!(", partner {}", p.0.name));
297 let child =
298 child.map(|c| format!(", child (grows up in {}d)", c.days_left(*tick)));
299 partner.unwrap_or_default() + &child.unwrap_or_default()
300 })
301 .unwrap_or_default()
302 })
303 .unwrap_or_else(|_| "-".into()),
304 };
305 let clock = format!(
306 "{} ({})",
307 format_clock(*tick),
308 time_of_day(hour_of_day(*tick))
309 );
310 let speed = describe_speed(*speed, time.is_paused(), time.relative_speed());
311 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
312 let home = zones.home(map.size / 2);
313 let stock_line = |food: bool| {
314 Stock::ALL
315 .iter()
316 .filter(|s| s.is_food() == food)
317 .map(|&s| {
318 let have: u32 = s
319 .members()
320 .map(|kind| {
321 stock(
322 items
323 .iter()
324 .filter(|(_, pos)| in_colony(home, pos.0))
325 .map(|(item, _)| item)
326 .chain(carried.iter()),
327 kind,
328 )
329 })
330 .sum();
331 format!("{} {have}/{}", s.label(), goals.goal(s))
332 })
333 .collect::<Vec<_>>()
334 .join(" ")
335 };
336 let materials = format!("{}\n{}", stock_line(false), stock_line(true));
337 let hovered_zone = match cursor.tile {
338 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
339 Some(tile) if zones.is_farm(tile) => ", Farm",
340 _ => "",
341 };
342 let hovered_items: String = items
343 .iter()
344 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
345 .map(|(item, _)| describe_item(item))
346 .collect();
347 let hovered_structure: String = structures
348 .iter()
349 .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
350 .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0)))
351 .chain(
352 cursor
353 .tile
354 .and_then(|t| blueprints.get(t))
355 .map(|kind| format!(", planned {}", kind.name())),
356 )
357 .chain(
358 animals
359 .iter()
360 .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
361 .map(|(animal, _, tame)| {
362 format!(
363 ", {}{}",
364 if tame { "tame " } else { "" },
365 animal.kind.name()
366 )
367 }),
368 )
369 .collect();
370 let events: String = log.0.iter().map(|line| format!("\n{line}")).collect();
371 let (tool, build_tool, designate_tool) = tools;
372 let tool_hint = match (tool.mode, build_tool.mode) {
373 _ if designate_tool.active => {
374 "\nCHOP/MINE: drag over trees and rock, shift-drag to clear".to_string()
375 }
376 (Some(ZoneKind::Stockpile), _) => {
377 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string()
378 }
379 (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(),
380 (None, Some(BuildMode::Deconstruct)) => {
381 "\nDECONSTRUCT: drag over structures, shift-drag to remove plans, B for next"
382 .to_string()
383 }
384 (None, Some(BuildMode::Place(kind))) => format!(
385 "\nBUILD {}: {}, shift-drag to remove plans, B for next",
386 kind.name().to_uppercase(),
387 if kind == StructureKind::Wall {
388 "drag a room outline"
389 } else {
390 "click to place"
391 }
392 ),
393 (None, None) => String::new(),
394 };
395 let info = format!(
396 "{clock} {speed}\nSeed: {} Colonists: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}{events}",
397 seed.0,
398 colonists.iter().count(),
399 describe_tile(&map, cursor.tile)
400 );
401 if text.0 != info {
402 text.0 = info;
403 }
404 }
405
406 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
407 let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain)))
408 else {
409 return "-".into();
410 };
411 let p = t.as_ivec2();
412 let roof = if map.is_roofed(p) { ", Roofed" } else { "" };
413 let plant = map
414 .plant(p)
415 .map(|plant| match plant.ripening_ticks() {
416 None => format!(", {plant:?}"),
417 Some(_) if plant.is_ripe(map.growth(p)) => format!(", {plant:?} (ripe)"),
418 Some(_) => format!(", {plant:?} ({:.0}% grown)", map.growth(p) * 100.0),
419 })
420 .unwrap_or_default();
421 let height = map.height(p).unwrap_or(0.0);
422 let wear = match map.wear(p) {
423 w if w >= 1.0 => format!(", worn {w:.0}"),
424 _ => String::new(),
425 };
426 format!(
427 "({}, {}) {terrain:?}{plant}{roof}, {height:.0} m{wear}",
428 t.x, t.y
429 )
430 }
431
432 pub fn describe_item(item: &Item) -> String {
433 let spoil = if item.kind.is_food() && item.spoil >= 0.01 {
434 format!(" ({:.0}% spoiled)", item.spoil * 100.0)
435 } else {
436 String::new()
437 };
438 format!(", {:?} x{}{spoil}", item.kind, item.count)
439 }
440
441 pub fn describe_speed(speed: SimSpeed, paused: bool, relative: f32) -> String {
442 match (paused, speed) {
443 (true, _) => "PAUSED".into(),
444 (false, SimSpeed::Max) => format!("Max ({relative:.0}x)"),
445 (false, _) => format!("{}x", speed.multiplier()),
446 }
447 }
448
449 pub fn time_of_day(hour: f32) -> &'static str {
450 match daylight(hour) {
451 1.0 => "day",
452 0.0 => "night",
453 _ if hour < 12.0 => "dawn",
454 _ => "dusk",
455 }
456 }
457
458 pub fn describe_structure(kind: StructureKind, fuel: Option<u32>) -> String {
459 match fuel {
460 Some(fuel) => {
461 let hours = fuel as f32 / TICKS_PER_HOUR as f32;
462 format!(", {:?} ({hours:.1}h of fuel)", kind)
463 }
464 None => format!(", {kind:?}"),
465 }
466 }
467
468 pub fn progress_fraction(task: Task) -> Option<f32> {
469 match task {
470 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
471 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
472 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
473 Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32),
474 Task::Cook { progress, .. } => Some(progress as f32 / COOK_WORK as f32),
475 Task::Butcher { progress, .. } => Some(progress as f32 / BUTCHER_WORK as f32),
476 Task::Tame { kind, progress, .. } => Some(progress as f32 / kind.tame_work() as f32),
477 _ => None,
478 }
479 }
480
481 fn percent(task: Task) -> u32 {
482 (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32
483 }
484
485 pub struct ColonistView<'a> {
486 pub name: &'a str,
487 pub tile: UVec2,
488 pub job: Option<Job>,
489 pub steps_left: usize,
490 pub carrying: Option<Item>,
491 pub rest: f32,
492 pub fed: f32,
493 }
494
495 pub fn describe_colonist(view: &ColonistView) -> String {
496 let (tile, job, carrying) = (view.tile, view.job, view.carrying);
497 let activity = match (job.map(|j| j.task), view.steps_left) {
498 (None, 0) => "idle".to_string(),
499 (None, _) => "wandering".to_string(),
500 (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
501 (
502 Some(Task::Work {
503 kind: WorkKind::Chop,
504 ..
505 }),
506 n,
507 ) if n > 0 => "walking to a tree".to_string(),
508 (
509 Some(Task::Work {
510 kind: WorkKind::Mine,
511 ..
512 }),
513 n,
514 ) if n > 0 => "walking to rock".to_string(),
515 (
516 Some(Task::Work {
517 kind: WorkKind::Harvest | WorkKind::Sow,
518 ..
519 }),
520 n,
521 ) if n > 0 => "walking to a plant".to_string(),
522 (Some(task @ Task::Work { kind, .. }), _) => {
523 let verb = match kind {
524 WorkKind::Chop => "chopping a tree",
525 WorkKind::Mine => "mining",
526 WorkKind::Harvest => "harvesting",
527 WorkKind::Sow => "sowing",
528 };
529 format!("{verb}, {}%", percent(task))
530 }
531 (Some(Task::ExpandStockpile { .. }), n) if n > 0 => {
532 "going to expand the stockpile".to_string()
533 }
534 (Some(task @ Task::ExpandStockpile { .. }), _) => {
535 format!("expanding the stockpile, {}%", percent(task))
536 }
537 (
538 Some(Task::Haul {
539 pickup: Some(_), ..
540 }),
541 _,
542 ) => "fetching items to haul".to_string(),
543 (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
544 (
545 Some(Task::Build {
546 kind,
547 pickup: Some(_),
548 ..
549 }),
550 _,
551 ) => format!("fetching {:?} for a {}", kind.cost().0, kind.name()).to_lowercase(),
552 (Some(Task::Build { kind, .. }), n) if n > 0 => {
553 format!("carrying {:?} to build a {}", kind.cost().0, kind.name()).to_lowercase()
554 }
555 (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
556 (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),
557 (
558 Some(Task::Eat {
559 pickup: Some(_), ..
560 }),
561 _,
562 ) => "fetching food".to_string(),
563 (Some(Task::Eat { .. }), _) => "eating".to_string(),
564 (Some(task @ Task::Hunt { kind, progress, .. }), _) if progress > 0 => {
565 format!("hunting a {}, {}%", kind.name(), percent(task))
566 }
567 (Some(Task::Hunt { kind, .. }), _) => format!("stalking a {}", kind.name()),
568 (
569 Some(Task::Cook {
570 pickup: Some(_), ..
571 }),
572 _,
573 ) => "fetching ingredients".to_string(),
574 (Some(Task::Cook { .. }), n) if n > 0 => "carrying ingredients to the fire".to_string(),
575 (Some(task @ Task::Cook { .. }), _) => format!("cooking a meal, {}%", percent(task)),
576 (Some(Task::Butcher { .. }), n) if n > 0 => "walking to a carcass".to_string(),
577 (Some(Task::Tame { kind, .. }), n) if n > 0 => format!("approaching a {}", kind.name()),
578 (Some(task @ Task::Tame { kind, .. }), _) => {
579 format!("taming a {}, {}%", kind.name(), percent(task))
580 }
581 (Some(task @ Task::Butcher { .. }), _) => format!("butchering, {}%", percent(task)),
582 (Some(task @ Task::Build { kind, .. }), _) => {
583 format!("building a {}, {}%", kind.name(), percent(task))
584 }
585 };
586 let load = carrying
587 .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
588 .unwrap_or_default();
589 let ordered = if job.is_some_and(|j| j.ordered) {
590 " (ordered)"
591 } else {
592 ""
593 };
594 format!(
595 "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%, food {:.0}%",
596 view.name,
597 tile.x,
598 tile.y,
599 view.rest * 100.0,
600 view.fed * 100.0
601 )
602 }
603
604 #[cfg(test)]
605 mod tests {
606 use super::*;
607 use crate::world::Terrain;
608
609 fn selection_app(cursor: Cursor, selection: Selection) -> App {
610 let mut app = App::new();
611 app.init_resource::<ButtonInput<MouseButton>>()
612 .init_resource::<PointerOverUi>()
613 .insert_resource(cursor)
614 .insert_resource(selection)
615 .add_message::<Order>()
616 .add_message::<Action>()
617 .add_systems(Update, (update_selection, issue_move_orders));
618 app
619 }
620
621 fn cursor_on_tile(tile: UVec2) -> Cursor {
622 Cursor {
623 screen: Some(Vec2::new(50.0, 50.0)),
624 world: Some(WorldMap::tile_center(tile)),
625 tile: Some(tile),
626 }
627 }
628
629 fn press(app: &mut App, button: MouseButton) {
630 app.world_mut()
631 .resource_mut::<ButtonInput<MouseButton>>()
632 .press(button);
633 app.update();
634 }
635
636 fn release(app: &mut App, button: MouseButton) {
637 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
638 input.clear();
639 input.release(button);
640 app.update();
641 }
642
643 fn selection(app: &App) -> Selection {
644 *app.world().resource::<Selection>()
645 }
646
647 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
648 app.world_mut()
649 .resource_mut::<Messages<Order>>()
650 .drain()
651 .map(|o| (o.colonist, o.tile))
652 .collect()
653 }
654
655 #[test]
656 fn clicking_selects_the_hovered_tile() {
657 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
658 press(&mut app, MouseButton::Left);
659 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
660 }
661
662 #[test]
663 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
664 let tile = UVec2::new(3, 4);
665 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
666 let colonist = app
667 .world_mut()
668 .spawn((
669 Colonist { name: "Ada".into() },
670 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
671 ))
672 .id();
673 press(&mut app, MouseButton::Left);
674 assert_eq!(selection(&app), Selection::Colonist(colonist));
675 }
676
677 #[test]
678 fn clicking_outside_the_world_clears_the_selection() {
679 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
680 press(&mut app, MouseButton::Left);
681 assert_eq!(selection(&app), Selection::None);
682 }
683
684 #[test]
685 fn cancel_clears_the_selection() {
686 let mut app = selection_app(
687 cursor_on_tile(UVec2::ONE),
688 Selection::Tile(UVec2::new(3, 4)),
689 );
690 app.world_mut().write_message(Action::Cancel);
691 app.update();
692 assert_eq!(selection(&app), Selection::None);
693 }
694
695 #[test]
696 fn clicks_on_the_ui_do_not_reach_the_map() {
697 let colonist = Entity::from_raw_u32(7).unwrap();
698 let mut app = selection_app(
699 cursor_on_tile(UVec2::new(5, 6)),
700 Selection::Colonist(colonist),
701 );
702 app.insert_resource(PointerOverUi(true));
703 press(&mut app, MouseButton::Left);
704 assert_eq!(selection(&app), Selection::Colonist(colonist));
705 press(&mut app, MouseButton::Right);
706 release(&mut app, MouseButton::Right);
707 assert!(sent_orders(&mut app).is_empty());
708 }
709
710 #[test]
711 fn right_click_orders_selected_colonist_to_hovered_tile() {
712 let colonist = Entity::from_raw_u32(7).unwrap();
713 let mut app = selection_app(
714 cursor_on_tile(UVec2::new(5, 6)),
715 Selection::Colonist(colonist),
716 );
717 press(&mut app, MouseButton::Right);
718 release(&mut app, MouseButton::Right);
719 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
720 }
721
722 #[test]
723 fn right_drag_does_not_order_because_it_pans_the_camera() {
724 let colonist = Entity::from_raw_u32(7).unwrap();
725 let mut app = selection_app(
726 cursor_on_tile(UVec2::new(5, 6)),
727 Selection::Colonist(colonist),
728 );
729 press(&mut app, MouseButton::Right);
730 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
731 release(&mut app, MouseButton::Right);
732 assert!(sent_orders(&mut app).is_empty());
733 }
734
735 #[test]
736 fn pick_nearest_ignores_far_candidates() {
737 let a = Entity::from_raw_u32(1).unwrap();
738 let b = Entity::from_raw_u32(2).unwrap();
739 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
740 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
741 assert_eq!(
742 pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()),
743 None
744 );
745 }
746
747 #[test]
748 fn describe_tile_shows_coordinates_terrain_plant_and_height() {
749 let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
750 map.set_height(UVec2::new(1, 2), 12.4);
751 assert_eq!(
752 describe_tile(&map, Some(UVec2::new(1, 2))),
753 "(1, 2) Sand, 12 m"
754 );
755 map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree));
756 assert_eq!(
757 describe_tile(&map, Some(UVec2::new(1, 2))),
758 "(1, 2) Sand, Tree, 12 m"
759 );
760 assert_eq!(describe_tile(&map, None), "-");
761 }
762
763 #[test]
764 fn time_of_day_labels() {
765 assert_eq!(time_of_day(12.0), "day");
766 assert_eq!(time_of_day(6.0), "dawn");
767 assert_eq!(time_of_day(19.0), "dusk");
768 assert_eq!(time_of_day(1.0), "night");
769 }
770
771 #[test]
772 fn describe_speed_shows_multiplier_or_paused() {
773 assert_eq!(describe_speed(SimSpeed::Fastest, false, 6.0), "6x");
774 assert_eq!(describe_speed(SimSpeed::Fast, true, 3.0), "PAUSED");
775 assert_eq!(describe_speed(SimSpeed::Max, false, 123.4), "Max (123x)");
776 }
777
778 fn view(job: Option<Job>, steps_left: usize, carrying: Option<Item>) -> ColonistView<'static> {
779 ColonistView {
780 name: "Ada",
781 tile: UVec2::new(1, 2),
782 job,
783 steps_left,
784 carrying,
785 rest: 1.0,
786 fed: 0.5,
787 }
788 }
789
790 #[test]
791 fn describe_colonist_shows_activity_and_needs() {
792 assert_eq!(
793 describe_colonist(&view(None, 0, None)),
794 "Ada at (1, 2), idle, rest 100%, food 50%"
795 );
796 let ordered_move = Job {
797 task: Task::MoveTo(UVec2::ZERO),
798 ordered: true,
799 };
800 assert_eq!(
801 describe_colonist(&view(Some(ordered_move), 3, None)),
802 "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%, food 50%"
803 );
804 let chop = Job {
805 task: Task::Work {
806 site: UVec2::ZERO,
807 kind: WorkKind::Chop,
808 progress: WorkKind::Chop.work() / 2,
809 },
810 ordered: false,
811 };
812 assert_eq!(
813 describe_colonist(&view(Some(chop), 0, None)),
814 "Ada at (1, 2), chopping a tree, 50%, rest 100%, food 50%"
815 );
816 let eat = Job {
817 task: Task::Eat {
818 pickup: None,
819 progress: 0,
820 },
821 ordered: false,
822 };
823 let meal = Item::new(crate::items::ItemKind::Meal, 1);
824 assert_eq!(
825 describe_colonist(&view(Some(eat), 0, Some(meal))),
826 "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%"
827 );
828 }
829 }