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:08:13 +02:00
parent 56d92cdb46
commit 31e1942870
16 changed files with 796 additions and 21 deletions
+47 -5
View File
@@ -1,3 +1,4 @@
use crate::engine::audio::Emitters;
use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
@@ -43,6 +44,13 @@ pub struct Session {
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
/// (`signal`-Property, siehe engine::trigger).
triggers: Triggers,
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
/// Empties, siehe engine::audio).
emitters: Emitters,
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
/// zieht sie per [`Session::take_sounds`] und spielt sie; die CLI lässt
/// sie liegen (ihr reicht die `[action]`-Textzeile).
pending_sounds: Vec<String>,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
@@ -87,6 +95,8 @@ impl Session {
player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
emitters: Emitters::new(),
pending_sounds: Vec::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
@@ -96,13 +106,14 @@ impl Session {
/// Sim-Seite eines Blender-Modells in die Welt einfügen — einmal pro
/// Modell vom Fenster-Frontend beim Start gerufen: Collider (`collide`-
/// Property → konvexe Hülle), Trigger (`signal`-Property → Klick-Ziele/
/// Zonen) und der Spieler-Spawn (Empty mit `role = spawn`; bringen
/// mehrere Modelle einen mit, gewinnt der zuletzt geladene). Die
/// Sichtgeometrie desselben Modells ist Sache des Renderers
/// (render::props).
/// Zonen), Ambient-Emitter (`sound`-Property auf Empties) und der
/// Spieler-Spawn (Empty mit `role = spawn`; bringen mehrere Modelle
/// einen mit, gewinnt der zuletzt geladene). Die Sichtgeometrie
/// desselben Modells ist Sache des Renderers (render::props).
pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model);
self.triggers.add_model(model);
self.emitters.add_model(model);
for e in &model.empties {
if e.props.get(model::ROLE_PROP).map(String::as_str) == Some(model::ROLE_SPAWN) {
self.player = Player::new(e.pos);
@@ -110,6 +121,25 @@ impl Session {
}
}
/// Die Ambient-Emitter in fester Reihenfolge — der Audio-Treiber legt
/// beim Start je Emitter eine Loop-Voice an.
pub fn emitters(&self) -> &[crate::engine::audio::Emitter] {
self.emitters.list()
}
/// Aktuelle Emitter-Gains (parallel zu [`Session::emitters`]) für die
/// Ohrposition des Spielers — jeden Frame vom Audio-Treiber gezogen.
pub fn emitter_gains(&self) -> Vec<f32> {
let p = self.player.pos;
self.emitters.gains([p[0], p[1] + player::EYE_HEIGHT, p[2]])
}
/// Aufgelaufene Einmal-SFX (`play_sound`-Actions) abholen — jeden Frame
/// vom Fenster-Frontend gerufen, das sie an den Audio-Treiber gibt.
pub fn take_sounds(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_sounds)
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
@@ -328,7 +358,10 @@ impl Session {
for a in actions {
match a {
Action::HideObject(n) => out.push(format!("[action] hide_object {n}")),
Action::PlaySound(n) => out.push(format!("[action] play_sound {n}")),
Action::PlaySound(n) => {
out.push(format!("[action] play_sound {n}"));
self.pending_sounds.push(n);
}
Action::SetMode(t) => {
self.set_mode(t);
out.push(format!("[action] mode {}", t.label()));
@@ -561,6 +594,15 @@ mod tests {
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
}
#[test]
fn play_sound_action_queues_for_frontend() {
let mut s = empty_session();
// Fall-through-Builtin über den Signal-Trichter, wie ein Ink-Tag.
s.exec("signal play_sound klick");
assert_eq!(s.take_sounds(), vec!["klick".to_string()]);
assert!(s.take_sounds().is_empty(), "take leert die Queue");
}
#[test]
fn spawn_empty_places_player() {
let mut s = empty_session();