Skip to main content

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    scene: &Scene,
181) -> Result<HashMap<GameInput, (f32, Interactable)>, GetInteractablesError> {
182    span!(_guard, "select_interactable");
183    use common::{spiral::Spiral2d, terrain::TerrainChunk, vol::RectRasterableVol};
184
185    let voxel_colliders_manifest = VOXEL_COLLIDER_MANIFEST.read();
186
187    let ecs = client.state().ecs();
188    let id_maps = ecs.read_resource::<IdMaps>();
189    let scene_terrain = scene.terrain();
190    let terrain = client.state().terrain();
191    let player_entity = client.entity();
192    let interpolated = ecs.read_storage::<crate::ecs::comp::Interpolated>();
193    let player_pos = interpolated
194        .get(player_entity)
195        .ok_or(GetInteractablesError::ClientMissingPosition)?
196        .pos;
197
198    let uids = ecs.read_storage::<Uid>();
199    let healths = ecs.read_storage::<comp::Health>();
200    let colliders = ecs.read_storage::<comp::Collider>();
201    let char_states = ecs.read_storage::<comp::CharacterState>();
202    let is_mounts = ecs.read_storage::<Is<Mount>>();
203    let bodies = ecs.read_storage::<comp::Body>();
204    let masses = ecs.read_storage::<comp::Mass>();
205    let items = ecs.read_storage::<comp::PickupItem>();
206    let alignments = ecs.read_storage::<comp::Alignment>();
207    let is_volume_rider = ecs.read_storage::<Is<VolumeRider>>();
208    let volume_riders = ecs.read_storage::<common::mounting::VolumeRiders>();
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        &interpolated,
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(), EntityInteraction::max_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, interpolated, 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                    Some(EntityInteraction::ActivatePortal)
255                } else if alignment.is_some_and(|alignment| {
256                    can_perform_pet(comp::Pos(player_pos), comp::Pos(interpolated.pos), *alignment)
257                }) {
258                    Some(EntityInteraction::Pet)
259                } else if alignment.is_some_and(|alignment| matches!(alignment, Alignment::Npc)) {
260                    Some(EntityInteraction::Talk)
261                } else {
262                    None
263                };
264
265                // Interaction using [`GameInput::Sit`]
266                let sit =
267                    (body.is_campfire() && !is_downed).then_some(EntityInteraction::CampfireSit);
268
269                // Interaction using [`GameInput::Trade`]
270                // TODO: Remove this once we have a better way do determine whether an entity
271                // can be traded with not based on alignment.
272                let trade = (!is_downed
273                    && alignment.is_some_and(|alignment| match alignment {
274                        Alignment::Npc => true,
275                        Alignment::Owned(other_uid) => other_uid == uid || player_uid == *other_uid,
276                        _ => false,
277                    }))
278                .then_some(EntityInteraction::Trade);
279
280                // Interaction using [`GameInput::Mount`]
281                let mount = (matches!(alignment, Some(Alignment::Owned(other_uid)) if player_uid == *other_uid)
282                    && pet::is_mountable(body, mass, player_body, player_mass)
283                    && !is_downed
284                    && !client.is_riding())
285                .then_some(EntityInteraction::Mount);
286
287                // Interaction using [`GameInput::StayFollow`]
288                let stayfollow = alignment
289                    .filter(|alignment| {
290                        matches!(alignment,
291                            Alignment::Owned(other_uid) if player_uid == *other_uid)
292                            && !is_downed
293                    })
294                    .map(|_| EntityInteraction::StayFollow);
295
296                // Roughly filter out entities farther than interaction distance
297                let distance_squared = player_pos.distance_squared(interpolated.pos);
298
299                    [interaction, sit, trade, mount, stayfollow].into_iter().flatten()
300                        .filter_map(move |interaction| {
301                            (distance_squared <= interaction.range().powi(2)).then_some((
302                                interaction,
303                                entity,
304                                distance_squared,
305                            ))
306                        })
307            },
308        );
309
310    let volumes_data = (
311        &entities,
312        &ecs.read_storage::<Uid>(),
313        &ecs.read_storage::<comp::Body>(),
314        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
315        &ecs.read_storage::<comp::Collider>(),
316    );
317
318    let mut volumes_data = volumes_data.lend_join();
319
320    let volume_interactables = spacial_grid
321        .0
322        .in_circle_aabr(player_pos.xy(), BlockInteraction::max_range())
323        .filter(|&e| e != player_entity)
324        .filter_map(|e| volumes_data.get(e, &entities))
325        .filter_map(|(entity, uid, body, interpolated, collider)| {
326            let vol = collider.get_vol(&voxel_colliders_manifest)?;
327            let (blocks_of_interest, offset) =
328                scene
329                    .figure_mgr()
330                    .get_blocks_of_interest(entity, body, Some(collider))?;
331
332            let mat = Mat4::from(interpolated.ori.to_quat()).translated_3d(interpolated.pos)
333                * Mat4::translation_3d(offset);
334
335            let p = mat.inverted().mul_point(player_pos);
336            let aabb = Aabb {
337                min: Vec3::zero(),
338                max: vol.volume().sz.as_(),
339            };
340
341            if aabb.contains_point(p) || aabb.distance_to_point(p) < BlockInteraction::max_range() {
342                Some(blocks_of_interest.interactables.iter().map(
343                    move |(block_offset, interaction)| {
344                        let wpos = mat.mul_point(block_offset.as_() + 0.5);
345                        (wpos, VolumePos::entity(*block_offset, *uid), *interaction)
346                    },
347                ))
348            } else {
349                None
350            }
351        })
352        .flatten();
353
354    // TODO: this formula for the number to take was guessed
355    // Note: assumes RECT_SIZE.x == RECT_SIZE.y
356    let interactable_blocks = Spiral2d::new()
357        .take(
358            ((BlockInteraction::max_range() / TerrainChunk::RECT_SIZE.x as f32).ceil() as usize
359                * 2
360                + 1)
361            .pow(2),
362        )
363        .flat_map(|offset| {
364            let chunk_pos = player_chunk + offset;
365            let chunk_voxel_pos =
366                Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
367            scene_terrain
368                .get(chunk_pos)
369                .map(|data| (data, chunk_voxel_pos))
370        })
371        .flat_map(|(chunk_data, chunk_pos)| {
372            // TODO: maybe we could make this more efficient by putting the
373            // interactables is some sort of spatial structure
374            chunk_data
375                .blocks_of_interest
376                .interactables
377                .iter()
378                .map(move |(block_offset, interaction)| (chunk_pos + block_offset, interaction))
379                .map(|(pos, interaction)| {
380                    (
381                        pos.as_::<f32>() + 0.5,
382                        VolumePos::terrain(pos),
383                        *interaction,
384                    )
385                })
386        })
387        .chain(volume_interactables)
388        .chain(
389            mine_target
390                .map(|t| t.position_int())
391                .into_iter()
392                .chain(collect_target.map(|t| t.position_int()))
393                .map(|pos| (pos.as_(), VolumePos::terrain(pos), Interaction::Collect)),
394        )
395        .filter_map(|(wpos, volume_pos, interaction)| {
396            let distance_sq = wpos.distance_squared(player_pos);
397            if distance_sq > BlockInteraction::max_range().powi(2) {
398                return None;
399            }
400
401            let (block, interaction) = BlockInteraction::from_block_pos(
402                &terrain,
403                &id_maps,
404                &colliders,
405                volume_pos,
406                interaction,
407            )?;
408
409            (distance_sq <= interaction.range().powi(2)).then_some((
410                block,
411                volume_pos,
412                interaction,
413                distance_sq,
414            ))
415        })
416        .filter(|(_, volume_pos, interaction, _)| {
417            // Additional checks for `BlockInteraction::Mount` after filtering by distance.
418            if let BlockInteraction::Mount = interaction {
419                !is_volume_rider.contains(player_entity)
420                    // Only check all entities when mounting terrain block, otherwise we can check
421                    // if spot is present in VolumeRiders component.
422                    && match volume_pos.kind {
423                        Volume::Terrain => !is_volume_rider
424                            .join()
425                            .any(|is_volume_rider| is_volume_rider.pos == *volume_pos),
426                        Volume::Entity(volume_uid) => {
427                            id_maps.uid_entity(volume_uid).is_some_and(|volume_entity| {
428                                // Component may not be present if there are no riders.
429                                volume_riders
430                                    .get(volume_entity)
431                                    .is_none_or(|riders| !riders.spot_taken(volume_pos.pos))
432                            })
433                        },
434                    }
435            } else {
436                true
437            }
438        });
439
440    // Helper to check if an interactable is directly targetted by the player, and
441    // should thus be prioritized over non-directly targetted ones.
442    let is_direct_target = |interactable: &Interactable| match interactable {
443        Interactable::Block {
444            volume_pos,
445            interaction,
446            ..
447        } => {
448            matches!(
449                (mine_target, volume_pos, interaction),
450                (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Mine(_))
451                    if target.position_int() == *pos)
452                || matches!(
453                        (collect_target, volume_pos, interaction),
454                        (Some(target), VolumePos { kind: Volume::Terrain, pos }, BlockInteraction::Collect { .. } | BlockInteraction::Unlock { .. })
455                            if target.position_int() == *pos)
456        },
457        Interactable::Entity { entity, .. } => {
458            entity_target.is_some_and(|target| target.kind.0 == *entity)
459        },
460    };
461
462    Ok(interactable_entities
463        .map(|(interaction, entity, distance)| {
464            (distance.powi(2), Interactable::Entity {
465                entity,
466                interaction,
467            })
468        })
469        .chain(
470            interactable_blocks.map(|(block, volume_pos, interaction, distance_squared)| {
471                (distance_squared, Interactable::Block {
472                    block,
473                    volume_pos,
474                    interaction,
475                })
476            }),
477        )
478        .fold(HashMap::new(), |mut map, (distance_sq, interaction)| {
479            let input = interaction.game_input();
480
481            if map
482                .get(&input)
483                .is_none_or(|(other_distance_sq, other_interaction)| {
484                    (
485                        // Prioritize direct targets
486                        is_direct_target(other_interaction),
487                        other_interaction.priority(),
488                        Reverse(*other_distance_sq),
489                    ) < (
490                        is_direct_target(&interaction),
491                        interaction.priority(),
492                        Reverse(distance_sq),
493                    )
494                })
495            {
496                map.insert(input, (distance_sq, interaction));
497            }
498
499            map
500        }))
501}
502
503impl Interactable {
504    fn game_input(&self) -> GameInput {
505        match self {
506            Interactable::Block { interaction, .. } => interaction.game_input(),
507            Interactable::Entity { interaction, .. } => interaction.game_input(),
508        }
509    }
510
511    /// Priorities for different interactions. Note: Priorities are grouped by
512    /// GameInput
513    #[rustfmt::skip]
514    fn priority(&self) -> usize {
515        match self {
516            // GameInput::Interact
517            Self::Entity { interaction: EntityInteraction::ActivatePortal, .. }  => 4,
518            Self::Entity { interaction: EntityInteraction::PickupItem, .. }      => 3,
519            Self::Block  { interaction: BlockInteraction::Craft(_), .. }         => 3,
520            Self::Block  { interaction: BlockInteraction::Collect { .. }, .. }   => 3,
521            Self::Entity { interaction: EntityInteraction::HelpDowned, .. }      => 2,
522            Self::Block  { interaction: BlockInteraction::Unlock { .. }, .. }    => 1,
523            Self::Block  { interaction: BlockInteraction::Read(_), .. }          => 1,
524            Self::Block  { interaction: BlockInteraction::LightToggle(_), .. }   => 1,
525            Self::Entity { interaction: EntityInteraction::Pet, .. }             => 0,
526            Self::Entity { interaction: EntityInteraction::Talk , .. }           => 0,
527
528            // GameInput::Mount
529            Self::Entity { interaction: EntityInteraction::Mount, .. }           => 1,
530            Self::Block  { interaction: BlockInteraction::Mount, .. }            => 0,
531
532            // These interactions have dedicated keybinds already, no need to prioritize them
533            Self::Block  { interaction: BlockInteraction::Mine(_), .. }          => 0,
534            Self::Entity { interaction: EntityInteraction::StayFollow, .. }      => 0,
535            Self::Entity { interaction: EntityInteraction::Trade, .. }           => 0,
536            Self::Entity { interaction: EntityInteraction::CampfireSit, .. }     => 0,
537        }
538    }
539}
540
541impl EntityInteraction {
542    pub(crate) fn game_input(&self) -> GameInput {
543        match self {
544            EntityInteraction::HelpDowned
545            | EntityInteraction::PickupItem
546            | EntityInteraction::ActivatePortal
547            | EntityInteraction::Pet
548            | EntityInteraction::Talk => GameInput::Interact,
549            EntityInteraction::StayFollow => GameInput::StayFollow,
550            EntityInteraction::Trade => GameInput::Trade,
551            EntityInteraction::Mount => GameInput::Mount,
552            EntityInteraction::CampfireSit => GameInput::Sit,
553        }
554    }
555
556    fn range(&self) -> f32 {
557        // Update `max_range` below when editing this.
558        match self {
559            Self::Trade => consts::MAX_TRADE_RANGE,
560            Self::Mount | Self::Pet => consts::MAX_MOUNT_RANGE,
561            Self::PickupItem => consts::MAX_PICKUP_RANGE,
562            Self::Talk => consts::MAX_NPCINTERACT_RANGE,
563            Self::CampfireSit => consts::MAX_CAMPFIRE_RANGE,
564            Self::ActivatePortal => consts::TELEPORTER_RADIUS,
565            Self::HelpDowned | Self::StayFollow => consts::MAX_INTERACT_RANGE,
566        }
567    }
568
569    fn max_range() -> f32 {
570        consts::MAX_TRADE_RANGE
571            .max(consts::MAX_MOUNT_RANGE)
572            .max(consts::MAX_PICKUP_RANGE)
573            .max(consts::MAX_NPCINTERACT_RANGE)
574            .max(consts::MAX_CAMPFIRE_RANGE)
575            .max(consts::MAX_INTERACT_RANGE)
576    }
577}
578
579impl Interactables {
580    /// Maps the interaction targets to all their available interactions
581    pub fn inverted_map(
582        &self,
583    ) -> (
584        HashMap<specs::Entity, Vec<EntityInteraction>>,
585        HashMap<VolumePos, (Block, Vec<&BlockInteraction>)>,
586    ) {
587        let (mut entity_map, block_map) = self.input_map.iter().fold(
588            (HashMap::new(), HashMap::new()),
589            |(mut entity_map, mut block_map), (_input, (_, interactable))| {
590                match interactable {
591                    Interactable::Entity {
592                        entity,
593                        interaction,
594                    } => {
595                        entity_map
596                            .entry(*entity)
597                            .and_modify(|i: &mut Vec<_>| i.push(*interaction))
598                            .or_insert_with(|| vec![*interaction]);
599                    },
600                    Interactable::Block {
601                        block,
602                        volume_pos,
603                        interaction,
604                    } => {
605                        block_map
606                            .entry(*volume_pos)
607                            .and_modify(|(_, i): &mut (_, Vec<_>)| i.push(interaction))
608                            .or_insert_with(|| (*block, vec![interaction]));
609                    },
610                }
611
612                (entity_map, block_map)
613            },
614        );
615
616        // Ensure interactions are ordered in a stable way
617        // TODO: Once blocks can have more than one interaction, do the same for blocks
618        // here too
619        for v in entity_map.values_mut() {
620            v.sort_unstable_by_key(|i| i.game_input())
621        }
622
623        (entity_map, block_map)
624    }
625}