Weiter gearbeitet
22
blight-assets/src/main/resources/MatDefs/FlowingWater.j3md
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
62
blight-assets/src/main/resources/Shaders/FlowingWater.frag
Normal 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);
|
||||
}
|
||||
21
blight-assets/src/main/resources/Shaders/FlowingWater.vert
Normal 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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 384 KiB |
BIN
blight-assets/src/main/resources/Textures/water/river.jpg
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
blight-assets/src/main/resources/Textures/water/river_normal.jpg
Normal file
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 47 KiB |
BIN
blight-assets/src/main/resources/animations/clips/idle.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/idle_jump.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/running.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/sprint.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/stand_up.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/tpose.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/walking.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/idle.glb
Normal file
BIN
blight-assets/src/main/resources/animations/idle_jump.glb
Normal file
BIN
blight-assets/src/main/resources/animations/running_jump.glb
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
blight-assets/src/main/resources/animations/sprinting.glb
Normal file
BIN
blight-assets/src/main/resources/animations/stand_up.glb
Normal file
BIN
blight-assets/src/main/resources/animations/tpose.glb
Normal file
19
blight-assets/src/main/resources/character/hero.character
Normal 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"
|
||||
}
|
||||
10
blight-assets/src/main/resources/character/silas.character
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"trader": false,
|
||||
"characterId": "silas",
|
||||
"name": {
|
||||
"id": "silas.name"
|
||||
},
|
||||
"modelPath": "Models/Chars/silas.j3o",
|
||||
"animSetPath": "human",
|
||||
"type": "NPC"
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,25 @@ public final class MapData {
|
||||
/** Gras-Dichte [SPLAT_SIZE²], Bytes 0–255 (0=kein Gras, 255=max Dichte). */
|
||||
public final byte[] grassDensity;
|
||||
|
||||
/**
|
||||
* Gras-Höhe pro Pixel [SPLAT_SIZE²].
|
||||
* 0 = nicht gesetzt → grassDefaultHeight verwenden.
|
||||
* 1–255 = 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, 1–N=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). */
|
||||
public final byte[] upperHole;
|
||||
|
||||
@@ -91,7 +110,9 @@ public final class MapData {
|
||||
upperSplatG = new byte [SPLAT_SIZE * SPLAT_SIZE];
|
||||
upperSplatB = new byte [SPLAT_SIZE * SPLAT_SIZE];
|
||||
upperSplatA = new byte [SPLAT_SIZE * SPLAT_SIZE];
|
||||
grassDensity = new byte [SPLAT_SIZE * SPLAT_SIZE];
|
||||
upperHole = new byte [UPPER_CELLS * UPPER_CELLS];
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import java.util.zip.*;
|
||||
* 4 – wie 3 + Spawnpunkt (2× float)
|
||||
* 5 – wie 4 + Splatmap-Alpha + Texturpfade + Gebirge-Splatmap (RGBA + Pfade)
|
||||
* 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 {
|
||||
|
||||
@@ -50,7 +53,7 @@ public final class MapIO {
|
||||
}
|
||||
|
||||
private static final int MAGIC = 0x424C4947; // "BLIG"
|
||||
private static final int VERSION = 6;
|
||||
private static final int VERSION = 9;
|
||||
|
||||
private MapIO() {}
|
||||
|
||||
@@ -104,6 +107,19 @@ public final class MapIO {
|
||||
out.write(data.upperSplatB);
|
||||
out.write(data.upperSplatA);
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@ public record PlacedModel(
|
||||
/** Relativer Asset-Pfad zur exportierten j3o-Datei des Custom Meshes; "" wenn nicht verwendet. */
|
||||
String meshFile,
|
||||
/** AnimationLibrary-Clip-Key (z. B. "walk/Run"); "" = keine Animation. */
|
||||
String animClip
|
||||
String animClip,
|
||||
boolean castShadow,
|
||||
boolean receiveShadow
|
||||
) {}
|
||||
|
||||
@@ -8,10 +8,10 @@ import java.util.*;
|
||||
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei
|
||||
* ({@code blight_objects.blo}) neben der Kartendatei.
|
||||
*
|
||||
* Spalten (seit v2):
|
||||
* modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile
|
||||
* Spalten (seit v3):
|
||||
* 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 {
|
||||
|
||||
@@ -25,18 +25,19 @@ public final class PlacedModelIO {
|
||||
Path p = getPath();
|
||||
Files.createDirectories(p.getParent());
|
||||
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();
|
||||
for (PlacedModel m : models) {
|
||||
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.x(), m.y(), m.z(),
|
||||
m.rotY(), m.scale(),
|
||||
m.rotX(), m.rotZ(),
|
||||
m.solid(),
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,11 +64,14 @@ public final class PlacedModelIO {
|
||||
String texPath = f.length > 9 ? f[9] : "";
|
||||
String nmPath = f.length > 10 ? f[10] : "";
|
||||
String matPath = f.length > 11 ? f[11] : "";
|
||||
String meshFile = f.length > 12 ? f[12] : "";
|
||||
String animClip = f.length > 13 ? f[13] : "";
|
||||
String meshFile = f.length > 12 ? f[12] : "";
|
||||
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,
|
||||
rotY, rotX, rotZ, scale, solid,
|
||||
texPath, nmPath, matPath, meshFile, animClip));
|
||||
texPath, nmPath, matPath, meshFile, animClip,
|
||||
castShadow, receiveShadow));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package de.blight.common;
|
||||
|
||||
/** Unveränderliche Daten einer platzierten Wasseroberfläche (Teich, See). */
|
||||
public record PlacedWater(
|
||||
float x, float y, float z,
|
||||
float width, float depth
|
||||
) {}
|
||||
/**
|
||||
* Platzierte Wasserfläche.
|
||||
* Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet –
|
||||
* gespeichert werden nur Saatpunkt und Wasserstand.
|
||||
*/
|
||||
public record PlacedWater(float seedX, float seedZ, float waterHeight) {}
|
||||
|
||||
73
blight-common/src/main/java/de/blight/common/RiverIO.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import java.nio.file.*;
|
||||
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.
|
||||
*
|
||||
* 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 {
|
||||
|
||||
@@ -22,12 +23,11 @@ public final class WaterBodyIO {
|
||||
Path p = getPath();
|
||||
Files.createDirectories(p.getParent());
|
||||
try (BufferedWriter w = Files.newBufferedWriter(p)) {
|
||||
w.write("# x\ty\tz\twidth\tdepth");
|
||||
w.write("# seedX\tseedZ\twaterHeight");
|
||||
w.newLine();
|
||||
for (PlacedWater b : bodies) {
|
||||
w.write(String.format(Locale.ROOT,
|
||||
"%.5f\t%.5f\t%.5f\t%.5f\t%.5f%n",
|
||||
b.x(), b.y(), b.z(), b.width(), b.depth()));
|
||||
w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n",
|
||||
b.seedX(), b.seedZ(), b.waterHeight()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,14 +40,12 @@ public final class WaterBodyIO {
|
||||
line = line.strip();
|
||||
if (line.isEmpty() || line.startsWith("#")) continue;
|
||||
String[] f = line.split("\t", -1);
|
||||
if (f.length < 5) continue;
|
||||
if (f.length < 3) continue;
|
||||
try {
|
||||
list.add(new PlacedWater(
|
||||
Float.parseFloat(f[0]),
|
||||
Float.parseFloat(f[1]),
|
||||
Float.parseFloat(f[2]),
|
||||
Float.parseFloat(f[3]),
|
||||
Float.parseFloat(f[4])));
|
||||
Float.parseFloat(f[2])));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -21,11 +21,12 @@ import java.util.stream.Stream;
|
||||
* "characterId": "hero",
|
||||
* "name": "Der Held",
|
||||
* "modelPath": "Models/hero.j3o",
|
||||
* "animSetPath": "animations/sets/hero.j3o",
|
||||
* "animSetPath": "human",
|
||||
* ... (subclass fields via Gson)
|
||||
* }
|
||||
* Die Aktions-Zuweisung (IDLE → Clip-Name usw.) ist im AnimSet gespeichert
|
||||
* (animSetPath.replaceAll(".j3o", "") + ".animset.json").
|
||||
* animSetPath ist der Set-Name (ohne Pfad/Extension).
|
||||
* Die Aktions-Zuweisung liegt in animations/sets/{animSetPath}.animset.json.
|
||||
* Die Clip-Dateien liegen in animations/clips/{clipName}.j3o.
|
||||
*/
|
||||
public final class CharacterIO {
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
implementation 'org.slf4j:slf4j-api: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'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.38'
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ public class FrameTransfer implements SceneProcessor {
|
||||
private int[] argbBuf; // gesamtes Bild für einmaligen bulk-Write
|
||||
|
||||
private final AtomicBoolean jfxBusy = new AtomicBoolean(false);
|
||||
private volatile Runnable onFirstFrame;
|
||||
|
||||
public void setOnFirstFrame(Runnable cb) { this.onFirstFrame = cb; }
|
||||
|
||||
public FrameTransfer(WritableImage image) {
|
||||
this.pw = image.getPixelWriter();
|
||||
@@ -77,6 +80,8 @@ public class FrameTransfer implements SceneProcessor {
|
||||
}
|
||||
}
|
||||
pw.setPixels(0, 0, width, height, fmt, argbBuf, 0, width);
|
||||
Runnable cb = onFirstFrame;
|
||||
if (cb != null) { onFirstFrame = null; cb.run(); }
|
||||
} finally {
|
||||
jfxBusy.set(false);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.jme3.texture.Texture2D;
|
||||
import de.blight.editor.state.AnimPreviewState;
|
||||
import de.blight.editor.state.EmitterState;
|
||||
import de.blight.editor.state.MusicAreaState;
|
||||
import de.blight.editor.state.RiverEditorState;
|
||||
import de.blight.editor.state.PlayToolState;
|
||||
import de.blight.editor.state.SoundAreaState;
|
||||
import de.blight.editor.state.WaterBodyState;
|
||||
@@ -68,8 +69,7 @@ public class JmeEditorApp extends SimpleApplication {
|
||||
public void simpleInitApp() {
|
||||
flyCam.setEnabled(false);
|
||||
|
||||
// Explizit registrieren, falls General.cfg die Klassen beim ersten Start
|
||||
// noch nicht gefunden hat (jme3-plugins war zuvor nicht auf dem Classpath).
|
||||
input.loadingStatus = "Registriere Asset-Loader...";
|
||||
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf");
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
input.loadingStatus = "Initialisiere Renderer...";
|
||||
currentW = vpWidth;
|
||||
currentH = vpHeight;
|
||||
buildFrameBuffer(vpWidth, vpHeight, initialImage);
|
||||
|
||||
input.loadingStatus = "Lade Editor-States...";
|
||||
stateManager.attach(new SceneObjectState(input));
|
||||
stateManager.attach(new TerrainEditorState(input));
|
||||
stateManager.attach(new TreeGeneratorState(input));
|
||||
@@ -93,10 +94,11 @@ public class JmeEditorApp extends SimpleApplication {
|
||||
stateManager.attach(new WaterBodyState(input));
|
||||
stateManager.attach(new SoundAreaState(input));
|
||||
stateManager.attach(new MusicAreaState(input));
|
||||
stateManager.attach(new RiverEditorState(input));
|
||||
stateManager.attach(new PlayToolState(input));
|
||||
stateManager.attach(new AnimPreviewState(input));
|
||||
|
||||
// JME-Konsole (Editor-Modus: kein RawInputListener – Eingabe via SharedInput)
|
||||
input.loadingStatus = "Initialisiere Konsole...";
|
||||
jmeConsole = new JmeConsole(false);
|
||||
registerEditorCommands();
|
||||
jmeConsole.setOnVisibilityChanged(open -> {
|
||||
@@ -158,6 +160,7 @@ public class JmeEditorApp extends SimpleApplication {
|
||||
viewPort.setOutputFrameBuffer(fb);
|
||||
guiViewPort.setOutputFrameBuffer(fb);
|
||||
frameTransfer = new FrameTransfer(image);
|
||||
frameTransfer.setOnFirstFrame(() -> { input.loadingStatus = "Bereit"; input.jmeReady = true; });
|
||||
guiViewPort.addProcessor(frameTransfer);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ public class SharedInput {
|
||||
public final HoleTool holeTool = new HoleTool();
|
||||
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 ─────────────────────
|
||||
public volatile int activeLayer = 0;
|
||||
|
||||
@@ -63,6 +67,18 @@ public class SharedInput {
|
||||
public record GrassEdit(float screenX, float screenY, int action) {}
|
||||
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, 1–7 = grassTextureSlots[slot-1]). */
|
||||
public volatile int grassActiveSlot = 0;
|
||||
/** JFX setzt true wenn Slot-Texturen geändert wurden. */
|
||||
public volatile boolean grassSlotsChanged = false;
|
||||
|
||||
// ── Textur-Edits ─────────────────────────────────────────────────────────
|
||||
/** action +1 = selektierte Textur malen, -1 = auf Gras zurücksetzen. */
|
||||
public record TextureEdit(float screenX, float screenY, int action) {}
|
||||
@@ -123,15 +139,15 @@ public class SharedInput {
|
||||
public volatile boolean treePreviewResized = false;
|
||||
|
||||
// ── 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<>();
|
||||
|
||||
// ── 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<>();
|
||||
|
||||
// ── 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<>();
|
||||
|
||||
// ── Objekt-Werkzeug ──────────────────────────────────────────────────────
|
||||
@@ -201,6 +217,8 @@ public class SharedInput {
|
||||
float x, float y, float z,
|
||||
float rotX, float rotY, float rotZ,
|
||||
boolean solid,
|
||||
boolean castShadow,
|
||||
boolean receiveShadow,
|
||||
String texPath, // null = nicht ändern
|
||||
String normalMapPath, // null = nicht ändern
|
||||
String matPath // null = nicht ändern
|
||||
@@ -325,18 +343,38 @@ public class SharedInput {
|
||||
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
|
||||
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. */
|
||||
public record WaterClick(float screenX, float screenY, boolean rightButton) {}
|
||||
public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>();
|
||||
|
||||
/**
|
||||
* 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 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<>();
|
||||
|
||||
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
|
||||
@@ -389,6 +427,11 @@ public class SharedInput {
|
||||
public volatile float tempSpawnX = 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 ──────────────────────────────────────────────────
|
||||
public volatile float animPreviewRotY = 0f;
|
||||
public volatile float animPreviewRotX = 25f;
|
||||
@@ -437,8 +480,15 @@ public class SharedInput {
|
||||
public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest>
|
||||
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. */
|
||||
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 ──────────────────────────────────────────────────
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,9 @@ public class SceneObject extends PlacedObject {
|
||||
private float rotX; // X-Achsen-Rotation in Radiant
|
||||
private float rotZ; // Z-Achsen-Rotation in Radiant
|
||||
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 texturePath = "";
|
||||
public String normalMapPath = "";
|
||||
|
||||
@@ -173,6 +173,9 @@ public class AnimPreviewState extends BaseAppState {
|
||||
SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null);
|
||||
if (setReq != null) executeAnimSetSave(setReq);
|
||||
|
||||
SharedInput.AnimEmbedRequest embedReq = input.animEmbedRequest.getAndSet(null);
|
||||
if (embedReq != null) executeAnimEmbed(embedReq);
|
||||
|
||||
// Geschwindigkeit live anpassen
|
||||
if (currentAction != null) {
|
||||
try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {}
|
||||
@@ -389,7 +392,7 @@ public class AnimPreviewState extends BaseAppState {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Animation hinzufügen (Retargeting) ───────────────────────────────────
|
||||
// ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
|
||||
|
||||
private void addAnimation(String animAssetPath) {
|
||||
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 dstArm = targetSC != null ? targetSC.getArmature() : null;
|
||||
|
||||
// Diagnose: Knochen-Namen beider Skelette ausgeben
|
||||
if (srcArm != null) {
|
||||
System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):");
|
||||
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);
|
||||
}
|
||||
|
||||
// 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<>();
|
||||
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()) {
|
||||
String name = clip.getName();
|
||||
// Prüfen ob Name dem Muster "base.NNN" entspricht (Blender-Duplikat)
|
||||
if (name.matches(".*\\.\\d{3}$")) {
|
||||
String base = name.substring(0, name.length() - 4);
|
||||
if (srcNames.contains(base)) {
|
||||
@@ -461,20 +463,20 @@ public class AnimPreviewState extends BaseAppState {
|
||||
AnimClip result = retarget
|
||||
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
|
||||
: clip;
|
||||
if (result != null) {
|
||||
targetAC.addAnimClip(result);
|
||||
added++;
|
||||
}
|
||||
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);
|
||||
saved++;
|
||||
}
|
||||
// Clip-Liste neu aufbauen
|
||||
List<String> clips = new ArrayList<>();
|
||||
collectClips(currentModel, clips);
|
||||
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
||||
input.animPreviewStatus = added + " Clip(s) hinzugefügt"
|
||||
+ (retarget ? " (retargeted)" : " (direkt, kein Retargeting)");
|
||||
|
||||
// Modell mit neuem Clip persistieren, damit der Clip nach Editor-Neustart noch da ist
|
||||
if (added > 0) saveModel();
|
||||
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
|
||||
+ (retarget ? " (retargeted)" : " (direkt)");
|
||||
} catch (Exception e) {
|
||||
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
|
||||
}
|
||||
@@ -503,7 +505,6 @@ public class AnimPreviewState extends BaseAppState {
|
||||
collectClips(currentModel, clips);
|
||||
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
||||
input.animPreviewStatus = "Clip entfernt: " + clipName;
|
||||
saveModel();
|
||||
}
|
||||
|
||||
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());
|
||||
renamed.setTracks(src.getTracks());
|
||||
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 {
|
||||
com.jme3.scene.Node holder = new com.jme3.scene.Node("animExport");
|
||||
AnimComposer expAC = new AnimComposer();
|
||||
expAC.addAnimClip(renamed);
|
||||
holder.addControl(expAC);
|
||||
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
|
||||
SkinningControl sc = findControl(currentModel, SkinningControl.class);
|
||||
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
|
||||
saveClipToFile(renamed, sc != null ? sc.getArmature() : null,
|
||||
clipsDir.resolve(req.newName() + ".j3o"));
|
||||
java.util.List<String> clips = new java.util.ArrayList<>();
|
||||
collectClips(currentModel, clips);
|
||||
input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips));
|
||||
@@ -780,37 +776,106 @@ public class AnimPreviewState extends BaseAppState {
|
||||
// ── Animations-Set speichern ──────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
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.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();
|
||||
animSet.setClips(req.clips());
|
||||
animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>());
|
||||
animSet.save(setDir, req.setName());
|
||||
|
||||
input.animOpStatus = "Set '" + req.setName() + "' gespeichert (" + added + " Clips)";
|
||||
input.animOpStatus = "Animations-Set '" + req.setName() + "' gespeichert";
|
||||
} catch (Exception e) {
|
||||
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>
|
||||
buildMS(com.jme3.anim.Armature arm) {
|
||||
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache = new java.util.HashMap<>();
|
||||
|
||||
@@ -138,7 +138,7 @@ public class EzTreeState extends BaseAppState {
|
||||
});
|
||||
|
||||
if (!req.exportAfter()) {
|
||||
input.treeGenStatusMsg = "EZ-Tree Vorschau: '" + req.exportName() + "'";
|
||||
input.treeGenStatusMsg = "EZ-Tree Vorschau: " + resolveSubPath(req.presetName());
|
||||
} else {
|
||||
input.treeGenStatusMsg = "EZ-Tree: generiere…";
|
||||
}
|
||||
@@ -356,10 +356,14 @@ public class EzTreeState extends BaseAppState {
|
||||
Node treeNode = pendingTreeNode;
|
||||
cleanupCapture();
|
||||
|
||||
String exportName = req.exportName() + "_"
|
||||
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
String subPath = resolveSubPath(req.presetName());
|
||||
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);
|
||||
exportTree(treeNode, req.exportName(), req.treeCategory());
|
||||
exportTree(treeNode, exportName, subPath);
|
||||
|
||||
pendingRequest = null;
|
||||
pendingTreeNode = null;
|
||||
@@ -540,21 +544,40 @@ public class EzTreeState extends BaseAppState {
|
||||
}
|
||||
}
|
||||
|
||||
private void exportTree(Node treeNode, String name, String treeCategory) {
|
||||
private void exportTree(Node treeNode, String fileName, String subPath) {
|
||||
try {
|
||||
Path baseDir = (treeCategory != null && !treeCategory.isBlank())
|
||||
? ASSET_ROOT.resolve("trees").resolve(treeCategory)
|
||||
: ASSET_ROOT.resolve("Models");
|
||||
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
|
||||
Files.createDirectories(baseDir);
|
||||
File out = baseDir.resolve(name + ".j3o").toFile();
|
||||
File out = baseDir.resolve(fileName + ".j3o").toFile();
|
||||
BinaryExporter.getInstance().save(treeNode, out);
|
||||
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
|
||||
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
|
||||
input.refreshAssets = true;
|
||||
input.refreshTreeFolders = true;
|
||||
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + subPath + "/" + fileName + ".j3o";
|
||||
input.refreshAssets = true;
|
||||
input.refreshTreeFolders = true;
|
||||
} catch (IOException e) {
|
||||
log.error("[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,17 +89,14 @@ public class PalmGeneratorState extends BaseAppState {
|
||||
final Vector3f finalTarget = target;
|
||||
final Node finalPalm = palm;
|
||||
final PalmOptions finalOpts = req.options();
|
||||
final String finalName = req.exportName();
|
||||
final boolean doExport = req.exportAfter();
|
||||
|
||||
app.enqueue(() -> {
|
||||
previewHost.setPreviewContent(finalPalm, finalDist, finalTarget);
|
||||
if (doExport) exportPalm(finalPalm, finalName);
|
||||
if (doExport) exportPalm(finalPalm);
|
||||
});
|
||||
|
||||
input.treeGenStatusMsg = doExport
|
||||
? "Palme: exportiere…"
|
||||
: "Palme: Vorschau '" + req.exportName() + "'";
|
||||
input.treeGenStatusMsg = doExport ? "Palme: exportiere…" : "Palme: Vorschau";
|
||||
}
|
||||
|
||||
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 {
|
||||
Path modelDir = ASSET_ROOT.resolve("trees").resolve("palm");
|
||||
Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm");
|
||||
Files.createDirectories(modelDir);
|
||||
String stampedName = name + "_"
|
||||
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile();
|
||||
String fileName = "palm_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
File out = modelDir.resolve(fileName + ".j3o").toFile();
|
||||
BinaryExporter.getInstance().save(palmNode, out);
|
||||
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
|
||||
input.treeGenStatusMsg = "Palme exportiert: " + out.getName();
|
||||
input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o";
|
||||
input.refreshAssets = true;
|
||||
} catch (IOException e) {
|
||||
log.error("[Palme] Export-Fehler: {}", e.getMessage());
|
||||
|
||||
@@ -19,6 +19,8 @@ import com.jme3.scene.VertexBuffer;
|
||||
import com.jme3.scene.control.AbstractControl;
|
||||
import com.jme3.terrain.geomipmap.TerrainQuad;
|
||||
import com.jme3.util.BufferUtils;
|
||||
import de.blight.common.GrassTuft;
|
||||
import de.blight.common.GrassTuftIO;
|
||||
import de.blight.common.MapData;
|
||||
import de.blight.editor.SharedInput;
|
||||
|
||||
@@ -27,31 +29,27 @@ import java.nio.IntBuffer;
|
||||
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).
|
||||
* Rendering: Pro 128×128-WE-Chunk ein gebatchtes Kreuz-Quad-Mesh.
|
||||
* LOD: GrassVisibilityControl cullt Chunks jenseits FAR_DIST.
|
||||
* Wind: MatDefs/Grass.j3md (Vertex-Shader mit Sinus-Wind).
|
||||
* Jeder Büschel hat eine feste Weltposition (x, z), eine gebackene Höhe und einen Textur-Slot.
|
||||
* Die Y-Koordinate wird beim Chunk-Rebuild live aus dem Terrain abgelesen.
|
||||
*
|
||||
* Chunks: 128×128-WE-Kacheln, lazy rebuild, LOD-Culling via GrassVisibilityControl.
|
||||
*/
|
||||
public class PlacedObjectState extends BaseAppState {
|
||||
|
||||
// ── Terrain-Konstanten ────────────────────────────────────────────────────
|
||||
// ── Terrain ───────────────────────────────────────────────────────────────
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
private static final int CHUNK_SIZE = 128;
|
||||
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_SIZE = 128;
|
||||
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; // 1024
|
||||
|
||||
// ── Gras-Generierung ──────────────────────────────────────────────────────
|
||||
private static final int MAX_BLADES_PER_PIXEL = 3;
|
||||
private static final float BLADE_WIDTH_FACTOR = 0.18f;
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
private static final float BLADE_WIDTH = 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 ───────────────────────────────────────────────────────────────────
|
||||
private static final float GRASS_FAR_DIST = 400f;
|
||||
@@ -64,23 +62,49 @@ public class PlacedObjectState extends BaseAppState {
|
||||
private final SharedInput input;
|
||||
private Camera cam;
|
||||
private TerrainQuad terrain;
|
||||
private AssetManager assetManager;
|
||||
|
||||
private Node grassNode;
|
||||
private Material grassMat;
|
||||
private Node grassNode;
|
||||
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
|
||||
|
||||
private byte[] densityMap;
|
||||
|
||||
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
|
||||
private final Geometry[] chunkGeos = new Geometry[CHUNK_COUNT];
|
||||
@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];
|
||||
|
||||
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||
|
||||
public PlacedObjectState(SharedInput input, MapData loadedData) {
|
||||
this.input = input;
|
||||
this.densityMap = new byte[SPLAT_SIZE * SPLAT_SIZE];
|
||||
if (loadedData != null && loadedData.grassDensity != null) {
|
||||
System.arraycopy(loadedData.grassDensity, 0, densityMap, 0, densityMap.length);
|
||||
Arrays.fill(dirtyChunks, true);
|
||||
this.input = input;
|
||||
for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Gibt die aktuelle Dichte-Map zurück (für performSave). */
|
||||
public byte[] getDensityMap() { return densityMap; }
|
||||
// ── Getters für Save ──────────────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.cam = app.getCamera();
|
||||
grassNode = new Node("grassNode");
|
||||
this.cam = app.getCamera();
|
||||
this.assetManager = app.getAssetManager();
|
||||
grassNode = new Node("grassNode");
|
||||
((SimpleApplication) app).getRootNode().attachChild(grassNode);
|
||||
grassMat = buildGrassMaterial(app.getAssetManager());
|
||||
applyAllSlotMaterials();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -111,30 +154,63 @@ public class PlacedObjectState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (input.grassSettingsChanged || input.grassSlotsChanged) {
|
||||
input.grassSettingsChanged = false;
|
||||
input.grassSlotsChanged = false;
|
||||
applyAllSlotMaterials();
|
||||
}
|
||||
processGrassEdits();
|
||||
rebuildDirtyChunks();
|
||||
}
|
||||
|
||||
// ── Material ──────────────────────────────────────────────────────────────
|
||||
// ── Materialien ───────────────────────────────────────────────────────────
|
||||
|
||||
private Material buildGrassMaterial(AssetManager assets) {
|
||||
private Material getMaterialForSlot(int slot) {
|
||||
return slotMaterials.computeIfAbsent(slot, s -> buildFreshGrassMaterial());
|
||||
}
|
||||
|
||||
private Material buildFreshGrassMaterial() {
|
||||
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.setFloat("WindSpeed", 0.5f);
|
||||
mat.setFloat("WindStrength", 0.12f);
|
||||
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
|
||||
return mat;
|
||||
} catch (Exception e) {
|
||||
System.err.println("[PlacedObjectState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage());
|
||||
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f));
|
||||
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
|
||||
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() {
|
||||
SharedInput.GrassEdit edit;
|
||||
@@ -144,60 +220,58 @@ public class PlacedObjectState extends BaseAppState {
|
||||
float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY);
|
||||
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
|
||||
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();
|
||||
terrain.collideWith(ray, hits);
|
||||
if (hits.size() == 0) continue;
|
||||
Vector3f contact = hits.getClosestCollision().getContactPoint();
|
||||
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) {
|
||||
int centerPX = Math.round((cx + TERRAIN_HALF) / SPLAT_WE_PER_PX);
|
||||
int centerPZ = Math.round((cz + TERRAIN_HALF) / SPLAT_WE_PER_PX);
|
||||
int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX);
|
||||
float strength = (float) input.grassTool.density.getValue() / 10f; // 0.1–5.0
|
||||
|
||||
for (int dz = -pixR; dz <= pixR; dz++) {
|
||||
int pz = centerPZ + dz;
|
||||
if (pz < 0 || pz >= SPLAT_SIZE) continue;
|
||||
for (int dx = -pixR; dx <= pixR; dx++) {
|
||||
int px = centerPX + dx;
|
||||
if (px < 0 || px >= SPLAT_SIZE) continue;
|
||||
float distWE = FastMath.sqrt(dx * dx + dz * dz) * SPLAT_WE_PER_PX;
|
||||
if (distWE >= radius) continue;
|
||||
float t = distWE / radius;
|
||||
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);
|
||||
}
|
||||
private void paintGrass(float cx, float cz, float radius) {
|
||||
int n = Math.max(1, (int) input.grassTool.density.getValue());
|
||||
float baseH = (float) input.grassTool.grassHeight.getValue();
|
||||
int slot = input.grassActiveSlot;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void eraseGrass(float cx, float cz, float radius) {
|
||||
float rSq = radius * radius;
|
||||
for (int[] cc : overlappingChunks(cx, cz, radius)) {
|
||||
int ci = cc[0] + cc[1] * CHUNKS_PER_AXIS;
|
||||
boolean changed = chunkTufts[ci].removeIf(t -> {
|
||||
float dx = t.x() - cx, dz = t.z() - cz;
|
||||
return dx * dx + dz * dz <= rSq;
|
||||
});
|
||||
if (changed) dirtyChunks[ci] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
for (Vector2f loc : locs) {
|
||||
int cx = (int)((loc.x + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
int cz = (int)((loc.y + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) {
|
||||
int cx = (int) Math.floor((loc.x + 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)
|
||||
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,90 +289,76 @@ public class PlacedObjectState extends BaseAppState {
|
||||
|
||||
private void rebuildChunk(int idx) {
|
||||
if (terrain == null) return;
|
||||
int cx = idx % CHUNKS_PER_AXIS;
|
||||
int cz = idx / CHUNKS_PER_AXIS;
|
||||
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
|
||||
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
|
||||
|
||||
int cx = idx % CHUNKS_PER_AXIS;
|
||||
int cz = idx / CHUNKS_PER_AXIS;
|
||||
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
|
||||
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
|
||||
if (chunkNodes[idx] != null) {
|
||||
grassNode.detachChild(chunkNodes[idx]);
|
||||
chunkNodes[idx] = null;
|
||||
}
|
||||
if (chunkTufts[idx].isEmpty()) return;
|
||||
|
||||
// Dichte-Pixel-Bereich dieses Chunks
|
||||
int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
|
||||
int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
|
||||
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));
|
||||
|
||||
float baseH = (float) input.grassTool.grassHeight.getValue();
|
||||
|
||||
// Blatt-Positionen generieren
|
||||
List<float[]> blades = new ArrayList<>(); // [x, y, z, height]
|
||||
for (int pz = pzMin; pz <= pzMax; pz++) {
|
||||
for (int px = pxMin; px <= pxMax; px++) {
|
||||
int d = densityMap[pz * SPLAT_SIZE + px] & 0xFF;
|
||||
if (d == 0) continue;
|
||||
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PIXEL));
|
||||
Random rng = new Random((long) px * 100003L + pz);
|
||||
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;
|
||||
float th = terrain.getHeight(new Vector2f(bx, bz));
|
||||
if (Float.isNaN(th)) continue;
|
||||
float h = baseH * (0.7f + rng.nextFloat() * 0.6f);
|
||||
blades.add(new float[]{bx, th, bz, h});
|
||||
}
|
||||
Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
|
||||
for (GrassTuft t : chunkTufts[idx]) {
|
||||
long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
|
||||
^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL;
|
||||
Random rng = new Random(seed);
|
||||
List<float[]> blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>());
|
||||
for (int b = 0; b < BLADES_PER_TUFT; b++) {
|
||||
float ox = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
|
||||
float oz = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
|
||||
float bx = t.x() + ox;
|
||||
float bz = t.z() + oz;
|
||||
float th = terrain.getHeight(new Vector2f(bx, bz));
|
||||
if (Float.isNaN(th)) continue;
|
||||
float bh = t.height() * (0.7f + rng.nextFloat() * 0.6f);
|
||||
blades.add(new float[]{bx, th, bz, bh});
|
||||
}
|
||||
}
|
||||
|
||||
// Alte Geometrie entfernen
|
||||
if (chunkGeos[idx] != null) {
|
||||
grassNode.detachChild(chunkGeos[idx]);
|
||||
chunkGeos[idx] = null;
|
||||
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
|
||||
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
|
||||
Node node = new Node("grassChunk_" + idx);
|
||||
for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
|
||||
if (entry.getValue().isEmpty()) continue;
|
||||
Geometry geo = new Geometry("grassChunk_" + idx + "_s" + entry.getKey(),
|
||||
buildGrassMesh(entry.getValue()));
|
||||
geo.setMaterial(getMaterialForSlot(entry.getKey()));
|
||||
node.attachChild(geo);
|
||||
}
|
||||
if (blades.isEmpty()) return;
|
||||
|
||||
Mesh mesh = buildGrassMesh(blades);
|
||||
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
|
||||
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
|
||||
Geometry geo = new Geometry("grassChunk_" + idx, mesh);
|
||||
geo.setMaterial(grassMat);
|
||||
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
|
||||
grassNode.attachChild(geo);
|
||||
chunkGeos[idx] = 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) {
|
||||
int n = blades.size();
|
||||
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 8 * 3);
|
||||
FloatBuffer uv = BufferUtils.createFloatBuffer(n * 8 * 2);
|
||||
IntBuffer idx = BufferUtils.createIntBuffer(n * 12);
|
||||
|
||||
int vi = 0;
|
||||
for (float[] blade : blades) {
|
||||
float x = blade[0], y = blade[1], z = blade[2], h = blade[3];
|
||||
float w = Math.max(0.05f, h * BLADE_WIDTH_FACTOR);
|
||||
|
||||
// Quad A – Breite entlang X-Achse
|
||||
float w = Math.max(0.05f, h * BLADE_WIDTH);
|
||||
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+h).put(z); uv.put(1).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(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(0).put(1);
|
||||
|
||||
idx.put(vi ).put(vi+1).put(vi+2);
|
||||
idx.put(vi ).put(vi+2).put(vi+3);
|
||||
idx.put(vi).put(vi+1).put(vi+2);
|
||||
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+6).put(vi+7);
|
||||
vi += 8;
|
||||
}
|
||||
|
||||
Mesh mesh = new Mesh();
|
||||
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
|
||||
@@ -310,14 +370,12 @@ public class PlacedObjectState extends BaseAppState {
|
||||
// ── LOD-Control ───────────────────────────────────────────────────────────
|
||||
|
||||
private static final class GrassVisibilityControl extends AbstractControl {
|
||||
private final Camera cam;
|
||||
private final Camera cam;
|
||||
private final Vector3f center;
|
||||
|
||||
GrassVisibilityControl(Camera cam, Vector3f center) {
|
||||
this.cam = cam;
|
||||
this.center = center;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void controlUpdate(float tpf) {
|
||||
float distSq = cam.getLocation().distanceSquared(center);
|
||||
@@ -325,19 +383,30 @@ public class PlacedObjectState extends BaseAppState {
|
||||
? Spatial.CullHint.Always
|
||||
: Spatial.CullHint.Inherit);
|
||||
}
|
||||
|
||||
@Override protected void controlRender(RenderManager rm, ViewPort vp) {}
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
private void markChunkDirtyAtPixel(int px, int pz) {
|
||||
float worldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF;
|
||||
float worldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF;
|
||||
int cx = (int)((worldX + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
int cz = (int)((worldZ + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) {
|
||||
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
|
||||
private int chunkIndex(float wx, float wz) {
|
||||
int cx = (int) Math.floor((wx + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
int cz = (int) Math.floor((wz + TERRAIN_HALF) / CHUNK_SIZE);
|
||||
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
|
||||
return cx + cz * CHUNKS_PER_AXIS;
|
||||
}
|
||||
|
||||
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][]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,8 @@ public class SceneObjectState extends BaseAppState {
|
||||
so.getRotY(), so.getRotX(), so.getRotZ(),
|
||||
so.getScale(), so.solid,
|
||||
so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(),
|
||||
meshFile, animClips.get(i)));
|
||||
meshFile, animClips.get(i),
|
||||
so.castShadow, so.receiveShadow));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -165,6 +166,8 @@ public class SceneObjectState extends BaseAppState {
|
||||
so.setTexturePath(pm.texturePath());
|
||||
so.setNormalMapPath(pm.normalMapPath());
|
||||
so.setMaterialPath(pm.materialPath());
|
||||
so.castShadow = pm.castShadow();
|
||||
so.receiveShadow = pm.receiveShadow();
|
||||
objects.add(so);
|
||||
animClips.add(pm.animClip() != null ? pm.animClip() : "");
|
||||
|
||||
@@ -777,7 +780,8 @@ public class SceneObjectState extends BaseAppState {
|
||||
+ "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ()
|
||||
+ "|" + so.getScale() + "|" + so.getTexturePath()
|
||||
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
|
||||
+ "|" + animClips.get(idx);
|
||||
+ "|" + animClips.get(idx)
|
||||
+ "|" + so.castShadow + "|" + so.receiveShadow;
|
||||
} else {
|
||||
input.selectedObjectInfo = String.valueOf(n);
|
||||
}
|
||||
@@ -1220,7 +1224,9 @@ public class SceneObjectState extends BaseAppState {
|
||||
q.fromAngles(prop.rotX(), prop.rotY(), prop.rotZ());
|
||||
node.setLocalRotation(q);
|
||||
|
||||
so.solid = prop.solid();
|
||||
so.solid = prop.solid();
|
||||
so.castShadow = prop.castShadow();
|
||||
so.receiveShadow = prop.receiveShadow();
|
||||
|
||||
boolean appearanceChanged = false;
|
||||
if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; }
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.jme3.texture.Texture2D;
|
||||
import com.jme3.util.BufferUtils;
|
||||
import com.jme3.util.SkyFactory;
|
||||
import de.blight.common.EmitterIO;
|
||||
import de.blight.common.GrassTuftIO;
|
||||
import de.blight.common.LightIO;
|
||||
import de.blight.common.MusicAreaIO;
|
||||
import de.blight.common.SoundAreaIO;
|
||||
@@ -35,6 +36,8 @@ import de.blight.common.MapIO;
|
||||
import de.blight.common.PlacedModelIO;
|
||||
import de.blight.editor.SharedInput;
|
||||
import de.blight.editor.tool.HeightTool;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
@@ -52,6 +55,8 @@ import java.util.Properties;
|
||||
|
||||
public class TerrainEditorState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TerrainEditorState.class);
|
||||
|
||||
// ── Terrain-Konstanten ────────────────────────────────────────────────────
|
||||
private static final int TERRAIN_SIZE = 4096;
|
||||
private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097
|
||||
@@ -77,6 +82,7 @@ public class TerrainEditorState extends BaseAppState {
|
||||
private TerrainQuad terrain;
|
||||
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
|
||||
private Geometry brushIndicator;
|
||||
private Geometry livePlayerMarker;
|
||||
private PlacedObjectState placedObjectState;
|
||||
private SceneObjectState sceneObjState;
|
||||
private LightState lightState;
|
||||
@@ -84,6 +90,7 @@ public class TerrainEditorState extends BaseAppState {
|
||||
private WaterBodyState waterBodyState;
|
||||
private SoundAreaState soundAreaState;
|
||||
private MusicAreaState musicAreaState;
|
||||
private RiverEditorState riverEditorState;
|
||||
private MapData loadedMapData;
|
||||
private Node axesGizmo;
|
||||
|
||||
@@ -127,9 +134,9 @@ public class TerrainEditorState extends BaseAppState {
|
||||
if (MapIO.exists()) {
|
||||
try {
|
||||
loadedMapData = MapIO.load();
|
||||
System.out.println("[TerrainEditor] Karte geladen: " + MapIO.getMapPath());
|
||||
log.info("Karte geladen: {}", MapIO.getMapPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println("[TerrainEditor] Karte nicht ladbar: " + e.getMessage());
|
||||
log.error("Karte nicht ladbar", e);
|
||||
}
|
||||
}
|
||||
loadCameraPrefs();
|
||||
@@ -154,7 +161,7 @@ public class TerrainEditorState extends BaseAppState {
|
||||
camYaw = (float) Math.toRadians(parsePref(p, "cam.yaw", 0f));
|
||||
camPitch = (float) Math.toRadians(parsePref(p, "cam.pitch", (float) Math.toDegrees(DEFAULT_PITCH)));
|
||||
} 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 ────────────────────────────────────────────────────────
|
||||
|
||||
private void buildScene() {
|
||||
input.loadingStatus = "Lade Terrain...";
|
||||
terrain = buildTerrain();
|
||||
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
|
||||
rootNode.attachChild(terrain);
|
||||
|
||||
input.loadingStatus = "Lade platzierte Objekte...";
|
||||
placedObjectState = new PlacedObjectState(input, loadedMapData);
|
||||
placedObjectState.setTerrain(terrain);
|
||||
app.getStateManager().attach(placedObjectState);
|
||||
@@ -199,10 +208,11 @@ public class TerrainEditorState extends BaseAppState {
|
||||
var placed = PlacedModelIO.load();
|
||||
if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed);
|
||||
} 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);
|
||||
if (lightState != null) {
|
||||
lightState.setTerrain(terrain);
|
||||
@@ -210,10 +220,11 @@ public class TerrainEditorState extends BaseAppState {
|
||||
var lights = LightIO.load();
|
||||
if (!lights.isEmpty()) lightState.loadPlacedLights(lights);
|
||||
} 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);
|
||||
if (emitterState != null) {
|
||||
emitterState.setTerrain(terrain);
|
||||
@@ -221,21 +232,24 @@ public class TerrainEditorState extends BaseAppState {
|
||||
var emitters = EmitterIO.load();
|
||||
if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters);
|
||||
} 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);
|
||||
if (waterBodyState != null) {
|
||||
waterBodyState.setTerrain(terrain);
|
||||
waterBodyState.setHeightMap(cachedHeightMap);
|
||||
try {
|
||||
var waters = WaterBodyIO.load();
|
||||
if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters);
|
||||
} 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);
|
||||
if (soundAreaState != null) {
|
||||
soundAreaState.setTerrain(terrain);
|
||||
@@ -243,10 +257,11 @@ public class TerrainEditorState extends BaseAppState {
|
||||
var soundAreas = SoundAreaIO.load();
|
||||
if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas);
|
||||
} 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);
|
||||
if (musicAreaState != null) {
|
||||
musicAreaState.setTerrain(terrain);
|
||||
@@ -254,10 +269,17 @@ public class TerrainEditorState extends BaseAppState {
|
||||
var musicAreas = MusicAreaIO.load();
|
||||
if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas);
|
||||
} 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);
|
||||
if (playToolState != null) playToolState.setTerrain(terrain);
|
||||
|
||||
@@ -267,6 +289,9 @@ public class TerrainEditorState extends BaseAppState {
|
||||
brushIndicator = buildBrushIndicator();
|
||||
rootNode.attachChild(brushIndicator);
|
||||
|
||||
livePlayerMarker = buildLivePlayerMarker();
|
||||
rootNode.attachChild(livePlayerMarker);
|
||||
|
||||
axesGizmo = buildAxesGizmo();
|
||||
rootNode.attachChild(axesGizmo);
|
||||
}
|
||||
@@ -612,6 +637,7 @@ public class TerrainEditorState extends BaseAppState {
|
||||
processTextureEdits();
|
||||
updateBrushIndicator();
|
||||
updateAxesGizmo();
|
||||
updateLivePlayerMarker();
|
||||
|
||||
// Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
|
||||
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
|
||||
@@ -673,6 +699,120 @@ public class TerrainEditorState extends BaseAppState {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void performSave() {
|
||||
@@ -702,8 +842,13 @@ public class TerrainEditorState extends BaseAppState {
|
||||
}
|
||||
|
||||
if (placedObjectState != null) {
|
||||
System.arraycopy(placedObjectState.getDensityMap(), 0,
|
||||
data.grassDensity, 0, data.grassDensity.length);
|
||||
try {
|
||||
GrassTuftIO.save(new GrassTuftIO.GrassData(
|
||||
placedObjectState.getSlotPaths(),
|
||||
placedObjectState.getAllTufts()));
|
||||
} catch (IOException e) {
|
||||
log.error("Gras nicht speicherbar", e);
|
||||
}
|
||||
}
|
||||
|
||||
MapIO.save(data);
|
||||
@@ -719,6 +864,9 @@ public class TerrainEditorState extends BaseAppState {
|
||||
if (waterBodyState != null) {
|
||||
WaterBodyIO.save(waterBodyState.getPlacedBodies());
|
||||
}
|
||||
if (riverEditorState != null) {
|
||||
de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers());
|
||||
}
|
||||
if (soundAreaState != null) {
|
||||
SoundAreaIO.save(soundAreaState.getPlacedAreas());
|
||||
}
|
||||
@@ -726,10 +874,10 @@ public class TerrainEditorState extends BaseAppState {
|
||||
MusicAreaIO.save(musicAreaState.getPlacedAreas());
|
||||
}
|
||||
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
|
||||
System.out.println("[TerrainEditor] " + input.saveStatusMsg);
|
||||
log.info("{}", input.saveStatusMsg);
|
||||
} catch (IOException e) {
|
||||
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();
|
||||
int mode = input.heightTool.mode.getSelectedIndex();
|
||||
boolean terrainChanged = true;
|
||||
if (mode == HeightTool.MODE_SMOOTH) {
|
||||
smoothHeight(contact);
|
||||
} else if (mode == HeightTool.MODE_PLATEAU) {
|
||||
@@ -830,6 +979,7 @@ public class TerrainEditorState extends BaseAppState {
|
||||
input.heightTool.plateauHeight.setValue(h);
|
||||
input.heightTool.plateauHeightChanged = true;
|
||||
}
|
||||
terrainChanged = false;
|
||||
} else {
|
||||
// Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen
|
||||
flattenToPlateauHeight(contact);
|
||||
@@ -838,6 +988,10 @@ public class TerrainEditorState extends BaseAppState {
|
||||
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
|
||||
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();
|
||||
}
|
||||
@@ -1224,4 +1378,28 @@ public class TerrainEditorState extends BaseAppState {
|
||||
geo.setCullHint(Spatial.CullHint.Always);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,14 +292,11 @@ public class TreeGeneratorState extends BaseAppState {
|
||||
app.getRenderer().readFrameBuffer(captureFB, pixels);
|
||||
cleanupCapture();
|
||||
|
||||
String baseName = pendingRequest.exportName();
|
||||
String exportName = pendingRequest.exportAfter()
|
||||
? baseName + "_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now())
|
||||
: baseName;
|
||||
String treeType = pendingRequest.treeType();
|
||||
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
|
||||
|
||||
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,
|
||||
pendingBarkMat.clone(), pendingLeafMat.clone(), "prev");
|
||||
previewTreeHolder.detachAllChildren();
|
||||
@@ -311,10 +308,11 @@ public class TreeGeneratorState extends BaseAppState {
|
||||
Math.max(bb.getYExtent(), bb.getZExtent())) * 3f;
|
||||
|
||||
if (pendingRequest.exportAfter()) {
|
||||
float treeHeight = bb.getCenter().y + bb.getYExtent();
|
||||
Node treeNode = assembleLodNode(impostorTex);
|
||||
exportTree(treeNode, exportName);
|
||||
exportTree(treeNode, treeType, timestamp, treeHeight);
|
||||
} else {
|
||||
input.treeGenStatusMsg = "Vorschau: '" + baseName + "'";
|
||||
input.treeGenStatusMsg = "Vorschau: " + treeType;
|
||||
}
|
||||
|
||||
pendingRequest = null;
|
||||
@@ -328,7 +326,7 @@ public class TreeGeneratorState extends BaseAppState {
|
||||
// ── LOD-Aufbau ────────────────────────────────────────────────────────────
|
||||
|
||||
private Node assembleLodNode(Texture2D impostorTex) {
|
||||
Node root = new Node("GeneratedTree_" + pendingRequest.exportName());
|
||||
Node root = new Node(pendingRequest.treeType());
|
||||
root.attachChild(pendingHdNode);
|
||||
root.attachChild(pendingLdNode);
|
||||
|
||||
@@ -557,18 +555,19 @@ public class TreeGeneratorState extends BaseAppState {
|
||||
|
||||
// ── .j3o-Export ───────────────────────────────────────────────────────────
|
||||
|
||||
private void exportTree(Node treeNode, String name) {
|
||||
private void exportTree(Node treeNode, String treeType, String timestamp, float height) {
|
||||
try {
|
||||
Path modelDir = ASSET_ROOT.resolve("Models");
|
||||
Files.createDirectories(modelDir);
|
||||
File out = modelDir.resolve("GeneratedTree_" + name + ".j3o").toFile();
|
||||
// Strip runtime controls before export — they lack no-arg constructors
|
||||
// and cannot be deserialized by BinaryImporter.
|
||||
String sizeClass = height < 6f ? "small" : height < 14f ? "medium" : "large";
|
||||
String fileName = treeType + "_" + sizeClass + "_" + timestamp;
|
||||
Path dir = ASSET_ROOT.resolve("Models").resolve("trees")
|
||||
.resolve(treeType).resolve(sizeClass);
|
||||
Files.createDirectories(dir);
|
||||
File out = dir.resolve(fileName + ".j3o").toFile();
|
||||
while (treeNode.getNumControls() > 0)
|
||||
treeNode.removeControl(treeNode.getControl(0));
|
||||
BinaryExporter.getInstance().save(treeNode, out);
|
||||
log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath());
|
||||
input.treeGenStatusMsg = "Exportiert: " + out.getName();
|
||||
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o";
|
||||
input.refreshAssets = true;
|
||||
} catch (IOException e) {
|
||||
log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage());
|
||||
|
||||
@@ -10,8 +10,10 @@ import com.jme3.material.RenderState;
|
||||
import com.jme3.math.*;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.*;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Mesh;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.VertexBuffer;
|
||||
import com.jme3.terrain.geomipmap.TerrainQuad;
|
||||
import com.jme3.util.BufferUtils;
|
||||
import de.blight.common.PlacedWater;
|
||||
@@ -19,17 +21,25 @@ import de.blight.editor.SharedInput;
|
||||
|
||||
import java.nio.FloatBuffer;
|
||||
import java.nio.IntBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
private static final ColorRGBA WATER_COLOR = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.52f);
|
||||
private static final ColorRGBA BORDER_COLOR = new ColorRGBA(0.30f, 0.60f, 1.00f, 0.85f);
|
||||
private static final ColorRGBA BORDER_SEL = new ColorRGBA(1.00f, 1.00f, 0.00f, 1.00f);
|
||||
// Flood-Fill-Raster mit 1 WE Auflösung (= volle HeightMap-Auflösung)
|
||||
private static final int TOTAL_VERTS = 4097;
|
||||
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 String GEO_BORDER = "water_border";
|
||||
private static final ColorRGBA COLOR_WATER = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.50f);
|
||||
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(0.20f, 0.60f, 1.00f, 0.70f);
|
||||
|
||||
private final SharedInput input;
|
||||
private SimpleApplication app;
|
||||
@@ -37,17 +47,20 @@ public class WaterBodyState extends BaseAppState {
|
||||
private AssetManager assets;
|
||||
private Node rootNode;
|
||||
private TerrainQuad terrain;
|
||||
private float[] heightMap;
|
||||
|
||||
// parallel lists
|
||||
private final List<PlacedWater> bodies = new ArrayList<>();
|
||||
private final List<Node> markers = new ArrayList<>();
|
||||
private final List<PlacedWater> bodies = 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 List<PlacedWater> pendingBodies = null;
|
||||
private int selectedIdx = -1;
|
||||
private List<PlacedWater> pendingLoad = null;
|
||||
|
||||
public WaterBodyState(SharedInput input) {
|
||||
this.input = input;
|
||||
}
|
||||
public WaterBodyState(SharedInput input) { this.input = input; }
|
||||
|
||||
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
|
||||
public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; }
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,16 +76,11 @@ public class WaterBodyState extends BaseAppState {
|
||||
|
||||
@Override
|
||||
protected void onEnable() {
|
||||
if (pendingBodies != null) {
|
||||
loadPlacedBodies(pendingBodies);
|
||||
pendingBodies = null;
|
||||
}
|
||||
if (pendingLoad != null) { loadPlacedBodies(pendingLoad); pendingLoad = null; }
|
||||
}
|
||||
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
@@ -80,14 +88,10 @@ public class WaterBodyState extends BaseAppState {
|
||||
if (input.activeLayer != SharedInput.LAYER_WATER) return;
|
||||
|
||||
SharedInput.WaterClick click;
|
||||
while ((click = input.waterClickQueue.poll()) != null) {
|
||||
handleClick(click);
|
||||
}
|
||||
while ((click = input.waterClickQueue.poll()) != null) handleClick(click);
|
||||
|
||||
PlacedWater pending = input.pendingWater.getAndSet(null);
|
||||
if (pending != null && selectedIdx >= 0) {
|
||||
applyProperty(selectedIdx, pending);
|
||||
}
|
||||
if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight());
|
||||
|
||||
if (input.deleteWaterRequested) {
|
||||
input.deleteWaterRequested = false;
|
||||
@@ -95,57 +99,56 @@ public class WaterBodyState extends BaseAppState {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Click handling ────────────────────────────────────────────────────────
|
||||
// ── Click-Handling ────────────────────────────────────────────────────────
|
||||
|
||||
private void handleClick(SharedInput.WaterClick click) {
|
||||
float jmeX = click.screenX() * (float) input.viewportScaleX;
|
||||
float jmeY = cam.getHeight() - click.screenY() * (float) 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());
|
||||
|
||||
int hit = pickMarker(ray);
|
||||
if (hit >= 0) {
|
||||
if (click.rightButton()) deselect();
|
||||
else selectBody(hit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (click.rightButton()) { deselect(); return; }
|
||||
|
||||
int hit = pickBody(ray);
|
||||
if (hit >= 0) { selectBody(hit); return; }
|
||||
|
||||
if (terrain == null) return;
|
||||
CollisionResults hits = new CollisionResults();
|
||||
terrain.collideWith(ray, hits);
|
||||
if (hits.size() == 0) return;
|
||||
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);
|
||||
}
|
||||
|
||||
private int pickMarker(Ray ray) {
|
||||
for (int i = 0; i < markers.size(); i++) {
|
||||
private int pickBody(Ray ray) {
|
||||
for (int i = 0; i < geos.size(); i++) {
|
||||
CollisionResults res = new CollisionResults();
|
||||
markers.get(i).collideWith(ray, res);
|
||||
geos.get(i).collideWith(ray, res);
|
||||
if (res.size() > 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────────
|
||||
// ── Selektion ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void selectBody(int idx) {
|
||||
deselect();
|
||||
selectedIdx = idx;
|
||||
setBorderColor(idx, BORDER_SEL);
|
||||
geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
|
||||
publishSelection(idx);
|
||||
}
|
||||
|
||||
private void deselect() {
|
||||
if (selectedIdx >= 0 && selectedIdx < bodies.size()) {
|
||||
setBorderColor(selectedIdx, BORDER_COLOR);
|
||||
}
|
||||
if (selectedIdx >= 0 && selectedIdx < geos.size())
|
||||
geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
|
||||
selectedIdx = -1;
|
||||
input.selectedWaterInfo = null;
|
||||
input.waterSelectionChanged = true;
|
||||
@@ -154,120 +157,196 @@ public class WaterBodyState extends BaseAppState {
|
||||
private void publishSelection(int idx) {
|
||||
PlacedWater b = bodies.get(idx);
|
||||
input.selectedWaterInfo = String.format(java.util.Locale.ROOT,
|
||||
"%d|%.3f|%.3f|%.3f|%.3f|%.3f",
|
||||
idx, b.x(), b.y(), b.z(), b.width(), b.depth());
|
||||
"%d|%.3f|%.3f|%.3f|%d",
|
||||
idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size());
|
||||
input.waterSelectionChanged = true;
|
||||
}
|
||||
|
||||
// ── Add / Remove ──────────────────────────────────────────────────────────
|
||||
// ── Hinzufügen / Entfernen ────────────────────────────────────────────────
|
||||
|
||||
private void addBody(PlacedWater b) {
|
||||
Node marker = buildMarker(b);
|
||||
rootNode.attachChild(marker);
|
||||
markers.add(marker);
|
||||
bodies.add(b);
|
||||
private void addBody(PlacedWater body, Set<Integer> cells) {
|
||||
Geometry geo = buildWaterGeo(cells, body.waterHeight());
|
||||
rootNode.attachChild(geo);
|
||||
bodies.add(body);
|
||||
cellSets.add(cells);
|
||||
geos.add(geo);
|
||||
bodyBounds.add(computeBounds(cells));
|
||||
}
|
||||
|
||||
private void removeBody(int idx) {
|
||||
rootNode.detachChild(markers.get(idx));
|
||||
rootNode.detachChild(geos.get(idx));
|
||||
bodies.remove(idx);
|
||||
markers.remove(idx);
|
||||
cellSets.remove(idx);
|
||||
geos.remove(idx);
|
||||
bodyBounds.remove(idx);
|
||||
selectedIdx = -1;
|
||||
input.selectedWaterInfo = null;
|
||||
input.waterSelectionChanged = true;
|
||||
}
|
||||
|
||||
private void clearAll() {
|
||||
for (Node m : markers) rootNode.detachChild(m);
|
||||
for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g);
|
||||
bodies.clear();
|
||||
markers.clear();
|
||||
cellSets.clear();
|
||||
geos.clear();
|
||||
bodyBounds.clear();
|
||||
selectedIdx = -1;
|
||||
}
|
||||
|
||||
// ── Property application ──────────────────────────────────────────────────
|
||||
|
||||
private void applyProperty(int idx, PlacedWater updated) {
|
||||
rootNode.detachChild(markers.get(idx));
|
||||
Node newMarker = buildMarker(updated);
|
||||
setBorderColorOnNode(newMarker, BORDER_SEL);
|
||||
rootNode.attachChild(newMarker);
|
||||
markers.set(idx, newMarker);
|
||||
bodies.set(idx, updated);
|
||||
publishSelection(idx);
|
||||
}
|
||||
|
||||
// ── Marker visuals ────────────────────────────────────────────────────────
|
||||
|
||||
private Node buildMarker(PlacedWater b) {
|
||||
// Water surface (semi-transparent quad)
|
||||
Quad quad = new Quad(b.width(), b.depth());
|
||||
Geometry surface = new Geometry(GEO_SURFACE, quad);
|
||||
Material waterMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
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)
|
||||
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");
|
||||
node.attachChild(surface);
|
||||
node.attachChild(border);
|
||||
node.setLocalTranslation(b.x(), b.y(), b.z());
|
||||
return node;
|
||||
}
|
||||
|
||||
private static Mesh buildBorderMesh(float w, float d) {
|
||||
// 4 corner points at +0.02 above water surface (local coords, XZ plane)
|
||||
float hw = w * 0.5f, hd = d * 0.5f, y = 0.02f;
|
||||
FloatBuffer pos = BufferUtils.createFloatBuffer(4 * 3);
|
||||
pos.put(-hw).put(y).put(-hd);
|
||||
pos.put( hw).put(y).put(-hd);
|
||||
pos.put( hw).put(y).put( hd);
|
||||
pos.put(-hw).put(y).put( hd);
|
||||
IntBuffer idx = BufferUtils.createIntBuffer(8); // 4 edges
|
||||
idx.put(0).put(1).put(1).put(2).put(2).put(3).put(3).put(0);
|
||||
Mesh mesh = new Mesh();
|
||||
mesh.setMode(Mesh.Mode.Lines);
|
||||
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||
mesh.setBuffer(VertexBuffer.Type.Index, 2, idx);
|
||||
mesh.updateBound();
|
||||
return mesh;
|
||||
}
|
||||
|
||||
private void setBorderColor(int idx, ColorRGBA color) {
|
||||
setBorderColorOnNode(markers.get(idx), color);
|
||||
}
|
||||
|
||||
private static void setBorderColorOnNode(Node node, ColorRGBA color) {
|
||||
for (Spatial child : node.getChildren()) {
|
||||
if (child instanceof Geometry geo && GEO_BORDER.equals(geo.getName())) {
|
||||
geo.getMaterial().setColor("Color", color);
|
||||
return;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save / Load ───────────────────────────────────────────────────────────
|
||||
|
||||
public List<PlacedWater> getPlacedBodies() {
|
||||
return new ArrayList<>(bodies);
|
||||
private static float[] computeBounds(Set<Integer> cells) {
|
||||
float minX = Float.MAX_VALUE, minZ = Float.MAX_VALUE;
|
||||
float maxX = -Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
|
||||
for (int cell : cells) {
|
||||
float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
|
||||
float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
|
||||
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};
|
||||
}
|
||||
|
||||
public void loadPlacedBodies(List<PlacedWater> loaded) {
|
||||
if (rootNode == null) {
|
||||
pendingBodies = new ArrayList<>(loaded);
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
|
||||
if (sampleHeight(seedPX, seedPZ) > waterHeight + 0.05f) 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) <= 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) {
|
||||
if (heightMap != null) {
|
||||
int vx = Math.min(px * STEP, TOTAL_VERTS - 1);
|
||||
int vz = Math.min(pz * STEP, TOTAL_VERTS - 1);
|
||||
return heightMap[vz * TOTAL_VERTS + vx];
|
||||
}
|
||||
if (terrain != null) {
|
||||
float worldX = px * STEP - WORLD_HALF;
|
||||
float worldZ = pz * STEP - WORLD_HALF;
|
||||
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.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
|
||||
mesh.updateBound();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Speichern / Laden ─────────────────────────────────────────────────────
|
||||
|
||||
public List<PlacedWater> getPlacedBodies() { return new ArrayList<>(bodies); }
|
||||
|
||||
public void loadPlacedBodies(List<PlacedWater> loaded) {
|
||||
if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; }
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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"; }
|
||||
|
||||
|
||||
BIN
blight-editor/src/main/resources/icon_editor.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
blight-editor/src/main/resources/logo.png
Normal file
|
After Width: | Height: | Size: 1009 KiB |
@@ -29,6 +29,7 @@ dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.11.0'
|
||||
implementation 'org.slf4j:slf4j-api: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'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.38'
|
||||
}
|
||||
|
||||
@@ -1,96 +1,164 @@
|
||||
package de.blight.game;
|
||||
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.system.AppSettings;
|
||||
import de.blight.game.config.*;
|
||||
import org.slf4j.bridge.SLF4JBridgeHandler;
|
||||
import de.blight.game.scene.WorldScene;
|
||||
|
||||
public class BlightGame extends SimpleApplication {
|
||||
|
||||
private KeyBindings keyBindings;
|
||||
private GraphicsSettings graphicsSettings;
|
||||
private WorldScene worldScene;
|
||||
private ConfigScreen configScreen;
|
||||
private GraphicsScreen graphicsScreen;
|
||||
private PauseMenu pauseMenu;
|
||||
|
||||
public static void main(String[] args) {
|
||||
SLF4JBridgeHandler.removeHandlersForRootLogger();
|
||||
SLF4JBridgeHandler.install();
|
||||
|
||||
BlightGame app = new BlightGame();
|
||||
|
||||
GraphicsSettings gs = GraphicsStore.load();
|
||||
AppSettings settings = new AppSettings(true);
|
||||
settings.setTitle("Blight");
|
||||
settings.setResolution(gs.width, gs.height);
|
||||
settings.setFullscreen(gs.fullscreen);
|
||||
settings.setVSync(gs.vsync);
|
||||
settings.setSamples(gs.samples);
|
||||
|
||||
app.setSettings(settings);
|
||||
app.setShowSettings(false);
|
||||
app.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void simpleInitApp() {
|
||||
flyCam.setEnabled(false);
|
||||
inputManager.deleteMapping(INPUT_MAPPING_EXIT);
|
||||
|
||||
keyBindings = KeyBindingStore.load();
|
||||
graphicsSettings = GraphicsStore.load();
|
||||
|
||||
worldScene = new WorldScene(keyBindings);
|
||||
stateManager.attach(worldScene);
|
||||
|
||||
configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings));
|
||||
configScreen.setOnClose(() -> pauseMenu.setEnabled(true));
|
||||
stateManager.attach(configScreen);
|
||||
configScreen.setEnabled(false);
|
||||
|
||||
graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true));
|
||||
stateManager.attach(graphicsScreen);
|
||||
graphicsScreen.setEnabled(false);
|
||||
|
||||
pauseMenu = new PauseMenu(
|
||||
() -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); },
|
||||
() -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); }
|
||||
);
|
||||
stateManager.attach(pauseMenu);
|
||||
pauseMenu.setEnabled(false);
|
||||
|
||||
inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE));
|
||||
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
|
||||
if (graphicsScreen.isEnabled()) {
|
||||
// GraphicsScreen wird nur über seine eigenen Buttons geschlossen
|
||||
return;
|
||||
}
|
||||
if (configScreen.isEnabled()) {
|
||||
if (configScreen.isWaiting()) {
|
||||
configScreen.cancelWaiting();
|
||||
} else {
|
||||
configScreen.setEnabled(false);
|
||||
pauseMenu.setEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pauseMenu.isEnabled()) {
|
||||
pauseMenu.setEnabled(false);
|
||||
worldScene.setPaused(false);
|
||||
return;
|
||||
}
|
||||
pauseMenu.setEnabled(true);
|
||||
worldScene.setPaused(true);
|
||||
}, "ToggleMenu");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void simpleUpdate(float tpf) {}
|
||||
}
|
||||
package de.blight.game;
|
||||
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.ScreenshotAppState;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.system.AppSettings;
|
||||
import de.blight.game.config.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.bridge.SLF4JBridgeHandler;
|
||||
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 {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BlightGame.class);
|
||||
|
||||
private KeyBindings keyBindings;
|
||||
private GraphicsSettings graphicsSettings;
|
||||
private ScreenshotAppState screenshotState;
|
||||
private WorldScene worldScene;
|
||||
private ConfigScreen configScreen;
|
||||
private GraphicsScreen graphicsScreen;
|
||||
private PauseMenu pauseMenu;
|
||||
|
||||
private JWindow splashWindow;
|
||||
|
||||
public static void main(String[] args) {
|
||||
SLF4JBridgeHandler.removeHandlersForRootLogger();
|
||||
SLF4JBridgeHandler.install();
|
||||
|
||||
BlightGame app = new BlightGame();
|
||||
app.splashWindow = showSplash();
|
||||
|
||||
GraphicsSettings gs = GraphicsStore.load();
|
||||
AppSettings settings = new AppSettings(true);
|
||||
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.setFullscreen(gs.fullscreen);
|
||||
settings.setVSync(gs.vsync);
|
||||
settings.setSamples(gs.samples);
|
||||
|
||||
app.setSettings(settings);
|
||||
app.setShowSettings(false);
|
||||
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
|
||||
public void simpleInitApp() {
|
||||
if (splashWindow != null) {
|
||||
SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; });
|
||||
}
|
||||
|
||||
flyCam.setEnabled(false);
|
||||
inputManager.deleteMapping(INPUT_MAPPING_EXIT);
|
||||
|
||||
keyBindings = KeyBindingStore.load();
|
||||
graphicsSettings = GraphicsStore.load();
|
||||
|
||||
worldScene = new WorldScene(keyBindings);
|
||||
stateManager.attach(worldScene);
|
||||
|
||||
configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings));
|
||||
configScreen.setOnClose(() -> pauseMenu.setEnabled(true));
|
||||
stateManager.attach(configScreen);
|
||||
configScreen.setEnabled(false);
|
||||
|
||||
graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true));
|
||||
stateManager.attach(graphicsScreen);
|
||||
graphicsScreen.setEnabled(false);
|
||||
|
||||
pauseMenu = new PauseMenu(
|
||||
() -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); },
|
||||
() -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); }
|
||||
);
|
||||
stateManager.attach(pauseMenu);
|
||||
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.addListener((ActionListener) (name, isPressed, tpf) -> {
|
||||
if (!isPressed) return;
|
||||
|
||||
if (graphicsScreen.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (configScreen.isEnabled()) {
|
||||
if (configScreen.isWaiting()) {
|
||||
configScreen.cancelWaiting();
|
||||
} else {
|
||||
configScreen.setEnabled(false);
|
||||
pauseMenu.setEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pauseMenu.isEnabled()) {
|
||||
pauseMenu.setEnabled(false);
|
||||
worldScene.setPaused(false);
|
||||
return;
|
||||
}
|
||||
pauseMenu.setEnabled(true);
|
||||
worldScene.setPaused(true);
|
||||
}, "ToggleMenu");
|
||||
}
|
||||
|
||||
@Override
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
29
blight-game/src/main/java/de/blight/game/LiveBroadcast.java
Normal 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) {}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import java.util.Map;
|
||||
* Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die
|
||||
* Zuordnung semantischer Aktionen (IDLE, WALK, …) zu Clip-Namen.
|
||||
*
|
||||
* Wird als {@code <setName>.animset.json} neben der {@code .j3o}-Datei gespeichert
|
||||
* und ersetzt sowohl die alte {@code .clips.json} als auch die {@code .animmap}-Datei.
|
||||
* Wird als {@code animations/sets/<setName>.animset.json} gespeichert.
|
||||
* Die Clip-Dateien liegen als eigenständige .j3o in {@code animations/clips/}.
|
||||
*/
|
||||
public class AnimSet {
|
||||
|
||||
@@ -44,26 +44,4 @@ public class AnimSet {
|
||||
if (!Files.exists(f)) return new AnimSet();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,27 @@ package de.blight.game.animation;
|
||||
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
|
||||
*/
|
||||
public enum AnimationAction {
|
||||
IDLE,
|
||||
DEFAULT,
|
||||
IDLE,
|
||||
WALK,
|
||||
RUN,
|
||||
SPRINT,
|
||||
JUMP,
|
||||
RUNNING_JUMP,
|
||||
DUCK;
|
||||
|
||||
|
||||
/** Lesbare Bezeichnung für UI-Anzeige. */
|
||||
public String displayName() {
|
||||
return switch (this) {
|
||||
case IDLE -> "Idle";
|
||||
case DEFAULT -> "Default";
|
||||
case IDLE -> "Idle";
|
||||
case WALK -> "Walk";
|
||||
case RUN -> "Run";
|
||||
case JUMP -> "Jump";
|
||||
case DUCK -> "Duck";
|
||||
case SPRINT -> "Sprint";
|
||||
case JUMP -> "Jump";
|
||||
case RUNNING_JUMP -> "Running Jump";
|
||||
case DUCK -> "Duck";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Loads all .j3o animation files from the animations/ asset folder at startup.
|
||||
* Provides retargeted animation clips for any model with a SkinningControl.
|
||||
*
|
||||
* Clip keys follow the pattern "filename/clipname" (e.g. "walk/Run").
|
||||
* Lädt alle Clip-Dateien aus {@code animations/clips/} beim Start.
|
||||
* Clip-Schlüssel entsprechen dem Dateinamen ohne Extension (= Clip-Name).
|
||||
*/
|
||||
public class AnimationLibrary extends BaseAppState {
|
||||
|
||||
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 = {
|
||||
"blight-assets/src/main/resources",
|
||||
"assets",
|
||||
@@ -35,9 +31,9 @@ public class AnimationLibrary extends BaseAppState {
|
||||
|
||||
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<>();
|
||||
/** clip key → armature the clip was loaded from */
|
||||
/** clip name → Armatur der Quell-Datei */
|
||||
private final Map<String, Armature> armatures = new LinkedHashMap<>();
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
@@ -45,6 +41,15 @@ public class AnimationLibrary extends BaseAppState {
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -54,51 +59,60 @@ public class AnimationLibrary extends BaseAppState {
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** All loaded clip keys (filename/clipname). */
|
||||
/** Alle geladenen Clip-Namen. */
|
||||
public Collection<String> getClipKeys() {
|
||||
return Collections.unmodifiableSet(clips.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retargets the clip to {@code model}'s skeleton and registers it
|
||||
* in the model's AnimComposer (idempotent).
|
||||
* Retargeted den Clip auf das Skeleton von {@code model} und registriert
|
||||
* 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) {
|
||||
AnimClip src = clips.get(clipKey);
|
||||
Armature srcArm = armatures.get(clipKey);
|
||||
if (src == null) return false;
|
||||
public boolean applyTo(String clipName, Spatial model) {
|
||||
AnimClip src = clips.get(clipName);
|
||||
Armature srcArm = armatures.get(clipName);
|
||||
if (src == null) {
|
||||
log.warn("[AnimLib] applyTo: Clip '{}' nicht in Bibliothek (verfügbar: {})", clipName, clips.keySet());
|
||||
return false;
|
||||
}
|
||||
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(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(shortName) != null) return true; // already present
|
||||
if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden
|
||||
|
||||
AnimClip target;
|
||||
if (srcArm != null && srcArm != sc.getArmature()) {
|
||||
// Pre-baked animations (Blender retargeting) have identical bone names →
|
||||
// copy directly without retargeting. Different skeleton → retarget.
|
||||
if (haveSameBoneNames(srcArm, sc.getArmature())) {
|
||||
target = src;
|
||||
} else {
|
||||
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
|
||||
}
|
||||
// Immer retarget() aufrufen – auch bei gleichen Knochen-Namen.
|
||||
// retarget() nutzt intern den "redirect"-Schnellpfad für gleiche Rigs,
|
||||
// erstellt aber korrekte TransformTrack-Referenzen auf die Ziel-Armatur.
|
||||
// Direkte Nutzung des Quell-Clips würde Transforms auf die falschen
|
||||
// (entkoppelten) Joint-Objekte der Quell-j3o anwenden.
|
||||
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
|
||||
} else {
|
||||
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);
|
||||
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
|
||||
public void applyAllTo(Spatial model) {
|
||||
if (RetargetingSystem.findSkinningControl(model) == null) return;
|
||||
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) {
|
||||
if (!applyTo(clipKey, model)) return false;
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
|
||||
if (ac == null) return false;
|
||||
ac.setCurrentAction(shortName(clipKey));
|
||||
public boolean ensureApplied(String clipName, Spatial model) {
|
||||
if (!applyTo(clipName, model)) return false;
|
||||
enableSkinningControls(model);
|
||||
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.
|
||||
*
|
||||
* @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 action semantische Aktion, z. B. {@code AnimationAction.IDLE}
|
||||
* @return Clip-Name oder {@code null} wenn keine Zuweisung existiert
|
||||
* @return true bei Erfolg
|
||||
*/
|
||||
public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) {
|
||||
AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath);
|
||||
return set.getActionMap().get(action.name());
|
||||
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 setName Name des Animations-Sets (ohne Pfad/Extension, z. B. {@code "human"})
|
||||
* @param action semantische Aktion
|
||||
* @return Clip-Name oder {@code null}
|
||||
*/
|
||||
public static String getClipForAction(Path assetRoot, String setName, AnimationAction action) {
|
||||
Path setDir = assetRoot.resolve("animations").resolve("sets");
|
||||
try {
|
||||
AnimSet set = AnimSet.load(setDir, setName);
|
||||
return set.getActionMap().get(action.name());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void loadAll() {
|
||||
Path animDir = findAnimDir();
|
||||
if (animDir == null) {
|
||||
log.info("[AnimLib] Kein Animations-Verzeichnis gefunden – Bibliothek leer.");
|
||||
Path clipsDir = findClipsDir();
|
||||
log.info("[AnimLib] Asset-Root: {}", findAssetRoot().toAbsolutePath());
|
||||
if (clipsDir == null) {
|
||||
log.warn("[AnimLib] Kein clips-Verzeichnis gefunden – Bibliothek leer.");
|
||||
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"))
|
||||
.forEach(this::loadFromFile);
|
||||
.forEach(this::loadClipFromFile);
|
||||
} catch (IOException e) {
|
||||
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) {
|
||||
Path animDir = findAnimDir();
|
||||
if (animDir == null) return;
|
||||
String relPath = animDir.relativize(file).toString().replace('\\', '/');
|
||||
String assetKey = "animations/" + relPath;
|
||||
String fileBase = relPath.replaceFirst("\\.j3o$", "");
|
||||
private void loadClipFromFile(Path file) {
|
||||
String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
|
||||
String assetKey = "animations/clips/" + clipName + ".j3o";
|
||||
|
||||
try {
|
||||
Spatial loaded = assetManager.loadModel(assetKey);
|
||||
Spatial loaded = assetManager.loadModel(assetKey);
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(loaded);
|
||||
SkinningControl sc = RetargetingSystem.findSkinningControl(loaded);
|
||||
if (ac == null) {
|
||||
log.debug("[AnimLib] Kein AnimComposer in {}", assetKey);
|
||||
log.warn("[AnimLib] Kein AnimComposer in {} – übersprungen", assetKey);
|
||||
return;
|
||||
}
|
||||
Armature armature = sc != null ? sc.getArmature() : null;
|
||||
|
||||
for (String clipName : ac.getAnimClipsNames()) {
|
||||
String key = fileBase + "/" + clipName;
|
||||
clips.put(key, ac.getAnimClip(clipName));
|
||||
if (armature != null) armatures.put(key, armature);
|
||||
log.info("[AnimLib] Clip: {}", key);
|
||||
for (String name : ac.getAnimClipsNames()) {
|
||||
clips.put(name, ac.getAnimClip(name));
|
||||
if (armature != null) armatures.put(name, armature);
|
||||
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class KeyBindings {
|
||||
public int right = KeyInput.KEY_D;
|
||||
public int jump = KeyInput.KEY_SPACE;
|
||||
public int sprint = KeyInput.KEY_LSHIFT;
|
||||
public int walk = KeyInput.KEY_LMENU;
|
||||
|
||||
/** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */
|
||||
public static final String[][] ENTRIES = {
|
||||
@@ -20,6 +21,7 @@ public class KeyBindings {
|
||||
{"right", "Rechts"},
|
||||
{"jump", "Springen"},
|
||||
{"sprint", "Rennen"},
|
||||
{"walk", "Gehen"},
|
||||
};
|
||||
|
||||
public int get(String fieldName) {
|
||||
|
||||
@@ -1,117 +1,203 @@
|
||||
package de.blight.game.control;
|
||||
|
||||
import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.input.InputManager;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.math.FastMath;
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
import com.jme3.scene.Spatial;
|
||||
import de.blight.game.config.KeyBindings;
|
||||
|
||||
public class PlayerInputControl {
|
||||
|
||||
private static final float MOVE_SPEED = 0.07f;
|
||||
private static final float SPRINT_MULT = 1.5f;
|
||||
private static final float ROTATE_SPEED = 10f;
|
||||
|
||||
private static final String[] ACTION_NAMES =
|
||||
{"Forward", "Backward", "Left", "Right", "Jump", "Sprint"};
|
||||
|
||||
private final InputManager inputManager;
|
||||
private final Camera cam;
|
||||
|
||||
private CharacterControl physicsChar;
|
||||
private Spatial visual;
|
||||
|
||||
private boolean forward, backward, left, right, sprint;
|
||||
private boolean paused = false;
|
||||
|
||||
// Listener als Feld, damit er bei reload nicht doppelt registriert wird
|
||||
private final ActionListener actionListener = (name, isPressed, tpf) -> {
|
||||
if (paused) return;
|
||||
switch (name) {
|
||||
case "Forward" -> forward = isPressed;
|
||||
case "Backward" -> backward = isPressed;
|
||||
case "Left" -> left = isPressed;
|
||||
case "Right" -> right = isPressed;
|
||||
case "Sprint" -> sprint = isPressed;
|
||||
case "Jump" -> { if (isPressed && physicsChar != null) physicsChar.jump(); }
|
||||
}
|
||||
};
|
||||
|
||||
public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) {
|
||||
this.inputManager = inputManager;
|
||||
this.cam = cam;
|
||||
registerMappings(kb);
|
||||
}
|
||||
|
||||
public void setPhysicsCharacter(CharacterControl physicsChar) {
|
||||
this.physicsChar = physicsChar;
|
||||
}
|
||||
|
||||
public void setVisual(Spatial visual) {
|
||||
this.visual = visual;
|
||||
}
|
||||
|
||||
public void setPaused(boolean paused) {
|
||||
this.paused = paused;
|
||||
if (paused) {
|
||||
forward = backward = left = right = sprint = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
/** Löscht alte Mappings und registriert neue aus den übergebenen KeyBindings. */
|
||||
public void reloadBindings(KeyBindings kb) {
|
||||
for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
|
||||
registerMappings(kb);
|
||||
// Zustand zurücksetzen, damit keine Taste „hängt"
|
||||
forward = backward = left = right = sprint = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
}
|
||||
|
||||
private void registerMappings(KeyBindings kb) {
|
||||
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
|
||||
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
|
||||
inputManager.addMapping("Left", new KeyTrigger(kb.left));
|
||||
inputManager.addMapping("Right", new KeyTrigger(kb.right));
|
||||
inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
|
||||
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
|
||||
inputManager.addListener(actionListener, ACTION_NAMES);
|
||||
}
|
||||
|
||||
public void update(float tpf) {
|
||||
if (physicsChar == null || paused) return;
|
||||
|
||||
Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal();
|
||||
Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal();
|
||||
|
||||
Vector3f moveDir = new Vector3f();
|
||||
if (forward) moveDir.addLocal(camDir);
|
||||
if (backward) moveDir.subtractLocal(camDir);
|
||||
if (left) moveDir.addLocal(camLeft);
|
||||
if (right) moveDir.subtractLocal(camLeft);
|
||||
|
||||
if (moveDir.lengthSquared() > 0.001f) {
|
||||
moveDir.normalizeLocal();
|
||||
float speed = sprint ? MOVE_SPEED * SPRINT_MULT : MOVE_SPEED;
|
||||
physicsChar.setWalkDirection(moveDir.mult(speed));
|
||||
|
||||
if (visual != null) {
|
||||
Quaternion targetRot = new Quaternion();
|
||||
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();
|
||||
current.slerp(targetRot, ROTATE_SPEED * tpf);
|
||||
visual.setLocalRotation(current);
|
||||
}
|
||||
} else {
|
||||
physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
package de.blight.game.control;
|
||||
|
||||
import com.jme3.anim.AnimComposer;
|
||||
import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.input.InputManager;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.math.FastMath;
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
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 java.nio.file.Path;
|
||||
|
||||
public class PlayerInputControl {
|
||||
|
||||
private static final float MOVE_SPEED = 0.07f;
|
||||
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 String[] ACTION_NAMES =
|
||||
{"Forward", "Backward", "Left", "Right", "Jump", "Sprint", "Walk"};
|
||||
|
||||
private final InputManager inputManager;
|
||||
private final Camera cam;
|
||||
|
||||
private CharacterControl physicsChar;
|
||||
private Spatial visual;
|
||||
|
||||
private boolean forward, backward, left, right, sprint, walk;
|
||||
private boolean paused = false;
|
||||
|
||||
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) -> {
|
||||
if (paused) return;
|
||||
switch (name) {
|
||||
case "Forward" -> forward = isPressed;
|
||||
case "Backward" -> backward = isPressed;
|
||||
case "Left" -> left = isPressed;
|
||||
case "Right" -> right = isPressed;
|
||||
case "Sprint" -> sprint = isPressed;
|
||||
case "Walk" -> walk = isPressed;
|
||||
case "Jump" -> { if (isPressed && physicsChar != null) { physicsChar.jump(); jumpFrames = 12; } }
|
||||
}
|
||||
};
|
||||
|
||||
public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) {
|
||||
this.inputManager = inputManager;
|
||||
this.cam = cam;
|
||||
registerMappings(kb);
|
||||
}
|
||||
|
||||
public void setPhysicsCharacter(CharacterControl physicsChar) {
|
||||
this.physicsChar = physicsChar;
|
||||
}
|
||||
|
||||
public void setVisual(Spatial 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) {
|
||||
this.paused = paused;
|
||||
if (paused) {
|
||||
forward = backward = left = right = sprint = walk = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
public void reloadBindings(KeyBindings kb) {
|
||||
for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
|
||||
registerMappings(kb);
|
||||
forward = backward = left = right = sprint = walk = false;
|
||||
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
|
||||
}
|
||||
|
||||
private void registerMappings(KeyBindings kb) {
|
||||
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
|
||||
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
|
||||
inputManager.addMapping("Left", new KeyTrigger(kb.left));
|
||||
inputManager.addMapping("Right", new KeyTrigger(kb.right));
|
||||
inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
|
||||
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
|
||||
inputManager.addMapping("Walk", new KeyTrigger(kb.walk));
|
||||
inputManager.addListener(actionListener, ACTION_NAMES);
|
||||
}
|
||||
|
||||
public void update(float tpf) {
|
||||
if (physicsChar == null || paused) return;
|
||||
|
||||
Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal();
|
||||
Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal();
|
||||
|
||||
Vector3f moveDir = new Vector3f();
|
||||
if (forward) moveDir.addLocal(camDir);
|
||||
if (backward) moveDir.subtractLocal(camDir);
|
||||
if (left) moveDir.addLocal(camLeft);
|
||||
if (right) moveDir.subtractLocal(camLeft);
|
||||
|
||||
boolean moving = moveDir.lengthSquared() > 0.001f;
|
||||
|
||||
if (moving) {
|
||||
moveDir.normalizeLocal();
|
||||
float speed = walk ? MOVE_SPEED * WALK_MULT
|
||||
: sprint ? MOVE_SPEED * SPRINT_MULT
|
||||
: MOVE_SPEED;
|
||||
physicsChar.setWalkDirection(moveDir.mult(speed));
|
||||
|
||||
if (visual != null) {
|
||||
Quaternion targetRot = new Quaternion();
|
||||
targetRot.lookAt(moveDir, Vector3f.UNIT_Y);
|
||||
Quaternion current = visual.getLocalRotation().clone();
|
||||
current.slerp(targetRot, ROTATE_SPEED * tpf);
|
||||
visual.setLocalRotation(current);
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ import com.jme3.scene.Spatial;
|
||||
public class ThirdPersonCamera {
|
||||
|
||||
private static final float MOUSE_SENSITIVITY = 1.8f;
|
||||
private static final float MIN_DISTANCE = 3f;
|
||||
private static final float MAX_DISTANCE = 20f;
|
||||
private static final float MIN_VERTICAL_ANGLE = -0.3f;
|
||||
private static final float BASE_DISTANCE = 5f;
|
||||
private static final float MIN_DISTANCE = BASE_DISTANCE - 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 TARGET_HEIGHT = 1.6f;
|
||||
|
||||
@@ -30,7 +31,7 @@ public class ThirdPersonCamera {
|
||||
|
||||
private float yaw = 0f;
|
||||
private float pitch = 0.4f;
|
||||
private float distance = 10f;
|
||||
private float distance = BASE_DISTANCE;
|
||||
private boolean paused = false;
|
||||
|
||||
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 "MouseYNeg" -> pitch = FastMath.clamp(pitch + value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE);
|
||||
// Zoom
|
||||
case "ZoomIn" -> distance = FastMath.clamp(distance - value * 20f, MIN_DISTANCE, MAX_DISTANCE);
|
||||
case "ZoomOut" -> 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 * 0.5f, MIN_DISTANCE, MAX_DISTANCE);
|
||||
}
|
||||
};
|
||||
inputManager.addListener(analogListener,
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.bullet.BulletAppState;
|
||||
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.RigidBodyControl;
|
||||
import com.jme3.bullet.util.CollisionShapeFactory;
|
||||
@@ -26,27 +25,47 @@ import com.jme3.util.SkyFactory;
|
||||
import java.nio.ByteBuffer;
|
||||
import de.blight.common.MapData;
|
||||
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.control.PlayerInputControl;
|
||||
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.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.nio.FloatBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class WorldScene extends BaseAppState {
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node rootNode;
|
||||
private AssetManager assetManager;
|
||||
private BulletAppState bulletAppState;
|
||||
private MapData loadedMapData;
|
||||
private SimpleApplication app;
|
||||
private Node rootNode;
|
||||
private AssetManager assetManager;
|
||||
private BulletAppState bulletAppState;
|
||||
private MapData loadedMapData;
|
||||
private FilterPostProcessor sharedFPP;
|
||||
|
||||
private final KeyBindings keyBindings;
|
||||
private ThirdPersonCamera thirdPersonCam;
|
||||
private PlayerInputControl playerInput;
|
||||
private float spawnY = 5f; // wird in buildTerrain() gesetzt
|
||||
private final KeyBindings keyBindings;
|
||||
private ThirdPersonCamera thirdPersonCam;
|
||||
private PlayerInputControl playerInput;
|
||||
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) {
|
||||
this.keyBindings = keyBindings;
|
||||
@@ -74,6 +93,9 @@ public class WorldScene extends BaseAppState {
|
||||
|
||||
bulletAppState = new BulletAppState();
|
||||
app.getStateManager().attach(bulletAppState);
|
||||
|
||||
animLib = new AnimationLibrary();
|
||||
app.getStateManager().attach(animLib);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -81,27 +103,27 @@ public class WorldScene extends BaseAppState {
|
||||
buildLighting();
|
||||
TerrainQuad terrain = buildTerrain();
|
||||
|
||||
if (loadedMapData != null) {
|
||||
rootNode.attachChild(buildGebirge(loadedMapData));
|
||||
app.getStateManager().attach(new GrassState(loadedMapData, terrain));
|
||||
}
|
||||
app.getStateManager().attach(new GrassState(terrain));
|
||||
app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP));
|
||||
app.getStateManager().attach(new RiverState());
|
||||
app.getStateManager().attach(new WorldObjectsState());
|
||||
|
||||
Node character = buildCharacter();
|
||||
character = loadOrBuildCharacter();
|
||||
rootNode.attachChild(character);
|
||||
|
||||
// Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (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.setFallSpeed(35f);
|
||||
physicsChar.setGravity(35f);
|
||||
physicsChar.setPhysicsLocation(new Vector3f(0, spawnY, 0));
|
||||
character.addControl(physicsChar);
|
||||
bulletAppState.getPhysicsSpace().add(physicsChar);
|
||||
physicsChar.setPhysicsLocation(new Vector3f(spawnX, spawnY, spawnZ));
|
||||
|
||||
playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings);
|
||||
playerInput.setPhysicsCharacter(physicsChar);
|
||||
playerInput.setVisual(character);
|
||||
playerInput.setVisual(characterVisual != null ? characterVisual : character);
|
||||
|
||||
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
|
||||
thirdPersonCam.setTarget(character);
|
||||
@@ -110,15 +132,110 @@ public class WorldScene extends BaseAppState {
|
||||
app.getInputManager().setCursorVisible(false);
|
||||
}
|
||||
|
||||
private float livePosTimer = 0f;
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (!animContextReady && animLib != null && animLib.isInitialized()) {
|
||||
setupAnimationContext();
|
||||
animContextReady = true;
|
||||
}
|
||||
playerInput.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() {}
|
||||
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -145,6 +262,42 @@ public class WorldScene extends BaseAppState {
|
||||
SkyFactory.EnvMapType.CubeMap);
|
||||
rootNode.attachChild(sky);
|
||||
} 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
|
||||
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
|
||||
for (float h : heights) { if (h < minH) minH = h; if (h > maxH) maxH = h; }
|
||||
float midH = (minH + maxH) * 0.5f;
|
||||
float maxUpperTop = maxH;
|
||||
for (float h : map.upperTop) { if (h > maxUpperTop) maxUpperTop = h; }
|
||||
spawnY = maxUpperTop + 20f;
|
||||
// Temp-Spawn aus Editor-Property überschreibt gespeicherten Karten-Spawn
|
||||
String propX = System.getProperty("blight.temp.spawn.x");
|
||||
String propZ = System.getProperty("blight.temp.spawn.z");
|
||||
spawnX = propX != null ? Float.parseFloat(propX) : map.spawnX;
|
||||
spawnZ = propZ != null ? Float.parseFloat(propZ) : map.spawnZ;
|
||||
System.out.println("[WorldScene] SpawnXZ Quelle: " + (propX != null ? "Editor-Property" : "Karte")
|
||||
+ " → X=" + spawnX + " Z=" + spawnZ);
|
||||
|
||||
TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights);
|
||||
terrain.setLocalScale(8f, 1f, 8f);
|
||||
@@ -202,17 +355,22 @@ public class WorldScene extends BaseAppState {
|
||||
applyTerrainMaterial(terrain, map);
|
||||
rootNode.attachChild(terrain);
|
||||
|
||||
// jBullet subtrahiert midH intern in getVertex() → Physics-Body bei midH
|
||||
// damit Kollisionsfläche und sichtbares Terrain übereinstimmen.
|
||||
HeightfieldCollisionShape hcs = new HeightfieldCollisionShape(
|
||||
heights, terrain.getLocalScale());
|
||||
RigidBodyControl terrainPhysics = new RigidBodyControl(hcs, 0f);
|
||||
// Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ
|
||||
float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f));
|
||||
if (Float.isNaN(terrainH)) {
|
||||
float maxH = -Float.MAX_VALUE;
|
||||
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);
|
||||
bulletAppState.getPhysicsSpace().add(terrainPhysics);
|
||||
terrainPhysics.setPhysicsLocation(new Vector3f(0f, midH, 0f));
|
||||
|
||||
System.out.println("[WorldScene] Karte geladen, Spawn Y=" + spawnY
|
||||
+ " maxGebirgeH=" + maxUpperTop);
|
||||
System.out.println("[WorldScene] Karte geladen, SpawnXYZ=("
|
||||
+ spawnX + ", " + spawnY + ", " + spawnZ + ")");
|
||||
return terrain;
|
||||
}
|
||||
|
||||
@@ -389,25 +547,40 @@ public class WorldScene extends BaseAppState {
|
||||
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) {
|
||||
if (map != null) {
|
||||
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",
|
||||
new ColorRGBA(0.28f, 0.58f, 0.18f, 1f));
|
||||
Texture tex2 = loadTexOrFallback("Textures/Terrain/splat/road.jpg",
|
||||
new ColorRGBA(0.55f, 0.50f, 0.40f, 1f));
|
||||
Texture tex3 = loadTexOrFallback("Textures/Terrain/splat/Gravel.jpg",
|
||||
new ColorRGBA(0.45f, 0.35f, 0.25f, 1f));
|
||||
tex1.setWrap(Texture.WrapMode.Repeat);
|
||||
tex2.setWrap(Texture.WrapMode.Repeat);
|
||||
tex3.setWrap(Texture.WrapMode.Repeat);
|
||||
mat.setTexture("Tex1", tex1); mat.setFloat("Tex1Scale", 512f);
|
||||
mat.setTexture("Tex2", tex2); mat.setFloat("Tex2Scale", 512f);
|
||||
mat.setTexture("Tex3", tex3); mat.setFloat("Tex3Scale", 512f);
|
||||
String[] mapTex = map.terrainTextures;
|
||||
String[] matParams = {"DiffuseMap","DiffuseMap_1","DiffuseMap_2","DiffuseMap_3"};
|
||||
String[] scaleP = {"DiffuseMap_0_scale","DiffuseMap_1_scale","DiffuseMap_2_scale","DiffuseMap_3_scale"};
|
||||
for (int i = 0; i < 4; i++) {
|
||||
String path = (mapTex[i] != null && !mapTex[i].isEmpty()) ? mapTex[i] : DEF_TEX[i];
|
||||
if (path == null || path.isEmpty()) continue;
|
||||
Texture tex = loadTexOrFallback(path, DEF_COLOR[i]);
|
||||
tex.setWrap(Texture.WrapMode.Repeat);
|
||||
mat.setTexture(matParams[i], tex);
|
||||
mat.setFloat(scaleP[i], 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;
|
||||
boolean rAllZero = true;
|
||||
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(map.splatG[i]);
|
||||
splatBuf.put(map.splatB[i]);
|
||||
splatBuf.put((byte) 0);
|
||||
splatBuf.put(map.splatA[i]);
|
||||
}
|
||||
splatBuf.flip();
|
||||
Texture2D splatTex = new Texture2D(new Image(Image.Format.RGBA8, sz, sz, splatBuf));
|
||||
splatTex.setWrap(Texture.WrapMode.EdgeClamp);
|
||||
splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
|
||||
splatTex.setMagFilter(Texture.MagFilter.Bilinear);
|
||||
mat.setTexture("Alpha", splatTex);
|
||||
mat.setTexture("AlphaMap", splatTex);
|
||||
|
||||
terrain.setMaterial(mat);
|
||||
return;
|
||||
@@ -595,6 +768,34 @@ public class WorldScene extends BaseAppState {
|
||||
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) {
|
||||
Geometry limb = new Geometry("limb", new Cylinder(6, 12, radius, height, true));
|
||||
limb.setMaterial(mat);
|
||||
@@ -602,4 +803,5 @@ public class WorldScene extends BaseAppState {
|
||||
limb.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
|
||||
return limb;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,48 +18,44 @@ import com.jme3.scene.VertexBuffer;
|
||||
import com.jme3.scene.control.AbstractControl;
|
||||
import com.jme3.terrain.geomipmap.TerrainQuad;
|
||||
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.IntBuffer;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Rendert Gras im Spiel aus der in MapData gespeicherten Dichte-Map.
|
||||
*
|
||||
* Chunks werden gestreckt über mehrere Frames aufgebaut (INIT_PER_FRAME),
|
||||
* um Startlags zu vermeiden. GrassVisibilityControl cullt entfernte Chunks.
|
||||
* Rendert individuell platzierte Gras-Büschel aus blight_grass.blg.
|
||||
* Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME).
|
||||
* GrassVisibilityControl cullt entfernte Chunks.
|
||||
*/
|
||||
public class GrassState extends BaseAppState {
|
||||
|
||||
// ── Konstanten (identisch mit PlacedObjectState im Editor) ────────────────
|
||||
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 CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE;
|
||||
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS;
|
||||
private static final int MAX_BLADES_PER_PX = 3;
|
||||
private static final float BLADE_WIDTH = 0.18f;
|
||||
private static final float DEFAULT_HEIGHT = 1.5f;
|
||||
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 int INIT_PER_FRAME = 4;
|
||||
private static final int TERRAIN_HALF = 2048;
|
||||
private static final int CHUNK_SIZE = 128;
|
||||
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; // 1024
|
||||
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 FAR_DIST = 150f;
|
||||
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
|
||||
private static final int INIT_PER_FRAME = 4;
|
||||
|
||||
// ── Abhängigkeiten ────────────────────────────────────────────────────────
|
||||
private final MapData mapData;
|
||||
private final TerrainQuad terrain;
|
||||
|
||||
// ── Runtime-Zustand ───────────────────────────────────────────────────────
|
||||
private Camera cam;
|
||||
private Node grassNode;
|
||||
private Material grassMat;
|
||||
private int nextChunk = 0;
|
||||
private Camera cam;
|
||||
private Node grassNode;
|
||||
|
||||
public GrassState(MapData mapData, TerrainQuad terrain) {
|
||||
this.mapData = mapData;
|
||||
this.terrain = terrain;
|
||||
@SuppressWarnings("unchecked")
|
||||
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
|
||||
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
|
||||
private int nextChunk = 0;
|
||||
|
||||
public GrassState(TerrainQuad terrain) {
|
||||
this.terrain = terrain;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +65,25 @@ public class GrassState extends BaseAppState {
|
||||
this.cam = app.getCamera();
|
||||
grassNode = new Node("gameGrass");
|
||||
((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
|
||||
@@ -84,20 +98,40 @@ public class GrassState extends BaseAppState {
|
||||
public void update(float tpf) {
|
||||
int built = 0;
|
||||
while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) {
|
||||
buildChunk(nextChunk++);
|
||||
if (!chunkTufts[nextChunk].isEmpty()) buildChunk(nextChunk);
|
||||
nextChunk++;
|
||||
built++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
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("WindStrength", 0.14f);
|
||||
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;
|
||||
} catch (Exception e) {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
private void buildChunk(int idx) {
|
||||
@@ -116,47 +164,42 @@ public class GrassState extends BaseAppState {
|
||||
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
|
||||
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
|
||||
|
||||
int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
|
||||
int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
|
||||
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<GrassTuft> tufts = chunkTufts[idx];
|
||||
if (tufts.isEmpty()) return;
|
||||
|
||||
List<float[]> blades = new ArrayList<>();
|
||||
Vector3f scale = terrain.getWorldScale();
|
||||
Vector3f trans = terrain.getWorldTranslation();
|
||||
|
||||
for (int pz = pzMin; pz <= pzMax; pz++) {
|
||||
for (int px = pxMin; px <= pxMax; px++) {
|
||||
int d = mapData.grassDensity[pz * SPLAT_SIZE + px] & 0xFF;
|
||||
if (d == 0) continue;
|
||||
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PX));
|
||||
Random rng = new Random((long) px * 100003L + pz);
|
||||
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;
|
||||
float worldY = trans.y + th * scale.y;
|
||||
float h = DEFAULT_HEIGHT * (0.7f + rng.nextFloat() * 0.6f);
|
||||
blades.add(new float[]{bx, worldY, bz, h});
|
||||
}
|
||||
Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
|
||||
for (GrassTuft t : tufts) {
|
||||
long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
|
||||
^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL;
|
||||
Random rng = new Random(seed);
|
||||
List<float[]> blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>());
|
||||
for (int b = 0; b < BLADES_PER_TUFT; b++) {
|
||||
float bx = t.x() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
|
||||
float bz = t.z() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
|
||||
float th = terrain.getHeight(new Vector2f(bx, bz));
|
||||
if (Float.isNaN(th)) continue;
|
||||
float h = t.height() * (0.7f + rng.nextFloat() * 0.6f);
|
||||
blades.add(new float[]{bx, th, bz, h});
|
||||
}
|
||||
}
|
||||
|
||||
if (blades.isEmpty()) return;
|
||||
if (bySlot.isEmpty()) return;
|
||||
|
||||
Mesh mesh = buildGrassMesh(blades);
|
||||
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
|
||||
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
|
||||
Geometry geo = new Geometry("grass_" + idx, mesh);
|
||||
geo.setMaterial(grassMat);
|
||||
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
|
||||
grassNode.attachChild(geo);
|
||||
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
|
||||
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
|
||||
Node node = new Node("grass_" + idx);
|
||||
for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
|
||||
if (entry.getValue().isEmpty()) continue;
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
314
blight-game/src/main/java/de/blight/game/state/RiverState.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
BIN
blight-game/src/main/resources/icon.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |