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