Skip to main content

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