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

+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();
}