veloren_voxygen/session/
interactable.rs

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    /// Set of all nearby interactable entities, stored separately for fast
36    /// access in scene
37    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    // TODO: mining blocks don't use the interaction key, so it might not be the best abstraction
46    // to have them here, will see how things turn out
47    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/// The type of interaction an entity has
67#[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                // Check if the block is not collectable
92                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                // Check if this is an unlockable sprite
100                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                        // HACK: No other way to distinguish between things that should be
107                        // unlockable and regular sprites with the current
108                        // unlock_condition method so we hack around that by
109                        // saying that it is a regular collectible sprite if
110                        // `unlock_condition` returns UnlockKind::Free and the cfg was `None`.
111                        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                // Signs on volume entities are not currently supported
140                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
182/// Scan for any nearby interactables, including further ones if directly
183/// targetted.
184pub(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                // If an entity is downed, the only allowed interaction is HelpDowned
254                let is_downed = comp::is_downed(health, char_state);
255
256                // Interactions using [`GameInput::Interact`]
257                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                // Interaction using [`GameInput::Sit`]
276                let sit =
277                    (body.is_campfire() && !is_downed).then_some(EntityInteraction::CampfireSit);
278
279                // Interaction using [`GameInput::Trade`]
280                // TODO: Remove this once we have a better way do determine whether an entity
281                // can be traded with not based on alignment.
282                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                // Interaction using [`GameInput::Mount`]
291                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                // Interaction using [`GameInput::StayFollow`]
298                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                // Roughly filter out entities farther than interaction distance
307                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    // TODO: this formula for the number to take was guessed
372    // Note: assumes RECT_SIZE.x == RECT_SIZE.y
373    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            // TODO: maybe we could make this more efficient by putting the
387            // interactables is some sort of spatial structure
388            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                        // TODO: Use shared volume riders component here
407                        && (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    // Helper to check if an interactable is directly targetted by the player, and
443    // should thus be prioritized over non-directly targetted ones.
444    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                        // Prioritize direct targets
488                        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    /// Priorities for different interactions. Note: Priorities are grouped by
514    /// GameInput
515    #[rustfmt::skip]
516    pub fn priority(&self) -> usize {
517        match self {
518            // GameInput::Interact
519            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            // GameInput::Mount
531            Self::Entity { interaction: EntityInteraction::Mount, .. }           => 1,
532            Self::Block  { interaction: BlockInteraction::Mount, .. }            => 0,
533
534            // These interactions have dedicated keybinds already, no need to prioritize them
535            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    /// Maps the interaction targets to all their available interactions
571    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        // Ensure interactions are ordered in a stable way
607        // TODO: Once blocks can have more than one interaction, do the same for blocks
608        // here too
609        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}