src/selection.rs 11.3 K · 350 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 camera::CameraController,
5 colonist::{Colonist, MoveOrder, Path, TilePos},
6 world::{TILE_SIZE, WorldMap},
7 worldgen::{WorldGenerated, WorldSeed},
8 };
9
10 pub struct SelectionPlugin;
11
12 impl Plugin for SelectionPlugin {
13 fn build(&self, app: &mut App) {
14 app.init_resource::<Cursor>()
15 .init_resource::<Selection>()
16 .add_systems(Startup, spawn_tile_info)
17 .add_systems(
18 Update,
19 clear_selection.run_if(on_message::<WorldGenerated>),
20 )
21 .add_systems(
22 PostUpdate,
23 (
24 update_cursor,
25 update_selection,
26 issue_move_orders,
27 draw_highlights,
28 update_tile_info,
29 )
30 .chain()
31 .after(TransformSystems::Propagate),
32 );
33 }
34 }
35
36 #[derive(Resource, Default, Debug, PartialEq)]
37 pub struct Cursor {
38 pub screen: Option<Vec2>,
39 pub world: Option<Vec2>,
40 pub tile: Option<UVec2>,
41 }
42
43 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
44 pub enum Selection {
45 #[default]
46 None,
47 Tile(UVec2),
48 Colonist(Entity),
49 }
50
51 #[derive(Component)]
52 struct TileInfoText;
53
54 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
55 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
56 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
57 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
58 const CLICK_SLOP_PX: f32 = 4.0;
59
60 fn update_cursor(
61 map: Res<WorldMap>,
62 window: Single<&Window, With<PrimaryWindow>>,
63 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
64 mut cursor: ResMut<Cursor>,
65 ) {
66 let (camera, global) = *camera;
67 let screen = window.cursor_position();
68 let world = screen.and_then(|s| camera.viewport_to_world_2d(global, s).ok());
69 let tile = world.and_then(|w| map.tile_at(w));
70 cursor.set_if_neq(Cursor { screen, world, tile });
71 }
72
73 fn clear_selection(mut selection: ResMut<Selection>) {
74 *selection = Selection::None;
75 }
76
77 fn update_selection(
78 buttons: Res<ButtonInput<MouseButton>>,
79 keys: Res<ButtonInput<KeyCode>>,
80 cursor: Res<Cursor>,
81 colonists: Query<(Entity, &Transform), With<Colonist>>,
82 mut selection: ResMut<Selection>,
83 ) {
84 if keys.just_pressed(KeyCode::Escape) {
85 selection.set_if_neq(Selection::None);
86 } else if buttons.just_pressed(MouseButton::Left) {
87 let picked = cursor.world.and_then(|world| {
88 pick_nearest(world, colonists.iter().map(|(e, t)| (e, t.translation.truncate())))
89 });
90 let new = match (picked, cursor.tile) {
91 (Some(entity), _) => Selection::Colonist(entity),
92 (None, Some(tile)) => Selection::Tile(tile),
93 (None, None) => Selection::None,
94 };
95 selection.set_if_neq(new);
96 }
97 }
98
99 pub fn pick_nearest(point: Vec2, candidates: impl Iterator<Item = (Entity, Vec2)>) -> Option<Entity> {
100 candidates
101 .map(|(e, pos)| (e, pos.distance(point)))
102 .filter(|&(_, d)| d <= PICK_RADIUS)
103 .min_by(|a, b| a.1.total_cmp(&b.1))
104 .map(|(e, _)| e)
105 }
106
107 fn issue_move_orders(
108 buttons: Res<ButtonInput<MouseButton>>,
109 cursor: Res<Cursor>,
110 selection: Res<Selection>,
111 mut orders: MessageWriter<MoveOrder>,
112 mut pressed_at: Local<Option<Vec2>>,
113 ) {
114 if buttons.just_pressed(MouseButton::Right) {
115 *pressed_at = cursor.screen;
116 }
117 if !buttons.just_released(MouseButton::Right) {
118 return;
119 }
120 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
121 return;
122 };
123 if let (true, Selection::Colonist(colonist), Some(goal)) =
124 (is_click(press, release), *selection, cursor.tile)
125 {
126 orders.write(MoveOrder { colonist, goal });
127 }
128 }
129
130 pub fn is_click(press: Vec2, release: Vec2) -> bool {
131 press.distance(release) <= CLICK_SLOP_PX
132 }
133
134 fn draw_highlights(
135 mut gizmos: Gizmos,
136 cursor: Res<Cursor>,
137 selection: Res<Selection>,
138 colonists: Query<(&Transform, &Path), With<Colonist>>,
139 ) {
140 if let Some(tile) = cursor.tile {
141 gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), HOVER_COLOR);
142 }
143 match *selection {
144 Selection::None => {}
145 Selection::Tile(tile) => {
146 gizmos.rect_2d(WorldMap::tile_center(tile), Vec2::splat(TILE_SIZE), SELECT_COLOR);
147 }
148 Selection::Colonist(entity) => {
149 let Ok((transform, path)) = colonists.get(entity) else {
150 return;
151 };
152 let pos = transform.translation.truncate();
153 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
154 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
155 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
156 }
157 }
158 }
159
160 fn spawn_tile_info(mut commands: Commands) {
161 commands.spawn((
162 TileInfoText,
163 Text::default(),
164 TextFont::from_font_size(14.0),
165 Node {
166 position_type: PositionType::Absolute,
167 top: px(8),
168 left: px(8),
169 padding: UiRect::all(px(6)),
170 ..default()
171 },
172 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
173 ));
174 }
175
176 fn update_tile_info(
177 map: Res<WorldMap>,
178 cursor: Res<Cursor>,
179 selection: Res<Selection>,
180 colonists: Query<(&Colonist, &TilePos, &Path)>,
181 time: Res<Time<Virtual>>,
182 seed: Res<WorldSeed>,
183 mut text: Single<&mut Text, With<TileInfoText>>,
184 ) {
185 let selected = match *selection {
186 Selection::None => "-".to_string(),
187 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
188 Selection::Colonist(entity) => colonists
189 .get(entity)
190 .map(|(colonist, tile, path)| describe_colonist(colonist, tile.0, path.0.len()))
191 .unwrap_or_else(|_| "-".into()),
192 };
193 let paused = if time.is_paused() { "PAUSED\n" } else { "" };
194 let info = format!(
195 "{paused}Seed: {} (R: new world)\nHover: {}\nSelected: {selected}",
196 seed.0,
197 describe_tile(&map, cursor.tile)
198 );
199 if text.0 != info {
200 text.0 = info;
201 }
202 }
203
204 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
205 match tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain))) {
206 Some((t, terrain)) => format!("({}, {}) {terrain:?}", t.x, t.y),
207 None => "-".into(),
208 }
209 }
210
211 pub fn describe_colonist(colonist: &Colonist, tile: UVec2, steps_left: usize) -> String {
212 let activity = match steps_left {
213 0 => "idle".to_string(),
214 n => format!("moving, {n} steps left"),
215 };
216 format!("{} at ({}, {}), {activity}", colonist.name, tile.x, tile.y)
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222 use crate::world::Terrain;
223
224 fn selection_app(cursor: Cursor, selection: Selection) -> App {
225 let mut app = App::new();
226 app.init_resource::<ButtonInput<MouseButton>>()
227 .init_resource::<ButtonInput<KeyCode>>()
228 .insert_resource(cursor)
229 .insert_resource(selection)
230 .add_message::<MoveOrder>()
231 .add_systems(Update, (update_selection, issue_move_orders));
232 app
233 }
234
235 fn cursor_on_tile(tile: UVec2) -> Cursor {
236 Cursor {
237 screen: Some(Vec2::new(50.0, 50.0)),
238 world: Some(WorldMap::tile_center(tile)),
239 tile: Some(tile),
240 }
241 }
242
243 fn press(app: &mut App, button: MouseButton) {
244 app.world_mut().resource_mut::<ButtonInput<MouseButton>>().press(button);
245 app.update();
246 }
247
248 fn release(app: &mut App, button: MouseButton) {
249 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
250 input.clear();
251 input.release(button);
252 app.update();
253 }
254
255 fn selection(app: &App) -> Selection {
256 *app.world().resource::<Selection>()
257 }
258
259 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
260 app.world_mut()
261 .resource_mut::<Messages<MoveOrder>>()
262 .drain()
263 .map(|o| (o.colonist, o.goal))
264 .collect()
265 }
266
267 #[test]
268 fn clicking_selects_the_hovered_tile() {
269 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
270 press(&mut app, MouseButton::Left);
271 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
272 }
273
274 #[test]
275 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
276 let tile = UVec2::new(3, 4);
277 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
278 let colonist = app
279 .world_mut()
280 .spawn((
281 Colonist { name: "Ada".into() },
282 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
283 ))
284 .id();
285 press(&mut app, MouseButton::Left);
286 assert_eq!(selection(&app), Selection::Colonist(colonist));
287 }
288
289 #[test]
290 fn clicking_outside_the_world_clears_the_selection() {
291 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
292 press(&mut app, MouseButton::Left);
293 assert_eq!(selection(&app), Selection::None);
294 }
295
296 #[test]
297 fn escape_clears_the_selection() {
298 let mut app = selection_app(cursor_on_tile(UVec2::ONE), Selection::Tile(UVec2::new(3, 4)));
299 app.world_mut()
300 .resource_mut::<ButtonInput<KeyCode>>()
301 .press(KeyCode::Escape);
302 app.update();
303 assert_eq!(selection(&app), Selection::None);
304 }
305
306 #[test]
307 fn right_click_orders_selected_colonist_to_hovered_tile() {
308 let colonist = Entity::from_raw_u32(7).unwrap();
309 let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
310 press(&mut app, MouseButton::Right);
311 release(&mut app, MouseButton::Right);
312 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
313 }
314
315 #[test]
316 fn right_drag_does_not_order_because_it_pans_the_camera() {
317 let colonist = Entity::from_raw_u32(7).unwrap();
318 let mut app = selection_app(cursor_on_tile(UVec2::new(5, 6)), Selection::Colonist(colonist));
319 press(&mut app, MouseButton::Right);
320 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
321 release(&mut app, MouseButton::Right);
322 assert!(sent_orders(&mut app).is_empty());
323 }
324
325 #[test]
326 fn pick_nearest_ignores_far_candidates() {
327 let a = Entity::from_raw_u32(1).unwrap();
328 let b = Entity::from_raw_u32(2).unwrap();
329 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
330 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
331 assert_eq!(pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()), None);
332 }
333
334 #[test]
335 fn describe_tile_shows_coordinates_and_terrain() {
336 let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
337 assert_eq!(describe_tile(&map, Some(UVec2::new(1, 2))), "(1, 2) Sand");
338 assert_eq!(describe_tile(&map, None), "-");
339 }
340
341 #[test]
342 fn describe_colonist_shows_activity() {
343 let ada = Colonist { name: "Ada".into() };
344 assert_eq!(describe_colonist(&ada, UVec2::new(1, 2), 0), "Ada at (1, 2), idle");
345 assert_eq!(
346 describe_colonist(&ada, UVec2::new(1, 2), 3),
347 "Ada at (1, 2), moving, 3 steps left"
348 );
349 }
350 }