Objekte können jetzt Sounds haben (z.B. Lagerfeuer). Sound System optimiert

This commit is contained in:
2026-09-14 16:09:21 +02:00
parent 08c76a798d
commit 45f7ee1045
42 changed files with 1721 additions and 109 deletions

View File

@@ -0,0 +1,179 @@
package de.blight.game.audio;
import com.jme3.asset.AssetManager;
import com.jme3.audio.AudioBuffer;
import com.jme3.audio.AudioData;
import com.jme3.audio.AudioKey;
import com.jme3.audio.AudioNode;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Node;
import com.jme3.util.BufferUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
/**
* Zentrale Abstraktion für positionalen Stereo-Klang (L/R-Panning).
*
* Strategie (identisch zu OceanSoundState):
* Zwei positionale AudioNodes (L + R) bleiben immer PAN_DIST Meter links/rechts
* der Kamera. Das Lautstärkeverhältnis (constant-power Panning) simuliert die
* wahrgenommene Richtung. Distanz-Fading wird vom Aufrufer übergeben.
*
* Stereo-Dateien werden beim Laden automatisch auf Mono downgemixet (L+R average),
* damit setPositional(true) immer funktioniert – transparent für den Aufrufer.
*
* Typische Verwendung:
* <pre>
* StereoSource src = StereoSource.tryLoad(assets, "audio/birds.ogg");
* // pro Frame:
* if (inRange && !src.isAttached()) src.attachAndPlay(rootNode);
* if (src.isAttached()) src.update(cam, soundWorldPos, playerPos, volume);
* if (!inRange && src.isAttached()) src.stop(rootNode);
* </pre>
*/
public final class StereoSource {
private static final Logger log = LoggerFactory.getLogger(StereoSource.class);
/** L/R-Quellen PAN_DIST Meter neben der Kamera — muss < refDistance (10 m JME3-Default) sein. */
public static final float PAN_DIST = 2f;
private final AudioNode nodeL;
private final AudioNode nodeR;
private boolean attached = false;
// ── Erzeugung ─────────────────────────────────────────────────────────────
/**
* Lädt die Audiodatei, downmixed Stereo automatisch zu Mono und erstellt
* ein L/R-Paar. Gibt {@code null} zurück wenn die Datei nicht ladbar ist.
*/
public static StereoSource tryLoad(AssetManager assets, String path) {
try {
AudioNode l = buildPositionalNode(assets, path);
AudioNode r = buildPositionalNode(assets, path);
return new StereoSource(l, r);
} catch (Exception e) {
log.warn("[StereoSource] Nicht ladbar '{}': {}", path, e.getMessage());
return null;
}
}
private StereoSource(AudioNode nodeL, AudioNode nodeR) {
this.nodeL = nodeL;
this.nodeR = nodeR;
}
private static AudioNode buildPositionalNode(AssetManager assets, String path) {
AudioKey key = new AudioKey(path, false, true); // Standard-Key (stream=false, streamCache=true)
AudioData ad = (AudioData) assets.loadAsset(key);
if (ad instanceof AudioBuffer ab && ab.getChannels() == 2) {
ad = downmixToMono(ab);
log.debug("[StereoSource] '{}' downgemixed Stereo→Mono für Positional-Audio.", path);
}
AudioNode n = new AudioNode(ad, key);
n.setLooping(true);
n.setVolume(0f);
n.setPositional(true);
return n;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
public void attachAndPlay(Node rootNode) {
if (attached) return;
rootNode.attachChild(nodeL);
rootNode.attachChild(nodeR);
nodeL.play();
nodeR.play();
attached = true;
}
public void stop(Node rootNode) {
if (!attached) return;
nodeL.stop();
nodeR.stop();
if (nodeL.getParent() != null) rootNode.detachChild(nodeL);
if (nodeR.getParent() != null) rootNode.detachChild(nodeR);
attached = false;
}
// ── Update ────────────────────────────────────────────────────────────────
/**
* Aktualisiert Position und Lautstärke der L/R-Nodes.
*
* @param cam aktuelle Kamera
* @param soundPos Weltposition der Sound-Quelle
* @param playerPos Weltposition des Spielers
* @param volume Endlautstärke (distanceFactor * baseVol * ambientVol bereits eingerechnet)
*/
public void update(Camera cam, Vector3f soundPos, Vector3f playerPos, float volume) {
Vector3f camLoc = cam.getLocation();
Vector3f camLeft = cam.getLeft();
// L/R-Quellen PAN_DIST Meter neben der Kamera halten
float lx = camLoc.x + camLeft.x * PAN_DIST;
float lz = camLoc.z + camLeft.z * PAN_DIST;
float rx = camLoc.x - camLeft.x * PAN_DIST;
float rz = camLoc.z - camLeft.z * PAN_DIST;
nodeL.setLocalTranslation(lx, camLoc.y, lz);
nodeR.setLocalTranslation(rx, camLoc.y, rz);
// Pan = Dot(Richtung Spieler→Quelle, Kamera-Rechts) = −Dot mit camLeft
float dx = soundPos.x - playerPos.x;
float dz = soundPos.z - playerPos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
float pan = 0f;
if (len > 0.1f) {
dx /= len; dz /= len;
pan = Math.max(-1f, Math.min(1f, -(dx * camLeft.x + dz * camLeft.z)));
}
// Constant-power Panning
float panL = (float) Math.sqrt((1f - pan) / 2f);
float panR = (float) Math.sqrt((1f + pan) / 2f);
nodeL.setVolume(volume * panL);
nodeR.setVolume(volume * panR);
}
public boolean isAttached() { return attached; }
// ── Stereo → Mono Downmix ────────────────────────────────────────────────
private static AudioBuffer downmixToMono(AudioBuffer stereo) {
ByteBuffer src = stereo.getData().duplicate().order(ByteOrder.LITTLE_ENDIAN);
src.rewind();
int bps = stereo.getBitsPerSample();
int rate = stereo.getSampleRate();
ByteBuffer dst;
if (bps == 16) {
ShortBuffer inS = src.asShortBuffer();
int monoSamples = inS.limit() / 2;
dst = BufferUtils.createByteBuffer(monoSamples * 2);
ShortBuffer outS = dst.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer();
for (int i = 0; i < monoSamples; i++) {
outS.put((short) ((inS.get() + inS.get()) >> 1));
}
} else {
int monoSamples = src.limit() / 2;
dst = BufferUtils.createByteBuffer(monoSamples);
for (int i = 0; i < monoSamples; i++) {
dst.put((byte) (((src.get() & 0xFF) + (src.get() & 0xFF)) / 2));
}
}
dst.rewind();
AudioBuffer mono = new AudioBuffer();
mono.setupFormat(1, bps, rate);
mono.updateData(dst);
return mono;
}
}

View File

@@ -105,6 +105,8 @@ public class WorldScene extends BaseAppState {
private de.blight.game.state.AmbientSoundSystem ambientSounds;
private de.blight.game.state.MusicSystem musicSystem;
private de.blight.game.state.EmitterSystem emitterSystem;
private de.blight.game.state.WaterfallState waterfallState;
private de.blight.game.state.PlacedSoundSystem placedSoundSystem;
private de.blight.game.audio.FootstepSystem footstepSystem;
private de.blight.game.state.DrownState drownState;
private WaterInteractionState waterInteractionState;
@@ -277,9 +279,12 @@ public class WorldScene extends BaseAppState {
BlightGame.status("Lade Welt-Objekte...");
app.getStateManager().attach(new RiverState());
app.getStateManager().attach(new de.blight.game.state.WaterfallState());
waterfallState = new de.blight.game.state.WaterfallState();
app.getStateManager().attach(waterfallState);
emitterSystem = new de.blight.game.state.EmitterSystem();
app.getStateManager().attach(emitterSystem);
placedSoundSystem = new de.blight.game.state.PlacedSoundSystem();
app.getStateManager().attach(placedSoundSystem);
WorldObjectsState worldObjects = new WorldObjectsState();
worldObjects.setLodFactor((float) graphicsSettings.viewDistance.lod0Range);
app.getStateManager().attach(worldObjects);
@@ -470,7 +475,9 @@ public class WorldScene extends BaseAppState {
if (riverSound != null) riverSound.setPlayerPosition(pos);
if (ambientSounds != null) ambientSounds.setPlayerPosition(pos);
if (musicSystem != null) musicSystem.setPlayerPosition(pos);
if (emitterSystem != null) emitterSystem.setPlayerPosition(pos);
if (emitterSystem != null) emitterSystem.setPlayerPosition(pos);
if (placedSoundSystem != null) placedSoundSystem.setPlayerPosition(pos);
if (waterfallState != null) waterfallState.setPlayerPosition(pos);
}
if (musicSystem != null && dayNight != null)

View File

@@ -0,0 +1,112 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import de.blight.common.PlacedSound;
import de.blight.common.SoundIO;
import de.blight.game.audio.StereoSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Lädt platzierte Sound-Quellen (blight_sounds.bls) und spielt sie mit
* korrektem L/R-Panning und Distanz-Fading ab.
* Panning-Logik: {@link StereoSource}.
*/
public class PlacedSoundSystem extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PlacedSoundSystem.class);
private static final float CHECK_INTERVAL = 0.1f;
private SimpleApplication app;
private Node rootNode;
private AudioSettingsState audioSettings;
private final List<PlacedSound> data = new ArrayList<>();
private final List<StereoSource> sources = new ArrayList<>();
private final Vector3f playerPos = new Vector3f();
private float checkTimer = 0f;
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
rootNode = ((SimpleApplication) application).getRootNode();
audioSettings = getStateManager().getState(AudioSettingsState.class);
try {
for (PlacedSound ps : SoundIO.load()) {
if (ps.soundPath() == null || ps.soundPath().isEmpty()) continue;
StereoSource src = StereoSource.tryLoad(app.getAssetManager(), ps.soundPath());
if (src != null) {
data.add(ps);
sources.add(src);
}
}
if (!data.isEmpty()) log.info("[PlacedSoundSystem] {} Sound-Quellen geladen.", data.size());
} catch (IOException e) {
log.warn("[PlacedSoundSystem] blight_sounds.bls nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void cleanup(Application application) {
for (StereoSource src : sources) src.stop(rootNode);
data.clear();
sources.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setPlayerPosition(Vector3f pos) { playerPos.set(pos); }
@Override
public void update(float tpf) {
if (data.isEmpty()) return;
checkTimer += tpf;
if (checkTimer < CHECK_INTERVAL) return;
checkTimer = 0f;
float ambientVol = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f;
Vector3f soundPos = new Vector3f();
for (int i = 0; i < data.size(); i++) {
PlacedSound ps = data.get(i);
StereoSource src = sources.get(i);
float dx = ps.x() - playerPos.x;
float dz = ps.z() - playerPos.z;
float dist = (float) Math.sqrt(dx * dx + dz * dz);
float innerR = ps.radius() * 0.33f;
float distFactor;
if (dist <= innerR) distFactor = 1f;
else if (dist >= ps.radius()) distFactor = 0f;
else distFactor = 1f - (dist - innerR) / (ps.radius() - innerR);
float volume = ps.volume() * ambientVol * distFactor;
if (volume > 0f && !src.isAttached()) {
src.attachAndPlay(rootNode);
}
if (src.isAttached()) {
if (volume <= 0f) {
src.stop(rootNode);
} else {
soundPos.set(ps.x(), ps.y(), ps.z());
src.update(app.getCamera(), soundPos, playerPos, volume);
}
}
}
}
}

View File

@@ -22,6 +22,7 @@ import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedWaterfall;
import de.blight.common.WaterfallIO;
import de.blight.game.audio.StereoSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -38,11 +39,16 @@ public class WaterfallState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WaterfallState.class);
private static final int ROWS = 24; // Tessellierungs-Zeilen vertikal
private static final int COLS = 6; // Tessellierungs-Spalten horizontal
private static final float BULGE_MAX = 1.2f; // Maximale horizontale Vorwölbung am oberen Rand (m)
private static final float V_RANGE = 0.30f; // Anteil der Wasserfallhöhe für den Bogen (0..1)
private static final float SPLASH_HEIGHT = 0.9f; // Höhe des vertikalen Schaum-Vorhangs (m)
private static final int ROWS = 24;
private static final int COLS = 6;
private static final float BULGE_MAX = 1.2f;
private static final float V_RANGE = 0.30f;
private static final float SPLASH_HEIGHT = 0.9f;
private static final String AUDIO_PATH = "audio/ambient/water/waterfall.ogg";
private static final float AUDIO_RADIUS = 25f;
private static final float AUDIO_INNER_R = 8f;
private static final float AUDIO_VOLUME = 0.8f;
private Node rootNode;
private final List<Geometry> geos = new ArrayList<>();
@@ -51,12 +57,18 @@ public class WaterfallState extends BaseAppState {
private final List<Material> foamMaterials = new ArrayList<>();
private final List<ParticleEmitter> mists = new ArrayList<>();
private float time = 0f;
private Texture2D softCircleTex = null; // lazily built, shared by alle Emitter
private Texture2D softCircleTex = null;
private final List<StereoSource> audioSources = new ArrayList<>();
private final List<Vector3f> audioPositions = new ArrayList<>();
private AudioSettingsState audioSettings;
private final Vector3f playerPos = new Vector3f();
@Override
protected void initialize(Application app) {
rootNode = ((SimpleApplication) app).getRootNode();
AssetManager assets = app.getAssetManager();
protected void initialize(Application application) {
rootNode = ((SimpleApplication) application).getRootNode();
AssetManager assets = application.getAssetManager();
audioSettings = getStateManager().getState(AudioSettingsState.class);
List<PlacedWaterfall> list;
try {
@@ -70,6 +82,7 @@ public class WaterfallState extends BaseAppState {
for (PlacedWaterfall wf : list) {
try {
buildWaterfall(wf, assets);
buildAudioSource(wf, assets);
} catch (Exception e) {
log.error("Fehler beim Aufbauen eines Wasserfalls", e);
}
@@ -77,6 +90,28 @@ public class WaterfallState extends BaseAppState {
log.info("{} Wasserfall-Quad(s) geladen.", list.size());
}
private void buildAudioSource(PlacedWaterfall wf, AssetManager assets) {
Vector3f a = new Vector3f(wf.ax(), wf.ay(), wf.az());
Vector3f b = new Vector3f(wf.bx(), wf.by(), wf.bz());
Vector3f c = new Vector3f(wf.cx(), wf.cy(), wf.cz());
Vector3f d = new Vector3f(wf.dx(), wf.dy(), wf.dz());
Vector3f outward = computeOutward(a, b, c, d);
float waterY = (wf.cy() + wf.dy()) * 0.5f;
Vector3f audioPos = c.add(d).multLocal(0.5f).addLocal(outward.mult(BULGE_MAX));
audioPos.y = waterY;
StereoSource src = StereoSource.tryLoad(assets, AUDIO_PATH);
if (src != null) {
// Sofort attachen und bei Vol=0 starten — kein späteres Start/Stop.
// Identisches Muster wie OceanSoundState: immer spielend, nur Lautstärke variiert.
src.attachAndPlay(rootNode);
audioSources.add(src);
audioPositions.add(audioPos);
}
}
public void setPlayerPosition(Vector3f pos) { playerPos.set(pos); }
@Override
public void update(float tpf) {
if (!materials.isEmpty()) {
@@ -89,10 +124,36 @@ public class WaterfallState extends BaseAppState {
foamMaterials.get(i).setColor("Color", new ColorRGBA(1f, 1f, 1f, alpha));
}
}
if (audioSources.isEmpty()) return;
float ambientVol = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f;
for (int i = 0; i < audioSources.size(); i++) {
StereoSource src = audioSources.get(i);
Vector3f apos = audioPositions.get(i);
float dx = apos.x - playerPos.x;
float dz = apos.z - playerPos.z;
float dist = (float) Math.sqrt(dx * dx + dz * dz);
float distFactor;
if (dist <= AUDIO_INNER_R) distFactor = 1f;
else if (dist >= AUDIO_RADIUS) distFactor = 0f;
else distFactor = 1f - (dist - AUDIO_INNER_R) / (AUDIO_RADIUS - AUDIO_INNER_R);
float volume = AUDIO_VOLUME * ambientVol * distFactor;
// Panning + Lautstärke jeden Frame — kein Start/Stop, nur Vol=0 außerhalb
src.update(getApplication().getCamera(), apos, playerPos, volume);
}
}
@Override
protected void cleanup(Application app) {
protected void cleanup(Application application) {
for (StereoSource src : audioSources) src.stop(rootNode);
audioSources.clear();
audioPositions.clear();
for (Geometry g : geos) g.removeFromParent();
for (Geometry g : foams) g.removeFromParent();
for (ParticleEmitter e : mists) e.removeFromParent();