src/selection.rs 28.4 K · 848 lines · raw · history

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