Skip to main content

steel_core/entity/entities/objects/technical/
interaction.rs

1use crate::behavior::InteractionResult;
2use crate::entity::damage::DamageSource;
3use crate::entity::{BorrowedNbtCompoundView, SharedEntity};
4use crate::entity::{Entity, EntityBase, EntityBaseLoad, EntitySyncedData};
5use crate::player::Player;
6use crate::world::World;
7use glam::DVec3;
8use parking_lot::MutexGuard;
9use simdnbt::owned::{NbtCompound, NbtTag};
10use simdnbt::{FromNbtTag, ToNbtTag};
11use std::sync::Arc;
12use std::sync::Weak;
13use steel_macros::entity_behavior;
14use steel_registry::blocks::behavior::PushReaction;
15use steel_registry::entity_data::EntityPose;
16use steel_registry::entity_type::{EntityDimensions, EntityTypeRef};
17use steel_registry::vanilla_entity_data::InteractionEntityData;
18use steel_utils::locks::SyncMutex;
19use steel_utils::nbt::NbtNumeric as _;
20use steel_utils::nbt::NbtValueInput as _;
21use steel_utils::types::InteractionHand;
22use steel_utils::{DowncastType, DowncastTypeKey, UuidExt, WorldAabb};
23use uuid::Uuid;
24
25const DEFAULT_WIDTH: f32 = 1.0;
26const DEFAULT_HEIGHT: f32 = 1.0;
27const DEFAULT_RESPONSE: bool = false;
28
29const DEFAULT_DIMENSIONS: EntityDimensions =
30    EntityDimensions::with_default_eye_height(DEFAULT_WIDTH, DEFAULT_HEIGHT);
31
32const TAG_WIDTH: &str = "width";
33const TAG_HEIGHT: &str = "height";
34const TAG_ATTACK: &str = "attack";
35const TAG_INTERACTION: &str = "interaction";
36const TAG_RESPONSE: &str = "response";
37
38const TAG_PLAYER: &str = "player";
39const TAG_TIMESTAMP: &str = "timestamp";
40
41/// An invisible, invincible, interactable entity which records when a player clicks on its bounding
42/// box. It is used in map-making or data packs, and its bounding box is customizable.
43///
44/// The dimensions of its bounding box (`width` and `height`) and whether it triggers a response from
45/// the player (`response`) can be accessed and/or modified with [`InteractionEntity::with_entity_data`].
46#[entity_behavior(class = "Interaction")]
47pub struct InteractionEntity {
48    base: EntityBase,
49    entity_type: EntityTypeRef,
50    entity_data: SyncMutex<InteractionEntityData>,
51    interaction: SyncMutex<Option<PlayerAction>>,
52    attack: SyncMutex<Option<PlayerAction>>,
53}
54
55// SAFETY: This key is owned by Steel and uniquely identifies `InteractionEntity`.
56unsafe impl DowncastType for InteractionEntity {
57    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/interaction");
58}
59
60impl InteractionEntity {
61    /// Creates a new interaction entity.
62    #[must_use]
63    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
64        Self {
65            base: EntityBase::new(id, position, DEFAULT_DIMENSIONS, world),
66            entity_type,
67            entity_data: SyncMutex::new(InteractionEntityData::new()),
68            attack: SyncMutex::new(None),
69            interaction: SyncMutex::new(None),
70        }
71    }
72
73    /// Creates a new interaction entity with a specific UUID.
74    #[must_use]
75    pub fn with_uuid(
76        entity_type: EntityTypeRef,
77        id: i32,
78        position: DVec3,
79        uuid: Uuid,
80        world: Weak<World>,
81    ) -> Self {
82        Self {
83            base: EntityBase::with_uuid(id, uuid, position, DEFAULT_DIMENSIONS, world),
84            entity_type,
85            entity_data: SyncMutex::new(InteractionEntityData::new()),
86            attack: SyncMutex::new(None),
87            interaction: SyncMutex::new(None),
88        }
89    }
90
91    /// Creates an interaction entity from saved data.
92    #[must_use]
93    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
94        Self {
95            base: EntityBase::from_load(load, DEFAULT_DIMENSIONS),
96            entity_type,
97            entity_data: SyncMutex::new(InteractionEntityData::new()),
98            attack: SyncMutex::new(None),
99            interaction: SyncMutex::new(None),
100        }
101    }
102
103    /// Provides an exclusive view to the synced entity data to the given closure, which includes
104    /// the dimensions of its bounding box (`width` and `height`), and whether it triggers a response
105    /// from the player (`response`).
106    ///
107    /// Do not attempt to lock entity data within the closure provided.
108    pub fn with_entity_data<R>(
109        &self,
110        f: impl FnOnce(&mut InteractionEntityDataView<'_>) -> R,
111    ) -> R {
112        let (value, dimensions_changed) = {
113            let mut view = InteractionEntityDataView {
114                guard: self.entity_data.lock(),
115                dimensions_changed: false,
116            };
117            let value = f(&mut view);
118            (value, view.dimensions_changed)
119        };
120        if dimensions_changed {
121            self.refresh_dimensions();
122        }
123        value
124    }
125
126    /// Provides the latest attack (left-click) on this entity, if it exists.
127    pub fn last_attack(&self) -> Option<PlayerAction> {
128        *self.attack.lock()
129    }
130
131    /// Provides the latest interaction (right-click) on this entity, if it exists.
132    pub fn last_interaction(&self) -> Option<PlayerAction> {
133        *self.interaction.lock()
134    }
135}
136
137impl Entity for InteractionEntity {
138    fn base(&self) -> &EntityBase {
139        &self.base
140    }
141
142    fn entity_type(&self) -> EntityTypeRef {
143        self.entity_type
144    }
145
146    fn is_pickable(&self) -> bool {
147        true
148    }
149
150    fn skip_attack_interaction(&self, source: &SharedEntity) -> bool {
151        let Some(player) = source.as_player() else {
152            return false;
153        };
154        if let Some(world) = self.level() {
155            *self.attack.lock() = Some(PlayerAction {
156                player: player.uuid(),
157                timestamp: world.game_time(),
158            });
159            // TODO: Trigger PLAYER_HURT_ENTITY advancement criterion trigger
160        }
161        !self.entity_data.lock().response.get()
162    }
163
164    fn is_ignoring_block_triggers(&self) -> bool {
165        true
166    }
167
168    fn piston_push_reaction(&self) -> PushReaction {
169        PushReaction::IgnoreEntity
170    }
171
172    fn can_be_hit_by_projectile(&self) -> bool {
173        false
174    }
175
176    fn make_bounding_box_at(&self, position: DVec3) -> WorldAabb {
177        let guard = self.entity_data.lock();
178        WorldAabb::entity_box(
179            position.x,
180            position.y,
181            position.z,
182            f64::from(*guard.width.get() / 2.0),
183            f64::from(*guard.height.get()),
184        )
185    }
186
187    fn tick(self: Arc<Self>) {}
188
189    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
190        Some(&self.entity_data)
191    }
192
193    fn interact(
194        &self,
195        player: &Player,
196        _hand: InteractionHand,
197        _location: DVec3,
198    ) -> InteractionResult {
199        if let Some(world) = self.level() {
200            *self.interaction.lock() = Some(PlayerAction {
201                player: player.uuid(),
202                timestamp: world.game_time(),
203            });
204        }
205        InteractionResult::Consume
206    }
207
208    fn no_physics(&self) -> bool {
209        true
210    }
211
212    fn dimensions_for_pose(&self, _pose: EntityPose) -> EntityDimensions {
213        let guard = self.entity_data.lock();
214        EntityDimensions::with_default_eye_height(*guard.width.get(), *guard.height.get())
215    }
216
217    fn save_additional(&self, nbt: &mut NbtCompound) {
218        {
219            let guard = self.entity_data.lock();
220            nbt.insert(TAG_WIDTH, *guard.width.get());
221            nbt.insert(TAG_HEIGHT, *guard.height.get());
222            nbt.insert(TAG_RESPONSE, *guard.response.get());
223        }
224        {
225            let guard = self.attack.lock();
226            if let Some(attack) = guard.as_ref() {
227                nbt.insert(TAG_ATTACK, attack);
228            }
229        }
230        {
231            let guard = self.interaction.lock();
232            if let Some(interaction) = guard.as_ref() {
233                nbt.insert(TAG_INTERACTION, interaction);
234            }
235        }
236    }
237
238    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
239        let dimensions_changed = {
240            let mut guard = self.entity_data.lock();
241
242            guard
243                .width
244                .set(nbt.float_value(TAG_WIDTH).unwrap_or(DEFAULT_WIDTH));
245            guard
246                .height
247                .set(nbt.float_value(TAG_HEIGHT).unwrap_or(DEFAULT_HEIGHT));
248            guard.response.set(
249                nbt.byte_value(TAG_RESPONSE)
250                    .map_or(DEFAULT_RESPONSE, |b| b != 0),
251            );
252
253            guard.width.is_dirty() || guard.height.is_dirty()
254        };
255        *self.attack.lock() = nbt.get(TAG_ATTACK).and_then(PlayerAction::from_nbt_tag);
256        *self.interaction.lock() = nbt
257            .get(TAG_INTERACTION)
258            .and_then(PlayerAction::from_nbt_tag);
259        if dimensions_changed {
260            self.refresh_dimensions();
261        }
262        self.base
263            .set_bounding_box(self.make_bounding_box_at(self.position()));
264    }
265
266    fn hurt(&self, _world: &World, _source: &DamageSource, _amount: f32) -> bool {
267        false
268    }
269}
270
271/// Represents an action of a player. It contains the UUID of the player who executed this action
272/// and the timestamp (based on game time, expressed in ticks) when it was executed.
273#[derive(Debug, Copy, Clone, PartialEq, Eq)]
274pub struct PlayerAction {
275    /// The player who executed the action.
276    player: Uuid,
277
278    /// The game time (in ticks) when the player executed the action.
279    timestamp: i64,
280}
281
282impl PlayerAction {
283    /// Returns the unique ID of the player who executed this action.
284    #[must_use]
285    pub const fn player(&self) -> Uuid {
286        self.player
287    }
288
289    /// Returns the game time (in ticks) when the player executed the action.
290    #[must_use]
291    pub const fn timestamp(&self) -> i64 {
292        self.timestamp
293    }
294}
295
296impl FromNbtTag for PlayerAction {
297    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
298        let compound = tag.compound()?;
299        Some(Self {
300            player: Uuid::from_int_array(&compound.int_array(TAG_PLAYER)?)?,
301            timestamp: compound.get(TAG_TIMESTAMP)?.codec_i64()?,
302        })
303    }
304}
305
306impl ToNbtTag for &PlayerAction {
307    fn to_nbt_tag(self) -> NbtTag {
308        let mut compound = NbtCompound::new();
309        compound.insert(
310            TAG_PLAYER,
311            NbtTag::IntArray(self.player.to_int_array().to_vec()),
312        );
313        compound.insert(TAG_TIMESTAMP, self.timestamp);
314        NbtTag::Compound(compound)
315    }
316}
317
318/// Provides an exclusive view of the synchronized entity data of an interaction entity.
319/// This includes its `width`, its `height`, and the `response` boolean.
320pub struct InteractionEntityDataView<'a> {
321    guard: MutexGuard<'a, InteractionEntityData>,
322    dimensions_changed: bool,
323}
324
325impl InteractionEntityDataView<'_> {
326    /// Gets the width of the bounding box of the interaction entity.
327    #[must_use]
328    pub fn width(&self) -> f32 {
329        *self.guard.width.get()
330    }
331
332    /// Sets the width of the bounding box of the interaction entity to the provided value.
333    pub fn set_width(&mut self, width: f32) {
334        self.guard.width.set(width);
335        if self.guard.width.is_dirty() {
336            self.dimensions_changed = true;
337        }
338    }
339
340    /// Gets the height of the bounding box of the interaction entity.
341    #[must_use]
342    pub fn height(&self) -> f32 {
343        *self.guard.height.get()
344    }
345
346    /// Sets the height of the bounding box of the interaction entity to the provided value.
347    pub fn set_height(&mut self, height: f32) {
348        self.guard.height.set(height);
349        if self.guard.height.is_dirty() {
350            self.dimensions_changed = true;
351        }
352    }
353
354    /// Gets whether interacting with the interaction entity will trigger a response
355    /// from the player. If `true`, this means that
356    /// - for left clicks, a punching sound will play, and
357    /// - for right clicks, the player's hand will swing.
358    #[must_use]
359    pub fn response(&self) -> bool {
360        *self.guard.response.get()
361    }
362
363    /// Sets whether interacting with the interaction entity will trigger a response
364    /// from the player. If set to `true`, this means that
365    /// - for left clicks, a punching sound will play, and
366    /// - for right clicks, the player's hand will swing.
367    pub fn set_response(&mut self, response: bool) {
368        self.guard.response.set(response);
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use crate::entity::entities::InteractionEntity;
375    use crate::entity::entities::objects::technical::interaction::{
376        DEFAULT_HEIGHT, DEFAULT_WIDTH, PlayerAction, TAG_HEIGHT, TAG_WIDTH,
377    };
378    use crate::entity::{Entity, SharedEntity};
379    use crate::player::Player;
380    use crate::test_support::tick_test_world;
381    use crate::test_support::{TestPlayerBuilder, fresh_test_world};
382    use glam::DVec3;
383    use simdnbt::borrow::read_compound;
384    use simdnbt::owned::NbtCompound;
385    use std::io::Cursor;
386    use std::sync::Arc;
387    use steel_registry::vanilla_entities;
388    use steel_utils::types::InteractionHand;
389
390    const TEST_POSITION: DVec3 = DVec3::new(-8.0, 128.0, 4.0);
391
392    #[test]
393    fn skip_attack_interaction_when_required() {
394        let world_fixture = fresh_test_world("skip_interaction_when_required");
395        let world = &world_fixture.world;
396        let player = TestPlayerBuilder::new(Arc::clone(world), "InteractPlayer", 0).build();
397
398        let response_false_interaction = InteractionEntity::new(
399            &vanilla_entities::INTERACTION,
400            1,
401            TEST_POSITION,
402            Arc::downgrade(world),
403        );
404        let response_true_interaction = InteractionEntity::new(
405            &vanilla_entities::INTERACTION,
406            2,
407            TEST_POSITION,
408            Arc::downgrade(world),
409        );
410        response_true_interaction.with_entity_data(|data| data.set_response(true));
411
412        let shared_player: SharedEntity = Arc::<Player>::clone(&player);
413        assert!(response_false_interaction.skip_attack_interaction(&shared_player));
414        assert!(!response_true_interaction.skip_attack_interaction(&shared_player));
415    }
416
417    #[test]
418    fn record_player_actions() {
419        let world_fixture = fresh_test_world("interaction_records_player_actions");
420        let world = &world_fixture.world;
421        let player = TestPlayerBuilder::new(Arc::clone(world), "InteractPlayer", 0).build();
422
423        let interaction = InteractionEntity::new(
424            &vanilla_entities::INTERACTION,
425            1,
426            TEST_POSITION,
427            Arc::downgrade(world),
428        );
429
430        assert_eq!(interaction.last_attack(), None);
431        assert_eq!(interaction.last_interaction(), None);
432
433        tick_test_world(world, 0, true);
434        tick_test_world(world, 1, true);
435        tick_test_world(world, 2, true);
436
437        let shared_player: SharedEntity = Arc::<Player>::clone(&player);
438        interaction.skip_attack_interaction(&shared_player);
439        assert_eq!(
440            interaction.last_attack(),
441            Some(PlayerAction {
442                player: player.uuid(),
443                timestamp: 3
444            })
445        );
446        assert_eq!(interaction.last_interaction(), None);
447
448        tick_test_world(world, 3, true);
449        tick_test_world(world, 4, true);
450
451        interaction.interact(player.as_ref(), InteractionHand::MainHand, TEST_POSITION);
452        assert_eq!(
453            interaction.last_attack(),
454            Some(PlayerAction {
455                player: player.uuid(),
456                timestamp: 3
457            })
458        );
459        assert_eq!(
460            interaction.last_interaction(),
461            Some(PlayerAction {
462                player: player.uuid(),
463                timestamp: 5
464            })
465        );
466
467        tick_test_world(world, 5, true);
468
469        interaction.skip_attack_interaction(&shared_player);
470        assert_eq!(
471            interaction.last_attack(),
472            Some(PlayerAction {
473                player: player.uuid(),
474                timestamp: 6
475            })
476        );
477    }
478
479    #[test]
480    fn update_dimensions_on_edit() {
481        let world_fixture = fresh_test_world("interaction_updates_dimensions_on_edit");
482        let world = &world_fixture.world;
483
484        let interaction = InteractionEntity::new(
485            &vanilla_entities::INTERACTION,
486            0,
487            TEST_POSITION,
488            Arc::downgrade(world),
489        );
490
491        let check_dimensions_and_bounding_box = |expected_width: f32, expected_height: f32| {
492            let dimensions = interaction.base.dimensions();
493            assert_eq!(dimensions.width, expected_width);
494            assert_eq!(dimensions.height, expected_height);
495
496            let bounding_box = interaction.base.bounding_box();
497            assert_eq!(bounding_box.width(), f64::from(expected_width));
498            assert_eq!(bounding_box.height(), f64::from(expected_height));
499        };
500
501        check_dimensions_and_bounding_box(DEFAULT_WIDTH, DEFAULT_HEIGHT);
502
503        interaction.with_entity_data(|data| {
504            data.set_width(2.0);
505        });
506        check_dimensions_and_bounding_box(2.0, DEFAULT_HEIGHT);
507
508        interaction.with_entity_data(|data| {
509            data.set_height(4.0);
510        });
511        check_dimensions_and_bounding_box(2.0, 4.0);
512
513        interaction.with_entity_data(|data| {
514            data.set_width(0.5);
515            data.set_height(0.25);
516        });
517        check_dimensions_and_bounding_box(0.5, 0.25);
518
519        let bytes = {
520            let mut compound = NbtCompound::new();
521            compound.insert(TAG_WIDTH, 2.0f32);
522            compound.remove(TAG_HEIGHT);
523            let mut bytes = Vec::new();
524            compound.write(&mut bytes);
525            bytes
526        };
527        let borrowed = read_compound(&mut Cursor::new(&bytes))
528            .unwrap_or_else(|error| panic!("test nbt should reborrow: {error}"));
529        interaction.load_additional((&borrowed).into());
530        check_dimensions_and_bounding_box(2.0, DEFAULT_HEIGHT);
531    }
532}