OceanSound: L/R-Panning fix via Dual-Source + constant-power panning

Zwei AudioNodes pro Sound (L + R) fest auf ±90° der Kamera positioniert;
Lautstärkeverhältnis (constant-power) bestimmt die wahrgenommene Richtung.
Behebt HRTF-0°-Stille wenn Kamera aufs Wasser zeigt. Außerdem weitere
Soundsystem- und Editor-Korrekturen aus dieser Session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 22:22:06 +02:00
parent 66d7956a28
commit 4fd337aea4
9 changed files with 272 additions and 101 deletions

View File

@@ -1262,6 +1262,11 @@ public class EditorApp extends Application {
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT); camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY); camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
Button camResetBtn = new Button("⌂ Reset");
camResetBtn.setStyle("-fx-font-weight:bold;");
camResetBtn.setTooltip(new javafx.scene.control.Tooltip("Kamera auf x=0, z=0, y=Terrain+10m zurücksetzen"));
camResetBtn.setOnAction(e -> input.resetCameraRequested.set(true));
Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief"); Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief");
hint.setStyle("-fx-text-fill: #555;"); hint.setStyle("-fx-text-fill: #555;");
@@ -1274,7 +1279,7 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn, new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn, new Separator(Orientation.VERTICAL), voxelBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), hint); new Separator(Orientation.VERTICAL), hint);
worldToolBar = toolBar; worldToolBar = toolBar;
@@ -5094,12 +5099,18 @@ public class EditorApp extends Application {
catch (IOException ignored) {} catch (IOException ignored) {}
} }
/** Konvertiert eine beliebige Audiodatei mit ffmpeg zu OGG Vorbis. /**
* Gibt den Pfad zur erzeugten .ogg-Datei zurück. */ * Konvertiert / normalisiert eine Audiodatei zu OGG Vorbis bei 48000 Hz.
* Das Ziel-Format stimmt mit PipeWire/PulseAudio überein, sodass OpenALSofts
* Spatial-Mixer kein On-the-fly-Resampling durchführen muss (kein Knistern).
*/
private static Path convertToOgg(Path src, Path destOgg) throws IOException { private static Path convertToOgg(Path src, Path destOgg) throws IOException {
try { try {
Process proc = new ProcessBuilder( Process proc = new ProcessBuilder(
"ffmpeg", "-i", src.toString(), "-q:a", "4", destOgg.toString(), "-y") "ffmpeg", "-i", src.toString(),
"-ar", "48000",
"-q:a", "4",
destOgg.toString(), "-y")
.redirectErrorStream(true) .redirectErrorStream(true)
.start(); .start();
proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
@@ -5158,12 +5169,8 @@ public class EditorApp extends Application {
if (isAudio) { if (isAudio) {
String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path destOgg = destDir.resolve(baseName + ".ogg"); Path destOgg = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) { setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
Files.copy(file.toPath(), destOgg, StandardCopyOption.REPLACE_EXISTING); convertToOgg(file.toPath(), destOgg);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), destOgg);
}
String finalName = baseName + ".ogg"; String finalName = baseName + ".ogg";
TreeItem<String> newItem = new TreeItem<>(finalName); TreeItem<String> newItem = new TreeItem<>(finalName);
itemPaths.put(newItem, destOgg); itemPaths.put(newItem, destOgg);
@@ -5716,12 +5723,8 @@ public class EditorApp extends Application {
String name = file.getName().toLowerCase(); String name = file.getName().toLowerCase();
String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path dest = destDir.resolve(baseName + ".ogg"); Path dest = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) { setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
Files.copy(file.toPath(), dest, StandardCopyOption.REPLACE_EXISTING); convertToOgg(file.toPath(), dest);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), dest);
}
TreeItem<String> item = new TreeItem<>(dest.getFileName().toString()); TreeItem<String> item = new TreeItem<>(dest.getFileName().toString());
itemPaths.put(item, dest); itemPaths.put(item, dest);
audioNode.getChildren().add(item); audioNode.getChildren().add(item);

View File

@@ -49,6 +49,10 @@ public class SharedInput {
public static final int CAM_ORBIT = 0; public static final int CAM_ORBIT = 0;
public static final int CAM_FREEFLY = 1; public static final int CAM_FREEFLY = 1;
/** Gesetzt von JavaFX; konsumiert von TerrainEditorState: Kamera auf x=0,z=0,y=terrain+10 zurücksetzen. */
public final java.util.concurrent.atomic.AtomicBoolean resetCameraRequested =
new java.util.concurrent.atomic.AtomicBoolean(false);
// ── Kamerabewegung (WASD + QE) ────────────────────────────────────────── // ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
public volatile boolean forward, backward, left, right, up, down; public volatile boolean forward, backward, left, right, up, down;

View File

@@ -1607,12 +1607,24 @@ public class TerrainEditorState extends BaseAppState {
if (scroll != 0) if (scroll != 0)
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f)); camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
// Kamera-Reset auf x=0,z=0,y=terrain+10
if (input.resetCameraRequested.getAndSet(false)) {
float h = getTerrainHeightFast(0f, 0f);
camPos.set(0f, h + 10f, 0f);
camYaw = 0f;
camPitch = DEFAULT_PITCH;
}
// NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert) // NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert)
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) { if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
camPos.set(0f, DEFAULT_CAM_Y, 0f); camPos.set(0f, DEFAULT_CAM_Y, 0f);
} }
camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y); camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y);
// Kamera nicht unter das Terrain fallen lassen
float terrainFloor = getTerrainHeightFast(camPos.x, camPos.z) + 2f;
if (camPos.y < terrainFloor) camPos.y = terrainFloor;
cam.setLocation(camPos); cam.setLocation(camPos);
} }

View File

@@ -1,7 +1,7 @@
package de.blight.game.audio; package de.blight.game.audio;
public enum SurfaceType { public enum SurfaceType {
GRASS, DIRT, SAND, ROCK, GRAVEL, LEAVES, PAVEMENT, WOOD, UNKNOWN; GRASS, DIRT, SAND, ROCK, GRAVEL, LEAVES, PAVEMENT, WOOD, WATER, UNKNOWN;
public static SurfaceType fromTexturePath(String path) { public static SurfaceType fromTexturePath(String path) {
if (path == null || path.isEmpty()) return UNKNOWN; if (path == null || path.isEmpty()) return UNKNOWN;

View File

@@ -64,9 +64,15 @@ public class PlayerInputControl {
private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot"; private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot";
// Y-Schwelle im Model-Space: Fuß gilt als am Boden wenn Y < FOOT_GROUND_Y // Y-Schwelle im Model-Space: Fuß gilt als am Boden wenn Y < FOOT_GROUND_Y
private static final float FOOT_GROUND_Y = 0.05f; private static final float FOOT_GROUND_Y = 0.05f;
/** Wassertiefe (m) ab der Laufen/Sprinten gesperrt ist und Watgeräusch spielt. */
public static final float WATER_WADING_DEPTH = 0.5f;
private de.blight.game.audio.FootstepSystem footstepSystem; private de.blight.game.audio.FootstepSystem footstepSystem;
private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery; private java.util.function.BiFunction<Float, Float, float[]> surfaceQuery;
private java.util.function.BiFunction<Float, Float, Float> waterDepthQuery;
private com.jme3.audio.AudioNode wadingNode;
/** Aktuell berechnete Wassertiefe einmal pro Update() gesetzt, in pollFootsteps() gelesen. */
private float currentWaterDepth = 0f;
private float lastLeftFootY = Float.MAX_VALUE; private float lastLeftFootY = Float.MAX_VALUE;
private float lastRightFootY = Float.MAX_VALUE; private float lastRightFootY = Float.MAX_VALUE;
@@ -191,6 +197,7 @@ public class PlayerInputControl {
forward = backward = left = right = sprint = walk = false; forward = backward = left = right = sprint = walk = false;
autopilotDir = null; autopilotDir = null;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
stopWading();
} }
} }
@@ -356,6 +363,18 @@ public class PlayerInputControl {
} }
} }
/**
* Sperrt alle Tastatureingaben, lässt aber die aktuelle Laufrichtung des
* Physik-Charakters bestehen (Charakter bewegt sich weiter). Wird für den
* Ertrink-Fade-out verwendet.
*/
public void blockInputsKeepMoving() {
inputsBlocked = true;
forward = backward = left = right = sprint = walk = false;
autopilotDir = null;
stopWading();
}
/** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */ /** Hebt die Input-Blockade auf und gibt Bewegungseingaben wieder frei. */
public void unblockInputs() { public void unblockInputs() {
inputsBlocked = false; inputsBlocked = false;
@@ -554,11 +573,16 @@ public class PlayerInputControl {
boolean moving = moveDir.lengthSquared() > 0.001f; boolean moving = moveDir.lengthSquared() > 0.001f;
// Wassertiefe bestimmen einmal pro Frame, auch in pollFootsteps() genutzt
Vector3f charPos = physicsChar.getPhysicsLocation();
currentWaterDepth = (waterDepthQuery != null) ? waterDepthQuery.apply(charPos.x, charPos.z) : 0f;
boolean inDeepWater = currentWaterDepth > WATER_WADING_DEPTH;
if (moving) { if (moving) {
moveDir.normalizeLocal(); moveDir.normalizeLocal();
float speed = walk ? MOVE_SPEED * WALK_MULT float speed = (inDeepWater || walk) ? MOVE_SPEED * WALK_MULT
: sprint ? MOVE_SPEED * SPRINT_MULT : sprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED; : MOVE_SPEED;
physicsChar.setWalkDirection(moveDir.mult(speed)); physicsChar.setWalkDirection(moveDir.mult(speed));
if (visual != null) { if (visual != null) {
@@ -580,9 +604,9 @@ public class PlayerInputControl {
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) { if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP; target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) { } else if (moving) {
target = walk ? AnimationAction.WALK target = (inDeepWater || walk) ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT : sprint ? AnimationAction.SPRINT
: AnimationAction.RUN; : AnimationAction.RUN;
} else { } else {
target = AnimationAction.IDLE; target = AnimationAction.IDLE;
} }
@@ -618,6 +642,14 @@ public class PlayerInputControl {
this.surfaceQuery = query; this.surfaceQuery = query;
} }
public void setWaterDepthQuery(java.util.function.BiFunction<Float, Float, Float> query) {
this.waterDepthQuery = query;
}
public void setWadingSound(com.jme3.audio.AudioNode node) {
this.wadingNode = node;
}
private void logJointNames(com.jme3.anim.Armature arm) { private void logJointNames(com.jme3.anim.Armature arm) {
if (arm == null) return; if (arm == null) return;
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):"); StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
@@ -628,22 +660,34 @@ public class PlayerInputControl {
} }
private void pollFootsteps() { private void pollFootsteps() {
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) { if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
stopWading();
lastLeftFootY = Float.MAX_VALUE; lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE; lastRightFootY = Float.MAX_VALUE;
return; return;
} }
float speed = physicsChar.getWalkDirection().length(); float speed = physicsChar.getWalkDirection().length();
if (speed < 0.001f) { if (speed < 0.001f) {
stopWading();
lastLeftFootY = Float.MAX_VALUE; lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE; lastRightFootY = Float.MAX_VALUE;
return; return;
} }
String gait = currentAnimToGait(currentAnim);
if (gait == null) { if (currentWaterDepth > WATER_WADING_DEPTH) {
// Im Wasser: Watgeräusch abspielen, Fußgeräusche unterdrücken
startWading();
lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE;
return; return;
} }
stopWading();
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
String gait = currentAnimToGait(currentAnim);
if (gait == null) return;
com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT); com.jme3.anim.Joint lf = armature.getJoint(BONE_LEFT_FOOT);
com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT); com.jme3.anim.Joint rf = armature.getJoint(BONE_RIGHT_FOOT);
if (lf != null) { if (lf != null) {
@@ -662,6 +706,20 @@ public class PlayerInputControl {
} }
} }
private void startWading() {
if (wadingNode == null) return;
if (wadingNode.getStatus() != com.jme3.audio.AudioSource.Status.Playing) {
wadingNode.play();
}
}
private void stopWading() {
if (wadingNode == null) return;
if (wadingNode.getStatus() == com.jme3.audio.AudioSource.Status.Playing) {
wadingNode.stop();
}
}
private String currentAnimToGait(AnimationAction anim) { private String currentAnimToGait(AnimationAction anim) {
if (anim == AnimationAction.WALK) return "walking"; if (anim == AnimationAction.WALK) return "walking";
if (anim == AnimationAction.RUN) return "running"; if (anim == AnimationAction.RUN) return "running";

View File

@@ -92,6 +92,7 @@ public class WorldScene extends BaseAppState {
private de.blight.game.state.OceanSoundState oceanSound; private de.blight.game.state.OceanSoundState oceanSound;
private de.blight.game.state.AmbientSoundSystem ambientSounds; private de.blight.game.state.AmbientSoundSystem ambientSounds;
private de.blight.game.audio.FootstepSystem footstepSystem; private de.blight.game.audio.FootstepSystem footstepSystem;
private de.blight.game.state.DrownState drownState;
public WorldScene(KeyBindings keyBindings) { public WorldScene(KeyBindings keyBindings) {
this.keyBindings = keyBindings; this.keyBindings = keyBindings;
@@ -107,6 +108,11 @@ public class WorldScene extends BaseAppState {
*/ */
public float[] querySurfaceWeights(float worldX, float worldZ) { public float[] querySurfaceWeights(float worldX, float worldZ) {
float[] result = new float[de.blight.game.audio.SurfaceType.values().length]; float[] result = new float[de.blight.game.audio.SurfaceType.values().length];
// Wasser hat Vorrang vor Splatmap: Terrainoberfläche unter Wasserstand → WATER
if (terrainChunkState != null && terrainChunkState.getHeightAt(worldX, worldZ) < 0f) {
result[de.blight.game.audio.SurfaceType.WATER.ordinal()] = 1.0f;
return result;
}
if (loadedMapData == null) return result; if (loadedMapData == null) return result;
int size = de.blight.common.MapData.SPLAT_SIZE; int size = de.blight.common.MapData.SPLAT_SIZE;
@@ -169,6 +175,12 @@ public class WorldScene extends BaseAppState {
return result; return result;
} }
/** Liefert die Wassertiefe an (worldX, worldZ) in Metern; 0 wenn an Land. */
public float queryWaterDepth(float worldX, float worldZ) {
if (terrainChunkState == null) return 0f;
return Math.max(0f, -terrainChunkState.getHeightAt(worldX, worldZ));
}
/** Wird von ConfigScreen nach dem Speichern aufgerufen. */ /** Wird von ConfigScreen nach dem Speichern aufgerufen. */
public void reloadBindings(KeyBindings kb) { public void reloadBindings(KeyBindings kb) {
if (playerInput != null) playerInput.reloadBindings(kb); if (playerInput != null) playerInput.reloadBindings(kb);
@@ -254,6 +266,20 @@ public class WorldScene extends BaseAppState {
footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings, footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings,
AnimationLibrary.findAssetRoot()); AnimationLibrary.findAssetRoot());
playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights); playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights);
playerInput.setWaterDepthQuery(this::queryWaterDepth);
try {
com.jme3.audio.AudioNode wadingNode = new com.jme3.audio.AudioNode(
assetManager, "audio/footsteps/water/wading.ogg", com.jme3.audio.AudioData.DataType.Buffer);
wadingNode.setLooping(true);
wadingNode.setPositional(false);
de.blight.game.state.AudioSettingsState _as =
app.getStateManager().getState(de.blight.game.state.AudioSettingsState.class);
wadingNode.setVolume(_as != null ? _as.effectiveEffects() : 0.7f);
rootNode.attachChild(wadingNode);
playerInput.setWadingSound(wadingNode);
} catch (Exception e) {
log.warn("[WorldScene] wading.ogg nicht ladbar Watgeräusch deaktiviert: {}", e.getMessage());
}
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt) // Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
try { try {
@@ -286,8 +312,14 @@ public class WorldScene extends BaseAppState {
inventoryState = new InventoryState(mc, keyBindings); inventoryState = new InventoryState(mc, keyBindings);
inventoryState.setEnabled(false); inventoryState.setEnabled(false);
app.getStateManager().attach(inventoryState); app.getStateManager().attach(inventoryState);
app.getStateManager().attach(new de.blight.game.state.HudState(mc));
} }
// Ertrinken-System (Wassertiefe > 1,8 m → Teleport zum Strand)
MainCharacter drownMc = findMainCharacter();
drownState = new de.blight.game.state.DrownState(terrainChunkState, physicsChar, playerInput, drownMc);
app.getStateManager().attach(drownState);
// Maus einfangen keine Klick-Pflicht für Kamerasteuerung // Maus einfangen keine Klick-Pflicht für Kamerasteuerung
app.getInputManager().setCursorVisible(false); app.getInputManager().setCursorVisible(false);
} }
@@ -421,10 +453,16 @@ public class WorldScene extends BaseAppState {
playerInput.setInitialFacing(spawnYaw); playerInput.setInitialFacing(spawnYaw);
String reviveClip = de.blight.game.animation.AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, de.blight.game.animation.AnimationAction.REVIVE);
float reviveLength = playerInput.getReviveClipLength();
// REVIVE-Info immer an DrownState weitergeben (für Ertrinken-Sequenz)
if (drownState != null) {
drownState.setReviveInfo(reviveClip, reviveLength);
}
if ("true".equals(System.getProperty("blight.new.game"))) { if ("true".equals(System.getProperty("blight.new.game"))) {
String reviveClip = de.blight.game.animation.AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, de.blight.game.animation.AnimationAction.REVIVE);
float reviveLength = playerInput.getReviveClipLength();
app.getStateManager().attach( app.getStateManager().attach(
new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength)); new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength));
} }

View File

@@ -10,24 +10,29 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** /**
* Spielt Wellensounds positional ab. Die AudioNodes werden EINMALIG nach dem * Spielt Wellensounds mit korrektem L/R-Panning ab.
* ersten gültigen Scan positioniert (vermeidet den initialen Sprung von (0,0,0) *
* zur Ozean-Kante, der OpenAL-Knistern verursacht). Danach wird die Position * Strategie: Je Sound zwei Quellen (L + R), die immer genau PAN_DIST Meter
* nur noch alle SCAN_INTERVAL Sekunden aktualisiert, wenn sich der Ozean * links/rechts der Kamera positioniert sind (±90° Azimuth).
* signifikant verschoben hat. * Das Lautstärkeverhältnis (constant-power Panning) bestimmt die wahrgenommene
* Richtung. So entsteht kein HRTF-0°-Problem: Wasser direkt vorne → beide
* Lautsprecher gleich laut (Phantommitte); Wasser links → links lauter.
*/ */
public class OceanSoundState extends BaseAppState { public class OceanSoundState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class); private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class);
private static final float MAX_DIST = 60f; private static final float MAX_DIST = 60f;
private static final float REF_DIST = 8f; private static final float REF_DIST = 8f;
private static final float WIND_THRESHOLD = 20f; private static final float WIND_THRESHOLD = 20f;
private static final float FADE_RATE = 1f / 3f; private static final float FADE_RATE = 1f / 3f;
private static final float SCAN_INTERVAL = 0.25f; private static final float SCAN_INTERVAL = 0.25f;
private static final float SCAN_STEP = 5f; private static final float SCAN_STEP = 5f;
/** Mindestverschiebung (m²) bevor die Node-Position aktualisiert wird. */ /**
private static final float POS_UPDATE_SQ = 4f * 4f; // 4 Meter * Abstand Source↔Listener. Muss < refDistance (10 m JME3-Default) sein,
* damit Clamped-Inverse-Formel Gain = 1.0 liefert.
*/
private static final float PAN_DIST = 2f;
private static final float[] DIR_X = { 0f, 0.707f, 1f, 0.707f, 0f, -0.707f, -1f, -0.707f }; private static final float[] DIR_X = { 0f, 0.707f, 1f, 0.707f, 0f, -0.707f, -1f, -0.707f };
private static final float[] DIR_Z = { 1f, 0.707f, 0f, -0.707f, -1f, -0.707f, 0f, 0.707f }; private static final float[] DIR_Z = { 1f, 0.707f, 0f, -0.707f, -1f, -0.707f, 0f, 0.707f };
@@ -35,22 +40,22 @@ public class OceanSoundState extends BaseAppState {
private final TerrainChunkState terrain; private final TerrainChunkState terrain;
private SimpleApplication app; private SimpleApplication app;
private AudioNode nodeCalmSound; // L/R-Paare: beide spielen dieselbe Datei, immer auf ±90° der Kamera
private AudioNode nodeStormySound; private AudioNode nodeCalmL, nodeCalmR;
private AudioNode nodeStormyL, nodeStormyR;
private final Vector3f playerPos = new Vector3f(); private final Vector3f playerPos = new Vector3f();
private final Vector3f targetPos = new Vector3f(); private final Vector3f oceanPos = new Vector3f();
private final Vector3f nodePos = new Vector3f(); // zuletzt gesetzte Node-Position
/** true bis der erste gültige Scan die Nodes positioniert und abgespielt hat. */ private boolean firstScan = true;
private boolean firstScan = true; private boolean playing = false;
private boolean playing = false; private boolean oceanFound = false;
private float calmVol = 0f; private float calmVol = 0f;
private float stormyVol = 0f; private float stormyVol = 0f;
private float scanTimer = 0f; private float scanTimer = 0f;
private boolean oceanInRange = false; private boolean oceanInRange = false;
private float oceanDist = Float.MAX_VALUE; private float oceanDist = Float.MAX_VALUE;
public OceanSoundState(TerrainChunkState terrain) { public OceanSoundState(TerrainChunkState terrain) {
this.terrain = terrain; this.terrain = terrain;
@@ -59,15 +64,18 @@ public class OceanSoundState extends BaseAppState {
@Override @Override
protected void initialize(Application application) { protected void initialize(Application application) {
app = (SimpleApplication) application; app = (SimpleApplication) application;
nodeCalmSound = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm"); nodeCalmL = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_L");
nodeStormySound = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy"); nodeCalmR = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_R");
// Noch NICHT abspielen erst wenn wir eine gültige Position haben (firstScan). nodeStormyL = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_L");
nodeStormyR = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_R");
} }
@Override @Override
protected void cleanup(Application application) { protected void cleanup(Application application) {
stop(nodeCalmSound); stop(nodeCalmL);
stop(nodeStormySound); stop(nodeCalmR);
stop(nodeStormyL);
stop(nodeStormyR);
} }
@Override protected void onEnable() {} @Override protected void onEnable() {}
@@ -79,16 +87,24 @@ public class OceanSoundState extends BaseAppState {
@Override @Override
public void update(float tpf) { public void update(float tpf) {
if (nodeCalmSound == null && nodeStormySound == null) return; if (nodeCalmL == null && nodeStormyL == null) {
return;
}
// Periodisch nächste Ozean-Position berechnen
scanTimer -= tpf; scanTimer -= tpf;
if (scanTimer <= 0f) { if (scanTimer <= 0f) {
scanTimer = SCAN_INTERVAL; scanTimer = SCAN_INTERVAL;
scanOceanSource(); scanOceanSource();
} }
// Lautstärke boolean playerInWater = terrain.getHeightAt(playerPos.x, playerPos.z) < 0f;
if (playerInWater) {
applyVolumes(0f, 0f, 0.707f, 0.707f);
calmVol = 0f;
stormyVol = 0f;
return;
}
WeatherState weather = getApplication().getStateManager().getState(WeatherState.class); WeatherState weather = getApplication().getStateManager().getState(WeatherState.class);
float wind = weather != null ? weather.getWindSpeed() : 4f; float wind = weather != null ? weather.getWindSpeed() : 4f;
@@ -99,17 +115,31 @@ public class OceanSoundState extends BaseAppState {
? Math.max(0f, 1f - Math.max(0f, oceanDist - REF_DIST) / (MAX_DIST - REF_DIST)) ? Math.max(0f, 1f - Math.max(0f, oceanDist - REF_DIST) / (MAX_DIST - REF_DIST))
: 0f; : 0f;
float calmTarget = (oceanInRange && wind < WIND_THRESHOLD) ? scale * distScale : 0f; float calmTarget = (oceanInRange && wind < WIND_THRESHOLD) ? scale * distScale : 0f;
float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f; float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f;
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf); calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf); stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
if (nodeCalmSound != null) nodeCalmSound.setVolume(calmVol); float panL = 0.707f, panR = 0.707f; // default: center
if (nodeStormySound != null) nodeStormySound.setVolume(stormyVol); if (playing && oceanFound) {
float panValue = computePan(); // -1 = links, 0 = Mitte, +1 = rechts
panL = (float) Math.sqrt((1f - panValue) / 2f);
panR = (float) Math.sqrt((1f + panValue) / 2f);
updateSourcePositions();
}
applyVolumes(calmVol, stormyVol, panL, panR);
} }
// ── Scan ──────────────────────────────────────────────────────────────── private void applyVolumes(float calm, float stormy, float panL, float panR) {
if (nodeCalmL != null) nodeCalmL.setVolume(calm * panL);
if (nodeCalmR != null) nodeCalmR.setVolume(calm * panR);
if (nodeStormyL != null) nodeStormyL.setVolume(stormy * panL);
if (nodeStormyR != null) nodeStormyR.setVolume(stormy * panR);
}
// ── Scan ──────────────────────────────────────────────────────────────────
private void scanOceanSource() { private void scanOceanSource() {
float px = playerPos.x; float px = playerPos.x;
@@ -118,7 +148,6 @@ public class OceanSoundState extends BaseAppState {
if (terrain.getHeightAt(px, pz) < 0f) { if (terrain.getHeightAt(px, pz) < 0f) {
oceanInRange = true; oceanInRange = true;
oceanDist = 0f; oceanDist = 0f;
applyTarget(px, pz);
return; return;
} }
@@ -151,53 +180,78 @@ public class OceanSoundState extends BaseAppState {
} }
} }
/**
* Zielposition setzen. Beim ersten Scan: Nodes positionieren und Wiedergabe starten.
* Danach: Node nur aktualisieren wenn Verschiebung > POS_UPDATE_SQ.
*/
private void applyTarget(float x, float z) { private void applyTarget(float x, float z) {
targetPos.set(x, 0f, z); oceanPos.set(x, playerPos.y, z);
oceanFound = true;
if (firstScan) { if (firstScan) {
// Beim ersten gültigen Scan: Nodes korrekt platzieren, dann erst abspielen.
firstScan = false; firstScan = false;
nodePos.set(targetPos); attachAndPlay(nodeCalmL);
if (nodeCalmSound != null) { attachAndPlay(nodeCalmR);
app.getRootNode().attachChild(nodeCalmSound); attachAndPlay(nodeStormyL);
nodeCalmSound.setLocalTranslation(nodePos); attachAndPlay(nodeStormyR);
nodeCalmSound.play();
}
if (nodeStormySound != null) {
app.getRootNode().attachChild(nodeStormySound);
nodeStormySound.setLocalTranslation(nodePos);
nodeStormySound.play();
}
playing = true; playing = true;
return;
} }
if (!playing) return;
// Nur aktualisieren wenn sich die Zielposition signifikant geändert hat
float dxSq = (targetPos.x - nodePos.x);
float dzSq = (targetPos.z - nodePos.z);
if (dxSq * dxSq + dzSq * dzSq < POS_UPDATE_SQ) return;
nodePos.set(targetPos);
if (nodeCalmSound != null) nodeCalmSound.setLocalTranslation(nodePos);
if (nodeStormySound != null) nodeStormySound.setLocalTranslation(nodePos);
} }
// ── Hilfsmethoden ─────────────────────────────────────────────────────── private void attachAndPlay(AudioNode node) {
if (node == null) {
return;
}
app.getRootNode().attachChild(node);
node.play();
}
// ── Panning ───────────────────────────────────────────────────────────────
/**
* Pan-Wert: -1 = Ozean voll links der Kamera, 0 = Mitte, +1 = voll rechts.
* Berechnet als Dot-Produkt der horizontalen Ozean-Richtung mit dem
* Kamera-Rechtsvektor — unabhängig davon, ob Kamera zum Wasser zeigt.
*/
private float computePan() {
float dx = oceanPos.x - playerPos.x;
float dz = oceanPos.z - playerPos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.1f) {
return 0f;
}
dx /= len;
dz /= len;
Vector3f camLeft = app.getCamera().getLeft(); // camRight = -camLeft
float rightComp = -(dx * camLeft.x + dz * camLeft.z);
return Math.max(-1f, Math.min(1f, rightComp));
}
/**
* Hält L-Quelle PAN_DIST Meter links der Kamera, R-Quelle PAN_DIST Meter rechts.
* → Immer ±90° Azimuth zum Listener, nie 0°/180° → kein HRTF-Nullpunkt.
*/
private void updateSourcePositions() {
Vector3f cam = app.getCamera().getLocation();
Vector3f camLeft = app.getCamera().getLeft();
float lx = cam.x + camLeft.x * PAN_DIST;
float lz = cam.z + camLeft.z * PAN_DIST;
float rx = cam.x - camLeft.x * PAN_DIST;
float rz = cam.z - camLeft.z * PAN_DIST;
float y = cam.y;
if (nodeCalmL != null) nodeCalmL.setLocalTranslation(lx, y, lz);
if (nodeCalmR != null) nodeCalmR.setLocalTranslation(rx, y, rz);
if (nodeStormyL != null) nodeStormyL.setLocalTranslation(lx, y, lz);
if (nodeStormyR != null) nodeStormyR.setLocalTranslation(rx, y, rz);
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private AudioNode loadLoop(String path, String label) { private AudioNode loadLoop(String path, String label) {
try { try {
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Stream); AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Buffer);
n.setLooping(true); n.setLooping(true);
n.setVolume(0f); n.setVolume(0f);
n.setPositional(true); n.setPositional(true);
n.setRefDistance(REF_DIST); // refDistance = 10 m (JME3-Default), Dist = PAN_DIST = 2 m → Gain = 1.0
n.setMaxDistance(MAX_DIST);
return n; return n;
} catch (Exception e) { } catch (Exception e) {
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage()); log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
@@ -208,7 +262,9 @@ public class OceanSoundState extends BaseAppState {
private void stop(AudioNode node) { private void stop(AudioNode node) {
if (node != null) { if (node != null) {
node.stop(); node.stop();
if (node.getParent() != null) app.getRootNode().detachChild(node); if (node.getParent() != null) {
app.getRootNode().detachChild(node);
}
} }
} }