Skip to main content

veloren_voxygen/audio/sfx/
mod.rs

1//! Manages individual sfx event system, listens for sfx events, and requests
2//! playback at the requested position and volume
3//!
4//! Veloren's sfx are managed through a configuration which lives in the
5//! codebase under `/assets/voxygen/audio/sfx.ron`.
6//!
7//! If there are errors while reading or deserialising the configuration file, a
8//! warning is logged and sfx will be disabled.
9//!
10//! Each entry in the configuration consists of an
11//! [SfxEvent](../../../veloren_common/event/enum.SfxEvent.html) item, with some
12//! additional information to allow playback:
13//! - `files` - the paths to the `.wav` files to be played for the sfx. minus
14//!   the file extension. This can be a single item if the same sound can be
15//!   played each time, or a list of files from which one is chosen at random to
16//!   be played.
17//! - `threshold` - the time that the system should wait between successive
18//!   plays. This avoids playing the sound with very fast successive repetition
19//!   when the character can maintain a state over a long period, such as
20//!   running or climbing.
21//!
22//! The following snippet details some entries in the configuration and how they
23//! map to the sound files:
24//! ```ignore
25//! Run(Grass): ( // depends on underfoot block
26//!    files: [
27//!        "voxygen.audio.sfx.footsteps.stepgrass_1",
28//!        "voxygen.audio.sfx.footsteps.stepgrass_2",
29//!        "voxygen.audio.sfx.footsteps.stepgrass_3",
30//!        "voxygen.audio.sfx.footsteps.stepgrass_4",
31//!        "voxygen.audio.sfx.footsteps.stepgrass_5",
32//!        "voxygen.audio.sfx.footsteps.stepgrass_6",
33//!    ],
34//!    threshold: 1.6, // travelled distance before next play
35//! ),
36//! Wield(Sword): ( // depends on the player's weapon
37//!    files: [
38//!        "voxygen.audio.sfx.weapon.sword_out",
39//!    ],
40//!    threshold: 0.5, // wait 0.5s between plays
41//! ),
42//! ...
43//! ```
44//!
45//! These items (for example, the `Wield(Sword)` occasionally depend on some
46//! property which varies in game. The
47//! [SfxEvent](../../../veloren_common/event/enum.SfxEvent.html) documentation
48//! provides links to those variables, some examples are provided her for longer
49//! items:
50//!
51//! ```ignore
52//! // An inventory action
53//! Inventory(Dropped): (
54//!     files: [
55//!        "voxygen.audio.sfx.footsteps.stepgrass_4",
56//!    ],
57//!    threshold: 0.5,
58//! ),
59//! // An inventory action which depends upon the item
60//! Inventory(Consumed(Apple)): (
61//!    files: [
62//!        "voxygen.audio.sfx.inventory.consumable.apple",
63//!    ],
64//!    threshold: 0.5
65//! ),
66//! // An attack ability which depends on the weapon
67//! Attack(DashMelee, Sword): (
68//!     files: [
69//!         "voxygen.audio.sfx.weapon.sword_dash_01",
70//!         "voxygen.audio.sfx.weapon.sword_dash_02",
71//!     ],
72//!     threshold: 1.2,
73//! ),
74//! ```
75
76mod event_mapper;
77use specs::WorldExt;
78
79use crate::{
80    audio::{
81        AudioFrontend,
82        channel::{SFX_DIST_LIMIT_SQR, UiChannelTag},
83    },
84    scene::{Camera, FigureMgr, Terrain},
85};
86
87use client::Client;
88use common::{
89    DamageSource,
90    assets::{AssetExt, AssetHandle, Ron},
91    comp::{
92        Body, CharacterAbilityType, Health, InventoryUpdateEvent, UtteranceKind, beam, biped_large,
93        biped_small, bird_large, bird_medium, crustacean, humanoid,
94        item::{AbilitySpec, ItemDefinitionId, ItemDesc, ItemKind, ToolKind, item_key::ItemKey},
95        object,
96        poise::PoiseState,
97        quadruped_low, quadruped_medium, quadruped_small,
98    },
99    outcome::Outcome,
100    terrain::{BlockKind, SpriteKind, TerrainChunk},
101    uid::Uid,
102    vol::ReadVol,
103};
104use common_state::State;
105use event_mapper::SfxEventMapper;
106use hashbrown::HashMap;
107use rand::prelude::*;
108use serde::Deserialize;
109use tracing::{debug, error, warn};
110use vek::*;
111
112#[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
113pub enum SfxEvent {
114    Campfire,
115    Embers,
116    Birdcall,
117    Owl,
118    Cricket1,
119    Cricket2,
120    Cricket3,
121    Frog,
122    Bees,
123    RunningWaterSlow,
124    Lavapool,
125    Idle,
126    Swim,
127    SplashSmall,
128    SplashMedium,
129    SplashBig,
130    Run(BlockKind),
131    QuadRun(BlockKind),
132    OctoRun(BlockKind),
133    Roll,
134    RollCancel,
135    Sneak,
136    Climb,
137    GliderOpen,
138    Glide,
139    GliderClose,
140    CatchAir,
141    Jump,
142    Fall,
143    Attack(CharacterAbilityType, ToolKind),
144    Wield(ToolKind),
145    Unwield(ToolKind),
146    Inventory(SfxInventoryEvent),
147    Explosion,
148    Damage,
149    Death,
150    Parry,
151    Block,
152    BreakBlock,
153    PickaxeDamage,
154    PickaxeDamageStrong,
155    PickaxeBreakBlock,
156    SceptreBeam,
157    SkillPointGain,
158    ArrowHit,
159    ArrowMiss,
160    ArrowShot,
161    FireShot,
162    NapalmShot,
163    NapalmImpact,
164    FireBreathShot,
165    FireBreathCharge,
166    PyroclasmCharge,
167    PyroclasmBolt,
168    FlameThrower,
169    PoiseChange(PoiseState),
170    GroundSlam,
171    FlashFreeze,
172    GigaRoar,
173    IceSpikes,
174    IceCrack,
175    Utterance(UtteranceKind, VoiceKind),
176    Lightning,
177    CyclopsCharge,
178    TerracottaStatueCharge,
179    LaserBeam,
180    Steam,
181    FuseCharge,
182    Music(ToolKind, AbilitySpec),
183    Yeet,
184    Hiss,
185    LongHiss,
186    Klonk,
187    SmashKlonk,
188    FireShockwave,
189    DeepLaugh,
190    Whoosh,
191    Swoosh,
192    GroundDig,
193    PortalActivated,
194    TeleportedByPortal,
195    FromTheAshes,
196    SurpriseEgg,
197    Transformation,
198    Bleep,
199    Charge,
200    StrigoiHead,
201    BloodmoonHeiressSummon,
202    TrainChugg,
203    TrainChuggSteam,
204    TrainAmbience,
205    TrainClack,
206    TrainSpeed,
207}
208
209#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
210pub enum VoiceKind {
211    HumanFemale,
212    HumanMale,
213    BipedLarge,
214    Wendigo,
215    Reptile,
216    Bird,
217    Critter,
218    Sheep,
219    Pig,
220    Cow,
221    Canine,
222    Dagon,
223    Lion,
224    Mindflayer,
225    Marlin,
226    Maneater,
227    Adlet,
228    Antelope,
229    Alligator,
230    SeaCrocodile,
231    Saurok,
232    Cat,
233    Goat,
234    Mandragora,
235    Asp,
236    Fungome,
237    Truffler,
238    Wolf,
239    Wyvern,
240    Phoenix,
241    VampireBat,
242    Legoom,
243}
244
245fn body_to_voice(body: &Body) -> Option<VoiceKind> {
246    Some(match body {
247        Body::Humanoid(body) => match &body.body_type {
248            humanoid::BodyType::Female => VoiceKind::HumanFemale,
249            humanoid::BodyType::Male => VoiceKind::HumanMale,
250        },
251        Body::QuadrupedLow(body) => match body.species {
252            quadruped_low::Species::Maneater => VoiceKind::Maneater,
253            quadruped_low::Species::Alligator | quadruped_low::Species::Snaretongue => {
254                VoiceKind::Alligator
255            },
256            quadruped_low::Species::SeaCrocodile => VoiceKind::SeaCrocodile,
257            quadruped_low::Species::Dagon => VoiceKind::Dagon,
258            quadruped_low::Species::Asp => VoiceKind::Asp,
259            _ => return None,
260        },
261        Body::QuadrupedSmall(body) => match body.species {
262            quadruped_small::Species::Truffler => VoiceKind::Truffler,
263            quadruped_small::Species::Fungome => VoiceKind::Fungome,
264            quadruped_small::Species::Sheep => VoiceKind::Sheep,
265            quadruped_small::Species::Pig | quadruped_small::Species::Boar => VoiceKind::Pig,
266            quadruped_small::Species::Cat => VoiceKind::Cat,
267            quadruped_small::Species::Goat => VoiceKind::Goat,
268            _ => VoiceKind::Critter,
269        },
270        Body::QuadrupedMedium(body) => match body.species {
271            quadruped_medium::Species::Saber
272            | quadruped_medium::Species::Tiger
273            | quadruped_medium::Species::Lion
274            | quadruped_medium::Species::Frostfang
275            | quadruped_medium::Species::Snowleopard => VoiceKind::Lion,
276            quadruped_medium::Species::Wolf => VoiceKind::Wolf,
277            quadruped_medium::Species::Roshwalr
278            | quadruped_medium::Species::Tarasque
279            | quadruped_medium::Species::Darkhound
280            | quadruped_medium::Species::Bonerattler
281            | quadruped_medium::Species::Grolgar => VoiceKind::Canine,
282            quadruped_medium::Species::Cattle
283            | quadruped_medium::Species::Catoblepas
284            | quadruped_medium::Species::Highland
285            | quadruped_medium::Species::Yak
286            | quadruped_medium::Species::Moose
287            | quadruped_medium::Species::Dreadhorn => VoiceKind::Cow,
288            quadruped_medium::Species::Antelope => VoiceKind::Antelope,
289            _ => return None,
290        },
291        Body::BirdMedium(body) => match body.species {
292            bird_medium::Species::BloodmoonBat | bird_medium::Species::VampireBat => {
293                VoiceKind::VampireBat
294            },
295            _ => VoiceKind::Bird,
296        },
297        Body::BirdLarge(body) => match body.species {
298            bird_large::Species::CloudWyvern
299            | bird_large::Species::FlameWyvern
300            | bird_large::Species::FrostWyvern
301            | bird_large::Species::SeaWyvern
302            | bird_large::Species::WealdWyvern => VoiceKind::Wyvern,
303            bird_large::Species::Phoenix => VoiceKind::Phoenix,
304            _ => VoiceKind::Bird,
305        },
306        Body::BipedSmall(body) => match body.species {
307            biped_small::Species::Adlet => VoiceKind::Adlet,
308            biped_small::Species::Mandragora => VoiceKind::Mandragora,
309            biped_small::Species::Flamekeeper => VoiceKind::BipedLarge,
310            biped_small::Species::GreenLegoom
311            | biped_small::Species::OchreLegoom
312            | biped_small::Species::PurpleLegoom
313            | biped_small::Species::RedLegoom
314            | biped_small::Species::UmberLegoom => VoiceKind::Legoom,
315            _ => return None,
316        },
317        Body::BipedLarge(body) => match body.species {
318            biped_large::Species::Wendigo => VoiceKind::Wendigo,
319            biped_large::Species::Occultsaurok
320            | biped_large::Species::Mightysaurok
321            | biped_large::Species::Slysaurok => VoiceKind::Saurok,
322            biped_large::Species::Mindflayer => VoiceKind::Mindflayer,
323            _ => VoiceKind::BipedLarge,
324        },
325        Body::Theropod(_) | Body::Dragon(_) => VoiceKind::Reptile,
326        Body::FishSmall(_) | Body::FishMedium(_) => VoiceKind::Marlin,
327        _ => return None,
328    })
329}
330
331#[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
332pub enum SfxInventoryEvent {
333    Collected,
334    CollectedTool(ToolKind),
335    CollectedItem(String),
336    CollectFailed,
337    Consumed(ItemKey),
338    Debug,
339    Dropped,
340    Given,
341    Swapped,
342    Craft,
343}
344
345// TODO Move to a separate event mapper?
346impl From<&InventoryUpdateEvent> for SfxEvent {
347    fn from(value: &InventoryUpdateEvent) -> Self {
348        match value {
349            InventoryUpdateEvent::Collected(item) => {
350                // Handle sound effects for types of collected items, falling
351                // back to the default Collected event
352                match &*item.kind() {
353                    ItemKind::Tool(tool) => {
354                        SfxEvent::Inventory(SfxInventoryEvent::CollectedTool(tool.kind))
355                    },
356                    ItemKind::Ingredient { .. }
357                        if matches!(
358                            item.item_definition_id(),
359                            ItemDefinitionId::Simple(id) if id.contains("mineral.gem.")
360                        ) =>
361                    {
362                        SfxEvent::Inventory(SfxInventoryEvent::CollectedItem(String::from(
363                            "Gemstone",
364                        )))
365                    },
366                    _ => SfxEvent::Inventory(SfxInventoryEvent::Collected),
367                }
368            },
369            InventoryUpdateEvent::BlockCollectFailed { .. }
370            | InventoryUpdateEvent::EntityCollectFailed { .. } => {
371                SfxEvent::Inventory(SfxInventoryEvent::CollectFailed)
372            },
373            InventoryUpdateEvent::Consumed(consumable) => {
374                SfxEvent::Inventory(SfxInventoryEvent::Consumed(consumable.clone()))
375            },
376            InventoryUpdateEvent::Debug => SfxEvent::Inventory(SfxInventoryEvent::Debug),
377            InventoryUpdateEvent::Dropped => SfxEvent::Inventory(SfxInventoryEvent::Dropped),
378            InventoryUpdateEvent::Given => SfxEvent::Inventory(SfxInventoryEvent::Given),
379            InventoryUpdateEvent::Swapped => SfxEvent::Inventory(SfxInventoryEvent::Swapped),
380            InventoryUpdateEvent::Craft => SfxEvent::Inventory(SfxInventoryEvent::Craft),
381            _ => SfxEvent::Inventory(SfxInventoryEvent::Swapped),
382        }
383    }
384}
385
386#[derive(Deserialize, Debug)]
387pub struct SfxTriggerItem {
388    /// A list of SFX filepaths for this event
389    pub files: Vec<String>,
390    /// The time to wait before repeating this SfxEvent
391    pub threshold: f32,
392
393    #[serde(default)]
394    pub subtitle: Option<String>,
395}
396
397pub type SfxTriggers = Ron<HashMap<SfxEvent, SfxTriggerItem>>;
398
399/// Tags in descending order of priority
400/// Tags lower in the list will be interrupted by tags higher in the list if no
401/// channels are available
402#[derive(Copy, Clone, Debug, PartialEq)]
403pub enum SfxTag {
404    /// Looping sfx e.g. campfires
405    Looping,
406    /// Hitsounds, abilities, moves
407    Combat,
408    /// Items, utility equipment, block interactions
409    Interaction,
410    /// Trains, airships, etc.
411    Vehicle,
412    /// NPC utterances
413    Utterance,
414    /// Sounds emitted by blocks meant to represent in-world entities (e.g.
415    /// birds, frogs)
416    Blocksound,
417    /// All footsteps and some types of movement, but not movement that relates
418    /// to combat
419    Footstep,
420    /// Unimportant sounds emitted by blocks that are meant to add to the
421    /// natural soundscape (e.g. bubbling)
422    Ambient,
423}
424
425pub struct SfxMgr {
426    /// This is an `AssetHandle` so it is reloaded automatically
427    /// when the manifest is edited.
428    pub triggers: AssetHandle<SfxTriggers>,
429    event_mapper: SfxEventMapper,
430}
431
432impl Default for SfxMgr {
433    fn default() -> Self {
434        Self {
435            triggers: Self::load_sfx_items(),
436            event_mapper: SfxEventMapper::new(),
437        }
438    }
439}
440
441impl SfxMgr {
442    pub fn maintain(
443        &mut self,
444        audio: &mut AudioFrontend,
445        state: &State,
446        player_entity: specs::Entity,
447        camera: &Camera,
448        terrain: &Terrain<TerrainChunk>,
449        client: &Client,
450        figure_mgr: &FigureMgr,
451    ) {
452        // Checks if the SFX volume is set to zero or audio is disabled
453        // This prevents us from running all the following code unnecessarily
454        if !audio.sfx_enabled() && !audio.subtitles_enabled {
455            return;
456        }
457
458        let cam_pos = camera.get_pos_with_focus();
459
460        // Sets the listener position to the camera position facing the
461        // same direction as the camera
462        audio.set_listener_pos(cam_pos, camera.dependents().cam_dir);
463
464        let triggers = self.triggers.read();
465
466        let underwater = state
467            .terrain()
468            .get(cam_pos.map(|e| e.floor() as i32))
469            .map(|b| b.is_liquid())
470            .unwrap_or(false);
471
472        if underwater {
473            audio.set_sfx_master_filter(888);
474        } else {
475            audio.set_sfx_master_filter(20000);
476        }
477
478        let player_pos = client.position().unwrap_or_default();
479
480        // Update continuing sounds with player position
481        if let Some(inner) = audio.inner.as_mut() {
482            inner.player_pos = player_pos;
483            inner.channels.sfx.iter_mut().for_each(|c| {
484                if !c.is_done() {
485                    c.update(player_pos)
486                }
487            })
488        }
489
490        self.event_mapper.maintain(
491            audio,
492            state,
493            player_entity,
494            camera,
495            &triggers,
496            terrain,
497            client,
498            figure_mgr,
499        );
500    }
501
502    #[expect(clippy::single_match)]
503    pub fn handle_outcome(
504        &mut self,
505        outcome: &Outcome,
506        audio: &mut AudioFrontend,
507        client: &Client,
508    ) {
509        if !audio.sfx_enabled() && !audio.subtitles_enabled {
510            return;
511        }
512        let triggers = self.triggers.read();
513        let uids = client.state().ecs().read_storage::<Uid>();
514        if audio.get_listener().is_none() {
515            return;
516        }
517        match outcome {
518            Outcome::Explosion { pos, power, .. } => {
519                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
520                audio.emit_sfx(
521                    sfx_trigger_item,
522                    *pos,
523                    Some((power.abs() / 2.5).min(1.5)),
524                    SfxTag::Combat,
525                );
526            },
527            Outcome::Lightning { pos } => {
528                let distance = pos.distance(audio.get_listener_pos());
529                let power = (1.0 - distance / 6_000.0).max(0.0).powi(7);
530                if power > 0.0 {
531                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Lightning);
532                    let volume = (power * 3.0).min(2.9);
533                    // Delayed based on distance / speed of sound (approxmately 340 m/s)
534                    audio.play_ambience_oneshot(
535                        super::channel::AmbienceChannelTag::Thunder,
536                        sfx_trigger_item,
537                        Some(volume),
538                        Some(distance / 340.0),
539                    );
540                }
541            },
542            Outcome::GroundSlam { pos, .. } | Outcome::ClayGolemDash { pos, .. } => {
543                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundSlam);
544                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
545            },
546            Outcome::SurpriseEgg { pos, .. } => {
547                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SurpriseEgg);
548                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
549            },
550            Outcome::Transformation { pos, .. } => {
551                // TODO: Give this a sound
552                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Transformation);
553                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
554            },
555            Outcome::LaserBeam { pos, .. } => {
556                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
557                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
558            },
559            Outcome::CyclopsCharge { pos, .. } => {
560                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
561                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
562            },
563            Outcome::FlamethrowerCharge { pos, .. }
564            | Outcome::TerracottaStatueCharge { pos, .. } => {
565                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
566                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
567            },
568            Outcome::PyroclasmCharge { pos, .. } => {
569                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmCharge);
570                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
571            },
572            Outcome::FireBreathCharge { pos, .. } => {
573                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireBreathCharge);
574                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
575            },
576            Outcome::FuseCharge { pos, .. } => {
577                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FuseCharge);
578                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
579            },
580            Outcome::Charge { pos, .. } => {
581                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
582                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
583            },
584            Outcome::FlashFreeze { pos, .. } => {
585                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlashFreeze);
586                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
587            },
588            Outcome::SummonedCreature { pos, body, .. } => {
589                match body {
590                    Body::BipedSmall(body) => match body.species {
591                        biped_small::Species::IronDwarf => {
592                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
593                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
594                        },
595                        biped_small::Species::Boreal | biped_small::Species::Ashen => {
596                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GigaRoar);
597                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
598                        },
599                        biped_small::Species::ShamanicSpirit | biped_small::Species::Jiangshi => {
600                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
601                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
602                        },
603                        _ => {},
604                    },
605                    Body::BipedLarge(body) => match body.species {
606                        biped_large::Species::TerracottaBesieger
607                        | biped_large::Species::TerracottaPursuer => {
608                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
609                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
610                        },
611                        _ => {},
612                    },
613                    Body::BirdMedium(body) => match body.species {
614                        bird_medium::Species::Bat => {
615                            let sfx_trigger_item =
616                                triggers.0.get_key_value(&SfxEvent::BloodmoonHeiressSummon);
617                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
618                        },
619                        _ => {},
620                    },
621                    Body::Crustacean(body) => match body.species {
622                        crustacean::Species::SoldierCrab => {
623                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Hiss);
624                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
625                        },
626                        _ => {},
627                    },
628                    Body::Object(object::Body::Lavathrower) => {
629                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::DeepLaugh);
630                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
631                    },
632                    Body::Object(object::Body::SeaLantern) => {
633                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LongHiss);
634                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
635                    },
636                    Body::Object(object::Body::Tornado)
637                    | Body::Object(object::Body::FieryTornado) => {
638                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
639                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
640                    },
641                    _ => { // not mapped to sfx file
642                    },
643                }
644            },
645            Outcome::GroundDig { pos, .. } => {
646                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundDig);
647                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
648            },
649            Outcome::PortalActivated { pos, .. } => {
650                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PortalActivated);
651                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
652            },
653            Outcome::TeleportedByPortal { pos, .. } => {
654                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::TeleportedByPortal);
655                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
656            },
657            Outcome::IceSpikes { pos, .. } => {
658                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceSpikes);
659                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
660            },
661            Outcome::IceCrack { pos, .. } => {
662                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceCrack);
663                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
664            },
665            Outcome::Steam { pos, .. } => {
666                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Steam);
667                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
668            },
669            Outcome::FireShockwave { pos, .. } | Outcome::FireLowShockwave { pos, .. } => {
670                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
671                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
672            },
673            Outcome::FromTheAshes { pos, .. } => {
674                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FromTheAshes);
675                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
676            },
677            Outcome::ProjectileShot { pos, body, .. } => {
678                match body {
679                    Body::Object(
680                        object::Body::Arrow
681                        | object::Body::MultiArrow
682                        | object::Body::ArrowSnake
683                        | object::Body::ArrowTurret
684                        | object::Body::ArrowClay
685                        | object::Body::ArrowHeavy
686                        | object::Body::BoltBesieger
687                        | object::Body::HarlequinDagger
688                        | object::Body::SpectralSwordSmall
689                        | object::Body::SpectralSwordLarge,
690                    ) => {
691                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowShot);
692                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
693                    },
694                    Body::Object(
695                        object::Body::BoltFire
696                        | object::Body::BoltFireBig
697                        | object::Body::BoltNature
698                        | object::Body::BoltIcicle
699                        | object::Body::SpearIcicle
700                        | object::Body::GrenadeClay
701                        | object::Body::SpitPoison,
702                    ) => {
703                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireShot);
704                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
705                    },
706                    Body::Object(object::Body::NapalmShot) => {
707                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmShot);
708                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
709                    },
710                    Body::Object(object::Body::FireRing) => {},
711                    Body::Object(object::Body::PyroclasmBolt) => {
712                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmBolt);
713                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
714                    },
715                    Body::Object(
716                        object::Body::IronPikeBomb
717                        | object::Body::BubbleBomb
718                        | object::Body::MinotaurAxe
719                        | object::Body::Pebble,
720                    ) => {
721                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
722                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
723                    },
724                    Body::Object(
725                        object::Body::LaserBeam
726                        | object::Body::LaserBeamSmall
727                        | object::Body::LightningBolt,
728                    ) => {
729                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
730                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
731                    },
732                    Body::Object(
733                        object::Body::AdletTrap | object::Body::BorealTrap | object::Body::Mine,
734                    ) => {
735                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Yeet);
736                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
737                    },
738                    Body::Object(object::Body::StrigoiHead) => {
739                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::StrigoiHead);
740                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
741                    },
742                    _ => {
743                        // not mapped to sfx file
744                    },
745                }
746            },
747            Outcome::ProjectileHit {
748                pos,
749                body,
750                source,
751                target,
752                ..
753            } => match body {
754                Body::Object(
755                    object::Body::Arrow
756                    | object::Body::MultiArrow
757                    | object::Body::ArrowSnake
758                    | object::Body::ArrowTurret
759                    | object::Body::ArrowClay
760                    | object::Body::ArrowHeavy
761                    | object::Body::BoltBesieger
762                    | object::Body::HarlequinDagger
763                    | object::Body::SpectralSwordSmall
764                    | object::Body::SpectralSwordLarge
765                    | object::Body::Pebble,
766                ) => {
767                    if target.is_none() {
768                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowMiss);
769                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
770                    } else if *source == client.uid() {
771                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
772                        audio.emit_sfx(
773                            sfx_trigger_item,
774                            client.position().unwrap_or(*pos),
775                            Some(2.0),
776                            SfxTag::Combat,
777                        );
778                    } else {
779                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
780                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
781                    }
782                },
783                Body::Object(
784                    object::Body::AdletTrap
785                    | object::Body::BorealTrap
786                    | object::Body::Mine
787                    | object::Body::StrigoiHead,
788                ) => {
789                    if target.is_none() {
790                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
791                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
792                    } else if *source == client.uid() {
793                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
794                        audio.emit_sfx(
795                            sfx_trigger_item,
796                            client.position().unwrap_or(*pos),
797                            Some(2.0),
798                            SfxTag::Combat,
799                        );
800                    } else {
801                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
802                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
803                    }
804                },
805                Body::Object(object::Body::NapalmShot) => {
806                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmImpact);
807                    audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
808                },
809                _ => {},
810            },
811            Outcome::SkillPointGain { uid, .. } => {
812                if let Some(client_uid) = uids.get(client.entity())
813                    && uid == client_uid
814                {
815                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SkillPointGain);
816                    audio.emit_ui_sfx(sfx_trigger_item, Some(0.4), Some(UiChannelTag::LevelUp));
817                }
818            },
819            Outcome::Beam { pos, specifier } => match specifier {
820                beam::FrontendSpecifier::LifestealBeam
821                | beam::FrontendSpecifier::Steam
822                | beam::FrontendSpecifier::Poison
823                | beam::FrontendSpecifier::Ink
824                | beam::FrontendSpecifier::Lightning
825                | beam::FrontendSpecifier::Frost
826                | beam::FrontendSpecifier::Bubbles => {
827                    if rand::rng().random_bool(0.5) {
828                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SceptreBeam);
829                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
830                    };
831                },
832                beam::FrontendSpecifier::Flamethrower
833                | beam::FrontendSpecifier::Cultist
834                | beam::FrontendSpecifier::PhoenixLaser
835                | beam::FrontendSpecifier::FireGigasOverheat
836                | beam::FrontendSpecifier::FirePillar => {
837                    if rand::rng().random_bool(0.5) {
838                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
839                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
840                    }
841                },
842                beam::FrontendSpecifier::FlameWallPillar => {
843                    if rand::rng().random_bool(0.02) {
844                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
845                        audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
846                    }
847                },
848                beam::FrontendSpecifier::Gravewarden | beam::FrontendSpecifier::WebStrand => {},
849            },
850            Outcome::SpriteUnlocked { pos } => {
851                // TODO: Dedicated sound effect!
852                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
853                audio.emit_sfx(
854                    sfx_trigger_item,
855                    pos.map(|e| e as f32 + 0.5),
856                    Some(2.0),
857                    SfxTag::Interaction,
858                );
859            },
860            Outcome::FailedSpriteUnlock { pos } => {
861                // TODO: Dedicated sound effect!
862                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::BreakBlock);
863                audio.emit_sfx(
864                    sfx_trigger_item,
865                    pos.map(|e| e as f32 + 0.5),
866                    Some(2.0),
867                    SfxTag::Interaction,
868                );
869            },
870            Outcome::BreakBlock { pos, tool, .. } => {
871                let sfx_trigger_item =
872                    triggers
873                        .0
874                        .get_key_value(&if matches!(tool, Some(ToolKind::Pick)) {
875                            SfxEvent::PickaxeBreakBlock
876                        } else {
877                            SfxEvent::BreakBlock
878                        });
879                audio.emit_sfx(
880                    sfx_trigger_item,
881                    pos.map(|e| e as f32 + 0.5),
882                    Some(1.2),
883                    SfxTag::Interaction,
884                );
885            },
886            Outcome::DamagedBlock {
887                pos,
888                stage_changed,
889                tool,
890                ..
891            } => {
892                let sfx_trigger_item = triggers.0.get_key_value(&match (stage_changed, tool) {
893                    (false, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamage,
894                    (true, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamageStrong,
895                    // SFX already emitted by ability
896                    (_, Some(ToolKind::Shovel)) => return,
897                    (_, _) => SfxEvent::BreakBlock,
898                });
899
900                audio.emit_sfx(
901                    sfx_trigger_item,
902                    pos.map(|e| e as f32 + 0.5),
903                    Some(1.0),
904                    SfxTag::Interaction,
905                );
906            },
907            Outcome::HealthChange { pos, info, .. } => {
908                // Ignore positive damage (healing) and buffs for now
909                if info.amount < Health::HEALTH_EPSILON
910                    && !matches!(info.cause, Some(DamageSource::Buff(_)))
911                {
912                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Damage);
913                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
914                }
915            },
916            Outcome::Death { pos, .. } => {
917                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
918                audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
919            },
920            Outcome::Block { pos, parry, .. } => {
921                if *parry {
922                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Parry);
923                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
924                } else {
925                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Block);
926                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
927                }
928            },
929            Outcome::PoiseChange {
930                pos,
931                state: poise_state,
932                ..
933            } => match poise_state {
934                PoiseState::Normal => {},
935                PoiseState::Interrupted => {
936                    let sfx_trigger_item = triggers
937                        .0
938                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Interrupted));
939                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
940                },
941                PoiseState::Stunned => {
942                    let sfx_trigger_item = triggers
943                        .0
944                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Stunned));
945                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
946                },
947                PoiseState::Dazed => {
948                    let sfx_trigger_item = triggers
949                        .0
950                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Dazed));
951                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
952                },
953                PoiseState::KnockedDown => {
954                    let sfx_trigger_item = triggers
955                        .0
956                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::KnockedDown));
957                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
958                },
959            },
960            Outcome::Utterance { pos, kind, body } => {
961                if let Some(voice) = body_to_voice(body) {
962                    let sfx_trigger_item =
963                        triggers.0.get_key_value(&SfxEvent::Utterance(*kind, voice));
964                    if let Some(sfx_trigger_item) = sfx_trigger_item {
965                        // TODO: Dirty hack to turn down the volume of one creature. Need another
966                        // way to do this.
967                        if matches!(voice, VoiceKind::Wolf) {
968                            audio.emit_sfx(
969                                Some(sfx_trigger_item),
970                                *pos,
971                                Some(0.75),
972                                SfxTag::Combat,
973                            );
974                        } else {
975                            audio.emit_sfx(
976                                Some(sfx_trigger_item),
977                                *pos,
978                                Some(1.5),
979                                SfxTag::Utterance,
980                            );
981                        }
982                    } else {
983                        debug!(
984                            "No utterance sound effect exists for ({:?}, {:?})",
985                            kind, voice
986                        );
987                    }
988                }
989            },
990            Outcome::Glider { pos, wielded } => {
991                if *wielded {
992                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
993                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), SfxTag::Interaction);
994                } else {
995                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderClose);
996                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), SfxTag::Interaction);
997                }
998            },
999            Outcome::SpriteDelete {
1000                pos,
1001                sprite: SpriteKind::SeaUrchin,
1002            } => {
1003                let pos = pos.map(|e| e as f32 + 0.5);
1004                let power = (0.6 - pos.distance(audio.get_listener_pos()) / 5_000.0)
1005                    .max(0.0)
1006                    .powi(7);
1007                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
1008                audio.emit_sfx(
1009                    sfx_trigger_item,
1010                    pos,
1011                    Some((power.abs() / 2.5).min(0.3)),
1012                    SfxTag::Combat,
1013                );
1014            },
1015            Outcome::Whoosh { pos, .. } => {
1016                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
1017                audio.emit_sfx(
1018                    sfx_trigger_item,
1019                    pos.map(|e| e + 0.5),
1020                    Some(3.0),
1021                    SfxTag::Combat,
1022                );
1023            },
1024            Outcome::Swoosh { pos, .. } => {
1025                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
1026                audio.emit_sfx(
1027                    sfx_trigger_item,
1028                    pos.map(|e| e + 0.5),
1029                    Some(3.0),
1030                    SfxTag::Combat,
1031                );
1032            },
1033            Outcome::Slash { pos, .. } => {
1034                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
1035                audio.emit_sfx(
1036                    sfx_trigger_item,
1037                    pos.map(|e| e + 0.5),
1038                    Some(3.0),
1039                    SfxTag::Combat,
1040                );
1041            },
1042            Outcome::Bleep { pos, .. } => {
1043                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
1044                audio.emit_sfx(
1045                    sfx_trigger_item,
1046                    pos.map(|e| e + 0.5),
1047                    Some(3.0),
1048                    SfxTag::Combat,
1049                );
1050            },
1051            Outcome::HeadLost { uid, .. } => {
1052                let positions = client.state().ecs().read_storage::<common::comp::Pos>();
1053                if let Some(pos) = client
1054                    .state()
1055                    .ecs()
1056                    .read_resource::<common::uid::IdMaps>()
1057                    .uid_entity(*uid)
1058                    .and_then(|entity| positions.get(entity))
1059                {
1060                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
1061                    audio.emit_sfx(sfx_trigger_item, pos.0, Some(2.0), SfxTag::Combat);
1062                } else {
1063                    error!("Couldn't get position of entity that lost head");
1064                }
1065            },
1066            Outcome::Splash { vel, pos, mass, .. } => {
1067                let magnitude = (-vel.z).max(0.0);
1068                let energy = mass * magnitude;
1069
1070                if energy > 0.0 {
1071                    let (sfx, volume) = if energy < 10.0 {
1072                        (SfxEvent::SplashSmall, (energy / 20.0).max(0.25))
1073                    } else if energy < 100.0 {
1074                        (SfxEvent::SplashMedium, ((energy - 10.0) / 50.0 + 0.9))
1075                    } else {
1076                        (
1077                            SfxEvent::SplashBig,
1078                            ((energy / 100.0).sqrt() + 0.5).min(2.0),
1079                        )
1080                    };
1081                    let sfx_trigger_item = triggers.0.get_key_value(&sfx);
1082                    audio.emit_sfx(sfx_trigger_item, *pos, Some(volume), SfxTag::Footstep);
1083                }
1084            },
1085            _ => {},
1086        }
1087    }
1088
1089    fn load_sfx_items() -> AssetHandle<SfxTriggers> {
1090        SfxTriggers::load_or_insert_with("voxygen.audio.sfx", |error| {
1091            warn!(
1092                "Error reading sfx config file, sfx will not be available: {:#?}",
1093                error
1094            );
1095
1096            SfxTriggers::default()
1097        })
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use crate::credits::Credits;
1104
1105    use super::*;
1106    use chumsky::container::Seq;
1107    use common::assets::{self, AssetExt, Ron};
1108    use std::{fs, path::PathBuf};
1109
1110    #[test]
1111    fn test_load_sfx_triggers() { let _ = SfxTriggers::load_expect("voxygen.audio.sfx"); }
1112
1113    #[test]
1114    fn new_sfx_credited() {
1115        let sfx_path = assets::ASSETS_PATH.join(std::path::PathBuf::from("voxygen/audio/sfx/"));
1116        sfx_path.try_exists().unwrap_or_else(|_| {
1117            panic!(
1118                "{}/voxygen/audio/sfx does not exist",
1119                assets::ASSETS_PATH.display()
1120            )
1121        });
1122        let mut files = Vec::new();
1123        list_files(sfx_path.clone(), &mut files);
1124
1125        let credits = Ron::<Credits>::load_expect_cloned("credits").into_inner();
1126        let mut sounds = Vec::new();
1127        for credit in &credits.sounds {
1128            sounds.append(
1129                &mut credit
1130                    .files
1131                    .iter()
1132                    .map(|f| sfx_path.clone().join(f))
1133                    .collect::<Vec<PathBuf>>(),
1134            )
1135        }
1136        for file in files.iter() {
1137            if !sounds.contains(file) {
1138                panic!(
1139                    "{} was not found in credits. Credit the authors of the sound in \
1140                     assets/credits.ron!",
1141                    file.display(),
1142                );
1143            }
1144        }
1145    }
1146
1147    fn list_files(path: PathBuf, buffer: &mut Vec<PathBuf>) {
1148        for dir in fs::read_dir(path).expect("Could not read directory") {
1149            if dir
1150                .as_ref()
1151                .expect("Could not read file entry")
1152                .file_type()
1153                .expect("Could not read filetype")
1154                .is_dir()
1155            {
1156                list_files(dir.unwrap().path(), buffer);
1157            } else {
1158                buffer.push(dir.as_ref().unwrap().path());
1159            }
1160        }
1161    }
1162}