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

View File

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

View File

@@ -36,10 +36,9 @@ import de.blight.eztree.TreeOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
@@ -122,10 +121,12 @@ public class EzTreeState extends BaseAppState {
Node hdNode = tryNodeJsGeneration(req);
if (hdNode == null) hdNode = javaFallback(req);
addWindWeights(hdNode);
hdNode.setLocalScale(1f / 3f);
hdNode.updateGeometricState();
Node ld1Node = buildLod1Node(req);
addWindWeights(ld1Node);
ld1Node.setLocalScale(1f / 3f);
BoundingBox bb = boundsOf(hdNode);
@@ -596,33 +597,11 @@ public class EzTreeState extends BaseAppState {
}
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try {
pixels.rewind();
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
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) {
ByteBuffer atlas = BufferUtils.createByteBuffer(ATLAS_W * ATLAS_H * 4);
@@ -705,6 +684,51 @@ public class EzTreeState extends BaseAppState {
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) {
try {
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);

View File

@@ -1,6 +1,5 @@
package de.blight.editor.state;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -9,8 +8,6 @@ import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.imageio.ImageIO;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
@@ -116,8 +113,7 @@ public class PalmGeneratorState extends BaseAppState {
Texture t = assets.loadTexture(opts.leafTexture);
int w = t.getImage().getWidth();
int h = t.getImage().getHeight();
boolean stemLeft = opts.leafTexture.contains("palm2");
opts.leafTextureAspect = stemLeft ? (float) h / w : (float) w / h;
opts.leafTextureAspect = (float) h / w; // Stiel entlang U → Aspekt = Höhe/Breite
} catch (Exception ignored) {}
}
@@ -353,37 +349,11 @@ public class PalmGeneratorState extends BaseAppState {
}
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try {
pixels.rewind();
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
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 ────────────────────────────────────────────────────────────────

View File

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

View File

@@ -1,17 +1,13 @@
package de.blight.editor.state;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.imageio.ImageIO;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
@@ -580,40 +576,14 @@ public class TreeGeneratorState extends BaseAppState {
return copy;
}
// ── Impostor-PNG speichern ────────────────────────────────────────────────
// ── Impostor-Textur in-memory erzeugen ────────────────────────────────────
private Texture2D saveImpostor(ByteBuffer pixels, String name, int width, int height) {
try {
pixels.rewind();
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < height; y++) {
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 ───────────────────────────────────────────────────────────

View File

@@ -236,7 +236,7 @@ public class PalmMeshBuilder {
float halfW = opts.leafTextureAspect > 0f
? scaledLength * opts.leafTextureAspect * 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;
int base = acc.vertexCount;

View File

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

View File

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

View File

@@ -92,6 +92,7 @@ public class WorldScene extends BaseAppState {
private de.blight.game.state.OceanSoundState oceanSound;
private de.blight.game.state.AmbientSoundSystem ambientSounds;
private de.blight.game.audio.FootstepSystem footstepSystem;
private de.blight.game.state.DrownState drownState;
public WorldScene(KeyBindings keyBindings) {
this.keyBindings = keyBindings;
@@ -107,6 +108,11 @@ public class WorldScene extends BaseAppState {
*/
public float[] querySurfaceWeights(float worldX, float worldZ) {
float[] result = new float[de.blight.game.audio.SurfaceType.values().length];
// Wasser hat Vorrang vor Splatmap: Terrainoberfläche unter Wasserstand → WATER
if (terrainChunkState != null && terrainChunkState.getHeightAt(worldX, worldZ) < 0f) {
result[de.blight.game.audio.SurfaceType.WATER.ordinal()] = 1.0f;
return result;
}
if (loadedMapData == null) return result;
int size = de.blight.common.MapData.SPLAT_SIZE;
@@ -169,6 +175,12 @@ public class WorldScene extends BaseAppState {
return result;
}
/** Liefert die Wassertiefe an (worldX, worldZ) in Metern; 0 wenn an Land. */
public float queryWaterDepth(float worldX, float worldZ) {
if (terrainChunkState == null) return 0f;
return Math.max(0f, -terrainChunkState.getHeightAt(worldX, worldZ));
}
/** Wird von ConfigScreen nach dem Speichern aufgerufen. */
public void reloadBindings(KeyBindings kb) {
if (playerInput != null) playerInput.reloadBindings(kb);
@@ -254,6 +266,20 @@ public class WorldScene extends BaseAppState {
footstepSystem = new de.blight.game.audio.FootstepSystem(assetManager, rootNode, audioSettings,
AnimationLibrary.findAssetRoot());
playerInput.setFootstepSystem(footstepSystem, this::querySurfaceWeights);
playerInput.setWaterDepthQuery(this::queryWaterDepth);
try {
com.jme3.audio.AudioNode wadingNode = new com.jme3.audio.AudioNode(
assetManager, "audio/footsteps/water/wading.ogg", com.jme3.audio.AudioData.DataType.Buffer);
wadingNode.setLooping(true);
wadingNode.setPositional(false);
de.blight.game.state.AudioSettingsState _as =
app.getStateManager().getState(de.blight.game.state.AudioSettingsState.class);
wadingNode.setVolume(_as != null ? _as.effectiveEffects() : 0.7f);
rootNode.attachChild(wadingNode);
playerInput.setWadingSound(wadingNode);
} catch (Exception e) {
log.warn("[WorldScene] wading.ogg nicht ladbar Watgeräusch deaktiviert: {}", e.getMessage());
}
// Navigation: PathFinder + Terrain bereitstellen (Navigator wird in setAnimationContext erstellt)
try {
@@ -286,8 +312,14 @@ public class WorldScene extends BaseAppState {
inventoryState = new InventoryState(mc, keyBindings);
inventoryState.setEnabled(false);
app.getStateManager().attach(inventoryState);
app.getStateManager().attach(new de.blight.game.state.HudState(mc));
}
// Ertrinken-System (Wassertiefe > 1,8 m → Teleport zum Strand)
MainCharacter drownMc = findMainCharacter();
drownState = new de.blight.game.state.DrownState(terrainChunkState, physicsChar, playerInput, drownMc);
app.getStateManager().attach(drownState);
// Maus einfangen keine Klick-Pflicht für Kamerasteuerung
app.getInputManager().setCursorVisible(false);
}
@@ -421,10 +453,16 @@ public class WorldScene extends BaseAppState {
playerInput.setInitialFacing(spawnYaw);
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();
// 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"))) {
app.getStateManager().attach(
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,11 +10,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Spielt Wellensounds positional ab. Die AudioNodes werden EINMALIG nach dem
* ersten gültigen Scan positioniert (vermeidet den initialen Sprung von (0,0,0)
* zur Ozean-Kante, der OpenAL-Knistern verursacht). Danach wird die Position
* nur noch alle SCAN_INTERVAL Sekunden aktualisiert, wenn sich der Ozean
* signifikant verschoben hat.
* Spielt Wellensounds mit korrektem L/R-Panning ab.
*
* Strategie: Je Sound zwei Quellen (L + R), die immer genau PAN_DIST Meter
* links/rechts der Kamera positioniert sind (±90° Azimuth).
* Das Lautstärkeverhältnis (constant-power Panning) bestimmt die wahrgenommene
* Richtung. So entsteht kein HRTF-0°-Problem: Wasser direkt vorne → beide
* Lautsprecher gleich laut (Phantommitte); Wasser links → links lauter.
*/
public class OceanSoundState extends BaseAppState {
@@ -26,8 +28,11 @@ public class OceanSoundState extends BaseAppState {
private static final float FADE_RATE = 1f / 3f;
private static final float SCAN_INTERVAL = 0.25f;
private static final float SCAN_STEP = 5f;
/** Mindestverschiebung (m²) bevor die Node-Position aktualisiert wird. */
private static final float POS_UPDATE_SQ = 4f * 4f; // 4 Meter
/**
* Abstand Source↔Listener. Muss < refDistance (10 m JME3-Default) sein,
* damit Clamped-Inverse-Formel Gain = 1.0 liefert.
*/
private static final float PAN_DIST = 2f;
private static final float[] DIR_X = { 0f, 0.707f, 1f, 0.707f, 0f, -0.707f, -1f, -0.707f };
private static final float[] DIR_Z = { 1f, 0.707f, 0f, -0.707f, -1f, -0.707f, 0f, 0.707f };
@@ -35,16 +40,16 @@ public class OceanSoundState extends BaseAppState {
private final TerrainChunkState terrain;
private SimpleApplication app;
private AudioNode nodeCalmSound;
private AudioNode nodeStormySound;
// L/R-Paare: beide spielen dieselbe Datei, immer auf ±90° der Kamera
private AudioNode nodeCalmL, nodeCalmR;
private AudioNode nodeStormyL, nodeStormyR;
private final Vector3f playerPos = new Vector3f();
private final Vector3f targetPos = new Vector3f();
private final Vector3f nodePos = new Vector3f(); // zuletzt gesetzte Node-Position
private final Vector3f oceanPos = new Vector3f();
/** true bis der erste gültige Scan die Nodes positioniert und abgespielt hat. */
private boolean firstScan = true;
private boolean playing = false;
private boolean oceanFound = false;
private float calmVol = 0f;
private float stormyVol = 0f;
@@ -59,15 +64,18 @@ public class OceanSoundState extends BaseAppState {
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
nodeCalmSound = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm");
nodeStormySound = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy");
// Noch NICHT abspielen erst wenn wir eine gültige Position haben (firstScan).
nodeCalmL = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_L");
nodeCalmR = loadLoop("audio/ambient/water/waves_calm.ogg", "waves_calm_R");
nodeStormyL = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_L");
nodeStormyR = loadLoop("audio/ambient/water/waves_stormy.ogg", "waves_stormy_R");
}
@Override
protected void cleanup(Application application) {
stop(nodeCalmSound);
stop(nodeStormySound);
stop(nodeCalmL);
stop(nodeCalmR);
stop(nodeStormyL);
stop(nodeStormyR);
}
@Override protected void onEnable() {}
@@ -79,16 +87,24 @@ public class OceanSoundState extends BaseAppState {
@Override
public void update(float tpf) {
if (nodeCalmSound == null && nodeStormySound == null) return;
if (nodeCalmL == null && nodeStormyL == null) {
return;
}
// Periodisch nächste Ozean-Position berechnen
scanTimer -= tpf;
if (scanTimer <= 0f) {
scanTimer = SCAN_INTERVAL;
scanOceanSource();
}
// Lautstärke
boolean playerInWater = terrain.getHeightAt(playerPos.x, playerPos.z) < 0f;
if (playerInWater) {
applyVolumes(0f, 0f, 0.707f, 0.707f);
calmVol = 0f;
stormyVol = 0f;
return;
}
WeatherState weather = getApplication().getStateManager().getState(WeatherState.class);
float wind = weather != null ? weather.getWindSpeed() : 4f;
@@ -105,11 +121,25 @@ public class OceanSoundState extends BaseAppState {
calmVol = approach(calmVol, calmTarget, FADE_RATE * tpf);
stormyVol = approach(stormyVol, stormyTarget, FADE_RATE * tpf);
if (nodeCalmSound != null) nodeCalmSound.setVolume(calmVol);
if (nodeStormySound != null) nodeStormySound.setVolume(stormyVol);
float panL = 0.707f, panR = 0.707f; // default: center
if (playing && oceanFound) {
float panValue = computePan(); // -1 = links, 0 = Mitte, +1 = rechts
panL = (float) Math.sqrt((1f - panValue) / 2f);
panR = (float) Math.sqrt((1f + panValue) / 2f);
updateSourcePositions();
}
// ── Scan ────────────────────────────────────────────────────────────────
applyVolumes(calmVol, stormyVol, panL, panR);
}
private void applyVolumes(float calm, float stormy, float panL, float panR) {
if (nodeCalmL != null) nodeCalmL.setVolume(calm * panL);
if (nodeCalmR != null) nodeCalmR.setVolume(calm * panR);
if (nodeStormyL != null) nodeStormyL.setVolume(stormy * panL);
if (nodeStormyR != null) nodeStormyR.setVolume(stormy * panR);
}
// ── Scan ──────────────────────────────────────────────────────────────────
private void scanOceanSource() {
float px = playerPos.x;
@@ -118,7 +148,6 @@ public class OceanSoundState extends BaseAppState {
if (terrain.getHeightAt(px, pz) < 0f) {
oceanInRange = true;
oceanDist = 0f;
applyTarget(px, pz);
return;
}
@@ -151,53 +180,78 @@ public class OceanSoundState extends BaseAppState {
}
}
/**
* Zielposition setzen. Beim ersten Scan: Nodes positionieren und Wiedergabe starten.
* Danach: Node nur aktualisieren wenn Verschiebung > POS_UPDATE_SQ.
*/
private void applyTarget(float x, float z) {
targetPos.set(x, 0f, z);
oceanPos.set(x, playerPos.y, z);
oceanFound = true;
if (firstScan) {
// Beim ersten gültigen Scan: Nodes korrekt platzieren, dann erst abspielen.
firstScan = false;
nodePos.set(targetPos);
if (nodeCalmSound != null) {
app.getRootNode().attachChild(nodeCalmSound);
nodeCalmSound.setLocalTranslation(nodePos);
nodeCalmSound.play();
}
if (nodeStormySound != null) {
app.getRootNode().attachChild(nodeStormySound);
nodeStormySound.setLocalTranslation(nodePos);
nodeStormySound.play();
}
attachAndPlay(nodeCalmL);
attachAndPlay(nodeCalmR);
attachAndPlay(nodeStormyL);
attachAndPlay(nodeStormyR);
playing = true;
}
}
private void attachAndPlay(AudioNode node) {
if (node == null) {
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);
app.getRootNode().attachChild(node);
node.play();
}
// ── Hilfsmethoden ───────────────────────────────────────────────────────
// ── Panning ───────────────────────────────────────────────────────────────
/**
* Pan-Wert: -1 = Ozean voll links der Kamera, 0 = Mitte, +1 = voll rechts.
* Berechnet als Dot-Produkt der horizontalen Ozean-Richtung mit dem
* Kamera-Rechtsvektor — unabhängig davon, ob Kamera zum Wasser zeigt.
*/
private float computePan() {
float dx = oceanPos.x - playerPos.x;
float dz = oceanPos.z - playerPos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.1f) {
return 0f;
}
dx /= len;
dz /= len;
Vector3f camLeft = app.getCamera().getLeft(); // camRight = -camLeft
float rightComp = -(dx * camLeft.x + dz * camLeft.z);
return Math.max(-1f, Math.min(1f, rightComp));
}
/**
* Hält L-Quelle PAN_DIST Meter links der Kamera, R-Quelle PAN_DIST Meter rechts.
* → Immer ±90° Azimuth zum Listener, nie 0°/180° → kein HRTF-Nullpunkt.
*/
private void updateSourcePositions() {
Vector3f cam = app.getCamera().getLocation();
Vector3f camLeft = app.getCamera().getLeft();
float lx = cam.x + camLeft.x * PAN_DIST;
float lz = cam.z + camLeft.z * PAN_DIST;
float rx = cam.x - camLeft.x * PAN_DIST;
float rz = cam.z - camLeft.z * PAN_DIST;
float y = cam.y;
if (nodeCalmL != null) nodeCalmL.setLocalTranslation(lx, y, lz);
if (nodeCalmR != null) nodeCalmR.setLocalTranslation(rx, y, rz);
if (nodeStormyL != null) nodeStormyL.setLocalTranslation(lx, y, lz);
if (nodeStormyR != null) nodeStormyR.setLocalTranslation(rx, y, rz);
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private AudioNode loadLoop(String path, String label) {
try {
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Stream);
AudioNode n = new AudioNode(app.getAssetManager(), path, AudioData.DataType.Buffer);
n.setLooping(true);
n.setVolume(0f);
n.setPositional(true);
n.setRefDistance(REF_DIST);
n.setMaxDistance(MAX_DIST);
// refDistance = 10 m (JME3-Default), Dist = PAN_DIST = 2 m → Gain = 1.0
return n;
} catch (Exception e) {
log.warn("[OceanSound] {} nicht ladbar: {}", label, e.getMessage());
@@ -208,7 +262,9 @@ public class OceanSoundState extends BaseAppState {
private void stop(AudioNode node) {
if (node != null) {
node.stop();
if (node.getParent() != null) app.getRootNode().detachChild(node);
if (node.getParent() != null) {
app.getRootNode().detachChild(node);
}
}
}

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