src/selection.rs 22.7 K · 722 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 actions::Action,
5 building::Blueprints,
6 camera::CameraController,
7 colonist::{Colonist, Path},
8 food::Hunger,
9 items::{Carrying, Item, ItemKind, stock},
10 jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
11 light::daylight,
12 needs::Rest,
13 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
14 structures::{Fuel, Structure, StructureKind},
15 ui::{HideableUi, PointerOverUi},
16 world::{TILE_SIZE, TilePos, WorldMap},
17 worldgen::{WorldGenerated, WorldSeed},
18 zones::{ZoneTool, Zones},
19 };
20
21 pub struct SelectionPlugin;
22
23 impl Plugin for SelectionPlugin {
24 fn build(&self, app: &mut App) {
25 app.init_resource::<Cursor>()
26 .init_resource::<Selection>()
27 .add_systems(Startup, spawn_tile_info)
28 .add_systems(Update, clear_selection.run_if(on_message::<WorldGenerated>))
29 .add_systems(
30 PostUpdate,
31 (
32 update_cursor,
33 update_selection.run_if(|tool: Res<ZoneTool>| !tool.active),
34 issue_move_orders,
35 draw_highlights,
36 update_tile_info,
37 )
38 .chain()
39 .after(TransformSystems::Propagate),
40 );
41 }
42 }
43
44 #[derive(Resource, Default, Debug, PartialEq)]
45 pub struct Cursor {
46 pub screen: Option<Vec2>,
47 pub world: Option<Vec2>,
48 pub tile: Option<UVec2>,
49 }
50
51 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
52 pub enum Selection {
53 #[default]
54 None,
55 Tile(UVec2),
56 Colonist(Entity),
57 }
58
59 #[derive(Component)]
60 struct TileInfoText;
61
62 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
63 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
64 const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4);
65 const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6);
66 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
67 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
68 const CLICK_SLOP_PX: f32 = 4.0;
69
70 pub fn update_cursor(
71 map: Res<WorldMap>,
72 window: Single<&Window, With<PrimaryWindow>>,
73 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
74 over_ui: Res<PointerOverUi>,
75 mut cursor: ResMut<Cursor>,
76 ) {
77 let (camera, global) = *camera;
78 let screen = window.cursor_position();
79 let world = screen
80 .filter(|_| !over_ui.0)
81 .and_then(|s| camera.viewport_to_world_2d(global, s).ok());
82 let tile = world.and_then(|w| map.tile_at(w));
83 cursor.set_if_neq(Cursor {
84 screen,
85 world,
86 tile,
87 });
88 }
89
90 fn clear_selection(mut selection: ResMut<Selection>) {
91 *selection = Selection::None;
92 }
93
94 fn update_selection(
95 mut actions: MessageReader<Action>,
96 buttons: Res<ButtonInput<MouseButton>>,
97 over_ui: Res<PointerOverUi>,
98 cursor: Res<Cursor>,
99 colonists: Query<(Entity, &Transform), With<Colonist>>,
100 mut selection: ResMut<Selection>,
101 ) {
102 if actions.read().any(|a| *a == Action::Cancel) {
103 selection.set_if_neq(Selection::None);
104 } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
105 let picked = cursor.world.and_then(|world| {
106 pick_nearest(
107 world,
108 colonists.iter().map(|(e, t)| (e, t.translation.truncate())),
109 )
110 });
111 let new = match (picked, cursor.tile) {
112 (Some(entity), _) => Selection::Colonist(entity),
113 (None, Some(tile)) => Selection::Tile(tile),
114 (None, None) => Selection::None,
115 };
116 selection.set_if_neq(new);
117 }
118 }
119
120 pub fn pick_nearest(
121 point: Vec2,
122 candidates: impl Iterator<Item = (Entity, Vec2)>,
123 ) -> Option<Entity> {
124 candidates
125 .map(|(e, pos)| (e, pos.distance(point)))
126 .filter(|&(_, d)| d <= PICK_RADIUS)
127 .min_by(|a, b| a.1.total_cmp(&b.1))
128 .map(|(e, _)| e)
129 }
130
131 fn issue_move_orders(
132 buttons: Res<ButtonInput<MouseButton>>,
133 cursor: Res<Cursor>,
134 selection: Res<Selection>,
135 over_ui: Res<PointerOverUi>,
136 mut orders: MessageWriter<Order>,
137 mut pressed_at: Local<Option<Vec2>>,
138 ) {
139 if buttons.just_pressed(MouseButton::Right) {
140 *pressed_at = cursor.screen.filter(|_| !over_ui.0);
141 }
142 if !buttons.just_released(MouseButton::Right) {
143 return;
144 }
145 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
146 return;
147 };
148 if let (true, Selection::Colonist(colonist), Some(tile)) =
149 (is_click(press, release), *selection, cursor.tile)
150 {
151 orders.write(Order { colonist, tile });
152 }
153 }
154
155 pub fn is_click(press: Vec2, release: Vec2) -> bool {
156 press.distance(release) <= CLICK_SLOP_PX
157 }
158
159 fn draw_highlights(
160 mut gizmos: Gizmos,
161 cursor: Res<Cursor>,
162 selection: Res<Selection>,
163 colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
164 ) {
165 for (_, transform, _, job) in &colonists {
166 if let Some(fraction) = job.0.and_then(|j| progress_fraction(j.task))
167 && fraction > 0.0
168 {
169 let left =
170 transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
171 let full = TILE_SIZE * 0.8;
172 let done = full * fraction;
173 gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
174 gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
175 }
176 }
177 if let Some(tile) = cursor.tile {
178 gizmos.rect_2d(
179 WorldMap::tile_center(tile),
180 Vec2::splat(TILE_SIZE),
181 HOVER_COLOR,
182 );
183 }
184 match *selection {
185 Selection::None => {}
186 Selection::Tile(tile) => {
187 gizmos.rect_2d(
188 WorldMap::tile_center(tile),
189 Vec2::splat(TILE_SIZE),
190 SELECT_COLOR,
191 );
192 }
193 Selection::Colonist(entity) => {
194 let Ok((_, transform, path, job)) = colonists.get(entity) else {
195 return;
196 };
197 if let Some(Job {
198 task:
199 Task::Work { site, .. }
200 | Task::ExpandStockpile { site, .. }
201 | Task::Build { site, .. },
202 ..
203 }) = job.0
204 {
205 gizmos.rect_2d(
206 WorldMap::tile_center(site),
207 Vec2::splat(TILE_SIZE),
208 PATH_COLOR,
209 );
210 }
211 let pos = transform.translation.truncate();
212 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
213 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
214 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
215 }
216 }
217 }
218
219 fn spawn_tile_info(mut commands: Commands) {
220 commands.spawn((
221 TileInfoText,
222 HideableUi,
223 Text::default(),
224 TextFont::from_font_size(14.0),
225 Node {
226 position_type: PositionType::Absolute,
227 top: px(8),
228 left: px(8),
229 padding: UiRect::all(px(6)),
230 ..default()
231 },
232 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
233 ));
234 }
235
236 fn update_tile_info(
237 map: Res<WorldMap>,
238 cursor: Res<Cursor>,
239 selection: Res<Selection>,
240 colonists: Query<(
241 &Colonist,
242 &TilePos,
243 &Path,
244 &CurrentJob,
245 &Carrying,
246 &Rest,
247 &Hunger,
248 )>,
249 items: Query<(&Item, &TilePos)>,
250 structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
251 blueprints: Res<Blueprints>,
252 goals: Res<ColonyGoals>,
253 zones: Res<Zones>,
254 tool: Res<ZoneTool>,
255 time: Res<Time<Virtual>>,
256 tick: Res<SimTick>,
257 speed: Res<SimSpeed>,
258 seed: Res<WorldSeed>,
259 mut text: Single<&mut Text, With<TileInfoText>>,
260 ) {
261 let selected = match *selection {
262 Selection::None => "-".to_string(),
263 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
264 Selection::Colonist(entity) => colonists
265 .get(entity)
266 .map(|(colonist, tile, path, job, carrying, rest, hunger)| {
267 describe_colonist(&ColonistView {
268 name: &colonist.name,
269 tile: tile.0,
270 job: job.0,
271 steps_left: path.0.len(),
272 carrying: carrying.0,
273 rest: rest.0,
274 fed: hunger.0,
275 })
276 })
277 .unwrap_or_else(|_| "-".into()),
278 };
279 let clock = format!(
280 "{} ({})",
281 format_clock(*tick),
282 time_of_day(hour_of_day(*tick))
283 );
284 let speed = describe_speed(*speed, time.is_paused());
285 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
286 let stock_line = |food: bool| {
287 ItemKind::ALL
288 .iter()
289 .filter(|kind| kind.is_food() == food)
290 .map(|&kind| {
291 let have = stock(
292 items.iter().map(|(item, _)| item).chain(carried.iter()),
293 kind,
294 );
295 format!("{kind:?} {have}/{}", goals.goal(kind))
296 })
297 .collect::<Vec<_>>()
298 .join(" ")
299 };
300 let materials = format!("{}\n{}", stock_line(false), stock_line(true));
301 let hovered_zone = match cursor.tile {
302 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
303 _ => "",
304 };
305 let hovered_items: String = items
306 .iter()
307 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
308 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
309 .collect();
310 let hovered_structure: String = structures
311 .iter()
312 .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
313 .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0)))
314 .chain(
315 cursor
316 .tile
317 .and_then(|t| blueprints.get(t))
318 .map(|kind| format!(", planned {}", kind.name())),
319 )
320 .collect();
321 let tool_hint = if tool.active {
322 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
323 } else {
324 ""
325 };
326 let info = format!(
327 "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}",
328 seed.0,
329 describe_tile(&map, cursor.tile)
330 );
331 if text.0 != info {
332 text.0 = info;
333 }
334 }
335
336 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
337 let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain)))
338 else {
339 return "-".into();
340 };
341 let p = t.as_ivec2();
342 let roof = if map.is_roofed(p) { ", Roofed" } else { "" };
343 let plant = map
344 .plant(p)
345 .map(|plant| match plant.ripening_ticks() {
346 None => format!(", {plant:?}"),
347 Some(_) if plant.is_ripe(map.growth(p)) => format!(", {plant:?} (ripe)"),
348 Some(_) => format!(", {plant:?} ({:.0}% grown)", map.growth(p) * 100.0),
349 })
350 .unwrap_or_default();
351 let height = map.height(p).unwrap_or(0.0);
352 format!("({}, {}) {terrain:?}{plant}{roof}, {height:.0} m", t.x, t.y)
353 }
354
355 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
356 if paused {
357 "PAUSED".into()
358 } else {
359 format!("{}x", speed.multiplier())
360 }
361 }
362
363 pub fn time_of_day(hour: f32) -> &'static str {
364 match daylight(hour) {
365 1.0 => "day",
366 0.0 => "night",
367 _ if hour < 12.0 => "dawn",
368 _ => "dusk",
369 }
370 }
371
372 pub fn describe_structure(kind: StructureKind, fuel: Option<u32>) -> String {
373 match fuel {
374 Some(fuel) => {
375 let hours = fuel as f32 / TICKS_PER_HOUR as f32;
376 format!(", {:?} ({hours:.1}h of fuel)", kind)
377 }
378 None => format!(", {kind:?}"),
379 }
380 }
381
382 pub fn progress_fraction(task: Task) -> Option<f32> {
383 match task {
384 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
385 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
386 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
387 _ => None,
388 }
389 }
390
391 fn percent(task: Task) -> u32 {
392 (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32
393 }
394
395 pub struct ColonistView<'a> {
396 pub name: &'a str,
397 pub tile: UVec2,
398 pub job: Option<Job>,
399 pub steps_left: usize,
400 pub carrying: Option<Item>,
401 pub rest: f32,
402 pub fed: f32,
403 }
404
405 pub fn describe_colonist(view: &ColonistView) -> String {
406 let (tile, job, carrying) = (view.tile, view.job, view.carrying);
407 let activity = match (job.map(|j| j.task), view.steps_left) {
408 (None, 0) => "idle".to_string(),
409 (None, _) => "wandering".to_string(),
410 (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
411 (
412 Some(Task::Work {
413 kind: WorkKind::Chop,
414 ..
415 }),
416 n,
417 ) if n > 0 => "walking to a tree".to_string(),
418 (
419 Some(Task::Work {
420 kind: WorkKind::Mine,
421 ..
422 }),
423 n,
424 ) if n > 0 => "walking to rock".to_string(),
425 (
426 Some(Task::Work {
427 kind: WorkKind::Harvest,
428 ..
429 }),
430 n,
431 ) if n > 0 => "walking to a plant".to_string(),
432 (Some(task @ Task::Work { kind, .. }), _) => {
433 let verb = match kind {
434 WorkKind::Chop => "chopping a tree",
435 WorkKind::Mine => "mining",
436 WorkKind::Harvest => "harvesting",
437 };
438 format!("{verb}, {}%", percent(task))
439 }
440 (Some(Task::ExpandStockpile { .. }), n) if n > 0 => {
441 "going to expand the stockpile".to_string()
442 }
443 (Some(task @ Task::ExpandStockpile { .. }), _) => {
444 format!("expanding the stockpile, {}%", percent(task))
445 }
446 (
447 Some(Task::Haul {
448 pickup: Some(_), ..
449 }),
450 _,
451 ) => "fetching items to haul".to_string(),
452 (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
453 (
454 Some(Task::Build {
455 kind,
456 pickup: Some(_),
457 ..
458 }),
459 _,
460 ) => format!("fetching {:?} for a {}", kind.cost().0, kind.name()).to_lowercase(),
461 (Some(Task::Build { kind, .. }), n) if n > 0 => {
462 format!("carrying {:?} to build a {}", kind.cost().0, kind.name()).to_lowercase()
463 }
464 (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
465 (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),
466 (
467 Some(Task::Eat {
468 pickup: Some(_), ..
469 }),
470 _,
471 ) => "fetching food".to_string(),
472 (Some(Task::Eat { .. }), _) => "eating".to_string(),
473 (Some(task @ Task::Build { kind, .. }), _) => {
474 format!("building a {}, {}%", kind.name(), percent(task))
475 }
476 };
477 let load = carrying
478 .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
479 .unwrap_or_default();
480 let ordered = if job.is_some_and(|j| j.ordered) {
481 " (ordered)"
482 } else {
483 ""
484 };
485 format!(
486 "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%, food {:.0}%",
487 view.name,
488 tile.x,
489 tile.y,
490 view.rest * 100.0,
491 view.fed * 100.0
492 )
493 }
494
495 #[cfg(test)]
496 mod tests {
497 use super::*;
498 use crate::world::Terrain;
499
500 fn selection_app(cursor: Cursor, selection: Selection) -> App {
501 let mut app = App::new();
502 app.init_resource::<ButtonInput<MouseButton>>()
503 .init_resource::<PointerOverUi>()
504 .insert_resource(cursor)
505 .insert_resource(selection)
506 .add_message::<Order>()
507 .add_message::<Action>()
508 .add_systems(Update, (update_selection, issue_move_orders));
509 app
510 }
511
512 fn cursor_on_tile(tile: UVec2) -> Cursor {
513 Cursor {
514 screen: Some(Vec2::new(50.0, 50.0)),
515 world: Some(WorldMap::tile_center(tile)),
516 tile: Some(tile),
517 }
518 }
519
520 fn press(app: &mut App, button: MouseButton) {
521 app.world_mut()
522 .resource_mut::<ButtonInput<MouseButton>>()
523 .press(button);
524 app.update();
525 }
526
527 fn release(app: &mut App, button: MouseButton) {
528 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
529 input.clear();
530 input.release(button);
531 app.update();
532 }
533
534 fn selection(app: &App) -> Selection {
535 *app.world().resource::<Selection>()
536 }
537
538 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
539 app.world_mut()
540 .resource_mut::<Messages<Order>>()
541 .drain()
542 .map(|o| (o.colonist, o.tile))
543 .collect()
544 }
545
546 #[test]
547 fn clicking_selects_the_hovered_tile() {
548 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
549 press(&mut app, MouseButton::Left);
550 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
551 }
552
553 #[test]
554 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
555 let tile = UVec2::new(3, 4);
556 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
557 let colonist = app
558 .world_mut()
559 .spawn((
560 Colonist { name: "Ada".into() },
561 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
562 ))
563 .id();
564 press(&mut app, MouseButton::Left);
565 assert_eq!(selection(&app), Selection::Colonist(colonist));
566 }
567
568 #[test]
569 fn clicking_outside_the_world_clears_the_selection() {
570 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
571 press(&mut app, MouseButton::Left);
572 assert_eq!(selection(&app), Selection::None);
573 }
574
575 #[test]
576 fn cancel_clears_the_selection() {
577 let mut app = selection_app(
578 cursor_on_tile(UVec2::ONE),
579 Selection::Tile(UVec2::new(3, 4)),
580 );
581 app.world_mut().write_message(Action::Cancel);
582 app.update();
583 assert_eq!(selection(&app), Selection::None);
584 }
585
586 #[test]
587 fn clicks_on_the_ui_do_not_reach_the_map() {
588 let colonist = Entity::from_raw_u32(7).unwrap();
589 let mut app = selection_app(
590 cursor_on_tile(UVec2::new(5, 6)),
591 Selection::Colonist(colonist),
592 );
593 app.insert_resource(PointerOverUi(true));
594 press(&mut app, MouseButton::Left);
595 assert_eq!(selection(&app), Selection::Colonist(colonist));
596 press(&mut app, MouseButton::Right);
597 release(&mut app, MouseButton::Right);
598 assert!(sent_orders(&mut app).is_empty());
599 }
600
601 #[test]
602 fn right_click_orders_selected_colonist_to_hovered_tile() {
603 let colonist = Entity::from_raw_u32(7).unwrap();
604 let mut app = selection_app(
605 cursor_on_tile(UVec2::new(5, 6)),
606 Selection::Colonist(colonist),
607 );
608 press(&mut app, MouseButton::Right);
609 release(&mut app, MouseButton::Right);
610 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
611 }
612
613 #[test]
614 fn right_drag_does_not_order_because_it_pans_the_camera() {
615 let colonist = Entity::from_raw_u32(7).unwrap();
616 let mut app = selection_app(
617 cursor_on_tile(UVec2::new(5, 6)),
618 Selection::Colonist(colonist),
619 );
620 press(&mut app, MouseButton::Right);
621 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
622 release(&mut app, MouseButton::Right);
623 assert!(sent_orders(&mut app).is_empty());
624 }
625
626 #[test]
627 fn pick_nearest_ignores_far_candidates() {
628 let a = Entity::from_raw_u32(1).unwrap();
629 let b = Entity::from_raw_u32(2).unwrap();
630 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
631 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
632 assert_eq!(
633 pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()),
634 None
635 );
636 }
637
638 #[test]
639 fn describe_tile_shows_coordinates_terrain_plant_and_height() {
640 let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
641 map.set_height(UVec2::new(1, 2), 12.4);
642 assert_eq!(
643 describe_tile(&map, Some(UVec2::new(1, 2))),
644 "(1, 2) Sand, 12 m"
645 );
646 map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree));
647 assert_eq!(
648 describe_tile(&map, Some(UVec2::new(1, 2))),
649 "(1, 2) Sand, Tree, 12 m"
650 );
651 assert_eq!(describe_tile(&map, None), "-");
652 }
653
654 #[test]
655 fn time_of_day_labels() {
656 assert_eq!(time_of_day(12.0), "day");
657 assert_eq!(time_of_day(6.0), "dawn");
658 assert_eq!(time_of_day(19.0), "dusk");
659 assert_eq!(time_of_day(1.0), "night");
660 }
661
662 #[test]
663 fn describe_speed_shows_multiplier_or_paused() {
664 assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x");
665 assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED");
666 }
667
668 fn view(job: Option<Job>, steps_left: usize, carrying: Option<Item>) -> ColonistView<'static> {
669 ColonistView {
670 name: "Ada",
671 tile: UVec2::new(1, 2),
672 job,
673 steps_left,
674 carrying,
675 rest: 1.0,
676 fed: 0.5,
677 }
678 }
679
680 #[test]
681 fn describe_colonist_shows_activity_and_needs() {
682 assert_eq!(
683 describe_colonist(&view(None, 0, None)),
684 "Ada at (1, 2), idle, rest 100%, food 50%"
685 );
686 let ordered_move = Job {
687 task: Task::MoveTo(UVec2::ZERO),
688 ordered: true,
689 };
690 assert_eq!(
691 describe_colonist(&view(Some(ordered_move), 3, None)),
692 "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%, food 50%"
693 );
694 let chop = Job {
695 task: Task::Work {
696 site: UVec2::ZERO,
697 kind: WorkKind::Chop,
698 progress: WorkKind::Chop.work() / 2,
699 },
700 ordered: false,
701 };
702 assert_eq!(
703 describe_colonist(&view(Some(chop), 0, None)),
704 "Ada at (1, 2), chopping a tree, 50%, rest 100%, food 50%"
705 );
706 let eat = Job {
707 task: Task::Eat {
708 pickup: None,
709 progress: 0,
710 },
711 ordered: false,
712 };
713 let meal = Item {
714 kind: ItemKind::Meal,
715 count: 1,
716 };
717 assert_eq!(
718 describe_colonist(&view(Some(eat), 0, Some(meal))),
719 "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%"
720 );
721 }
722 }