1use std::sync::{Arc, Weak};
8
9use glam::DVec3;
10use simdnbt::borrow::NbtCompound as BorrowedNbtCompoundView;
11use simdnbt::owned::NbtCompound;
12use steel_macros::entity_behavior;
13use steel_protocol::packets::game::SoundSource;
14use steel_registry::blocks::block_state_ext::BlockStateExt as _;
15use steel_registry::data_components::vanilla_components::FIREWORKS;
16use steel_registry::entity_type::EntityTypeRef;
17use steel_registry::item_stack::ItemStack;
18use steel_registry::vanilla_entity_data::FireworkRocketEntityData;
19use steel_registry::{
20 sound_events, vanilla_damage_type_tags, vanilla_damage_types, vanilla_game_events,
21 vanilla_items,
22};
23use steel_utils::entity_events::EntityStatus;
24use steel_utils::locks::SyncMutex;
25use steel_utils::{DowncastType, DowncastTypeKey};
26
27use crate::behavior::BLOCK_BEHAVIORS;
28use crate::entity::damage::DamageSource;
29use crate::entity::{
30 Entity, EntityBase, EntityBaseLoad, EntityEventSource, EntitySyncedData,
31 InsideBlockEffectCollector, LivingEntity, Projectile, ProjectileBase, ProjectileHit,
32 RemovalReason, SharedEntity,
33};
34use crate::physics::MoverType;
35use crate::world::game_event::GameEventContext;
36use crate::world::{ClipBlockShape, ClipFluid, ClipHitResult, World};
37
38const INITIAL_VERTICAL_VELOCITY: f64 = 0.05;
39const INITIAL_HORIZONTAL_DEVIATION: f64 = 0.002_297;
40const HORIZONTAL_ACCELERATION: f64 = 1.15;
41const VERTICAL_ACCELERATION: f64 = 0.04;
42const ELYTRA_TARGET_SPEED: f64 = 1.5;
43const ELYTRA_POWER_ADD: f64 = 0.1;
44const ELYTRA_VELOCITY_BLEND: f64 = 0.5;
45const EXPLOSION_RADIUS: f64 = 5.0;
46const EXPLOSION_RADIUS_SQUARED: f64 = EXPLOSION_RADIUS * EXPLOSION_RADIUS;
47
48struct FireworkRocketState {
49 life: i32,
50 lifetime: i32,
51 attached_to_entity: Option<Weak<dyn Entity>>,
52}
53
54#[entity_behavior(class = "FireworkRocketEntity")]
56pub struct FireworkRocketEntity {
57 base: EntityBase,
58 entity_type: EntityTypeRef,
59 entity_data: SyncMutex<FireworkRocketEntityData>,
60 projectile_base: ProjectileBase,
61 state: SyncMutex<FireworkRocketState>,
62}
63
64unsafe impl DowncastType for FireworkRocketEntity {
66 const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:entity/firework_rocket");
67}
68
69impl FireworkRocketEntity {
70 #[must_use]
72 pub fn new(entity_type: EntityTypeRef, id: i32, position: DVec3, world: Weak<World>) -> Self {
73 Self {
74 base: EntityBase::new(id, position, entity_type.dimensions, world),
75 entity_type,
76 entity_data: SyncMutex::new(FireworkRocketEntityData::new()),
77 projectile_base: ProjectileBase::new(),
78 state: SyncMutex::new(FireworkRocketState {
79 life: 0,
80 lifetime: 0,
81 attached_to_entity: None,
82 }),
83 }
84 }
85
86 #[must_use]
88 pub fn from_saved(entity_type: EntityTypeRef, load: EntityBaseLoad) -> Self {
89 Self {
90 base: EntityBase::from_load(load, entity_type.dimensions),
91 entity_type,
92 entity_data: SyncMutex::new(FireworkRocketEntityData::new()),
93 projectile_base: ProjectileBase::new(),
94 state: SyncMutex::new(FireworkRocketState {
95 life: 0,
96 lifetime: 0,
97 attached_to_entity: None,
98 }),
99 }
100 }
101
102 fn is_base_invulnerable_to(&self, source: &DamageSource) -> bool {
103 self.is_removed()
104 || self.is_invulnerable() && !source.bypasses_invulnerability()
105 || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FIRE) && self.fire_immune()
106 || source.is(&vanilla_damage_type_tags::DamageTypeTag::IS_FALL)
107 && self.is_fall_damage_immune()
108 }
109
110 #[must_use]
112 pub fn launched(
113 entity_type: EntityTypeRef,
114 id: i32,
115 position: DVec3,
116 world: Weak<World>,
117 source_item: ItemStack,
118 ) -> Self {
119 let rocket = Self::new(entity_type, id, position, world);
120 rocket.initialize_launch(source_item);
121 rocket
122 }
123
124 #[must_use]
127 pub fn attached_to_living(
128 entity_type: EntityTypeRef,
129 id: i32,
130 world: Weak<World>,
131 source_item: ItemStack,
132 attached_to: &dyn LivingEntity,
133 ) -> Self {
134 let rocket = Self::launched(entity_type, id, attached_to.position(), world, source_item);
135 rocket.set_owner_uuid(Some(attached_to.uuid()));
136 if let Ok(attached_id) = u32::try_from(attached_to.id()) {
137 rocket
138 .entity_data
139 .lock()
140 .attached_to_target
141 .set(Some(attached_id));
142 }
143 rocket
144 }
145
146 fn initialize_launch(&self, source_item: ItemStack) {
147 let flight_count = source_item
148 .get(FIREWORKS)
149 .map_or(1, |fireworks| 1 + fireworks.flight_duration());
150 self.entity_data.lock().id_fireworks_item.set(source_item);
151 self.set_velocity(DVec3::new(
152 triangle_random(0.0, INITIAL_HORIZONTAL_DEVIATION),
153 INITIAL_VERTICAL_VELOCITY,
154 triangle_random(0.0, INITIAL_HORIZONTAL_DEVIATION),
155 ));
156 self.state.lock().lifetime =
157 10 * flight_count + rand::random_range(0..6) + rand::random_range(0..7);
158 }
159
160 pub fn set_shot_at_angle(&self, shot_at_angle: bool) {
162 self.entity_data.lock().shot_at_angle.set(shot_at_angle);
163 }
164
165 #[must_use]
167 pub fn is_shot_at_angle(&self) -> bool {
168 *self.entity_data.lock().shot_at_angle.get()
169 }
170
171 fn is_attached_to_entity(&self) -> bool {
172 self.entity_data.lock().attached_to_target.get().is_some()
173 }
174
175 fn attached_entity(&self, world: &Arc<World>) -> Option<SharedEntity> {
176 if let Some(attached) = self
177 .state
178 .lock()
179 .attached_to_entity
180 .as_ref()
181 .and_then(Weak::upgrade)
182 && !attached.is_removed()
183 && attached.as_living_entity().is_some()
184 {
185 return Some(attached);
186 }
187
188 let attached_id = *self.entity_data.lock().attached_to_target.get();
189 let attached_id = i32::try_from(attached_id?).ok()?;
190 let attached = world.get_entity_by_id(attached_id)?;
191 attached.as_living_entity()?;
192 self.state.lock().attached_to_entity = Some(Arc::downgrade(&attached));
193 Some(attached)
194 }
195
196 fn tick_attached(&self, world: &Arc<World>) -> Option<ProjectileHit> {
197 if let Some(attached) = self.attached_entity(world)
198 && let Some(living) = attached.as_living_entity()
199 {
200 let hand_angle = if living.is_fall_flying() {
201 let look_angle = living.look_angle();
202 let movement = living.velocity();
203 living.set_velocity(elytra_boosted_velocity(movement, look_angle));
204 living.hand_holding_item_angle(&vanilla_items::FIREWORK_ROCKET)
205 } else {
206 DVec3::ZERO
207 };
208
209 if let Err(error) = self.try_set_position(living.position() + hand_angle) {
210 log::debug!("failed to move attached firework rocket: {error}");
211 }
212 self.set_velocity(living.velocity());
213 }
214
215 self.get_hit_result_on_move_vector()
216 }
217
218 fn tick_free_flying(&self) -> Option<ProjectileHit> {
219 if !self.is_shot_at_angle() {
220 let horizontal_acceleration = if self.horizontal_collision() {
221 1.0
222 } else {
223 HORIZONTAL_ACCELERATION
224 };
225 let movement = self.velocity();
226 self.set_velocity(DVec3::new(
227 movement.x * horizontal_acceleration,
228 movement.y + VERTICAL_ACCELERATION,
229 movement.z * horizontal_acceleration,
230 ));
231 }
232
233 let movement = self.velocity();
234 let hit = self.get_hit_result_on_move_vector();
235 self.move_entity(MoverType::SelfMovement, movement);
236 self.apply_effects_from_blocks();
237 self.set_velocity(movement);
238 hit
239 }
240
241 fn explosion_count(&self) -> usize {
242 self.entity_data
243 .lock()
244 .id_fireworks_item
245 .get()
246 .get(FIREWORKS)
247 .map_or(0, |fireworks| fireworks.explosions().len())
248 }
249
250 fn has_explosion(&self) -> bool {
251 self.explosion_count() != 0
252 }
253
254 fn fireworks_damage_source(&self) -> DamageSource {
255 let mut source = DamageSource::environment(&vanilla_damage_types::FIREWORKS)
256 .with_direct_entity(self.id());
257 if let Some(owner) = self.get_owner() {
258 source = source.with_causing_entity(owner.id());
259 }
260 source
261 }
262
263 fn deal_explosion_damage(&self, world: &Arc<World>) {
264 let explosion_count = self.explosion_count();
265 if explosion_count == 0 {
266 return;
267 }
268 let damage_amount = 5.0 + explosion_count as f32 * 2.0;
269 let attached = self.attached_entity(world);
270 let attached_id = attached.as_ref().map(|entity| entity.id());
271
272 if let Some(attached) = &attached {
273 attached.hurt(world, &self.fireworks_damage_source(), damage_amount);
274 }
275
276 let rocket_position = self.position();
277 let search_box = self.bounding_box().inflate(EXPLOSION_RADIUS);
278 for target in world.get_entities_in_aabb_matching(&search_box, Entity::is_living_entity) {
279 if attached_id == Some(target.id()) {
280 continue;
281 }
282 let distance_squared = rocket_position.distance_squared(target.position());
283 if distance_squared > EXPLOSION_RADIUS_SQUARED {
284 continue;
285 }
286
287 let target_height = f64::from(target.base().dimensions().height);
288 let can_see = [0.0, 0.5].into_iter().any(|height_scale| {
289 let target_position = target.position();
290 let to = DVec3::new(
291 target_position.x,
292 target_position.y + target_height * height_scale,
293 target_position.z,
294 );
295 world
296 .clip(
297 rocket_position,
298 to,
299 ClipBlockShape::Collider,
300 ClipFluid::None,
301 )
302 .is_miss()
303 });
304 if !can_see {
305 continue;
306 }
307
308 let distance = distance_squared.sqrt();
309 let distance_scale = ((EXPLOSION_RADIUS - distance) / EXPLOSION_RADIUS).sqrt();
310 target.hurt(
311 world,
312 &self.fireworks_damage_source(),
313 damage_amount * distance_scale as f32,
314 );
315 }
316 }
317
318 fn explode(&self, world: &Arc<World>) {
319 self.broadcast_entity_event(EntityStatus::FireworksExplode);
320 let owner = self.get_owner();
321 world.game_event_at(
322 &vanilla_game_events::EXPLODE,
323 self.position(),
324 &GameEventContext::new(owner.as_deref(), None),
325 );
326 self.deal_explosion_damage(world);
327 self.set_removed(RemovalReason::Discarded);
328 }
329
330 fn run_hit_block_entity_inside(&self, world: &Arc<World>, hit: &ClipHitResult) {
331 let state = world.get_block_state(hit.block_pos);
332 let behavior = BLOCK_BEHAVIORS.get_behavior(state.get_block());
333 let mut ignored_effects = InsideBlockEffectCollector::new();
334 behavior.entity_inside(
335 state,
336 world,
337 hit.block_pos,
338 self.as_entity_event_source(),
339 &mut ignored_effects,
340 true,
341 );
342 }
343
344 #[cfg(test)]
345 fn life_and_lifetime(&self) -> (i32, i32) {
346 let state = self.state.lock();
347 (state.life, state.lifetime)
348 }
349}
350
351impl Entity for FireworkRocketEntity {
352 fn base(&self) -> &EntityBase {
353 &self.base
354 }
355
356 fn entity_type(&self) -> EntityTypeRef {
357 self.entity_type
358 }
359
360 fn tick(&self) {
361 self.projectile_base_tick();
362 let Some(world) = self.level() else {
363 return;
364 };
365
366 let hit = if self.is_attached_to_entity() {
367 self.tick_attached(&world)
368 } else {
369 self.tick_free_flying()
370 };
371 if !self.no_physics()
372 && self.is_alive()
373 && let Some(hit) = &hit
374 {
375 self.hit_target_or_deflect_self(hit);
376 self.mark_velocity_sync();
377 }
378
379 self.update_rotation();
380 let (play_launch_sound, expired) = {
381 let mut state = self.state.lock();
382 let play_launch_sound = state.life == 0;
383 state.life = state.life.wrapping_add(1);
384 (play_launch_sound, state.life > state.lifetime)
385 };
386 if play_launch_sound && !self.is_silent() {
387 world.play_sound_at(
388 &sound_events::ENTITY_FIREWORK_ROCKET_LAUNCH,
389 SoundSource::Ambient,
390 self.position(),
391 3.0,
392 1.0,
393 None,
394 );
395 }
396 if expired {
397 self.explode(&world);
398 }
399 }
400
401 fn spawn_data(&self) -> i32 {
402 self.get_owner().map_or(0, |owner| owner.id())
403 }
404
405 fn restore_owner_reference(&self, owner: &SharedEntity) {
406 self.cache_owner_entity(owner);
407 }
408
409 fn projectile_owner_uuid(&self) -> Option<uuid::Uuid> {
410 self.owner_uuid()
411 }
412
413 fn projectile_owner(&self) -> Option<SharedEntity> {
414 self.get_owner()
415 }
416
417 fn attackable(&self) -> bool {
418 false
419 }
420
421 fn synced_data(&self) -> Option<&dyn EntitySyncedData> {
422 Some(&self.entity_data)
423 }
424
425 fn hurt(&self, _world: &World, source: &DamageSource, _amount: f32) -> bool {
426 if !self.is_base_invulnerable_to(source) {
427 self.mark_hurt();
428 }
429 false
430 }
431
432 fn save_additional(&self, nbt: &mut NbtCompound) {
433 self.save_projectile(nbt);
434 let state = self.state.lock();
435 nbt.insert("Life", state.life);
436 nbt.insert("LifeTime", state.lifetime);
437 drop(state);
438 nbt.insert("FireworksItem", self.get_item().to_nbt_tag_ref());
439 nbt.insert("ShotAtAngle", i8::from(self.is_shot_at_angle()));
440 }
441
442 fn load_additional(&self, nbt: BorrowedNbtCompoundView<'_, '_>) {
443 self.load_projectile(nbt);
444 {
445 let mut state = self.state.lock();
446 state.life = nbt.int("Life").unwrap_or(0);
447 state.lifetime = nbt.int("LifeTime").unwrap_or(0);
448 }
449 let item = nbt
450 .compound("FireworksItem")
451 .and_then(|item| ItemStack::from_borrowed_compound(&item))
452 .unwrap_or_else(|| ItemStack::new(&vanilla_items::FIREWORK_ROCKET));
453 self.set_item(item);
454 self.set_shot_at_angle(nbt.byte("ShotAtAngle").is_some_and(|value| value != 0));
455 }
456}
457
458impl Projectile for FireworkRocketEntity {
459 fn projectile_base(&self) -> &ProjectileBase {
460 &self.projectile_base
461 }
462
463 fn calculate_horizontal_hurt_knockback_direction(
464 &self,
465 hurt_entity: &dyn LivingEntity,
466 _damage_source: &DamageSource,
467 ) -> (f64, f64) {
468 let delta = hurt_entity.position() - self.position();
469 (delta.x, delta.z)
470 }
471
472 fn on_hit_entity(&self, _entity: &SharedEntity, _location: DVec3) {
473 if let Some(world) = self.level() {
474 self.explode(&world);
475 }
476 }
477
478 fn on_hit_block(&self, hit: &ClipHitResult) {
479 if let Some(world) = self.level() {
480 self.run_hit_block_entity_inside(&world, hit);
481 if self.has_explosion() {
482 self.explode(&world);
483 }
484 }
485 self.projectile_on_hit_block(hit);
486 }
487}
488
489impl FireworkRocketEntity {
490 #[must_use]
492 pub fn get_item(&self) -> ItemStack {
493 self.entity_data.lock().id_fireworks_item.get().clone()
494 }
495
496 pub fn set_item(&self, item: ItemStack) {
498 self.entity_data.lock().id_fireworks_item.set(item);
499 }
500}
501
502fn triangle_random(mode: f64, deviation: f64) -> f64 {
503 mode + deviation * (rand::random::<f64>() - rand::random::<f64>())
504}
505
506fn elytra_boosted_velocity(movement: DVec3, look_angle: DVec3) -> DVec3 {
507 movement
508 + look_angle * ELYTRA_POWER_ADD
509 + (look_angle * ELYTRA_TARGET_SPEED - movement) * ELYTRA_VELOCITY_BLEND
510}
511
512#[cfg(test)]
513mod tests {
514 use std::io::Cursor;
515
516 use simdnbt::borrow::read_compound as read_borrowed_compound;
517 use simdnbt::owned::NbtCompound;
518 use steel_registry::data_components::components::Fireworks;
519 use steel_registry::data_components::vanilla_components::FIREWORKS;
520 use steel_registry::item_stack::ItemStack;
521 use steel_registry::{init_vanilla_registry, vanilla_entities, vanilla_items};
522
523 use crate::{
524 entity::{Entity, Projectile, entities::PigEntity},
525 test_support::test_world,
526 };
527
528 use super::*;
529
530 #[test]
531 fn launched_rocket_uses_fireworks_flight_duration_for_lifetime() {
532 init_vanilla_registry();
533 let mut item = ItemStack::new(&vanilla_items::FIREWORK_ROCKET);
534 item.set(
535 FIREWORKS,
536 Fireworks::new(3, Vec::new()).unwrap_or_else(|error| {
537 panic!("valid firework component should construct: {error}")
538 }),
539 );
540 let rocket = FireworkRocketEntity::launched(
541 &vanilla_entities::FIREWORK_ROCKET,
542 1,
543 DVec3::ZERO,
544 Weak::new(),
545 item,
546 );
547
548 let (_, lifetime) = rocket.life_and_lifetime();
549 assert!((40..=51).contains(&lifetime));
550 assert_eq!(
551 rocket.velocity().y.to_bits(),
552 INITIAL_VERTICAL_VELOCITY.to_bits()
553 );
554 }
555
556 #[test]
557 fn firework_uses_vanilla_neutral_sound_source() {
558 init_vanilla_registry();
559 let rocket = FireworkRocketEntity::new(
560 &vanilla_entities::FIREWORK_ROCKET,
561 1,
562 DVec3::ZERO,
563 Weak::new(),
564 );
565
566 assert_eq!(rocket.sound_source(), SoundSource::Neutral);
567 }
568
569 #[test]
570 fn hurt_marks_rocket_unless_base_invulnerable_and_always_returns_false() {
571 init_vanilla_registry();
572 let rocket = FireworkRocketEntity::new(
573 &vanilla_entities::FIREWORK_ROCKET,
574 1,
575 DVec3::ZERO,
576 Weak::new(),
577 );
578 let source = DamageSource::environment(&vanilla_damage_types::GENERIC);
579
580 assert!(!rocket.hurt(test_world(), &source, 1.0));
581 assert!(rocket.hurt_marked());
582
583 rocket.clear_hurt_mark();
584 rocket.set_invulnerable(true);
585 assert!(!rocket.hurt(test_world(), &source, 1.0));
586 assert!(!rocket.hurt_marked());
587 }
588
589 #[test]
590 fn firework_metadata_carries_item_attachment_and_angle() {
591 init_vanilla_registry();
592 let target: SharedEntity = Arc::new(PigEntity::new(
593 &vanilla_entities::PIG,
594 19,
595 DVec3::new(1.0, 2.0, 3.0),
596 Weak::new(),
597 ));
598 let Some(living_target) = target.as_living_entity() else {
599 panic!("pig test entity should be living");
600 };
601 let rocket = FireworkRocketEntity::attached_to_living(
602 &vanilla_entities::FIREWORK_ROCKET,
603 2,
604 Weak::new(),
605 ItemStack::new(&vanilla_items::FIREWORK_ROCKET),
606 living_target,
607 );
608 rocket.set_shot_at_angle(true);
609
610 let data = rocket.entity_data.lock();
611 assert_eq!(*data.attached_to_target.get(), Some(19));
612 assert!(*data.shot_at_angle.get());
613 assert!(
614 data.id_fireworks_item
615 .get()
616 .is(&vanilla_items::FIREWORK_ROCKET)
617 );
618 assert_eq!(rocket.owner_uuid(), Some(target.uuid()));
619 }
620
621 #[test]
622 fn firework_state_persists_with_vanilla_keys() {
623 init_vanilla_registry();
624 let rocket = FireworkRocketEntity::launched(
625 &vanilla_entities::FIREWORK_ROCKET,
626 1,
627 DVec3::ZERO,
628 Weak::new(),
629 ItemStack::new(&vanilla_items::FIREWORK_ROCKET),
630 );
631 {
632 let mut state = rocket.state.lock();
633 state.life = 7;
634 state.lifetime = 29;
635 }
636 rocket.set_shot_at_angle(true);
637 rocket.set_owner_uuid(Some(uuid::Uuid::from_u128(42)));
638
639 let mut nbt = NbtCompound::new();
640 rocket.save_additional(&mut nbt);
641 assert_eq!(nbt.int("Life"), Some(7));
642 assert_eq!(nbt.int("LifeTime"), Some(29));
643 assert_eq!(nbt.byte("ShotAtAngle"), Some(1));
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 let loaded = FireworkRocketEntity::new(
650 &vanilla_entities::FIREWORK_ROCKET,
651 2,
652 DVec3::ZERO,
653 Weak::new(),
654 );
655 loaded.load_additional((&borrowed).into());
656
657 assert_eq!(loaded.life_and_lifetime(), (7, 29));
658 assert!(loaded.is_shot_at_angle());
659 assert_eq!(loaded.owner_uuid(), Some(uuid::Uuid::from_u128(42)));
660 assert!(loaded.get_item().is(&vanilla_items::FIREWORK_ROCKET));
661 }
662
663 #[test]
664 fn firework_knockback_direction_points_from_rocket_to_target() {
665 init_vanilla_registry();
666 let rocket = FireworkRocketEntity::new(
667 &vanilla_entities::FIREWORK_ROCKET,
668 1,
669 DVec3::new(2.0, 0.0, 3.0),
670 Weak::new(),
671 );
672 let target = PigEntity::new(
673 &vanilla_entities::PIG,
674 2,
675 DVec3::new(5.0, 0.0, 1.0),
676 Weak::new(),
677 );
678 let source = DamageSource::environment(&vanilla_damage_types::FIREWORKS);
679
680 assert_eq!(
681 rocket.calculate_horizontal_hurt_knockback_direction(&target, &source),
682 (3.0, -2.0)
683 );
684 assert!(rocket.as_projectile().is_some());
685 }
686
687 #[test]
688 fn firework_damage_source_has_no_raw_position() {
689 init_vanilla_registry();
690 let rocket = FireworkRocketEntity::new(
691 &vanilla_entities::FIREWORK_ROCKET,
692 23,
693 DVec3::new(1.0, 2.0, 3.0),
694 Weak::new(),
695 );
696
697 let source = rocket.fireworks_damage_source();
698
699 assert_eq!(source.direct_entity_id, Some(23));
700 assert!(source.source_position.is_none());
701 }
702
703 #[test]
704 fn elytra_boost_matches_vanilla_vector_formula() {
705 let movement = DVec3::new(0.2, -0.1, 0.4);
706 let look_angle = DVec3::new(0.0, 0.0, 1.0);
707
708 assert_eq!(
709 elytra_boosted_velocity(movement, look_angle),
710 DVec3::new(0.1, -0.05, 1.05)
711 );
712 }
713}