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