use super::{Muted, sfx_gain};
use crate::app::sim_events::SimEvent;
use crate::app::{PlacementDenied, Screen, Sim};
use bevy::audio::{SpatialScale, Volume};
use bevy::input::gamepad::{GamepadRumbleIntensity, GamepadRumbleRequest};
use bevy::prelude::*;
use std::time::Duration;
#[derive(Resource, Default)]
pub struct Sounds {
pub(super) place: Handle<AudioSource>,
pub(super) remove: Handle<AudioSource>,
pub(super) bank: Handle<AudioSource>,
pub(super) eat: Handle<AudioSource>,
pub(super) raid: Handle<AudioSource>,
pub(super) win: Handle<AudioSource>,
pub(super) lose: Handle<AudioSource>,
pub(super) takeoff: Handle<AudioSource>,
pub(super) screech: Handle<AudioSource>,
pub(super) event: Handle<AudioSource>,
pub(super) golden: Handle<AudioSource>,
pub(super) tier: Handle<AudioSource>,
pub(super) surge: Handle<AudioSource>,
pub(super) horn: Handle<AudioSource>,
pub(super) denied: Handle<AudioSource>,
pub(super) evict: Handle<AudioSource>,
pub(super) lure: Handle<AudioSource>,
pub(super) count: Handle<AudioSource>,
pub(super) go: Handle<AudioSource>,
pub(super) tick: Handle<AudioSource>,
}
const PAN_WIDTH: f32 = 0.5;
pub(super) const EAR_GAP: f32 = 0.3;
const SPATIAL_GAIN: f32 = 1.4;
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)
}
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) {
play_pitched(commands, sound, gain, at, 1.0);
}
fn play_pitched(
commands: &mut Commands,
sound: &Handle<AudioSource>,
gain: f32,
at: Vec3,
speed: f32,
) {
if gain <= 0.0 {
return;
}
commands.spawn((
AudioPlayer::new(sound.clone()),
PlaybackSettings {
volume: Volume::Linear(gain * SPATIAL_GAIN),
speed,
spatial: true,
spatial_scale: Some(SpatialScale::new_2d(1.0)),
..PlaybackSettings::DESPAWN
},
Transform::from_translation(at),
));
}
pub const JACKPOT_POINTS: i32 = crate::sim::GOLDEN_CALLED_VALUE as i32;
pub fn bank_chime(points: i32) -> (f32, Option<f32>) {
match points {
i32::MIN..=1 => (1.0, None),
2..=4 => (1.26, None),
5..=9 => (1.5, None),
10.. => (2.0, Some(1.5)),
}
}
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>,
}
impl Speaker<'_, '_> {
fn centred(&mut self, pick: impl FnOnce(&Sounds) -> &Handle<AudioSource>) {
let gain = sfx_gain(&self.settings, &self.muted);
play(&mut self.commands, pick(&self.sounds), gain);
}
}
#[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 = crate::app::layout::board_extent(&sim.0).x / 2.0;
let pan = |pos: &Vec2| pan_pos(half_width, *pos);
let (mut placed, mut removed, mut evicted) = (false, false, false);
let mut banked: [Option<(i32, Vec3)>; crate::sim::MAX_PLAYERS] =
[None; crate::sim::MAX_PLAYERS];
let mut goldened = [false; crate::sim::MAX_PLAYERS];
for event in events.read() {
match event {
SimEvent::CrabBanked { points, .. } if *points <= 0 => {}
SimEvent::CrabBanked {
owner, pos, points, ..
} => {
let seat = usize::from(*owner);
let at = pan(pos);
if let Some(best) = banked.get_mut(seat)
&& best.is_none_or(|(most, _)| *points > most)
{
*best = Some((*points, at));
}
if *points >= JACKPOT_POINTS
&& 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::GullIncoming { .. } => {}
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 crate::app::cursor::post_noticed_here(&cursors, *owner) {
once(&mut commands, &mut placed, &sounds.place, gain, pan(pos));
}
}
SimEvent::SignpostRemoved { owner, pos } => {
if crate::app::cursor::post_noticed_here(&cursors, *owner) {
once(&mut commands, &mut removed, &sounds.remove, gain, pan(pos));
}
}
SimEvent::SignpostEvicted { owner, pos, .. } => {
if crate::app::cursor::post_noticed_here(&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 { owner } => {
let castle = sim.0.castle_of(*owner).map_or(Vec2::ZERO, |(x, y)| {
crate::app::layout::tile_center(&sim.0, x, y)
});
play_at(&mut commands, &sounds.lure, gain, pan(&castle));
}
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 { .. }
| SimEvent::LureRefused { .. } => {}
}
}
for (points, at) in banked.into_iter().flatten() {
let (speed, octave) = bank_chime(points);
play_pitched(&mut commands, &sounds.bank, gain, at, speed);
if let Some(speed) = octave {
play_pitched(&mut commands, &sounds.bank, gain * 0.6, at, speed);
}
}
}
pub fn play_win(mut speaker: Speaker) {
speaker.centred(|sounds| &sounds.win);
}
pub fn play_lose(mut speaker: Speaker) {
speaker.centred(|sounds| &sounds.lose);
}
pub fn play_denied(mut speaker: Speaker, mut denials: MessageReader<PlacementDenied>) {
let any = denials.read().next().is_some();
denials.clear();
if any {
speaker.centred(|sounds| &sounds.denied);
}
}
pub fn play_chime(commands: &mut Commands, sounds: &Sounds, gain: f32) {
play(commands, &sounds.tier, gain);
}
pub fn play_count(
mut speaker: Speaker,
mut beats: MessageReader<crate::app::countdown::CountBeat>,
) {
use crate::app::countdown::CountBeat;
for beat in beats.read() {
match beat {
CountBeat::Number(_) => speaker.centred(|sounds| &sounds.count),
CountBeat::Go => speaker.centred(|sounds| &sounds.go),
}
}
}
pub const TICKED_SECONDS: u64 = 5;
pub fn ticks_aloud(secs: u64) -> bool {
(1..=TICKED_SECONDS).contains(&secs)
}
pub fn play_final_seconds(mut speaker: Speaker, sim: Res<Sim>, mut last: Local<Option<u64>>) {
let tps = u64::from(crate::sim::TICKS_PER_SECOND);
let now = sim.0.remaining_ticks().map(|ticks| ticks.div_ceil(tps));
let was = std::mem::replace(&mut *last, now);
if let (Some(now), Some(was)) = (now, was)
&& now < was
&& ticks_aloud(now)
{
speaker.centred(|sounds| &sounds.tick);
}
}
#[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 {
banked_for(owner, kind, 1)
}
fn banked_for(owner: u8, kind: crate::sim::CrabKind, points: i32) -> SimEvent {
SimEvent::CrabBanked {
id: 0,
owner,
pos: Vec2::ZERO,
keep: Vec2::ZERO,
points,
kind,
handed: crate::sim::Handedness::Right,
}
}
#[test]
fn a_bank_that_costs_plays_no_reward() {
use crate::sim::CrabKind;
let mut app = table(1);
let played = heard(&mut app, [banked_for(0, CrabKind::Common, -2)]);
assert_eq!(played, 0, "a loss does not chime");
let played = heard(&mut app, [banked_for(0, CrabKind::Golden, -50)]);
assert_eq!(played, 0, "nor fanfare, golden or not");
let played = heard(&mut app, [banked_for(0, CrabKind::Common, 0)]);
assert_eq!(played, 0, "and a bank cut to nothing is nothing");
let played = heard(
&mut app,
[
banked_for(1, CrabKind::Common, -1),
banked_for(1, CrabKind::Common, 2),
],
);
assert_eq!(played, 1, "the gain in the same frame still chimes");
}
#[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_for(owner, CrabKind::Golden, JACKPOT_POINTS);
let played = heard(&mut app, [golden(0)]);
assert_eq!(played, 3, "the bank's chord it is, and the fanfare");
let played = heard(&mut app, [golden(0), golden(0)]);
assert_eq!(played, 3, "two into one castle are still one of each");
let played = heard(&mut app, [golden(0), golden(1)]);
assert_eq!(played, 6, "two castles are two of each");
}
#[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) };
}
fn speeds(app: &mut App, events: impl IntoIterator<Item = SimEvent>) -> Vec<f32> {
for event in events {
app.world_mut().write_message(event);
}
app.update();
let mut out: Vec<f32> = app
.world_mut()
.query::<&PlaybackSettings>()
.iter(app.world())
.map(|settings| settings.speed)
.collect();
out.sort_by(f32::total_cmp);
heard(app, []);
out
}
#[test]
fn a_bigger_crab_banks_a_brighter_chime() {
use crate::sim::CrabKind;
let mut app = table(1);
let mut last = 0.0;
for kind in [
CrabKind::Common,
CrabKind::Juvenile,
CrabKind::Molting,
CrabKind::Giant,
] {
let played = speeds(&mut app, [banked_for(0, kind, kind.value() as i32)]);
let top = played.last().copied().unwrap_or(0.0);
assert!(top > last, "{kind:?}: {played:?}");
last = top;
let layered = kind.value() >= 10;
assert_eq!(played.len(), 1 + usize::from(layered), "{kind:?}");
}
let both = speeds(
&mut app,
[
banked_for(0, CrabKind::Common, 1),
banked_for(0, CrabKind::Giant, 10),
],
);
assert_eq!(both, [1.5, 2.0], "the giant's chord, once");
}
#[test]
fn the_chime_follows_the_points_not_the_crab() {
use crate::sim::CrabKind;
let mut app = table(1);
let doubled = speeds(&mut app, [banked_for(0, CrabKind::Common, 2)]);
let juvenile = speeds(&mut app, [banked_for(0, CrabKind::Juvenile, 2)]);
assert_eq!(doubled, juvenile);
let giant = speeds(&mut app, [banked_for(0, CrabKind::Giant, 20)]);
assert_eq!(giant.len(), 2, "a doubled giant is no jackpot: {giant:?}");
let jackpot = JACKPOT_POINTS;
let golden = heard(&mut app, [banked_for(0, CrabKind::Golden, jackpot)]);
assert_eq!(golden, 3, "the chord and the fanfare");
}
#[test]
fn a_lure_is_heard_unless_muted() {
let mut app = table(1);
let lure = || SimEvent::LureStarted { owner: 0 };
assert_eq!(heard(&mut app, [lure()]), 1);
app.insert_resource(Muted(true));
assert_eq!(heard(&mut app, [lure()]), 0, "M silences it");
}
#[test]
fn the_count_is_heard_beat_by_beat() {
use crate::app::countdown::CountBeat;
let mut app = table(0);
app.add_message::<CountBeat>();
app.add_systems(Update, play_count);
for beat in [CountBeat::Number(3), CountBeat::Number(2), CountBeat::Go] {
app.world_mut().write_message(beat);
}
assert_eq!(heard(&mut app, []), 3);
app.insert_resource(Muted(true));
app.world_mut().write_message(CountBeat::Go);
assert_eq!(heard(&mut app, []), 0, "muted");
}
#[test]
fn the_closing_seconds_tick_once_each() {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.insert_resource(Muted(false));
app.insert_resource(Sounds::default());
let mut board = crate::sim::Board::new(5, 5, 1);
let tps = crate::sim::TICKS_PER_SECOND;
board.rules_mut().round_length = Some(8 * tps);
app.insert_resource(Sim(board));
app.add_systems(Update, play_final_seconds);
let mut ticked = Vec::new();
for _ in 0..8 * tps {
app.world_mut().resource_mut::<Sim>().0.tick_idle();
let secs = app
.world()
.resource::<Sim>()
.0
.remaining_ticks()
.unwrap_or(0);
if heard(&mut app, []) > 0 {
ticked.push(secs.div_ceil(u64::from(tps)));
}
}
assert_eq!(ticked, [5, 4, 3, 2, 1]);
let mut board = crate::sim::Board::new(5, 5, 1);
board.rules_mut().round_length = Some(8 * tps);
app.insert_resource(Sim(board));
assert_eq!(heard(&mut app, []), 0, "a clock that went up is no second");
for _ in 0..5 * tps {
app.world_mut().resource_mut::<Sim>().0.tick_idle();
}
assert_eq!(heard(&mut app, []), 1, "one tick for the frame");
}
}