640 lines
23 KiB
Rust

//! 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<Vec3<f32>>,
/// The volume to play the sound at
pub vol: Option<f32>,
}
impl SfxEventItem {
pub fn new(sfx: SfxEvent, pos: Option<Vec3<f32>>, vol: Option<f32>) -> 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<VoiceKind> {
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<String>,
/// The time to wait before repeating this SfxEvent
pub threshold: f32,
}
#[derive(Deserialize, Default)]
pub struct SfxTriggers(HashMap<SfxEvent, SfxTriggerItem>);
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<SfxTriggers>,
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<TerrainChunk>,
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::<Uid>();
// 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> {
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"); }
}