1use std::{cmp::Reverse, collections::HashSet};
2
3use specs::{Join, LendJoin, ReadStorage, WorldExt};
4use vek::*;
5
6use super::target::{self, Target};
7use client::Client;
8use common::{
9 CachedSpatialGrid,
10 comp::{
11 self, Alignment, Collider, Content, pet, ship::figuredata::VOXEL_COLLIDER_MANIFEST,
12 tool::ToolKind,
13 },
14 consts::{
15 self, MAX_INTERACT_RANGE, MAX_PICKUP_RANGE, MAX_SPRITE_MOUNT_RANGE, TELEPORTER_RADIUS,
16 },
17 link::Is,
18 mounting::{Mount, Volume, VolumePos, VolumeRider},
19 states::utils::can_perform_pet,
20 terrain::{Block, TerrainGrid, UnlockKind},
21 uid::{IdMaps, Uid},
22};
23use common_base::span;
24use hashbrown::HashMap;
25
26use crate::{
27 game_input::GameInput,
28 hud::CraftingTab,
29 scene::{Scene, terrain::Interaction},
30};
31
32#[derive(Debug, Default)]
33pub struct Interactables {
34 pub input_map: HashMap<GameInput, (f32, Interactable)>,
35 pub entities: HashSet<specs::Entity>,
38}
39
40#[derive(Clone, Debug)]
41pub enum BlockInteraction {
42 Collect { steal: bool },
43 Unlock(UnlockKind),
44 Craft(CraftingTab),
45 Mine(ToolKind),
48 Mount,
49 Read(Content),
50 LightToggle(bool),
51}
52
53#[derive(Debug, Clone)]
54pub enum Interactable {
55 Block {
56 block: Block,
57 volume_pos: VolumePos,
58 interaction: BlockInteraction,
59 },
60 Entity {
61 entity: specs::Entity,
62 interaction: EntityInteraction,
63 },
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, enum_map::Enum)]
68pub enum EntityInteraction {
69 HelpDowned,
70 PickupItem,
71 ActivatePortal,
72 Pet,
73 Talk,
74 CampfireSit,
75 Trade,
76 StayFollow,
77 Mount,
78}
79
80impl BlockInteraction {
81 fn from_block_pos(
82 terrain: &TerrainGrid,
83 id_maps: &IdMaps,
84 colliders: &ReadStorage<Collider>,
85 volume_pos: VolumePos,
86 interaction: Interaction,
87 ) -> Option<(Block, Self)> {
88 let block = volume_pos.get_block(terrain, id_maps, colliders)?;
89 match interaction {
90 Interaction::Collect => {
91 if terrain.pos_chunk(volume_pos.pos).is_some_and(|chunk| {
93 let sprite_chunk_pos = TerrainGrid::chunk_offs(volume_pos.pos);
94 let sprite_cfg = chunk.meta().sprite_cfg_at(sprite_chunk_pos);
95 !block.is_collectible(sprite_cfg)
96 }) {
97 return None;
98 };
99 let unlock = match volume_pos.kind {
101 Volume::Terrain => block.get_sprite().and_then(|sprite| {
102 let chunk = terrain.pos_chunk(volume_pos.pos)?;
103 let sprite_chunk_pos = TerrainGrid::chunk_offs(volume_pos.pos);
104 let sprite_cfg = chunk.meta().sprite_cfg_at(sprite_chunk_pos);
105 let unlock_condition = sprite.unlock_condition(sprite_cfg.cloned());
106 if sprite_cfg.is_some() || !matches!(&unlock_condition, UnlockKind::Free) {
112 Some(unlock_condition)
113 } else {
114 None
115 }
116 }),
117 Volume::Entity(_) => None,
118 };
119
120 if let Some(unlock) = unlock {
121 Some((block, BlockInteraction::Unlock(unlock)))
122 } else if let Some(mine_tool) = block.mine_tool() {
123 Some((block, BlockInteraction::Mine(mine_tool)))
124 } else {
125 Some((block, BlockInteraction::Collect {
126 steal: block.is_owned(),
127 }))
128 }
129 },
130 Interaction::Read => match volume_pos.kind {
131 common::mounting::Volume::Terrain => block.get_sprite().and_then(|sprite| {
132 let chunk = terrain.pos_chunk(volume_pos.pos)?;
133 let sprite_chunk_pos = TerrainGrid::chunk_offs(volume_pos.pos);
134 let sprite_cfg = chunk.meta().sprite_cfg_at(sprite_chunk_pos);
135 sprite
136 .content(sprite_cfg.cloned())
137 .map(|content| Some((block, BlockInteraction::Read(content))))
138 })?,
139 common::mounting::Volume::Entity(_) => None,
141 },
142 Interaction::Craft(tab) => Some((block, BlockInteraction::Craft(tab))),
143 Interaction::Mount => Some((block, BlockInteraction::Mount)),
144 Interaction::LightToggle(enable) => {
145 Some((block, BlockInteraction::LightToggle(enable)))
146 },
147 }
148 }
149
150 pub fn game_input(&self) -> GameInput {
151 match self {
152 BlockInteraction::Collect { .. }
153 | BlockInteraction::Read(_)
154 | BlockInteraction::LightToggle(_)
155 | BlockInteraction::Craft(_)
156 | BlockInteraction::Unlock(_) => GameInput::Interact,
157 BlockInteraction::Mine(_) => GameInput::Primary,
158 BlockInteraction::Mount => GameInput::Mount,
159 }
160 }
161
162 pub fn range(&self) -> f32 {
163 match self {
164 BlockInteraction::Collect { .. }
165 | BlockInteraction::Unlock(_)
166 | BlockInteraction::Mine(_)
167 | BlockInteraction::Craft(_) => consts::MAX_PICKUP_RANGE,
168 BlockInteraction::Mount => consts::MAX_MOUNT_RANGE,
169 BlockInteraction::LightToggle(_) | BlockInteraction::Read(_) => {
170 consts::MAX_INTERACT_RANGE
171 },
172 }
173 }
174}
175
176#[derive(Debug)]
177pub enum GetInteractablesError {
178 ClientMissingPosition,
179 ClientMissingUid,
180}
181
182pub(super) fn get_interactables(
185 client: &Client,
186 collect_target: Option<Target<target::Collectable>>,
187 entity_target: Option<Target<target::Entity>>,
188 mine_target: Option<Target<target::Mine>>,
189 scene: &Scene,
190) -> Result<HashMap<GameInput, (f32, Interactable)>, GetInteractablesError> {
191 span!(_guard, "select_interactable");
192 use common::{spiral::Spiral2d, terrain::TerrainChunk, vol::RectRasterableVol};
193
194 let voxel_colliders_manifest = VOXEL_COLLIDER_MANIFEST.read();
195
196 let ecs = client.state().ecs();
197 let id_maps = ecs.read_resource::<IdMaps>();
198 let scene_terrain = scene.terrain();
199 let terrain = client.state().terrain();
200 let player_entity = client.entity();
201 let interpolated = ecs.read_storage::<crate::ecs::comp::Interpolated>();
202 let player_pos = interpolated
203 .get(player_entity)
204 .ok_or(GetInteractablesError::ClientMissingPosition)?
205 .pos;
206
207 let uids = ecs.read_storage::<Uid>();
208 let healths = ecs.read_storage::<comp::Health>();
209 let colliders = ecs.read_storage::<comp::Collider>();
210 let char_states = ecs.read_storage::<comp::CharacterState>();
211 let is_mounts = ecs.read_storage::<Is<Mount>>();
212 let bodies = ecs.read_storage::<comp::Body>();
213 let masses = ecs.read_storage::<comp::Mass>();
214 let items = ecs.read_storage::<comp::PickupItem>();
215 let alignments = ecs.read_storage::<comp::Alignment>();
216 let is_volume_rider = ecs.read_storage::<Is<VolumeRider>>();
217
218 let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
219 (e.floor() as i32).div_euclid(sz as i32)
220 });
221 let player_body = bodies.get(player_entity);
222 let player_mass = masses.get(player_entity);
223 let Some(player_uid) = uids.get(player_entity).copied() else {
224 tracing::error!("Client has no Uid component! Not scanning for any interactables.");
225 return Err(GetInteractablesError::ClientMissingUid);
226 };
227
228 let spacial_grid = ecs.read_resource::<CachedSpatialGrid>();
229
230 let entities = ecs.entities();
231 let mut entity_data = (
232 &entities,
233 !&is_mounts,
234 &uids,
235 &interpolated,
236 &bodies,
237 &masses,
238 char_states.maybe(),
239 healths.maybe(),
240 alignments.maybe(),
241 items.mask().maybe(),
242 )
243 .lend_join();
244
245 let interactable_entities = spacial_grid
246 .0
247 .in_circle_aabr(player_pos.xy(), MAX_PICKUP_RANGE)
248 .chain(entity_target.map(|t| t.kind.0))
249 .filter(|&entity| entity != player_entity)
250 .filter_map(|entity| entity_data.get(entity, &entities))
251 .flat_map(
252 |(entity, _, uid, interpolated, body, mass, char_state, health, alignment, has_item)| {
253 let is_downed = comp::is_downed(health, char_state);
255
256 let interaction = if is_downed {
258 Some(EntityInteraction::HelpDowned)
259 } else if has_item.is_some() {
260 Some(EntityInteraction::PickupItem)
261 } else if body.is_portal()
262 && interpolated.pos.distance_squared(player_pos) <= TELEPORTER_RADIUS.powi(2)
263 {
264 Some(EntityInteraction::ActivatePortal)
265 } else if alignment.is_some_and(|alignment| {
266 can_perform_pet(comp::Pos(player_pos), comp::Pos(interpolated.pos), *alignment)
267 }) {
268 Some(EntityInteraction::Pet)
269 } else if alignment.is_some_and(|alignment| matches!(alignment, Alignment::Npc)) {
270 Some(EntityInteraction::Talk)
271 } else {
272 None
273 };
274
275 let sit =
277 (body.is_campfire() && !is_downed).then_some(EntityInteraction::CampfireSit);
278
279 let trade = (!is_downed
283 && alignment.is_some_and(|alignment| match alignment {
284 Alignment::Npc => true,
285 Alignment::Owned(other_uid) => other_uid == uid || player_uid == *other_uid,
286 _ => false,
287 }))
288 .then_some(EntityInteraction::Trade);
289
290 let mount = (matches!(alignment, Some(Alignment::Owned(other_uid)) if player_uid == *other_uid)
292 && pet::is_mountable(body, mass, player_body, player_mass)
293 && !is_downed
294 && !client.is_riding())
295 .then_some(EntityInteraction::Mount);
296
297 let stayfollow = alignment
299 .filter(|alignment| {
300 matches!(alignment,
301 Alignment::Owned(other_uid) if player_uid == *other_uid)
302 && !is_downed
303 })
304 .map(|_| EntityInteraction::StayFollow);
305
306 let distance_squared = player_pos.distance_squared(interpolated.pos);
308
309 Some(
310 interaction
311 .into_iter()
312 .chain(sit)
313 .chain(trade)
314 .chain(mount)
315 .chain(stayfollow)
316 .filter_map(move |interaction| {
317 (distance_squared <= interaction.range().powi(2)).then_some((
318 interaction,
319 entity,
320 distance_squared,
321 ))
322 }),
323 )
324 },
325 )
326 .flatten();
327
328 let volumes_data = (
329 &entities,
330 &ecs.read_storage::<Uid>(),
331 &ecs.read_storage::<comp::Body>(),
332 &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
333 &ecs.read_storage::<comp::Collider>(),
334 );
335
336 let mut volumes_data = volumes_data.lend_join();
337
338 let volume_interactables = spacial_grid
339 .0
340 .in_circle_aabr(player_pos.xy(), MAX_PICKUP_RANGE)
341 .filter(|&e| e != player_entity)
342 .filter_map(|e| volumes_data.get(e, &entities))
343 .filter_map(|(entity, uid, body, interpolated, collider)| {
344 let vol = collider.get_vol(&voxel_colliders_manifest)?;
345 let (blocks_of_interest, offset) =
346 scene
347 .figure_mgr()
348 .get_blocks_of_interest(entity, body, Some(collider))?;
349
350 let mat = Mat4::from(interpolated.ori.to_quat()).translated_3d(interpolated.pos)
351 * Mat4::translation_3d(offset);
352
353 let p = mat.inverted().mul_point(player_pos);
354 let aabb = Aabb {
355 min: Vec3::zero(),
356 max: vol.volume().sz.as_(),
357 };
358 if aabb.contains_point(p) || aabb.distance_to_point(p) < MAX_PICKUP_RANGE {
359 Some(blocks_of_interest.interactables.iter().map(
360 move |(block_offset, interaction)| {
361 let wpos = mat.mul_point(block_offset.as_() + 0.5);
362 (wpos, VolumePos::entity(*block_offset, *uid), *interaction)
363 },
364 ))
365 } else {
366 None
367 }
368 })
369 .flatten();
370
371 let interactable_blocks = Spiral2d::new()
374 .take(
375 ((MAX_PICKUP_RANGE / TerrainChunk::RECT_SIZE.x as f32).ceil() as usize * 2 + 1).pow(2),
376 )
377 .flat_map(|offset| {
378 let chunk_pos = player_chunk + offset;
379 let chunk_voxel_pos =
380 Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
381 scene_terrain
382 .get(chunk_pos)
383 .map(|data| (data, chunk_voxel_pos))
384 })
385 .flat_map(|(chunk_data, chunk_pos)| {
386 chunk_data
389 .blocks_of_interest
390 .interactables
391 .iter()
392 .map(move |(block_offset, interaction)| (chunk_pos + block_offset, interaction))
393 .map(|(pos, interaction)| {
394 (
395 pos.as_::<f32>() + 0.5,
396 VolumePos::terrain(pos),
397 *interaction,
398 )
399 })
400 })
401 .chain(volume_interactables)
402 .filter(|(wpos, volume_pos, interaction)| match interaction {
403 Interaction::Mount => {
404 !is_volume_rider.contains(player_entity)
405 && wpos.distance_squared(player_pos) < MAX_SPRITE_MOUNT_RANGE.powi(2)
406 && (volume_pos.is_entity()
408 || !is_volume_rider
409 .join()
410 .any(|is_volume_rider| is_volume_rider.pos == *volume_pos))
411 },
412 Interaction::LightToggle(_) => {
413 wpos.distance_squared(player_pos) < MAX_INTERACT_RANGE.powi(2)
414 },
415 _ => true,
416 })
417 .chain(
418 mine_target
419 .map(|t| t.position_int())
420 .into_iter()
421 .chain(collect_target.map(|t| t.position_int()))
422 .map(|pos| (pos.as_(), VolumePos::terrain(pos), Interaction::Collect)),
423 )
424 .filter_map(|(wpos, volume_pos, interaction)| {
425 let (block, interaction) = BlockInteraction::from_block_pos(
426 &terrain,
427 &id_maps,
428 &colliders,
429 volume_pos,
430 interaction,
431 )?;
432
433 let distance = wpos.distance_squared(player_pos);
434 (distance <= interaction.range().powi(2)).then_some((
435 block,
436 volume_pos,
437 interaction,
438 distance,
439 ))
440 });
441
442 let is_direct_target = |interactable: &Interactable| match interactable {
445 Interactable::Block {
446 volume_pos,
447 interaction,
448 ..
449 } => {
450 matches!(
451 (mine_target, volume_pos, interaction),
452 (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Mine(_))
453 if target.position_int() == *pos)
454 || matches!(
455 (collect_target, volume_pos, interaction),
456 (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Collect { .. } | BlockInteraction::Unlock { .. })
457 if target.position_int() == *pos)
458 },
459 Interactable::Entity { entity, .. } => {
460 entity_target.is_some_and(|target| target.kind.0 == *entity)
461 },
462 };
463
464 Ok(interactable_entities
465 .map(|(interaction, entity, distance)| {
466 (distance.powi(2), Interactable::Entity {
467 entity,
468 interaction,
469 })
470 })
471 .chain(
472 interactable_blocks.map(|(block, volume_pos, interaction, distance_squared)| {
473 (distance_squared, Interactable::Block {
474 block,
475 volume_pos,
476 interaction,
477 })
478 }),
479 )
480 .fold(HashMap::new(), |mut map, (distance, interaction)| {
481 let input = interaction.game_input();
482
483 if map
484 .get(&input)
485 .is_none_or(|(other_distance, other_interaction)| {
486 (
487 is_direct_target(other_interaction),
489 other_interaction.priority(),
490 Reverse(*other_distance),
491 ) < (
492 is_direct_target(&interaction),
493 interaction.priority(),
494 Reverse(distance),
495 )
496 })
497 {
498 map.insert(input, (distance, interaction));
499 }
500
501 map
502 }))
503}
504
505impl Interactable {
506 pub fn game_input(&self) -> GameInput {
507 match self {
508 Interactable::Block { interaction, .. } => interaction.game_input(),
509 Interactable::Entity { interaction, .. } => interaction.game_input(),
510 }
511 }
512
513 #[rustfmt::skip]
516 pub fn priority(&self) -> usize {
517 match self {
518 Self::Entity { interaction: EntityInteraction::ActivatePortal, .. } => 4,
520 Self::Entity { interaction: EntityInteraction::PickupItem, .. } => 3,
521 Self::Block { interaction: BlockInteraction::Craft(_), .. } => 3,
522 Self::Block { interaction: BlockInteraction::Collect { .. }, .. } => 3,
523 Self::Entity { interaction: EntityInteraction::HelpDowned, .. } => 2,
524 Self::Block { interaction: BlockInteraction::Unlock(_), .. } => 1,
525 Self::Block { interaction: BlockInteraction::Read(_), .. } => 1,
526 Self::Block { interaction: BlockInteraction::LightToggle(_), .. } => 1,
527 Self::Entity { interaction: EntityInteraction::Pet, .. } => 0,
528 Self::Entity { interaction: EntityInteraction::Talk , .. } => 0,
529
530 Self::Entity { interaction: EntityInteraction::Mount, .. } => 1,
532 Self::Block { interaction: BlockInteraction::Mount, .. } => 0,
533
534 Self::Block { interaction: BlockInteraction::Mine(_), .. } => 0,
536 Self::Entity { interaction: EntityInteraction::StayFollow, .. } => 0,
537 Self::Entity { interaction: EntityInteraction::Trade, .. } => 0,
538 Self::Entity { interaction: EntityInteraction::CampfireSit, .. } => 0,
539 }
540 }
541}
542
543impl EntityInteraction {
544 pub fn game_input(&self) -> GameInput {
545 match self {
546 EntityInteraction::HelpDowned
547 | EntityInteraction::PickupItem
548 | EntityInteraction::ActivatePortal
549 | EntityInteraction::Pet
550 | EntityInteraction::Talk => GameInput::Interact,
551 EntityInteraction::StayFollow => GameInput::StayFollow,
552 EntityInteraction::Trade => GameInput::Trade,
553 EntityInteraction::Mount => GameInput::Mount,
554 EntityInteraction::CampfireSit => GameInput::Sit,
555 }
556 }
557
558 pub fn range(&self) -> f32 {
559 match self {
560 Self::Trade => consts::MAX_TRADE_RANGE,
561 Self::Mount | Self::Pet => consts::MAX_MOUNT_RANGE,
562 Self::PickupItem => consts::MAX_PICKUP_RANGE,
563 Self::Talk => consts::MAX_NPCINTERACT_RANGE,
564 _ => consts::MAX_INTERACT_RANGE,
565 }
566 }
567}
568
569impl Interactables {
570 pub fn inverted_map(
572 &self,
573 ) -> (
574 HashMap<specs::Entity, Vec<EntityInteraction>>,
575 HashMap<VolumePos, (Block, Vec<&BlockInteraction>)>,
576 ) {
577 let (mut entity_map, block_map) = self.input_map.iter().fold(
578 (HashMap::new(), HashMap::new()),
579 |(mut entity_map, mut block_map), (_input, (_, interactable))| {
580 match interactable {
581 Interactable::Entity {
582 entity,
583 interaction,
584 } => {
585 entity_map
586 .entry(*entity)
587 .and_modify(|i: &mut Vec<_>| i.push(*interaction))
588 .or_insert_with(|| vec![*interaction]);
589 },
590 Interactable::Block {
591 block,
592 volume_pos,
593 interaction,
594 } => {
595 block_map
596 .entry(*volume_pos)
597 .and_modify(|(_, i): &mut (_, Vec<_>)| i.push(interaction))
598 .or_insert_with(|| (*block, vec![interaction]));
599 },
600 }
601
602 (entity_map, block_map)
603 },
604 );
605
606 for v in entity_map.values_mut() {
610 v.sort_unstable_by_key(|i| i.game_input())
611 }
612
613 (entity_map, block_map)
614 }
615}