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);
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");
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), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), hint);
worldToolBar = toolBar;
@@ -5094,12 +5099,18 @@ public class EditorApp extends Application {
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 {
try {
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)
.start();
proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
@@ -5158,12 +5169,8 @@ public class EditorApp extends Application {
if (isAudio) {
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path destOgg = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) {
Files.copy(file.toPath(), destOgg, StandardCopyOption.REPLACE_EXISTING);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), destOgg);
}
setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
convertToOgg(file.toPath(), destOgg);
String finalName = baseName + ".ogg";
TreeItem<String> newItem = new TreeItem<>(finalName);
itemPaths.put(newItem, destOgg);
@@ -5716,12 +5723,8 @@ public class EditorApp extends Application {
String name = file.getName().toLowerCase();
String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path dest = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) {
Files.copy(file.toPath(), dest, StandardCopyOption.REPLACE_EXISTING);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), dest);
}
setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
convertToOgg(file.toPath(), dest);
TreeItem<String> item = new TreeItem<>(dest.getFileName().toString());
itemPaths.put(item, dest);
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_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) ──────────────────────────────────────────
public volatile boolean forward, backward, left, right, up, down;

View File

@@ -1607,12 +1607,24 @@ public class TerrainEditorState extends BaseAppState {
if (scroll != 0)
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)
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
camPos.set(0f, DEFAULT_CAM_Y, 0f);
}
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);
}

View File

@@ -1,7 +1,7 @@
package de.blight.game.audio;
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) {
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";
// Y-Schwelle im Model-Space: Fuß gilt als am Boden wenn Y < FOOT_GROUND_Y
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 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 lastRightFootY = Float.MAX_VALUE;
@@ -191,6 +197,7 @@ public class PlayerInputControl {
forward = backward = left = right = sprint = walk = false;
autopilotDir = null;
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. */
public void unblockInputs() {
inputsBlocked = false;
@@ -554,11 +573,16 @@ public class PlayerInputControl {
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) {
moveDir.normalizeLocal();
float speed = walk ? MOVE_SPEED * WALK_MULT
: sprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
float speed = (inDeepWater || walk) ? MOVE_SPEED * WALK_MULT
: sprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
physicsChar.setWalkDirection(moveDir.mult(speed));
if (visual != null) {
@@ -580,9 +604,9 @@ public class PlayerInputControl {
if (jumpFrames > 0 || (!physicsChar.onGround() && groundGraceFrames <= 0)) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) {
target = walk ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
target = (inDeepWater || walk) ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
} else {
target = AnimationAction.IDLE;
}
@@ -618,6 +642,14 @@ public class PlayerInputControl {
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) {
if (arm == null) return;
StringBuilder sb = new StringBuilder("[Footstep] Joint-Namen (").append(arm.getJointCount()).append("):");
@@ -628,22 +660,34 @@ public class PlayerInputControl {
}
private void pollFootsteps() {
if (armature == null || footstepSystem == null || surfaceQuery == null) return;
if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) {
stopWading();
lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE;
return;
}
float speed = physicsChar.getWalkDirection().length();
if (speed < 0.001f) {
stopWading();
lastLeftFootY = Float.MAX_VALUE;
lastRightFootY = Float.MAX_VALUE;
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;
}
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 rf = armature.getJoint(BONE_RIGHT_FOOT);
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) {
if (anim == AnimationAction.WALK) return "walking";
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.AmbientSoundSystem ambientSounds;
private de.blight.game.audio.FootstepSystem footstepSystem;
private de.blight.game.state.DrownState drownState;
public WorldScene(KeyBindings keyBindings) {
this.keyBindings = keyBindings;
@@ -107,6 +108,11 @@ public class WorldScene extends BaseAppState {
*/
public float[] querySurfaceWeights(float worldX, float worldZ) {
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;
int size = de.blight.common.MapData.SPLAT_SIZE;
@@ -169,6 +175,12 @@ public class WorldScene extends BaseAppState {
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. */
public void reloadBindings(KeyBindings 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,
AnimationLibrary.findAssetRoot());
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)
try {
@@ -286,8 +312,14 @@ public class WorldScene extends BaseAppState {
inventoryState = new InventoryState(mc, keyBindings);
inventoryState.setEnabled(false);
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
app.getInputManager().setCursorVisible(false);
}
@@ -421,10 +453,16 @@ public class WorldScene extends BaseAppState {
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"))) {
String reviveClip = de.blight.game.animation.AnimationLibrary.getClipForAction(
AnimationLibrary.findAssetRoot(), setName, de.blight.game.animation.AnimationAction.REVIVE);
float reviveLength = playerInput.getReviveClipLength();
app.getStateManager().attach(
new de.blight.game.state.NewGameIntroState(playerInput, reviveClip, reviveLength));
}

View File

@@ -10,24 +10,29 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Spielt Wellensounds positional ab. Die AudioNodes werden EINMALIG nach dem
* ersten gültigen Scan positioniert (vermeidet den initialen Sprung von (0,0,0)
* zur Ozean-Kante, der OpenAL-Knistern verursacht). Danach wird die Position
* nur noch alle SCAN_INTERVAL Sekunden aktualisiert, wenn sich der Ozean
* signifikant verschoben hat.
* Spielt Wellensounds mit korrektem L/R-Panning ab.
*
* Strategie: Je Sound zwei Quellen (L + R), die immer genau PAN_DIST Meter
* links/rechts der Kamera positioniert sind (±90° Azimuth).
* 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 {
private static final Logger log = LoggerFactory.getLogger(OceanSoundState.class);
private static final float MAX_DIST = 60f;
private static final float REF_DIST = 8f;
private static final float WIND_THRESHOLD = 20f;
private static final float FADE_RATE = 1f / 3f;
private static final float SCAN_INTERVAL = 0.25f;
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
private static final float MAX_DIST = 60f;
private static final float REF_DIST = 8f;
private static final float WIND_THRESHOLD = 20f;
private static final float FADE_RATE = 1f / 3f;
private static final float SCAN_INTERVAL = 0.25f;
private static final float SCAN_STEP = 5f;
/**
* 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_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 SimpleApplication app;
private AudioNode nodeCalmSound;
private AudioNode nodeStormySound;
// L/R-Paare: beide spielen dieselbe Datei, immer auf ±90° der Kamera
private AudioNode nodeCalmL, nodeCalmR;
private AudioNode nodeStormyL, nodeStormyR;
private final Vector3f playerPos = new Vector3f();
private final Vector3f targetPos = new Vector3f();
private final Vector3f nodePos = new Vector3f(); // zuletzt gesetzte Node-Position
private final Vector3f playerPos = new Vector3f();
private final Vector3f oceanPos = new Vector3f();
/** true bis der erste gültige Scan die Nodes positioniert und abgespielt hat. */
private boolean firstScan = true;
private boolean playing = false;
private boolean firstScan = true;
private boolean playing = false;
private boolean oceanFound = false;
private float calmVol = 0f;
private float stormyVol = 0f;
private float scanTimer = 0f;
private float calmVol = 0f;
private float stormyVol = 0f;
private float scanTimer = 0f;
private boolean oceanInRange = false;
private float oceanDist = Float.MAX_VALUE;
private float oceanDist = Float.MAX_VALUE;
public OceanSoundState(TerrainChunkState terrain) {
this.terrain = terrain;
@@ -59,15 +64,18 @@ public class OceanSoundState extends BaseAppState {
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
nodeCalmSound = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm");
nodeStormySound = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy");
// Noch NICHT abspielen erst wenn wir eine gültige Position haben (firstScan).
nodeCalmL = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_L");
nodeCalmR = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_R");
nodeStormyL = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_L");
nodeStormyR = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_R");
}
@Override
protected void cleanup(Application application) {
stop(nodeCalmSound);
stop(nodeStormySound);
stop(nodeCalmL);
stop(nodeCalmR);
stop(nodeStormyL);
stop(nodeStormyR);
}
@Override protected void onEnable() {}
@@ -79,16 +87,24 @@ public class OceanSoundState extends BaseAppState {
@Override
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;
if (scanTimer <= 0f) {
scanTimer = SCAN_INTERVAL;
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);
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))
: 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;
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
if (nodeCalmSound != null) nodeCalmSound.setVolume(calmVol);
if (nodeStormySound != null) nodeStormySound.setVolume(stormyVol);
float panL = 0.707f, panR = 0.707f; // default: center
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() {
float px = playerPos.x;
@@ -118,7 +148,6 @@ public class OceanSoundState extends BaseAppState {
if (terrain.getHeightAt(px, pz) < 0f) {
oceanInRange = true;
oceanDist = 0f;
applyTarget(px, pz);
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) {
targetPos.set(x, 0f, z);
oceanPos.set(x, playerPos.y, z);
oceanFound = true;
if (firstScan) {
// Beim ersten gültigen Scan: Nodes korrekt platzieren, dann erst abspielen.
firstScan = false;
nodePos.set(targetPos);
if (nodeCalmSound != null) {
app.getRootNode().attachChild(nodeCalmSound);
nodeCalmSound.setLocalTranslation(nodePos);
nodeCalmSound.play();
}
if (nodeStormySound != null) {
app.getRootNode().attachChild(nodeStormySound);
nodeStormySound.setLocalTranslation(nodePos);
nodeStormySound.play();
}
attachAndPlay(nodeCalmL);
attachAndPlay(nodeCalmR);
attachAndPlay(nodeStormyL);
attachAndPlay(nodeStormyR);
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) {
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.setVolume(0f);
n.setPositional(true);
n.setRefDistance(REF_DIST);
n.setMaxDistance(MAX_DIST);
// refDistance = 10 m (JME3-Default), Dist = PAN_DIST = 2 m → Gain = 1.0
return n;
} catch (Exception e) {
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
@@ -208,7 +262,9 @@ public class OceanSoundState extends BaseAppState {
private void stop(AudioNode node) {
if (node != null) {
node.stop();
if (node.getParent() != null) app.getRootNode().detachChild(node);
if (node.getParent() != null) {
app.getRootNode().detachChild(node);
}
}
}