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:
@@ -0,0 +1,125 @@
|
||||
//! Ambient-Emitter aus Blender-Modellen: Empties mit `sound`-Custom-Property
|
||||
//! sind positionale Dauerschleifen — `sound = <name>` löst zu
|
||||
//! `assets/sounds/{name}.wav` auf (macht der Frontend-Treiber), `radius`
|
||||
//! (Meter, optional) bestimmt die Distance ramp.
|
||||
//!
|
||||
//! Headless wie `trigger`: hier lebt nur die *Logik* — wer sendet wo, und
|
||||
//! wie laut ist das gerade für den Hörer ([`Emitters::gains`], lineare
|
||||
//! Rampe [`distance_gain`]). Gerätezugriff und Mixing sind Sache des
|
||||
//! Frontend-Treibers (render::audio), der die Gains jeden Frame zieht und
|
||||
//! auf seine Voices legt — die Engine ruft nie ins Frontend.
|
||||
//!
|
||||
//! Einmal-SFX laufen nicht hier, sondern über den Signal-Dispatcher
|
||||
//! (`play_sound <name>` → `Action::PlaySound` → `Session::take_sounds`).
|
||||
|
||||
use crate::engine::model::Model;
|
||||
|
||||
/// Custom-Property-Schlüssel, der ein Empty zum Ambient-Emitter macht
|
||||
/// (Wert = Sound-Name). Emitter loopen immer; „an/aus" macht die Autorin
|
||||
/// über Distanz/Radius oder (später) per Signal.
|
||||
pub const SOUND_PROP: &str = "sound";
|
||||
/// Optionaler Hörradius in Metern (Custom Property am selben Empty).
|
||||
pub const RADIUS_PROP: &str = "radius";
|
||||
const DEFAULT_RADIUS: f32 = 8.0;
|
||||
|
||||
pub struct Emitter {
|
||||
pub sound: String,
|
||||
pos: [f32; 3],
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Emitters {
|
||||
list: Vec<Emitter>,
|
||||
}
|
||||
|
||||
impl Emitters {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
/// Emitter eines Modells übernehmen: alle Empties mit `sound`-Property.
|
||||
pub fn add_model(&mut self, model: &Model) {
|
||||
for e in &model.empties {
|
||||
let Some(sound) = e.props.get(SOUND_PROP) else { continue; };
|
||||
let radius = e.props.get(RADIUS_PROP)
|
||||
.and_then(|r| r.parse::<f32>().ok())
|
||||
.filter(|r| *r > 0.0)
|
||||
.unwrap_or(DEFAULT_RADIUS);
|
||||
self.list.push(Emitter { sound: sound.clone(), pos: e.pos, radius });
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Emitter in fester Reihenfolge (Voice-Zuordnung des Treibers).
|
||||
pub fn list(&self) -> &[Emitter] { &self.list }
|
||||
|
||||
/// Aktueller Gain je Emitter (parallel zu [`Emitters::list`]) für einen
|
||||
/// Hörer bei `listener` — jeden Frame ziehen, auf die Voices legen.
|
||||
pub fn gains(&self, listener: [f32; 3]) -> Vec<f32> {
|
||||
self.list.iter().map(|e| {
|
||||
let d = [
|
||||
e.pos[0] - listener[0],
|
||||
e.pos[1] - listener[1],
|
||||
e.pos[2] - listener[2],
|
||||
];
|
||||
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
distance_gain(dist, e.radius)
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lineare Distance ramp: volle Lautstärke am Emitter, Stille ab `radius`.
|
||||
/// Bewusst linear statt 1/d² — bei Zimmer-Skalen klingt die gerade Rampe
|
||||
/// kontrollierbarer, und die Autorin kann per `radius` direkt denken.
|
||||
pub fn distance_gain(dist: f32, radius: f32) -> f32 {
|
||||
(1.0 - dist / radius).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::model::{Empty, Props};
|
||||
|
||||
fn model(empties: Vec<Empty>) -> Model {
|
||||
Model { objects: Vec::new(), empties, materials: Vec::new() }
|
||||
}
|
||||
|
||||
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
|
||||
let mut p = Props::new();
|
||||
for (k, v) in props { p.insert((*k).into(), (*v).into()); }
|
||||
Empty { name: name.into(), pos, props: p }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sound_empties_with_radius() {
|
||||
let mut em = Emitters::new();
|
||||
em.add_model(&model(vec![
|
||||
empty("Radio", [5.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
|
||||
empty("spawn", [0.0, 0.0, 0.0], &[("role", "spawn")]),
|
||||
empty("Bach", [9.0, 0.0, 0.0], &[("sound", "wasser")]),
|
||||
]));
|
||||
assert_eq!(em.list().len(), 2, "nur Empties mit sound-Property");
|
||||
assert_eq!(em.list()[0].sound, "hum");
|
||||
assert_eq!(em.list()[1].radius, DEFAULT_RADIUS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gains_ramp_linearly_to_radius() {
|
||||
let mut em = Emitters::new();
|
||||
em.add_model(&model(vec![
|
||||
empty("Radio", [0.0, 0.0, 0.0], &[("sound", "hum"), ("radius", "4")]),
|
||||
]));
|
||||
assert_eq!(em.gains([0.0, 0.0, 0.0]), vec![1.0]);
|
||||
assert_eq!(em.gains([2.0, 0.0, 0.0]), vec![0.5]);
|
||||
assert_eq!(em.gains([4.0, 0.0, 0.0]), vec![0.0]);
|
||||
assert_eq!(em.gains([9.0, 0.0, 0.0]), vec![0.0], "hinter dem Radius still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_radius_falls_back_to_default() {
|
||||
let mut em = Emitters::new();
|
||||
em.add_model(&model(vec![
|
||||
empty("A", [0.0; 3], &[("sound", "x"), ("radius", "kaputt")]),
|
||||
empty("B", [0.0; 3], &[("sound", "y"), ("radius", "-2")]),
|
||||
]));
|
||||
assert!(em.list().iter().all(|e| e.radius == DEFAULT_RADIUS));
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -16,17 +16,20 @@
|
||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||
//! signals::dispatch zu rufen).
|
||||
//!
|
||||
//! `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
|
||||
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten, hängen
|
||||
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||
//! Frontend per Pull konsumiert. `gltf` ist DER 3D-Pfad (Blender-first:
|
||||
//! Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||
//! einem Export) und produziert das neutrale `model::Model`.
|
||||
//! einem Export) und produziert das neutrale `model::Model`. `audio` ist
|
||||
//! die headless Emitter-Logik (Distance ramps) zum Frontend-Treiber
|
||||
//! render::audio.
|
||||
//!
|
||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
||||
//! ableitet; `collision` (hängt an `model`) liefert ihr die Welt für den
|
||||
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||
|
||||
pub mod assets;
|
||||
pub mod audio;
|
||||
pub mod collision;
|
||||
pub mod game;
|
||||
pub mod gltf;
|
||||
@@ -38,3 +41,4 @@ pub mod signals;
|
||||
pub mod story_ctrl;
|
||||
pub mod tga;
|
||||
pub mod trigger;
|
||||
pub mod wav;
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
|
||||
//! `clear <name>` KV-Eintrag entfernen
|
||||
//! `hide_object <name>` → Action::HideObject (deferred)
|
||||
//! `play_sound <file>` → Action::PlaySound (deferred)
|
||||
//! `play_sound <name>` → Action::PlaySound (deferred): SFX aus
|
||||
//! `assets/sounds/{name}.wav`
|
||||
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
||||
//! Eingabemodus wechseln
|
||||
//!
|
||||
@@ -182,7 +183,7 @@ mod tests {
|
||||
let mut signals = Signals::new();
|
||||
signals.insert("pickup".into(), vec![
|
||||
"inc items".into(),
|
||||
"play_sound pickup.wav".into(),
|
||||
"play_sound pickup".into(),
|
||||
"hide_object $self".into(),
|
||||
]);
|
||||
let mut game = Game::new(signals);
|
||||
@@ -192,7 +193,7 @@ mod tests {
|
||||
|
||||
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
|
||||
assert_eq!(game.actions, vec![
|
||||
Action::PlaySound("pickup.wav".into()),
|
||||
Action::PlaySound("pickup".into()),
|
||||
Action::HideObject("Mushroom.005".into()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Minimaler WAV-Decoder (Blender-/DAW-Export) → Mono-Samples.
|
||||
//!
|
||||
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/wav.rs), auf die
|
||||
//! WDS-Loader-Regeln gebracht: reiner Decoder (Bytes → owned Daten),
|
||||
//! `Result` statt Panic — Sounds sind Autorinnen-Content, ein kaputtes
|
||||
//! File wird gemeldet und bleibt still, nie fatal.
|
||||
//!
|
||||
//! Akzeptiert ausschließlich PCM (`format = 1`), 1 Kanal, 16 bit, 44100 Hz,
|
||||
//! little-endian — das feste Format des Mixers (render::audio). „Als WAV
|
||||
//! mono 16-bit 44,1 kHz exportieren" ist die ganze Anleitung; alles andere
|
||||
//! ist eine klare Fehlermeldung mit Pfad. Über unbekannte RIFF-Chunks
|
||||
//! (`LIST`, `INFO`, `bext`, …) wird hinweggesprungen.
|
||||
|
||||
/// Sample-Rate des gesamten Audio-Pfads (Decoder-Kontrakt und Mixer-Takt).
|
||||
pub const SAMPLE_RATE: u32 = 44100;
|
||||
|
||||
pub struct Wav {
|
||||
pub samples: Vec<i16>,
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Result<Wav, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("wav load {path}: {e}"))?;
|
||||
parse(&bytes).map_err(|e| format!("wav {path}: {e}"))
|
||||
}
|
||||
|
||||
pub fn parse(data: &[u8]) -> Result<Wav, String> {
|
||||
if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WAVE" {
|
||||
return Err("kein RIFF/WAVE".into());
|
||||
}
|
||||
|
||||
let mut fmt_found = false;
|
||||
let mut samples: Option<Vec<i16>> = None;
|
||||
|
||||
let mut i = 12;
|
||||
while i + 8 <= data.len() {
|
||||
let id = &data[i..i + 4];
|
||||
let size = u32::from_le_bytes(data[i + 4..i + 8].try_into().unwrap()) as usize;
|
||||
let body = i + 8;
|
||||
let end = body + size;
|
||||
if end > data.len() { return Err("Chunk länger als Datei".into()); }
|
||||
match id {
|
||||
b"fmt " => {
|
||||
if size < 16 { return Err("fmt-Chunk zu kurz".into()); }
|
||||
let format = u16::from_le_bytes(data[body..body + 2].try_into().unwrap());
|
||||
let channels = u16::from_le_bytes(data[body + 2..body + 4].try_into().unwrap());
|
||||
let rate = u32::from_le_bytes(data[body + 4..body + 8].try_into().unwrap());
|
||||
let bits = u16::from_le_bytes(data[body + 14..body + 16].try_into().unwrap());
|
||||
if format != 1 || channels != 1 || bits != 16 || rate != SAMPLE_RATE {
|
||||
return Err(format!(
|
||||
"brauche PCM mono 16-bit {SAMPLE_RATE} Hz, ist format={format} \
|
||||
channels={channels} bits={bits} rate={rate}"));
|
||||
}
|
||||
fmt_found = true;
|
||||
}
|
||||
b"data" => {
|
||||
if !fmt_found { return Err("data-Chunk vor fmt".into()); }
|
||||
samples = Some(data[body..end].chunks_exact(2)
|
||||
.map(|c| i16::from_le_bytes([c[0], c[1]]))
|
||||
.collect());
|
||||
}
|
||||
_ => {} // LIST, INFO, bext, … überspringen
|
||||
}
|
||||
// Chunks sind word-aligned (Padding-Byte bei ungerader Größe).
|
||||
i = end + (size & 1);
|
||||
}
|
||||
|
||||
match samples {
|
||||
Some(samples) if !samples.is_empty() => Ok(Wav { samples }),
|
||||
Some(_) => Err("data-Chunk leer".into()),
|
||||
None => Err("kein data-Chunk".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimale WAV-Datei bauen, wie ein Exporter sie schriebe.
|
||||
fn wav_bytes(format: u16, channels: u16, rate: u32, bits: u16, samples: &[i16]) -> Vec<u8> {
|
||||
let data_len = samples.len() * 2;
|
||||
let mut out = Vec::new();
|
||||
out.extend(b"RIFF");
|
||||
out.extend(((4 + 8 + 16 + 8 + data_len) as u32).to_le_bytes());
|
||||
out.extend(b"WAVE");
|
||||
out.extend(b"fmt ");
|
||||
out.extend(16u32.to_le_bytes());
|
||||
out.extend(format.to_le_bytes());
|
||||
out.extend(channels.to_le_bytes());
|
||||
out.extend(rate.to_le_bytes());
|
||||
out.extend((rate * 2).to_le_bytes()); // byte rate
|
||||
out.extend(2u16.to_le_bytes()); // block align
|
||||
out.extend(bits.to_le_bytes());
|
||||
out.extend(b"data");
|
||||
out.extend((data_len as u32).to_le_bytes());
|
||||
for s in samples { out.extend(s.to_le_bytes()); }
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_pcm_mono_16bit() {
|
||||
let w = parse(&wav_bytes(1, 1, SAMPLE_RATE, 16, &[0, 1000, -1000])).unwrap();
|
||||
assert_eq!(w.samples, vec![0, 1000, -1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_unknown_chunks() {
|
||||
// LIST-Chunk zwischen fmt und data.
|
||||
let base = wav_bytes(1, 1, SAMPLE_RATE, 16, &[7]);
|
||||
let mut out = base[..36].to_vec(); // bis inkl. fmt
|
||||
out.extend(b"LIST");
|
||||
out.extend(4u32.to_le_bytes());
|
||||
out.extend(b"INFO");
|
||||
out.extend(&base[36..]); // data-Chunk
|
||||
let len = (out.len() - 8) as u32;
|
||||
out[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert_eq!(parse(&out).unwrap().samples, vec![7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_formats() {
|
||||
assert!(parse(b"OggS...nope").is_err());
|
||||
assert!(parse(&wav_bytes(1, 2, SAMPLE_RATE, 16, &[0])).is_err(), "stereo");
|
||||
assert!(parse(&wav_bytes(1, 1, 22050, 16, &[0])).is_err(), "falsche Rate");
|
||||
assert!(parse(&wav_bytes(1, 1, SAMPLE_RATE, 8, &[0])).is_err(), "8 bit");
|
||||
assert!(parse(&wav_bytes(3, 1, SAMPLE_RATE, 16, &[0])).is_err(), "float-PCM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_file_is_err() {
|
||||
assert!(load("/nonexistent/x.wav").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Audio-Treiber auf tinyaudio — Frontend-Gegenstück zur headless
|
||||
//! Emitter-Logik in engine::audio (wie `Gpu` zu `scene`: die Engine kennt
|
||||
//! dieses Modul nie).
|
||||
//!
|
||||
//! Eingebettete Fassung des irl3d-Mixers (../irl3d/src/audio.rs), auf die
|
||||
//! WDS-Regeln gebracht: kein Panic — kein Gerät oder fehlende/kaputte
|
||||
//! WAVs sind eine Meldung und Stille, nie fatal ([`Audio::open`] liefert
|
||||
//! `None`, alle Aufrufe darauf entfallen still beim Aufrufer).
|
||||
//!
|
||||
//! Mono, 44100 Hz (fest, siehe engine::wav). Zwei Voice-Klassen:
|
||||
//! - 8 SFX-Voices: einmal durchlaufen, dann geräumt; sind alle belegt,
|
||||
//! wird die am weitesten gespielte verdrängt (`play`).
|
||||
//! - Ambient-Voices: eine je Emitter, loopen immer; ihr Gain ist die
|
||||
//! Distance ramp, die das Frontend jeden Frame aus
|
||||
//! `Session::emitter_gains` zieht ([`Audio::set_ambient_gains`]).
|
||||
//!
|
||||
//! Gemischt wird im Callback-Thread in f32 über alle aktiven Voices
|
||||
//! (Sample × Gain), am Ende auf [-1, 1] geklemmt. Der Mixer hängt hinter
|
||||
//! einem Mutex; Frontend-Seite fasst ihn nur kurz pro Frame/SFX an.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tinyaudio::{run_output_device, OutputDevice, OutputDeviceParameters};
|
||||
|
||||
use crate::engine::wav;
|
||||
|
||||
const SFX_VOICES: usize = 8;
|
||||
/// Callback-Puffergröße in Samples (~6 ms bei 44,1 kHz).
|
||||
const BUFFER_LEN: usize = 256;
|
||||
|
||||
/// Dekodierte Mono-Samples, geteilt zwischen Cache und laufenden Voices.
|
||||
#[derive(Clone)]
|
||||
pub struct Sound {
|
||||
samples: Arc<Vec<i16>>,
|
||||
}
|
||||
|
||||
impl Sound {
|
||||
pub fn new(w: wav::Wav) -> Self {
|
||||
Sound { samples: Arc::new(w.samples) }
|
||||
}
|
||||
}
|
||||
|
||||
struct Voice {
|
||||
samples: Arc<Vec<i16>>,
|
||||
cursor: usize,
|
||||
gain: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
sfx: Vec<Option<Voice>>,
|
||||
/// Loop-Voices der Ambient-Emitter, Index = Emitter-Reihenfolge.
|
||||
ambient: Vec<Voice>,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
fn mix(&mut self, out: &mut [f32]) {
|
||||
for s in out.iter_mut() {
|
||||
let mut acc = 0.0f32;
|
||||
for slot in self.sfx.iter_mut() {
|
||||
if let Some(v) = slot {
|
||||
acc += v.samples[v.cursor] as f32 * v.gain;
|
||||
v.cursor += 1;
|
||||
if v.cursor >= v.samples.len() { *slot = None; }
|
||||
}
|
||||
}
|
||||
for v in self.ambient.iter_mut() {
|
||||
// Auch bei Gain 0 weiterlaufen lassen: ein Emitter, in dessen
|
||||
// Radius man zurückkehrt, spielt mitten in seiner Schleife —
|
||||
// nicht jedes Mal von vorn.
|
||||
acc += v.samples[v.cursor] as f32 * v.gain;
|
||||
v.cursor = (v.cursor + 1) % v.samples.len();
|
||||
}
|
||||
*s = (acc / 32768.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
_device: OutputDevice, // lebt, solange Audio lebt
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
/// Ausgabegerät öffnen. `None`, wenn keins da ist (Meldung, Spiel läuft
|
||||
/// stumm weiter).
|
||||
pub fn open() -> Option<Self> {
|
||||
// pipewire-pulse/PulseAudio respektiert diese Variable als
|
||||
// Ziel-Latenz des Streams (Default ~50–200 ms) — herabsetzen für
|
||||
// snappige SFX. Muss vor `run_output_device` gesetzt sein; auf
|
||||
// Windows/macOS ignoriert.
|
||||
// SAFETY: einmalig beim Start, bevor weitere Threads laufen (wird
|
||||
// in render::run vor dem stdin-Thread gerufen).
|
||||
unsafe { std::env::set_var("PULSE_LATENCY_MSEC", "15"); }
|
||||
|
||||
let mixer = Arc::new(Mutex::new(Mixer {
|
||||
sfx: (0..SFX_VOICES).map(|_| None).collect(),
|
||||
ambient: Vec::new(),
|
||||
}));
|
||||
let cb_mixer = Arc::clone(&mixer);
|
||||
let device = run_output_device(
|
||||
OutputDeviceParameters {
|
||||
channels_count: 1,
|
||||
sample_rate: wav::SAMPLE_RATE as usize,
|
||||
channel_sample_count: BUFFER_LEN,
|
||||
},
|
||||
move |buf: &mut [f32]| { cb_mixer.lock().unwrap().mix(buf); },
|
||||
);
|
||||
match device {
|
||||
Ok(device) => Some(Audio { mixer, _device: device }),
|
||||
Err(e) => {
|
||||
eprintln!("[audio] kein Ausgabegerät ({e}) — Spiel läuft stumm");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Einmal-SFX starten: freier Slot, sonst wird die am weitesten
|
||||
/// gespielte Voice verdrängt.
|
||||
pub fn play(&self, s: &Sound) {
|
||||
if s.samples.is_empty() { return; }
|
||||
let mut m = self.mixer.lock().unwrap();
|
||||
let target = m.sfx.iter().enumerate()
|
||||
.max_by_key(|(_, slot)| slot.as_ref().map_or(usize::MAX, |v| v.cursor))
|
||||
.map_or(0, |(i, _)| i);
|
||||
m.sfx[target] = Some(Voice {
|
||||
samples: Arc::clone(&s.samples), cursor: 0, gain: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Die Ambient-Loop-Voices anlegen — einmal beim Start, Reihenfolge =
|
||||
/// `Session::emitters` (die Gains kommen dann parallel dazu, deshalb
|
||||
/// bekommt auch ein nicht ladbarer Sound seine — stille — Voice).
|
||||
pub fn set_ambients(&self, sounds: &[Option<Sound>]) {
|
||||
let mut m = self.mixer.lock().unwrap();
|
||||
m.ambient = sounds.iter().map(|s| Voice {
|
||||
samples: s.as_ref().map_or_else(|| Arc::new(vec![0]), |s| Arc::clone(&s.samples)),
|
||||
cursor: 0,
|
||||
gain: 0.0,
|
||||
}).collect();
|
||||
}
|
||||
|
||||
/// Distance-ramp-Gains auf die Ambient-Voices legen — jeden Frame, aus
|
||||
/// `Session::emitter_gains`.
|
||||
pub fn set_ambient_gains(&self, gains: &[f32]) {
|
||||
let mut m = self.mixer.lock().unwrap();
|
||||
for (v, g) in m.ambient.iter_mut().zip(gains) {
|
||||
v.gain = *g;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-4
@@ -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).
|
||||
|
||||
+47
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user