veloren_common_systems/
projectile.rs

1use common::{
2    Damage, DamageKind, DamageSource, 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, projectile,
10    },
11    effect,
12    event::{
13        BonkEvent, BuffEvent, ComboChangeEvent, CreateNpcEvent, DeleteEvent, EmitExt, Emitter,
14        EnergyChangeEvent, EntityAttackedHookEvent, EventBus, ExplosionEvent, HealthChangeEvent,
15        KnockbackEvent, NpcBuilder, ParryHookEvent, PoiseChangeEvent, PossessEvent, ShootEvent,
16        SoundEvent,
17    },
18    event_emitters,
19    outcome::Outcome,
20    resources::{DeltaTime, Time},
21    uid::{IdMaps, Uid},
22    util::Dir,
23};
24
25use common::vol::ReadVol;
26use common_ecs::{Job, Origin, Phase, System};
27use rand::Rng;
28use specs::{
29    Entities, Entity as EcsEntity, Join, Read, ReadExpect, ReadStorage, SystemData, WriteStorage,
30    shred,
31};
32use std::time::Duration;
33use vek::*;
34
35use common::terrain::TerrainGrid;
36
37event_emitters! {
38    struct Events[Emitters] {
39        sound: SoundEvent,
40        delete: DeleteEvent,
41        explosion: ExplosionEvent,
42        health_change: HealthChangeEvent,
43        energy_change: EnergyChangeEvent,
44        poise_change: PoiseChangeEvent,
45        parry_hook: ParryHookEvent,
46        knockback: KnockbackEvent,
47        entity_attack_hook: EntityAttackedHookEvent,
48        shoot: ShootEvent,
49        create_npc: CreateNpcEvent,
50        combo_change: ComboChangeEvent,
51        buff: BuffEvent,
52        bonk: BonkEvent,
53        possess: PossessEvent,
54    }
55}
56
57#[derive(SystemData)]
58pub struct ReadData<'a> {
59    time: Read<'a, Time>,
60    entities: Entities<'a>,
61    players: ReadStorage<'a, Player>,
62    dt: Read<'a, DeltaTime>,
63    id_maps: Read<'a, IdMaps>,
64    events: Events<'a>,
65    uids: ReadStorage<'a, Uid>,
66    positions: ReadStorage<'a, Pos>,
67    alignments: ReadStorage<'a, Alignment>,
68    physics_states: ReadStorage<'a, PhysicsState>,
69    velocities: ReadStorage<'a, Vel>,
70    inventories: ReadStorage<'a, Inventory>,
71    groups: ReadStorage<'a, Group>,
72    energies: ReadStorage<'a, Energy>,
73    stats: ReadStorage<'a, Stats>,
74    combos: ReadStorage<'a, Combo>,
75    healths: ReadStorage<'a, Health>,
76    bodies: ReadStorage<'a, Body>,
77    character_states: ReadStorage<'a, CharacterState>,
78    terrain: ReadExpect<'a, TerrainGrid>,
79    buffs: ReadStorage<'a, Buffs>,
80    entered_auras: ReadStorage<'a, EnteredAuras>,
81    masses: ReadStorage<'a, Mass>,
82}
83
84/// This system is responsible for handling projectile effect triggers
85#[derive(Default)]
86pub struct Sys;
87impl<'a> System<'a> for Sys {
88    type SystemData = (
89        ReadData<'a>,
90        WriteStorage<'a, Ori>,
91        WriteStorage<'a, Projectile>,
92        Read<'a, EventBus<Outcome>>,
93    );
94
95    const NAME: &'static str = "projectile";
96    const ORIGIN: Origin = Origin::Common;
97    const PHASE: Phase = Phase::Create;
98
99    fn run(
100        _job: &mut Job<Self>,
101        (read_data, mut orientations, mut projectiles, outcomes): Self::SystemData,
102    ) {
103        let mut emitters = read_data.events.get_emitters();
104        let mut outcomes_emitter = outcomes.emitter();
105        let mut rng = rand::rng();
106
107        // Attacks
108        'projectile_loop: for (entity, pos, physics, vel, body, projectile) in (
109            &read_data.entities,
110            &read_data.positions,
111            &read_data.physics_states,
112            &read_data.velocities,
113            &read_data.bodies,
114            &mut projectiles,
115        )
116            .join()
117        {
118            let projectile_owner = projectile
119                .owner
120                .and_then(|uid| read_data.id_maps.uid_entity(uid));
121
122            if physics.on_surface().is_none() && rng.random_bool(0.05) {
123                emitters.emit(SoundEvent {
124                    sound: Sound::new(SoundKind::Projectile, pos.0, 4.0, read_data.time.0),
125                });
126            }
127
128            let mut projectile_vanished: bool = false;
129
130            // Hit entity
131            for (&other, &pos_hit_other) in physics.touch_entities.iter() {
132                let same_group = projectile_owner
133                    // Note: somewhat inefficient since we do the lookup for every touching
134                    // entity, but if we pull this out of the loop we would want to do it only
135                    // if there is at least one touching entity
136                    .and_then(|e| read_data.groups.get(e)).is_some_and(|owner_group|
137                        Some(owner_group) == read_data.id_maps
138                        .uid_entity(other)
139                        .and_then(|e| read_data.groups.get(e)));
140
141                // Skip if in the same group
142                let target_group = if same_group {
143                    GroupTarget::InGroup
144                } else {
145                    GroupTarget::OutOfGroup
146                };
147
148                if projectile.ignore_group
149                    && same_group
150                    && projectile
151                        .owner
152                        .and_then(|owner| {
153                            read_data
154                                .id_maps
155                                .uid_entity(owner)
156                                .zip(read_data.id_maps.uid_entity(other))
157                        })
158                        .is_none_or(|(owner, other)| {
159                            !combat::allow_friendly_fire(&read_data.entered_auras, owner, other)
160                        })
161                {
162                    continue;
163                }
164
165                if projectile.owner == Some(other) {
166                    continue;
167                }
168
169                let projectile = &mut *projectile;
170
171                let entity_of = |uid: Uid| read_data.id_maps.uid_entity(uid);
172
173                // Don't hit if there is terrain between the projectile and where the entity was
174                // supposed to be hit by it.
175
176                if physics.on_surface().is_some() {
177                    let projectile_direction = orientations
178                        .get(entity)
179                        .map_or_else(Vec3::zero, |ori| ori.look_vec());
180                    let pos_wall = pos.0 - 0.2 * projectile_direction;
181                    if !matches!(
182                        read_data
183                            .terrain
184                            .ray(pos_wall, pos_hit_other)
185                            .until(|b| b.is_filled())
186                            .cast()
187                            .1,
188                        Ok(None)
189                    ) {
190                        continue;
191                    }
192                }
193
194                for effect in projectile.hit_entity.drain(..) {
195                    let owner = projectile.owner.and_then(entity_of);
196                    let projectile_info = ProjectileInfo {
197                        entity,
198                        effect,
199                        owner_uid: projectile.owner,
200                        owner,
201                        ori: orientations.get(entity),
202                        pos,
203                        vel,
204                    };
205
206                    let target = entity_of(other);
207                    let projectile_target_info = ProjectileTargetInfo {
208                        uid: other,
209                        entity: target,
210                        target_group,
211                        ori: target.and_then(|target| orientations.get(target)),
212                    };
213
214                    dispatch_hit(
215                        projectile_info,
216                        projectile_target_info,
217                        &read_data,
218                        &mut projectile_vanished,
219                        &mut outcomes_emitter,
220                        &mut emitters,
221                        &mut rng,
222                    );
223                }
224
225                if projectile_vanished {
226                    continue 'projectile_loop;
227                }
228            }
229
230            if physics.on_surface().is_some() {
231                let projectile = &mut *projectile;
232                for effect in projectile.hit_solid.drain(..) {
233                    match effect {
234                        projectile::Effect::Explode(e) => {
235                            // We offset position a little back on the way,
236                            // so if we hit non-exploadable block
237                            // we still can affect blocks around it.
238                            //
239                            // TODO: orientation of fallen projectile is
240                            // fragile heuristic for direction, find more
241                            // robust method.
242                            let projectile_direction = orientations
243                                .get(entity)
244                                .map_or_else(Vec3::zero, |ori| ori.look_vec());
245                            let offset = -0.2 * projectile_direction;
246                            emitters.emit(ExplosionEvent {
247                                pos: pos.0 + offset,
248                                explosion: e,
249                                owner: projectile.owner,
250                            });
251                        },
252                        projectile::Effect::Vanish => {
253                            emitters.emit(DeleteEvent(entity));
254                            projectile_vanished = true;
255                        },
256                        projectile::Effect::Bonk => {
257                            emitters.emit(BonkEvent {
258                                pos: pos.0,
259                                owner: projectile.owner,
260                                target: None,
261                            });
262                        },
263                        projectile::Effect::SurpriseEgg => {
264                            outcomes_emitter.emit(Outcome::SurpriseEgg { pos: pos.0 });
265                        },
266                        projectile::Effect::TrainingDummy => {
267                            let body = Body::Object(object::Body::TrainingDummy);
268                            emitters.emit(CreateNpcEvent {
269                                pos: *pos,
270                                ori: Ori::default(),
271                                npc: NpcBuilder::new(
272                                    Stats::new(
273                                        Content::with_attr("name-custom-village-dummy", "neut"),
274                                        body,
275                                    ),
276                                    body,
277                                    Alignment::Npc,
278                                )
279                                .with_health(Health::new(body))
280                                .with_poise(Poise::new(body)),
281                            });
282                        },
283                        _ => {},
284                    }
285                }
286
287                if projectile_vanished {
288                    continue 'projectile_loop;
289                }
290            } else if let Some(ori) = orientations.get_mut(entity)
291                && let Some(dir) = Dir::from_unnormalized(vel.0)
292            {
293                *ori = dir.into();
294            }
295
296            if projectile.time_left == Duration::ZERO {
297                emitters.emit(DeleteEvent(entity));
298
299                for effect in projectile.timeout.drain(..) {
300                    if let projectile::Effect::Firework(reagent) = effect {
301                        const ENABLE_RECURSIVE_FIREWORKS: bool = true;
302                        if ENABLE_RECURSIVE_FIREWORKS {
303                            use common::{comp::LightEmitter, event::ShootEvent};
304                            use std::f32::consts::PI;
305                            // Note that if the expected fireworks per firework is > 1, this
306                            // will eventually cause
307                            // enough server lag that more players can't log in.
308                            let thresholds: &[(f32, usize)] = &[(0.25, 2), (0.7, 1)];
309                            let expected = {
310                                let mut total = 0.0;
311                                let mut cumulative_probability = 0.0;
312                                for (p, n) in thresholds {
313                                    total += (p - cumulative_probability) * *n as f32;
314                                    cumulative_probability += p;
315                                }
316                                total
317                            };
318                            assert!(expected < 1.0);
319                            let num_fireworks = (|| {
320                                let x = rng.random_range(0.0..1.0);
321                                for (p, n) in thresholds {
322                                    if x < *p {
323                                        return *n;
324                                    }
325                                }
326                                0
327                            })();
328                            for _ in 0..num_fireworks {
329                                let speed: f32 = rng.random_range(40.0..80.0);
330                                let theta: f32 = rng.random_range(0.0..2.0 * PI);
331                                let phi: f32 = rng.random_range(0.25 * PI..0.5 * PI);
332                                let dir = Dir::from_unnormalized(Vec3::new(
333                                    theta.cos(),
334                                    theta.sin(),
335                                    phi.sin(),
336                                ))
337                                .expect("nonzero vector should normalize");
338                                emitters.emit(ShootEvent {
339                                    entity: Some(entity),
340                                    pos: *pos,
341                                    dir,
342                                    body: *body,
343                                    light: Some(LightEmitter {
344                                        animated: true,
345                                        flicker: 2.0,
346                                        strength: 2.0,
347                                        col: Rgb::new(1.0, 1.0, 0.0),
348                                        dir: None,
349                                    }),
350                                    projectile: Projectile {
351                                        hit_solid: Vec::new(),
352                                        hit_entity: Vec::new(),
353                                        timeout: vec![projectile::Effect::Firework(reagent)],
354                                        time_left: Duration::from_secs(1),
355                                        ignore_group: true,
356                                        is_sticky: true,
357                                        is_point: true,
358                                        owner: projectile.owner,
359                                    },
360                                    speed,
361                                    object: None,
362                                });
363                            }
364                        }
365                        emitters.emit(DeleteEvent(entity));
366                        emitters.emit(ExplosionEvent {
367                            pos: pos.0,
368                            explosion: Explosion {
369                                effects: vec![
370                                    RadiusEffect::Entity(effect::Effect::Damage(Damage {
371                                        source: DamageSource::Explosion,
372                                        kind: DamageKind::Energy,
373                                        value: 5.0,
374                                    })),
375                                    RadiusEffect::Entity(effect::Effect::Poise(-40.0)),
376                                    RadiusEffect::TerrainDestruction(4.0, Rgb::black()),
377                                ],
378                                radius: 12.0,
379                                reagent: Some(reagent),
380                                min_falloff: 0.0,
381                            },
382                            owner: projectile.owner,
383                        });
384                    }
385                }
386            }
387            projectile.time_left = projectile
388                .time_left
389                .checked_sub(Duration::from_secs_f32(read_data.dt.0))
390                .unwrap_or_default();
391        }
392    }
393}
394
395struct ProjectileInfo<'a> {
396    entity: EcsEntity,
397    effect: projectile::Effect,
398    owner_uid: Option<Uid>,
399    owner: Option<EcsEntity>,
400    ori: Option<&'a Ori>,
401    pos: &'a Pos,
402    vel: &'a Vel,
403}
404
405struct ProjectileTargetInfo<'a> {
406    uid: Uid,
407    entity: Option<EcsEntity>,
408    target_group: GroupTarget,
409    ori: Option<&'a Ori>,
410}
411
412fn dispatch_hit(
413    projectile_info: ProjectileInfo,
414    projectile_target_info: ProjectileTargetInfo,
415    read_data: &ReadData,
416    projectile_vanished: &mut bool,
417    outcomes_emitter: &mut Emitter<Outcome>,
418    emitters: &mut Emitters,
419    rng: &mut rand::rngs::ThreadRng,
420) {
421    match projectile_info.effect {
422        projectile::Effect::Attack(attack) => {
423            let target_uid = projectile_target_info.uid;
424            let target = if let Some(entity) = projectile_target_info.entity {
425                entity
426            } else {
427                return;
428            };
429
430            let (target_pos, projectile_dir) = {
431                let target_pos = read_data.positions.get(target);
432                let projectile_ori = projectile_info.ori;
433                match target_pos.zip(projectile_ori) {
434                    Some((tgt_pos, proj_ori)) => {
435                        let Pos(tgt_pos) = tgt_pos;
436                        (*tgt_pos, proj_ori.look_dir())
437                    },
438                    None => return,
439                }
440            };
441
442            let owner = projectile_info.owner;
443            let projectile_entity = projectile_info.entity;
444
445            let attacker_info =
446                owner
447                    .zip(projectile_info.owner_uid)
448                    .map(|(entity, uid)| AttackerInfo {
449                        entity,
450                        uid,
451                        group: read_data.groups.get(entity),
452                        energy: read_data.energies.get(entity),
453                        combo: read_data.combos.get(entity),
454                        inventory: read_data.inventories.get(entity),
455                        stats: read_data.stats.get(entity),
456                        mass: read_data.masses.get(entity),
457                    });
458
459            let target_info = TargetInfo {
460                entity: target,
461                uid: target_uid,
462                inventory: read_data.inventories.get(target),
463                stats: read_data.stats.get(target),
464                health: read_data.healths.get(target),
465                pos: target_pos,
466                ori: projectile_target_info.ori,
467                char_state: read_data.character_states.get(target),
468                energy: read_data.energies.get(target),
469                buffs: read_data.buffs.get(target),
470                mass: read_data.masses.get(target),
471            };
472
473            // TODO: Is it possible to have projectile without body??
474            if let Some(&body) = read_data.bodies.get(projectile_entity) {
475                outcomes_emitter.emit(Outcome::ProjectileHit {
476                    pos: target_pos,
477                    body,
478                    vel: read_data
479                        .velocities
480                        .get(projectile_entity)
481                        .map_or(Vec3::zero(), |v| v.0),
482                    source: projectile_info.owner_uid,
483                    target: read_data.uids.get(target).copied(),
484                });
485            }
486
487            let allow_friendly_fire = owner.is_some_and(|owner| {
488                combat::allow_friendly_fire(&read_data.entered_auras, owner, target)
489            });
490
491            // PvP check
492            let permit_pvp = combat::permit_pvp(
493                &read_data.alignments,
494                &read_data.players,
495                &read_data.entered_auras,
496                &read_data.id_maps,
497                owner,
498                target,
499            );
500
501            let target_dodging = read_data
502                .character_states
503                .get(target)
504                .and_then(|cs| cs.roll_attack_immunities())
505                .is_some_and(|i| i.projectiles);
506
507            let precision_from_flank = combat::precision_mult_from_flank(
508                *projectile_dir,
509                target_info.ori,
510                Default::default(),
511                false,
512            );
513
514            let precision_from_head = {
515                // This performs a cylinder and line segment intersection check. The cylinder is
516                // the upper 10% of an entity's dimensions. The line segment is from the
517                // projectile's positions on the current and previous tick.
518                let curr_pos = projectile_info.pos.0;
519                let last_pos = projectile_info.pos.0 - projectile_info.vel.0 * read_data.dt.0;
520                let vel = projectile_info.vel.0;
521                let (target_height, target_radius) = read_data
522                    .bodies
523                    .get(target)
524                    .map_or((0.0, 0.0), |b| (b.height(), b.max_radius()));
525                let head_top_pos = target_pos.with_z(target_pos.z + target_height);
526                let head_bottom_pos = head_top_pos.with_z(
527                    head_top_pos.z - target_height * combat::PROJECTILE_HEADSHOT_PROPORTION,
528                );
529                if (curr_pos.z < head_bottom_pos.z && last_pos.z < head_bottom_pos.z)
530                    || (curr_pos.z > head_top_pos.z && last_pos.z > head_top_pos.z)
531                {
532                    None
533                } else if curr_pos.z > head_top_pos.z
534                    || curr_pos.z < head_bottom_pos.z
535                    || last_pos.z > head_top_pos.z
536                    || last_pos.z < head_bottom_pos.z
537                {
538                    let proj_top_intersection = {
539                        let t = (head_top_pos.z - last_pos.z) / vel.z;
540                        last_pos + vel * t
541                    };
542                    let proj_bottom_intersection = {
543                        let t = (head_bottom_pos.z - last_pos.z) / vel.z;
544                        last_pos + vel * t
545                    };
546                    let intersected_bottom = head_bottom_pos
547                        .distance_squared(proj_bottom_intersection)
548                        < target_radius.powi(2);
549                    let intersected_top = head_top_pos.distance_squared(proj_top_intersection)
550                        < target_radius.powi(2);
551                    let hit_head = intersected_bottom || intersected_top;
552                    let hit_from_bottom = last_pos.z < head_bottom_pos.z && intersected_bottom;
553                    let hit_from_top = last_pos.z > head_top_pos.z && intersected_top;
554                    // If projectile from bottom, do not award precision damage because it trivial
555                    // to get from up close If projectile from top, reduce
556                    // precision damage to mitigate cheesing benefits
557                    if !hit_head || hit_from_bottom {
558                        None
559                    } else if hit_from_top {
560                        Some(combat::MAX_TOP_HEADSHOT_PRECISION)
561                    } else {
562                        Some(combat::MAX_HEADSHOT_PRECISION)
563                    }
564                } else {
565                    let trajectory = LineSegment3 {
566                        start: last_pos,
567                        end: curr_pos,
568                    };
569                    let head_middle_pos = head_bottom_pos.with_z(
570                        head_bottom_pos.z
571                            + target_height * combat::PROJECTILE_HEADSHOT_PROPORTION * 0.5,
572                    );
573                    if trajectory.distance_to_point(head_middle_pos) < target_radius {
574                        Some(combat::MAX_HEADSHOT_PRECISION)
575                    } else {
576                        None
577                    }
578                }
579            };
580
581            let precision_mult = match (precision_from_flank, precision_from_head) {
582                (Some(a), Some(b)) => Some(a.max(b)),
583                (Some(a), None) | (None, Some(a)) => Some(a),
584                (None, None) => None,
585            };
586
587            let attack_options = AttackOptions {
588                target_dodging,
589                permit_pvp,
590                allow_friendly_fire,
591                target_group: projectile_target_info.target_group,
592                precision_mult,
593            };
594
595            attack.apply_attack(
596                attacker_info,
597                &target_info,
598                projectile_dir,
599                attack_options,
600                1.0,
601                AttackSource::Projectile,
602                *read_data.time,
603                emitters,
604                |o| outcomes_emitter.emit(o),
605                rng,
606                0,
607            );
608        },
609        projectile::Effect::Explode(e) => {
610            let Pos(pos) = *projectile_info.pos;
611            let owner_uid = projectile_info.owner_uid;
612            emitters.emit(ExplosionEvent {
613                pos,
614                explosion: e,
615                owner: owner_uid,
616            });
617        },
618        projectile::Effect::Bonk => {
619            let Pos(pos) = *projectile_info.pos;
620            let owner_uid = projectile_info.owner_uid;
621            emitters.emit(BonkEvent {
622                pos,
623                owner: owner_uid,
624                target: Some(projectile_target_info.uid),
625            });
626        },
627        projectile::Effect::Vanish => {
628            let entity = projectile_info.entity;
629            emitters.emit(DeleteEvent(entity));
630            *projectile_vanished = true;
631        },
632        projectile::Effect::Possess => {
633            let target_uid = projectile_target_info.uid;
634            let owner_uid = projectile_info.owner_uid;
635            if let Some(owner_uid) = owner_uid
636                && target_uid != owner_uid
637            {
638                emitters.emit(PossessEvent(owner_uid, target_uid));
639            }
640        },
641        projectile::Effect::Stick => {},
642        projectile::Effect::Firework(_) => {},
643        projectile::Effect::SurpriseEgg => {
644            let Pos(pos) = *projectile_info.pos;
645            outcomes_emitter.emit(Outcome::SurpriseEgg { pos });
646        },
647        projectile::Effect::TrainingDummy => emitters.emit(CreateNpcEvent {
648            pos: *projectile_info.pos,
649            ori: Ori::default(),
650            npc: NpcBuilder::new(
651                Stats::new(
652                    Content::with_attr("name-custom-village-dummy", "neut"),
653                    Body::Object(object::Body::TrainingDummy),
654                ),
655                Body::Object(object::Body::TrainingDummy),
656                Alignment::Npc,
657            ),
658        }),
659    }
660}