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