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
399pub struct SfxMgr {
400    /// This is an `AssetHandle` so it is reloaded automatically
401    /// when the manifest is edited.
402    pub triggers: AssetHandle<SfxTriggers>,
403    event_mapper: SfxEventMapper,
404}
405
406impl Default for SfxMgr {
407    fn default() -> Self {
408        Self {
409            triggers: Self::load_sfx_items(),
410            event_mapper: SfxEventMapper::new(),
411        }
412    }
413}
414
415impl SfxMgr {
416    pub fn maintain(
417        &mut self,
418        audio: &mut AudioFrontend,
419        state: &State,
420        player_entity: specs::Entity,
421        camera: &Camera,
422        terrain: &Terrain<TerrainChunk>,
423        client: &Client,
424        figure_mgr: &FigureMgr,
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            figure_mgr,
473        );
474    }
475
476    #[expect(clippy::single_match)]
477    pub fn handle_outcome(
478        &mut self,
479        outcome: &Outcome,
480        audio: &mut AudioFrontend,
481        client: &Client,
482    ) {
483        if !audio.sfx_enabled() && !audio.subtitles_enabled {
484            return;
485        }
486        let triggers = self.triggers.read();
487        let uids = client.state().ecs().read_storage::<Uid>();
488        if audio.get_listener().is_none() {
489            return;
490        }
491        match outcome {
492            Outcome::Explosion { pos, power, .. } => {
493                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
494                audio.emit_sfx(sfx_trigger_item, *pos, Some(power.abs()));
495            },
496            Outcome::Lightning { pos } => {
497                let distance = pos.distance(audio.get_listener_pos());
498                let power = (1.0 - distance / 6_000.0).max(0.0).powi(7);
499                if power > 0.0 {
500                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Lightning);
501                    let volume = (power * 3.0).min(2.9);
502                    // Delayed based on distance / speed of sound (approxmately 340 m/s)
503                    audio.play_ambience_oneshot(
504                        super::channel::AmbienceChannelTag::Thunder,
505                        sfx_trigger_item,
506                        Some(volume),
507                        Some(distance / 340.0),
508                    );
509                }
510            },
511            Outcome::GroundSlam { pos, .. } | Outcome::ClayGolemDash { pos, .. } => {
512                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundSlam);
513                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
514            },
515            Outcome::SurpriseEgg { pos, .. } => {
516                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SurpriseEgg);
517                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
518            },
519            Outcome::Transformation { pos, .. } => {
520                // TODO: Give this a sound
521                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Transformation);
522                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
523            },
524            Outcome::LaserBeam { pos, .. } => {
525                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
526                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
527            },
528            Outcome::CyclopsCharge { pos, .. } => {
529                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
530                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
531            },
532            Outcome::FlamethrowerCharge { pos, .. }
533            | Outcome::TerracottaStatueCharge { pos, .. } => {
534                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
535                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
536            },
537            Outcome::PyroclasmCharge { pos, .. } => {
538                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmCharge);
539                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
540            },
541            Outcome::FireBreathCharge { pos, .. } => {
542                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireBreathCharge);
543                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
544            },
545            Outcome::FuseCharge { pos, .. } => {
546                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FuseCharge);
547                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
548            },
549            Outcome::Charge { pos, .. } => {
550                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
551                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
552            },
553            Outcome::FlashFreeze { pos, .. } => {
554                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlashFreeze);
555                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
556            },
557            Outcome::SummonedCreature { pos, body, .. } => {
558                match body {
559                    Body::BipedSmall(body) => match body.species {
560                        biped_small::Species::IronDwarf => {
561                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
562                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
563                        },
564                        biped_small::Species::Boreal | biped_small::Species::Ashen => {
565                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GigaRoar);
566                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
567                        },
568                        biped_small::Species::ShamanicSpirit | biped_small::Species::Jiangshi => {
569                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
570                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
571                        },
572                        _ => {},
573                    },
574                    Body::BipedLarge(body) => match body.species {
575                        biped_large::Species::TerracottaBesieger
576                        | biped_large::Species::TerracottaPursuer => {
577                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
578                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
579                        },
580                        _ => {},
581                    },
582                    Body::BirdMedium(body) => match body.species {
583                        bird_medium::Species::Bat => {
584                            let sfx_trigger_item =
585                                triggers.0.get_key_value(&SfxEvent::BloodmoonHeiressSummon);
586                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
587                        },
588                        _ => {},
589                    },
590                    Body::Crustacean(body) => match body.species {
591                        crustacean::Species::SoldierCrab => {
592                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Hiss);
593                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
594                        },
595                        _ => {},
596                    },
597                    Body::Object(object::Body::Lavathrower) => {
598                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::DeepLaugh);
599                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
600                    },
601                    Body::Object(object::Body::SeaLantern) => {
602                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LongHiss);
603                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
604                    },
605                    Body::Object(object::Body::Tornado)
606                    | Body::Object(object::Body::FieryTornado) => {
607                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
608                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
609                    },
610                    _ => { // not mapped to sfx file
611                    },
612                }
613            },
614            Outcome::GroundDig { pos, .. } => {
615                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundDig);
616                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
617            },
618            Outcome::PortalActivated { pos, .. } => {
619                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PortalActivated);
620                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
621            },
622            Outcome::TeleportedByPortal { pos, .. } => {
623                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::TeleportedByPortal);
624                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
625            },
626            Outcome::IceSpikes { pos, .. } => {
627                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceSpikes);
628                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
629            },
630            Outcome::IceCrack { pos, .. } => {
631                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceCrack);
632                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
633            },
634            Outcome::Steam { pos, .. } => {
635                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Steam);
636                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
637            },
638            Outcome::FireShockwave { pos, .. } | Outcome::FireLowShockwave { pos, .. } => {
639                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
640                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
641            },
642            Outcome::FromTheAshes { pos, .. } => {
643                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FromTheAshes);
644                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
645            },
646            Outcome::ProjectileShot { pos, body, .. } => {
647                match body {
648                    Body::Object(
649                        object::Body::Arrow
650                        | object::Body::MultiArrow
651                        | object::Body::ArrowSnake
652                        | object::Body::ArrowTurret
653                        | object::Body::ArrowClay
654                        | object::Body::ArrowHeavy
655                        | object::Body::BoltBesieger
656                        | object::Body::HarlequinDagger
657                        | object::Body::SpectralSwordSmall
658                        | object::Body::SpectralSwordLarge,
659                    ) => {
660                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowShot);
661                        audio.emit_sfx(sfx_trigger_item, *pos, None);
662                    },
663                    Body::Object(
664                        object::Body::BoltFire
665                        | object::Body::BoltFireBig
666                        | object::Body::BoltNature
667                        | object::Body::BoltIcicle
668                        | object::Body::SpearIcicle
669                        | object::Body::GrenadeClay
670                        | object::Body::SpitPoison,
671                    ) => {
672                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireShot);
673                        audio.emit_sfx(sfx_trigger_item, *pos, None);
674                    },
675                    Body::Object(object::Body::NapalmShot) => {
676                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmShot);
677                        audio.emit_sfx(sfx_trigger_item, *pos, None);
678                    },
679                    Body::Object(object::Body::FireRing) => {},
680                    Body::Object(object::Body::PyroclasmBolt) => {
681                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmBolt);
682                        audio.emit_sfx(sfx_trigger_item, *pos, None);
683                    },
684                    Body::Object(
685                        object::Body::IronPikeBomb
686                        | object::Body::BubbleBomb
687                        | object::Body::MinotaurAxe
688                        | object::Body::Pebble,
689                    ) => {
690                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
691                        audio.emit_sfx(sfx_trigger_item, *pos, None);
692                    },
693                    Body::Object(
694                        object::Body::LaserBeam
695                        | object::Body::LaserBeamSmall
696                        | object::Body::LightningBolt,
697                    ) => {
698                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
699                        audio.emit_sfx(sfx_trigger_item, *pos, None);
700                    },
701                    Body::Object(
702                        object::Body::AdletTrap | object::Body::BorealTrap | object::Body::Mine,
703                    ) => {
704                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Yeet);
705                        audio.emit_sfx(sfx_trigger_item, *pos, None);
706                    },
707                    Body::Object(object::Body::StrigoiHead) => {
708                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::StrigoiHead);
709                        audio.emit_sfx(sfx_trigger_item, *pos, None);
710                    },
711                    _ => {
712                        // not mapped to sfx file
713                    },
714                }
715            },
716            Outcome::ProjectileHit {
717                pos,
718                body,
719                source,
720                target,
721                ..
722            } => match body {
723                Body::Object(
724                    object::Body::Arrow
725                    | object::Body::MultiArrow
726                    | object::Body::ArrowSnake
727                    | object::Body::ArrowTurret
728                    | object::Body::ArrowClay
729                    | object::Body::ArrowHeavy
730                    | object::Body::BoltBesieger
731                    | object::Body::HarlequinDagger
732                    | object::Body::SpectralSwordSmall
733                    | object::Body::SpectralSwordLarge
734                    | object::Body::Pebble,
735                ) => {
736                    if target.is_none() {
737                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowMiss);
738                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
739                    } else if *source == client.uid() {
740                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
741                        audio.emit_sfx(
742                            sfx_trigger_item,
743                            client.position().unwrap_or(*pos),
744                            Some(2.0),
745                        );
746                    } else {
747                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
748                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
749                    }
750                },
751                Body::Object(
752                    object::Body::AdletTrap
753                    | object::Body::BorealTrap
754                    | object::Body::Mine
755                    | object::Body::StrigoiHead,
756                ) => {
757                    if target.is_none() {
758                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
759                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
760                    } else if *source == client.uid() {
761                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
762                        audio.emit_sfx(
763                            sfx_trigger_item,
764                            client.position().unwrap_or(*pos),
765                            Some(2.0),
766                        );
767                    } else {
768                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
769                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
770                    }
771                },
772                Body::Object(object::Body::NapalmShot) => {
773                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmImpact);
774                    audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
775                },
776                _ => {},
777            },
778            Outcome::SkillPointGain { uid, .. } => {
779                if let Some(client_uid) = uids.get(client.entity())
780                    && uid == client_uid
781                {
782                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SkillPointGain);
783                    audio.emit_ui_sfx(sfx_trigger_item, Some(0.4), Some(UiChannelTag::LevelUp));
784                }
785            },
786            Outcome::Beam { pos, specifier } => match specifier {
787                beam::FrontendSpecifier::LifestealBeam
788                | beam::FrontendSpecifier::Steam
789                | beam::FrontendSpecifier::Poison
790                | beam::FrontendSpecifier::Ink
791                | beam::FrontendSpecifier::Lightning
792                | beam::FrontendSpecifier::Frost
793                | beam::FrontendSpecifier::Bubbles => {
794                    if rand::rng().random_bool(0.5) {
795                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SceptreBeam);
796                        audio.emit_sfx(sfx_trigger_item, *pos, None);
797                    };
798                },
799                beam::FrontendSpecifier::Flamethrower
800                | beam::FrontendSpecifier::Cultist
801                | beam::FrontendSpecifier::PhoenixLaser
802                | beam::FrontendSpecifier::FireGigasOverheat
803                | beam::FrontendSpecifier::FirePillar => {
804                    if rand::rng().random_bool(0.5) {
805                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
806                        audio.emit_sfx(sfx_trigger_item, *pos, None);
807                    }
808                },
809                beam::FrontendSpecifier::FlameWallPillar => {
810                    if rand::rng().random_bool(0.02) {
811                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
812                        audio.emit_sfx(sfx_trigger_item, *pos, None);
813                    }
814                },
815                beam::FrontendSpecifier::Gravewarden | beam::FrontendSpecifier::WebStrand => {},
816            },
817            Outcome::SpriteUnlocked { pos } => {
818                // TODO: Dedicated sound effect!
819                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
820                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(2.0));
821            },
822            Outcome::FailedSpriteUnlock { pos } => {
823                // TODO: Dedicated sound effect!
824                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::BreakBlock);
825                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(2.0));
826            },
827            Outcome::BreakBlock { pos, tool, .. } => {
828                let sfx_trigger_item =
829                    triggers
830                        .0
831                        .get_key_value(&if matches!(tool, Some(ToolKind::Pick)) {
832                            SfxEvent::PickaxeBreakBlock
833                        } else {
834                            SfxEvent::BreakBlock
835                        });
836                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(1.2));
837            },
838            Outcome::DamagedBlock {
839                pos,
840                stage_changed,
841                tool,
842                ..
843            } => {
844                let sfx_trigger_item = triggers.0.get_key_value(&match (stage_changed, tool) {
845                    (false, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamage,
846                    (true, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamageStrong,
847                    // SFX already emitted by ability
848                    (_, Some(ToolKind::Shovel)) => return,
849                    (_, _) => SfxEvent::BreakBlock,
850                });
851
852                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(1.0));
853            },
854            Outcome::HealthChange { pos, info, .. } => {
855                // Ignore positive damage (healing) and buffs for now
856                if info.amount < Health::HEALTH_EPSILON
857                    && !matches!(info.cause, Some(DamageSource::Buff(_)))
858                {
859                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Damage);
860                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
861                }
862            },
863            Outcome::Death { pos, .. } => {
864                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
865                audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
866            },
867            Outcome::Block { pos, parry, .. } => {
868                if *parry {
869                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Parry);
870                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
871                } else {
872                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Block);
873                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
874                }
875            },
876            Outcome::PoiseChange {
877                pos,
878                state: poise_state,
879                ..
880            } => match poise_state {
881                PoiseState::Normal => {},
882                PoiseState::Interrupted => {
883                    let sfx_trigger_item = triggers
884                        .0
885                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Interrupted));
886                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
887                },
888                PoiseState::Stunned => {
889                    let sfx_trigger_item = triggers
890                        .0
891                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Stunned));
892                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
893                },
894                PoiseState::Dazed => {
895                    let sfx_trigger_item = triggers
896                        .0
897                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::Dazed));
898                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
899                },
900                PoiseState::KnockedDown => {
901                    let sfx_trigger_item = triggers
902                        .0
903                        .get_key_value(&SfxEvent::PoiseChange(PoiseState::KnockedDown));
904                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5));
905                },
906            },
907            Outcome::Utterance { pos, kind, body } => {
908                if let Some(voice) = body_to_voice(body) {
909                    let sfx_trigger_item =
910                        triggers.0.get_key_value(&SfxEvent::Utterance(*kind, voice));
911                    if let Some(sfx_trigger_item) = sfx_trigger_item {
912                        // TODO: Dirty hack to turn down the volume of one creature. Need another
913                        // way to do this.
914                        if matches!(voice, VoiceKind::Wolf) {
915                            audio.emit_sfx(Some(sfx_trigger_item), *pos, Some(0.75));
916                        } else {
917                            audio.emit_sfx(Some(sfx_trigger_item), *pos, Some(1.5));
918                        }
919                    } else {
920                        debug!(
921                            "No utterance sound effect exists for ({:?}, {:?})",
922                            kind, voice
923                        );
924                    }
925                }
926            },
927            Outcome::Glider { pos, wielded } => {
928                if *wielded {
929                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
930                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0));
931                } else {
932                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderClose);
933                    audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0));
934                }
935            },
936            Outcome::SpriteDelete {
937                pos,
938                sprite: SpriteKind::SeaUrchin,
939            } => {
940                let pos = pos.map(|e| e as f32 + 0.5);
941                let power = (0.6 - pos.distance(audio.get_listener_pos()) / 5_000.0)
942                    .max(0.0)
943                    .powi(7);
944                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
945                audio.emit_sfx(sfx_trigger_item, pos, Some((power.abs() / 2.5).min(0.3)));
946            },
947            Outcome::Whoosh { pos, .. } => {
948                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
949                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e + 0.5), Some(3.0));
950            },
951            Outcome::Swoosh { pos, .. } => {
952                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
953                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e + 0.5), Some(3.0));
954            },
955            Outcome::Slash { pos, .. } => {
956                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
957                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e + 0.5), Some(3.0));
958            },
959            Outcome::Bleep { pos, .. } => {
960                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
961                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e + 0.5), Some(3.0));
962            },
963            Outcome::HeadLost { uid, .. } => {
964                let positions = client.state().ecs().read_storage::<common::comp::Pos>();
965                if let Some(pos) = client
966                    .state()
967                    .ecs()
968                    .read_resource::<common::uid::IdMaps>()
969                    .uid_entity(*uid)
970                    .and_then(|entity| positions.get(entity))
971                {
972                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
973                    audio.emit_sfx(sfx_trigger_item, pos.0, Some(2.0));
974                } else {
975                    error!("Couldn't get position of entity that lost head");
976                }
977            },
978            Outcome::Splash { vel, pos, mass, .. } => {
979                let magnitude = (-vel.z).max(0.0);
980                let energy = mass * magnitude;
981
982                if energy > 0.0 {
983                    let (sfx, volume) = if energy < 10.0 {
984                        (SfxEvent::SplashSmall, (energy / 20.0).max(0.25))
985                    } else if energy < 100.0 {
986                        (SfxEvent::SplashMedium, ((energy - 10.0) / 50.0 + 0.9))
987                    } else {
988                        (
989                            SfxEvent::SplashBig,
990                            ((energy / 100.0).sqrt() + 0.5).min(2.0),
991                        )
992                    };
993                    let sfx_trigger_item = triggers.0.get_key_value(&sfx);
994                    audio.emit_sfx(sfx_trigger_item, *pos, Some(volume));
995                }
996            },
997            Outcome::ExpChange { .. } | Outcome::ComboChange { .. } => {},
998            _ => {},
999        }
1000    }
1001
1002    fn load_sfx_items() -> AssetHandle<SfxTriggers> {
1003        SfxTriggers::load_or_insert_with("voxygen.audio.sfx", |error| {
1004            warn!(
1005                "Error reading sfx config file, sfx will not be available: {:#?}",
1006                error
1007            );
1008
1009            SfxTriggers::default()
1010        })
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use crate::credits::Credits;
1017
1018    use super::*;
1019    use chumsky::container::Seq;
1020    use common::assets::{self, AssetExt, Ron};
1021    use std::{fs, path::PathBuf};
1022
1023    #[test]
1024    fn test_load_sfx_triggers() { let _ = SfxTriggers::load_expect("voxygen.audio.sfx"); }
1025
1026    #[test]
1027    fn new_sfx_credited() {
1028        let sfx_path = assets::ASSETS_PATH.join(std::path::PathBuf::from("voxygen/audio/sfx/"));
1029        sfx_path.try_exists().unwrap_or_else(|_| {
1030            panic!(
1031                "{}/voxygen/audio/sfx does not exist",
1032                assets::ASSETS_PATH.display()
1033            )
1034        });
1035        let mut files = Vec::new();
1036        list_files(sfx_path.clone(), &mut files);
1037
1038        let credits = Ron::<Credits>::load_expect_cloned("credits").into_inner();
1039        let mut sounds = Vec::new();
1040        for credit in &credits.sounds {
1041            sounds.append(
1042                &mut credit
1043                    .files
1044                    .iter()
1045                    .map(|f| sfx_path.clone().join(f))
1046                    .collect::<Vec<PathBuf>>(),
1047            )
1048        }
1049        for file in files.iter() {
1050            if !sounds.contains(file) {
1051                panic!(
1052                    "{} was not found in credits. Credit the authors of the sound in \
1053                     assets/credits.ron!",
1054                    file.display(),
1055                );
1056            }
1057        }
1058    }
1059
1060    fn list_files(path: PathBuf, buffer: &mut Vec<PathBuf>) {
1061        for dir in fs::read_dir(path).expect("Could not read directory") {
1062            if dir
1063                .as_ref()
1064                .expect("Could not read file entry")
1065                .file_type()
1066                .expect("Could not read filetype")
1067                .is_dir()
1068            {
1069                list_files(dir.unwrap().path(), buffer);
1070            } else {
1071                buffer.push(dir.as_ref().unwrap().path());
1072            }
1073        }
1074    }
1075}