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