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