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