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