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;
#[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() }
}
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 {
tracks: Vec<Handle<AudioSource>>,
next: usize,
}
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 {
tracks: 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"),
],
next: 0,
});
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);
}
}
#[allow(clippy::too_many_arguments)]
pub fn play_events(
mut commands: Commands,
mut events: MessageReader<SimEvent>,
sounds: Res<Sounds>,
screen: Res<State<Screen>>,
sim: Res<Sim>,
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
pads: Query<Entity, With<Gamepad>>,
cursors: Query<&crate::app::cursor::Cursor>,
mut rumble: MessageWriter<GamepadRumbleRequest>,
) {
let mut buzz = |ms: u64, strength: f32| {
if !settings.rumble {
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, .. } => {
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::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 { .. } => {}
}
}
}
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);
}
pub fn rotate_music(
mut commands: Commands,
mut playlist: ResMut<MusicPlaylist>,
settings: Res<crate::app::settings::GameSettings>,
muted: Res<Muted>,
menu: Res<crate::app::pause::PauseMenu>,
playing: Query<(), With<Music>>,
) {
if !playing.is_empty() || !music_audible(&settings, &muted, menu.open) {
return;
}
let track = playlist.tracks[playlist.next].clone();
playlist.next = (playlist.next + 1) % playlist.tracks.len();
commands.spawn((
Music,
AudioPlayer::new(track),
PlaybackSettings {
volume: Volume::Linear(settings.music_gain()),
..PlaybackSettings::DESPAWN
},
));
}
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.open);
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::settings::GameSettings;
use crate::sim::classic_arena;
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,
value: 1,
kind,
}
}
#[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_playlist_walks_only_while_somebody_could_hear_it() {
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(MusicPlaylist {
tracks: vec![Handle::default(), Handle::default(), Handle::default()],
next: 0,
});
app.add_systems(Update, rotate_music);
let playing = |app: &mut App| {
app.world_mut()
.query_filtered::<Entity, With<Music>>()
.iter(app.world())
.count()
};
let next = |app: &App| app.world().resource::<MusicPlaylist>().next;
app.update();
assert_eq!(playing(&mut app), 1, "a track starts");
assert_eq!(next(&app), 1, "and the playlist moves on");
app.update();
app.update();
assert_eq!(playing(&mut app), 1, "still the one song");
assert_eq!(next(&app), 1, "and the playlist did not move");
let 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();
}
};
end_it(&mut app);
app.update();
assert_eq!(next(&app), 2);
end_it(&mut app);
app.update();
assert_eq!(next(&app), 0, "three tracks and back to the first");
for silence in 0..4 {
end_it(&mut app);
let at = next(&app);
{
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;
}
app.update();
assert_eq!(playing(&mut app), 0, "silence {silence} starts nothing");
assert_eq!(next(&app), at, "and does not walk the list either");
}
}
#[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) };
}
}