use super::Muted;
use crate::app::Screen;
use bevy::audio::Volume;
use bevy::prelude::*;
#[derive(Component)]
pub struct Music(Mood);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mood {
Calm,
Battle,
}
impl Mood {
pub fn of(screen: Screen) -> Mood {
match screen {
Screen::Versus | Screen::Interlude => Mood::Battle,
Screen::Menu
| Screen::Puzzle
| Screen::Editor
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Language
| Screen::NewVersion => Mood::Calm,
}
}
fn rest_secs(self) -> std::ops::Range<f32> {
match self {
Mood::Calm => 30.0..90.0,
Mood::Battle => 3.0..6.0,
}
}
}
#[derive(Component)]
pub struct FadingOut(f32);
const FADE_SECS: f32 = 0.8;
#[derive(bevy::ecs::system::SystemParam)]
pub struct Loudness<'w> {
settings: Res<'w, crate::app::settings::GameSettings>,
muted: Res<'w, Muted>,
menu: Res<'w, crate::app::pause::PauseMenu>,
}
fn music_audible(
settings: &crate::app::settings::GameSettings,
muted: &Muted,
card_open: bool,
) -> bool {
settings.music_gain() > 0.0 && !muted.0 && !card_open
}
#[derive(Resource)]
pub struct MusicPlaylist {
calm: Shelf,
battle: Shelf,
rest: Rest,
rng: crate::app::effects::VisualRng,
}
struct Shelf {
tracks: Vec<Handle<AudioSource>>,
bag: Vec<usize>,
last: Option<usize>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Rest {
Due,
Playing(Mood),
Waiting(Mood, f32),
}
impl Shelf {
fn new(tracks: Vec<Handle<AudioSource>>) -> Self {
Shelf {
tracks,
bag: Vec::new(),
last: None,
}
}
fn draw(&mut self, rng: &mut crate::app::effects::VisualRng) -> usize {
if self.bag.is_empty() {
self.bag = (0..self.tracks.len()).collect();
for i in (1..self.bag.len()).rev() {
let j = (rng.next() >> 16) as usize % (i + 1);
self.bag.swap(i, j);
}
if self.bag.len() > 1 && self.bag.last() == self.last.as_ref() {
let end = self.bag.len() - 1;
self.bag.swap(0, end);
}
}
let track = self.bag.pop().unwrap_or(0);
self.last = Some(track);
track
}
}
impl MusicPlaylist {
pub(super) fn new(
calm: Vec<Handle<AudioSource>>,
battle: Vec<Handle<AudioSource>>,
rng: crate::app::effects::VisualRng,
) -> Self {
MusicPlaylist {
calm: Shelf::new(calm),
battle: Shelf::new(battle),
rest: Rest::Due,
rng,
}
}
fn draw(&mut self, mood: Mood) -> Handle<AudioSource> {
let MusicPlaylist {
calm, battle, rng, ..
} = self;
let shelf = match mood {
Mood::Calm => calm,
Mood::Battle => battle,
};
let index = shelf.draw(rng);
shelf.tracks[index].clone()
}
}
fn wanted(screen: Screen, phase: crate::app::VersusPhase) -> Option<Mood> {
match (screen, phase) {
(Screen::Versus, crate::app::VersusPhase::Over) => None,
(screen, _) => Some(Mood::of(screen)),
}
}
pub fn rotate_music(
mut commands: Commands,
mut playlist: ResMut<MusicPlaylist>,
loudness: Loudness,
screen: Res<State<Screen>>,
phase: Res<State<crate::app::VersusPhase>>,
time: Res<Time>,
playing: Query<(Entity, &Music), Without<FadingOut>>,
) {
let Loudness {
settings,
muted,
menu,
} = loudness;
let mood = wanted(*screen.get(), *phase.get());
let mut live = false;
for (entity, music) in &playing {
if Some(music.0) == mood {
live = true;
} else {
commands.entity(entity).try_insert(FadingOut(FADE_SECS));
}
}
let Some(mood) = mood else {
playlist.rest = Rest::Due;
return;
};
if live {
playlist.rest = Rest::Playing(mood);
return;
}
if !music_audible(&settings, &muted, menu.stops_the_round()) {
return;
}
match playlist.rest {
Rest::Playing(was) if was == mood => {
let rest = mood.rest_secs();
let secs = playlist.rng.range(rest.start, rest.end);
playlist.rest = Rest::Waiting(mood, secs);
return;
}
Rest::Waiting(was, left) if was == mood => {
let left = left - time.delta_secs();
if left > 0.0 {
playlist.rest = Rest::Waiting(mood, left);
return;
}
}
Rest::Due | Rest::Playing(_) | Rest::Waiting(..) => {}
}
let track = playlist.draw(mood);
playlist.rest = Rest::Playing(mood);
commands.spawn((
Music(mood),
AudioPlayer::new(track),
PlaybackSettings {
volume: Volume::Linear(settings.music_gain()),
..PlaybackSettings::DESPAWN
},
));
}
pub fn fade_music(
mut commands: Commands,
settings: Res<crate::app::settings::GameSettings>,
time: Res<Time>,
mut fading: Query<(Entity, &mut FadingOut, Option<&mut AudioSink>)>,
) {
for (entity, mut fade, sink) in &mut fading {
fade.0 -= time.delta_secs();
if fade.0 <= 0.0 {
commands.entity(entity).try_despawn();
} else if let Some(mut sink) = sink {
sink.set_volume(Volume::Linear(settings.music_gain() * fade.0 / FADE_SECS));
}
}
}
pub fn toggle_mute(keyboard: crate::app::keycaps::Keyboard, mut muted: ResMut<Muted>) {
if keyboard.global('M') {
muted.0 = !muted.0;
}
}
pub fn drive_music(loudness: Loudness, sinks: Query<&AudioSink, With<Music>>) {
let Loudness {
settings,
muted,
menu,
} = loudness;
let audible = music_audible(&settings, &muted, menu.stops_the_round());
for sink in &sinks {
if audible && sink.is_paused() {
sink.play();
} else if !audible && !sink.is_paused() {
sink.pause();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::effects::VisualRng;
use crate::app::settings::GameSettings;
use std::time::Duration;
#[test]
fn the_series_interlude_keeps_its_music() {
use crate::app::VersusPhase;
for phase in [VersusPhase::Running, VersusPhase::Over] {
assert_eq!(
wanted(Screen::Interlude, phase),
Some(Mood::Battle),
"{phase:?}"
);
}
}
#[test]
fn the_music_stops_when_a_versus_round_is_over() {
use crate::app::VersusPhase;
assert_eq!(wanted(Screen::Versus, VersusPhase::Over), None);
assert_eq!(
wanted(Screen::Versus, VersusPhase::Running),
Some(Mood::Battle)
);
assert_eq!(wanted(Screen::Menu, VersusPhase::Over), Some(Mood::Calm));
let mut app = App::new();
app.insert_resource(MusicPlaylist::new(
vec![Handle::default()],
vec![Handle::default()],
VisualRng::default(),
));
app.insert_resource(GameSettings::default());
app.init_resource::<Muted>();
app.init_resource::<crate::app::pause::PauseMenu>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(State::new(VersusPhase::Over));
app.init_resource::<Time>();
app.add_systems(Update, rotate_music);
let track = app.world_mut().spawn(Music(Mood::Battle)).id();
app.update();
assert!(
app.world().get::<FadingOut>(track).is_some(),
"the round's track fades out"
);
let mut music = app.world_mut().query::<&Music>();
assert_eq!(
music.iter(app.world()).count(),
1,
"and nothing starts in its place"
);
}
#[test]
fn every_silence_is_a_veto_and_none_of_them_lifts_another() {
let settings = |on: bool, volume: u8| GameSettings {
music_on: on,
music_volume: volume,
..GameSettings::default()
};
let audible =
|on, volume, mute, card| music_audible(&settings(on, volume), &Muted(mute), card);
assert!(audible(true, 45, false, false), "on, up, unmuted, playing");
assert!(!audible(false, 45, false, false), "the settings switch");
assert!(!audible(true, 0, false, false), "the slider at the bottom");
assert!(!audible(true, 45, true, false), "the master mute");
assert!(!audible(true, 45, false, true), "the pause card");
assert!(!audible(true, 45, true, true), "both, on the way in");
assert!(!audible(true, 45, true, false), "and M still holds it");
assert!(!audible(true, 45, false, true), "the card still holds it");
}
#[test]
fn the_shuffle_plays_everything_and_never_repeats_back_to_back() {
let tracks = vec![Handle::default(); 7];
for seed in 0..50 {
let mut rng = VisualRng::seeded(seed);
let mut shelf = Shelf::new(tracks.clone());
let mut previous = None;
for _ in 0..20 {
let mut round: Vec<usize> = (0..7).map(|_| shelf.draw(&mut rng)).collect();
for &track in &round {
assert_ne!(Some(track), previous, "seed {seed}: {track} twice running");
previous = Some(track);
}
round.sort_unstable();
assert_eq!(
round,
(0..7).collect::<Vec<_>>(),
"seed {seed}: one of each"
);
}
}
let openers: std::collections::HashSet<usize> = (0..50)
.map(|seed| Shelf::new(tracks.clone()).draw(&mut VisualRng::seeded(seed)))
.collect();
assert!(openers.len() > 1, "every launch opened on {openers:?}");
}
#[test]
fn only_a_round_plays_the_battle_set() {
for screen in Screen::ALL {
let battle = matches!(screen, Screen::Versus | Screen::Interlude);
let want = if battle { Mood::Battle } else { Mood::Calm };
assert_eq!(Mood::of(screen), want, "{screen:?}");
}
}
fn radio() -> App {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.insert_resource(Muted(false));
app.insert_resource(crate::app::pause::PauseMenu::default());
app.insert_resource(State::new(Screen::Menu));
app.insert_resource(State::new(crate::app::VersusPhase::Running));
app.insert_resource(Time::<()>::default());
app.insert_resource(MusicPlaylist::new(
vec![Handle::default(), Handle::default()],
vec![Handle::default(), Handle::default()],
VisualRng::seeded(7),
));
app.add_systems(Update, (rotate_music, fade_music).chain());
app
}
fn on_air(app: &mut App) -> Vec<(Mood, bool)> {
app.world_mut()
.query::<(&Music, Has<FadingOut>)>()
.iter(app.world())
.map(|(music, fading)| (music.0, fading))
.collect()
}
fn wait(app: &mut App, secs: f32) {
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(secs));
app.update();
}
fn end_it(app: &mut App) {
let live: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<Music>>()
.iter(app.world())
.collect();
for entity in live {
app.world_mut().entity_mut(entity).despawn();
}
}
#[test]
fn the_playlist_rests_between_tracks_and_only_while_somebody_could_hear_it() {
let mut app = radio();
let rest = Mood::Calm.rest_secs();
let playing = |app: &mut App| on_air(app).len();
app.update();
assert_eq!(playing(&mut app), 1, "the first track starts at once");
app.update();
wait(&mut app, 200.0);
assert_eq!(playing(&mut app), 1, "still the one song");
end_it(&mut app);
app.update();
wait(&mut app, rest.start - 1.0);
assert_eq!(playing(&mut app), 0, "resting");
for silence in 0..4 {
{
let world = app.world_mut();
let mut settings = world.resource_mut::<GameSettings>();
settings.music_on = silence != 1;
settings.music_volume = if silence == 2 { 0 } else { 45 };
world.resource_mut::<Muted>().0 = silence == 0;
*world.resource_mut::<crate::app::pause::PauseMenu>() = match silence {
3 => crate::app::pause::PauseMenu::raised(),
_ => crate::app::pause::PauseMenu::default(),
};
}
wait(&mut app, 500.0);
assert_eq!(playing(&mut app), 0, "silence {silence} starts nothing");
}
*app.world_mut().resource_mut::<GameSettings>() = GameSettings::default();
*app.world_mut()
.resource_mut::<crate::app::pause::PauseMenu>() =
crate::app::pause::PauseMenu::default();
wait(&mut app, 0.5);
assert_eq!(playing(&mut app), 0, "the rest was held, not spent");
wait(&mut app, rest.end);
assert_eq!(playing(&mut app), 1, "and then the next one");
}
#[test]
fn a_round_changes_the_set_without_waiting_for_a_rest() {
let mut app = radio();
app.update();
assert_eq!(on_air(&mut app), [(Mood::Calm, false)], "the menu is calm");
app.insert_resource(State::new(Screen::Versus));
wait(&mut app, 0.1);
app.update();
let mut air = on_air(&mut app);
air.sort_by_key(|&(mood, _)| mood == Mood::Battle);
assert_eq!(
air,
[(Mood::Calm, true), (Mood::Battle, false)],
"the calm one fades under the battle one"
);
wait(&mut app, FADE_SECS);
assert_eq!(on_air(&mut app), [(Mood::Battle, false)], "and is gone");
end_it(&mut app);
app.update();
wait(&mut app, Mood::Battle.rest_secs().end);
assert_eq!(on_air(&mut app), [(Mood::Battle, false)], "the next one");
end_it(&mut app);
app.update();
app.insert_resource(State::new(Screen::Menu));
wait(&mut app, 0.1);
assert_eq!(on_air(&mut app), [(Mood::Calm, false)], "straight back");
}
}