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