Weiter gearbeitet

This commit is contained in:
2026-06-04 22:40:17 +02:00
parent 875c39ab27
commit d56f2ea41f
108 changed files with 4283 additions and 1122 deletions

View File

@@ -0,0 +1,22 @@
MaterialDef Flowing Water {
MaterialParameters {
Texture2D NormalMap
Texture2D FoamMap
Color Tint
Float UVScale : 6.0
Float Time : 0.0
Float FlowSpeed : 1.0
Float FoamAmount : 0.0
}
Technique {
VertexShader GLSL150: Shaders/FlowingWater.vert
FragmentShader GLSL150: Shaders/FlowingWater.frag
WorldParameters {
WorldViewProjectionMatrix
}
Defines {
HAS_NORMALMAP : NormalMap
HAS_FOAMMAP : FoamMap
}
}
}

View File

@@ -0,0 +1,62 @@
#ifdef HAS_NORMALMAP
uniform sampler2D m_NormalMap;
#endif
#ifdef HAS_FOAMMAP
uniform sampler2D m_FoamMap;
#endif
uniform vec4 m_Tint;
uniform float m_FoamAmount;
in vec2 vUV;
in vec2 vTex1;
in vec2 vTex2;
out vec4 outFragColor;
void main() {
// ── Randabstand: 0 = Mitte, 1 = Rand ─────────────────────────────────────
float edgeDist = abs(vUV.x * 2.0 - 1.0);
// ── Tiefengradient ────────────────────────────────────────────────────────
// Ränder heller/transparenter (flaches Ufer), Mitte dunkler (tief)
vec3 baseColor = m_Tint.rgb * (1.0 - edgeDist * 0.45);
float baseAlpha = m_Tint.a * smoothstep(0.0, 0.18, 1.0 - edgeDist);
// ── Dual-Layer Normal-Map: Specular + Wellenhelligkeit ────────────────────
float specular = 0.0;
float ripple = 0.0;
#ifdef HAS_NORMALMAP
vec3 n1 = texture(m_NormalMap, vTex1).rgb * 2.0 - 1.0;
vec3 n2 = texture(m_NormalMap, vTex2).rgb * 2.0 - 1.0;
vec3 n = normalize(n1 + n2);
// Feste Sonnenrichtung gut für Bachlauf-Optik
vec3 sunDir = normalize(vec3(0.5, 1.0, 0.3));
specular = pow(max(0.0, dot(n, sunDir)), 22.0);
// Wellenhelligkeit variiert den Basiston leicht
ripple = dot(n, vec3(0.0, 1.0, 0.0)) * 0.10;
#endif
// ── Schaum ────────────────────────────────────────────────────────────────
// Uferschaum wird stärker je näher am Rand; Wasserfälle extra
float foamEdge = smoothstep(0.55, 1.0, edgeDist);
float foamMask = clamp(foamEdge + m_FoamAmount * 0.75, 0.0, 1.0);
#ifdef HAS_FOAMMAP
float foamSample = texture(m_FoamMap, vTex1 * 0.4).r;
foamMask *= foamSample;
#else
// Prozeduraler Fallback wenn keine Schaumtextur geladen
float procFoam = (sin(vTex1.x * 6.283) * 0.5 + 0.5)
* (sin(vTex1.y * 3.141) * 0.5 + 0.5);
foamMask *= procFoam;
#endif
// ── Zusammenführen ────────────────────────────────────────────────────────
vec3 waterColor = baseColor + ripple + vec3(0.35, 0.45, 0.55) * specular;
vec3 finalColor = mix(waterColor, vec3(1.0), foamMask);
float finalAlpha = max(baseAlpha, foamMask * 0.9);
outFragColor = vec4(clamp(finalColor, 0.0, 1.0), finalAlpha);
}

View File

@@ -0,0 +1,21 @@
uniform mat4 g_WorldViewProjectionMatrix;
uniform float m_Time;
uniform float m_UVScale;
uniform float m_FlowSpeed;
in vec3 inPosition;
in vec2 inTexCoord;
out vec2 vUV; // raw [0..1] UV für Tiefe / Rand-Logik
out vec2 vTex1; // scrollende Normal-Map-UV (flussabwärts)
out vec2 vTex2; // scrollende Normal-Map-UV (diagonal, gegenläufig)
void main() {
vUV = inTexCoord;
float t = m_Time * m_FlowSpeed;
vTex1 = vec2(inTexCoord.x * m_UVScale,
inTexCoord.y * m_UVScale - t);
vTex2 = vec2(inTexCoord.x * m_UVScale * 0.7 - t * 0.25,
inTexCoord.y * m_UVScale * 0.7 + t * 0.55);
gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

View File

@@ -0,0 +1,21 @@
{
"clips": [
"idle",
"idle_jump",
"running",
"running_jump",
"sprint",
"stand_up",
"tpose",
"walking"
],
"actionMap": {
"DEFAULT": "tpose",
"IDLE": "idle",
"WALK": "walking",
"RUN": "running",
"SPRINT": "sprint",
"JUMP": "idle_jump",
"RUNNING_JUMP": "running_jump"
}
}

View File

@@ -0,0 +1,21 @@
{
"clips": [
"idle",
"idle_jump",
"running",
"running_jump",
"sprint",
"stand_up",
"tpose",
"walking"
],
"actionMap": {
"DEFAULT": "tpose",
"IDLE": "idle",
"JUMP": "idle_jump",
"WALK": "walking",
"RUN": "running",
"SPRINT": "sprint",
"RUNNING_JUMP": "running_jump"
}
}

Binary file not shown.

View File

@@ -0,0 +1,19 @@
{
"chapter": 0,
"level": 0,
"xp": 0,
"currentHp": 0,
"maxHp": 0,
"currentStamina": 0,
"maxStamina": 0,
"currentMana": 0,
"myMana": 0,
"listeners": [],
"characterId": "hero",
"name": {
"id": "hero.name"
},
"modelPath": "Models/Chars/mainchar.j3o",
"animSetPath": "human",
"type": "MAIN_CHARACTER"
}

View File

@@ -0,0 +1,10 @@
{
"trader": false,
"characterId": "silas",
"name": {
"id": "silas.name"
},
"modelPath": "Models/Chars/silas.j3o",
"animSetPath": "human",
"type": "NPC"
}

View File

@@ -0,0 +1,4 @@
package de.blight.common;
/** Einzeln platzierter Gras-Büschel. Y-Position wird zur Renderzeit aus dem Terrain berechnet. */
public record GrassTuft(float x, float z, float height, int slot) {}

View File

@@ -0,0 +1,86 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* Liest und schreibt individuell platzierte Gras-Büschel als komprimierte Binärdatei
* ({@code blight_grass.blg}) neben der Kartendatei.
*
* Format v1:
* int MAGIC 0x47525A53 ("GRZS")
* int VERSION 1
* int slotCount (8)
* 8× UTF Texturpfad pro Slot (Slot 0 = Standardtextur)
* int tuftCount
* N× float x, float z, float height, byte slot
*/
public final class GrassTuftIO {
private static final int MAGIC = 0x47525A53;
private static final int VERSION = 1;
private static final int SLOT_COUNT = 8;
private GrassTuftIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_grass.blg");
}
public record GrassData(String[] slotPaths, List<GrassTuft> tufts) {}
public static void save(GrassData data) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(p))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
String[] paths = data.slotPaths() != null ? data.slotPaths() : new String[0];
out.writeInt(SLOT_COUNT);
for (int i = 0; i < SLOT_COUNT; i++) {
String s = (i < paths.length && paths[i] != null) ? paths[i] : "";
out.writeUTF(s);
}
List<GrassTuft> tufts = data.tufts() != null ? data.tufts() : List.of();
out.writeInt(tufts.size());
for (GrassTuft t : tufts) {
out.writeFloat(t.x());
out.writeFloat(t.z());
out.writeFloat(t.height());
out.writeByte(t.slot());
}
}
}
public static GrassData load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return null;
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) {
int magic = in.readInt();
int version = in.readInt();
if (magic != MAGIC) throw new IOException("Ungültige Grasdatei (Magic)");
if (version > VERSION) throw new IOException("Unbekannte Gras-Version: " + version);
int slotCount = in.readInt();
String[] paths = new String[SLOT_COUNT];
Arrays.fill(paths, "");
for (int i = 0; i < slotCount; i++) {
String s = in.readUTF();
if (i < SLOT_COUNT) paths[i] = s;
}
int tuftCount = in.readInt();
List<GrassTuft> tufts = new ArrayList<>(tuftCount);
for (int i = 0; i < tuftCount; i++) {
float x = in.readFloat();
float z = in.readFloat();
float h = in.readFloat();
int slot = in.readUnsignedByte();
tufts.add(new GrassTuft(x, z, h, slot));
}
return new GrassData(paths, tufts);
}
}
}

View File

@@ -70,6 +70,25 @@ public final class MapData {
/** Gras-Dichte [SPLAT_SIZE²], Bytes 0255 (0=kein Gras, 255=max Dichte). */ /** Gras-Dichte [SPLAT_SIZE²], Bytes 0255 (0=kein Gras, 255=max Dichte). */
public final byte[] grassDensity; public final byte[] grassDensity;
/**
* Gras-Höhe pro Pixel [SPLAT_SIZE²].
* 0 = nicht gesetzt → grassDefaultHeight verwenden.
* 1255 = 0.1 m bis 10.0 m (linear: h = 0.1 + (b-1) * 9.9/254).
*/
public final byte[] grassHeightMap;
/** Relativer Asset-Pfad der Gras-Textur (z. B. "Textures/grass.png"), "" = Standardfarbe. */
public String grassTexturePath = "";
/** Standard-Blatt-Höhe für den Gras-Renderer (entspricht dem Grashöhe-Slider). */
public float grassDefaultHeight = 1.5f;
/** Per-Pixel Textur-Slot-Index [SPLAT_SIZE²]. 0=grassTexturePath, 1N=grassTextureSlots[slot-1]. */
public final byte[] grassTextureMap;
/** Texturpfade für Gras-Slots 1..N. Leerstring = nicht belegt. */
public String[] grassTextureSlots = new String[0];
/** Loch-Maske der oberen Schicht [UPPER_CELLS²], != 0 = Loch (Zelle ausgeblendet). */ /** Loch-Maske der oberen Schicht [UPPER_CELLS²], != 0 = Loch (Zelle ausgeblendet). */
public final byte[] upperHole; public final byte[] upperHole;
@@ -92,6 +111,8 @@ public final class MapData {
upperSplatB = new byte [SPLAT_SIZE * SPLAT_SIZE]; upperSplatB = new byte [SPLAT_SIZE * SPLAT_SIZE];
upperSplatA = new byte [SPLAT_SIZE * SPLAT_SIZE]; upperSplatA = new byte [SPLAT_SIZE * SPLAT_SIZE];
grassDensity = new byte [SPLAT_SIZE * SPLAT_SIZE]; grassDensity = new byte [SPLAT_SIZE * SPLAT_SIZE];
grassHeightMap = new byte [SPLAT_SIZE * SPLAT_SIZE];
grassTextureMap = new byte [SPLAT_SIZE * SPLAT_SIZE];
upperHole = new byte [UPPER_CELLS * UPPER_CELLS]; upperHole = new byte [UPPER_CELLS * UPPER_CELLS];
} }
} }

View File

@@ -19,6 +19,9 @@ import java.util.zip.*;
* 4 wie 3 + Spawnpunkt (2× float) * 4 wie 3 + Spawnpunkt (2× float)
* 5 wie 4 + Splatmap-Alpha + Texturpfade + Gebirge-Splatmap (RGBA + Pfade) * 5 wie 4 + Splatmap-Alpha + Texturpfade + Gebirge-Splatmap (RGBA + Pfade)
* 6 wie 5 ohne upperHole; upperTop/Bottom behalten (zukünftige Höhlen-Architektur) * 6 wie 5 ohne upperHole; upperTop/Bottom behalten (zukünftige Höhlen-Architektur)
* 7 wie 6 + Gras-Texturpfad (UTF-String) + Gras-Standardhöhe (float)
* 8 wie 7 + Gras-Höhen-Map (SPLAT_SIZE² Bytes, 0=ungesetzt 1-255=0.1-10m)
* 9 wie 8 + Gras-Textur-Map (SPLAT_SIZE² Bytes) + Slots (N×UTF)
*/ */
public final class MapIO { public final class MapIO {
@@ -50,7 +53,7 @@ public final class MapIO {
} }
private static final int MAGIC = 0x424C4947; // "BLIG" private static final int MAGIC = 0x424C4947; // "BLIG"
private static final int VERSION = 6; private static final int VERSION = 9;
private MapIO() {} private MapIO() {}
@@ -104,6 +107,19 @@ public final class MapIO {
out.write(data.upperSplatB); out.write(data.upperSplatB);
out.write(data.upperSplatA); out.write(data.upperSplatA);
writeStrings(out, data.upperTextures); writeStrings(out, data.upperTextures);
// v7: gras-textur + standardhöhe
out.writeUTF(data.grassTexturePath != null ? data.grassTexturePath : "");
out.writeFloat(data.grassDefaultHeight);
// v8: gras-höhen-map
out.write(data.grassHeightMap);
// v9: gras-textur-slots + gras-textur-map
String[] slots = data.grassTextureSlots != null ? data.grassTextureSlots : new String[0];
// trim trailing empty entries
int slotEnd = slots.length;
while (slotEnd > 0 && (slots[slotEnd-1] == null || slots[slotEnd-1].isEmpty())) slotEnd--;
out.writeInt(slotEnd);
for (int i = 0; i < slotEnd; i++) out.writeUTF(slots[i] != null ? slots[i] : "");
out.write(data.grassTextureMap);
} }
} }
@@ -150,6 +166,19 @@ public final class MapIO {
// Ältere Maps: upperSplatR auf 255 initialisieren (Tex1 immer sichtbar) // Ältere Maps: upperSplatR auf 255 initialisieren (Tex1 immer sichtbar)
java.util.Arrays.fill(data.upperSplatR, (byte) 255); java.util.Arrays.fill(data.upperSplatR, (byte) 255);
} }
if (version >= 7) {
data.grassTexturePath = in.readUTF();
data.grassDefaultHeight = in.readFloat();
}
if (version >= 8) {
in.readFully(data.grassHeightMap);
}
if (version >= 9) {
int n = in.readInt();
data.grassTextureSlots = new String[n];
for (int i = 0; i < n; i++) data.grassTextureSlots[i] = in.readUTF();
in.readFully(data.grassTextureMap);
}
} }
return data; return data;
} }

View File

@@ -11,5 +11,7 @@ public record PlacedModel(
/** Relativer Asset-Pfad zur exportierten j3o-Datei des Custom Meshes; "" wenn nicht verwendet. */ /** Relativer Asset-Pfad zur exportierten j3o-Datei des Custom Meshes; "" wenn nicht verwendet. */
String meshFile, String meshFile,
/** AnimationLibrary-Clip-Key (z. B. "walk/Run"); "" = keine Animation. */ /** AnimationLibrary-Clip-Key (z. B. "walk/Run"); "" = keine Animation. */
String animClip String animClip,
boolean castShadow,
boolean receiveShadow
) {} ) {}

View File

@@ -8,10 +8,10 @@ import java.util.*;
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei * Liest und schreibt platzierte Modelle als tab-separierte Textdatei
* ({@code blight_objects.blo}) neben der Kartendatei. * ({@code blight_objects.blo}) neben der Kartendatei.
* *
* Spalten (seit v2): * Spalten (seit v3):
* modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile * modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow
* *
* Alte Dateien mit 6 Spalten (v1) werden gelesen; fehlende Felder erhalten Standardwerte. * Alte Dateien mit 6 Spalten (v1/v2) werden gelesen; fehlende Felder erhalten Standardwerte.
*/ */
public final class PlacedModelIO { public final class PlacedModelIO {
@@ -25,18 +25,19 @@ public final class PlacedModelIO {
Path p = getPath(); Path p = getPath();
Files.createDirectories(p.getParent()); Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) { try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip"); w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow");
w.newLine(); w.newLine();
for (PlacedModel m : models) { for (PlacedModel m : models) {
w.write(String.format(Locale.ROOT, w.write(String.format(Locale.ROOT,
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s%n", "%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b%n",
m.modelPath(), m.modelPath(),
m.x(), m.y(), m.z(), m.x(), m.y(), m.z(),
m.rotY(), m.scale(), m.rotY(), m.scale(),
m.rotX(), m.rotZ(), m.rotX(), m.rotZ(),
m.solid(), m.solid(),
nvl(m.texturePath()), nvl(m.normalMapPath()), nvl(m.materialPath()), nvl(m.texturePath()), nvl(m.normalMapPath()), nvl(m.materialPath()),
nvl(m.meshFile()), nvl(m.animClip()))); nvl(m.meshFile()), nvl(m.animClip()),
m.castShadow(), m.receiveShadow()));
} }
} }
} }
@@ -65,9 +66,12 @@ public final class PlacedModelIO {
String matPath = f.length > 11 ? f[11] : ""; String matPath = f.length > 11 ? f[11] : "";
String meshFile = f.length > 12 ? f[12] : ""; String meshFile = f.length > 12 ? f[12] : "";
String animClip = f.length > 13 ? f[13] : ""; String animClip = f.length > 13 ? f[13] : "";
boolean castShadow = f.length > 14 ? Boolean.parseBoolean(f[14]) : true;
boolean receiveShadow = f.length > 15 ? Boolean.parseBoolean(f[15]) : true;
list.add(new PlacedModel(modelPath, x, y, z, list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid, rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip)); texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow));
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
} }
return list; return list;

View File

@@ -1,7 +1,8 @@
package de.blight.common; package de.blight.common;
/** Unveränderliche Daten einer platzierten Wasseroberfläche (Teich, See). */ /**
public record PlacedWater( * Platzierte Wasserfläche.
float x, float y, float z, * Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet
float width, float depth * gespeichert werden nur Saatpunkt und Wasserstand.
) {} */
public record PlacedWater(float seedX, float seedZ, float waterHeight) {}

View File

@@ -0,0 +1,73 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt platzierte Flüsse als Textdatei
* ({@code blight_rivers.blr}) neben der Kartendatei.
*
* Format: ein Fluss pro Zeile; Punkte getrennt durch {@code |};
* jeder Punkt: {@code x,y,z,width,uvSpeed} (5 Komma-getrennte Floats).
* Kommentarzeilen beginnen mit {@code #}.
*/
public final class RiverIO {
private RiverIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_rivers.blr");
}
public static void save(List<List<RiverPoint>> rivers) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# blight_rivers.blr Flussdaten");
w.newLine();
w.write("# Format: x,y,z,width,uvSpeed | x,y,z,width,uvSpeed | ...");
w.newLine();
for (List<RiverPoint> river : rivers) {
if (river == null || river.isEmpty()) continue;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < river.size(); i++) {
if (i > 0) sb.append('|');
RiverPoint pt = river.get(i);
sb.append(String.format(Locale.ROOT, "%.5f,%.5f,%.5f,%.5f,%.5f",
pt.x(), pt.y(), pt.z(), pt.width(), pt.uvSpeed()));
}
w.write(sb.toString());
w.newLine();
}
}
}
public static List<List<RiverPoint>> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<List<RiverPoint>> result = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] parts = line.split("\\|", -1);
List<RiverPoint> river = new ArrayList<>();
for (String part : parts) {
part = part.strip();
if (part.isEmpty()) continue;
String[] f = part.split(",", -1);
if (f.length < 5) continue;
try {
river.add(new RiverPoint(
Float.parseFloat(f[0]),
Float.parseFloat(f[1]),
Float.parseFloat(f[2]),
Float.parseFloat(f[3]),
Float.parseFloat(f[4])));
} catch (NumberFormatException ignored) {}
}
if (!river.isEmpty()) result.add(river);
}
return result;
}
}

View File

@@ -0,0 +1,9 @@
package de.blight.common;
/** Kontrollpunkt eines Flusses in Weltkoordinaten. */
public record RiverPoint(float x, float y, float z, float width, float uvSpeed) {
public static final float DEFAULT_WIDTH = 4.0f;
public static final float RIVER_SPEED = 0.4f;
public static final float WATERFALL_SPEED = 3.0f;
public boolean isWaterfall() { return uvSpeed >= 1.5f; }
}

View File

@@ -0,0 +1,71 @@
package de.blight.common;
import java.util.ArrayList;
import java.util.List;
/**
* Catmull-Rom-Spline-Glättung für Flusskontrollpunkte.
* Die Originalpunkte bleiben als Kontrollpunkte erhalten; subdivide()
* gibt eine dichtere Punktliste nur für die Visualisierung zurück.
*/
public final class RiverSpline {
/** Schritte pro Segment zwischen zwei Kontrollpunkten. */
private static final int STEPS = 8;
private RiverSpline() {}
/**
* Gibt eine Catmull-Rom-interpolierte Kopie der Punktliste zurück.
* Weniger als 2 Punkte werden unverändert zurückgegeben.
*/
public static List<RiverPoint> subdivide(List<RiverPoint> pts) {
int n = pts.size();
if (n < 2) return new ArrayList<>(pts);
List<RiverPoint> result = new ArrayList<>(n * STEPS);
for (int i = 0; i < n - 1; i++) {
// Ghost-Punkte an den Enden: Spiegelung des Nachbarn
RiverPoint p0 = (i > 0) ? pts.get(i - 1) : ghost(pts.get(i), pts.get(i + 1));
RiverPoint p1 = pts.get(i);
RiverPoint p2 = pts.get(i + 1);
RiverPoint p3 = (i < n - 2) ? pts.get(i + 2) : ghost(pts.get(i + 1), pts.get(i));
for (int s = 0; s < STEPS; s++) {
result.add(eval(p0, p1, p2, p3, (float) s / STEPS));
}
}
result.add(pts.get(n - 1)); // letzten Originalpunkt immer anhängen
return result;
}
// ── Catmull-Rom-Formel ────────────────────────────────────────────────────
private static RiverPoint eval(RiverPoint p0, RiverPoint p1,
RiverPoint p2, RiverPoint p3, float t) {
float t2 = t * t, t3 = t2 * t;
float b0 = -t3 + 2*t2 - t;
float b1 = 3*t3 - 5*t2 + 2;
float b2 = -3*t3 + 4*t2 + t;
float b3 = t3 - t2;
float x = 0.5f * (b0*p0.x() + b1*p1.x() + b2*p2.x() + b3*p3.x());
float y = 0.5f * (b0*p0.y() + b1*p1.y() + b2*p2.y() + b3*p3.y());
float z = 0.5f * (b0*p0.z() + b1*p1.z() + b2*p2.z() + b3*p3.z());
float w = p1.width() + t * (p2.width() - p1.width());
float s = p1.uvSpeed() + t * (p2.uvSpeed() - p1.uvSpeed());
return new RiverPoint(x, y, z, w, s);
}
/** Spiegelung von 'other' durch 'anchor' als Ghost-Kontrollpunkt. */
private static RiverPoint ghost(RiverPoint anchor, RiverPoint other) {
return new RiverPoint(
2*anchor.x() - other.x(),
2*anchor.y() - other.y(),
2*anchor.z() - other.z(),
anchor.width(),
anchor.uvSpeed()
);
}
}

View File

@@ -5,10 +5,11 @@ import java.nio.file.*;
import java.util.*; import java.util.*;
/** /**
* Liest und schreibt platzierte Wasseroberflächen als tab-separierte Textdatei * Liest und schreibt platzierte Wasserflächen als tab-separierte Textdatei
* ({@code blight_water.blw}) neben der Kartendatei. * ({@code blight_water.blw}) neben der Kartendatei.
* *
* Spalten: x y z width depth * Format: seedX seedZ waterHeight
* Die Form des Wasserkörpers wird per Flood-Fill zur Laufzeit rekonstruiert.
*/ */
public final class WaterBodyIO { public final class WaterBodyIO {
@@ -22,12 +23,11 @@ public final class WaterBodyIO {
Path p = getPath(); Path p = getPath();
Files.createDirectories(p.getParent()); Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) { try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# x\ty\tz\twidth\tdepth"); w.write("# seedX\tseedZ\twaterHeight");
w.newLine(); w.newLine();
for (PlacedWater b : bodies) { for (PlacedWater b : bodies) {
w.write(String.format(Locale.ROOT, w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n",
"%.5f\t%.5f\t%.5f\t%.5f\t%.5f%n", b.seedX(), b.seedZ(), b.waterHeight()));
b.x(), b.y(), b.z(), b.width(), b.depth()));
} }
} }
} }
@@ -40,14 +40,12 @@ public final class WaterBodyIO {
line = line.strip(); line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue; if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1); String[] f = line.split("\t", -1);
if (f.length < 5) continue; if (f.length < 3) continue;
try { try {
list.add(new PlacedWater( list.add(new PlacedWater(
Float.parseFloat(f[0]), Float.parseFloat(f[0]),
Float.parseFloat(f[1]), Float.parseFloat(f[1]),
Float.parseFloat(f[2]), Float.parseFloat(f[2])));
Float.parseFloat(f[3]),
Float.parseFloat(f[4])));
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
} }
return list; return list;

View File

@@ -21,11 +21,12 @@ import java.util.stream.Stream;
* "characterId": "hero", * "characterId": "hero",
* "name": "Der Held", * "name": "Der Held",
* "modelPath": "Models/hero.j3o", * "modelPath": "Models/hero.j3o",
* "animSetPath": "animations/sets/hero.j3o", * "animSetPath": "human",
* ... (subclass fields via Gson) * ... (subclass fields via Gson)
* } * }
* Die Aktions-Zuweisung (IDLE → Clip-Name usw.) ist im AnimSet gespeichert * animSetPath ist der Set-Name (ohne Pfad/Extension).
* (animSetPath.replaceAll(".j3o", "") + ".animset.json"). * Die Aktions-Zuweisung liegt in animations/sets/{animSetPath}.animset.json.
* Die Clip-Dateien liegen in animations/clips/{clipName}.j3o.
*/ */
public final class CharacterIO { public final class CharacterIO {

View File

@@ -41,6 +41,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.11.0' implementation 'com.google.code.gson:gson:2.11.0'
implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'org.slf4j:jul-to-slf4j:2.0.17' implementation 'org.slf4j:jul-to-slf4j:2.0.17'
runtimeOnly 'ch.qos.logback:logback-classic:1.5.18'
compileOnly 'org.projectlombok:lombok:1.18.38' compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38' annotationProcessor 'org.projectlombok:lombok:1.18.38'
} }

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,9 @@ public class FrameTransfer implements SceneProcessor {
private int[] argbBuf; // gesamtes Bild für einmaligen bulk-Write private int[] argbBuf; // gesamtes Bild für einmaligen bulk-Write
private final AtomicBoolean jfxBusy = new AtomicBoolean(false); private final AtomicBoolean jfxBusy = new AtomicBoolean(false);
private volatile Runnable onFirstFrame;
public void setOnFirstFrame(Runnable cb) { this.onFirstFrame = cb; }
public FrameTransfer(WritableImage image) { public FrameTransfer(WritableImage image) {
this.pw = image.getPixelWriter(); this.pw = image.getPixelWriter();
@@ -77,6 +80,8 @@ public class FrameTransfer implements SceneProcessor {
} }
} }
pw.setPixels(0, 0, width, height, fmt, argbBuf, 0, width); pw.setPixels(0, 0, width, height, fmt, argbBuf, 0, width);
Runnable cb = onFirstFrame;
if (cb != null) { onFirstFrame = null; cb.run(); }
} finally { } finally {
jfxBusy.set(false); jfxBusy.set(false);
} }

View File

@@ -10,6 +10,7 @@ import com.jme3.texture.Texture2D;
import de.blight.editor.state.AnimPreviewState; import de.blight.editor.state.AnimPreviewState;
import de.blight.editor.state.EmitterState; import de.blight.editor.state.EmitterState;
import de.blight.editor.state.MusicAreaState; import de.blight.editor.state.MusicAreaState;
import de.blight.editor.state.RiverEditorState;
import de.blight.editor.state.PlayToolState; import de.blight.editor.state.PlayToolState;
import de.blight.editor.state.SoundAreaState; import de.blight.editor.state.SoundAreaState;
import de.blight.editor.state.WaterBodyState; import de.blight.editor.state.WaterBodyState;
@@ -68,8 +69,7 @@ public class JmeEditorApp extends SimpleApplication {
public void simpleInitApp() { public void simpleInitApp() {
flyCam.setEnabled(false); flyCam.setEnabled(false);
// Explizit registrieren, falls General.cfg die Klassen beim ersten Start input.loadingStatus = "Registriere Asset-Loader...";
// noch nicht gefunden hat (jme3-plugins war zuvor nicht auf dem Classpath).
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf"); assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf");
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb"); assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb");
@@ -78,11 +78,12 @@ public class JmeEditorApp extends SimpleApplication {
assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class); assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class);
} }
input.loadingStatus = "Initialisiere Renderer...";
currentW = vpWidth; currentW = vpWidth;
currentH = vpHeight; currentH = vpHeight;
buildFrameBuffer(vpWidth, vpHeight, initialImage); buildFrameBuffer(vpWidth, vpHeight, initialImage);
input.loadingStatus = "Lade Editor-States...";
stateManager.attach(new SceneObjectState(input)); stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input)); stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input)); stateManager.attach(new TreeGeneratorState(input));
@@ -93,10 +94,11 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new WaterBodyState(input)); stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(input)); stateManager.attach(new SoundAreaState(input));
stateManager.attach(new MusicAreaState(input)); stateManager.attach(new MusicAreaState(input));
stateManager.attach(new RiverEditorState(input));
stateManager.attach(new PlayToolState(input)); stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input)); stateManager.attach(new AnimPreviewState(input));
// JME-Konsole (Editor-Modus: kein RawInputListener Eingabe via SharedInput) input.loadingStatus = "Initialisiere Konsole...";
jmeConsole = new JmeConsole(false); jmeConsole = new JmeConsole(false);
registerEditorCommands(); registerEditorCommands();
jmeConsole.setOnVisibilityChanged(open -> { jmeConsole.setOnVisibilityChanged(open -> {
@@ -158,6 +160,7 @@ public class JmeEditorApp extends SimpleApplication {
viewPort.setOutputFrameBuffer(fb); viewPort.setOutputFrameBuffer(fb);
guiViewPort.setOutputFrameBuffer(fb); guiViewPort.setOutputFrameBuffer(fb);
frameTransfer = new FrameTransfer(image); frameTransfer = new FrameTransfer(image);
frameTransfer.setOnFirstFrame(() -> { input.loadingStatus = "Bereit"; input.jmeReady = true; });
guiViewPort.addProcessor(frameTransfer); guiViewPort.addProcessor(frameTransfer);
} }

View File

@@ -25,6 +25,10 @@ public class SharedInput {
public final HoleTool holeTool = new HoleTool(); public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool; public volatile EditorTool activeTool = heightTool;
// ── Initialisierungs-Status ───────────────────────────────────────────────
public volatile boolean jmeReady = false;
public volatile String loadingStatus = "Initialisiere...";
// ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ───────────────────── // ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ─────────────────────
public volatile int activeLayer = 0; public volatile int activeLayer = 0;
@@ -63,6 +67,18 @@ public class SharedInput {
public record GrassEdit(float screenX, float screenY, int action) {} public record GrassEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassEdit> grassEditQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<GrassEdit> grassEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Einstellungen (JavaFX → JME3) ───────────────────────────────────
/** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */
public volatile String grassTexturePath = "";
/** JFX setzt true wenn Textur geändert; JME liest + resettet. */
public volatile boolean grassSettingsChanged = false;
/** Texturpfade für Gras-Slots 1..7 (Index 0 = Slot 1). Neues Array zuweisen bei Änderung. */
public volatile String[] grassTextureSlots = new String[]{"", "", "", "", "", "", ""};
/** Aktiver Maler-Slot (0 = grassTexturePath/Slot0, 17 = grassTextureSlots[slot-1]). */
public volatile int grassActiveSlot = 0;
/** JFX setzt true wenn Slot-Texturen geändert wurden. */
public volatile boolean grassSlotsChanged = false;
// ── Textur-Edits ───────────────────────────────────────────────────────── // ── Textur-Edits ─────────────────────────────────────────────────────────
/** action +1 = selektierte Textur malen, -1 = auf Gras zurücksetzen. */ /** action +1 = selektierte Textur malen, -1 = auf Gras zurücksetzen. */
public record TextureEdit(float screenX, float screenY, int action) {} public record TextureEdit(float screenX, float screenY, int action) {}
@@ -123,15 +139,15 @@ public class SharedInput {
public volatile boolean treePreviewResized = false; public volatile boolean treePreviewResized = false;
// ── Baum-Generator ─────────────────────────────────────────────────────── // ── Baum-Generator ───────────────────────────────────────────────────────
public record TreeGenRequest(TreeParams params, boolean exportAfter, String exportName) {} public record TreeGenRequest(TreeParams params, boolean exportAfter, String treeType) {}
public final ConcurrentLinkedQueue<TreeGenRequest> treeGenQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<TreeGenRequest> treeGenQueue = new ConcurrentLinkedQueue<>();
// ── EZ-Tree-Generator ───────────────────────────────────────────────────── // ── EZ-Tree-Generator ─────────────────────────────────────────────────────
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter, String exportName, String treeCategory) {} public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter) {}
public final ConcurrentLinkedQueue<EzTreeGenRequest> ezTreeGenQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<EzTreeGenRequest> ezTreeGenQueue = new ConcurrentLinkedQueue<>();
// ── Palmen-Generator ────────────────────────────────────────────────────── // ── Palmen-Generator ──────────────────────────────────────────────────────
public record PalmGenRequest(PalmOptions options, boolean exportAfter, String exportName) {} public record PalmGenRequest(PalmOptions options, boolean exportAfter) {}
public final ConcurrentLinkedQueue<PalmGenRequest> palmGenQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<PalmGenRequest> palmGenQueue = new ConcurrentLinkedQueue<>();
// ── Objekt-Werkzeug ────────────────────────────────────────────────────── // ── Objekt-Werkzeug ──────────────────────────────────────────────────────
@@ -201,6 +217,8 @@ public class SharedInput {
float x, float y, float z, float x, float y, float z,
float rotX, float rotY, float rotZ, float rotX, float rotY, float rotZ,
boolean solid, boolean solid,
boolean castShadow,
boolean receiveShadow,
String texPath, // null = nicht ändern String texPath, // null = nicht ändern
String normalMapPath, // null = nicht ändern String normalMapPath, // null = nicht ändern
String matPath // null = nicht ändern String matPath // null = nicht ändern
@@ -325,18 +343,38 @@ public class SharedInput {
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */ /** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
public static final int LAYER_PLAY_TOOL = 12; public static final int LAYER_PLAY_TOOL = 12;
/** activeLayer==13 → Flüsse platzieren */
public static final int LAYER_RIVERS = 13;
// ── Fluss-Werkzeug ─────────────────────────────────────────────────────────
public record RiverClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<RiverClick> riverClickQueue = new ConcurrentLinkedQueue<>();
public volatile float riverNewWidth = 4.0f; // Breite des nächsten Punktes
public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall)
public volatile boolean undoRiverPointRequested = false;
public volatile String riverHint = null;
/** JME → JavaFX: Info des selektierten Flusses. Format: "idx|numPoints|totalLengthM" oder null. */
public volatile String selectedRiverInfo = null;
public volatile boolean riverSelectionChanged = false;
/** JavaFX → JME: Selektierten Fluss löschen. */
public volatile boolean deleteRiverRequested = false;
/** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */ /** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */
public record WaterClick(float screenX, float screenY, boolean rightButton) {} public record WaterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>();
/** /**
* JME → JavaFX: Info der selektierten Wasseroberfläche. * JME → JavaFX: Info der selektierten Wasseroberfläche.
* Format: "idx|x|y|z|width|depth" oder null. * Format: "idx|seedX|seedZ|waterHeight|cellCount" oder null.
*/ */
public volatile String selectedWaterInfo = null; public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false; public volatile boolean waterSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter der selektierten Wasseroberfläche. */ /** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */
public volatile String waterHint = null;
/** JavaFX → JME: aktualisierte Wasserhöhe der selektierten Fläche. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>(); public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */ /** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
@@ -389,6 +427,11 @@ public class SharedInput {
public volatile float tempSpawnX = Float.NaN; public volatile float tempSpawnX = Float.NaN;
public volatile float tempSpawnZ = Float.NaN; public volatile float tempSpawnZ = Float.NaN;
/** Live-Spielerposition aus dem laufenden Spiel (NaN = kein Spiel aktiv). */
public volatile float livePlayerX = Float.NaN;
public volatile float livePlayerY = Float.NaN;
public volatile float livePlayerZ = Float.NaN;
// ── Animations-Vorschau ────────────────────────────────────────────────── // ── Animations-Vorschau ──────────────────────────────────────────────────
public volatile float animPreviewRotY = 0f; public volatile float animPreviewRotY = 0f;
public volatile float animPreviewRotX = 25f; public volatile float animPreviewRotX = 25f;
@@ -437,8 +480,15 @@ public class SharedInput {
public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest> public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest>
animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>(); animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animationen in Charakter-Modell einbetten ─────────────────────────────
public record AnimEmbedRequest(String characterModelPath, String setName) {}
public final java.util.concurrent.atomic.AtomicReference<AnimEmbedRequest>
animEmbedRequest = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */ /** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */
public volatile String animOpStatus = null; public volatile String animOpStatus = null;
/** JME3 → JavaFX: Status-Meldung für Einbett-Operationen (Character Editor). */
public volatile String animEmbedStatus = null;
// ── Modell-Konvertierung ────────────────────────────────────────────────── // ── Modell-Konvertierung ──────────────────────────────────────────────────
/** /**

View File

@@ -13,6 +13,8 @@ public class SceneObject extends PlacedObject {
private float rotZ; // Z-Achsen-Rotation in Radiant private float rotZ; // Z-Achsen-Rotation in Radiant
private float scale; private float scale;
public boolean solid; // Charakter-Kollision public boolean solid; // Charakter-Kollision
public boolean castShadow = true;
public boolean receiveShadow = true;
public String modelPath; // relativ zu blight-assets/src/main/resources/ public String modelPath; // relativ zu blight-assets/src/main/resources/
public String texturePath = ""; public String texturePath = "";
public String normalMapPath = ""; public String normalMapPath = "";

View File

@@ -173,6 +173,9 @@ public class AnimPreviewState extends BaseAppState {
SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null); SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null);
if (setReq != null) executeAnimSetSave(setReq); if (setReq != null) executeAnimSetSave(setReq);
SharedInput.AnimEmbedRequest embedReq = input.animEmbedRequest.getAndSet(null);
if (embedReq != null) executeAnimEmbed(embedReq);
// Geschwindigkeit live anpassen // Geschwindigkeit live anpassen
if (currentAction != null) { if (currentAction != null) {
try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {} try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {}
@@ -389,7 +392,7 @@ public class AnimPreviewState extends BaseAppState {
return false; return false;
} }
// ── Animation hinzufügen (Retargeting) ─────────────────────────────────── // ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
private void addAnimation(String animAssetPath) { private void addAnimation(String animAssetPath) {
if (currentModel == null) { if (currentModel == null) {
@@ -422,7 +425,6 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null; com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null;
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null; com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
// Diagnose: Knochen-Namen beider Skelette ausgeben
if (srcArm != null) { if (srcArm != null) {
System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):"); System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):");
for (var j : srcArm.getJointList()) System.err.println(" src: " + j.getName()); for (var j : srcArm.getJointList()) System.err.println(" src: " + j.getName());
@@ -442,15 +444,15 @@ public class AnimPreviewState extends BaseAppState {
System.err.println("[Retarget] Mapping (" + mapping.size() + " Treffer): " + mapping); System.err.println("[Retarget] Mapping (" + mapping.size() + " Treffer): " + mapping);
} }
// Blender-Duplikate herausfiltern: Clips deren Name mit ".NNN" endet und deren
// Basis-Name (ohne Suffix) ebenfalls in der Quelle vorkommt, werden übersprungen.
java.util.Set<String> srcNames = new java.util.HashSet<>(); java.util.Set<String> srcNames = new java.util.HashSet<>();
for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName()); for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName());
int added = 0; java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Files.createDirectories(clipsDir);
int saved = 0;
for (AnimClip clip : sourceAC.getAnimClips()) { for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName(); String name = clip.getName();
// Prüfen ob Name dem Muster "base.NNN" entspricht (Blender-Duplikat)
if (name.matches(".*\\.\\d{3}$")) { if (name.matches(".*\\.\\d{3}$")) {
String base = name.substring(0, name.length() - 4); String base = name.substring(0, name.length() - 4);
if (srcNames.contains(base)) { if (srcNames.contains(base)) {
@@ -461,20 +463,20 @@ public class AnimPreviewState extends BaseAppState {
AnimClip result = retarget AnimClip result = retarget
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm) ? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
: clip; : clip;
if (result != null) { if (result == null) continue;
// Direkt in die Clip-Bibliothek speichern das Modell wird nicht modifiziert
saveClipToFile(result, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(name + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden
targetAC.addAnimClip(result); targetAC.addAnimClip(result);
added++; saved++;
} }
}
// Clip-Liste neu aufbauen
List<String> clips = new ArrayList<>(); List<String> clips = new ArrayList<>();
collectClips(currentModel, clips); collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips)); input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = added + " Clip(s) hinzugefügt" input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt, kein Retargeting)"); + (retarget ? " (retargeted)" : " (direkt)");
// Modell mit neuem Clip persistieren, damit der Clip nach Editor-Neustart noch da ist
if (added > 0) saveModel();
} catch (Exception e) { } catch (Exception e) {
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage(); input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
} }
@@ -503,7 +505,6 @@ public class AnimPreviewState extends BaseAppState {
collectClips(currentModel, clips); collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips)); input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = "Clip entfernt: " + clipName; input.animPreviewStatus = "Clip entfernt: " + clipName;
saveModel();
} }
private <T extends com.jme3.scene.control.Control> T findControl(Spatial s, Class<T> type) { private <T extends com.jme3.scene.control.Control> T findControl(Spatial s, Class<T> type) {
@@ -755,19 +756,14 @@ public class AnimPreviewState extends BaseAppState {
AnimClip renamed = new AnimClip(req.newName()); AnimClip renamed = new AnimClip(req.newName());
renamed.setTracks(src.getTracks()); renamed.setTracks(src.getTracks());
ac.addAnimClip(renamed); ac.addAnimClip(renamed);
saveModel(); // kein saveModel() Quell-Modell bleibt unverändert
// Als eigenständige .j3o nach animations/ exportieren // Als eigenständige .j3o nach animations/clips/ exportieren
try { try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animExport"); SkinningControl sc = findControl(currentModel, SkinningControl.class);
AnimComposer expAC = new AnimComposer(); java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
expAC.addAnimClip(renamed); saveClipToFile(renamed, sc != null ? sc.getArmature() : null,
holder.addControl(expAC); clipsDir.resolve(req.newName() + ".j3o"));
java.nio.file.Path outDir = ASSET_ROOT.resolve("animations");
java.nio.file.Files.createDirectories(outDir);
com.jme3.export.binary.BinaryExporter.getInstance()
.save(holder, outDir.resolve(req.newName() + ".j3o").toFile());
// Clip-Liste aktualisieren
java.util.List<String> clips = new java.util.ArrayList<>(); java.util.List<String> clips = new java.util.ArrayList<>();
collectClips(currentModel, clips); collectClips(currentModel, clips);
input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips)); input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips));
@@ -780,37 +776,106 @@ public class AnimPreviewState extends BaseAppState {
// ── Animations-Set speichern ────────────────────────────────────────────── // ── Animations-Set speichern ──────────────────────────────────────────────
private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) { private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) {
if (currentModel == null) { input.animOpStatus = "Fehler: kein Modell geladen"; return; }
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) { input.animOpStatus = "Fehler: kein AnimComposer"; return; }
try { try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animSet");
AnimComposer setAC = new AnimComposer();
int added = 0;
for (String clipName : req.clips()) {
AnimClip clip = ac.getAnimClip(clipName);
if (clip != null) { setAC.addAnimClip(clip); added++; }
}
holder.addControl(setAC);
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Files.createDirectories(setDir); java.nio.file.Files.createDirectories(setDir);
java.nio.file.Path j3oPath = setDir.resolve(req.setName() + ".j3o");
com.jme3.export.binary.BinaryExporter.getInstance().save(holder, j3oPath.toFile());
// Begleitende .animset.json mit Clip-Namen und Aktions-Zuweisung
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet(); de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(req.clips()); animSet.setClips(req.clips());
animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>()); animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>());
animSet.save(setDir, req.setName()); animSet.save(setDir, req.setName());
input.animOpStatus = "Animations-Set '" + req.setName() + "' gespeichert";
input.animOpStatus = "Set '" + req.setName() + "' gespeichert (" + added + " Clips)";
} catch (Exception e) { } catch (Exception e) {
input.animOpStatus = "Set-Fehler: " + e.getMessage(); input.animOpStatus = "Set-Fehler: " + e.getMessage();
} }
} }
private void executeAnimEmbed(SharedInput.AnimEmbedRequest req) {
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
de.blight.game.animation.AnimSet set;
try {
set = de.blight.game.animation.AnimSet.load(setDir, req.setName());
} catch (Exception e) {
input.animEmbedStatus = "Fehler: Set nicht gefunden " + e.getMessage();
return;
}
if (set.getClips().isEmpty()) {
input.animEmbedStatus = "Fehler: Set '" + req.setName() + "' enthält keine Clips";
return;
}
try {
Spatial charModel = loadFresh(req.characterModelPath());
AnimComposer charAC = findControl(charModel, AnimComposer.class);
SkinningControl charSC = findControl(charModel, SkinningControl.class);
if (charAC == null) {
input.animEmbedStatus = "Fehler: Charakter-Modell hat keinen AnimComposer";
return;
}
com.jme3.anim.Armature dstArm = charSC != null ? charSC.getArmature() : null;
int embedded = 0;
for (String clipName : set.getClips()) {
if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) {
System.err.println("[AnimEmbed] Clip nicht gefunden: " + clipName);
continue;
}
try {
Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o");
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
if (clipAC == null) continue;
com.jme3.anim.Armature srcArm = clipSC != null ? clipSC.getArmature() : null;
for (AnimClip clip : clipAC.getAnimClips()) {
if (charAC.getAnimClip(clip.getName()) != null) continue;
AnimClip target;
if (srcArm != null && dstArm != null && !haveSameBoneNames(srcArm, dstArm)) {
target = de.blight.game.animation.RetargetingSystem
.retarget(clip, srcArm, dstArm);
} else {
target = clip;
}
if (target != null) { charAC.addAnimClip(target); embedded++; }
}
} catch (Exception e) {
System.err.println("[AnimEmbed] Fehler bei Clip " + clipName + ": " + e.getMessage());
}
}
// Charakter-Modell mit eingebetteten Clips speichern
java.nio.file.Path charFile = ASSET_ROOT.resolve(
req.characterModelPath().replace('/', java.io.File.separatorChar));
BinaryExporter.getInstance().save(charModel, charFile.toFile());
assets.deleteFromCache(new com.jme3.asset.ModelKey(req.characterModelPath()));
input.animEmbedStatus = embedded + " Clip(s) in '"
+ req.characterModelPath() + "' eingebettet";
} catch (Exception e) {
input.animEmbedStatus = "Embed-Fehler: " + e.getMessage();
}
}
private void saveClipToFile(AnimClip clip, com.jme3.anim.Armature armature,
java.nio.file.Path outFile) throws Exception {
Node holder = new Node("clip_" + clip.getName());
AnimComposer expAC = new AnimComposer();
expAC.addAnimClip(clip);
holder.addControl(expAC);
if (armature != null) holder.addControl(new SkinningControl(armature));
java.nio.file.Files.createDirectories(outFile.getParent());
BinaryExporter.getInstance().save(holder, outFile.toFile());
}
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
if (a.getJointCount() != b.getJointCount()) return false;
java.util.Set<String> namesA = new java.util.HashSet<>();
for (var j : a.getJointList()) namesA.add(j.getName());
for (var j : b.getJointList()) if (!namesA.contains(j.getName())) return false;
return true;
}
private static java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> private static java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion>
buildMS(com.jme3.anim.Armature arm) { buildMS(com.jme3.anim.Armature arm) {
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache = new java.util.HashMap<>(); java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache = new java.util.HashMap<>();

View File

@@ -138,7 +138,7 @@ public class EzTreeState extends BaseAppState {
}); });
if (!req.exportAfter()) { if (!req.exportAfter()) {
input.treeGenStatusMsg = "EZ-Tree Vorschau: '" + req.exportName() + "'"; input.treeGenStatusMsg = "EZ-Tree Vorschau: " + resolveSubPath(req.presetName());
} else { } else {
input.treeGenStatusMsg = "EZ-Tree: generiere…"; input.treeGenStatusMsg = "EZ-Tree: generiere…";
} }
@@ -356,10 +356,14 @@ public class EzTreeState extends BaseAppState {
Node treeNode = pendingTreeNode; Node treeNode = pendingTreeNode;
cleanupCapture(); cleanupCapture();
String exportName = req.exportName() + "_" String subPath = resolveSubPath(req.presetName());
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); String namePart = req.presetName() != null
? req.presetName().toLowerCase().replace(" ", "_")
: subPath;
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String exportName = namePart + "_" + timestamp;
saveImpostor(pixels, "ez_impostor_" + exportName); saveImpostor(pixels, "ez_impostor_" + exportName);
exportTree(treeNode, req.exportName(), req.treeCategory()); exportTree(treeNode, exportName, subPath);
pendingRequest = null; pendingRequest = null;
pendingTreeNode = null; pendingTreeNode = null;
@@ -540,16 +544,14 @@ public class EzTreeState extends BaseAppState {
} }
} }
private void exportTree(Node treeNode, String name, String treeCategory) { private void exportTree(Node treeNode, String fileName, String subPath) {
try { try {
Path baseDir = (treeCategory != null && !treeCategory.isBlank()) Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
? ASSET_ROOT.resolve("trees").resolve(treeCategory)
: ASSET_ROOT.resolve("Models");
Files.createDirectories(baseDir); Files.createDirectories(baseDir);
File out = baseDir.resolve(name + ".j3o").toFile(); File out = baseDir.resolve(fileName + ".j3o").toFile();
BinaryExporter.getInstance().save(treeNode, out); BinaryExporter.getInstance().save(treeNode, out);
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath()); log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName(); input.treeGenStatusMsg = "Gespeichert: Models/trees/" + subPath + "/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;
input.refreshTreeFolders = true; input.refreshTreeFolders = true;
} catch (IOException e) { } catch (IOException e) {
@@ -557,4 +559,25 @@ public class EzTreeState extends BaseAppState {
input.treeGenStatusMsg = "EZ-Tree Export-Fehler: " + e.getMessage(); input.treeGenStatusMsg = "EZ-Tree Export-Fehler: " + e.getMessage();
} }
} }
private static String resolveSubPath(String presetName) {
if (presetName == null) return "unknown";
String lo = presetName.toLowerCase();
String size = lo.contains(" small") ? "/small"
: lo.contains(" medium") ? "/medium"
: lo.contains(" large") ? "/large"
: lo.contains(" 1") ? "/1"
: lo.contains(" 2") ? "/2"
: lo.contains(" 3") ? "/3"
: "";
if (lo.contains("oak")) return "oak" + size;
if (lo.contains("ash")) return "ash" + size;
if (lo.contains("aspen")) return "aspen" + size;
if (lo.contains("pine")) return "pine" + size;
if (lo.contains("bush")) return "bush" + size;
if (lo.contains("trellis")) return "trellis" + size;
return lo.replaceAll("\\s+.*", "") + size;
}
} }

View File

@@ -89,17 +89,14 @@ public class PalmGeneratorState extends BaseAppState {
final Vector3f finalTarget = target; final Vector3f finalTarget = target;
final Node finalPalm = palm; final Node finalPalm = palm;
final PalmOptions finalOpts = req.options(); final PalmOptions finalOpts = req.options();
final String finalName = req.exportName();
final boolean doExport = req.exportAfter(); final boolean doExport = req.exportAfter();
app.enqueue(() -> { app.enqueue(() -> {
previewHost.setPreviewContent(finalPalm, finalDist, finalTarget); previewHost.setPreviewContent(finalPalm, finalDist, finalTarget);
if (doExport) exportPalm(finalPalm, finalName); if (doExport) exportPalm(finalPalm);
}); });
input.treeGenStatusMsg = doExport input.treeGenStatusMsg = doExport ? "Palme: exportiere…" : "Palme: Vorschau";
? "Palme: exportiere…"
: "Palme: Vorschau '" + req.exportName() + "'";
} }
private void applyMaterials(Node palm, PalmOptions opts) { private void applyMaterials(Node palm, PalmOptions opts) {
@@ -190,16 +187,15 @@ public class PalmGeneratorState extends BaseAppState {
} }
} }
private void exportPalm(Node palmNode, String name) { private void exportPalm(Node palmNode) {
try { try {
Path modelDir = ASSET_ROOT.resolve("trees").resolve("palm"); Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm");
Files.createDirectories(modelDir); Files.createDirectories(modelDir);
String stampedName = name + "_" String fileName = "palm_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); File out = modelDir.resolve(fileName + ".j3o").toFile();
File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile();
BinaryExporter.getInstance().save(palmNode, out); BinaryExporter.getInstance().save(palmNode, out);
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath()); log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Palme exportiert: " + out.getName(); input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;
} catch (IOException e) { } catch (IOException e) {
log.error("[Palme] Export-Fehler: {}", e.getMessage()); log.error("[Palme] Export-Fehler: {}", e.getMessage());

View File

@@ -19,6 +19,8 @@ import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl; import com.jme3.scene.control.AbstractControl;
import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.common.GrassTuft;
import de.blight.common.GrassTuftIO;
import de.blight.common.MapData; import de.blight.common.MapData;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
@@ -27,31 +29,27 @@ import java.nio.IntBuffer;
import java.util.*; import java.util.*;
/** /**
* Rendert Gras auf dem Basis-Terrain. * Rendert individuell platzierte Gras-Büschel im Editor.
* *
* Datenmodell: Dichte-Map (513×513 Bytes, gleiche Auflösung wie Splatmap). * Jeder Büschel hat eine feste Weltposition (x, z), eine gebackene Höhe und einen Textur-Slot.
* Rendering: Pro 128×128-WE-Chunk ein gebatchtes Kreuz-Quad-Mesh. * Die Y-Koordinate wird beim Chunk-Rebuild live aus dem Terrain abgelesen.
* LOD: GrassVisibilityControl cullt Chunks jenseits FAR_DIST. *
* Wind: MatDefs/Grass.j3md (Vertex-Shader mit Sinus-Wind). * Chunks: 128×128-WE-Kacheln, lazy rebuild, LOD-Culling via GrassVisibilityControl.
*/ */
public class PlacedObjectState extends BaseAppState { public class PlacedObjectState extends BaseAppState {
// ── Terrain-Konstanten ──────────────────────────────────────────────────── // ── Terrain ───────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048; private static final int TERRAIN_HALF = 2048;
private static final float WORLD_SIZE = 4096f;
// ── Dichte-Map ────────────────────────────────────────────────────────────
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; // 513
private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1); // 8.0
// ── Chunks ──────────────────────────────────────────────────────────────── // ── Chunks ────────────────────────────────────────────────────────────────
private static final int CHUNK_SIZE = 128; private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32 private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
// ── Gras-Generierung ────────────────────────────────────────────────────── // ── Rendering ─────────────────────────────────────────────────────────────
private static final int MAX_BLADES_PER_PIXEL = 3; private static final float BLADE_WIDTH = 0.18f;
private static final float BLADE_WIDTH_FACTOR = 0.18f; private static final int BLADES_PER_TUFT = 4; // Kreuz-Quads pro Büschel
private static final float TUFT_SPREAD = 0.5f; // Streuradius (WE)
// ── LOD ─────────────────────────────────────────────────────────────────── // ── LOD ───────────────────────────────────────────────────────────────────
private static final float GRASS_FAR_DIST = 400f; private static final float GRASS_FAR_DIST = 400f;
@@ -64,23 +62,49 @@ public class PlacedObjectState extends BaseAppState {
private final SharedInput input; private final SharedInput input;
private Camera cam; private Camera cam;
private TerrainQuad terrain; private TerrainQuad terrain;
private AssetManager assetManager;
private Node grassNode; private Node grassNode;
private Material grassMat; private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private byte[] densityMap;
@SuppressWarnings("unchecked")
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT]; private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
private final Geometry[] chunkGeos = new Geometry[CHUNK_COUNT];
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
public PlacedObjectState(SharedInput input, MapData loadedData) { public PlacedObjectState(SharedInput input, MapData loadedData) {
this.input = input; this.input = input;
this.densityMap = new byte[SPLAT_SIZE * SPLAT_SIZE]; for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
if (loadedData != null && loadedData.grassDensity != null) {
System.arraycopy(loadedData.grassDensity, 0, densityMap, 0, densityMap.length); try {
Arrays.fill(dirtyChunks, true); GrassTuftIO.GrassData data = GrassTuftIO.load();
if (data != null) {
String[] paths = data.slotPaths();
if (paths != null && paths.length > 0) {
input.grassTexturePath = paths[0] != null ? paths[0] : "";
String[] slots = new String[]{"", "", "", "", "", "", ""};
for (int i = 0; i < 7; i++) {
int si = i + 1;
slots[i] = (si < paths.length && paths[si] != null) ? paths[si] : "";
}
input.grassTextureSlots = slots;
}
for (GrassTuft t : data.tufts()) {
int ci = chunkIndex(t.x(), t.z());
if (ci >= 0) {
chunkTufts[ci].add(t);
dirtyChunks[ci] = true;
}
}
}
} catch (Exception e) {
System.err.println("[PlacedObjectState] Grasdaten nicht ladbar: " + e.getMessage());
}
if (loadedData != null) {
input.grassTool.grassHeight.setValue(loadedData.grassDefaultHeight);
} }
} }
@@ -88,17 +112,36 @@ public class PlacedObjectState extends BaseAppState {
this.terrain = terrain; this.terrain = terrain;
} }
/** Gibt die aktuelle Dichte-Map zurück (für performSave). */ // ── Getters für Save ──────────────────────────────────────────────────────
public byte[] getDensityMap() { return densityMap; }
public List<GrassTuft> getAllTufts() {
List<GrassTuft> all = new ArrayList<>();
for (List<GrassTuft> list : chunkTufts) all.addAll(list);
return all;
}
public String[] getSlotPaths() {
String[] paths = new String[8];
paths[0] = input.grassTexturePath != null ? input.grassTexturePath : "";
String[] slots = input.grassTextureSlots;
for (int i = 0; i < 7 && i < slots.length; i++)
paths[i + 1] = slots[i] != null ? slots[i] : "";
return paths;
}
public float getGrassDefaultHeight() {
return (float) input.grassTool.grassHeight.getValue();
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
protected void initialize(Application app) { protected void initialize(Application app) {
this.cam = app.getCamera(); this.cam = app.getCamera();
this.assetManager = app.getAssetManager();
grassNode = new Node("grassNode"); grassNode = new Node("grassNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode); ((SimpleApplication) app).getRootNode().attachChild(grassNode);
grassMat = buildGrassMaterial(app.getAssetManager()); applyAllSlotMaterials();
} }
@Override @Override
@@ -111,30 +154,63 @@ public class PlacedObjectState extends BaseAppState {
@Override @Override
public void update(float tpf) { public void update(float tpf) {
if (input.grassSettingsChanged || input.grassSlotsChanged) {
input.grassSettingsChanged = false;
input.grassSlotsChanged = false;
applyAllSlotMaterials();
}
processGrassEdits(); processGrassEdits();
rebuildDirtyChunks(); rebuildDirtyChunks();
} }
// ── Material ────────────────────────────────────────────────────────────── // ── Materialien ───────────────────────────────────────────────────────────
private Material buildGrassMaterial(AssetManager assets) { private Material getMaterialForSlot(int slot) {
return slotMaterials.computeIfAbsent(slot, s -> buildFreshGrassMaterial());
}
private Material buildFreshGrassMaterial() {
try { try {
Material mat = new Material(assets, "MatDefs/Grass.j3md"); Material mat = new Material(assetManager, "MatDefs/Grass.j3md");
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f)); mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
mat.setFloat("WindSpeed", 0.5f); mat.setFloat("WindSpeed", 0.5f);
mat.setFloat("WindStrength", 0.12f); mat.setFloat("WindStrength", 0.12f);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat; return mat;
} catch (Exception e) { } catch (Exception e) {
System.err.println("[PlacedObjectState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage()); Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f)); mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat; return mat;
} }
} }
// ── Pinsel: Dichte-Map anpassen ─────────────────────────────────────────── private void applyAllSlotMaterials() {
if (assetManager == null) return;
applyTexToMat(getMaterialForSlot(0), input.grassTexturePath);
String[] slots = input.grassTextureSlots;
for (int i = 0; i < slots.length; i++) {
if (slots[i] != null && !slots[i].isEmpty())
applyTexToMat(getMaterialForSlot(i + 1), slots[i]);
}
}
private void applyTexToMat(Material mat, String path) {
if (path != null && !path.isEmpty()) {
try {
mat.setTexture("ColorMap", assetManager.loadTexture(path));
mat.setColor("Color", ColorRGBA.White);
} catch (Exception e) {
try { mat.clearParam("ColorMap"); } catch (Exception ignored) {}
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
}
} else {
try { mat.clearParam("ColorMap"); } catch (Exception ignored) {}
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
}
}
// ── Pinsel-Interaktion ────────────────────────────────────────────────────
private void processGrassEdits() { private void processGrassEdits() {
SharedInput.GrassEdit edit; SharedInput.GrassEdit edit;
@@ -144,62 +220,60 @@ public class PlacedObjectState extends BaseAppState {
float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY); float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY);
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal()); Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults(); CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits); terrain.collideWith(ray, hits);
if (hits.size() == 0) continue; if (hits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint(); Vector3f contact = hits.getClosestCollision().getContactPoint();
float radius = (float) input.grassTool.brushRadius.getValue(); float radius = (float) input.grassTool.brushRadius.getValue();
paintDensity(contact.x, contact.z, radius, edit.action()); if (edit.action() > 0) paintGrass(contact.x, contact.z, radius);
else eraseGrass(contact.x, contact.z, radius);
} }
} }
private void paintDensity(float cx, float cz, float radius, int action) { private void paintGrass(float cx, float cz, float radius) {
int centerPX = Math.round((cx + TERRAIN_HALF) / SPLAT_WE_PER_PX); int n = Math.max(1, (int) input.grassTool.density.getValue());
int centerPZ = Math.round((cz + TERRAIN_HALF) / SPLAT_WE_PER_PX); float baseH = (float) input.grassTool.grassHeight.getValue();
int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX); int slot = input.grassActiveSlot;
float strength = (float) input.grassTool.density.getValue() / 10f; // 0.15.0 Random rng = new Random();
for (int i = 0; i < n; i++) {
float angle = rng.nextFloat() * FastMath.TWO_PI;
float dist = FastMath.sqrt(rng.nextFloat()) * radius;
float bx = cx + dist * FastMath.cos(angle);
float bz = cz + dist * FastMath.sin(angle);
if (bx < -TERRAIN_HALF || bx > TERRAIN_HALF
|| bz < -TERRAIN_HALF || bz > TERRAIN_HALF) continue;
float h = baseH * (0.7f + rng.nextFloat() * 0.6f);
int ci = chunkIndex(bx, bz);
if (ci >= 0) {
chunkTufts[ci].add(new GrassTuft(bx, bz, h, slot));
dirtyChunks[ci] = true;
}
}
}
for (int dz = -pixR; dz <= pixR; dz++) { private void eraseGrass(float cx, float cz, float radius) {
int pz = centerPZ + dz; float rSq = radius * radius;
if (pz < 0 || pz >= SPLAT_SIZE) continue; for (int[] cc : overlappingChunks(cx, cz, radius)) {
for (int dx = -pixR; dx <= pixR; dx++) { int ci = cc[0] + cc[1] * CHUNKS_PER_AXIS;
int px = centerPX + dx; boolean changed = chunkTufts[ci].removeIf(t -> {
if (px < 0 || px >= SPLAT_SIZE) continue; float dx = t.x() - cx, dz = t.z() - cz;
float distWE = FastMath.sqrt(dx * dx + dz * dz) * SPLAT_WE_PER_PX; return dx * dx + dz * dz <= rSq;
if (distWE >= radius) continue; });
float t = distWE / radius; if (changed) dirtyChunks[ci] = true;
float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f;
int delta = (int)(strength * falloff * 40f);
int idx = pz * SPLAT_SIZE + px;
int cur = densityMap[idx] & 0xFF;
int nxt = (action > 0)
? Math.min(255, cur + delta)
: Math.max(0, cur - delta);
if (nxt != cur) {
densityMap[idx] = (byte) nxt;
markChunkDirtyAtPixel(px, pz);
}
}
} }
} }
// ── Höhenanpassung bei Terrain-Edit ─────────────────────────────────────── // ── Höhenanpassung bei Terrain-Edit ───────────────────────────────────────
/**
* Markiert alle Chunks dirty, deren Fläche eine der übergebenen Terrain-Positionen
* enthält. Die Blatt-Y-Koordinaten werden beim nächsten Rebuild neu von
* terrain.getHeight() abgelesen.
*/
public void adjustObjectHeights(List<Vector2f> locs, List<Float> deltas) { public void adjustObjectHeights(List<Vector2f> locs, List<Float> deltas) {
for (Vector2f loc : locs) { for (Vector2f loc : locs) {
int cx = (int)((loc.x + TERRAIN_HALF) / CHUNK_SIZE); int cx = (int) Math.floor((loc.x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((loc.y + TERRAIN_HALF) / CHUNK_SIZE); int cz = (int) Math.floor((loc.y + TERRAIN_HALF) / CHUNK_SIZE);
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) { if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS)
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true; dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
} }
} }
}
// ── Chunk-Rebuild ───────────────────────────────────────────────────────── // ── Chunk-Rebuild ─────────────────────────────────────────────────────────
@@ -215,90 +289,76 @@ public class PlacedObjectState extends BaseAppState {
private void rebuildChunk(int idx) { private void rebuildChunk(int idx) {
if (terrain == null) return; if (terrain == null) return;
int cx = idx % CHUNKS_PER_AXIS; int cx = idx % CHUNKS_PER_AXIS;
int cz = idx / CHUNKS_PER_AXIS; int cz = idx / CHUNKS_PER_AXIS;
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE; float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE; float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
// Dichte-Pixel-Bereich dieses Chunks if (chunkNodes[idx] != null) {
int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); grassNode.detachChild(chunkNodes[idx]);
int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); chunkNodes[idx] = null;
int pxMax = Math.min(SPLAT_SIZE - 1, (int)((wXMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); }
int pzMax = Math.min(SPLAT_SIZE - 1, (int)((wZMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); if (chunkTufts[idx].isEmpty()) return;
float baseH = (float) input.grassTool.grassHeight.getValue(); Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
for (GrassTuft t : chunkTufts[idx]) {
// Blatt-Positionen generieren long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
List<float[]> blades = new ArrayList<>(); // [x, y, z, height] ^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL;
for (int pz = pzMin; pz <= pzMax; pz++) { Random rng = new Random(seed);
for (int px = pxMin; px <= pxMax; px++) { List<float[]> blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>());
int d = densityMap[pz * SPLAT_SIZE + px] & 0xFF; for (int b = 0; b < BLADES_PER_TUFT; b++) {
if (d == 0) continue; float ox = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PIXEL)); float oz = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
Random rng = new Random((long) px * 100003L + pz); float bx = t.x() + ox;
float pixWorldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF; float bz = t.z() + oz;
float pixWorldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF;
for (int b = 0; b < count; b++) {
float bx = pixWorldX + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
float bz = pixWorldZ + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
float th = terrain.getHeight(new Vector2f(bx, bz)); float th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue; if (Float.isNaN(th)) continue;
float h = baseH * (0.7f + rng.nextFloat() * 0.6f); float bh = t.height() * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, h}); blades.add(new float[]{bx, th, bz, bh});
}
} }
} }
// Alte Geometrie entfernen
if (chunkGeos[idx] != null) {
grassNode.detachChild(chunkGeos[idx]);
chunkGeos[idx] = null;
}
if (blades.isEmpty()) return;
Mesh mesh = buildGrassMesh(blades);
float chunkCX = wXMin + CHUNK_SIZE * 0.5f; float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Geometry geo = new Geometry("grassChunk_" + idx, mesh); Node node = new Node("grassChunk_" + idx);
geo.setMaterial(grassMat); for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); if (entry.getValue().isEmpty()) continue;
grassNode.attachChild(geo); Geometry geo = new Geometry("grassChunk_" + idx + "_s" + entry.getKey(),
chunkGeos[idx] = geo; buildGrassMesh(entry.getValue()));
geo.setMaterial(getMaterialForSlot(entry.getKey()));
node.attachChild(geo);
}
if (node.getChildren().isEmpty()) return;
node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(node);
chunkNodes[idx] = node;
} }
// ── Mesh: Kreuz-Quad pro Halm mit UV-Koordinaten ────────────────────────── // ── Mesh ──────────────────────────────────────────────────────────────────
private static Mesh buildGrassMesh(List<float[]> blades) { private static Mesh buildGrassMesh(List<float[]> blades) {
int n = blades.size(); int n = blades.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 8 * 3); FloatBuffer pos = BufferUtils.createFloatBuffer(n * 8 * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(n * 8 * 2); FloatBuffer uv = BufferUtils.createFloatBuffer(n * 8 * 2);
IntBuffer idx = BufferUtils.createIntBuffer(n * 12); IntBuffer idx = BufferUtils.createIntBuffer(n * 12);
int vi = 0; int vi = 0;
for (float[] blade : blades) { for (float[] blade : blades) {
float x = blade[0], y = blade[1], z = blade[2], h = blade[3]; float x = blade[0], y = blade[1], z = blade[2], h = blade[3];
float w = Math.max(0.05f, h * BLADE_WIDTH_FACTOR); float w = Math.max(0.05f, h * BLADE_WIDTH);
// Quad A Breite entlang X-Achse
pos.put(x-w).put(y ).put(z); uv.put(0).put(0); pos.put(x-w).put(y ).put(z); uv.put(0).put(0);
pos.put(x+w).put(y ).put(z); uv.put(1).put(0); pos.put(x+w).put(y ).put(z); uv.put(1).put(0);
pos.put(x+w).put(y+h).put(z); uv.put(1).put(1); pos.put(x+w).put(y+h).put(z); uv.put(1).put(1);
pos.put(x-w).put(y+h).put(z); uv.put(0).put(1); pos.put(x-w).put(y+h).put(z); uv.put(0).put(1);
// Quad B Breite entlang Z-Achse
pos.put(x).put(y ).put(z-w); uv.put(0).put(0); pos.put(x).put(y ).put(z-w); uv.put(0).put(0);
pos.put(x).put(y ).put(z+w); uv.put(1).put(0); pos.put(x).put(y ).put(z+w); uv.put(1).put(0);
pos.put(x).put(y+h).put(z+w); uv.put(1).put(1); pos.put(x).put(y+h).put(z+w); uv.put(1).put(1);
pos.put(x).put(y+h).put(z-w); uv.put(0).put(1); pos.put(x).put(y+h).put(z-w); uv.put(0).put(1);
idx.put(vi).put(vi+1).put(vi+2);
idx.put(vi ).put(vi+1).put(vi+2); idx.put(vi).put(vi+2).put(vi+3);
idx.put(vi ).put(vi+2).put(vi+3);
idx.put(vi+4).put(vi+5).put(vi+6); idx.put(vi+4).put(vi+5).put(vi+6);
idx.put(vi+4).put(vi+6).put(vi+7); idx.put(vi+4).put(vi+6).put(vi+7);
vi += 8; vi += 8;
} }
Mesh mesh = new Mesh(); Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv); mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
@@ -312,12 +372,10 @@ public class PlacedObjectState extends BaseAppState {
private static final class GrassVisibilityControl extends AbstractControl { private static final class GrassVisibilityControl extends AbstractControl {
private final Camera cam; private final Camera cam;
private final Vector3f center; private final Vector3f center;
GrassVisibilityControl(Camera cam, Vector3f center) { GrassVisibilityControl(Camera cam, Vector3f center) {
this.cam = cam; this.cam = cam;
this.center = center; this.center = center;
} }
@Override @Override
protected void controlUpdate(float tpf) { protected void controlUpdate(float tpf) {
float distSq = cam.getLocation().distanceSquared(center); float distSq = cam.getLocation().distanceSquared(center);
@@ -325,19 +383,30 @@ public class PlacedObjectState extends BaseAppState {
? Spatial.CullHint.Always ? Spatial.CullHint.Always
: Spatial.CullHint.Inherit); : Spatial.CullHint.Inherit);
} }
@Override protected void controlRender(RenderManager rm, ViewPort vp) {} @Override protected void controlRender(RenderManager rm, ViewPort vp) {}
} }
// ── Hilfsmethoden ───────────────────────────────────────────────────────── // ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void markChunkDirtyAtPixel(int px, int pz) { private int chunkIndex(float wx, float wz) {
float worldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF; int cx = (int) Math.floor((wx + TERRAIN_HALF) / CHUNK_SIZE);
float worldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF; int cz = (int) Math.floor((wz + TERRAIN_HALF) / CHUNK_SIZE);
int cx = (int)((worldX + TERRAIN_HALF) / CHUNK_SIZE); if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
int cz = (int)((worldZ + TERRAIN_HALF) / CHUNK_SIZE); return cx + cz * CHUNKS_PER_AXIS;
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) {
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
} }
private int[][] overlappingChunks(float cx, float cz, float radius) {
int r = (int) Math.ceil(radius / CHUNK_SIZE) + 1;
int ccx = (int) Math.floor((cx + TERRAIN_HALF) / CHUNK_SIZE);
int ccz = (int) Math.floor((cz + TERRAIN_HALF) / CHUNK_SIZE);
List<int[]> result = new ArrayList<>();
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
int nx = ccx + dx, nz = ccz + dz;
if (nx >= 0 && nx < CHUNKS_PER_AXIS && nz >= 0 && nz < CHUNKS_PER_AXIS)
result.add(new int[]{nx, nz});
}
}
return result.toArray(new int[0][]);
} }
} }

View File

@@ -0,0 +1,493 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
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.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.RiverIO;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* Editor-State für das Fluss-Werkzeug.
* Erlaubt das interaktive Platzieren von Fluss-Kontrollpunkten auf dem Terrain
* und zeigt eine Live-Ribbon-Vorschau.
*/
public class RiverEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(RiverEditorState.class);
private static final float UV_SCALE = 4.0f;
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// ── Zustand ───────────────────────────────────────────────────────────────
private final List<List<RiverPoint>> rivers = new ArrayList<>();
private final List<List<Geometry>> pointGeos = new ArrayList<>();
private final List<Geometry> ribbonGeos = new ArrayList<>();
private int activeRiver = -1; // -1 = kein aktiver Fluss (wird gebaut)
private int selectedRiver = -1; // -1 = keine Selektion
public RiverEditorState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.cam = app.getCamera();
this.assets = app.getAssetManager();
this.rootNode = this.app.getRootNode();
try {
List<List<RiverPoint>> saved = RiverIO.load();
if (!saved.isEmpty()) loadPlacedRivers(saved);
} catch (Exception e) {
log.error("Flüsse nicht ladbar", e);
}
}
@Override
protected void cleanup(Application app) {
clearAll();
}
@Override
protected void onEnable() {
setCullHintAll(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
setCullHintAll(Spatial.CullHint.Always);
}
// ── Terrain ───────────────────────────────────────────────────────────────
private TerrainEditorState terrainEditor;
public void setTerrain(TerrainQuad t) {
this.terrain = t;
}
public void setTerrainEditor(TerrainEditorState te) {
this.terrainEditor = te;
}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_RIVERS) return;
// Undo: letzten Punkt des aktiven Flusses entfernen
if (input.undoRiverPointRequested) {
input.undoRiverPointRequested = false;
undoLastPoint();
}
// Selektierten Fluss löschen
if (input.deleteRiverRequested) {
input.deleteRiverRequested = false;
if (selectedRiver >= 0) {
removeRiver(selectedRiver);
selectedRiver = -1;
input.selectedRiverInfo = null;
input.riverSelectionChanged = true;
}
}
// Click-Queue verarbeiten
SharedInput.RiverClick click;
while ((click = input.riverClickQueue.poll()) != null) {
handleClick(click);
}
}
// ── Click-Verarbeitung ────────────────────────────────────────────────────
private void handleClick(SharedInput.RiverClick click) {
if (click.rightButton()) {
// Rechtsklick: aktiven Fluss abschließen
finalizeActiveRiver();
return;
}
if (terrain == null) return;
float jmeX = (float)(click.screenX() * input.viewportScaleX);
float jmeY = cam.getHeight() - (float)(click.screenY() * input.viewportScaleY);
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f hit = hits.getClosestCollision().getContactPoint();
// Kein aktiver Fluss: prüfen ob ein bestehender Fluss in der Nähe liegt → selektieren
if (activeRiver < 0) {
int nearby = findNearestRiver(hit, 8f);
if (nearby >= 0) {
selectRiver(nearby);
return;
}
// Klick ins Leere → Selektion aufheben
selectRiver(-1);
}
float w = input.riverNewWidth;
float speed = input.riverNewSpeed;
RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, w, speed);
addPoint(pt);
}
private void addPoint(RiverPoint pt) {
// Neuen Fluss starten wenn kein aktiver
if (activeRiver < 0 || activeRiver >= rivers.size()) {
rivers.add(new ArrayList<>());
pointGeos.add(new ArrayList<>());
ribbonGeos.add(null);
activeRiver = rivers.size() - 1;
}
List<RiverPoint> current = rivers.get(activeRiver);
if (!current.isEmpty() && terrainEditor != null) {
RiverPoint prev = current.get(current.size() - 1);
// Gefälle sicherstellen: neuer Punkt darf nicht höher als vorheriger sein
float newY = Math.min(pt.y(), prev.y() - 0.05f);
pt = new RiverPoint(pt.x(), newY, pt.z(), pt.width(), pt.uvSpeed());
// Flussbett graben
terrainEditor.carveRiverbedSegment(
prev.x(), prev.y(), prev.z(),
pt.x(), pt.y(), pt.z(),
pt.width() * 0.5f
);
}
rivers.get(activeRiver).add(pt);
// Kontrollpunkt-Geo
Geometry sphere = buildPointGeo(pt);
rootNode.attachChild(sphere);
pointGeos.get(activeRiver).add(sphere);
// Ribbon neu aufbauen
rebuildActiveRibbon();
}
private void finalizeActiveRiver() {
// Fluss mit weniger als 2 Punkten verwerfen
if (activeRiver >= 0 && activeRiver < rivers.size()) {
if (rivers.get(activeRiver).size() < 2) {
removeRiver(activeRiver);
}
}
activeRiver = -1;
}
private void undoLastPoint() {
if (activeRiver < 0 || activeRiver >= rivers.size()) return;
List<RiverPoint> pts = rivers.get(activeRiver);
List<Geometry> geos = pointGeos.get(activeRiver);
if (pts.isEmpty()) return;
pts.remove(pts.size() - 1);
Geometry last = geos.remove(geos.size() - 1);
rootNode.detachChild(last);
if (pts.isEmpty()) {
removeRiver(activeRiver);
activeRiver = -1;
} else {
rebuildActiveRibbon();
}
}
// ── Ribbon-Vorschau ───────────────────────────────────────────────────────
private void rebuildActiveRibbon() {
if (activeRiver < 0 || activeRiver >= rivers.size()) return;
// Altes Ribbon entfernen
Geometry old = ribbonGeos.get(activeRiver);
if (old != null) rootNode.detachChild(old);
List<RiverPoint> pts = rivers.get(activeRiver);
if (pts.size() < 2) {
ribbonGeos.set(activeRiver, null);
return;
}
Geometry ribbon = buildRibbon(pts);
ribbonGeos.set(activeRiver, ribbon);
rootNode.attachChild(ribbon);
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Lädt bereits gespeicherte Flüsse und baut deren Visualisierungen auf.
*/
public void loadPlacedRivers(List<List<RiverPoint>> loaded) {
clearAll();
log.info("Lade {} Fluss/Flüsse aus Datei", loaded.size());
for (List<RiverPoint> river : loaded) {
if (river == null || river.isEmpty()) continue;
int idx = rivers.size();
rivers.add(new ArrayList<>(river));
pointGeos.add(new ArrayList<>());
ribbonGeos.add(null);
for (RiverPoint pt : river) {
Geometry sphere = buildPointGeo(pt);
rootNode.attachChild(sphere);
pointGeos.get(idx).add(sphere);
}
if (river.size() >= 2) {
Geometry ribbon = buildRibbon(river);
ribbonGeos.set(idx, ribbon);
rootNode.attachChild(ribbon);
}
}
activeRiver = -1;
}
/**
* Gibt eine Kopie der aktuell platzierten Flüsse zurück.
*/
public List<List<RiverPoint>> getPlacedRivers() {
List<List<RiverPoint>> copy = new ArrayList<>();
for (List<RiverPoint> river : rivers) {
if (river != null && river.size() >= 2) {
copy.add(new ArrayList<>(river));
}
}
return copy;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void clearAll() {
for (List<Geometry> geos : pointGeos) {
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
}
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) rootNode.detachChild(ribbon);
}
rivers.clear();
pointGeos.clear();
ribbonGeos.clear();
activeRiver = -1;
}
private void removeRiver(int idx) {
if (idx < 0 || idx >= rivers.size()) return;
List<Geometry> geos = pointGeos.get(idx);
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
Geometry ribbon = ribbonGeos.get(idx);
if (ribbon != null) rootNode.detachChild(ribbon);
rivers.remove(idx);
pointGeos.remove(idx);
ribbonGeos.remove(idx);
if (activeRiver > idx) activeRiver--;
else if (activeRiver == idx) activeRiver = -1;
if (selectedRiver > idx) selectedRiver--;
else if (selectedRiver == idx) selectedRiver = -1;
}
private void selectRiver(int idx) {
if (selectedRiver == idx) return;
// Altes Highlight zurücksetzen
if (selectedRiver >= 0 && selectedRiver < ribbonGeos.size()) {
Geometry old = ribbonGeos.get(selectedRiver);
if (old != null)
old.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f));
}
selectedRiver = idx;
if (idx >= 0 && idx < rivers.size()) {
Geometry ribbon = ribbonGeos.get(idx);
if (ribbon != null)
ribbon.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(1.0f, 0.75f, 0.0f, 0.9f));
List<RiverPoint> pts = rivers.get(idx);
float len = computeLength(pts);
input.selectedRiverInfo = idx + "|" + pts.size() + "|" + String.format(java.util.Locale.ROOT, "%.1f", len);
} else {
input.selectedRiverInfo = null;
}
input.riverSelectionChanged = true;
}
private static float computeLength(List<RiverPoint> pts) {
float len = 0f;
for (int i = 1; i < pts.size(); i++) {
RiverPoint a = pts.get(i - 1), b = pts.get(i);
float dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z();
len += FastMath.sqrt(dx * dx + dy * dy + dz * dz);
}
return len;
}
/** Gibt den Index des Flusses zurück, dessen nächster Punkt < threshold entfernt liegt, sonst -1. */
private int findNearestRiver(Vector3f worldPos, float threshold) {
float minDist = threshold * threshold;
int best = -1;
for (int i = 0; i < rivers.size(); i++) {
List<RiverPoint> pts = rivers.get(i);
if (pts == null) continue;
for (RiverPoint p : pts) {
float dx = p.x() - worldPos.x;
float dz = p.z() - worldPos.z;
float d2 = dx * dx + dz * dz;
if (d2 < minDist) { minDist = d2; best = i; }
}
}
return best;
}
private void setCullHintAll(Spatial.CullHint hint) {
for (List<Geometry> geos : pointGeos) {
if (geos != null) for (Geometry g : geos) g.setCullHint(hint);
}
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) ribbon.setCullHint(hint);
}
}
private Geometry buildPointGeo(RiverPoint pt) {
Sphere sphere = new Sphere(8, 8, 0.4f);
Geometry geo = new Geometry("riverPoint", sphere);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (pt.isWaterfall()) {
mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 1f));
} else {
mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 1f));
}
geo.setMaterial(mat);
geo.setLocalTranslation(pt.x(), pt.y() + 0.4f, pt.z());
return geo;
}
/**
* Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau).
*/
Geometry buildRibbon(List<RiverPoint> pts) {
pts = RiverSpline.subdivide(pts);
int n = pts.size();
if (n < 2) return null;
int vertCount = n * 2;
FloatBuffer pos = BufferUtils.createFloatBuffer(vertCount * 3);
FloatBuffer norm = BufferUtils.createFloatBuffer(vertCount * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(vertCount * 2);
IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3);
Vector3f UP = Vector3f.UNIT_Y;
float[] arcLen = new float[n];
arcLen[0] = 0f;
for (int i = 1; i < n; i++) {
RiverPoint a = pts.get(i - 1);
RiverPoint b = pts.get(i);
float dx = b.x() - a.x();
float dz = b.z() - a.z();
float dy = b.y() - a.y();
arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz);
}
for (int i = 0; i < n; i++) {
RiverPoint pt = pts.get(i);
Vector3f tangent;
if (i == 0) {
RiverPoint next = pts.get(1);
tangent = new Vector3f(next.x() - pt.x(), next.y() - pt.y(), next.z() - pt.z());
} else if (i == n - 1) {
RiverPoint prev = pts.get(n - 2);
tangent = new Vector3f(pt.x() - prev.x(), pt.y() - prev.y(), pt.z() - prev.z());
} else {
RiverPoint prev = pts.get(i - 1);
RiverPoint next = pts.get(i + 1);
tangent = new Vector3f(next.x() - prev.x(), next.y() - prev.y(), next.z() - prev.z());
}
if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f);
tangent.normalizeLocal();
Vector3f right = tangent.cross(UP).normalizeLocal();
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y() + 0.05f, pz = pt.z();
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(0f).put(arcLen[i] / UV_SCALE);
pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(1f).put(arcLen[i] / UV_SCALE);
}
for (int i = 0; i < n - 1; i++) {
int v0 = 2 * i, v1 = 2 * i + 1, v2 = 2 * i + 2, v3 = 2 * i + 3;
idx.put(v0).put(v1).put(v3);
idx.put(v0).put(v3).put(v2);
}
pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Normal, 3, norm);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
Geometry geo = new Geometry("riverRibbon", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
return geo;
}
}

View File

@@ -143,7 +143,8 @@ public class SceneObjectState extends BaseAppState {
so.getRotY(), so.getRotX(), so.getRotZ(), so.getRotY(), so.getRotX(), so.getRotZ(),
so.getScale(), so.solid, so.getScale(), so.solid,
so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(), so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(),
meshFile, animClips.get(i))); meshFile, animClips.get(i),
so.castShadow, so.receiveShadow));
} }
return list; return list;
} }
@@ -165,6 +166,8 @@ public class SceneObjectState extends BaseAppState {
so.setTexturePath(pm.texturePath()); so.setTexturePath(pm.texturePath());
so.setNormalMapPath(pm.normalMapPath()); so.setNormalMapPath(pm.normalMapPath());
so.setMaterialPath(pm.materialPath()); so.setMaterialPath(pm.materialPath());
so.castShadow = pm.castShadow();
so.receiveShadow = pm.receiveShadow();
objects.add(so); objects.add(so);
animClips.add(pm.animClip() != null ? pm.animClip() : ""); animClips.add(pm.animClip() != null ? pm.animClip() : "");
@@ -777,7 +780,8 @@ public class SceneObjectState extends BaseAppState {
+ "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ() + "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ()
+ "|" + so.getScale() + "|" + so.getTexturePath() + "|" + so.getScale() + "|" + so.getTexturePath()
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath() + "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
+ "|" + animClips.get(idx); + "|" + animClips.get(idx)
+ "|" + so.castShadow + "|" + so.receiveShadow;
} else { } else {
input.selectedObjectInfo = String.valueOf(n); input.selectedObjectInfo = String.valueOf(n);
} }
@@ -1221,6 +1225,8 @@ public class SceneObjectState extends BaseAppState {
node.setLocalRotation(q); node.setLocalRotation(q);
so.solid = prop.solid(); so.solid = prop.solid();
so.castShadow = prop.castShadow();
so.receiveShadow = prop.receiveShadow();
boolean appearanceChanged = false; boolean appearanceChanged = false;
if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; } if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; }

View File

@@ -26,6 +26,7 @@ import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import com.jme3.util.SkyFactory; import com.jme3.util.SkyFactory;
import de.blight.common.EmitterIO; import de.blight.common.EmitterIO;
import de.blight.common.GrassTuftIO;
import de.blight.common.LightIO; import de.blight.common.LightIO;
import de.blight.common.MusicAreaIO; import de.blight.common.MusicAreaIO;
import de.blight.common.SoundAreaIO; import de.blight.common.SoundAreaIO;
@@ -35,6 +36,8 @@ import de.blight.common.MapIO;
import de.blight.common.PlacedModelIO; import de.blight.common.PlacedModelIO;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import de.blight.editor.tool.HeightTool; import de.blight.editor.tool.HeightTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.io.Reader;
@@ -52,6 +55,8 @@ import java.util.Properties;
public class TerrainEditorState extends BaseAppState { public class TerrainEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TerrainEditorState.class);
// ── Terrain-Konstanten ──────────────────────────────────────────────────── // ── Terrain-Konstanten ────────────────────────────────────────────────────
private static final int TERRAIN_SIZE = 4096; private static final int TERRAIN_SIZE = 4096;
private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097 private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097
@@ -77,6 +82,7 @@ public class TerrainEditorState extends BaseAppState {
private TerrainQuad terrain; private TerrainQuad terrain;
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
private Geometry brushIndicator; private Geometry brushIndicator;
private Geometry livePlayerMarker;
private PlacedObjectState placedObjectState; private PlacedObjectState placedObjectState;
private SceneObjectState sceneObjState; private SceneObjectState sceneObjState;
private LightState lightState; private LightState lightState;
@@ -84,6 +90,7 @@ public class TerrainEditorState extends BaseAppState {
private WaterBodyState waterBodyState; private WaterBodyState waterBodyState;
private SoundAreaState soundAreaState; private SoundAreaState soundAreaState;
private MusicAreaState musicAreaState; private MusicAreaState musicAreaState;
private RiverEditorState riverEditorState;
private MapData loadedMapData; private MapData loadedMapData;
private Node axesGizmo; private Node axesGizmo;
@@ -127,9 +134,9 @@ public class TerrainEditorState extends BaseAppState {
if (MapIO.exists()) { if (MapIO.exists()) {
try { try {
loadedMapData = MapIO.load(); loadedMapData = MapIO.load();
System.out.println("[TerrainEditor] Karte geladen: " + MapIO.getMapPath()); log.info("Karte geladen: {}", MapIO.getMapPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Karte nicht ladbar: " + e.getMessage()); log.error("Karte nicht ladbar", e);
} }
} }
loadCameraPrefs(); loadCameraPrefs();
@@ -154,7 +161,7 @@ public class TerrainEditorState extends BaseAppState {
camYaw = (float) Math.toRadians(parsePref(p, "cam.yaw", 0f)); camYaw = (float) Math.toRadians(parsePref(p, "cam.yaw", 0f));
camPitch = (float) Math.toRadians(parsePref(p, "cam.pitch", (float) Math.toDegrees(DEFAULT_PITCH))); camPitch = (float) Math.toRadians(parsePref(p, "cam.pitch", (float) Math.toDegrees(DEFAULT_PITCH)));
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Kamera-Prefs nicht ladbar: " + e.getMessage()); log.warn("Kamera-Prefs nicht ladbar", e);
} }
} }
@@ -184,10 +191,12 @@ public class TerrainEditorState extends BaseAppState {
// ── Szene aufbauen ──────────────────────────────────────────────────────── // ── Szene aufbauen ────────────────────────────────────────────────────────
private void buildScene() { private void buildScene() {
input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain(); terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
rootNode.attachChild(terrain); rootNode.attachChild(terrain);
input.loadingStatus = "Lade platzierte Objekte...";
placedObjectState = new PlacedObjectState(input, loadedMapData); placedObjectState = new PlacedObjectState(input, loadedMapData);
placedObjectState.setTerrain(terrain); placedObjectState.setTerrain(terrain);
app.getStateManager().attach(placedObjectState); app.getStateManager().attach(placedObjectState);
@@ -199,10 +208,11 @@ public class TerrainEditorState extends BaseAppState {
var placed = PlacedModelIO.load(); var placed = PlacedModelIO.load();
if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed); if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Objekte nicht ladbar: " + e.getMessage()); log.error("Objekte nicht ladbar", e);
} }
} }
input.loadingStatus = "Lade Lichter...";
lightState = app.getStateManager().getState(LightState.class); lightState = app.getStateManager().getState(LightState.class);
if (lightState != null) { if (lightState != null) {
lightState.setTerrain(terrain); lightState.setTerrain(terrain);
@@ -210,10 +220,11 @@ public class TerrainEditorState extends BaseAppState {
var lights = LightIO.load(); var lights = LightIO.load();
if (!lights.isEmpty()) lightState.loadPlacedLights(lights); if (!lights.isEmpty()) lightState.loadPlacedLights(lights);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Lichter nicht ladbar: " + e.getMessage()); log.error("Lichter nicht ladbar", e);
} }
} }
input.loadingStatus = "Lade Emitter...";
emitterState = app.getStateManager().getState(EmitterState.class); emitterState = app.getStateManager().getState(EmitterState.class);
if (emitterState != null) { if (emitterState != null) {
emitterState.setTerrain(terrain); emitterState.setTerrain(terrain);
@@ -221,21 +232,24 @@ public class TerrainEditorState extends BaseAppState {
var emitters = EmitterIO.load(); var emitters = EmitterIO.load();
if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters); if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Emitter nicht ladbar: " + e.getMessage()); log.error("Emitter nicht ladbar", e);
} }
} }
input.loadingStatus = "Lade Wasserflächen...";
waterBodyState = app.getStateManager().getState(WaterBodyState.class); waterBodyState = app.getStateManager().getState(WaterBodyState.class);
if (waterBodyState != null) { if (waterBodyState != null) {
waterBodyState.setTerrain(terrain); waterBodyState.setTerrain(terrain);
waterBodyState.setHeightMap(cachedHeightMap);
try { try {
var waters = WaterBodyIO.load(); var waters = WaterBodyIO.load();
if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters); if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Wasseroberflächen nicht ladbar: " + e.getMessage()); log.error("Wasseroberflächen nicht ladbar", e);
} }
} }
input.loadingStatus = "Lade Sound-Bereiche...";
soundAreaState = app.getStateManager().getState(SoundAreaState.class); soundAreaState = app.getStateManager().getState(SoundAreaState.class);
if (soundAreaState != null) { if (soundAreaState != null) {
soundAreaState.setTerrain(terrain); soundAreaState.setTerrain(terrain);
@@ -243,10 +257,11 @@ public class TerrainEditorState extends BaseAppState {
var soundAreas = SoundAreaIO.load(); var soundAreas = SoundAreaIO.load();
if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas); if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Sound-Bereiche nicht ladbar: " + e.getMessage()); log.error("Sound-Bereiche nicht ladbar", e);
} }
} }
input.loadingStatus = "Lade Musikbereiche...";
musicAreaState = app.getStateManager().getState(MusicAreaState.class); musicAreaState = app.getStateManager().getState(MusicAreaState.class);
if (musicAreaState != null) { if (musicAreaState != null) {
musicAreaState.setTerrain(terrain); musicAreaState.setTerrain(terrain);
@@ -254,10 +269,17 @@ public class TerrainEditorState extends BaseAppState {
var musicAreas = MusicAreaIO.load(); var musicAreas = MusicAreaIO.load();
if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas); if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[TerrainEditor] Musik-Bereiche nicht ladbar: " + e.getMessage()); log.error("Musik-Bereiche nicht ladbar", e);
} }
} }
riverEditorState = app.getStateManager().getState(RiverEditorState.class);
if (riverEditorState != null) {
riverEditorState.setTerrain(terrain);
riverEditorState.setTerrainEditor(this);
}
input.loadingStatus = "Baue Szene...";
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class); PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
if (playToolState != null) playToolState.setTerrain(terrain); if (playToolState != null) playToolState.setTerrain(terrain);
@@ -267,6 +289,9 @@ public class TerrainEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator(); brushIndicator = buildBrushIndicator();
rootNode.attachChild(brushIndicator); rootNode.attachChild(brushIndicator);
livePlayerMarker = buildLivePlayerMarker();
rootNode.attachChild(livePlayerMarker);
axesGizmo = buildAxesGizmo(); axesGizmo = buildAxesGizmo();
rootNode.attachChild(axesGizmo); rootNode.attachChild(axesGizmo);
} }
@@ -612,6 +637,7 @@ public class TerrainEditorState extends BaseAppState {
processTextureEdits(); processTextureEdits();
updateBrushIndicator(); updateBrushIndicator();
updateAxesGizmo(); updateAxesGizmo();
updateLivePlayerMarker();
// Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert // Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
@@ -673,6 +699,120 @@ public class TerrainEditorState extends BaseAppState {
return h != null ? h : 0f; return h != null ? h : 0f;
} }
// ── Flussbett graben ──────────────────────────────────────────────────────
/**
* Gräbt ein Flussbett zwischen zwei Wasseroberflächenpunkten A und B.
* Alle Terrain-Vertices innerhalb von halfWidth werden auf die linear
* interpolierte Höhe (A→B) minus Kanaltiefen-Offset abgesenkt.
* Am Kanalrand weicher Übergang zurück zur Original-Höhe.
*/
public void carveRiverbedSegment(float ax, float ay, float az,
float bx, float by, float bz,
float halfWidth) {
if (terrain == null || cachedHeightMap == null) return;
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx * segDx + segDz * segDz;
if (segLen2 < 0.001f) return;
// Tiefe proportional zur Breite: 0,5m bei 4m Breite, 1,0m bei 10m Breite
float width = halfWidth * 2f;
float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f));
// ── Terrain-Vertices graben ──────────────────────────────────────────
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vxMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(ax, bx) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vzMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(az, bz) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
List<Vector2f> locs = new ArrayList<>();
List<Float> deltas = new ArrayList<>();
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx - TERRAIN_SIZE * 0.5f;
float worldZ = vz - TERRAIN_SIZE * 0.5f;
float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2;
t = FastMath.clamp(t, 0f, 1f);
float projX = ax + t * segDx, projZ = az + t * segDz;
float dist = FastMath.sqrt((worldX - projX) * (worldX - projX)
+ (worldZ - projZ) * (worldZ - projZ));
if (dist > halfWidth) continue;
float waterY = ay + t * (by - ay);
// U-Form: 60% des Kanals als flacher Boden, danach linearer Anstieg
float norm = dist / halfWidth;
float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f);
float depth = maxDepth * uShape;
float target = waterY - depth;
int idx = vz * TOTAL_SIZE + vx;
float curH = cachedHeightMap[idx];
if (curH > target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
}
}
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
// ── Textur 4 (splatA) malen: Flussbett + 25% Breite pro Seite ────────
if (splatR == null) return;
float paintHW = halfWidth * 1.5f; // +25% Breite auf jeder Seite
float minX = Math.min(ax, bx) - paintHW;
float maxX = Math.max(ax, bx) + paintHW;
float minZ = Math.min(az, bz) - paintHW;
float maxZ = Math.max(az, bz) + paintHW;
int pxMin = Math.max(0, (int)((minX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pxMax = Math.min(SPLAT_SIZE - 1, (int)((maxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
// Z-Achse ist in der Splatmap gespiegelt
int pzMin = Math.max(0, (SPLAT_SIZE - 1) - (int)((maxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pzMax = Math.min(SPLAT_SIZE - 1, (SPLAT_SIZE - 1) - (int)((minZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
boolean splatChanged = false;
for (int pz = pzMin; pz <= pzMax; pz++) {
float worldZ = (SPLAT_SIZE - 1 - pz) * SPLAT_WE_PER_PX - WORLD_HALF;
for (int px = pxMin; px <= pxMax; px++) {
float worldX = px * SPLAT_WE_PER_PX - WORLD_HALF;
float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2;
t = FastMath.clamp(t, 0f, 1f);
float projX = ax + t * segDx, projZ = az + t * segDz;
float dist = FastMath.sqrt((worldX - projX) * (worldX - projX)
+ (worldZ - projZ) * (worldZ - projZ));
if (dist > paintHW) continue;
int sidx = pz * SPLAT_SIZE + px;
splatR[sidx] = (byte) 255;
splatG[sidx] = (byte) 0;
splatB[sidx] = (byte) 0;
splatA[sidx] = (byte) 255;
int bi = sidx * 4;
splatBuf.put(bi, (byte) 255);
splatBuf.put(bi + 1, (byte) 0);
splatBuf.put(bi + 2, (byte) 0);
splatBuf.put(bi + 3, (byte) 255);
splatChanged = true;
}
}
if (splatChanged) {
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
}
// ── Speichern ───────────────────────────────────────────────────────────── // ── Speichern ─────────────────────────────────────────────────────────────
private void performSave() { private void performSave() {
@@ -702,8 +842,13 @@ public class TerrainEditorState extends BaseAppState {
} }
if (placedObjectState != null) { if (placedObjectState != null) {
System.arraycopy(placedObjectState.getDensityMap(), 0, try {
data.grassDensity, 0, data.grassDensity.length); GrassTuftIO.save(new GrassTuftIO.GrassData(
placedObjectState.getSlotPaths(),
placedObjectState.getAllTufts()));
} catch (IOException e) {
log.error("Gras nicht speicherbar", e);
}
} }
MapIO.save(data); MapIO.save(data);
@@ -719,6 +864,9 @@ public class TerrainEditorState extends BaseAppState {
if (waterBodyState != null) { if (waterBodyState != null) {
WaterBodyIO.save(waterBodyState.getPlacedBodies()); WaterBodyIO.save(waterBodyState.getPlacedBodies());
} }
if (riverEditorState != null) {
de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers());
}
if (soundAreaState != null) { if (soundAreaState != null) {
SoundAreaIO.save(soundAreaState.getPlacedAreas()); SoundAreaIO.save(soundAreaState.getPlacedAreas());
} }
@@ -726,10 +874,10 @@ public class TerrainEditorState extends BaseAppState {
MusicAreaIO.save(musicAreaState.getPlacedAreas()); MusicAreaIO.save(musicAreaState.getPlacedAreas());
} }
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath(); input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
System.out.println("[TerrainEditor] " + input.saveStatusMsg); log.info("{}", input.saveStatusMsg);
} catch (IOException e) { } catch (IOException e) {
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage(); input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
System.err.println("[TerrainEditor] " + input.saveStatusMsg); log.error("Speichern fehlgeschlagen", e);
} }
} }
@@ -820,6 +968,7 @@ public class TerrainEditorState extends BaseAppState {
Vector3f contact = hits.getClosestCollision().getContactPoint(); Vector3f contact = hits.getClosestCollision().getContactPoint();
int mode = input.heightTool.mode.getSelectedIndex(); int mode = input.heightTool.mode.getSelectedIndex();
boolean terrainChanged = true;
if (mode == HeightTool.MODE_SMOOTH) { if (mode == HeightTool.MODE_SMOOTH) {
smoothHeight(contact); smoothHeight(contact);
} else if (mode == HeightTool.MODE_PLATEAU) { } else if (mode == HeightTool.MODE_PLATEAU) {
@@ -830,6 +979,7 @@ public class TerrainEditorState extends BaseAppState {
input.heightTool.plateauHeight.setValue(h); input.heightTool.plateauHeight.setValue(h);
input.heightTool.plateauHeightChanged = true; input.heightTool.plateauHeightChanged = true;
} }
terrainChanged = false;
} else { } else {
// Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen // Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen
flattenToPlateauHeight(contact); flattenToPlateauHeight(contact);
@@ -838,6 +988,10 @@ public class TerrainEditorState extends BaseAppState {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action(); float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode); modifyHeight(contact, delta, mode);
} }
if (terrainChanged && waterBodyState != null) {
float r = (float) input.heightTool.brushRadius.getValue();
waterBodyState.invalidateNear(contact.x, contact.z, r);
}
} }
if (processed > 0) terrain.updateModelBound(); if (processed > 0) terrain.updateModelBound();
} }
@@ -1224,4 +1378,28 @@ public class TerrainEditorState extends BaseAppState {
geo.setCullHint(Spatial.CullHint.Always); geo.setCullHint(Spatial.CullHint.Always);
return geo; return geo;
} }
// ── Live-Spieler-Marker ───────────────────────────────────────────────────
private Geometry buildLivePlayerMarker() {
com.jme3.scene.shape.Cylinder cyl =
new com.jme3.scene.shape.Cylinder(8, 8, 0.3f, 1.8f, true);
Geometry geo = new Geometry("livePlayerMarker", cyl);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.45f, 0f, 1f));
geo.setMaterial(mat);
// Cylinder-Achse liegt entlang Y → keine Rotation nötig
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
private void updateLivePlayerMarker() {
float x = input.livePlayerX;
if (Float.isNaN(x)) {
livePlayerMarker.setCullHint(Spatial.CullHint.Always);
} else {
livePlayerMarker.setCullHint(Spatial.CullHint.Inherit);
livePlayerMarker.setLocalTranslation(x, input.livePlayerY + 0.9f, input.livePlayerZ);
}
}
} }

View File

@@ -292,14 +292,11 @@ public class TreeGeneratorState extends BaseAppState {
app.getRenderer().readFrameBuffer(captureFB, pixels); app.getRenderer().readFrameBuffer(captureFB, pixels);
cleanupCapture(); cleanupCapture();
String baseName = pendingRequest.exportName(); String treeType = pendingRequest.treeType();
String exportName = pendingRequest.exportAfter() String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
? baseName + "_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now())
: baseName;
Texture2D impostorTex = saveImpostor(pixels, "impostor_" + exportName); Texture2D impostorTex = saveImpostor(pixels, "impostor_" + treeType + "_" + timestamp);
// HD-Mesh im Dialog-Preview anzeigen (keine LOD-Umschaltung, kein Welt-Platzierung)
Node previewTree = makeTreeNode(pendingHdResult, Node previewTree = makeTreeNode(pendingHdResult,
pendingBarkMat.clone(), pendingLeafMat.clone(), "prev"); pendingBarkMat.clone(), pendingLeafMat.clone(), "prev");
previewTreeHolder.detachAllChildren(); previewTreeHolder.detachAllChildren();
@@ -311,10 +308,11 @@ public class TreeGeneratorState extends BaseAppState {
Math.max(bb.getYExtent(), bb.getZExtent())) * 3f; Math.max(bb.getYExtent(), bb.getZExtent())) * 3f;
if (pendingRequest.exportAfter()) { if (pendingRequest.exportAfter()) {
float treeHeight = bb.getCenter().y + bb.getYExtent();
Node treeNode = assembleLodNode(impostorTex); Node treeNode = assembleLodNode(impostorTex);
exportTree(treeNode, exportName); exportTree(treeNode, treeType, timestamp, treeHeight);
} else { } else {
input.treeGenStatusMsg = "Vorschau: '" + baseName + "'"; input.treeGenStatusMsg = "Vorschau: " + treeType;
} }
pendingRequest = null; pendingRequest = null;
@@ -328,7 +326,7 @@ public class TreeGeneratorState extends BaseAppState {
// ── LOD-Aufbau ──────────────────────────────────────────────────────────── // ── LOD-Aufbau ────────────────────────────────────────────────────────────
private Node assembleLodNode(Texture2D impostorTex) { private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("GeneratedTree_" + pendingRequest.exportName()); Node root = new Node(pendingRequest.treeType());
root.attachChild(pendingHdNode); root.attachChild(pendingHdNode);
root.attachChild(pendingLdNode); root.attachChild(pendingLdNode);
@@ -557,18 +555,19 @@ public class TreeGeneratorState extends BaseAppState {
// ── .j3o-Export ─────────────────────────────────────────────────────────── // ── .j3o-Export ───────────────────────────────────────────────────────────
private void exportTree(Node treeNode, String name) { private void exportTree(Node treeNode, String treeType, String timestamp, float height) {
try { try {
Path modelDir = ASSET_ROOT.resolve("Models"); String sizeClass = height < 6f ? "small" : height < 14f ? "medium" : "large";
Files.createDirectories(modelDir); String fileName = treeType + "_" + sizeClass + "_" + timestamp;
File out = modelDir.resolve("GeneratedTree_" + name + ".j3o").toFile(); Path dir = ASSET_ROOT.resolve("Models").resolve("trees")
// Strip runtime controls before export — they lack no-arg constructors .resolve(treeType).resolve(sizeClass);
// and cannot be deserialized by BinaryImporter. Files.createDirectories(dir);
File out = dir.resolve(fileName + ".j3o").toFile();
while (treeNode.getNumControls() > 0) while (treeNode.getNumControls() > 0)
treeNode.removeControl(treeNode.getControl(0)); treeNode.removeControl(treeNode.getControl(0));
BinaryExporter.getInstance().save(treeNode, out); BinaryExporter.getInstance().save(treeNode, out);
log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath()); log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Exportiert: " + out.getName(); input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;
} catch (IOException e) { } catch (IOException e) {
log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage()); log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage());

View File

@@ -10,8 +10,10 @@ import com.jme3.material.RenderState;
import com.jme3.math.*; import com.jme3.math.*;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue; import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*; import com.jme3.scene.Geometry;
import com.jme3.scene.shape.Quad; import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.common.PlacedWater; import de.blight.common.PlacedWater;
@@ -19,17 +21,25 @@ import de.blight.editor.SharedInput;
import java.nio.FloatBuffer; import java.nio.FloatBuffer;
import java.nio.IntBuffer; import java.nio.IntBuffer;
import java.util.ArrayList; import java.util.*;
import java.util.List;
/**
* Platziert und visualisiert Wasserflächen per Flood-Fill aus dem Gelände.
*
* Raster: 2 WE pro Pixel (WATER_GRID = 2049, STEP = 2).
* BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen.
*/
public class WaterBodyState extends BaseAppState { public class WaterBodyState extends BaseAppState {
private static final ColorRGBA WATER_COLOR = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.52f); // Flood-Fill-Raster mit 1 WE Auflösung (= volle HeightMap-Auflösung)
private static final ColorRGBA BORDER_COLOR = new ColorRGBA(0.30f, 0.60f, 1.00f, 0.85f); private static final int TOTAL_VERTS = 4097;
private static final ColorRGBA BORDER_SEL = new ColorRGBA(1.00f, 1.00f, 0.00f, 1.00f); private static final int WATER_GRID = 4097; // (4096 / STEP) + 1
private static final int STEP = 1; // WE pro Gitterpixel
private static final int WORLD_HALF = 2048;
private static final int MAX_CELLS = 200_000;
private static final String GEO_SURFACE = "water_surface"; private static final ColorRGBA COLOR_WATER = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.50f);
private static final String GEO_BORDER = "water_border"; private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(0.20f, 0.60f, 1.00f, 0.70f);
private final SharedInput input; private final SharedInput input;
private SimpleApplication app; private SimpleApplication app;
@@ -37,17 +47,20 @@ public class WaterBodyState extends BaseAppState {
private AssetManager assets; private AssetManager assets;
private Node rootNode; private Node rootNode;
private TerrainQuad terrain; private TerrainQuad terrain;
private float[] heightMap;
// parallel lists
private final List<PlacedWater> bodies = new ArrayList<>(); private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Node> markers = new ArrayList<>(); private final List<Set<Integer>> cellSets = new ArrayList<>();
private final List<Geometry> geos = new ArrayList<>();
private final List<float[]> bodyBounds = new ArrayList<>(); // {minX,minZ,maxX,maxZ}
private int selectedIdx = -1; private int selectedIdx = -1;
private List<PlacedWater> pendingBodies = null; private List<PlacedWater> pendingLoad = null;
public WaterBodyState(SharedInput input) { public WaterBodyState(SharedInput input) { this.input = input; }
this.input = input;
} public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; }
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -63,16 +76,11 @@ public class WaterBodyState extends BaseAppState {
@Override @Override
protected void onEnable() { protected void onEnable() {
if (pendingBodies != null) { if (pendingLoad != null) { loadPlacedBodies(pendingLoad); pendingLoad = null; }
loadPlacedBodies(pendingBodies);
pendingBodies = null;
}
} }
@Override protected void onDisable() {} @Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ──────────────────────────────────────────────────────────────── // ── Update ────────────────────────────────────────────────────────────────
@Override @Override
@@ -80,14 +88,10 @@ public class WaterBodyState extends BaseAppState {
if (input.activeLayer != SharedInput.LAYER_WATER) return; if (input.activeLayer != SharedInput.LAYER_WATER) return;
SharedInput.WaterClick click; SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) { while ((click = input.waterClickQueue.poll()) != null) handleClick(click);
handleClick(click);
}
PlacedWater pending = input.pendingWater.getAndSet(null); PlacedWater pending = input.pendingWater.getAndSet(null);
if (pending != null && selectedIdx >= 0) { if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight());
applyProperty(selectedIdx, pending);
}
if (input.deleteWaterRequested) { if (input.deleteWaterRequested) {
input.deleteWaterRequested = false; input.deleteWaterRequested = false;
@@ -95,57 +99,56 @@ public class WaterBodyState extends BaseAppState {
} }
} }
// ── Click handling ──────────────────────────────────────────────────────── // ── Click-Handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.WaterClick click) { private void handleClick(SharedInput.WaterClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX; float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
int hit = pickMarker(ray);
if (hit >= 0) {
if (click.rightButton()) deselect();
else selectBody(hit);
return;
}
if (click.rightButton()) { deselect(); return; } if (click.rightButton()) { deselect(); return; }
int hit = pickBody(ray);
if (hit >= 0) { selectBody(hit); return; }
if (terrain == null) return; if (terrain == null) return;
CollisionResults hits = new CollisionResults(); CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits); terrain.collideWith(ray, hits);
if (hits.size() == 0) return; if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint(); Vector3f pt = hits.getClosestCollision().getContactPoint();
addBody(new PlacedWater(pt.x, pt.y + 0.05f, pt.z, 30f, 30f)); Set<Integer> cells = floodFill(pt.x, pt.z, pt.y);
if (cells == null) {
input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle.";
return;
}
addBody(new PlacedWater(pt.x, pt.z, pt.y), cells);
selectBody(bodies.size() - 1); selectBody(bodies.size() - 1);
} }
private int pickMarker(Ray ray) { private int pickBody(Ray ray) {
for (int i = 0; i < markers.size(); i++) { for (int i = 0; i < geos.size(); i++) {
CollisionResults res = new CollisionResults(); CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res); geos.get(i).collideWith(ray, res);
if (res.size() > 0) return i; if (res.size() > 0) return i;
} }
return -1; return -1;
} }
// ── Selection ───────────────────────────────────────────────────────────── // ── Selektion ─────────────────────────────────────────────────────────────
private void selectBody(int idx) { private void selectBody(int idx) {
deselect(); deselect();
selectedIdx = idx; selectedIdx = idx;
setBorderColor(idx, BORDER_SEL); geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx); publishSelection(idx);
} }
private void deselect() { private void deselect() {
if (selectedIdx >= 0 && selectedIdx < bodies.size()) { if (selectedIdx >= 0 && selectedIdx < geos.size())
setBorderColor(selectedIdx, BORDER_COLOR); geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
}
selectedIdx = -1; selectedIdx = -1;
input.selectedWaterInfo = null; input.selectedWaterInfo = null;
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
@@ -154,120 +157,196 @@ public class WaterBodyState extends BaseAppState {
private void publishSelection(int idx) { private void publishSelection(int idx) {
PlacedWater b = bodies.get(idx); PlacedWater b = bodies.get(idx);
input.selectedWaterInfo = String.format(java.util.Locale.ROOT, input.selectedWaterInfo = String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%.3f", "%d|%.3f|%.3f|%.3f|%d",
idx, b.x(), b.y(), b.z(), b.width(), b.depth()); idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size());
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
} }
// ── Add / Remove ────────────────────────────────────────────────────────── // ── Hinzufügen / Entfernen ────────────────────────────────────────────────
private void addBody(PlacedWater b) { private void addBody(PlacedWater body, Set<Integer> cells) {
Node marker = buildMarker(b); Geometry geo = buildWaterGeo(cells, body.waterHeight());
rootNode.attachChild(marker); rootNode.attachChild(geo);
markers.add(marker); bodies.add(body);
bodies.add(b); cellSets.add(cells);
geos.add(geo);
bodyBounds.add(computeBounds(cells));
} }
private void removeBody(int idx) { private void removeBody(int idx) {
rootNode.detachChild(markers.get(idx)); rootNode.detachChild(geos.get(idx));
bodies.remove(idx); bodies.remove(idx);
markers.remove(idx); cellSets.remove(idx);
geos.remove(idx);
bodyBounds.remove(idx);
selectedIdx = -1; selectedIdx = -1;
input.selectedWaterInfo = null; input.selectedWaterInfo = null;
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
} }
private void clearAll() { private void clearAll() {
for (Node m : markers) rootNode.detachChild(m); for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g);
bodies.clear(); bodies.clear();
markers.clear(); cellSets.clear();
geos.clear();
bodyBounds.clear();
selectedIdx = -1; selectedIdx = -1;
} }
// ── Property application ────────────────────────────────────────────────── /**
* Löscht alle Wasserflächen, deren AABB den übergebenen Pinselkreis berührt.
* Wird von TerrainEditorState nach jeder Geländeänderung aufgerufen.
*/
public void invalidateNear(float worldX, float worldZ, float brushRadius) {
for (int i = bodies.size() - 1; i >= 0; i--) {
float[] b = bodyBounds.get(i);
// Nächster Punkt auf AABB zum Kreismittelpunkt
float nearX = Math.max(b[0], Math.min(worldX, b[2]));
float nearZ = Math.max(b[1], Math.min(worldZ, b[3]));
float dx = worldX - nearX, dz = worldZ - nearZ;
if (dx * dx + dz * dz <= brushRadius * brushRadius) {
removeBody(i);
}
}
}
private void applyProperty(int idx, PlacedWater updated) { private static float[] computeBounds(Set<Integer> cells) {
rootNode.detachChild(markers.get(idx)); float minX = Float.MAX_VALUE, minZ = Float.MAX_VALUE;
Node newMarker = buildMarker(updated); float maxX = -Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
setBorderColorOnNode(newMarker, BORDER_SEL); for (int cell : cells) {
rootNode.attachChild(newMarker); float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
markers.set(idx, newMarker); float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
bodies.set(idx, updated); if (wx < minX) minX = wx;
if (wz < minZ) minZ = wz;
if (wx + STEP > maxX) maxX = wx + STEP;
if (wz + STEP > maxZ) maxZ = wz + STEP;
}
return new float[]{minX, minZ, maxX, maxZ};
}
// ── Höhe ändern ───────────────────────────────────────────────────────────
private void applyHeightChange(int idx, float newHeight) {
PlacedWater b = bodies.get(idx);
Set<Integer> newCells = floodFill(b.seedX(), b.seedZ(), newHeight);
if (newCells == null) {
input.waterHint = "Ungültige Höhe Becken bei dieser Höhe nicht eingeschlossen.";
return;
}
rootNode.detachChild(geos.get(idx));
Geometry newGeo = buildWaterGeo(newCells, newHeight);
newGeo.getMaterial().setColor("Color", COLOR_SELECTED);
rootNode.attachChild(newGeo);
bodies.set(idx, new PlacedWater(b.seedX(), b.seedZ(), newHeight));
cellSets.set(idx, newCells);
geos.set(idx, newGeo);
publishSelection(idx); publishSelection(idx);
} }
// ── Marker visuals ──────────────────────────────────────────────────────── // ── Flood-Fill ────────────────────────────────────────────────────────────
private Node buildMarker(PlacedWater b) { private Set<Integer> floodFill(float seedWorldX, float seedWorldZ, float waterHeight) {
// Water surface (semi-transparent quad) int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP);
Quad quad = new Quad(b.width(), b.depth()); int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP);
Geometry surface = new Geometry(GEO_SURFACE, quad); seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX));
Material waterMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ));
waterMat.setColor("Color", WATER_COLOR);
waterMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
waterMat.getAdditionalRenderState().setDepthWrite(false);
surface.setMaterial(waterMat);
surface.setQueueBucket(RenderQueue.Bucket.Transparent);
surface.rotate(-FastMath.HALF_PI, 0, 0);
surface.setLocalTranslation(-b.width() * 0.5f, 0f, b.depth() * 0.5f);
// Border outline (Line mesh forming a rectangle) if (sampleHeight(seedPX, seedPZ) > waterHeight + 0.05f) return null;
Geometry border = new Geometry(GEO_BORDER, buildBorderMesh(b.width(), b.depth()));
Material borderMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
borderMat.setColor("Color", BORDER_COLOR);
borderMat.getAdditionalRenderState().setDepthTest(false);
border.setMaterial(borderMat);
Node node = new Node("water_node"); Set<Integer> visited = new HashSet<>();
node.attachChild(surface); Deque<int[]> queue = new ArrayDeque<>();
node.attachChild(border); visited.add(seedPZ * WATER_GRID + seedPX);
node.setLocalTranslation(b.x(), b.y(), b.z()); queue.add(new int[]{seedPX, seedPZ});
return node;
final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] c = queue.poll();
int px = c[0], pz = c[1];
if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1)
return null;
for (int[] d : dirs) {
int nx = px + d[0], nz = pz + d[1];
int nIdx = nz * WATER_GRID + nx;
if (visited.contains(nIdx)) continue;
if (sampleHeight(nx, nz) <= waterHeight) {
visited.add(nIdx);
if (visited.size() > MAX_CELLS) return null;
queue.add(new int[]{nx, nz});
}
}
}
return visited.isEmpty() ? null : visited;
} }
private static Mesh buildBorderMesh(float w, float d) { private float sampleHeight(int px, int pz) {
// 4 corner points at +0.02 above water surface (local coords, XZ plane) if (heightMap != null) {
float hw = w * 0.5f, hd = d * 0.5f, y = 0.02f; int vx = Math.min(px * STEP, TOTAL_VERTS - 1);
FloatBuffer pos = BufferUtils.createFloatBuffer(4 * 3); int vz = Math.min(pz * STEP, TOTAL_VERTS - 1);
pos.put(-hw).put(y).put(-hd); return heightMap[vz * TOTAL_VERTS + vx];
pos.put( hw).put(y).put(-hd); }
pos.put( hw).put(y).put( hd); if (terrain != null) {
pos.put(-hw).put(y).put( hd); float worldX = px * STEP - WORLD_HALF;
IntBuffer idx = BufferUtils.createIntBuffer(8); // 4 edges float worldZ = pz * STEP - WORLD_HALF;
idx.put(0).put(1).put(1).put(2).put(2).put(3).put(3).put(0); Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
return (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
}
return Float.MAX_VALUE;
}
// ── Mesh-Aufbau (für Editor-Vorschau) ────────────────────────────────────
private Geometry buildWaterGeo(Set<Integer> cells, float waterHeight) {
int n = cells.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(n * 6);
int vi = 0;
float h = waterHeight + 0.05f;
for (int cell : cells) {
int pz = cell / WATER_GRID;
int px = cell % WATER_GRID;
float wx = px * STEP - WORLD_HALF;
float wz = pz * STEP - WORLD_HALF;
pos.put(wx ).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz + STEP);
pos.put(wx ).put(h).put(wz + STEP);
idx.put(vi).put(vi+1).put(vi+2);
idx.put(vi).put(vi+2).put(vi+3);
vi += 4;
}
Mesh mesh = new Mesh(); Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 2, idx); mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound(); mesh.updateBound();
return mesh;
Geometry geo = new Geometry("water_body", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", COLOR_WATER);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthWrite(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
return geo;
} }
private void setBorderColor(int idx, ColorRGBA color) { // ── Speichern / Laden ─────────────────────────────────────────────────────
setBorderColorOnNode(markers.get(idx), color);
}
private static void setBorderColorOnNode(Node node, ColorRGBA color) { public List<PlacedWater> getPlacedBodies() { return new ArrayList<>(bodies); }
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && GEO_BORDER.equals(geo.getName())) {
geo.getMaterial().setColor("Color", color);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedWater> getPlacedBodies() {
return new ArrayList<>(bodies);
}
public void loadPlacedBodies(List<PlacedWater> loaded) { public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) { if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; }
pendingBodies = new ArrayList<>(loaded);
return;
}
clearAll(); clearAll();
for (PlacedWater b : loaded) addBody(b); for (PlacedWater b : loaded) {
Set<Integer> cells = floodFill(b.seedX(), b.seedZ(), b.waterHeight());
if (cells != null) {
addBody(b, cells);
} else {
System.err.println("[WaterBodyState] Becken nicht rekonstruierbar: "
+ b.seedX() + "/" + b.seedZ());
}
}
} }
} }

View File

@@ -9,7 +9,7 @@ public class GrassTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 40.0, 1.0, 500.0); public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 40.0, 1.0, 500.0);
public final ToolParameter grassHeight = new ToolParameter("Grashöhe", 1.5, 0.1, 10.0); public final ToolParameter grassHeight = new ToolParameter("Grashöhe", 1.5, 0.1, 10.0);
public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 50.0); public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 200.0);
@Override public String getName() { return "Gras"; } @Override public String getName() { return "Gras"; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

View File

@@ -29,6 +29,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.11.0' implementation 'com.google.code.gson:gson:2.11.0'
implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'org.slf4j:jul-to-slf4j:2.0.17' implementation 'org.slf4j:jul-to-slf4j:2.0.17'
runtimeOnly 'ch.qos.logback:logback-classic:1.5.18'
compileOnly 'org.projectlombok:lombok:1.18.38' compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38' annotationProcessor 'org.projectlombok:lombok:1.18.38'
} }

View File

@@ -1,32 +1,53 @@
package de.blight.game; package de.blight.game;
import com.jme3.app.SimpleApplication; import com.jme3.app.SimpleApplication;
import com.jme3.app.state.ScreenshotAppState;
import com.jme3.input.KeyInput; import com.jme3.input.KeyInput;
import com.jme3.input.controls.ActionListener; import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger; import com.jme3.input.controls.KeyTrigger;
import com.jme3.system.AppSettings; import com.jme3.system.AppSettings;
import de.blight.game.config.*; import de.blight.game.config.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler; import org.slf4j.bridge.SLF4JBridgeHandler;
import de.blight.game.scene.WorldScene; import de.blight.game.scene.WorldScene;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class BlightGame extends SimpleApplication { public class BlightGame extends SimpleApplication {
private static final Logger log = LoggerFactory.getLogger(BlightGame.class);
private KeyBindings keyBindings; private KeyBindings keyBindings;
private GraphicsSettings graphicsSettings; private GraphicsSettings graphicsSettings;
private ScreenshotAppState screenshotState;
private WorldScene worldScene; private WorldScene worldScene;
private ConfigScreen configScreen; private ConfigScreen configScreen;
private GraphicsScreen graphicsScreen; private GraphicsScreen graphicsScreen;
private PauseMenu pauseMenu; private PauseMenu pauseMenu;
private JWindow splashWindow;
public static void main(String[] args) { public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger(); SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install(); SLF4JBridgeHandler.install();
BlightGame app = new BlightGame(); BlightGame app = new BlightGame();
app.splashWindow = showSplash();
GraphicsSettings gs = GraphicsStore.load(); GraphicsSettings gs = GraphicsStore.load();
AppSettings settings = new AppSettings(true); AppSettings settings = new AppSettings(true);
settings.setTitle("Blight"); settings.setTitle("Blight");
try {
settings.setIcons(new Object[]{ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"))});
} catch (IOException | NullPointerException ignored) {}
settings.setResolution(gs.width, gs.height); settings.setResolution(gs.width, gs.height);
settings.setFullscreen(gs.fullscreen); settings.setFullscreen(gs.fullscreen);
settings.setVSync(gs.vsync); settings.setVSync(gs.vsync);
@@ -37,8 +58,28 @@ public class BlightGame extends SimpleApplication {
app.start(); app.start();
} }
private static JWindow showSplash() {
try {
BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png"));
BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"));
JWindow win = new JWindow();
if (icon != null) win.setIconImages(java.util.List.of(icon));
win.getContentPane().add(new JLabel(new ImageIcon(img)));
win.pack();
win.setLocationRelativeTo(null);
win.setVisible(true);
return win;
} catch (IOException | NullPointerException ignored) {
return null;
}
}
@Override @Override
public void simpleInitApp() { public void simpleInitApp() {
if (splashWindow != null) {
SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; });
}
flyCam.setEnabled(false); flyCam.setEnabled(false);
inputManager.deleteMapping(INPUT_MAPPING_EXIT); inputManager.deleteMapping(INPUT_MAPPING_EXIT);
@@ -64,12 +105,26 @@ public class BlightGame extends SimpleApplication {
stateManager.attach(pauseMenu); stateManager.attach(pauseMenu);
pauseMenu.setEnabled(false); pauseMenu.setEnabled(false);
// ── Screenshot (Druck-Taste) ───────────────────────────────────────────
try {
Path screenshotDir = findProjectRoot().resolve("screenshots");
Files.createDirectories(screenshotDir);
screenshotState = new ScreenshotAppState(screenshotDir + File.separator, "screenshot");
stateManager.attach(screenshotState);
log.info("Screenshots werden gespeichert in: {}", screenshotDir.toAbsolutePath());
} catch (IOException e) {
log.warn("Screenshot-Verzeichnis konnte nicht angelegt werden", e);
}
inputManager.addMapping("Screenshot", new KeyTrigger(KeyInput.KEY_SYSRQ));
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
if (isPressed && screenshotState != null) screenshotState.takeScreenshot();
}, "Screenshot");
inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE)); inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE));
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> { inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
if (!isPressed) return; if (!isPressed) return;
if (graphicsScreen.isEnabled()) { if (graphicsScreen.isEnabled()) {
// GraphicsScreen wird nur über seine eigenen Buttons geschlossen
return; return;
} }
if (configScreen.isEnabled()) { if (configScreen.isEnabled()) {
@@ -93,4 +148,17 @@ public class BlightGame extends SimpleApplication {
@Override @Override
public void simpleUpdate(float tpf) {} public void simpleUpdate(float tpf) {}
private static Path findProjectRoot() {
String prop = System.getProperty("blight.project.root");
if (prop != null) return Paths.get(prop);
File dir = Paths.get(".").toAbsolutePath().normalize().toFile();
while (dir != null) {
if (new File(dir, "blight-editor").isDirectory()
&& new File(dir, "blight-game").isDirectory())
return dir.toPath();
dir = dir.getParentFile();
}
return Paths.get(".").toAbsolutePath().normalize();
}
} }

View File

@@ -0,0 +1,29 @@
package de.blight.game;
import de.blight.common.MapIO;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Schreibt Live-Daten (Spielerposition) in eine Temp-Datei neben der Karte,
* damit der Editor sie live anzeigen kann.
*/
public final class LiveBroadcast {
public static final Path POS_FILE =
MapIO.getMapPath().resolveSibling("blight_live.pos");
private LiveBroadcast() {}
public static void writePosition(float x, float y, float z) {
try {
Files.writeString(POS_FILE, x + "|" + y + "|" + z);
} catch (IOException ignored) {}
}
public static void clear() {
try { Files.deleteIfExists(POS_FILE); } catch (IOException ignored) {}
}
}

View File

@@ -14,8 +14,8 @@ import java.util.Map;
* Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die * Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die
* Zuordnung semantischer Aktionen (IDLE, WALK, …) zu Clip-Namen. * Zuordnung semantischer Aktionen (IDLE, WALK, …) zu Clip-Namen.
* *
* Wird als {@code <setName>.animset.json} neben der {@code .j3o}-Datei gespeichert * Wird als {@code animations/sets/<setName>.animset.json} gespeichert.
* und ersetzt sowohl die alte {@code .clips.json} als auch die {@code .animmap}-Datei. * Die Clip-Dateien liegen als eigenständige .j3o in {@code animations/clips/}.
*/ */
public class AnimSet { public class AnimSet {
@@ -44,26 +44,4 @@ public class AnimSet {
if (!Files.exists(f)) return new AnimSet(); if (!Files.exists(f)) return new AnimSet();
return GSON.fromJson(Files.readString(f), AnimSet.class); return GSON.fromJson(Files.readString(f), AnimSet.class);
} }
/**
* Lädt ein AnimSet anhand des Asset-Pfades der zugehörigen {@code .j3o}-Datei.
*
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
* @param j3oAssetPath relativer Pfad zur {@code .j3o}-Datei (z. B. {@code "animations/sets/foo.j3o"})
*/
public static AnimSet loadByJ3oPath(Path assetRoot, String j3oAssetPath) {
Path j3o = assetRoot.resolve(j3oAssetPath.replace('/', java.io.File.separatorChar));
String name = j3o.getFileName().toString().replaceFirst("\\.j3o$", "");
try {
return load(j3o.getParent(), name);
} catch (IOException e) {
return new AnimSet();
}
}
/** Gibt den Companion-Pfad der {@code .animset.json}-Datei neben einer {@code .j3o}-Datei zurück. */
public static Path companionPath(Path j3oPath) {
String name = j3oPath.getFileName().toString().replaceFirst("\\.j3o$", "");
return j3oPath.getParent().resolve(name + SUFFIX);
}
} }

View File

@@ -5,19 +5,26 @@ package de.blight.game.animation;
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen. * Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
*/ */
public enum AnimationAction { public enum AnimationAction {
DEFAULT,
IDLE, IDLE,
WALK, WALK,
RUN, RUN,
SPRINT,
JUMP, JUMP,
RUNNING_JUMP,
DUCK; DUCK;
/** Lesbare Bezeichnung für UI-Anzeige. */ /** Lesbare Bezeichnung für UI-Anzeige. */
public String displayName() { public String displayName() {
return switch (this) { return switch (this) {
case DEFAULT -> "Default";
case IDLE -> "Idle"; case IDLE -> "Idle";
case WALK -> "Walk"; case WALK -> "Walk";
case RUN -> "Run"; case RUN -> "Run";
case SPRINT -> "Sprint";
case JUMP -> "Jump"; case JUMP -> "Jump";
case RUNNING_JUMP -> "Running Jump";
case DUCK -> "Duck"; case DUCK -> "Duck";
}; };
} }

View File

@@ -14,19 +14,15 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/** /**
* Loads all .j3o animation files from the animations/ asset folder at startup. * Lädt alle Clip-Dateien aus {@code animations/clips/} beim Start.
* Provides retargeted animation clips for any model with a SkinningControl. * Clip-Schlüssel entsprechen dem Dateinamen ohne Extension (= Clip-Name).
*
* Clip keys follow the pattern "filename/clipname" (e.g. "walk/Run").
*/ */
public class AnimationLibrary extends BaseAppState { public class AnimationLibrary extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(AnimationLibrary.class); private static final Logger log = LoggerFactory.getLogger(AnimationLibrary.class);
// Possible base paths for the animations folder (relative to working dir)
private static final String[] ASSET_BASES = { private static final String[] ASSET_BASES = {
"blight-assets/src/main/resources", "blight-assets/src/main/resources",
"assets", "assets",
@@ -35,9 +31,9 @@ public class AnimationLibrary extends BaseAppState {
private AssetManager assetManager; private AssetManager assetManager;
/** clip key → clip (bound to the SOURCE armature; retargeted before use) */ /** clip name → clip (an Quell-Armatur gebunden; wird bei Bedarf retargeted) */
private final Map<String, AnimClip> clips = new LinkedHashMap<>(); private final Map<String, AnimClip> clips = new LinkedHashMap<>();
/** clip keyarmature the clip was loaded from */ /** clip nameArmatur der Quell-Datei */
private final Map<String, Armature> armatures = new LinkedHashMap<>(); private final Map<String, Armature> armatures = new LinkedHashMap<>();
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -45,6 +41,15 @@ public class AnimationLibrary extends BaseAppState {
@Override @Override
protected void initialize(Application app) { protected void initialize(Application app) {
assetManager = ((SimpleApplication) app).getAssetManager(); assetManager = ((SimpleApplication) app).getAssetManager();
Path assetRoot = findAssetRoot();
try {
assetManager.registerLocator(
assetRoot.toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
log.info("[AnimLib] Asset-Root registriert: {}", assetRoot.toAbsolutePath());
} catch (Exception e) {
log.warn("[AnimLib] Asset-Root konnte nicht registriert werden: {}", e.getMessage());
}
loadAll(); loadAll();
} }
@@ -54,51 +59,60 @@ public class AnimationLibrary extends BaseAppState {
// ── Public API ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
/** All loaded clip keys (filename/clipname). */ /** Alle geladenen Clip-Namen. */
public Collection<String> getClipKeys() { public Collection<String> getClipKeys() {
return Collections.unmodifiableSet(clips.keySet()); return Collections.unmodifiableSet(clips.keySet());
} }
/** /**
* Retargets the clip to {@code model}'s skeleton and registers it * Retargeted den Clip auf das Skeleton von {@code model} und registriert
* in the model's AnimComposer (idempotent). * ihn im AnimComposer des Modells (idempotent).
* *
* @return true if the clip was applied successfully * @return true wenn der Clip erfolgreich angewendet wurde
*/ */
public boolean applyTo(String clipKey, Spatial model) { public boolean applyTo(String clipName, Spatial model) {
AnimClip src = clips.get(clipKey); AnimClip src = clips.get(clipName);
Armature srcArm = armatures.get(clipKey); Armature srcArm = armatures.get(clipName);
if (src == null) return false; if (src == null) {
log.warn("[AnimLib] applyTo: Clip '{}' nicht in Bibliothek (verfügbar: {})", clipName, clips.keySet());
return false;
}
AnimComposer ac = RetargetingSystem.findAnimComposer(model); AnimComposer ac = RetargetingSystem.findAnimComposer(model);
SkinningControl sc = RetargetingSystem.findSkinningControl(model); SkinningControl sc = RetargetingSystem.findSkinningControl(model);
if (ac == null || sc == null) return false; if (ac == null) {
log.warn("[AnimLib] applyTo: Kein AnimComposer in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
if (sc == null) {
log.warn("[AnimLib] applyTo: Kein SkinningControl in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
String shortName = shortName(clipKey); if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden
if (ac.getAnimClip(shortName) != null) return true; // already present
AnimClip target; AnimClip target;
if (srcArm != null && srcArm != sc.getArmature()) { if (srcArm != null && srcArm != sc.getArmature()) {
// Pre-baked animations (Blender retargeting) have identical bone names → // Immer retarget() aufrufen auch bei gleichen Knochen-Namen.
// copy directly without retargeting. Different skeleton → retarget. // retarget() nutzt intern den "redirect"-Schnellpfad für gleiche Rigs,
if (haveSameBoneNames(srcArm, sc.getArmature())) { // erstellt aber korrekte TransformTrack-Referenzen auf die Ziel-Armatur.
target = src; // Direkte Nutzung des Quell-Clips würde Transforms auf die falschen
} else { // (entkoppelten) Joint-Objekte der Quell-j3o anwenden.
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature()); target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
}
} else { } else {
target = src; target = src;
} }
if (target == null) return false; if (target == null) {
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
return false;
}
ac.addAnimClip(target); ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
return true; return true;
} }
/** /** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
* Applies all loaded clips to {@code model} (only if the model has a skinned rig).
* Useful for auto-equipping all available animations on a freshly loaded character.
*/
public void applyAllTo(Spatial model) { public void applyAllTo(Spatial model) {
if (RetargetingSystem.findSkinningControl(model) == null) return; if (RetargetingSystem.findSkinningControl(model) == null) return;
int applied = 0; int applied = 0;
@@ -110,92 +124,124 @@ public class AnimationLibrary extends BaseAppState {
} }
/** /**
* Applies the clip and immediately starts playing it. * Stellt sicher dass der Clip auf das Modell angewendet ist und SkinningControl aktiv ist,
* startet ihn aber NICHT (das übernimmt der Aufrufer via AnimComposer.setCurrentAction).
* *
* @return true on success * @return true wenn der Clip bereit ist
*/ */
public boolean playOn(String clipKey, Spatial model) { public boolean ensureApplied(String clipName, Spatial model) {
if (!applyTo(clipKey, model)) return false; if (!applyTo(clipName, model)) return false;
AnimComposer ac = RetargetingSystem.findAnimComposer(model); enableSkinningControls(model);
if (ac == null) return false;
ac.setCurrentAction(shortName(clipKey));
return true; return true;
} }
/** /**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Animations-Set zugeordnet ist. * Wendet den Clip an und startet ihn sofort.
*
* @return true bei Erfolg
*/
public boolean playOn(String clipName, Spatial model) {
if (!applyTo(clipName, model)) return false;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
if (ac == null) return false;
enableSkinningControls(model);
log.info("[AnimLib] setCurrentAction('{}') auf '{}'", clipName, model.getName());
com.jme3.anim.tween.action.Action action = ac.setCurrentAction(clipName);
log.info("[AnimLib] Action: {} length={}", action, action != null ? action.getLength() : "N/A");
return action != null;
}
private static void enableSkinningControls(Spatial s) {
SkinningControl sc = s.getControl(SkinningControl.class);
if (sc != null && !sc.isEnabled()) {
sc.setEnabled(true);
log.info("[AnimLib] SkinningControl aktiviert auf '{}'", s.getName());
}
if (s instanceof com.jme3.scene.Node n) {
for (Spatial child : n.getChildren()) enableSkinningControls(child);
}
}
/**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Set zugeordnet ist.
* *
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis * @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
* @param j3oAssetPath relativer Asset-Pfad der {@code .j3o}-Set-Datei (z. B. {@code "animations/sets/hero.j3o"}) * @param setName Name des Animations-Sets (ohne Pfad/Extension, z. B. {@code "human"})
* @param action semantische Aktion, z. B. {@code AnimationAction.IDLE} * @param action semantische Aktion
* @return Clip-Name oder {@code null} wenn keine Zuweisung existiert * @return Clip-Name oder {@code null}
*/ */
public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) { public static String getClipForAction(Path assetRoot, String setName, AnimationAction action) {
AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath); Path setDir = assetRoot.resolve("animations").resolve("sets");
try {
AnimSet set = AnimSet.load(setDir, setName);
return set.getActionMap().get(action.name()); return set.getActionMap().get(action.name());
} catch (Exception e) {
return null;
}
} }
// ── Loading ─────────────────────────────────────────────────────────────── // ── Loading ───────────────────────────────────────────────────────────────
private void loadAll() { private void loadAll() {
Path animDir = findAnimDir(); Path clipsDir = findClipsDir();
if (animDir == null) { log.info("[AnimLib] Asset-Root: {}", findAssetRoot().toAbsolutePath());
log.info("[AnimLib] Kein Animations-Verzeichnis gefunden Bibliothek leer."); if (clipsDir == null) {
log.warn("[AnimLib] Kein clips-Verzeichnis gefunden Bibliothek leer.");
return; return;
} }
try (var walk = Files.walk(animDir)) { log.info("[AnimLib] Scanne clips-Verzeichnis: {}", clipsDir.toAbsolutePath());
try (var walk = Files.walk(clipsDir, 1)) {
walk.filter(p -> p.toString().endsWith(".j3o")) walk.filter(p -> p.toString().endsWith(".j3o"))
.forEach(this::loadFromFile); .forEach(this::loadClipFromFile);
} catch (IOException e) { } catch (IOException e) {
log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage()); log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage());
} }
log.info("[AnimLib] {} Clips geladen.", clips.size()); if (clips.isEmpty()) {
log.warn("[AnimLib] KEINE Clips geladen! Prüfe ob der Asset-Root korrekt ist.");
} else {
log.info("[AnimLib] {} Clips geladen: {}", clips.size(), clips.keySet());
}
} }
private void loadFromFile(Path file) { private void loadClipFromFile(Path file) {
Path animDir = findAnimDir(); String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
if (animDir == null) return; String assetKey = "animations/clips/" + clipName + ".j3o";
String relPath = animDir.relativize(file).toString().replace('\\', '/');
String assetKey = "animations/" + relPath;
String fileBase = relPath.replaceFirst("\\.j3o$", "");
try { try {
Spatial loaded = assetManager.loadModel(assetKey); Spatial loaded = assetManager.loadModel(assetKey);
AnimComposer ac = RetargetingSystem.findAnimComposer(loaded); AnimComposer ac = RetargetingSystem.findAnimComposer(loaded);
SkinningControl sc = RetargetingSystem.findSkinningControl(loaded); SkinningControl sc = RetargetingSystem.findSkinningControl(loaded);
if (ac == null) { if (ac == null) {
log.debug("[AnimLib] Kein AnimComposer in {}", assetKey); log.warn("[AnimLib] Kein AnimComposer in {} übersprungen", assetKey);
return; return;
} }
Armature armature = sc != null ? sc.getArmature() : null; Armature armature = sc != null ? sc.getArmature() : null;
for (String clipName : ac.getAnimClipsNames()) { for (String name : ac.getAnimClipsNames()) {
String key = fileBase + "/" + clipName; clips.put(name, ac.getAnimClip(name));
clips.put(key, ac.getAnimClip(clipName)); if (armature != null) armatures.put(name, armature);
if (armature != null) armatures.put(key, armature); log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
log.info("[AnimLib] Clip: {}", key);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage()); log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());
} }
} }
private static Path findAnimDir() { /** Gibt den Asset-Wurzelpfad zurück (erstes Verzeichnis mit {@code animations/}-Unterordner). */
public static Path findAssetRoot() {
for (String base : ASSET_BASES) { for (String base : ASSET_BASES) {
Path p = Paths.get(base, "animations"); Path p = Paths.get(base);
if (Files.isDirectory(p.resolve("animations"))) return p;
}
return Paths.get(ASSET_BASES[0]);
}
private static Path findClipsDir() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base, "animations", "clips");
if (Files.isDirectory(p)) return p; if (Files.isDirectory(p)) return p;
} }
return null; return null;
} }
private static boolean haveSameBoneNames(Armature a, Armature b) {
Set<String> namesA = a.getJointList().stream().map(Joint::getName).collect(Collectors.toSet());
Set<String> namesB = b.getJointList().stream().map(Joint::getName).collect(Collectors.toSet());
return namesA.equals(namesB);
}
private static String shortName(String clipKey) {
int slash = clipKey.lastIndexOf('/');
return slash >= 0 ? clipKey.substring(slash + 1) : clipKey;
}
} }

View File

@@ -11,6 +11,7 @@ public class KeyBindings {
public int right = KeyInput.KEY_D; public int right = KeyInput.KEY_D;
public int jump = KeyInput.KEY_SPACE; public int jump = KeyInput.KEY_SPACE;
public int sprint = KeyInput.KEY_LSHIFT; public int sprint = KeyInput.KEY_LSHIFT;
public int walk = KeyInput.KEY_LMENU;
/** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */ /** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */
public static final String[][] ENTRIES = { public static final String[][] ENTRIES = {
@@ -20,6 +21,7 @@ public class KeyBindings {
{"right", "Rechts"}, {"right", "Rechts"},
{"jump", "Springen"}, {"jump", "Springen"},
{"sprint", "Rennen"}, {"sprint", "Rennen"},
{"walk", "Gehen"},
}; };
public int get(String fieldName) { public int get(String fieldName) {

View File

@@ -1,5 +1,6 @@
package de.blight.game.control; package de.blight.game.control;
import com.jme3.anim.AnimComposer;
import com.jme3.bullet.control.CharacterControl; import com.jme3.bullet.control.CharacterControl;
import com.jme3.input.InputManager; import com.jme3.input.InputManager;
import com.jme3.input.controls.ActionListener; import com.jme3.input.controls.ActionListener;
@@ -9,16 +10,22 @@ import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f; import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera; import com.jme3.renderer.Camera;
import com.jme3.scene.Spatial; import com.jme3.scene.Spatial;
import de.blight.game.animation.AnimationAction;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.animation.RetargetingSystem;
import de.blight.game.config.KeyBindings; import de.blight.game.config.KeyBindings;
import java.nio.file.Path;
public class PlayerInputControl { public class PlayerInputControl {
private static final float MOVE_SPEED = 0.07f; private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f; private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f; private static final float ROTATE_SPEED = 10f;
private static final String[] ACTION_NAMES = private static final String[] ACTION_NAMES =
{"Forward", "Backward", "Left", "Right", "Jump", "Sprint"}; {"Forward", "Backward", "Left", "Right", "Jump", "Sprint", "Walk"};
private final InputManager inputManager; private final InputManager inputManager;
private final Camera cam; private final Camera cam;
@@ -26,10 +33,21 @@ public class PlayerInputControl {
private CharacterControl physicsChar; private CharacterControl physicsChar;
private Spatial visual; private Spatial visual;
private boolean forward, backward, left, right, sprint; private boolean forward, backward, left, right, sprint, walk;
private boolean paused = false; private boolean paused = false;
// Listener als Feld, damit er bei reload nicht doppelt registriert wird private AnimationLibrary animLib;
private String animSetName;
private Path assetRoot;
private AnimationAction currentAnim;
private boolean animCtxLogged = false;
private AnimComposer animComposer;
/** Letzter gestarteter Clip (nur für tryPlay-Deduplizierung genutzt). */
private String runningClip;
/** Frames, für die JUMP erzwungen wird (überbrückt onGround()-Lag). */
private int jumpFrames = 0;
private final ActionListener actionListener = (name, isPressed, tpf) -> { private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return; if (paused) return;
switch (name) { switch (name) {
@@ -38,7 +56,8 @@ public class PlayerInputControl {
case "Left" -> left = isPressed; case "Left" -> left = isPressed;
case "Right" -> right = isPressed; case "Right" -> right = isPressed;
case "Sprint" -> sprint = isPressed; case "Sprint" -> sprint = isPressed;
case "Jump" -> { if (isPressed && physicsChar != null) physicsChar.jump(); } case "Walk" -> walk = isPressed;
case "Jump" -> { if (isPressed && physicsChar != null) { physicsChar.jump(); jumpFrames = 12; } }
} }
}; };
@@ -56,20 +75,34 @@ public class PlayerInputControl {
this.visual = visual; this.visual = visual;
} }
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
this.animLib = animLib;
this.animSetName = animSetName;
this.assetRoot = assetRoot;
this.currentAnim = null;
this.runningClip = null;
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
System.out.println("[AnimCtx] AnimComposer gefunden: " + (animComposer != null));
if (animSetName != null) {
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
if (clip != null && tryPlay(clip)) {
currentAnim = AnimationAction.IDLE;
}
}
}
public void setPaused(boolean paused) { public void setPaused(boolean paused) {
this.paused = paused; this.paused = paused;
if (paused) { if (paused) {
forward = backward = left = right = sprint = false; forward = backward = left = right = sprint = walk = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
} }
} }
/** Löscht alte Mappings und registriert neue aus den übergebenen KeyBindings. */
public void reloadBindings(KeyBindings kb) { public void reloadBindings(KeyBindings kb) {
for (String a : ACTION_NAMES) inputManager.deleteMapping(a); for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
registerMappings(kb); registerMappings(kb);
// Zustand zurücksetzen, damit keine Taste „hängt" forward = backward = left = right = sprint = walk = false;
forward = backward = left = right = sprint = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
} }
@@ -80,6 +113,7 @@ public class PlayerInputControl {
inputManager.addMapping("Right", new KeyTrigger(kb.right)); inputManager.addMapping("Right", new KeyTrigger(kb.right));
inputManager.addMapping("Jump", new KeyTrigger(kb.jump)); inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint)); inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
inputManager.addMapping("Walk", new KeyTrigger(kb.walk));
inputManager.addListener(actionListener, ACTION_NAMES); inputManager.addListener(actionListener, ACTION_NAMES);
} }
@@ -95,17 +129,18 @@ public class PlayerInputControl {
if (left) moveDir.addLocal(camLeft); if (left) moveDir.addLocal(camLeft);
if (right) moveDir.subtractLocal(camLeft); if (right) moveDir.subtractLocal(camLeft);
if (moveDir.lengthSquared() > 0.001f) { boolean moving = moveDir.lengthSquared() > 0.001f;
if (moving) {
moveDir.normalizeLocal(); moveDir.normalizeLocal();
float speed = sprint ? MOVE_SPEED * SPRINT_MULT : MOVE_SPEED; float speed = walk ? MOVE_SPEED * WALK_MULT
: sprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
physicsChar.setWalkDirection(moveDir.mult(speed)); physicsChar.setWalkDirection(moveDir.mult(speed));
if (visual != null) { if (visual != null) {
Quaternion targetRot = new Quaternion(); Quaternion targetRot = new Quaternion();
targetRot.lookAt(moveDir, Vector3f.UNIT_Y); targetRot.lookAt(moveDir, Vector3f.UNIT_Y);
// Modell hat +X als Vorwärtsrichtung; lookAt zeigt -Z nach vorne →
// 90°-Y-Versatz korrigiert den Orientierungsunterschied.
targetRot.multLocal(new Quaternion().fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y));
Quaternion current = visual.getLocalRotation().clone(); Quaternion current = visual.getLocalRotation().clone();
current.slerp(targetRot, ROTATE_SPEED * tpf); current.slerp(targetRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(current); visual.setLocalRotation(current);
@@ -113,5 +148,56 @@ public class PlayerInputControl {
} else { } else {
physicsChar.setWalkDirection(Vector3f.ZERO); physicsChar.setWalkDirection(Vector3f.ZERO);
} }
// Animation
if (jumpFrames > 0) jumpFrames--;
AnimationAction target;
if (jumpFrames > 0 || !physicsChar.onGround()) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) {
target = walk ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
} else {
target = AnimationAction.IDLE;
}
if (target != currentAnim) {
playAction(target);
currentAnim = target;
}
}
private void playAction(AnimationAction action) {
if (animLib == null || visual == null || animSetName == null) {
if (!animCtxLogged) {
animCtxLogged = true;
System.out.println("[Anim] Kein Animations-Kontext:"
+ " animLib=" + animLib + " visual=" + visual + " setName=" + animSetName);
}
return;
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
System.out.println("[Anim] " + action + " → clip='" + clip + "' (set=" + animSetName + ")");
if (clip != null && tryPlay(clip)) return;
if (action != AnimationAction.DEFAULT) {
String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
if (defClip != null) tryPlay(defClip);
}
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.ensureApplied(clip, visual)) {
System.out.println("[Anim] tryPlay('" + clip + "') → ensureApplied FAILED");
return false;
}
com.jme3.anim.tween.action.Action action = animComposer.setCurrentAction(clip);
System.out.println("[Anim] setCurrentAction('" + clip + "') → " + (action != null ? "OK" : "FAILED"));
if (action != null) {
runningClip = clip;
return true;
}
return false;
} }
} }

View File

@@ -17,9 +17,10 @@ import com.jme3.scene.Spatial;
public class ThirdPersonCamera { public class ThirdPersonCamera {
private static final float MOUSE_SENSITIVITY = 1.8f; private static final float MOUSE_SENSITIVITY = 1.8f;
private static final float MIN_DISTANCE = 3f; private static final float BASE_DISTANCE = 5f;
private static final float MAX_DISTANCE = 20f; private static final float MIN_DISTANCE = BASE_DISTANCE - 3f;
private static final float MIN_VERTICAL_ANGLE = -0.3f; private static final float MAX_DISTANCE = BASE_DISTANCE + 3f;
private static final float MIN_VERTICAL_ANGLE = 0.08f; // Kamera immer leicht über Schulter
private static final float MAX_VERTICAL_ANGLE = FastMath.HALF_PI - 0.1f; private static final float MAX_VERTICAL_ANGLE = FastMath.HALF_PI - 0.1f;
private static final float TARGET_HEIGHT = 1.6f; private static final float TARGET_HEIGHT = 1.6f;
@@ -30,7 +31,7 @@ public class ThirdPersonCamera {
private float yaw = 0f; private float yaw = 0f;
private float pitch = 0.4f; private float pitch = 0.4f;
private float distance = 10f; private float distance = BASE_DISTANCE;
private boolean paused = false; private boolean paused = false;
public ThirdPersonCamera(Camera cam, InputManager inputManager) { public ThirdPersonCamera(Camera cam, InputManager inputManager) {
@@ -65,8 +66,8 @@ public class ThirdPersonCamera {
case "MouseY" -> pitch = FastMath.clamp(pitch - value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE); case "MouseY" -> pitch = FastMath.clamp(pitch - value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE);
case "MouseYNeg" -> pitch = FastMath.clamp(pitch + value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE); case "MouseYNeg" -> pitch = FastMath.clamp(pitch + value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE);
// Zoom // Zoom
case "ZoomIn" -> distance = FastMath.clamp(distance - value * 20f, MIN_DISTANCE, MAX_DISTANCE); case "ZoomIn" -> distance = FastMath.clamp(distance - value * 0.5f, MIN_DISTANCE, MAX_DISTANCE);
case "ZoomOut" -> distance = FastMath.clamp(distance + value * 20f, MIN_DISTANCE, MAX_DISTANCE); case "ZoomOut" -> distance = FastMath.clamp(distance + value * 0.5f, MIN_DISTANCE, MAX_DISTANCE);
} }
}; };
inputManager.addListener(analogListener, inputManager.addListener(analogListener,

View File

@@ -6,7 +6,6 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager; import com.jme3.asset.AssetManager;
import com.jme3.bullet.BulletAppState; import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.collision.shapes.CapsuleCollisionShape; import com.jme3.bullet.collision.shapes.CapsuleCollisionShape;
import com.jme3.bullet.collision.shapes.HeightfieldCollisionShape;
import com.jme3.bullet.control.CharacterControl; import com.jme3.bullet.control.CharacterControl;
import com.jme3.bullet.control.RigidBodyControl; import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory; import com.jme3.bullet.util.CollisionShapeFactory;
@@ -26,12 +25,24 @@ import com.jme3.util.SkyFactory;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import de.blight.common.MapData; import de.blight.common.MapData;
import de.blight.common.MapIO; import de.blight.common.MapIO;
import de.blight.common.model.CharacterIO;
import de.blight.common.model.GameCharacter;
import de.blight.common.model.MainCharacter;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings; import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl; import de.blight.game.control.PlayerInputControl;
import de.blight.game.control.ThirdPersonCamera; import de.blight.game.control.ThirdPersonCamera;
import com.jme3.post.FilterPostProcessor;
import com.jme3.post.filters.FogFilter;
import com.jme3.water.WaterFilter;
import de.blight.game.state.GrassState; import de.blight.game.state.GrassState;
import de.blight.game.state.RiverState;
import de.blight.game.state.WaterBodyState;
import de.blight.game.state.WeatherState;
import de.blight.game.state.WorldObjectsState;
import java.io.IOException; import java.io.IOException;
import java.nio.FloatBuffer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -42,11 +53,19 @@ public class WorldScene extends BaseAppState {
private AssetManager assetManager; private AssetManager assetManager;
private BulletAppState bulletAppState; private BulletAppState bulletAppState;
private MapData loadedMapData; private MapData loadedMapData;
private FilterPostProcessor sharedFPP;
private final KeyBindings keyBindings; private final KeyBindings keyBindings;
private ThirdPersonCamera thirdPersonCam; private ThirdPersonCamera thirdPersonCam;
private PlayerInputControl playerInput; private PlayerInputControl playerInput;
private float spawnY = 5f; // wird in buildTerrain() gesetzt private AnimationLibrary animLib;
private Node character;
private Spatial characterVisual;
private CharacterControl physicsChar;
private boolean animContextReady = false;
private float spawnX = 0f;
private float spawnY = 5f;
private float spawnZ = 0f;
public WorldScene(KeyBindings keyBindings) { public WorldScene(KeyBindings keyBindings) {
this.keyBindings = keyBindings; this.keyBindings = keyBindings;
@@ -74,6 +93,9 @@ public class WorldScene extends BaseAppState {
bulletAppState = new BulletAppState(); bulletAppState = new BulletAppState();
app.getStateManager().attach(bulletAppState); app.getStateManager().attach(bulletAppState);
animLib = new AnimationLibrary();
app.getStateManager().attach(animLib);
} }
@Override @Override
@@ -81,27 +103,27 @@ public class WorldScene extends BaseAppState {
buildLighting(); buildLighting();
TerrainQuad terrain = buildTerrain(); TerrainQuad terrain = buildTerrain();
if (loadedMapData != null) { app.getStateManager().attach(new GrassState(terrain));
rootNode.attachChild(buildGebirge(loadedMapData)); app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP));
app.getStateManager().attach(new GrassState(loadedMapData, terrain)); app.getStateManager().attach(new RiverState());
} app.getStateManager().attach(new WorldObjectsState());
Node character = buildCharacter(); character = loadOrBuildCharacter();
rootNode.attachChild(character); rootNode.attachChild(character);
// Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1) // Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1)
CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1); CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1);
CharacterControl physicsChar = new CharacterControl(capsule, 0.05f); physicsChar = new CharacterControl(capsule, 0.05f);
physicsChar.setJumpSpeed(12f); physicsChar.setJumpSpeed(12f);
physicsChar.setFallSpeed(35f); physicsChar.setFallSpeed(35f);
physicsChar.setGravity(35f); physicsChar.setGravity(35f);
physicsChar.setPhysicsLocation(new Vector3f(0, spawnY, 0));
character.addControl(physicsChar); character.addControl(physicsChar);
bulletAppState.getPhysicsSpace().add(physicsChar); bulletAppState.getPhysicsSpace().add(physicsChar);
physicsChar.setPhysicsLocation(new Vector3f(spawnX, spawnY, spawnZ));
playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings); playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings);
playerInput.setPhysicsCharacter(physicsChar); playerInput.setPhysicsCharacter(physicsChar);
playerInput.setVisual(character); playerInput.setVisual(characterVisual != null ? characterVisual : character);
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager()); thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
thirdPersonCam.setTarget(character); thirdPersonCam.setTarget(character);
@@ -110,15 +132,110 @@ public class WorldScene extends BaseAppState {
app.getInputManager().setCursorVisible(false); app.getInputManager().setCursorVisible(false);
} }
private float livePosTimer = 0f;
@Override @Override
public void update(float tpf) { public void update(float tpf) {
if (!animContextReady && animLib != null && animLib.isInitialized()) {
setupAnimationContext();
animContextReady = true;
}
playerInput.update(tpf); playerInput.update(tpf);
thirdPersonCam.update(tpf); thirdPersonCam.update(tpf);
livePosTimer += tpf;
if (livePosTimer >= 0.2f && physicsChar != null) {
livePosTimer = 0f;
com.jme3.math.Vector3f pos = physicsChar.getPhysicsLocation();
de.blight.game.LiveBroadcast.writePosition(pos.x, pos.y, pos.z);
}
} }
@Override protected void cleanup(Application app) {} @Override protected void cleanup(Application app) {
de.blight.game.LiveBroadcast.clear();
}
@Override protected void onDisable() {} @Override protected void onDisable() {}
private void setupAnimationContext() {
animLib.applyAllTo(characterVisual != null ? characterVisual : character);
MainCharacter mc = findMainCharacter();
String setName = (mc != null) ? mc.getAnimSetPath() : null;
System.out.println("[AnimCtx] MainCharacter: " + (mc != null ? mc.getCharacterId() : "null")
+ " animSetPath: " + setName
+ " clipCount: " + animLib.getClipKeys().size()
+ " clips: " + animLib.getClipKeys());
// AnimSet-ActionMap ausgeben
if (setName != null) {
java.nio.file.Path setDir = AnimationLibrary.findAssetRoot().resolve("animations").resolve("sets");
try {
de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, setName);
System.out.println("[AnimCtx] AnimSet '" + setName + "' actionMap: " + set.getActionMap());
} catch (Exception e) {
System.out.println("[AnimCtx] AnimSet '" + setName + "' nicht ladbar: " + e.getMessage());
}
}
playerInput.setAnimationContext(animLib, setName, AnimationLibrary.findAssetRoot());
}
// CharacterControl setzt den Spatial auf den Kapsel-Mittelpunkt: radius=0.4, halfCyl=0.5 → 0.9m über dem Boden.
// Das Modell hat den Ursprung an den Füßen → wir brauchen einen -0.9m-Versatz im wrapper-Node.
private static final float CAPSULE_VISUAL_OFFSET_Y = -(0.5f + 0.4f); // -(halfCylHeight + radius)
/** Lädt das Hauptcharakter-Modell, falls im character/-Verzeichnis definiert; sonst Platzhalter. */
private Node loadOrBuildCharacter() {
MainCharacter mc = findMainCharacter();
if (mc != null && mc.getModelPath() != null) {
try {
Spatial loaded = assetManager.loadModel(mc.getModelPath());
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
// Auf 1.8 m skalieren Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
// bei Skinned-Meshes, die vor der ersten SkinningControl-Runde falsche Bounds liefern)
float[] yRange = vertexYRange(loaded);
float modelHeight = yRange[1] - yRange[0];
System.out.println("[WorldScene] Vertex-Y-Range: min=" + yRange[0] + " max=" + yRange[1]
+ " height=" + modelHeight);
float offsetY;
if (modelHeight > 0.1f) {
float scale = 1.8f / modelHeight;
loaded.setLocalScale(scale);
// Füße des Modells (scale * minY in loaded-Local) auf Kapsel-Unterkante legen
offsetY = -(0.9f + scale * yRange[0]);
System.out.println("[WorldScene] Charakter skaliert: " + scale
+ "x offsetY=" + offsetY);
} else {
offsetY = CAPSULE_VISUAL_OFFSET_Y;
System.out.println("[WorldScene] Kein Scale möglich (height=" + modelHeight + "), Fallback-Offset");
}
// rotationNode als Drehpunkt (CharacterControl überschreibt wrapper-Rotation jeden Frame)
Node rotNode = new Node("charRot");
loaded.setLocalTranslation(0, offsetY, 0);
rotNode.attachChild(loaded);
Node wrapper = new Node("character");
wrapper.attachChild(rotNode);
characterVisual = rotNode;
System.out.println("[WorldScene] Hauptcharakter geladen: " + mc.getModelPath());
return wrapper;
} catch (Exception e) {
System.err.println("[WorldScene] Modell nicht ladbar (" + mc.getModelPath()
+ "): " + e.getMessage() + " Fallback auf Platzhalter");
}
}
characterVisual = null;
return buildCharacter();
}
private MainCharacter findMainCharacter() {
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
for (GameCharacter c : CharacterIO.loadAll(charDir)) {
if (c instanceof MainCharacter mc) return mc;
}
return null;
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Beleuchtung // Beleuchtung
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -145,6 +262,42 @@ public class WorldScene extends BaseAppState {
SkyFactory.EnvMapType.CubeMap); SkyFactory.EnvMapType.CubeMap);
rootNode.attachChild(sky); rootNode.attachChild(sky);
} catch (Exception ignored) {} } catch (Exception ignored) {}
setupPostProcessing(sun.getDirection());
}
private void setupPostProcessing(Vector3f sunDir) {
sharedFPP = new FilterPostProcessor(assetManager);
FilterPostProcessor fpp = sharedFPP;
// Globales Wasser bei Y=0 (bedeckt die gesamte Karte unterhalb der Wasserlinie)
try {
WaterFilter waterFilter = new WaterFilter(rootNode, sunDir);
waterFilter.setWaterHeight(0f);
waterFilter.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
waterFilter.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
waterFilter.setWaterTransparency(0.15f);
waterFilter.setMaxAmplitude(0.3f);
waterFilter.setWaveScale(0.008f);
waterFilter.setSpeed(0.5f);
fpp.addFilter(waterFilter);
WeatherState weather = new WeatherState();
weather.setWaterFilter(waterFilter);
FogFilter fogFilter = new FogFilter();
fogFilter.setFogColor(new ColorRGBA(0.75f, 0.80f, 0.88f, 1f));
fogFilter.setFogDensity(0.0f);
fogFilter.setFogDistance(600f);
fpp.addFilter(fogFilter);
weather.setFogFilter(fogFilter);
app.getStateManager().attach(weather);
} catch (Exception e) {
System.err.println("[WorldScene] Post-Processing nicht verfügbar: " + e.getMessage());
}
app.getViewPort().addProcessor(fpp);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -188,13 +341,13 @@ public class WorldScene extends BaseAppState {
} }
} }
// Spawn über dem höchsten Punkt Basis-Terrain UND Gebirge-Oberkante // Temp-Spawn aus Editor-Property überschreibt gespeicherten Karten-Spawn
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE; String propX = System.getProperty("blight.temp.spawn.x");
for (float h : heights) { if (h < minH) minH = h; if (h > maxH) maxH = h; } String propZ = System.getProperty("blight.temp.spawn.z");
float midH = (minH + maxH) * 0.5f; spawnX = propX != null ? Float.parseFloat(propX) : map.spawnX;
float maxUpperTop = maxH; spawnZ = propZ != null ? Float.parseFloat(propZ) : map.spawnZ;
for (float h : map.upperTop) { if (h > maxUpperTop) maxUpperTop = h; } System.out.println("[WorldScene] SpawnXZ Quelle: " + (propX != null ? "Editor-Property" : "Karte")
spawnY = maxUpperTop + 20f; + " → X=" + spawnX + " Z=" + spawnZ);
TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights); TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights);
terrain.setLocalScale(8f, 1f, 8f); terrain.setLocalScale(8f, 1f, 8f);
@@ -202,17 +355,22 @@ public class WorldScene extends BaseAppState {
applyTerrainMaterial(terrain, map); applyTerrainMaterial(terrain, map);
rootNode.attachChild(terrain); rootNode.attachChild(terrain);
// jBullet subtrahiert midH intern in getVertex() → Physics-Body bei midH // Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ
// damit Kollisionsfläche und sichtbares Terrain übereinstimmen. float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f));
HeightfieldCollisionShape hcs = new HeightfieldCollisionShape( if (Float.isNaN(terrainH)) {
heights, terrain.getLocalScale()); float maxH = -Float.MAX_VALUE;
RigidBodyControl terrainPhysics = new RigidBodyControl(hcs, 0f); for (float h : heights) { if (h > maxH) maxH = h; }
terrainH = maxH;
}
spawnY = terrainH + 10f;
RigidBodyControl terrainPhysics = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(terrain), 0f);
terrain.addControl(terrainPhysics); terrain.addControl(terrainPhysics);
bulletAppState.getPhysicsSpace().add(terrainPhysics); bulletAppState.getPhysicsSpace().add(terrainPhysics);
terrainPhysics.setPhysicsLocation(new Vector3f(0f, midH, 0f));
System.out.println("[WorldScene] Karte geladen, Spawn Y=" + spawnY System.out.println("[WorldScene] Karte geladen, SpawnXYZ=("
+ " maxGebirgeH=" + maxUpperTop); + spawnX + ", " + spawnY + ", " + spawnZ + ")");
return terrain; return terrain;
} }
@@ -389,25 +547,40 @@ public class WorldScene extends BaseAppState {
return a; return a;
} }
// Default-Texturen identisch mit Editor (TerrainEditorState.DEFAULT_TERRAIN_TEXTURES)
private static final String[] DEF_TEX = {
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/splat/dirt.jpg",
""
};
private static final ColorRGBA[] DEF_COLOR = {
new ColorRGBA(0.28f, 0.58f, 0.18f, 1f),
new ColorRGBA(0.45f, 0.32f, 0.25f, 1f),
new ColorRGBA(0.55f, 0.45f, 0.30f, 1f),
new ColorRGBA(0.80f, 0.72f, 0.50f, 1f),
};
private void applyTerrainMaterial(TerrainQuad terrain, MapData map) { private void applyTerrainMaterial(TerrainQuad terrain, MapData map) {
if (map != null) { if (map != null) {
try { try {
Material mat = new Material(assetManager, "Common/MatDefs/Terrain/Terrain.j3md"); Material mat = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md");
mat.setBoolean("useTriPlanarMapping", false);
mat.setFloat("Shininess", 0f);
Texture tex1 = loadTexOrFallback("Textures/Terrain/splat/grass.jpg", String[] mapTex = map.terrainTextures;
new ColorRGBA(0.28f, 0.58f, 0.18f, 1f)); String[] matParams = {"DiffuseMap","DiffuseMap_1","DiffuseMap_2","DiffuseMap_3"};
Texture tex2 = loadTexOrFallback("Textures/Terrain/splat/road.jpg", String[] scaleP = {"DiffuseMap_0_scale","DiffuseMap_1_scale","DiffuseMap_2_scale","DiffuseMap_3_scale"};
new ColorRGBA(0.55f, 0.50f, 0.40f, 1f)); for (int i = 0; i < 4; i++) {
Texture tex3 = loadTexOrFallback("Textures/Terrain/splat/Gravel.jpg", String path = (mapTex[i] != null && !mapTex[i].isEmpty()) ? mapTex[i] : DEF_TEX[i];
new ColorRGBA(0.45f, 0.35f, 0.25f, 1f)); if (path == null || path.isEmpty()) continue;
tex1.setWrap(Texture.WrapMode.Repeat); Texture tex = loadTexOrFallback(path, DEF_COLOR[i]);
tex2.setWrap(Texture.WrapMode.Repeat); tex.setWrap(Texture.WrapMode.Repeat);
tex3.setWrap(Texture.WrapMode.Repeat); mat.setTexture(matParams[i], tex);
mat.setTexture("Tex1", tex1); mat.setFloat("Tex1Scale", 512f); mat.setFloat(scaleP[i], 512f);
mat.setTexture("Tex2", tex2); mat.setFloat("Tex2Scale", 512f); }
mat.setTexture("Tex3", tex3); mat.setFloat("Tex3Scale", 512f);
// Ältere Maps haben splatR=0 → Gras (Tex1) wäre unsichtbar; auf 255 setzen. // Ältere Maps haben splatR=0 → Gras (Slot 0) wäre unsichtbar; auf 255 setzen.
byte[] splatR = map.splatR; byte[] splatR = map.splatR;
boolean rAllZero = true; boolean rAllZero = true;
for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } } for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } }
@@ -422,14 +595,14 @@ public class WorldScene extends BaseAppState {
splatBuf.put(splatR[i]); splatBuf.put(splatR[i]);
splatBuf.put(map.splatG[i]); splatBuf.put(map.splatG[i]);
splatBuf.put(map.splatB[i]); splatBuf.put(map.splatB[i]);
splatBuf.put((byte) 0); splatBuf.put(map.splatA[i]);
} }
splatBuf.flip(); splatBuf.flip();
Texture2D splatTex = new Texture2D(new Image(Image.Format.RGBA8, sz, sz, splatBuf)); Texture2D splatTex = new Texture2D(new Image(Image.Format.RGBA8, sz, sz, splatBuf));
splatTex.setWrap(Texture.WrapMode.EdgeClamp); splatTex.setWrap(Texture.WrapMode.EdgeClamp);
splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
splatTex.setMagFilter(Texture.MagFilter.Bilinear); splatTex.setMagFilter(Texture.MagFilter.Bilinear);
mat.setTexture("Alpha", splatTex); mat.setTexture("AlphaMap", splatTex);
terrain.setMaterial(mat); terrain.setMaterial(mat);
return; return;
@@ -595,6 +768,34 @@ public class WorldScene extends BaseAppState {
return character; return character;
} }
/** Gibt {minY, maxY} aller Vertex-Positionen im Teilbaum zurück. */
private static float[] vertexYRange(Spatial root) {
float[] r = {Float.MAX_VALUE, -Float.MAX_VALUE};
collectYRange(root, r);
if (r[0] == Float.MAX_VALUE) return new float[]{0f, 0f};
return r;
}
private static void collectYRange(Spatial s, float[] r) {
if (s instanceof Geometry g) {
var buf = g.getMesh().getBuffer(VertexBuffer.Type.Position);
if (buf == null) return;
FloatBuffer fb = (FloatBuffer) buf.getData();
int saved = fb.position();
fb.rewind();
while (fb.remaining() >= 3) {
fb.get(); // x überspringen
float y = fb.get(); // y
fb.get(); // z überspringen
if (y < r[0]) r[0] = y;
if (y > r[1]) r[1] = y;
}
fb.position(saved);
} else if (s instanceof Node n) {
for (Spatial c : n.getChildren()) collectYRange(c, r);
}
}
private Geometry buildLimb(Material mat, float radius, float height) { private Geometry buildLimb(Material mat, float radius, float height) {
Geometry limb = new Geometry("limb", new Cylinder(6, 12, radius, height, true)); Geometry limb = new Geometry("limb", new Cylinder(6, 12, radius, height, true));
limb.setMaterial(mat); limb.setMaterial(mat);
@@ -602,4 +803,5 @@ public class WorldScene extends BaseAppState {
limb.setShadowMode(RenderQueue.ShadowMode.CastAndReceive); limb.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
return limb; return limb;
} }
} }

View File

@@ -18,47 +18,43 @@ import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl; import com.jme3.scene.control.AbstractControl;
import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.common.MapData; import de.blight.common.GrassTuft;
import de.blight.common.GrassTuftIO;
import java.io.IOException;
import java.nio.FloatBuffer; import java.nio.FloatBuffer;
import java.nio.IntBuffer; import java.nio.IntBuffer;
import java.util.*; import java.util.*;
/** /**
* Rendert Gras im Spiel aus der in MapData gespeicherten Dichte-Map. * Rendert individuell platzierte Gras-Büschel aus blight_grass.blg.
* * Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME).
* Chunks werden gestreckt über mehrere Frames aufgebaut (INIT_PER_FRAME), * GrassVisibilityControl cullt entfernte Chunks.
* um Startlags zu vermeiden. GrassVisibilityControl cullt entfernte Chunks.
*/ */
public class GrassState extends BaseAppState { public class GrassState extends BaseAppState {
// ── Konstanten (identisch mit PlacedObjectState im Editor) ────────────────
private static final int TERRAIN_HALF = 2048; private static final int TERRAIN_HALF = 2048;
private static final float WORLD_SIZE = 4096f;
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE;
private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1);
private static final int CHUNK_SIZE = 128; private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
private static final int MAX_BLADES_PER_PX = 3; private static final int BLADES_PER_TUFT = 4;
private static final float TUFT_SPREAD = 0.5f;
private static final float BLADE_WIDTH = 0.18f; private static final float BLADE_WIDTH = 0.18f;
private static final float DEFAULT_HEIGHT = 1.5f; private static final float FAR_DIST = 150f;
private static final float FAR_DIST = 150f; // WE (game terrain is 1:1 WE)
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST; private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
private static final int INIT_PER_FRAME = 4; private static final int INIT_PER_FRAME = 4;
// ── Abhängigkeiten ────────────────────────────────────────────────────────
private final MapData mapData;
private final TerrainQuad terrain; private final TerrainQuad terrain;
// ── Runtime-Zustand ───────────────────────────────────────────────────────
private Camera cam; private Camera cam;
private Node grassNode; private Node grassNode;
private Material grassMat;
@SuppressWarnings("unchecked")
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private int nextChunk = 0; private int nextChunk = 0;
public GrassState(MapData mapData, TerrainQuad terrain) { public GrassState(TerrainQuad terrain) {
this.mapData = mapData;
this.terrain = terrain; this.terrain = terrain;
} }
@@ -69,7 +65,25 @@ public class GrassState extends BaseAppState {
this.cam = app.getCamera(); this.cam = app.getCamera();
grassNode = new Node("gameGrass"); grassNode = new Node("gameGrass");
((SimpleApplication) app).getRootNode().attachChild(grassNode); ((SimpleApplication) app).getRootNode().attachChild(grassNode);
grassMat = buildGrassMaterial(app.getAssetManager());
for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
try {
GrassTuftIO.GrassData data = GrassTuftIO.load();
if (data != null) {
initSlotMaterials(app.getAssetManager(), data.slotPaths());
for (GrassTuft t : data.tufts()) {
int ci = chunkIndex(t.x(), t.z());
if (ci >= 0) chunkTufts[ci].add(t);
}
}
} catch (IOException e) {
System.err.println("[GrassState] Gras nicht ladbar: " + e.getMessage());
}
if (slotMaterials.isEmpty()) {
slotMaterials.put(0, buildGrassMat(app.getAssetManager(), ""));
}
} }
@Override @Override
@@ -84,20 +98,40 @@ public class GrassState extends BaseAppState {
public void update(float tpf) { public void update(float tpf) {
int built = 0; int built = 0;
while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) { while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) {
buildChunk(nextChunk++); if (!chunkTufts[nextChunk].isEmpty()) buildChunk(nextChunk);
nextChunk++;
built++; built++;
} }
} }
// ── Material ────────────────────────────────────────────────────────────── // ── Material ──────────────────────────────────────────────────────────────
private Material buildGrassMaterial(AssetManager assets) { private void initSlotMaterials(AssetManager assets, String[] slotPaths) {
for (int i = 0; i < 8; i++) {
String p = (slotPaths != null && i < slotPaths.length) ? slotPaths[i] : "";
if (i == 0 || (p != null && !p.isEmpty())) {
slotMaterials.put(i, buildGrassMat(assets, p));
}
}
}
private Material buildGrassMat(AssetManager assets, String texPath) {
try { try {
Material mat = new Material(assets, "MatDefs/Grass.j3md"); Material mat = new Material(assets, "MatDefs/Grass.j3md");
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
mat.setFloat("WindSpeed", 0.5f); mat.setFloat("WindSpeed", 0.5f);
mat.setFloat("WindStrength", 0.14f); mat.setFloat("WindStrength", 0.14f);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
if (texPath != null && !texPath.isEmpty()) {
try {
mat.setTexture("ColorMap", assets.loadTexture(texPath));
mat.setColor("Color", ColorRGBA.White);
} catch (Exception te) {
System.err.println("[GrassState] Gras-Textur nicht ladbar '" + texPath + "': " + te.getMessage());
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
}
} else {
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
}
return mat; return mat;
} catch (Exception e) { } catch (Exception e) {
System.err.println("[GrassState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage()); System.err.println("[GrassState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage());
@@ -108,6 +142,20 @@ public class GrassState extends BaseAppState {
} }
} }
private Material getSlotMaterial(int slot) {
Material m = slotMaterials.get(slot);
return m != null ? m : slotMaterials.get(0);
}
// ── Chunk-Index ───────────────────────────────────────────────────────────
private static int chunkIndex(float x, float z) {
int cx = (int) ((x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) ((z + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
// ── Chunk aufbauen ──────────────────────────────────────────────────────── // ── Chunk aufbauen ────────────────────────────────────────────────────────
private void buildChunk(int idx) { private void buildChunk(int idx) {
@@ -116,47 +164,42 @@ public class GrassState extends BaseAppState {
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE; float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE; float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); List<GrassTuft> tufts = chunkTufts[idx];
int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); if (tufts.isEmpty()) return;
int pxMax = Math.min(SPLAT_SIZE - 1, (int)((wXMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX));
int pzMax = Math.min(SPLAT_SIZE - 1, (int)((wZMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX));
List<float[]> blades = new ArrayList<>(); Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
Vector3f scale = terrain.getWorldScale(); for (GrassTuft t : tufts) {
Vector3f trans = terrain.getWorldTranslation(); long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL;
for (int pz = pzMin; pz <= pzMax; pz++) { Random rng = new Random(seed);
for (int px = pxMin; px <= pxMax; px++) { List<float[]> blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>());
int d = mapData.grassDensity[pz * SPLAT_SIZE + px] & 0xFF; for (int b = 0; b < BLADES_PER_TUFT; b++) {
if (d == 0) continue; float bx = t.x() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PX)); float bz = t.z() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
Random rng = new Random((long) px * 100003L + pz); float th = terrain.getHeight(new Vector2f(bx, bz));
float pixWorldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF;
float pixWorldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF;
for (int b = 0; b < count; b++) {
float bx = pixWorldX + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
float bz = pixWorldZ + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
// Welt→lokal→Höhe→Welt
float localX = (bx - trans.x) / scale.x;
float localZ = (bz - trans.z) / scale.z;
float th = terrain.getHeight(new Vector2f(localX, localZ));
if (Float.isNaN(th)) continue; if (Float.isNaN(th)) continue;
float worldY = trans.y + th * scale.y; float h = t.height() * (0.7f + rng.nextFloat() * 0.6f);
float h = DEFAULT_HEIGHT * (0.7f + rng.nextFloat() * 0.6f); blades.add(new float[]{bx, th, bz, h});
blades.add(new float[]{bx, worldY, bz, h});
}
} }
} }
if (blades.isEmpty()) return; if (bySlot.isEmpty()) return;
Mesh mesh = buildGrassMesh(blades);
float chunkCX = wXMin + CHUNK_SIZE * 0.5f; float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Geometry geo = new Geometry("grass_" + idx, mesh); Node node = new Node("grass_" + idx);
geo.setMaterial(grassMat); for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); if (entry.getValue().isEmpty()) continue;
grassNode.attachChild(geo); Material mat = getSlotMaterial(entry.getKey());
if (mat == null) continue;
Geometry geo = new Geometry("grass_" + idx + "_s" + entry.getKey(),
buildGrassMesh(entry.getValue()));
geo.setMaterial(mat);
node.attachChild(geo);
}
if (node.getChildren().isEmpty()) return;
node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(node);
} }
// ── Mesh: Kreuz-Quad mit UV ─────────────────────────────────────────────── // ── Mesh: Kreuz-Quad mit UV ───────────────────────────────────────────────

View File

@@ -0,0 +1,314 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.effect.ParticleEmitter;
import com.jme3.effect.ParticleMesh;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
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.Image;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.common.RiverIO;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.List;
import java.util.Random;
/**
* Rendert alle gespeicherten Flüsse als Ribbon-Meshes mit Dual-Layer Normal-Map,
* Tiefengradient, Uferschaum (Worley-Noise) und g_Time-basierter UV-Animation.
*/
public class RiverState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(RiverState.class);
private static final float UV_SCALE = 6.0f;
private Node riverNode;
private AssetManager assets;
private Texture2D foamTexture;
private final List<Material> animatedMaterials = new java.util.ArrayList<>();
private float time = 0f;
public RiverState() {}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
log.info("RiverState: initialisiere");
this.assets = app.getAssetManager();
this.foamTexture = generateFoamTexture();
riverNode = new Node("rivers");
((SimpleApplication) app).getRootNode().attachChild(riverNode);
List<List<RiverPoint>> rivers;
try {
rivers = RiverIO.load();
} catch (Exception e) {
log.error("Flüsse nicht ladbar", e);
return;
}
log.info("RiverState: {} Fluss/Flüsse in Datei", rivers.size());
if (rivers.isEmpty()) return;
int built = 0;
for (List<RiverPoint> river : rivers) {
if (river == null || river.size() < 2) continue;
try {
buildRiver(river);
built++;
} catch (Exception e) {
log.error("Fehler beim Fluss-Aufbau", e);
}
}
log.info("{}/{} Fluss/Flüsse geladen.", built, rivers.size());
}
@Override
protected void cleanup(Application app) {
riverNode.detachAllChildren();
((SimpleApplication) app).getRootNode().detachChild(riverNode);
foamTexture = null;
}
@Override
protected void onEnable() { riverNode.setCullHint(Spatial.CullHint.Inherit); }
@Override
protected void onDisable() { riverNode.setCullHint(Spatial.CullHint.Always); }
@Override
public void update(float tpf) {
if (animatedMaterials.isEmpty()) return;
time += tpf;
for (Material m : animatedMaterials) {
m.setFloat("Time", time);
}
}
// ── Fluss bauen ───────────────────────────────────────────────────────────
private void buildRiver(List<RiverPoint> pts) {
int n = pts.size();
int i = 0;
while (i < n - 1) {
boolean wf = pts.get(i).isWaterfall();
int j = i + 1;
while (j < n - 1 && pts.get(j).isWaterfall() == wf) j++;
List<RiverPoint> run = RiverSpline.subdivide(pts.subList(i, j + 1));
if (run.size() >= 2) {
buildRibbonSection(run, wf);
if (wf) buildWaterfallParticles(run.get(run.size() - 1));
}
i = j;
}
}
private void buildRibbonSection(List<RiverPoint> pts, boolean isWaterfall) {
Mesh mesh = buildRibbonMesh(pts);
if (mesh == null) return;
Material mat = buildMaterial(isWaterfall);
Geometry geo = new Geometry("river_ribbon", mesh);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
riverNode.attachChild(geo);
if (mat.getMaterialDef().getName().equals("Flowing Water")) {
animatedMaterials.add(mat);
}
}
private Mesh buildRibbonMesh(List<RiverPoint> pts) {
int n = pts.size();
if (n < 2) return null;
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3);
FloatBuffer norm = BufferUtils.createFloatBuffer(n * 2 * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(n * 2 * 2);
IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3);
// Kumulierte Bogenlänge für V-Koordinate
float[] arcLen = new float[n];
for (int i = 1; i < n; i++) {
RiverPoint a = pts.get(i - 1), b = pts.get(i);
float dx = b.x()-a.x(), dz = b.z()-a.z(), dy = b.y()-a.y();
arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz);
}
for (int i = 0; i < n; i++) {
RiverPoint pt = pts.get(i);
Vector3f tangent;
if (i == 0) {
RiverPoint next = pts.get(1);
tangent = new Vector3f(next.x()-pt.x(), next.y()-pt.y(), next.z()-pt.z());
} else if (i == n - 1) {
RiverPoint prev = pts.get(n - 2);
tangent = new Vector3f(pt.x()-prev.x(), pt.y()-prev.y(), pt.z()-prev.z());
} else {
RiverPoint prev = pts.get(i - 1), next = pts.get(i + 1);
tangent = new Vector3f(next.x()-prev.x(), next.y()-prev.y(), next.z()-prev.z());
}
if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f);
tangent.normalizeLocal();
Vector3f right = tangent.cross(Vector3f.UNIT_Y).normalizeLocal();
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y(), pz = pt.z();
float vCoord = arcLen[i] / UV_SCALE;
// Linker Rand (U=0), rechter Rand (U=1)
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(0f).put(vCoord);
pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(1f).put(vCoord);
}
for (int i = 0; i < n - 1; i++) {
int v0 = 2*i, v1 = 2*i+1, v2 = 2*i+2, v3 = 2*i+3;
idx.put(v0).put(v1).put(v3);
idx.put(v0).put(v3).put(v2);
}
pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Normal, 3, norm);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
private Material buildMaterial(boolean isWaterfall) {
ColorRGBA tint = isWaterfall
? new ColorRGBA(0.65f, 0.82f, 0.95f, 0.80f)
: new ColorRGBA(0.10f, 0.30f, 0.62f, 0.85f);
Material mat;
try {
mat = new Material(assets, "MatDefs/FlowingWater.j3md");
try {
Texture nm = assets.loadTexture(
"Common/MatDefs/Water/Textures/water_normalmap.png");
nm.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("NormalMap", nm);
} catch (Exception e) {
log.warn("Normal-Map nicht ladbar, wird ohne Wellenstruktur gerendert");
}
if (foamTexture != null) {
mat.setTexture("FoamMap", foamTexture);
}
mat.setColor("Tint", tint);
mat.setFloat("UVScale", UV_SCALE);
mat.setFloat("FlowSpeed", isWaterfall ? RiverPoint.WATERFALL_SPEED
: RiverPoint.RIVER_SPEED);
mat.setFloat("FoamAmount", isWaterfall ? 1.0f : 0.0f);
} catch (Exception e) {
log.warn("FlowingWater-Material nicht ladbar, Fallback auf Unshaded", e);
mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", tint);
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
mat.getAdditionalRenderState().setDepthWrite(false);
return mat;
}
// ── Worley-Noise Schaum-Textur ────────────────────────────────────────────
private Texture2D generateFoamTexture() {
int size = 256;
int nPts = 40;
Random rng = new Random(12345L);
float[] sx = new float[nPts];
float[] sy = new float[nPts];
for (int i = 0; i < nPts; i++) {
sx[i] = rng.nextFloat() * size;
sy[i] = rng.nextFloat() * size;
}
float cellR = size / (float) Math.sqrt(nPts) * 0.55f;
ByteBuffer buf = BufferUtils.createByteBuffer(size * size * 4);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
float minD = Float.MAX_VALUE;
for (int i = 0; i < nPts; i++) {
float dx = Math.abs(x - sx[i]);
float dy = Math.abs(y - sy[i]);
if (dx > size * 0.5f) dx = size - dx; // Kachelung
if (dy > size * 0.5f) dy = size - dy;
float d = (float) Math.sqrt(dx*dx + dy*dy);
if (d < minD) minD = d;
}
float v = Math.max(0f, 1f - minD / cellR);
v = v * v; // Schaum-Blasen schärfer abgrenzen
byte bv = (byte) Math.round(v * 255);
buf.put(bv).put(bv).put(bv).put((byte) 255);
}
}
buf.flip();
Texture2D tex = new Texture2D(new Image(Image.Format.RGBA8, size, size, buf));
tex.setWrap(Texture.WrapMode.Repeat);
tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
tex.setMagFilter(Texture.MagFilter.Bilinear);
return tex;
}
// ── Partikel-Emitter ──────────────────────────────────────────────────────
private void buildWaterfallParticles(RiverPoint base) {
ParticleEmitter emitter = new ParticleEmitter(
"waterfall_particles", ParticleMesh.Type.Triangle, 30);
Material pMat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
try {
pMat.setTexture("Texture", assets.loadTexture("Effects/Smoke/Smoke.png"));
} catch (Exception e) {
log.warn("Partikel-Textur nicht ladbar", e);
}
emitter.setMaterial(pMat);
emitter.setImagesX(1);
emitter.setImagesY(1);
emitter.setStartColor(new ColorRGBA(1f, 1f, 1f, 0.5f));
emitter.setEndColor(new ColorRGBA(1f, 1f, 1f, 0f));
emitter.setStartSize(1.2f);
emitter.setEndSize(2.5f);
emitter.setGravity(0f, -0.5f, 0f);
emitter.setLowLife(0.8f);
emitter.setHighLife(1.2f);
emitter.setInitialVelocity(new Vector3f(0f, 3f, 0f));
emitter.setVelocityVariation(0.6f);
emitter.setParticlesPerSec(15);
emitter.setLocalTranslation(base.x(), base.y(), base.z());
riverNode.attachChild(emitter);
}
}

View File

@@ -0,0 +1,277 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.post.FilterPostProcessor;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import com.jme3.water.WaterFilter;
import de.blight.common.PlacedWater;
import de.blight.common.WaterBodyIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.*;
/**
* Rendert im Editor platzierte Wasserflächen per WaterFilter (visuelle Qualität).
* Ein unsichtbares Komplementär-Mesh (nur Tiefenpuffer) verhindert, dass der
* WaterFilter außerhalb der Flood-Fill-Form Wasser rendert.
*/
public class WaterBodyState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WaterBodyState.class);
private static final int WATER_GRID = 2049;
private static final int STEP = 2;
private static final int WORLD_HALF = 2048;
private static final int MAX_CELLS = 500_000;
private static final float MASK_MARGIN = 20f; // Puffer um den Becken-Radius
private final TerrainQuad terrain;
private final FilterPostProcessor fpp;
private Node waterNode;
private final List<WaterFilter> waterFilters = new ArrayList<>();
public WaterBodyState(TerrainQuad terrain, FilterPostProcessor fpp) {
this.terrain = terrain;
this.fpp = fpp;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
SimpleApplication sa = (SimpleApplication) app;
waterNode = new Node("waterBodies");
sa.getRootNode().attachChild(waterNode);
List<PlacedWater> bodies;
try {
bodies = WaterBodyIO.load();
} catch (Exception e) {
log.error("Wasserflächen nicht ladbar", e);
return;
}
if (bodies.isEmpty()) return;
Vector3f sunDir = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal();
for (PlacedWater body : bodies) {
try {
Set<Integer> cells = floodFill(body.seedX(), body.seedZ(), body.waterHeight());
if (cells == null || cells.isEmpty()) {
log.warn("Becken nicht rekonstruierbar: {}/{}", body.seedX(), body.seedZ());
continue;
}
float[] cr = computeCentroidAndRadius(cells);
float cx = cr[0], cz = cr[1];
float filterRadius = cr[2] + MASK_MARGIN;
WaterFilter wf = new WaterFilter(sa.getRootNode(), sunDir);
wf.setWaterHeight(body.waterHeight());
wf.setCenter(new Vector3f(cx, body.waterHeight(), cz));
wf.setRadius(filterRadius);
wf.setShapeType(WaterFilter.AreaShape.Circular);
wf.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
wf.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
wf.setWaterTransparency(0.15f);
wf.setMaxAmplitude(0.3f);
wf.setWaveScale(0.008f);
wf.setSpeed(0.5f);
fpp.addFilter(wf);
waterFilters.add(wf);
// Tiefenpuffer-Maske: alle Zellen im Filterkreis außerhalb des Beckens
Geometry mask = buildDepthMask(cells, cx, cz, filterRadius, body.waterHeight(),
app.getAssetManager());
if (mask != null) waterNode.attachChild(mask);
log.info("Becken: cells={} h={} center=({},{}) r={}",
cells.size(), body.waterHeight(), cx, cz, filterRadius);
} catch (Exception e) {
log.error("Fehler bei Becken {}/{}", body.seedX(), body.seedZ(), e);
}
}
log.info("{}/{} Wasserfläche(n) geladen.", waterFilters.size(), bodies.size());
}
@Override
protected void cleanup(Application app) {
for (WaterFilter wf : waterFilters) fpp.removeFilter(wf);
waterFilters.clear();
if (waterNode != null)
((SimpleApplication) app).getRootNode().detachChild(waterNode);
}
@Override
protected void onEnable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Always);
}
// ── Flood-Fill ────────────────────────────────────────────────────────────
private Set<Integer> floodFill(float seedWorldX, float seedWorldZ, float waterHeight) {
int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP);
int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP);
seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX));
seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ));
Map<Integer, Float> heightCache = new HashMap<>();
float seedH = sampleHeight(seedPX, seedPZ, heightCache);
if (seedH > waterHeight + 0.5f) {
log.warn("Seed-Höhe {} über waterHeight {} Becken nicht rekonstruierbar", seedH, waterHeight);
return null;
}
Set<Integer> visited = new HashSet<>();
Deque<int[]> queue = new ArrayDeque<>();
visited.add(seedPZ * WATER_GRID + seedPX);
queue.add(new int[]{seedPX, seedPZ});
final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] c = queue.poll();
int px = c[0], pz = c[1];
if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1)
return null;
for (int[] d : dirs) {
int nx = px + d[0], nz = pz + d[1];
int nIdx = nz * WATER_GRID + nx;
if (visited.contains(nIdx)) continue;
if (sampleHeight(nx, nz, heightCache) <= waterHeight) {
visited.add(nIdx);
if (visited.size() > MAX_CELLS) return null;
queue.add(new int[]{nx, nz});
}
}
}
return visited.isEmpty() ? null : visited;
}
private float sampleHeight(int px, int pz, Map<Integer, Float> cache) {
int key = pz * WATER_GRID + px;
Float cached = cache.get(key);
if (cached != null) return cached;
float worldX = (float)(px * STEP) - WORLD_HALF;
float worldZ = (float)(pz * STEP) - WORLD_HALF;
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
float height = (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
cache.put(key, height);
return height;
}
// ── Geometrie-Hilfsmethoden ───────────────────────────────────────────────
private static float[] computeCentroidAndRadius(Set<Integer> cells) {
double sumX = 0, sumZ = 0;
for (int cell : cells) {
sumX += (double)((cell % WATER_GRID) * STEP) - WORLD_HALF;
sumZ += (double)((cell / WATER_GRID) * STEP) - WORLD_HALF;
}
float cx = (float)(sumX / cells.size());
float cz = (float)(sumZ / cells.size());
float maxR = 0f;
for (int cell : cells) {
float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
float r = FastMath.sqrt(dx * dx + dz * dz);
if (r > maxR) maxR = r;
}
return new float[]{cx, cz, maxR};
}
/**
* Erstellt ein unsichtbares Mesh bei waterHeight+0.01 für alle Zellen im
* Filterkreis, die NICHT zum Becken gehören.
*
* Das Mesh schreibt nur in den Tiefenpuffer (ColorWrite=false, DepthWrite=true).
* Vom WaterFilter aus gesehen liegt diese "Fläche" über dem Terrain (höheres Y =
* näher zur Kamera von oben) und blockiert damit das Wasser-Rendering außerhalb
* des Beckens.
*/
private static Geometry buildDepthMask(Set<Integer> basinCells, float cx, float cz,
float radius, float waterHeight,
AssetManager assets) {
float h = waterHeight + 0.01f;
float r2 = radius * radius;
int minPX = Math.max(0, (int) Math.floor(((cx - radius) + WORLD_HALF) / STEP) - 1);
int maxPX = Math.min(WATER_GRID - 1, (int) Math.ceil (((cx + radius) + WORLD_HALF) / STEP) + 1);
int minPZ = Math.max(0, (int) Math.floor(((cz - radius) + WORLD_HALF) / STEP) - 1);
int maxPZ = Math.min(WATER_GRID - 1, (int) Math.ceil (((cz + radius) + WORLD_HALF) / STEP) + 1);
List<Integer> maskCells = new ArrayList<>();
for (int pz = minPZ; pz <= maxPZ; pz++) {
for (int px = minPX; px <= maxPX; px++) {
int cellIdx = pz * WATER_GRID + px;
if (basinCells.contains(cellIdx)) continue;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
if (dx * dx + dz * dz <= r2) maskCells.add(cellIdx);
}
}
if (maskCells.isEmpty()) return null;
int n = maskCells.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(n * 6);
int vi = 0;
for (int cell : maskCells) {
int pz = cell / WATER_GRID;
int px = cell % WATER_GRID;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
pos.put(wx ).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz + STEP);
pos.put(wx ).put(h).put(wz + STEP);
// CCW von oben → Normalen zeigen +Y
idx.put(vi).put(vi+2).put(vi+1);
idx.put(vi).put(vi+3).put(vi+2);
vi += 4;
}
pos.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
Geometry geo = new Geometry("water_mask", mesh);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Black);
mat.getAdditionalRenderState().setColorWrite(false);
mat.getAdditionalRenderState().setDepthWrite(true);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
// Transparent: renders after Opaque terrain, so terrain depth is already in buffer.
// The mask (closer to camera = higher Y) passes depth test and overwrites it,
// blocking WaterFilter from reading terrain Y < waterHeight at non-basin pixels.
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
return geo;
}
}

View File

@@ -0,0 +1,168 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.*;
import com.jme3.texture.Texture;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.game.animation.AnimationLibrary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WorldObjectsState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldObjectsState.class);
private SimpleApplication app;
private AssetManager assets;
private BulletAppState bulletAppState;
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
this.bulletAppState = app.getStateManager().getState(BulletAppState.class);
// Asset-Root registrieren, damit Modell-Pfade auflösbar sind
try {
assets.registerLocator(
AnimationLibrary.findAssetRoot().toAbsolutePath().toString(),
FileLocator.class);
} catch (Exception ignored) {}
}
@Override
protected void onEnable() {
List<PlacedModel> models;
try {
models = PlacedModelIO.load();
} catch (Exception e) {
log.warn("[WorldObjects] Fehler beim Laden der Objekte: {}", e.getMessage());
return;
}
if (models.isEmpty()) {
log.info("[WorldObjects] Keine platzierten Objekte gefunden.");
return;
}
log.info("[WorldObjects] Lade {} Objekte…", models.size());
Node root = app.getRootNode();
int loaded = 0, failed = 0;
for (PlacedModel m : models) {
try {
Spatial s = buildSpatial(m);
s.setLocalTranslation(m.x(), m.y(), m.z());
Quaternion rot = new Quaternion();
rot.fromAngles(m.rotX(), m.rotY(), m.rotZ());
s.setLocalRotation(rot);
s.setLocalScale(m.scale());
// Schatten
RenderQueue.ShadowMode shadowMode;
if (m.castShadow() && m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.CastAndReceive;
else if (m.castShadow()) shadowMode = RenderQueue.ShadowMode.Cast;
else if (m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.Receive;
else shadowMode = RenderQueue.ShadowMode.Off;
s.setShadowMode(shadowMode);
// Physik-Kollision für solide Objekte
if (m.solid() && bulletAppState != null) {
try {
RigidBodyControl rbc = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(s), 0f);
s.addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
} catch (Exception pe) {
log.warn("[WorldObjects] Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage());
}
}
root.attachChild(s);
loaded++;
} catch (Exception e) {
log.warn("[WorldObjects] Objekt '{}' nicht ladbar: {}", m.modelPath(), e.getMessage());
failed++;
}
}
log.info("[WorldObjects] {} geladen, {} fehlgeschlagen.", loaded, failed);
}
@Override protected void cleanup(Application app) {}
@Override protected void onDisable() {}
private Spatial buildSpatial(PlacedModel m) {
// Exportiertes Mesh hat Vorrang vor modelPath
String path = (m.meshFile() != null && !m.meshFile().isBlank())
? m.meshFile() : m.modelPath();
Spatial spatial;
if (path.startsWith("@")) {
spatial = createPrimitive(path.substring(1));
applyMaterial(spatial, m);
} else {
spatial = assets.loadModel(path);
}
spatial.setName("obj_" + path);
return spatial;
}
private Spatial createPrimitive(String type) {
return switch (type) {
case "sphere" -> new Geometry("sphere", new Sphere(16, 16, 1f));
case "cylinder" -> new Geometry("cylinder", new Cylinder(2, 16, 0.5f, 2f, true));
case "plane" -> {
Geometry g = new Geometry("plane", new Quad(2f, 2f));
g.rotate(-FastMath.HALF_PI, 0, 0);
g.move(-1f, 0, 1f);
yield g;
}
default -> new Geometry("box", new Box(0.5f, 0.5f, 0.5f));
};
}
private void applyMaterial(Spatial s, PlacedModel m) {
boolean hasTex = m.texturePath() != null && !m.texturePath().isBlank();
boolean hasNmap = m.normalMapPath() != null && !m.normalMapPath().isBlank();
boolean hasMat = m.materialPath() != null && !m.materialPath().isBlank();
if (!(s instanceof Geometry g)) return;
try {
if (hasMat) {
g.setMaterial(assets.loadMaterial(m.materialPath()));
return;
}
if (hasTex || hasNmap) {
Material mat = new Material(assets, "Common/MatDefs/Light/Lighting.j3md");
mat.setBoolean("UseMaterialColors", false);
if (hasTex) {
Texture t = assets.loadTexture(m.texturePath());
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("DiffuseMap", t);
}
if (hasNmap) {
Texture n = assets.loadTexture(m.normalMapPath());
n.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("NormalMap", n);
}
g.setMaterial(mat);
return;
}
} catch (Exception e) {
log.warn("[WorldObjects] Material-Fehler: {}", e.getMessage());
}
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Gray);
g.setMaterial(mat);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Some files were not shown because too many files have changed in this diff Show More