mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
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:
parent
01aa89a0c3
commit
ea2e0d17de
67
assets/voxygen/audio/sfx.ron
Normal file
67
assets/voxygen/audio/sfx.ron
Normal 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,
|
||||
),
|
||||
],
|
||||
)
|
BIN
assets/voxygen/audio/sfx/blank.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/blank.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/chat_message_received.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/chat_message_received.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepsnow_1.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepsnow_1.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepsnow_2.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepsnow_2.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_1.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_1.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_2.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_2.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_3.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_3.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_4.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_4.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_5.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_5.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_6.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_6.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_7.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_7.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_8.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepstone_8.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepwater_1.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepwater_1.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepwater_2.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepwater_2.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepwood_1.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepwood_1.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/footsteps/stepwood_2.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/footsteps/stepwood_2.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/glider_close.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/glider_close.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/glider_open.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/glider_open.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/inventory_add.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/inventory_add.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/steps/step_1.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/steps/step_1.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/steps/step_2.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/steps/step_2.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/steps/step_3.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/steps/step_3.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/steps/step_4.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/steps/step_4.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/steps/step_5.wav
(Stored with Git LFS)
BIN
assets/voxygen/audio/sfx/steps/step_5.wav
(Stored with Git LFS)
Binary file not shown.
BIN
assets/voxygen/audio/sfx/weapon/bow.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/weapon/bow.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
assets/voxygen/audio/sfx/weapon/sword.wav
(Stored with Git LFS)
Normal file
BIN
assets/voxygen/audio/sfx/weapon/sword.wav
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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());
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
443
voxygen/src/audio/sfx/event_mapper.rs
Normal file
443
voxygen/src/audio/sfx/event_mapper.rs
Normal 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));
|
||||
}
|
||||
}
|
95
voxygen/src/audio/sfx/mod.rs
Normal file
95
voxygen/src/audio/sfx/mod.rs
Normal 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")
|
||||
}
|
||||
}
|
@ -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`.
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user