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
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# quad-snd (macroquad audio) imports JS functions from mq_js_bundle.js, and our own
|
# Our JS plugin (www/dc_plugin.js) provides functions that the WASM module imports.
|
||||||
# plugin (www/dc_plugin.js) provides more. Newer Rust versions need --allow-undefined for that.
|
# Newer Rust versions need --allow-undefined for imports that are not known at link time.
|
||||||
[target.wasm32-unknown-unknown]
|
[target.wasm32-unknown-unknown]
|
||||||
rustflags = ["-C", "link-arg=--allow-undefined"]
|
rustflags = ["-C", "link-arg=--allow-undefined"]
|
||||||
Generated
-97
@@ -14,24 +14,6 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -86,21 +68,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "dungeoncraft"
|
name = "dungeoncraft"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -169,12 +136,6 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hound"
|
|
||||||
version = "3.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image"
|
name = "image"
|
||||||
version = "0.24.9"
|
version = "0.24.9"
|
||||||
@@ -194,17 +155,6 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.191"
|
version = "0.2.191"
|
||||||
@@ -229,7 +179,6 @@ dependencies = [
|
|||||||
"macroquad_macro",
|
"macroquad_macro",
|
||||||
"miniquad",
|
"miniquad",
|
||||||
"quad-rand",
|
"quad-rand",
|
||||||
"quad-snd",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -247,12 +196,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "maybe-uninit"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
@@ -315,15 +258,6 @@ dependencies = [
|
|||||||
"malloc_buf",
|
"malloc_buf",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ogg"
|
|
||||||
version = "0.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.16"
|
version = "0.17.16"
|
||||||
@@ -346,34 +280,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quad-rand"
|
name = "quad-rand"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40"
|
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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -432,15 +344,6 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.9"
|
version = "3.0.9"
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ macroquad = { version = "0.4" }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -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: 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
|
- Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings
|
||||||
|
|
||||||
## Planned
|
## Music & sound
|
||||||
- Music: own **XM/MOD replayer in Rust**, shared mixer for music + sound effects (streamed via a JS audio plugin, replaces macroquad audio)
|
- Own **XM/MOD replayer in Rust** (`src/xm.rs`): instruments, envelopes, fadeout, auto-vibrato, linear/Amiga frequencies, common FT2 effects; verified against libopenmpt
|
||||||
- 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**
|
- **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
|
## Game content
|
||||||
- **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground
|
- **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ python3 -m http.server 8080 -d www # open http://localhost:8080
|
|||||||
|
|
||||||
### Testing the game logic
|
### 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
|
```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
|
## Controls
|
||||||
|
|
||||||
| Input | Action |
|
| Input | Action |
|
||||||
|
|||||||
+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).
|
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||||
|
|
||||||
|
mod audio;
|
||||||
mod game;
|
mod game;
|
||||||
mod gfx;
|
mod gfx;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
@@ -9,8 +10,10 @@ mod platform;
|
|||||||
mod render3d;
|
mod render3d;
|
||||||
mod save;
|
mod save;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod sfx;
|
||||||
mod sound;
|
mod sound;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod xm;
|
||||||
|
|
||||||
use game::{Game, Msg};
|
use game::{Game, Msg};
|
||||||
use gfx::Gfx;
|
use gfx::Gfx;
|
||||||
@@ -61,14 +64,45 @@ fn next_seed(seed: u32) -> u32 {
|
|||||||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
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);
|
i18n::set_language(&s.language);
|
||||||
ui::set_ui_scale(s.ui_scale as f32);
|
ui::set_ui_scale(s.ui_scale as f32);
|
||||||
r3d.outlines = s.outlines;
|
r3d.outlines = s.outlines;
|
||||||
r3d.set_wall_detail(s.wall_subdivisions());
|
r3d.set_wall_detail(s.wall_subdivisions());
|
||||||
r3d.max_lights = s.max_lights as usize;
|
r3d.max_lights = s.max_lights as usize;
|
||||||
sounds.volume = s.volume;
|
audio::set_volumes(s.music_volume, s.volume, s.muted);
|
||||||
sounds.muted = 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) {
|
fn store_settings(s: &Settings) {
|
||||||
@@ -107,7 +141,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load a savegame (settings in the savegame are always applied)
|
// 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) {
|
match save::from_bytes(bytes) {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
self.game = l.game;
|
self.game = l.game;
|
||||||
@@ -116,7 +150,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
if let Some(s) = l.settings {
|
if let Some(s) = l.settings {
|
||||||
self.settings = s;
|
self.settings = s;
|
||||||
apply_settings(&self.settings, r3d, sounds);
|
apply_settings(&self.settings, r3d);
|
||||||
store_settings(&self.settings);
|
store_settings(&self.settings);
|
||||||
}
|
}
|
||||||
self.game.hints = self.settings.hints;
|
self.game.hints = self.settings.hints;
|
||||||
@@ -141,7 +175,7 @@ async fn main() {
|
|||||||
let gfx = Gfx::new();
|
let gfx = Gfx::new();
|
||||||
let mut r3d = R3d::new();
|
let mut r3d = R3d::new();
|
||||||
let mut sounds = SoundBank::new();
|
let mut sounds = SoundBank::new();
|
||||||
sounds.generate_all().await;
|
let mut music = MusicDirector { build: 0 };
|
||||||
let mut ui = Ui::new();
|
let mut ui = Ui::new();
|
||||||
let mut menu = Menu::new();
|
let mut menu = Menu::new();
|
||||||
|
|
||||||
@@ -154,7 +188,7 @@ async fn main() {
|
|||||||
None => ui.show_langsel = true,
|
None => ui.show_langsel = true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply_settings(&settings, &mut r3d, &mut sounds);
|
apply_settings(&settings, &mut r3d);
|
||||||
ui.show_help = false;
|
ui.show_help = false;
|
||||||
menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some();
|
menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some();
|
||||||
|
|
||||||
@@ -180,7 +214,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(bytes) = incoming {
|
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
|
// ---------------- key capture for the key binding dialog
|
||||||
@@ -190,12 +224,12 @@ async fn main() {
|
|||||||
} else if let Some(k) = get_last_key_pressed() {
|
} else if let Some(k) = get_last_key_pressed() {
|
||||||
if bindable_keys().any(|b| b == k) {
|
if bindable_keys().any(|b| b == k) {
|
||||||
app.settings.bind(action, k);
|
app.settings.bind(action, k);
|
||||||
apply_settings(&app.settings, &mut r3d, &mut sounds);
|
apply_settings(&app.settings, &mut r3d);
|
||||||
store_settings(&app.settings);
|
store_settings(&app.settings);
|
||||||
menu.finish_capture();
|
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;
|
next_frame().await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -391,7 +425,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
MenuAction::Continue => {
|
MenuAction::Continue => {
|
||||||
if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) {
|
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 => {
|
MenuAction::Load => {
|
||||||
@@ -477,7 +511,7 @@ async fn main() {
|
|||||||
app.cam.tz = app.cam.tz.clamp(0.0, MH as f32);
|
app.cam.tz = app.cam.tz.clamp(0.0, MH as f32);
|
||||||
|
|
||||||
if settings_changed {
|
if settings_changed {
|
||||||
apply_settings(&app.settings, &mut r3d, &mut sounds);
|
apply_settings(&app.settings, &mut r3d);
|
||||||
store_settings(&app.settings);
|
store_settings(&app.settings);
|
||||||
app.game.hints = app.settings.hints;
|
app.game.hints = app.settings.hints;
|
||||||
}
|
}
|
||||||
@@ -503,20 +537,22 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let hear = app.cam.zoom * 1.2;
|
let hear = app.cam.zoom * 1.2;
|
||||||
|
let (right, _) = app.cam.ground_axes();
|
||||||
for ev in app.game.events.drain(..) {
|
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(..) {
|
for m in app.game.msgs.drain(..) {
|
||||||
ui.log.push((m, now));
|
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
|
next_frame().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[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 (mx, my) = mouse_position();
|
||||||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
||||||
let over_ui = blocked || ui.over_ui(mx, my);
|
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);
|
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||||||
hover_info(&app.game, ent, hover_tile)
|
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 {
|
} else {
|
||||||
ui.rects.clear();
|
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.
|
// Sound effect dispatch: turns game sound events into positioned effect voices
|
||||||
// Sounds will be generated later and registered via `SoundBank::set` (WAV bytes).
|
// (volume by distance to the camera, stereo panning along the screen's x axis).
|
||||||
// Events without a registered sound stay silent.
|
// The audio itself is rendered by audio.rs.
|
||||||
|
|
||||||
|
use crate::audio;
|
||||||
use crate::game::{Sfx, SfxEvent, SFX_ALL};
|
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::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
pub struct SoundBank {
|
pub struct SoundBank {
|
||||||
sounds: Vec<Option<Sound>>,
|
|
||||||
last: Vec<f64>,
|
last: Vec<f64>,
|
||||||
pub volume: f32,
|
|
||||||
pub muted: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoundBank {
|
impl SoundBank {
|
||||||
pub fn new() -> 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)
|
// minimum gap between two identical sounds (avoids a wall of noise)
|
||||||
fn min_gap(sfx: Sfx) -> f64 {
|
fn min_gap(sfx: Sfx) -> f64 {
|
||||||
match sfx {
|
match sfx {
|
||||||
@@ -60,51 +24,25 @@ impl SoundBank {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listener: camera target on the ground; hear: audible radius in tiles
|
// listener: camera target on the ground; right: screen-right direction on the ground;
|
||||||
pub fn play(&mut self, ev: SfxEvent, listener: Vec2, hear: f32, now: f64) {
|
// hear: audible radius in tiles
|
||||||
if self.muted || self.volume <= 0.0 {
|
pub fn play(&mut self, ev: SfxEvent, listener: Vec2, right: Vec2, hear: f32, now: f64) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = ev.sfx.index();
|
let i = ev.sfx.index();
|
||||||
let Some(sound) = &self.sounds[i] else { return };
|
|
||||||
if now - self.last[i] < Self::min_gap(ev.sfx) {
|
if now - self.last[i] < Self::min_gap(ev.sfx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// volume by distance to the listener
|
let mut vol = 1.0;
|
||||||
let mut vol = self.volume;
|
let mut pan = 0.0;
|
||||||
if let Some((wx, wy)) = ev.pos {
|
if let Some((wx, wy)) = ev.pos {
|
||||||
let d = ((wx - listener.x).powi(2) + (wy - listener.y).powi(2)).sqrt();
|
let d = vec2(wx, wy) - listener;
|
||||||
let f = 1.0 - ((d - hear * 0.5) / hear).clamp(0.0, 1.0);
|
let f = 1.0 - ((d.length() - hear * 0.5) / hear).clamp(0.0, 1.0);
|
||||||
if f <= 0.05 {
|
if f <= 0.05 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
vol *= f;
|
vol *= f;
|
||||||
|
pan = (d.dot(right) / hear).clamp(-1.0, 1.0) * 0.7;
|
||||||
}
|
}
|
||||||
self.last[i] = now;
|
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::map::*;
|
||||||
use crate::render3d::{minimap_color, Cam};
|
use crate::render3d::{minimap_color, Cam};
|
||||||
use crate::settings::{key_name, Action, Settings};
|
use crate::settings::{key_name, Action, Settings};
|
||||||
use crate::sound::SoundBank;
|
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
// UI font scale (setting: 2 = normal, 3 = large); character cell size follows it
|
// UI font scale (setting: 2 = normal, 3 = large); character cell size follows it
|
||||||
@@ -237,7 +236,7 @@ impl Ui {
|
|||||||
UiAction::None
|
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();
|
self.rects.clear();
|
||||||
let keys: Vec<String> = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect();
|
let keys: Vec<String> = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect();
|
||||||
let sw = screen_width();
|
let sw = screen_width();
|
||||||
@@ -280,7 +279,7 @@ impl Ui {
|
|||||||
let wave_col = if g.wave_timer < 30.0 { RED } else { TXT };
|
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(&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);
|
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
|
// volume only if it still fits into the bar
|
||||||
if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 {
|
if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 {
|
||||||
seg("♪", DIM, &vol, DIM, &mut x);
|
seg("♪", DIM, &vol, DIM, &mut x);
|
||||||
|
|||||||
Binary file not shown.
+40
-1
@@ -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 () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var pending = null; // value handed over by dc_storage_get
|
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" });
|
miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" });
|
||||||
})();
|
})();
|
||||||
Reference in new issue
Block a user