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:
@@ -86,6 +86,7 @@ GPU (`--mode ascii` / `--mode graphics`):
|
||||
- `r` — drop first inventory item
|
||||
- `1`–`0` / `x` — paint material brush
|
||||
- `y` / `u` / `i` / `o` — move camera offset
|
||||
- `m` — toggle audio
|
||||
- `q` / `esc` — quit
|
||||
|
||||
## Shaders
|
||||
|
||||
@@ -15,6 +15,7 @@ ash-window = "0.13"
|
||||
raw-window-handle = "0.6"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
rodio = { version = "0.20", default-features = false, features = ["wav"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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,4 +1,5 @@
|
||||
pub mod ai;
|
||||
pub mod audio;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod input;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user