diff --git a/AGENTS.md b/AGENTS.md index d4898e2..3a6331d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 6d1a8e2..7b807b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/assets/sounds/acid_sizzle.wav b/assets/sounds/acid_sizzle.wav new file mode 100644 index 0000000..7f1a6e2 Binary files /dev/null and b/assets/sounds/acid_sizzle.wav differ diff --git a/assets/sounds/death.wav b/assets/sounds/death.wav new file mode 100644 index 0000000..bb07dd1 Binary files /dev/null and b/assets/sounds/death.wav differ diff --git a/assets/sounds/descend.wav b/assets/sounds/descend.wav new file mode 100644 index 0000000..1022f3c Binary files /dev/null and b/assets/sounds/descend.wav differ diff --git a/assets/sounds/explosion.wav b/assets/sounds/explosion.wav new file mode 100644 index 0000000..93881c7 Binary files /dev/null and b/assets/sounds/explosion.wav differ diff --git a/assets/sounds/fire_crackle.wav b/assets/sounds/fire_crackle.wav new file mode 100644 index 0000000..66b2634 Binary files /dev/null and b/assets/sounds/fire_crackle.wav differ diff --git a/assets/sounds/goblin_growl.wav b/assets/sounds/goblin_growl.wav new file mode 100644 index 0000000..888232d Binary files /dev/null and b/assets/sounds/goblin_growl.wav differ diff --git a/assets/sounds/hit.wav b/assets/sounds/hit.wav new file mode 100644 index 0000000..1954f4a Binary files /dev/null and b/assets/sounds/hit.wav differ diff --git a/assets/sounds/jump.wav b/assets/sounds/jump.wav new file mode 100644 index 0000000..e677ca1 Binary files /dev/null and b/assets/sounds/jump.wav differ diff --git a/assets/sounds/lava_bubble.wav b/assets/sounds/lava_bubble.wav new file mode 100644 index 0000000..d6a707c Binary files /dev/null and b/assets/sounds/lava_bubble.wav differ diff --git a/assets/sounds/pickup.wav b/assets/sounds/pickup.wav new file mode 100644 index 0000000..8a05ed4 Binary files /dev/null and b/assets/sounds/pickup.wav differ diff --git a/assets/sounds/powerup.wav b/assets/sounds/powerup.wav new file mode 100644 index 0000000..2b1fb85 Binary files /dev/null and b/assets/sounds/powerup.wav differ diff --git a/assets/sounds/shoot.wav b/assets/sounds/shoot.wav new file mode 100644 index 0000000..63991f8 Binary files /dev/null and b/assets/sounds/shoot.wav differ diff --git a/assets/sounds/step.wav b/assets/sounds/step.wav new file mode 100644 index 0000000..7c693ec Binary files /dev/null and b/assets/sounds/step.wav differ diff --git a/assets/sounds/ui_click.wav b/assets/sounds/ui_click.wav new file mode 100644 index 0000000..2ced429 Binary files /dev/null and b/assets/sounds/ui_click.wav differ diff --git a/assets/sounds/water_splash.wav b/assets/sounds/water_splash.wav new file mode 100644 index 0000000..bbf0979 Binary files /dev/null and b/assets/sounds/water_splash.wav differ diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..598ca22 --- /dev/null +++ b/src/audio/mod.rs @@ -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, + sink: Option, + 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::(); + 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() + } +} diff --git a/src/game.rs b/src/game.rs index 43b966b..01f6a1b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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"); } } } diff --git a/src/lib.rs b/src/lib.rs index 4eb2da8..773d52a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod ai; +pub mod audio; pub mod entity; pub mod game; pub mod input; diff --git a/src/main.rs b/src/main.rs index 2267258..5d6e61a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,8 +295,15 @@ fn run_gpu_mode(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); } diff --git a/src/render/window_input.rs b/src/render/window_input.rs index 92fe640..532222e 100644 --- a/src/render/window_input.rs +++ b/src/render/window_input.rs @@ -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, 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, } @@ -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);