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