src/selection.rs 17.6 K · 562 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 actions::Action,
5 camera::CameraController,
6 colonist::{Colonist, Path},
7 items::{Carrying, Item, ItemKind, stock},
8 jobs::{CHOP_TICKS, ColonyGoals, CurrentJob, Job, Order, Task},
9 sim::{SimSpeed, SimTick, format_clock},
10 ui::{HideableUi, PointerOverUi},
11 world::{TILE_SIZE, TilePos, WorldMap},
12 worldgen::{WorldGenerated, WorldSeed},
13 zones::{ZoneTool, Zones},
14 };
15
16 pub struct SelectionPlugin;
17
18 impl Plugin for SelectionPlugin {
19 fn build(&self, app: &mut App) {
20 app.init_resource::<Cursor>()
21 .init_resource::<Selection>()
22 .add_systems(Startup, spawn_tile_info)
23 .add_systems(Update, clear_selection.run_if(on_message::<WorldGenerated>))
24 .add_systems(
25 PostUpdate,
26 (
27 update_cursor,
28 update_selection.run_if(|tool: Res<ZoneTool>| !tool.active),
29 issue_move_orders,
30 draw_highlights,
31 update_tile_info,
32 )
33 .chain()
34 .after(TransformSystems::Propagate),
35 );
36 }
37 }
38
39 #[derive(Resource, Default, Debug, PartialEq)]
40 pub struct Cursor {
41 pub screen: Option<Vec2>,
42 pub world: Option<Vec2>,
43 pub tile: Option<UVec2>,
44 }
45
46 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
47 pub enum Selection {
48 #[default]
49 None,
50 Tile(UVec2),
51 Colonist(Entity),
52 }
53
54 #[derive(Component)]
55 struct TileInfoText;
56
57 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
58 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
59 const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4);
60 const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6);
61 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
62 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
63 const CLICK_SLOP_PX: f32 = 4.0;
64
65 pub fn update_cursor(
66 map: Res<WorldMap>,
67 window: Single<&Window, With<PrimaryWindow>>,
68 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
69 over_ui: Res<PointerOverUi>,
70 mut cursor: ResMut<Cursor>,
71 ) {
72 let (camera, global) = *camera;
73 let screen = window.cursor_position();
74 let world = screen
75 .filter(|_| !over_ui.0)
76 .and_then(|s| camera.viewport_to_world_2d(global, s).ok());
77 let tile = world.and_then(|w| map.tile_at(w));
78 cursor.set_if_neq(Cursor {
79 screen,
80 world,
81 tile,
82 });
83 }
84
85 fn clear_selection(mut selection: ResMut<Selection>) {
86 *selection = Selection::None;
87 }
88
89 fn update_selection(
90 mut actions: MessageReader<Action>,
91 buttons: Res<ButtonInput<MouseButton>>,
92 over_ui: Res<PointerOverUi>,
93 cursor: Res<Cursor>,
94 colonists: Query<(Entity, &Transform), With<Colonist>>,
95 mut selection: ResMut<Selection>,
96 ) {
97 if actions.read().any(|a| *a == Action::Cancel) {
98 selection.set_if_neq(Selection::None);
99 } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
100 let picked = cursor.world.and_then(|world| {
101 pick_nearest(
102 world,
103 colonists.iter().map(|(e, t)| (e, t.translation.truncate())),
104 )
105 });
106 let new = match (picked, cursor.tile) {
107 (Some(entity), _) => Selection::Colonist(entity),
108 (None, Some(tile)) => Selection::Tile(tile),
109 (None, None) => Selection::None,
110 };
111 selection.set_if_neq(new);
112 }
113 }
114
115 pub fn pick_nearest(
116 point: Vec2,
117 candidates: impl Iterator<Item = (Entity, Vec2)>,
118 ) -> Option<Entity> {
119 candidates
120 .map(|(e, pos)| (e, pos.distance(point)))
121 .filter(|&(_, d)| d <= PICK_RADIUS)
122 .min_by(|a, b| a.1.total_cmp(&b.1))
123 .map(|(e, _)| e)
124 }
125
126 fn issue_move_orders(
127 buttons: Res<ButtonInput<MouseButton>>,
128 cursor: Res<Cursor>,
129 selection: Res<Selection>,
130 over_ui: Res<PointerOverUi>,
131 mut orders: MessageWriter<Order>,
132 mut pressed_at: Local<Option<Vec2>>,
133 ) {
134 if buttons.just_pressed(MouseButton::Right) {
135 *pressed_at = cursor.screen.filter(|_| !over_ui.0);
136 }
137 if !buttons.just_released(MouseButton::Right) {
138 return;
139 }
140 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
141 return;
142 };
143 if let (true, Selection::Colonist(colonist), Some(tile)) =
144 (is_click(press, release), *selection, cursor.tile)
145 {
146 orders.write(Order { colonist, tile });
147 }
148 }
149
150 pub fn is_click(press: Vec2, release: Vec2) -> bool {
151 press.distance(release) <= CLICK_SLOP_PX
152 }
153
154 fn draw_highlights(
155 mut gizmos: Gizmos,
156 cursor: Res<Cursor>,
157 selection: Res<Selection>,
158 colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
159 ) {
160 for (_, transform, _, job) in &colonists {
161 if let Some(Job {
162 task: Task::Chop { progress, .. },
163 ..
164 }) = job.0
165 && progress > 0
166 {
167 let left =
168 transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
169 let full = TILE_SIZE * 0.8;
170 let done = full * progress as f32 / CHOP_TICKS as f32;
171 gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
172 gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
173 }
174 }
175 if let Some(tile) = cursor.tile {
176 gizmos.rect_2d(
177 WorldMap::tile_center(tile),
178 Vec2::splat(TILE_SIZE),
179 HOVER_COLOR,
180 );
181 }
182 match *selection {
183 Selection::None => {}
184 Selection::Tile(tile) => {
185 gizmos.rect_2d(
186 WorldMap::tile_center(tile),
187 Vec2::splat(TILE_SIZE),
188 SELECT_COLOR,
189 );
190 }
191 Selection::Colonist(entity) => {
192 let Ok((_, transform, path, job)) = colonists.get(entity) else {
193 return;
194 };
195 if let Some(Job {
196 task: Task::Chop { tree, .. },
197 ..
198 }) = job.0
199 {
200 gizmos.rect_2d(
201 WorldMap::tile_center(tree),
202 Vec2::splat(TILE_SIZE),
203 PATH_COLOR,
204 );
205 }
206 let pos = transform.translation.truncate();
207 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
208 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
209 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
210 }
211 }
212 }
213
214 fn spawn_tile_info(mut commands: Commands) {
215 commands.spawn((
216 TileInfoText,
217 HideableUi,
218 Text::default(),
219 TextFont::from_font_size(14.0),
220 Node {
221 position_type: PositionType::Absolute,
222 top: px(8),
223 left: px(8),
224 padding: UiRect::all(px(6)),
225 ..default()
226 },
227 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
228 ));
229 }
230
231 fn update_tile_info(
232 map: Res<WorldMap>,
233 cursor: Res<Cursor>,
234 selection: Res<Selection>,
235 colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying)>,
236 items: Query<(&Item, &TilePos)>,
237 goals: Res<ColonyGoals>,
238 zones: Res<Zones>,
239 tool: Res<ZoneTool>,
240 time: Res<Time<Virtual>>,
241 tick: Res<SimTick>,
242 speed: Res<SimSpeed>,
243 seed: Res<WorldSeed>,
244 mut text: Single<&mut Text, With<TileInfoText>>,
245 ) {
246 let selected = match *selection {
247 Selection::None => "-".to_string(),
248 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
249 Selection::Colonist(entity) => colonists
250 .get(entity)
251 .map(|(colonist, tile, path, job, carrying)| {
252 describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0)
253 })
254 .unwrap_or_else(|_| "-".into()),
255 };
256 let clock = format_clock(*tick);
257 let speed = describe_speed(*speed, time.is_paused());
258 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
259 let wood = stock(
260 items.iter().map(|(item, _)| item).chain(carried.iter()),
261 ItemKind::Wood,
262 );
263 let stored = stock(
264 items
265 .iter()
266 .filter(|(_, pos)| zones.is_stockpile(pos.0))
267 .map(|(item, _)| item),
268 ItemKind::Wood,
269 );
270 let hovered_zone = match cursor.tile {
271 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
272 _ => "",
273 };
274 let hovered_items: String = items
275 .iter()
276 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
277 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
278 .collect();
279 let tool_hint = if tool.active {
280 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
281 } else {
282 ""
283 };
284 let info = format!(
285 "{clock} {speed}\nSeed: {}\nWood: {wood} / {} ({stored} stored){tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
286 seed.0,
287 goals.wood,
288 describe_tile(&map, cursor.tile)
289 );
290 if text.0 != info {
291 text.0 = info;
292 }
293 }
294
295 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
296 let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain)))
297 else {
298 return "-".into();
299 };
300 let p = t.as_ivec2();
301 let plant = map
302 .plant(p)
303 .map(|plant| format!(", {plant:?}"))
304 .unwrap_or_default();
305 let height = map.height(p).unwrap_or(0.0);
306 format!("({}, {}) {terrain:?}{plant}, {height:.0} m", t.x, t.y)
307 }
308
309 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
310 if paused {
311 "PAUSED".into()
312 } else {
313 format!("{}x", speed.multiplier())
314 }
315 }
316
317 pub fn describe_colonist(
318 colonist: &Colonist,
319 tile: UVec2,
320 job: Option<Job>,
321 steps_left: usize,
322 carrying: Option<Item>,
323 ) -> String {
324 let activity = match (job.map(|j| j.task), steps_left) {
325 (None, 0) => "idle".to_string(),
326 (None, _) => "wandering".to_string(),
327 (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
328 (Some(Task::Chop { .. }), n) if n > 0 => "walking to a tree".to_string(),
329 (Some(Task::Chop { progress, .. }), _) => {
330 format!("chopping a tree, {}%", progress * 100 / CHOP_TICKS)
331 }
332 (
333 Some(Task::Haul {
334 pickup: Some(_), ..
335 }),
336 _,
337 ) => "fetching items to haul".to_string(),
338 (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
339 };
340 let load = carrying
341 .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
342 .unwrap_or_default();
343 let ordered = if job.is_some_and(|j| j.ordered) {
344 " (ordered)"
345 } else {
346 ""
347 };
348 format!(
349 "{} at ({}, {}), {activity}{ordered}{load}",
350 colonist.name, tile.x, tile.y
351 )
352 }
353
354 #[cfg(test)]
355 mod tests {
356 use super::*;
357 use crate::world::Terrain;
358
359 fn selection_app(cursor: Cursor, selection: Selection) -> App {
360 let mut app = App::new();
361 app.init_resource::<ButtonInput<MouseButton>>()
362 .init_resource::<PointerOverUi>()
363 .insert_resource(cursor)
364 .insert_resource(selection)
365 .add_message::<Order>()
366 .add_message::<Action>()
367 .add_systems(Update, (update_selection, issue_move_orders));
368 app
369 }
370
371 fn cursor_on_tile(tile: UVec2) -> Cursor {
372 Cursor {
373 screen: Some(Vec2::new(50.0, 50.0)),
374 world: Some(WorldMap::tile_center(tile)),
375 tile: Some(tile),
376 }
377 }
378
379 fn press(app: &mut App, button: MouseButton) {
380 app.world_mut()
381 .resource_mut::<ButtonInput<MouseButton>>()
382 .press(button);
383 app.update();
384 }
385
386 fn release(app: &mut App, button: MouseButton) {
387 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
388 input.clear();
389 input.release(button);
390 app.update();
391 }
392
393 fn selection(app: &App) -> Selection {
394 *app.world().resource::<Selection>()
395 }
396
397 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
398 app.world_mut()
399 .resource_mut::<Messages<Order>>()
400 .drain()
401 .map(|o| (o.colonist, o.tile))
402 .collect()
403 }
404
405 #[test]
406 fn clicking_selects_the_hovered_tile() {
407 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
408 press(&mut app, MouseButton::Left);
409 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
410 }
411
412 #[test]
413 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
414 let tile = UVec2::new(3, 4);
415 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
416 let colonist = app
417 .world_mut()
418 .spawn((
419 Colonist { name: "Ada".into() },
420 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
421 ))
422 .id();
423 press(&mut app, MouseButton::Left);
424 assert_eq!(selection(&app), Selection::Colonist(colonist));
425 }
426
427 #[test]
428 fn clicking_outside_the_world_clears_the_selection() {
429 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
430 press(&mut app, MouseButton::Left);
431 assert_eq!(selection(&app), Selection::None);
432 }
433
434 #[test]
435 fn cancel_clears_the_selection() {
436 let mut app = selection_app(
437 cursor_on_tile(UVec2::ONE),
438 Selection::Tile(UVec2::new(3, 4)),
439 );
440 app.world_mut().write_message(Action::Cancel);
441 app.update();
442 assert_eq!(selection(&app), Selection::None);
443 }
444
445 #[test]
446 fn clicks_on_the_ui_do_not_reach_the_map() {
447 let colonist = Entity::from_raw_u32(7).unwrap();
448 let mut app = selection_app(
449 cursor_on_tile(UVec2::new(5, 6)),
450 Selection::Colonist(colonist),
451 );
452 app.insert_resource(PointerOverUi(true));
453 press(&mut app, MouseButton::Left);
454 assert_eq!(selection(&app), Selection::Colonist(colonist));
455 press(&mut app, MouseButton::Right);
456 release(&mut app, MouseButton::Right);
457 assert!(sent_orders(&mut app).is_empty());
458 }
459
460 #[test]
461 fn right_click_orders_selected_colonist_to_hovered_tile() {
462 let colonist = Entity::from_raw_u32(7).unwrap();
463 let mut app = selection_app(
464 cursor_on_tile(UVec2::new(5, 6)),
465 Selection::Colonist(colonist),
466 );
467 press(&mut app, MouseButton::Right);
468 release(&mut app, MouseButton::Right);
469 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
470 }
471
472 #[test]
473 fn right_drag_does_not_order_because_it_pans_the_camera() {
474 let colonist = Entity::from_raw_u32(7).unwrap();
475 let mut app = selection_app(
476 cursor_on_tile(UVec2::new(5, 6)),
477 Selection::Colonist(colonist),
478 );
479 press(&mut app, MouseButton::Right);
480 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
481 release(&mut app, MouseButton::Right);
482 assert!(sent_orders(&mut app).is_empty());
483 }
484
485 #[test]
486 fn pick_nearest_ignores_far_candidates() {
487 let a = Entity::from_raw_u32(1).unwrap();
488 let b = Entity::from_raw_u32(2).unwrap();
489 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
490 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
491 assert_eq!(
492 pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()),
493 None
494 );
495 }
496
497 #[test]
498 fn describe_tile_shows_coordinates_terrain_plant_and_height() {
499 let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
500 map.set_height(UVec2::new(1, 2), 12.4);
501 assert_eq!(
502 describe_tile(&map, Some(UVec2::new(1, 2))),
503 "(1, 2) Sand, 12 m"
504 );
505 map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree));
506 assert_eq!(
507 describe_tile(&map, Some(UVec2::new(1, 2))),
508 "(1, 2) Sand, Tree, 12 m"
509 );
510 assert_eq!(describe_tile(&map, None), "-");
511 }
512
513 #[test]
514 fn describe_speed_shows_multiplier_or_paused() {
515 assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x");
516 assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED");
517 }
518
519 #[test]
520 fn describe_colonist_shows_activity() {
521 let ada = Colonist { name: "Ada".into() };
522 let tile = UVec2::new(1, 2);
523 assert_eq!(
524 describe_colonist(&ada, tile, None, 0, None),
525 "Ada at (1, 2), idle"
526 );
527 let ordered_move = Job {
528 task: Task::MoveTo(UVec2::ZERO),
529 ordered: true,
530 };
531 assert_eq!(
532 describe_colonist(&ada, tile, Some(ordered_move), 3, None),
533 "Ada at (1, 2), moving, 3 steps left (ordered)"
534 );
535 let chop = Job {
536 task: Task::Chop {
537 tree: UVec2::ZERO,
538 progress: CHOP_TICKS / 2,
539 },
540 ordered: false,
541 };
542 assert_eq!(
543 describe_colonist(&ada, tile, Some(chop), 0, None),
544 "Ada at (1, 2), chopping a tree, 50%"
545 );
546 let haul = Job {
547 task: Task::Haul {
548 pickup: None,
549 to: UVec2::ZERO,
550 },
551 ordered: false,
552 };
553 let wood = Item {
554 kind: ItemKind::Wood,
555 count: 10,
556 };
557 assert_eq!(
558 describe_colonist(&ada, tile, Some(haul), 4, Some(wood)),
559 "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood"
560 );
561 }
562 }