//! Manages individual sfx event system, listens for sfx events, and requests //! playback at the requested position and volume //! //! Veloren's sfx are managed through a configuration which lives in the //! codebase under `/assets/voxygen/audio/sfx.ron`. //! //! If there are errors while reading or deserialising the configuration file, a //! warning is logged and sfx will be disabled. //! //! Each entry in the configuration consists of an //! [SfxEvent](../../../veloren_common/event/enum.SfxEvent.html) item, with some //! additional information to allow playback: //! - `files` - the paths to the `.wav` files to be played for the sfx. minus //! the file extension. This can be a single item if the same sound can be //! played each time, or a list of files from which one is chosen at random to //! be played. //! - `threshold` - the time that the system should wait between successive //! plays. This avoids playing the sound with very fast successive repetition //! when the character can maintain a state over a long period, such as //! running or climbing. //! //! The following snippet details some entries in the configuration and how they //! map to the sound files: //! ```ignore //! Run(Grass): ( // depends on underfoot block //! files: [ //! "voxygen.audio.sfx.footsteps.stepgrass_1", //! "voxygen.audio.sfx.footsteps.stepgrass_2", //! "voxygen.audio.sfx.footsteps.stepgrass_3", //! "voxygen.audio.sfx.footsteps.stepgrass_4", //! "voxygen.audio.sfx.footsteps.stepgrass_5", //! "voxygen.audio.sfx.footsteps.stepgrass_6", //! ], //! threshold: 1.6, // travelled distance before next play //! ), //! Wield(Sword): ( // depends on the player's weapon //! files: [ //! "voxygen.audio.sfx.weapon.sword_out", //! ], //! threshold: 0.5, // wait 0.5s between plays //! ), //! ... //! ``` //! //! These items (for example, the `Wield(Sword)` occasionally depend on some //! property which varies in game. The //! [SfxEvent](../../../veloren_common/event/enum.SfxEvent.html) documentation //! provides links to those variables, some examples are provided her for longer //! items: //! //! ```ignore //! // An inventory action //! Inventory(Dropped): ( //! files: [ //! "voxygen.audio.sfx.footsteps.stepgrass_4", //! ], //! threshold: 0.5, //! ), //! // An inventory action which depends upon the item //! Inventory(Consumed(Apple)): ( //! files: [ //! "voxygen.audio.sfx.inventory.consumable.apple", //! ], //! threshold: 0.5 //! ), //! // An attack ability which depends on the weapon //! Attack(DashMelee, Sword): ( //! files: [ //! "voxygen.audio.sfx.weapon.sword_dash_01", //! "voxygen.audio.sfx.weapon.sword_dash_02", //! ], //! threshold: 1.2, //! ), //! // A multi-stage attack ability which depends on the weapon //! Attack(ComboMelee(Swing, 1), Sword): ( //! files: [ //! "voxygen.audio.sfx.abilities.swing_sword", //! ], //! threshold: 0.5, //! ), //! ``` mod event_mapper; use specs::WorldExt; use crate::{ audio::AudioFrontend, scene::{Camera, Terrain}, }; use client::Client; use common::{ assets::{self, AssetExt, AssetHandle}, comp::{ beam, biped_large, biped_small, humanoid, item::{ItemDefinitionId, ItemKind, ToolKind}, object, poise::PoiseState, quadruped_low, quadruped_medium, quadruped_small, Body, CharacterAbilityType, Health, InventoryUpdateEvent, UtteranceKind, }, outcome::Outcome, terrain::{BlockKind, TerrainChunk}, uid::Uid, DamageSource, }; use common_state::State; use event_mapper::SfxEventMapper; use hashbrown::HashMap; use rand::prelude::*; use serde::Deserialize; use tracing::{debug, warn}; use vek::*; /// We watch the states of nearby entities in order to emit SFX at their /// position based on their state. This constant limits the radius that we /// observe to prevent tracking distant entities. It approximates the distance /// at which the volume of the sfx emitted is too quiet to be meaningful for the /// player. const SFX_DIST_LIMIT_SQR: f32 = 20000.0; pub struct SfxEventItem { /// The SFX event that triggers this sound pub sfx: SfxEvent, /// The position at which the sound should play pub pos: Option>, /// The volume to play the sound at pub vol: Option, } impl SfxEventItem { pub fn new(sfx: SfxEvent, pos: Option>, vol: Option) -> Self { Self { sfx, pos, vol } } pub fn at_player_position(sfx: SfxEvent) -> Self { Self { sfx, pos: None, vol: None, } } } #[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)] pub enum SfxEvent { Campfire, Embers, Birdcall, Owl, Cricket1, Cricket2, Cricket3, Frog, Bees, RunningWaterSlow, RunningWaterFast, Idle, Swim, Run(BlockKind), QuadRun(BlockKind), Roll, Sneak, Climb, GliderOpen, Glide, GliderClose, CatchAir, Jump, Fall, ExperienceGained, LevelUp, Attack(CharacterAbilityType, ToolKind), Wield(ToolKind), Unwield(ToolKind), Inventory(SfxInventoryEvent), Explosion, Damage, Death, Parry, Block, BreakBlock, SceptreBeam, SkillPointGain, ArrowHit, ArrowMiss, ArrowShot, FireShot, FlameThrower, PoiseChange(PoiseState), GroundSlam, Utterance(UtteranceKind, VoiceKind), Lightning, } #[derive(Copy, Clone, Debug, PartialEq, Deserialize, Hash, Eq)] pub enum VoiceKind { HumanFemale, HumanMale, BipedLarge, Wendigo, Reptile, Bird, Critter, Sheep, Pig, Cow, Canine, Lion, Mindflayer, Marlin, Maneater, Adlet, Antelope, Alligator, SeaCrocodile, Saurok, Cat, Goat, Mandragora, Asp, Fungome, Truffler, Wolf, } fn body_to_voice(body: &Body) -> Option { Some(match body { Body::Humanoid(body) => match &body.body_type { humanoid::BodyType::Female => VoiceKind::HumanFemale, humanoid::BodyType::Male => VoiceKind::HumanMale, }, Body::QuadrupedLow(body) => match body.species { quadruped_low::Species::Maneater => VoiceKind::Maneater, quadruped_low::Species::Alligator => VoiceKind::Alligator, quadruped_low::Species::SeaCrocodile => VoiceKind::SeaCrocodile, quadruped_low::Species::Asp => VoiceKind::Asp, _ => return None, }, Body::QuadrupedSmall(body) => match body.species { quadruped_small::Species::Truffler => VoiceKind::Truffler, quadruped_small::Species::Fungome => VoiceKind::Fungome, quadruped_small::Species::Sheep => VoiceKind::Sheep, quadruped_small::Species::Pig | quadruped_small::Species::Boar => VoiceKind::Pig, quadruped_small::Species::Cat => VoiceKind::Cat, quadruped_small::Species::Goat => VoiceKind::Goat, _ => VoiceKind::Critter, }, Body::QuadrupedMedium(body) => match body.species { quadruped_medium::Species::Saber | quadruped_medium::Species::Tiger | quadruped_medium::Species::Lion | quadruped_medium::Species::Frostfang | quadruped_medium::Species::Snowleopard => VoiceKind::Lion, quadruped_medium::Species::Wolf => VoiceKind::Wolf, quadruped_medium::Species::Roshwalr | quadruped_medium::Species::Tarasque | quadruped_medium::Species::Darkhound | quadruped_medium::Species::Bonerattler | quadruped_medium::Species::Grolgar => VoiceKind::Canine, quadruped_medium::Species::Cattle | quadruped_medium::Species::Catoblepas | quadruped_medium::Species::Highland | quadruped_medium::Species::Yak | quadruped_medium::Species::Moose | quadruped_medium::Species::Dreadhorn => VoiceKind::Cow, quadruped_medium::Species::Antelope => VoiceKind::Antelope, _ => return None, }, Body::BirdMedium(_) | Body::BirdLarge(_) => VoiceKind::Bird, Body::BipedSmall(body) => match body.species { biped_small::Species::Adlet => VoiceKind::Adlet, biped_small::Species::Mandragora => VoiceKind::Mandragora, _ => return None, }, Body::BipedLarge(body) => match body.species { biped_large::Species::Wendigo => VoiceKind::Wendigo, biped_large::Species::Occultsaurok | biped_large::Species::Mightysaurok | biped_large::Species::Slysaurok => VoiceKind::Saurok, biped_large::Species::Mindflayer => VoiceKind::Mindflayer, _ => VoiceKind::BipedLarge, }, Body::Theropod(_) | Body::Dragon(_) => VoiceKind::Reptile, Body::FishSmall(_) | Body::FishMedium(_) => VoiceKind::Marlin, _ => return None, }) } #[derive(Clone, Debug, PartialEq, Deserialize, Hash, Eq)] pub enum SfxInventoryEvent { Collected, CollectedTool(ToolKind), CollectedItem(String), CollectFailed, Consumed(String), Debug, Dropped, Given, Swapped, Craft, } // TODO Move to a separate event mapper? impl From<&InventoryUpdateEvent> for SfxEvent { fn from(value: &InventoryUpdateEvent) -> Self { match value { InventoryUpdateEvent::Collected(item) => { // Handle sound effects for types of collected items, falling // back to the default Collected event match &*item.kind() { ItemKind::Tool(tool) => { SfxEvent::Inventory(SfxInventoryEvent::CollectedTool(tool.kind)) }, ItemKind::Ingredient { .. } if matches!(item.item_definition_id(), ItemDefinitionId::Simple(id) if id.contains("mineral.gem.")) => { SfxEvent::Inventory(SfxInventoryEvent::CollectedItem(String::from( "Gemstone", ))) }, _ => SfxEvent::Inventory(SfxInventoryEvent::Collected), } }, InventoryUpdateEvent::BlockCollectFailed { .. } | InventoryUpdateEvent::EntityCollectFailed { .. } => { SfxEvent::Inventory(SfxInventoryEvent::CollectFailed) }, InventoryUpdateEvent::Consumed(consumable) => { SfxEvent::Inventory(SfxInventoryEvent::Consumed(consumable.clone())) }, InventoryUpdateEvent::Debug => SfxEvent::Inventory(SfxInventoryEvent::Debug), InventoryUpdateEvent::Dropped => SfxEvent::Inventory(SfxInventoryEvent::Dropped), InventoryUpdateEvent::Given => SfxEvent::Inventory(SfxInventoryEvent::Given), InventoryUpdateEvent::Swapped => SfxEvent::Inventory(SfxInventoryEvent::Swapped), InventoryUpdateEvent::Craft => SfxEvent::Inventory(SfxInventoryEvent::Craft), _ => SfxEvent::Inventory(SfxInventoryEvent::Swapped), } } } #[derive(Deserialize)] pub struct SfxTriggerItem { /// A list of SFX filepaths for this event pub files: Vec, /// The time to wait before repeating this SfxEvent pub threshold: f32, } #[derive(Deserialize, Default)] pub struct SfxTriggers(HashMap); impl SfxTriggers { pub fn get_trigger(&self, trigger: &SfxEvent) -> Option<&SfxTriggerItem> { self.0.get(trigger) } pub fn get_key_value(&self, trigger: &SfxEvent) -> Option<(&SfxEvent, &SfxTriggerItem)> { self.0.get_key_value(trigger) } } pub struct SfxMgr { /// This is an `AssetHandle` so it is reloaded automatically /// when the manifest is edited. pub triggers: AssetHandle, event_mapper: SfxEventMapper, } impl Default for SfxMgr { fn default() -> Self { Self { triggers: Self::load_sfx_items(), event_mapper: SfxEventMapper::new(), } } } impl SfxMgr { pub fn maintain( &mut self, audio: &mut AudioFrontend, state: &State, player_entity: specs::Entity, camera: &Camera, terrain: &Terrain, client: &Client, ) { // Checks if the SFX volume is set to zero or audio is disabled // This prevents us from running all the following code unnecessarily if !audio.sfx_enabled() { return; } let focus_off = camera.get_focus_pos().map(f32::trunc); let cam_pos = camera.dependents().cam_pos + focus_off; // Sets the listener position to the camera position facing the // same direction as the camera audio.set_listener_pos(cam_pos, camera.dependents().cam_dir); let triggers = self.triggers.read(); self.event_mapper.maintain( audio, state, player_entity, camera, &triggers, terrain, client, ); } #[allow(clippy::single_match)] pub fn handle_outcome( &mut self, outcome: &Outcome, audio: &mut AudioFrontend, client: &Client, underwater: bool, ) { if !audio.sfx_enabled() { return; } let triggers = self.triggers.read(); let uids = client.state().ecs().read_storage::(); // TODO handle underwater match outcome { Outcome::Explosion { pos, power, .. } => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Explosion); audio.emit_sfx( sfx_trigger_item, *pos, Some((power.abs() / 2.5).min(1.5)), underwater, ); }, Outcome::Lightning { pos } => { let power = (1.0 - pos.distance(audio.listener.pos) / 5_000.0) .max(0.0) .powi(7); if power > 0.0 { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Lightning); // TODO: Don't use UI sfx, add a way to control position falloff audio.emit_ui_sfx(sfx_trigger_item, Some((power * 3.0).min(2.9))); } }, Outcome::GroundSlam { pos, .. } => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::GroundSlam); audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), underwater); }, Outcome::ProjectileShot { pos, body, .. } => { match body { Body::Object( object::Body::Arrow | object::Body::MultiArrow | object::Body::ArrowSnake | object::Body::ArrowTurret, ) => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::ArrowShot); audio.emit_sfx(sfx_trigger_item, *pos, None, underwater); }, Body::Object( object::Body::BoltFire | object::Body::BoltFireBig | object::Body::BoltNature, ) => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::FireShot); audio.emit_sfx(sfx_trigger_item, *pos, None, underwater); }, _ => { // not mapped to sfx file }, } }, Outcome::ProjectileHit { pos, body, source, target, .. } => match body { Body::Object( object::Body::Arrow | object::Body::MultiArrow | object::Body::ArrowSnake | object::Body::ArrowTurret, ) => { if target.is_none() { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::ArrowMiss); audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), underwater); } else if *source == client.uid() { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::ArrowHit); audio.emit_sfx( sfx_trigger_item, client.position().unwrap_or(*pos), Some(2.0), underwater, ); } else { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::ArrowHit); audio.emit_sfx(sfx_trigger_item, *pos, Some(2.0), underwater); } }, _ => {}, }, Outcome::SkillPointGain { uid, .. } => { if let Some(client_uid) = uids.get(client.entity()) { if uid == client_uid { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::SkillPointGain); audio.emit_ui_sfx(sfx_trigger_item, Some(0.4)); } } }, Outcome::Beam { pos, specifier } => match specifier { beam::FrontendSpecifier::LifestealBeam => { if thread_rng().gen_bool(0.5) { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::SceptreBeam); audio.emit_sfx(sfx_trigger_item, *pos, None, underwater); }; }, beam::FrontendSpecifier::Flamethrower | beam::FrontendSpecifier::Cultist => { if thread_rng().gen_bool(0.5) { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::FlameThrower); audio.emit_sfx(sfx_trigger_item, *pos, None, underwater); } }, beam::FrontendSpecifier::ClayGolem | beam::FrontendSpecifier::Bubbles | beam::FrontendSpecifier::Frost | beam::FrontendSpecifier::WebStrand => {}, }, Outcome::BreakBlock { pos, .. } => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::BreakBlock); audio.emit_sfx( sfx_trigger_item, pos.map(|e| e as f32 + 0.5), Some(3.0), underwater, ); }, Outcome::HealthChange { pos, info, .. } => { // Ignore positive damage (healing) and buffs for now if info.amount < Health::HEALTH_EPSILON && !matches!(info.cause, Some(DamageSource::Buff(_))) { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Damage); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); } }, Outcome::Death { pos, .. } => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Death); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); }, Outcome::Block { pos, parry, .. } => { if *parry { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Parry); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); } else { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Block); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); } }, Outcome::PoiseChange { pos, state, .. } => match state { PoiseState::Normal => {}, PoiseState::Interrupted => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::PoiseChange(PoiseState::Interrupted)); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); }, PoiseState::Stunned => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::PoiseChange(PoiseState::Stunned)); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); }, PoiseState::Dazed => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::PoiseChange(PoiseState::Dazed)); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); }, PoiseState::KnockedDown => { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::PoiseChange(PoiseState::KnockedDown)); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.5), underwater); }, }, Outcome::Utterance { pos, kind, body } => { if let Some(voice) = body_to_voice(body) { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::Utterance(*kind, voice)); if let Some(sfx_trigger_item) = sfx_trigger_item { audio.emit_sfx(Some(sfx_trigger_item), *pos, Some(1.5), underwater); } else { debug!( "No utterance sound effect exists for ({:?}, {:?})", kind, voice ); } } }, Outcome::Glider { pos, wielded } => { if *wielded { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::GliderOpen); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), underwater); } else { let sfx_trigger_item = triggers.get_key_value(&SfxEvent::GliderClose); audio.emit_sfx(sfx_trigger_item, *pos, Some(1.0), underwater); } }, Outcome::ExpChange { .. } | Outcome::ComboChange { .. } | Outcome::SummonedCreature { .. } => {}, } } fn load_sfx_items() -> AssetHandle { SfxTriggers::load_or_insert_with("voxygen.audio.sfx", |error| { warn!( "Error reading sfx config file, sfx will not be available: {:#?}", error ); SfxTriggers::default() }) } } impl assets::Asset for SfxTriggers { type Loader = assets::RonLoader; const EXTENSION: &'static str = "ron"; } #[cfg(test)] mod tests { use super::*; #[test] fn test_load_sfx_triggers() { let _ = SfxTriggers::load_expect("voxygen.audio.sfx"); } }