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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 06:10:28 +01:00
1 parent 2dba65be8b
commit 925be17e82
13 files changed
+1711 -205

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
# quad-snd (macroquad audio) imports JS functions from mq_js_bundle.js, and our own
# plugin (www/dc_plugin.js) provides more. Newer Rust versions need --allow-undefined for that.
# Our JS plugin (www/dc_plugin.js) provides functions that the WASM module imports.
# Newer Rust versions need --allow-undefined for imports that are not known at link time.
[target.wasm32-unknown-unknown]
rustflags = ["-C", "link-arg=--allow-undefined"]
Generated
-97
View File
@@ -14,24 +14,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "audir-sles"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea47348666a8edb7ad80cbee3940eb2bccf70df0e6ce09009abe1a836cb779f5"
[[package]]
name = "audrey"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b92a84e89497e3cd25d3672cd5d1c288abaac02c18ff21283f17d118b889b8"
dependencies = [
"dasp_frame",
"dasp_sample",
"hound",
"lewton",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -86,21 +68,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "dasp_frame"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
dependencies = [
"dasp_sample",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "dungeoncraft"
version = "0.1.0"
@@ -169,12 +136,6 @@ dependencies = [
"foldhash",
]
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "image"
version = "0.24.9"
@@ -194,17 +155,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lewton"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d542c1a317036c45c2aa1cf10cc9d403ca91eb2d333ef1a4917e5cb10628bd0"
dependencies = [
"byteorder",
"ogg",
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.191"
@@ -229,7 +179,6 @@ dependencies = [
"macroquad_macro",
"miniquad",
"quad-rand",
"quad-snd",
]
[[package]]
@@ -247,12 +196,6 @@ dependencies = [
"libc",
]
[[package]]
name = "maybe-uninit"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
[[package]]
name = "memchr"
version = "2.8.3"
@@ -315,15 +258,6 @@ dependencies = [
"malloc_buf",
]
[[package]]
name = "ogg"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651"
dependencies = [
"byteorder",
]
[[package]]
name = "png"
version = "0.17.16"
@@ -346,34 +280,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quad-alsa-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66c2f04a6946293477973d85adc251d502da51c57b08cd9c997f0cfd8dcd4b5"
dependencies = [
"libc",
]
[[package]]
name = "quad-rand"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40"
[[package]]
name = "quad-snd"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba0c4943fc67147fbe9d1eb731fb9e678bfc9d926507eebbbfe0103e154e5b0"
dependencies = [
"audir-sles",
"audrey",
"libc",
"quad-alsa-sys",
"winapi",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -432,15 +344,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "0.6.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0"
dependencies = [
"maybe-uninit",
]
[[package]]
name = "syn"
version = "3.0.9"
-5
View File
@@ -8,11 +8,6 @@ macroquad = { version = "0.4" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
default = ["audio"]
# macroquad audio (needs libasound natively); disable for native tests: cargo test --no-default-features
audio = ["macroquad/audio"]
[profile.release]
opt-level = 3
lto = true
+6 -3
View File
@@ -36,9 +36,12 @@ An original dungeon management game (genre: dungeon building & defence) that run
- Settings: language + sound, camera (rotation/zoom speed, edge scrolling, invert mouse), graphics (tile outlines, wall detail, light sources, interface size), game (speed 1×/2×, hints), **key bindings** (conflicts swap keys; Esc and arrow keys are fixed)
- Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings
## Planned
- Music: own **XM/MOD replayer in Rust**, shared mixer for music + sound effects (streamed via a JS audio plugin, replaces macroquad audio)
- Soundtrack (composed as `.xm`, editable in MilkyTracker): title/menu, several calm build tracks, battle, victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece**
## Music & sound
- Own **XM/MOD replayer in Rust** (`src/xm.rs`): instruments, envelopes, fadeout, auto-vibrato, linear/Amiga frequencies, common FT2 effects; verified against libopenmpt
- **Shared mixer** for music + sound effects (`src/audio.rs`), streamed to the browser by the JS plugin (replaces macroquad audio)
- Soundtrack in classic **Amiga chiptune** style, composed as `.xm` by `tools/compose_music.py` (editable in MilkyTracker): title/menu, **3 build tracks** (rotating), **2 battle tracks** (wave defence, gate assault), victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece**
- Music changes with **soft crossfades** (2.5 s): title → build → battle when heroes invade → storm music while attacking the gate → jingles
- Sound effects are **synthesised chip sounds** (`src/sfx.rs`), positioned by distance and stereo panning
## Game content
- **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground
+6 -2
View File
@@ -34,12 +34,16 @@ python3 -m http.server 8080 -d www # open http://localhost:8080
### Testing the game logic
The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad. The tests (a headless simulation of whole games and a savegame round trip) run natively without the audio feature:
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
```sh
cargo test --release --no-default-features
cargo test --release
```
## Music
The soundtrack consists of FastTracker 2 modules in `assets/music/`, generated by `tools/compose_music.py` (`python3 tools/compose_music.py`). They can be opened and edited in MilkyTracker. The game plays them with its own XM/MOD replayer (`src/xm.rs`); sound effects are synthesised chip sounds (`src/sfx.rs`). Both are mixed in `src/audio.rs` and streamed to the browser by `www/dc_plugin.js`.
## Controls
| Input | Action |
+248
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+2 -3
View File
@@ -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);
+1186
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.
+40 -1
View File
@@ -1,4 +1,4 @@
// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave.
// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, audio output.
(function () {
"use strict";
var pending = null; // value handed over by dc_storage_get
@@ -77,5 +77,44 @@
};
}
// ---- audio: the Rust mixer renders on demand (dc_audio_render), started by the first user input
var actx = null;
var stats = { frames: 0, peak: 0 };
// diagnostics, e.g. for automated tests: state of the audio output
window.dc_audio_stats = function () {
return { state: actx ? actx.state : "not started", frames: stats.frames, peak: stats.peak };
};
function startAudio() {
if (typeof wasm_exports === "undefined" || !wasm_exports.dc_audio_render) return;
if (actx) {
if (actx.state !== "running") actx.resume();
return;
}
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
actx = new AC();
wasm_exports.dc_audio_init(actx.sampleRate);
var node = actx.createScriptProcessor(2048, 0, 2);
node.onaudioprocess = function (e) {
var n = e.outputBuffer.length;
var l = e.outputBuffer.getChannelData(0);
var r = e.outputBuffer.getChannelData(1);
var ptr = wasm_exports.dc_audio_render(n);
if (!ptr) { l.fill(0); r.fill(0); return; }
var buf = new Float32Array(wasm_memory.buffer, ptr, n * 2);
var peak = 0;
for (var i = 0; i < n; i++) {
l[i] = buf[2 * i]; r[i] = buf[2 * i + 1];
var a = Math.abs(l[i]); if (a > peak) peak = a;
}
stats.frames += n;
stats.peak = Math.max(stats.peak * 0.9, peak);
};
node.connect(actx.destination);
}
["mousedown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, startAudio, true);
});
miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" });
})();