Skip to main content

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

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