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_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#[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
70unsafe impl DowncastType for ExperienceOrbEntity {
72 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/experience_orb");
73}
74
75impl ExperienceOrbEntity {
76 #[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 #[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 #[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 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 #[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 #[must_use]
165 pub fn value(&self) -> i32 {
166 *self.entity_data.lock().value.get()
167 }
168
169 pub fn set_value(&self, value: i32) {
171 self.entity_data.lock().value.set(value);
172 }
173
174 #[must_use]
176 pub fn count(&self) -> i32 {
177 self.state.lock().count
178 }
179
180 #[must_use]
182 pub fn age(&self) -> i32 {
183 self.state.lock().age
184 }
185
186 pub fn set_age(&self, age: i32) {
188 self.state.lock().age = age;
189 }
190
191 #[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 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}