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