pinch-points 0.2.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before the tide comes in
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Sound effects, driven by the [`SimEvent`] stream (one observer diffs the
//! sim; this module just maps events to one-shots and rumble). Win/lose
//! stingers hook the puzzle phase transitions; denied placements arrive on
//! their own message from the input layer.
//!
//! Anything that happens *somewhere* on the beach is panned to that side of
//! the stereo field ([`pan_pos`]); banners and stingers, which belong to the
//! whole round rather than a tile, stay centred.

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;

/// Marker for the looping background theme entity.
#[derive(Component)]
pub struct Music;

#[derive(Resource)]
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>,
}

/// The rotating background playlist; a track spawns, plays once, despawns,
/// and `rotate_music` starts the next.
#[derive(Resource)]
pub struct MusicPlaylist {
    tracks: Vec<Handle<AudioSource>>,
    next: usize,
}

/// How far, in listener units, the edge of the board sits from the centre
/// of the stereo field. Small on purpose: rodio only attenuates by distance
/// beyond one unit, so keeping the whole beach inside that radius leaves
/// panning as the only thing that varies.
const PAN_WIDTH: f32 = 0.5;
/// Distance between the listener's ears, same units.
const EAR_GAP: f32 = 0.3;
/// A panned one-shot's two channels sum to less than a centred one's, so
/// positional sounds get a lift to sit level with the stingers.
const SPATIAL_GAIN: f32 = 1.4;

/// Where a board position lands in the listener's little stereo field.
///
/// The x is mirrored: rodio's spatial mixer boosts the ear *further* from
/// the emitter, so without the flip a raid on the left of the beach would
/// come out of the right speaker.
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>) {
    // One listener, parked at the origin; emitters are placed relative to it
    // by `pan_pos` rather than at their true board positions, so the camera
    // zooming or sliding never changes how the beach sounds.
    commands.spawn(SpatialListener::new(EAR_GAP));
    commands.insert_resource(MusicPlaylist {
        tracks: vec![
            // theme.wav predates the sound generator and has no source to
            // re-encode from; the generated loops ship as OGG.
            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"),
    });
}

/// A one-shot at the given gain, centred in the stereo field. A gain of
/// zero (the slider at the bottom) spawns nothing at all.
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
        },
    ));
}

/// A one-shot panned to a spot in the listener's field (see [`pan_pos`]).
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),
    ));
}

/// Map each sim event to its one-shot (and rumble where it matters).
#[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>,
    pads: Query<Entity, With<Gamepad>>,
    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 = settings.sfx_gain();
    let half_width = f32::from(sim.0.width()) * crate::app::layout::TILE / 2.0;
    let pan = |pos: &Vec2| pan_pos(half_width, *pos);
    for event in events.read() {
        match event {
            SimEvent::CrabBanked { kind, pos, .. } => {
                play_at(&mut commands, &sounds.bank, gain, pan(pos));
                if *kind == crate::sim::CrabKind::Golden {
                    play_at(&mut commands, &sounds.golden, gain, pan(pos));
                }
            }
            SimEvent::CrabEaten { pos } => play_at(&mut commands, &sounds.eat, gain, pan(pos)),
            SimEvent::CastleRaided { pos, .. } => {
                play_at(&mut commands, &sounds.raid, gain, pan(pos));
                buzz(400, 0.7);
            }
            SimEvent::GullArrived => play(&mut commands, &sounds.screech, gain),
            SimEvent::GullTookOff => play(&mut commands, &sounds.takeoff, gain),
            SimEvent::SignpostsChanged { delta } => {
                play(
                    &mut commands,
                    if *delta > 0 {
                        &sounds.place
                    } else {
                        &sounds.remove
                    },
                    gain,
                );
            }
            SimEvent::SignpostEvicted { pos, .. } => {
                play_at(&mut commands, &sounds.denied, 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 => {
                // The horn calls the tide in versus; puzzle endings have
                // their own win/lose stingers.
                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>,
    screen: Res<State<Screen>>,
    settings: Res<crate::app::settings::GameSettings>,
) {
    if *screen.get() == Screen::Puzzle {
        play(&mut commands, &sounds.win, settings.sfx_gain());
    }
}

pub fn play_lose(
    mut commands: Commands,
    sounds: Res<Sounds>,
    screen: Res<State<Screen>>,
    settings: Res<crate::app::settings::GameSettings>,
) {
    if *screen.get() == Screen::Puzzle {
        play(&mut commands, &sounds.lose, settings.sfx_gain());
    }
}

/// One dull knock per frame with at least one rejected placement.
pub fn play_denied(
    mut commands: Commands,
    mut denials: MessageReader<PlacementDenied>,
    sounds: Option<Res<Sounds>>,
    settings: Res<crate::app::settings::GameSettings>,
) {
    let any = denials.read().next().is_some();
    denials.clear();
    if any && let Some(sounds) = sounds {
        play(&mut commands, &sounds.denied, settings.sfx_gain());
    }
}

/// A bright unlock chime (the tier fanfare doing double duty).
pub fn play_chime(commands: &mut Commands, sounds: &Sounds, gain: f32) {
    play(commands, &sounds.tier, gain);
}

/// Keep the playlist rolling: whenever no track entity is alive, start the
/// next one. Tracks despawn when they finish; a paused track (M) persists,
/// so the toggle keeps working mid-song.
pub fn rotate_music(
    mut commands: Commands,
    mut playlist: ResMut<MusicPlaylist>,
    settings: Res<crate::app::settings::GameSettings>,
    playing: Query<(), With<Music>>,
) {
    if !playing.is_empty() {
        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
        },
    ));
}

/// The tide nudge: over the last 30 seconds of a versus round the music
/// speeds up, ramping to +35% at the wave. Everywhere else it plays
/// straight. Speed-only (pitch rises with it, which is the point).
pub fn surge_tempo(
    sim: Res<crate::app::Sim>,
    screen: Res<State<Screen>>,
    sinks: Query<&AudioSink, With<Music>>,
) {
    let ramp = if *screen.get() == Screen::Versus {
        match sim.0.remaining_ticks() {
            Some(ticks) if ticks <= 900 => 1.0 + 0.35 * (1.0 - ticks as f32 / 900.0),
            _ => 1.0,
        }
    } else {
        1.0
    };
    for sink in &sinks {
        if (sink.speed() - ramp).abs() > 0.005 {
            sink.set_speed(ramp);
        }
    }
}

/// M toggles the background music anywhere the round is running: the cap
/// that says M, on whatever keyboard this is.
///
/// Held off while the pause card is open, where the music is already down
/// and M would only fight [`hush_while_paused`] over the same sink. That
/// also keeps the toggle honest: whatever the music was doing when the
/// round was paused is what it goes back to.
pub fn toggle_music(
    keys: Res<ButtonInput<KeyCode>>,
    settings: Res<crate::app::settings::GameSettings>,
    sinks: Query<&AudioSink, With<Music>>,
) {
    if settings.keycaps.just_pressed(&keys, 'M') {
        for sink in &sinks {
            sink.toggle_playback();
        }
    }
}

/// Whether the pause card is what silenced the music, so resuming lifts
/// only what pausing put down.
///
/// Not the same question as "is the music stopped": M stops it too, and a
/// player who muted the theme and then paused must not be handed it back
/// on the way out.
#[derive(Resource, Default)]
pub struct HushedByPause(bool);

/// Put the music down while the pause card is up, and pick it up again on
/// the way out.
///
/// The card is the signal rather than [`crate::app::Paused`], because that
/// flag is an offline one: an online pause deliberately leaves the ticker
/// running so the network pump can carry the resume, and the beach there is
/// held still by the lockstep instead. The card is open in both.
pub fn hush_while_paused(
    menu: Res<crate::app::pause::PauseMenu>,
    mut hushed: ResMut<HushedByPause>,
    sinks: Query<&AudioSink, With<Music>>,
) {
    let mut still_ours = false;
    for sink in &sinks {
        match hush_step(menu.open, hushed.0, sink.is_paused()) {
            Some(true) => {
                sink.pause();
                still_ours = true;
            }
            Some(false) => sink.play(),
            None => still_ours |= hushed.0 && menu.open,
        }
    }
    hushed.0 = still_ours;
}

/// What the card asks of one track this frame: `Some(true)` to put it down,
/// `Some(false)` to pick it up, `None` to leave it where it is.
///
/// The third arm is the one worth having: a track already stopped when the
/// card opens was stopped by the player (M), so the card neither claims it
/// nor hands it back, and a muted round resumes muted.
fn hush_step(card_open: bool, hushed: bool, stopped: bool) -> Option<bool> {
    match (card_open, hushed, stopped) {
        (true, _, false) => Some(true),
        (false, true, _) => Some(false),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The pause card takes the music down and gives back exactly what it
    /// took: a theme the player had already muted with M is not handed
    /// back on the way out, which is the whole reason the flag exists.
    #[test]
    fn the_card_returns_only_the_music_it_silenced() {
        // Playing when the card opens: put it down, and keep it down.
        assert_eq!(hush_step(true, false, false), Some(true), "down it goes");
        assert_eq!(hush_step(true, true, true), None, "and stays down");
        // Closing gives it back, once.
        assert_eq!(hush_step(false, true, true), Some(false), "and comes back");
        assert_eq!(hush_step(false, false, false), None, "nothing left owing");

        // Muted with M *before* the pause: the card never claimed it, so
        // closing the card leaves it muted.
        assert_eq!(hush_step(true, false, true), None, "not ours to take");
        assert_eq!(hush_step(false, false, true), None, "nor ours to return");
    }

    /// The stereo field mirrors the board (rodio boosts the far ear), tops
    /// out at the edges, and never reaches far enough from the listener for
    /// distance attenuation to make a far-off bank quieter than a near one.
    #[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");
        // Off-board positions clamp rather than sliding out of the field.
        assert_eq!(pan_pos(half, Vec2::new(-9.0 * half, 0.0)).x, PAN_WIDTH);
        // rodio attenuates beyond one unit; every ear-to-emitter distance
        // must stay inside that, or the pan would double as a volume drop.
        const { assert!(PAN_WIDTH + EAR_GAP / 2.0 < 1.0) };
    }
}