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