veloren_common_systems/
beam.rs

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