veloren_common_systems/
shockwave.rs

1use common::{
2    GroupTarget,
3    combat::{self, AttackOptions, AttackerInfo, TargetInfo},
4    comp::{
5        Alignment, Body, Buffs, CharacterState, Combo, Energy, Group, Health, Inventory, Mass, Ori,
6        PhysicsState, Player, Pos, Scale, Shockwave, ShockwaveHitEntities, Stats,
7        ability::Dodgeable,
8        agent::{Sound, SoundKind},
9        aura::EnteredAuras,
10    },
11    event::{
12        BuffEvent, ComboChangeEvent, DeleteEvent, EmitExt, EnergyChangeEvent,
13        EntityAttackedHookEvent, EventBus, HealthChangeEvent, KnockbackEvent, MineBlockEvent,
14        ParryHookEvent, PoiseChangeEvent, SoundEvent, TransformEvent,
15    },
16    event_emitters,
17    outcome::Outcome,
18    resources::{DeltaTime, Time},
19    uid::{IdMaps, Uid},
20    util::Dir,
21};
22use common_ecs::{Job, Origin, Phase, System};
23use rand::Rng;
24use specs::{Entities, Join, LendJoin, Read, ReadStorage, SystemData, WriteStorage, shred};
25use vek::*;
26
27event_emitters! {
28    struct Events[Emitters] {
29        health_change: HealthChangeEvent,
30        energy_change: EnergyChangeEvent,
31        poise_change: PoiseChangeEvent,
32        sound: SoundEvent,
33        mine_block: MineBlockEvent,
34        parry_hook: ParryHookEvent,
35        knockback: KnockbackEvent,
36        entity_attack_hook: EntityAttackedHookEvent,
37        combo_change: ComboChangeEvent,
38        buff: BuffEvent,
39        delete: DeleteEvent,
40        transform: TransformEvent,
41    }
42}
43
44#[derive(SystemData)]
45pub struct ReadData<'a> {
46    entities: Entities<'a>,
47    events: Events<'a>,
48    time: Read<'a, Time>,
49    players: ReadStorage<'a, Player>,
50    dt: Read<'a, DeltaTime>,
51    id_maps: Read<'a, IdMaps>,
52    uids: ReadStorage<'a, Uid>,
53    positions: ReadStorage<'a, Pos>,
54    orientations: ReadStorage<'a, Ori>,
55    alignments: ReadStorage<'a, Alignment>,
56    scales: ReadStorage<'a, Scale>,
57    bodies: ReadStorage<'a, Body>,
58    healths: ReadStorage<'a, Health>,
59    inventories: ReadStorage<'a, Inventory>,
60    groups: ReadStorage<'a, Group>,
61    physics_states: ReadStorage<'a, PhysicsState>,
62    energies: ReadStorage<'a, Energy>,
63    stats: ReadStorage<'a, Stats>,
64    combos: ReadStorage<'a, Combo>,
65    character_states: ReadStorage<'a, CharacterState>,
66    buffs: ReadStorage<'a, Buffs>,
67    entered_auras: ReadStorage<'a, EnteredAuras>,
68    masses: ReadStorage<'a, Mass>,
69}
70
71/// This system is responsible for handling accepted inputs like moving or
72/// attacking
73#[derive(Default)]
74pub struct Sys;
75impl<'a> System<'a> for Sys {
76    type SystemData = (
77        ReadData<'a>,
78        WriteStorage<'a, Shockwave>,
79        WriteStorage<'a, ShockwaveHitEntities>,
80        Read<'a, EventBus<Outcome>>,
81    );
82
83    const NAME: &'static str = "shockwave";
84    const ORIGIN: Origin = Origin::Common;
85    const PHASE: Phase = Phase::Create;
86
87    fn run(
88        _job: &mut Job<Self>,
89        (read_data, mut shockwaves, mut shockwave_hit_lists, outcomes): Self::SystemData,
90    ) {
91        let mut emitters = read_data.events.get_emitters();
92        let mut outcomes_emitter = outcomes.emitter();
93        let mut rng = rand::rng();
94
95        let time = read_data.time.0;
96        let dt = read_data.dt.0;
97
98        // Shockwaves
99        for (entity, pos, ori, shockwave, shockwave_hit_list) in (
100            &read_data.entities,
101            &read_data.positions,
102            &read_data.orientations,
103            &shockwaves,
104            &mut shockwave_hit_lists,
105        )
106            .join()
107        {
108            let creation_time = match shockwave.creation {
109                Some(time) => time,
110                // Skip newly created shockwaves
111                None => continue,
112            };
113
114            let end_time = creation_time + shockwave.duration.as_secs_f64();
115
116            let shockwave_owner = shockwave
117                .owner
118                .and_then(|uid| read_data.id_maps.uid_entity(uid));
119
120            if rng.random_bool(0.05) {
121                emitters.emit(SoundEvent {
122                    sound: Sound::new(SoundKind::Shockwave, pos.0, 40.0, time),
123                });
124            }
125
126            // If shockwave is out of time emit destroy event but still continue since it
127            // may have traveled and produced effects a bit before reaching it's end point
128            if time > end_time {
129                emitters.emit(DeleteEvent(entity));
130                continue;
131            }
132
133            // Determine area that was covered by the shockwave in the last tick
134            let time_since_creation = (time - creation_time) as f32;
135            let frame_start_dist = (shockwave.speed * (time_since_creation - dt)).max(0.0);
136            let frame_end_dist = (shockwave.speed * time_since_creation).max(frame_start_dist);
137            let pos2 = Vec2::from(pos.0);
138            let look_dir = ori.look_dir();
139
140            // From one frame to the next a shockwave travels over a strip of an arc
141            // This is used for collision detection
142            let arc_strip = ArcStrip {
143                origin: pos2,
144                // TODO: make sure this is not Vec2::new(0.0, 0.0)
145                dir: look_dir.xy(),
146                angle: shockwave.angle,
147                start: frame_start_dist,
148                end: frame_end_dist,
149            };
150
151            // Group to ignore collisions with
152            // Might make this more nuanced if shockwaves are used for non damage effects
153            let group = shockwave_owner.and_then(|e| read_data.groups.get(e));
154
155            // Go through all other effectable entities
156            for (target, uid_b, pos_b, health_b, body_b, physics_state_b) in (
157                &read_data.entities,
158                &read_data.uids,
159                &read_data.positions,
160                &read_data.healths,
161                &read_data.bodies,
162                &read_data.physics_states,
163            )
164                .join()
165            {
166                // Check to see if entity has already been hit
167                if shockwave_hit_list.hit_entities.contains(uid_b) {
168                    continue;
169                }
170
171                // 2D versions
172                let pos_b2 = pos_b.0.xy();
173
174                // Scales
175                let scale_b = read_data.scales.get(target).map_or(1.0, |s| s.0);
176                // TODO: use Capsule Prism instead of Cylinder
177                let rad_b = body_b.max_radius() * scale_b;
178
179                // Angle checks
180                let pos_b_ground = Vec3::new(pos_b.0.x, pos_b.0.y, pos.0.z);
181                let max_angle = shockwave.vertical_angle.to_radians();
182
183                // See if entities are in the same group
184                let same_group = group
185                    .map(|group_a| Some(group_a) == read_data.groups.get(target))
186                    .unwrap_or(Some(*uid_b) == shockwave.owner);
187
188                let target_group = if same_group {
189                    GroupTarget::InGroup
190                } else {
191                    GroupTarget::OutOfGroup
192                };
193
194                // Check if it is a hit
195                //
196                // TODO: Should the owner entity really be filtered out here? Unlike other
197                // attacks, explosions and shockwaves are rather "imprecise"
198                // attacks with which one shoud be easily able to hit oneself.
199                // Once we make shockwaves start out a little way out from the center, this can
200                // be removed.
201                let hit = entity != target
202                    && (shockwave_owner != Some(target))
203                    && !health_b.is_dead
204                    && (pos_b.0 - pos.0).magnitude() < frame_end_dist + rad_b
205                    // Collision shapes
206                    && {
207                        // TODO: write code to collide rect with the arc strip so that we can do
208                        // more complete collision detection for rapidly moving entities
209                        arc_strip.collides_with_circle(Disk::new(pos_b2, rad_b))
210                    }
211                    && (pos_b_ground - pos.0).angle_between(pos_b.0 - pos.0) < max_angle
212                    && match shockwave.dodgeable {
213                        Dodgeable::Roll | Dodgeable::No => true,
214                        Dodgeable::Jump => physics_state_b.on_ground.is_some()
215                    };
216
217                if hit {
218                    let allow_friendly_fire = shockwave_owner.is_some_and(|entity| {
219                        combat::allow_friendly_fire(&read_data.entered_auras, entity, target)
220                    });
221                    let dir = Dir::from_unnormalized(pos_b.0 - pos.0).unwrap_or(look_dir);
222
223                    let attacker_info =
224                        shockwave_owner
225                            .zip(shockwave.owner)
226                            .map(|(entity, uid)| AttackerInfo {
227                                entity,
228                                uid,
229                                group: read_data.groups.get(entity),
230                                energy: read_data.energies.get(entity),
231                                combo: read_data.combos.get(entity),
232                                inventory: read_data.inventories.get(entity),
233                                stats: read_data.stats.get(entity),
234                                mass: read_data.masses.get(entity),
235                                pos: Some(pos.0),
236                            });
237
238                    let target_info = TargetInfo {
239                        entity: target,
240                        uid: *uid_b,
241                        inventory: read_data.inventories.get(target),
242                        stats: read_data.stats.get(target),
243                        health: read_data.healths.get(target),
244                        pos: pos_b.0,
245                        ori: read_data.orientations.get(target),
246                        char_state: read_data.character_states.get(target),
247                        energy: read_data.energies.get(target),
248                        buffs: read_data.buffs.get(target),
249                        mass: read_data.masses.get(target),
250                        player: read_data.players.get(target),
251                    };
252
253                    let target_dodging = read_data
254                        .character_states
255                        .get(target)
256                        .and_then(|cs| cs.roll_attack_immunities())
257                        .is_some_and(|i| match shockwave.dodgeable {
258                            Dodgeable::Roll => i.air_shockwaves,
259                            Dodgeable::Jump => i.ground_shockwaves,
260                            Dodgeable::No => false,
261                        });
262                    // PvP check
263                    let permit_pvp = combat::permit_pvp(
264                        &read_data.alignments,
265                        &read_data.players,
266                        &read_data.entered_auras,
267                        &read_data.id_maps,
268                        shockwave_owner,
269                        target,
270                    );
271                    // Shockwaves aren't precise, and thus cannot be a precise strike
272                    let precision_mult = None;
273                    let attack_options = AttackOptions {
274                        target_dodging,
275                        permit_pvp,
276                        allow_friendly_fire,
277                        target_group,
278                        precision_mult,
279                    };
280
281                    shockwave.properties.attack.apply_attack(
282                        attacker_info,
283                        &target_info,
284                        dir,
285                        attack_options,
286                        1.0,
287                        shockwave.dodgeable.shockwave_attack_source(),
288                        *read_data.time,
289                        &mut emitters,
290                        |o| outcomes_emitter.emit(o),
291                        &mut rng,
292                        0,
293                    );
294
295                    shockwave_hit_list.hit_entities.push(*uid_b);
296                }
297            }
298        }
299
300        // Set start time on new shockwaves
301        // This change doesn't need to be recorded as it is not sent to the client
302        shockwaves.set_event_emission(false);
303        (&mut shockwaves).lend_join().for_each(|mut shockwave| {
304            if shockwave.creation.is_none() {
305                shockwave.creation = Some(time);
306            }
307        });
308        shockwaves.set_event_emission(true);
309    }
310}
311
312#[derive(Clone, Copy)]
313struct ArcStrip {
314    origin: Vec2<f32>,
315    /// Normalizable direction
316    dir: Vec2<f32>,
317    /// Angle in degrees
318    angle: f32,
319    /// Start radius
320    start: f32,
321    /// End radius
322    end: f32,
323}
324
325impl ArcStrip {
326    fn collides_with_circle(self, d: Disk<f32, f32>) -> bool {
327        // Quit if aabb's don't collide
328        if (self.origin.x - d.center.x).abs() > self.end + d.radius
329            || (self.origin.y - d.center.y).abs() > self.end + d.radius
330        {
331            return false;
332        }
333
334        let dist = self.origin.distance(d.center);
335        let half_angle = self.angle.to_radians() / 2.0;
336
337        if dist > self.end + d.radius || dist + d.radius < self.start {
338            // Completely inside or outside full ring
339            return false;
340        }
341
342        let inside_edge = Disk::new(self.origin, self.start);
343        let outside_edge = Disk::new(self.origin, self.end);
344        let inner_corner_in_circle = || {
345            let midpoint = self.dir.normalized() * self.start;
346            d.contains_point(midpoint.rotated_z(half_angle) + self.origin)
347                || d.contains_point(midpoint.rotated_z(-half_angle) + self.origin)
348        };
349        let arc_segment_in_circle = || {
350            let midpoint = self.dir.normalized();
351            let segment_in_circle = |angle| {
352                let dir = midpoint.rotated_z(angle);
353                let side = LineSegment2 {
354                    start: dir * self.start + self.origin,
355                    end: dir * self.end + self.origin,
356                };
357                d.contains_point(side.projected_point(d.center))
358            };
359            segment_in_circle(half_angle) || segment_in_circle(-half_angle)
360        };
361
362        if dist > self.end {
363            // Circle center is outside ring
364            // Check intersection with line segments
365            arc_segment_in_circle() || {
366                // Check angle of intersection points on outside edge of ring
367                let (p1, p2) = intersection_points(outside_edge, d, dist);
368                self.dir.angle_between(p1 - self.origin) < half_angle
369                    || self.dir.angle_between(p2 - self.origin) < half_angle
370            }
371        } else if dist < self.start {
372            // Circle center is inside ring
373            // Check angle of intersection points on inside edge of ring
374            // Check if circle contains one of the inner points of the arc
375            inner_corner_in_circle()
376                || (
377                    // Check that the circles aren't identical
378                    inside_edge != d && {
379                        let (p1, p2) = intersection_points(inside_edge, d, dist);
380                        self.dir.angle_between(p1 - self.origin) < half_angle
381                            || self.dir.angle_between(p2 - self.origin) < half_angle
382                    }
383                )
384        } else if d.radius > dist {
385            // Circle center inside ring
386            // but center of ring is inside the circle so we can't calculate the angle
387            inner_corner_in_circle()
388        } else {
389            // Circle center inside ring
390            // Calculate extra angle to account for circle radius
391            let extra_angle = (d.radius / dist).asin();
392            self.dir.angle_between(d.center - self.origin) < half_angle + extra_angle
393        }
394    }
395}
396
397// Assumes an intersection is occuring at 2 points
398// Uses precalculated distance
399// https://www.xarg.org/2016/07/calculate-the-intersection-points-of-two-circles/
400fn intersection_points(
401    disk1: Disk<f32, f32>,
402    disk2: Disk<f32, f32>,
403    dist: f32,
404) -> (Vec2<f32>, Vec2<f32>) {
405    let e = (disk2.center - disk1.center) / dist;
406
407    let x = (disk1.radius.powi(2) - disk2.radius.powi(2) + dist.powi(2)) / (2.0 * dist);
408    let y = (disk1.radius.powi(2) - x.powi(2)).sqrt();
409
410    let pxe = disk1.center + x * e;
411    let eyx = e.yx();
412
413    let p1 = pxe + Vec2::new(-y, y) * eyx;
414    let p2 = pxe + Vec2::new(y, -y) * eyx;
415
416    (p1, p2)
417}