src/selection.rs 26.4 K · 805 lines · raw · history

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