veloren_common_systems/
beam.rs

1use std::f32::consts::PI;
2
3use common::{
4    GroupTarget,
5    combat::{self, AttackOptions, AttackSource, AttackerInfo, TargetInfo},
6    comp::{
7        Alignment, Beam, Body, Buffs, CharacterState, Combo, Energy, Group, Health, Inventory,
8        Mass, Ori, Player, Pos, Scale, Stats,
9        agent::{Sound, SoundKind},
10        aura::EnteredAuras,
11    },
12    event::{self, EmitExt, EventBus},
13    event_emitters,
14    outcome::Outcome,
15    resources::{DeltaTime, Time},
16    terrain::TerrainGrid,
17    uid::{IdMaps, Uid},
18    vol::ReadVol,
19};
20use common_ecs::{Job, Origin, ParMode, Phase, System};
21use rand::Rng;
22use rayon::iter::ParallelIterator;
23use specs::{
24    Entities, LendJoin, ParJoin, Read, ReadExpect, ReadStorage, SystemData, WriteStorage, shred,
25};
26use vek::*;
27
28event_emitters! {
29    struct ReadAttackEvents[AttackEmitters] {
30        health_change: event::HealthChangeEvent,
31        energy_change: event::EnergyChangeEvent,
32        poise_change: event::PoiseChangeEvent,
33        sound: event::SoundEvent,
34        parry_hook: event::ParryHookEvent,
35        knockback: event::KnockbackEvent,
36        entity_attack_hoow: event::EntityAttackedHookEvent,
37        combo_change: event::ComboChangeEvent,
38        buff: event::BuffEvent,
39    }
40}
41
42#[derive(SystemData)]
43pub struct ReadData<'a> {
44    entities: Entities<'a>,
45    players: ReadStorage<'a, Player>,
46    time: Read<'a, Time>,
47    dt: Read<'a, DeltaTime>,
48    terrain: ReadExpect<'a, TerrainGrid>,
49    id_maps: Read<'a, IdMaps>,
50    cached_spatial_grid: Read<'a, common::CachedSpatialGrid>,
51    uids: ReadStorage<'a, Uid>,
52    positions: ReadStorage<'a, Pos>,
53    orientations: ReadStorage<'a, Ori>,
54    alignments: ReadStorage<'a, Alignment>,
55    scales: ReadStorage<'a, Scale>,
56    bodies: ReadStorage<'a, Body>,
57    healths: ReadStorage<'a, Health>,
58    inventories: ReadStorage<'a, Inventory>,
59    groups: ReadStorage<'a, Group>,
60    energies: ReadStorage<'a, Energy>,
61    stats: ReadStorage<'a, Stats>,
62    combos: ReadStorage<'a, Combo>,
63    character_states: ReadStorage<'a, CharacterState>,
64    buffs: ReadStorage<'a, Buffs>,
65    entered_auras: ReadStorage<'a, EnteredAuras>,
66    outcomes: Read<'a, EventBus<Outcome>>,
67    events: ReadAttackEvents<'a>,
68    masses: ReadStorage<'a, Mass>,
69}
70
71/// This system is responsible for handling beams that heal or do damage
72#[derive(Default)]
73pub struct Sys;
74impl<'a> System<'a> for Sys {
75    type SystemData = (ReadData<'a>, WriteStorage<'a, Beam>);
76
77    const NAME: &'static str = "beam";
78    const ORIGIN: Origin = Origin::Common;
79    const PHASE: Phase = Phase::Create;
80
81    fn run(job: &mut Job<Self>, (read_data, mut beams): Self::SystemData) {
82        let mut outcomes_emitter = read_data.outcomes.emitter();
83
84        (
85            &read_data.positions,
86            &read_data.orientations,
87            &read_data.character_states,
88            &mut beams,
89        )
90            .lend_join()
91            .for_each(|(pos, ori, char_state, mut beam)| {
92                // Clear hit entities list if list should be cleared
93                if read_data.time.0 % beam.tick_dur.0 < read_data.dt.0 as f64 {
94                    let (hit_entities, hit_durations) = beam.hit_entities_and_durations();
95                    hit_durations.retain(|e, _| hit_entities.contains(e));
96                    for entity in hit_entities {
97                        *hit_durations.entry(*entity).or_insert(0) += 1;
98                    }
99                    beam.hit_entities.clear();
100                }
101                // Update start, end, and control positions of beam bezier
102                let (offset, target_dir) = if let CharacterState::BasicBeam(c) = char_state {
103                    (c.beam_offset, c.aim_dir)
104                } else {
105                    (Vec3::zero(), ori.look_dir())
106                };
107                beam.bezier.start = pos.0 + offset;
108                const REL_CTRL_DIST: f32 = 0.3;
109                let target_ctrl = beam.bezier.start + *target_dir * beam.range * REL_CTRL_DIST;
110                let ctrl_translate = (target_ctrl - beam.bezier.ctrl) * read_data.dt.0
111                    / (beam.duration.0 as f32 * REL_CTRL_DIST);
112                beam.bezier.ctrl += ctrl_translate;
113                let target_end = beam.bezier.start + *target_dir * beam.range;
114                let end_translate =
115                    (target_end - beam.bezier.end) * read_data.dt.0 / beam.duration.0 as f32;
116                beam.bezier.end += end_translate;
117            });
118
119        job.cpu_stats.measure(ParMode::Rayon);
120
121        // Beams
122        // Emitters will append their events when dropped.
123        let (_emitters, add_hit_entities, new_outcomes) = (
124            &read_data.entities,
125            &read_data.positions,
126            &read_data.orientations,
127            &read_data.uids,
128            &beams,
129        )
130            .par_join()
131            .fold(
132                || (read_data.events.get_emitters(), Vec::new(), Vec::new()),
133                |(mut emitters, mut add_hit_entities, mut outcomes),
134                 (entity, pos, ori, uid, beam)| {
135                    // Note: rayon makes it difficult to hold onto a thread-local RNG, if grabbing
136                    // this becomes a bottleneck we can look into alternatives.
137                    let mut rng = rand::thread_rng();
138                    if rng.gen_bool(0.005) {
139                        emitters.emit(event::SoundEvent {
140                            sound: Sound::new(SoundKind::Beam, pos.0, 13.0, read_data.time.0),
141                        });
142                    }
143                    outcomes.push(Outcome::Beam {
144                        pos: pos.0,
145                        specifier: beam.specifier,
146                    });
147
148                    // Group to ignore collisions with
149                    // Might make this more nuanced if beams are used for non damage effects
150                    let group = read_data.groups.get(entity);
151
152                    // Go through all affectable entities by querying the spatial grid
153                    let target_iter = read_data
154                        .cached_spatial_grid
155                        .0
156                        .in_circle_aabr(beam.bezier.start.xy(), beam.range)
157                        .filter_map(|target| {
158                            read_data
159                                .positions
160                                .get(target)
161                                .and_then(|l| read_data.healths.get(target).map(|r| (l, r)))
162                                .and_then(|l| read_data.uids.get(target).map(|r| (l, r)))
163                                .and_then(|l| read_data.bodies.get(target).map(|r| (l, r)))
164                                .map(|(((pos_b, health_b), uid_b), body_b)| {
165                                    (target, uid_b, pos_b, health_b, body_b)
166                                })
167                        });
168                    target_iter.for_each(|(target, uid_b, pos_b, health_b, body_b)| {
169                        // Check to see if entity has already been hit recently
170                        if beam.hit_entities.iter().any(|&e| e == target) {
171                            return;
172                        }
173
174                        // Scales
175                        let scale_b = read_data.scales.get(target).map_or(1.0, |s| s.0);
176                        let rad_b = body_b.max_radius() * scale_b;
177                        let height_b = body_b.height() * scale_b;
178
179                        // Check if it is a hit
180                        // TODO: use Capsule Prism instead of cylinder
181                        let hit = entity != target
182                            && !health_b.is_dead
183                            && conical_bezier_cylinder_collision(
184                                beam.bezier,
185                                beam.end_radius,
186                                beam.range,
187                                pos_b.0,
188                                rad_b,
189                                height_b,
190                            );
191
192                        // Finally, ensure that a hit has actually occurred by performing a raycast.
193                        // We do this last because it's likely to be the
194                        // most expensive operation.
195                        let tgt_dist = beam.bezier.start.distance(pos_b.0);
196                        let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
197                            / beam.bezier.start.distance(beam.bezier.ctrl).max(0.01);
198                        let hit = hit
199                            && read_data
200                                .terrain
201                                .ray(
202                                    beam.bezier.start,
203                                    beam.bezier.start + beam_dir * (tgt_dist + 1.0),
204                                )
205                                .until(|b| b.is_filled())
206                                .cast()
207                                .0
208                                >= tgt_dist;
209
210                        if hit {
211                            let allow_friendly_fire = combat::allow_friendly_fire(
212                                &read_data.entered_auras,
213                                entity,
214                                target,
215                            );
216
217                            // See if entities are in the same group
218                            let same_group = group
219                                .map(|group_a| Some(group_a) == read_data.groups.get(target))
220                                .unwrap_or(false);
221
222                            let target_group = if same_group {
223                                GroupTarget::InGroup
224                            } else {
225                                GroupTarget::OutOfGroup
226                            };
227
228                            let attacker_info = Some(AttackerInfo {
229                                entity,
230                                uid: *uid,
231                                group: read_data.groups.get(entity),
232                                energy: read_data.energies.get(entity),
233                                combo: read_data.combos.get(entity),
234                                inventory: read_data.inventories.get(entity),
235                                stats: read_data.stats.get(entity),
236                                mass: read_data.masses.get(entity),
237                            });
238
239                            let target_info = TargetInfo {
240                                entity: target,
241                                uid: *uid_b,
242                                inventory: read_data.inventories.get(target),
243                                stats: read_data.stats.get(target),
244                                health: read_data.healths.get(target),
245                                pos: pos_b.0,
246                                ori: read_data.orientations.get(target),
247                                char_state: read_data.character_states.get(target),
248                                energy: read_data.energies.get(target),
249                                buffs: read_data.buffs.get(target),
250                                mass: read_data.masses.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| i.beams);
258                            // PvP check
259                            let permit_pvp = combat::permit_pvp(
260                                &read_data.alignments,
261                                &read_data.players,
262                                &read_data.entered_auras,
263                                &read_data.id_maps,
264                                Some(entity),
265                                target,
266                            );
267
268                            let precision_from_flank = combat::precision_mult_from_flank(
269                                beam.bezier.ctrl - beam.bezier.start,
270                                target_info.ori,
271                                Default::default(),
272                                false,
273                            );
274
275                            let precision_from_time = {
276                                if let Some(ticks) = beam.hit_durations.get(&target) {
277                                    let dur = *ticks as f32 * beam.tick_dur.0 as f32;
278                                    let mult =
279                                        (dur / combat::BEAM_DURATION_PRECISION).clamp(0.0, 1.0);
280                                    Some(combat::MAX_BEAM_DUR_PRECISION * mult)
281                                } else {
282                                    None
283                                }
284                            };
285
286                            let precision_mult = match (precision_from_flank, precision_from_time) {
287                                (Some(a), Some(b)) => Some(a.max(b)),
288                                (Some(a), None) | (None, Some(a)) => Some(a),
289                                (None, None) => None,
290                            };
291
292                            let attack_options = AttackOptions {
293                                target_dodging,
294                                permit_pvp,
295                                allow_friendly_fire,
296                                target_group,
297                                precision_mult,
298                            };
299
300                            beam.attack.apply_attack(
301                                attacker_info,
302                                &target_info,
303                                ori.look_dir(),
304                                attack_options,
305                                1.0,
306                                AttackSource::Beam,
307                                *read_data.time,
308                                &mut emitters,
309                                |o| outcomes.push(o),
310                                &mut rng,
311                                0,
312                            );
313
314                            add_hit_entities.push((entity, target));
315                        }
316                    });
317                    (emitters, add_hit_entities, outcomes)
318                },
319            )
320            .reduce(
321                || (read_data.events.get_emitters(), Vec::new(), Vec::new()),
322                |(mut events_a, mut hit_entities_a, mut outcomes_a),
323                 (events_b, mut hit_entities_b, mut outcomes_b)| {
324                    events_a.append(events_b);
325                    hit_entities_a.append(&mut hit_entities_b);
326                    outcomes_a.append(&mut outcomes_b);
327                    (events_a, hit_entities_a, outcomes_a)
328                },
329            );
330        job.cpu_stats.measure(ParMode::Single);
331
332        outcomes_emitter.emit_many(new_outcomes);
333
334        for (entity, hit_entity) in add_hit_entities {
335            if let Some(ref mut beam) = beams.get_mut(entity) {
336                beam.hit_entities.push(hit_entity);
337            }
338        }
339    }
340}
341
342/// Assumes upright cylinder
343fn conical_bezier_cylinder_collision(
344    // Values for spherical wedge
345    bezier: QuadraticBezier3<f32>,
346    max_rad: f32, // Radius at end_pos (radius is 0 at start_pos)
347    range: f32,   // Used to decide number of steps in bezier function
348    // Values for cylinder
349    bottom_pos_b: Vec3<f32>, // Position of bottom of cylinder
350    rad_b: f32,
351    length_b: f32,
352) -> bool {
353    // This algorithm first determines the nearest point on the bezier to the point
354    // in the middle of the cylinder. It then checks that the bezier cone's radius
355    // at this point could allow it to be in the z bounds of the cylinder and within
356    // the cylinder's radius.
357    let center_pos_b = bottom_pos_b.with_z(bottom_pos_b.z + length_b / 2.0);
358    let (t, closest_pos) =
359        bezier.binary_search_point_by_steps(center_pos_b, (range * 5.0) as u16, 0.1);
360    let bezier_rad = t * max_rad;
361    let z_check = {
362        let dist = (closest_pos.z - center_pos_b.z).abs();
363        dist < bezier_rad + length_b / 2.0
364    };
365    let rad_check = {
366        let dist_sqrd = closest_pos.xy().distance_squared(center_pos_b.xy());
367        dist_sqrd < (bezier_rad + rad_b).powi(2)
368    };
369    let endpoints_check = {
370        let tangent = bezier.evaluate_derivative(t);
371        let dir = center_pos_b - closest_pos;
372        // `rad_check` only makes sense if the angle from the nearest bezier point to
373        // the target is 90 degrees
374        tangent.angle_between(dir) >= 0.45 * PI
375    };
376
377    z_check && rad_check && endpoints_check
378}