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