veloren_common_systems/
projectile.rs

1use common::{
2    Damage, DamageKind, Explosion, GroupTarget, RadiusEffect,
3    combat::{self, AttackOptions, AttackSource, AttackerInfo, TargetInfo},
4    comp::{
5        Alignment, Body, Buffs, CharacterState, Combo, Content, Energy, Group, Health, Inventory,
6        Mass, Ori, PhysicsState, Player, Poise, Pos, Projectile, Stats, Vel,
7        agent::{Sound, SoundKind},
8        aura::EnteredAuras,
9        object,
10        projectile::{self, ProjectileHitEntities, SplitOptions},
11    },
12    effect,
13    event::{
14        ArcingEvent, BonkEvent, BuffEvent, ComboChangeEvent, CreateNpcEvent, DeleteEvent, EmitExt,
15        Emitter, EnergyChangeEvent, EntityAttackedHookEvent, EventBus, ExplosionEvent,
16        HealthChangeEvent, KnockbackEvent, NpcBuilder, ParryHookEvent, PoiseChangeEvent,
17        PossessEvent, ShootEvent, SoundEvent, TransformEvent,
18    },
19    event_emitters,
20    outcome::Outcome,
21    resources::{DeltaTime, Secs, Time},
22    uid::{IdMaps, Uid},
23    util::Dir,
24};
25
26use common::vol::ReadVol;
27use common_ecs::{Job, Origin, Phase, System};
28use itertools::Either;
29use rand::Rng;
30use specs::{
31    Entities, Entity as EcsEntity, Join, Read, ReadExpect, ReadStorage, SystemData, WriteStorage,
32    shred,
33};
34use std::{
35    f32::consts::{PI, TAU},
36    time::Duration,
37};
38use vek::*;
39
40use common::terrain::TerrainGrid;
41
42event_emitters! {
43    struct Events[Emitters] {
44        sound: SoundEvent,
45        delete: DeleteEvent,
46        explosion: ExplosionEvent,
47        health_change: HealthChangeEvent,
48        energy_change: EnergyChangeEvent,
49        poise_change: PoiseChangeEvent,
50        parry_hook: ParryHookEvent,
51        knockback: KnockbackEvent,
52        entity_attack_hook: EntityAttackedHookEvent,
53        shoot: ShootEvent,
54        create_npc: CreateNpcEvent,
55        combo_change: ComboChangeEvent,
56        buff: BuffEvent,
57        bonk: BonkEvent,
58        possess: PossessEvent,
59        arc: ArcingEvent,
60        transform: TransformEvent,
61    }
62}
63
64#[derive(SystemData)]
65pub struct ReadData<'a> {
66    time: Read<'a, Time>,
67    entities: Entities<'a>,
68    players: ReadStorage<'a, Player>,
69    dt: Read<'a, DeltaTime>,
70    id_maps: Read<'a, IdMaps>,
71    events: Events<'a>,
72    uids: ReadStorage<'a, Uid>,
73    positions: ReadStorage<'a, Pos>,
74    alignments: ReadStorage<'a, Alignment>,
75    physics_states: ReadStorage<'a, PhysicsState>,
76    inventories: ReadStorage<'a, Inventory>,
77    groups: ReadStorage<'a, Group>,
78    energies: ReadStorage<'a, Energy>,
79    stats: ReadStorage<'a, Stats>,
80    combos: ReadStorage<'a, Combo>,
81    healths: ReadStorage<'a, Health>,
82    bodies: ReadStorage<'a, Body>,
83    character_states: ReadStorage<'a, CharacterState>,
84    terrain: ReadExpect<'a, TerrainGrid>,
85    buffs: ReadStorage<'a, Buffs>,
86    entered_auras: ReadStorage<'a, EnteredAuras>,
87    masses: ReadStorage<'a, Mass>,
88}
89
90/// This system is responsible for handling projectile effect triggers
91#[derive(Default)]
92pub struct Sys;
93impl<'a> System<'a> for Sys {
94    type SystemData = (
95        ReadData<'a>,
96        WriteStorage<'a, Ori>,
97        WriteStorage<'a, Projectile>,
98        Read<'a, EventBus<Outcome>>,
99        WriteStorage<'a, Vel>,
100        WriteStorage<'a, ProjectileHitEntities>,
101    );
102
103    const NAME: &'static str = "projectile";
104    const ORIGIN: Origin = Origin::Common;
105    const PHASE: Phase = Phase::Create;
106
107    fn run(
108        _job: &mut Job<Self>,
109        (read_data, mut orientations, mut projectiles, outcomes, mut velocities, mut hit_entities): Self::SystemData,
110    ) {
111        let mut emitters = read_data.events.get_emitters();
112        let mut outcomes_emitter = outcomes.emitter();
113        let mut rng = rand::rng();
114
115        // Attacks
116        'projectile_loop: for (entity, pos, physics, body, projectile) in (
117            &read_data.entities,
118            &read_data.positions,
119            &read_data.physics_states,
120            &read_data.bodies,
121            &mut projectiles,
122        )
123            .join()
124        {
125            let projectile_owner = projectile
126                .owner
127                .and_then(|uid| read_data.id_maps.uid_entity(uid));
128
129            if physics.on_surface().is_none() && rng.random_bool(0.05) {
130                emitters.emit(SoundEvent {
131                    sound: Sound::new(SoundKind::Projectile, pos.0, 4.0, read_data.time.0),
132                });
133            }
134
135            let mut projectile_vanished: bool = false;
136
137            // Hit entity
138            for (&other, &pos_hit_other) in physics.touch_entities.iter() {
139                let same_group = projectile_owner
140                    // Note: somewhat inefficient since we do the lookup for every touching
141                    // entity, but if we pull this out of the loop we would want to do it only
142                    // if there is at least one touching entity
143                    .and_then(|e| read_data.groups.get(e)).is_some_and(|owner_group|
144                        Some(owner_group) == read_data.id_maps
145                        .uid_entity(other)
146                        .and_then(|e| read_data.groups.get(e)));
147
148                // Skip if in the same group
149                let target_group = if same_group {
150                    GroupTarget::InGroup
151                } else {
152                    GroupTarget::OutOfGroup
153                };
154
155                if projectile.ignore_group
156                    && same_group
157                    && projectile
158                        .owner
159                        .and_then(|owner| {
160                            read_data
161                                .id_maps
162                                .uid_entity(owner)
163                                .zip(read_data.id_maps.uid_entity(other))
164                        })
165                        .is_none_or(|(owner, other)| {
166                            !combat::allow_friendly_fire(&read_data.entered_auras, owner, other)
167                        })
168                {
169                    continue;
170                }
171
172                if projectile.owner == Some(other) {
173                    continue;
174                }
175
176                // Skip if projectile has already hit this entity
177                if projectile.hit_entities.contains(&other) {
178                    continue;
179                }
180
181                // Skip if another projectile from the attacker has already hit this entity, and
182                // if this projectile limits the number of hits per ability
183                if projectile.limit_per_ability
184                    && projectile
185                        .owner
186                        .and_then(|owner| read_data.id_maps.uid_entity(owner))
187                        .and_then(|owner| hit_entities.get(owner))
188                        .is_some_and(|h_e| h_e.hit_entities.iter().any(|(hit, _)| *hit == other))
189                {
190                    continue;
191                }
192
193                let projectile = &mut *projectile;
194
195                let entity_of = |uid: Uid| read_data.id_maps.uid_entity(uid);
196
197                // Don't hit if there is terrain between the projectile and where the entity was
198                // supposed to be hit by it.
199
200                if physics.on_surface().is_some() {
201                    let projectile_direction = orientations
202                        .get(entity)
203                        .map_or_else(Vec3::zero, |ori| ori.look_vec());
204                    let pos_wall = pos.0 - 0.2 * projectile_direction;
205                    if !matches!(
206                        read_data
207                            .terrain
208                            .ray(pos_wall, pos_hit_other)
209                            .until(|b| b.is_filled())
210                            .cast()
211                            .1,
212                        Ok(None)
213                    ) {
214                        continue;
215                    }
216                }
217
218                // This hit entities list prevents a single projectile from hitting the same
219                // target multiple times
220                projectile.hit_entities.push(other);
221
222                // This hit entities list prevents multiple projectiles fired from the same
223                // attacker hitting the same target multiple times (if the ability calls for
224                // this behavior)
225                if let Some(owner) = projectile
226                    .owner
227                    .and_then(|owner| read_data.id_maps.uid_entity(owner))
228                    && projectile.limit_per_ability
229                    && let Some(hit_entities) = hit_entities.get_mut(owner)
230                {
231                    hit_entities.hit_entities.push((other, *read_data.time))
232                }
233
234                let effects = if projectile.pierce_entities {
235                    Either::Left(projectile.hit_entity.clone().into_iter())
236                } else {
237                    Either::Right(projectile.hit_entity.drain(..))
238                };
239
240                for effect in effects {
241                    let owner = projectile.owner.and_then(entity_of);
242                    let projectile_info = ProjectileInfo {
243                        entity,
244                        effect,
245                        owner_uid: projectile.owner,
246                        owner,
247                        ori: orientations.get(entity),
248                        pos,
249                        vel: velocities.get(entity).copied().unwrap_or(Vel(Vec3::zero())),
250                    };
251
252                    let target = entity_of(other);
253                    let projectile_target_info = ProjectileTargetInfo {
254                        uid: other,
255                        entity: target,
256                        target_group,
257                        ori: target.and_then(|target| orientations.get(target)),
258                    };
259
260                    dispatch_hit(
261                        projectile_info,
262                        projectile_target_info,
263                        &read_data,
264                        &mut projectile_vanished,
265                        &mut outcomes_emitter,
266                        &mut emitters,
267                        &mut rng,
268                    );
269                }
270
271                if projectile_vanished {
272                    continue 'projectile_loop;
273                }
274            }
275
276            if physics.on_surface().is_some() {
277                let init_projectile = projectile.clone();
278                let projectile = &mut *projectile;
279                for effect in projectile.hit_solid.drain(..) {
280                    match effect {
281                        projectile::Effect::Explode(e) => {
282                            // We offset position a little back on the way,
283                            // so if we hit non-exploadable block
284                            // we still can affect blocks around it.
285                            //
286                            // TODO: orientation of fallen projectile is
287                            // fragile heuristic for direction, find more
288                            // robust method.
289                            let projectile_direction = orientations
290                                .get(entity)
291                                .map_or_else(Vec3::zero, |ori| ori.look_vec());
292                            let offset = -0.2 * projectile_direction;
293                            emitters.emit(ExplosionEvent {
294                                pos: pos.0 + offset,
295                                explosion: e,
296                                owner: projectile.owner,
297                            });
298                        },
299                        projectile::Effect::Vanish => {
300                            emitters.emit(DeleteEvent(entity));
301                            projectile_vanished = true;
302                        },
303                        projectile::Effect::Bonk => {
304                            emitters.emit(BonkEvent {
305                                pos: pos.0,
306                                owner: projectile.owner,
307                                target: None,
308                            });
309                        },
310                        projectile::Effect::SurpriseEgg => {
311                            outcomes_emitter.emit(Outcome::SurpriseEgg { pos: pos.0 });
312                        },
313                        projectile::Effect::TrainingDummy => {
314                            let body = Body::Object(object::Body::TrainingDummy);
315                            emitters.emit(CreateNpcEvent {
316                                pos: *pos,
317                                ori: Ori::default(),
318                                npc: NpcBuilder::new(
319                                    Stats::new(
320                                        Content::with_attr("name-custom-village-dummy", "neut"),
321                                        body,
322                                    ),
323                                    body,
324                                    Alignment::Npc,
325                                )
326                                .with_health(Health::new(body))
327                                .with_poise(Poise::new(body)),
328                            });
329                        },
330                        projectile::Effect::Split(split) => {
331                            let init_dir = physics.on_surface().map(|d| -d).unwrap_or_default();
332
333                            let new_pos = Pos(pos.0 - physics.on_surface().unwrap_or_default());
334
335                            let speed = 10.0;
336
337                            handle_split_effect(
338                                &split,
339                                &init_projectile,
340                                init_dir,
341                                new_pos,
342                                &mut rng,
343                                projectile_owner,
344                                body,
345                                speed,
346                                &mut emitters,
347                            );
348
349                            // If the projectile splits, the original projectile should vanish
350                            projectile_vanished = true;
351                        },
352                        _ => {},
353                    }
354                }
355
356                if projectile_vanished {
357                    continue 'projectile_loop;
358                }
359            } else {
360                if let Some(ori) = orientations.get_mut(entity)
361                    && let Some(dir) = velocities
362                        .get(entity)
363                        .and_then(|v| Dir::from_unnormalized(v.0))
364                {
365                    *ori = dir.into();
366                }
367
368                if let Some(vel) = velocities.get_mut(entity)
369                    && let Some((tgt_uid, rate)) = projectile.homing
370                    && let Some(tgt_pos) = read_data
371                        .id_maps
372                        .uid_entity(tgt_uid)
373                        .and_then(|e| read_data.positions.get(e))
374                    && let Some((init_dir, tgt_dir)) = Dir::from_unnormalized(vel.0).zip(
375                        Dir::from_unnormalized(tgt_pos.0.with_z(tgt_pos.0.z + 1.0) - pos.0),
376                    )
377                {
378                    // We want the homing to be weaker when projectile first fired
379                    let time_factor = (projectile.init_time.0 as f32
380                        - projectile.time_left.as_secs_f32())
381                    .min(1.0);
382                    let factor = (rate * read_data.dt.0 / init_dir.angle_between(*tgt_dir)
383                        * time_factor)
384                        .min(1.0);
385                    let new_dir = init_dir.slerped_to(tgt_dir, factor);
386                    *vel = Vel(*new_dir * vel.0.magnitude());
387                }
388            }
389
390            if projectile.time_left == Duration::ZERO {
391                emitters.emit(DeleteEvent(entity));
392
393                // Check this to avoid needless cloning as we need to clone projectile in some
394                // timeout events
395                if !projectile.timeout.is_empty() {
396                    let init_projectile = projectile.clone();
397                    for effect in projectile.timeout.drain(..) {
398                        match effect {
399                            projectile::Effect::Firework(reagent) => {
400                                const ENABLE_RECURSIVE_FIREWORKS: bool = true;
401                                if ENABLE_RECURSIVE_FIREWORKS {
402                                    use common::{comp::LightEmitter, event::ShootEvent};
403                                    use std::f32::consts::PI;
404                                    // Note that if the expected fireworks per firework is > 1, this
405                                    // will eventually cause
406                                    // enough server lag that more players can't log in.
407                                    let thresholds: &[(f32, usize)] = &[(0.25, 2), (0.7, 1)];
408                                    let expected = {
409                                        let mut total = 0.0;
410                                        let mut cumulative_probability = 0.0;
411                                        for (p, n) in thresholds {
412                                            total += (p - cumulative_probability) * *n as f32;
413                                            cumulative_probability += p;
414                                        }
415                                        total
416                                    };
417                                    assert!(expected < 1.0);
418                                    let num_fireworks = (|| {
419                                        let x = rng.random_range(0.0..1.0);
420                                        for (p, n) in thresholds {
421                                            if x < *p {
422                                                return *n;
423                                            }
424                                        }
425                                        0
426                                    })();
427                                    for _ in 0..num_fireworks {
428                                        let speed: f32 = rng.random_range(40.0..80.0);
429                                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
430                                        let phi: f32 = rng.random_range(0.25 * PI..0.5 * PI);
431                                        let dir = Dir::from_unnormalized(Vec3::new(
432                                            theta.cos(),
433                                            theta.sin(),
434                                            phi.sin(),
435                                        ))
436                                        .expect("nonzero vector should normalize");
437                                        emitters.emit(ShootEvent {
438                                            entity: Some(entity),
439                                            source_vel: velocities.get(entity).copied(),
440                                            pos: *pos,
441                                            dir,
442                                            body: *body,
443                                            light: Some(LightEmitter {
444                                                animated: true,
445                                                flicker: 2.0,
446                                                strength: 2.0,
447                                                col: Rgb::new(1.0, 1.0, 0.0),
448                                                dir: None,
449                                            }),
450                                            projectile: Projectile {
451                                                hit_solid: Vec::new(),
452                                                hit_entity: Vec::new(),
453                                                timeout: vec![projectile::Effect::Firework(
454                                                    reagent,
455                                                )],
456                                                time_left: Duration::from_secs(1),
457                                                init_time: Secs(1.0),
458                                                ignore_group: true,
459                                                is_sticky: true,
460                                                is_point: true,
461                                                owner: projectile.owner,
462                                                homing: None,
463                                                pierce_entities: false,
464                                                hit_entities: Vec::new(),
465                                                limit_per_ability: false,
466                                                override_collider: None,
467                                            },
468                                            speed,
469                                            object: None,
470                                            marker: None,
471                                        });
472                                    }
473                                }
474                                emitters.emit(ExplosionEvent {
475                                    pos: pos.0,
476                                    explosion: Explosion {
477                                        effects: vec![
478                                            RadiusEffect::Entity(effect::Effect::Damage(Damage {
479                                                kind: DamageKind::Energy,
480                                                value: 5.0,
481                                            })),
482                                            RadiusEffect::Entity(effect::Effect::Poise(-40.0)),
483                                            RadiusEffect::TerrainDestruction(4.0, Rgb::black()),
484                                        ],
485                                        radius: 12.0,
486                                        reagent: Some(reagent),
487                                        min_falloff: 0.0,
488                                    },
489                                    owner: projectile.owner,
490                                });
491                            },
492                            projectile::Effect::Split(split) if physics.on_surface().is_none() => {
493                                let init_dir = velocities
494                                    .get(entity)
495                                    .and_then(|v| Dir::from_unnormalized(v.0))
496                                    .unwrap_or_default();
497
498                                let speed = velocities.get(entity).map_or(0.0, |v| v.0.magnitude());
499
500                                handle_split_effect(
501                                    &split,
502                                    &init_projectile,
503                                    *init_dir,
504                                    *pos,
505                                    &mut rng,
506                                    projectile_owner,
507                                    body,
508                                    speed,
509                                    &mut emitters,
510                                );
511                            },
512                            _ => {},
513                        }
514                    }
515                }
516            }
517            projectile.time_left = projectile
518                .time_left
519                .checked_sub(Duration::from_secs_f32(read_data.dt.0))
520                .unwrap_or_default();
521        }
522
523        // Hit entities lists
524        for hit_entities_list in (&mut hit_entities).join() {
525            hit_entities_list
526                .hit_entities
527                .retain(|(_, time)| read_data.time.0 < time.0 + 1.0)
528        }
529    }
530}
531
532struct ProjectileInfo<'a> {
533    entity: EcsEntity,
534    effect: projectile::Effect,
535    owner_uid: Option<Uid>,
536    owner: Option<EcsEntity>,
537    ori: Option<&'a Ori>,
538    pos: &'a Pos,
539    vel: Vel,
540}
541
542struct ProjectileTargetInfo<'a> {
543    uid: Uid,
544    entity: Option<EcsEntity>,
545    target_group: GroupTarget,
546    ori: Option<&'a Ori>,
547}
548
549fn dispatch_hit(
550    projectile_info: ProjectileInfo,
551    projectile_target_info: ProjectileTargetInfo,
552    read_data: &ReadData,
553    projectile_vanished: &mut bool,
554    outcomes_emitter: &mut Emitter<Outcome>,
555    emitters: &mut Emitters,
556    rng: &mut rand::rngs::ThreadRng,
557) {
558    match projectile_info.effect {
559        projectile::Effect::Attack(attack) => {
560            let target_uid = projectile_target_info.uid;
561            let target = if let Some(entity) = projectile_target_info.entity {
562                entity
563            } else {
564                return;
565            };
566
567            let (target_pos, projectile_dir) = {
568                let target_pos = read_data.positions.get(target);
569                let projectile_ori = projectile_info.ori;
570                match target_pos.zip(projectile_ori) {
571                    Some((tgt_pos, proj_ori)) => {
572                        let Pos(tgt_pos) = tgt_pos;
573                        (*tgt_pos, proj_ori.look_dir())
574                    },
575                    None => return,
576                }
577            };
578
579            let owner = projectile_info.owner;
580            let projectile_entity = projectile_info.entity;
581
582            let attacker_info =
583                owner
584                    .zip(projectile_info.owner_uid)
585                    .map(|(entity, uid)| AttackerInfo {
586                        entity,
587                        uid,
588                        group: read_data.groups.get(entity),
589                        energy: read_data.energies.get(entity),
590                        combo: read_data.combos.get(entity),
591                        inventory: read_data.inventories.get(entity),
592                        stats: read_data.stats.get(entity),
593                        mass: read_data.masses.get(entity),
594                        pos: read_data.positions.get(entity).map(|p| p.0),
595                    });
596
597            let target_info = TargetInfo {
598                entity: target,
599                uid: target_uid,
600                inventory: read_data.inventories.get(target),
601                stats: read_data.stats.get(target),
602                health: read_data.healths.get(target),
603                pos: target_pos,
604                ori: projectile_target_info.ori,
605                char_state: read_data.character_states.get(target),
606                energy: read_data.energies.get(target),
607                buffs: read_data.buffs.get(target),
608                mass: read_data.masses.get(target),
609                player: read_data.players.get(target),
610            };
611
612            // TODO: Is it possible to have projectile without body??
613            if let Some(&body) = read_data.bodies.get(projectile_entity) {
614                outcomes_emitter.emit(Outcome::ProjectileHit {
615                    pos: target_pos,
616                    body,
617                    vel: projectile_info.vel.0,
618                    source: projectile_info.owner_uid,
619                    target: read_data.uids.get(target).copied(),
620                });
621            }
622
623            let allow_friendly_fire = owner.is_some_and(|owner| {
624                combat::allow_friendly_fire(&read_data.entered_auras, owner, target)
625            });
626
627            // PvP check
628            let permit_pvp = combat::permit_pvp(
629                &read_data.alignments,
630                &read_data.players,
631                &read_data.entered_auras,
632                &read_data.id_maps,
633                owner,
634                target,
635            );
636
637            let target_dodging = read_data
638                .character_states
639                .get(target)
640                .and_then(|cs| cs.roll_attack_immunities())
641                .is_some_and(|i| i.projectiles);
642
643            let precision_from_flank = combat::precision_mult_from_flank(
644                *projectile_dir,
645                target_info.ori,
646                Default::default(),
647                false,
648            );
649
650            let precision_from_head = {
651                // This performs a cylinder and line segment intersection check. The cylinder is
652                // the upper 10% of an entity's dimensions. The line segment is from the
653                // projectile's positions on the current and previous tick.
654                let curr_pos = projectile_info.pos.0;
655                let last_pos = projectile_info.pos.0 - projectile_info.vel.0 * read_data.dt.0;
656                let vel = projectile_info.vel;
657                let (target_height, target_radius) = read_data
658                    .bodies
659                    .get(target)
660                    .map_or((0.0, 0.0), |b| (b.height(), b.max_radius()));
661                let head_top_pos = target_pos.with_z(target_pos.z + target_height);
662                let head_bottom_pos = head_top_pos.with_z(
663                    head_top_pos.z - target_height * combat::PROJECTILE_HEADSHOT_PROPORTION,
664                );
665                if (curr_pos.z < head_bottom_pos.z && last_pos.z < head_bottom_pos.z)
666                    || (curr_pos.z > head_top_pos.z && last_pos.z > head_top_pos.z)
667                {
668                    None
669                } else if curr_pos.z > head_top_pos.z
670                    || curr_pos.z < head_bottom_pos.z
671                    || last_pos.z > head_top_pos.z
672                    || last_pos.z < head_bottom_pos.z
673                {
674                    let proj_top_intersection = {
675                        let t = (head_top_pos.z - last_pos.z) / vel.0.z;
676                        last_pos + vel.0 * t
677                    };
678                    let proj_bottom_intersection = {
679                        let t = (head_bottom_pos.z - last_pos.z) / vel.0.z;
680                        last_pos + vel.0 * t
681                    };
682                    let intersected_bottom = head_bottom_pos
683                        .distance_squared(proj_bottom_intersection)
684                        < target_radius.powi(2);
685                    let intersected_top = head_top_pos.distance_squared(proj_top_intersection)
686                        < target_radius.powi(2);
687                    let hit_head = intersected_bottom || intersected_top;
688                    let hit_from_bottom = last_pos.z < head_bottom_pos.z && intersected_bottom;
689                    let hit_from_top = last_pos.z > head_top_pos.z && intersected_top;
690                    // If projectile from bottom, do not award precision damage because it trivial
691                    // to get from up close If projectile from top, reduce
692                    // precision damage to mitigate cheesing benefits
693                    if !hit_head || hit_from_bottom {
694                        None
695                    } else if hit_from_top {
696                        Some(combat::MAX_TOP_HEADSHOT_PRECISION)
697                    } else {
698                        Some(combat::MAX_HEADSHOT_PRECISION)
699                    }
700                } else {
701                    let trajectory = LineSegment3 {
702                        start: last_pos,
703                        end: curr_pos,
704                    };
705                    let head_middle_pos = head_bottom_pos.with_z(
706                        head_bottom_pos.z
707                            + target_height * combat::PROJECTILE_HEADSHOT_PROPORTION * 0.5,
708                    );
709                    if trajectory.distance_to_point(head_middle_pos) < target_radius {
710                        Some(combat::MAX_HEADSHOT_PRECISION)
711                    } else {
712                        None
713                    }
714                }
715            };
716
717            let precision_mult = match (precision_from_flank, precision_from_head) {
718                (Some(a), Some(b)) => Some(a.max(b)),
719                (Some(a), None) | (None, Some(a)) => Some(a),
720                (None, None) => None,
721            };
722
723            let attack_options = AttackOptions {
724                target_dodging,
725                permit_pvp,
726                allow_friendly_fire,
727                target_group: projectile_target_info.target_group,
728                precision_mult,
729            };
730
731            attack.apply_attack(
732                attacker_info,
733                &target_info,
734                projectile_dir,
735                attack_options,
736                1.0,
737                AttackSource::Projectile,
738                *read_data.time,
739                emitters,
740                |o| outcomes_emitter.emit(o),
741                rng,
742                0,
743            );
744        },
745        projectile::Effect::Explode(e) => {
746            let Pos(pos) = *projectile_info.pos;
747            let owner_uid = projectile_info.owner_uid;
748            emitters.emit(ExplosionEvent {
749                pos,
750                explosion: e,
751                owner: owner_uid,
752            });
753        },
754        projectile::Effect::Arc(a) => {
755            emitters.emit(ArcingEvent {
756                arc: a,
757                owner: projectile_info.owner_uid,
758                target: projectile_target_info.uid,
759                pos: *projectile_info.pos,
760            });
761        },
762        projectile::Effect::Bonk => {
763            let Pos(pos) = *projectile_info.pos;
764            let owner_uid = projectile_info.owner_uid;
765            emitters.emit(BonkEvent {
766                pos,
767                owner: owner_uid,
768                target: Some(projectile_target_info.uid),
769            });
770        },
771        projectile::Effect::Vanish => {
772            let entity = projectile_info.entity;
773            emitters.emit(DeleteEvent(entity));
774            *projectile_vanished = true;
775        },
776        projectile::Effect::Possess => {
777            let target_uid = projectile_target_info.uid;
778            let owner_uid = projectile_info.owner_uid;
779            if let Some(owner_uid) = owner_uid
780                && target_uid != owner_uid
781            {
782                emitters.emit(PossessEvent(owner_uid, target_uid));
783            }
784        },
785        projectile::Effect::Stick => {},
786        projectile::Effect::Firework(_) => {},
787        projectile::Effect::SurpriseEgg => {
788            let Pos(pos) = *projectile_info.pos;
789            outcomes_emitter.emit(Outcome::SurpriseEgg { pos });
790        },
791        projectile::Effect::TrainingDummy => emitters.emit(CreateNpcEvent {
792            pos: *projectile_info.pos,
793            ori: Ori::default(),
794            npc: NpcBuilder::new(
795                Stats::new(
796                    Content::with_attr("name-custom-village-dummy", "neut"),
797                    Body::Object(object::Body::TrainingDummy),
798                ),
799                Body::Object(object::Body::TrainingDummy),
800                Alignment::Npc,
801            ),
802        }),
803        projectile::Effect::Split(_) => {},
804    }
805}
806
807fn handle_split_effect(
808    split: &SplitOptions,
809    init_projectile: &Projectile,
810    init_dir: Vec3<f32>,
811    pos: Pos,
812    rng: &mut impl Rng,
813    projectile_owner: Option<EcsEntity>,
814    body: &Body,
815    speed: f32,
816    emitters: &mut impl EmitExt<ShootEvent>,
817) {
818    let split_projectile = || {
819        let mut projectile = init_projectile.clone();
820        // Remove split from effects here to avoid projectile infinitely
821        // splitting
822        projectile
823            .timeout
824            .retain(|e| !matches!(e, projectile::Effect::Split(_)));
825        projectile
826            .hit_solid
827            .retain(|e| !matches!(e, projectile::Effect::Split(_)));
828        projectile.time_left = Duration::from_secs_f64(split.new_lifetime.0);
829        projectile.init_time = split.new_lifetime;
830        projectile.override_collider = split.override_collider;
831        projectile
832    };
833
834    /// Do not allow too many projectiles to be generated. Otherwise, some
835    /// weapons (such as the admin bow) can produce tens of thousands of
836    /// projectiles and halt the server/client. The limit may need to be tweaked
837    /// in the future and was chosen purely for keeping game performance in
838    /// check. The number itself is somewhat arbitrary and no game
839    /// design/balancing decisions should be oriented around it, so please feel
840    /// free to increase or decrease as needed.
841    const MAX_PROJECTILES: u32 = 100;
842    for _ in 0..u32::min(split.amount, MAX_PROJECTILES) {
843        let dir = {
844            let theta = rng.random_range(0.0..TAU);
845            let phi = rng.random_range(0.0..PI);
846            let offset = {
847                let x = theta.sin() * phi.sin();
848                let y = theta.cos() * phi.sin();
849                let z = phi.cos();
850                Vec3::new(x, y, z) * split.spread
851            };
852            Dir::from_unnormalized(init_dir + offset).unwrap_or_default()
853        };
854
855        emitters.emit(ShootEvent {
856            entity: projectile_owner,
857            source_vel: None,
858            pos,
859            dir,
860            body: *body,
861            light: None,
862            projectile: split_projectile(),
863            speed,
864            object: None,
865            marker: None, /* TODO: Do we check original for
866                           * projectile's marker? */
867        })
868    }
869}