use crate::app::sim_events::SimEvent;
use crate::app::{PlacementDenied, Screen, Sim};
use bevy::audio::{SpatialListener, SpatialScale, Volume};
use bevy::input::gamepad::{GamepadRumbleIntensity, GamepadRumbleRequest};
use bevy::prelude::*;
use std::time::Duration;
#[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(Resource, Default)]
pub struct Muted(pub bool);
pub(crate) fn sfx_gain(settings: &crate::app::settings::GameSettings, muted: &Muted) -> f32 {
if muted.0 { 0.0 } else { settings.sfx_gain() }
}
#[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, Default)]
pub struct Sounds {
place: Handle<AudioSource>,
remove: Handle<AudioSource>,
bank: Handle<AudioSource>,
eat: Handle<AudioSource>,
raid: Handle<AudioSource>,
win: Handle<AudioSource>,
lose: Handle<AudioSource>,
takeoff: Handle<AudioSource>,
screech: Handle<AudioSource>,
event: Handle<AudioSource>,
golden: Handle<AudioSource>,
tier: Handle<AudioSource>,
surge: Handle<AudioSource>,
horn: Handle<AudioSource>,
denied: Handle<AudioSource>,
evict: Handle<AudioSource>,
}
#[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 {
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()
}
}
const PAN_WIDTH: f32 = 0.5;
const EAR_GAP: f32 = 0.3;
const SPATIAL_GAIN: f32 = 1.4;
pub(crate) fn post_is_heard(cursors: &Query<&crate::app::cursor::Cursor>, owner: u8) -> bool {
cursors.is_empty() || crate::app::cursor::seated_here(cursors, owner)
}
pub(crate) fn pan_pos(half_width: f32, pos: Vec2) -> Vec3 {
let x = (pos.x / half_width.max(1.0)).clamp(-1.0, 1.0);
Vec3::new(-x * PAN_WIDTH, 0.0, 0.0)
}
pub fn load_sounds(mut commands: Commands, assets: Res<AssetServer>) {
commands.spawn(SpatialListener::new(EAR_GAP));
commands.insert_resource(MusicPlaylist::new(
vec![
assets.load("sounds/theme.wav"),
assets.load("sounds/theme_b.ogg"),
assets.load("sounds/theme_c.ogg"),
assets.load("sounds/theme_d.ogg"),
assets.load("sounds/theme_e.ogg"),
assets.load("sounds/theme_f.ogg"),
assets.load("sounds/theme_g.ogg"),
assets.load("sounds/theme_h.ogg"),
assets.load("sounds/theme_i.ogg"),
assets.load("sounds/theme_j.ogg"),
],
vec![
assets.load("sounds/theme_k.ogg"),
assets.load("sounds/theme_l.ogg"),
assets.load("sounds/theme_m.ogg"),
assets.load("sounds/theme_n.ogg"),
assets.load("sounds/theme_o.ogg"),
assets.load("sounds/theme_p.ogg"),
],
crate::app::effects::VisualRng::from_clock(),
));
commands.insert_resource(Sounds {
place: assets.load("sounds/place.wav"),
remove: assets.load("sounds/remove.wav"),
bank: assets.load("sounds/bank.wav"),
eat: assets.load("sounds/eat.wav"),
raid: assets.load("sounds/raid.wav"),
win: assets.load("sounds/win.wav"),
lose: assets.load("sounds/lose.wav"),
takeoff: assets.load("sounds/takeoff.wav"),
screech: assets.load("sounds/screech.wav"),
event: assets.load("sounds/event.wav"),
golden: assets.load("sounds/golden.wav"),
tier: assets.load("sounds/tier.wav"),
surge: assets.load("sounds/surge.wav"),
horn: assets.load("sounds/horn.wav"),
denied: assets.load("sounds/denied.wav"),
evict: assets.load("sounds/evict.wav"),
});
}
fn play(commands: &mut Commands, sound: &Handle<AudioSource>, gain: f32) {
if gain <= 0.0 {
return;
}
commands.spawn((
AudioPlayer::new(sound.clone()),
PlaybackSettings {
volume: Volume::Linear(gain),
..PlaybackSettings::DESPAWN
},
));
}
fn play_at(commands: &mut Commands, sound: &Handle<AudioSource>, gain: f32, at: Vec3) {
if gain <= 0.0 {
return;
}
commands.spawn((
AudioPlayer::new(sound.clone()),
PlaybackSettings {
volume: Volume::Linear(gain * SPATIAL_GAIN),
spatial: true,
spatial_scale: Some(SpatialScale::new_2d(1.0)),
..PlaybackSettings::DESPAWN
},
Transform::from_translation(at),
));
}
fn once(
commands: &mut Commands,
used: &mut bool,
sound: &Handle<AudioSource>,
gain: f32,
at: Vec3,
) {
if !std::mem::replace(used, true) {
play_at(commands, sound, gain, at);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Speaker<'w, 's> {
commands: Commands<'w, 's>,
sounds: Res<'w, Sounds>,
settings: Res<'w, crate::app::settings::GameSettings>,
muted: Res<'w, Muted>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Rumble<'w, 's> {
pads: Query<'w, 's, Entity, With<Gamepad>>,
requests: MessageWriter<'w, GamepadRumbleRequest>,
}
pub fn play_events(
speaker: Speaker,
mut events: MessageReader<SimEvent>,
screen: Res<State<Screen>>,
sim: Res<Sim>,
cursors: Query<&crate::app::cursor::Cursor>,
rumble: Rumble,
) {
let Speaker {
mut commands,
sounds,
settings,
muted,
} = speaker;
let Rumble {
pads,
requests: mut rumble,
} = rumble;
let hands_here = !cursors.is_empty();
let mut buzz = |ms: u64, strength: f32| {
if !settings.rumble || !hands_here {
return;
}
for gamepad in &pads {
rumble.write(GamepadRumbleRequest::Add {
duration: Duration::from_millis(ms),
intensity: GamepadRumbleIntensity::strong_motor(strength),
gamepad,
});
}
};
let gain = sfx_gain(&settings, &muted);
let half_width = f32::from(sim.0.width()) * crate::app::layout::TILE / 2.0;
let pan = |pos: &Vec2| pan_pos(half_width, *pos);
let (mut placed, mut removed, mut evicted) = (false, false, false);
let mut banked = [false; crate::sim::MAX_PLAYERS];
let mut goldened = [false; crate::sim::MAX_PLAYERS];
for event in events.read() {
match event {
SimEvent::CrabBanked {
owner, kind, pos, ..
} => {
let seat = usize::from(*owner);
let at = pan(pos);
if let Some(used) = banked.get_mut(seat) {
once(&mut commands, used, &sounds.bank, gain, at);
}
if *kind == crate::sim::CrabKind::Golden
&& let Some(used) = goldened.get_mut(seat)
{
once(&mut commands, used, &sounds.golden, gain, at);
}
}
SimEvent::CrabEaten { pos } => play_at(&mut commands, &sounds.eat, gain, pan(pos)),
SimEvent::CastleRaided { pos, .. } => {
play_at(&mut commands, &sounds.raid, gain, pan(pos));
}
SimEvent::GullArrived => play(&mut commands, &sounds.screech, gain),
SimEvent::GullTookOff => play(&mut commands, &sounds.takeoff, gain),
SimEvent::GullShooed { pos, .. } | SimEvent::GullFlewOff { pos } => {
play_at(&mut commands, &sounds.takeoff, gain, pan(pos));
}
SimEvent::SignpostPlaced { owner, pos } => {
if post_is_heard(&cursors, *owner) {
once(&mut commands, &mut placed, &sounds.place, gain, pan(pos));
}
}
SimEvent::SignpostRemoved { owner, pos } => {
if post_is_heard(&cursors, *owner) {
once(&mut commands, &mut removed, &sounds.remove, gain, pan(pos));
}
}
SimEvent::SignpostEvicted { owner, pos, .. } => {
if post_is_heard(&cursors, *owner) {
once(&mut commands, &mut evicted, &sounds.evict, gain, pan(pos));
}
}
SimEvent::TierUp { .. } => play(&mut commands, &sounds.tier, gain),
SimEvent::TideEventFired { .. } => play(&mut commands, &sounds.event, gain),
SimEvent::LureStarted { .. } => {}
SimEvent::GoldenCalled { pos } => {
play_at(&mut commands, &sounds.golden, gain, pan(pos));
}
SimEvent::GoldenOut { pos } => {
play_at(&mut commands, &sounds.golden, gain, pan(pos));
play_at(&mut commands, &sounds.event, gain * 0.6, pan(pos));
}
SimEvent::SurgeStarted => {
play(&mut commands, &sounds.surge, gain);
buzz(300, 0.4);
}
SimEvent::RoundEnded => {
if *screen.get() == Screen::Versus {
play(&mut commands, &sounds.horn, gain);
buzz(500, 0.5);
}
}
SimEvent::CrabSpawned { .. }
| SimEvent::GullLanded { .. }
| SimEvent::WheelLocked { .. } => {}
}
}
}
pub fn play_win(
mut commands: Commands,
sounds: Res<Sounds>,
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
) {
play(&mut commands, &sounds.win, sfx_gain(&settings, &muted));
}
pub fn play_lose(
mut commands: Commands,
sounds: Res<Sounds>,
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
) {
play(&mut commands, &sounds.lose, sfx_gain(&settings, &muted));
}
pub fn play_denied(
mut commands: Commands,
mut denials: MessageReader<PlacementDenied>,
sounds: Res<Sounds>,
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
) {
let any = denials.read().next().is_some();
denials.clear();
if any {
play(&mut commands, &sounds.denied, sfx_gain(&settings, &muted));
}
}
pub fn play_chime(commands: &mut Commands, sounds: &Sounds, gain: f32) {
play(commands, &sounds.tier, gain);
}
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(
keys: Res<ButtonInput<KeyCode>>,
caps: Res<crate::app::keycaps::KeyCaps>,
mut muted: ResMut<Muted>,
) {
if caps.just_pressed(&keys, 'M') {
muted.0 = !muted.0;
}
}
pub fn drive_music(
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
menu: Res<crate::app::pause::PauseMenu>,
sinks: Query<&AudioSink, With<Music>>,
) {
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::cursor::Cursor;
use crate::app::effects::VisualRng;
use crate::app::settings::GameSettings;
use crate::sim::classic_arena;
#[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"
);
}
fn table(humans: u8) -> App {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(Sim(classic_arena(false, 6)));
app.insert_resource(GameSettings::default());
app.insert_resource(Muted(false));
app.insert_resource(Sounds::default());
app.add_message::<SimEvent>();
app.add_message::<GamepadRumbleRequest>();
for seat in 0..humans {
app.world_mut().spawn(Cursor::seated(seat));
}
app.add_systems(Update, play_events);
app
}
fn heard(app: &mut App, events: impl IntoIterator<Item = SimEvent>) -> usize {
for event in events {
app.world_mut().write_message(event);
}
app.update();
let live: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<AudioPlayer>>()
.iter(app.world())
.collect();
let played = live.len();
for entity in live {
app.world_mut().entity_mut(entity).despawn();
}
played
}
fn placed(owner: u8) -> SimEvent {
SimEvent::SignpostPlaced {
owner,
pos: Vec2::ZERO,
}
}
fn banked(owner: u8, kind: crate::sim::CrabKind) -> SimEvent {
SimEvent::CrabBanked {
id: 0,
owner,
pos: Vec2::ZERO,
keep: Vec2::ZERO,
points: 1,
kind,
handed: crate::sim::Handedness::Right,
}
}
#[test]
fn only_the_seats_at_this_machine_are_heard() {
let mut app = table(1);
assert_eq!(heard(&mut app, [placed(0)]), 1, "your own post knocks");
for bot in 1..6 {
let played = heard(&mut app, [placed(bot)]);
assert_eq!(played, 0, "seat {bot} is not at this table");
}
}
#[test]
fn a_post_leaving_is_as_quiet_as_a_post_arriving() {
let mut app = table(1);
let gone = |owner| SimEvent::SignpostRemoved {
owner,
pos: Vec2::ZERO,
};
let played = heard(&mut app, [gone(0)]);
assert_eq!(played, 1, "your own post going is worth hearing");
assert_eq!(heard(&mut app, [gone(3)]), 0, "a bot's expiring is not");
}
#[test]
fn a_post_traded_away_is_as_quiet_as_the_other_two() {
let mut app = table(1);
let traded = |owner| SimEvent::SignpostEvicted {
owner,
pos: Vec2::ZERO,
dir: crate::sim::Direction::Up,
};
let played = heard(&mut app, [traded(0)]);
assert_eq!(played, 1, "losing your own oldest is worth hearing");
let played = heard(&mut app, [traded(4)]);
assert_eq!(played, 0, "a bot trading one of its own is not");
}
#[test]
fn a_shared_couch_hears_everybody_sitting_at_it() {
let mut app = table(2);
assert_eq!(heard(&mut app, [placed(0)]), 1);
let played = heard(&mut app, [placed(1)]);
assert_eq!(played, 1, "the seat beside you is in the room");
assert_eq!(heard(&mut app, [placed(2)]), 0, "the bot is not");
}
#[test]
fn a_watcher_hears_every_seat_because_it_holds_none() {
let mut app = table(0);
assert_eq!(heard(&mut app, [placed(0)]), 1, "somebody's post knocks");
let played = heard(&mut app, [placed(4)]);
assert_eq!(played, 1, "and so does a seat right across the beach");
}
#[test]
fn a_castle_taking_a_stream_of_crabs_banks_once_for_it() {
use crate::sim::CrabKind;
let mut app = table(1);
let common = |owner| banked(owner, CrabKind::Common);
assert_eq!(heard(&mut app, [common(0)]), 1, "one crab, one sound");
let played = heard(&mut app, (0..40).map(|_| common(0)));
assert_eq!(played, 1, "and forty into the one castle is still one");
assert_eq!(heard(&mut app, [common(3)]), 1, "a rival's castle too");
let played = heard(&mut app, (0..6).map(common));
assert_eq!(played, 6, "six castles at once are six things");
let played = heard(&mut app, (0..60).map(|i| common(i % 6)));
assert_eq!(played, 6, "however many crabs each of them took");
}
#[test]
fn a_golden_arrival_chimes_once_a_castle_too() {
use crate::sim::CrabKind;
let mut app = table(1);
let golden = |owner| banked(owner, CrabKind::Golden);
let played = heard(&mut app, [golden(0)]);
assert_eq!(played, 2, "the bank it is, and the chime it is worth");
let played = heard(&mut app, [golden(0), golden(0)]);
assert_eq!(played, 2, "two into one castle are still one of each");
let played = heard(&mut app, [golden(0), golden(1)]);
assert_eq!(played, 4, "two castles are two of each");
}
#[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_master_mute_silences_the_effects_too() {
let settings = GameSettings {
sfx_on: true,
sfx_volume: 80,
..GameSettings::default()
};
assert!(sfx_gain(&settings, &Muted(false)) > 0.0, "heard by default");
assert_eq!(sfx_gain(&settings, &Muted(true)), 0.0, "M silences them");
let off = GameSettings {
sfx_on: false,
..settings.clone()
};
assert_eq!(sfx_gain(&off, &Muted(false)), 0.0, "switched off");
assert_eq!(off.sfx_volume, 80, "the slider is where it was left");
}
#[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>().open = silence == 3;
}
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>()
.open = false;
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");
}
#[test]
fn panning_mirrors_the_board_and_stays_inside_the_listener() {
let half = 6.0 * crate::app::layout::TILE;
assert_eq!(pan_pos(half, Vec2::ZERO).x, 0.0, "the middle is centred");
let left = pan_pos(half, Vec2::new(-half, 0.0));
let right = pan_pos(half, Vec2::new(half, 0.0));
assert_eq!(left.x, PAN_WIDTH, "mirrored: left of the beach, left ear");
assert_eq!(right.x, -left.x, "the two edges are symmetric");
assert_eq!(pan_pos(half, Vec2::new(-9.0 * half, 0.0)).x, PAN_WIDTH);
const { assert!(PAN_WIDTH + EAR_GAP / 2.0 < 1.0) };
}
}