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