src/selection.rs 16.8 K · 540 lines · raw · history

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