SFX system

This is an event based approach to SFX sounds. There is a specific
character sound event mapper which determines sfx to play based on
character or NPC state, as well as emitting sfx events for
non-character-triggers such as levelling up.
This commit is contained in:
S Handley 2019-11-23 08:26:39 +00:00 committed by Songtronix
parent 2fabf10758
commit 1c607c0a0c
36 changed files with 672 additions and 82 deletions

View File

@ -0,0 +1,67 @@
(
items: [
(
trigger: Run,
files: [
"voxygen.audio.sfx.footsteps.stepdirt_1",
"voxygen.audio.sfx.footsteps.stepdirt_2",
"voxygen.audio.sfx.footsteps.stepdirt_3",
"voxygen.audio.sfx.footsteps.stepdirt_4",
"voxygen.audio.sfx.footsteps.stepdirt_5",
"voxygen.audio.sfx.footsteps.stepdirt_6",
"voxygen.audio.sfx.footsteps.stepdirt_7",
"voxygen.audio.sfx.footsteps.stepdirt_8",
],
threshold: 0.25,
),
(
trigger: GliderOpen,
files: [
"voxygen.audio.sfx.glider_open",
],
threshold: 0.5,
),
(
trigger: GliderClose,
files: [
"voxygen.audio.sfx.glider_close",
],
threshold: 0.5,
),
(
trigger: Attack(Sword),
files: [
"voxygen.audio.sfx.weapon.sword",
],
threshold: 0.5,
),
(
trigger: Attack(Hammer),
files: [
"voxygen.audio.sfx.weapon.sword",
],
threshold: 0.5,
),
(
trigger: Attack(Bow),
files: [
"voxygen.audio.sfx.weapon.bow",
],
threshold: 0.5,
),
(
trigger: LevelUp,
files: [
"voxygen.audio.sfx.chat_message_received"
],
threshold: 0.2,
),
(
trigger: InventoryAdd,
files: [
"voxygen.audio.sfx.inventory_add"
],
threshold: 0.2,
),
],
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,10 +1,53 @@
use crate::comp;
use comp::item::Tool;
use parking_lot::Mutex;
use serde::Deserialize;
use specs::Entity as EcsEntity;
use sphynx::Uid;
use std::{collections::VecDeque, ops::DerefMut};
use vek::*;
pub struct SfxEventItem {
pub sfx: SfxEvent,
pub pos: Option<Vec3<f32>>,
}
impl SfxEventItem {
pub fn new(sfx: SfxEvent, pos: Option<Vec3<f32>>) -> Self {
Self { sfx, pos }
}
pub fn at_player_position(sfx: SfxEvent) -> Self {
Self { sfx, pos: None }
}
}
#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Hash, Eq)]
pub enum SfxEvent {
Idle,
PlaceBlock,
RemoveBlock,
OpenChest,
ChatMessageReceived,
OpenBag,
LevelUp,
Roll,
Climb,
Swim,
Run,
GliderOpen,
Glide,
GliderClose,
Jump,
Fall,
InventoryAdd,
InventoryDrop,
LightLantern,
ExtinguishLantern,
Attack(Tool),
AttackWolf,
}
pub enum LocalEvent {
Jump(EcsEntity),
WallLeap {

View File

@ -5,7 +5,7 @@ use serde_json;
use std::str::FromStr;
use std::sync::Arc;
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq)]
pub enum NpcKind {
Humanoid,
Wolf,

View File

@ -3,7 +3,7 @@ pub use sphynx::Uid;
use crate::{
comp,
event::{EventBus, LocalEvent, ServerEvent},
event::{EventBus, LocalEvent, ServerEvent, SfxEventItem},
msg::{EcsCompPacket, EcsResPacket},
region::RegionMap,
sys,
@ -173,6 +173,7 @@ impl State {
ecs.add_resource(TerrainChanges::default());
ecs.add_resource(EventBus::<ServerEvent>::default());
ecs.add_resource(EventBus::<LocalEvent>::default());
ecs.add_resource(EventBus::<SfxEventItem>::default());
ecs.add_resource(RegionMap::new());
}

View File

@ -1,26 +1,31 @@
use crate::{
comp::{HealthSource, Stats},
event::{EventBus, ServerEvent},
event::{EventBus, ServerEvent, SfxEvent, SfxEventItem},
state::DeltaTime,
};
use specs::{Entities, Join, Read, System, WriteStorage};
/// This system kills players
/// and handles players levelling up
pub struct Sys;
impl<'a> System<'a> for Sys {
type SystemData = (
Entities<'a>,
Read<'a, DeltaTime>,
Read<'a, EventBus<ServerEvent>>,
Read<'a, EventBus<SfxEventItem>>,
WriteStorage<'a, Stats>,
);
fn run(&mut self, (entities, dt, event_bus, mut stats): Self::SystemData) {
let mut event_emitter = event_bus.emitter();
fn run(
&mut self,
(entities, dt, server_event_bus, audio_event_bus, mut stats): Self::SystemData,
) {
let mut server_event_emitter = server_event_bus.emitter();
for (entity, mut stat) in (&entities, &mut stats).join() {
if stat.should_die() && !stat.is_dead {
event_emitter.emit(ServerEvent::Destroy {
server_event_emitter.emit(ServerEvent::Destroy {
entity,
cause: stat.health.last_change.1.cause,
});
@ -36,6 +41,11 @@ impl<'a> System<'a> for Sys {
stat.exp.change_maximum_by(25);
stat.level.change_by(1);
}
audio_event_bus
.emitter()
.emit(SfxEventItem::at_player_position(SfxEvent::LevelUp));
stat.update_max_hp();
stat.health
.set_to(stat.health.maximum(), HealthSource::LevelUp)

View File

@ -1,6 +1,8 @@
pub mod channel;
pub mod fader;
pub mod sfx;
pub mod soundcache;
use channel::{AudioType, Channel};
use fader::Fader;
use soundcache::SoundCache;

View File

@ -0,0 +1,443 @@
/// sfx::event_mapper watches the local entities and determines which sfx to emit,
/// and the position at which the sound should be emitted from
use crate::audio::sfx::{SfxTriggerItem, SfxTriggers};
use client::Client;
use common::{
comp::{ActionState, Body, CharacterState, ItemKind, MovementState, Pos, Stats, Vel},
event::{EventBus, SfxEvent, SfxEventItem},
};
use hashbrown::HashMap;
use specs::{Entity as EcsEntity, Join};
use std::time::{Duration, Instant};
use vek::*;
#[derive(Clone)]
struct LastSfxEvent {
event: SfxEvent,
time: Instant,
}
pub struct SfxEventMapper {
event_history: HashMap<EcsEntity, LastSfxEvent>,
}
impl SfxEventMapper {
pub fn new() -> Self {
Self {
event_history: HashMap::new(),
}
}
pub fn maintain(&mut self, client: &Client, triggers: &SfxTriggers) {
const SFX_DIST_LIMIT_SQR: f32 = 22500.0;
let ecs = client.state().ecs();
let player_position = ecs
.read_storage::<Pos>()
.get(client.entity())
.map_or(Vec3::zero(), |pos| pos.0);
for (entity, pos, body, vel, stats, character) in (
&ecs.entities(),
&ecs.read_storage::<Pos>(),
&ecs.read_storage::<Body>(),
&ecs.read_storage::<Vel>(),
&ecs.read_storage::<Stats>(),
ecs.read_storage::<CharacterState>().maybe(),
)
.join()
.filter(|(_, e_pos, ..)| {
(e_pos.0.distance_squared(player_position)) < SFX_DIST_LIMIT_SQR
})
{
if let (pos, body, Some(character), stats, vel) = (pos, body, character, stats, vel) {
let state = self
.event_history
.entry(entity)
.or_insert_with(|| LastSfxEvent {
event: SfxEvent::Idle,
time: Instant::now(),
});
let mapped_event = match body {
Body::Humanoid(_) => {
Self::map_character_event(character, state.event.clone(), vel.0, stats)
}
Body::QuadrupedMedium(_) => {
Self::map_quadriped_event(character, state.event.clone(), vel.0, stats)
}
_ => SfxEvent::Idle,
};
// Check for SFX config entry for this movement
let sfx_trigger_item: Option<&SfxTriggerItem> = triggers
.items
.iter()
.find(|item| item.trigger == mapped_event);
if Self::should_emit(state, sfx_trigger_item) {
ecs.read_resource::<EventBus<SfxEventItem>>()
.emitter()
.emit(SfxEventItem::new(mapped_event, Some(pos.0)));
// Update the last play time
state.event = mapped_event;
state.time = Instant::now();
} else {
// Keep the last event, it may not have an SFX trigger but it helps us determine the next one
state.event = mapped_event;
}
}
}
self.cleanup(client.entity());
}
/// As the player explores the world, we track the last event of the nearby entities to determine the correct
/// SFX item to play next based on their activity. `cleanup` will remove entities from event tracking if they
/// have not triggered an event for > n seconds. This prevents stale records from bloating the Map size.
fn cleanup(&mut self, player: EcsEntity) {
const TRACKING_TIMEOUT: u64 = 15;
let now = Instant::now();
self.event_history.retain(|entity, event| {
now.duration_since(event.time) < Duration::from_secs(TRACKING_TIMEOUT)
|| entity.id() == player.id()
});
}
/// When specific entity movements are detected, the associated sound (if any) needs to satisfy two conditions to
/// be allowed to play:
/// 1. An sfx.ron entry exists for the movement (we need to know which sound file(s) to play)
/// 2. The sfx has not been played since it's timeout threshold has elapsed, which prevents firing every tick
fn should_emit(
last_play_entry: &LastSfxEvent,
sfx_trigger_item: Option<&SfxTriggerItem>,
) -> bool {
if let Some(item) = sfx_trigger_item {
if last_play_entry.event == item.trigger {
last_play_entry.time.elapsed().as_secs_f64() >= item.threshold
} else {
true
}
} else {
false
}
}
/// Voxygen has an existing list of character states via `MovementState::*` and `ActivityState::*`
/// however that list does not provide enough resolution to target specific entity events, such
/// as opening or closing the glider. These methods translate those entity states with some additional
/// data into more specific `SfxEvent`'s which we attach sounds to
fn map_quadriped_event(
current_event: &CharacterState,
previous_event: SfxEvent,
vel: Vec3<f32>,
stats: &Stats,
) -> SfxEvent {
match (
current_event.movement,
current_event.action,
previous_event,
vel,
stats,
) {
(_, ActionState::Attack { .. }, _, _, stats) => match stats.name.as_ref() {
"Wolf" => SfxEvent::AttackWolf,
_ => SfxEvent::Idle,
},
_ => SfxEvent::Idle,
}
}
fn map_character_event(
current_event: &CharacterState,
previous_event: SfxEvent,
vel: Vec3<f32>,
stats: &Stats,
) -> SfxEvent {
match (
current_event.movement,
current_event.action,
previous_event,
vel,
stats,
) {
(MovementState::Roll { .. }, ..) => SfxEvent::Roll,
(MovementState::Climb, ..) => SfxEvent::Climb,
(MovementState::Swim, ..) => SfxEvent::Swim,
(MovementState::Run, ..) => SfxEvent::Run,
(MovementState::Jump, _, previous_event, vel, _) => {
// MovementState::Jump only indicates !on_ground
if previous_event != SfxEvent::Glide {
if vel.z > 0.0 {
SfxEvent::Jump
} else {
SfxEvent::Fall
}
} else {
SfxEvent::GliderClose
}
}
(MovementState::Glide, _, previous_event, ..) => {
if previous_event != SfxEvent::GliderOpen && previous_event != SfxEvent::Glide {
SfxEvent::GliderOpen
} else {
SfxEvent::Glide
}
}
(_, ActionState::Attack { .. }, _, _, stats) => {
match &stats.equipment.main.as_ref().map(|i| &i.kind) {
Some(ItemKind::Tool { kind, .. }) => SfxEvent::Attack(*kind),
_ => SfxEvent::Idle,
}
}
_ => SfxEvent::Idle,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::{
assets,
comp::{item::Tool, ActionState, MovementState, Stats},
event::SfxEvent,
};
use std::time::{Duration, Instant};
#[test]
fn no_item_config_no_emit() {
let last_sfx_event = LastSfxEvent {
event: SfxEvent::Idle,
time: Instant::now(),
};
let result = SfxEventMapper::should_emit(&last_sfx_event, None);
assert_eq!(result, false);
}
#[test]
fn config_but_played_since_threshold_no_emit() {
let trigger_item = SfxTriggerItem {
trigger: SfxEvent::Run,
files: vec![String::from("some.path.to.sfx.file")],
threshold: 1.0,
};
// Triggered a 'Run' 0 seconds ago
let last_sfx_event = LastSfxEvent {
event: SfxEvent::Run,
time: Instant::now(),
};
let result = SfxEventMapper::should_emit(&last_sfx_event, Some(&trigger_item));
assert_eq!(result, false);
}
#[test]
fn config_and_not_played_since_threshold_emits() {
let trigger_item = SfxTriggerItem {
trigger: SfxEvent::Run,
files: vec![String::from("some.path.to.sfx.file")],
threshold: 0.5,
};
let last_sfx_event = LastSfxEvent {
event: SfxEvent::Idle,
time: Instant::now().checked_add(Duration::from_secs(1)).unwrap(),
};
let result = SfxEventMapper::should_emit(&last_sfx_event, Some(&trigger_item));
assert_eq!(result, true);
}
#[test]
fn same_previous_event_elapsed_emits() {
let trigger_item = SfxTriggerItem {
trigger: SfxEvent::Run,
files: vec![String::from("some.path.to.sfx.file")],
threshold: 0.5,
};
let last_sfx_event = LastSfxEvent {
event: SfxEvent::Run,
time: Instant::now()
.checked_sub(Duration::from_millis(500))
.unwrap(),
};
let result = SfxEventMapper::should_emit(&last_sfx_event, Some(&trigger_item));
assert_eq!(result, true);
}
#[test]
fn maps_idle() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Stand,
action: ActionState::Idle,
},
SfxEvent::Idle,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::Idle);
}
#[test]
fn maps_run() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Run,
action: ActionState::Idle,
},
SfxEvent::Idle,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::Run);
}
#[test]
fn maps_roll() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Roll {
time_left: Duration::new(1, 0),
},
action: ActionState::Idle,
},
SfxEvent::Run,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::Roll);
}
#[test]
fn maps_jump_or_fall() {
let stats = Stats::new(String::from("Test"), None);
// positive z velocity, the character is on the rise (jumping)
let vel_jumping = Vec3::new(0.0, 0.0, 1.0);
let positive_result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Jump,
action: ActionState::Idle,
},
SfxEvent::Idle,
vel_jumping,
&stats,
);
assert_eq!(positive_result, SfxEvent::Jump);
// negative z velocity, the character is on the way down (!jumping)
let vel_falling = Vec3::new(0.0, 0.0, -1.0);
let negative_result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Jump,
action: ActionState::Idle,
},
SfxEvent::Idle,
vel_falling,
&stats,
);
assert_eq!(negative_result, SfxEvent::Fall);
}
#[test]
fn maps_glider_open() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Glide,
action: ActionState::Idle,
},
SfxEvent::Jump,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::GliderOpen);
}
#[test]
fn maps_glide() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Glide,
action: ActionState::Idle,
},
SfxEvent::Glide,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::Glide);
}
#[test]
fn maps_glider_close() {
let stats = Stats::new(String::from("Test"), None);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Jump,
action: ActionState::Idle,
},
SfxEvent::Glide,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::GliderClose);
}
#[test]
fn maps_attack() {
let stats = Stats::new(
String::from("Test"),
Some(assets::load_expect_cloned(
"common.items.weapons.starter_sword",
)),
);
let result = SfxEventMapper::map_character_event(
&CharacterState {
movement: MovementState::Stand,
action: ActionState::Attack {
time_left: Duration::new(1, 0),
applied: true,
},
},
SfxEvent::Idle,
Vec3::zero(),
&stats,
);
assert_eq!(result, SfxEvent::Attack(Tool::Sword));
}
}

View File

@ -0,0 +1,95 @@
/// The SfxManager listens for SFX events and plays the sound at the provided position
mod event_mapper;
use crate::audio::AudioFrontend;
use client::Client;
use common::{
assets,
comp::{Ori, Pos},
event::{EventBus, SfxEvent, SfxEventItem},
};
use serde::Deserialize;
use vek::*;
#[derive(Deserialize)]
pub struct SfxTriggerItem {
pub trigger: SfxEvent,
pub files: Vec<String>,
pub threshold: f64,
}
#[derive(Deserialize)]
pub struct SfxTriggers {
pub items: Vec<SfxTriggerItem>,
}
pub struct SfxMgr {
triggers: SfxTriggers,
event_mapper: event_mapper::SfxEventMapper,
}
impl SfxMgr {
pub fn new() -> Self {
Self {
triggers: Self::load_sfx_items(),
event_mapper: event_mapper::SfxEventMapper::new(),
}
}
pub fn maintain(&mut self, audio: &mut AudioFrontend, client: &Client) {
self.event_mapper.maintain(client, &self.triggers);
let ecs = client.state().ecs();
let player_position = ecs
.read_storage::<Pos>()
.get(client.entity())
.map_or(Vec3::zero(), |pos| pos.0);
let player_ori = ecs
.read_storage::<Ori>()
.get(client.entity())
.map_or(Vec3::zero(), |pos| pos.0);
audio.set_listener_pos(&player_position, &player_ori);
let events = ecs.read_resource::<EventBus<SfxEventItem>>().recv_all();
for event in events {
let position = match event.pos {
Some(pos) => pos,
_ => player_position,
};
// Get the SFX config entry for this movement
let sfx_trigger_item: Option<&SfxTriggerItem> = self
.triggers
.items
.iter()
.find(|item| item.trigger == event.sfx);
if sfx_trigger_item.is_some() {
let item = sfx_trigger_item.expect("Invalid sfx item");
let sfx_file = match item.files.len() {
1 => item
.files
.last()
.expect("Failed to determine sound file for this trigger item."),
_ => {
let rand_step = rand::random::<usize>() % item.files.len();
&item.files[rand_step]
}
};
audio.play_sound(sfx_file, position);
}
}
}
fn load_sfx_items() -> SfxTriggers {
let file = assets::load_file("voxygen.audio.sfx", &["ron"])
.expect("Failed to load the sfx config file");
ron::de::from_reader(file).expect("Error parsing sfx manifest")
}
}

View File

@ -1,16 +1,14 @@
pub mod camera;
pub mod figure;
pub mod sound;
pub mod terrain;
use self::{
camera::{Camera, CameraMode},
figure::FigureMgr,
sound::SoundMgr,
terrain::Terrain,
};
use crate::{
audio::AudioFrontend,
audio::{sfx::SfxMgr, AudioFrontend},
render::{
create_pp_mesh, create_skybox_mesh, Consts, Globals, Light, Model, PostProcessLocals,
PostProcessPipeline, Renderer, Shadow, SkyboxLocals, SkyboxPipeline,
@ -58,7 +56,7 @@ pub struct Scene {
select_pos: Option<Vec3<i32>>,
figure_mgr: FigureMgr,
sound_mgr: SoundMgr,
sfx_mgr: SfxMgr,
}
impl Scene {
@ -91,7 +89,7 @@ impl Scene {
select_pos: None,
figure_mgr: FigureMgr::new(),
sound_mgr: SoundMgr::new(),
sfx_mgr: SfxMgr::new(),
}
}
@ -287,8 +285,8 @@ impl Scene {
// Remove unused figures.
self.figure_mgr.clean(client.get_tick());
// Maintain audio
self.sound_mgr.maintain(audio, client);
// Maintain sfx
self.sfx_mgr.maintain(audio, client);
}
/// Render the scene using the provided `Renderer`.

View File

@ -1,69 +0,0 @@
use crate::audio::AudioFrontend;
use client::Client;
use common::comp::{Body, CharacterState, MovementState::*, Ori, Pos};
use hashbrown::HashMap;
use specs::{Entity as EcsEntity, Join};
use std::time::Instant;
use vek::*;
pub struct AnimState {
last_step_sound: Instant,
}
pub struct SoundMgr {
character_states: HashMap<EcsEntity, AnimState>,
}
impl SoundMgr {
pub fn new() -> Self {
Self {
character_states: HashMap::new(),
}
}
pub fn maintain(&mut self, audio: &mut AudioFrontend, client: &Client) {
const SFX_DIST_LIMIT_SQR: f32 = 22500.0;
let ecs = client.state().ecs();
// Get player position.
let player_pos = ecs
.read_storage::<Pos>()
.get(client.entity())
.map_or(Vec3::zero(), |pos| pos.0);
let player_ori = ecs
.read_storage::<Ori>()
.get(client.entity())
.map_or(Vec3::zero(), |pos| pos.0);
audio.set_listener_pos(&player_pos, &player_ori);
for (entity, pos, body, character) in (
&ecs.entities(),
&ecs.read_storage::<Pos>(),
&ecs.read_storage::<Body>(),
ecs.read_storage::<CharacterState>().maybe(),
)
.join()
.filter(|(_, e_pos, _, _)| (e_pos.0.distance_squared(player_pos)) < SFX_DIST_LIMIT_SQR)
{
if let (Body::Humanoid(_), Some(character)) = (body, character) {
let state = self
.character_states
.entry(entity)
.or_insert_with(|| AnimState {
last_step_sound: Instant::now(),
});
if character.movement == Run && state.last_step_sound.elapsed().as_secs_f64() > 0.25
{
let rand_step = (rand::random::<usize>() % 5) + 1;
audio.play_sound(
&format!("voxygen.audio.sfx.steps.step_{}", rand_step),
pos.0,
);
state.last_step_sound = Instant::now();
}
}
}
}
}