1mod event_mapper;
77use specs::WorldExt;
78
79use crate::{
80 audio::{
81 AudioFrontend,
82 channel::{SFX_DIST_LIMIT_SQR, UiChannelTag},
83 },
84 scene::{Camera, FigureMgr, Terrain},
85};
86
87use client::Client;
88use common::{
89 DamageSource,
90 assets::{AssetExt, AssetHandle, Ron},
91 comp::{
92 Body, CharacterAbilityType, Health, InventoryUpdateEvent, UtteranceKind, beam, biped_large,
93 biped_small, bird_large, bird_medium, crustacean, humanoid,
94 item::{AbilitySpec, ItemDefinitionId, ItemDesc, ItemKind, ToolKind, item_key::ItemKey},
95 object,
96 poise::PoiseState,
97 quadruped_low, quadruped_medium, quadruped_small,
98 },
99 outcome::Outcome,
100 terrain::{BlockKind, SpriteKind, TerrainChunk},
101 uid::Uid,
102 vol::ReadVol,
103};
104use common_state::State;
105use event_mapper::SfxEventMapper;
106use hashbrown::HashMap;
107use rand::prelude::*;
108use serde::Deserialize;
109use tracing::{debug, error, warn};
110use vek::*;
111
112#[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
113pub enum SfxEvent {
114 Campfire,
115 Embers,
116 Birdcall,
117 Owl,
118 Cricket1,
119 Cricket2,
120 Cricket3,
121 Frog,
122 Bees,
123 RunningWaterSlow,
124 Lavapool,
125 Idle,
126 Swim,
127 SplashSmall,
128 SplashMedium,
129 SplashBig,
130 Run(BlockKind),
131 QuadRun(BlockKind),
132 OctoRun(BlockKind),
133 Roll,
134 RollCancel,
135 Sneak,
136 Climb,
137 GliderOpen,
138 Glide,
139 GliderClose,
140 CatchAir,
141 Jump,
142 Fall,
143 Attack(CharacterAbilityType, ToolKind),
144 Wield(ToolKind),
145 Unwield(ToolKind),
146 Inventory(SfxInventoryEvent),
147 Explosion,
148 Damage,
149 Death,
150 Parry,
151 Block,
152 BreakBlock,
153 PickaxeDamage,
154 PickaxeDamageStrong,
155 PickaxeBreakBlock,
156 SceptreBeam,
157 SkillPointGain,
158 ArrowHit,
159 ArrowMiss,
160 ArrowShot,
161 FireShot,
162 NapalmShot,
163 NapalmImpact,
164 FireBreathShot,
165 FireBreathCharge,
166 PyroclasmCharge,
167 PyroclasmBolt,
168 FlameThrower,
169 PoiseChange(PoiseState),
170 GroundSlam,
171 FlashFreeze,
172 GigaRoar,
173 IceSpikes,
174 IceCrack,
175 Utterance(UtteranceKind, VoiceKind),
176 Lightning,
177 CyclopsCharge,
178 TerracottaStatueCharge,
179 LaserBeam,
180 Steam,
181 FuseCharge,
182 Music(ToolKind, AbilitySpec),
183 Yeet,
184 Hiss,
185 LongHiss,
186 Klonk,
187 SmashKlonk,
188 FireShockwave,
189 DeepLaugh,
190 Whoosh,
191 Swoosh,
192 GroundDig,
193 PortalActivated,
194 TeleportedByPortal,
195 FromTheAshes,
196 SurpriseEgg,
197 Transformation,
198 Bleep,
199 Charge,
200 StrigoiHead,
201 BloodmoonHeiressSummon,
202 TrainChugg,
203 TrainChuggSteam,
204 TrainAmbience,
205 TrainClack,
206 TrainSpeed,
207}
208
209#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
210pub enum VoiceKind {
211 HumanFemale,
212 HumanMale,
213 BipedLarge,
214 Wendigo,
215 Reptile,
216 Bird,
217 Critter,
218 Sheep,
219 Pig,
220 Cow,
221 Canine,
222 Dagon,
223 Lion,
224 Mindflayer,
225 Marlin,
226 Maneater,
227 Adlet,
228 Antelope,
229 Alligator,
230 SeaCrocodile,
231 Saurok,
232 Cat,
233 Goat,
234 Mandragora,
235 Asp,
236 Fungome,
237 Truffler,
238 Wolf,
239 Wyvern,
240 Phoenix,
241 VampireBat,
242 Legoom,
243}
244
245fn body_to_voice(body: &Body) -> Option<VoiceKind> {
246 Some(match body {
247 Body::Humanoid(body) => match &body.body_type {
248 humanoid::BodyType::Female => VoiceKind::HumanFemale,
249 humanoid::BodyType::Male => VoiceKind::HumanMale,
250 },
251 Body::QuadrupedLow(body) => match body.species {
252 quadruped_low::Species::Maneater => VoiceKind::Maneater,
253 quadruped_low::Species::Alligator | quadruped_low::Species::Snaretongue => {
254 VoiceKind::Alligator
255 },
256 quadruped_low::Species::SeaCrocodile => VoiceKind::SeaCrocodile,
257 quadruped_low::Species::Dagon => VoiceKind::Dagon,
258 quadruped_low::Species::Asp => VoiceKind::Asp,
259 _ => return None,
260 },
261 Body::QuadrupedSmall(body) => match body.species {
262 quadruped_small::Species::Truffler => VoiceKind::Truffler,
263 quadruped_small::Species::Fungome => VoiceKind::Fungome,
264 quadruped_small::Species::Sheep => VoiceKind::Sheep,
265 quadruped_small::Species::Pig | quadruped_small::Species::Boar => VoiceKind::Pig,
266 quadruped_small::Species::Cat => VoiceKind::Cat,
267 quadruped_small::Species::Goat => VoiceKind::Goat,
268 _ => VoiceKind::Critter,
269 },
270 Body::QuadrupedMedium(body) => match body.species {
271 quadruped_medium::Species::Saber
272 | quadruped_medium::Species::Tiger
273 | quadruped_medium::Species::Lion
274 | quadruped_medium::Species::Frostfang
275 | quadruped_medium::Species::Snowleopard => VoiceKind::Lion,
276 quadruped_medium::Species::Wolf => VoiceKind::Wolf,
277 quadruped_medium::Species::Roshwalr
278 | quadruped_medium::Species::Tarasque
279 | quadruped_medium::Species::Darkhound
280 | quadruped_medium::Species::Bonerattler
281 | quadruped_medium::Species::Grolgar => VoiceKind::Canine,
282 quadruped_medium::Species::Cattle
283 | quadruped_medium::Species::Catoblepas
284 | quadruped_medium::Species::Highland
285 | quadruped_medium::Species::Yak
286 | quadruped_medium::Species::Moose
287 | quadruped_medium::Species::Dreadhorn => VoiceKind::Cow,
288 quadruped_medium::Species::Antelope => VoiceKind::Antelope,
289 _ => return None,
290 },
291 Body::BirdMedium(body) => match body.species {
292 bird_medium::Species::BloodmoonBat | bird_medium::Species::VampireBat => {
293 VoiceKind::VampireBat
294 },
295 _ => VoiceKind::Bird,
296 },
297 Body::BirdLarge(body) => match body.species {
298 bird_large::Species::CloudWyvern
299 | bird_large::Species::FlameWyvern
300 | bird_large::Species::FrostWyvern
301 | bird_large::Species::SeaWyvern
302 | bird_large::Species::WealdWyvern => VoiceKind::Wyvern,
303 bird_large::Species::Phoenix => VoiceKind::Phoenix,
304 _ => VoiceKind::Bird,
305 },
306 Body::BipedSmall(body) => match body.species {
307 biped_small::Species::Adlet => VoiceKind::Adlet,
308 biped_small::Species::Mandragora => VoiceKind::Mandragora,
309 biped_small::Species::Flamekeeper => VoiceKind::BipedLarge,
310 biped_small::Species::GreenLegoom
311 | biped_small::Species::OchreLegoom
312 | biped_small::Species::PurpleLegoom
313 | biped_small::Species::RedLegoom
314 | biped_small::Species::UmberLegoom => VoiceKind::Legoom,
315 _ => return None,
316 },
317 Body::BipedLarge(body) => match body.species {
318 biped_large::Species::Wendigo => VoiceKind::Wendigo,
319 biped_large::Species::Occultsaurok
320 | biped_large::Species::Mightysaurok
321 | biped_large::Species::Slysaurok => VoiceKind::Saurok,
322 biped_large::Species::Mindflayer => VoiceKind::Mindflayer,
323 _ => VoiceKind::BipedLarge,
324 },
325 Body::Theropod(_) | Body::Dragon(_) => VoiceKind::Reptile,
326 Body::FishSmall(_) | Body::FishMedium(_) => VoiceKind::Marlin,
327 _ => return None,
328 })
329}
330
331#[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
332pub enum SfxInventoryEvent {
333 Collected,
334 CollectedTool(ToolKind),
335 CollectedItem(String),
336 CollectFailed,
337 Consumed(ItemKey),
338 Debug,
339 Dropped,
340 Given,
341 Swapped,
342 Craft,
343}
344
345impl From<&InventoryUpdateEvent> for SfxEvent {
347 fn from(value: &InventoryUpdateEvent) -> Self {
348 match value {
349 InventoryUpdateEvent::Collected(item) => {
350 match &*item.kind() {
353 ItemKind::Tool(tool) => {
354 SfxEvent::Inventory(SfxInventoryEvent::CollectedTool(tool.kind))
355 },
356 ItemKind::Ingredient { .. }
357 if matches!(
358 item.item_definition_id(),
359 ItemDefinitionId::Simple(id) if id.contains("mineral.gem.")
360 ) =>
361 {
362 SfxEvent::Inventory(SfxInventoryEvent::CollectedItem(String::from(
363 "Gemstone",
364 )))
365 },
366 _ => SfxEvent::Inventory(SfxInventoryEvent::Collected),
367 }
368 },
369 InventoryUpdateEvent::BlockCollectFailed { .. }
370 | InventoryUpdateEvent::EntityCollectFailed { .. } => {
371 SfxEvent::Inventory(SfxInventoryEvent::CollectFailed)
372 },
373 InventoryUpdateEvent::Consumed(consumable) => {
374 SfxEvent::Inventory(SfxInventoryEvent::Consumed(consumable.clone()))
375 },
376 InventoryUpdateEvent::Debug => SfxEvent::Inventory(SfxInventoryEvent::Debug),
377 InventoryUpdateEvent::Dropped => SfxEvent::Inventory(SfxInventoryEvent::Dropped),
378 InventoryUpdateEvent::Given => SfxEvent::Inventory(SfxInventoryEvent::Given),
379 InventoryUpdateEvent::Swapped => SfxEvent::Inventory(SfxInventoryEvent::Swapped),
380 InventoryUpdateEvent::Craft => SfxEvent::Inventory(SfxInventoryEvent::Craft),
381 _ => SfxEvent::Inventory(SfxInventoryEvent::Swapped),
382 }
383 }
384}
385
386#[derive(Deserialize, Debug)]
387pub struct SfxTriggerItem {
388 pub files: Vec<String>,
390 pub threshold: f32,
392
393 #[serde(default)]
394 pub subtitle: Option<String>,
395}
396
397pub type SfxTriggers = Ron<HashMap<SfxEvent, SfxTriggerItem>>;
398
399#[derive(Copy, Clone, Debug, PartialEq)]
403pub enum SfxTag {
404 Looping,
406 Combat,
408 Interaction,
410 Vehicle,
412 Utterance,
414 Blocksound,
417 Footstep,
420 Ambient,
423}
424
425pub struct SfxMgr {
426 pub triggers: AssetHandle<SfxTriggers>,
429 event_mapper: SfxEventMapper,
430}
431
432impl Default for SfxMgr {
433 fn default() -> Self {
434 Self {
435 triggers: Self::load_sfx_items(),
436 event_mapper: SfxEventMapper::new(),
437 }
438 }
439}
440
441impl SfxMgr {
442 pub fn maintain(
443 &mut self,
444 audio: &mut AudioFrontend,
445 state: &State,
446 player_entity: specs::Entity,
447 camera: &Camera,
448 terrain: &Terrain<TerrainChunk>,
449 client: &Client,
450 figure_mgr: &FigureMgr,
451 ) {
452 if !audio.sfx_enabled() && !audio.subtitles_enabled {
455 return;
456 }
457
458 let cam_pos = camera.get_pos_with_focus();
459
460 audio.set_listener_pos(cam_pos, camera.dependents().cam_dir);
463
464 let triggers = self.triggers.read();
465
466 let underwater = state
467 .terrain()
468 .get(cam_pos.map(|e| e.floor() as i32))
469 .map(|b| b.is_liquid())
470 .unwrap_or(false);
471
472 if underwater {
473 audio.set_sfx_master_filter(888);
474 } else {
475 audio.set_sfx_master_filter(20000);
476 }
477
478 let player_pos = client.position().unwrap_or_default();
479
480 if let Some(inner) = audio.inner.as_mut() {
482 inner.player_pos = player_pos;
483 inner.channels.sfx.iter_mut().for_each(|c| {
484 if !c.is_done() {
485 c.update(player_pos)
486 }
487 })
488 }
489
490 self.event_mapper.maintain(
491 audio,
492 state,
493 player_entity,
494 camera,
495 &triggers,
496 terrain,
497 client,
498 figure_mgr,
499 );
500 }
501
502 #[expect(clippy::single_match)]
503 pub fn handle_outcome(
504 &mut self,
505 outcome: &Outcome,
506 audio: &mut AudioFrontend,
507 client: &Client,
508 ) {
509 if !audio.sfx_enabled() && !audio.subtitles_enabled {
510 return;
511 }
512 let triggers = self.triggers.read();
513 let uids = client.state().ecs().read_storage::<Uid>();
514 if audio.get_listener().is_none() {
515 return;
516 }
517 match outcome {
518 Outcome::Explosion { pos, power, .. } => {
519 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
520 audio.emit_sfx(
521 sfx_trigger_item,
522 *pos,
523 Some((power.abs() / 2.5).min(1.5)),
524 SfxTag::Combat,
525 );
526 },
527 Outcome::Lightning { pos } => {
528 let distance = pos.distance(audio.get_listener_pos());
529 let power = (1.0 - distance / 6_000.0).max(0.0).powi(7);
530 if power > 0.0 {
531 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Lightning);
532 let volume = (power * 3.0).min(2.9);
533 audio.play_ambience_oneshot(
535 super::channel::AmbienceChannelTag::Thunder,
536 sfx_trigger_item,
537 Some(volume),
538 Some(distance / 340.0),
539 );
540 }
541 },
542 Outcome::GroundSlam { pos, .. } | Outcome::ClayGolemDash { pos, .. } => {
543 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundSlam);
544 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
545 },
546 Outcome::SurpriseEgg { pos, .. } => {
547 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SurpriseEgg);
548 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
549 },
550 Outcome::Transformation { pos, .. } => {
551 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Transformation);
553 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
554 },
555 Outcome::LaserBeam { pos, .. } => {
556 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
557 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
558 },
559 Outcome::CyclopsCharge { pos, .. } => {
560 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
561 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
562 },
563 Outcome::FlamethrowerCharge { pos, .. }
564 | Outcome::TerracottaStatueCharge { pos, .. } => {
565 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
566 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
567 },
568 Outcome::PyroclasmCharge { pos, .. } => {
569 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmCharge);
570 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
571 },
572 Outcome::FireBreathCharge { pos, .. } => {
573 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireBreathCharge);
574 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
575 },
576 Outcome::FuseCharge { pos, .. } => {
577 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FuseCharge);
578 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
579 },
580 Outcome::Charge { pos, .. } => {
581 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::CyclopsCharge);
582 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
583 },
584 Outcome::FlashFreeze { pos, .. } => {
585 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlashFreeze);
586 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
587 },
588 Outcome::SummonedCreature { pos, body, .. } => {
589 match body {
590 Body::BipedSmall(body) => match body.species {
591 biped_small::Species::IronDwarf => {
592 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
593 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
594 },
595 biped_small::Species::Boreal | biped_small::Species::Ashen => {
596 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GigaRoar);
597 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
598 },
599 biped_small::Species::ShamanicSpirit | biped_small::Species::Jiangshi => {
600 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
601 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
602 },
603 _ => {},
604 },
605 Body::BipedLarge(body) => match body.species {
606 biped_large::Species::TerracottaBesieger
607 | biped_large::Species::TerracottaPursuer => {
608 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
609 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
610 },
611 _ => {},
612 },
613 Body::BirdMedium(body) => match body.species {
614 bird_medium::Species::Bat => {
615 let sfx_trigger_item =
616 triggers.0.get_key_value(&SfxEvent::BloodmoonHeiressSummon);
617 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
618 },
619 _ => {},
620 },
621 Body::Crustacean(body) => match body.species {
622 crustacean::Species::SoldierCrab => {
623 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Hiss);
624 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
625 },
626 _ => {},
627 },
628 Body::Object(object::Body::Lavathrower) => {
629 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::DeepLaugh);
630 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
631 },
632 Body::Object(object::Body::SeaLantern) => {
633 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LongHiss);
634 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
635 },
636 Body::Object(object::Body::Tornado)
637 | Body::Object(object::Body::FieryTornado) => {
638 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
639 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
640 },
641 _ => { },
643 }
644 },
645 Outcome::GroundDig { pos, .. } => {
646 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GroundDig);
647 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
648 },
649 Outcome::PortalActivated { pos, .. } => {
650 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PortalActivated);
651 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
652 },
653 Outcome::TeleportedByPortal { pos, .. } => {
654 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::TeleportedByPortal);
655 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Interaction);
656 },
657 Outcome::IceSpikes { pos, .. } => {
658 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceSpikes);
659 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
660 },
661 Outcome::IceCrack { pos, .. } => {
662 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::IceCrack);
663 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
664 },
665 Outcome::Steam { pos, .. } => {
666 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Steam);
667 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
668 },
669 Outcome::FireShockwave { pos, .. } | Outcome::FireLowShockwave { pos, .. } => {
670 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
671 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
672 },
673 Outcome::FromTheAshes { pos, .. } => {
674 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FromTheAshes);
675 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
676 },
677 Outcome::ProjectileShot { pos, body, .. } => {
678 match body {
679 Body::Object(
680 object::Body::Arrow
681 | object::Body::MultiArrow
682 | object::Body::ArrowSnake
683 | object::Body::ArrowTurret
684 | object::Body::ArrowClay
685 | object::Body::ArrowHeavy
686 | object::Body::BoltBesieger
687 | object::Body::HarlequinDagger
688 | object::Body::SpectralSwordSmall
689 | object::Body::SpectralSwordLarge,
690 ) => {
691 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowShot);
692 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
693 },
694 Body::Object(
695 object::Body::BoltFire
696 | object::Body::BoltFireBig
697 | object::Body::BoltNature
698 | object::Body::BoltIcicle
699 | object::Body::SpearIcicle
700 | object::Body::GrenadeClay
701 | object::Body::SpitPoison,
702 ) => {
703 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FireShot);
704 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
705 },
706 Body::Object(object::Body::NapalmShot) => {
707 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmShot);
708 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
709 },
710 Body::Object(object::Body::FireRing) => {},
711 Body::Object(object::Body::PyroclasmBolt) => {
712 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::PyroclasmBolt);
713 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
714 },
715 Body::Object(
716 object::Body::IronPikeBomb
717 | object::Body::BubbleBomb
718 | object::Body::MinotaurAxe
719 | object::Body::Pebble,
720 ) => {
721 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
722 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
723 },
724 Body::Object(
725 object::Body::LaserBeam
726 | object::Body::LaserBeamSmall
727 | object::Body::LightningBolt,
728 ) => {
729 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::LaserBeam);
730 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
731 },
732 Body::Object(
733 object::Body::AdletTrap | object::Body::BorealTrap | object::Body::Mine,
734 ) => {
735 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Yeet);
736 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
737 },
738 Body::Object(object::Body::StrigoiHead) => {
739 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::StrigoiHead);
740 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
741 },
742 _ => {
743 },
745 }
746 },
747 Outcome::ProjectileHit {
748 pos,
749 body,
750 source,
751 target,
752 ..
753 } => match body {
754 Body::Object(
755 object::Body::Arrow
756 | object::Body::MultiArrow
757 | object::Body::ArrowSnake
758 | object::Body::ArrowTurret
759 | object::Body::ArrowClay
760 | object::Body::ArrowHeavy
761 | object::Body::BoltBesieger
762 | object::Body::HarlequinDagger
763 | object::Body::SpectralSwordSmall
764 | object::Body::SpectralSwordLarge
765 | object::Body::Pebble,
766 ) => {
767 if target.is_none() {
768 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowMiss);
769 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
770 } else if *source == client.uid() {
771 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
772 audio.emit_sfx(
773 sfx_trigger_item,
774 client.position().unwrap_or(*pos),
775 Some(2.0),
776 SfxTag::Combat,
777 );
778 } else {
779 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::ArrowHit);
780 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
781 }
782 },
783 Body::Object(
784 object::Body::AdletTrap
785 | object::Body::BorealTrap
786 | object::Body::Mine
787 | object::Body::StrigoiHead,
788 ) => {
789 if target.is_none() {
790 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Klonk);
791 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
792 } else if *source == client.uid() {
793 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
794 audio.emit_sfx(
795 sfx_trigger_item,
796 client.position().unwrap_or(*pos),
797 Some(2.0),
798 SfxTag::Combat,
799 );
800 } else {
801 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
802 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
803 }
804 },
805 Body::Object(object::Body::NapalmShot) => {
806 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::NapalmImpact);
807 audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), SfxTag::Combat);
808 },
809 _ => {},
810 },
811 Outcome::SkillPointGain { uid, .. } => {
812 if let Some(client_uid) = uids.get(client.entity())
813 && uid == client_uid
814 {
815 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SkillPointGain);
816 audio.emit_ui_sfx(sfx_trigger_item, Some(0.4), Some(UiChannelTag::LevelUp));
817 }
818 },
819 Outcome::Beam { pos, specifier } => match specifier {
820 beam::FrontendSpecifier::LifestealBeam
821 | beam::FrontendSpecifier::Steam
822 | beam::FrontendSpecifier::Poison
823 | beam::FrontendSpecifier::Ink
824 | beam::FrontendSpecifier::Lightning
825 | beam::FrontendSpecifier::Frost
826 | beam::FrontendSpecifier::Bubbles => {
827 if rand::rng().random_bool(0.5) {
828 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SceptreBeam);
829 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
830 };
831 },
832 beam::FrontendSpecifier::Flamethrower
833 | beam::FrontendSpecifier::Cultist
834 | beam::FrontendSpecifier::PhoenixLaser
835 | beam::FrontendSpecifier::FireGigasOverheat
836 | beam::FrontendSpecifier::FirePillar => {
837 if rand::rng().random_bool(0.5) {
838 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
839 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
840 }
841 },
842 beam::FrontendSpecifier::FlameWallPillar => {
843 if rand::rng().random_bool(0.02) {
844 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::FlameThrower);
845 audio.emit_sfx(sfx_trigger_item, *pos, None, SfxTag::Combat);
846 }
847 },
848 beam::FrontendSpecifier::Gravewarden | beam::FrontendSpecifier::WebStrand => {},
849 },
850 Outcome::SpriteUnlocked { pos } => {
851 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
853 audio.emit_sfx(
854 sfx_trigger_item,
855 pos.map(|e| e as f32 + 0.5),
856 Some(2.0),
857 SfxTag::Interaction,
858 );
859 },
860 Outcome::FailedSpriteUnlock { pos } => {
861 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::BreakBlock);
863 audio.emit_sfx(
864 sfx_trigger_item,
865 pos.map(|e| e as f32 + 0.5),
866 Some(2.0),
867 SfxTag::Interaction,
868 );
869 },
870 Outcome::BreakBlock { pos, tool, .. } => {
871 let sfx_trigger_item =
872 triggers
873 .0
874 .get_key_value(&if matches!(tool, Some(ToolKind::Pick)) {
875 SfxEvent::PickaxeBreakBlock
876 } else {
877 SfxEvent::BreakBlock
878 });
879 audio.emit_sfx(
880 sfx_trigger_item,
881 pos.map(|e| e as f32 + 0.5),
882 Some(1.2),
883 SfxTag::Interaction,
884 );
885 },
886 Outcome::DamagedBlock {
887 pos,
888 stage_changed,
889 tool,
890 ..
891 } => {
892 let sfx_trigger_item = triggers.0.get_key_value(&match (stage_changed, tool) {
893 (false, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamage,
894 (true, Some(ToolKind::Pick)) => SfxEvent::PickaxeDamageStrong,
895 (_, Some(ToolKind::Shovel)) => return,
897 (_, _) => SfxEvent::BreakBlock,
898 });
899
900 audio.emit_sfx(
901 sfx_trigger_item,
902 pos.map(|e| e as f32 + 0.5),
903 Some(1.0),
904 SfxTag::Interaction,
905 );
906 },
907 Outcome::HealthChange { pos, info, .. } => {
908 if info.amount < Health::HEALTH_EPSILON
910 && !matches!(info.cause, Some(DamageSource::Buff(_)))
911 {
912 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Damage);
913 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
914 }
915 },
916 Outcome::Death { pos, .. } => {
917 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
918 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
919 },
920 Outcome::Block { pos, parry, .. } => {
921 if *parry {
922 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Parry);
923 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
924 } else {
925 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Block);
926 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
927 }
928 },
929 Outcome::PoiseChange {
930 pos,
931 state: poise_state,
932 ..
933 } => match poise_state {
934 PoiseState::Normal => {},
935 PoiseState::Interrupted => {
936 let sfx_trigger_item = triggers
937 .0
938 .get_key_value(&SfxEvent::PoiseChange(PoiseState::Interrupted));
939 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
940 },
941 PoiseState::Stunned => {
942 let sfx_trigger_item = triggers
943 .0
944 .get_key_value(&SfxEvent::PoiseChange(PoiseState::Stunned));
945 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
946 },
947 PoiseState::Dazed => {
948 let sfx_trigger_item = triggers
949 .0
950 .get_key_value(&SfxEvent::PoiseChange(PoiseState::Dazed));
951 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
952 },
953 PoiseState::KnockedDown => {
954 let sfx_trigger_item = triggers
955 .0
956 .get_key_value(&SfxEvent::PoiseChange(PoiseState::KnockedDown));
957 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), SfxTag::Combat);
958 },
959 },
960 Outcome::Utterance { pos, kind, body } => {
961 if let Some(voice) = body_to_voice(body) {
962 let sfx_trigger_item =
963 triggers.0.get_key_value(&SfxEvent::Utterance(*kind, voice));
964 if let Some(sfx_trigger_item) = sfx_trigger_item {
965 if matches!(voice, VoiceKind::Wolf) {
968 audio.emit_sfx(
969 Some(sfx_trigger_item),
970 *pos,
971 Some(0.75),
972 SfxTag::Combat,
973 );
974 } else {
975 audio.emit_sfx(
976 Some(sfx_trigger_item),
977 *pos,
978 Some(1.5),
979 SfxTag::Utterance,
980 );
981 }
982 } else {
983 debug!(
984 "No utterance sound effect exists for ({:?}, {:?})",
985 kind, voice
986 );
987 }
988 }
989 },
990 Outcome::Glider { pos, wielded } => {
991 if *wielded {
992 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderOpen);
993 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), SfxTag::Interaction);
994 } else {
995 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::GliderClose);
996 audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), SfxTag::Interaction);
997 }
998 },
999 Outcome::SpriteDelete {
1000 pos,
1001 sprite: SpriteKind::SeaUrchin,
1002 } => {
1003 let pos = pos.map(|e| e as f32 + 0.5);
1004 let power = (0.6 - pos.distance(audio.get_listener_pos()) / 5_000.0)
1005 .max(0.0)
1006 .powi(7);
1007 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Explosion);
1008 audio.emit_sfx(
1009 sfx_trigger_item,
1010 pos,
1011 Some((power.abs() / 2.5).min(0.3)),
1012 SfxTag::Combat,
1013 );
1014 },
1015 Outcome::Whoosh { pos, .. } => {
1016 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Whoosh);
1017 audio.emit_sfx(
1018 sfx_trigger_item,
1019 pos.map(|e| e + 0.5),
1020 Some(3.0),
1021 SfxTag::Combat,
1022 );
1023 },
1024 Outcome::Swoosh { pos, .. } => {
1025 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Swoosh);
1026 audio.emit_sfx(
1027 sfx_trigger_item,
1028 pos.map(|e| e + 0.5),
1029 Some(3.0),
1030 SfxTag::Combat,
1031 );
1032 },
1033 Outcome::Slash { pos, .. } => {
1034 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::SmashKlonk);
1035 audio.emit_sfx(
1036 sfx_trigger_item,
1037 pos.map(|e| e + 0.5),
1038 Some(3.0),
1039 SfxTag::Combat,
1040 );
1041 },
1042 Outcome::Bleep { pos, .. } => {
1043 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Bleep);
1044 audio.emit_sfx(
1045 sfx_trigger_item,
1046 pos.map(|e| e + 0.5),
1047 Some(3.0),
1048 SfxTag::Combat,
1049 );
1050 },
1051 Outcome::HeadLost { uid, .. } => {
1052 let positions = client.state().ecs().read_storage::<common::comp::Pos>();
1053 if let Some(pos) = client
1054 .state()
1055 .ecs()
1056 .read_resource::<common::uid::IdMaps>()
1057 .uid_entity(*uid)
1058 .and_then(|entity| positions.get(entity))
1059 {
1060 let sfx_trigger_item = triggers.0.get_key_value(&SfxEvent::Death);
1061 audio.emit_sfx(sfx_trigger_item, pos.0, Some(2.0), SfxTag::Combat);
1062 } else {
1063 error!("Couldn't get position of entity that lost head");
1064 }
1065 },
1066 Outcome::Splash { vel, pos, mass, .. } => {
1067 let magnitude = (-vel.z).max(0.0);
1068 let energy = mass * magnitude;
1069
1070 if energy > 0.0 {
1071 let (sfx, volume) = if energy < 10.0 {
1072 (SfxEvent::SplashSmall, (energy / 20.0).max(0.25))
1073 } else if energy < 100.0 {
1074 (SfxEvent::SplashMedium, ((energy - 10.0) / 50.0 + 0.9))
1075 } else {
1076 (
1077 SfxEvent::SplashBig,
1078 ((energy / 100.0).sqrt() + 0.5).min(2.0),
1079 )
1080 };
1081 let sfx_trigger_item = triggers.0.get_key_value(&sfx);
1082 audio.emit_sfx(sfx_trigger_item, *pos, Some(volume), SfxTag::Footstep);
1083 }
1084 },
1085 _ => {},
1086 }
1087 }
1088
1089 fn load_sfx_items() -> AssetHandle<SfxTriggers> {
1090 SfxTriggers::load_or_insert_with("voxygen.audio.sfx", |error| {
1091 warn!(
1092 "Error reading sfx config file, sfx will not be available: {:#?}",
1093 error
1094 );
1095
1096 SfxTriggers::default()
1097 })
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use crate::credits::Credits;
1104
1105 use super::*;
1106 use chumsky::container::Seq;
1107 use common::assets::{self, AssetExt, Ron};
1108 use std::{fs, path::PathBuf};
1109
1110 #[test]
1111 fn test_load_sfx_triggers() { let _ = SfxTriggers::load_expect("voxygen.audio.sfx"); }
1112
1113 #[test]
1114 fn new_sfx_credited() {
1115 let sfx_path = assets::ASSETS_PATH.join(std::path::PathBuf::from("voxygen/audio/sfx/"));
1116 sfx_path.try_exists().unwrap_or_else(|_| {
1117 panic!(
1118 "{}/voxygen/audio/sfx does not exist",
1119 assets::ASSETS_PATH.display()
1120 )
1121 });
1122 let mut files = Vec::new();
1123 list_files(sfx_path.clone(), &mut files);
1124
1125 let credits = Ron::<Credits>::load_expect_cloned("credits").into_inner();
1126 let mut sounds = Vec::new();
1127 for credit in &credits.sounds {
1128 sounds.append(
1129 &mut credit
1130 .files
1131 .iter()
1132 .map(|f| sfx_path.clone().join(f))
1133 .collect::<Vec<PathBuf>>(),
1134 )
1135 }
1136 for file in files.iter() {
1137 if !sounds.contains(file) {
1138 panic!(
1139 "{} was not found in credits. Credit the authors of the sound in \
1140 assets/credits.ron!",
1141 file.display(),
1142 );
1143 }
1144 }
1145 }
1146
1147 fn list_files(path: PathBuf, buffer: &mut Vec<PathBuf>) {
1148 for dir in fs::read_dir(path).expect("Could not read directory") {
1149 if dir
1150 .as_ref()
1151 .expect("Could not read file entry")
1152 .file_type()
1153 .expect("Could not read filetype")
1154 .is_dir()
1155 {
1156 list_files(dir.unwrap().path(), buffer);
1157 } else {
1158 buffer.push(dir.as_ref().unwrap().path());
1159 }
1160 }
1161 }
1162}