diff --git a/blight-assets/src/main/resources/audio/River Loop.ogg b/blight-assets/src/main/resources/audio/River Loop.ogg deleted file mode 100644 index 46f73c1..0000000 Binary files a/blight-assets/src/main/resources/audio/River Loop.ogg and /dev/null differ diff --git a/blight-assets/src/main/resources/audio/River Stream Loop.ogg b/blight-assets/src/main/resources/audio/ambient/water/river.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/River Stream Loop.ogg rename to blight-assets/src/main/resources/audio/ambient/water/river.ogg diff --git a/blight-assets/src/main/resources/audio/Waterfall Loop.ogg b/blight-assets/src/main/resources/audio/ambient/water/waterfall.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/Waterfall Loop.ogg rename to blight-assets/src/main/resources/audio/ambient/water/waterfall.ogg diff --git a/blight-assets/src/main/resources/audio/ambient/water/wading.ogg b/blight-assets/src/main/resources/audio/footsteps/water/wading/wading.ogg similarity index 100% rename from blight-assets/src/main/resources/audio/ambient/water/wading.ogg rename to blight-assets/src/main/resources/audio/footsteps/water/wading/wading.ogg 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 01f905b..0c122b2 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -128,7 +128,6 @@ public class EditorApp extends Application { private StackPane plantPreviewPanel; // einmaliges Vorschau-Panel für alle Generatoren private Stage primaryStage; private JmeEditorApp jmeApp; - private AudioPreviewPopup audioPreviewPopup; // Baum-Generator-Zustand (wird beim Preset-Wechsel neu gesetzt) private TreeParams treeParams = TreeParams.oak(); @@ -5345,8 +5344,12 @@ public class EditorApp extends Application { input.animPreviewLoadPath = relPath; if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); } else if (cat == audioNode && relPath.endsWith(".ogg")) { - if (audioPreviewPopup == null) audioPreviewPopup = new AudioPreviewPopup(); - audioPreviewPopup.open(p); + try { + new ProcessBuilder("xdg-open", p.toAbsolutePath().toString()) + .start(); + } catch (Exception ex) { + log.warn("[Audio] xdg-open fehlgeschlagen: {}", ex.getMessage()); + } setStatus("▶ " + relPath); } else if (relPath.endsWith(".animset.json")) { openAnimSetEditor(relPath, p); diff --git a/blight-game/src/main/java/de/blight/game/audio/FootstepSystem.java b/blight-game/src/main/java/de/blight/game/audio/FootstepSystem.java index 0a120b2..3483d38 100644 --- a/blight-game/src/main/java/de/blight/game/audio/FootstepSystem.java +++ b/blight-game/src/main/java/de/blight/game/audio/FootstepSystem.java @@ -110,8 +110,13 @@ public class FootstepSystem { * @param gait "walking" / "running" / "sprinting" */ public void play(float[] surfaceWeights, String gait) { + play(surfaceWeights, gait, 1f); + } + + public void play(float[] surfaceWeights, String gait, float volumeScale) { float baseVol = BASE_VOLUME * gaitVolumeFactor(gait) - * (audioSettings != null ? audioSettings.effectiveEffects() : 1f); + * (audioSettings != null ? audioSettings.effectiveEffects() : 1f) + * volumeScale; float pitch = PITCH_CENTER + (random.nextFloat() - 0.5f) * PITCH_RANGE; SurfaceType[] types = SurfaceType.values(); 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 efcc248..ac19e5f 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 @@ -65,7 +65,7 @@ public class PlayerInputControl { private static final String BONE_LEFT_FOOT = "mixamorig:LeftFoot"; private static final String BONE_RIGHT_FOOT = "mixamorig:RightFoot"; /** Wassertiefe (m) ab der Laufen/Sprinten gesperrt ist und Watgeräusch spielt. */ - public static final float WATER_WADING_DEPTH = 0.5f; + public static final float WATER_WADING_DEPTH = 0.75f; private de.blight.game.audio.FootstepSystem footstepSystem; private java.util.function.BiFunction surfaceQuery; @@ -680,27 +680,23 @@ public class PlayerInputControl { } private void pollFootsteps(float tpf) { - if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) { + // Wading-Ambient: immer wenn im Wasser, auch im Stand oder blockiert + if (currentWaterDepth > 0f) { + startWading(); + } else { stopWading(); + } + + if (!physicsChar.onGround() || blockingAnimActive || lockedInPlace) { resetFootstepState(); return; } float speed = physicsChar.getWalkDirection().length(); if (speed < 0.001f) { - stopWading(); resetFootstepState(); return; } - if (currentWaterDepth > WATER_WADING_DEPTH) { - // Im Wasser: Watgeräusch abspielen, Fußgeräusche unterdrücken - startWading(); - resetFootstepState(); - return; - } - - stopWading(); - if (armature == null || footstepSystem == null || surfaceQuery == null) return; String gait = currentAnimToGait(currentAnim); if (gait == null) return; @@ -754,9 +750,16 @@ public class PlayerInputControl { } private void triggerStep(String gait) { - Vector3f pos = physicsChar.getPhysicsLocation(); - float[] weights = surfaceQuery.apply(pos.x, pos.z); - footstepSystem.play(weights, gait); + if (currentWaterDepth > 0f) { + // Im Wasser: Wassergeräusche aus footsteps/water/walking bzw. /running + float[] weights = new float[de.blight.game.audio.SurfaceType.values().length]; + weights[de.blight.game.audio.SurfaceType.WATER.ordinal()] = 1f; + footstepSystem.play(weights, gait); + } else { + Vector3f pos = physicsChar.getPhysicsLocation(); + float[] weights = surfaceQuery.apply(pos.x, pos.z); + footstepSystem.play(weights, gait); + } } /** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */ 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 1cbb70f..e6f7569 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 @@ -101,6 +101,7 @@ public class WorldScene extends BaseAppState { private float spawnZ = 0f; private float spawnYaw = 0f; private de.blight.game.state.OceanSoundState oceanSound; + private de.blight.game.state.RiverSoundState riverSound; private de.blight.game.state.AmbientSoundSystem ambientSounds; private de.blight.game.state.MusicSystem musicSystem; private de.blight.game.audio.FootstepSystem footstepSystem; @@ -189,10 +190,20 @@ public class WorldScene extends BaseAppState { return result; } - /** Liefert die Wassertiefe des Spielers in Metern; 0 wenn über Y=0. */ + /** Liefert die Wassertiefe (Meer + Fluss) an (worldX, worldZ) in Metern. */ public float queryWaterDepth(float worldX, float worldZ) { if (physicsChar == null) return 0f; - return Math.max(0f, -physicsChar.getPhysicsLocation().y); + float playerY = physicsChar.getPhysicsLocation().y; + // Ozean: Terrain unter Meeresspiegel → Tiefe = -terrainH + float oceanDepth = 0f; + if (terrainChunkState != null) { + float h = terrainChunkState.getHeightAt(worldX, worldZ); + if (h < 0f) oceanDepth = -h; + } + // Fluss: echte Tiefe = Wasseroberfläche - Spieler-Y + float riverDepth = (riverSound != null) + ? riverSound.getWaterDepth(worldX, playerY, worldZ) : 0f; + return Math.max(oceanDepth, riverDepth); } /** Wird von ConfigScreen nach dem Speichern aufgerufen. */ @@ -296,12 +307,12 @@ public class WorldScene extends BaseAppState { 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); + assetManager, "audio/footsteps/water/wading/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); + wadingNode.setVolume((_as != null ? _as.effectiveEffects() : 0.7f) * 0.1f); rootNode.attachChild(wadingNode); playerInput.setWadingSound(wadingNode); } catch (Exception e) { @@ -445,6 +456,7 @@ public class WorldScene extends BaseAppState { app.getListener().setLocation(pos); app.getListener().setRotation(app.getCamera().getRotation()); if (oceanSound != null) oceanSound.setPlayerPosition(pos); + if (riverSound != null) riverSound.setPlayerPosition(pos); if (ambientSounds != null) ambientSounds.setPlayerPosition(pos); if (musicSystem != null) musicSystem.setPlayerPosition(pos); } @@ -966,6 +978,11 @@ public class WorldScene extends BaseAppState { oceanSound = new de.blight.game.state.OceanSoundState(terrainChunkState); app.getStateManager().attach(oceanSound); + app.getStateManager().attach(new de.blight.game.state.WaterfallSoundState()); + + riverSound = new de.blight.game.state.RiverSoundState(); + app.getStateManager().attach(riverSound); + ambientSounds = new de.blight.game.state.AmbientSoundSystem(); app.getStateManager().attach(ambientSounds); 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 3faff97..f1495e4 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 @@ -25,7 +25,8 @@ public class OceanSoundState extends BaseAppState { 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 FADE_RATE_CALM = 1f / 3f; + private static final float FADE_RATE_STORMY = 1f / 10f; private static final float SCAN_INTERVAL = 0.25f; private static final float SCAN_STEP = 5f; /** @@ -98,12 +99,6 @@ public class OceanSoundState extends BaseAppState { } 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; @@ -111,22 +106,29 @@ public class OceanSoundState extends BaseAppState { AudioSettingsState audioSettings = getApplication().getStateManager().getState(AudioSettingsState.class); float scale = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f; + // Im Wasser: distScale = 1.0 (scan setzt oceanDist=0), Sound bleibt voll float distScale = oceanInRange ? 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 stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale : 0f; + float stormyTarget = (oceanInRange && wind >= WIND_THRESHOLD) ? scale * distScale * 0.75f : 0f; - calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf); - stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf); + calmVol = approach(calmVol, calmTarget, FADE_RATE_CALM * tpf); + stormyVol = approach(stormyVol, stormyTarget, FADE_RATE_STORMY * tpf); - float panL = 0.707f, panR = 0.707f; // default: center - if (playing && oceanFound) { + float panL, panR; + if (playerInWater) { + // Im Wasser: omnidirektional — gleiches Panning links/rechts, kein Richtungseffekt + panL = panR = 0.707f; + updateSourcePositions(); // Quellen nah am Listener halten + } else 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(); + } else { + panL = panR = 0.707f; } applyVolumes(calmVol, stormyVol, panL, panR); @@ -148,6 +150,9 @@ public class OceanSoundState extends BaseAppState { if (terrain.getHeightAt(px, pz) < 0f) { oceanInRange = true; oceanDist = 0f; + if (!playing) { + applyTarget(px, pz); // Sounds starten falls Spieler direkt ins Wasser geht + } return; } diff --git a/blight-game/src/main/java/de/blight/game/state/RiverSoundState.java b/blight-game/src/main/java/de/blight/game/state/RiverSoundState.java new file mode 100644 index 0000000..e55622a --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/RiverSoundState.java @@ -0,0 +1,243 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.audio.AudioData; +import com.jme3.audio.AudioNode; +import com.jme3.math.Vector3f; +import de.blight.common.PlacedWater; +import de.blight.common.RiverIO; +import de.blight.common.RiverPoint; +import de.blight.common.WaterBodyIO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * Spielt river.ogg in der Nähe von Flüssen und Wasserflächen ab (non-positional, + * Stereo-kompatibel). Lautstärke = ambient × 0.5 × distanceScale. + * Liefert außerdem isInRiver() für die Wading-Erkennung. + */ +public class RiverSoundState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(RiverSoundState.class); + + private static final String AUDIO_PATH = "audio/ambient/water/river.ogg"; + private static final float MAX_DIST = 55f; + private static final float REF_DIST = 8f; + private static final float FADE_RATE = 1f / 3f; + private static final float SCAN_INTERVAL = 0.25f; + private static final float VOLUME_SCALE = 0.5f; + /** RiverState senkt Quads 0.5m unter die Kontrollpunkte. */ + private static final float WATER_SINK = 0.5f; + /** + * Spieler-Y (Kapselmittelpunkt, 0.9m über Boden) darf maximal diese Distanz über + * der Wasseroberfläche liegen und gilt noch als "im Fluss". + * Kapsel: radius=0.4, halfCyl=0.5 → Mitte = 0.9m über Boden. + * 1.1m = 0.9m Kapselmitte + 0.2m Buffer → Erkennung ab erstem Kontakt mit Wasser. + */ + private static final float WADING_Y_TOL = 1.1f; + /** Nominale Minimaltiefe: Terrain liegt meist auf Höhe der Wasseroberfläche → echte Tiefe ≈ 0. */ + private static final float NOMINAL_RIVER_DEPTH = 0.35f; + + private SimpleApplication app; + private AudioNode node; + + private final Vector3f playerPos = new Vector3f(); + + private boolean riverInRange = false; + private float riverDist = Float.MAX_VALUE; + private float scanTimer = 0f; + private float vol = 0f; + private boolean started = false; + + /** Alle Kandidaten-Punkte für den Distanz-Scan {x, z}. */ + private final List candidates = new ArrayList<>(); + /** + * Fluss-Segmente für den Wading-Check. + * Jedes Element: {ax, az, bx, bz, halfWidth, waterSurfaceY} + * Segment-Abstand statt Punkt-Abstand → keine Lücken zwischen Kontrollpunkten. + */ + private final List riverSegments = new ArrayList<>(); + /** Wasserflächen-Polygone: float[3][] = {xs, zs, {waterHeight}}. */ + private final List waterPolygons = new ArrayList<>(); + + @Override + protected void initialize(Application application) { + app = (SimpleApplication) application; + + try { + node = new AudioNode(app.getAssetManager(), AUDIO_PATH, AudioData.DataType.Buffer); + node.setLooping(true); + node.setVolume(0f); + node.setPositional(false); + } catch (Exception e) { + log.warn("[RiverSound] Audio nicht ladbar: {}", e.getMessage()); + } + + buildData(); + log.info("[RiverSound] {} Kandidaten, {} Segmente, {} Polygone.", + candidates.size(), riverSegments.size(), waterPolygons.size()); + } + + @Override + protected void cleanup(Application application) { + if (node != null) { + node.stop(); + if (node.getParent() != null) node.removeFromParent(); + } + } + + @Override protected void onEnable() {} + @Override protected void onDisable() {} + + public void setPlayerPosition(Vector3f pos) { + playerPos.set(pos); + } + + @Override + public void update(float tpf) { + if (node == null || candidates.isEmpty()) return; + + scanTimer -= tpf; + if (scanTimer <= 0f) { + scanTimer = SCAN_INTERVAL; + scanNearest(); + } + + AudioSettingsState audioSettings = getApplication().getStateManager() + .getState(AudioSettingsState.class); + float scale = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f; + + float distScale = riverInRange + ? Math.max(0f, 1f - Math.max(0f, riverDist - REF_DIST) / (MAX_DIST - REF_DIST)) + : 0f; + + float target = scale * VOLUME_SCALE * distScale; + vol = approach(vol, target, FADE_RATE * tpf); + node.setVolume(vol); + } + + /** + * Liefert die echte Wassertiefe an (x, playerY, z) in Metern. + * Tiefe = Wasseroberfläche − Spieler-Y. 0 wenn der Spieler über dem Wasser ist. + * Wird von WorldScene.queryWaterDepth() genutzt. + */ + public float getWaterDepth(float x, float playerY, float z) { + float maxDepth = 0f; + for (float[] seg : riverSegments) { + if (distToSegmentSq(x, z, seg[0], seg[1], seg[2], seg[3]) > seg[4] * seg[4]) continue; + float waterSurfY = seg[5]; + if (playerY > waterSurfY + WADING_Y_TOL) continue; // Terrain über dem Wasser + float depth = Math.max(NOMINAL_RIVER_DEPTH, waterSurfY - playerY); + if (depth > maxDepth) maxDepth = depth; + } + for (float[][] poly : waterPolygons) { + if (!pointInPolygon(x, z, poly[0], poly[1])) continue; + float waterH = poly[2][0]; + if (playerY > waterH + WADING_Y_TOL) continue; + float depth = Math.max(NOMINAL_RIVER_DEPTH, waterH - playerY); + if (depth > maxDepth) maxDepth = depth; + } + return maxDepth; + } + + // ── Scan ────────────────────────────────────────────────────────────────── + + private void scanNearest() { + float px = playerPos.x, pz = playerPos.z; + float minDist = Float.MAX_VALUE; + + for (float[] c : candidates) { + float dx = c[0] - px, dz = c[1] - pz; + float d = (float) Math.sqrt(dx * dx + dz * dz); + if (d < minDist) minDist = d; + } + + if (minDist > MAX_DIST) { + riverInRange = false; + riverDist = Float.MAX_VALUE; + } else { + riverInRange = true; + riverDist = minDist; + if (!started) { + app.getRootNode().attachChild(node); + node.play(); + started = true; + } + } + } + + // ── Datenaufbau ─────────────────────────────────────────────────────────── + + private void buildData() { + try { + List> rivers = RiverIO.load(); + for (List river : rivers) { + for (int i = 0; i < river.size() - 1; i++) { + RiverPoint a = river.get(i); + RiverPoint b = river.get(i + 1); + float halfWidth = Math.max(a.width(), b.width()) * 0.5f + 0.3f; + float waterSurfY = ((a.y() + b.y()) * 0.5f) - WATER_SINK; + riverSegments.add(new float[]{ a.x(), a.z(), b.x(), b.z(), halfWidth, waterSurfY }); + // Kandidaten: jeden 2. Punkt + if (i % 2 == 0) candidates.add(new float[]{ a.x(), a.z() }); + } + // letzten Punkt auch als Kandidat + if (!river.isEmpty()) { + RiverPoint last = river.get(river.size() - 1); + candidates.add(new float[]{ last.x(), last.z() }); + } + } + } catch (Exception e) { + log.warn("[RiverSound] Flüsse nicht ladbar: {}", e.getMessage()); + } + + try { + List bodies = WaterBodyIO.load(); + for (PlacedWater b : bodies) { + float[] xs = b.pointsX(), zs = b.pointsZ(); + waterPolygons.add(new float[][]{ xs, zs, { b.waterHeight() } }); + float cx = 0, cz = 0; + for (int i = 0; i < xs.length; i++) { cx += xs[i]; cz += zs[i]; } + candidates.add(new float[]{ cx / xs.length, cz / xs.length }); + for (int i = 0; i < xs.length; i++) candidates.add(new float[]{ xs[i], zs[i] }); + } + } catch (Exception e) { + log.warn("[RiverSound] Wasserflächen nicht ladbar: {}", e.getMessage()); + } + } + + // ── Geometrie-Hilfsmethoden ─────────────────────────────────────────────── + + /** Quadrierter Abstand von Punkt (px,pz) zum Segment (ax,az)→(bx,bz). */ + private static float distToSegmentSq(float px, float pz, + float ax, float az, float bx, float bz) { + float dx = bx - ax, dz = bz - az; + float len2 = dx * dx + dz * dz; + float t = len2 < 1e-6f ? 0f + : Math.max(0f, Math.min(1f, ((px - ax) * dx + (pz - az) * dz) / len2)); + float cx = ax + t * dx, cz = az + t * dz; + float ex = px - cx, ez = pz - cz; + return ex * ex + ez * ez; + } + + private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) { + boolean inside = false; + int n = xs.length; + for (int i = 0, j = n - 1; i < n; j = i++) { + if (((zs[i] > pz) != (zs[j] > pz)) && + (px < (xs[j] - xs[i]) * (pz - zs[i]) / (zs[j] - zs[i]) + xs[i])) + inside = !inside; + } + return inside; + } + + private static float approach(float cur, float target, float maxStep) { + float d = target - cur; + return Math.abs(d) <= maxStep ? target : cur + Math.signum(d) * maxStep; + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WaterfallSoundState.java b/blight-game/src/main/java/de/blight/game/state/WaterfallSoundState.java new file mode 100644 index 0000000..535861c --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WaterfallSoundState.java @@ -0,0 +1,117 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.audio.AudioData; +import com.jme3.audio.AudioNode; +import com.jme3.math.FastMath; +import de.blight.common.PlacedWaterfall; +import de.blight.common.WaterfallIO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +/** + * Spielt waterfall.ogg an jedem Wasserfall ab (non-positional, Stereo-kompatibel). + * Lautstärke = ambient × steepness × distanceScale. + * Steilheit: 45° → 0, 90° → 1 (linear). Distanz: ref=6m, max=40m. + */ +public class WaterfallSoundState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WaterfallSoundState.class); + + private static final String AUDIO_PATH = "audio/ambient/water/waterfall.ogg"; + private static final float REF_DIST = 6f; + private static final float MAX_DIST = 40f; + private static final float FADE_RATE = 1f / 2f; + + private record WaterfallEntry(AudioNode node, float cx, float cy, float cz, float steepnessScale) {} + + private final List entries = new ArrayList<>(); + private SimpleApplication app; + + @Override + protected void initialize(Application application) { + app = (SimpleApplication) application; + + List waterfalls; + try { + waterfalls = WaterfallIO.load(); + } catch (Exception e) { + log.error("[WaterfallSound] Wasserfälle nicht ladbar", e); + return; + } + + for (PlacedWaterfall wf : waterfalls) { + try { + AudioNode node = new AudioNode(app.getAssetManager(), AUDIO_PATH, AudioData.DataType.Buffer); + node.setLooping(true); + node.setVolume(0f); + node.setPositional(false); + app.getRootNode().attachChild(node); + node.play(); + + float cx = (wf.ax() + wf.bx() + wf.cx() + wf.dx()) * 0.25f; + float cy = (wf.ay() + wf.by() + wf.cy() + wf.dy()) * 0.25f; + float cz = (wf.az() + wf.bz() + wf.cz() + wf.dz()) * 0.25f; + entries.add(new WaterfallEntry(node, cx, cy, cz, computeSteepnessScale(wf))); + } catch (Exception e) { + log.warn("[WaterfallSound] Audio nicht ladbar: {}", e.getMessage()); + } + } + log.info("[WaterfallSound] {} Wasserfall-Soundquellen geladen.", entries.size()); + } + + @Override + protected void cleanup(Application application) { + for (WaterfallEntry e : entries) { + e.node().stop(); + e.node().removeFromParent(); + } + entries.clear(); + } + + @Override + public void update(float tpf) { + if (entries.isEmpty()) return; + + AudioSettingsState audioSettings = getApplication().getStateManager() + .getState(AudioSettingsState.class); + float ambient = audioSettings != null ? audioSettings.effectiveAmbient() : 0.5f; + + com.jme3.math.Vector3f listenerPos = app.getListener().getLocation(); + float lx = listenerPos.x, ly = listenerPos.y, lz = listenerPos.z; + + for (WaterfallEntry e : entries) { + float dx = e.cx() - lx, dy = e.cy() - ly, dz = e.cz() - lz; + float dist = FastMath.sqrt(dx * dx + dy * dy + dz * dz); + float distScale = dist <= REF_DIST ? 1f + : dist >= MAX_DIST ? 0f + : 1f - (dist - REF_DIST) / (MAX_DIST - REF_DIST); + + float target = ambient * e.steepnessScale() * distScale * 1.25f; + float next = approach(e.node().getVolume(), target, FADE_RATE * tpf); + e.node().setVolume(next); + } + } + + @Override protected void onEnable() {} + @Override protected void onDisable() {} + + private static float computeSteepnessScale(PlacedWaterfall wf) { + float topX = (wf.ax() + wf.bx()) * 0.5f, topY = (wf.ay() + wf.by()) * 0.5f, topZ = (wf.az() + wf.bz()) * 0.5f; + float botX = (wf.cx() + wf.dx()) * 0.5f, botY = (wf.cy() + wf.dy()) * 0.5f, botZ = (wf.cz() + wf.dz()) * 0.5f; + float horiz = FastMath.sqrt((botX - topX) * (botX - topX) + (botZ - topZ) * (botZ - topZ)); + float vert = FastMath.abs(botY - topY); + float angleDeg = FastMath.RAD_TO_DEG * FastMath.atan2(vert, horiz); + return FastMath.clamp((angleDeg - 45f) / 45f, 0f, 1f); + } + + private static float approach(float cur, float target, float maxStep) { + float d = target - cur; + return FastMath.abs(d) <= maxStep ? target : cur + FastMath.sign(d) * maxStep; + } +} diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 4d5ee22..4d3caf0 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ