veloren/voxygen/src/audio/music.rs

426 lines
14 KiB
Rust
Raw Normal View History

2020-06-14 16:56:32 +00:00
//! Handles music playback and transitions
//!
//! Game music is controlled though a configuration file found in the source at
//! `/assets/voxygen/audio/soundtrack.ron`. Each track enabled in game has a
//! configuration corresponding to the
//! [`SoundtrackItem`](struct.SoundtrackItem.html) format, as well as the
//! corresponding `.ogg` file in the `/assets/voxygen/audio/soundtrack/`
//! directory.
//!
//! If there are errors while reading or deserialising the configuration file, a
//! warning is logged and music will be disabled.
//!
//! ## Adding new music
//!
//! To add a new item, append the details to the audio configuration file, and
//! add the audio file (in `.ogg` format) to the assets directory.
//!
//! The `length` should be provided in seconds. This allows us to know when to
//! transition to another track, without having to spend time determining track
//! length programmatically.
//!
//! An example of a new night time track:
//! ```text
//! (
//! title: "Sleepy Song",
//! path: "voxygen.audio.soundtrack.sleepy",
//! length: 400.0,
//! timing: Some(Night),
2020-11-12 03:55:40 +00:00
//! biomes: [
//! (Forest, 1),
//! (Grassland, 2),
//! ],
//! site: None,
//! activity: Explore,
2020-06-14 16:56:32 +00:00
//! artist: "Elvis",
//! ),
//! ```
//!
//! Before sending an MR for your new track item:
//! - Be conscious of the file size for your new track. Assets contribute to
//! download sizes
//! - Ensure that the track is mastered to a volume proportionate to other music
//! tracks
//! - If you are not the author of the track, ensure that the song's licensing
//! permits usage of the track for non-commercial use
2020-11-14 00:27:09 +00:00
use crate::audio::{AudioFrontend, MusicChannelTag};
use client::Client;
2020-11-02 07:19:28 +00:00
use common::{
2020-12-12 22:14:24 +00:00
assets::{self, AssetExt, AssetHandle},
2020-11-02 07:19:28 +00:00
terrain::{BiomeKind, SitesKind},
};
2020-12-01 12:13:07 +00:00
use common_sys::state::State;
2020-11-06 08:05:29 +00:00
use rand::{prelude::SliceRandom, thread_rng, Rng};
use serde::Deserialize;
use std::time::Instant;
use tracing::{debug, trace, warn};
/// Collection of all the tracks
#[derive(Debug, Deserialize)]
struct SoundtrackCollection<T> {
/// List of tracks
tracks: Vec<T>,
}
impl<T> Default for SoundtrackCollection<T> {
fn default() -> Self { Self { tracks: Vec::new() } }
}
2020-06-14 16:56:32 +00:00
/// Configuration for a single music track in the soundtrack
#[derive(Clone, Debug, Deserialize)]
pub struct SoundtrackItem {
/// Song title
title: String,
/// File path to asset
path: String,
2020-06-14 16:56:32 +00:00
/// Length of the track in seconds
2020-11-06 08:05:29 +00:00
length: f32,
2020-06-14 16:56:32 +00:00
/// Whether this track should play during day or night
timing: Option<DayPeriod>,
/// What biomes this track should play in with chance of play
2020-11-04 23:22:56 +00:00
biomes: Vec<(BiomeKind, u8)>,
/// Whether this track should play in a specific site
2020-11-02 07:19:28 +00:00
site: Option<SitesKind>,
/// What the player is doing when the track is played (i.e. exploring,
/// combat)
activity: MusicActivity,
}
#[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, MusicActivity)>,
},
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum CombatIntensity {
Low,
High,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum MusicActivityState {
Explore,
Combat(CombatIntensity),
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
enum MusicActivity {
State(MusicActivityState),
Transition(MusicActivityState, MusicActivityState),
}
2020-06-14 16:56:32 +00:00
/// Allows control over when a track should play based on in-game time of day
#[derive(Clone, Debug, Deserialize, PartialEq)]
enum DayPeriod {
2020-06-14 16:56:32 +00:00
/// 8:00 AM to 7:30 PM
Day,
/// 7:31 PM to 6:59 AM
Night,
}
/// Determines whether the sound is stopped, playing, or fading
#[derive(Debug, Deserialize, PartialEq)]
enum PlayState {
Playing,
Stopped,
FadingOut,
FadingIn,
}
2020-06-14 16:56:32 +00:00
/// Provides methods to control music playback
pub struct MusicMgr {
/// Collection of all the tracks
soundtrack: AssetHandle<SoundtrackCollection<SoundtrackItem>>,
/// Instant at which the current track began playing
began_playing: Instant,
/// Time until the next track should be played
2020-11-06 08:05:29 +00:00
next_track_change: f32,
2020-06-14 16:56:32 +00:00
/// The title of the last track played. Used to prevent a track
/// being played twice in a row
last_track: String,
/// The previous track's activity kind, for transitions
last_activity: MusicActivityState,
}
impl Default for MusicMgr {
fn default() -> Self {
Self {
soundtrack: Self::load_soundtrack_items(),
began_playing: Instant::now(),
next_track_change: 0.0,
last_track: String::from("None"),
last_activity: MusicActivityState::Explore,
}
}
}
impl MusicMgr {
2020-06-14 16:56:32 +00:00
/// Checks whether the previous track has completed. If so, sends a
/// request to play the next (random) track
pub fn maintain(&mut self, audio: &mut AudioFrontend, state: &State, client: &Client) {
2020-11-07 01:55:59 +00:00
//if let Some(current_chunk) = client.current_chunk() {
//println!("biome: {:?}", current_chunk.meta().biome());
//println!("chaos: {}", current_chunk.meta().chaos());
//println!("alt: {}", current_chunk.meta().alt());
//println!("tree_density: {}",
// current_chunk.meta().tree_density());
2020-11-14 00:27:09 +00:00
//if let Some(position) = client.current::<comp::Pos>() {
// player_alt = position.0.z;
2020-11-07 01:55:59 +00:00
//}
2020-11-02 07:19:28 +00:00
use common::comp::{group::ENEMY, Group, Health, Pos};
use specs::{Join, WorldExt};
use MusicActivityState::*;
let mut activity_state = 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>();
if let Some(player_pos) = positions.get(player) {
const NEARBY_RADIUS: f32 = 50.0;
const HEALTH_FACTOR: u32 = 100;
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() < NEARBY_RADIUS.powf(2.0)
{
(health.maximum() / HEALTH_FACTOR).max(1)
} else {
0
}
})
.sum();
if num_nearby_entities > 2 {
activity_state = Combat(CombatIntensity::High);
} else if num_nearby_entities >= 1 {
activity_state = Combat(CombatIntensity::Low);
}
}
let activity = if self.last_activity != activity_state {
MusicActivity::Transition(self.last_activity, activity_state)
} else {
MusicActivity::State(activity_state)
};
let interrupt = matches!(activity, MusicActivity::Transition(_, _));
if audio.music_enabled()
2020-12-12 22:14:24 +00:00
&& !self.soundtrack.read().tracks.is_empty()
&& (self.began_playing.elapsed().as_secs_f32() > self.next_track_change || interrupt)
{
trace!("in audio maintain: {:?} {:?}", self.last_activity, activity);
if let Ok(()) = self.play_random_track(audio, state, client, &activity) {
self.last_activity = activity_state;
}
}
}
fn play_random_track(
&mut self,
audio: &mut AudioFrontend,
state: &State,
client: &Client,
activity: &MusicActivity,
) -> Result<(), ()> {
let mut rng = thread_rng();
// Adds a bit of randomness between plays
let silence_between_tracks_seconds: f32 =
if matches!(activity, MusicActivity::State(MusicActivityState::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();
// 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 mut res = Ok(());
2020-12-12 22:14:24 +00:00
let soundtrack = self.soundtrack.read();
// First, filter out tracks not matching the timing, site, and biome
let mut maybe_tracks = soundtrack
.tracks
.iter()
.filter(|track| {
(match &track.timing {
Some(period_of_day) => period_of_day == &current_period_of_day,
None => true,
}) && match &track.site {
Some(site) => site == &current_site,
None => true,
}
2020-11-02 07:19:28 +00:00
})
2020-11-04 23:22:56 +00:00
.filter(|track| {
let mut result = false;
2020-11-10 01:10:08 +00:00
if !track.biomes.is_empty() {
2020-11-04 23:22:56 +00:00
for biome in track.biomes.iter() {
if biome.0 == current_biome {
result = true;
}
}
} else {
result = true;
}
result
2020-11-04 06:41:54 +00:00
})
.collect::<Vec<&SoundtrackItem>>();
// Second, filter out any tracks that don't match the current activity.
let filtered_tracks: Vec<_> = maybe_tracks
.iter()
.filter(|track| &track.activity == activity)
.cloned()
.collect();
trace!(
"maybe_len: {}, filtered len: {}",
maybe_tracks.len(),
filtered_tracks.len()
);
if !filtered_tracks.is_empty() {
maybe_tracks = filtered_tracks;
} else {
res = Err(());
}
// Third, 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))
.cloned()
.collect();
trace!(
"maybe_len: {}, filtered len: {}",
maybe_tracks.len(),
filtered_tracks.len()
);
if !filtered_tracks.is_empty() {
maybe_tracks = filtered_tracks;
}
2020-11-04 06:41:54 +00:00
// Randomly selects a track from the remaining tracks weighted based
// on the biome
let new_maybe_track = maybe_tracks.choose_weighted(&mut rng, |track| {
2020-11-04 23:22:56 +00:00
let mut chance = 0;
2020-11-10 01:10:08 +00:00
if !track.biomes.is_empty() {
2020-11-04 23:22:56 +00:00
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
2020-11-04 23:22:56 +00:00
chance = 1;
}
chance
2020-11-04 06:41:54 +00:00
});
debug!(
"selecting new track for {:?}: {:?}",
activity, new_maybe_track
);
2020-11-04 06:41:54 +00:00
if let Ok(track) = new_maybe_track {
2020-11-12 03:55:40 +00:00
//println!("Now playing {:?}", track.title);
2020-06-14 16:56:32 +00:00
self.last_track = String::from(&track.title);
self.began_playing = Instant::now();
2020-11-06 08:05:29 +00:00
self.next_track_change = track.length + silence_between_tracks_seconds;
let tag = if matches!(
activity,
MusicActivity::State(MusicActivityState::Explore)
| MusicActivity::Transition(_, MusicActivityState::Explore)
) {
MusicChannelTag::Exploration
} else {
MusicChannelTag::Combat
};
audio.play_music(&track.path, tag);
res
} else {
Err(())
2020-06-14 16:56:32 +00:00
}
}
fn get_current_day_period(is_dark: bool) -> DayPeriod {
if is_dark {
DayPeriod::Night
} else {
DayPeriod::Day
}
}
fn load_soundtrack_items() -> AssetHandle<SoundtrackCollection<SoundtrackItem>> {
2020-12-12 22:14:24 +00:00
// Cannot fail: A default value is always provided
SoundtrackCollection::load_expect("voxygen.audio.soundtrack")
}
}
impl assets::Asset for SoundtrackCollection<RawSoundtrackItem> {
2020-12-12 22:14:24 +00:00
type Loader = assets::RonLoader;
2020-12-13 01:09:57 +00:00
const EXTENSION: &'static str = "ron";
}
2020-12-13 01:09:57 +00:00
impl assets::Compound for SoundtrackCollection<SoundtrackItem> {
fn load<S: assets::source::Source>(
_: &assets::AssetCache<S>,
id: &str,
) -> Result<Self, assets::Error> {
let inner = || -> Result<_, assets::Error> {
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, activity) in segments.into_iter() {
soundtracks.tracks.push(SoundtrackItem {
title: title.clone(),
path,
length,
timing: timing.clone(),
biomes: biomes.clone(),
site,
activity,
});
}
},
}
}
Ok(soundtracks)
};
match inner() {
Ok(soundtracks) => Ok(soundtracks),
Err(e) => {
warn!("Error loading soundtracks: {:?}", e);
Ok(SoundtrackCollection::default())
},
}
2020-12-12 22:14:24 +00:00
}
2020-12-13 01:09:57 +00:00
}