feat: audio system with 15 procedurally generated sounds via soundgen

- AudioEngine with rodio (WAV playback, throttling, volume, toggle)
- 15 embedded sounds: jump, shoot, hit, explosion, death, pickup, descend,
  powerup, step, lava_bubble, acid_sizzle, water_splash, fire_crackle,
  ui_click, goblin_growl
- Sound events: shoot, hit, explosion (fireball), pickup, descend, powerup,
  jump, combat hit, ambient material sounds (lava/fire/acid/water)
- M key toggles audio in GPU modes
- Ambient sounds: scans 15-cell radius around player, plays throttled sounds
  for nearby lava/fire/acid/water
- AudioEngine gracefully degrades when no audio device available
- All 185 tests + 14 scenarios pass
This commit is contained in:
Emil
2026-06-22 00:03:27 +03:00
parent 24b6d0320f
commit 9ce45cff4a
22 changed files with 236 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
use rodio::source::Source;
use rodio::{OutputStream, Sink};
use std::collections::HashMap;
use std::io::Cursor;
use std::time::Instant;
pub struct AudioEngine {
_stream: Option<OutputStream>,
sink: Option<Sink>,
sounds: HashMap<&'static str, &'static [u8]>,
last_played: HashMap<&'static str, Instant>,
volume: f32,
enabled: bool,
}
impl AudioEngine {
pub fn new() -> Self {
let (stream, handle) = match OutputStream::try_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Audio init failed: {}", e);
return Self::disabled();
}
};
let sink = match Sink::try_new(&handle) {
Ok(s) => s,
Err(e) => {
eprintln!("Audio sink init failed: {}", e);
return Self::disabled();
}
};
let mut sounds = HashMap::new();
sounds.insert(
"jump",
include_bytes!("../../assets/sounds/jump.wav") as &[u8],
);
sounds.insert("shoot", include_bytes!("../../assets/sounds/shoot.wav"));
sounds.insert("hit", include_bytes!("../../assets/sounds/hit.wav"));
sounds.insert(
"explosion",
include_bytes!("../../assets/sounds/explosion.wav"),
);
sounds.insert("death", include_bytes!("../../assets/sounds/death.wav"));
sounds.insert("pickup", include_bytes!("../../assets/sounds/pickup.wav"));
sounds.insert("descend", include_bytes!("../../assets/sounds/descend.wav"));
sounds.insert("powerup", include_bytes!("../../assets/sounds/powerup.wav"));
sounds.insert("step", include_bytes!("../../assets/sounds/step.wav"));
sounds.insert(
"lava_bubble",
include_bytes!("../../assets/sounds/lava_bubble.wav"),
);
sounds.insert(
"acid_sizzle",
include_bytes!("../../assets/sounds/acid_sizzle.wav"),
);
sounds.insert(
"water_splash",
include_bytes!("../../assets/sounds/water_splash.wav"),
);
sounds.insert(
"fire_crackle",
include_bytes!("../../assets/sounds/fire_crackle.wav"),
);
sounds.insert(
"ui_click",
include_bytes!("../../assets/sounds/ui_click.wav"),
);
sounds.insert(
"goblin_growl",
include_bytes!("../../assets/sounds/goblin_growl.wav"),
);
Self {
_stream: Some(stream),
sink: Some(sink),
sounds,
last_played: HashMap::new(),
volume: 0.5,
enabled: true,
}
}
fn disabled() -> Self {
Self {
_stream: None,
sink: None,
sounds: HashMap::new(),
last_played: HashMap::new(),
volume: 0.0,
enabled: false,
}
}
pub fn play(&mut self, name: &'static str) {
self.play_throttled(name, 50);
}
pub fn play_throttled(&mut self, name: &'static str, min_interval_ms: u64) {
if !self.enabled {
return;
}
let now = Instant::now();
if let Some(&last) = self.last_played.get(name) {
if now.duration_since(last).as_millis() < min_interval_ms as u128 {
return;
}
}
self.last_played.insert(name, now);
let data = match self.sounds.get(name) {
Some(d) => *d,
None => return,
};
let sink = match &self.sink {
Some(s) => s,
None => return,
};
if let Ok(decoder) = rodio::Decoder::new(Cursor::new(data.to_vec())) {
let source = decoder.amplify(self.volume).convert_samples::<f32>();
sink.append(source);
}
}
pub fn set_volume(&mut self, vol: f32) {
self.volume = vol.clamp(0.0, 1.0);
}
pub fn toggle(&mut self) {
self.enabled = !self.enabled;
if let Some(sink) = &self.sink {
if !self.enabled {
sink.pause();
} else {
sink.play();
}
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
}
impl Default for AudioEngine {
fn default() -> Self {
Self::new()
}
}
+69
View File
@@ -16,6 +16,8 @@ use crate::world::chunked_grid::ChunkedGrid;
use crate::world::grid::{WORLD_H, WORLD_W};
use crate::world::worldgen::WorldGenerator;
use crate::audio::AudioEngine;
pub struct Game {
pub grid: ChunkedGrid,
pub ca: CellularAutomaton,
@@ -26,6 +28,7 @@ pub struct Game {
pub player: Player,
pub input: InputHandler,
pub ui: UiLayer,
pub audio: AudioEngine,
pub cam_x: i32,
pub cam_y: i32,
pub cam_offset_x: i32,
@@ -68,6 +71,7 @@ impl Game {
player,
input: InputHandler::new(),
ui: UiLayer::new(),
audio: AudioEngine::new(),
cam_x: 100,
cam_y: 100,
cam_offset_x: 0,
@@ -110,6 +114,7 @@ impl Game {
player,
input: InputHandler::new(),
ui: UiLayer::new(),
audio: AudioEngine::new(),
cam_x: 0,
cam_y: 0,
cam_offset_x: 0,
@@ -309,6 +314,9 @@ impl Game {
// Jump: only on press, not held
if self.input.jump_requested() {
let on_ground = self.check_on_ground();
if on_ground {
self.audio.play("jump");
}
self.player.jump(&mut self.entities, on_ground);
}
@@ -471,9 +479,51 @@ impl Game {
self.try_spawn_slime();
}
self.play_ambient_sounds();
self.grid.swap_modified_flags();
}
fn play_ambient_sounds(&mut self) {
if !self.audio.is_enabled() {
return;
}
let (px, py) = self.player.center(&self.entities);
let radius = 15i32;
let mut has_lava = false;
let mut has_fire = false;
let mut has_acid = false;
let mut has_water = false;
for dy in -radius..=radius {
for dx in -radius..=radius {
let x = px as i32 + dx;
let y = py as i32 + dy;
if !self.grid.in_bounds(x, y) {
continue;
}
let cell = self.grid.get(x, y);
match cell.material {
MaterialId::Lava => has_lava = true,
MaterialId::Fire => has_fire = true,
MaterialId::Acid => has_acid = true,
MaterialId::Water => has_water = true,
_ => {}
}
}
}
if has_lava && self.tick % 30 == 0 {
self.audio.play_throttled("lava_bubble", 500);
}
if has_fire && self.tick % 15 == 0 {
self.audio.play_throttled("fire_crackle", 200);
}
if has_acid && self.tick % 20 == 0 {
self.audio.play_throttled("acid_sizzle", 300);
}
if has_water && self.tick % 40 == 0 {
self.audio.play_throttled("water_splash", 600);
}
}
fn update_score(&mut self) {
let mut new_kills = 0;
for e in self.entities.all_mut() {
@@ -528,6 +578,7 @@ impl Game {
let item = self.items.all_mut().remove(idx);
self.ui.add_message(&format!("Picked up {}", item.name()));
self.player.inventory.push(item);
self.audio.play("pickup");
}
}
@@ -553,6 +604,7 @@ impl Game {
self.init_world();
self.ui
.add_message(&format!("Descended to depth {}", self.depth));
self.audio.play("descend");
}
pub fn use_item(&mut self, index: usize) {
@@ -583,6 +635,7 @@ impl Game {
self.player.inventory.remove(index);
self.ui.add_message(&format!("Consumed {}", name));
}
self.audio.play("powerup");
}
pub fn drop_item(&mut self, index: usize) {
@@ -701,10 +754,24 @@ impl Game {
}
pub fn update_projectiles(&mut self) {
let count_before = self.projectiles.all().len();
self.projectiles.update(&self.grid);
self.projectiles
.resolve_hits(&mut self.grid, self.entities.all_mut(), &mut self.ui);
self.projectiles.cull_dead();
let count_after = self.projectiles.all().len();
if count_after < count_before {
let any_fireball = self
.projectiles
.all()
.iter()
.any(|p| p.typ == ProjectileType::Fireball);
if any_fireball || self.fireball_mode {
self.audio.play("explosion");
} else {
self.audio.play("hit");
}
}
}
pub fn player_shoot(&mut self, dir_x: f32, dir_y: f32) {
@@ -734,6 +801,7 @@ impl Game {
self.projectiles
.spawn(typ, spawn_x, spawn_y, vx, vy, owner, damage_bonus);
self.last_shot_tick = self.tick;
self.audio.play("shoot");
}
fn update_goblin_ai(&mut self) {
@@ -959,6 +1027,7 @@ impl Game {
_ => "Enemy hits you!",
};
self.ui.add_message(msg);
self.audio.play("hit");
}
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod ai;
pub mod audio;
pub mod entity;
pub mod game;
pub mod input;
+7
View File
@@ -295,8 +295,15 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
return;
}
if input.toggle_audio {
game.audio.toggle();
}
if input.jump {
let on_ground = game.check_on_ground();
if on_ground {
game.audio.play("jump");
}
game.player.jump(&mut game.entities, on_ground);
}
+8
View File
@@ -18,6 +18,7 @@ pub struct WindowInput {
pub cam_up: bool,
pub cam_down: bool,
pub quit: bool,
pub toggle_audio: bool,
pub paint: Option<u8>,
pub mouse_x: f64,
pub mouse_y: f64,
@@ -39,6 +40,7 @@ pub struct WindowInput {
descend_was_down: bool,
use_item_was_down: bool,
drop_item_was_down: bool,
audio_was_down: bool,
down_keys: HashSet<KeyCode>,
}
@@ -61,6 +63,7 @@ impl WindowInput {
cam_up: false,
cam_down: false,
quit: false,
toggle_audio: false,
paint: None,
mouse_x: 0.0,
mouse_y: 0.0,
@@ -82,6 +85,7 @@ impl WindowInput {
descend_was_down: false,
use_item_was_down: false,
drop_item_was_down: false,
audio_was_down: false,
down_keys: HashSet::new(),
}
}
@@ -203,6 +207,10 @@ impl WindowInput {
self.cam_down = keys.contains(&KeyCode::KeyO);
self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape);
let now_audio = keys.contains(&KeyCode::KeyM);
self.toggle_audio = now_audio && !self.audio_was_down;
self.audio_was_down = now_audio;
self.paint = None;
if keys.contains(&KeyCode::Digit1) {
self.paint = Some(1);