Atmosphäre-Tools, EZ-Tree-Fixes, i18n, AnimSet, Baum-Export

- blight-lang: TextResolver + EN/DE Sprachpakete (TextReference i18n)
- AnimSet: Clips + ActionMap in .animset.json zusammengeführt
- EZ-Tree: Branch-Parameter-Fixes (length/radius/children/force nicht senden,
  twist Grad→Radiant, leaves.size ×5); Ordner-ComboBox mit Auto-Refresh
- Logging beim Baum-Export in allen drei Generatoren (EZ-Tree, Blight, Palme)
- Atmosphäre-Tools: Emitter, Licht, Wasser, Sound-/Musikbereiche, Spiel-Starten
- AnimPreviewState, RetargetingSystem, AnimationLibrary (Animations-Editor)
- Terrain-Transparenz-Fix, Schatten-Fix, ThirdPersonCamera-Fix
- DayNightState, WeatherState, CloudsNode, JmeConsole
- MapIO v6, neue blight-common Modell-Klassen (GameCharacter, NPC, Quests…)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:12:46 +02:00
parent 1e0789461f
commit 50f496c864
121 changed files with 13277 additions and 806 deletions

View File

@@ -0,0 +1,98 @@
ANIMATIONEN BLENDER RETARGETING WORKFLOW
==========================================
WARUM DIESER WORKFLOW?
----------------------
Das Mixamo-Skelett (stand.glb etc.) und das Tripo3D-Skelett (Charakter-Modell) haben
unterschiedliche Bone-Orientierungs-Konventionen. Das Runtime-Retargeting in Java kann
diesen Mismatch nicht zuverlässig beheben. Lösung: Animationen einmalig in Blender auf
das Tripo-Skelett "umrechnen" (baken) und als neue GLBs exportieren.
Diese Arbeit ist EINMALIG pro Animations-Clip. Danach funktioniert jedes weitere
Tripo3D-Modell mit denselben Bone-Namen automatisch über AnimationLibrary.
VORAUSSETZUNGEN
---------------
- Charakter-Modell als GLB-Datei (die Quelldatei, aus der die .j3o erzeugt wurde)
- Mixamo-Animations-GLB (z.B. stand.glb, twohand_idle.glb)
- Blender 3.x oder 4.x
WORKFLOW (für jede Animation einmal wiederholen)
------------------------------------------------
Schritt 1: Beide Armatures in Blender laden
File → Import → glTF 2.0 → Charakter-Modell .glb (= Custom-Armature)
File → Import → glTF 2.0 → stand.glb (= Mixamo-Armature + Animation)
Schritt 2: Custom-Armature vorbereiten
- Custom-Armature selektieren
- Pose Mode (Ctrl+Tab)
- Alle Bones selektieren (A)
Schritt 3: Copy-Rotation-Constraints setzen
Für jeden Bone in der Custom-Armature:
Bone-Properties → Constraints → Add Bone Constraint → Copy Rotation
Target: Mixamo-Armature
Bone: entsprechender Mixamo-Bone (siehe Mapping unten)
BONE-MAPPING (Custom → Mixamo):
┌─────────────────┬─────────────────────────┐
│ Custom │ Mixamo │
├─────────────────┼─────────────────────────┤
│ Pelvis │ mixamorig:Hips │
│ Waist │ mixamorig:Spine │
│ Spine01 │ mixamorig:Spine1 │
│ Spine02 │ mixamorig:Spine2 │
│ NeckTwist01 │ mixamorig:Neck │
│ L_Clavicle │ mixamorig:LeftShoulder │
│ L_Upperarm │ mixamorig:LeftArm │
│ L_Forearm │ mixamorig:LeftForeArm │
│ L_Hand │ mixamorig:LeftHand │
│ R_Clavicle │ mixamorig:RightShoulder │
│ R_Upperarm │ mixamorig:RightArm │
│ R_Forearm │ mixamorig:RightForeArm │
│ R_Hand │ mixamorig:RightHand │
│ L_Thigh │ mixamorig:LeftUpLeg │
│ L_Calf │ mixamorig:LeftLeg │
│ L_Foot │ mixamorig:LeftFoot │
│ R_Thigh │ mixamorig:RightUpLeg │
│ R_Calf │ mixamorig:RightLeg │
│ R_Foot │ mixamorig:RightFoot │
└─────────────────┴─────────────────────────┘
Schritt 4: Animation baken
Pose → Animation → Bake Action
✓ Only Selected Bones
✓ Visual Keying
✓ Clear Constraints
Bake Data: Pose
→ Erzeugt eine neue Action auf dem Custom-Skelett.
Schritt 5: Exportieren
- Mixamo-Armature aus der Szene löschen (X → Delete)
- Custom-Armature selektieren
- File → Export → glTF 2.0
Format: GLB
Include: Selected Objects only
Animation: ✓ (Export animations)
- Speichern als: blight-assets/src/main/resources/animations/stand.glb
(überschreibt das alte, fehlerhafte GLB)
→ Workflow für jede weitere Animation (twohand_idle.glb etc.) wiederholen.
WIE ES DANACH FUNKTIONIERT
---------------------------
Die neuen GLBs haben das Tripo-Skelett mit den korrekten vorgerechneten Animationen.
AnimationLibrary lädt sie beim Start automatisch und verteilt sie per applyAllTo()
auf alle Charaktere im Spiel.
Neue Tripo3D-Charaktere (gleiche Bone-Namen) bekommen alle Animationen automatisch —
kein weiteres Blender-Mapping nötig, weil das RetargetingSystem Bind-Pose-Unterschiede
(z.B. unterschiedliche Proportionen) selbst korrigiert.
Neuer Animations-Clip von Mixamo:
→ Einmal durch diesen Workflow → GLB in animations/ ablegen → fertig.

View File

@@ -3,3 +3,12 @@
// geteilten Assets (MatDefs, Shaders, Textures) auf dem Classpath zu erhalten.
//
// Editor-spezifische Assets (Tool-Icons etc.) verbleiben in blight-editor.
apply plugin: 'java'
sourceSets {
main {
java { srcDirs = [] }
resources { srcDirs = ['src/main/resources'] }
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@@ -21,3 +21,10 @@ compileJava.options.encoding = 'UTF-8'
repositories {
mavenCentral()
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'com.google.code.gson:gson:2.11.0'
}

View File

@@ -0,0 +1,86 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt platzierte Partikel-Emitter als tab-separierte Textdatei
* ({@code blight_emitters.bpe}) neben der Kartendatei.
*/
public final class EmitterIO {
private EmitterIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_emitters.bpe");
}
public static void save(List<PlacedEmitter> emitters) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# x\ty\tz\tactivationRadius\ttexturePath\timagesX\timagesY"
+ "\tstartR\tstartG\tstartB\tstartA"
+ "\tendR\tendG\tendB\tendA"
+ "\tstartSize\tendSize"
+ "\tvelX\tvelY\tvelZ\tvelVariation"
+ "\tgravX\tgravY\tgravZ"
+ "\tlowLife\thighLife\tmaxParticles\temitRate");
w.newLine();
for (PlacedEmitter e : emitters) {
w.write(String.format(Locale.ROOT,
"%.5f\t%.5f\t%.5f\t%.5f\t%s\t%d\t%d"
+ "\t%.4f\t%.4f\t%.4f\t%.4f"
+ "\t%.4f\t%.4f\t%.4f\t%.4f"
+ "\t%.4f\t%.4f"
+ "\t%.4f\t%.4f\t%.4f\t%.4f"
+ "\t%.4f\t%.4f\t%.4f"
+ "\t%.4f\t%.4f\t%d\t%.4f%n",
e.x(), e.y(), e.z(), e.activationRadius(),
e.texturePath(), e.imagesX(), e.imagesY(),
e.startR(), e.startG(), e.startB(), e.startA(),
e.endR(), e.endG(), e.endB(), e.endA(),
e.startSize(), e.endSize(),
e.velX(), e.velY(), e.velZ(), e.velocityVariation(),
e.gravX(), e.gravY(), e.gravZ(),
e.lowLife(), e.highLife(), e.maxParticles(), e.emitRate()));
}
}
}
public static List<PlacedEmitter> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedEmitter> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 28) continue;
try {
list.add(new PlacedEmitter(
Float.parseFloat(f[0]), // x
Float.parseFloat(f[1]), // y
Float.parseFloat(f[2]), // z
Float.parseFloat(f[3]), // activationRadius
f[4], // texturePath
Integer.parseInt(f[5]), // imagesX
Integer.parseInt(f[6]), // imagesY
Float.parseFloat(f[7]), Float.parseFloat(f[8]),
Float.parseFloat(f[9]), Float.parseFloat(f[10]), // startRGBA
Float.parseFloat(f[11]), Float.parseFloat(f[12]),
Float.parseFloat(f[13]), Float.parseFloat(f[14]), // endRGBA
Float.parseFloat(f[15]), Float.parseFloat(f[16]), // startSize, endSize
Float.parseFloat(f[17]), Float.parseFloat(f[18]),
Float.parseFloat(f[19]), Float.parseFloat(f[20]), // vel XYZ + var
Float.parseFloat(f[21]), Float.parseFloat(f[22]),
Float.parseFloat(f[23]), // grav XYZ
Float.parseFloat(f[24]), Float.parseFloat(f[25]), // lowLife, highLife
Integer.parseInt(f[26]), Float.parseFloat(f[27]) // maxParticles, emitRate
));
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -0,0 +1,60 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt platzierte Lichtquellen als tab-separierte Textdatei
* ({@code blight_lights.bll}) neben der Kartendatei.
*
* Spalten: x y z r g b intensity radius
*/
public final class LightIO {
private LightIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_lights.bll");
}
public static void save(List<PlacedLight> lights) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# x\ty\tz\tr\tg\tb\tintensity\tradius");
w.newLine();
for (PlacedLight l : lights) {
w.write(String.format(Locale.ROOT,
"%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f%n",
l.x(), l.y(), l.z(),
l.r(), l.g(), l.b(),
l.intensity(), l.radius()));
}
}
}
public static List<PlacedLight> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedLight> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 8) continue;
try {
float x = Float.parseFloat(f[0]);
float y = Float.parseFloat(f[1]);
float z = Float.parseFloat(f[2]);
float r = Float.parseFloat(f[3]);
float g = Float.parseFloat(f[4]);
float b = Float.parseFloat(f[5]);
float intensity = Float.parseFloat(f[6]);
float radius = Float.parseFloat(f[7]);
list.add(new PlacedLight(x, y, z, r, g, b, intensity, radius));
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -6,8 +6,8 @@ package de.blight.common;
* Basis-Terrain : 4097 × 4097 Vertices (= 4096 × 4096 Zellen),
* 8 Welteinheiten pro Zelle → Welt 2048 .. +2048.
* Obere Schicht : 513 × 513 Vertices (= 512 × 512 Zellen), gleiche Weltausdehnung.
* Splatmap : 513 × 513 Pixel (passt auf Spiel-Terrain 1:1).
* Kanäle R/G/B = Gewicht für Tex2/Tex3/Tex4; Tex1 füllt den Rest.
* Splatmap : 513 × 513 Pixel (1:1 zu beiden Terrain-Grids).
* Kanäle R/G/B/A = Gewicht für Tex1-Helligkeit / Tex2 / Tex3 / Tex4.
*/
public final class MapData {
@@ -29,6 +29,9 @@ public final class MapData {
/** Pixel pro Achse der Splatmap (entspricht UPPER_VERTS = Spiel-Terrain-Auflösung). */
public static final int SPLAT_SIZE = 513;
/** Anzahl konfigurierbarer Textur-Slots pro Terrain-Layer. */
public static final int TEXTURE_SLOTS = 4;
// ── Daten ─────────────────────────────────────────────────────────────────
/** Y-Höhe jedes Vertex im Basis-Terrain [TERRAIN_VERTS²]. */
@@ -40,29 +43,55 @@ public final class MapData {
/** Y der Höhlendecke [UPPER_VERTS²]. */
public final float[] upperBottom;
/** 1 = Loch (offen), 0 = massiv [UPPER_CELLS²]. */
public final byte[] upperHole;
/** Splatmap Rot-Kanal: Tex1-Helligkeit (Alpha.R), immer 255 [SPLAT_SIZE²]. */
/** Splatmap Rot-Kanal: Tex1-Helligkeit (Alpha.R), immer 255 [SPLAT_SIZE²]. */
public final byte[] splatR;
/** Splatmap Grün-Kanal: Tex2 (Fels) mix-Faktor (Alpha.G) [SPLAT_SIZE²], Bytes 0255. */
/** Splatmap Grün-Kanal: Tex2-Blend (Alpha.G) [SPLAT_SIZE²]. */
public final byte[] splatG;
/** Splatmap Blau-Kanal: Tex3 (Erde) mix-Faktor (Alpha.B) [SPLAT_SIZE²], Bytes 0255. */
/** Splatmap Blau-Kanal: Tex3-Blend (Alpha.B) [SPLAT_SIZE²]. */
public final byte[] splatB;
/** Splatmap Alpha-Kanal: Tex4-Blend (Alpha.A) [SPLAT_SIZE²]. */
public final byte[] splatA;
/** Texturpfade für Basis-Terrain (4 Slots, "" = Standard-Textur). */
public final String[] terrainTextures = new String[]{"", "", "", ""};
/** Splatmap Rot-Kanal Gebirge: Tex1-Helligkeit, immer 255 [SPLAT_SIZE²]. */
public final byte[] upperSplatR;
/** Splatmap Grün-Kanal Gebirge: Tex2-Blend [SPLAT_SIZE²]. */
public final byte[] upperSplatG;
/** Splatmap Blau-Kanal Gebirge: Tex3-Blend [SPLAT_SIZE²]. */
public final byte[] upperSplatB;
/** Splatmap Alpha-Kanal Gebirge: Tex4-Blend [SPLAT_SIZE²]. */
public final byte[] upperSplatA;
/** Texturpfade für Gebirge (4 Slots, "" = Standard-Textur). */
public final String[] upperTextures = new String[]{"", "", "", ""};
/** Gras-Dichte [SPLAT_SIZE²], Bytes 0255 (0=kein Gras, 255=max Dichte). */
public final byte[] grassDensity;
/** Loch-Maske der oberen Schicht [UPPER_CELLS²], != 0 = Loch (Zelle ausgeblendet). */
public final byte[] upperHole;
/** Spawnpunkt X-Koordinate in Welteinheiten (default: 0 = Kartenmitte). */
public float spawnX = 0f;
/** Spawnpunkt Z-Koordinate in Welteinheiten (default: 0 = Kartenmitte). */
public float spawnZ = 0f;
public MapData() {
terrainHeight = new float[TERRAIN_VERTS * TERRAIN_VERTS];
upperTop = new float[UPPER_VERTS * UPPER_VERTS];
upperBottom = new float[UPPER_VERTS * UPPER_VERTS];
upperHole = new byte [UPPER_CELLS * UPPER_CELLS];
splatR = new byte [SPLAT_SIZE * SPLAT_SIZE];
splatG = new byte [SPLAT_SIZE * SPLAT_SIZE];
splatB = new byte [SPLAT_SIZE * SPLAT_SIZE];
splatA = new byte [SPLAT_SIZE * SPLAT_SIZE];
upperSplatR = new byte [SPLAT_SIZE * SPLAT_SIZE];
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];
}
}

View File

@@ -16,6 +16,9 @@ import java.util.zip.*;
* 1 Basis-Terrain + Obere Schicht (kein Splatmap)
* 2 wie 1 + Splatmap (R/G/B je 513×513 Bytes)
* 3 wie 2 + Gras-Dichte (513×513 Bytes)
* 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)
*/
public final class MapIO {
@@ -47,7 +50,7 @@ public final class MapIO {
}
private static final int MAGIC = 0x424C4947; // "BLIG"
private static final int VERSION = 3;
private static final int VERSION = 6;
private MapIO() {}
@@ -84,13 +87,23 @@ public final class MapIO {
writeFloats(out, data.terrainHeight);
writeFloats(out, data.upperTop);
writeFloats(out, data.upperBottom);
out.write(data.upperHole);
// v2: splatmap
out.write(data.splatR);
out.write(data.splatG);
out.write(data.splatB);
// v3: gras-dichte
out.write(data.grassDensity);
// v4: spawnpunkt
out.writeFloat(data.spawnX);
out.writeFloat(data.spawnZ);
// v5: splatA + texturpfade + gebirge-splatmap
out.write(data.splatA);
writeStrings(out, data.terrainTextures);
out.write(data.upperSplatR);
out.write(data.upperSplatG);
out.write(data.upperSplatB);
out.write(data.upperSplatA);
writeStrings(out, data.upperTextures);
}
}
@@ -108,7 +121,10 @@ public final class MapIO {
readFloats(in, data.terrainHeight);
readFloats(in, data.upperTop);
readFloats(in, data.upperBottom);
in.readFully(data.upperHole);
if (version <= 5) {
// v5 had upperHole[UPPER_CELLS²]; read and discard
in.skip((long) MapData.UPPER_CELLS * MapData.UPPER_CELLS);
}
if (version >= 2) {
in.readFully(data.splatR);
@@ -118,7 +134,22 @@ public final class MapIO {
if (version >= 3) {
in.readFully(data.grassDensity);
}
// version 1/2: grassDensity stays all-zeros (= kein Gras)
if (version >= 4) {
data.spawnX = in.readFloat();
data.spawnZ = in.readFloat();
}
if (version >= 5) {
in.readFully(data.splatA);
readStrings(in, data.terrainTextures);
in.readFully(data.upperSplatR);
in.readFully(data.upperSplatG);
in.readFully(data.upperSplatB);
in.readFully(data.upperSplatA);
readStrings(in, data.upperTextures);
} else {
// Ältere Maps: upperSplatR auf 255 initialisieren (Tex1 immer sichtbar)
java.util.Arrays.fill(data.upperSplatR, (byte) 255);
}
}
return data;
}
@@ -137,4 +168,14 @@ public final class MapIO {
in.readFully(bytes);
ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).asFloatBuffer().get(arr);
}
private static void writeStrings(DataOutputStream out, String[] arr) throws IOException {
out.writeInt(arr.length);
for (String s : arr) out.writeUTF(s != null ? s : "");
}
private static void readStrings(DataInputStream in, String[] arr) throws IOException {
int len = in.readInt();
for (int i = 0; i < len && i < arr.length; i++) arr[i] = in.readUTF();
}
}

View File

@@ -0,0 +1,51 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
public final class MusicAreaIO {
private MusicAreaIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_music_areas.bma");
}
public static void save(List<PlacedMusicArea> areas) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# polygon\tdayTrack\tnightTrack\tcombatTrack");
w.newLine();
for (PlacedMusicArea a : areas) {
w.write(SoundAreaIO.encodePolygon(a.pointsX(), a.pointsZ()));
w.write('\t');
w.write(a.dayTrack());
w.write('\t');
w.write(a.nightTrack());
w.write('\t');
w.write(a.combatTrack());
w.newLine();
}
}
}
public static List<PlacedMusicArea> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedMusicArea> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 4) continue;
try {
float[][] pts = SoundAreaIO.decodePolygon(f[0]);
if (pts[0].length < 3) continue;
list.add(new PlacedMusicArea(pts[0], pts[1], f[1], f[2], f[3]));
} catch (Exception ignored) {}
}
return list;
}
}

View File

@@ -0,0 +1,52 @@
package de.blight.common;
public record PlacedEmitter(
float x, float y, float z,
float activationRadius,
String texturePath,
int imagesX, int imagesY,
float startR, float startG, float startB, float startA,
float endR, float endG, float endB, float endA,
float startSize, float endSize,
float velX, float velY, float velZ, float velocityVariation,
float gravX, float gravY, float gravZ,
float lowLife, float highLife,
int maxParticles, float emitRate
) {
public static PlacedEmitter fire(float x, float y, float z) {
return new PlacedEmitter(x, y + 0.5f, z, 20f,
"Effects/Explosion/flame.png", 2, 2,
1f, 0.7f, 0.1f, 1f,
1f, 0.1f, 0f, 0f,
0.5f, 1.2f,
0f, 2.5f, 0f, 0.5f,
0f, -0.1f, 0f,
1f, 3f,
60, 25f);
}
public static PlacedEmitter smoke(float x, float y, float z) {
return new PlacedEmitter(x, y + 1.5f, z, 20f,
"Effects/Smoke/Smoke.png", 1, 1,
0.5f, 0.5f, 0.5f, 0.4f,
0.2f, 0.2f, 0.2f, 0f,
0.8f, 2.5f,
0f, 1.2f, 0f, 0.3f,
0f, 0.05f, 0f,
3f, 6f,
24, 6f);
}
public static PlacedEmitter sparks(float x, float y, float z) {
return new PlacedEmitter(x, y + 0.3f, z, 20f,
"Effects/Explosion/spark.png", 1, 1,
1f, 0.9f, 0.2f, 1f,
1f, 0.3f, 0f, 0f,
0.05f, 0.02f,
0f, 4f, 0f, 1.0f,
0f, -5f, 0f,
0.5f, 2f,
50, 20f);
}
}

View File

@@ -0,0 +1,9 @@
package de.blight.common;
/** Unveränderliche Snapshot-Daten einer platzierten Lichtquelle auf der Karte. */
public record PlacedLight(
float x, float y, float z,
float r, float g, float b,
float intensity,
float radius
) {}

View File

@@ -0,0 +1,15 @@
package de.blight.common;
/** Unveränderliche Snapshot-Daten eines platzierten 3D-Modells auf der Karte. */
public record PlacedModel(
String modelPath, // "@box" / "@sphere" / ... / "models/foo.j3o"
float x, float y, float z,
float rotY, float rotX, float rotZ,
float scale,
boolean solid,
String texturePath, String normalMapPath, String materialPath,
/** 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
) {}

View File

@@ -0,0 +1,77 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
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
*
* Alte Dateien mit 6 Spalten (v1) werden gelesen; fehlende Felder erhalten Standardwerte.
*/
public final class PlacedModelIO {
private PlacedModelIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_objects.blo");
}
public static void save(List<PlacedModel> models) throws IOException {
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.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",
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())));
}
}
}
public static List<PlacedModel> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedModel> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 6) continue;
try {
String modelPath = f[0];
float x = Float.parseFloat(f[1]);
float y = Float.parseFloat(f[2]);
float z = Float.parseFloat(f[3]);
float rotY = Float.parseFloat(f[4]);
float scale = Float.parseFloat(f[5]);
float rotX = f.length > 6 ? Float.parseFloat(f[6]) : 0f;
float rotZ = f.length > 7 ? Float.parseFloat(f[7]) : 0f;
boolean solid = f.length > 8 ? Boolean.parseBoolean(f[8]) : false;
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] : "";
list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip));
} catch (NumberFormatException ignored) {}
}
return list;
}
private static String nvl(String s) { return s != null ? s : ""; }
}

View File

@@ -0,0 +1,9 @@
package de.blight.common;
public record PlacedMusicArea(
float[] pointsX,
float[] pointsZ,
String dayTrack,
String nightTrack,
String combatTrack
) {}

View File

@@ -0,0 +1,9 @@
package de.blight.common;
public record PlacedSoundArea(
float[] pointsX,
float[] pointsZ,
String soundPath,
float volume,
boolean crossfade
) {}

View File

@@ -0,0 +1,7 @@
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
) {}

View File

@@ -0,0 +1,70 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
public final class SoundAreaIO {
private SoundAreaIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_sound_areas.bsa");
}
public static void save(List<PlacedSoundArea> areas) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# polygon\tsoundPath\tvolume\tcrossfade");
w.newLine();
for (PlacedSoundArea a : areas) {
w.write(encodePolygon(a.pointsX(), a.pointsZ()));
w.write('\t');
w.write(a.soundPath());
w.write('\t');
w.write(String.format(Locale.ROOT, "%.4f\t%b%n", a.volume(), a.crossfade()));
}
}
}
public static List<PlacedSoundArea> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedSoundArea> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 4) continue;
try {
float[][] pts = decodePolygon(f[0]);
if (pts[0].length < 3) continue;
list.add(new PlacedSoundArea(pts[0], pts[1], f[1],
Float.parseFloat(f[2]), Boolean.parseBoolean(f[3])));
} catch (Exception ignored) {}
}
return list;
}
static String encodePolygon(float[] xs, float[] zs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < xs.length; i++) {
if (i > 0) sb.append(';');
sb.append(String.format(Locale.ROOT, "%.3f,%.3f", xs[i], zs[i]));
}
return sb.toString();
}
static float[][] decodePolygon(String encoded) {
String[] pts = encoded.split(";", -1);
float[] xs = new float[pts.length];
float[] zs = new float[pts.length];
for (int i = 0; i < pts.length; i++) {
String[] xz = pts[i].split(",", -1);
xs[i] = Float.parseFloat(xz[0]);
zs[i] = Float.parseFloat(xz[1]);
}
return new float[][]{xs, zs};
}
}

View File

@@ -0,0 +1,55 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/**
* Liest und schreibt platzierte Wasseroberflächen als tab-separierte Textdatei
* ({@code blight_water.blw}) neben der Kartendatei.
*
* Spalten: x y z width depth
*/
public final class WaterBodyIO {
private WaterBodyIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_water.blw");
}
public static void save(List<PlacedWater> bodies) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# x\ty\tz\twidth\tdepth");
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()));
}
}
}
public static List<PlacedWater> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedWater> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 5) 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])));
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -0,0 +1,5 @@
package de.blight.common.model;
public interface AudioReference {
}

View File

@@ -0,0 +1,91 @@
package de.blight.common.model;
import com.google.gson.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/**
* Lädt und speichert {@link GameCharacter}-Instanzen als JSON.
* Dateiformat: <id>.character im Verzeichnis character/
*
* JSON-Struktur:
* {
* "type": "MAIN_CHARACTER" | "NPC",
* "characterId": "hero",
* "name": "Der Held",
* "modelPath": "Models/hero.j3o",
* "animSetPath": "animations/sets/hero.j3o",
* ... (subclass fields via Gson)
* }
* Die Aktions-Zuweisung (IDLE → Clip-Name usw.) ist im AnimSet gespeichert
* (animSetPath.replaceAll(".j3o", "") + ".animset.json").
*/
public final class CharacterIO {
private static final Logger log = LoggerFactory.getLogger(CharacterIO.class);
private static final String EXTENSION = ".character";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private CharacterIO() {}
// ── Save ──────────────────────────────────────────────────────────────────
public static void save(GameCharacter character, Path directory) throws IOException {
String id = character.getCharacterId();
if (id == null || id.isBlank()) throw new IllegalArgumentException("characterId must be set");
Files.createDirectories(directory);
JsonObject obj = (JsonObject) GSON.toJsonTree(character);
obj.addProperty("type", character instanceof MainCharacter ? "MAIN_CHARACTER" : "NPC");
Files.writeString(directory.resolve(id + EXTENSION),
GSON.toJson(obj), StandardCharsets.UTF_8);
log.debug("[CharacterIO] Gespeichert: {}", id);
}
// ── Load ──────────────────────────────────────────────────────────────────
public static GameCharacter load(Path file) throws IOException {
String json = Files.readString(file, StandardCharsets.UTF_8);
JsonObject obj = JsonParser.parseString(json).getAsJsonObject();
String type = obj.has("type") ? obj.get("type").getAsString() : "NPC";
GameCharacter c = "MAIN_CHARACTER".equals(type)
? GSON.fromJson(obj, MainCharacter.class)
: GSON.fromJson(obj, NPC.class);
log.debug("[CharacterIO] Geladen: {}", file.getFileName());
return c;
}
/** Lädt alle .character-Dateien aus dem angegebenen Verzeichnis. */
public static List<GameCharacter> loadAll(Path directory) {
if (!Files.isDirectory(directory)) return List.of();
List<GameCharacter> result = new ArrayList<>();
try (Stream<Path> walk = Files.list(directory)) {
walk.filter(p -> p.toString().endsWith(EXTENSION))
.sorted()
.forEach(p -> {
try { result.add(load(p)); } catch (IOException e) {
log.warn("[CharacterIO] Fehler beim Laden von {}: {}", p, e.getMessage());
}
});
} catch (IOException e) {
log.warn("[CharacterIO] Fehler beim Scannen von {}: {}", directory, e.getMessage());
}
return result;
}
/** Gibt true zurück wenn bereits eine MainCharacter-Datei im Verzeichnis existiert (außer der mit excludeId). */
public static boolean mainCharacterExists(Path directory, String excludeId) {
for (GameCharacter c : loadAll(directory)) {
if (c instanceof MainCharacter
&& !Objects.equals(c.getCharacterId(), excludeId)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model;
import de.blight.common.model.quests.Quest;
public interface CharacterListener {
public void questFulfilled(Quest quest);
public void questAborted(Quest quest);
public void levelUp();
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Collectable implements Interactable {
private Item item;
private ObjectReference objectReference;
}

View File

@@ -0,0 +1,19 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
/**
* AlchemyTable
* EnchantmentTable
* Smithy
* Goldsmiths
* Workshop
*/
@Getter
@Setter
public class CraftingTable {
private TextReference name;
private ObjectReference object;
}

View File

@@ -0,0 +1,42 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import de.blight.common.model.quests.Quest;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DialogOption {
private int requiresChapter;
private Quest requiresQuestOpen;
private Quest requiresQuestComplete;
private Status requiresStatus;
private TextReference textHero;
private AudioReference audioHero;
private TextReference textNpc;
private AudioReference audioNpc;
private List<DialogOption> nextOptions;
private List<DialogOption> disablesOptions;
private RequiredItem requiredItem;
private RecievesItem recievesItem;
private Quest recievesQuest;
private Quest fulfillsQuest;
private List<Quest> abortsQuests = new ArrayList<Quest>();
private boolean enablesTrade;
public Status getRequiredStatus() {
if (requiresStatus == null) {
return Status.ENEMY;
}
return requiresStatus;
}
}

View File

@@ -0,0 +1,17 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public abstract class GameCharacter {
/** Eindeutige ID — entspricht dem Dateinamen ohne Extension. */
private String characterId;
/** Anzeigename des Charakters — wird zur Laufzeit per TextReference aufgelöst. */
private TextReference name;
/** Relativer Pfad zum .j3o-Modell (relativ zu blight-assets/src/main/resources). */
private String modelPath;
/** Relativer Pfad zum .j3o-Animations-Set (in animations/sets/). */
private String animSetPath;
}

View File

@@ -0,0 +1,5 @@
package de.blight.common.model;
public interface Interactable {
}

View File

@@ -0,0 +1,80 @@
package de.blight.common.model;
import java.util.HashMap;
import java.util.Map.Entry;
public class Inventar {
private HashMap<Item, Integer> items = new HashMap<Item, Integer>();
public void collect(Item item) {
add(item, 1);
}
public boolean remove(Item item, int count) {
if (items.containsKey(item) && items.get(item) >= count) {
items.merge(item, count * -1, Integer::sum);
if (items.get(item) <= 0) {
items.remove(item);
}
return true;
}
return false;
}
public boolean remove(Item item) {
return remove(item, 1);
}
public boolean remove(ItemCount itemCount) {
return remove(itemCount.getItem(), itemCount.getCount());
}
public boolean use(Item item, MainCharacter character) {
if (remove(item)) {
item.use(character);
return true;
}
return false;
}
public boolean hasItem(ItemCount itemCount) {
return hasItem(itemCount.getItem(), itemCount.getCount());
}
public boolean hasItem(Item item, Integer count) {
return items.containsKey(item) && items.get(item) >= count;
}
public void recieveItem(ItemCount itemCount) {
add(itemCount.getItem(), itemCount.getCount());
}
private void add(Item item) {
add(item, 1);
}
private void add(Item item, int count) {
if (items.containsKey(item)) {
items.merge(item, count, Integer::sum);
} else {
items.put(item, count);
}
}
public boolean canCraft(Recipe recipe) {
for (Entry<Item, Integer> entry : recipe.getComponents().entrySet()) {
if (!hasItem(entry.getKey(), entry.getValue())) {
return false;
}
}
return true;
}
public void craft(Recipe recipe) {
for (Entry<Item, Integer> entry : recipe.getComponents().entrySet()) {
remove(entry.getKey(), entry.getValue());
}
add(recipe.getCreates());
}
}

View File

@@ -0,0 +1,20 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Item {
private String itemId;
private ItemCategory category;
private TextReference name;
private TextReference description;
private int worthGold;
public void use(MainCharacter character) {
}
}

View File

@@ -0,0 +1,11 @@
package de.blight.common.model;
public enum ItemCategory {
WEAPON,
GEAR,
CONSUMABLES,
QUEST_ITEMS,
USABLES,
MISC;
}

View File

@@ -0,0 +1,7 @@
package de.blight.common.model;
public interface ItemCount {
public Item getItem();
public int getCount();
}

View File

@@ -0,0 +1,5 @@
package de.blight.common.model;
public interface Location {
}

View File

@@ -0,0 +1,78 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import de.blight.common.model.quests.Quest;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class MainCharacter extends GameCharacter {
private int chapter;
private Inventar inventar;
private int level;
private int xp;
private int currentHp;
private int maxHp;
private int currentStamina;
private int maxStamina;
private int currentMana;
private int myMana;
private List<Quest> openQuests;
private List<Quest> completedQuests;
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private List<CharacterListener> listeners = new ArrayList<CharacterListener>();
public void handleDialogOption(DialogOption option) {
if (option.getRequiredItem() != null) {
getInventar().remove(option.getRequiredItem());
}
if (option.getRecievesItem() != null) {
getInventar().recieveItem(option.getRecievesItem());
}
if (option.getFulfillsQuest() != null ) {
fullfillQuest(option.getFulfillsQuest());
}
option.getAbortsQuests().forEach(this::abortQuest);
}
public void fullfillQuest(Quest quest) {
openQuests.remove(quest);
completedQuests.add(quest);
checkLevelUp(quest.getXp());
listeners.forEach(listener -> listener.questFulfilled(quest));
}
private void checkLevelUp(int questXp) {
var xpRequired = XPHelper.getXpRequired(level);
var newXp = xp + questXp;
if (newXp > xpRequired) {
this.level++;
this.xp = newXp - xpRequired;
listeners.forEach(CharacterListener::levelUp);
}
}
private void abortQuest(Quest quest) {
openQuests.remove(quest);
listeners.forEach(listener -> listener.questAborted(quest));
}
public void removeListener(CharacterListener listener) {
listeners.remove(listener);
}
public void addListener(CharacterListener listener) {
listeners.add(listener);
}
}

View File

@@ -0,0 +1,65 @@
package de.blight.common.model;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class NPC extends GameCharacter {
private static final Logger LOG = LoggerFactory.getLogger(NPC.class);
private Status status;
private boolean trader;
private List<Item> items;
private List<DialogOption> currentOptions;
public List<DialogOption> getAvailableOptions(MainCharacter character) {
return currentOptions.stream().filter(option ->
option.getRequiresChapter() < character.getChapter() &&
option.getRequiresStatus().requirementFulfilled(status) &&
(option.getRequiresQuestOpen() == null || character.getOpenQuests().contains(option.getRequiresQuestOpen())) &&
(option.getRequiredItem() == null || character.getInventar().hasItem(option.getRequiredItem())) &&
(option.getRequiresQuestComplete() == null || character.getCompletedQuests().contains(option.getRequiresQuestComplete()))).toList();
}
public boolean chooseDialogOption(DialogOption option, MainCharacter character) {
if (!currentOptions.contains(option)) {
LOG.warn("Dialog Option was choosen but is not available");
return false;
}
if (option.getRequiredItem() != null && !character.getInventar().hasItem(option.getRequiredItem())) {
LOG.warn("Dialog Option was choosen but required item is not in Inventar");
return false;
}
if (option.getRequiresQuestOpen() != null && !character.getOpenQuests().contains(option.getRequiresQuestOpen())) {
LOG.warn("Dialog Option was choosen but required quest is not open");
return false;
}
if (option.getRequiredStatus().requirementFulfilled(status)) {
LOG.warn("Dialog Option was choosen but required Status is not fulfilled");
return false;
}
if (option.getRequiresQuestComplete() != null && !character.getCompletedQuests().contains(option.getRequiresQuestComplete())) {
LOG.warn("Dialog Option was choosen but required Quest is not complete");
return false;
}
currentOptions.remove(option);
currentOptions.removeAll(option.getDisablesOptions());
currentOptions.addAll(option.getNextOptions());
character.handleDialogOption(option);
if (option.isEnablesTrade()) {
this.trader = true;
}
return true;
}
}

View File

@@ -0,0 +1,5 @@
package de.blight.common.model;
public interface ObjectReference {
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class RecievesItem implements ItemCount {
private Item item;
private int count = 1;
}

View File

@@ -0,0 +1,15 @@
package de.blight.common.model;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Recipe {
private Item creates;
private Map<Item, Integer> components;
private Interactable requires;
}

View File

@@ -0,0 +1,13 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class RequiredItem implements ItemCount {
private Item item;
private int count = 1;
private boolean gives;
}

View File

@@ -0,0 +1,16 @@
package de.blight.common.model;
public enum Status {
FRIENDLY(3), NEUTRAL(2), ENRAGED(1), ENEMY(0);
private int val;
private Status(int val) {
this.val = val;
}
public boolean requirementFulfilled(Status minVal) {
return this.val >= minVal.val;
}
}

View File

@@ -0,0 +1,3 @@
package de.blight.common.model;
public record TextReference(String id) {}

View File

@@ -0,0 +1,14 @@
package de.blight.common.model;
public class XPHelper {
private static final int BASE_VAL = 450;
private static final int GROTH_A = 5;
private static final int GROTH_B = 45;
private XPHelper() {}
public static int getXpRequired(int level) {
return (GROTH_A * (level * level)) + (GROTH_B * level) + BASE_VAL;
}
}

View File

@@ -0,0 +1,143 @@
package de.blight.common.model.abilities;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Abilities {
private int lvlMagic; // 1-10
private int lvlStaffCombat; // 1-10
private int lvlSwordsmanship; // 1-10
private int lvlArchery; // 1-10
private int lvlHeavyWeapons; // 1-10
private int lvlCrossbow; // 1-10
/**
* Level 1: Ermöglicht das Knacken von Schlössern
* Level 2: Ermöglicht den Taschendiebstahl
* Level 3: Auf leisen Sohlen - Schleichen ist effektiver
*/
private int lvlThievery; // 1-3
/**
* Level 1: Ermöglicht das Brauen von Trönken, die die Wirkung von Kräutern verstärken
* Level 2: Ermöglicht das Brauen von Tränken, die den Helden temporär
* Level 3: Ermöglicht das Brauen von Tränken, die den Helden dauerhaft verstärken
*/
private int lvlAlchemy; // 1-3
/**
* Level 1: Ermöglicht das Herstellen von Bandagen und das Herstellen von Spritzen, die temporär den Helden verbessern
* Level 2: Ermöglicht die Herstllung von Wurfbomben und ähnlichem
* Level 3: Ermöglicht die Verwendung von Attelerie
*/
private int lvlEngineering; // 1-3
public enum StaffAbilities {
BASE_ATTACK(1), // Eine Basisattacke mit einem Stab
BLOCK(3), // Ein Block für Nahkampfattacken
HEAVY_ATTACK(5), // Ein Schwerer Schlag, der aufgeladen werden kann
STEADINESS(7), // Erhöht für 3 Sekunden die Standfestigkeit und der Held nimmt weniger Schaden
FEINT(9), // Lässt den Gegner bei einem Angriff ins leere laufen
DISARM(10); // Entwaffnet einen Angreifer temporär
private int minLvl;
private StaffAbilities(int minLvl) {
this.minLvl = minLvl;
}
public int getMinLvl() {
return minLvl;
}
}
public enum MagicAbilities {
FIREBALL(1), // Feuert einen Feuerball auf einen Gegner der Schaden verursacht
LIGHT(1), // Erzeugt ein Licht, dass für 1 minute die umgebung erleuchtet
SHIELD(3), // Erzeugt für eine Sekunde einen Shild um den MainChar der Fernkampf Angriffe blockiert
ROOT(5), // Wurzelt alle Gegner im Umkreis von 20m am für 3 Sekunden Boden fest
SHOCKWAVE(7), // Feuert eine Shockwelle ab, die im Umkreis von 5 Metern die Gegner wegstößt, je dichter der Gegner, desto größer der Schaden
DRAIN(9), // Entzieht dem Gegner leben und heilt gleichzeitig den Helden
CHAIN_LIGHTNING(10); // Feuert einen Kettenblitz der auf mehrere Gegner überspringen und schaden verursachen kann
private int minLvl;
private MagicAbilities(int minLvl) {
this.minLvl = minLvl;
}
public int getMinLvl() {
return minLvl;
}
}
public enum SwordAbilities {
BASE_ATTACK(1), // Eine Basisattacke mit einem Schwert
BLOCK(3), // Ein Block für Nahkampfattacken
HEAVY_ATTACK(5), // Ein Schwerer Schlag, der aufgeladen werden kann
QUICK_THRUST(7), // Führt einen gezielten Stich durch, der einen Block teilweise ignoriert
BLADE_DANCE(9), // Führt mehrere schnelle Schläge nacheinander durch, kann einen Block durchbrechen
DEATH_BLOW(10); // Exekutiert Gegner mit weniger als 10% HP
private int minLvl;
private SwordAbilities(int minLvl) {
this.minLvl = minLvl;
}
public int getMinLvl() {
return minLvl;
}
}
public enum HeavyWeaponAbilities {
BASE_ATTACK(1), // Eine Basisattacke mit einem Zweihänder oder einer Helebarde
BLOCK(3), // Ein Block für Nahkampfattacken
HEAVY_ATTACK(5), // Ein Schwerer Schlag, der aufgeladen werden kann, Durchbricht sehr effektiv einen Block
SWIRL_ATTACK(7), // Rotiert mit der Waffe um sich und fügt allen Gegnern im Radius Schaden zu
DOUBLE_ATTACK(9), // Führt unmittelbar nacheinander zwei Agriff mit beiden enden der Waffe durch
EXECUTION(10); // Führt einen Angriff durch der kritischen Schaden verursacht
private int minLvl;
private HeavyWeaponAbilities(int minLvl) {
this.minLvl = minLvl;
}
public int getMinLvl() {
return minLvl;
}
}
public enum RangedWeaponAbilities {
BASE_ATTACK(1), // ermöglicht das Abfeuern einer Armbrust oder eines Bogens
PRECISION_SHOT(3), // Höhere Genauigkeit bei Schüssen auf Distanz
FAST_RELOAD(5), // Erhöht die Nachladegeschwindigkeit um 50%
LETHALITY(7), // Erhöht die Durchdringung von Schüssen mit Pfeilen oder Bolzen
DOUBLE_ATTACK(9), // ERmöglicht das Abfeuern von zwei Bolzen oder Pfeilen zur Selben Zeit, erhöht den Schaden
HAWK_EYE(10); // Zeitlupe für kurze Dauer beim Zielen, um Schwachstellen zu treffen.
private int minLvl;
private RangedWeaponAbilities(int minLvl) {
this.minLvl = minLvl;
}
public int getMinLvl() {
return minLvl;
}
}
public enum MiscAbilities {
SNEAK,
MINE_ORE,
GUT_ANIMALS,
ACROBATICS;
}
}

View File

@@ -0,0 +1,10 @@
package de.blight.common.model.abilities;
import de.blight.common.model.MainCharacter;
public interface Ability {
boolean requirementsFulfilled(MainCharacter character);
void used(MainCharacter character);
}

View File

@@ -0,0 +1,14 @@
package de.blight.common.model.quests;
import de.blight.common.model.Location;
import de.blight.common.model.NPC;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BringQuest {
private NPC bring;
private Location bringTo;
}

View File

@@ -0,0 +1,14 @@
package de.blight.common.model.quests;
import de.blight.common.model.Location;
import de.blight.common.model.NPC;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class FollowQuest implements QuestType {
private NPC follow;
private Location followTo;
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model.quests;
import de.blight.common.model.Interactable;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class InteractQuest implements QuestType {
private Interactable interactWith;
}

View File

@@ -0,0 +1,13 @@
package de.blight.common.model.quests;
import de.blight.common.model.Item;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ItemQuest implements QuestType {
private Item item;
private int count;
}

View File

@@ -0,0 +1,18 @@
package de.blight.common.model.quests;
import de.blight.common.model.TextReference;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Quest {
private int xp;
private String questId;
private TextReference text;
private TextReference description;
private TextReference successText;
private QuestType questType;
}

View File

@@ -0,0 +1,5 @@
package de.blight.common.model.quests;
public interface QuestType {
}

View File

@@ -0,0 +1,12 @@
package de.blight.common.model.quests;
import de.blight.common.model.NPC;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class TalkQuest implements QuestType {
private NPC talkTo;
}

View File

@@ -0,0 +1,57 @@
package de.blight.common.time;
import java.util.ArrayList;
import java.util.List;
/**
* Verwaltet die Spielzeit innerhalb eines Tages (0.01.0).
* 0.0 = Mitternacht, 0.25 = 6 Uhr (Sonnenaufgang), 0.5 = Mittag, 0.75 = 18 Uhr.
* Keine JME-Abhängigkeit reine Zeitlogik.
*/
public class DayTime {
/** Standard-Tagesdauer in Echtzeit-Sekunden (5 Minuten). */
public static final float DEFAULT_DAY_DURATION = 300f;
private float timeOfDay;
private float timeScale;
private boolean paused;
private final List<TimeListener> listeners = new ArrayList<>();
public DayTime() {
this(DEFAULT_DAY_DURATION);
}
/** @param dayDuration Echtzeit-Sekunden für einen kompletten Spieltag */
public DayTime(float dayDuration) {
this.timeScale = 1f / dayDuration;
this.timeOfDay = 0.25f; // Start: Sonnenaufgang
}
public void update(float tpf) {
if (paused) return;
timeOfDay = (timeOfDay + tpf * timeScale) % 1f;
for (TimeListener l : listeners) l.onTimeChanged(timeOfDay);
}
/** Tageszeit 0.01.0. */
public float getTimeOfDay() { return timeOfDay; }
public void setTimeOfDay(float t) { timeOfDay = ((t % 1f) + 1f) % 1f; }
/** @param seconds Echtzeit-Sekunden pro Spieltag */
public void setDayDuration(float seconds) { timeScale = 1f / seconds; }
public void setPaused(boolean paused) { this.paused = paused; }
public boolean isPaused() { return paused; }
/** Aktuelle Spielstunde (023). */
public int getHour() { return (int)(timeOfDay * 24f); }
/** Aktuelle Spielminute (059). */
public int getMinute() { return (int)(timeOfDay * 24f * 60f) % 60; }
public void addListener(TimeListener l) { listeners.add(l); }
public void removeListener(TimeListener l) { listeners.remove(l); }
}

View File

@@ -0,0 +1,7 @@
package de.blight.common.time;
@FunctionalInterface
public interface TimeListener {
/** @param timeOfDay 0.0 = Mitternacht, 0.25 = 6 Uhr, 0.5 = Mittag, 0.75 = 18 Uhr */
void onTimeChanged(float timeOfDay);
}

View File

@@ -10,7 +10,7 @@ javafx {
}
application {
mainClass = 'de.blight.editor.EditorLauncher'
mainClass = 'de.blight.editor.BlightEditor'
applicationDefaultJvmArgs = [
'--add-opens', 'java.base/java.lang=ALL-UNNAMED',
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
@@ -36,7 +36,13 @@ dependencies {
implementation "org.jmonkeyengine:jme3-lwjgl3:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-terrain:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-effects:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-plugins:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-testdata:${jmeVersion}"
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'
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}
tasks.register('extractNatives', Copy) {
@@ -65,6 +71,6 @@ run {
jar {
manifest {
attributes 'Main-Class': application.mainClass
attributes 'Main-Class': application.mainClass.get()
}
}

View File

@@ -1,11 +1,16 @@
package de.blight.editor;
import org.slf4j.bridge.SLF4JBridgeHandler;
/**
* Separater Launcher-Einstiegspunkt, damit der JavaFX-Klassenpfad korrekt
* aufgelöst wird, bevor Application.launch() aufgerufen wird.
*/
public class EditorLauncher {
public class BlightEditor {
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
// ProjectRoot muss als erstes initialisiert werden, damit alle
// relativen Pfade korrekt aufgelöst werden (auch bei IDE-Start mit
// workingDir = blight-editor/ statt Projekt-Root).

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +1,222 @@
package de.blight.editor;
import com.jme3.app.SimpleApplication;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeContext;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.state.EzTreeState;
import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import javafx.scene.image.WritableImage;
public class JmeEditorApp extends SimpleApplication {
private final SharedInput input;
private final WritableImage jfxImage;
private final int vpWidth;
private final int vpHeight;
public JmeEditorApp(SharedInput input, WritableImage jfxImage, int vpWidth, int vpHeight) {
this.input = input;
this.jfxImage = jfxImage;
this.vpWidth = vpWidth;
this.vpHeight = vpHeight;
}
/** Startet JME3 in einem Daemon-Thread (blockierend bis App endet). */
public static JmeEditorApp launch(SharedInput input, WritableImage jfxImage,
int vpWidth, int vpHeight) {
JmeEditorApp app = new JmeEditorApp(input, jfxImage, vpWidth, vpHeight);
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight Editor JME3");
settings.setResolution(vpWidth, vpHeight);
settings.setRenderer(AppSettings.LWJGL_OPENGL32);
settings.setAudioRenderer(null);
settings.setSamples(1);
app.setSettings(settings);
app.setShowSettings(false);
app.setPauseOnLostFocus(false);
Thread t = new Thread(() -> app.start(JmeContext.Type.OffscreenSurface), "jme3-editor");
t.setDaemon(true);
t.start();
return app;
}
@Override
public void simpleInitApp() {
flyCam.setEnabled(false);
// editor-assets/ im AssetManager registrieren, damit Texturen und Modelle
// aus diesem Verzeichnis geladen werden können (relativ zum Arbeitsverzeichnis).
try {
assetManager.registerLocator(
ProjectRoot.resolve("editor-assets").toAbsolutePath().toString(),
FileLocator.class);
} catch (Exception ignored) {}
// Texture2D-Attachment: readFrameBuffer() funktioniert nur mit Texture, nicht Renderbuffer
Texture2D colorTex = new Texture2D(vpWidth, vpHeight, Image.Format.RGBA8);
FrameBuffer fb = new FrameBuffer(vpWidth, vpHeight, 1);
fb.setDepthBuffer(Image.Format.Depth);
fb.setColorTexture(colorTex);
viewPort.setOutputFrameBuffer(fb);
// Frame-Export in das JavaFX-WritableImage
viewPort.addProcessor(new FrameTransfer(jfxImage));
stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input));
stateManager.attach(new EzTreeState(input));
stateManager.attach(new PalmGeneratorState(input));
}
@Override
public void simpleUpdate(float tpf) {
com.jme3.math.Vector3f loc = cam.getLocation();
com.jme3.math.Vector3f dir = cam.getDirection();
input.camX = loc.x;
input.camY = loc.y;
input.camZ = loc.z;
input.camYaw = (float) Math.toDegrees(Math.atan2(-dir.x, -dir.z));
input.camPitch = (float) Math.toDegrees(
Math.asin(Math.max(-1f, Math.min(1f, dir.y))));
String cmd = input.pendingCommand;
if (cmd != null) {
input.pendingCommand = null;
processCommand(cmd);
}
}
private void processCommand(String raw) {
String[] parts = raw.trim().split("\\s+");
switch (parts[0].toLowerCase()) {
case "goto" -> {
try {
if (parts.length >= 4) {
// goto x y z — direkte Koordinaten
float x = Float.parseFloat(parts[1]);
float y = Float.parseFloat(parts[2]);
float z = Float.parseFloat(parts[3]);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
input.consoleOutput = "Goto" + x + " / " + y + " / " + z;
} else if (parts.length >= 3) {
// goto x z — Bodenabstand beibehalten
float x = Float.parseFloat(parts[1]);
float z = Float.parseFloat(parts[2]);
TerrainEditorState tes =
stateManager.getState(TerrainEditorState.class);
float srcGround = tes != null
? tes.getTerrainHeightAt(cam.getLocation().x,
cam.getLocation().z)
: 0f;
float heightAboveGround = cam.getLocation().y - srcGround;
float dstGround = tes != null
? tes.getTerrainHeightAt(x, z)
: 0f;
float y = dstGround + heightAboveGround;
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
input.consoleOutput = "Goto → X=" + x + " Z=" + z
+ " (Y=" + String.format("%.1f", y) + ")";
} else {
input.consoleOutput = "Syntax: goto <x> <z> oder goto <x> <y> <z>";
}
} catch (NumberFormatException e) {
input.consoleOutput = "Fehler: Koordinaten müssen Zahlen sein";
}
}
case "help" -> input.consoleOutput =
"Befehle: goto <x> <z> | goto <x> <y> <z> | help";
default ->
input.consoleOutput = "Unbekannter Befehl: " + parts[0];
}
}
}
package de.blight.editor;
import com.jme3.app.SimpleApplication;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeContext;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
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.PlayToolState;
import de.blight.editor.state.SoundAreaState;
import de.blight.editor.state.WaterBodyState;
import de.blight.editor.state.EzTreeState;
import de.blight.editor.state.LightState;
import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import de.blight.game.console.JmeConsole;
import de.blight.game.state.DayNightState;
import javafx.scene.image.WritableImage;
public class JmeEditorApp extends SimpleApplication {
private final SharedInput input;
private final WritableImage initialImage;
private final int vpWidth;
private final int vpHeight;
private JmeConsole jmeConsole;
private FrameTransfer frameTransfer;
private int currentW;
private int currentH;
public JmeEditorApp(SharedInput input, WritableImage jfxImage, int vpWidth, int vpHeight) {
this.input = input;
this.initialImage = jfxImage;
this.vpWidth = vpWidth;
this.vpHeight = vpHeight;
}
/** Startet JME3 in einem Daemon-Thread (blockierend bis App endet). */
public static JmeEditorApp launch(SharedInput input, WritableImage jfxImage,
int vpWidth, int vpHeight) {
JmeEditorApp app = new JmeEditorApp(input, jfxImage, vpWidth, vpHeight);
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight Editor JME3");
settings.setResolution(vpWidth, vpHeight);
settings.setRenderer(AppSettings.LWJGL_OPENGL32);
settings.setAudioRenderer(null);
settings.setSamples(1);
app.setSettings(settings);
app.setShowSettings(false);
app.setPauseOnLostFocus(false);
Thread t = new Thread(() -> app.start(JmeContext.Type.OffscreenSurface), "jme3-editor");
t.setDaemon(true);
t.start();
return app;
}
@Override
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).
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf");
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb");
java.nio.file.Path blightAssets = ProjectRoot.resolve("blight-assets", "src", "main", "resources");
if (java.nio.file.Files.isDirectory(blightAssets)) {
assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class);
}
currentW = vpWidth;
currentH = vpHeight;
buildFrameBuffer(vpWidth, vpHeight, initialImage);
stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input));
stateManager.attach(new EzTreeState(input));
stateManager.attach(new PalmGeneratorState(input));
stateManager.attach(new LightState(input));
stateManager.attach(new EmitterState(input));
stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(input));
stateManager.attach(new MusicAreaState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input));
// JME-Konsole (Editor-Modus: kein RawInputListener Eingabe via SharedInput)
jmeConsole = new JmeConsole(false);
registerEditorCommands();
jmeConsole.setOnVisibilityChanged(open -> {
input.consoleIsOpen = open;
if (!open) input.consoleChars.clear();
});
stateManager.attach(jmeConsole);
}
private void registerEditorCommands() {
jmeConsole.registerCommand("goto", args -> {
try {
if (args.length >= 4) {
float x = Float.parseFloat(args[1]);
float y = Float.parseFloat(args[2]);
float z = Float.parseFloat(args[3]);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
return String.format("Goto → %.1f / %.1f / %.1f", x, y, z);
} else if (args.length >= 3) {
float x = Float.parseFloat(args[1]);
float z = Float.parseFloat(args[2]);
TerrainEditorState tes = stateManager.getState(TerrainEditorState.class);
float srcH = tes != null ? tes.getTerrainHeightAt(cam.getLocation().x, cam.getLocation().z) : 0f;
float dstH = tes != null ? tes.getTerrainHeightAt(x, z) : 0f;
float y = dstH + (cam.getLocation().y - srcH);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
return String.format("Goto → X=%.1f Z=%.1f (Y=%.1f)", x, z, y);
}
return "Syntax: goto <x> <z> oder goto <x> <y> <z>";
} catch (NumberFormatException e) {
return "Fehler: Koordinaten müssen Zahlen sein";
}
});
jmeConsole.registerCommand("time", args -> {
if (args.length < 2) return "Syntax: time <024> (0 = Mitternacht, 12 = Mittag)";
try {
float hours = Float.parseFloat(args[1]);
if (hours < 0 || hours > 24) return "Fehler: Wert zwischen 0 und 24";
DayNightState dns = stateManager.getState(DayNightState.class);
if (dns == null) return "Tag/Nacht-System nicht aktiv";
dns.getDayTime().setTimeOfDay(hours / 24f);
int h = (int) hours;
int m = (int)((hours - h) * 60f);
return String.format("Zeit gesetzt: %02d:%02d Uhr", h, m);
} catch (NumberFormatException e) {
return "Fehler: Zahl erwartet";
}
});
}
// ── Framebuffer-Verwaltung ────────────────────────────────────────────────
private void buildFrameBuffer(int w, int h, WritableImage image) {
Texture2D colorTex = new Texture2D(w, h, Image.Format.RGBA8);
FrameBuffer fb = new FrameBuffer(w, h, 1);
fb.setDepthBuffer(Image.Format.Depth);
fb.setColorTexture(colorTex);
viewPort.setOutputFrameBuffer(fb);
guiViewPort.setOutputFrameBuffer(fb);
frameTransfer = new FrameTransfer(image);
guiViewPort.addProcessor(frameTransfer);
}
private void resizeViewport(int newW, int newH) {
guiViewPort.removeProcessor(frameTransfer);
cam.resize(newW, newH, true);
guiViewPort.getCamera().resize(newW, newH, false);
WritableImage newImage = new WritableImage(newW, newH);
buildFrameBuffer(newW, newH, newImage);
if (jmeConsole != null) jmeConsole.rebuild();
input.resizedImage.set(newImage);
currentW = newW;
currentH = newH;
}
@Override
public void simpleUpdate(float tpf) {
// Viewport-Resize (von JavaFX angefordert)
int[] req = input.resizeRequest.getAndSet(null);
if (req != null && req[0] > 0 && req[1] > 0
&& (req[0] != currentW || req[1] != currentH)) {
resizeViewport(req[0], req[1]);
}
com.jme3.math.Vector3f loc = cam.getLocation();
com.jme3.math.Vector3f dir = cam.getDirection();
input.camX = loc.x;
input.camY = loc.y;
input.camZ = loc.z;
input.camYaw = (float) Math.toDegrees(Math.atan2(-dir.x, -dir.z));
input.camPitch = (float) Math.toDegrees(
Math.asin(Math.max(-1f, Math.min(1f, dir.y))));
if (jmeConsole == null) return;
// Toggle-Signal von JavaFX
if (input.consoleToggle) {
input.consoleToggle = false;
jmeConsole.toggle();
}
if (!jmeConsole.isOpen()) return;
// Zeichen-Eingabe
Character c;
while ((c = input.consoleChars.poll()) != null) jmeConsole.feedChar(c);
// Sondertasten
Integer key;
while ((key = input.consoleKeys.poll()) != null) {
switch (key) {
case 8 -> jmeConsole.feedBackspace();
case 10 -> jmeConsole.feedEnter();
case 27 -> jmeConsole.feedEscape();
}
}
}
}

View File

@@ -12,6 +12,7 @@ import javafx.scene.image.WritableImage;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/** Thread-safe Brücke: JavaFX-Events → JME3-Update-Schleife. */
public class SharedInput {
@@ -19,18 +20,27 @@ public class SharedInput {
// ── Aktive Tools ─────────────────────────────────────────────────────────
public final HeightTool heightTool = new HeightTool();
public final UpperHeightTool upperHeightTool = new UpperHeightTool();
public final HoleTool holeTool = new HoleTool();
public final GrassTool grassTool = new GrassTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool;
// ── Aktive Ebene: 0=Basis-Terrain, 1=Gebirge, 2=Höhlen, 3=Gras, 4=Textur ──
// ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ─────────────────────
public volatile int activeLayer = 0;
// ── Upper-Layer-Sichtbarkeit ─────────────────────────────────────────────
public volatile boolean upperLayerVisible = true;
// ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
public volatile boolean forward, backward, left, right, up, down;
// ── Mausrad (JavaFX akkumuliert, JME konsumiert einmal pro Frame) ────────
public final java.util.concurrent.atomic.AtomicInteger scrollAccum =
new java.util.concurrent.atomic.AtomicInteger();
// ── Shift: horizontale Bewegung (Y-Höhe wird beibehalten) ───────────────
public volatile boolean shiftHeld;
// ── Kamerarotation (Maus-Drag mit mittlerer Taste) ───────────────────────
private final AtomicInteger mouseDxAccum = new AtomicInteger();
private final AtomicInteger mouseDyAccum = new AtomicInteger();
@@ -48,10 +58,6 @@ public class SharedInput {
public record TerrainEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TerrainEdit> editQueue = new ConcurrentLinkedQueue<>();
// ── Upper-Layer-Edits ─────────────────────────────────────────────────────
public record UpperLayerEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<UpperLayerEdit> upperLayerEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Edits ────────────────────────────────────────────────────────────
/** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */
public record GrassEdit(float screenX, float screenY, int action) {}
@@ -62,10 +68,35 @@ public class SharedInput {
public record TextureEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TextureEdit> textureEditQueue = new ConcurrentLinkedQueue<>();
// ── Textur-Konfiguration (JavaFX → JME3) ─────────────────────────────────
/** Pfade der 4 Terrain-Textur-Slots ("" = Standard). JFX schreibt neue Referenz, JME liest. */
public volatile String[] terrainTexturePaths = new String[]{"", "", "", ""};
/** JFX setzt true, JME liest + resettet. */
public volatile boolean terrainTexturesChanged = false;
/** Normal-Map-Pfade der 4 Terrain-Slots ("" = keine). */
public volatile String[] terrainNormalMapPaths = new String[]{"", "", "", ""};
public volatile boolean terrainNormalMapsChanged = false;
/** Pfade der 4 Gebirge-Textur-Slots ("" = Standard). */
public volatile String[] upperTexturePaths = new String[]{"", "", "", ""};
public volatile boolean upperTexturesChanged = false;
/** Normal-Map-Pfade der 4 Gebirge-Slots ("" = keine). */
public volatile String[] upperNormalMapPaths = new String[]{"", "", "", ""};
public volatile boolean upperNormalMapsChanged = false;
/** JME setzt true nach Map-Load → JFX kann Textur-UI aktualisieren. */
public volatile boolean texturePathsLoaded = false;
// ── Viewport-Skalierung (JavaFX-Pixel → JME3-Pixel) ─────────────────────
public volatile double viewportScaleX = 1.0;
public volatile double viewportScaleY = 1.0;
// ── Viewport-Resize (JavaFX → JME → JavaFX) ──────────────────────────────
/** JavaFX setzt neue Zielgröße; JME liest einmalig per getAndSet(null). */
public final AtomicReference<int[]> resizeRequest = new AtomicReference<>();
/** JME setzt fertiges WritableImage nach Resize; JavaFX liest per getAndSet(null). */
public final AtomicReference<WritableImage> resizedImage = new AtomicReference<>();
// ── Mausposition im Viewport (JavaFX-Pixel, -1 = außerhalb) ─────────────
public volatile float mouseScreenX = -1f;
public volatile float mouseScreenY = -1f;
@@ -82,7 +113,8 @@ public class SharedInput {
public volatile int treePreviewW = 1024;
public volatile int treePreviewH = 1024;
public volatile String treeGenStatusMsg = null;
public volatile boolean refreshAssets = false;
public volatile boolean refreshAssets = false;
public volatile boolean refreshTreeFolders = false;
/**
* Aktuelles Vorschau-Bild. JME3 ersetzt die Referenz bei Größenänderung;
* treePreviewResized signalisiert JavaFX, die ImageView zu aktualisieren.
@@ -95,7 +127,7 @@ public class SharedInput {
public final ConcurrentLinkedQueue<TreeGenRequest> treeGenQueue = new ConcurrentLinkedQueue<>();
// ── EZ-Tree-Generator ─────────────────────────────────────────────────────
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, boolean exportAfter, String exportName) {}
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter, String exportName, String treeCategory) {}
public final ConcurrentLinkedQueue<EzTreeGenRequest> ezTreeGenQueue = new ConcurrentLinkedQueue<>();
// ── Palmen-Generator ──────────────────────────────────────────────────────
@@ -107,9 +139,27 @@ public class SharedInput {
public static final int LAYER_OBJECTS = 5;
/** activeLayer==6 → Objekte bearbeiten (Selektion + Gizmo) */
public static final int LAYER_OBJECTS_EDIT = 6;
/** activeLayer==7 → Lichtquellen platzieren und bearbeiten */
public static final int LAYER_LIGHTS = 7;
// Selektionsmodi (LAYER_OBJECTS_EDIT)
public static final int SEL_MODE_OBJECT = 0;
public static final int SEL_MODE_POLYGON = 1; // ganze Geometry (war SEL_MODE_FACE)
public static final int SEL_MODE_EDGE = 2;
public static final int SEL_MODE_VERTEX = 3; // einzelner Punkt
// Bearbeitungswerkzeuge (LAYER_OBJECTS_EDIT)
public static final int EDIT_TOOL_MOVE = 0;
public static final int EDIT_TOOL_ROTATE = 1;
public static final int EDIT_TOOL_SCALE = 2;
/** JavaFX → JME3: Aktiver Selektionsmodus. */
public volatile int objectSelectionMode = SEL_MODE_OBJECT;
/** JavaFX → JME3: Aktives Bearbeitungswerkzeug. */
public volatile int objectEditTool = EDIT_TOOL_MOVE;
/** Klick im Viewport: Objekt auswählen oder am Terrain-Treffpunkt platzieren. */
public record ObjectClick(float screenX, float screenY, boolean rightButton) {}
public record ObjectClick(float screenX, float screenY, boolean rightButton, boolean shift) {}
public final ConcurrentLinkedQueue<ObjectClick> objectClickQueue = new ConcurrentLinkedQueue<>();
/**
@@ -120,7 +170,9 @@ public class SharedInput {
public final ConcurrentLinkedQueue<ObjectDrag> objectDragQueue = new ConcurrentLinkedQueue<>();
/** Wird von JME3 gesetzt wenn ein neues Objekt oder eine neue Selektion vorliegt. */
public volatile String selectedObjectInfo = null; // "modelPath|solid|x|y|z|rotY|scale"
// Format: "1|modelPath|solid|x|y|z|rotX|rotY|rotZ|scale|texPath" (1 Objekt)
// "N" (N≥2 Objekte ausgewählt)
public volatile String selectedObjectInfo = null;
public volatile boolean objectSelectionChanged = false;
/** Wird von JME3 gesetzt, wenn ein Objekt gerade neu platziert wurde (nicht nur selektiert). */
public volatile boolean objectJustPlaced = false;
@@ -128,9 +180,41 @@ public class SharedInput {
/** JavaFX → JME3: Modell-Pfad für nächste Platzierung (relativ zu editor-assets/). */
public volatile String pendingModelPath = null;
/** Wenn gesetzt: Baum-Ordner-Platzierungsmodus. Relativ zu blight-assets/src/main/resources. */
public volatile String treeFolderPath = null;
/** Status-Meldung aus JME: welcher Baum gerade ausgewählt ist. */
public volatile String randomTreeStatus = "";
/** JavaFX → JME3: Textur-Pfad für nächste Platzierung (relativ zu editor-assets/, "" = keine). */
public volatile String pendingTexturePath = "";
/** JavaFX → JME3: Normal-Map-Pfad für nächste Platzierung ("" = keine). */
public volatile String pendingNormalMapPath = "";
/** JavaFX → JME3: Solid-Flag des selektierten Objekts ändern. */
public volatile Boolean pendingSolidChange = null;
/** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */
public volatile String pendingAnimClip = null;
// Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen
public record ObjectPropertyChange(
float x, float y, float z,
float rotX, float rotY, float rotZ,
boolean solid,
String texPath, // null = nicht ändern
String normalMapPath, // null = nicht ändern
String matPath // null = nicht ändern
) {}
public final ConcurrentLinkedQueue<ObjectPropertyChange> objectPropertyQueue =
new ConcurrentLinkedQueue<>();
// Selektion zusammenfassen (JavaFX → JME)
public volatile boolean mergeSelectedRequested = false;
// Selektion löschen (JavaFX → JME)
public volatile boolean deleteSelectedRequested = false;
// Als Vorlage speichern: Name (JavaFX → JME, null = kein Auftrag)
public volatile String saveAsTemplateRequest = null;
// ── Mesh-Erstellung ───────────────────────────────────────────────────────
/**
* Form: "Box" | "Kugel" | "Zylinder" | "Ebene"
@@ -159,21 +243,225 @@ public class SharedInput {
/** Pitch in Grad: positiv = Blick nach oben, negativ = nach unten. */
public volatile float camPitch = 0f;
// ── Konsole (JavaFX → JME3 und zurück) ──────────────────────────────────
/** Befehl, der beim nächsten JME3-Update ausgeführt werden soll. */
public volatile String pendingCommand = null;
/** Antworttext, den JME3 nach der Befehlsausführung setzt. */
// ── JME-Konsole: JavaFX → JME-Thread ────────────────────────────────────
/** Toggle-Signal: JavaFX setzt true, JME liest und setzt zurück. */
public volatile boolean consoleToggle = false;
/** Zeichen-Eingabe (druckbare Zeichen ≥ 0x20). */
public final ConcurrentLinkedQueue<Character> consoleChars = new ConcurrentLinkedQueue<>();
/** Sondertasten: 8=Backspace, 10=Enter, 27=Escape. */
public final ConcurrentLinkedQueue<Integer> consoleKeys = new ConcurrentLinkedQueue<>();
/** JME setzt diesen Wert; JavaFX liest ihn (z. B. für Eingabesperre). */
public volatile boolean consoleIsOpen = false;
/** @deprecated Nur noch für Rückwärtskompatibilität nicht mehr verwenden. */
@Deprecated public volatile String pendingCommand = null;
/** Status-/Fehlermeldungen von JME an JavaFX-Statusleiste. */
public volatile String consoleOutput = null;
// ── Vertex-Snap ───────────────────────────────────────────────────────────
/** Wenn true: Punkte, die beim Ziehen in Snap-Radius geraten, verschmelzen. */
public volatile boolean vertexSnapEnabled = false;
public volatile float vertexSnapRadius = 0.5f;
/** JavaFX setzt true beim Loslassen der Maustaste → JME3 führt Snap aus. */
public volatile boolean vertexSnapTrigger = false;
// ── Licht-Werkzeug ────────────────────────────────────────────────────────
/** Klick im Viewport im Licht-Modus: platzieren oder selektieren. */
public record LightClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<LightClick> lightClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Lichts. Format: "idx|x|y|z|r|g|b|intensity|radius" oder null. */
public volatile String selectedLightInfo = null;
public volatile boolean lightSelectionChanged = false;
/** JavaFX → JME: Eigenschaftsänderung des selektierten Lichts. */
public record LightPropertyChange(float r, float g, float b, float intensity, float radius) {}
public final AtomicReference<LightPropertyChange> pendingLightProp = new AtomicReference<>();
/** JavaFX → JME: Selektiertes Licht löschen. */
public volatile boolean deleteLightRequested = false;
// ── Emitter-Werkzeug ──────────────────────────────────────────────────────
/** activeLayer==8 → Partikel-Emitter platzieren und bearbeiten */
public static final int LAYER_EMITTERS = 8;
/** Klick im Viewport im Emitter-Modus: platzieren oder selektieren. */
public record EmitterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<EmitterClick> emitterClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Info des selektierten Emitters.
* Format: "idx|x|y|z|activationRadius|texturePath|imagesX|imagesY|
* startR|startG|startB|startA|endR|endG|endB|endA|
* startSize|endSize|velX|velY|velZ|velVar|
* gravX|gravY|gravZ|lowLife|highLife|maxParticles|emitRate"
* oder null wenn nichts gewählt.
*/
public volatile String selectedEmitterInfo = null;
public volatile boolean emitterSelectionChanged = false;
/** JavaFX → JME: vollständige aktualisierte Parameter des selektierten Emitters. */
public final AtomicReference<de.blight.common.PlacedEmitter> pendingEmitter = new AtomicReference<>();
/** JavaFX → JME: Selektierten Emitter löschen. */
public volatile boolean deleteEmitterRequested = false;
/** JavaFX → JME: Preset für nächsten neu platzierten Emitter (0=Feuer,1=Rauch,2=Funken). */
public volatile int emitterPreset = 0;
// ── Wasser-Werkzeug ───────────────────────────────────────────────────────
/** activeLayer==9 → Wasseroberflächen platzieren und bearbeiten */
public static final int LAYER_WATER = 9;
// ── Sound-Bereiche ────────────────────────────────────────────────────────
/** activeLayer==10 → Sound-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_SOUND_AREAS = 10;
// ── Musik-Bereiche ────────────────────────────────────────────────────────
/** activeLayer==11 → Musik-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_MUSIC_AREAS = 11;
// ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
public static final int LAYER_PLAY_TOOL = 12;
/** 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.
*/
public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter der selektierten Wasseroberfläche. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
public volatile boolean deleteWaterRequested = false;
// ── Sound-Bereich-Werkzeug ────────────────────────────────────────────────
public record SoundAreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<SoundAreaClick> soundAreaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Sound-Bereichs. Format: "idx|soundPath|volume|crossfade" oder null. */
public volatile String selectedSoundAreaInfo = null;
public volatile boolean soundAreaSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter des selektierten Sound-Bereichs. */
public final AtomicReference<de.blight.common.PlacedSoundArea> pendingSoundArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Sound-Bereich löschen. */
public volatile boolean deleteSoundAreaRequested = false;
// ── Musik-Bereich-Werkzeug ────────────────────────────────────────────────
public record MusicAreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<MusicAreaClick> musicAreaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Musik-Bereichs. Format: "idx|dayTrack|nightTrack|combatTrack" oder null. */
public volatile String selectedMusicAreaInfo = null;
public volatile boolean musicAreaSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter des selektierten Musik-Bereichs. */
public final AtomicReference<de.blight.common.PlacedMusicArea> pendingMusicArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Musik-Bereich löschen. */
public volatile boolean deleteMusicAreaRequested = false;
/** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */
public volatile boolean cancelZoneDrawing = false;
// ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** Klick im Viewport zum Setzen des temporären Spawnpunkts. */
public record PlayToolClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<PlayToolClick> playToolClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Terrain-Treffpunkt nach Spawn-Klick.
* Format: "x|z" oder null.
*/
public volatile String pickedSpawnInfo = null;
public volatile boolean spawnPickChanged = false;
/** Temporärer Spawnpunkt (NaN = nicht gesetzt). Wird beim Spielstart als System-Property übergeben. */
public volatile float tempSpawnX = Float.NaN;
public volatile float tempSpawnZ = Float.NaN;
// ── Animations-Vorschau ──────────────────────────────────────────────────
public volatile float animPreviewRotY = 0f;
public volatile float animPreviewRotX = 25f;
public volatile float animPreviewZoom = 1.0f;
public volatile float animPreviewSpeed = 1.0f;
public volatile int animPreviewW = 512;
public volatile int animPreviewH = 512;
public volatile WritableImage animPreviewImage = new WritableImage(512, 512);
public volatile boolean animPreviewResized = false;
/** JavaFX → JME3: Modell laden (relativer Asset-Pfad). null = kein Auftrag. */
public volatile String animPreviewLoadPath = null;
/** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */
public volatile String animPreviewPlayClip = null;
/** JavaFX → JME3: Animation-j3o-Pfad zum Retargeting + Hinzufügen. null = kein Auftrag. */
public volatile String animPreviewAddAnimPath = null;
/** JavaFX → JME3: Clip-Name zum Entfernen aus dem geladenen Modell. null = kein Auftrag. */
public volatile String animPreviewRemoveClip = null;
/** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */
public volatile boolean scanSkeletalRequested = false;
public volatile boolean animDumpRequested = false;
/** JME3 → JavaFX: Relative Pfade (Assets-Root) aller j3o mit Skelett; getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.Set<String>>
skeletalPaths = new java.util.concurrent.atomic.AtomicReference<>();
public volatile boolean animPreviewLoop = true;
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
public volatile String animPreviewStatus = null;
/** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewClips = new java.util.concurrent.atomic.AtomicReference<>();
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
* Wird von JavaFX benötigt, um die zugehörige .animset.json-Datei zu finden.
* getAndSet(null) konsumiert.
*/
public final java.util.concurrent.atomic.AtomicReference<String>
animPreviewLoadedPath = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animations-Clip umbenennen ────────────────────────────────────────────
public record ClipRenameRequest(String oldName, String newName) {}
public final java.util.concurrent.atomic.AtomicReference<ClipRenameRequest>
clipRenameRequest = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animations-Set speichern ──────────────────────────────────────────────
public record AnimSetSaveRequest(java.util.List<String> clips, String setName, java.util.Map<String, String> actionMap) {}
public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest>
animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */
public volatile String animOpStatus = null;
// ── Modell-Konvertierung ──────────────────────────────────────────────────
/**
* Konvertiert ein natives Modell (OBJ/GLTF/…) zu .j3o.
* assetPath : Pfad relativ zu editor-assets/ (z. B. "models/tree.obj")
* destJ3o : absoluter Ziel-Pfad der .j3o-Datei
* srcToDelete: absoluter Pfad der Original-Datei (wird nach Konvertierung gelöscht)
* assetPath : Pfad relativ zu blight-assets/src/main/resources/
* destJ3o : absoluter Ziel-Pfad der .j3o-Datei
* srcToDelete : absoluter Pfad der Original-Datei (wird nach Konvertierung gelöscht)
* keepControls : true = AnimComposer/SkinningControl bleiben erhalten (Animationen)
* yRotationDeg : Y-Rotation die beim Import auf das Root-Spatial angewendet wird (Grad)
* centerOrigin : true = Root-Translation auf (0,0,0) setzen
*/
public record ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete) {}
java.nio.file.Path srcToDelete, boolean keepControls,
float yRotationDeg, boolean centerOrigin) {
public ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete) {
this(assetPath, destJ3o, srcToDelete, false, 0f, false);
}
public ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete, boolean keepControls) {
this(assetPath, destJ3o, srcToDelete, keepControls, 0f, false);
}
}
public final ConcurrentLinkedQueue<ModelConvertRequest> modelConvertQueue =
new ConcurrentLinkedQueue<>();
}

View File

@@ -0,0 +1,131 @@
package de.blight.editor;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
/** Thin wrapper around the Tripo3D v2 REST API. All methods are blocking. */
public class TripoGenerator {
private static final String BASE = "https://api.tripo3d.ai/v2/openapi";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
// ── Data ─────────────────────────────────────────────────────────────────
public record TaskResult(
String taskId,
String status, // "queued" | "running" | "success" | "failed" | "cancelled"
int progress, // 0100
String modelUrl, // null until success
String previewUrl // null until success
) {}
// ── Public API ────────────────────────────────────────────────────────────
/** Creates a text-to-3D-model task and returns the task ID. */
public static String createTextToModelTask(String apiKey, String prompt) throws IOException {
String body = "{\"type\":\"text_to_model\",\"prompt\":" + jsonString(prompt) + "}";
return postTask(apiKey, body);
}
/**
* Creates a skeleton-rigging task from a previously generated model.
* Tripo adds a humanoid rig suitable for animation retargeting.
*/
public static String createRigTask(String apiKey, String originalTaskId) throws IOException {
String body = "{\"type\":\"animate_rig\",\"original_model_task_id\":" + jsonString(originalTaskId) + "}";
return postTask(apiKey, body);
}
/** Polls task status once (non-blocking polling loop is the caller's responsibility). */
public static TaskResult pollTask(String apiKey, String taskId) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/task/" + taskId))
.header("Authorization", "Bearer " + apiKey)
.GET()
.timeout(Duration.ofSeconds(30))
.build();
JsonObject data = send(req).getAsJsonObject("data");
String status = data.get("status").getAsString();
int progress = data.has("progress") ? data.get("progress").getAsInt() : 0;
String modelUrl = null, previewUrl = null;
if (data.has("output") && !data.get("output").isJsonNull()) {
JsonObject out = data.getAsJsonObject("output");
if (out.has("model")) modelUrl = out.get("model").getAsString();
if (out.has("rendered_image")) previewUrl = out.get("rendered_image").getAsString();
}
return new TaskResult(taskId, status, progress, modelUrl, previewUrl);
}
/** Downloads a URL to the given path (replaces existing file). */
public static void downloadFile(String url, Path dest) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(180))
.build();
try {
HttpResponse<InputStream> resp =
HTTP.send(req, HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() != 200)
throw new IOException("Download HTTP " + resp.statusCode());
Files.createDirectories(dest.getParent());
try (InputStream in = resp.body()) {
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Download unterbrochen", e);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static String postTask(String apiKey, String body) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/task"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(30))
.build();
return send(req).getAsJsonObject("data").get("task_id").getAsString();
}
private static JsonObject send(HttpRequest req) throws IOException {
try {
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject();
int code = json.has("code") ? json.get("code").getAsInt() : -1;
if (code != 0) {
String msg = json.has("message") ? json.get("message").getAsString() : "?";
throw new IOException("Tripo3D-Fehler " + code + ": " + msg);
}
return json;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("HTTP-Anfrage unterbrochen", e);
}
}
private static String jsonString(String s) {
return "\"" + s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
+ "\"";
}
}

View File

@@ -9,9 +9,14 @@ public class SceneObject extends PlacedObject {
private float worldXMut;
private float worldZMut;
private float rotY; // Y-Achsen-Rotation in Radiant
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 String modelPath; // relativ zu editor-assets/
public String texturePath = "";
public String normalMapPath = "";
public String materialPath = "";
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
boolean solid) {
@@ -19,6 +24,8 @@ public class SceneObject extends PlacedObject {
this.worldXMut = worldX;
this.worldZMut = worldZ;
this.rotY = 0f;
this.rotX = 0f;
this.rotZ = 0f;
this.scale = 1f;
this.solid = solid;
this.modelPath = modelPath;
@@ -30,7 +37,14 @@ public class SceneObject extends PlacedObject {
@Override public float getWorldZ() { return worldZMut; }
public float getRotY() { return rotY; }
public float getRotX() { return rotX; }
public float getRotZ() { return rotZ; }
public float getScale() { return scale; }
public String getTexturePath() { return texturePath; }
public String getNormalMapPath() { return normalMapPath; }
public String getMaterialPath() { return materialPath; }
public void setNormalMapPath(String p) { this.normalMapPath = p != null ? p : ""; }
public void setMaterialPath(String p) { this.materialPath = p != null ? p : ""; }
public void translate(float dx, float dy, float dz) {
worldXMut += dx;
@@ -38,6 +52,20 @@ public class SceneObject extends PlacedObject {
worldZMut += dz;
}
public void setPosition(float x, float y, float z) {
worldXMut = x;
this.groundY = y;
worldZMut = z;
}
public void setRotation(float rx, float ry, float rz) {
rotX = rx;
rotY = ry;
rotZ = rz;
}
public void setTexturePath(String path) { this.texturePath = path != null ? path : ""; }
public void rotateY(float deltaRad) { rotY += deltaRad; }
public void setScale(float s) { scale = s; }
}

View File

@@ -0,0 +1,835 @@
package de.blight.editor.state;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.anim.AnimClip;
import com.jme3.anim.AnimComposer;
import com.jme3.anim.SkinningControl;
import com.jme3.anim.tween.action.Action;
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.ModelKey;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.FrameTransfer;
import de.blight.editor.ProjectRoot;
import de.blight.editor.SharedInput;
import javafx.scene.image.WritableImage;
public class AnimPreviewState extends BaseAppState {
private static final Logger LOG = LoggerFactory.getLogger(AnimPreviewState.class);
private static final int PREVIEW_SIZE = 512;
private static final Path ASSET_ROOT =
ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private ViewPort previewVP;
private FrameBuffer previewFB;
private FrameTransfer previewTransfer;
private Node previewScene;
private Node previewHolder;
private Vector3f previewTarget = new Vector3f(0f, 1f, 0f);
private float previewCamDist = 3f;
private int currentW = PREVIEW_SIZE;
private int currentH = PREVIEW_SIZE;
private Spatial currentModel;
private String currentModelPath;
private Action currentAction;
private String currentClipName;
private Node axesNode;
public AnimPreviewState(SharedInput input) { this.input = input; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
previewFB = buildFrameBuffer(PREVIEW_SIZE, PREVIEW_SIZE);
Camera cam = new Camera(PREVIEW_SIZE, PREVIEW_SIZE);
cam.setFrustumPerspective(45f, 1f, 0.01f, 2000f);
previewVP = this.app.getRenderManager().createPostView("animPreview", cam);
previewVP.setOutputFrameBuffer(previewFB);
previewVP.setBackgroundColor(new ColorRGBA(0.18f, 0.18f, 0.22f, 1f));
previewVP.setClearFlags(true, true, true);
previewScene = new Node("animPreviewScene");
previewScene.addLight(new DirectionalLight(
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
previewScene.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.45f, 0.5f, 0.7f, 1f)));
previewScene.addLight(new AmbientLight(new ColorRGBA(0.4f, 0.4f, 0.45f, 1f)));
previewHolder = new Node("animHolder");
previewScene.attachChild(previewHolder);
axesNode = buildAxesNode();
previewScene.attachChild(axesNode);
previewVP.attachScene(previewScene);
previewTransfer = new FrameTransfer(input.animPreviewImage);
previewVP.addProcessor(previewTransfer);
}
@Override
protected void cleanup(Application app) {
if (previewVP != null) {
this.app.getRenderManager().removePostView(previewVP);
previewVP = null;
}
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Update-Schleife ───────────────────────────────────────────────────────
@Override
public void update(float tpf) {
String loadPath = input.animPreviewLoadPath;
if (loadPath != null) {
input.animPreviewLoadPath = null;
loadModel(loadPath);
}
String playClip = input.animPreviewPlayClip;
if (playClip != null) {
input.animPreviewPlayClip = null;
if (playClip.isEmpty()) stopAll();
else playClip(playClip);
}
String addAnimPath = input.animPreviewAddAnimPath;
if (addAnimPath != null) {
input.animPreviewAddAnimPath = null;
addAnimation(addAnimPath);
}
String removeClip = input.animPreviewRemoveClip;
if (removeClip != null) {
input.animPreviewRemoveClip = null;
removeAnimation(removeClip);
}
if (input.scanSkeletalRequested) {
input.scanSkeletalRequested = false;
new Thread(this::scanSkeletalModels, "skeletal-scan").start();
}
if (input.animDumpRequested) {
input.animDumpRequested = false;
dumpCurrentModel();
}
SharedInput.ClipRenameRequest renameReq = input.clipRenameRequest.getAndSet(null);
if (renameReq != null) executeClipRename(renameReq);
SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null);
if (setReq != null) executeAnimSetSave(setReq);
// Geschwindigkeit live anpassen
if (currentAction != null) {
try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {}
}
// Loop-Steuerung: AnimComposer spielt einmal ab wir starten manuell neu
if (currentClipName != null && currentModel != null && currentAction != null) {
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac != null) {
double length = currentAction.getLength();
double time = ac.getTime();
if (length <= 0) {
System.err.println("[AnimPreview] Loop-Check: length=0, Clip hat keine Dauer!");
} else if (time >= length - 0.02) {
LOG.trace("[AnimPreview] Loop-Check fired: time={} length={}", time, length);
if (input.animPreviewLoop) {
currentAction = ac.setCurrentAction(currentClipName);
if (currentAction != null) currentAction.setSpeed(input.animPreviewSpeed);
} else {
currentAction = null;
currentClipName = null;
setSkinningEnabled(currentModel, false);
}
}
}
}
// Resize
int reqW = Math.max(64, input.animPreviewW);
int reqH = Math.max(64, input.animPreviewH);
if (previewVP != null && (Math.abs(reqW - currentW) > 8 || Math.abs(reqH - currentH) > 8)) {
resizePreview(reqW, reqH);
}
// Kamera-Orbit
if (previewVP != null) {
float rotY = input.animPreviewRotY * FastMath.DEG_TO_RAD;
float rotX = input.animPreviewRotX * FastMath.DEG_TO_RAD;
float dist = previewCamDist * input.animPreviewZoom;
Camera c = previewVP.getCamera();
c.setLocation(new Vector3f(
previewTarget.x + FastMath.sin(rotY) * FastMath.cos(rotX) * dist,
previewTarget.y + FastMath.sin(rotX) * dist,
previewTarget.z + FastMath.cos(rotY) * FastMath.cos(rotX) * dist));
c.lookAt(previewTarget, Vector3f.UNIT_Y);
// Achsen: Größe proportional zur Kameradistanz, immer am Ursprung
if (axesNode != null) {
float s = previewCamDist * input.animPreviewZoom * 0.18f;
axesNode.setLocalScale(s);
axesNode.setLocalTranslation(previewTarget);
}
previewScene.updateLogicalState(tpf);
previewScene.updateGeometricState();
}
}
// ── Model laden ───────────────────────────────────────────────────────────
private void loadModel(String assetPath) {
previewHolder.detachAllChildren();
currentModel = null;
currentModelPath = null;
currentAction = null;
try {
Spatial model = loadFresh(assetPath);
// SkinningControl nur aktiv lassen wenn eine Animation läuft,
// sonst kollabiert das Mesh durch uninitalisierte Skin-Matrizen.
setSkinningEnabled(model, false);
// Im Animations-Editor soll der Charakter immer am Ursprung stehen.
// Eventuelle Translation die beim GLB-Export eingebacken wurde entfernen.
model.setLocalTranslation(Vector3f.ZERO);
currentModel = model;
currentModelPath = assetPath;
previewHolder.attachChild(model);
// Kamera auf Bounding Box ausrichten
model.updateGeometricState();
if (model.getWorldBound() instanceof BoundingBox bb) {
float ext = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
previewCamDist = ext * 2.8f;
previewTarget.set(bb.getCenter());
} else {
previewCamDist = 3f;
previewTarget.set(0, 1, 0);
}
input.animPreviewZoom = 1.0f;
// Clips sammeln und melden
List<String> clips = new ArrayList<>();
collectClips(model, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewLoadedPath.set(assetPath);
if (clips.isEmpty()) {
if (!hasSkeleton(model)) {
input.animPreviewStatus =
"Kein Skelett gefunden. Modell wurde möglicherweise ohne " +
"\"keepControls\" importiert bitte Datei neu importieren.";
} else {
input.animPreviewStatus = "Skelett vorhanden, aber keine Animations-Clips. " +
"Animation über 'Animation hinzufügen' laden.";
}
} else {
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
}
} catch (Exception e) {
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
input.animPreviewClips.set(List.of());
}
}
private void collectClips(Spatial s, List<String> out) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null) {
List<String> names = new ArrayList<>(ac.getAnimClipsNames());
Collections.sort(names);
out.addAll(names);
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) collectClips(child, out);
}
}
// ── Animation abspielen ───────────────────────────────────────────────────
private void playClip(String clipName) {
if (currentModel == null) return;
currentAction = null;
currentClipName = null;
// SkinningControls auf dem gesamten Modell aktivieren AnimComposer und
// SkinningControl sitzen oft auf verschiedenen Geschwisterknoten.
setSkinningEnabled(currentModel, true);
playOnSpatial(currentModel, clipName);
}
private boolean playOnSpatial(Spatial s, String clipName) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null && ac.getAnimClipsNames().contains(clipName)) {
try {
currentAction = ac.setCurrentAction(clipName);
currentClipName = clipName;
if (currentAction != null) {
currentAction.setSpeed(input.animPreviewSpeed);
System.err.println("[AnimPreview] Play '" + clipName
+ "' length=" + currentAction.getLength());
}
} catch (Exception e) {
input.animPreviewStatus = "Abspielen fehlgeschlagen: " + e.getMessage();
}
return true;
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
if (playOnSpatial(child, clipName)) return true;
}
}
return false;
}
private void stopAll() {
currentAction = null;
currentClipName = null;
if (currentModel != null) {
stopOnSpatial(currentModel);
setSkinningEnabled(currentModel, false);
}
}
private void stopOnSpatial(Spatial s) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null) {
try { ac.removeCurrentAction(AnimComposer.DEFAULT_LAYER); } catch (Exception ignored) {}
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) stopOnSpatial(child);
}
}
// ── Skelett-Scan ─────────────────────────────────────────────────────────
private void scanSkeletalModels() {
Set<String> result = new HashSet<>();
for (String dir : new String[]{"Models", "animations"}) {
Path base = ASSET_ROOT.resolve(dir);
if (!Files.isDirectory(base)) continue;
try (Stream<Path> walk = Files.walk(base)) {
walk.filter(p -> p.toString().endsWith(".j3o")).forEach(p -> {
String rel = ASSET_ROOT.relativize(p).toString().replace('\\', '/');
try {
Spatial model = assets.loadModel(rel);
if (hasSkeleton(model)) result.add(rel);
} catch (Exception ignored) {}
});
} catch (IOException ignored) {}
}
input.skeletalPaths.set(Collections.unmodifiableSet(result));
}
private boolean hasSkeleton(Spatial s) {
if (s.getControl(AnimComposer.class) != null) return true;
// Fallback auf altes AnimControl (Legacy-Modelle)
try {
if (s.getControl(com.jme3.animation.AnimControl.class) != null) return true;
} catch (Exception ignored) {}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
if (hasSkeleton(child)) return true;
}
}
return false;
}
// ── Animation hinzufügen (Retargeting) ───────────────────────────────────
private void addAnimation(String animAssetPath) {
if (currentModel == null) {
input.animPreviewStatus = "Fehler: zuerst ein Modell laden";
return;
}
AnimComposer targetAC = findControl(currentModel, AnimComposer.class);
SkinningControl targetSC = findControl(currentModel, SkinningControl.class);
if (targetAC == null) {
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
return;
}
try {
Spatial animSource = loadFresh(animAssetPath);
AnimComposer sourceAC = findControl(animSource, AnimComposer.class);
if (sourceAC == null) {
String controls = listControlTypes(animSource);
String info = "Kein AnimComposer | Controls: " + controls
+ " | Nodes: " + countNodes(animSource);
input.animPreviewStatus = info;
System.err.println("[AnimPreview] addAnimation " + info + " Datei: " + animAssetPath);
return;
}
if (sourceAC.getAnimClips().isEmpty()) {
input.animPreviewStatus = "AnimComposer leer (0 Clips) in: " + animAssetPath;
System.err.println("[AnimPreview] AnimComposer leer in " + animAssetPath);
return;
}
SkinningControl sourceSC = findControl(animSource, SkinningControl.class);
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());
} else {
System.err.println("[Retarget] Keine SkinningControl in Quelle!");
}
if (dstArm != null) {
System.err.println("[Retarget] Ziel-Knochen (" + dstArm.getJointCount() + "):");
for (var j : dstArm.getJointList()) System.err.println(" dst: " + j.getName());
} else {
System.err.println("[Retarget] Keine SkinningControl im Modell!");
}
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
if (retarget) {
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
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;
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)) {
System.err.println("[AnimPreview] Überspringe Blender-Duplikat: " + name);
continue;
}
}
AnimClip result = retarget
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
: clip;
if (result != null) {
targetAC.addAnimClip(result);
added++;
}
}
// 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();
} catch (Exception e) {
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
}
}
// ── Animation entfernen ──────────────────────────────────────────────────
private void removeAnimation(String clipName) {
if (currentModel == null) {
input.animPreviewStatus = "Fehler: kein Modell geladen";
return;
}
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) {
input.animPreviewStatus = "Fehler: kein AnimComposer";
return;
}
AnimClip clip = ac.getAnimClip(clipName);
if (clip == null) {
input.animPreviewStatus = "Clip nicht gefunden: " + clipName;
return;
}
if (clipName.equals(currentClipName)) stopAll();
ac.removeAnimClip(clip);
List<String> clips = new ArrayList<>();
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) {
T c = s.getControl(type);
if (c != null) return c;
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
T r = findControl(child, type);
if (r != null) return r;
}
}
return null;
}
// ── Achsen-Visualisierung ────────────────────────────────────────────────
// ── Achsen: Rot=X Grün=Y Blau=Z ──────────────────────────────────────────
// Solid-Box-Geometrie, depthTest=false → immer vor dem Modell sichtbar.
// Der axesNode sitzt am previewTarget (Modell-Mittelpunkt) und wird per
// setLocalScale auf ~18 % der Kameradistanz skaliert.
private Node buildAxesNode() {
Node n = new Node("axes");
n.attachChild(makeAxisShaft(ColorRGBA.Red, 0.5f, 0f, 0f, "X"));
n.attachChild(makeAxisShaft(ColorRGBA.Green, 0f, 0.5f, 0f, "Y"));
n.attachChild(makeAxisShaft(ColorRGBA.Blue, 0f, 0f, 0.5f, "Z"));
n.attachChild(makeAxisTip(ColorRGBA.Red, 0.5f, 0f, 0f ));
n.attachChild(makeAxisTip(ColorRGBA.Green, 0f, 0.5f, 0f ));
n.attachChild(makeAxisTip(ColorRGBA.Blue, 0f, 0f, 0.5f ));
addAxisLabel(n, new Vector3f(0.60f, 0.00f, 0.00f), "X", ColorRGBA.Red);
addAxisLabel(n, new Vector3f(0.00f, 0.60f, 0.00f), "Y", ColorRGBA.Green);
addAxisLabel(n, new Vector3f(0.00f, 0.00f, 0.60f), "Z", ColorRGBA.Blue);
return n;
}
/** Dünner Stab von (0,0,0) zum Punkt (ex,ey,ez), halb-Breite 0.015. */
private Geometry makeAxisShaft(ColorRGBA color, float ex, float ey, float ez, String name) {
float hw = 0.015f;
float hx = ex != 0 ? Math.abs(ex) / 2f : hw;
float hy = ey != 0 ? Math.abs(ey) / 2f : hw;
float hz = ez != 0 ? Math.abs(ez) / 2f : hw;
Geometry g = new Geometry("axis-" + name, new com.jme3.scene.shape.Box(hx, hy, hz));
// Mittelpunkt des Stabs liegt bei halber Länge
g.setLocalTranslation(ex / 2f, ey / 2f, ez / 2f);
g.setMaterial(unshadedDepthOff(color));
g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent);
return g;
}
/** Kleiner Würfel an der Spitze (tx,ty,tz). */
private Geometry makeAxisTip(ColorRGBA color, float tx, float ty, float tz) {
Geometry g = new Geometry("axis-tip",
new com.jme3.scene.shape.Box(0.04f, 0.04f, 0.04f));
g.setLocalTranslation(tx, ty, tz);
g.setMaterial(unshadedDepthOff(color));
g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent);
return g;
}
private Material unshadedDepthOff(ColorRGBA color) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setDepthWrite(false);
return mat;
}
private void addAxisLabel(Node parent, Vector3f pos, String text, ColorRGBA color) {
try {
BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt");
BitmapText label = new BitmapText(font, false);
label.setSize(0.18f);
label.setColor(color);
label.setText(text);
label.setLocalTranslation(pos);
parent.attachChild(label);
} catch (Exception ignored) {}
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
/** Listet alle Control-Typen im Subgraph für Diagnose-Ausgaben. */
private String listControlTypes(Spatial s) {
List<String> types = new ArrayList<>();
collectControlTypes(s, types);
return types.isEmpty() ? "(keine)" : String.join(", ", types);
}
private int countNodes(Spatial s) {
int n = 1;
if (s instanceof Node nd) for (Spatial c : nd.getChildren()) n += countNodes(c);
return n;
}
private void collectControlTypes(Spatial s, List<String> out) {
for (int i = 0; i < s.getNumControls(); i++)
out.add(s.getControl(i).getClass().getSimpleName());
if (s instanceof Node n)
for (Spatial child : n.getChildren()) collectControlTypes(child, out);
}
/** Speichert das aktuelle Modell (inkl. aller AnimClips) zurück auf Disk. */
private void saveModel() {
if (currentModelPath == null || currentModel == null) return;
if (!currentModelPath.endsWith(".j3o")) {
System.err.println("[AnimPreview] Speichern übersprungen kein .j3o: " + currentModelPath);
return;
}
Path file = ASSET_ROOT.resolve(currentModelPath.replace('/', java.io.File.separatorChar));
try {
BinaryExporter.getInstance().save(currentModel, file.toFile());
System.err.println("[AnimPreview] Modell gespeichert: " + currentModelPath);
assets.deleteFromCache(new ModelKey(currentModelPath));
} catch (Exception e) {
input.animPreviewStatus += " | Speicherfehler: " + e.getMessage();
System.err.println("[AnimPreview] Speicherfehler: " + e);
}
}
/** Lädt eine j3o-Datei direkt von Disk (BinaryImporter), ohne AssetManager-Cache. */
private Spatial loadFresh(String assetPath) throws Exception {
Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar));
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
BinaryImporter bi = BinaryImporter.getInstance();
bi.setAssetManager(assets);
return (Spatial) bi.load(file.toFile());
}
assets.deleteFromCache(new ModelKey(assetPath));
return assets.loadModel(assetPath);
}
/** Aktiviert oder deaktiviert alle SkinningControls im Subgraph. */
private void setSkinningEnabled(Spatial s, boolean enabled) {
SkinningControl sc = s.getControl(SkinningControl.class);
if (sc != null) sc.setEnabled(enabled);
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) setSkinningEnabled(child, enabled);
}
}
// ── Framebuffer ───────────────────────────────────────────────────────────
private FrameBuffer buildFrameBuffer(int w, int h) {
FrameBuffer fb = new FrameBuffer(w, h, 1);
fb.addColorTexture(new Texture2D(w, h, Image.Format.RGBA8));
fb.setDepthTexture(new Texture2D(w, h, Image.Format.Depth));
return fb;
}
private void resizePreview(int newW, int newH) {
currentW = newW;
currentH = newH;
previewVP.removeProcessor(previewTransfer);
try { previewFB.dispose(); } catch (Exception ignored) {}
previewFB = buildFrameBuffer(newW, newH);
previewVP.setOutputFrameBuffer(previewFB);
Camera cam = previewVP.getCamera();
cam.resize(newW, newH, true);
cam.setFrustumPerspective(45f, (float) newW / newH, 0.01f, 2000f);
WritableImage newImg = new WritableImage(newW, newH);
previewTransfer = new FrameTransfer(newImg);
previewVP.addProcessor(previewTransfer);
input.animPreviewImage = newImg;
input.animPreviewResized = true;
}
// ── Skelett-Dump ──────────────────────────────────────────────────────────
private void dumpCurrentModel() {
if (currentModel == null) {
System.err.println("[Dump] Kein Modell geladen.");
return;
}
System.err.println("=".repeat(70));
System.err.println("[Dump] Modell: " + currentModelPath);
com.jme3.anim.SkinningControl sc = findControl(currentModel, com.jme3.anim.SkinningControl.class);
com.jme3.anim.AnimComposer ac = findControl(currentModel, com.jme3.anim.AnimComposer.class);
if (sc == null) {
System.err.println("[Dump] Kein SkinningControl gefunden kein Skelett.");
} else {
com.jme3.anim.Armature arm = sc.getArmature();
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> ms = buildMS(arm);
System.err.println("[Dump] Skelett: " + arm.getJointCount() + " Knochen");
System.err.println("[Dump] ── Knochen (Name | Elternteil | bind-local° | live-local° | ms°) ──");
for (com.jme3.anim.Joint j : arm.getJointList()) {
String parent = j.getParent() != null ? j.getParent().getName() : "(root)";
com.jme3.math.Transform bind = j.getInitialTransform();
com.jme3.math.Quaternion bindRot = bind != null ? bind.getRotation() : new com.jme3.math.Quaternion();
// live = was AnimComposer aktuell in den Knochen geschrieben hat
com.jme3.math.Transform live = j.getLocalTransform();
com.jme3.math.Quaternion liveRot = live != null ? live.getRotation() : new com.jme3.math.Quaternion();
float[] be = bindRot.toAngles(null);
float[] le = liveRot.toAngles(null);
float[] mse = ms.get(j).toAngles(null);
System.err.printf("[Dump] %-30s parent=%-25s bind=[%6.1f %6.1f %6.1f]° live=[%6.1f %6.1f %6.1f]° ms=[%6.1f %6.1f %6.1f]°%n",
j.getName(), parent,
Math.toDegrees(be[0]), Math.toDegrees(be[1]), Math.toDegrees(be[2]),
Math.toDegrees(le[0]), Math.toDegrees(le[1]), Math.toDegrees(le[2]),
Math.toDegrees(mse[0]), Math.toDegrees(mse[1]), Math.toDegrees(mse[2]));
}
// Bone-Name-Mapping gegen Standard-Namen
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(arm, arm);
System.err.println("[Dump] ── Bone-Name-Normalisierung ──");
for (com.jme3.anim.Joint j : arm.getJointList()) {
String norm = de.blight.game.animation.BoneNameMapping.normalize(j.getName());
System.err.printf("[Dump] %-30s → normalized: %s%n", j.getName(), norm);
}
}
if (ac == null) {
System.err.println("[Dump] Kein AnimComposer gefunden keine Clips.");
} else {
System.err.println("[Dump] ── Clips ──");
for (com.jme3.anim.AnimClip clip : ac.getAnimClips()) {
System.err.printf("[Dump] Clip: %-40s Dauer=%.3fs Tracks=%d%n",
clip.getName(), clip.getLength(), clip.getTracks().length);
for (com.jme3.anim.AnimTrack<?> track : clip.getTracks()) {
if (track instanceof com.jme3.anim.TransformTrack tt
&& tt.getTarget() instanceof com.jme3.anim.Joint j) {
System.err.printf("[Dump] Track: %-28s frames=%d%n",
j.getName(),
tt.getTimes() != null ? tt.getTimes().length : 0);
}
}
}
}
System.err.println("=".repeat(70));
input.animPreviewStatus = "Dump ins Log geschrieben (stderr)";
}
// ── Clip umbenennen / exportieren ─────────────────────────────────────────
private void executeClipRename(SharedInput.ClipRenameRequest 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; }
AnimClip src = ac.getAnimClip(req.oldName());
if (src == null) { input.animOpStatus = "Clip nicht gefunden: " + req.oldName(); return; }
// Neuen AnimClip mit neuem Namen und denselben Tracks erstellen
AnimClip renamed = new AnimClip(req.newName());
renamed.setTracks(src.getTracks());
ac.addAnimClip(renamed);
saveModel();
// Als eigenständige .j3o nach animations/ 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
java.util.List<String> clips = new java.util.ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips));
input.animOpStatus = "Clip als '" + req.newName() + "' gespeichert";
} catch (Exception e) {
input.animOpStatus = "Export-Fehler: " + e.getMessage();
}
}
// ── 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)";
} catch (Exception e) {
input.animOpStatus = "Set-Fehler: " + e.getMessage();
}
}
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<>();
for (com.jme3.anim.Joint j : arm.getJointList()) buildMSRec(j, cache);
return cache;
}
private static com.jme3.math.Quaternion buildMSRec(
com.jme3.anim.Joint j,
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache) {
com.jme3.math.Quaternion cached = cache.get(j);
if (cached != null) return cached;
com.jme3.math.Quaternion local = j.getInitialTransform() != null
? j.getInitialTransform().getRotation() : new com.jme3.math.Quaternion();
com.jme3.math.Quaternion result = j.getParent() == null
? new com.jme3.math.Quaternion(local)
: buildMSRec(j.getParent(), cache).mult(local);
cache.put(j, result);
return result;
}
}

View File

@@ -0,0 +1,326 @@
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.effect.ParticleEmitter;
import com.jme3.effect.ParticleMesh;
import com.jme3.material.Material;
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.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.PlacedEmitter;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
public class EmitterState extends BaseAppState {
private static final float MARKER_RADIUS = 0.4f;
private static final float ACTIVATION_ALPHA = 0.07f;
private static final ColorRGBA SEL_COLOR = new ColorRGBA(1f, 1f, 0f, 1f);
private static final String GEO_MARKER = "emitter_marker";
private static final String GEO_ACTRADIUS = "activation_sphere";
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists: emitters[i] <-> markers[i] <-> particles[i]
private final List<PlacedEmitter> emitters = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private final List<ParticleEmitter> particles = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedEmitter> pendingEmitters = null;
public EmitterState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingEmitters != null) {
loadPlacedEmitters(pendingEmitters);
pendingEmitters = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_EMITTERS) return;
SharedInput.EmitterClick click;
while ((click = input.emitterClickQueue.poll()) != null) {
handleClick(click);
}
PlacedEmitter pending = input.pendingEmitter.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.deleteEmitterRequested) {
input.deleteEmitterRequested = false;
if (selectedIdx >= 0) removeEmitter(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.EmitterClick 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 selectEmitter(hit);
return;
}
if (click.rightButton()) { deselect(); return; }
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
PlacedEmitter pe = createPreset(input.emitterPreset, pt.x, pt.y, pt.z);
addEmitter(pe);
selectEmitter(emitters.size() - 1);
}
private static PlacedEmitter createPreset(int preset, float x, float y, float z) {
return switch (preset) {
case 1 -> PlacedEmitter.smoke(x, y, z);
case 2 -> PlacedEmitter.sparks(x, y, z);
default -> PlacedEmitter.fire(x, y, z);
};
}
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectEmitter(int idx) {
deselect();
selectedIdx = idx;
setMarkerColor(idx, SEL_COLOR);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < emitters.size()) {
PlacedEmitter e = emitters.get(selectedIdx);
setMarkerColor(selectedIdx, new ColorRGBA(e.startR(), e.startG(), e.startB(), 1f));
}
selectedIdx = -1;
input.selectedEmitterInfo = null;
input.emitterSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedEmitter e = emitters.get(idx);
input.selectedEmitterInfo = buildInfoString(idx, e);
input.emitterSelectionChanged = true;
}
static String buildInfoString(int idx, PlacedEmitter e) {
return String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%s|%d|%d"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%d|%.4f",
idx, e.x(), e.y(), e.z(), e.activationRadius(),
e.texturePath(), e.imagesX(), e.imagesY(),
e.startR(), e.startG(), e.startB(), e.startA(),
e.endR(), e.endG(), e.endB(), e.endA(),
e.startSize(), e.endSize(),
e.velX(), e.velY(), e.velZ(), e.velocityVariation(),
e.gravX(), e.gravY(), e.gravZ(),
e.lowLife(), e.highLife(), e.maxParticles(), e.emitRate());
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addEmitter(PlacedEmitter pe) {
Node marker = buildMarker(pe);
rootNode.attachChild(marker);
markers.add(marker);
ParticleEmitter particle = buildParticleEmitter(pe);
if (particle != null) rootNode.attachChild(particle);
particles.add(particle);
emitters.add(pe);
}
private void removeEmitter(int idx) {
rootNode.detachChild(markers.get(idx));
ParticleEmitter pe = particles.get(idx);
if (pe != null) rootNode.detachChild(pe);
emitters.remove(idx);
markers.remove(idx);
particles.remove(idx);
selectedIdx = -1;
input.selectedEmitterInfo = null;
input.emitterSelectionChanged = true;
}
private void clearAll() {
for (Node m : markers) rootNode.detachChild(m);
for (ParticleEmitter pe : particles) if (pe != null) rootNode.detachChild(pe);
emitters.clear();
markers.clear();
particles.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, PlacedEmitter updated) {
rootNode.detachChild(markers.get(idx));
ParticleEmitter oldPe = particles.get(idx);
if (oldPe != null) rootNode.detachChild(oldPe);
Node newMarker = buildMarker(updated);
setMarkerColor(newMarker, SEL_COLOR);
rootNode.attachChild(newMarker);
markers.set(idx, newMarker);
ParticleEmitter newPe = buildParticleEmitter(updated);
if (newPe != null) rootNode.attachChild(newPe);
particles.set(idx, newPe);
emitters.set(idx, updated);
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
private Node buildMarker(PlacedEmitter pe) {
ColorRGBA color = new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), 1f);
Material sphereMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
sphereMat.setColor("Color", color);
Geometry sphere = new Geometry(GEO_MARKER, new Sphere(10, 10, MARKER_RADIUS));
sphere.setMaterial(sphereMat);
Material activMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
activMat.setColor("Color", new ColorRGBA(color.r, color.g, color.b, ACTIVATION_ALPHA));
activMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
activMat.getAdditionalRenderState().setDepthWrite(false);
activMat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Front);
Geometry activSphere = new Geometry(GEO_ACTRADIUS, new Sphere(8, 8, pe.activationRadius()));
activSphere.setMaterial(activMat);
activSphere.setQueueBucket(RenderQueue.Bucket.Transparent);
Node node = new Node("emitter_node");
node.attachChild(activSphere);
node.attachChild(sphere);
node.setLocalTranslation(pe.x(), pe.y(), pe.z());
return node;
}
private ParticleEmitter buildParticleEmitter(PlacedEmitter pe) {
try {
ParticleEmitter effect = new ParticleEmitter(
"particle_effect", ParticleMesh.Type.Triangle, pe.maxParticles());
Material mat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
mat.setTexture("Texture", assets.loadTexture(pe.texturePath()));
effect.setMaterial(mat);
effect.setImagesX(pe.imagesX());
effect.setImagesY(pe.imagesY());
effect.setStartColor(new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), pe.startA()));
effect.setEndColor( new ColorRGBA(pe.endR(), pe.endG(), pe.endB(), pe.endA()));
effect.setStartSize(pe.startSize());
effect.setEndSize(pe.endSize());
effect.getParticleInfluencer()
.setInitialVelocity(new Vector3f(pe.velX(), pe.velY(), pe.velZ()));
effect.getParticleInfluencer().setVelocityVariation(pe.velocityVariation());
effect.setGravity(pe.gravX(), pe.gravY(), pe.gravZ());
effect.setLowLife(pe.lowLife());
effect.setHighLife(pe.highLife());
effect.setParticlesPerSec(pe.emitRate());
effect.setLocalTranslation(pe.x(), pe.y(), pe.z());
return effect;
} catch (Exception e) {
System.err.println("[EmitterState] Textur nicht ladbar: " + pe.texturePath()
+ "" + e.getMessage());
return null;
}
}
private void setMarkerColor(int idx, ColorRGBA color) {
setMarkerColor(markers.get(idx), color);
}
private static void setMarkerColor(Node node, ColorRGBA color) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && GEO_MARKER.equals(geo.getName())) {
geo.getMaterial().setColor("Color", color);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedEmitter> getPlacedEmitters() {
return new ArrayList<>(emitters);
}
public void loadPlacedEmitters(List<PlacedEmitter> loaded) {
if (rootNode == null) {
pendingEmitters = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedEmitter pe : loaded) addEmitter(pe);
}
}

View File

@@ -1,5 +1,7 @@
package de.blight.editor.state;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
@@ -19,8 +21,10 @@ import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
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.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
@@ -29,37 +33,45 @@ import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import de.blight.eztree.Tree;
import de.blight.eztree.TreeOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/**
* JME3-AppState für den EZ-Tree-Generator.
*
* Teilt den Vorschau-Viewport mit {@link TreeGeneratorState} (kein eigenes Framebuffer).
* Verarbeitet {@link SharedInput.EzTreeGenRequest}-Einträge aus der Queue,
* baut einen {@link Tree}-Node, weist Materialien zu und zeigt ihn in der Vorschau.
* Optional: .j3o-Export mit Impostor-PNG.
* Versucht zuerst, Geometrie über das npm-Paket @dgreenheck/ez-tree via Node.js
* zu generieren (höhere Qualität). Fällt auf den Java-Port zurück wenn Node.js
* nicht verfügbar ist oder fehlschlägt.
*/
public class EzTreeState extends BaseAppState {
private static final int IMPOSTOR_SIZE = 512;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private static final Logger log = LoggerFactory.getLogger(EzTreeState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private static final Path BLIGHT_ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private static final Path TOOLS_DIR = de.blight.editor.ProjectRoot.resolve("tools");
private static final Gson GSON = new Gson();
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private TreeGeneratorState previewHost;
// ── Laufende Capture-Operation ────────────────────────────────────────────
// ── Capture-Phase ────────────────────────────────────────────────────────
private SharedInput.EzTreeGenRequest pendingRequest = null;
private Node pendingTreeNode = null;
private ViewPort captureVP = null;
@@ -74,7 +86,6 @@ public class EzTreeState extends BaseAppState {
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
// previewHost via lazy-init in update() TreeGeneratorState evtl. noch nicht attached
}
@Override protected void cleanup(Application app) { cleanupCapture(); }
@@ -85,7 +96,6 @@ public class EzTreeState extends BaseAppState {
@Override
public void update(float tpf) {
// Lazy-init: TreeGeneratorState muss initialisiert sein, bevor wir darauf zugreifen
if (previewHost == null) {
previewHost = getStateManager().getState(TreeGeneratorState.class);
if (previewHost == null) return;
@@ -104,12 +114,14 @@ public class EzTreeState extends BaseAppState {
private void startGeneration(SharedInput.EzTreeGenRequest req) {
cleanupCapture();
Tree tree = new Tree(req.options());
tree.generate();
applyMaterials(tree, req.options());
tree.updateGeometricState();
Node treeNode = tryNodeJsGeneration(req);
if (treeNode == null) {
treeNode = javaFallback(req);
}
final Node finalNode = treeNode;
finalNode.updateGeometricState();
BoundingBox bb = boundsOf(tree);
BoundingBox bb = boundsOf(finalNode);
float camDist = bb != null
? Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3.2f
: 20f;
@@ -117,14 +129,12 @@ public class EzTreeState extends BaseAppState {
? new Vector3f(0f, bb.getCenter().y, 0f)
: new Vector3f(0f, 5f, 0f);
// Szenenänderung über enqueue() läuft am Anfang des nächsten Frames,
// bevor TreeGeneratorState.update() updateGeometricState() aufruft.
final float dist = camDist;
final Vector3f tgt = target;
app.enqueue(() -> {
previewHost.setPreviewContent(tree, dist, tgt);
previewHost.setPreviewContent(finalNode, dist, tgt);
if (req.exportAfter()) {
setupCapture(tree, boundsOf(tree), req);
setupCapture(finalNode, boundsOf(finalNode), req);
}
});
@@ -135,9 +145,196 @@ public class EzTreeState extends BaseAppState {
}
}
// ── Node.js-Generierung ───────────────────────────────────────────────────
private Node tryNodeJsGeneration(SharedInput.EzTreeGenRequest req) {
String presetName = req.presetName();
if (presetName == null || presetName.isBlank()) return null;
Path polyfill = TOOLS_DIR.resolve("dom_polyfill.cjs");
Path script = TOOLS_DIR.resolve("ez_tree_generate.mjs");
if (!Files.exists(polyfill) || !Files.exists(script)) return null;
String nodeInput = GSON.toJson(new NodeInput(presetName, buildJsParams(req.options())));
try {
ProcessBuilder pb = new ProcessBuilder(
"node",
"--require", polyfill.toAbsolutePath().toString(),
script.toAbsolutePath().toString(),
nodeInput
);
pb.directory(TOOLS_DIR.toFile());
pb.redirectErrorStream(false);
Process proc = pb.start();
// read stdout and stderr concurrently to avoid pipe-buffer deadlock
StringBuilder out = new StringBuilder();
StringBuilder err = new StringBuilder();
Thread outT = new Thread(() -> {
try (InputStream is = proc.getInputStream()) {
out.append(new String(is.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException ignored) {}
});
Thread errT = new Thread(() -> {
try (InputStream is = proc.getErrorStream()) {
err.append(new String(is.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException ignored) {}
});
outT.start(); errT.start();
boolean ok = proc.waitFor(30, TimeUnit.SECONDS);
outT.join(5000); errT.join(1000);
if (!ok || proc.exitValue() != 0) {
log.warn("[EzTree] Node.js Fehler (exit {}): {}", proc.exitValue(), err.toString().trim());
return null;
}
if (!err.isEmpty()) log.debug("[EzTree] Node.js stderr: {}", err.toString().trim());
return buildNodeFromJson(out.toString(), req);
} catch (Exception e) {
log.warn("[EzTree] Node.js nicht verfügbar: {}", e.getMessage());
return null;
}
}
private record NodeInput(String preset, java.util.Map<String, Object> params) {}
private static java.util.Map<String, Object> buildJsParams(TreeOptions opts) {
var p = new java.util.LinkedHashMap<String, Object>();
p.put("seed", opts.seed);
p.put("type", opts.type == de.blight.eztree.TreeType.EVERGREEN ? "evergreen" : "deciduous");
// bark
var bark = new java.util.LinkedHashMap<String, Object>();
bark.put("tint", rgbToTint(opts.bark.r, opts.bark.g, opts.bark.b));
bark.put("flatShading", opts.bark.flatShading);
bark.put("textureScale", java.util.Map.of("x", opts.bark.textureScaleX, "y", opts.bark.textureScaleY));
p.put("bark", bark);
// branch — only send user-controlled value; all internal preset values (angle, children,
// gnarliness, length, radius, sections, segments, start, taper, twist) are left to the
// JS preset file, because Java has adapted them for its own renderer (capped pine children,
// JME-scaled length/radius, degrees instead of radians for twist, etc.)
p.put("branch", java.util.Map.of("levels", opts.branch.levels));
// leaves
var leaves = new java.util.LinkedHashMap<String, Object>();
leaves.put("tint", rgbToTint(opts.leaves.r, opts.leaves.g, opts.leaves.b));
leaves.put("billboard", opts.leaves.billboard == de.blight.eztree.Billboard.CROSS ? "double" : "single");
leaves.put("count", opts.leaves.count);
leaves.put("start", opts.leaves.start);
leaves.put("size", opts.leaves.size * 5f); // Java stores size ÷ 5 vs JS absolute
leaves.put("sizeVariance", opts.leaves.sizeVariance);
leaves.put("alphaTest", opts.leaves.alphaTest);
leaves.put("angle", opts.leaves.angle);
p.put("leaves", leaves);
// trellis
p.put("trellis", java.util.Map.of("enabled", opts.trellis.enabled));
return p;
}
private static int rgbToTint(float r, float g, float b) {
return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255);
}
private Node buildNodeFromJson(String json, SharedInput.EzTreeGenRequest req) {
try {
JsonObject root = GSON.fromJson(json, JsonObject.class);
Mesh branchMesh = parseMesh(root.getAsJsonObject("branches"), true);
Mesh leafMesh = parseMesh(root.getAsJsonObject("leaves"), true);
Geometry barkGeo = new Geometry("bark", branchMesh);
Geometry leavesGeo = new Geometry("leaves", leafMesh);
barkGeo.setMaterial(buildBarkMat(req.options()));
barkGeo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
leavesGeo.setMaterial(buildLeafMat(req.options()));
leavesGeo.setQueueBucket(RenderQueue.Bucket.Transparent);
leavesGeo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
Node node = new Node("EzTree");
node.attachChild(barkGeo);
node.attachChild(leavesGeo);
return node;
} catch (Exception e) {
log.warn("[EzTree] JSON→Mesh Fehler: {}", e.getMessage());
return null;
}
}
private static Mesh parseMesh(JsonObject geo, boolean hasUvs) {
float[] positions = toFloatArray(geo.getAsJsonArray("positions"));
float[] normals = toFloatArray(geo.getAsJsonArray("normals"));
int[] indices = toIntArray(geo.getAsJsonArray("indices"));
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3,
BufferUtils.createFloatBuffer(positions));
if (normals.length > 0) {
mesh.setBuffer(VertexBuffer.Type.Normal, 3,
BufferUtils.createFloatBuffer(normals));
}
if (hasUvs && geo.has("uvs")) {
float[] uvs = toFloatArray(geo.getAsJsonArray("uvs"));
if (uvs.length > 0) {
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2,
BufferUtils.createFloatBuffer(uvs));
}
}
// Use short buffer if fits, otherwise int buffer
if (indices.length > 0) {
if (canFitShort(indices)) {
short[] shorts = new short[indices.length];
for (int i = 0; i < indices.length; i++) shorts[i] = (short) indices[i];
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createShortBuffer(shorts));
} else {
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createIntBuffer(indices));
}
}
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
private static float[] toFloatArray(com.google.gson.JsonArray arr) {
float[] out = new float[arr.size()];
for (int i = 0; i < out.length; i++) out[i] = arr.get(i).getAsFloat();
return out;
}
private static int[] toIntArray(com.google.gson.JsonArray arr) {
int[] out = new int[arr.size()];
for (int i = 0; i < out.length; i++) out[i] = arr.get(i).getAsInt();
return out;
}
private static boolean canFitShort(int[] indices) {
for (int idx : indices) if (idx > 65535) return false;
return true;
}
// ── Java-Fallback ─────────────────────────────────────────────────────────
private Node javaFallback(SharedInput.EzTreeGenRequest req) {
Tree tree = new Tree(req.options());
tree.generate();
applyMaterials(tree, req.options());
return tree;
}
// ── Phase 2: Impostor-Capture ─────────────────────────────────────────────
private void setupCapture(Tree tree, BoundingBox bb, SharedInput.EzTreeGenRequest req) {
private void setupCapture(Node treeNode, BoundingBox bb, SharedInput.EzTreeGenRequest req) {
BoundingBox safeBb = bb != null ? bb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f);
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8);
@@ -145,22 +342,25 @@ public class EzTreeState extends BaseAppState {
captureFB.addColorTexture(capTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth));
captureVP = buildCaptureViewPort(tree, safeBb, captureFB);
captureVP = buildCaptureViewPort(treeNode, safeBb, captureFB);
captureReady = false;
pendingRequest = req;
pendingTreeNode = tree;
pendingTreeNode = treeNode;
input.treeGenStatusMsg = "EZ-Tree: rendere Impostor…";
}
private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4);
app.getRenderer().readFrameBuffer(captureFB, pixels);
SharedInput.EzTreeGenRequest req = pendingRequest;
Node treeNode = pendingTreeNode;
cleanupCapture();
String exportName = pendingRequest.exportName() + "_"
String exportName = req.exportName() + "_"
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
saveImpostor(pixels, "ez_impostor_" + exportName);
exportTree(pendingTreeNode, exportName);
exportTree(treeNode, req.exportName(), req.treeCategory());
pendingRequest = null;
pendingTreeNode = null;
@@ -183,7 +383,6 @@ public class EzTreeState extends BaseAppState {
}
}
} else if (child instanceof Node trellis) {
// Trellis-Node: Rinden-Material auf alle Geometrien
Material mat = buildBarkMat(opts);
for (Spatial s : trellis.getChildren()) {
if (s instanceof Geometry g) g.setMaterial(mat.clone());
@@ -238,7 +437,7 @@ public class EzTreeState extends BaseAppState {
// ── Offscreen-Viewport für Impostor ───────────────────────────────────────
private ViewPort buildCaptureViewPort(Tree src, BoundingBox bb, FrameBuffer fb) {
private ViewPort buildCaptureViewPort(Node src, BoundingBox bb, FrameBuffer fb) {
Vector3f center = bb.getCenter().add(0f, 2f, 0f);
float extent = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
float dist = extent * 3f;
@@ -279,7 +478,7 @@ public class EzTreeState extends BaseAppState {
return vp;
}
private static Node cloneForCapture(Tree src) {
private static Node cloneForCapture(Node src) {
Node copy = new Node("ezCap");
copy.setLocalTranslation(src.getLocalTranslation());
for (Spatial child : src.getChildren()) {
@@ -304,8 +503,9 @@ public class EzTreeState extends BaseAppState {
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static BoundingBox boundsOf(Tree tree) {
if (tree.getWorldBound() instanceof BoundingBox bb) return bb;
private static BoundingBox boundsOf(Node node) {
node.updateModelBound();
if (node.getWorldBound() instanceof BoundingBox bb) return bb;
return null;
}
@@ -337,19 +537,24 @@ public class EzTreeState extends BaseAppState {
Files.createDirectories(texDir);
ImageIO.write(img, "PNG", texDir.resolve(name + ".png").toFile());
} catch (IOException e) {
System.err.println("[EzTreeState] Impostor-Fehler: " + e.getMessage());
log.error("[EzTree] Impostor-Fehler: {}", e.getMessage());
}
}
private void exportTree(Node treeNode, String name) {
private void exportTree(Node treeNode, String name, String treeCategory) {
try {
Path modelDir = ASSET_ROOT.resolve("models");
Files.createDirectories(modelDir);
File out = modelDir.resolve("EzTree_" + name + ".j3o").toFile();
Path baseDir = (treeCategory != null && !treeCategory.isBlank())
? BLIGHT_ASSET_ROOT.resolve("trees").resolve(treeCategory)
: ASSET_ROOT.resolve("models");
Files.createDirectories(baseDir);
File out = baseDir.resolve(name + ".j3o").toFile();
BinaryExporter.getInstance().save(treeNode, out);
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
input.refreshAssets = true;
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
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();
}
}

View File

@@ -0,0 +1,320 @@
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.light.PointLight;
import com.jme3.material.Material;
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.Cylinder;
import com.jme3.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.PlacedLight;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
public class LightState extends BaseAppState {
// ── Glühbirnen-Geometrie ──────────────────────────────────────────────────
private static final float BULB_RADIUS = 0.38f;
private static final float SOCKET_RADIUS = 0.14f;
private static final float SOCKET_HEIGHT = 0.28f;
private static final float GLOW_RADIUS = BULB_RADIUS * 2.6f;
private static final float GLOW_ALPHA = 0.16f;
private static final String GEO_BULB = "bulb";
private static final String GEO_SOCKET = "socket";
private static final String GEO_GLOW = "glow";
private static final ColorRGBA SEL_COLOR = new ColorRGBA(1f, 1f, 0f, 1f);
private static final ColorRGBA SOCKET_COLOR = new ColorRGBA(0.22f, 0.22f, 0.22f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists: lights[i] <-> markers[i] <-> pointLights[i]
private final List<PlacedLight> lights = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private final List<PointLight> pointLights = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedLight> pendingLights = null;
public LightState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override
protected void cleanup(Application application) {
clearAll();
}
@Override
protected void onEnable() {
if (pendingLights != null) {
loadPlacedLights(pendingLights);
pendingLights = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_LIGHTS) return;
SharedInput.LightClick click;
while ((click = input.lightClickQueue.poll()) != null) {
handleClick(click);
}
SharedInput.LightPropertyChange prop = input.pendingLightProp.getAndSet(null);
if (prop != null && selectedIdx >= 0) {
applyProperty(selectedIdx, prop);
}
if (input.deleteLightRequested) {
input.deleteLightRequested = false;
if (selectedIdx >= 0) removeLight(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.LightClick 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 {
selectLight(hit);
}
return;
}
if (click.rightButton()) {
deselect();
return;
}
// Neues Licht auf dem Terrain platzieren
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
PlacedLight pl = new PlacedLight(pt.x, pt.y + 1f, pt.z, 1f, 1f, 1f, 1f, 20f);
addLight(pl);
selectLight(lights.size() - 1);
}
/** Trifft den Glow-Halo oder den Bulb irgendeines Markers. */
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectLight(int idx) {
deselect();
selectedIdx = idx;
setBulbColor(idx, SEL_COLOR);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < lights.size()) {
PlacedLight l = lights.get(selectedIdx);
setBulbColor(selectedIdx, new ColorRGBA(l.r(), l.g(), l.b(), 1f));
}
selectedIdx = -1;
input.selectedLightInfo = null;
input.lightSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedLight l = lights.get(idx);
input.selectedLightInfo = String.format(
java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f",
idx, l.x(), l.y(), l.z(), l.r(), l.g(), l.b(), l.intensity(), l.radius());
input.lightSelectionChanged = true;
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addLight(PlacedLight pl) {
PointLight pt = new PointLight();
pt.setColor(new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f).mult(pl.intensity()));
pt.setRadius(pl.radius());
pt.setPosition(new Vector3f(pl.x(), pl.y(), pl.z()));
rootNode.addLight(pt);
Node marker = buildMarker(pl);
rootNode.attachChild(marker);
lights.add(pl);
markers.add(marker);
pointLights.add(pt);
}
private void removeLight(int idx) {
rootNode.removeLight(pointLights.get(idx));
rootNode.detachChild(markers.get(idx));
lights.remove(idx);
markers.remove(idx);
pointLights.remove(idx);
selectedIdx = -1;
input.selectedLightInfo = null;
input.lightSelectionChanged = true;
}
private void clearAll() {
for (PointLight pl : pointLights) rootNode.removeLight(pl);
for (Node m : markers) rootNode.detachChild(m);
lights.clear();
markers.clear();
pointLights.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, SharedInput.LightPropertyChange prop) {
PlacedLight old = lights.get(idx);
PlacedLight updated = new PlacedLight(
old.x(), old.y(), old.z(),
prop.r(), prop.g(), prop.b(),
prop.intensity(), prop.radius());
lights.set(idx, updated);
PointLight pt = pointLights.get(idx);
pt.setColor(new ColorRGBA(prop.r(), prop.g(), prop.b(), 1f).mult(prop.intensity()));
pt.setRadius(prop.radius());
setGlowColor(idx, prop.r(), prop.g(), prop.b());
setBulbColor(idx, SEL_COLOR); // gelb während Selektion
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
/**
* Glühbirne: Glaskolben (Sphere) + Sockel (Cylinder) + weicher Leucht-Halo (Sphere, transparent).
* Naming convention:
* "bulb" Glaskolben, Farbe = Lichtfarbe (gelb wenn selektiert)
* "socket" Metallsockel, immer dunkelgrau
* "glow" Leuchthalo, immer Lichtfarbe (halbtransparent)
*/
private Node buildMarker(PlacedLight pl) {
ColorRGBA lightColor = new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f);
// Glaskolben
Material bulbMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
bulbMat.setColor("Color", lightColor);
Geometry bulb = new Geometry(GEO_BULB, new Sphere(12, 12, BULB_RADIUS));
bulb.setMaterial(bulbMat);
// Metallsockel (Zylinder unterhalb des Kolbens)
Material socketMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
socketMat.setColor("Color", SOCKET_COLOR);
Geometry socket = new Geometry(GEO_SOCKET,
new Cylinder(4, 10, SOCKET_RADIUS, SOCKET_HEIGHT, true));
socket.setMaterial(socketMat);
socket.rotate(FastMath.HALF_PI, 0, 0);
socket.setLocalTranslation(0, -(BULB_RADIUS + SOCKET_HEIGHT * 0.5f), 0);
// Leuchthalo (halbtransparente Sphere um den Kolben)
Material glowMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
glowMat.setColor("Color", new ColorRGBA(pl.r(), pl.g(), pl.b(), GLOW_ALPHA));
glowMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
glowMat.getAdditionalRenderState().setDepthWrite(false);
glowMat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Front);
Geometry glow = new Geometry(GEO_GLOW, new Sphere(8, 8, GLOW_RADIUS));
glow.setMaterial(glowMat);
glow.setQueueBucket(RenderQueue.Bucket.Transparent);
Node node = new Node("light_node");
node.attachChild(glow); // zuerst → transparent nach opaken
node.attachChild(bulb);
node.attachChild(socket);
node.setLocalTranslation(pl.x(), pl.y(), pl.z());
return node;
}
/** Setzt die Farbe des Glaskolbens (Auswahl-Feedback oder Lichtfarbe). */
private void setBulbColor(int idx, ColorRGBA color) {
findGeo(markers.get(idx), GEO_BULB, geo ->
geo.getMaterial().setColor("Color", color));
}
/** Aktualisiert den Glow-Halo auf die neue Lichtfarbe. */
private void setGlowColor(int idx, float r, float g, float b) {
findGeo(markers.get(idx), GEO_GLOW, geo ->
geo.getMaterial().setColor("Color", new ColorRGBA(r, g, b, GLOW_ALPHA)));
}
private static void findGeo(Node node, String name, java.util.function.Consumer<Geometry> action) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && name.equals(geo.getName())) {
action.accept(geo);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedLight> getPlacedLights() {
return new ArrayList<>(lights);
}
public void loadPlacedLights(List<PlacedLight> loaded) {
if (rootNode == null) {
pendingLights = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedLight pl : loaded) {
addLight(pl);
}
}
}

View File

@@ -0,0 +1,358 @@
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.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedMusicArea;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class MusicAreaState extends BaseAppState {
private static final float LINE_OFFSET_Y = 0.35f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.5f, 0.3f, 1f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.8f, 0.5f, 1f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedMusicArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedMusicArea> pendingAreas = null;
public MusicAreaState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingAreas != null) {
loadAreas(pendingAreas);
pendingAreas = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_MUSIC_AREAS) {
if (placing) cancelPoly();
return;
}
SharedInput.MusicAreaClick click;
while ((click = input.musicAreaClickQueue.poll()) != null) {
handleClick(click);
}
PlacedMusicArea pending = input.pendingMusicArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteMusicAreaRequested) {
input.deleteMusicAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.MusicAreaClick 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());
if (click.rightButton()) {
if (placing) closePoly();
else deselect();
return;
}
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
for (int i = 0; i < areas.size(); i++) {
PlacedMusicArea a = areas.get(i);
if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i);
return;
}
}
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST;
float bx = x, bz = z;
for (PlacedMusicArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = a.pointsX()[i]; bz = a.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedMusicArea area = new PlacedMusicArea(xs, zs, "", "", "");
addArea(area);
selectArea(areas.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("music_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("music_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectArea(int idx) {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedMusicArea a = areas.get(idx);
input.selectedMusicAreaInfo = idx + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack();
input.musicAreaSelectionChanged = true;
}
// ── Add / Remove / Apply ──────────────────────────────────────────────────
private void addArea(PlacedMusicArea area) {
areas.add(area);
List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ());
Geometry geo = buildLineGeo("music_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
areaGeos.add(geo);
}
private void removeArea(int idx) {
rootNode.detachChild(areaGeos.get(idx));
areas.remove(idx);
areaGeos.remove(idx);
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : areaGeos) rootNode.detachChild(g);
areas.clear();
areaGeos.clear();
cancelPoly();
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedMusicArea updated) {
if (updated.pointsX().length == 0) {
PlacedMusicArea existing = areas.get(idx);
areas.set(idx, new PlacedMusicArea(
existing.pointsX(), existing.pointsZ(),
updated.dayTrack(), updated.nightTrack(), updated.combatTrack()));
} else {
areas.set(idx, updated);
}
publishSelection(idx);
}
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedMusicArea> getPlacedAreas() {
return new ArrayList<>(areas);
}
public void loadAreas(List<PlacedMusicArea> loaded) {
if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedMusicArea a : loaded) addArea(a);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -3,6 +3,8 @@ package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
@@ -29,6 +31,8 @@ import java.time.format.DateTimeFormatter;
public class PalmGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PalmGeneratorState.class);
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private final SharedInput input;
@@ -194,9 +198,11 @@ public class PalmGeneratorState extends BaseAppState {
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile();
BinaryExporter.getInstance().save(palmNode, out);
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Palme exportiert: " + out.getName();
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Palme] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Palme Export-Fehler: " + e.getMessage();
}
}

View File

@@ -0,0 +1,103 @@
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.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.shape.Cylinder;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.editor.SharedInput;
public class PlayToolState extends BaseAppState {
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private Geometry spawnMarker;
public PlayToolState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { removeMarker(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_PLAY_TOOL) return;
SharedInput.PlayToolClick click;
while ((click = input.playToolClickQueue.poll()) != null) {
handleClick(click);
}
// Update marker position if spawn changed from text fields
if (!Float.isNaN(input.tempSpawnX) && !Float.isNaN(input.tempSpawnZ)) {
placeMarkerAt(input.tempSpawnX, input.tempSpawnZ);
}
}
private void handleClick(SharedInput.PlayToolClick 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());
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
input.tempSpawnX = pt.x;
input.tempSpawnZ = pt.z;
input.pickedSpawnInfo = pt.x + "|" + pt.z;
input.spawnPickChanged = true;
placeMarkerAt(pt.x, pt.z);
}
private void placeMarkerAt(float x, float z) {
float y = terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f;
if (Float.isNaN(y)) y = 0f;
if (spawnMarker == null) {
Cylinder cyl = new Cylinder(8, 16, 0.4f, 0.1f, true);
spawnMarker = new Geometry("spawn_marker", cyl);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0f, 1f, 0f, 1f));
spawnMarker.setMaterial(mat);
spawnMarker.rotate(FastMath.HALF_PI, 0, 0);
rootNode.attachChild(spawnMarker);
}
spawnMarker.setLocalTranslation(x, y + 0.05f, z);
}
private void removeMarker() {
if (spawnMarker != null) {
rootNode.detachChild(spawnMarker);
spawnMarker = null;
}
}
}

View File

@@ -0,0 +1,390 @@
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.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.mesh.IndexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedSoundArea;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.ShortBuffer;
import java.util.ArrayList;
import java.util.List;
public class SoundAreaState extends BaseAppState {
static final float SNAP_DIST = 8f;
private static final float LINE_OFFSET_Y = 0.3f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.1f, 0.85f, 0.4f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 1f, 0f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.3f, 0.9f, 1f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedSoundArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
// polygon being placed
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedSoundArea> pendingAreas = null;
public SoundAreaState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingAreas != null) {
loadAreas(pendingAreas);
pendingAreas = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_SOUND_AREAS) {
if (placing) cancelPoly();
return;
}
SharedInput.SoundAreaClick click;
while ((click = input.soundAreaClickQueue.poll()) != null) {
handleClick(click);
}
PlacedSoundArea pending = input.pendingSoundArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteSoundAreaRequested) {
input.deleteSoundAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.SoundAreaClick 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());
if (click.rightButton()) {
if (placing) closePoly();
else deselect();
return;
}
// get terrain hit
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
// snap to existing vertex?
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
// auto-close: if close to first vertex and ≥3 points
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
// try to select existing area
for (int i = 0; i < areas.size(); i++) {
PlacedSoundArea a = areas.get(i);
if (pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i);
return;
}
}
// no area hit start new polygon
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SNAP_DIST * SNAP_DIST;
float bx = x, bz = z;
for (PlacedSoundArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = a.pointsX()[i]; bz = a.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) {
cancelPoly();
return;
}
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedSoundArea area = new PlacedSoundArea(xs, zs, "", 1f, false);
addArea(area);
selectArea(areas.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
// ── In-progress visual ────────────────────────────────────────────────────
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("sound_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("sound_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectArea(int idx) {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedSoundAreaInfo = null;
input.soundAreaSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedSoundArea a = areas.get(idx);
input.selectedSoundAreaInfo = idx + "|" + a.soundPath() + "|" + a.volume() + "|" + a.crossfade();
input.soundAreaSelectionChanged = true;
}
// ── Add / Remove / Apply ──────────────────────────────────────────────────
private void addArea(PlacedSoundArea area) {
areas.add(area);
List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ());
Geometry geo = buildLineGeo("sound_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
areaGeos.add(geo);
}
private void removeArea(int idx) {
rootNode.detachChild(areaGeos.get(idx));
areas.remove(idx);
areaGeos.remove(idx);
selectedIdx = -1;
input.selectedSoundAreaInfo = null;
input.soundAreaSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : areaGeos) rootNode.detachChild(g);
areas.clear();
areaGeos.clear();
cancelPoly();
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedSoundArea updated) {
if (updated.pointsX().length == 0) {
// zero-length polygon = only update metadata, keep existing polygon
PlacedSoundArea existing = areas.get(idx);
areas.set(idx, new PlacedSoundArea(
existing.pointsX(), existing.pointsZ(),
updated.soundPath(), updated.volume(), updated.crossfade()));
} else {
areas.set(idx, updated);
}
publishSelection(idx);
}
// ── Line geometry builder ─────────────────────────────────────────────────
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedSoundArea> getPlacedAreas() {
return new ArrayList<>(areas);
}
public void loadAreas(List<PlacedSoundArea> loaded) {
if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedSoundArea a : loaded) addArea(a);
}
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
float xi = xs[i], zi = zs[i];
float xj = xs[j], zj = zs[j];
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi)) {
inside = !inside;
}
}
return inside;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -52,6 +52,8 @@ import de.blight.editor.FrameTransfer;
import de.blight.editor.SharedInput;
import de.blight.editor.tree.TreeMeshBuilder;
import de.blight.editor.tree.TreeParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* JME3-Zustand für den prozeduralen Baum-Generator.
@@ -64,6 +66,8 @@ import de.blight.editor.tree.TreeParams;
*/
public class TreeGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TreeGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final int PREVIEW_SIZE = 1024;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
@@ -563,12 +567,12 @@ public class TreeGeneratorState extends BaseAppState {
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.refreshAssets = true;
System.out.println("[TreeGenerator] Exportiert: " + out.getAbsolutePath());
} catch (IOException e) {
log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Export-Fehler: " + e.getMessage();
System.err.println("[TreeGenerator] Export-Fehler: " + e.getMessage());
}
}

View File

@@ -0,0 +1,273 @@
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.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Quad;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedWater;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
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);
private static final String GEO_SURFACE = "water_surface";
private static final String GEO_BORDER = "water_border";
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists
private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedWater> pendingBodies = null;
public WaterBodyState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingBodies != null) {
loadPlacedBodies(pendingBodies);
pendingBodies = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_WATER) return;
SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) {
handleClick(click);
}
PlacedWater pending = input.pendingWater.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.deleteWaterRequested) {
input.deleteWaterRequested = false;
if (selectedIdx >= 0) removeBody(selectedIdx);
}
}
// ── 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; }
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));
selectBody(bodies.size() - 1);
}
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectBody(int idx) {
deselect();
selectedIdx = idx;
setBorderColor(idx, BORDER_SEL);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < bodies.size()) {
setBorderColor(selectedIdx, BORDER_COLOR);
}
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
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());
input.waterSelectionChanged = true;
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addBody(PlacedWater b) {
Node marker = buildMarker(b);
rootNode.attachChild(marker);
markers.add(marker);
bodies.add(b);
}
private void removeBody(int idx) {
rootNode.detachChild(markers.get(idx));
bodies.remove(idx);
markers.remove(idx);
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
private void clearAll() {
for (Node m : markers) rootNode.detachChild(m);
bodies.clear();
markers.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;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedWater> getPlacedBodies() {
return new ArrayList<>(bodies);
}
public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) {
pendingBodies = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedWater b : loaded) addBody(b);
}
}

View File

@@ -25,8 +25,10 @@ public class HeightTool extends EditorTool {
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 50.0, 1.0, 500.0);
public final ToolParameter brushStrength = new ToolParameter("Pinselstärke", 2.0, 0.1, 50.0);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 50.0, 1.0, 500.0);
public final ToolParameter brushStrength = new ToolParameter("Pinselstärke", 2.0, 0.1, 50.0);
public final ToolParameter plateauHeight = new ToolParameter("Plateau-Höhe", 0.0, -500.0, 500.0);
public volatile boolean plateauHeightChanged = false;
@Override
public String getName() { return "Höhe"; }

View File

@@ -0,0 +1,119 @@
package de.blight.editor.ui;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
/**
* Dialog zur Auswahl einer JME-Material-Definition (.j3md).
*
* Zeigt JME-Standard-Materialien und eigene MatDefs aus dem Asset-Verzeichnis.
* Gibt den relativen Asset-Pfad (z.B. {@code Common/MatDefs/Light/Lighting.j3md}
* oder {@code MatDefs/Grass.j3md}) zurück, oder {@code null} bei Abbruch.
*/
public class MaterialChooser extends Dialog<String> {
private static final List<String> JME_MATERIALS = List.of(
"Common/MatDefs/Misc/Unshaded.j3md",
"Common/MatDefs/Light/Lighting.j3md",
"Common/MatDefs/Light/PBRLighting.j3md"
);
private final ToggleGroup toggleGroup = new ToggleGroup();
private final List<ToggleButton> allButtons = new ArrayList<>();
private String selectedPath;
/**
* @param matDefsRoot Verzeichnis mit eigenen .j3md-Dateien (darf {@code null} sein)
*/
public MaterialChooser(Path matDefsRoot) {
setTitle("Material auswählen");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
VBox contentBox = new VBox(12);
contentBox.setPadding(new Insets(4));
contentBox.getChildren().add(buildSection("JME Standard", JME_MATERIALS));
if (matDefsRoot != null && Files.isDirectory(matDefsRoot)) {
List<String> custom = new ArrayList<>();
try (Stream<Path> walk = Files.walk(matDefsRoot)) {
walk.filter(p -> p.toString().toLowerCase().endsWith(".j3md"))
.sorted()
.forEach(p -> custom.add("MatDefs/" + p.getFileName().toString()));
} catch (Exception ignored) {}
if (!custom.isEmpty())
contentBox.getChildren().add(buildSection("Eigene MatDefs", custom));
}
TextField filterField = new TextField();
filterField.setPromptText("Filtern...");
filterField.textProperty().addListener(
(obs, o, n) -> applyFilter(n == null ? "" : n.toLowerCase()));
ScrollPane scroll = new ScrollPane(contentBox);
scroll.setFitToWidth(true);
scroll.setPrefSize(460, 380);
scroll.setStyle("-fx-background-color: transparent;");
VBox root = new VBox(8, filterField, scroll);
root.setPadding(new Insets(10));
getDialogPane().setContent(root);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setText("OK");
okBtn.setDisable(true);
toggleGroup.selectedToggleProperty().addListener((obs, o, n) -> {
okBtn.setDisable(n == null);
selectedPath = (n instanceof ToggleButton tb) ? (String) tb.getUserData() : null;
});
setResultConverter(btn -> btn == ButtonType.OK ? selectedPath : null);
}
private VBox buildSection(String title, List<String> paths) {
Label lbl = new Label(title);
lbl.setStyle("-fx-font-weight: bold; -fx-font-size: 12;");
VBox items = new VBox(3);
for (String path : paths) {
String name = path.substring(path.lastIndexOf('/') + 1);
if (name.toLowerCase().endsWith(".j3md"))
name = name.substring(0, name.length() - 5);
ToggleButton btn = new ToggleButton(name);
btn.setUserData(path);
btn.setToggleGroup(toggleGroup);
btn.setMaxWidth(Double.MAX_VALUE);
btn.setTooltip(new Tooltip(path));
btn.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
selectedPath = path;
Button ok = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (ok != null) ok.fire();
}
});
items.getChildren().add(btn);
allButtons.add(btn);
}
return new VBox(4, lbl, new Separator(), items);
}
private void applyFilter(String lower) {
for (ToggleButton btn : allButtons) {
String path = (String) btn.getUserData();
boolean vis = lower.isBlank() || path.toLowerCase().contains(lower);
btn.setVisible(vis);
btn.setManaged(vis);
}
}
}

View File

@@ -0,0 +1,253 @@
package de.blight.editor.ui;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
/**
* Photo-explorer-style texture picker dialog.
*
* Shows user-imported textures from {@code assetRoot/textures/} and optionally
* JME built-in terrain textures from the classpath (jme3-testdata).
* Returns the selected JME asset path (relative to asset root for user textures,
* classpath path for JME textures), or {@code null} when cancelled.
*
* Usage:
* <pre>
* new TextureChooser(assetRoot, true)
* .showAndWait()
* .ifPresent(path -> { ... });
* </pre>
*/
public class TextureChooser extends Dialog<String> {
private static final int THUMB_SIZE = 80;
private static final int CARD_W = THUMB_SIZE + 20;
private static final int CARD_H = THUMB_SIZE + 38;
private static final Set<String> IMAGE_EXTS = Set.of(
".jpg", ".jpeg", ".png", ".bmp", ".tga", ".dds");
/** Curated list of JME built-in terrain diffuse textures (jme3-testdata). */
private static final List<String> JME_TEXTURES = List.of(
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/splat/dirt.jpg",
"Textures/Terrain/splat/road.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/Rocky/RockyTexture.jpg",
"Textures/Terrain/BrickWall/BrickWall.jpg",
"Textures/Terrain/Pond/Pond.jpg",
"Textures/Terrain/Rock/Rock.PNG",
"Textures/Terrain/PBR/Gravel015_1K_Color.png",
"Textures/Terrain/PBR/Ground036_1K_Color.png",
"Textures/Terrain/PBR/Ground037_1K_Color.png",
"Textures/Terrain/PBR/Marble013_1K_Color.png",
"Textures/Terrain/PBR/Rock035_1K_Color.png",
"Textures/Terrain/PBR/Snow006_1K_Color.png",
"Textures/Terrain/PBR/Tiles083_1K_Color.png"
);
private final ToggleGroup toggleGroup = new ToggleGroup();
private final VBox contentBox = new VBox(16);
private final TextField filterField = new TextField();
private final List<ToggleButton> allCards = new ArrayList<>();
private String selectedPath;
/**
* @param assetRoot path to the editor-assets directory (may be {@code null} to skip user textures)
* @param includeJmeBuiltin whether to include JME built-in terrain textures
*/
public TextureChooser(Path assetRoot, boolean includeJmeBuiltin) {
setTitle("Textur auswählen");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
contentBox.setPadding(new Insets(4));
// ── User-imported textures ────────────────────────────────────────────
if (assetRoot != null) {
Path texturesDir = assetRoot.resolve("Textures");
if (Files.isDirectory(texturesDir)) {
FlowPane userPane = newFlowPane();
addUserTextures(userPane, texturesDir, assetRoot);
if (!userPane.getChildren().isEmpty()) {
contentBox.getChildren().add(section("Eigene Texturen", userPane));
}
}
}
// ── JME built-in textures ─────────────────────────────────────────────
if (includeJmeBuiltin) {
FlowPane jmePane = newFlowPane();
for (String jmePath : JME_TEXTURES) {
ToggleButton card = buildJmeCard(jmePath);
jmePane.getChildren().add(card);
allCards.add(card);
}
if (!jmePane.getChildren().isEmpty()) {
contentBox.getChildren().add(section("JME Texturen", jmePane));
}
}
// ── Filter ────────────────────────────────────────────────────────────
filterField.setPromptText("Filtern...");
filterField.textProperty().addListener(
(obs, o, n) -> applyFilter(n == null ? "" : n.toLowerCase()));
ScrollPane scroll = new ScrollPane(contentBox);
scroll.setFitToWidth(true);
scroll.setPrefSize(680, 440);
scroll.setStyle("-fx-background-color: transparent;");
VBox root = new VBox(8, filterField, scroll);
root.setPadding(new Insets(10));
getDialogPane().setContent(root);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setText("OK");
okBtn.setDisable(true);
// ── Selection tracking ────────────────────────────────────────────────
toggleGroup.selectedToggleProperty().addListener((obs, o, n) -> {
okBtn.setDisable(n == null);
selectedPath = (n instanceof ToggleButton tb) ? (String) tb.getUserData() : null;
});
setResultConverter(btn -> btn == ButtonType.OK ? selectedPath : null);
}
// ── Builder helpers ───────────────────────────────────────────────────────
private static FlowPane newFlowPane() {
FlowPane fp = new FlowPane(8, 8);
fp.setPadding(new Insets(6, 4, 4, 4));
return fp;
}
private static VBox section(String title, FlowPane pane) {
Label lbl = new Label(title);
lbl.setStyle("-fx-font-weight: bold; -fx-font-size: 12;");
Separator sep = new Separator();
VBox box = new VBox(4, lbl, sep, pane);
return box;
}
// ── User textures ─────────────────────────────────────────────────────────
private void addUserTextures(FlowPane pane, Path texturesDir, Path assetRoot) {
try (Stream<Path> walk = Files.walk(texturesDir)) {
walk.filter(Files::isRegularFile)
.filter(p -> {
String low = p.getFileName().toString().toLowerCase();
return IMAGE_EXTS.stream().anyMatch(low::endsWith);
})
.sorted()
.forEach(file -> {
String assetPath = assetRoot.relativize(file)
.toString().replace('\\', '/');
Image img;
try {
img = new Image(file.toUri().toString(),
THUMB_SIZE, THUMB_SIZE, true, true, true);
} catch (Exception e) {
img = null;
}
String name = file.getFileName().toString();
ToggleButton card = buildCard(name, assetPath, img);
pane.getChildren().add(card);
allCards.add(card);
});
} catch (Exception ignored) {}
}
// ── JME textures ──────────────────────────────────────────────────────────
private ToggleButton buildJmeCard(String jmePath) {
Image img = null;
try {
InputStream is = TextureChooser.class.getClassLoader()
.getResourceAsStream(jmePath);
if (is != null) {
try (is) {
img = new Image(is, THUMB_SIZE, THUMB_SIZE, true, true);
}
}
} catch (Exception ignored) {}
String name = jmePath.substring(jmePath.lastIndexOf('/') + 1);
return buildCard(name, jmePath, img);
}
// ── Card factory ──────────────────────────────────────────────────────────
private ToggleButton buildCard(String displayName, String assetPath, Image img) {
ImageView iv = new ImageView(img);
iv.setFitWidth(THUMB_SIZE);
iv.setFitHeight(THUMB_SIZE);
iv.setPreserveRatio(true);
// Checkerboard background for transparency indication
iv.setStyle("-fx-background-color: #cccccc;");
Label lbl = new Label(truncate(displayName, 13));
lbl.setMaxWidth(CARD_W - 4);
lbl.setAlignment(Pos.CENTER);
lbl.setStyle("-fx-font-size: 10;");
VBox graphic = new VBox(4, iv, lbl);
graphic.setAlignment(Pos.TOP_CENTER);
graphic.setPrefSize(THUMB_SIZE, THUMB_SIZE + 18);
ToggleButton btn = new ToggleButton();
btn.setGraphic(graphic);
btn.setUserData(assetPath);
btn.setToggleGroup(toggleGroup);
btn.setPrefSize(CARD_W, CARD_H);
btn.setTooltip(new Tooltip(assetPath));
btn.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
selectedPath = assetPath;
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (okBtn != null) okBtn.fire();
}
});
return btn;
}
// ── Filter ────────────────────────────────────────────────────────────────
private void applyFilter(String lowerFilter) {
for (ToggleButton card : allCards) {
String path = (String) card.getUserData();
boolean visible = lowerFilter.isBlank()
|| path.toLowerCase().contains(lowerFilter);
card.setVisible(visible);
card.setManaged(visible);
}
}
// ── Utilities ─────────────────────────────────────────────────────────────
private static String truncate(String s, int max) {
if (s.length() <= max) return s;
int dot = s.lastIndexOf('.');
if (dot > 0 && s.length() - dot <= 5) {
String ext = s.substring(dot);
int keep = max - ext.length() - 1;
return (keep > 0 ? s.substring(0, keep) : "") + "" + ext;
}
return s.substring(0, max - 1) + "";
}
}

View File

@@ -0,0 +1,22 @@
/* Globale Textfarben verhindert weiße Labels bei dunklen System-Themes.
Inline-Styles (setStyle) haben höhere Spezifität und überschreiben diese Regeln. */
.label {
-fx-text-fill: #111111;
}
.check-box .text {
-fx-fill: #111111;
}
.radio-button .text {
-fx-fill: #111111;
}
.toggle-button {
-fx-text-fill: #111111;
}
.titled-pane > .title > .text {
-fx-fill: #111111;
}

View File

@@ -0,0 +1,30 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/blight-editor.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/blight-editor.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" bekanntes JME-Verhalten, kein Fehler -->
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

View File

@@ -5,7 +5,7 @@ plugins {
id 'java'
}
ext { mainClassName = 'de.blight.game.BlightApp' }
ext { mainClassName = 'de.blight.game.BlightGame' }
ext {
jmeVersion = '3.9.0-stable'
@@ -15,6 +15,7 @@ dependencies {
implementation project(':blight-common')
implementation project(':blight-assets')
implementation project(':blight-map')
implementation project(':blight-lang')
implementation "org.jmonkeyengine:jme3-core:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-desktop:${jmeVersion}"
@@ -22,8 +23,14 @@ dependencies {
implementation "org.jmonkeyengine:jme3-terrain:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-effects:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-jbullet:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-jogg:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-plugins:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-testdata:${jmeVersion}"
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'
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}
tasks.register('extractNatives', Copy) {

View File

@@ -6,9 +6,10 @@ 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 BlightApp extends SimpleApplication {
public class BlightGame extends SimpleApplication {
private KeyBindings keyBindings;
private GraphicsSettings graphicsSettings;
@@ -18,7 +19,10 @@ public class BlightApp extends SimpleApplication {
private PauseMenu pauseMenu;
public static void main(String[] args) {
BlightApp app = new BlightApp();
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
BlightGame app = new BlightGame();
GraphicsSettings gs = GraphicsStore.load();
AppSettings settings = new AppSettings(true);

View File

@@ -0,0 +1,69 @@
package de.blight.game.animation;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
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.
*/
public class AnimSet {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String SUFFIX = ".animset.json";
private List<String> clips = new ArrayList<>();
private Map<String, String> actionMap = new LinkedHashMap<>();
public List<String> getClips() { return clips; }
public void setClips(List<String> clips) { this.clips = clips; }
public Map<String, String> getActionMap() { return actionMap; }
public void setActionMap(Map<String, String> actionMap) { this.actionMap = actionMap; }
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
public void save(Path setDir, String setName) throws IOException {
Files.writeString(setDir.resolve(setName + SUFFIX), GSON.toJson(this));
}
/**
* Lädt ein AnimSet aus dem Verzeichnis {@code setDir}.
* Existiert keine Datei, wird ein leeres {@code AnimSet} zurückgegeben.
*/
public static AnimSet load(Path setDir, String setName) throws IOException {
Path f = setDir.resolve(setName + SUFFIX);
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);
}
}

View File

@@ -0,0 +1,24 @@
package de.blight.game.animation;
/**
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
*/
public enum AnimationAction {
IDLE,
WALK,
RUN,
JUMP,
DUCK;
/** Lesbare Bezeichnung für UI-Anzeige. */
public String displayName() {
return switch (this) {
case IDLE -> "Idle";
case WALK -> "Walk";
case RUN -> "Run";
case JUMP -> "Jump";
case DUCK -> "Duck";
};
}
}

View File

@@ -0,0 +1,201 @@
package de.blight.game.animation;
import com.jme3.anim.*;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.scene.Spatial;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
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").
*/
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",
".",
};
private AssetManager assetManager;
/** clip key → clip (bound to the SOURCE armature; retargeted before use) */
private final Map<String, AnimClip> clips = new LinkedHashMap<>();
/** clip key → armature the clip was loaded from */
private final Map<String, Armature> armatures = new LinkedHashMap<>();
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
assetManager = ((SimpleApplication) app).getAssetManager();
loadAll();
}
@Override protected void cleanup(Application app) { clips.clear(); armatures.clear(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Public API ────────────────────────────────────────────────────────────
/** All loaded clip keys (filename/clipname). */
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).
*
* @return true if the clip was applied successfully
*/
public boolean applyTo(String clipKey, Spatial model) {
AnimClip src = clips.get(clipKey);
Armature srcArm = armatures.get(clipKey);
if (src == null) return false;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
SkinningControl sc = RetargetingSystem.findSkinningControl(model);
if (ac == null || sc == null) return false;
String shortName = shortName(clipKey);
if (ac.getAnimClip(shortName) != null) return true; // already present
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());
}
} else {
target = src;
}
if (target == null) return false;
ac.addAnimClip(target);
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.
*/
public void applyAllTo(Spatial model) {
if (RetargetingSystem.findSkinningControl(model) == null) return;
int applied = 0;
for (String key : clips.keySet()) {
if (applyTo(key, model)) applied++;
}
if (applied > 0)
log.info("[AnimLib] {} Clips auf '{}' angewendet", applied, model.getName());
}
/**
* Applies the clip and immediately starts playing it.
*
* @return true on success
*/
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));
return true;
}
/**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Animations-Set zugeordnet ist.
*
* @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
*/
public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) {
AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath);
return set.getActionMap().get(action.name());
}
// ── Loading ───────────────────────────────────────────────────────────────
private void loadAll() {
Path animDir = findAnimDir();
if (animDir == null) {
log.info("[AnimLib] Kein Animations-Verzeichnis gefunden Bibliothek leer.");
return;
}
try (var walk = Files.walk(animDir)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.forEach(this::loadFromFile);
} catch (IOException e) {
log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage());
}
log.info("[AnimLib] {} Clips geladen.", clips.size());
}
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$", "");
try {
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);
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);
}
} catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());
}
}
private static Path findAnimDir() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base, "animations");
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;
}
}

View File

@@ -0,0 +1,106 @@
package de.blight.game.animation;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
/**
* Ordnet {@link AnimationAction}-Werte Clip-Namen zu.
* Wird als JSON-Companion-Datei neben dem Modell gespeichert:
* Models/charakter.j3o → Models/charakter.animmap
*
* @deprecated Ersetzt durch {@link AnimSet}, das sowohl Clip-Liste als auch
* Aktions-Zuweisung in einer einzigen {@code .animset.json}-Datei speichert.
*/
@Deprecated
public class AnimationMap {
private static final Logger log = LoggerFactory.getLogger(AnimationMap.class);
private static final Gson GSON = new Gson();
private final EnumMap<AnimationAction, String> map = new EnumMap<>(AnimationAction.class);
// ── API ────────────────────────────────────────────────────────────────────
public void set(AnimationAction action, String clipName) {
if (clipName == null || clipName.isBlank()) {
map.remove(action);
} else {
map.put(action, clipName);
}
}
/** Gibt den zugewiesenen Clip-Namen zurück, oder {@code null} wenn nicht belegt. */
public String get(AnimationAction action) {
return map.get(action);
}
public Map<AnimationAction, String> asMap() {
return new EnumMap<>(map);
}
// ── Persistenz ────────────────────────────────────────────────────────────
/**
* Speichert die Map in die Companion-Datei neben {@code modelPath}
* (Extension {@code .j3o} wird durch {@code .animmap} ersetzt).
*/
public void save(Path modelPath) {
Path target = companionPath(modelPath);
try {
Map<String, String> raw = new HashMap<>();
map.forEach((action, clip) -> raw.put(action.name(), clip));
Files.writeString(target, GSON.toJson(raw), StandardCharsets.UTF_8);
log.debug("[AnimMap] Gespeichert: {}", target);
} catch (IOException e) {
log.warn("[AnimMap] Speicherfehler {}: {}", target, e.getMessage());
}
}
/**
* Lädt die Companion-Datei und gibt eine {@link AnimationMap} zurück.
* Existiert keine Datei, wird eine leere Map zurückgegeben.
*/
public static AnimationMap load(Path modelPath) {
AnimationMap result = new AnimationMap();
Path source = companionPath(modelPath);
if (!Files.exists(source)) return result;
try {
String json = Files.readString(source, StandardCharsets.UTF_8);
Type type = new TypeToken<Map<String, String>>(){}.getType();
Map<String, String> raw = GSON.fromJson(json, type);
if (raw != null) {
raw.forEach((key, clip) -> {
try {
result.map.put(AnimationAction.valueOf(key), clip);
} catch (IllegalArgumentException ignored) {
// Unbekannte Aktion aus zukünftiger Version ignorieren
}
});
}
log.debug("[AnimMap] Geladen: {}", source);
} catch (IOException e) {
log.warn("[AnimMap] Ladefehler {}: {}", source, e.getMessage());
}
return result;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
/** Leitet den Companion-Pfad aus dem Modell-Pfad ab. */
public static Path companionPath(Path modelPath) {
String name = modelPath.getFileName().toString();
String base = name.endsWith(".j3o") ? name.substring(0, name.length() - 4) : name;
return modelPath.resolveSibling(base + ".animmap");
}
}

View File

@@ -0,0 +1,121 @@
package de.blight.game.animation;
import com.jme3.anim.Armature;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* Normalizes bone names across different skeleton conventions (Mixamo, standard, Tripo3D).
* Used by RetargetingSystem to map source joints to target joints.
*/
public final class BoneNameMapping {
private BoneNameMapping() {}
// Mixamo "mixamorig:" prefix → unprefixed standard name
private static final Map<String, String> MIXAMO_TO_STD = new HashMap<>();
static {
String[] bones = {
"Hips", "Spine", "Spine1", "Spine2",
"Neck", "Head", "HeadTop_End",
"LeftEye", "RightEye",
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
"LeftHandThumb1", "LeftHandThumb2", "LeftHandThumb3", "LeftHandThumb4",
"LeftHandIndex1", "LeftHandIndex2", "LeftHandIndex3", "LeftHandIndex4",
"LeftHandMiddle1", "LeftHandMiddle2", "LeftHandMiddle3", "LeftHandMiddle4",
"LeftHandRing1", "LeftHandRing2", "LeftHandRing3", "LeftHandRing4",
"LeftHandPinky1", "LeftHandPinky2", "LeftHandPinky3", "LeftHandPinky4",
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
"RightHandThumb1", "RightHandThumb2", "RightHandThumb3", "RightHandThumb4",
"RightHandIndex1", "RightHandIndex2", "RightHandIndex3", "RightHandIndex4",
"RightHandMiddle1", "RightHandMiddle2", "RightHandMiddle3", "RightHandMiddle4",
"RightHandRing1", "RightHandRing2", "RightHandRing3", "RightHandRing4",
"RightHandPinky1", "RightHandPinky2", "RightHandPinky3", "RightHandPinky4",
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "LeftToe_End",
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase", "RightToe_End",
};
for (String b : bones) {
MIXAMO_TO_STD.put("mixamorig:" + b, b);
MIXAMO_TO_STD.put("mixamorig_" + b, b);
}
}
// Finger joints carry no useful motion in walking clips.
private static final Set<String> FINGER_EXCLUDE = Set.of(
"lefthandthumb1", "lefthandthumb2", "lefthandthumb3", "lefthandthumb4",
"lefthandindex1", "lefthandindex2", "lefthandindex3", "lefthandindex4",
"lefthandmiddle1", "lefthandmiddle2", "lefthandmiddle3", "lefthandmiddle4",
"lefthandring1", "lefthandring2", "lefthandring3", "lefthandring4",
"lefthandpinky1", "lefthandpinky2", "lefthandpinky3", "lefthandpinky4",
"righthandthumb1", "righthandthumb2", "righthandthumb3", "righthandthumb4",
"righthandindex1", "righthandindex2", "righthandindex3", "righthandindex4",
"righthandmiddle1", "righthandmiddle2", "righthandmiddle3", "righthandmiddle4",
"righthandring1", "righthandring2", "righthandring3", "righthandring4",
"righthandpinky1", "righthandpinky2", "righthandpinky3", "righthandpinky4"
);
// Tripo3D humanoid rig → Mixamo standard name
private static final Map<String, String> TRIPO_TO_STD = new HashMap<>();
static {
// Hip ist Elternteil von Pelvis UND Waist — kein direktes Mixamo-Äquivalent.
// Pelvis ist der direkte Elternteil der Oberschenkelknochen, wie Mixamo-Hips.
TRIPO_TO_STD.put("Pelvis", "Hips");
TRIPO_TO_STD.put("Waist", "Spine");
TRIPO_TO_STD.put("Spine01", "Spine1");
TRIPO_TO_STD.put("Spine02", "Spine2");
// Nur NeckTwist01 → Neck; NeckTwist02 ist ein Twist-Knochen und folgt NeckTwist01.
TRIPO_TO_STD.put("NeckTwist01", "Neck");
TRIPO_TO_STD.put("L_Clavicle", "LeftShoulder");
TRIPO_TO_STD.put("L_Upperarm", "LeftArm");
TRIPO_TO_STD.put("L_Forearm", "LeftForeArm");
TRIPO_TO_STD.put("L_Hand", "LeftHand");
TRIPO_TO_STD.put("R_Clavicle", "RightShoulder");
TRIPO_TO_STD.put("R_Upperarm", "RightArm");
TRIPO_TO_STD.put("R_Forearm", "RightForeArm");
TRIPO_TO_STD.put("R_Hand", "RightHand");
TRIPO_TO_STD.put("L_Thigh", "LeftUpLeg");
TRIPO_TO_STD.put("L_Calf", "LeftLeg");
TRIPO_TO_STD.put("L_Foot", "LeftFoot");
TRIPO_TO_STD.put("L_ToeBase", "LeftToeBase");
TRIPO_TO_STD.put("R_Thigh", "RightUpLeg");
TRIPO_TO_STD.put("R_Calf", "RightLeg");
TRIPO_TO_STD.put("R_Foot", "RightFoot");
TRIPO_TO_STD.put("R_ToeBase", "RightToeBase");
}
public static Map<String, String> buildMapping(Armature source, Armature target) {
Map<String, String> result = new HashMap<>();
Map<String, String> targetByNorm = new HashMap<>();
for (var joint : target.getJointList())
targetByNorm.put(normalize(joint.getName()), joint.getName());
for (var joint : source.getJointList()) {
String sName = joint.getName();
String norm = normalize(sName);
if (FINGER_EXCLUDE.contains(norm)) continue;
if (target.getJoint(sName) != null) {
result.put(sName, sName);
continue;
}
String tName = targetByNorm.get(norm);
if (tName != null) result.put(sName, tName);
}
return result;
}
public static String normalize(String name) {
String n = MIXAMO_TO_STD.getOrDefault(name, name);
n = TRIPO_TO_STD.getOrDefault(n, n);
if (n.startsWith("mixamorig:")) n = n.substring("mixamorig:".length());
if (n.startsWith("mixamorig_")) n = n.substring("mixamorig_".length());
return n.replace("_", "").replace(".", "").replace(" ", "").toLowerCase();
}
}

View File

@@ -0,0 +1,421 @@
package de.blight.game.animation;
import com.jme3.anim.*;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.Control;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
/**
* Model-space retargeting with correct parent-chain propagation.
*
* For each mapped dst joint j:
* dstActualMS[j][k] = dstBindMS[j] × inv(srcBindMS[j]) × srcAnimMS[j][k]
*
* For each unmapped dst joint j (needed for parent propagation):
* dstActualMS[j][k] = dstActualMS[parent][k] × dstBindLocal[j]
*
* Local rotation for rendering:
* dstLocal[j][k] = inv(dstActualMS[parent][k]) × dstActualMS[j][k]
*
* This correctly handles the case where a mapped parent joint is animated —
* the child's local rotation is computed relative to the parent's ANIMATED
* model-space, not its bind model-space.
*/
public final class RetargetingSystem {
private static final Logger log = LoggerFactory.getLogger(RetargetingSystem.class);
// Manuelle Model-Space-Korrekturen pro normalisiertem Knochen-Namen (via BoneNameMapping.normalize).
// Wird vom Bone-Editor befüllt und per retarget(clip, src, dst, corrections) übergeben.
private static final Map<String, Quaternion> MS_CORRECTIONS = new HashMap<>();
private RetargetingSystem() {}
// ── Public API ────────────────────────────────────────────────────────────
public static AnimClip retarget(AnimClip sourceClip,
Armature sourceArmature,
Armature targetArmature) {
return retarget(sourceClip, sourceArmature, targetArmature, MS_CORRECTIONS);
}
public static AnimClip retarget(AnimClip sourceClip,
Armature sourceArmature,
Armature targetArmature,
Map<String, Quaternion> corrections) {
Map<String, String> nameMap = BoneNameMapping.buildMapping(sourceArmature, targetArmature);
if (nameMap.isEmpty()) {
log.warn("[Retarget] Keine Knochen-Übereinstimmung für '{}'", sourceClip.getName());
return null;
}
// ── Same-rig fast path: nur wenn Namen UND Bind-Posen übereinstimmen ──
// Mixamo-Clips, deren Knochen in Blender nur umbenannt (nicht retargeted) wurden,
// haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel.
if (isSameRig(nameMap, sourceArmature, targetArmature)) {
log.warn("[Retarget] '{}' same-rig detected fast path (redirect only)", sourceClip.getName());
return redirectTracks(sourceClip, targetArmature);
}
// ── Source track lookup ───────────────────────────────────────────────
Map<String, TransformTrack> srcTrackMap = new HashMap<>();
for (AnimTrack<?> t : sourceClip.getTracks())
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint j)
srcTrackMap.put(j.getName(), tt);
// Reference keyframe times
float[] times = null;
for (String name : nameMap.keySet()) {
TransformTrack tt = srcTrackMap.get(name);
if (tt != null) { times = tt.getTimes(); break; }
}
if (times == null || times.length == 0) {
log.warn("[Retarget] Keine Keyframe-Zeiten für '{}'", sourceClip.getName());
return null;
}
int numFrames = times.length;
// ── Bind-pose model-space for both skeletons ──────────────────────────
Map<Joint, Quaternion> srcBindMS = buildModelSpaceBind(sourceArmature);
Map<Joint, Quaternion> dstBindMS = buildModelSpaceBind(targetArmature);
// ── Source animated model-space per frame ─────────────────────────────
Map<Joint, Quaternion[]> srcAnimMS = new HashMap<>();
for (Joint j : sourceArmature.getJointList())
computeModelSpaceAnim(j, srcTrackMap, numFrames, srcAnimMS);
// ── Arm-Diagnose: Quell-Local, Quell-ModelSpace und Bind-MS bei Frame 0 ──
for (var e : nameMap.entrySet()) {
String dstName = e.getValue();
if (!dstName.equals("L_Upperarm") && !dstName.equals("R_Upperarm")
&& !dstName.equals("L_Clavicle") && !dstName.equals("R_Clavicle")) continue;
Joint srcJ = sourceArmature.getJoint(e.getKey());
Joint dstJ = targetArmature.getJoint(dstName);
if (srcJ == null || dstJ == null) continue;
TransformTrack tt = srcTrackMap.get(e.getKey());
Quaternion[] localRots = tt != null ? tt.getRotations() : null;
float[] loc = localRots != null && localRots.length > 0
? localRots[0].toAngles(null) : new float[3];
Quaternion[] ms = srcAnimMS.get(srcJ);
float[] msA = ms != null ? ms[0].toAngles(null) : new float[3];
float[] sbsA = srcBindMS.get(srcJ).toAngles(null);
float[] dbsA = dstBindMS.get(dstJ).toAngles(null);
log.warn("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°",
sourceClip.getName(), e.getKey(), dstName,
String.format("%.1f", Math.toDegrees(loc[0])),
String.format("%.1f", Math.toDegrees(loc[1])),
String.format("%.1f", Math.toDegrees(loc[2])),
String.format("%.1f", Math.toDegrees(msA[0])),
String.format("%.1f", Math.toDegrees(msA[1])),
String.format("%.1f", Math.toDegrees(msA[2])),
String.format("%.1f", Math.toDegrees(sbsA[0])),
String.format("%.1f", Math.toDegrees(sbsA[1])),
String.format("%.1f", Math.toDegrees(sbsA[2])),
String.format("%.1f", Math.toDegrees(dbsA[0])),
String.format("%.1f", Math.toDegrees(dbsA[1])),
String.format("%.1f", Math.toDegrees(dbsA[2])));
}
// ── dst name → src Joint (reverse map) ───────────────────────────────
Map<String, Joint> dstToSrc = new HashMap<>();
for (var e : nameMap.entrySet()) {
Joint src = sourceArmature.getJoint(e.getKey());
if (src != null) dstToSrc.put(e.getValue(), src);
}
// Manuelle Korrekturen (vom Bone-Editor übergeben) keine automatischen Arm-Korrekturen mehr.
// Die Standardformel dstBind × inv(srcBind) × srcAnimMS überträgt die relative Bewegung
// korrekt, unabhängig davon ob Source und Target unterschiedliche globale Orientierungen haben.
Map<String, Quaternion> effectiveCorrections = new HashMap<>(corrections);
// ── Allocate result arrays for mapped dst joints ──────────────────────
Map<Joint, Quaternion[]> dstLocalArrays = new LinkedHashMap<>();
for (String dstName : dstToSrc.keySet()) {
Joint dst = targetArmature.getJoint(dstName);
if (dst != null) dstLocalArrays.put(dst, new Quaternion[numFrames]);
}
// ── Per-frame retargeting ─────────────────────────────────────────────
for (int k = 0; k < numFrames; k++) {
// Compute dstActualMS for all dst joints (recursive, cached per frame)
Map<Joint, Quaternion> dstActualMS = new HashMap<>();
for (Joint dst : targetArmature.getJointList())
computeDstActualMS(dst, k, dstToSrc, srcAnimMS, srcBindMS, dstBindMS, dstActualMS);
// Welt-Raum-Korrekturen: Pre-Multiplikation auf Model-Space aller betroffenen Gelenke.
// Dadurch drehen sich linke und rechte Seite symmetrisch (keine Spiegelproblematik).
// Nur die Schulter-Tracks ändern sich sichtbar; Kind-Locals kürzen sich gegenseitig raus,
// die Korrektur propagiert beim Rendern durch die Hierarchie automatisch weiter.
for (Joint dst : targetArmature.getJointList()) {
Quaternion corr = effectiveCorrections.get(BoneNameMapping.normalize(dst.getName()));
if (corr != null) {
Quaternion orig = dstActualMS.get(dst);
if (orig != null) dstActualMS.put(dst, corr.mult(orig));
}
}
// ── Ziel-ModelSpace-Diagnose bei Frame 0 ────────────────────────────
if (k == 0) {
// Root-Bones: bestimmen die Blickrichtung des Charakters
for (Joint d : targetArmature.getJointList()) {
if (d.getParent() != null) continue; // nur Wurzel-Bones
Quaternion ams = dstActualMS.get(d);
if (ams == null) continue;
Quaternion bind = dstBindMS.get(d);
float[] a = ams.toAngles(null);
float[] b = bind != null ? bind.toAngles(null) : new float[3];
log.warn("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°",
sourceClip.getName(), d.getName(),
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
String.format("%.1f", Math.toDegrees(a[2])),
String.format("%.1f", Math.toDegrees(b[0])),
String.format("%.1f", Math.toDegrees(b[1])),
String.format("%.1f", Math.toDegrees(b[2])));
// Auch direkte Kinder des Root loggen
for (Joint child : d.getChildren()) {
Quaternion cms = dstActualMS.get(child);
Quaternion cbind = dstBindMS.get(child);
if (cms == null) continue;
float[] ca = cms.toAngles(null);
float[] cb = cbind != null ? cbind.toAngles(null) : new float[3];
Quaternion cl = ams.inverse().mult(cms);
float[] cl_ = cl.toAngles(null);
log.warn("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
sourceClip.getName(), child.getName(),
String.format("%.1f", Math.toDegrees(ca[0])),
String.format("%.1f", Math.toDegrees(ca[1])),
String.format("%.1f", Math.toDegrees(ca[2])),
String.format("%.1f", Math.toDegrees(cb[0])),
String.format("%.1f", Math.toDegrees(cb[1])),
String.format("%.1f", Math.toDegrees(cb[2])),
String.format("%.1f", Math.toDegrees(cl_[0])),
String.format("%.1f", Math.toDegrees(cl_[1])),
String.format("%.1f", Math.toDegrees(cl_[2])));
}
}
// Arm-Bones
for (String dName : new String[]{"L_Clavicle","L_Upperarm","R_Clavicle","R_Upperarm"}) {
Joint d = targetArmature.getJoint(dName);
if (d == null) continue;
Quaternion ams = dstActualMS.get(d);
if (ams == null) continue;
Quaternion pms = d.getParent() != null
? dstActualMS.get(d.getParent()) : new Quaternion();
Quaternion local0 = pms.inverse().mult(ams);
float[] a = ams.toAngles(null);
float[] l = local0.toAngles(null);
log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
sourceClip.getName(), dName,
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
String.format("%.1f", Math.toDegrees(a[2])),
String.format("%.1f", Math.toDegrees(l[0])),
String.format("%.1f", Math.toDegrees(l[1])),
String.format("%.1f", Math.toDegrees(l[2])));
}
}
// Convert model-space → local for each mapped joint
for (var entry : dstLocalArrays.entrySet()) {
Joint dst = entry.getKey();
Quaternion ms = dstActualMS.get(dst);
Quaternion parentMS = dst.getParent() != null
? dstActualMS.get(dst.getParent())
: new Quaternion();
entry.getValue()[k] = parentMS.inverse().mult(ms);
}
}
// ── Build tracks ──────────────────────────────────────────────────────
if (dstLocalArrays.isEmpty()) {
log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName());
return null;
}
List<AnimTrack<?>> newTracks = new ArrayList<>();
for (var entry : dstLocalArrays.entrySet())
newTracks.add(new TransformTrack(entry.getKey(), times, null, entry.getValue(), null));
AnimClip result = new AnimClip(sourceClip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
log.warn("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}
// ── Compute desired dst model-space for one joint at one frame ────────────
private static Quaternion computeDstActualMS(
Joint dst, int k,
Map<String, Joint> dstToSrc,
Map<Joint, Quaternion[]> srcAnimMS,
Map<Joint, Quaternion> srcBindMS,
Map<Joint, Quaternion> dstBindMS,
Map<Joint, Quaternion> cache) {
Quaternion cached = cache.get(dst);
if (cached != null) return cached;
Joint srcJoint = dstToSrc.get(dst.getName());
Quaternion result;
if (srcJoint != null) {
Quaternion[] srcFrames = srcAnimMS.get(srcJoint);
Quaternion srcMS_k = srcFrames[Math.min(k, srcFrames.length - 1)];
// When both this bone AND its parent are mapped to the same source parent,
// use local-space retargeting. This avoids axis-swap artifacts caused by
// different root orientation conventions (Rx vs Ry) between skeletons:
// the relative arm/leg motion is transferred in the parent's own frame,
// so "arm rotates Z -90° relative to shoulder" stays exactly that.
Joint dstParent = dst.getParent();
Joint srcViaMap = dstParent != null ? dstToSrc.get(dstParent.getName()) : null;
Joint srcParentDirect = srcJoint.getParent();
if (srcViaMap != null && srcViaMap == srcParentDirect) {
// src_local_anim = inv(srcAnimMS_parent) × srcAnimMS
Quaternion[] pFrames = srcAnimMS.get(srcParentDirect);
Quaternion srcPar_k = pFrames[Math.min(k, pFrames.length - 1)];
Quaternion srcLocal = srcPar_k.inverse().mult(srcMS_k);
// correction = dstBind_local × inv(srcBind_local)
// = inv(dstBind_parent) × dstBind × inv(srcBind) × srcBind_parent
Quaternion correction = dstBindMS.get(dstParent).inverse()
.mult(dstBindMS.get(dst))
.mult(srcBindMS.get(srcJoint).inverse())
.mult(srcBindMS.get(srcParentDirect));
Quaternion parentActual = computeDstActualMS(dstParent, k,
dstToSrc, srcAnimMS, srcBindMS, dstBindMS, cache);
result = parentActual.mult(correction.mult(srcLocal));
} else {
// Root of mapped chain (parent unmapped): model-space formula
result = dstBindMS.get(dst).mult(srcBindMS.get(srcJoint).inverse().mult(srcMS_k));
}
} else {
// Unmapped: propagate parent's actual MS × own bind local
Quaternion parentMS = dst.getParent() != null
? computeDstActualMS(dst.getParent(), k, dstToSrc, srcAnimMS, srcBindMS, dstBindMS, cache)
: new Quaternion();
Quaternion bindLocal = dst.getInitialTransform() != null
? dst.getInitialTransform().getRotation() : new Quaternion();
result = parentMS.mult(bindLocal);
}
cache.put(dst, result);
return result;
}
// ── Model-space bind accumulation ─────────────────────────────────────────
private static Map<Joint, Quaternion> buildModelSpaceBind(Armature arm) {
Map<Joint, Quaternion> cache = new HashMap<>();
for (Joint j : arm.getJointList())
buildModelSpaceBindRec(j, cache);
return cache;
}
private static Quaternion buildModelSpaceBindRec(Joint j, Map<Joint, Quaternion> cache) {
Quaternion cached = cache.get(j);
if (cached != null) return cached;
Quaternion local = j.getInitialTransform() != null
? j.getInitialTransform().getRotation() : new Quaternion();
Quaternion result = j.getParent() == null
? new Quaternion(local)
: buildModelSpaceBindRec(j.getParent(), cache).mult(local);
cache.put(j, result);
return result;
}
// ── Source animated model-space accumulation ──────────────────────────────
private static Quaternion[] computeModelSpaceAnim(Joint j,
Map<String, TransformTrack> tracks,
int numFrames,
Map<Joint, Quaternion[]> cache) {
Quaternion[] cached = cache.get(j);
if (cached != null) return cached;
Quaternion[] parentMS = j.getParent() != null
? computeModelSpaceAnim(j.getParent(), tracks, numFrames, cache)
: null;
TransformTrack tt = tracks.get(j.getName());
Quaternion[] srcR = tt != null ? tt.getRotations() : null;
Quaternion bind = j.getInitialTransform() != null
? j.getInitialTransform().getRotation() : new Quaternion();
Quaternion[] ms = new Quaternion[numFrames];
for (int i = 0; i < numFrames; i++) {
Quaternion local = (srcR != null && srcR.length > 0)
? srcR[Math.min(i, srcR.length - 1)] : bind;
ms[i] = parentMS != null ? parentMS[i].mult(local) : new Quaternion(local);
}
cache.put(j, ms);
return ms;
}
// ── Same-rig detection & redirect ────────────────────────────────────────
/** True nur wenn alle Namen exakt selbst-gemappt UND alle Bind-Posen übereinstimmen. */
private static boolean isSameRig(Map<String, String> nameMap, Armature src, Armature dst) {
for (var e : nameMap.entrySet()) {
if (!e.getKey().equals(e.getValue())) return false;
Joint srcJ = src.getJoint(e.getKey());
Joint dstJ = dst.getJoint(e.getValue());
if (srcJ == null || dstJ == null) continue;
Quaternion sb = srcJ.getInitialTransform() != null
? srcJ.getInitialTransform().getRotation() : new Quaternion();
Quaternion db = dstJ.getInitialTransform() != null
? dstJ.getInitialTransform().getRotation() : new Quaternion();
if (Math.abs(sb.dot(db)) < 0.9999f) return false;
}
return true;
}
private static AnimClip redirectTracks(AnimClip sourceClip, Armature targetArmature) {
List<AnimTrack<?>> newTracks = new ArrayList<>();
for (AnimTrack<?> t : sourceClip.getTracks()) {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint srcJoint) {
Joint dstJoint = targetArmature.getJoint(srcJoint.getName());
if (dstJoint == null) continue;
newTracks.add(new TransformTrack(dstJoint, tt.getTimes(),
tt.getTranslations(), tt.getRotations(), tt.getScales()));
}
}
if (newTracks.isEmpty()) return null;
AnimClip result = new AnimClip(sourceClip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
log.warn("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────────
public static AnimComposer findAnimComposer(Spatial s) {
return findControl(s, AnimComposer.class);
}
public static SkinningControl findSkinningControl(Spatial s) {
return findControl(s, SkinningControl.class);
}
@SuppressWarnings("unchecked")
static <T extends Control> T findControl(Spatial s, Class<T> type) {
T c = s.getControl(type);
if (c != null) return c;
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
c = findControl(child, type);
if (c != null) return c;
}
}
return null;
}
}

View File

@@ -0,0 +1,253 @@
package de.blight.game.console;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.input.KeyInput;
import com.jme3.input.RawInputListener;
import com.jme3.input.event.*;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* JME-native Konsole.
* Game-Modus : useRawInput=true → RawInputListener fängt Tastatur direkt ab.
* Editor-Modus: useRawInput=false → Zeichen per feedChar/feedBackspace/… zuführen.
*
* Toggle-Taste (Game): KEY_GRAVE (^ auf DE-Tastatur).
*/
public class JmeConsole extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(JmeConsole.class);
// ── Layout ────────────────────────────────────────────────────────────────
private static final int HISTORY = 6;
private static final float LINE_H = 18f;
private static final float PAD = 6f;
private static final float BG_H = PAD + LINE_H * (HISTORY + 1) + PAD;
/** Taste zum Öffnen/Schließen der Konsole im Game. */
public static final int KEY_TOGGLE = KeyInput.KEY_GRAVE;
// ── Befehle ───────────────────────────────────────────────────────────────
private final Map<String, Function<String[], String>> commands = new LinkedHashMap<>();
private Consumer<Boolean> onVisibilityChanged;
// ── Zustand ───────────────────────────────────────────────────────────────
private boolean open = false;
private StringBuilder inputBuf = new StringBuilder();
private final Deque<String> history = new ArrayDeque<>();
private float blinkTimer = 0f;
private boolean cursorOn = true;
private final boolean useRawInput;
// ── JME-Objekte ───────────────────────────────────────────────────────────
private SimpleApplication app;
private Node guiNode;
private Node consoleNode;
private BitmapText inputLine;
private BitmapText[] histLines;
// ── Raw-Input (Game-Modus) ────────────────────────────────────────────────
private final RawInputListener rawListener = new RawInputListener() {
@Override public void beginInput() {}
@Override public void endInput() {}
@Override public void onMouseMotionEvent(MouseMotionEvent e) {}
@Override public void onMouseButtonEvent(MouseButtonEvent e) {}
@Override public void onJoyAxisEvent(JoyAxisEvent e) {}
@Override public void onJoyButtonEvent(JoyButtonEvent e) {}
@Override public void onTouchEvent(TouchEvent e) {}
@Override
public void onKeyEvent(KeyInputEvent e) {
if (!e.isPressed()) return;
int key = e.getKeyCode();
if (key == KEY_TOGGLE) { toggle(); return; }
if (!open) return;
char c = e.getKeyChar();
if (key == KeyInput.KEY_RETURN) feedEnter();
else if (key == KeyInput.KEY_BACK) feedBackspace();
else if (key == KeyInput.KEY_ESCAPE) feedEscape();
else if (c >= ' ') feedChar(c);
}
};
// ── Konstruktoren ─────────────────────────────────────────────────────────
/** Game-Modus: verwendet RawInputListener. */
public JmeConsole() { this(true); }
/**
* @param useRawInput true = Game (RawInputListener),
* false = Editor (Zeichen per feed-Methoden zuführen)
*/
public JmeConsole(boolean useRawInput) {
this.useRawInput = useRawInput;
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/** Registriert einen Konsolenbefehl. */
public void registerCommand(String name, Function<String[], String> handler) {
commands.put(name.toLowerCase(), handler);
}
/** Callback: wird beim Öffnen (true) und Schließen (false) der Konsole aufgerufen. */
public void setOnVisibilityChanged(Consumer<Boolean> cb) { onVisibilityChanged = cb; }
public boolean isOpen() { return open; }
public void toggle() { if (open) hide(); else show(); }
/** Baut die Konsolen-UI nach einem Viewport-Resize neu auf. */
public void rebuild() {
boolean wasOpen = open;
if (consoleNode != null) {
guiNode.detachChild(consoleNode);
consoleNode = null;
}
buildUI();
if (!wasOpen) consoleNode.setCullHint(Spatial.CullHint.Always);
}
public void show() {
open = true;
consoleNode.setCullHint(Spatial.CullHint.Inherit);
if (onVisibilityChanged != null) onVisibilityChanged.accept(true);
}
public void hide() {
open = false;
consoleNode.setCullHint(Spatial.CullHint.Always);
inputBuf.setLength(0);
refreshInput();
if (onVisibilityChanged != null) onVisibilityChanged.accept(false);
}
// Feed-Methoden für Editor-Modus (SharedInput → JME-Thread)
public void feedChar(char c) { inputBuf.append(c); refreshInput(); }
public void feedBackspace() { if (inputBuf.length() > 0) { inputBuf.deleteCharAt(inputBuf.length()-1); refreshInput(); } }
public void feedEnter() { String raw = inputBuf.toString().trim(); inputBuf.setLength(0); execute(raw); refreshInput(); }
public void feedEscape() { hide(); }
/** Gibt eine Zeile in der Konsole aus. */
public void print(String msg) {
history.addFirst(msg);
while (history.size() > HISTORY) history.pollLast();
refreshHistory();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.guiNode = this.app.getGuiNode();
buildUI();
registerBuiltin();
if (useRawInput) app.getInputManager().addRawInputListener(rawListener);
}
@Override
protected void cleanup(Application app) {
if (useRawInput) app.getInputManager().removeRawInputListener(rawListener);
guiNode.detachChild(consoleNode);
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
if (!open) return;
blinkTimer += tpf;
if (blinkTimer >= 0.5f) {
blinkTimer = 0f;
cursorOn = !cursorOn;
refreshInput();
}
}
// ── UI ────────────────────────────────────────────────────────────────────
private void buildUI() {
int sw = app.getCamera().getWidth();
float topY = app.getCamera().getHeight(); // Bildschirmoberseite
float baseY = topY - BG_H; // Unterkante der Konsole
Geometry bg = new Geometry("consoleBg", new Quad(sw, BG_H));
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0f, 0f, 0f, 0.82f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
bg.setMaterial(mat);
bg.setQueueBucket(RenderQueue.Bucket.Gui);
bg.setLocalTranslation(0, baseY, -1f);
BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
inputLine = makeLine(font, new ColorRGBA(0.25f, 1f, 0.25f, 1f),
PAD, baseY + PAD + LINE_H);
histLines = new BitmapText[HISTORY];
for (int i = 0; i < HISTORY; i++) {
histLines[i] = makeLine(font, ColorRGBA.White,
PAD, baseY + PAD + LINE_H * (i + 2));
}
consoleNode = new Node("jmeConsole");
consoleNode.attachChild(bg);
consoleNode.attachChild(inputLine);
for (BitmapText t : histLines) consoleNode.attachChild(t);
consoleNode.setCullHint(Spatial.CullHint.Always);
guiNode.attachChild(consoleNode);
refreshInput();
}
private BitmapText makeLine(BitmapFont font, ColorRGBA color, float x, float y) {
BitmapText t = new BitmapText(font, false);
t.setSize(LINE_H - 2f);
t.setColor(color);
t.setLocalTranslation(x, y, 0f);
return t;
}
private void refreshInput() {
if (inputLine != null)
inputLine.setText("> " + inputBuf + (cursorOn ? "_" : " "));
}
private void refreshHistory() {
String[] arr = history.toArray(new String[0]);
for (int i = 0; i < histLines.length; i++)
histLines[i].setText(i < arr.length ? arr[i] : "");
}
private void execute(String raw) {
if (raw.isEmpty()) return;
print("> " + raw);
String[] parts = raw.split("\\s+");
Function<String[], String> h = commands.get(parts[0].toLowerCase());
String out = (h != null) ? h.apply(parts) : "Unbekannter Befehl: " + parts[0] + " (help)";
if (out != null && !out.isEmpty()) print(out);
}
private void registerBuiltin() {
registerCommand("help", args ->
"Befehle: " + String.join(" | ", commands.keySet()));
}
}

View File

@@ -4,6 +4,7 @@ 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;
@@ -102,6 +103,9 @@ public class PlayerInputControl {
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);

View File

@@ -0,0 +1,179 @@
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.audio.AudioData;
import com.jme3.audio.AudioNode;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import de.blight.common.PlacedSoundArea;
import de.blight.common.SoundAreaIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Distanzbasierter Ambient-Sound pro Polygon-Bereich.
* Innerhalb des Polygons: volle Lautstärke.
* Außerhalb: lineares Fade bis zur Reichweite (volume * CROSSFADE_SCALE Einheiten).
*/
public class AmbientSoundSystem extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(AmbientSoundSystem.class);
private static final float CROSSFADE_SCALE = 30f; // Einheiten Reichweite außerhalb bei volume=1.0
private static final float FADE_DURATION = 2f; // Sekunden für vollen Lautstärke-Hub
private SimpleApplication app;
private AssetManager assets;
private Node rootNode;
private final List<PlacedSoundArea> data = new ArrayList<>();
private final List<AudioNode> sounds = new ArrayList<>();
private final List<Boolean> attached = new ArrayList<>();
private final Vector3f playerPos = new Vector3f();
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
assets = app.getAssetManager();
rootNode = app.getRootNode();
try {
for (PlacedSoundArea area : SoundAreaIO.load()) {
if (area.soundPath().isEmpty()) continue;
try {
AudioNode node = new AudioNode(assets, area.soundPath(), AudioData.DataType.Stream);
node.setLooping(true);
node.setVolume(0f);
node.setPositional(false);
data.add(area);
sounds.add(node);
attached.add(false);
} catch (Exception e) {
log.warn("[AmbientSoundSystem] Sound nicht ladbar '{}': {}", area.soundPath(), e.getMessage());
}
}
if (data.isEmpty()) log.info("[AmbientSoundSystem] Keine Sound-Bereiche geladen.");
else log.info("[AmbientSoundSystem] {} Sound-Bereiche geladen.", data.size());
} catch (IOException e) {
log.warn("[AmbientSoundSystem] Sound-Bereich-Datei nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void cleanup(Application application) {
for (int i = 0; i < sounds.size(); i++) {
if (attached.get(i)) {
sounds.get(i).stop();
rootNode.detachChild(sounds.get(i));
}
}
data.clear();
sounds.clear();
attached.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setPlayerPosition(Vector3f pos) {
playerPos.set(pos);
}
@Override
public void update(float tpf) {
if (data.isEmpty()) return;
for (int i = 0; i < data.size(); i++) {
PlacedSoundArea area = data.get(i);
AudioNode node = sounds.get(i);
float target = computeTarget(area);
float cur = node.getVolume();
boolean wasOn = attached.get(i);
if (target > 0f && !wasOn) {
node.setVolume(0f);
rootNode.attachChild(node);
node.play();
attached.set(i, true);
log.info("[AmbientSoundSystem] Bereich {} hörbar → spiele: {}", i, area.soundPath());
}
if (attached.get(i)) {
float step = area.volume() * tpf / FADE_DURATION;
float nv = target > cur
? Math.min(cur + step, target)
: Math.max(cur - step, target);
node.setVolume(nv);
if (nv <= 0f) {
node.stop();
rootNode.detachChild(node);
attached.set(i, false);
log.info("[AmbientSoundSystem] Bereich {} unhörbar → gestoppt: {}", i, area.soundPath());
}
}
}
}
/**
* Zielvolumen basierend auf signiertem Abstand zur Polygongrenze.
* Innen (≥0): volle Lautstärke.
* Außen: linear von voll (Grenze) auf null (hearRange = volume * CROSSFADE_SCALE).
*/
private float computeTarget(PlacedSoundArea area) {
float signedDist = signedDistToPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
if (signedDist >= 0f) return area.volume();
float hearRange = CROSSFADE_SCALE * area.volume();
if (signedDist <= -hearRange) return 0f;
return area.volume() * (1f + signedDist / hearRange);
}
/**
* Positiv = Spieler ist innen (Distanz zur nächsten Kante).
* Negativ = Spieler ist außen (negierte Distanz zur nächsten Kante).
*/
private static float signedDistToPolygon(float px, float pz, float[] xs, float[] zs) {
float edgeDist = minDistToPolygonEdge(px, pz, xs, zs);
return pointInPolygon(px, pz, xs, zs) ? edgeDist : -edgeDist;
}
private static float minDistToPolygonEdge(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
float minD2 = Float.MAX_VALUE;
for (int i = 0, j = n - 1; i < n; j = i++) {
float d2 = pointToSegmentDist2(px, pz, xs[j], zs[j], xs[i], zs[i]);
if (d2 < minD2) minD2 = d2;
}
return (float) Math.sqrt(minD2);
}
private static float pointToSegmentDist2(float px, float pz,
float ax, float az,
float bx, float bz) {
float dx = bx - ax, dz = bz - az;
float lenSq = dx * dx + dz * dz;
float t = lenSq == 0f ? 0f : Math.max(0f, Math.min(1f, ((px - ax) * dx + (pz - az) * dz) / lenSq));
float cx = ax + t * dx - px;
float cz = az + t * dz - pz;
return cx * cx + cz * cz;
}
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
float xi = xs[i], zi = zs[i];
float xj = xs[j], zj = zs[j];
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi))
inside = !inside;
}
return inside;
}
}

View File

@@ -0,0 +1,68 @@
package de.blight.game.state;
import com.jme3.asset.AssetManager;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.shape.Box;
public class CloudsNode extends Node {
private static final int COUNT = 14;
private static final float Y = 800f;
private static final float WRAP_HALF = 1800f;
private final Geometry[] geoms = new Geometry[COUNT];
private final float[] offsetX = new float[COUNT];
private final float[] offsetZ = new float[COUNT];
private final Material mat;
private static final float[] W = { 350,250,420,180,300,380,220,160,480,260,310,200,440,190 };
private static final float[] D = { 280,160,300,220,350,200,280,180,320,240,180,350,260,300 };
private static final float[] H = { 30, 20, 25, 18, 35, 28, 22, 15, 40, 24, 20, 30, 28, 22 };
private static final float[] IX = {-600,200,-100,500,-800,300,700,-400,0,-200,600,-700,100,-500};
private static final float[] IZ = { 400,-300,700,-500,100,-700,-200,600,-100,800,-400,200,-600,500};
public CloudsNode(AssetManager assetManager) {
super("clouds");
mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.95f, 0.95f, 0.95f, 0.45f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthWrite(false);
for (int i = 0; i < COUNT; i++) {
Box box = new Box(W[i] * 0.5f, H[i] * 0.5f, D[i] * 0.5f);
Geometry g = new Geometry("cloud_" + i, box);
g.setMaterial(mat);
g.setQueueBucket(RenderQueue.Bucket.Transparent);
g.setShadowMode(RenderQueue.ShadowMode.Off);
offsetX[i] = IX[i];
offsetZ[i] = IZ[i];
attachChild(g);
geoms[i] = g;
}
}
public void setCloudColor(ColorRGBA color) {
mat.setColor("Color", color);
}
public void update(float tpf, Vector3f windDir, float windSpeed, Vector3f camPos) {
float dx = windDir.x * windSpeed * tpf;
float dz = windDir.z * windSpeed * tpf;
for (int i = 0; i < COUNT; i++) {
offsetX[i] += dx;
offsetZ[i] += dz;
if (offsetX[i] > WRAP_HALF) offsetX[i] -= WRAP_HALF * 2f;
if (offsetX[i] < -WRAP_HALF) offsetX[i] += WRAP_HALF * 2f;
if (offsetZ[i] > WRAP_HALF) offsetZ[i] -= WRAP_HALF * 2f;
if (offsetZ[i] < -WRAP_HALF) offsetZ[i] += WRAP_HALF * 2f;
geoms[i].setLocalTranslation(camPos.x + offsetX[i], Y, camPos.z + offsetZ[i]);
}
}
}

View File

@@ -0,0 +1,226 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Sphere;
import com.jme3.shadow.DirectionalLightShadowFilter;
import com.jme3.shadow.EdgeFilteringMode;
import com.jme3.util.SkyFactory;
import de.blight.common.time.DayTime;
import de.blight.common.time.TimeListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Tag/Nacht-Zyklus: Sonne, Ambiente, Schatten, Himmelsfarbe.
* Wiederverwendbar im Game und Editor (withShadows=false für Editor).
*/
public class DayNightState extends BaseAppState implements TimeListener {
private static final Logger log = LoggerFactory.getLogger(DayNightState.class);
// ── Farb-Konstanten ───────────────────────────────────────────────────────
private static final ColorRGBA SUN_DAY = new ColorRGBA(1.00f, 0.95f, 0.88f, 1f);
private static final ColorRGBA SUN_DAWN = new ColorRGBA(1.00f, 0.55f, 0.20f, 1f);
private static final ColorRGBA AMB_DAY = new ColorRGBA(0.35f, 0.38f, 0.46f, 1f);
private static final ColorRGBA AMB_NIGHT = new ColorRGBA(0.04f, 0.04f, 0.12f, 1f);
private static final ColorRGBA BG_DAY = new ColorRGBA(0.35f, 0.55f, 0.85f, 1f);
private static final ColorRGBA BG_NIGHT = new ColorRGBA(0.01f, 0.01f, 0.06f, 1f);
// ── Konfiguration ─────────────────────────────────────────────────────────
private final DayTime dayTime;
private final boolean withShadows;
// ── JME-Objekte ───────────────────────────────────────────────────────────
private SimpleApplication app;
private Node rootNode;
private DirectionalLight sun;
private AmbientLight ambient;
private DirectionalLightShadowFilter shadowFilter;
private Spatial sky;
private Geometry sunSphere;
// ── Konstruktoren ─────────────────────────────────────────────────────────
/** Game-Modus: Schatten aktiv, 5-Minuten-Tag. */
public DayNightState() {
this(new DayTime(), true);
}
/** @param withShadows false für Editor (kein Shadow-Renderer) */
public DayNightState(boolean withShadows) {
this(new DayTime(), withShadows);
}
public DayNightState(DayTime dayTime, boolean withShadows) {
this.dayTime = dayTime;
this.withShadows = withShadows;
}
public DayTime getDayTime() { return dayTime; }
public void setPaused(boolean paused) { dayTime.setPaused(paused); }
/** Aktuelle Sonnenrichtung (normalisiert), oder (0,-1,0) falls noch nicht initialisiert. */
public com.jme3.math.Vector3f getSunDirection() {
return sun != null ? sun.getDirection() : new com.jme3.math.Vector3f(0f, -1f, 0f);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.rootNode = this.app.getRootNode();
// Himmel
try {
sky = SkyFactory.createSky(app.getAssetManager(),
"Textures/Sky/Bright/BrightSky.dds",
SkyFactory.EnvMapType.CubeMap);
rootNode.attachChild(sky);
} catch (Exception e) {
log.warn("Sky-Textur nicht geladen Viewport-Farbe als Fallback");
}
// Sonnen-Sphere (sichtbare Sonne am Himmel)
Sphere sphereMesh = new Sphere(16, 16, 18f);
sunSphere = new Geometry("sunSphere", sphereMesh);
Material sunMat = new Material(app.getAssetManager(),
"Common/MatDefs/Misc/Unshaded.j3md");
sunMat.setColor("Color", new ColorRGBA(1f, 0.92f, 0.65f, 1f));
sunSphere.setMaterial(sunMat);
sunSphere.setQueueBucket(RenderQueue.Bucket.Sky);
sunSphere.setShadowMode(RenderQueue.ShadowMode.Off);
rootNode.attachChild(sunSphere);
// Sonne (DirectionalLight)
sun = new DirectionalLight();
rootNode.addLight(sun);
// Ambient
ambient = new AmbientLight();
rootNode.addLight(ambient);
// Schatten (nur im Game) als Filter, damit WorldScene ihn in den FPP einhängen kann
if (withShadows) {
try {
shadowFilter = new DirectionalLightShadowFilter(app.getAssetManager(), 4096, 4);
shadowFilter.setLight(sun);
shadowFilter.setLambda(0.75f);
shadowFilter.setShadowZExtend(40f);
shadowFilter.setShadowZFadeLength(8f);
shadowFilter.setEdgeFilteringMode(EdgeFilteringMode.PCFPOISSON);
// Nicht direkt zum Viewport hinzufügen WorldScene hängt ihn in den FPP ein
} catch (Exception e) {
log.error("Shadow-Filter konnte nicht erstellt werden", e);
}
}
dayTime.addListener(this);
onTimeChanged(dayTime.getTimeOfDay());
}
/** Gibt den Shadow-Filter zurück, damit WorldScene ihn in den FPP einhängen kann. */
public DirectionalLightShadowFilter getShadowFilter() { return shadowFilter; }
@Override
protected void cleanup(Application app) {
dayTime.removeListener(this);
rootNode.removeLight(sun);
rootNode.removeLight(ambient);
shadowFilter = null; // FPP-Cleanup liegt bei WorldScene
if (sky != null && sky.getParent() != null) rootNode.detachChild(sky);
if (sunSphere != null && sunSphere.getParent() != null) rootNode.detachChild(sunSphere);
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
dayTime.update(tpf);
// Sky/Sonnen-Sphere nach rootNode.detachAllChildren() wiederherstellen
if (sky != null && sky.getParent() == null)
rootNode.attachChild(sky);
if (sunSphere != null && sunSphere.getParent() == null)
rootNode.attachChild(sunSphere);
// Sonnen-Sphere immer relativ zur Kamera positionieren
if (sunSphere != null && sunSphere.getCullHint() != Spatial.CullHint.Always) {
Vector3f camPos = app.getCamera().getLocation();
sunSphere.setLocalTranslation(camPos.add(sun.getDirection().negate().mult(480f)));
}
}
// ── Zeit-Callback ─────────────────────────────────────────────────────────
@Override
public void onTimeChanged(float t) {
float elev = sunElevation(t);
float elevC = FastMath.clamp(elev, 0f, 1f);
// ── Sonnenrichtung ──────────────────────────────────────────────────
float angle = t * FastMath.TWO_PI;
// Sonne bewegt sich von Ost (X+) über Süden nach West (X-)
Vector3f sunPos = new Vector3f(
FastMath.sin(angle) * 0.85f,
elev,
-0.15f
);
sun.setDirection(sunPos.negate().normalizeLocal());
// ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) ──────────────
float dawnFactor = 1f - FastMath.clamp(elev * 5f, 0f, 1f);
ColorRGBA sunColor = SUN_DAWN.clone().interpolateLocal(SUN_DAY, 1f - dawnFactor);
sun.setColor(sunColor.multLocal(elevC * 1.35f));
// ── Sonnen-Sphere ausblenden wenn unter Horizont ────────────────────
if (sunSphere != null) {
sunSphere.setCullHint(elev > -0.02f
? Spatial.CullHint.Inherit
: Spatial.CullHint.Always);
// Farbe bei Dämmerung orange, bei Tag weiß-gelb
Material m = sunSphere.getMaterial();
if (m != null) {
ColorRGBA sphereColor = new ColorRGBA(1f, 0.6f + elevC * 0.32f, 0.3f + elevC * 0.35f, 1f);
m.setColor("Color", sphereColor);
}
}
// ── Ambient: Nacht → Tag ────────────────────────────────────────────
float ambFactor = FastMath.clamp((elev + 0.2f) / 0.6f, 0f, 1f);
ambient.setColor(AMB_NIGHT.clone().interpolateLocal(AMB_DAY, ambFactor));
// ── Schatten ────────────────────────────────────────────────────────
if (shadowFilter != null)
shadowFilter.setShadowIntensity(FastMath.clamp(elev * 0.8f, 0f, 0.5f));
// ── Himmel & Hintergrundfarbe ────────────────────────────────────────
float skyFactor = FastMath.clamp((elev + 0.05f) / 0.2f, 0f, 1f);
if (sky != null)
sky.setCullHint(skyFactor > 0.01f
? Spatial.CullHint.Inherit
: Spatial.CullHint.Always);
app.getViewPort().setBackgroundColor(
BG_NIGHT.clone().interpolateLocal(BG_DAY, skyFactor));
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
/** Sonnenhöhe: 1 = Mitternacht, 0 = Horizont, +1 = Mittag. */
private static float sunElevation(float t) {
return -FastMath.cos(t * FastMath.TWO_PI);
}
}

View File

@@ -0,0 +1,135 @@
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.math.*;
import com.jme3.scene.Node;
import de.blight.common.EmitterIO;
import de.blight.common.PlacedEmitter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Lädt platzierte Partikel-Emitter und aktiviert/deaktiviert sie basierend
* auf der Nähe des Spielers (proximity-based activation).
*/
public class EmitterSystem extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(EmitterSystem.class);
private static final float CHECK_INTERVAL = 0.25f;
private SimpleApplication app;
private AssetManager assets;
private Node rootNode;
private final List<PlacedEmitter> data = new ArrayList<>();
private final List<ParticleEmitter> effects = new ArrayList<>();
private final List<Boolean> active = new ArrayList<>();
private Vector3f playerPos = new Vector3f();
private float checkTimer = 0f;
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
assets = app.getAssetManager();
rootNode = app.getRootNode();
try {
for (PlacedEmitter pe : EmitterIO.load()) {
ParticleEmitter effect = buildEffect(pe);
if (effect != null) {
data.add(pe);
effects.add(effect);
active.add(false);
}
}
if (!data.isEmpty())
log.info("[EmitterSystem] {} Emitter geladen.", data.size());
} catch (IOException e) {
log.warn("[EmitterSystem] Emitter-Datei nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void cleanup(Application application) {
for (int i = 0; i < effects.size(); i++) {
if (active.get(i)) rootNode.detachChild(effects.get(i));
}
data.clear();
effects.clear();
active.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setPlayerPosition(Vector3f pos) {
playerPos.set(pos);
}
@Override
public void update(float tpf) {
if (data.isEmpty()) return;
checkTimer += tpf;
if (checkTimer < CHECK_INTERVAL) return;
checkTimer = 0f;
for (int i = 0; i < data.size(); i++) {
PlacedEmitter pe = data.get(i);
float dx = playerPos.x - pe.x();
float dz = playerPos.z - pe.z();
float r = pe.activationRadius();
boolean inRange = (dx * dx + dz * dz) <= (r * r);
if (inRange && !active.get(i)) {
rootNode.attachChild(effects.get(i));
effects.get(i).setParticlesPerSec(pe.emitRate());
active.set(i, true);
} else if (!inRange && active.get(i)) {
effects.get(i).setParticlesPerSec(0);
rootNode.detachChild(effects.get(i));
active.set(i, false);
}
}
}
private ParticleEmitter buildEffect(PlacedEmitter pe) {
try {
ParticleEmitter effect = new ParticleEmitter(
"emitter_" + pe.x() + "_" + pe.z(),
ParticleMesh.Type.Triangle, pe.maxParticles());
Material mat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
mat.setTexture("Texture", assets.loadTexture(pe.texturePath()));
effect.setMaterial(mat);
effect.setImagesX(pe.imagesX());
effect.setImagesY(pe.imagesY());
effect.setStartColor(new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), pe.startA()));
effect.setEndColor( new ColorRGBA(pe.endR(), pe.endG(), pe.endB(), pe.endA()));
effect.setStartSize(pe.startSize());
effect.setEndSize(pe.endSize());
effect.getParticleInfluencer()
.setInitialVelocity(new Vector3f(pe.velX(), pe.velY(), pe.velZ()));
effect.getParticleInfluencer().setVelocityVariation(pe.velocityVariation());
effect.setGravity(pe.gravX(), pe.gravY(), pe.gravZ());
effect.setLowLife(pe.lowLife());
effect.setHighLife(pe.highLife());
effect.setParticlesPerSec(0); // inaktiv bis Spieler in Reichweite
effect.setLocalTranslation(pe.x(), pe.y(), pe.z());
return effect;
} catch (Exception e) {
log.warn("[EmitterSystem] Emitter konnte nicht erstellt werden: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,180 @@
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.audio.AudioData;
import com.jme3.audio.AudioNode;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import de.blight.common.MusicAreaIO;
import de.blight.common.PlacedMusicArea;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Proximity-based ambient music per polygon area.
* Three parallel tracks (day / night / combat) play when the player is inside.
* All three are attached + played on entry and detached on exit.
* Which one is actually audible is controlled via volume (wiring to day/night deferred).
*/
public class MusicSystem extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(MusicSystem.class);
private static final float CHECK_INTERVAL = 0.25f;
private static final float FADE_DURATION = 3f;
private static final float MUSIC_VOLUME = 0.7f;
private enum FadeState { INACTIVE, FADING_IN, ACTIVE, FADING_OUT }
private SimpleApplication app;
private AssetManager assets;
private Node rootNode;
private final List<PlacedMusicArea> data = new ArrayList<>();
// three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null
private final List<AudioNode[]> tracks = new ArrayList<>();
private final List<FadeState> fadeStates = new ArrayList<>();
private Vector3f playerPos = new Vector3f();
private float checkTimer = 0f;
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
assets = app.getAssetManager();
rootNode = app.getRootNode();
try {
for (PlacedMusicArea area : MusicAreaIO.load()) {
AudioNode[] arr = {
loadAmbient(area.dayTrack()),
loadAmbient(area.nightTrack()),
loadAmbient(area.combatTrack())
};
boolean hasAny = false;
for (AudioNode n : arr) if (n != null) { hasAny = true; break; }
if (!hasAny) continue;
data.add(area);
tracks.add(arr);
fadeStates.add(FadeState.INACTIVE);
}
if (!data.isEmpty()) log.info("[MusicSystem] {} Musik-Bereiche geladen.", data.size());
} catch (IOException e) {
log.warn("[MusicSystem] Musik-Bereich-Datei nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void cleanup(Application application) {
for (int i = 0; i < tracks.size(); i++) {
if (fadeStates.get(i) != FadeState.INACTIVE) {
for (AudioNode n : tracks.get(i)) {
if (n != null) { n.stop(); rootNode.detachChild(n); }
}
}
}
data.clear();
tracks.clear();
fadeStates.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setPlayerPosition(Vector3f pos) {
playerPos.set(pos);
}
@Override
public void update(float tpf) {
if (data.isEmpty()) return;
for (int i = 0; i < tracks.size(); i++) {
FadeState fs = fadeStates.get(i);
if (fs == FadeState.INACTIVE || fs == FadeState.ACTIVE) continue;
AudioNode[] arr = tracks.get(i);
if (fs == FadeState.FADING_IN) {
boolean done = true;
for (AudioNode n : arr) {
if (n == null) continue;
float nv = Math.min(n.getVolume() + MUSIC_VOLUME * tpf / FADE_DURATION, MUSIC_VOLUME);
n.setVolume(nv);
if (nv < MUSIC_VOLUME) done = false;
}
if (done) fadeStates.set(i, FadeState.ACTIVE);
} else { // FADING_OUT
boolean done = true;
for (AudioNode n : arr) {
if (n == null) continue;
float nv = Math.max(n.getVolume() - MUSIC_VOLUME * tpf / FADE_DURATION, 0f);
n.setVolume(nv);
if (nv > 0f) done = false;
}
if (done) {
for (AudioNode n : arr) {
if (n != null) { n.stop(); rootNode.detachChild(n); }
}
fadeStates.set(i, FadeState.INACTIVE);
}
}
}
checkTimer += tpf;
if (checkTimer < CHECK_INTERVAL) return;
checkTimer = 0f;
for (int i = 0; i < data.size(); i++) {
PlacedMusicArea area = data.get(i);
boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
FadeState fs = fadeStates.get(i);
if (inside && (fs == FadeState.INACTIVE || fs == FadeState.FADING_OUT)) {
if (fs == FadeState.INACTIVE) {
log.info("[MusicSystem] Bereich {} betreten → starte Tracks", i);
for (AudioNode n : tracks.get(i)) {
if (n != null) { n.setVolume(0f); rootNode.attachChild(n); n.play(); }
}
}
fadeStates.set(i, FadeState.FADING_IN);
} else if (!inside && (fs == FadeState.ACTIVE || fs == FadeState.FADING_IN)) {
log.info("[MusicSystem] Bereich {} verlassen → fade out", i);
fadeStates.set(i, FadeState.FADING_OUT);
}
}
}
private AudioNode loadAmbient(String path) {
if (path == null || path.isEmpty()) return null;
try {
// Use Stream for music (files are large), Buffer for short sfx
AudioNode n = new AudioNode(assets, path, AudioData.DataType.Stream);
n.setLooping(true);
n.setPositional(false);
n.setVolume(0f);
return n;
} catch (Exception e) {
log.warn("[MusicSystem] Track nicht ladbar '{}': {}", path, e.getMessage());
return null;
}
}
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
float xi = xs[i], zi = zs[i];
float xj = xs[j], zj = zs[j];
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi)) {
inside = !inside;
}
}
return inside;
}
}

View File

@@ -0,0 +1,188 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.post.filters.FogFilter;
import com.jme3.water.WaterFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WeatherState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WeatherState.class);
public enum Weather { SUNNY, CLOUDY, OVERCAST, STORM }
// ── Per-weather targets (Reihenfolge: SUNNY, CLOUDY, OVERCAST, STORM) ─────
private static final float[] FOG_DENSITY = { 0.00f, 0.30f, 0.65f, 0.90f };
private static final float[] FOG_DISTANCE = { 600f, 350f, 140f, 50f };
private static final float[] WIND_SPEED = { 4f, 14f, 26f, 55f };
private static final float[] WAVE_SPEED = { 0.5f, 1.0f, 1.5f, 3.2f };
private static final float[] WAVE_AMP = { 0.3f, 0.5f, 0.8f, 1.8f };
private static final float[] WAVE_SCALE = { 0.008f, 0.007f, 0.006f, 0.005f};
private static final float[] WATER_TRANS = { 0.15f, 0.10f, 0.07f, 0.02f };
private static final float[] FOAM_INTENSITY= { 0.0f, 0.20f, 0.45f, 0.90f };
private static final ColorRGBA[] FOG_COLOR = {
new ColorRGBA(0.75f, 0.80f, 0.88f, 1f),
new ColorRGBA(0.62f, 0.65f, 0.70f, 1f),
new ColorRGBA(0.42f, 0.43f, 0.46f, 1f),
new ColorRGBA(0.18f, 0.19f, 0.21f, 1f),
};
private static final ColorRGBA[] CLOUD_COLOR = {
new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f),
new ColorRGBA(0.75f, 0.75f, 0.78f, 0.72f),
new ColorRGBA(0.35f, 0.35f, 0.37f, 0.92f),
new ColorRGBA(0.08f, 0.08f, 0.10f, 0.98f),
};
private static final ColorRGBA[] WATER_COLOR = {
new ColorRGBA(0.05f, 0.25f, 0.55f, 1f),
new ColorRGBA(0.04f, 0.18f, 0.42f, 1f),
new ColorRGBA(0.03f, 0.12f, 0.28f, 1f),
new ColorRGBA(0.02f, 0.06f, 0.14f, 1f),
};
private static final ColorRGBA[] DEEP_WATER_COLOR = {
new ColorRGBA(0.02f, 0.12f, 0.30f, 1f),
new ColorRGBA(0.01f, 0.08f, 0.20f, 1f),
new ColorRGBA(0.01f, 0.04f, 0.12f, 1f),
new ColorRGBA(0.00f, 0.02f, 0.06f, 1f),
};
// ── State ────────────────────────────────────────────────────────────────
private Weather active = Weather.SUNNY;
private float changeTimer = 120f;
// Aktuell interpolierte Werte
private float fogDensity = 0f;
private float fogDistance = 600f;
private float windSpeed = 4f;
private float waveSpeed = 0.5f;
private float waveAmp = 0.3f;
private float waveScale = 0.008f;
private float waterTrans = 0.15f;
private float foamIntensity = 0f;
private float windAngle = 0f;
private float windAngleTgt = 0.4f;
private final ColorRGBA fogColor = new ColorRGBA(0.75f, 0.80f, 0.88f, 1f);
private final ColorRGBA cloudColor = new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f);
private final ColorRGBA waterColor = new ColorRGBA(0.05f, 0.25f, 0.55f, 1f);
private final ColorRGBA deepWaterColor= new ColorRGBA(0.02f, 0.12f, 0.30f, 1f);
// ── Externe Referenzen ────────────────────────────────────────────────────
private FogFilter fogFilter;
private WaterFilter waterFilter;
private CloudsNode cloudsNode;
private Application app;
public void setFogFilter(FogFilter f) { this.fogFilter = f; }
public void setWaterFilter(WaterFilter f) { this.waterFilter = f; }
public void setCloudsNode(CloudsNode n) { this.cloudsNode = n; }
public Weather getActiveWeather() { return active; }
public float getWindSpeed() { return windSpeed; }
/**
* Setzt das Wetter sofort; Werte interpolieren sanft zum neuen Ziel.
* Der automatische Wechsel-Timer wird zurückgesetzt.
*/
public void forceWeather(Weather w) {
active = w;
changeTimer = 90f + FastMath.nextRandomFloat() * 150f;
windAngleTgt = FastMath.nextRandomFloat() * FastMath.TWO_PI;
log.info("[Weather] forceWeather → {}", w);
}
public Vector3f getWindDirection() {
return new Vector3f(FastMath.sin(windAngle), 0f, FastMath.cos(windAngle));
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override protected void initialize(Application app) { this.app = app; }
@Override protected void cleanup(Application app) {}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
changeTimer -= tpf;
if (changeTimer <= 0f) {
changeTimer = 90f + FastMath.nextRandomFloat() * 150f;
pickNextWeather();
}
int i = active.ordinal();
fogDensity = approach(fogDensity, FOG_DENSITY[i], tpf * 0.025f);
fogDistance = approach(fogDistance, FOG_DISTANCE[i], tpf * 0.025f);
windSpeed = approach(windSpeed, WIND_SPEED[i], tpf * 0.040f);
waveSpeed = approach(waveSpeed, WAVE_SPEED[i], tpf * 0.030f);
waveAmp = approach(waveAmp, WAVE_AMP[i], tpf * 0.025f);
waveScale = approach(waveScale, WAVE_SCALE[i], tpf * 0.020f);
waterTrans = approach(waterTrans, WATER_TRANS[i], tpf * 0.025f);
foamIntensity = approach(foamIntensity, FOAM_INTENSITY[i], tpf * 0.020f);
windAngle = approachAngle(windAngle, windAngleTgt, tpf * 0.012f);
fogColor.interpolateLocal(FOG_COLOR[i], tpf * 0.025f);
cloudColor.interpolateLocal(CLOUD_COLOR[i], tpf * 0.025f);
waterColor.interpolateLocal(WATER_COLOR[i], tpf * 0.020f);
deepWaterColor.interpolateLocal(DEEP_WATER_COLOR[i], tpf * 0.020f);
if (fogFilter != null) {
fogFilter.setFogDensity(fogDensity);
fogFilter.setFogDistance(fogDistance);
fogFilter.setFogColor(fogColor.clone());
}
if (waterFilter != null) {
waterFilter.setSpeed(waveSpeed);
waterFilter.setMaxAmplitude(waveAmp);
waterFilter.setWaveScale(waveScale);
waterFilter.setWaterTransparency(waterTrans);
waterFilter.setFoamIntensity(foamIntensity);
waterFilter.setWaterColor(waterColor.clone());
waterFilter.setDeepWaterColor(deepWaterColor.clone());
waterFilter.setWindDirection(
new com.jme3.math.Vector2f(FastMath.sin(windAngle), FastMath.cos(windAngle)));
}
if (cloudsNode != null) {
cloudsNode.setCloudColor(cloudColor.clone());
Vector3f camPos = ((SimpleApplication) app).getCamera().getLocation();
cloudsNode.update(tpf, getWindDirection(), windSpeed * 0.5f, camPos);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void pickNextWeather() {
float r = FastMath.nextRandomFloat();
Weather next = r < 0.40f ? Weather.SUNNY
: r < 0.70f ? Weather.CLOUDY
: r < 0.88f ? Weather.OVERCAST
: Weather.STORM;
windAngleTgt = FastMath.nextRandomFloat() * FastMath.TWO_PI;
if (next != active) {
active = next;
log.info("[Weather] transitioning to {}", active);
}
}
private static float approach(float cur, float tgt, float alpha) {
return cur + (tgt - cur) * FastMath.clamp(alpha, 0f, 1f);
}
private static float approachAngle(float cur, float tgt, float alpha) {
float d = tgt - cur;
while (d > FastMath.PI) d -= FastMath.TWO_PI;
while (d < -FastMath.PI) d += FastMath.TWO_PI;
return cur + d * FastMath.clamp(alpha, 0f, 1f);
}
}

View File

@@ -0,0 +1,30 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/blight-game.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/blight-game.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" bekanntes JME-Verhalten, kein Fehler -->
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

11
blight-lang/build.gradle Normal file
View File

@@ -0,0 +1,11 @@
// Sprachpakete — enthält TextResolver und die .properties-Dateien für alle Sprachen.
plugins {
id 'java'
}
dependencies {
implementation project(':blight-common')
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
implementation 'org.slf4j:slf4j-api:2.0.17'
}

View File

@@ -0,0 +1,71 @@
package de.blight.lang;
import de.blight.common.model.TextReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
/**
* Löst TextReference-IDs zur Laufzeit in den lokalisierten Text auf.
* Vor dem ersten Aufruf von resolve() muss init(Locale) aufgerufen werden.
* Ohne explizites init() wird Englisch als Standard verwendet.
*/
public class TextResolver {
private static final Logger LOG = LoggerFactory.getLogger(TextResolver.class);
private static final String BASE_NAME = "lang/messages";
private static TextResolver instance;
private final ResourceBundle bundle;
private final ResourceBundle fallback;
private TextResolver(Locale locale) {
this.fallback = ResourceBundle.getBundle(BASE_NAME, Locale.ENGLISH);
if (locale.equals(Locale.ENGLISH)) {
this.bundle = this.fallback;
} else {
ResourceBundle loaded;
try {
loaded = ResourceBundle.getBundle(BASE_NAME, locale);
} catch (MissingResourceException e) {
LOG.warn("Kein Sprachpaket für {} gefunden, falle auf Englisch zurück.", locale);
loaded = this.fallback;
}
this.bundle = loaded;
}
}
public static void init(Locale locale) {
instance = new TextResolver(locale);
}
public static TextResolver get() {
if (instance == null) {
instance = new TextResolver(Locale.ENGLISH);
}
return instance;
}
public String resolve(TextReference ref) {
if (ref == null) return "";
return resolveId(ref.id());
}
public String resolveId(String id) {
if (id == null || id.isBlank()) return "";
try {
return bundle.getString(id);
} catch (MissingResourceException e) {
try {
return fallback.getString(id);
} catch (MissingResourceException ex) {
LOG.warn("Unbekannte Text-ID: {}", id);
return "[" + id + "]";
}
}
}
}

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