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