Merge branch 'aweinstock/combat-music' into 'master'

Add combat music transitions based on number of enemies in player radius.

See merge request veloren/veloren!2077
This commit is contained in:
Samuel Keiffer 2021-04-12 06:01:19 +00:00
commit e1020945dd
14 changed files with 464 additions and 180 deletions

View File

@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- "Poise" renamed to "Stun resilience" - "Poise" renamed to "Stun resilience"
- Stun resilience stat display - Stun resilience stat display
- Villagers and guards now spawn with potions, and know how to use them. - Villagers and guards now spawn with potions, and know how to use them.
- Combat music in dungeons when within range of enemies.
### Changed ### Changed

View File

@ -0,0 +1,15 @@
(
combat_nearby_radius: 40.0,
combat_health_factor: 1000,
combat_nearby_high_thresh: 4,
combat_nearby_low_thresh: 1,
fade_timings: {
(TitleMusic, Exploration): (2.0, 12.0),
(TitleMusic, Combat): (2.0, 3.0),
(Exploration, TitleMusic): (2.0, 12.0),
(Exploration, Combat): (5.0, 3.0),
(Combat, Exploration): (5.0, 3.0),
(Combat, TitleMusic): (2.0, 12.0),
},
interrupt_delay: 5.0,
)

View File

@ -6,17 +6,17 @@
( (
tracks: [ tracks: [
( Individual((
title: "Dank Dungeon", title: "Dank Dungeon",
path: "voxygen.audio.soundtrack.dungeon.dank_dungeon", path: "voxygen.audio.soundtrack.dungeon.dank_dungeon",
length: 130.0, length: 130.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Dungeon), site: Some(Dungeon),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Calming Hills", title: "Calming Hills",
path: "voxygen.audio.soundtrack.overworld.calming_hills", path: "voxygen.audio.soundtrack.overworld.calming_hills",
length: 101.0, length: 101.0,
@ -25,10 +25,10 @@
(Mountain, 1) (Mountain, 1)
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Ultimafounding; mixed by Robotnik", artist: "Ultimafounding; mixed by Robotnik",
), )),
( Individual((
title: "Fiesta Del Pueblo", title: "Fiesta Del Pueblo",
path: "voxygen.audio.soundtrack.overworld.fiesta_del_pueblo", path: "voxygen.audio.soundtrack.overworld.fiesta_del_pueblo",
length: 183.0, length: 183.0,
@ -37,50 +37,50 @@
(Desert, 1) (Desert, 1)
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic; mixed by Robotnik", artist: "Aeronic; mixed by Robotnik",
), )),
( Individual((
title: "Ruination", title: "Ruination",
path: "voxygen.audio.soundtrack.dungeon.ruination", path: "voxygen.audio.soundtrack.dungeon.ruination",
length: 135.0, length: 135.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Dungeon), site: Some(Dungeon),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Dank Hallows", title: "Dank Hallows",
path: "voxygen.audio.soundtrack.cave.dank_hallows", path: "voxygen.audio.soundtrack.cave.dank_hallows",
length: 227.0, length: 227.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Cave), site: Some(Cave),
activity: Explore, music_state: Activity(Explore),
artist: "Flashbang", artist: "Flashbang",
), )),
( Individual((
title: "Vast Onslaught", title: "Vast Onslaught",
path: "voxygen.audio.soundtrack.dungeon.vast_onslaught", path: "voxygen.audio.soundtrack.dungeon.vast_onslaught",
length: 237.0, length: 237.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Dungeon), site: Some(Dungeon),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Sacred Temple", title: "Sacred Temple",
path: "voxygen.audio.soundtrack.dungeon.sacred_temple", path: "voxygen.audio.soundtrack.dungeon.sacred_temple",
length: 75.0, length: 75.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Dungeon), site: Some(Dungeon),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "True Nature", title: "True Nature",
path: "voxygen.audio.soundtrack.overworld.true_nature", path: "voxygen.audio.soundtrack.overworld.true_nature",
length: 169.0, length: 169.0,
@ -89,10 +89,10 @@
(Forest, 1), (Forest, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "DaforLynx", artist: "DaforLynx",
), )),
( Individual((
title: "Jungle Ambient", title: "Jungle Ambient",
path: "voxygen.audio.soundtrack.overworld.jungle_ambient", path: "voxygen.audio.soundtrack.overworld.jungle_ambient",
length: 218.0, length: 218.0,
@ -101,10 +101,10 @@
(Jungle, 1), (Jungle, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Ethereal Bonds", title: "Ethereal Bonds",
path: "voxygen.audio.soundtrack.overworld.ethereal_bonds", path: "voxygen.audio.soundtrack.overworld.ethereal_bonds",
length: 59.0, length: 59.0,
@ -113,10 +113,10 @@
(Mountain, 1), (Mountain, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Leap of Faith", title: "Leap of Faith",
path: "voxygen.audio.soundtrack.overworld.leap_of_faith", path: "voxygen.audio.soundtrack.overworld.leap_of_faith",
length: 269.0, length: 269.0,
@ -126,10 +126,10 @@
(Lake, 1), (Lake, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Highland of the Hawk", title: "Highland of the Hawk",
path: "voxygen.audio.soundtrack.overworld.highland_of_the_hawk", path: "voxygen.audio.soundtrack.overworld.highland_of_the_hawk",
length: 283.0, length: 283.0,
@ -139,10 +139,10 @@
(Mountain, 1), (Mountain, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Verdant Glades", title: "Verdant Glades",
path: "voxygen.audio.soundtrack.overworld.verdant_glades", path: "voxygen.audio.soundtrack.overworld.verdant_glades",
length: 97.0, length: 97.0,
@ -151,10 +151,10 @@
(Grassland, 1), (Grassland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Calling Wild", title: "Calling Wild",
path: "voxygen.audio.soundtrack.overworld.calling_wild", path: "voxygen.audio.soundtrack.overworld.calling_wild",
length: 160.0, length: 160.0,
@ -163,10 +163,10 @@
(Grassland, 1), (Grassland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Ultimafounding", artist: "Ultimafounding",
), )),
( Individual((
title: "Drifting Along", title: "Drifting Along",
path: "voxygen.audio.soundtrack.overworld.drifting_along", path: "voxygen.audio.soundtrack.overworld.drifting_along",
length: 164.0, length: 164.0,
@ -176,35 +176,35 @@
(Ocean, 1), (Ocean, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "DaforLynx", artist: "DaforLynx",
), )),
( Individual((
title: "Winter Falls", title: "Winter Falls",
path: "voxygen.audio.soundtrack.overworld.winter_falls", path: "voxygen.audio.soundtrack.overworld.winter_falls",
length: 215.0, length: 215.0,
timing: Some(Day), timing: Some(Day),
biomes: [ biomes: [
(Snowland, 1), (Snowland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "DaforLynx", artist: "DaforLynx",
), )),
( Individual((
title: "Short Meandering", title: "Short Meandering",
path: "voxygen.audio.soundtrack.overworld.short_meandering", path: "voxygen.audio.soundtrack.overworld.short_meandering",
length: 147.0, length: 147.0,
timing: Some(Night), timing: Some(Night),
biomes: [ biomes: [
(Desert, 1), (Desert, 1),
(Mountain, 1), (Mountain, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Ap1evideogame", artist: "Ap1evideogame",
), )),
( Individual((
title: "Oceania", title: "Oceania",
path: "voxygen.audio.soundtrack.overworld.oceania", path: "voxygen.audio.soundtrack.overworld.oceania",
length: 135.0, length: 135.0,
@ -214,10 +214,10 @@
(Ocean, 1), (Ocean, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Eden", artist: "Eden",
), )),
( Individual((
title: "A Solemn Quest", title: "A Solemn Quest",
path: "voxygen.audio.soundtrack.overworld.a_solemn_quest", path: "voxygen.audio.soundtrack.overworld.a_solemn_quest",
length: 206.0, length: 206.0,
@ -227,10 +227,10 @@
(Mountain, 1), (Mountain, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Eden", artist: "Eden",
), )),
( Individual((
title: "Into The Dark Forest", title: "Into The Dark Forest",
path: "voxygen.audio.soundtrack.overworld.into_the_dark_forest", path: "voxygen.audio.soundtrack.overworld.into_the_dark_forest",
length: 184.0, length: 184.0,
@ -240,23 +240,23 @@
(Jungle, 1), (Jungle, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Field Grazing", title: "Field Grazing",
path: "voxygen.audio.soundtrack.overworld.field_grazing", path: "voxygen.audio.soundtrack.overworld.field_grazing",
length: 154.0, length: 154.0,
timing: Some(Day), timing: Some(Day),
biomes: [ biomes: [
(Grassland, 1), (Grassland, 1),
(Forest, 1), (Forest, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Wandering Voices", title: "Wandering Voices",
path: "voxygen.audio.soundtrack.overworld.wandering_voices", path: "voxygen.audio.soundtrack.overworld.wandering_voices",
length: 137.0, length: 137.0,
@ -265,10 +265,10 @@
(Grassland, 1), (Grassland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Snowtop Volume", title: "Snowtop Volume",
path: "voxygen.audio.soundtrack.overworld.snowtop_volume", path: "voxygen.audio.soundtrack.overworld.snowtop_volume",
length: 89.0, length: 89.0,
@ -277,20 +277,20 @@
(Snowland, 1), (Snowland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Mineral Deposits", title: "Mineral Deposits",
path: "voxygen.audio.soundtrack.cave.mineral_deposits", path: "voxygen.audio.soundtrack.cave.mineral_deposits",
length: 148.0, length: 148.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Cave), site: Some(Cave),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Moonbeams", title: "Moonbeams",
path: "voxygen.audio.soundtrack.overworld.moonbeams", path: "voxygen.audio.soundtrack.overworld.moonbeams",
length: 158.0, length: 158.0,
@ -299,23 +299,23 @@
(Snowland, 1), (Snowland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Serene Meadows", title: "Serene Meadows",
path: "voxygen.audio.soundtrack.overworld.serene_meadows", path: "voxygen.audio.soundtrack.overworld.serene_meadows",
length: 173.0, length: 173.0,
timing: Some(Night), timing: Some(Night),
biomes: [ biomes: [
(Grassland, 1), (Grassland, 1),
(Desert, 1), (Desert, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "Aeronic", artist: "Aeronic",
), )),
( Individual((
title: "Just The Beginning", title: "Just The Beginning",
path: "voxygen.audio.soundtrack.overworld.just_the_beginning", path: "voxygen.audio.soundtrack.overworld.just_the_beginning",
length: 188.0, length: 188.0,
@ -324,10 +324,10 @@
(Grassland, 1), (Grassland, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Campfire Stories", title: "Campfire Stories",
path: "voxygen.audio.soundtrack.overworld.campfire_stories", path: "voxygen.audio.soundtrack.overworld.campfire_stories",
length: 100.0, length: 100.0,
@ -336,10 +336,10 @@
(Forest, 1), (Forest, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Limits", title: "Limits",
path: "voxygen.audio.soundtrack.overworld.limits", path: "voxygen.audio.soundtrack.overworld.limits",
length: 203.0, length: 203.0,
@ -348,20 +348,20 @@
(Mountain, 1), (Mountain, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Down The Rabbit Hole", title: "Down The Rabbit Hole",
path: "voxygen.audio.soundtrack.dungeon.down_the_rabbit_hole", path: "voxygen.audio.soundtrack.dungeon.down_the_rabbit_hole",
length: 244.0, length: 244.0,
timing: None, timing: None,
biomes: [], biomes: [],
site: Some(Dungeon), site: Some(Dungeon),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
), )),
( Individual((
title: "Between The Fairies", title: "Between The Fairies",
path: "voxygen.audio.soundtrack.overworld.between_the_fairies", path: "voxygen.audio.soundtrack.overworld.between_the_fairies",
length: 175.0, length: 175.0,
@ -370,8 +370,25 @@
(Forest, 1), (Forest, 1),
], ],
site: Some(Void), site: Some(Void),
activity: Explore, music_state: Activity(Explore),
artist: "badbbad", artist: "badbbad",
) )),
Segmented(
title: "Barred Paths",
author: "DaforLynx",
timing: None,
biomes: [],
site: Some(Dungeon),
segments: [
("voxygen.audio.soundtrack.barred_paths.barred_paths-hi-end", 6.0, Transition(Combat(High), Explore), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-hi-loop", 54.0, Activity(Combat(High)), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-hi-start", 55.0, Transition(Explore, Combat(High)), Some(Combat(High))),
("voxygen.audio.soundtrack.barred_paths.barred_paths-lo-end", 3.0, Transition(Combat(Low), Explore), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-lo-loop", 7.0, Activity(Combat(Low)), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-lo-start", 10.0, Transition(Explore, Combat(Low)), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-trans-hi-lo", 10.0, Transition(Combat(High), Combat(Low)), None),
("voxygen.audio.soundtrack.barred_paths.barred_paths-trans-lo-hi", 7.0, Transition(Combat(Low), Combat(High)), None),
],
),
] ]
) )

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.

Binary file not shown.

View File

@ -37,10 +37,11 @@ enum ChannelState {
/// transition between `TitleMusic` and `Exploration` when a player enters the /// transition between `TitleMusic` and `Exploration` when a player enters the
/// world by crossfading over a slow duration. In the future, transitions in the /// world by crossfading over a slow duration. In the future, transitions in the
/// world such as `Exploration` -> `BossBattle` would transition more rapidly. /// world such as `Exploration` -> `BossBattle` would transition more rapidly.
#[derive(PartialEq, Clone, Copy)] #[derive(PartialEq, Clone, Copy, Hash, Eq, Deserialize)]
pub enum MusicChannelTag { pub enum MusicChannelTag {
TitleMusic, TitleMusic,
Exploration, Exploration,
Combat,
} }
/// A MusicChannel uses a non-positional audio sink designed to play music which /// A MusicChannel uses a non-positional audio sink designed to play music which

View File

@ -9,12 +9,13 @@ pub mod soundcache;
use channel::{AmbientChannel, AmbientChannelTag, MusicChannel, MusicChannelTag, SfxChannel}; use channel::{AmbientChannel, AmbientChannelTag, MusicChannel, MusicChannelTag, SfxChannel};
use fader::Fader; use fader::Fader;
use music::MusicTransitionManifest;
use sfx::{SfxEvent, SfxTriggerItem}; use sfx::{SfxEvent, SfxTriggerItem};
use soundcache::{OggSound, WavSound}; use soundcache::{OggSound, WavSound};
use std::time::Duration; use std::time::Duration;
use tracing::{debug, error}; use tracing::{debug, error};
use common::assets::AssetExt; use common::assets::{AssetExt, AssetHandle};
use rodio::{source::Source, OutputStream, OutputStreamHandle, StreamError}; use rodio::{source::Source, OutputStream, OutputStreamHandle, StreamError};
use vek::*; use vek::*;
@ -45,6 +46,8 @@ pub struct AudioFrontend {
sfx_volume: f32, sfx_volume: f32,
music_volume: f32, music_volume: f32,
listener: Listener, listener: Listener,
mtm: AssetHandle<MusicTransitionManifest>,
} }
impl AudioFrontend { impl AudioFrontend {
@ -90,6 +93,7 @@ impl AudioFrontend {
sfx_volume: 1.0, sfx_volume: 1.0,
music_volume: 1.0, music_volume: 1.0,
listener: Listener::default(), listener: Listener::default(),
mtm: AssetExt::load_expect("voxygen.audio.music_transition_manifest"),
} }
} }
@ -108,6 +112,9 @@ impl AudioFrontend {
sfx_volume: 1.0, sfx_volume: 1.0,
music_volume: 1.0, music_volume: 1.0,
listener: Listener::default(), listener: Listener::default(),
// This expect should be fine, since `<MusicTransitionManifest as Asset>::default_value`
// is specified
mtm: AssetExt::load_expect("voxygen.audio.music_transition_manifest"),
} }
} }
@ -152,14 +159,19 @@ impl AudioFrontend {
let existing_channel = self.music_channels.last_mut()?; let existing_channel = self.music_channels.last_mut()?;
if existing_channel.get_tag() != next_channel_tag { if existing_channel.get_tag() != next_channel_tag {
let mtm = self.mtm.read();
let (fade_out, fade_in) = mtm
.fade_timings
.get(&(existing_channel.get_tag(), next_channel_tag))
.unwrap_or(&(1.0, 1.0));
let fade_out = Duration::from_secs_f32(*fade_out);
let fade_in = Duration::from_secs_f32(*fade_in);
// Fade the existing channel out. It will be removed when the fade completes. // Fade the existing channel out. It will be removed when the fade completes.
existing_channel existing_channel.set_fader(Fader::fade_out(fade_out, self.music_volume));
.set_fader(Fader::fade_out(Duration::from_secs(2), self.music_volume));
let mut next_music_channel = MusicChannel::new(&audio_stream); let mut next_music_channel = MusicChannel::new(&audio_stream);
next_music_channel next_music_channel.set_fader(Fader::fade_in(fade_in, self.music_volume));
.set_fader(Fader::fade_in(Duration::from_secs(12), self.music_volume));
self.music_channels.push(next_music_channel); self.music_channels.push(next_music_channel);
} }

View File

@ -50,20 +50,25 @@ use common::{
terrain::{BiomeKind, SitesKind}, terrain::{BiomeKind, SitesKind},
}; };
use common_sys::state::State; use common_sys::state::State;
use hashbrown::HashMap;
use rand::{prelude::SliceRandom, thread_rng, Rng}; use rand::{prelude::SliceRandom, thread_rng, Rng};
use serde::Deserialize; use serde::Deserialize;
use std::time::Instant; use std::time::Instant;
use tracing::warn; use tracing::{debug, warn};
/// Collection of all the tracks /// Collection of all the tracks
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Deserialize)]
struct SoundtrackCollection { struct SoundtrackCollection<T> {
/// List of tracks /// List of tracks
tracks: Vec<SoundtrackItem>, tracks: Vec<T>,
}
impl<T> Default for SoundtrackCollection<T> {
fn default() -> Self { Self { tracks: Vec::new() } }
} }
/// Configuration for a single music track in the soundtrack /// Configuration for a single music track in the soundtrack
#[derive(Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct SoundtrackItem { pub struct SoundtrackItem {
/// Song title /// Song title
title: String, title: String,
@ -79,17 +84,46 @@ pub struct SoundtrackItem {
site: Option<SitesKind>, site: Option<SitesKind>,
/// What the player is doing when the track is played (i.e. exploring, /// What the player is doing when the track is played (i.e. exploring,
/// combat) /// combat)
activity: MusicActivity, music_state: MusicState,
/// What activity to override the activity state with, if any (e.g. to make
/// a long combat intro also act like the loop for the purposes of outro
/// transitions)
#[serde(default)]
activity_override: Option<MusicActivity>,
} }
#[derive(Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize)]
enum RawSoundtrackItem {
Individual(SoundtrackItem),
Segmented {
title: String,
timing: Option<DayPeriod>,
biomes: Vec<(BiomeKind, u8)>,
site: Option<SitesKind>,
segments: Vec<(String, f32, MusicState, Option<MusicActivity>)>,
},
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum CombatIntensity {
Low,
High,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum MusicActivity { enum MusicActivity {
Explore, Explore,
Combat, Combat(CombatIntensity),
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum MusicState {
Activity(MusicActivity),
Transition(MusicActivity, MusicActivity),
} }
/// Allows control over when a track should play based on in-game time of day /// Allows control over when a track should play based on in-game time of day
#[derive(Debug, Deserialize, PartialEq)] #[derive(Clone, Debug, Deserialize, PartialEq)]
enum DayPeriod { enum DayPeriod {
/// 8:00 AM to 7:30 PM /// 8:00 AM to 7:30 PM
Day, Day,
@ -109,7 +143,7 @@ enum PlayState {
/// Provides methods to control music playback /// Provides methods to control music playback
pub struct MusicMgr { pub struct MusicMgr {
/// Collection of all the tracks /// Collection of all the tracks
soundtrack: AssetHandle<SoundtrackCollection>, soundtrack: AssetHandle<SoundtrackCollection<SoundtrackItem>>,
/// Instant at which the current track began playing /// Instant at which the current track began playing
began_playing: Instant, began_playing: Instant,
/// Time until the next track should be played /// Time until the next track should be played
@ -117,6 +151,51 @@ pub struct MusicMgr {
/// The title of the last track played. Used to prevent a track /// The title of the last track played. Used to prevent a track
/// being played twice in a row /// being played twice in a row
last_track: String, last_track: String,
/// Time of the last interrupt (to avoid rapid switching)
last_interrupt: Instant,
/// The previous track's activity kind, for transitions
last_activity: MusicState,
}
#[derive(Deserialize)]
pub struct MusicTransitionManifest {
/// Within what radius do enemies count towards combat music?
combat_nearby_radius: f32,
/// Each multiple of this factor that an enemy has health counts as an extra
/// enemy
combat_health_factor: u32,
/// How many nearby enemies trigger High combat music
combat_nearby_high_thresh: u32,
/// How many nearby enemies trigger Low combat music
combat_nearby_low_thresh: u32,
/// Fade in and fade out timings for transitions between channels
pub fade_timings: HashMap<(MusicChannelTag, MusicChannelTag), (f32, f32)>,
/// How many seconds between interrupt checks
pub interrupt_delay: f32,
}
impl Default for MusicTransitionManifest {
fn default() -> MusicTransitionManifest {
MusicTransitionManifest {
combat_nearby_radius: 40.0,
combat_health_factor: 1000,
combat_nearby_high_thresh: 3,
combat_nearby_low_thresh: 1,
fade_timings: HashMap::new(),
interrupt_delay: 5.0,
}
}
}
impl assets::Asset for MusicTransitionManifest {
type Loader = assets::RonLoader;
const EXTENSION: &'static str = "ron";
fn default_value(id: &str, e: assets::Error) -> Result<MusicTransitionManifest, assets::Error> {
warn!("Error loading MusicTransitionManifest {:?}: {:?}", id, e);
Ok(MusicTransitionManifest::default())
}
} }
impl Default for MusicMgr { impl Default for MusicMgr {
@ -126,6 +205,8 @@ impl Default for MusicMgr {
began_playing: Instant::now(), began_playing: Instant::now(),
next_track_change: 0.0, next_track_change: 0.0,
last_track: String::from("None"), last_track: String::from("None"),
last_interrupt: Instant::now(),
last_activity: MusicState::Activity(MusicActivity::Explore),
} }
} }
} }
@ -144,75 +225,159 @@ impl MusicMgr {
// player_alt = position.0.z; // player_alt = position.0.z;
//} //}
use common::comp::{group::ENEMY, Group, Health, Pos};
use specs::{Join, WorldExt};
let mut activity_state = MusicActivity::Explore;
let player = client.entity();
let ecs = state.ecs();
let entities = ecs.entities();
let positions = ecs.read_component::<Pos>();
let healths = ecs.read_component::<Health>();
let groups = ecs.read_component::<Group>();
let mtm = audio.mtm.read();
if let Some(player_pos) = positions.get(player) {
// TODO: `group::ENEMY` will eventually be moved server-side with an
// alignment/faction rework, so this will need an alternative way to measure
// "in-combat-ness"
let num_nearby_entities: u32 = (&entities, &positions, &healths, &groups)
.join()
.map(|(entity, pos, health, group)| {
if entity != player
&& group == &ENEMY
&& (player_pos.0 - pos.0).magnitude_squared()
< mtm.combat_nearby_radius.powf(2.0)
{
(health.maximum() / mtm.combat_health_factor).max(1)
} else {
0
}
})
.sum();
if num_nearby_entities >= mtm.combat_nearby_high_thresh {
activity_state = MusicActivity::Combat(CombatIntensity::High);
} else if num_nearby_entities >= mtm.combat_nearby_low_thresh {
activity_state = MusicActivity::Combat(CombatIntensity::Low);
}
}
// Override combat music with explore music if the player is dead
if let Some(health) = healths.get(player) {
if health.is_dead {
activity_state = MusicActivity::Explore;
}
}
let music_state = match self.last_activity {
MusicState::Activity(prev) => {
if prev != activity_state {
MusicState::Transition(prev, activity_state)
} else {
MusicState::Activity(activity_state)
}
},
MusicState::Transition(_, next) => MusicState::Activity(next),
};
let interrupt = matches!(music_state, MusicState::Transition(_, _))
&& self.last_interrupt.elapsed().as_secs_f32() > mtm.interrupt_delay;
if audio.music_enabled() if audio.music_enabled()
&& !self.soundtrack.read().tracks.is_empty() && !self.soundtrack.read().tracks.is_empty()
&& self.began_playing.elapsed().as_secs_f32() > self.next_track_change && (self.began_playing.elapsed().as_secs_f32() > self.next_track_change || interrupt)
{ {
self.play_random_track(audio, state, client); if interrupt {
self.last_interrupt = Instant::now();
}
debug!(
"pre-play_random_track: {:?} {:?}",
self.last_activity, music_state
);
if let Ok(next_activity) = self.play_random_track(audio, state, client, &music_state) {
self.last_activity = next_activity;
}
} }
} }
fn play_random_track(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) { fn play_random_track(
&mut self,
audio: &mut AudioFrontend,
state: &State,
client: &Client,
music_state: &MusicState,
) -> Result<MusicState, ()> {
let mut rng = thread_rng(); let mut rng = thread_rng();
// Adds a bit of randomness between plays // Adds a bit of randomness between plays
let silence_between_tracks_seconds: f32 = rng.gen_range(60.0..120.0); let silence_between_tracks_seconds: f32 =
if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
rng.gen_range(60.0..120.0)
} else {
0.0
};
let is_dark = (state.get_day_period().is_dark()) as bool; let is_dark = (state.get_day_period().is_dark()) as bool;
let current_period_of_day = Self::get_current_day_period(is_dark); let current_period_of_day = Self::get_current_day_period(is_dark);
let current_biome = client.current_biome(); let current_biome = client.current_biome();
let current_site = client.current_site(); let current_site = client.current_site();
// Filters out tracks not matching the timing, site, and biome // Filter the soundtrack in stages, so that we don't overprune it if there are
// too many constraints. Returning Err(()) signals that we couldn't find
// an appropriate track for the current state, and hence the state
// machine for the activity shouldn't be updated.
let soundtrack = self.soundtrack.read(); let soundtrack = self.soundtrack.read();
let maybe_tracks = soundtrack // First, filter out tracks not matching the timing, site, biome, and current
// activity
let mut maybe_tracks = soundtrack
.tracks .tracks
.iter() .iter()
.filter(|track| track.activity == MusicActivity::Explore)
.filter(|track| { .filter(|track| {
!track.title.eq(&self.last_track) (match &track.timing {
&& match &track.timing { Some(period_of_day) => period_of_day == &current_period_of_day,
Some(period_of_day) => period_of_day == &current_period_of_day, None => true,
None => true, }) && match &track.site {
} Some(site) => site == &current_site,
&& match &track.site { None => true,
Some(site) => site == &current_site,
None => true,
}
})
.filter(|track| {
let mut result = false;
if !track.biomes.is_empty() {
for biome in track.biomes.iter() {
if biome.0 == current_biome {
result = true;
}
}
} else {
result = true;
} }
result
}) })
.filter(|track| {
track.biomes.is_empty() || track.biomes.iter().any(|b| b.0 == current_biome)
})
.filter(|track| &track.music_state == music_state)
.collect::<Vec<&SoundtrackItem>>(); .collect::<Vec<&SoundtrackItem>>();
if maybe_tracks.is_empty() {
return Err(());
}
// Second, prevent playing the last track if possible (though don't return Err
// here, since the combat music is intended to loop)
let filtered_tracks: Vec<_> = maybe_tracks
.iter()
.filter(|track| !track.title.eq(&self.last_track))
.copied()
.collect();
if !filtered_tracks.is_empty() {
maybe_tracks = filtered_tracks;
}
// Randomly selects a track from the remaining tracks weighted based // Randomly selects a track from the remaining tracks weighted based
// on the biome // on the biome
let new_maybe_track = maybe_tracks.choose_weighted(&mut rng, |track| { let new_maybe_track = maybe_tracks.choose_weighted(&mut rng, |track| {
let mut chance = 0; // If no biome is listed, the song is still added to the
if !track.biomes.is_empty() { // rotation to allow for site specific songs to play
for biome in track.biomes.iter() { // in any biome
if biome.0 == current_biome { track
chance = biome.1; .biomes
} .iter()
} .find(|b| b.0 == current_biome)
} else { .map_or(1, |b| b.1)
// If no biome is listed, the song is still added to the
// rotation to allow for site specific songs to play
// in any biome
chance = 1;
}
chance
}); });
debug!(
"selecting new track for {:?}: {:?}",
music_state, new_maybe_track
);
if let Ok(track) = new_maybe_track { if let Ok(track) = new_maybe_track {
//println!("Now playing {:?}", track.title); //println!("Now playing {:?}", track.title);
@ -220,7 +385,20 @@ impl MusicMgr {
self.began_playing = Instant::now(); self.began_playing = Instant::now();
self.next_track_change = track.length + silence_between_tracks_seconds; self.next_track_change = track.length + silence_between_tracks_seconds;
audio.play_music(&track.path, MusicChannelTag::Exploration); let tag = if matches!(music_state, MusicState::Activity(MusicActivity::Explore)) {
MusicChannelTag::Exploration
} else {
MusicChannelTag::Combat
};
audio.play_music(&track.path, tag);
if let Some(state) = track.activity_override {
Ok(MusicState::Activity(state))
} else {
Ok(*music_state)
}
} else {
Err(())
} }
} }
@ -232,23 +410,59 @@ impl MusicMgr {
} }
} }
fn load_soundtrack_items() -> AssetHandle<SoundtrackCollection> { fn load_soundtrack_items() -> AssetHandle<SoundtrackCollection<SoundtrackItem>> {
// Cannot fail: A default value is always provided // Cannot fail: A default value is always provided
SoundtrackCollection::load_expect("voxygen.audio.soundtrack") SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
} }
} }
impl assets::Asset for SoundtrackCollection<RawSoundtrackItem> {
impl assets::Asset for SoundtrackCollection {
type Loader = assets::RonLoader; type Loader = assets::RonLoader;
const EXTENSION: &'static str = "ron"; const EXTENSION: &'static str = "ron";
}
fn default_value(_: &str, error: assets::Error) -> Result<Self, assets::Error> { impl assets::Compound for SoundtrackCollection<SoundtrackItem> {
warn!( fn load<S: assets::source::Source>(
"Error reading music config file, music will not be available: {:#?}", _: &assets::AssetCache<S>,
error id: &str,
); ) -> Result<Self, assets::Error> {
let inner = || -> Result<_, assets::Error> {
Ok(SoundtrackCollection::default()) let manifest: AssetHandle<assets::Ron<SoundtrackCollection<RawSoundtrackItem>>> =
AssetExt::load(id)?;
let mut soundtracks = SoundtrackCollection::default();
for item in manifest.read().0.tracks.iter().cloned() {
match item {
RawSoundtrackItem::Individual(track) => soundtracks.tracks.push(track),
RawSoundtrackItem::Segmented {
title,
timing,
biomes,
site,
segments,
} => {
for (path, length, music_state, activity_override) in segments.into_iter() {
soundtracks.tracks.push(SoundtrackItem {
title: title.clone(),
path,
length,
timing: timing.clone(),
biomes: biomes.clone(),
site,
music_state,
activity_override,
});
}
},
}
}
Ok(soundtracks)
};
match inner() {
Ok(soundtracks) => Ok(soundtracks),
Err(e) => {
warn!("Error loading soundtracks: {:?}", e);
Ok(SoundtrackCollection::default())
},
}
} }
} }