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