Compare commits

...

4 Commits

Author SHA1 Message Date
06bb955c78 Weiter gearbeitet an allem möglichen 2026-07-06 07:11:55 +02:00
76a192df67 VegetationEditor: EZ-Tree Windanimation + Palm-Textur-Rotation fix
EzTreeState: addWindWeights() setzt VertexBuffer.Color (R = Wind-Gewicht)
nach der Mesh-Erzeugung. Blätter bekommen 1.0 (volle Animation), Äste
werden per Y-Position normiert. Tree.vert liest windW aus inColor.r —
ohne diesen Buffer blieb windW=0 und kein Schwingen sichtbar.

PalmMeshBuilder/PalmGeneratorState: stemLeft immer true (Stiel entlang U),
leafTextureAspect immer h/w. Behebt die 90°-Drehung von palm.png.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 22:32:16 +02:00
dea1aa3d3e Impostor-Texturen: kein Dateisystem-Write mehr, eingebettet in .j3o
saveImpostor() in TreeGeneratorState, PalmGeneratorState und EzTreeState
schreibt keine PNG-Dateien mehr ins .impostors-Verzeichnis. Die Texture2D
wird direkt aus dem In-Memory-ByteBuffer erstellt und via BinaryExporter
in der .j3o-Datei eingebettet. Bestehende .impostors-Dateien entfernt.
deleteJ3oSideFiles() und populateAssetTree() ohne Impostor-Logik.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 22:27:34 +02:00
4fd337aea4 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>
2026-07-05 22:22:06 +02:00
37 changed files with 1324 additions and 221 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

View File

@@ -1262,6 +1262,11 @@ public class EditorApp extends Application {
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT); camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY); camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
Button camResetBtn = new Button("⌂ Reset");
camResetBtn.setStyle("-fx-font-weight:bold;");
camResetBtn.setTooltip(new javafx.scene.control.Tooltip("Kamera auf x=0, z=0, y=Terrain+10m zurücksetzen"));
camResetBtn.setOnAction(e -> input.resetCameraRequested.set(true));
Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief"); Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief");
hint.setStyle("-fx-text-fill: #555;"); hint.setStyle("-fx-text-fill: #555;");
@@ -1274,7 +1279,7 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn, new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn, new Separator(Orientation.VERTICAL), voxelBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), hint); new Separator(Orientation.VERTICAL), hint);
worldToolBar = toolBar; worldToolBar = toolBar;
@@ -4882,30 +4887,12 @@ public class EditorApp extends Application {
} }
} }
/** /** Löscht Thumbnail, das zu einer .j3o-Datei gehört. */
* Löscht Thumbnail und Impostor-Textur, die zu einer .j3o-Datei gehören.
* Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt.
*/
private void deleteJ3oSideFiles(Path j3oPath) { private void deleteJ3oSideFiles(Path j3oPath) {
// Thumbnail
try { try {
Files.deleteIfExists( Files.deleteIfExists(
de.blight.editor.state.ThumbnailRenderer.sidecarPath(j3oPath, ASSET_ROOT)); de.blight.editor.state.ThumbnailRenderer.sidecarPath(j3oPath, ASSET_ROOT));
} catch (IOException ignored) {} } catch (IOException ignored) {}
// Impostor: Zeitstempel aus Dateiname extrahieren und passende Datei suchen
String base = j3oPath.getFileName().toString().replace(".j3o", "");
java.util.regex.Matcher m = java.util.regex.Pattern
.compile(".*(\\d{8}_\\d{6})$").matcher(base);
if (!m.matches()) return;
String ts = m.group(1);
Path impostorDir = ASSET_ROOT.resolve(
de.blight.editor.state.ThumbnailRenderer.IMPOSTOR_DIR);
if (!Files.isDirectory(impostorDir)) return;
try (var stream = Files.list(impostorDir)) {
stream.filter(f -> f.getFileName().toString().endsWith("_" + ts + ".png"))
.forEach(f -> { try { Files.deleteIfExists(f); } catch (IOException ignored) {} });
} catch (IOException ignored) {}
} }
/** /**
@@ -4925,8 +4912,7 @@ public class EditorApp extends Application {
topDirs = s.filter(Files::isDirectory) topDirs = s.filter(Files::isDirectory)
.filter(p -> { .filter(p -> {
String n = p.getFileName().toString(); String n = p.getFileName().toString();
return !n.equals(de.blight.editor.state.ThumbnailRenderer.THUMB_DIR) return !n.equals(de.blight.editor.state.ThumbnailRenderer.THUMB_DIR);
&& !n.equals(de.blight.editor.state.ThumbnailRenderer.IMPOSTOR_DIR);
}) })
.sorted(Comparator.comparing(p -> p.getFileName().toString().toLowerCase())) .sorted(Comparator.comparing(p -> p.getFileName().toString().toLowerCase()))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
@@ -5094,12 +5080,18 @@ public class EditorApp extends Application {
catch (IOException ignored) {} catch (IOException ignored) {}
} }
/** Konvertiert eine beliebige Audiodatei mit ffmpeg zu OGG Vorbis. /**
* Gibt den Pfad zur erzeugten .ogg-Datei zurück. */ * Konvertiert / normalisiert eine Audiodatei zu OGG Vorbis bei 48000 Hz.
* Das Ziel-Format stimmt mit PipeWire/PulseAudio überein, sodass OpenALSofts
* Spatial-Mixer kein On-the-fly-Resampling durchführen muss (kein Knistern).
*/
private static Path convertToOgg(Path src, Path destOgg) throws IOException { private static Path convertToOgg(Path src, Path destOgg) throws IOException {
try { try {
Process proc = new ProcessBuilder( Process proc = new ProcessBuilder(
"ffmpeg", "-i", src.toString(), "-q:a", "4", destOgg.toString(), "-y") "ffmpeg", "-i", src.toString(),
"-ar", "48000",
"-q:a", "4",
destOgg.toString(), "-y")
.redirectErrorStream(true) .redirectErrorStream(true)
.start(); .start();
proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
@@ -5158,12 +5150,8 @@ public class EditorApp extends Application {
if (isAudio) { if (isAudio) {
String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path destOgg = destDir.resolve(baseName + ".ogg"); Path destOgg = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) { setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
Files.copy(file.toPath(), destOgg, StandardCopyOption.REPLACE_EXISTING); convertToOgg(file.toPath(), destOgg);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), destOgg);
}
String finalName = baseName + ".ogg"; String finalName = baseName + ".ogg";
TreeItem<String> newItem = new TreeItem<>(finalName); TreeItem<String> newItem = new TreeItem<>(finalName);
itemPaths.put(newItem, destOgg); itemPaths.put(newItem, destOgg);
@@ -5716,12 +5704,8 @@ public class EditorApp extends Application {
String name = file.getName().toLowerCase(); String name = file.getName().toLowerCase();
String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); String baseName = file.getName().replaceFirst("\\.[^.]+$", "");
Path dest = destDir.resolve(baseName + ".ogg"); Path dest = destDir.resolve(baseName + ".ogg");
if (name.endsWith(".ogg")) { setStatus("Normalisiere " + file.getName() + " → 48 kHz OGG …");
Files.copy(file.toPath(), dest, StandardCopyOption.REPLACE_EXISTING); convertToOgg(file.toPath(), dest);
} else {
setStatus("Konvertiere " + file.getName() + " → OGG …");
convertToOgg(file.toPath(), dest);
}
TreeItem<String> item = new TreeItem<>(dest.getFileName().toString()); TreeItem<String> item = new TreeItem<>(dest.getFileName().toString());
itemPaths.put(item, dest); itemPaths.put(item, dest);
audioNode.getChildren().add(item); audioNode.getChildren().add(item);

View File

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

View File

@@ -36,10 +36,9 @@ import de.blight.eztree.TreeOptions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.FloatBuffer;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -122,10 +121,12 @@ public class EzTreeState extends BaseAppState {
Node hdNode = tryNodeJsGeneration(req); Node hdNode = tryNodeJsGeneration(req);
if (hdNode == null) hdNode = javaFallback(req); if (hdNode == null) hdNode = javaFallback(req);
addWindWeights(hdNode);
hdNode.setLocalScale(1f / 3f); hdNode.setLocalScale(1f / 3f);
hdNode.updateGeometricState(); hdNode.updateGeometricState();
Node ld1Node = buildLod1Node(req); Node ld1Node = buildLod1Node(req);
addWindWeights(ld1Node);
ld1Node.setLocalScale(1f / 3f); ld1Node.setLocalScale(1f / 3f);
BoundingBox bb = boundsOf(hdNode); BoundingBox bb = boundsOf(hdNode);
@@ -596,32 +597,10 @@ public class EzTreeState extends BaseAppState {
} }
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) { private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try { pixels.rewind();
pixels.rewind(); Image jmeImg = new Image(Image.Format.RGBA8, width, height, pixels, null,
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); com.jme3.texture.image.ColorSpace.sRGB);
for (int y = 0; y < height; y++) { return new Texture2D(jmeImg);
for (int x = 0; x < width; x++) {
int r = pixels.get() & 0xFF, g = pixels.get() & 0xFF,
b = pixels.get() & 0xFF, a = pixels.get() & 0xFF;
img.setRGB(x, height - 1 - y, (a<<24)|(r<<16)|(g<<8)|b);
}
}
Path texDir = ASSET_ROOT.resolve(ThumbnailRenderer.IMPOSTOR_DIR);
Files.createDirectories(texDir);
File pngFile = texDir.resolve(name + ".png").toFile();
ImageIO.write(img, "PNG", pngFile);
try {
return (Texture2D) assets.loadTexture(ThumbnailRenderer.IMPOSTOR_DIR + "/" + name + ".png");
} catch (Exception ignored) {
pixels.rewind();
Image jmeImg = new Image(Image.Format.RGBA8, width, height, pixels, null,
com.jme3.texture.image.ColorSpace.sRGB);
return new Texture2D(jmeImg);
}
} catch (IOException e) {
log.error("[EzTree] Impostor-Fehler: {}", e.getMessage());
return null;
}
} }
private ByteBuffer combineAtlas(ByteBuffer[] passes) { private ByteBuffer combineAtlas(ByteBuffer[] passes) {
@@ -705,6 +684,51 @@ public class EzTreeState extends BaseAppState {
return g; return g;
} }
/**
* Setzt Vertex-Farben (R = Wind-Gewicht) für alle Geometrien eines Baum-Knotens.
* Blätter bekommen Gewicht 1.0 (volle Animation); Äste werden per Y-Position
* normiert (0 = Boden, 1 = Spitze), sodass höhere Äste stärker schwingen.
* Tree.vert liest den Wind-Weight aus inColor.r — ohne diesen Buffer bleibt windW=0.
*/
private static void addWindWeights(Node treeNode) {
for (Spatial child : treeNode.getChildren()) {
if (!(child instanceof Geometry g)) continue;
Mesh mesh = g.getMesh();
FloatBuffer pos = mesh.getFloatBuffer(VertexBuffer.Type.Position);
if (pos == null) continue;
pos.rewind();
int vCount = pos.limit() / 3;
float[] colors = new float[vCount * 4];
boolean isLeaf = g.getName().contains("leav") || g.getName().contains("leaf");
if (isLeaf) {
for (int i = 0; i < vCount; i++) {
colors[i * 4] = 1f;
colors[i * 4 + 3] = 1f;
}
} else {
float minY = Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
for (int i = 0; i < vCount; i++) {
pos.get();
float y = pos.get();
pos.get();
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
float range = maxY - minY;
pos.rewind();
for (int i = 0; i < vCount; i++) {
pos.get();
float y = pos.get();
pos.get();
colors[i * 4] = range > 0f ? (y - minY) / range : 0f;
colors[i * 4 + 3] = 1f;
}
}
mesh.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(colors));
}
}
private void exportTree(Node lodRoot, String fileName, String subPath) { private void exportTree(Node lodRoot, String fileName, String subPath) {
try { try {
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath); Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);

View File

@@ -1,6 +1,5 @@
package de.blight.editor.state; package de.blight.editor.state;
import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -9,8 +8,6 @@ import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import javax.imageio.ImageIO;
import com.jme3.app.Application; import com.jme3.app.Application;
import com.jme3.app.SimpleApplication; import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState; import com.jme3.app.state.BaseAppState;
@@ -116,8 +113,7 @@ public class PalmGeneratorState extends BaseAppState {
Texture t = assets.loadTexture(opts.leafTexture); Texture t = assets.loadTexture(opts.leafTexture);
int w = t.getImage().getWidth(); int w = t.getImage().getWidth();
int h = t.getImage().getHeight(); int h = t.getImage().getHeight();
boolean stemLeft = opts.leafTexture.contains("palm2"); opts.leafTextureAspect = (float) h / w; // Stiel entlang U → Aspekt = Höhe/Breite
opts.leafTextureAspect = stemLeft ? (float) h / w : (float) w / h;
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
@@ -353,36 +349,10 @@ public class PalmGeneratorState extends BaseAppState {
} }
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) { private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try { pixels.rewind();
pixels.rewind(); Image jmeImg = new Image(Image.Format.RGBA8, width, height,
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); pixels, null, com.jme3.texture.image.ColorSpace.sRGB);
for (int y = 0; y < height; y++) { return new Texture2D(jmeImg);
for (int x = 0; x < width; x++) {
int r = pixels.get() & 0xFF;
int g = pixels.get() & 0xFF;
int b = pixels.get() & 0xFF;
int a = pixels.get() & 0xFF;
img.setRGB(x, height - 1 - y, (a<<24)|(r<<16)|(g<<8)|b);
}
}
Path texDir = ASSET_ROOT.resolve(ThumbnailRenderer.IMPOSTOR_DIR);
Files.createDirectories(texDir);
File pngFile = texDir.resolve(name + ".png").toFile();
ImageIO.write(img, "PNG", pngFile);
log.info("[Palme] Impostor: {}", pngFile.getAbsolutePath());
try {
return (Texture2D) assets.loadTexture(ThumbnailRenderer.IMPOSTOR_DIR + "/" + name + ".png");
} catch (Exception loadEx) {
pixels.rewind();
Image jmeImg = new Image(Image.Format.RGBA8, width, height,
pixels, null, com.jme3.texture.image.ColorSpace.sRGB);
return new Texture2D(jmeImg);
}
} catch (IOException e) {
log.error("[Palme] Impostor-Fehler: {}", e.getMessage());
return null;
}
} }
// ── Export ──────────────────────────────────────────────────────────────── // ── Export ────────────────────────────────────────────────────────────────

View File

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

View File

@@ -1,17 +1,13 @@
package de.blight.editor.state; package de.blight.editor.state;
import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import javax.imageio.ImageIO;
import com.jme3.app.Application; import com.jme3.app.Application;
import com.jme3.app.SimpleApplication; import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState; import com.jme3.app.state.BaseAppState;
@@ -580,39 +576,13 @@ public class TreeGeneratorState extends BaseAppState {
return copy; return copy;
} }
// ── Impostor-PNG speichern ──────────────────────────────────────────────── // ── Impostor-Textur in-memory erzeugen ────────────────────────────────────
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) { private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try { pixels.rewind();
pixels.rewind(); Image jmeImg = new Image(Image.Format.RGBA8, width, height,
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); pixels, null, com.jme3.texture.image.ColorSpace.sRGB);
for (int y = 0; y < height; y++) { return new Texture2D(jmeImg);
for (int x = 0; x < width; x++) {
int r = pixels.get() & 0xFF;
int g = pixels.get() & 0xFF;
int b = pixels.get() & 0xFF;
int a = pixels.get() & 0xFF;
img.setRGB(x, height - 1 - y, (a<<24)|(r<<16)|(g<<8)|b);
}
}
Path texDir = ASSET_ROOT.resolve(ThumbnailRenderer.IMPOSTOR_DIR);
Files.createDirectories(texDir);
File pngFile = texDir.resolve(name + ".png").toFile();
ImageIO.write(img, "PNG", pngFile);
log.info("[Blight-Baum] Impostor: {}", pngFile.getAbsolutePath());
try {
return (Texture2D) assets.loadTexture(ThumbnailRenderer.IMPOSTOR_DIR + "/" + name + ".png");
} catch (Exception loadEx) {
pixels.rewind();
Image jmeImg = new Image(Image.Format.RGBA8, width, height,
pixels, null, com.jme3.texture.image.ColorSpace.sRGB);
return new Texture2D(jmeImg);
}
} catch (IOException e) {
log.error("[Blight-Baum] Impostor-Fehler: {}", e.getMessage());
return null;
}
} }
// ── .j3o-Export ─────────────────────────────────────────────────────────── // ── .j3o-Export ───────────────────────────────────────────────────────────

View File

@@ -236,7 +236,7 @@ public class PalmMeshBuilder {
float halfW = opts.leafTextureAspect > 0f float halfW = opts.leafTextureAspect > 0f
? scaledLength * opts.leafTextureAspect * 0.5f ? scaledLength * opts.leafTextureAspect * 0.5f
: opts.frondWidth * sizeScale * 0.5f; : opts.frondWidth * sizeScale * 0.5f;
boolean stemLeft = opts.leafTexture != null && opts.leafTexture.contains("palm2"); boolean stemLeft = true; // Stiel entlang U-Achse für alle Palmen-Texturen
float g = opts.gravity; float g = opts.gravity;
int base = acc.vertexCount; int base = acc.vertexCount;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,281 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.shape.Quad;
import de.blight.common.model.MainCharacter;
import de.blight.game.control.PlayerInputControl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Ertrink-Sequenz:
* 1. MONITORING Tiefe überwachen
* 2. FADING_OUT Tiefe > 1,5 m: Eingabe weg, Charakter läuft weiter,
* Bild + Ton in 3 s ausblenden
* 3. BLACK 2 s Schwarzbild, dann Teleport + HP/Mana/Stamina=1
* 4. WAIT_PHYSICS 1,5 s warten damit Terrain-Physik lädt
* 5. FADING_IN 3 s Einblenden (REVIVE eingefroren)
* 6. REVIVE_PLAYING REVIVE-Animation läuft durch
* → zurück zu MONITORING
*/
public class DrownState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(DrownState.class);
/** Tiefe (m) die den Ertrink-Vorgang auslöst. */
private static final float TRIGGER_DEPTH = 1.5f;
private static final float FADE_OUT_DUR = 3.0f;
private static final float BLACK_DUR = 2.0f;
private static final float WAIT_PHYSICS_DUR = 1.5f;
private static final float FADE_IN_DUR = 3.0f;
// Strand-Suche
private static final float MAX_BEACH_H = 5f;
private static final float MAX_SLOPE_DEG = 10f;
private static final float SCAN_STEP_R = 3f;
private static final float SCAN_MAX_R = 300f;
private static final int SCAN_DIRS = 16;
private final TerrainChunkState terrain;
private final CharacterControl physicsChar;
private final PlayerInputControl playerInput;
private final MainCharacter mainCharacter;
private SimpleApplication app;
private Geometry overlay;
private Material overlayMat;
/** Gesetzt von WorldScene nach setupAnimationContext(). */
private String reviveClip = null;
private float reviveLength = 0f;
private enum Phase { MONITORING, FADING_OUT, BLACK, WAIT_PHYSICS, FADING_IN, REVIVE_PLAYING }
private Phase phase = Phase.MONITORING;
private float timer = 0f;
public DrownState(TerrainChunkState terrain, CharacterControl physicsChar,
PlayerInputControl playerInput, MainCharacter mainCharacter) {
this.terrain = terrain;
this.physicsChar = physicsChar;
this.playerInput = playerInput;
this.mainCharacter = mainCharacter;
}
public void setReviveInfo(String clip, float length) {
this.reviveClip = clip;
this.reviveLength = length;
log.info("[DrownState] REVIVE-Info: clip='{}' {}s", clip, length);
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
float w = app.getCamera().getWidth();
float h = app.getCamera().getHeight();
overlay = new Geometry("drown_overlay", new Quad(w, h));
overlayMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
overlayMat.setColor("Color", new ColorRGBA(0f, 0f, 0f, 0f));
overlayMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
overlay.setMaterial(overlayMat);
overlay.setQueueBucket(RenderQueue.Bucket.Gui);
overlay.setLocalTranslation(0f, 0f, 50f);
}
@Override
protected void cleanup(Application application) {
detachOverlay();
restoreVolume();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
switch (phase) {
case MONITORING -> updateMonitoring();
case FADING_OUT -> updateFadingOut(tpf);
case BLACK -> updateBlack(tpf);
case WAIT_PHYSICS -> updateWaitPhysics(tpf);
case FADING_IN -> updateFadingIn(tpf);
case REVIVE_PLAYING -> updateRevivePlaying(tpf);
}
}
// ── MONITORING ────────────────────────────────────────────────────────────
private void updateMonitoring() {
if (physicsChar == null) return;
Vector3f pos = physicsChar.getPhysicsLocation();
float depth = Math.max(0f, -terrain.getHeightAt(pos.x, pos.z));
if (depth >= TRIGGER_DEPTH) {
log.info("[DrownState] Tiefe {}m Ertrink-Sequenz gestartet", depth);
playerInput.blockInputsKeepMoving();
attachOverlay();
setAlpha(0f);
setListenerScale(1f);
timer = FADE_OUT_DUR;
phase = Phase.FADING_OUT;
}
}
// ── FADING_OUT (3 s) ──────────────────────────────────────────────────────
private void updateFadingOut(float tpf) {
timer -= tpf;
float alpha = Math.max(0f, 1f - timer / FADE_OUT_DUR);
setAlpha(alpha);
setListenerScale(1f - alpha);
if (timer <= 0f) {
setAlpha(1f);
setListenerScale(0f);
timer = BLACK_DUR;
phase = Phase.BLACK;
}
}
// ── BLACK (2 s) ───────────────────────────────────────────────────────────
private void updateBlack(float tpf) {
timer -= tpf;
if (timer <= 0f) {
executeDrown();
}
}
private void executeDrown() {
// Vollständig blockieren (stoppt auch Laufrichtung)
playerInput.blockForIntro();
if (mainCharacter != null) {
mainCharacter.setCurrentHp(1);
mainCharacter.setCurrentMana(1);
mainCharacter.setCurrentStamina(1);
}
Vector3f pos = physicsChar.getPhysicsLocation();
Vector3f beach = findNearestBeach(pos.x, pos.z);
physicsChar.setPhysicsLocation(beach);
playerInput.setGroundGrace(60);
log.info("[DrownState] Teleportiert zu {}", beach);
timer = WAIT_PHYSICS_DUR;
phase = Phase.WAIT_PHYSICS;
}
// ── WAIT_PHYSICS (1,5 s) ──────────────────────────────────────────────────
private void updateWaitPhysics(float tpf) {
timer -= tpf;
if (timer <= 0f) {
if (reviveClip != null) {
playerInput.startFrozenRevive(reviveClip);
}
timer = FADE_IN_DUR;
phase = Phase.FADING_IN;
}
}
// ── FADING_IN (3 s) ───────────────────────────────────────────────────────
private void updateFadingIn(float tpf) {
timer -= tpf;
float alpha = Math.max(0f, timer / FADE_IN_DUR);
setAlpha(alpha);
setListenerScale(1f - alpha);
if (timer <= 0f) {
detachOverlay();
restoreVolume();
if (reviveClip != null) {
playerInput.unfreezeRevive();
timer = reviveLength;
phase = Phase.REVIVE_PLAYING;
} else {
playerInput.unblockInputs();
phase = Phase.MONITORING;
}
}
}
// ── REVIVE_PLAYING ────────────────────────────────────────────────────────
private void updateRevivePlaying(float tpf) {
timer -= tpf;
if (timer <= 0f) {
playerInput.unblockInputs();
phase = Phase.MONITORING;
log.info("[DrownState] Sequenz abgeschlossen Spieler freigegeben");
}
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void attachOverlay() {
if (overlay.getParent() == null) app.getGuiNode().attachChild(overlay);
}
private void detachOverlay() {
if (overlay != null && overlay.getParent() != null) app.getGuiNode().detachChild(overlay);
}
private void setAlpha(float alpha) {
overlayMat.setColor("Color", new ColorRGBA(0f, 0f, 0f, alpha));
}
private void setListenerScale(float scale) {
AudioSettingsState as = app.getStateManager().getState(AudioSettingsState.class);
float master = (as != null) ? as.getMaster() : 1f;
app.getListener().setVolume(master * Math.max(0f, Math.min(1f, scale)));
}
private void restoreVolume() {
AudioSettingsState as = app.getStateManager().getState(AudioSettingsState.class);
float master = (as != null) ? as.getMaster() : 1f;
app.getListener().setVolume(master);
}
/**
* Sucht radial vom Ausgangspunkt aus den nächsten flachen Landpunkt.
* Nahe Punkte liegen im selben Terrain-Chunk → Physik ist bereits geladen.
*/
private Vector3f findNearestBeach(float startX, float startZ) {
float maxSlope = (float) Math.tan(Math.toRadians(MAX_SLOPE_DEG));
float slopeStep = 4f;
double angleStep = 2 * Math.PI / SCAN_DIRS;
for (float r = SCAN_STEP_R; r <= SCAN_MAX_R; r += SCAN_STEP_R) {
for (int d = 0; d < SCAN_DIRS; d++) {
float x = startX + r * (float) Math.cos(d * angleStep);
float z = startZ + r * (float) Math.sin(d * angleStep);
float h = terrain.getHeightAt(x, z);
if (h < 0.2f || h > MAX_BEACH_H) continue;
float hxp = terrain.getHeightAt(x + slopeStep, z);
float hxn = terrain.getHeightAt(x - slopeStep, z);
float hzp = terrain.getHeightAt(x, z + slopeStep);
float hzn = terrain.getHeightAt(x, z - slopeStep);
float dhdx = (hxp - hxn) / (2f * slopeStep);
float dhdz = (hzp - hzn) / (2f * slopeStep);
float slope = (float) Math.sqrt(dhdx * dhdx + dhdz * dhdz);
if (slope > maxSlope) continue;
log.info("[DrownState] Strandpunkt: ({}, {}), h={}, r={}m", x, z, h, r);
return new Vector3f(x, h + 1.0f, z);
}
}
log.warn("[DrownState] Kein Strandpunkt gefunden Fallback");
return new Vector3f(startX, Math.max(terrain.getHeightAt(startX, startZ), 0f) + 3f, startZ);
}
}

View File

@@ -0,0 +1,286 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.texture.Texture;
import com.jme3.ui.Picture;
import com.jme3.util.BufferUtils;
import de.blight.common.model.MainCharacter;
import de.blight.game.animation.AnimationLibrary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* HUD-Balken (unten rechts): Health (rot), Stamina (gelb), Mana (blau).
* PNG-basiert Dateien in Textures/hud/ können durch eigene Grafiken ersetzt werden.
*/
public class HudState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(HudState.class);
// Dimensionen des Fülbereichs (ohne Rahmen)
private static final float BAR_W = 200f;
private static final float BAR_H = 18f;
private static final float BORDER = 2f;
private static final float MARGIN = 12f;
private static final float GAP = 5f;
private static final float SLOT_W = BAR_W + BORDER * 2;
private static final float SLOT_H = BAR_H + BORDER * 2;
private static final String[] FILL_ASSETS = {
"Textures/hud/bar_fill_health.png",
"Textures/hud/bar_fill_stamina.png",
"Textures/hud/bar_fill_mana.png",
};
private static final ColorRGBA[] FILL_COLORS = {
new ColorRGBA(0.85f, 0.10f, 0.10f, 1f),
new ColorRGBA(0.90f, 0.80f, 0.10f, 1f),
new ColorRGBA(0.10f, 0.30f, 0.90f, 1f),
};
private final MainCharacter mc;
private SimpleApplication app;
private Node hudNode;
private final Geometry[] fills = new Geometry[3];
private final float[] lastRatios = { -1f, -1f, -1f };
public HudState(MainCharacter mc) {
this.mc = mc;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
ensureAssets();
hudNode = new Node("hud_bars");
buildBars();
app.getGuiNode().attachChild(hudNode);
}
@Override
protected void cleanup(Application application) {
if (hudNode.getParent() != null) {
app.getGuiNode().detachChild(hudNode);
}
}
@Override
protected void onEnable() {
hudNode.setCullHint(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
hudNode.setCullHint(Spatial.CullHint.Always);
}
@Override
public void update(float tpf) {
if (mc == null) return;
int maxHp = mc.getMaxHp();
int maxSt = mc.getMaxStamina();
int maxMn = mc.getMaxMana();
applyRatio(0, maxHp > 0 ? (float) mc.getCurrentHp() / maxHp : 0f);
applyRatio(1, maxSt > 0 ? (float) mc.getCurrentStamina() / maxSt : 0f);
applyRatio(2, maxMn > 0 ? (float) mc.getCurrentMana() / maxMn : 0f);
}
// ── Aufbau ───────────────────────────────────────────────────────────────────
private void buildBars() {
float screenW = app.getCamera().getWidth();
// Stapelung von unten: idx=2 (mana) ganz unten, idx=0 (health) ganz oben
for (int i = 0; i < 3; i++) {
int stackPos = 2 - i; // health → stack 2 (höchste Y), mana → stack 0
float x = screenW - MARGIN - SLOT_W;
float y = MARGIN + stackPos * (SLOT_H + GAP);
buildBar(i, x, y);
}
}
private void buildBar(int idx, float x, float y) {
// Rahmen (Frame) transparent innen, Rand sichtbar
Picture frame = loadPicture("Textures/hud/bar_frame.png", SLOT_W, SLOT_H,
new ColorRGBA(0.65f, 0.65f, 0.65f, 1f));
frame.setLocalTranslation(x, y, 1f);
hudNode.attachChild(frame);
// Hintergrund (schwarz)
Picture bg = loadPicture("Textures/hud/bar_bg.png", BAR_W, BAR_H, ColorRGBA.Black);
bg.setLocalTranslation(x + BORDER, y + BORDER, 2f);
hudNode.attachChild(bg);
// Füllbalken (UV-geclippt, kein Strecken des Textur-Gradienten)
Geometry fill = buildFillGeometry(idx, x + BORDER, y + BORDER, 3f);
fills[idx] = fill;
hudNode.attachChild(fill);
}
private Geometry buildFillGeometry(int idx, float x, float y, float z) {
Mesh mesh = new Mesh();
fillMesh(mesh, BAR_W, 1f); // initial: voll
Material mat;
try {
Texture tex = app.getAssetManager().loadTexture(FILL_ASSETS[idx]);
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", FILL_COLORS[idx]);
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
Geometry g = new Geometry("hud_fill_" + idx, mesh);
g.setMaterial(mat);
g.setQueueBucket(RenderQueue.Bucket.Gui);
g.setLocalTranslation(x, y, z);
return g;
}
/** Setzt Breite und UV-Clipping des Fill-Meshes auf den gegebenen Ratio (01). */
private void applyRatio(int idx, float ratio) {
ratio = Math.max(0f, Math.min(1f, ratio));
if (Math.abs(ratio - lastRatios[idx]) < 0.001f) return;
lastRatios[idx] = ratio;
Geometry g = fills[idx];
float w = BAR_W * ratio;
Mesh mesh = g.getMesh();
FloatBuffer pos = (FloatBuffer) mesh.getBuffer(VertexBuffer.Type.Position).getData();
pos.put(3, w); // Vertex 1: x
pos.put(6, w); // Vertex 2: x
mesh.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded();
// UV-Clipping: nur ratio-Anteil der Textur sichtbar (kein Strecken)
FloatBuffer uv = (FloatBuffer) mesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(2, ratio); // UV 1: u
uv.put(4, ratio); // UV 2: u
mesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
mesh.updateBound();
}
/** Erstellt ein Quad-Mesh mit UV-Koordinaten die bei maxU enden (für Clipping). */
private static void fillMesh(Mesh mesh, float w, float maxU) {
float h = BAR_H;
mesh.setBuffer(VertexBuffer.Type.Position, 3,
BufferUtils.createFloatBuffer(
0, 0, 0,
w, 0, 0,
w, h, 0,
0, h, 0
));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2,
BufferUtils.createFloatBuffer(
0f, 0f,
maxU, 0f,
maxU, 1f,
0f, 1f
));
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createShortBuffer((short)0,(short)1,(short)2,(short)0,(short)2,(short)3));
mesh.setMode(Mesh.Mode.Triangles);
mesh.updateBound();
}
// ── PNG-Loader ────────────────────────────────────────────────────────────────
private Picture loadPicture(String assetPath, float w, float h, ColorRGBA fallback) {
Picture p = new Picture("hud_" + assetPath);
try {
p.setImage(app.getAssetManager(), assetPath, true);
} catch (Exception e) {
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", fallback);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
p.setMaterial(mat);
}
p.setWidth(w);
p.setHeight(h);
p.setQueueBucket(RenderQueue.Bucket.Gui);
return p;
}
// ── Placeholder-PNGs erzeugen ─────────────────────────────────────────────────
private void ensureAssets() {
Path root = AnimationLibrary.findAssetRoot();
Path dir = root.resolve("Textures").resolve("hud");
try {
Files.createDirectories(dir);
} catch (IOException e) {
log.warn("[HUD] Verzeichnis nicht erstellbar: {}", dir);
return;
}
// Rahmen: grauer Rand, transparente Mitte
ensureFrame(dir.resolve("bar_frame.png"), (int) SLOT_W, (int) SLOT_H, (int) BORDER,
180, 180, 180);
// Hintergrund: schwarz
ensureSolid(dir.resolve("bar_bg.png"), 4, 4, 0, 0, 0, 255);
// Füllfarben (kleine Kacheln, werden skaliert/geclippt)
ensureSolid(dir.resolve("bar_fill_health.png"), 4, 4, 217, 25, 25, 255);
ensureSolid(dir.resolve("bar_fill_stamina.png"), 4, 4, 229, 204, 25, 255);
ensureSolid(dir.resolve("bar_fill_mana.png"), 4, 4, 25, 76, 229, 255);
}
private static void ensureSolid(Path path, int w, int h,
int r, int g, int b, int a) {
if (Files.exists(path)) return;
try {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
int argb = (a << 24) | (r << 16) | (g << 8) | b;
for (int py = 0; py < h; py++) {
for (int px = 0; px < w; px++) {
img.setRGB(px, py, argb);
}
}
ImageIO.write(img, "PNG", path.toFile());
} catch (Exception e) {
log.warn("[HUD] PNG nicht erstellbar: {}", path);
}
}
private static void ensureFrame(Path path, int w, int h, int border,
int r, int g, int b) {
if (Files.exists(path)) return;
try {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
int borderArgb = (255 << 24) | (r << 16) | (g << 8) | b;
for (int py = 0; py < h; py++) {
for (int px = 0; px < w; px++) {
boolean isBorder = px < border || py < border
|| px >= w - border || py >= h - border;
img.setRGB(px, py, isBorder ? borderArgb : 0);
}
}
ImageIO.write(img, "PNG", path.toFile());
} catch (Exception e) {
log.warn("[HUD] Frame-PNG nicht erstellbar: {}", path);
}
}
}

View File

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

Binary file not shown.

View File

@@ -0,0 +1,420 @@
/*
* Copyright (c) 2009-2025 jMonkeyEngine
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* * Neither the name of 'jMonkeyEngine' nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
* TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.jme3.app;
import com.jme3.app.state.AppState;
import com.jme3.app.state.ConstantVerifierState;
import com.jme3.audio.AudioListenerState;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.input.FlyByCamera;
import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.profile.AppStep;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.queue.RenderQueue.Bucket;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial.CullHint;
import com.jme3.scene.threadwarden.SceneGraphThreadWarden;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeContext.Type;
import com.jme3.system.JmeSystem;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* `SimpleApplication` is the foundational base class for all jMonkeyEngine 3 (jME3) applications.
* It provides a streamlined setup for common game development tasks, including scene management,
* camera controls, and performance monitoring.
*
* <p>By default, `SimpleApplication` attaches several essential {@link com.jme3.app.state.AppState} instances:
* <ul>
* <li>{@link com.jme3.app.StatsAppState}: Displays real-time frames-per-second (FPS) and
* detailed performance statistics on-screen.</li>
* <li>{@link com.jme3.app.FlyCamAppState}: Provides a convenient first-person fly-by camera
* controller, allowing easy navigation within the scene.</li>
* <li>{@link com.jme3.audio.AudioListenerState}: Manages the audio listener, essential for 3D sound.</li>
* <li>{@link com.jme3.app.DebugKeysAppState}: Enables debug functionalities like displaying
* camera position and memory usage in the console.</li>
* <li>{@link com.jme3.app.state.ConstantVerifierState}: A utility state for verifying constant
* values, primarily for internal engine debugging.</li>
* </ul>
*
* <p><b>Default Key Bindings:</b></p>
* <ul>
* <li><b>Esc:</b> Closes and exits the application.</li>
* <li><b>F5:</b> Toggles the visibility of the statistics view (FPS and debug stats).</li>
* <li><b>C:</b> Prints the current camera position and rotation to the console.</li>
* <li><b>M:</b> Prints memory usage statistics to the console.</li>
* </ul>
*
* <p>Applications extending `SimpleApplication` should implement the
* {@link #simpleInitApp()} method to set up their initial scene and game logic.
*/
public abstract class SimpleApplication extends LegacyApplication {
protected static final Logger logger = Logger.getLogger(SimpleApplication.class.getName());
public static final String INPUT_MAPPING_EXIT = "SIMPLEAPP_Exit";
public static final String INPUT_MAPPING_CAMERA_POS = DebugKeysAppState.INPUT_MAPPING_CAMERA_POS;
public static final String INPUT_MAPPING_MEMORY = DebugKeysAppState.INPUT_MAPPING_MEMORY;
public static final String INPUT_MAPPING_HIDE_STATS = "SIMPLEAPP_HideStats";
protected Node rootNode = new Node("Root Node");
protected Node guiNode = new Node("Gui Node");
protected BitmapText fpsText;
protected BitmapFont guiFont;
protected FlyByCamera flyCam;
protected boolean showSettings = true;
private final AppActionListener actionListener = new AppActionListener();
private class AppActionListener implements ActionListener {
@Override
public void onAction(String name, boolean isPressed, float tpf) {
if (!isPressed) {
return;
}
if (name.equals(INPUT_MAPPING_EXIT)) {
stop();
} else if (name.equals(INPUT_MAPPING_HIDE_STATS)) {
StatsAppState statsState = stateManager.getState(StatsAppState.class);
if (statsState != null) {
statsState.toggleStats();
}
}
}
}
/**
* Constructs a `SimpleApplication` with a predefined set of default
* {@link com.jme3.app.state.AppState} instances.
* These states provide common functionalities like statistics display,
* fly camera control, audio listener, debug keys, and constant verification.
*/
public SimpleApplication() {
this(new StatsAppState(),
new FlyCamAppState(),
new AudioListenerState(),
new DebugKeysAppState(),
new ConstantVerifierState());
}
/**
* Constructs a `SimpleApplication` with a custom array of initial
* {@link com.jme3.app.state.AppState} instances.
*
* @param initialStates An array of `AppState` instances to be attached
* to the `stateManager` upon initialization.
*/
public SimpleApplication(AppState... initialStates) {
super(initialStates);
}
@Override
public void start() {
// set some default settings in-case
// settings dialog is not shown
boolean loadSettings = false;
if (settings == null) {
logger.log(Level.INFO, "AppSettings not set, creating default settings.");
setSettings(new AppSettings(true));
loadSettings = true;
}
// show settings dialog
if (showSettings) {
if (!JmeSystem.showSettingsDialog(settings, loadSettings)) {
return;
}
}
//re-setting settings they can have been merged from the registry.
setSettings(settings);
super.start();
}
/**
* Returns the current speed multiplier of the application.
* This value affects how quickly the game world updates relative to real time.
* A value of 1.0f means normal speed, 0.5f means half speed, 2.0f means double speed.
*
* @return The current speed of the application.
*/
public float getSpeed() {
return speed;
}
/**
* Changes the application's speed multiplier.
* A `speed` of 0.0f effectively pauses the application's update cycle.
*
* @param speed The desired speed multiplier. A value of 1.0f is normal speed.
* Must be non-negative.
*/
public void setSpeed(float speed) {
this.speed = speed;
}
/**
* Retrieves the `FlyByCamera` instance associated with this application.
* This camera allows free-form navigation within the 3D scene.
*
* @return The `FlyByCamera` object, or `null` if `FlyCamAppState` is not attached
* or has not yet initialized the camera.
*/
public FlyByCamera getFlyByCamera() {
return flyCam;
}
/**
* Retrieves the `Node` dedicated to 2D graphical user interface (GUI) elements.
* Objects attached to this node are rendered on top of the 3D scene,
* typically without perspective effects, suitable for HUDs and UI.
*
* @return The `Node` object representing the GUI root.
*/
public Node getGuiNode() {
return guiNode;
}
/**
* Retrieves the root `Node` of the 3D scene graph.
* All main 3D spatial objects and models should be attached to this node
* to be part of the rendered scene.
*
* @return The `Node` object representing the 3D scene root.
*/
public Node getRootNode() {
return rootNode;
}
/**
* Checks whether the settings dialog is configured to be shown at application startup.
*
* @return `true` if the settings dialog will be displayed, `false` otherwise.
*/
public boolean isShowSettings() {
return showSettings;
}
/**
* Sets whether the jME3 settings dialog should be displayed before the application starts.
*
* @param showSettings `true` to show the settings dialog, `false` to suppress it.
*/
public void setShowSettings(boolean showSettings) {
this.showSettings = showSettings;
}
/**
* Creates the font that will be set to the guiFont field
* and subsequently set as the font for the stats text.
*
* @return the loaded BitmapFont
*/
protected BitmapFont loadGuiFont() {
return assetManager.loadFont("Interface/Fonts/Default.fnt");
}
@Override
public void initialize() {
super.initialize();
//noinspection AssertWithSideEffects
assert SceneGraphThreadWarden.setup(rootNode);
//noinspection AssertWithSideEffects
assert SceneGraphThreadWarden.setup(guiNode);
// Several things rely on having this
guiFont = loadGuiFont();
guiNode.setQueueBucket(Bucket.Gui);
guiNode.setCullHint(CullHint.Never);
viewPort.attachScene(rootNode);
guiViewPort.attachScene(guiNode);
if (inputManager != null) {
// Special handling for FlyCamAppState:
// Although FlyCamAppState manages the FlyByCamera, SimpleApplication
// historically initializes and configures a default FlyByCamera instance
// and sets its initial speed. This allows subclasses to directly access
// 'flyCam' early in simpleInitApp().
FlyCamAppState flyCamState = stateManager.getState(FlyCamAppState.class);
if (flyCamState != null) {
flyCam = new FlyByCamera(cam);
flyCam.setMoveSpeed(1f); // Set a default movement speed for the camera
flyCamState.setCamera(flyCam); // Link the FlyCamAppState to this camera instance
}
// Register the "Exit" input mapping for the Escape key, but only for Display contexts.
if (context.getType() == Type.Display) {
inputManager.addMapping(INPUT_MAPPING_EXIT, new KeyTrigger(KeyInput.KEY_ESCAPE));
}
// Register the "Hide Stats" input mapping for the F5 key, if StatsAppState is active.
StatsAppState statsState = stateManager.getState(StatsAppState.class);
if (statsState != null) {
inputManager.addMapping(INPUT_MAPPING_HIDE_STATS, new KeyTrigger(KeyInput.KEY_F5));
inputManager.addListener(actionListener, INPUT_MAPPING_HIDE_STATS);
}
// Attach the action listener to the "Exit" mapping.
inputManager.addListener(actionListener, INPUT_MAPPING_EXIT);
}
// Configure the StatsAppState if it exists.
StatsAppState statsState = stateManager.getState(StatsAppState.class);
if (statsState != null) {
statsState.setFont(guiFont);
fpsText = statsState.getFpsText();
}
// Call the user's application initialization code.
simpleInitApp();
}
@Override
public void stop(boolean waitFor) {
//noinspection AssertWithSideEffects
assert SceneGraphThreadWarden.reset();
super.stop(waitFor);
}
@Override
public void update() {
if (prof != null) {
prof.appStep(AppStep.BeginFrame);
}
// Executes AppTasks from the main thread
super.update();
// Skip updates if paused or speed is zero
if (speed == 0 || paused) {
return;
}
float tpf = timer.getTimePerFrame() * speed;
// Update AppStates
if (prof != null) {
prof.appStep(AppStep.StateManagerUpdate);
}
stateManager.update(tpf);
// Call user's per-frame update method
simpleUpdate(tpf);
// Update scene graph nodes (logical and geometric states)
if (prof != null) {
prof.appStep(AppStep.SpatialUpdate);
}
rootNode.updateLogicalState(tpf);
guiNode.updateLogicalState(tpf);
rootNode.updateGeometricState();
guiNode.updateGeometricState();
// Render AppStates and the scene
if (prof != null) {
prof.appStep(AppStep.StateManagerRender);
}
stateManager.render(renderManager);
if (prof != null) {
prof.appStep(AppStep.RenderFrame);
}
renderManager.render(tpf, context.isRenderable());
// Call user's custom render method
simpleRender(renderManager);
stateManager.postRender();
if (prof != null) {
prof.appStep(AppStep.EndFrame);
}
}
/**
* Controls the visibility of the frames-per-second (FPS) display on the screen.
*
* @param show `true` to display the FPS, `false` to hide it.
*/
public void setDisplayFps(boolean show) {
StatsAppState statsState = stateManager.getState(StatsAppState.class);
if (statsState != null) {
statsState.setDisplayFps(show);
}
}
/**
* Controls the visibility of the comprehensive statistics view on the screen.
* This view typically includes details about memory, triangles, and other performance metrics.
*
* @param show `true` to display the statistics view, `false` to hide it.
*/
public void setDisplayStatView(boolean show) {
StatsAppState statsState = stateManager.getState(StatsAppState.class);
if (statsState != null) {
statsState.setDisplayStatView(show);
}
}
public abstract void simpleInitApp();
/**
* An optional method that can be overridden by subclasses for per-frame update logic.
* This method is called during the application's update loop, after AppStates are updated
* and before the scene graph's logical state is updated.
*
* @param tpf The time per frame (in seconds), adjusted by the application's speed.
*/
public void simpleUpdate(float tpf) {
// Default empty implementation; subclasses can override
}
/**
* An optional method that can be overridden by subclasses for custom rendering logic.
* This method is called during the application's render loop, after the main scene
* has been rendered and before post-rendering for states.
* Useful for drawing overlays or specific rendering tasks outside the main scene graph.
*
* @param rm The `RenderManager` instance, which provides access to rendering functionalities.
*/
public void simpleRender(RenderManager rm) {
// Default empty implementation; subclasses can override
}
}