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