Own XM/MOD replayer, audio mixer with crossfades, synthesised chip sound effects, music director
- src/xm.rs: FastTracker 2 XM and ProTracker MOD replayer (verified against libopenmpt) - src/audio.rs: music players with crossfade + effect voices, rendered on demand - src/sfx.rs: 29 synthesised chip sound effects - www/dc_plugin.js: AudioContext started on first input, streams samples from WASM - music follows the game: title, rotating build tracks, battle, gate assault, jingles - macroquad audio removed; all tests run natively with cargo test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dba65be8b
commit
925be17e82
13 files changed
+1711
-205
No files matched your search
+248
@@ -0,0 +1,248 @@
|
||||
// Audio engine: music (XM players with crossfades) and synthesised sound effects.
|
||||
// The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js),
|
||||
// which starts the AudioContext on the first user interaction.
|
||||
|
||||
use crate::sfx;
|
||||
use crate::xm::{self, Module, Player};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Track {
|
||||
Title,
|
||||
Build1,
|
||||
Build2,
|
||||
Build3,
|
||||
Battle1,
|
||||
Battle2,
|
||||
Victory,
|
||||
Defeat,
|
||||
}
|
||||
|
||||
const TRACKS: [(Track, &[u8]); 8] = [
|
||||
(Track::Title, include_bytes!("../assets/music/title.xm")),
|
||||
(Track::Build1, include_bytes!("../assets/music/build1.xm")),
|
||||
(Track::Build2, include_bytes!("../assets/music/build2.xm")),
|
||||
(Track::Build3, include_bytes!("../assets/music/build3.xm")),
|
||||
(Track::Battle1, include_bytes!("../assets/music/battle1.xm")),
|
||||
(Track::Battle2, include_bytes!("../assets/music/battle2.xm")),
|
||||
(Track::Victory, include_bytes!("../assets/music/victory.xm")),
|
||||
(Track::Defeat, include_bytes!("../assets/music/defeat.xm")),
|
||||
];
|
||||
|
||||
const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects
|
||||
const SFX_GAIN: f32 = 0.55;
|
||||
|
||||
struct MusicVoice {
|
||||
player: Player,
|
||||
gain: f32,
|
||||
target: f32,
|
||||
speed: f32, // gain change per second
|
||||
}
|
||||
|
||||
struct SfxVoice {
|
||||
data: Rc<Vec<f32>>,
|
||||
pos: usize,
|
||||
l: f32,
|
||||
r: f32,
|
||||
}
|
||||
|
||||
struct Engine {
|
||||
rate: f32,
|
||||
modules: Vec<Rc<Module>>,
|
||||
music: Vec<MusicVoice>,
|
||||
voices: Vec<SfxVoice>,
|
||||
sfx: Vec<Rc<Vec<f32>>>,
|
||||
buf: Vec<f32>,
|
||||
}
|
||||
|
||||
// Requests that arrive before the browser allows audio are remembered here
|
||||
struct State {
|
||||
music_vol: f32,
|
||||
sfx_vol: f32,
|
||||
muted: bool,
|
||||
track: Option<Track>,
|
||||
engine: Option<Engine>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static STATE: RefCell<State> = RefCell::new(State { music_vol: 0.6, sfx_vol: 0.7, muted: false, track: None, engine: None });
|
||||
}
|
||||
|
||||
fn track_index(t: Track) -> usize {
|
||||
TRACKS.iter().position(|(k, _)| *k == t).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
fn new(rate: f32) -> Engine {
|
||||
let modules = TRACKS.iter().map(|(_, d)| Rc::new(xm::load(d).expect("built-in music"))).collect();
|
||||
let sfx = sfx::generate_all(rate).into_iter().map(Rc::new).collect();
|
||||
Engine { rate, modules, music: Vec::new(), voices: Vec::new(), sfx, buf: Vec::new() }
|
||||
}
|
||||
|
||||
fn start_track(&mut self, t: Track, fade: f32) {
|
||||
let speed = 1.0 / fade.max(0.01);
|
||||
for v in self.music.iter_mut() {
|
||||
v.target = 0.0;
|
||||
v.speed = speed;
|
||||
}
|
||||
let player = Player::new(self.modules[track_index(t)].clone(), self.rate);
|
||||
let instant = self.music.is_empty() || fade <= 0.0;
|
||||
self.music.push(MusicVoice { player, gain: if instant { 1.0 } else { 0.0 }, target: 1.0, speed });
|
||||
}
|
||||
|
||||
fn render(&mut self, frames: usize, music_vol: f32, sfx_vol: f32, muted: bool) {
|
||||
self.buf.clear();
|
||||
self.buf.resize(frames * 2, 0.0);
|
||||
let dt = frames as f32 / self.rate;
|
||||
for v in self.music.iter_mut() {
|
||||
if v.gain < v.target {
|
||||
v.gain = (v.gain + v.speed * dt).min(v.target);
|
||||
} else if v.gain > v.target {
|
||||
v.gain = (v.gain - v.speed * dt).max(v.target);
|
||||
}
|
||||
if v.gain > 0.0 {
|
||||
v.player.render(&mut self.buf, v.gain * music_vol * MUSIC_GAIN);
|
||||
}
|
||||
}
|
||||
self.music.retain(|v| v.gain > 0.0 || v.target > 0.0);
|
||||
let g = sfx_vol * SFX_GAIN;
|
||||
for v in self.voices.iter_mut() {
|
||||
let n = (v.data.len() - v.pos).min(frames);
|
||||
for k in 0..n {
|
||||
let s = v.data[v.pos + k] * g;
|
||||
self.buf[k * 2] += s * v.l;
|
||||
self.buf[k * 2 + 1] += s * v.r;
|
||||
}
|
||||
v.pos += n;
|
||||
}
|
||||
self.voices.retain(|v| v.pos < v.data.len());
|
||||
for x in self.buf.iter_mut() {
|
||||
*x = if muted { 0.0 } else { soft_clip(*x) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn soft_clip(x: f32) -> f32 {
|
||||
if x.abs() < 0.8 {
|
||||
x
|
||||
} else {
|
||||
x.signum() * (0.8 + 0.2 * ((x.abs() - 0.8) / 0.2).tanh())
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API for the game
|
||||
|
||||
pub fn set_volumes(music: f32, sfx: f32, muted: bool) {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.music_vol = music;
|
||||
s.sfx_vol = sfx;
|
||||
s.muted = muted;
|
||||
});
|
||||
}
|
||||
|
||||
// Switch music with a crossfade (seconds); does nothing if the track already plays
|
||||
pub fn play_music(t: Track, fade: f32) {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.track == Some(t) {
|
||||
return;
|
||||
}
|
||||
s.track = Some(t);
|
||||
if let Some(e) = s.engine.as_mut() {
|
||||
e.start_track(t, fade);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// How often the current track has looped (0 before audio started)
|
||||
pub fn music_loops() -> u32 {
|
||||
STATE.with(|s| {
|
||||
let s = s.borrow();
|
||||
s.engine.as_ref().and_then(|e| e.music.iter().rev().find(|v| v.target > 0.0)).map_or(0, |v| v.player.loops)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_track() -> Option<Track> {
|
||||
STATE.with(|s| s.borrow().track)
|
||||
}
|
||||
|
||||
// pan: -1 (left) .. 1 (right)
|
||||
pub fn play_sfx(index: usize, vol: f32, pan: f32) {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.muted {
|
||||
return;
|
||||
}
|
||||
if let Some(e) = s.engine.as_mut() {
|
||||
if let Some(d) = e.sfx.get(index) {
|
||||
if e.voices.len() < 24 {
|
||||
let p = (pan.clamp(-1.0, 1.0) + 1.0) * 0.5;
|
||||
e.voices.push(SfxVoice { data: d.clone(), pos: 0, l: vol * (1.0 - p).sqrt(), r: vol * p.sqrt() });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ called from JS
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn dc_audio_init(rate: f32) {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.engine.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut e = Engine::new(rate.clamp(8000.0, 192000.0));
|
||||
if let Some(t) = s.track {
|
||||
e.start_track(t, 0.0);
|
||||
}
|
||||
s.engine = Some(e);
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn dc_audio_render(frames: u32) -> *const f32 {
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let (mv, sv, muted) = (s.music_vol, s.sfx_vol, s.muted);
|
||||
match s.engine.as_mut() {
|
||||
Some(e) => {
|
||||
e.render(frames as usize, mv, sv, muted);
|
||||
e.buf.as_ptr()
|
||||
}
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_crossfade_and_sfx() {
|
||||
dc_audio_init(48000.0);
|
||||
play_music(Track::Build1, 0.0);
|
||||
let mut peak = 0.0f32;
|
||||
for k in 0..200 {
|
||||
if k == 50 {
|
||||
play_music(Track::Battle1, 2.5);
|
||||
}
|
||||
if k % 10 == 0 {
|
||||
play_sfx(k % crate::game::SFX_ALL.len(), 1.0, 0.3);
|
||||
}
|
||||
let p = dc_audio_render(1024);
|
||||
assert!(!p.is_null());
|
||||
let buf = unsafe { std::slice::from_raw_parts(p, 2048) };
|
||||
for &v in buf {
|
||||
assert!(v.is_finite() && v.abs() <= 1.0);
|
||||
peak = peak.max(v.abs());
|
||||
}
|
||||
}
|
||||
assert!(peak > 0.05);
|
||||
assert_eq!(current_track(), Some(Track::Battle1));
|
||||
}
|
||||
}
|
||||
+52
-16
@@ -1,5 +1,6 @@
|
||||
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||
|
||||
mod audio;
|
||||
mod game;
|
||||
mod gfx;
|
||||
mod i18n;
|
||||
@@ -9,8 +10,10 @@ mod platform;
|
||||
mod render3d;
|
||||
mod save;
|
||||
mod settings;
|
||||
mod sfx;
|
||||
mod sound;
|
||||
mod ui;
|
||||
mod xm;
|
||||
|
||||
use game::{Game, Msg};
|
||||
use gfx::Gfx;
|
||||
@@ -61,14 +64,45 @@ fn next_seed(seed: u32) -> u32 {
|
||||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
||||
}
|
||||
|
||||
fn apply_settings(s: &Settings, r3d: &mut R3d, sounds: &mut SoundBank) {
|
||||
fn apply_settings(s: &Settings, r3d: &mut R3d) {
|
||||
i18n::set_language(&s.language);
|
||||
ui::set_ui_scale(s.ui_scale as f32);
|
||||
r3d.outlines = s.outlines;
|
||||
r3d.set_wall_detail(s.wall_subdivisions());
|
||||
r3d.max_lights = s.max_lights as usize;
|
||||
sounds.volume = s.volume;
|
||||
sounds.muted = s.muted;
|
||||
audio::set_volumes(s.music_volume, s.volume, s.muted);
|
||||
}
|
||||
|
||||
// Chooses the music for the current situation; build tracks rotate after two loops
|
||||
struct MusicDirector {
|
||||
build: usize,
|
||||
}
|
||||
|
||||
impl MusicDirector {
|
||||
fn update(&mut self, g: &Game, title: bool) {
|
||||
use audio::Track;
|
||||
const BUILD: [Track; 3] = [Track::Build1, Track::Build2, Track::Build3];
|
||||
let gate_fight = g.time - g.gate_hit_t < 12.0 || g.ents.iter().any(|e| e.alive && e.guard);
|
||||
let invasion = g.ents.iter().any(|e| e.alive && e.hero && !e.guard);
|
||||
let want = if title {
|
||||
Track::Title
|
||||
} else if g.state == 1 {
|
||||
Track::Victory
|
||||
} else if g.state == 2 {
|
||||
Track::Defeat
|
||||
} else if gate_fight {
|
||||
Track::Battle2
|
||||
} else if invasion {
|
||||
Track::Battle1
|
||||
} else {
|
||||
if BUILD.contains(&audio::current_track().unwrap_or(Track::Title)) && audio::music_loops() >= 2 {
|
||||
self.build = (self.build + 1) % BUILD.len();
|
||||
}
|
||||
BUILD[self.build]
|
||||
};
|
||||
let fade = if matches!(want, Track::Victory | Track::Defeat) { 1.0 } else { 2.5 };
|
||||
audio::play_music(want, fade);
|
||||
}
|
||||
}
|
||||
|
||||
fn store_settings(s: &Settings) {
|
||||
@@ -107,7 +141,7 @@ impl App {
|
||||
}
|
||||
|
||||
// Load a savegame (settings in the savegame are always applied)
|
||||
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, sounds: &mut SoundBank, now: f64) {
|
||||
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) {
|
||||
match save::from_bytes(bytes) {
|
||||
Ok(l) => {
|
||||
self.game = l.game;
|
||||
@@ -116,7 +150,7 @@ impl App {
|
||||
}
|
||||
if let Some(s) = l.settings {
|
||||
self.settings = s;
|
||||
apply_settings(&self.settings, r3d, sounds);
|
||||
apply_settings(&self.settings, r3d);
|
||||
store_settings(&self.settings);
|
||||
}
|
||||
self.game.hints = self.settings.hints;
|
||||
@@ -141,7 +175,7 @@ async fn main() {
|
||||
let gfx = Gfx::new();
|
||||
let mut r3d = R3d::new();
|
||||
let mut sounds = SoundBank::new();
|
||||
sounds.generate_all().await;
|
||||
let mut music = MusicDirector { build: 0 };
|
||||
let mut ui = Ui::new();
|
||||
let mut menu = Menu::new();
|
||||
|
||||
@@ -154,7 +188,7 @@ async fn main() {
|
||||
None => ui.show_langsel = true,
|
||||
}
|
||||
}
|
||||
apply_settings(&settings, &mut r3d, &mut sounds);
|
||||
apply_settings(&settings, &mut r3d);
|
||||
ui.show_help = false;
|
||||
menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some();
|
||||
|
||||
@@ -180,7 +214,7 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = incoming {
|
||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, &mut sounds, now);
|
||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||||
}
|
||||
|
||||
// ---------------- key capture for the key binding dialog
|
||||
@@ -190,12 +224,12 @@ async fn main() {
|
||||
} else if let Some(k) = get_last_key_pressed() {
|
||||
if bindable_keys().any(|b| b == k) {
|
||||
app.settings.bind(action, k);
|
||||
apply_settings(&app.settings, &mut r3d, &mut sounds);
|
||||
apply_settings(&app.settings, &mut r3d);
|
||||
store_settings(&app.settings);
|
||||
menu.finish_capture();
|
||||
}
|
||||
}
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now);
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now);
|
||||
next_frame().await;
|
||||
continue;
|
||||
}
|
||||
@@ -391,7 +425,7 @@ async fn main() {
|
||||
}
|
||||
MenuAction::Continue => {
|
||||
if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) {
|
||||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, &mut sounds, now);
|
||||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now);
|
||||
}
|
||||
}
|
||||
MenuAction::Load => {
|
||||
@@ -477,7 +511,7 @@ async fn main() {
|
||||
app.cam.tz = app.cam.tz.clamp(0.0, MH as f32);
|
||||
|
||||
if settings_changed {
|
||||
apply_settings(&app.settings, &mut r3d, &mut sounds);
|
||||
apply_settings(&app.settings, &mut r3d);
|
||||
store_settings(&app.settings);
|
||||
app.game.hints = app.settings.hints;
|
||||
}
|
||||
@@ -503,20 +537,22 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
let hear = app.cam.zoom * 1.2;
|
||||
let (right, _) = app.cam.ground_axes();
|
||||
for ev in app.game.events.drain(..) {
|
||||
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), hear, now);
|
||||
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now);
|
||||
}
|
||||
music.update(&app.game, menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })));
|
||||
for m in app.game.msgs.drain(..) {
|
||||
ui.log.push((m, now));
|
||||
}
|
||||
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now);
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now);
|
||||
next_frame().await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, sounds: &SoundBank, inp: &Input, now: f64) {
|
||||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, inp: &Input, now: f64) {
|
||||
let (mx, my) = mouse_position();
|
||||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
||||
let over_ui = blocked || ui.over_ui(mx, my);
|
||||
@@ -552,7 +588,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||||
hover_info(&app.game, ent, hover_tile)
|
||||
};
|
||||
ui.draw(&app.game, gfx, &app.cam, sounds, &app.settings, &info, now);
|
||||
ui.draw(&app.game, gfx, &app.cam, &app.settings, &info, now);
|
||||
} else {
|
||||
ui.rects.clear();
|
||||
}
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Chip-style sound effects, synthesised once when audio starts.
|
||||
// Each effect is a list of layers; a layer is a sequence of segments
|
||||
// (waveform, start/end frequency, duration, volume, decay).
|
||||
|
||||
use crate::game::{Sfx, SFX_ALL};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum W {
|
||||
Square(f32), // duty cycle
|
||||
Tri,
|
||||
Saw,
|
||||
Noise,
|
||||
Sine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Seg {
|
||||
w: W,
|
||||
f0: f32,
|
||||
f1: f32,
|
||||
dur: f32,
|
||||
vol: f32,
|
||||
decay: f32, // exponential decay rate per second (0 = flat)
|
||||
vib: f32, // vibrato depth (fraction of frequency)
|
||||
}
|
||||
|
||||
fn seg(w: W, f0: f32, f1: f32, dur: f32, vol: f32, decay: f32) -> Seg {
|
||||
Seg { w, f0, f1, dur, vol, decay, vib: 0.0 }
|
||||
}
|
||||
|
||||
fn vib(mut s: Seg, v: f32) -> Seg {
|
||||
s.vib = v;
|
||||
s
|
||||
}
|
||||
|
||||
fn render_layer(rate: f32, segs: &[Seg], seed: u32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
let mut ph = 0.0f32;
|
||||
let mut rng = seed | 1;
|
||||
let mut noise = 0.0f32;
|
||||
let mut noise_ph = 0.0f32;
|
||||
for s in segs {
|
||||
let n = (s.dur * rate) as usize;
|
||||
let attack = (rate * 0.003) as usize;
|
||||
let release = (rate * 0.008) as usize;
|
||||
for i in 0..n {
|
||||
let t = i as f32 / rate;
|
||||
let k = i as f32 / n.max(1) as f32;
|
||||
let mut f = s.f0 * (s.f1 / s.f0).powf(k); // exponential sweep
|
||||
if s.vib > 0.0 {
|
||||
f *= 1.0 + s.vib * (t * 2.0 * std::f32::consts::PI * 7.0).sin();
|
||||
}
|
||||
ph = (ph + f / rate).fract();
|
||||
let v = match s.w {
|
||||
W::Square(d) => {
|
||||
if ph < d {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
W::Tri => 4.0 * (ph - 0.5).abs() - 1.0,
|
||||
W::Saw => 2.0 * ph - 1.0,
|
||||
W::Sine => (ph * 2.0 * std::f32::consts::PI).sin(),
|
||||
W::Noise => {
|
||||
// sample-and-hold noise; frequency controls the "pitch" of the noise
|
||||
noise_ph += f / rate;
|
||||
if noise_ph >= 1.0 {
|
||||
noise_ph -= 1.0;
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
noise = (rng as f32 / u32::MAX as f32) * 2.0 - 1.0;
|
||||
}
|
||||
noise
|
||||
}
|
||||
};
|
||||
let mut env = (-s.decay * t).exp();
|
||||
if i < attack {
|
||||
env *= i as f32 / attack as f32;
|
||||
}
|
||||
if n - i < release {
|
||||
env *= (n - i) as f32 / release as f32;
|
||||
}
|
||||
out.push(v * s.vol * env);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mix(layers: Vec<Vec<f32>>) -> Vec<f32> {
|
||||
let n = layers.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
let mut out = vec![0.0; n];
|
||||
for l in layers {
|
||||
for (o, v) in out.iter_mut().zip(l) {
|
||||
*o += v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn arp(w: W, notes: &[f32], each: f32, vol: f32) -> Vec<Seg> {
|
||||
notes.iter().map(|&f| seg(w, f, f, each, vol, 6.0)).collect()
|
||||
}
|
||||
|
||||
fn def(s: Sfx, r: f32) -> Vec<f32> {
|
||||
use W::*;
|
||||
let l = |segs: &[Seg], seed: u32| render_layer(r, segs, seed);
|
||||
match s {
|
||||
Sfx::Dig => mix(vec![l(&[seg(Noise, 3000.0, 1500.0, 0.06, 0.35, 40.0)], 1), l(&[seg(Tri, 140.0, 70.0, 0.08, 0.5, 30.0)], 2)]),
|
||||
Sfx::GoldDig => mix(vec![l(&[seg(Square(0.25), 1568.0, 2093.0, 0.07, 0.22, 25.0)], 3), l(&[seg(Noise, 6000.0, 6000.0, 0.02, 0.2, 80.0)], 4)]),
|
||||
Sfx::DigDone => mix(vec![l(&[seg(Noise, 2500.0, 400.0, 0.3, 0.4, 9.0)], 5), l(&[seg(Tri, 110.0, 45.0, 0.25, 0.6, 12.0)], 6)]),
|
||||
Sfx::Claim => l(&[seg(Square(0.5), 440.0, 660.0, 0.07, 0.12, 20.0)], 7),
|
||||
Sfx::Deposit => l(&[seg(Square(0.25), 988.0, 988.0, 0.05, 0.22, 8.0), seg(Square(0.25), 1319.0, 1319.0, 0.14, 0.22, 14.0)], 8),
|
||||
Sfx::Build => mix(vec![l(&[seg(Tri, 220.0, 80.0, 0.14, 0.6, 15.0)], 9), l(&[seg(Noise, 1800.0, 900.0, 0.08, 0.25, 30.0)], 10)]),
|
||||
Sfx::Sell => l(&[seg(Square(0.25), 1319.0, 1319.0, 0.05, 0.2, 8.0), seg(Square(0.25), 988.0, 988.0, 0.12, 0.2, 14.0)], 11),
|
||||
Sfx::MeleeHit => mix(vec![l(&[seg(Noise, 4000.0, 2000.0, 0.07, 0.4, 35.0)], 12), l(&[seg(Tri, 200.0, 90.0, 0.08, 0.5, 30.0)], 13)]),
|
||||
Sfx::Shoot => l(&[seg(Square(0.125), 900.0, 300.0, 0.13, 0.25, 12.0)], 14),
|
||||
Sfx::Death => l(&[vib(seg(Square(0.5), 420.0, 70.0, 0.4, 0.3, 5.0), 0.04)], 15),
|
||||
Sfx::HeroDeath => mix(vec![l(&[seg(Noise, 2000.0, 300.0, 0.3, 0.3, 8.0)], 16), l(&[seg(Tri, 330.0, 60.0, 0.35, 0.5, 6.0)], 17)]),
|
||||
Sfx::Lightning => mix(vec![l(&[seg(Noise, 9000.0, 2000.0, 0.55, 0.6, 5.0)], 18), l(&[seg(Tri, 80.0, 40.0, 0.6, 0.6, 4.0)], 19)]),
|
||||
Sfx::Heal => l(&arp(Sine, &[523.0, 659.0, 784.0, 1047.0], 0.07, 0.45), 20),
|
||||
Sfx::Summon => l(&[vib(seg(Square(0.25), 300.0, 900.0, 0.45, 0.22, 2.0), 0.06)], 21),
|
||||
Sfx::PortalArrive => l(&[vib(seg(Saw, 900.0, 260.0, 0.45, 0.2, 3.0), 0.08)], 22),
|
||||
Sfx::WaveAlarm => l(&[
|
||||
seg(Square(0.5), 660.0, 660.0, 0.13, 0.2, 0.0),
|
||||
seg(Square(0.5), 880.0, 880.0, 0.13, 0.2, 0.0),
|
||||
seg(Square(0.5), 660.0, 660.0, 0.13, 0.2, 0.0),
|
||||
seg(Square(0.5), 880.0, 880.0, 0.13, 0.2, 0.0),
|
||||
], 23),
|
||||
Sfx::WaveStart => mix(vec![l(&[seg(Saw, 220.0, 220.0, 0.7, 0.22, 2.0)], 24), l(&[seg(Saw, 330.0, 330.0, 0.7, 0.16, 2.0)], 25)]),
|
||||
Sfx::CoreHit => mix(vec![l(&[seg(Tri, 95.0, 40.0, 0.22, 0.7, 10.0)], 26), l(&[seg(Noise, 1200.0, 600.0, 0.12, 0.25, 25.0)], 27)]),
|
||||
Sfx::GateHit => mix(vec![l(&[seg(Square(0.5), 310.0, 290.0, 0.1, 0.15, 25.0)], 28), l(&[seg(Square(0.5), 465.0, 440.0, 0.1, 0.12, 25.0)], 29), l(&[seg(Noise, 5000.0, 3000.0, 0.05, 0.2, 50.0)], 30)]),
|
||||
Sfx::GateDefenders => l(&[seg(Saw, 440.0, 440.0, 0.25, 0.22, 1.0), seg(Saw, 330.0, 330.0, 0.5, 0.22, 2.0)], 31),
|
||||
Sfx::LevelUp => l(&arp(Square(0.25), &[523.0, 659.0, 784.0, 1047.0, 1319.0], 0.05, 0.2), 32),
|
||||
Sfx::Pickup => l(&[seg(Tri, 300.0, 600.0, 0.06, 0.4, 10.0)], 33),
|
||||
Sfx::Drop => l(&[seg(Tri, 600.0, 250.0, 0.08, 0.4, 10.0)], 34),
|
||||
Sfx::Payday => l(&[
|
||||
seg(Square(0.25), 988.0, 988.0, 0.05, 0.18, 8.0),
|
||||
seg(Square(0.25), 1319.0, 1319.0, 0.05, 0.18, 8.0),
|
||||
seg(Square(0.25), 988.0, 988.0, 0.05, 0.18, 8.0),
|
||||
seg(Square(0.25), 1568.0, 1568.0, 0.15, 0.18, 10.0),
|
||||
], 35),
|
||||
Sfx::FlagSet => l(&[seg(Square(0.5), 392.0, 392.0, 0.08, 0.2, 6.0), seg(Square(0.5), 523.0, 523.0, 0.16, 0.2, 6.0)], 36),
|
||||
Sfx::Victory => l(&arp(Square(0.25), &[587.0, 740.0, 880.0, 1175.0], 0.08, 0.2), 37),
|
||||
Sfx::Defeat => l(&[seg(Square(0.5), 220.0, 110.0, 0.6, 0.22, 2.0)], 38),
|
||||
Sfx::Click => l(&[seg(Square(0.5), 1000.0, 1000.0, 0.02, 0.12, 50.0)], 39),
|
||||
Sfx::Error => l(&[seg(Square(0.5), 110.0, 105.0, 0.16, 0.2, 4.0)], 40),
|
||||
}
|
||||
}
|
||||
|
||||
// One sample buffer per effect, in SFX_ALL order
|
||||
pub fn generate_all(rate: f32) -> Vec<Vec<f32>> {
|
||||
SFX_ALL.iter().map(|&s| def(s, rate)).collect()
|
||||
}
|
||||
+14
-76
@@ -1,56 +1,20 @@
|
||||
// Sound playback. The game logic reports events (Sfx), they are played here.
|
||||
// Sounds will be generated later and registered via `SoundBank::set` (WAV bytes).
|
||||
// Events without a registered sound stay silent.
|
||||
// Sound effect dispatch: turns game sound events into positioned effect voices
|
||||
// (volume by distance to the camera, stereo panning along the screen's x axis).
|
||||
// The audio itself is rendered by audio.rs.
|
||||
|
||||
use crate::audio;
|
||||
use crate::game::{Sfx, SfxEvent, SFX_ALL};
|
||||
#[cfg(feature = "audio")]
|
||||
use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound};
|
||||
#[cfg(not(feature = "audio"))]
|
||||
use stub::*;
|
||||
|
||||
// Silent stand-in when the "audio" feature is off (native tests)
|
||||
#[cfg(not(feature = "audio"))]
|
||||
mod stub {
|
||||
#[derive(Clone)]
|
||||
pub struct Sound;
|
||||
pub struct PlaySoundParams {
|
||||
pub looped: bool,
|
||||
pub volume: f32,
|
||||
}
|
||||
pub async fn load_sound_from_bytes(_: &[u8]) -> Result<Sound, ()> {
|
||||
Err(())
|
||||
}
|
||||
pub fn play_sound(_: &Sound, _: PlaySoundParams) {}
|
||||
}
|
||||
use macroquad::prelude::*;
|
||||
|
||||
pub struct SoundBank {
|
||||
sounds: Vec<Option<Sound>>,
|
||||
last: Vec<f64>,
|
||||
pub volume: f32,
|
||||
pub muted: bool,
|
||||
}
|
||||
|
||||
impl SoundBank {
|
||||
pub fn new() -> SoundBank {
|
||||
SoundBank { sounds: vec![None; SFX_ALL.len()], last: vec![-1.0; SFX_ALL.len()], volume: 0.7, muted: false }
|
||||
SoundBank { last: vec![-1.0; SFX_ALL.len()] }
|
||||
}
|
||||
|
||||
// register the sound for an event (WAV or OGG bytes)
|
||||
#[allow(dead_code)]
|
||||
pub async fn set(&mut self, sfx: Sfx, bytes: &[u8]) -> bool {
|
||||
match load_sound_from_bytes(bytes).await {
|
||||
Ok(s) => {
|
||||
self.sounds[sfx.index()] = Some(s);
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// procedurally generated sounds will be registered here
|
||||
pub async fn generate_all(&mut self) {}
|
||||
|
||||
// minimum gap between two identical sounds (avoids a wall of noise)
|
||||
fn min_gap(sfx: Sfx) -> f64 {
|
||||
match sfx {
|
||||
@@ -60,51 +24,25 @@ impl SoundBank {
|
||||
}
|
||||
}
|
||||
|
||||
// listener: camera target on the ground; hear: audible radius in tiles
|
||||
pub fn play(&mut self, ev: SfxEvent, listener: Vec2, hear: f32, now: f64) {
|
||||
if self.muted || self.volume <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// listener: camera target on the ground; right: screen-right direction on the ground;
|
||||
// hear: audible radius in tiles
|
||||
pub fn play(&mut self, ev: SfxEvent, listener: Vec2, right: Vec2, hear: f32, now: f64) {
|
||||
let i = ev.sfx.index();
|
||||
let Some(sound) = &self.sounds[i] else { return };
|
||||
if now - self.last[i] < Self::min_gap(ev.sfx) {
|
||||
return;
|
||||
}
|
||||
// volume by distance to the listener
|
||||
let mut vol = self.volume;
|
||||
let mut vol = 1.0;
|
||||
let mut pan = 0.0;
|
||||
if let Some((wx, wy)) = ev.pos {
|
||||
let d = ((wx - listener.x).powi(2) + (wy - listener.y).powi(2)).sqrt();
|
||||
let f = 1.0 - ((d - hear * 0.5) / hear).clamp(0.0, 1.0);
|
||||
let d = vec2(wx, wy) - listener;
|
||||
let f = 1.0 - ((d.length() - hear * 0.5) / hear).clamp(0.0, 1.0);
|
||||
if f <= 0.05 {
|
||||
return;
|
||||
}
|
||||
vol *= f;
|
||||
pan = (d.dot(right) / hear).clamp(-1.0, 1.0) * 0.7;
|
||||
}
|
||||
self.last[i] = now;
|
||||
play_sound(sound, PlaySoundParams { looped: false, volume: vol });
|
||||
audio::play_sfx(i, vol, pan);
|
||||
}
|
||||
}
|
||||
|
||||
// helper for sound generation: samples (-1..1, mono) -> WAV (16 bit PCM)
|
||||
#[allow(dead_code)]
|
||||
pub fn wav_from_samples(samples: &[f32], rate: u32) -> Vec<u8> {
|
||||
let data_len = (samples.len() * 2) as u32;
|
||||
let mut out = Vec::with_capacity(44 + data_len as usize);
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVEfmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // mono
|
||||
out.extend_from_slice(&rate.to_le_bytes());
|
||||
out.extend_from_slice(&(rate * 2).to_le_bytes());
|
||||
out.extend_from_slice(&2u16.to_le_bytes());
|
||||
out.extend_from_slice(&16u16.to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&data_len.to_le_bytes());
|
||||
for &s in samples {
|
||||
let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use crate::i18n::{languages, tr, trf};
|
||||
use crate::map::*;
|
||||
use crate::render3d::{minimap_color, Cam};
|
||||
use crate::settings::{key_name, Action, Settings};
|
||||
use crate::sound::SoundBank;
|
||||
use macroquad::prelude::*;
|
||||
|
||||
// UI font scale (setting: 2 = normal, 3 = large); character cell size follows it
|
||||
@@ -237,7 +236,7 @@ impl Ui {
|
||||
UiAction::None
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, snd: &SoundBank, settings: &Settings, info: &[(String, Color)], now: f64) {
|
||||
pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, settings: &Settings, info: &[(String, Color)], now: f64) {
|
||||
self.rects.clear();
|
||||
let keys: Vec<String> = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect();
|
||||
let sw = screen_width();
|
||||
@@ -280,7 +279,7 @@ impl Ui {
|
||||
let wave_col = if g.wave_timer < 30.0 { RED } else { TXT };
|
||||
seg(&trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, &mmss(g.wave_timer), wave_col, &mut x);
|
||||
seg(&tr("hud.army"), TXT, &format!("{}", g.army_value() as i32), TXT, &mut x);
|
||||
let vol = if snd.muted { tr("hud.off") } else { format!("{}%", (snd.volume * 100.0).round() as i32) };
|
||||
let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) };
|
||||
// volume only if it still fits into the bar
|
||||
if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 {
|
||||
seg("♪", DIM, &vol, DIM, &mut x);
|
||||
|
||||
Reference in new issue
Block a user