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        let player_pos = client.position().unwrap_or_default();
445
446        // Update continuing sounds with player position
447        if let Some(inner) = audio.inner.as_mut() {
448            inner.player_pos = player_pos;
449            inner.channels.sfx.iter_mut().for_each(|c| {
450                if !c.is_done() {
451                    c.update(player_pos)
452                }
453            })
454        }
455
456        self.event_mapper.maintain(
457            audio,
458            state,
459            player_entity,
460            camera,
461            &triggers,
462            terrain,
463            client,
464        );
465    }
466
467    #[expect(clippy::single_match)]
468    pub fn handle_outcome(
469        &mut self,
470        outcome: &Outcome,
471        audio: &mut AudioFrontend,
472        client: &Client,
473    ) {
474        if !audio.sfx_enabled() && !audio.subtitles_enabled {
475            return;
476        }
477        let triggers = self.triggers.read();
478        let uids = client.state().ecs().read_storage::<Uid>();
479        if audio.get_listener().is_none() {
480            return;
481        }
482        match outcome {
483            Outcome::Explosion { pos, power, .. } => {
484                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
485                audio.emit_sfx(sfx_trigger_item, *pos, Some((power.abs() / 2.5).min(1.5)));
486            },
487            Outcome::Lightning { pos } => {
488                let distance = pos.distance(audio.get_listener_pos());
489                let power = (1.0 - distance / 6_000.0).max(0.0).powi(7);
490                if power > 0.0 {
491                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Lightning);
492                    let volume = (power * 3.0).min(2.9);
493                    // Delayed based on distance / speed of sound (approxmately 340 m/s)
494                    audio.play_ambience_oneshot(
495                        super::channel::AmbienceChannelTag::Thunder,
496                        sfx_trigger_item,
497                        Some(volume),
498                        Some(distance / 340.0),
499                    );
500                }
501            },
502            Outcome::GroundSlam { pos, .. } | Outcome::ClayGolemDash { pos, .. } => {
503                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundSlam);
504                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
505            },
506            Outcome::SurpriseEgg { pos, .. } => {
507                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SurpriseEgg);
508                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
509            },
510            Outcome::Transformation { pos, .. } => {
511                // TODO: Give this a sound
512                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Transformation);
513                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
514            },
515            Outcome::LaserBeam { pos, .. } => {
516                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
517                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
518            },
519            Outcome::CyclopsCharge { pos, .. } => {
520                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
521                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
522            },
523            Outcome::FlamethrowerCharge { pos, .. }
524            | Outcome::TerracottaStatueCharge { pos, .. } => {
525                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
526                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
527            },
528            Outcome::FuseCharge { pos, .. } => {
529                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FuseCharge);
530                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
531            },
532            Outcome::Charge { pos, .. } => {
533                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
534                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
535            },
536            Outcome::FlashFreeze { pos, .. } => {
537                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlashFreeze);
538                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
539            },
540            Outcome::SummonedCreature { pos, body, .. } => {
541                match body {
542                    Body::BipedSmall(body) => match body.species {
543                        biped_small::Species::IronDwarf => {
544                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
545                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
546                        },
547                        biped_small::Species::Boreal | biped_small::Species::Ashen => {
548                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GigaRoar);
549                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
550                        },
551                        biped_small::Species::ShamanicSpirit | biped_small::Species::Jiangshi => {
552                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
553                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
554                        },
555                        _ => {},
556                    },
557                    Body::BipedLarge(body) => match body.species {
558                        biped_large::Species::TerracottaBesieger
559                        | biped_large::Species::TerracottaPursuer => {
560                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
561                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
562                        },
563                        _ => {},
564                    },
565                    Body::BirdMedium(body) => match body.species {
566                        bird_medium::Species::Bat => {
567                            let sfx_trigger_item =
568                                triggers.0.get_key_value(&SfxEvent::BloodmoonHeiressSummon);
569                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
570                        },
571                        _ => {},
572                    },
573                    Body::Crustacean(body) => match body.species {
574                        crustacean::Species::SoldierCrab => {
575                            let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Hiss);
576                            audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
577                        },
578                        _ => {},
579                    },
580                    Body::Object(object::Body::Lavathrower) => {
581                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::DeepLaugh);
582                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
583                    },
584                    Body::Object(object::Body::SeaLantern) => {
585                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LongHiss);
586                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
587                    },
588                    Body::Object(object::Body::Tornado)
589                    | Body::Object(object::Body::FieryTornado) => {
590                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
591                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
592                    },
593                    _ => { // not mapped to sfx file
594                    },
595                }
596            },
597            Outcome::GroundDig { pos, .. } => {
598                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundDig);
599                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
600            },
601            Outcome::PortalActivated { pos, .. } => {
602                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PortalActivated);
603                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
604            },
605            Outcome::TeleportedByPortal { pos, .. } => {
606                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::TeleportedByPortal);
607                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
608            },
609            Outcome::IceSpikes { pos, .. } => {
610                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceSpikes);
611                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
612            },
613            Outcome::IceCrack { pos, .. } => {
614                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceCrack);
615                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
616            },
617            Outcome::Steam { pos, .. } => {
618                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Steam);
619                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
620            },
621            Outcome::FireShockwave { pos, .. } | Outcome::FireLowShockwave { pos, .. } => {
622                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
623                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
624            },
625            Outcome::FromTheAshes { pos, .. } => {
626                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FromTheAshes);
627                audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
628            },
629            Outcome::ProjectileShot { pos, body, .. } => {
630                match body {
631                    Body::Object(
632                        object::Body::Arrow
633                        | object::Body::MultiArrow
634                        | object::Body::ArrowSnake
635                        | object::Body::ArrowTurret
636                        | object::Body::ArrowClay
637                        | object::Body::BoltBesieger
638                        | object::Body::HarlequinDagger
639                        | object::Body::SpectralSwordSmall
640                        | object::Body::SpectralSwordLarge,
641                    ) => {
642                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowShot);
643                        audio.emit_sfx(sfx_trigger_item, *pos, None);
644                    },
645                    Body::Object(
646                        object::Body::BoltFire
647                        | object::Body::BoltFireBig
648                        | object::Body::BoltNature
649                        | object::Body::BoltIcicle
650                        | object::Body::SpearIcicle
651                        | object::Body::GrenadeClay
652                        | object::Body::SpitPoison,
653                    ) => {
654                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireShot);
655                        audio.emit_sfx(sfx_trigger_item, *pos, None);
656                    },
657                    Body::Object(
658                        object::Body::IronPikeBomb
659                        | object::Body::BubbleBomb
660                        | object::Body::MinotaurAxe
661                        | object::Body::Pebble,
662                    ) => {
663                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
664                        audio.emit_sfx(sfx_trigger_item, *pos, None);
665                    },
666                    Body::Object(
667                        object::Body::LaserBeam
668                        | object::Body::LaserBeamSmall
669                        | object::Body::LightningBolt,
670                    ) => {
671                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
672                        audio.emit_sfx(sfx_trigger_item, *pos, None);
673                    },
674                    Body::Object(
675                        object::Body::AdletTrap | object::Body::BorealTrap | object::Body::Mine,
676                    ) => {
677                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Yeet);
678                        audio.emit_sfx(sfx_trigger_item, *pos, None);
679                    },
680                    Body::Object(object::Body::StrigoiHead) => {
681                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::StrigoiHead);
682                        audio.emit_sfx(sfx_trigger_item, *pos, None);
683                    },
684                    _ => {
685                        // not mapped to sfx file
686                    },
687                }
688            },
689            Outcome::ProjectileHit {
690                pos,
691                body,
692                source,
693                target,
694                ..
695            } => match body {
696                Body::Object(
697                    object::Body::Arrow
698                    | object::Body::MultiArrow
699                    | object::Body::ArrowSnake
700                    | object::Body::ArrowTurret
701                    | object::Body::ArrowClay
702                    | object::Body::BoltBesieger
703                    | object::Body::HarlequinDagger
704                    | object::Body::SpectralSwordSmall
705                    | object::Body::SpectralSwordLarge
706                    | object::Body::Pebble,
707                ) => {
708                    if target.is_none() {
709                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowMiss);
710                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
711                    } else if *source == client.uid() {
712                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
713                        audio.emit_sfx(
714                            sfx_trigger_item,
715                            client.position().unwrap_or(*pos),
716                            Some(2.0),
717                        );
718                    } else {
719                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
720                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
721                    }
722                },
723                Body::Object(
724                    object::Body::AdletTrap
725                    | object::Body::BorealTrap
726                    | object::Body::Mine
727                    | object::Body::StrigoiHead,
728                ) => {
729                    if target.is_none() {
730                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
731                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
732                    } else if *source == client.uid() {
733                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
734                        audio.emit_sfx(
735                            sfx_trigger_item,
736                            client.position().unwrap_or(*pos),
737                            Some(2.0),
738                        );
739                    } else {
740                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
741                        audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0));
742                    }
743                },
744                _ => {},
745            },
746            Outcome::SkillPointGain { uid, .. } => {
747                if let Some(client_uid) = uids.get(client.entity())
748                    && uid == client_uid
749                {
750                    let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SkillPointGain);
751                    audio.emit_ui_sfx(sfx_trigger_item, Some(0.4), Some(UiChannelTag::LevelUp));
752                }
753            },
754            Outcome::Beam { pos, specifier } => match specifier {
755                beam::FrontendSpecifier::LifestealBeam
756                | beam::FrontendSpecifier::Steam
757                | beam::FrontendSpecifier::Poison
758                | beam::FrontendSpecifier::Ink
759                | beam::FrontendSpecifier::Lightning
760                | beam::FrontendSpecifier::Frost
761                | beam::FrontendSpecifier::Bubbles => {
762                    if rand::rng().random_bool(0.5) {
763                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SceptreBeam);
764                        audio.emit_sfx(sfx_trigger_item, *pos, None);
765                    };
766                },
767                beam::FrontendSpecifier::Flamethrower
768                | beam::FrontendSpecifier::Cultist
769                | beam::FrontendSpecifier::PhoenixLaser
770                | beam::FrontendSpecifier::FireGigasOverheat
771                | beam::FrontendSpecifier::FirePillar => {
772                    if rand::rng().random_bool(0.5) {
773                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
774                        audio.emit_sfx(sfx_trigger_item, *pos, None);
775                    }
776                },
777                beam::FrontendSpecifier::FlameWallPillar => {
778                    if rand::rng().random_bool(0.02) {
779                        let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
780                        audio.emit_sfx(sfx_trigger_item, *pos, None);
781                    }
782                },
783                beam::FrontendSpecifier::Gravewarden | beam::FrontendSpecifier::WebStrand => {},
784            },
785            Outcome::SpriteUnlocked { pos } => {
786                // TODO: Dedicated sound effect!
787                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
788                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(2.0));
789            },
790            Outcome::FailedSpriteUnlock { pos } => {
791                // TODO: Dedicated sound effect!
792                let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::BreakBlock);
793                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(2.0));
794            },
795            Outcome::BreakBlock { pos, tool, .. } => {
796                let sfx_trigger_item =
797                    triggers
798                        .0
799                        .get_key_value(&if matches!(tool, Some(ToolKind::Pick)) {
800                            SfxEvent::PickaxeBreakBlock
801                        } else {
802                            SfxEvent::BreakBlock
803                        });
804                audio.emit_sfx(sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(3.0));
805            },
806            Outcome::DamagedBlock {
807                pos,
808                stage_changed,
809                tool,
810                ..
811            } => {
812                let sfx_trigger_item = triggers.0.get_key_value(&match (stage_changed, tool) {
813                    (false, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamage,
814                    (true, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamageStrong,
815                    // SFX already emitted by ability
816                    (_, Some(ToolKind::Shovel)) => return,
817                    (_, _) => SfxEvent::BreakBlock,
818                });
819
820                audio.emit_sfx(
821                    sfx_trigger_item,
822                    pos.map(|e| e as f32 + 0.5),
823                    Some(if *stage_changed { 3.0 } else { 2.0 }),
824                );
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 super::*;
980
981    #[test]
982    fn test_load_sfx_triggers() { let _ = SfxTriggers::load_expect("voxygen.audio.sfx"); }
983}