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, 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                    );
158                    internal_state.time = Instant::now();
159                    internal_state.steps_taken = 0.0;
160                }
161
162                // update state to determine the next event. We only record the time (above) if
163                // it was dispatched
164                internal_state.event = mapped_event;
165                internal_state.on_ground = physics.on_ground.is_some();
166                internal_state.in_water = physics.in_liquid().is_some();
167                let dt = ecs.fetch::<DeltaTime>().0;
168                internal_state.steps_taken +=
169                    vel.0.magnitude() * dt / (body.stride_length() * scale.map_or(1.0, |s| s.0));
170                internal_state.is_stepping = footsteps.get(entity).map(|f| f.is_any_stepping());
171            }
172        }
173
174        self.cleanup(player_entity);
175    }
176}
177
178impl MovementEventMapper {
179    pub fn new() -> Self {
180        Self {
181            event_history: HashMap::new(),
182        }
183    }
184
185    /// As the player explores the world, we track the last event of the nearby
186    /// entities to determine the correct SFX item to play next based on
187    /// their activity. `cleanup` will remove entities from event tracking if
188    /// they have not triggered an event for > n seconds. This prevents
189    /// stale records from bloating the Map size.
190    fn cleanup(&mut self, player: EcsEntity) {
191        const TRACKING_TIMEOUT: u64 = 10;
192
193        let now = Instant::now();
194        self.event_history.retain(|entity, event| {
195            now.duration_since(event.time) < Duration::from_secs(TRACKING_TIMEOUT)
196                || entity.id() == player.id()
197        });
198    }
199
200    /// When specific entity movements are detected, the associated sound (if
201    /// any) needs to satisfy two conditions to be allowed to play:
202    /// 1. An sfx.ron entry exists for the movement (we need to know which sound
203    ///    file(s) to play)
204    /// 2. The sfx has not been played since it's timeout threshold has elapsed,
205    ///    which prevents firing every tick. For movement, threshold is not a
206    ///    time, but a distance.
207    fn should_emit(
208        previous_state: &PreviousEntityState,
209        sfx_trigger_item: Option<(&SfxEvent, &SfxTriggerItem)>,
210        physics: &PhysicsState,
211    ) -> bool {
212        if let Some((event, item)) = sfx_trigger_item {
213            let landed = physics.on_ground.is_some() && !previous_state.on_ground;
214            // If possible, use the new footstep detection logic
215            landed
216                || previous_state.is_stepping
217                .filter(|_| matches!(event, SfxEvent::Run(_) | SfxEvent::OctoRun(_) | SfxEvent::QuadRun(_)))
218                // Otherwise, fall back to simply footstep timers
219                .unwrap_or_else(|| match event {
220                    SfxEvent::Climb => previous_state.steps_taken >= item.threshold,
221                    _ => previous_state.time.elapsed().as_secs_f32() >= item.threshold
222                })
223        } else {
224            false
225        }
226    }
227
228    /// Voxygen has an existing list of character states; however that list does
229    /// not provide enough resolution to target specific entity events, such
230    /// as opening or closing the glider. These methods translate those
231    /// entity states with some additional data into more specific
232    /// `SfxEvent`'s which we attach sounds to
233    fn map_movement_event(
234        character_state: &CharacterState,
235        physics_state: &PhysicsState,
236        previous_state: &PreviousEntityState,
237        vel: Vec3<f32>,
238    ) -> SfxEvent {
239        // Match run / roll / swim state
240        if physics_state.in_liquid().is_some() && vel.magnitude() > 2.0
241            || !previous_state.in_water && physics_state.in_liquid().is_some()
242        {
243            return SfxEvent::Swim;
244        } else if let Some(block) = physics_state.on_ground
245            && (vel.magnitude() > 0.1 || !previous_state.on_ground)
246        {
247            return if let CharacterState::Roll(data) = character_state {
248                if data.static_data.was_cancel {
249                    SfxEvent::RollCancel
250                } else {
251                    SfxEvent::Roll
252                }
253            } else if character_state.is_stealthy() {
254                SfxEvent::Sneak
255            } else {
256                match block.kind() {
257                    BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::Run(BlockKind::Snow),
258                    BlockKind::Rock
259                    | BlockKind::WeakRock
260                    | BlockKind::GlowingRock
261                    | BlockKind::GlowingWeakRock
262                    | BlockKind::Ice => SfxEvent::Run(BlockKind::Rock),
263                    BlockKind::Earth => SfxEvent::Run(BlockKind::Earth),
264                    // BlockKind::Sand => SfxEvent::Run(BlockKind::Sand),
265                    BlockKind::Air => SfxEvent::Idle,
266                    _ => SfxEvent::Run(BlockKind::Grass),
267                }
268            };
269        }
270
271        // Match all other Movemement and Action states
272        match (previous_state.event.clone(), character_state) {
273            (_, CharacterState::Climb { .. }) => SfxEvent::Climb,
274            (_, CharacterState::Glide(glide))
275                if matches!(glide.booster, Some(states::glide::Boost::Forward(_))) =>
276            {
277                if rand::rng().random_bool(0.5) {
278                    SfxEvent::FlameThrower
279                } else {
280                    SfxEvent::Idle
281                }
282            },
283            (_, CharacterState::Glide(_)) => SfxEvent::Glide,
284            _ => SfxEvent::Idle,
285        }
286    }
287
288    /// Maps a limited set of movements for other non-humanoid entities
289    fn map_non_humanoid_movement_event(
290        physics_state: &PhysicsState,
291        previous_state: &PreviousEntityState,
292        vel: Vec3<f32>,
293    ) -> SfxEvent {
294        if physics_state.in_liquid().is_some()
295            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
296        {
297            SfxEvent::Swim
298        } else if let Some(block) = physics_state.on_ground
299            && vel.magnitude() > 0.1
300        {
301            match block.kind() {
302                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::Run(BlockKind::Snow),
303                BlockKind::Rock
304                | BlockKind::WeakRock
305                | BlockKind::GlowingRock
306                | BlockKind::GlowingWeakRock
307                | BlockKind::Ice => SfxEvent::Run(BlockKind::Rock),
308                // BlockKind::Sand => SfxEvent::Run(BlockKind::Sand),
309                BlockKind::Earth => SfxEvent::Run(BlockKind::Earth),
310                BlockKind::Air => SfxEvent::Idle,
311                _ => SfxEvent::Run(BlockKind::Grass),
312            }
313        } else {
314            SfxEvent::Idle
315        }
316    }
317
318    /// Maps a limited set of movements for quadruped entities
319    fn map_quadruped_movement_event(
320        physics_state: &PhysicsState,
321        previous_state: &PreviousEntityState,
322        vel: Vec3<f32>,
323    ) -> SfxEvent {
324        if physics_state.in_liquid().is_some()
325            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
326        {
327            SfxEvent::Swim
328        } else if let Some(block) = physics_state.on_ground
329            && vel.magnitude() > 0.1
330        {
331            match block.kind() {
332                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::QuadRun(BlockKind::Snow),
333                BlockKind::Rock
334                | BlockKind::WeakRock
335                | BlockKind::GlowingRock
336                | BlockKind::GlowingWeakRock
337                | BlockKind::Ice => SfxEvent::QuadRun(BlockKind::Rock),
338                // BlockKind::Sand => SfxEvent::QuadRun(BlockKind::Sand),
339                BlockKind::Earth => SfxEvent::QuadRun(BlockKind::Earth),
340                BlockKind::Air => SfxEvent::Idle,
341                _ => SfxEvent::QuadRun(BlockKind::Grass),
342            }
343        } else {
344            SfxEvent::Idle
345        }
346    }
347
348    /// Maps a limited set of movements for arthropod entities
349    fn map_arthropod_movement_event(
350        physics_state: &PhysicsState,
351        previous_state: &PreviousEntityState,
352        vel: Vec3<f32>,
353    ) -> SfxEvent {
354        if physics_state.in_liquid().is_some()
355            && (vel.magnitude() > 2.0 || !previous_state.on_ground)
356        {
357            SfxEvent::Swim
358        } else if let Some(block) = physics_state.on_ground
359            && vel.magnitude() > 0.1
360        {
361            match block.kind() {
362                BlockKind::Snow | BlockKind::ArtSnow => SfxEvent::OctoRun(BlockKind::Snow),
363                BlockKind::Rock
364                | BlockKind::WeakRock
365                | BlockKind::GlowingRock
366                | BlockKind::GlowingWeakRock
367                | BlockKind::Ice => SfxEvent::OctoRun(BlockKind::Rock),
368                // BlockKind::Sand => SfxEvent::OctoRun(BlockKind::Sand),
369                BlockKind::Earth => SfxEvent::OctoRun(BlockKind::Earth),
370                BlockKind::Air => SfxEvent::Idle,
371                _ => SfxEvent::OctoRun(BlockKind::Grass),
372            }
373        } else {
374            SfxEvent::Idle
375        }
376    }
377
378    /// Returns a relative volume value for a body type. This helps us emit sfx
379    /// at a volume appropriate fot the entity we are emitting the event for
380    fn get_volume_for_body_type(body: &Body) -> f32 {
381        match body {
382            Body::Humanoid(_) => 0.5,
383            Body::QuadrupedSmall(_) => 0.2,
384            Body::QuadrupedMedium(_) => 0.7,
385            Body::QuadrupedLow(_) => 0.7,
386            Body::BirdMedium(_) => 0.3,
387            Body::BirdLarge(_) => 0.5,
388            Body::BipedLarge(_) => 1.0,
389            _ => 0.9,
390        }
391    }
392}
393
394#[cfg(test)] mod tests;