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 { kind: UnlockKind, steal: bool },
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 {
122                        kind: unlock,
123                        steal: block.is_owned(),
124                    }))
125                } else if let Some(mine_tool) = block.mine_tool() {
126                    Some((block, BlockInteraction::Mine(mine_tool)))
127                } else {
128                    Some((block, BlockInteraction::Collect {
129                        steal: block.is_owned(),
130                    }))
131                }
132            },
133            Interaction::Read => match volume_pos.kind {
134                common::mounting::Volume::Terrain => block.get_sprite().and_then(|sprite| {
135                    let chunk = terrain.pos_chunk(volume_pos.pos)?;
136                    let sprite_chunk_pos = TerrainGrid::chunk_offs(volume_pos.pos);
137                    let sprite_cfg = chunk.meta().sprite_cfg_at(sprite_chunk_pos);
138                    sprite
139                        .content(sprite_cfg.cloned())
140                        .map(|content| Some((block, BlockInteraction::Read(content))))
141                })?,
142                // Signs on volume entities are not currently supported
143                common::mounting::Volume::Entity(_) => None,
144            },
145            Interaction::Craft(tab) => Some((block, BlockInteraction::Craft(tab))),
146            Interaction::Mount => Some((block, BlockInteraction::Mount)),
147            Interaction::LightToggle(enable) => {
148                Some((block, BlockInteraction::LightToggle(enable)))
149            },
150        }
151    }
152
153    pub fn game_input(&self) -> GameInput {
154        match self {
155            BlockInteraction::Collect { .. }
156            | BlockInteraction::Read(_)
157            | BlockInteraction::LightToggle(_)
158            | BlockInteraction::Craft(_)
159            | BlockInteraction::Unlock { .. } => GameInput::Interact,
160            BlockInteraction::Mine(_) => GameInput::Primary,
161            BlockInteraction::Mount => GameInput::Mount,
162        }
163    }
164
165    pub fn range(&self) -> f32 {
166        match self {
167            BlockInteraction::Collect { .. }
168            | BlockInteraction::Unlock { .. }
169            | BlockInteraction::Mine(_)
170            | BlockInteraction::Craft(_) => consts::MAX_PICKUP_RANGE,
171            BlockInteraction::Mount => consts::MAX_MOUNT_RANGE,
172            BlockInteraction::LightToggle(_) | BlockInteraction::Read(_) => {
173                consts::MAX_INTERACT_RANGE
174            },
175        }
176    }
177}
178
179#[derive(Debug)]
180pub enum GetInteractablesError {
181    ClientMissingPosition,
182    ClientMissingUid,
183}
184
185/// Scan for any nearby interactables, including further ones if directly
186/// targetted.
187pub(super) fn get_interactables(
188    client: &Client,
189    collect_target: Option<Target<target::Collectable>>,
190    entity_target: Option<Target<target::Entity>>,
191    mine_target: Option<Target<target::Mine>>,
192    terrain_target: Option<Target<target::Terrain>>,
193    scene: &Scene,
194) -> Result<HashMap<GameInput, (f32, Interactable)>, GetInteractablesError> {
195    span!(_guard, "select_interactable");
196    use common::{spiral::Spiral2d, terrain::TerrainChunk, vol::RectRasterableVol};
197
198    let voxel_colliders_manifest = VOXEL_COLLIDER_MANIFEST.read();
199
200    let ecs = client.state().ecs();
201    let id_maps = ecs.read_resource::<IdMaps>();
202    let scene_terrain = scene.terrain();
203    let terrain = client.state().terrain();
204    let player_entity = client.entity();
205    let interpolated = ecs.read_storage::<crate::ecs::comp::Interpolated>();
206    let player_pos = interpolated
207        .get(player_entity)
208        .ok_or(GetInteractablesError::ClientMissingPosition)?
209        .pos;
210
211    let uids = ecs.read_storage::<Uid>();
212    let healths = ecs.read_storage::<comp::Health>();
213    let colliders = ecs.read_storage::<comp::Collider>();
214    let char_states = ecs.read_storage::<comp::CharacterState>();
215    let is_mounts = ecs.read_storage::<Is<Mount>>();
216    let bodies = ecs.read_storage::<comp::Body>();
217    let masses = ecs.read_storage::<comp::Mass>();
218    let items = ecs.read_storage::<comp::PickupItem>();
219    let alignments = ecs.read_storage::<comp::Alignment>();
220    let is_volume_rider = ecs.read_storage::<Is<VolumeRider>>();
221
222    let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
223        (e.floor() as i32).div_euclid(sz as i32)
224    });
225    let player_body = bodies.get(player_entity);
226    let player_mass = masses.get(player_entity);
227    let Some(player_uid) = uids.get(player_entity).copied() else {
228        tracing::error!("Client has no Uid component! Not scanning for any interactables.");
229        return Err(GetInteractablesError::ClientMissingUid);
230    };
231
232    let spacial_grid = ecs.read_resource::<CachedSpatialGrid>();
233
234    let entities = ecs.entities();
235    let mut entity_data = (
236        &entities,
237        !&is_mounts,
238        &uids,
239        &interpolated,
240        &bodies,
241        &masses,
242        char_states.maybe(),
243        healths.maybe(),
244        alignments.maybe(),
245        items.mask().maybe(),
246    )
247        .lend_join();
248
249    let interactable_entities = spacial_grid
250        .0
251        .in_circle_aabr(player_pos.xy(), MAX_PICKUP_RANGE)
252        .chain(entity_target.map(|t| t.kind.0))
253        .filter(|&entity| entity != player_entity)
254        .filter_map(|entity| entity_data.get(entity, &entities))
255        .flat_map(
256            |(entity, _, uid, interpolated, body, mass, char_state, health, alignment, has_item)| {
257                // If an entity is downed, the only allowed interaction is HelpDowned
258                let is_downed = comp::is_downed(health, char_state);
259
260                // Interactions using [`GameInput::Interact`]
261                let interaction = if is_downed {
262                    Some(EntityInteraction::HelpDowned)
263                } else if has_item.is_some() {
264                    Some(EntityInteraction::PickupItem)
265                } else if body.is_portal()
266                    && interpolated.pos.distance_squared(player_pos) <= TELEPORTER_RADIUS.powi(2)
267                {
268                    Some(EntityInteraction::ActivatePortal)
269                } else if alignment.is_some_and(|alignment| {
270                    can_perform_pet(comp::Pos(player_pos), comp::Pos(interpolated.pos), *alignment)
271                }) {
272                    Some(EntityInteraction::Pet)
273                } else if alignment.is_some_and(|alignment| matches!(alignment, Alignment::Npc)) {
274                    Some(EntityInteraction::Talk)
275                } else {
276                    None
277                };
278
279                // Interaction using [`GameInput::Sit`]
280                let sit =
281                    (body.is_campfire() && !is_downed).then_some(EntityInteraction::CampfireSit);
282
283                // Interaction using [`GameInput::Trade`]
284                // TODO: Remove this once we have a better way do determine whether an entity
285                // can be traded with not based on alignment.
286                let trade = (!is_downed
287                    && alignment.is_some_and(|alignment| match alignment {
288                        Alignment::Npc => true,
289                        Alignment::Owned(other_uid) => other_uid == uid || player_uid == *other_uid,
290                        _ => false,
291                    }))
292                .then_some(EntityInteraction::Trade);
293
294                // Interaction using [`GameInput::Mount`]
295                let mount = (matches!(alignment, Some(Alignment::Owned(other_uid)) if player_uid == *other_uid)
296                    && pet::is_mountable(body, mass, player_body, player_mass)
297                    && !is_downed
298                    && !client.is_riding())
299                .then_some(EntityInteraction::Mount);
300
301                // Interaction using [`GameInput::StayFollow`]
302                let stayfollow = alignment
303                    .filter(|alignment| {
304                        matches!(alignment,
305                            Alignment::Owned(other_uid) if player_uid == *other_uid)
306                            && !is_downed
307                    })
308                    .map(|_| EntityInteraction::StayFollow);
309
310                // Roughly filter out entities farther than interaction distance
311                let distance_squared = player_pos.distance_squared(interpolated.pos);
312
313                Some(
314                    interaction
315                        .into_iter()
316                        .chain(sit)
317                        .chain(trade)
318                        .chain(mount)
319                        .chain(stayfollow)
320                        .filter_map(move |interaction| {
321                            (distance_squared <= interaction.range().powi(2)).then_some((
322                                interaction,
323                                entity,
324                                distance_squared,
325                            ))
326                        }),
327                )
328            },
329        )
330        .flatten();
331
332    let volumes_data = (
333        &entities,
334        &ecs.read_storage::<Uid>(),
335        &ecs.read_storage::<comp::Body>(),
336        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
337        &ecs.read_storage::<comp::Collider>(),
338    );
339
340    let mut volumes_data = volumes_data.lend_join();
341
342    let volume_interactables = spacial_grid
343        .0
344        .in_circle_aabr(player_pos.xy(), MAX_PICKUP_RANGE)
345        .filter(|&e| e != player_entity)
346        .filter_map(|e| volumes_data.get(e, &entities))
347        .filter_map(|(entity, uid, body, interpolated, collider)| {
348            let vol = collider.get_vol(&voxel_colliders_manifest)?;
349            let (blocks_of_interest, offset) =
350                scene
351                    .figure_mgr()
352                    .get_blocks_of_interest(entity, body, Some(collider))?;
353
354            let mat = Mat4::from(interpolated.ori.to_quat()).translated_3d(interpolated.pos)
355                * Mat4::translation_3d(offset);
356
357            let p = mat.inverted().mul_point(player_pos);
358            let aabb = Aabb {
359                min: Vec3::zero(),
360                max: vol.volume().sz.as_(),
361            };
362            if aabb.contains_point(p) || aabb.distance_to_point(p) < MAX_PICKUP_RANGE {
363                Some(blocks_of_interest.interactables.iter().map(
364                    move |(block_offset, interaction)| {
365                        let wpos = mat.mul_point(block_offset.as_() + 0.5);
366                        (wpos, VolumePos::entity(*block_offset, *uid), *interaction)
367                    },
368                ))
369            } else {
370                None
371            }
372        })
373        .flatten();
374
375    // TODO: this formula for the number to take was guessed
376    // Note: assumes RECT_SIZE.x == RECT_SIZE.y
377    let interactable_blocks = Spiral2d::new()
378        .take(
379            ((MAX_PICKUP_RANGE / TerrainChunk::RECT_SIZE.x as f32).ceil() as usize * 2 + 1).pow(2),
380        )
381        .flat_map(|offset| {
382            let chunk_pos = player_chunk + offset;
383            let chunk_voxel_pos =
384                Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
385            scene_terrain
386                .get(chunk_pos)
387                .map(|data| (data, chunk_voxel_pos))
388        })
389        .flat_map(|(chunk_data, chunk_pos)| {
390            // TODO: maybe we could make this more efficient by putting the
391            // interactables is some sort of spatial structure
392            chunk_data
393                .blocks_of_interest
394                .interactables
395                .iter()
396                .map(move |(block_offset, interaction)| (chunk_pos + block_offset, interaction))
397                .map(|(pos, interaction)| {
398                    (
399                        pos.as_::<f32>() + 0.5,
400                        VolumePos::terrain(pos),
401                        *interaction,
402                    )
403                })
404        })
405        .chain(volume_interactables)
406        .filter(|(wpos, volume_pos, interaction)| match interaction {
407            Interaction::Mount => {
408                !is_volume_rider.contains(player_entity)
409                        && wpos.distance_squared(player_pos) < MAX_SPRITE_MOUNT_RANGE.powi(2)
410                        // TODO: Use shared volume riders component here
411                        && (volume_pos.is_entity()
412                            || !is_volume_rider
413                                .join()
414                                .any(|is_volume_rider| is_volume_rider.pos == *volume_pos))
415            },
416            Interaction::LightToggle(_) => {
417                wpos.distance_squared(player_pos) < MAX_INTERACT_RANGE.powi(2)
418            },
419            _ => true,
420        })
421        .chain(
422            mine_target
423                .map(|t| t.position_int())
424                .into_iter()
425                .chain(collect_target.map(|t| t.position_int()))
426                .map(|pos| (pos.as_(), VolumePos::terrain(pos), Interaction::Collect)),
427        )
428        .filter_map(|(wpos, volume_pos, interaction)| {
429            let (block, interaction) = BlockInteraction::from_block_pos(
430                &terrain,
431                &id_maps,
432                &colliders,
433                volume_pos,
434                interaction,
435            )?;
436
437            let distance = wpos.distance_squared(player_pos);
438            (distance <= interaction.range().powi(2)).then_some((
439                block,
440                volume_pos,
441                interaction,
442                distance,
443            ))
444        });
445
446    // Helper to check if an interactable is directly targetted by the player, and
447    // should thus be prioritized over non-directly targetted ones.
448    let is_direct_target = |interactable: &Interactable| match interactable {
449        Interactable::Block {
450            volume_pos,
451            interaction,
452            ..
453        } => {
454            matches!(
455                (mine_target, volume_pos, interaction),
456                (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Mine(_))
457                    if target.position_int() == *pos)
458                || matches!(
459                        (collect_target, volume_pos, interaction),
460                        (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Collect { .. } | BlockInteraction::Unlock { .. })
461                            if target.position_int() == *pos)
462                || matches!(
463                    (terrain_target, volume_pos),
464                    (Some(target), VolumePos { kind: Volume::Terrain, pos })
465                        if target.position_int() == *pos)
466        },
467        Interactable::Entity { entity, .. } => {
468            entity_target.is_some_and(|target| target.kind.0 == *entity)
469        },
470    };
471
472    Ok(interactable_entities
473        .map(|(interaction, entity, distance)| {
474            (distance.powi(2), Interactable::Entity {
475                entity,
476                interaction,
477            })
478        })
479        .chain(
480            interactable_blocks.map(|(block, volume_pos, interaction, distance_squared)| {
481                (distance_squared, Interactable::Block {
482                    block,
483                    volume_pos,
484                    interaction,
485                })
486            }),
487        )
488        .fold(HashMap::new(), |mut map, (distance, interaction)| {
489            let input = interaction.game_input();
490
491            if map
492                .get(&input)
493                .is_none_or(|(other_distance, other_interaction)| {
494                    (
495                        // Prioritize direct targets
496                        is_direct_target(other_interaction),
497                        other_interaction.priority(),
498                        Reverse(*other_distance),
499                    ) < (
500                        is_direct_target(&interaction),
501                        interaction.priority(),
502                        Reverse(distance),
503                    )
504                })
505            {
506                map.insert(input, (distance, interaction));
507            }
508
509            map
510        }))
511}
512
513impl Interactable {
514    pub fn game_input(&self) -> GameInput {
515        match self {
516            Interactable::Block { interaction, .. } => interaction.game_input(),
517            Interactable::Entity { interaction, .. } => interaction.game_input(),
518        }
519    }
520
521    /// Priorities for different interactions. Note: Priorities are grouped by
522    /// GameInput
523    #[rustfmt::skip]
524    pub fn priority(&self) -> usize {
525        match self {
526            // GameInput::Interact
527            Self::Entity { interaction: EntityInteraction::ActivatePortal, .. }  => 4,
528            Self::Entity { interaction: EntityInteraction::PickupItem, .. }      => 3,
529            Self::Block  { interaction: BlockInteraction::Craft(_), .. }         => 3,
530            Self::Block  { interaction: BlockInteraction::Collect { .. }, .. }   => 3,
531            Self::Entity { interaction: EntityInteraction::HelpDowned, .. }      => 2,
532            Self::Block  { interaction: BlockInteraction::Unlock { .. }, .. }    => 1,
533            Self::Block  { interaction: BlockInteraction::Read(_), .. }          => 1,
534            Self::Block  { interaction: BlockInteraction::LightToggle(_), .. }   => 1,
535            Self::Entity { interaction: EntityInteraction::Pet, .. }             => 0,
536            Self::Entity { interaction: EntityInteraction::Talk , .. }           => 0,
537
538            // GameInput::Mount
539            Self::Entity { interaction: EntityInteraction::Mount, .. }           => 1,
540            Self::Block  { interaction: BlockInteraction::Mount, .. }            => 0,
541
542            // These interactions have dedicated keybinds already, no need to prioritize them
543            Self::Block  { interaction: BlockInteraction::Mine(_), .. }          => 0,
544            Self::Entity { interaction: EntityInteraction::StayFollow, .. }      => 0,
545            Self::Entity { interaction: EntityInteraction::Trade, .. }           => 0,
546            Self::Entity { interaction: EntityInteraction::CampfireSit, .. }     => 0,
547        }
548    }
549}
550
551impl EntityInteraction {
552    pub fn game_input(&self) -> GameInput {
553        match self {
554            EntityInteraction::HelpDowned
555            | EntityInteraction::PickupItem
556            | EntityInteraction::ActivatePortal
557            | EntityInteraction::Pet
558            | EntityInteraction::Talk => GameInput::Interact,
559            EntityInteraction::StayFollow => GameInput::StayFollow,
560            EntityInteraction::Trade => GameInput::Trade,
561            EntityInteraction::Mount => GameInput::Mount,
562            EntityInteraction::CampfireSit => GameInput::Sit,
563        }
564    }
565
566    pub fn range(&self) -> f32 {
567        match self {
568            Self::Trade => consts::MAX_TRADE_RANGE,
569            Self::Mount | Self::Pet => consts::MAX_MOUNT_RANGE,
570            Self::PickupItem => consts::MAX_PICKUP_RANGE,
571            Self::Talk => consts::MAX_NPCINTERACT_RANGE,
572            Self::CampfireSit => consts::MAX_CAMPFIRE_RANGE,
573            _ => consts::MAX_INTERACT_RANGE,
574        }
575    }
576}
577
578impl Interactables {
579    /// Maps the interaction targets to all their available interactions
580    pub fn inverted_map(
581        &self,
582    ) -> (
583        HashMap<specs::Entity, Vec<EntityInteraction>>,
584        HashMap<VolumePos, (Block, Vec<&BlockInteraction>)>,
585    ) {
586        let (mut entity_map, block_map) = self.input_map.iter().fold(
587            (HashMap::new(), HashMap::new()),
588            |(mut entity_map, mut block_map), (_input, (_, interactable))| {
589                match interactable {
590                    Interactable::Entity {
591                        entity,
592                        interaction,
593                    } => {
594                        entity_map
595                            .entry(*entity)
596                            .and_modify(|i: &mut Vec<_>| i.push(*interaction))
597                            .or_insert_with(|| vec![*interaction]);
598                    },
599                    Interactable::Block {
600                        block,
601                        volume_pos,
602                        interaction,
603                    } => {
604                        block_map
605                            .entry(*volume_pos)
606                            .and_modify(|(_, i): &mut (_, Vec<_>)| i.push(interaction))
607                            .or_insert_with(|| (*block, vec![interaction]));
608                    },
609                }
610
611                (entity_map, block_map)
612            },
613        );
614
615        // Ensure interactions are ordered in a stable way
616        // TODO: Once blocks can have more than one interaction, do the same for blocks
617        // here too
618        for v in entity_map.values_mut() {
619            v.sort_unstable_by_key(|i| i.game_input())
620        }
621
622        (entity_map, block_map)
623    }
624}