diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4f1c44bc..d449224398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - "Poise" renamed to "Stun resilience" - Stun resilience stat display - Villagers and guards now spawn with potions, and know how to use them. +- Combat music in dungeons when within range of enemies. ### Changed diff --git a/assets/voxygen/audio/music_transition_manifest.ron b/assets/voxygen/audio/music_transition_manifest.ron new file mode 100644 index 0000000000..ac1cfc8bcc --- /dev/null +++ b/assets/voxygen/audio/music_transition_manifest.ron @@ -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, +) diff --git a/assets/voxygen/audio/soundtrack.ron b/assets/voxygen/audio/soundtrack.ron index 950b735643..c7128aa303 100644 --- a/assets/voxygen/audio/soundtrack.ron +++ b/assets/voxygen/audio/soundtrack.ron @@ -6,17 +6,17 @@ ( tracks: [ - ( + Individual(( title: "Dank Dungeon", path: "voxygen.audio.soundtrack.dungeon.dank_dungeon", length: 130.0, timing: None, biomes: [], site: Some(Dungeon), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Calming Hills", path: "voxygen.audio.soundtrack.overworld.calming_hills", length: 101.0, @@ -25,10 +25,10 @@ (Mountain, 1) ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Ultimafounding; mixed by Robotnik", - ), - ( + )), + Individual(( title: "Fiesta Del Pueblo", path: "voxygen.audio.soundtrack.overworld.fiesta_del_pueblo", length: 183.0, @@ -37,50 +37,50 @@ (Desert, 1) ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic; mixed by Robotnik", - ), - ( + )), + Individual(( title: "Ruination", path: "voxygen.audio.soundtrack.dungeon.ruination", length: 135.0, timing: None, biomes: [], site: Some(Dungeon), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Dank Hallows", path: "voxygen.audio.soundtrack.cave.dank_hallows", length: 227.0, timing: None, biomes: [], site: Some(Cave), - activity: Explore, + music_state: Activity(Explore), artist: "Flashbang", - ), - ( + )), + Individual(( title: "Vast Onslaught", path: "voxygen.audio.soundtrack.dungeon.vast_onslaught", length: 237.0, timing: None, biomes: [], site: Some(Dungeon), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Sacred Temple", path: "voxygen.audio.soundtrack.dungeon.sacred_temple", length: 75.0, timing: None, biomes: [], site: Some(Dungeon), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "True Nature", path: "voxygen.audio.soundtrack.overworld.true_nature", length: 169.0, @@ -89,10 +89,10 @@ (Forest, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "DaforLynx", - ), - ( + )), + Individual(( title: "Jungle Ambient", path: "voxygen.audio.soundtrack.overworld.jungle_ambient", length: 218.0, @@ -101,10 +101,10 @@ (Jungle, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Ethereal Bonds", path: "voxygen.audio.soundtrack.overworld.ethereal_bonds", length: 59.0, @@ -113,10 +113,10 @@ (Mountain, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Leap of Faith", path: "voxygen.audio.soundtrack.overworld.leap_of_faith", length: 269.0, @@ -126,10 +126,10 @@ (Lake, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Highland of the Hawk", path: "voxygen.audio.soundtrack.overworld.highland_of_the_hawk", length: 283.0, @@ -139,10 +139,10 @@ (Mountain, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Verdant Glades", path: "voxygen.audio.soundtrack.overworld.verdant_glades", length: 97.0, @@ -151,10 +151,10 @@ (Grassland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Calling Wild", path: "voxygen.audio.soundtrack.overworld.calling_wild", length: 160.0, @@ -163,10 +163,10 @@ (Grassland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Ultimafounding", - ), - ( + )), + Individual(( title: "Drifting Along", path: "voxygen.audio.soundtrack.overworld.drifting_along", length: 164.0, @@ -176,35 +176,35 @@ (Ocean, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "DaforLynx", - ), - ( - title: "Winter Falls", - path: "voxygen.audio.soundtrack.overworld.winter_falls", - length: 215.0, - timing: Some(Day), - biomes: [ - (Snowland, 1), - ], - site: Some(Void), - activity: Explore, - artist: "DaforLynx", - ), - ( - title: "Short Meandering", - path: "voxygen.audio.soundtrack.overworld.short_meandering", - length: 147.0, - timing: Some(Night), - biomes: [ + )), + Individual(( + title: "Winter Falls", + path: "voxygen.audio.soundtrack.overworld.winter_falls", + length: 215.0, + timing: Some(Day), + biomes: [ + (Snowland, 1), + ], + site: Some(Void), + music_state: Activity(Explore), + artist: "DaforLynx", + )), + Individual(( + title: "Short Meandering", + path: "voxygen.audio.soundtrack.overworld.short_meandering", + length: 147.0, + timing: Some(Night), + biomes: [ (Desert, 1), (Mountain, 1), ], - site: Some(Void), - activity: Explore, - artist: "Ap1evideogame", - ), - ( + site: Some(Void), + music_state: Activity(Explore), + artist: "Ap1evideogame", + )), + Individual(( title: "Oceania", path: "voxygen.audio.soundtrack.overworld.oceania", length: 135.0, @@ -214,10 +214,10 @@ (Ocean, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Eden", - ), - ( + )), + Individual(( title: "A Solemn Quest", path: "voxygen.audio.soundtrack.overworld.a_solemn_quest", length: 206.0, @@ -227,10 +227,10 @@ (Mountain, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Eden", - ), - ( + )), + Individual(( title: "Into The Dark Forest", path: "voxygen.audio.soundtrack.overworld.into_the_dark_forest", length: 184.0, @@ -240,23 +240,23 @@ (Jungle, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Field Grazing", path: "voxygen.audio.soundtrack.overworld.field_grazing", length: 154.0, timing: Some(Day), biomes: [ (Grassland, 1), - (Forest, 1), + (Forest, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Wandering Voices", path: "voxygen.audio.soundtrack.overworld.wandering_voices", length: 137.0, @@ -265,10 +265,10 @@ (Grassland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Snowtop Volume", path: "voxygen.audio.soundtrack.overworld.snowtop_volume", length: 89.0, @@ -277,20 +277,20 @@ (Snowland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Mineral Deposits", path: "voxygen.audio.soundtrack.cave.mineral_deposits", length: 148.0, timing: None, biomes: [], site: Some(Cave), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Moonbeams", path: "voxygen.audio.soundtrack.overworld.moonbeams", length: 158.0, @@ -299,23 +299,23 @@ (Snowland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Serene Meadows", path: "voxygen.audio.soundtrack.overworld.serene_meadows", length: 173.0, timing: Some(Night), biomes: [ (Grassland, 1), - (Desert, 1), + (Desert, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "Aeronic", - ), - ( + )), + Individual(( title: "Just The Beginning", path: "voxygen.audio.soundtrack.overworld.just_the_beginning", length: 188.0, @@ -324,10 +324,10 @@ (Grassland, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Campfire Stories", path: "voxygen.audio.soundtrack.overworld.campfire_stories", length: 100.0, @@ -336,10 +336,10 @@ (Forest, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Limits", path: "voxygen.audio.soundtrack.overworld.limits", length: 203.0, @@ -348,20 +348,20 @@ (Mountain, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Down The Rabbit Hole", path: "voxygen.audio.soundtrack.dungeon.down_the_rabbit_hole", length: 244.0, timing: None, biomes: [], site: Some(Dungeon), - activity: Explore, + music_state: Activity(Explore), artist: "badbbad", - ), - ( + )), + Individual(( title: "Between The Fairies", path: "voxygen.audio.soundtrack.overworld.between_the_fairies", length: 175.0, @@ -370,8 +370,25 @@ (Forest, 1), ], site: Some(Void), - activity: Explore, + music_state: Activity(Explore), 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), + ], + ), ] ) diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-end.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-end.ogg new file mode 100644 index 0000000000..cea0c54854 --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-end.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92ae0dfddf02adb1b065f7b2c2916308e55fee86881f9327a887c5992d3cbc37 +size 118900 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-loop.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-loop.ogg new file mode 100644 index 0000000000..5d376cd018 --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-loop.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e2304916495be4dcb765e955c56c2999d5adcc58add42cb8228e0da48595d4a +size 980588 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-start.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-start.ogg new file mode 100644 index 0000000000..37da4f8d2e --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-hi-start.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65a4a4dfde1c76ce8419e1acc26b10484c12dd3438b7a4ec1d441cd109411a46 +size 970650 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-end.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-end.ogg new file mode 100644 index 0000000000..af2e027343 --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-end.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d533af2ac981004367024ee49cf28ff29a55b01beaa1ddf46e2ef3503fca2f4 +size 66906 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-loop.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-loop.ogg new file mode 100644 index 0000000000..fd7b53d16e --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-loop.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8075cd260e7a40dfd34f707536a0e14dc808a40c3ee6c60b64bcb58a32110f4 +size 137390 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-start.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-start.ogg new file mode 100644 index 0000000000..7ea491255f --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-lo-start.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f7d93a7e02d6028c09bfe2f6b84c9144d32085819e58ba69180dbd213f9f713 +size 193104 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-hi-lo.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-hi-lo.ogg new file mode 100644 index 0000000000..4ec0305287 --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-hi-lo.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5899c9320aa61f47b772b98906a205831e2f22d3697446031443827f18c67ba +size 203896 diff --git a/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-lo-hi.ogg b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-lo-hi.ogg new file mode 100644 index 0000000000..5a24de8c05 --- /dev/null +++ b/assets/voxygen/audio/soundtrack/barred_paths/barred_paths-trans-lo-hi.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe4daa29272234be5b94faa32c5e77a01433c3289409e822796f089f920dddd1 +size 133418 diff --git a/voxygen/src/audio/channel.rs b/voxygen/src/audio/channel.rs index 6d325d72d1..dd29aa4b85 100644 --- a/voxygen/src/audio/channel.rs +++ b/voxygen/src/audio/channel.rs @@ -37,10 +37,11 @@ enum ChannelState { /// transition between `TitleMusic` and `Exploration` when a player enters the /// world by crossfading over a slow duration. In the future, transitions in the /// world such as `Exploration` -> `BossBattle` would transition more rapidly. -#[derive(PartialEq, Clone, Copy)] +#[derive(PartialEq, Clone, Copy, Hash, Eq, Deserialize)] pub enum MusicChannelTag { TitleMusic, Exploration, + Combat, } /// A MusicChannel uses a non-positional audio sink designed to play music which diff --git a/voxygen/src/audio/mod.rs b/voxygen/src/audio/mod.rs index 0dad733c15..882d76cfc0 100644 --- a/voxygen/src/audio/mod.rs +++ b/voxygen/src/audio/mod.rs @@ -9,12 +9,13 @@ pub mod soundcache; use channel::{AmbientChannel, AmbientChannelTag, MusicChannel, MusicChannelTag, SfxChannel}; use fader::Fader; +use music::MusicTransitionManifest; use sfx::{SfxEvent, SfxTriggerItem}; use soundcache::{OggSound, WavSound}; use std::time::Duration; use tracing::{debug, error}; -use common::assets::AssetExt; +use common::assets::{AssetExt, AssetHandle}; use rodio::{source::Source, OutputStream, OutputStreamHandle, StreamError}; use vek::*; @@ -45,6 +46,8 @@ pub struct AudioFrontend { sfx_volume: f32, music_volume: f32, listener: Listener, + + mtm: AssetHandle, } impl AudioFrontend { @@ -90,6 +93,7 @@ impl AudioFrontend { sfx_volume: 1.0, music_volume: 1.0, listener: Listener::default(), + mtm: AssetExt::load_expect("voxygen.audio.music_transition_manifest"), } } @@ -108,6 +112,9 @@ impl AudioFrontend { sfx_volume: 1.0, music_volume: 1.0, listener: Listener::default(), + // This expect should be fine, since `::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()?; 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. - existing_channel - .set_fader(Fader::fade_out(Duration::from_secs(2), self.music_volume)); + existing_channel.set_fader(Fader::fade_out(fade_out, self.music_volume)); let mut next_music_channel = MusicChannel::new(&audio_stream); - next_music_channel - .set_fader(Fader::fade_in(Duration::from_secs(12), self.music_volume)); + next_music_channel.set_fader(Fader::fade_in(fade_in, self.music_volume)); self.music_channels.push(next_music_channel); } diff --git a/voxygen/src/audio/music.rs b/voxygen/src/audio/music.rs index 9f4433b955..cedb27e25a 100644 --- a/voxygen/src/audio/music.rs +++ b/voxygen/src/audio/music.rs @@ -50,20 +50,25 @@ use common::{ terrain::{BiomeKind, SitesKind}, }; use common_sys::state::State; +use hashbrown::HashMap; use rand::{prelude::SliceRandom, thread_rng, Rng}; use serde::Deserialize; use std::time::Instant; -use tracing::warn; +use tracing::{debug, warn}; /// Collection of all the tracks -#[derive(Debug, Default, Deserialize)] -struct SoundtrackCollection { +#[derive(Debug, Deserialize)] +struct SoundtrackCollection { /// List of tracks - tracks: Vec, + tracks: Vec, +} + +impl Default for SoundtrackCollection { + fn default() -> Self { Self { tracks: Vec::new() } } } /// Configuration for a single music track in the soundtrack -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct SoundtrackItem { /// Song title title: String, @@ -79,17 +84,46 @@ pub struct SoundtrackItem { site: Option, /// What the player is doing when the track is played (i.e. exploring, /// 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, } -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize)] +enum RawSoundtrackItem { + Individual(SoundtrackItem), + Segmented { + title: String, + timing: Option, + biomes: Vec<(BiomeKind, u8)>, + site: Option, + segments: Vec<(String, f32, MusicState, Option)>, + }, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +enum CombatIntensity { + Low, + High, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] enum MusicActivity { 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 -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, PartialEq)] enum DayPeriod { /// 8:00 AM to 7:30 PM Day, @@ -109,7 +143,7 @@ enum PlayState { /// Provides methods to control music playback pub struct MusicMgr { /// Collection of all the tracks - soundtrack: AssetHandle, + soundtrack: AssetHandle>, /// Instant at which the current track began playing began_playing: Instant, /// 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 /// being played twice in a row 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 { + warn!("Error loading MusicTransitionManifest {:?}: {:?}", id, e); + Ok(MusicTransitionManifest::default()) + } } impl Default for MusicMgr { @@ -126,6 +205,8 @@ impl Default for MusicMgr { began_playing: Instant::now(), next_track_change: 0.0, 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; //} + 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::(); + let healths = ecs.read_component::(); + let groups = ecs.read_component::(); + 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() && !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 { let mut rng = thread_rng(); // 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 current_period_of_day = Self::get_current_day_period(is_dark); let current_biome = client.current_biome(); 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 maybe_tracks = soundtrack + // First, filter out tracks not matching the timing, site, biome, and current + // activity + let mut maybe_tracks = soundtrack .tracks .iter() - .filter(|track| track.activity == MusicActivity::Explore) .filter(|track| { - !track.title.eq(&self.last_track) - && match &track.timing { - Some(period_of_day) => period_of_day == ¤t_period_of_day, - None => true, - } - && match &track.site { - Some(site) => site == ¤t_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; + (match &track.timing { + Some(period_of_day) => period_of_day == ¤t_period_of_day, + None => true, + }) && match &track.site { + Some(site) => site == ¤t_site, + None => 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::>(); + 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 // on the biome let new_maybe_track = maybe_tracks.choose_weighted(&mut rng, |track| { - let mut chance = 0; - if !track.biomes.is_empty() { - for biome in track.biomes.iter() { - if biome.0 == current_biome { - chance = biome.1; - } - } - } else { - // 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 + // If no biome is listed, the song is still added to the + // rotation to allow for site specific songs to play + // in any biome + track + .biomes + .iter() + .find(|b| b.0 == current_biome) + .map_or(1, |b| b.1) }); + debug!( + "selecting new track for {:?}: {:?}", + music_state, new_maybe_track + ); if let Ok(track) = new_maybe_track { //println!("Now playing {:?}", track.title); @@ -220,7 +385,20 @@ impl MusicMgr { self.began_playing = Instant::now(); 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 { + fn load_soundtrack_items() -> AssetHandle> { // Cannot fail: A default value is always provided SoundtrackCollection::load_expect("voxygen.audio.soundtrack") } } - -impl assets::Asset for SoundtrackCollection { +impl assets::Asset for SoundtrackCollection { type Loader = assets::RonLoader; const EXTENSION: &'static str = "ron"; +} - fn default_value(_: &str, error: assets::Error) -> Result { - warn!( - "Error reading music config file, music will not be available: {:#?}", - error - ); - - Ok(SoundtrackCollection::default()) +impl assets::Compound for SoundtrackCollection { + fn load( + _: &assets::AssetCache, + id: &str, + ) -> Result { + let inner = || -> Result<_, assets::Error> { + let manifest: AssetHandle>> = + 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()) + }, + } } }