Compare commits
4 Commits
66d7956a28
...
06bb955c78
| Author | SHA1 | Date | |
|---|---|---|---|
| 06bb955c78 | |||
| 76a192df67 | |||
| dea1aa3d3e | |||
| 4fd337aea4 |
|
Before Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 247 KiB |
BIN
blight-assets/src/main/resources/Textures/hud/bar_bg.png
Normal file
|
After Width: | Height: | Size: 74 B |
|
After Width: | Height: | Size: 75 B |
BIN
blight-assets/src/main/resources/Textures/hud/bar_fill_mana.png
Normal file
|
After Width: | Height: | Size: 75 B |
|
After Width: | Height: | Size: 75 B |
BIN
blight-assets/src/main/resources/Textures/hud/bar_frame.png
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
blight-assets/src/main/resources/audio/ambient/water/wading.ogg
Normal 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
281
blight-game/src/main/java/de/blight/game/state/DrownState.java
Normal 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);
|
||||
}
|
||||
}
|
||||
286
blight-game/src/main/java/de/blight/game/state/HudState.java
Normal 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 (0–1). */
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
BIN
blight-map/src/main/map/blight_map.blm.tmp
Normal file
420
com/jme3/app/SimpleApplication.java
Normal 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
|
||||
}
|
||||
}
|
||||