Audio: WAV + tinyaudio, Ambient-Emitter mit Distance ramps; artist.md

engine/wav.rs (Decoder) und render/audio.rs (Mixer, 8 SFX-Voices +
Loop-Voice je Emitter) aus irl3d eingebettet; Emitter-Logik headless in
engine/audio.rs. Empties mit sound/radius-Property senden positional,
play_sound-Signale laufen als SFX über Session::take_sounds.
Platzhalter-Sounds + Emitter in der Testszene; artist.md fasst die
Content-Regeln für die Autorin zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:37:18 +02:00
co-authored by Claude Fable 5
parent 56d92cdb46
commit 31e1942870
16 changed files with 796 additions and 21 deletions
+51 -4
View File
@@ -14,6 +14,7 @@
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
//! deren Schnittstellen — nie umgekehrt.
mod audio;
mod camera;
mod font;
mod gpu;
@@ -23,6 +24,7 @@ mod scene;
mod sprite;
mod ui;
use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::time::Instant;
@@ -35,7 +37,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, gltf, model, tga};
use crate::engine::{assets, gltf, model, tga, wav};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
@@ -79,10 +81,22 @@ pub fn run(mut session: Session) {
println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
// Sim-Welt (Collider, Trigger, Spawn-Empty) in die Session einspielen —
// die Simulation gehört der Session, nicht dem Render-Frontend.
// Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
// einspielen — die Simulation gehört der Session, nicht dem
// Render-Frontend.
for (_, m) in &models { session.load_props(m); }
// Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
// Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
// dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
let audio = audio::Audio::open();
if let Some(a) = &audio {
let sounds: Vec<Option<audio::Sound>> = session.emitters().iter()
.map(|e| load_sound(&e.sound))
.collect();
a.set_ambients(&sounds);
}
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
@@ -126,7 +140,7 @@ pub fn run(mut session: Session) {
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
// pollen nebenbei den Befehls-Channel.
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts);
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio);
event_loop.run_app(&mut app).expect("winit: run");
}
@@ -170,6 +184,18 @@ fn load_models(dir: &str) -> Vec<(String, model::Model)> {
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
fn load_sound(name: &str) -> Option<audio::Sound> {
match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) {
Ok(w) => Some(audio::Sound::new(w)),
Err(e) => {
eprintln!("[audio] {e}");
None
}
}
}
fn load_texture(name: &str) -> Image {
let path = assets::path(&format!("assets/textures/{name}.tga"));
if std::path::Path::new(&path).exists() {
@@ -220,6 +246,11 @@ struct App {
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
/// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der
/// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal
/// melden, nicht jeden Klick).
audio: Option<audio::Audio>,
sfx_cache: HashMap<String, Option<audio::Sound>>,
}
impl App {
@@ -230,6 +261,7 @@ impl App {
images: Vec<Image>,
ui_textures: Vec<Image>,
fonts: font::Fonts,
audio: Option<audio::Audio>,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
@@ -249,6 +281,8 @@ impl App {
images,
ui_textures,
fonts,
audio,
sfx_cache: HashMap::new(),
}
}
@@ -412,6 +446,19 @@ impl App {
self.input.mouse_dy = 0.0;
self.input.jump = false;
// Audio: Distance ramps der Ambient-Emitter nachführen und
// aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue
// immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt.
let sfx = self.session.take_sounds();
if let Some(a) = &self.audio {
a.set_ambient_gains(&self.session.emitter_gains());
for name in sfx {
let sound = self.sfx_cache.entry(name.clone())
.or_insert_with(|| load_sound(&name));
if let Some(s) = sound { a.play(s); }
}
}
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).