diff --git a/blight-assets/src/main/resources/audio/ambient/water/waves_calm.ogg b/blight-assets/src/main/resources/audio/ambient/water/waves_calm.ogg index 6e67512..78fdf6e 100644 Binary files a/blight-assets/src/main/resources/audio/ambient/water/waves_calm.ogg and b/blight-assets/src/main/resources/audio/ambient/water/waves_calm.ogg differ diff --git a/blight-assets/src/main/resources/audio/ambient/water/waves_stormy.ogg b/blight-assets/src/main/resources/audio/ambient/water/waves_stormy.ogg index ad89f40..b9038af 100644 Binary files a/blight-assets/src/main/resources/audio/ambient/water/waves_stormy.ogg and b/blight-assets/src/main/resources/audio/ambient/water/waves_stormy.ogg differ diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 1953981..0aa4f8d 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -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 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 item = new TreeItem<>(dest.getFileName().toString()); itemPaths.put(item, dest); audioNode.getChildren().add(item); diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 34ea7b0..16c64d7 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -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; diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 2d25065..6a8c429 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -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); } diff --git a/blight-game/src/main/java/de/blight/game/audio/SurfaceType.java b/blight-game/src/main/java/de/blight/game/audio/SurfaceType.java index 87b2377..532aaff 100644 --- a/blight-game/src/main/java/de/blight/game/audio/SurfaceType.java +++ b/blight-game/src/main/java/de/blight/game/audio/SurfaceType.java @@ -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; diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index 2d9284e..2d9259c 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -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 surfaceQuery; + private java.util.function.BiFunction 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 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"; diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 277c238..518a5a0 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -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)); } diff --git a/blight-game/src/main/java/de/blight/game/state/OceanSoundState.java b/blight-game/src/main/java/de/blight/game/state/OceanSoundState.java index 79bc8a4..3faff97 100644 --- a/blight-game/src/main/java/de/blight/game/state/OceanSoundState.java +++ b/blight-game/src/main/java/de/blight/game/state/OceanSoundState.java @@ -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); + } } }