Wasser-Sounds verfeinert

This commit is contained in:
2026-08-22 20:16:48 +02:00
parent f26a15b3b0
commit e4a41d1a0a
12 changed files with 428 additions and 35 deletions

View File

@@ -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();

View File

@@ -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<Float, Float, float[]> 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. */

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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<float[]> 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<float[]> riverSegments = new ArrayList<>();
/** Wasserflächen-Polygone: float[3][] = {xs, zs, {waterHeight}}. */
private final List<float[][]> 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<List<RiverPoint>> rivers = RiverIO.load();
for (List<RiverPoint> 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<PlacedWater> 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;
}
}

View File

@@ -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<WaterfallEntry> entries = new ArrayList<>();
private SimpleApplication app;
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
List<PlacedWaterfall> 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;
}
}