src/selection.rs 24.1 K · 750 lines · raw · history

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