1use 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#[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
73unsafe impl DowncastType for ExperienceOrbEntity {
75 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/experience_orb");
76}
77
78impl ExperienceOrbEntity {
79 #[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 #[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 #[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 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 #[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 #[must_use]
169 pub fn value(&self) -> i32 {
170 *self.entity_data.lock().value.get()
171 }
172
173 pub fn set_value(&self, value: i32) {
175 self.entity_data.lock().value.set(value);
176 }
177
178 #[must_use]
180 pub fn count(&self) -> i32 {
181 self.state.lock().count
182 }
183
184 #[must_use]
186 pub fn age(&self) -> i32 {
187 self.state.lock().age
188 }
189
190 pub fn set_age(&self, age: i32) {
192 self.state.lock().age = age;
193 }
194
195 #[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 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 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}