Skip to main content

steel_core/entity/entities/objects/items/
experience_orb.rs

1//! Experience orb entity implementation.
2
3use std::sync::{Arc, Weak};
4
5use glam::DVec3;
6use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
7use simdnbt::owned::NbtCompound;
8use steel_macros::entity_behavior;
9use steel_math::DEGREE_360;
10use steel_protocol::packets::game::{CTakeItemEntity, SoundSource};
11use steel_registry::blocks::block_state_ext::BlockStateExt as _;
12use steel_registry::entity_type::EntityTypeRef;
13use steel_registry::fluid::FluidStateExt as _;
14use steel_registry::vanilla_entities;
15use steel_registry::vanilla_entity_data::ExperienceOrbEntityData;
16use steel_utils::locks::SyncMutex;
17use steel_utils::nbt::NbtNumeric as _;
18use steel_utils::nbt::NbtValueInput as _;
19use steel_utils::{BlockPos, ChunkPos, Downcast as _, DowncastType, DowncastTypeKey, WorldAabb};
20
21use crate::entity::damage::DamageSource;
22use crate::entity::{
23    DefaultEntityPush, Entity, EntityBase, EntityBaseLoad, EntityMovementEmission,
24    EntitySyncedData, LivingEntity, RemovalReason, SharedEntity, next_entity_id,
25};
26use crate::fluid::get_fluid_state;
27use crate::physics::{MoverType, WorldCollisionProvider};
28use crate::player::Player;
29use crate::world::World;
30
31const LIFETIME: i32 = 6000;
32const ENTITY_SCAN_PERIOD: i32 = 20;
33const MAX_FOLLOW_DIST: f64 = 8.0;
34const MAX_FOLLOW_DIST_SQR: f64 = MAX_FOLLOW_DIST * MAX_FOLLOW_DIST;
35const ORB_GROUPS_PER_AREA: i32 = 40;
36const ORB_MERGE_DISTANCE: f64 = 0.5;
37const DEFAULT_HEALTH: i32 = 5;
38const DEFAULT_GRAVITY: f64 = 0.03;
39const AIR_FRICTION: f64 = 0.98;
40const BOUNCE_SCALE: f64 = 0.4;
41const UNDERWATER_DRAG: f64 = 0.99;
42const UNDERWATER_VERTICAL_ACCEL: f64 = 5.0e-4;
43const UNDERWATER_MAX_Y: f64 = 0.06;
44const FOLLOW_ACCELERATION: f64 = 0.1;
45
46struct ExperienceOrbState {
47    age: i32,
48    health: i32,
49    count: i32,
50    following_player_id: Option<i32>,
51}
52
53impl ExperienceOrbState {
54    const fn new() -> Self {
55        Self {
56            age: 0,
57            health: DEFAULT_HEALTH,
58            count: 1,
59            following_player_id: None,
60        }
61    }
62}
63
64/// Vanilla experience orb entity.
65#[entity_behavior(class = "ExperienceOrb")]
66pub struct ExperienceOrbEntity {
67    base: EntityBase,
68    entity_type: EntityTypeRef,
69    entity_data: SyncMutex<ExperienceOrbEntityData>,
70    state: SyncMutex<ExperienceOrbState>,
71}
72
73// SAFETY: This key is owned by Steel and uniquely identifies `ExperienceOrbEntity`.
74unsafe impl DowncastType for ExperienceOrbEntity {
75    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/experience_orb");
76}
77
78impl ExperienceOrbEntity {
79    /// Creates a new experience orb with value 0.
80    #[must_use]
81    pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
82        Self {
83            base: EntityBase::new(id, position, entity_type.dimensions, world),
84            entity_type,
85            entity_data: SyncMutex::new(ExperienceOrbEntityData::new()),
86            state: SyncMutex::new(ExperienceOrbState::new()),
87        }
88    }
89
90    /// Creates a new experience orb with a value and vanilla spawn motion.
91    #[must_use]
92    pub fn with_value(
93        entity_type: EntityTypeRef,
94        id: i32,
95        position: DVec3,
96        value: i32,
97        world: Weak<World>,
98    ) -> Self {
99        let entity = Self::new(entity_type, id, position, world);
100        entity.set_value(value);
101        entity.initialize_spawn_movement();
102        entity
103    }
104
105    /// Creates an experience orb from saved base data.
106    #[must_use]
107    pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
108        Self {
109            base: EntityBase::from_load(load, entity_type.dimensions),
110            entity_type,
111            entity_data: SyncMutex::new(ExperienceOrbEntityData::new()),
112            state: SyncMutex::new(ExperienceOrbState::new()),
113        }
114    }
115
116    /// Spawns vanilla experience orbs for an XP amount.
117    pub fn award(world: &Arc<World>, position: DVec3, mut amount: i32) {
118        while amount > 0 {
119            let value = Self::get_experience_value(amount);
120            amount -= value;
121            if Self::try_merge_to_existing(world, position, value) {
122                continue;
123            }
124
125            let entity: SharedEntity = Arc::new(Self::with_value(
126                &vanilla_entities::EXPERIENCE_ORB,
127                next_entity_id(),
128                position,
129                value,
130                Arc::downgrade(world),
131            ));
132            if let Err(error) = world.try_add_entity(entity) {
133                log::debug!("failed to add experience orb: {error}");
134            }
135        }
136    }
137
138    /// Splits a total XP amount into the largest single orb value at most
139    /// `max_value`, from a fixed tier table capped at 2477.
140    #[must_use]
141    pub const fn get_experience_value(max_value: i32) -> i32 {
142        if max_value >= 2477 {
143            2477
144        } else if max_value >= 1237 {
145            1237
146        } else if max_value >= 617 {
147            617
148        } else if max_value >= 307 {
149            307
150        } else if max_value >= 149 {
151            149
152        } else if max_value >= 73 {
153            73
154        } else if max_value >= 37 {
155            37
156        } else if max_value >= 17 {
157            17
158        } else if max_value >= 7 {
159            7
160        } else if max_value >= 3 {
161            3
162        } else {
163            1
164        }
165    }
166
167    /// Returns this orb's XP value.
168    #[must_use]
169    pub fn value(&self) -> i32 {
170        *self.entity_data.lock().value.get()
171    }
172
173    /// Sets this orb's XP value.
174    pub fn set_value(&self, value: i32) {
175        self.entity_data.lock().value.set(value);
176    }
177
178    /// Returns this orb's merge count.
179    #[must_use]
180    pub fn count(&self) -> i32 {
181        self.state.lock().count
182    }
183
184    /// Returns this orb's age.
185    #[must_use]
186    pub fn age(&self) -> i32 {
187        self.state.lock().age
188    }
189
190    /// Sets this orb's age.
191    pub fn set_age(&self, age: i32) {
192        self.state.lock().age = age;
193    }
194
195    /// Returns this orb's health.
196    #[must_use]
197    pub fn health(&self) -> i32 {
198        self.state.lock().health
199    }
200
201    fn initialize_spawn_movement(&self) {
202        let yaw = rand::random_range(0.0..DEGREE_360);
203        let velocity = DVec3::new(
204            f64::from(rand::random_range(-0.1f32..0.1)) * 2.0,
205            f64::from(rand::random_range(0.0f32..0.2)) * 2.0,
206            f64::from(rand::random_range(-0.1f32..0.1)) * 2.0,
207        );
208        self.base.set_rotation((yaw, 0.0));
209        self.base.set_velocity(velocity);
210    }
211
212    fn try_merge_to_existing(world: &Arc<World>, position: DVec3, value: i32) -> bool {
213        let search_box = WorldAabb::new(
214            position.x - 0.5,
215            position.y - 0.5,
216            position.z - 0.5,
217            position.x + 0.5,
218            position.y + 0.5,
219            position.z + 0.5,
220        );
221        let merge_id = rand::random_range(0..ORB_GROUPS_PER_AREA);
222        for entity in world.get_entities_in_aabb(&search_box) {
223            let Some(orb) = entity.downcast_ref::<Self>() else {
224                continue;
225            };
226            if !orb.can_merge_id(merge_id, value) {
227                continue;
228            }
229
230            let mut state = orb.state.lock();
231            state.count += 1;
232            state.age = 0;
233            return true;
234        }
235        false
236    }
237
238    fn scan_for_merges(&self, world: &Arc<World>) {
239        let search_box = self.bounding_box().inflate(ORB_MERGE_DISTANCE);
240        for entity in world.get_entities_in_aabb(&search_box) {
241            if entity.id() == self.id() {
242                continue;
243            }
244            let Some(orb) = entity.downcast_ref::<Self>() else {
245                continue;
246            };
247            if !orb.can_merge_id(self.id(), self.value()) {
248                continue;
249            }
250
251            self.merge(orb);
252            if self.is_removed() {
253                return;
254            }
255        }
256    }
257
258    fn can_merge_id(&self, id: i32, value: i32) -> bool {
259        !self.is_removed() && (self.id() - id) % ORB_GROUPS_PER_AREA == 0 && self.value() == value
260    }
261
262    fn merge(&self, other: &Self) {
263        let (other_count, other_age) = {
264            let state = other.state.lock();
265            (state.count, state.age)
266        };
267        let mut state = self.state.lock();
268        state.count += other_count;
269        state.age = state.age.min(other_age);
270        other.set_removed(RemovalReason::Discarded);
271    }
272
273    fn set_underwater_movement(&self) {
274        let velocity = self.velocity();
275        self.set_velocity(DVec3::new(
276            velocity.x * UNDERWATER_DRAG,
277            (velocity.y + UNDERWATER_VERTICAL_ACCEL).min(UNDERWATER_MAX_Y),
278            velocity.z * UNDERWATER_DRAG,
279        ));
280    }
281
282    fn apply_lava_movement(&self, world: &Arc<World>) {
283        if !get_fluid_state(world, self.block_position()).is_lava() {
284            return;
285        }
286
287        let velocity = DVec3::new(
288            f64::from(rand::random::<f32>() - rand::random::<f32>()) * 0.2,
289            0.2,
290            f64::from(rand::random::<f32>() - rand::random::<f32>()) * 0.2,
291        );
292        self.set_velocity(velocity);
293    }
294
295    fn is_aabb_colliding(&self, world: &Arc<World>, aabb: WorldAabb) -> bool {
296        let collision_world = WorldCollisionProvider::for_entity(world, self);
297        collision_world.has_entity_context_collision(aabb, self.position().y, self.is_descending())
298    }
299
300    fn follow_nearby_player(&self, world: &Arc<World>) {
301        let current = self
302            .state
303            .lock()
304            .following_player_id
305            .and_then(|id| world.players.get_by_entity_id(id));
306
307        let should_refresh = current.as_ref().is_none_or(|player| {
308            player.is_spectator()
309                || player.is_dead_or_dying()
310                || player.position().distance_squared(self.position()) > MAX_FOLLOW_DIST_SQR
311        });
312
313        let following = if should_refresh {
314            let nearest = world.nearest_player(self.position(), MAX_FOLLOW_DIST, |player| {
315                !player.is_spectator() && !player.is_dead_or_dying()
316            });
317            self.state.lock().following_player_id = nearest.as_ref().map(|player| player.id());
318            nearest
319        } else {
320            current
321        };
322
323        let Some(player) = following else {
324            return;
325        };
326
327        let player_pos = player.position();
328        let delta = DVec3::new(
329            player_pos.x - self.position().x,
330            player_pos.y + player.get_eye_height() / 2.0 - self.position().y,
331            player_pos.z - self.position().z,
332        );
333        let length_sqr = delta.length_squared();
334        if length_sqr <= f64::EPSILON {
335            return;
336        }
337
338        let power = 1.0 - length_sqr.sqrt() / MAX_FOLLOW_DIST;
339        self.set_velocity(
340            self.velocity() + delta.normalize() * (power * power * FOLLOW_ACCELERATION),
341        );
342    }
343
344    fn apply_friction_and_bounce(&self, world: &Arc<World>, fall_speed: f64) {
345        let friction = if self.on_ground() {
346            self.block_pos_below_that_affects_movement()
347                .map_or(AIR_FRICTION, |block_pos| {
348                    f64::from(world.get_block_state(block_pos).get_block().config.friction)
349                        * AIR_FRICTION
350                })
351        } else {
352            AIR_FRICTION
353        };
354
355        let mut velocity = self.velocity() * friction;
356        if self.vertical_collision_below() && fall_speed < -self.get_gravity() {
357            velocity.y = -fall_speed * BOUNCE_SCALE;
358        }
359        self.set_velocity(velocity);
360    }
361
362    /// Attempts to have a player pick up this experience orb.
363    pub fn try_pickup(&self, player: &Arc<Player>) -> bool {
364        if player.take_xp_delay() != 0 {
365            return false;
366        }
367
368        player.set_take_xp_delay(2);
369        if let Some(world) = self.level() {
370            let take_packet = CTakeItemEntity::new(self.id(), player.id(), 1);
371            world.broadcast_to_nearby(
372                ChunkPos::from_entity_pos(self.position()),
373                take_packet,
374                None,
375            );
376        }
377
378        let remaining = player
379            .inventory
380            .lock()
381            .repair_random_equipped_item_with_xp(self.value());
382        if remaining > 0 {
383            player.give_experience_points(remaining);
384        }
385
386        let remove = {
387            let mut state = self.state.lock();
388            state.count -= 1;
389            state.count == 0
390        };
391        if remove {
392            self.set_removed(RemovalReason::Discarded);
393        }
394        true
395    }
396}
397
398impl DefaultEntityPush for ExperienceOrbEntity {}
399
400impl Entity for ExperienceOrbEntity {
401    fn is_hard_collision_relevant(&self) -> bool {
402        false
403    }
404
405    fn as_default_entity_push(&self) -> Option<&dyn DefaultEntityPush> {
406        Some(self)
407    }
408
409    fn base(&self) -> &EntityBase {
410        &self.base
411    }
412
413    fn entity_type(&self) -> EntityTypeRef {
414        self.entity_type
415    }
416
417    // Vanilla `ExperienceOrb` overrides this with an empty body.
418    fn do_water_splash_effect(&self) {}
419
420    fn movement_emission(&self) -> EntityMovementEmission {
421        EntityMovementEmission::None
422    }
423
424    fn tick(self: Arc<Self>) {
425        self.default_tick();
426        self.set_old_position_to_current();
427
428        let Some(world) = self.level() else {
429            return;
430        };
431
432        let colliding = self.is_aabb_colliding(&world, self.bounding_box());
433        if self.fluid_contact().eye_in_water() {
434            self.set_underwater_movement();
435        } else if !colliding {
436            self.apply_gravity();
437        }
438
439        self.apply_lava_movement(&world);
440
441        if self.tick_count() % ENTITY_SCAN_PERIOD == 1 {
442            self.scan_for_merges(&world);
443            if self.is_removed() {
444                return;
445            }
446        }
447
448        self.follow_nearby_player(&world);
449        if self.state.lock().following_player_id.is_none() && colliding {
450            let next_colliding =
451                self.is_aabb_colliding(&world, self.bounding_box().translate(self.velocity()));
452            if next_colliding {
453                let bounding_box = self.bounding_box();
454                self.move_towards_closest_space(
455                    self.position().x,
456                    f64::midpoint(bounding_box.min_y(), bounding_box.max_y()),
457                    self.position().z,
458                );
459                self.mark_velocity_sync();
460            }
461        }
462
463        let fall_speed = self.velocity().y;
464        if Arc::clone(&self)
465            .move_entity(MoverType::SelfMovement, self.velocity())
466            .is_some()
467        {
468            self.apply_effects_from_blocks();
469            if self.is_removed() {
470                return;
471            }
472        }
473
474        self.apply_friction_and_bounce(&world, fall_speed);
475
476        let expired = {
477            let mut state = self.state.lock();
478            state.age += 1;
479            state.age >= LIFETIME
480        };
481        if expired {
482            self.set_removed(RemovalReason::Discarded);
483        }
484    }
485
486    fn get_default_gravity(&self) -> f64 {
487        DEFAULT_GRAVITY
488    }
489
490    fn block_pos_below_that_affects_movement(&self) -> Option<BlockPos> {
491        self.on_pos(0.999_999)
492    }
493
494    fn attackable(&self) -> bool {
495        false
496    }
497
498    fn sound_source(&self) -> SoundSource {
499        SoundSource::Ambient
500    }
501
502    fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
503        Some(&self.entity_data)
504    }
505
506    fn player_touch(self: Arc<Self>, player: &Arc<Player>) {
507        self.try_pickup(player);
508    }
509
510    fn hurt(&self, _world: &World, source: &DamageSource, amount: f32) -> bool {
511        if self.is_invulnerable_to_base(source) {
512            return false;
513        }
514
515        self.mark_hurt();
516        let health = {
517            let mut state = self.state.lock();
518            state.health = (state.health as f32 - amount) as i32;
519            state.health
520        };
521        if health <= 0 {
522            self.set_removed(RemovalReason::Discarded);
523        }
524        true
525    }
526
527    fn save_additional(&self, nbt: &mut NbtCompound) {
528        let state = self.state.lock();
529        nbt.insert("Health", state.health as i16);
530        nbt.insert("Age", state.age as i16);
531        nbt.insert("Value", self.value() as i16);
532        nbt.insert("Count", state.count);
533    }
534
535    fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
536        let mut state = self.state.lock();
537        state.health = i32::from(nbt.short_value("Health").unwrap_or(DEFAULT_HEALTH as i16));
538        state.age = i32::from(nbt.short_value("Age").unwrap_or(0));
539        if let Some(count) = nbt.get("Count").and_then(|tag| tag.codec_i32())
540            && count > 0
541        {
542            state.count = count;
543        }
544        drop(state);
545
546        self.set_value(i32::from(nbt.short_value("Value").unwrap_or(0)));
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use std::io::Cursor;
553
554    use simdnbt::borrow::read_compound as read_borrowed_compound;
555    use steel_registry::{init_vanilla_registry, vanilla_damage_types};
556
557    use crate::test_support::test_world;
558
559    use super::*;
560
561    #[test]
562    fn experience_value_buckets_match_vanilla() {
563        assert_eq!(ExperienceOrbEntity::get_experience_value(2477), 2477);
564        assert_eq!(ExperienceOrbEntity::get_experience_value(2476), 1237);
565        assert_eq!(ExperienceOrbEntity::get_experience_value(1236), 617);
566        assert_eq!(ExperienceOrbEntity::get_experience_value(616), 307);
567        assert_eq!(ExperienceOrbEntity::get_experience_value(306), 149);
568        assert_eq!(ExperienceOrbEntity::get_experience_value(148), 73);
569        assert_eq!(ExperienceOrbEntity::get_experience_value(72), 37);
570        assert_eq!(ExperienceOrbEntity::get_experience_value(36), 17);
571        assert_eq!(ExperienceOrbEntity::get_experience_value(16), 7);
572        assert_eq!(ExperienceOrbEntity::get_experience_value(6), 3);
573        assert_eq!(ExperienceOrbEntity::get_experience_value(2), 1);
574    }
575
576    #[test]
577    fn merge_id_uses_vanilla_grouping_and_value() {
578        init_vanilla_registry();
579
580        let orb = ExperienceOrbEntity::new(
581            &vanilla_entities::EXPERIENCE_ORB,
582            41,
583            DVec3::ZERO,
584            Weak::new(),
585        );
586        orb.set_value(7);
587
588        assert!(orb.can_merge_id(1, 7));
589        assert!(!orb.can_merge_id(2, 7));
590        assert!(!orb.can_merge_id(1, 3));
591    }
592
593    #[test]
594    fn experience_orb_merge_absorbs_existing_group() {
595        init_vanilla_registry();
596
597        let target = ExperienceOrbEntity::new(
598            &vanilla_entities::EXPERIENCE_ORB,
599            41,
600            DVec3::ZERO,
601            Weak::new(),
602        );
603        target.set_value(7);
604        target.set_age(50);
605
606        let other = ExperienceOrbEntity::new(
607            &vanilla_entities::EXPERIENCE_ORB,
608            81,
609            DVec3::ZERO,
610            Weak::new(),
611        );
612        other.set_value(7);
613        other.set_age(12);
614        other.state.lock().count = 3;
615
616        assert!(other.can_merge_id(target.id(), target.value()));
617        target.merge(&other);
618
619        assert_eq!(target.count(), 4);
620        assert_eq!(target.age(), 12);
621        assert!(other.is_removed());
622    }
623
624    #[test]
625    fn orb_damage_truncates_after_fractional_subtraction() {
626        init_vanilla_registry();
627
628        let orb = ExperienceOrbEntity::new(
629            &vanilla_entities::EXPERIENCE_ORB,
630            1,
631            DVec3::ZERO,
632            Weak::new(),
633        );
634
635        assert!(orb.hurt(
636            test_world(),
637            &DamageSource::environment(&vanilla_damage_types::GENERIC),
638            0.75,
639        ));
640
641        assert_eq!(orb.health(), 4);
642    }
643
644    #[test]
645    fn orb_saves_and_loads_vanilla_state() {
646        init_vanilla_registry();
647
648        let orb = ExperienceOrbEntity::new(
649            &vanilla_entities::EXPERIENCE_ORB,
650            1,
651            DVec3::ZERO,
652            Weak::new(),
653        );
654        orb.set_value(17);
655        orb.set_age(42);
656        {
657            let mut state = orb.state.lock();
658            state.health = 3;
659            state.count = 4;
660        }
661
662        let mut nbt = NbtCompound::new();
663        orb.save_additional(&mut nbt);
664
665        let mut bytes = Vec::new();
666        nbt.write(&mut bytes);
667        let borrowed = read_borrowed_compound(&mut Cursor::new(&bytes))
668            .unwrap_or_else(|error| panic!("test nbt should reborrow: {error}"));
669
670        let loaded = ExperienceOrbEntity::new(
671            &vanilla_entities::EXPERIENCE_ORB,
672            2,
673            DVec3::ZERO,
674            Weak::new(),
675        );
676        loaded.load_additional((&borrowed).into());
677
678        assert_eq!(loaded.value(), 17);
679        assert_eq!(loaded.age(), 42);
680        assert_eq!(loaded.health(), 3);
681        assert_eq!(loaded.count(), 4);
682    }
683}