src/selection.rs 29.6 K · 880 lines · raw · history

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