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