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::thread_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.gen_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                if let Some(dir) = Dir::from_unnormalized(vel.0) {
292                    *ori = dir.into();
293                }
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.gen_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.gen_range(40.0..80.0);
330                                let theta: f32 = rng.gen_range(0.0..2.0 * PI);
331                                let phi: f32 = rng.gen_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                                    }),
349                                    projectile: Projectile {
350                                        hit_solid: Vec::new(),
351                                        hit_entity: Vec::new(),
352                                        timeout: vec![projectile::Effect::Firework(reagent)],
353                                        time_left: Duration::from_secs(1),
354                                        ignore_group: true,
355                                        is_sticky: true,
356                                        is_point: true,
357                                        owner: projectile.owner,
358                                    },
359                                    speed,
360                                    object: None,
361                                });
362                            }
363                        }
364                        emitters.emit(DeleteEvent(entity));
365                        emitters.emit(ExplosionEvent {
366                            pos: pos.0,
367                            explosion: Explosion {
368                                effects: vec![
369                                    RadiusEffect::Entity(effect::Effect::Damage(Damage {
370                                        source: DamageSource::Explosion,
371                                        kind: DamageKind::Energy,
372                                        value: 5.0,
373                                    })),
374                                    RadiusEffect::Entity(effect::Effect::Poise(-40.0)),
375                                    RadiusEffect::TerrainDestruction(4.0, Rgb::black()),
376                                ],
377                                radius: 12.0,
378                                reagent: Some(reagent),
379                                min_falloff: 0.0,
380                            },
381                            owner: projectile.owner,
382                        });
383                    }
384                }
385            }
386            projectile.time_left = projectile
387                .time_left
388                .checked_sub(Duration::from_secs_f32(read_data.dt.0))
389                .unwrap_or_default();
390        }
391    }
392}
393
394struct ProjectileInfo<'a> {
395    entity: EcsEntity,
396    effect: projectile::Effect,
397    owner_uid: Option<Uid>,
398    owner: Option<EcsEntity>,
399    ori: Option<&'a Ori>,
400    pos: &'a Pos,
401    vel: &'a Vel,
402}
403
404struct ProjectileTargetInfo<'a> {
405    uid: Uid,
406    entity: Option<EcsEntity>,
407    target_group: GroupTarget,
408    ori: Option<&'a Ori>,
409}
410
411fn dispatch_hit(
412    projectile_info: ProjectileInfo,
413    projectile_target_info: ProjectileTargetInfo,
414    read_data: &ReadData,
415    projectile_vanished: &mut bool,
416    outcomes_emitter: &mut Emitter<Outcome>,
417    emitters: &mut Emitters,
418    rng: &mut rand::rngs::ThreadRng,
419) {
420    match projectile_info.effect {
421        projectile::Effect::Attack(attack) => {
422            let target_uid = projectile_target_info.uid;
423            let target = if let Some(entity) = projectile_target_info.entity {
424                entity
425            } else {
426                return;
427            };
428
429            let (target_pos, projectile_dir) = {
430                let target_pos = read_data.positions.get(target);
431                let projectile_ori = projectile_info.ori;
432                match target_pos.zip(projectile_ori) {
433                    Some((tgt_pos, proj_ori)) => {
434                        let Pos(tgt_pos) = tgt_pos;
435                        (*tgt_pos, proj_ori.look_dir())
436                    },
437                    None => return,
438                }
439            };
440
441            let owner = projectile_info.owner;
442            let projectile_entity = projectile_info.entity;
443
444            let attacker_info =
445                owner
446                    .zip(projectile_info.owner_uid)
447                    .map(|(entity, uid)| AttackerInfo {
448                        entity,
449                        uid,
450                        group: read_data.groups.get(entity),
451                        energy: read_data.energies.get(entity),
452                        combo: read_data.combos.get(entity),
453                        inventory: read_data.inventories.get(entity),
454                        stats: read_data.stats.get(entity),
455                        mass: read_data.masses.get(entity),
456                    });
457
458            let target_info = TargetInfo {
459                entity: target,
460                uid: target_uid,
461                inventory: read_data.inventories.get(target),
462                stats: read_data.stats.get(target),
463                health: read_data.healths.get(target),
464                pos: target_pos,
465                ori: projectile_target_info.ori,
466                char_state: read_data.character_states.get(target),
467                energy: read_data.energies.get(target),
468                buffs: read_data.buffs.get(target),
469                mass: read_data.masses.get(target),
470            };
471
472            // TODO: Is it possible to have projectile without body??
473            if let Some(&body) = read_data.bodies.get(projectile_entity) {
474                outcomes_emitter.emit(Outcome::ProjectileHit {
475                    pos: target_pos,
476                    body,
477                    vel: read_data
478                        .velocities
479                        .get(projectile_entity)
480                        .map_or(Vec3::zero(), |v| v.0),
481                    source: projectile_info.owner_uid,
482                    target: read_data.uids.get(target).copied(),
483                });
484            }
485
486            let allow_friendly_fire = owner.is_some_and(|owner| {
487                combat::allow_friendly_fire(&read_data.entered_auras, owner, target)
488            });
489
490            // PvP check
491            let permit_pvp = combat::permit_pvp(
492                &read_data.alignments,
493                &read_data.players,
494                &read_data.entered_auras,
495                &read_data.id_maps,
496                owner,
497                target,
498            );
499
500            let target_dodging = read_data
501                .character_states
502                .get(target)
503                .and_then(|cs| cs.roll_attack_immunities())
504                .is_some_and(|i| i.projectiles);
505
506            let precision_from_flank = combat::precision_mult_from_flank(
507                *projectile_dir,
508                target_info.ori,
509                Default::default(),
510                false,
511            );
512
513            let precision_from_head = {
514                // This performs a cylinder and line segment intersection check. The cylinder is
515                // the upper 10% of an entity's dimensions. The line segment is from the
516                // projectile's positions on the current and previous tick.
517                let curr_pos = projectile_info.pos.0;
518                let last_pos = projectile_info.pos.0 - projectile_info.vel.0 * read_data.dt.0;
519                let vel = projectile_info.vel.0;
520                let (target_height, target_radius) = read_data
521                    .bodies
522                    .get(target)
523                    .map_or((0.0, 0.0), |b| (b.height(), b.max_radius()));
524                let head_top_pos = target_pos.with_z(target_pos.z + target_height);
525                let head_bottom_pos = head_top_pos.with_z(
526                    head_top_pos.z - target_height * combat::PROJECTILE_HEADSHOT_PROPORTION,
527                );
528                if (curr_pos.z < head_bottom_pos.z && last_pos.z < head_bottom_pos.z)
529                    || (curr_pos.z > head_top_pos.z && last_pos.z > head_top_pos.z)
530                {
531                    None
532                } else if curr_pos.z > head_top_pos.z
533                    || curr_pos.z < head_bottom_pos.z
534                    || last_pos.z > head_top_pos.z
535                    || last_pos.z < head_bottom_pos.z
536                {
537                    let proj_top_intersection = {
538                        let t = (head_top_pos.z - last_pos.z) / vel.z;
539                        last_pos + vel * t
540                    };
541                    let proj_bottom_intersection = {
542                        let t = (head_bottom_pos.z - last_pos.z) / vel.z;
543                        last_pos + vel * t
544                    };
545                    let intersected_bottom = head_bottom_pos
546                        .distance_squared(proj_bottom_intersection)
547                        < target_radius.powi(2);
548                    let intersected_top = head_top_pos.distance_squared(proj_top_intersection)
549                        < target_radius.powi(2);
550                    let hit_head = intersected_bottom || intersected_top;
551                    let hit_from_bottom = last_pos.z < head_bottom_pos.z && intersected_bottom;
552                    let hit_from_top = last_pos.z > head_top_pos.z && intersected_top;
553                    // If projectile from bottom, do not award precision damage because it trivial
554                    // to get from up close If projectile from top, reduce
555                    // precision damage to mitigate cheesing benefits
556                    if !hit_head || hit_from_bottom {
557                        None
558                    } else if hit_from_top {
559                        Some(combat::MAX_TOP_HEADSHOT_PRECISION)
560                    } else {
561                        Some(combat::MAX_HEADSHOT_PRECISION)
562                    }
563                } else {
564                    let trajectory = LineSegment3 {
565                        start: last_pos,
566                        end: curr_pos,
567                    };
568                    let head_middle_pos = head_bottom_pos.with_z(
569                        head_bottom_pos.z
570                            + target_height * combat::PROJECTILE_HEADSHOT_PROPORTION * 0.5,
571                    );
572                    if trajectory.distance_to_point(head_middle_pos) < target_radius {
573                        Some(combat::MAX_HEADSHOT_PRECISION)
574                    } else {
575                        None
576                    }
577                }
578            };
579
580            let precision_mult = match (precision_from_flank, precision_from_head) {
581                (Some(a), Some(b)) => Some(a.max(b)),
582                (Some(a), None) | (None, Some(a)) => Some(a),
583                (None, None) => None,
584            };
585
586            let attack_options = AttackOptions {
587                target_dodging,
588                permit_pvp,
589                allow_friendly_fire,
590                target_group: projectile_target_info.target_group,
591                precision_mult,
592            };
593
594            attack.apply_attack(
595                attacker_info,
596                &target_info,
597                projectile_dir,
598                attack_options,
599                1.0,
600                AttackSource::Projectile,
601                *read_data.time,
602                emitters,
603                |o| outcomes_emitter.emit(o),
604                rng,
605                0,
606            );
607        },
608        projectile::Effect::Explode(e) => {
609            let Pos(pos) = *projectile_info.pos;
610            let owner_uid = projectile_info.owner_uid;
611            emitters.emit(ExplosionEvent {
612                pos,
613                explosion: e,
614                owner: owner_uid,
615            });
616        },
617        projectile::Effect::Bonk => {
618            let Pos(pos) = *projectile_info.pos;
619            let owner_uid = projectile_info.owner_uid;
620            emitters.emit(BonkEvent {
621                pos,
622                owner: owner_uid,
623                target: Some(projectile_target_info.uid),
624            });
625        },
626        projectile::Effect::Vanish => {
627            let entity = projectile_info.entity;
628            emitters.emit(DeleteEvent(entity));
629            *projectile_vanished = true;
630        },
631        projectile::Effect::Possess => {
632            let target_uid = projectile_target_info.uid;
633            let owner_uid = projectile_info.owner_uid;
634            if let Some(owner_uid) = owner_uid {
635                if target_uid != owner_uid {
636                    emitters.emit(PossessEvent(owner_uid, target_uid));
637                }
638            }
639        },
640        projectile::Effect::Stick => {},
641        projectile::Effect::Firework(_) => {},
642        projectile::Effect::SurpriseEgg => {
643            let Pos(pos) = *projectile_info.pos;
644            outcomes_emitter.emit(Outcome::SurpriseEgg { pos });
645        },
646        projectile::Effect::TrainingDummy => emitters.emit(CreateNpcEvent {
647            pos: *projectile_info.pos,
648            ori: Ori::default(),
649            npc: NpcBuilder::new(
650                Stats::new(
651                    Content::with_attr("name-custom-village-dummy", "neut"),
652                    Body::Object(object::Body::TrainingDummy),
653                ),
654                Body::Object(object::Body::TrainingDummy),
655                Alignment::Npc,
656            ),
657        }),
658    }
659}