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