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