Skip to main content

veloren_voxygen/audio/sfx/event_mapper/movement/
mod.rs

1/// EventMapper::Movement watches the movement states of surrounding entities,
2/// and triggers sfx related to running, climbing and gliding, at a volume
3/// proportionate to the extity's size
4use super::EventMapper;
5use crate::{
6    AudioFrontend,
7    audio::sfx::{SFX_DIST_LIMIT_SQR, SfxEvent, SfxTag, SfxTriggerItem, SfxTriggers},
8    ecs::comp::{Footsteps, Interpolated},
9    scene::{Camera, FigureMgr, Terrain},
10};
11use client::Client;
12use common::{
13    comp::{Body, CharacterState, PhysicsState, Scale, Vel},
14    resources::DeltaTime,
15    states,
16    terrain::{BlockKind, TerrainChunk},
17};
18use common_state::State;
19use hashbrown::HashMap;
20use rand::prelude::*;
21use specs::{Entity as EcsEntity, Join, LendJoin, WorldExt};
22use std::time::{Duration, Instant};
23use vek::*;
24
25#[derive(Clone)]
26struct PreviousEntityState {
27    event: SfxEvent,
28    time: Instant,
29    on_ground: bool,
30    in_water: bool,
31    steps_taken: f32,
32    is_stepping: Option<bool>,
33}
34
35impl Default for PreviousEntityState {
36    fn default() -> Self {
37        Self {
38            event: SfxEvent::Idle,
39            time: Instant::now(),
40            on_ground: true,
41            in_water: false,
42            steps_taken: 0.0,
43            is_stepping: None,
44        }
45    }
46}
47
48pub struct MovementEventMapper {
49    event_history: HashMap<EcsEntity, PreviousEntityState>,
50}
51
52impl EventMapper for MovementEventMapper {
53    fn maintain(
54        &mut self,
55        audio: &mut AudioFrontend,
56        state: &State,
57        player_entity: specs::Entity,
58        camera: &Camera,
59        triggers: &SfxTriggers,
60        _terrain: &Terrain<TerrainChunk>,
61        _client: &Client,
62        figure_mgr: &FigureMgr,
63    ) {
64        let ecs = state.ecs();
65
66        let cam_pos = camera.get_pos_with_focus();
67
68        let mut footsteps = ecs.write_storage::<Footsteps>();
69        for (entity, interpolated, vel, body, scale, physics, character) in (
70            &ecs.entities(),
71            &ecs.read_storage::<Interpolated>(),
72            &ecs.read_storage::<Vel>(),
73            &ecs.read_storage::<Body>(),
74            ecs.read_storage::<Scale>().maybe(),
75            &ecs.read_storage::<PhysicsState>(),
76            ecs.read_storage::<CharacterState>().maybe(),
77        )
78            .join()
79        {
80            // Update footstep detection
81            // TODO: Support non-humanoids, remove the old timing logic
82            if physics.on_ground.is_some()
83                && let Ok(footsteps) = footsteps.entry(entity)
84            {
85                // Look mom, I found an acceptable use for macros
86                macro_rules! update_footsteps {
87                    ($($name:ident: $body:ident => [$($foot:ident),* $(,)?]),* $(,)?) => {
88                        match body {
89                            $(Body::$body(_) => {
90                                if let Some(state) = figure_mgr.states.$name.get(&entity) {
91                                    let feet_pos = [$(state.computed_skeleton.$foot,)*].map(|f| {
92                                        -interpolated.ori.global_to_local(f.mul_point(Vec3::new(0.0, 1.0, -1.0))).y
93                                    });
94                                    footsteps
95                                        .or_insert_with(Default::default)
96                                        .update(&feet_pos);
97                                }
98                            },)*
99                            _ => {},
100                        }
101                    };
102                }
103
104                update_footsteps!(
105                    character_states: Humanoid => [foot_l, foot_r],
106                    biped_large_states: BipedLarge => [foot_l, foot_r],
107                    quadruped_medium_states: QuadrupedMedium => [foot_fl, foot_fr, foot_bl, foot_br],
108                    quadruped_small_states: QuadrupedSmall => [leg_fl, leg_fr, leg_bl, leg_br],
109                    quadruped_low_states: QuadrupedLow => [foot_fl, foot_fr, foot_bl, foot_br],
110                    bird_medium_states: BirdMedium => [leg_l, leg_r],
111                    bird_large_states: BirdLarge => [foot_l, foot_r],
112                    arthropod_states: Arthropod => [
113                        leg_fl,
114                        leg_fr,
115                        leg_fcl,
116                        leg_fcr,
117                        leg_bcl,
118                        leg_bcr,
119                        leg_bl,
120                        leg_br,
121                    ],
122                );
123            }
124
125            if interpolated.pos.distance_squared(cam_pos) < SFX_DIST_LIMIT_SQR
126                && let Some(character) = character
127            {
128                let internal_state = self.event_history.entry(entity).or_default();
129
130                let mapped_event = match body {
131                    Body::Humanoid(_) => {
132                        Self::map_movement_event(character, physics, internal_state, vel.0)
133                    },
134                    Body::QuadrupedMedium(_) | Body::QuadrupedSmall(_) | Body::QuadrupedLow(_) => {
135                        Self::map_quadruped_movement_event(physics, internal_state, vel.0)
136                    },
137                    Body::BirdMedium(_) | Body::BirdLarge(_) | Body::BipedLarge(_) => {
138                        Self::map_non_humanoid_movement_event(physics, internal_state, vel.0)
139                    },
140                    Body::Arthropod(_) => {
141                        Self::map_arthropod_movement_event(physics, internal_state, vel.0)
142                    },
143                    _ => SfxEvent::Idle, // Ignore fish, etc...
144                };
145
146                // Check for SFX config entry for this movement
147                if Self::should_emit(
148                    internal_state,
149                    triggers.0.get_key_value(&mapped_event),
150                    physics,
151                ) {
152                    let sfx_trigger_item = triggers.0.get_key_value(&mapped_event);
153                    audio.emit_sfx(
154                        sfx_trigger_item,
155                        interpolated.pos,
156                        Some(Self::get_volume_for_body_type(body)),
157                        SfxTag::Footstep,
158                    );
159                    internal_state.time = Instant::now();
160                    internal_state.steps_taken = 0.0;
161                }
162
163                // update state to determine the next event. We only record the time (above) if
164                // it was dispatched
165                internal_state.event = mapped_event;
166                internal_state.on_ground = physics.on_ground.is_some();
167                internal_state.in_water = physics.in_liquid().is_some();
168                let dt = ecs.fetch::<DeltaTime>().0;
169                internal_state.steps_taken +=
170                    vel.0.magnitude() * dt / (body.stride_length() * scale.map_or(1.0, |s| s.0));
171                internal_state.is_stepping = footsteps.get(entity).map(|f| f.is_any_stepping());
172            }
173        }
174
175        self.cleanup(player_entity);
176    }
177}
178
179impl MovementEventMapper {
180    pub fn new() -> Self {
181        Self {
182            event_history: HashMap::new(),
183        }
184    }
185
186    /// As the player explores the world, we track the last event of the nearby
187    /// entities to determine the correct SFX item to play next based on
188    /// their activity. `cleanup` will remove entities from event tracking if
189    /// they have not triggered an event for > n seconds. This prevents
190    /// stale records from bloating the Map size.
191    fn cleanup(&mut self, player: EcsEntity) {
192        const TRACKING_TIMEOUT: u64 = 10;
193
194        let now = Instant::now();
195        self.event_history.retain(|entity, event| {
196            now.duration_since(event.time) < Duration::from_secs(TRACKING_TIMEOUT)
197                || entity.id() == player.id()
198        });
199    }
200
201    /// When specific entity movements are detected, the associated sound (if
202    /// any) needs to satisfy two conditions to be allowed to play:
203    /// 1. An sfx.ron entry exists for the movement (we need to know which sound
204    ///    file(s) to play)
205    /// 2. The sfx has not been played since it's timeout threshold has elapsed,
206    ///    which prevents firing every tick. For movement, threshold is not a
207    ///    time, but a distance.
208    fn should_emit(
209        previous_state: &PreviousEntityState,
210        sfx_trigger_item: Option<(&SfxEvent, &SfxTriggerItem)>,
211        physics: &PhysicsState,
212    ) -> bool {
213        if let Some((event, item)) = sfx_trigger_item {
214            let landed = physics.on_ground.is_some() && !previous_state.on_ground;
215            // If possible, use the new footstep detection logic
216            landed
217                || previous_state.is_stepping
218                .filter(|_| matches!(event, SfxEvent::Run(_) | SfxEvent::OctoRun(_) | SfxEvent::QuadRun(_)))
219                // Otherwise, fall back to simply footstep timers
220                .unwrap_or_else(|| match event {
221                    SfxEvent::Climb => previous_state.steps_taken >= item.threshold,
222                    _ => previous_state.time.elapsed().as_secs_f32() >= item.threshold
223                })
224        } else {
225            false
226        }
227    }
228
229    /// Voxygen has an existing list of character states; however that list does
230    /// not provide enough resolution to target specific entity events, such
231    /// as opening or closing the glider. These methods translate those
232    /// entity states with some additional data into more specific
233    /// `SfxEvent`'s which we attach sounds to
234    fn map_movement_event(
235        character_state: &CharacterState,
236        physics_state: &PhysicsState,
237        previous_state: &PreviousEntityState,
238        vel: Vec3<f32>,
239    ) -> SfxEvent {
240        // Match run / roll / swim state
241        if physics_state.in_liquid().is_some() && vel.magnitude() > 2.0
242            || !previous_state.in_water && physics_state.in_liquid().is_some()
243        {
244            return SfxEvent::Swim;
245        } else if let Some(block) = physics_state.on_ground
246            && (vel.magnitude() > 0.1 || !previous_state.on_ground)
247        {
248            return if let CharacterState::Roll(data) = character_state {
249                if data.static_data.was_cancel {
250                    SfxEvent::RollCancel
251                } else {
252                    SfxEvent::Roll
253                }
254            } else if character_state.is_stealthy() {
255                SfxEvent::Sneak
256            } else {
257                match block.kind() {
258                    BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::Run(BlockKind::Snow),
259                    BlockKind::Rock
260                    | BlockKind::WeakRock
261                    | BlockKind::GlowingRock
262                    | BlockKind::GlowingWeakRock
263                    | BlockKind::Ice => SfxEvent::Run(BlockKind::Rock),
264                    BlockKind::Earth => SfxEvent::Run(BlockKind::Earth),
265                    // BlockKind::Sand => SfxEvent::Run(BlockKind::Sand),
266                    BlockKind::Air => SfxEvent::Idle,
267                    _ => SfxEvent::Run(BlockKind::Grass),
268                }
269            };
270        }
271
272        // Match all other Movemement and Action states
273        match (previous_state.event.clone(), character_state) {
274            (_, CharacterState::Climb { .. }) => SfxEvent::Climb,
275            (_, CharacterState::Glide(glide))
276                if matches!(glide.booster, Some(states::glide::Boost::Forward(_))) =>
277            {
278                if rand::rng().random_bool(0.5) {
279                    SfxEvent::FlameThrower
280                } else {
281                    SfxEvent::Idle
282                }
283            },
284            (_, CharacterState::Glide(_)) => SfxEvent::Glide,
285            _ => SfxEvent::Idle,
286        }
287    }
288
289    /// Maps a limited set of movements for other non-humanoid entities
290    fn map_non_humanoid_movement_event(
291        physics_state: &PhysicsState,
292        previous_state: &PreviousEntityState,
293        vel: Vec3<f32>,
294    ) -> SfxEvent {
295        if physics_state.in_liquid().is_some()
296            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
297        {
298            SfxEvent::Swim
299        } else if let Some(block) = physics_state.on_ground
300            && vel.magnitude() > 0.1
301        {
302            match block.kind() {
303                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::Run(BlockKind::Snow),
304                BlockKind::Rock
305                | BlockKind::WeakRock
306                | BlockKind::GlowingRock
307                | BlockKind::GlowingWeakRock
308                | BlockKind::Ice => SfxEvent::Run(BlockKind::Rock),
309                // BlockKind::Sand => SfxEvent::Run(BlockKind::Sand),
310                BlockKind::Earth => SfxEvent::Run(BlockKind::Earth),
311                BlockKind::Air => SfxEvent::Idle,
312                _ => SfxEvent::Run(BlockKind::Grass),
313            }
314        } else {
315            SfxEvent::Idle
316        }
317    }
318
319    /// Maps a limited set of movements for quadruped entities
320    fn map_quadruped_movement_event(
321        physics_state: &PhysicsState,
322        previous_state: &PreviousEntityState,
323        vel: Vec3<f32>,
324    ) -> SfxEvent {
325        if physics_state.in_liquid().is_some()
326            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
327        {
328            SfxEvent::Swim
329        } else if let Some(block) = physics_state.on_ground
330            && vel.magnitude() > 0.1
331        {
332            match block.kind() {
333                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::QuadRun(BlockKind::Snow),
334                BlockKind::Rock
335                | BlockKind::WeakRock
336                | BlockKind::GlowingRock
337                | BlockKind::GlowingWeakRock
338                | BlockKind::Ice => SfxEvent::QuadRun(BlockKind::Rock),
339                // BlockKind::Sand => SfxEvent::QuadRun(BlockKind::Sand),
340                BlockKind::Earth => SfxEvent::QuadRun(BlockKind::Earth),
341                BlockKind::Air => SfxEvent::Idle,
342                _ => SfxEvent::QuadRun(BlockKind::Grass),
343            }
344        } else {
345            SfxEvent::Idle
346        }
347    }
348
349    /// Maps a limited set of movements for arthropod entities
350    fn map_arthropod_movement_event(
351        physics_state: &PhysicsState,
352        previous_state: &PreviousEntityState,
353        vel: Vec3<f32>,
354    ) -> SfxEvent {
355        if physics_state.in_liquid().is_some()
356            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
357        {
358            SfxEvent::Swim
359        } else if let Some(block) = physics_state.on_ground
360            && vel.magnitude() > 0.1
361        {
362            match block.kind() {
363                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::OctoRun(BlockKind::Snow),
364                BlockKind::Rock
365                | BlockKind::WeakRock
366                | BlockKind::GlowingRock
367                | BlockKind::GlowingWeakRock
368                | BlockKind::Ice => SfxEvent::OctoRun(BlockKind::Rock),
369                // BlockKind::Sand => SfxEvent::OctoRun(BlockKind::Sand),
370                BlockKind::Earth => SfxEvent::OctoRun(BlockKind::Earth),
371                BlockKind::Air => SfxEvent::Idle,
372                _ => SfxEvent::OctoRun(BlockKind::Grass),
373            }
374        } else {
375            SfxEvent::Idle
376        }
377    }
378
379    /// Returns a relative volume value for a body type. This helps us emit sfx
380    /// at a volume appropriate fot the entity we are emitting the event for
381    fn get_volume_for_body_type(body: &Body) -> f32 {
382        match body {
383            Body::Humanoid(_) => 0.5,
384            Body::QuadrupedSmall(_) => 0.2,
385            Body::QuadrupedMedium(_) => 0.7,
386            Body::QuadrupedLow(_) => 0.7,
387            Body::BirdMedium(_) => 0.3,
388            Body::BirdLarge(_) => 0.5,
389            Body::BipedLarge(_) => 1.0,
390            _ => 0.9,
391        }
392    }
393}
394
395#[cfg(test)] mod tests;