diff --git a/ANIMATIONEN_BLENDER_WORKFLOW.txt b/ANIMATIONEN_BLENDER_WORKFLOW.txt new file mode 100644 index 0000000..dedb5c0 --- /dev/null +++ b/ANIMATIONEN_BLENDER_WORKFLOW.txt @@ -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. diff --git a/blight-assets/build.gradle b/blight-assets/build.gradle index 1e2dcc1..a4b67eb 100644 --- a/blight-assets/build.gradle +++ b/blight-assets/build.gradle @@ -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'] } + } +} diff --git a/blight-assets/src/main/resources/Textures/gras/gras1.png b/blight-assets/src/main/resources/Textures/gras/gras1.png new file mode 100644 index 0000000..946bf2b Binary files /dev/null and b/blight-assets/src/main/resources/Textures/gras/gras1.png differ diff --git a/blight-assets/src/main/resources/Textures/gras/gras2.png b/blight-assets/src/main/resources/Textures/gras/gras2.png new file mode 100644 index 0000000..0f7f707 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/gras/gras2.png differ diff --git a/blight-assets/src/main/resources/animations/running.glb b/blight-assets/src/main/resources/animations/running.glb new file mode 100644 index 0000000..6288385 Binary files /dev/null and b/blight-assets/src/main/resources/animations/running.glb differ diff --git a/blight-assets/src/main/resources/animations/walking.glb b/blight-assets/src/main/resources/animations/walking.glb new file mode 100644 index 0000000..ad9486d Binary files /dev/null and b/blight-assets/src/main/resources/animations/walking.glb differ diff --git a/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.mp3 b/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.mp3 new file mode 100644 index 0000000..6055467 Binary files /dev/null and b/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.mp3 differ diff --git a/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.ogg b/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.ogg new file mode 100644 index 0000000..cc19486 Binary files /dev/null and b/blight-assets/src/main/resources/audio/freesound_community-birds-chirping-75156.ogg differ diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o new file mode 100644 index 0000000..827ee0c Binary files /dev/null and b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o differ diff --git a/blight-common/build.gradle b/blight-common/build.gradle index 0ac6870..bc16989 100644 --- a/blight-common/build.gradle +++ b/blight-common/build.gradle @@ -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' +} diff --git a/blight-common/src/main/java/de/blight/common/EmitterIO.java b/blight-common/src/main/java/de/blight/common/EmitterIO.java new file mode 100644 index 0000000..3030ee6 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/EmitterIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/LightIO.java b/blight-common/src/main/java/de/blight/common/LightIO.java new file mode 100644 index 0000000..a31ee83 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/LightIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/MapData.java b/blight-common/src/main/java/de/blight/common/MapData.java index 76fe94d..0eec543 100644 --- a/blight-common/src/main/java/de/blight/common/MapData.java +++ b/blight-common/src/main/java/de/blight/common/MapData.java @@ -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 0–255. */ + /** 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 0–255. */ + /** 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 0–255 (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]; } } diff --git a/blight-common/src/main/java/de/blight/common/MapIO.java b/blight-common/src/main/java/de/blight/common/MapIO.java index 3a2dddc..d0a3c50 100644 --- a/blight-common/src/main/java/de/blight/common/MapIO.java +++ b/blight-common/src/main/java/de/blight/common/MapIO.java @@ -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(); + } } diff --git a/blight-common/src/main/java/de/blight/common/MusicAreaIO.java b/blight-common/src/main/java/de/blight/common/MusicAreaIO.java new file mode 100644 index 0000000..0f2454d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/MusicAreaIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/PlacedEmitter.java b/blight-common/src/main/java/de/blight/common/PlacedEmitter.java new file mode 100644 index 0000000..2d6daec --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedEmitter.java @@ -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); + } +} diff --git a/blight-common/src/main/java/de/blight/common/PlacedLight.java b/blight-common/src/main/java/de/blight/common/PlacedLight.java new file mode 100644 index 0000000..809e6df --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedLight.java @@ -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 +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedModel.java b/blight-common/src/main/java/de/blight/common/PlacedModel.java new file mode 100644 index 0000000..f4568f8 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedModel.java @@ -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 +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedModelIO.java b/blight-common/src/main/java/de/blight/common/PlacedModelIO.java new file mode 100644 index 0000000..bd072ee --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedModelIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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 : ""; } +} diff --git a/blight-common/src/main/java/de/blight/common/PlacedMusicArea.java b/blight-common/src/main/java/de/blight/common/PlacedMusicArea.java new file mode 100644 index 0000000..1b8d75d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedMusicArea.java @@ -0,0 +1,9 @@ +package de.blight.common; + +public record PlacedMusicArea( + float[] pointsX, + float[] pointsZ, + String dayTrack, + String nightTrack, + String combatTrack +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedSoundArea.java b/blight-common/src/main/java/de/blight/common/PlacedSoundArea.java new file mode 100644 index 0000000..669ad26 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedSoundArea.java @@ -0,0 +1,9 @@ +package de.blight.common; + +public record PlacedSoundArea( + float[] pointsX, + float[] pointsZ, + String soundPath, + float volume, + boolean crossfade +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedWater.java b/blight-common/src/main/java/de/blight/common/PlacedWater.java new file mode 100644 index 0000000..e4f1f81 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedWater.java @@ -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 +) {} diff --git a/blight-common/src/main/java/de/blight/common/SoundAreaIO.java b/blight-common/src/main/java/de/blight/common/SoundAreaIO.java new file mode 100644 index 0000000..f0156a9 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/SoundAreaIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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}; + } +} diff --git a/blight-common/src/main/java/de/blight/common/WaterBodyIO.java b/blight-common/src/main/java/de/blight/common/WaterBodyIO.java new file mode 100644 index 0000000..c4ddc3c --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/WaterBodyIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/AudioReference.java b/blight-common/src/main/java/de/blight/common/model/AudioReference.java new file mode 100644 index 0000000..98a5904 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/AudioReference.java @@ -0,0 +1,5 @@ +package de.blight.common.model; + +public interface AudioReference { + +} diff --git a/blight-common/src/main/java/de/blight/common/model/CharacterIO.java b/blight-common/src/main/java/de/blight/common/model/CharacterIO.java new file mode 100644 index 0000000..a8642c3 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CharacterIO.java @@ -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: .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 loadAll(Path directory) { + if (!Files.isDirectory(directory)) return List.of(); + List result = new ArrayList<>(); + try (Stream 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/CharacterListener.java b/blight-common/src/main/java/de/blight/common/model/CharacterListener.java new file mode 100644 index 0000000..9fa3144 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CharacterListener.java @@ -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(); +} diff --git a/blight-common/src/main/java/de/blight/common/model/Collectable.java b/blight-common/src/main/java/de/blight/common/model/Collectable.java new file mode 100644 index 0000000..6b645f1 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Collectable.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/CraftingTable.java b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java new file mode 100644 index 0000000..9a1ee90 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/DialogOption.java b/blight-common/src/main/java/de/blight/common/model/DialogOption.java new file mode 100644 index 0000000..873ac53 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/DialogOption.java @@ -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 nextOptions; + private List disablesOptions; + + private RequiredItem requiredItem; + private RecievesItem recievesItem; + + private Quest recievesQuest; + private Quest fulfillsQuest; + private List abortsQuests = new ArrayList(); + + private boolean enablesTrade; + + public Status getRequiredStatus() { + if (requiresStatus == null) { + return Status.ENEMY; + } + return requiresStatus; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/GameCharacter.java b/blight-common/src/main/java/de/blight/common/model/GameCharacter.java new file mode 100644 index 0000000..a940d2c --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/GameCharacter.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/Interactable.java b/blight-common/src/main/java/de/blight/common/model/Interactable.java new file mode 100644 index 0000000..6045ae8 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Interactable.java @@ -0,0 +1,5 @@ +package de.blight.common.model; + +public interface Interactable { + +} diff --git a/blight-common/src/main/java/de/blight/common/model/Inventar.java b/blight-common/src/main/java/de/blight/common/model/Inventar.java new file mode 100644 index 0000000..15c7a90 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Inventar.java @@ -0,0 +1,80 @@ +package de.blight.common.model; + +import java.util.HashMap; +import java.util.Map.Entry; + +public class Inventar { + + private HashMap items = new HashMap(); + + 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 entry : recipe.getComponents().entrySet()) { + if (!hasItem(entry.getKey(), entry.getValue())) { + return false; + } + } + return true; + } + + public void craft(Recipe recipe) { + for (Entry entry : recipe.getComponents().entrySet()) { + remove(entry.getKey(), entry.getValue()); + } + add(recipe.getCreates()); + } + } diff --git a/blight-common/src/main/java/de/blight/common/model/Item.java b/blight-common/src/main/java/de/blight/common/model/Item.java new file mode 100644 index 0000000..078cf4e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Item.java @@ -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) { + + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/ItemCategory.java b/blight-common/src/main/java/de/blight/common/model/ItemCategory.java new file mode 100644 index 0000000..9ae4b1f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/ItemCategory.java @@ -0,0 +1,11 @@ +package de.blight.common.model; + +public enum ItemCategory { + + WEAPON, + GEAR, + CONSUMABLES, + QUEST_ITEMS, + USABLES, + MISC; +} diff --git a/blight-common/src/main/java/de/blight/common/model/ItemCount.java b/blight-common/src/main/java/de/blight/common/model/ItemCount.java new file mode 100644 index 0000000..da33750 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/ItemCount.java @@ -0,0 +1,7 @@ +package de.blight.common.model; + +public interface ItemCount { + + public Item getItem(); + public int getCount(); +} diff --git a/blight-common/src/main/java/de/blight/common/model/Location.java b/blight-common/src/main/java/de/blight/common/model/Location.java new file mode 100644 index 0000000..8d3971a --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Location.java @@ -0,0 +1,5 @@ +package de.blight.common.model; + +public interface Location { + +} diff --git a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java new file mode 100644 index 0000000..d26fb1b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java @@ -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 openQuests; + private List completedQuests; + + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private List listeners = new ArrayList(); + + 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); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/NPC.java b/blight-common/src/main/java/de/blight/common/model/NPC.java new file mode 100644 index 0000000..7e9b0b6 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/NPC.java @@ -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 items; + + private List currentOptions; + + public List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/ObjectReference.java b/blight-common/src/main/java/de/blight/common/model/ObjectReference.java new file mode 100644 index 0000000..cb1f054 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/ObjectReference.java @@ -0,0 +1,5 @@ +package de.blight.common.model; + +public interface ObjectReference { + +} diff --git a/blight-common/src/main/java/de/blight/common/model/RecievesItem.java b/blight-common/src/main/java/de/blight/common/model/RecievesItem.java new file mode 100644 index 0000000..96840cf --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/RecievesItem.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/Recipe.java b/blight-common/src/main/java/de/blight/common/model/Recipe.java new file mode 100644 index 0000000..918cbf0 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Recipe.java @@ -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 components; + private Interactable requires; +} diff --git a/blight-common/src/main/java/de/blight/common/model/RequiredItem.java b/blight-common/src/main/java/de/blight/common/model/RequiredItem.java new file mode 100644 index 0000000..ec4be31 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/RequiredItem.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/Status.java b/blight-common/src/main/java/de/blight/common/model/Status.java new file mode 100644 index 0000000..3173320 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Status.java @@ -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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/TextReference.java b/blight-common/src/main/java/de/blight/common/model/TextReference.java new file mode 100644 index 0000000..4716198 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/TextReference.java @@ -0,0 +1,3 @@ +package de.blight.common.model; + +public record TextReference(String id) {} diff --git a/blight-common/src/main/java/de/blight/common/model/XPHelper.java b/blight-common/src/main/java/de/blight/common/model/XPHelper.java new file mode 100644 index 0000000..eb6658d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/XPHelper.java @@ -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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java b/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java new file mode 100644 index 0000000..ec98c59 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java @@ -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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/abilities/Ability.java b/blight-common/src/main/java/de/blight/common/model/abilities/Ability.java new file mode 100644 index 0000000..1e56223 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/abilities/Ability.java @@ -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); +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java new file mode 100644 index 0000000..c57c756 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java new file mode 100644 index 0000000..a5baa3f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java new file mode 100644 index 0000000..fc48fe3 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java new file mode 100644 index 0000000..f68a961 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/Quest.java b/blight-common/src/main/java/de/blight/common/model/quests/Quest.java new file mode 100644 index 0000000..9d0b047 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/Quest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java b/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java new file mode 100644 index 0000000..e249d04 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java @@ -0,0 +1,5 @@ +package de.blight.common.model.quests; + +public interface QuestType { + +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java new file mode 100644 index 0000000..0ccdc7f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java @@ -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; +} diff --git a/blight-common/src/main/java/de/blight/common/time/DayTime.java b/blight-common/src/main/java/de/blight/common/time/DayTime.java new file mode 100644 index 0000000..ce453d2 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/time/DayTime.java @@ -0,0 +1,57 @@ +package de.blight.common.time; + +import java.util.ArrayList; +import java.util.List; + +/** + * Verwaltet die Spielzeit innerhalb eines Tages (0.0–1.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 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.0–1.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 (0–23). */ + public int getHour() { return (int)(timeOfDay * 24f); } + + /** Aktuelle Spielminute (0–59). */ + 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); } +} diff --git a/blight-common/src/main/java/de/blight/common/time/TimeListener.java b/blight-common/src/main/java/de/blight/common/time/TimeListener.java new file mode 100644 index 0000000..038eddc --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/time/TimeListener.java @@ -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); +} diff --git a/blight-editor/build.gradle b/blight-editor/build.gradle index 13f61d8..9778131 100644 --- a/blight-editor/build.gradle +++ b/blight-editor/build.gradle @@ -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() } } diff --git a/blight-editor/src/main/java/de/blight/editor/BlightEditor.java b/blight-editor/src/main/java/de/blight/editor/BlightEditor.java index 1740038..59ca2c6 100644 --- a/blight-editor/src/main/java/de/blight/editor/BlightEditor.java +++ b/blight-editor/src/main/java/de/blight/editor/BlightEditor.java @@ -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). diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index d3cfa86..2bd3914 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -5,6 +5,7 @@ import de.blight.editor.tool.EditorTool; import de.blight.editor.tool.ToolParameter; import de.blight.editor.tree.PalmOptions; import de.blight.editor.tree.TreeParams; +import de.blight.editor.ui.TextureChooser; import de.blight.eztree.Billboard; import de.blight.eztree.TreeOptions; import de.blight.eztree.TreePresets; @@ -40,20 +41,17 @@ import java.util.function.Consumer; public class EditorApp extends Application { - // ── Viewport-Auflösung (JME3 rendert intern auf diese Größe) ──────────── - static final int VP_WIDTH = 1024; - static final int VP_HEIGHT = 640; + // ── Initiale JME-Startauflösung (wird sofort durch Fenster-Größe überschrieben) ─ + private static final int INITIAL_VP_W = 1280; + private static final int INITIAL_VP_H = 720; - private static final Path ASSET_ROOT = ProjectRoot.resolve("editor-assets"); + private static final Path ASSET_ROOT = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); private final SharedInput input = new SharedInput(); private WritableImage jfxImage; private ImageView viewport; private Label statusLabel; private Label camCoordsLabel; - private HBox consoleBar; - private TextField consoleField; - private boolean consoleOpen = false; private boolean launchGameAfterSave = false; private VBox toolPanel; private BorderPane root; @@ -69,8 +67,10 @@ public class EditorApp extends Application { private TreeParams treeParams = TreeParams.oak(); // EZ-Tree-Zustand - private TreeOptions ezTreeOptions = TreePresets.oakMedium(); - private final TextField ezTreeNameField = new TextField("EzBaum1"); + private TreeOptions ezTreeOptions = TreePresets.oakMedium(); + private String ezTreePresetName = "Oak Medium"; + private final TextField ezTreeNameField = new TextField("EzBaum1"); + private final TextField treeCategoryField = new TextField("oak/medium"); // Palmen-Generator-Zustand private PalmOptions palmOptions = new PalmOptions(); @@ -82,18 +82,98 @@ public class EditorApp extends Application { private Runnable onF6 = () -> {}; // Zufälligen Seed wählen // Asset-Panel: Pfad-Map + DnD-Zustand - private final Map, Path> itemPaths = new HashMap<>(); - private TreeItem draggedItem = null; + private final Map, Path> itemPaths = new HashMap<>(); + private final Map, String> jmePaths = new HashMap<>(); + private final java.util.Set> jmeFolderNodes = new java.util.HashSet<>(); + private TreeItem draggedItem = null; // Objekt-Werkzeug-Zustand private Label objModelLabel; // zeigt den ausgewählten Modell-Pfad private Label objPosLabel; // zeigt Position/Rotation private CheckBox objSolidCB; // Solid-Flag des selektierten Objekts private CheckBox multiPlaceCB; // Mehrfach-Platzierungs-Modus + private TextField objXField, objYField, objZField; + private TextField objRotXField, objRotYField, objRotZField; + private Label objTexLabel; + private Label objNormalMapLabel; + private Label objMatLabel; + private VBox objDynamicContent; + private Button objMergeBtn; + private Button objSaveBtn; + private Button objDeleteBtn; + + // Animations-Vorschau-Werkzeug-Zustand + private ImageView animPreviewView; + private ListView animClipListView; + private Label animPreviewStatusLabel; + private ComboBox animPreviewModelCombo; + private ComboBox addAnimComboField; + private double animPrevDragX, animPrevDragY; + // Cache: Menge aller j3o-Pfade die ein Skelett besitzen (null = Scan noch nicht abgeschlossen) + private java.util.Set skeletalModelPaths = null; + + /** Relativer Asset-Pfad des aktuell geladenen Modells (für .animset.json-Persistenz). */ + private String animCurrentModelPath = null; + + // Character-Editor-Zustand + private javafx.scene.control.TextField charNameField; + private javafx.scene.control.TextField charIdField; + private javafx.scene.control.ComboBox charTypeCombo; + private javafx.scene.control.ComboBox charModelCombo; + private javafx.scene.control.ComboBox charAnimSetCombo; + /** Read-only labels for action assignments shown in character editor. */ + private VBox charActionLabelsBox; + private Label charEditorStatusLabel; + private javafx.scene.control.ListView charListView; + + // Licht-Werkzeug-Zustand + private VBox lightDynamicContent; + + // Emitter-Werkzeug-Zustand + private VBox emitterDynamicContent; + + // Wasser-Werkzeug-Zustand + private VBox waterDynamicContent; + + // Sound-Bereich-Werkzeug-Zustand + private VBox soundAreaDynamicContent; + + // Musik-Bereich-Werkzeug-Zustand + private VBox musicAreaDynamicContent; + + // Spiel-Starten-Werkzeug-Zustand + private TextField spawnXField; + private TextField spawnZField; + + // Baum-Ordner-Modus + private Label randomTreeStatusLabel; + private ComboBox treeFolderCB; + + // Tripo3D-Generator-Zustand + private ImageView tripoPreviewView; + private Label tripoStatusLabel; + private ProgressBar tripoProgressBar; + private Button tripoImportBtn; + private Button tripoImportRigBtn; + private boolean tripoGenerating = false; + private String tripoLastModelUrl; + private String tripoLastTaskId; // Toolbar-Buttons (müssen vom Status-Poller erreichbar sein) + private ToggleButton baseBtn; + private ToggleButton grassBtn; + private ToggleButton textureBtn; private ToggleButton objPlaceBtn; private ToggleButton objEditBtn; + private ToggleButton lightBtn; + private ToggleButton emitterBtn; + private ToggleButton waterBtn; + private ToggleButton soundAreaBtn; + private ToggleButton musicAreaBtn; + private ToggleButton playToolBtn; + + // "Objekt"-Button in der Selektionsleiste (zum Zurückschalten bei importierten Objekten) + private ToggleButton selModeObjectBtn; // Mesh-Primitiv-Auswahl (Platzieren-Modus) private ToggleGroup meshToggleGroup; @@ -114,6 +194,8 @@ public class EditorApp extends Application { private TreeItem modelsNode; private TreeItem texturesNode; private TreeItem audioNode; + private TreeItem jmeModelsNode; + private TreeItem animationsNode; // ── JavaFX Entry-Point ─────────────────────────────────────────────────── @@ -121,8 +203,8 @@ public class EditorApp extends Application { @Override public void start(Stage stage) { - jfxImage = new WritableImage(VP_WIDTH, VP_HEIGHT); - JmeEditorApp.launch(input, jfxImage, VP_WIDTH, VP_HEIGHT); + jfxImage = new WritableImage(INITIAL_VP_W, INITIAL_VP_H); + JmeEditorApp.launch(input, jfxImage, INITIAL_VP_W, INITIAL_VP_H); assetPanel = buildAssetPanel(); toolPanel = buildToolPanel(); @@ -137,10 +219,21 @@ public class EditorApp extends Application { root.setBottom(buildBottomBox()); Scene scene = new Scene(root, 1280, 760); - scene.setOnKeyPressed(e -> handleKeyPress(e.getCode(), true)); - scene.setOnKeyReleased(e -> handleKeyPress(e.getCode(), false)); + java.net.URL cssUrl = getClass().getResource("/editor.css"); + if (cssUrl != null) scene.getStylesheets().add(cssUrl.toExternalForm()); + scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> handleKeyPress(e.getCode(), true)); + scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_RELEASED, e -> handleKeyPress(e.getCode(), false)); + scene.setOnKeyTyped(e -> { + if (!input.consoleIsOpen) return; + String ch = e.getCharacter(); + if (ch.length() == 1) { + char c = ch.charAt(0); + if (c >= ' ') input.consoleChars.offer(c); + } + }); primaryStage = stage; + input.scanSkeletalRequested = true; // Skelett-Scan beim Start anstoßen stage.setTitle("Blight World Editor"); stage.setScene(scene); stage.setMinWidth(900); @@ -170,11 +263,53 @@ public class EditorApp extends Application { treePreviewView.setImage(input.treePreviewImage); } + if (input.animPreviewResized) { + input.animPreviewResized = false; + if (animPreviewView != null) + animPreviewView.setImage(input.animPreviewImage); + } + java.util.Set skeletal = input.skeletalPaths.getAndSet(null); + if (skeletal != null) { + skeletalModelPaths = skeletal; + } + java.util.List newClips = input.animPreviewClips.getAndSet(null); + if (newClips != null && animClipListView != null) { + animClipListView.getItems().setAll(newClips); + if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst(); + } + String loadedPath = input.animPreviewLoadedPath.getAndSet(null); + if (loadedPath != null) { + animCurrentModelPath = loadedPath; + } + String animStatus = input.animPreviewStatus; + if (animStatus != null) { + input.animPreviewStatus = null; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus); + } + + String animOp = input.animOpStatus; + if (animOp != null) { + input.animOpStatus = null; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animOp); + } + + String rts = input.randomTreeStatus; + if (randomTreeStatusLabel != null && rts != null) { + randomTreeStatusLabel.setText(rts); + } + if (input.refreshAssets) { input.refreshAssets = false; refreshCategoryNode(modelsNode, ".j3o", ".obj", ".fbx", ".gltf", ".glb"); modelsNode.setExpanded(true); + refreshCategoryNode(animationsNode, ".j3o", ".gltf", ".glb"); + refreshAddAnimCombo(addAnimComboField); + } + + if (input.refreshTreeFolders) { + input.refreshTreeFolders = false; + populateTreeFolderCombo(); } if (input.objectJustPlaced) { @@ -191,6 +326,36 @@ public class EditorApp extends Application { updateObjectPanel(input.selectedObjectInfo); } + if (input.lightSelectionChanged) { + input.lightSelectionChanged = false; + updateLightPanel(input.selectedLightInfo); + } + + if (input.emitterSelectionChanged) { + input.emitterSelectionChanged = false; + updateEmitterPanel(input.selectedEmitterInfo); + } + + if (input.waterSelectionChanged) { + input.waterSelectionChanged = false; + updateWaterPanel(input.selectedWaterInfo); + } + + if (input.soundAreaSelectionChanged) { + input.soundAreaSelectionChanged = false; + updateSoundAreaPanel(input.selectedSoundAreaInfo); + } + + if (input.musicAreaSelectionChanged) { + input.musicAreaSelectionChanged = false; + updateMusicAreaPanel(input.selectedMusicAreaInfo); + } + + if (input.spawnPickChanged) { + input.spawnPickChanged = false; + updateSpawnFields(input.pickedSpawnInfo); + } + // Kamera-Koordinaten aktualisieren camCoordsLabel.setText(String.format( "X:%.1f Y:%.1f Z:%.1f Yaw:%.0f° Pitch:%.0f°", @@ -200,6 +365,30 @@ public class EditorApp extends Application { // Konsolen-Antwort anzeigen String consoleMsg = input.consoleOutput; if (consoleMsg != null) { input.consoleOutput = null; setStatus(consoleMsg); } + + // Textur-Slots aktualisieren sobald JME Map geladen hat + if (input.texturePathsLoaded) { + input.texturePathsLoaded = false; + if (input.activeLayer == 4) { + showToolParameters(toolPanel, input.activeTool); + } + } + + // Plateau-Höhe per Rechtsklick geändert → Slider neu aufbauen + if (input.activeTool instanceof de.blight.editor.tool.HeightTool ht + && ht.plateauHeightChanged) { + ht.plateauHeightChanged = false; + showToolParameters(toolPanel, input.activeTool); + } + + // Neues JME-Image nach Viewport-Resize übernehmen + javafx.scene.image.WritableImage newImg = input.resizedImage.getAndSet(null); + if (newImg != null) { + jfxImage = newImg; + viewport.setImage(newImg); + input.viewportScaleX = 1.0; + input.viewportScaleY = 1.0; + } }) ); statusPoller.setCycleCount(javafx.animation.Timeline.INDEFINITE); @@ -247,6 +436,25 @@ public class EditorApp extends Application { root.setRight(buildPalmParamsPanel()); } + private void switchToTripo() { + currentTool = "tripo"; + topBar.getChildren().set(1, buildTripoToolBar()); + + // Center: large image preview (filled once generation succeeds) + StackPane previewCenter = new StackPane(); + previewCenter.setStyle("-fx-background-color: #1a1a2e;"); + tripoPreviewView = new ImageView(); + tripoPreviewView.setPreserveRatio(true); + tripoPreviewView.fitWidthProperty().bind(previewCenter.widthProperty()); + tripoPreviewView.fitHeightProperty().bind(previewCenter.heightProperty()); + Label placeholder = new Label("Vorschau erscheint hier nach der Generierung"); + placeholder.setStyle("-fx-text-fill: #888; -fx-font-size: 14;"); + previewCenter.getChildren().addAll(placeholder, tripoPreviewView); + root.setCenter(previewCenter); + + root.setRight(buildTripoPanel()); + } + // ── Oberer Bereich: MenuBar + ToolBar ──────────────────────────────────── private VBox buildTop() { @@ -263,12 +471,19 @@ public class EditorApp extends Application { MenuItem treeGenItem = new MenuItem("Baum Generator (blight)"); MenuItem ezTreeItem = new MenuItem("Baum Generator (EZ Tree)"); MenuItem palmItem = new MenuItem("Baum Generator (Palme)"); + MenuItem tripoItem = new MenuItem("AI Modell-Generator (Tripo3D)"); + MenuItem animPrevItem = new MenuItem("Animationseditor"); MenuItem worldEditItem = new MenuItem("Welteneditor"); + MenuItem charEditItem = new MenuItem("Character Editor"); treeGenItem.setOnAction(e -> switchToTreeGenerator()); ezTreeItem.setOnAction(e -> switchToEzTree()); palmItem.setOnAction(e -> switchToPalm()); + tripoItem.setOnAction(e -> switchToTripo()); + animPrevItem.setOnAction(e -> switchToAnimPreview()); worldEditItem.setOnAction(e -> switchToWorldEditor()); - toolsMenu.getItems().addAll(treeGenItem, ezTreeItem, palmItem, worldEditItem); + charEditItem.setOnAction(e -> switchToCharacterEditor()); + toolsMenu.getItems().addAll(treeGenItem, ezTreeItem, palmItem, tripoItem, + animPrevItem, worldEditItem, charEditItem); Menu viewMenu = new Menu("Ansicht"); MenuItem resetCam = new MenuItem("Kamera zurücksetzen"); @@ -278,29 +493,41 @@ public class EditorApp extends Application { menuBar.getMenus().addAll(fileMenu, toolsMenu, viewMenu); ToolBar toolBar = new ToolBar(); - ToggleButton baseBtn = new ToggleButton("▲▼ Basis-Terrain"); - ToggleButton upperBtn = new ToggleButton("⛰ Gebirge"); - ToggleButton holesBtn = new ToggleButton("⬤ Höhlen/Löcher"); - ToggleButton grassBtn = new ToggleButton("🌿 Gras"); - ToggleButton textureBtn = new ToggleButton("🎨 Textur"); + baseBtn = new ToggleButton("▲▼ Basis-Terrain"); + grassBtn = new ToggleButton("🌿 Gras"); + textureBtn = new ToggleButton("🎨 Textur"); objPlaceBtn = new ToggleButton("📦 Platzieren"); objEditBtn = new ToggleButton("🔧 Bearbeiten"); + lightBtn = new ToggleButton("💡 Licht"); + emitterBtn = new ToggleButton("🔥 Emitter"); + waterBtn = new ToggleButton("💧 Wasser"); + soundAreaBtn = new ToggleButton("🔊 Sound"); + musicAreaBtn = new ToggleButton("🎵 Musik"); + playToolBtn = new ToggleButton("🎮 Spielen"); baseBtn.setStyle("-fx-font-weight:bold;"); - upperBtn.setStyle("-fx-font-weight:bold;"); - holesBtn.setStyle("-fx-font-weight:bold;"); grassBtn.setStyle("-fx-font-weight:bold;"); textureBtn.setStyle("-fx-font-weight:bold;"); objPlaceBtn.setStyle("-fx-font-weight:bold;"); objEditBtn.setStyle("-fx-font-weight:bold;"); + lightBtn.setStyle("-fx-font-weight:bold;"); + emitterBtn.setStyle("-fx-font-weight:bold;"); + waterBtn.setStyle("-fx-font-weight:bold;"); + soundAreaBtn.setStyle("-fx-font-weight:bold;"); + musicAreaBtn.setStyle("-fx-font-weight:bold;"); + playToolBtn.setStyle("-fx-font-weight:bold;"); ToggleGroup layerGroup = new ToggleGroup(); baseBtn.setToggleGroup(layerGroup); - upperBtn.setToggleGroup(layerGroup); - holesBtn.setToggleGroup(layerGroup); grassBtn.setToggleGroup(layerGroup); textureBtn.setToggleGroup(layerGroup); objPlaceBtn.setToggleGroup(layerGroup); objEditBtn.setToggleGroup(layerGroup); + lightBtn.setToggleGroup(layerGroup); + emitterBtn.setToggleGroup(layerGroup); + waterBtn.setToggleGroup(layerGroup); + soundAreaBtn.setToggleGroup(layerGroup); + musicAreaBtn.setToggleGroup(layerGroup); + playToolBtn.setToggleGroup(layerGroup); baseBtn.setSelected(true); baseBtn.setOnAction(e -> { @@ -308,16 +535,6 @@ public class EditorApp extends Application { root.setRight(toolPanel); showToolParameters(toolPanel, input.activeTool); }); - upperBtn.setOnAction(e -> { - input.activeLayer = 1; input.activeTool = input.upperHeightTool; - root.setRight(toolPanel); - showToolParameters(toolPanel, input.activeTool); - }); - holesBtn.setOnAction(e -> { - input.activeLayer = 2; input.activeTool = input.holeTool; - root.setRight(toolPanel); - showToolParameters(toolPanel, input.activeTool); - }); grassBtn.setOnAction(e -> { input.activeLayer = 3; input.activeTool = input.grassTool; root.setRight(toolPanel); @@ -337,29 +554,42 @@ public class EditorApp extends Application { input.pendingModelPath = null; root.setRight(buildObjectEditPanel()); }); - - CheckBox visibleCB = new CheckBox("Gebirge sichtbar"); - visibleCB.setSelected(true); - visibleCB.setOnAction(e -> input.upperLayerVisible = visibleCB.isSelected()); + lightBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_LIGHTS; + root.setRight(buildLightPanel()); + }); + emitterBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_EMITTERS; + root.setRight(buildEmitterPanel()); + }); + waterBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_WATER; + root.setRight(buildWaterPanel()); + }); + soundAreaBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_SOUND_AREAS; + root.setRight(buildSoundAreaPanel()); + }); + musicAreaBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_MUSIC_AREAS; + root.setRight(buildMusicAreaPanel()); + }); + playToolBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_PLAY_TOOL; + root.setRight(buildPlayToolPanel()); + }); Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief"); hint.setStyle("-fx-text-fill: #555;"); - Region toolbarSpacer = new Region(); - HBox.setHgrow(toolbarSpacer, Priority.ALWAYS); - - Button playBtn = new Button("▶ Spielen"); - playBtn.setStyle( - "-fx-background-color: #2d8a3e; -fx-text-fill: white; " + - "-fx-font-weight: bold; -fx-padding: 4 12 4 12;"); - playBtn.setOnAction(e -> launchGame()); - - toolBar.getItems().addAll(baseBtn, upperBtn, holesBtn, grassBtn, textureBtn, + toolBar.getItems().addAll(baseBtn, grassBtn, textureBtn, new Separator(Orientation.VERTICAL), objPlaceBtn, objEditBtn, - new Separator(Orientation.VERTICAL), visibleCB, - new Separator(Orientation.VERTICAL), hint, - toolbarSpacer, - new Separator(Orientation.VERTICAL), playBtn); + new Separator(Orientation.VERTICAL), lightBtn, + new Separator(Orientation.VERTICAL), emitterBtn, + new Separator(Orientation.VERTICAL), waterBtn, + new Separator(Orientation.VERTICAL), soundAreaBtn, musicAreaBtn, + new Separator(Orientation.VERTICAL), playToolBtn, + new Separator(Orientation.VERTICAL), hint); worldToolBar = toolBar; return new VBox(menuBar, toolBar); @@ -581,7 +811,8 @@ public class EditorApp extends Application { presetBox.getItems().addAll(TreePresets.presetNames()); presetBox.setValue("Oak Medium"); presetBox.setOnAction(e -> { - ezTreeOptions = TreePresets.byName(presetBox.getValue()); + ezTreePresetName = presetBox.getValue(); + ezTreeOptions = TreePresets.byName(ezTreePresetName); root.setRight(buildEzTreeParamsPanel()); }); @@ -589,10 +820,16 @@ public class EditorApp extends Application { nameLabel.setStyle("-fx-font-weight: bold;"); ezTreeNameField.setPrefWidth(130); + Label categoryLabel = new Label("Kategorie:"); + categoryLabel.setStyle("-fx-font-weight: bold;"); + treeCategoryField.setPrefWidth(120); + treeCategoryField.setPromptText("z.B. oak/small"); + onF5 = () -> { input.ezTreeGenQueue.offer( new SharedInput.EzTreeGenRequest( - ezTreeOptions.copy(), false, ezTreeNameField.getText().trim())); + ezTreeOptions.copy(), ezTreePresetName, false, ezTreeNameField.getText().trim(), + treeCategoryField.getText().trim())); setStatus("EZ-Tree: generiere Vorschau…"); }; Button previewBtn = new Button("▶ Vorschau [F5]"); @@ -603,7 +840,8 @@ public class EditorApp extends Application { exportBtn.setOnAction(e -> { input.ezTreeGenQueue.offer( new SharedInput.EzTreeGenRequest( - ezTreeOptions.copy(), true, ezTreeNameField.getText().trim())); + ezTreeOptions.copy(), ezTreePresetName, true, ezTreeNameField.getText().trim(), + treeCategoryField.getText().trim())); setStatus("EZ-Tree: generiere und exportiere…"); }); @@ -612,6 +850,8 @@ public class EditorApp extends Application { presetLabel, presetBox, new Separator(Orientation.VERTICAL), nameLabel, ezTreeNameField, + new Separator(Orientation.VERTICAL), + categoryLabel, treeCategoryField, previewBtn, exportBtn ); return bar; @@ -661,21 +901,6 @@ public class EditorApp extends Application { // ── Rinde ──────────────────────────────────────────────────────────── inner.getChildren().addAll(sectionTitle("Rinde"), new Separator()); - inner.getChildren().add(bold("Textur:")); - String[] barkTexNames = {"Keine", "Bark001", "Bark002", "Bark003", "Bark008"}; - String[] barkTexPaths = {null, - "Textures/bark/Bark001_Color.jpg", "Textures/bark/Bark002_Color.jpg", - "Textures/bark/Bark003_Color.jpg", "Textures/bark/Bark008_Color.jpg"}; - ChoiceBox barkTexCB = new ChoiceBox<>(); - barkTexCB.getItems().addAll(barkTexNames); - barkTexCB.setValue(pathToName(ezTreeOptions.bark.textureFile, barkTexPaths, barkTexNames)); - barkTexCB.setMaxWidth(Double.MAX_VALUE); - barkTexCB.setOnAction(e -> { - int idx = barkTexCB.getSelectionModel().getSelectedIndex(); - ezTreeOptions.bark.textureFile = barkTexPaths[idx]; - }); - inner.getChildren().add(barkTexCB); - inner.getChildren().add(ezFloat("UV-Skala X:", 0.1, 5.0, ezTreeOptions.bark.textureScaleX, v -> ezTreeOptions.bark.textureScaleX = v)); inner.getChildren().add(ezFloat("UV-Skala Y:", 0.1, 5.0, ezTreeOptions.bark.textureScaleY, @@ -707,36 +932,13 @@ public class EditorApp extends Application { // ── Blätter ────────────────────────────────────────────────────────── inner.getChildren().addAll(sectionTitle("Blätter"), new Separator()); - inner.getChildren().add(bold("Textur:")); - String[] leafTexNames = {"Keine", "Eiche", "Esche", "Espe", "Kiefer", "Palme"}; - String[] leafTexPaths = {null, - "Textures/leaves/oak.png", "Textures/leaves/ash.png", - "Textures/leaves/aspen.png", "Textures/leaves/pine.png", - "Textures/leaves/palm.png"}; - ChoiceBox leafTexCB = new ChoiceBox<>(); - leafTexCB.getItems().addAll(leafTexNames); - leafTexCB.setValue(pathToName(ezTreeOptions.leaves.textureFile, leafTexPaths, leafTexNames)); - leafTexCB.setMaxWidth(Double.MAX_VALUE); - leafTexCB.setOnAction(e -> { - int idx = leafTexCB.getSelectionModel().getSelectedIndex(); - ezTreeOptions.leaves.textureFile = leafTexPaths[idx]; - }); - inner.getChildren().add(leafTexCB); - inner.getChildren().add(bold("Billboard:")); ChoiceBox billCB = new ChoiceBox<>(); - billCB.getItems().addAll("Kein", "Kreuz", "X-Drehung"); - billCB.setValue(switch (ezTreeOptions.leaves.billboard) { - case CROSS -> "Kreuz"; - case ROTATE_X -> "X-Drehung"; - default -> "Kein"; - }); + billCB.getItems().addAll("Einfach", "Kreuz"); + billCB.setValue(ezTreeOptions.leaves.billboard == Billboard.CROSS ? "Kreuz" : "Einfach"); billCB.setMaxWidth(Double.MAX_VALUE); - billCB.setOnAction(e -> ezTreeOptions.leaves.billboard = switch (billCB.getValue()) { - case "Kreuz" -> Billboard.CROSS; - case "X-Drehung" -> Billboard.ROTATE_X; - default -> Billboard.NONE; - }); + billCB.setOnAction(e -> ezTreeOptions.leaves.billboard = + "Kreuz".equals(billCB.getValue()) ? Billboard.CROSS : Billboard.NONE); inner.getChildren().add(billCB); inner.getChildren().add(bold("Anzahl:")); @@ -750,6 +952,8 @@ public class EditorApp extends Application { v -> ezTreeOptions.leaves.size = v)); inner.getChildren().add(ezFloat("Größen-Varianz:", 0, 1, ezTreeOptions.leaves.sizeVariance, v -> ezTreeOptions.leaves.sizeVariance = v)); + inner.getChildren().add(ezFloat("Winkel (°):", 0, 90, ezTreeOptions.leaves.angle, + v -> ezTreeOptions.leaves.angle = v)); inner.getChildren().add(ezFloat("Alpha-Schwellwert:", 0, 1, ezTreeOptions.leaves.alphaTest, v -> ezTreeOptions.leaves.alphaTest = v)); inner.getChildren().add(ezFloat("Farbe R:", 0, 1, ezTreeOptions.leaves.r, @@ -1054,6 +1258,37 @@ public class EditorApp extends Application { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); + // ── Baum aus Ordner ─────────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Baum aus Ordner"), new Separator()); + + treeFolderCB = new ComboBox<>(); + treeFolderCB.setMaxWidth(Double.MAX_VALUE); + treeFolderCB.setOnAction(e -> { + String sel = treeFolderCB.getValue(); + if (sel == null || sel.equals("(keiner)")) { + input.treeFolderPath = null; + if (randomTreeStatusLabel != null) randomTreeStatusLabel.setText(""); + } else { + input.treeFolderPath = sel; + input.pendingModelPath = null; + } + }); + populateTreeFolderCombo(); + + Button refreshFolderBtn = new Button("↺ Aktualisieren"); + refreshFolderBtn.setMaxWidth(Double.MAX_VALUE); + refreshFolderBtn.setOnAction(e -> populateTreeFolderCombo()); + + randomTreeStatusLabel = new Label(""); + randomTreeStatusLabel.setWrapText(true); + randomTreeStatusLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 10;"); + + inner.getChildren().add(treeFolderCB); + inner.getChildren().add(refreshFolderBtn); + inner.getChildren().add(randomTreeStatusLabel); + inner.getChildren().add(styledHint("Linksklick → zufälligen Baum platzieren")); + inner.getChildren().add(styledHint("Rechtsklick → neuer Zufallsbaum + Rotation")); + // ── Modell aus Asset-Baum ───────────────────────────────────────────── inner.getChildren().addAll(sectionTitle("Modell"), new Separator()); objModelLabel = new Label(input.pendingModelPath != null @@ -1065,6 +1300,25 @@ public class EditorApp extends Application { inner.getChildren().add(styledHint("Doppelklick auf Modell links → auswählen")); inner.getChildren().add(styledHint("Linksklick ins Terrain → platzieren")); + // ── Textur (optional, nur für Primitive) ───────────────────────────── + inner.getChildren().addAll(sectionTitle("Textur"), new Separator()); + Label texLabel = new Label(input.pendingTexturePath != null && !input.pendingTexturePath.isEmpty() + ? java.nio.file.Paths.get(input.pendingTexturePath).getFileName().toString() + : "(keine)"); + texLabel.setWrapText(true); + texLabel.setStyle("-fx-text-fill: #333; -fx-font-size: 11;"); + Button clearTexBtn = new javafx.scene.control.Button("✕"); + clearTexBtn.setTooltip(new Tooltip("Textur entfernen")); + clearTexBtn.setOnAction(ev -> { + input.pendingTexturePath = ""; + root.setRight(buildObjectPlacePanel()); + }); + HBox texRow = new HBox(6, texLabel, clearTexBtn); + texRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + HBox.setHgrow(texLabel, Priority.ALWAYS); + inner.getChildren().add(texRow); + inner.getChildren().add(styledHint("Doppelklick auf Textur links → auswählen")); + // ── Platziermodus ───────────────────────────────────────────────────── boolean prevMulti = multiPlaceCB != null && multiPlaceCB.isSelected(); multiPlaceCB = new CheckBox("Mehrfach platzieren"); @@ -1116,6 +1370,27 @@ public class EditorApp extends Application { } inner.getChildren().add(grid); + // ── Vertex-Snap (beim Platzieren von custom Meshes) ─────────────────── + inner.getChildren().addAll(sectionTitle("Vertex-Snap"), new Separator()); + CheckBox placeSnapCB = new CheckBox("Snap aktiv"); + placeSnapCB.setSelected(input.vertexSnapEnabled); + placeSnapCB.setStyle("-fx-text-fill: #111111;"); + placeSnapCB.setOnAction(e -> input.vertexSnapEnabled = placeSnapCB.isSelected()); + + Slider placeSnapSlider = new Slider(0.05, 2.0, input.vertexSnapRadius); + placeSnapSlider.setShowTickLabels(false); + placeSnapSlider.setBlockIncrement(0.05); + Label placeSnapLbl = new Label(String.format("Radius: %.2f", input.vertexSnapRadius)); + placeSnapLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #555;"); + placeSnapSlider.valueProperty().addListener((o, ov, nv) -> { + input.vertexSnapRadius = nv.floatValue(); + placeSnapLbl.setText(String.format("Radius: %.2f", nv.floatValue())); + }); + placeSnapSlider.disableProperty().bind(placeSnapCB.selectedProperty().not()); + placeSnapLbl.disableProperty().bind(placeSnapCB.selectedProperty().not()); + inner.getChildren().addAll(new VBox(3, placeSnapCB, placeSnapSlider, placeSnapLbl)); + inner.getChildren().add(styledHint("Snap aktiv: Platzierung rastet am nächsten Vertex ein")); + ScrollPane scroll = new ScrollPane(inner); scroll.setFitToWidth(true); scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); @@ -1130,26 +1405,165 @@ public class EditorApp extends Application { // ── Objekt-Werkzeug – Bearbeiten-Panel ────────────────────────────────── + /** Selektionsmodus-Leiste: Objekt / Polygon / Kante / Punkt */ + private javafx.scene.Node buildSelectionModeBar() { + ToggleGroup tg = new ToggleGroup(); + String[] labels = {"Objekt", "Polygon", "Kante", "Punkt"}; + int[] modes = {SharedInput.SEL_MODE_OBJECT, SharedInput.SEL_MODE_POLYGON, + SharedInput.SEL_MODE_EDGE, SharedInput.SEL_MODE_VERTEX}; + + selModeObjectBtn = null; + + HBox bar = new HBox(2); + for (int i = 0; i < labels.length; i++) { + ToggleButton btn = new ToggleButton(labels[i]); + btn.setToggleGroup(tg); + btn.setUserData(modes[i]); + HBox.setHgrow(btn, Priority.ALWAYS); + btn.setMaxWidth(Double.MAX_VALUE); + if (modes[i] == input.objectSelectionMode) btn.setSelected(true); + int mode = modes[i]; + btn.setOnAction(e -> input.objectSelectionMode = mode); + bar.getChildren().add(btn); + if (modes[i] == SharedInput.SEL_MODE_OBJECT) selModeObjectBtn = btn; + } + bar.setStyle("-fx-font-size: 11;"); + + VBox box = new VBox(3, new Label("Selektion"), bar); + return box; + } + + /** Schaltet den Selektionsmodus visuell auf "Objekt" zurück. */ + private void resetSelectionModeToObject() { + if (selModeObjectBtn != null) selModeObjectBtn.setSelected(true); + } + + /** Bearbeitungswerkzeug-Leiste: Bewegen / Rotieren / Skalieren */ + private javafx.scene.Node buildEditToolBar() { + ToggleGroup tg = new ToggleGroup(); + record ToolEntry(String label, String tooltip, int mode) {} + var tools = List.of( + new ToolEntry("Bewegen", "Objekte entlang X/Y/Z verschieben", SharedInput.EDIT_TOOL_MOVE), + new ToolEntry("Rotieren", "Objekte rotieren (noch nicht implementiert)", SharedInput.EDIT_TOOL_ROTATE), + new ToolEntry("Skalieren", "Objekte skalieren (noch nicht implementiert)", SharedInput.EDIT_TOOL_SCALE) + ); + + HBox bar = new HBox(2); + for (var t : tools) { + ToggleButton btn = new ToggleButton(t.label()); + btn.setToggleGroup(tg); + btn.setTooltip(new Tooltip(t.tooltip())); + HBox.setHgrow(btn, Priority.ALWAYS); + btn.setMaxWidth(Double.MAX_VALUE); + if (t.mode() == input.objectEditTool) btn.setSelected(true); + int mode = t.mode(); + btn.setOnAction(e -> input.objectEditTool = mode); + // Noch nicht implementierte Tools ausgegraut + if (t.mode() != SharedInput.EDIT_TOOL_MOVE) btn.setStyle("-fx-opacity: 0.55;"); + bar.getChildren().add(btn); + } + bar.setStyle("-fx-font-size: 11;"); + + VBox box = new VBox(3, new Label("Werkzeug"), bar); + return box; + } + + private void populateTreeFolderCombo() { + if (treeFolderCB == null) return; + String prev = treeFolderCB.getValue(); + + java.nio.file.Path treeRoot = de.blight.editor.ProjectRoot.resolve( + "blight-assets", "src", "main", "resources", "trees"); + java.util.List items = new java.util.ArrayList<>(); + items.add("(keiner)"); + if (java.nio.file.Files.isDirectory(treeRoot)) { + try (var walk = java.nio.file.Files.walk(treeRoot)) { + walk.filter(java.nio.file.Files::isDirectory) + .filter(p -> !p.equals(treeRoot)) + .map(p -> "trees/" + treeRoot.relativize(p).toString() + .replace(java.io.File.separatorChar, '/')) + .sorted() + .forEach(items::add); + } catch (Exception ignored) {} + } + + treeFolderCB.getItems().setAll(items); + if (prev != null && items.contains(prev)) { + treeFolderCB.setValue(prev); + } else { + String cur = input.treeFolderPath; + treeFolderCB.setValue(cur != null && items.contains(cur) ? cur : "(keiner)"); + } + } + private VBox buildObjectEditPanel() { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); + CheckBox snapCB = new CheckBox("Snap – Punkte verschmelzen"); + snapCB.setSelected(input.vertexSnapEnabled); + snapCB.setStyle("-fx-font-size: 11;"); + snapCB.setOnAction(e -> input.vertexSnapEnabled = snapCB.isSelected()); - inner.getChildren().addAll(sectionTitle("Gewähltes Objekt"), new Separator()); + Slider snapRadiusSlider = new Slider(0.05, 2.0, input.vertexSnapRadius); + snapRadiusSlider.setShowTickLabels(false); + snapRadiusSlider.setBlockIncrement(0.05); + Label snapRadiusLbl = new Label(String.format("Radius: %.2f", input.vertexSnapRadius)); + snapRadiusLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #555;"); + snapRadiusSlider.valueProperty().addListener((o, ov, nv) -> { + input.vertexSnapRadius = nv.floatValue(); + snapRadiusLbl.setText(String.format("Radius: %.2f", nv.floatValue())); + }); + snapRadiusSlider.disableProperty().bind(snapCB.selectedProperty().not()); + snapRadiusLbl.disableProperty().bind(snapCB.selectedProperty().not()); + VBox snapBox = new VBox(3, snapCB, snapRadiusSlider, snapRadiusLbl); - objSolidCB = new CheckBox("Solid (Charakter-Kollision)"); - objSolidCB.setDisable(true); - objSolidCB.setOnAction(e -> input.pendingSolidChange = objSolidCB.isSelected()); - inner.getChildren().add(objSolidCB); + inner.getChildren().addAll( + sectionTitle("Bearbeiten"), + buildSelectionModeBar(), + buildEditToolBar(), + snapBox, + new Separator(), + sectionTitle("Gewähltes Objekt"), + new Separator()); - objPosLabel = new Label("Position: –"); - objPosLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); - objPosLabel.setWrapText(true); - inner.getChildren().add(objPosLabel); + objDynamicContent = new VBox(6); + Label noSel = new Label("Kein Objekt ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + objDynamicContent.getChildren().add(noSel); + inner.getChildren().add(objDynamicContent); inner.getChildren().add(new Separator()); - inner.getChildren().add(styledHint("Linksklick auf Objekt → auswählen")); - inner.getChildren().add(styledHint("Pfeile ziehen → X/Y/Z bewegen")); - inner.getChildren().add(styledHint("Ring ziehen → Y-Achse drehen")); + + objMergeBtn = new Button("⛙ Zusammenfassen"); + objMergeBtn.setMaxWidth(Double.MAX_VALUE); + objMergeBtn.setDisable(true); + objMergeBtn.setOnAction(e -> input.mergeSelectedRequested = true); + + objSaveBtn = new Button("💾 Als Vorlage speichern…"); + objSaveBtn.setMaxWidth(Double.MAX_VALUE); + objSaveBtn.setDisable(true); + objSaveBtn.setOnAction(e -> { + TextInputDialog dlg = new TextInputDialog("Vorlage"); + dlg.setTitle("Vorlage speichern"); + dlg.setHeaderText("Name der Vorlage (ohne .j3o):"); + dlg.setContentText("Name:"); + dlg.showAndWait().ifPresent(name -> { + if (!name.trim().isEmpty()) input.saveAsTemplateRequest = name.trim(); + }); + }); + + objDeleteBtn = new Button("🗑 Löschen"); + objDeleteBtn.setMaxWidth(Double.MAX_VALUE); + objDeleteBtn.setDisable(true); + objDeleteBtn.setStyle("-fx-text-fill: #c0392b;"); + objDeleteBtn.setOnAction(e -> input.deleteSelectedRequested = true); + + inner.getChildren().addAll(objMergeBtn, objSaveBtn, objDeleteBtn, new Separator(), + styledHint("Linksklick → auswählen"), + styledHint("Shift+Klick → zur Selektion hinzufügen"), + styledHint("Pfeile ziehen → X/Y/Z bewegen"), + styledHint("Ring ziehen → Y-Achse drehen"), + styledHint("Entf → ausgewählte löschen")); ScrollPane scroll = new ScrollPane(inner); scroll.setFitToWidth(true); @@ -1163,26 +1577,657 @@ public class EditorApp extends Application { return panel; } - private void updateObjectPanel(String info) { - if (objSolidCB == null || objPosLabel == null) return; + // ── Licht-Panel ─────────────────────────────────────────────────────────── + + private VBox buildLightPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Lichtquellen"), + new Separator(), + sectionTitle("Gewähltes Licht"), + new Separator()); + + lightDynamicContent = new VBox(6); + Label noSel = new Label("Kein Licht ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + lightDynamicContent.getChildren().add(noSel); + inner.getChildren().add(lightDynamicContent); + + inner.getChildren().addAll( + new Separator(), + styledHint("Linksklick → platzieren / auswählen"), + styledHint("Rechtsklick → Auswahl aufheben"), + styledHint("Entf → ausgewähltes Licht löschen")); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(260); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateLightPanel(String info) { + if (lightDynamicContent == null) return; + lightDynamicContent.getChildren().clear(); + if (info == null) { - objSolidCB.setDisable(true); - objSolidCB.setSelected(false); - objPosLabel.setText("Position: –"); + Label noSel = new Label("Kein Licht ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + lightDynamicContent.getChildren().add(noSel); return; } - // info format: "modelPath|solid|x|y|z|rotY|scale" - String[] parts = info.split("\\|", 7); - if (parts.length < 7) return; - objSolidCB.setDisable(false); - objSolidCB.setSelected(Boolean.parseBoolean(parts[1])); + + // Format: "idx|x|y|z|r|g|b|intensity|radius" + String[] parts = info.split("\\|", -1); + if (parts.length < 9) return; + float[] vals = new float[9]; try { - float x = Float.parseFloat(parts[2]); - float y = Float.parseFloat(parts[3]); - float z = Float.parseFloat(parts[4]); - float rotY = (float) Math.toDegrees(Float.parseFloat(parts[5])); - objPosLabel.setText(String.format( - "X: %.1f Y: %.1f Z: %.1f%nRot Y: %.1f°", x, y, z, rotY)); + for (int i = 0; i < 9; i++) vals[i] = Float.parseFloat(parts[i]); + } catch (NumberFormatException e) { return; } + + Label posLabel = new Label(String.format("X: %.1f Y: %.1f Z: %.1f", vals[1], vals[2], vals[3])); + posLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + lightDynamicContent.getChildren().addAll(posLabel, new Separator()); + + // cur[0]=r, cur[1]=g, cur[2]=b, cur[3]=intensity, cur[4]=radius + float[] cur = {vals[4], vals[5], vals[6], vals[7], vals[8]}; + + String[] labels = {"Rot", "Grün", "Blau", "Intensität", "Radius"}; + double[] mins = {0, 0, 0, 0.1, 0.5}; + double[] maxs = {1, 1, 1, 10.0, 200.0}; + double[] steps = {0.01, 0.01, 0.01, 0.1, 1.0}; + + Runnable publish = () -> input.pendingLightProp.set( + new SharedInput.LightPropertyChange(cur[0], cur[1], cur[2], cur[3], cur[4])); + + for (int i = 0; i < 5; i++) { + final int idx = i; + Label lbl = new Label(labels[i] + ": " + String.format("%.2f", cur[i])); + lbl.setStyle("-fx-font-size: 11;"); + Slider sl = new Slider(mins[i], maxs[i], cur[i]); + sl.setShowTickLabels(false); + sl.setBlockIncrement(steps[i]); + sl.valueProperty().addListener((o, ov, nv) -> { + cur[idx] = nv.floatValue(); + lbl.setText(labels[idx] + ": " + String.format("%.2f", nv.floatValue())); + publish.run(); + }); + lightDynamicContent.getChildren().addAll(lbl, sl); + } + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteLightRequested = true); + lightDynamicContent.getChildren().addAll(new Separator(), delBtn); + } + + // ── Wasser-Panel ────────────────────────────────────────────────────────── + + private VBox buildWaterPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Wasseroberflächen"), + new Separator(), + sectionTitle("Gewählte Fläche"), + new Separator()); + + waterDynamicContent = new VBox(6); + Label noSel = new Label("Keine Fläche ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + waterDynamicContent.getChildren().add(noSel); + inner.getChildren().add(waterDynamicContent); + + inner.getChildren().addAll( + new Separator(), + styledHint("Linksklick → Fläche platzieren / auswählen"), + styledHint("Rechtsklick → Auswahl aufheben"), + styledHint("Entf → Fläche löschen")); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(260); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateWaterPanel(String info) { + if (waterDynamicContent == null) return; + waterDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Keine Fläche ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + waterDynamicContent.getChildren().add(noSel); + return; + } + + // Format: "idx|x|y|z|width|depth" + String[] p = info.split("\\|", -1); + if (p.length < 6) return; + try { + float[] cur = { + Float.parseFloat(p[1]), // x + Float.parseFloat(p[2]), // y + Float.parseFloat(p[3]), // z + Float.parseFloat(p[4]), // width + Float.parseFloat(p[5]) // depth + }; + + Label posLabel = new Label(String.format("X:%.1f Y:%.1f Z:%.1f", cur[0], cur[1], cur[2])); + posLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + waterDynamicContent.getChildren().addAll(posLabel, new Separator()); + + Runnable publish = () -> input.pendingWater.set( + new de.blight.common.PlacedWater(cur[0], cur[1], cur[2], cur[3], cur[4])); + + String[] labels = {"X", "Y (Höhe)", "Z", "Breite", "Tiefe"}; + double[] mins = {-2048, -10, -2048, 5, 5}; + double[] maxs = { 2048, 500, 2048, 500, 500}; + double[] steps = { 1, 0.5, 1, 5, 5}; + + for (int i = 0; i < 5; i++) { + final int vi = i; + Label lbl = new Label(labels[i] + ": " + String.format("%.1f", cur[i])); + lbl.setStyle("-fx-font-size: 11;"); + Slider sl = new Slider(mins[i], maxs[i], cur[i]); + sl.setShowTickLabels(false); + sl.setBlockIncrement(steps[i]); + sl.valueProperty().addListener((o, ov, nv) -> { + cur[vi] = nv.floatValue(); + lbl.setText(labels[vi] + ": " + String.format("%.1f", nv.floatValue())); + publish.run(); + }); + if (i == 2) waterDynamicContent.getChildren().add(new Separator()); + waterDynamicContent.getChildren().addAll(lbl, sl); + } + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteWaterRequested = true); + waterDynamicContent.getChildren().addAll(new Separator(), delBtn); + + } catch (NumberFormatException ignored) {} + } + + // ── Emitter-Panel ───────────────────────────────────────────────────────── + + private VBox buildEmitterPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Partikel-Emitter"), + new Separator()); + + // Preset-Buttons (setzen emitterPreset; wirken sofort auf selektierten Emitter) + Button fireBtn = new Button("🔥 Feuer"); + Button smokeBtn = new Button("💨 Rauch"); + Button sparksBtn = new Button("✨ Funken"); + fireBtn.setMaxWidth(Double.MAX_VALUE); + smokeBtn.setMaxWidth(Double.MAX_VALUE); + sparksBtn.setMaxWidth(Double.MAX_VALUE); + fireBtn.setOnAction(e -> applyEmitterPreset(0)); + smokeBtn.setOnAction(e -> applyEmitterPreset(1)); + sparksBtn.setOnAction(e -> applyEmitterPreset(2)); + HBox presets = new HBox(4, fireBtn, smokeBtn, sparksBtn); + HBox.setHgrow(fireBtn, Priority.ALWAYS); + HBox.setHgrow(smokeBtn, Priority.ALWAYS); + HBox.setHgrow(sparksBtn, Priority.ALWAYS); + inner.getChildren().addAll(presets, new Separator(), + sectionTitle("Gewählter Emitter"), new Separator()); + + emitterDynamicContent = new VBox(6); + Label noSel = new Label("Kein Emitter ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + emitterDynamicContent.getChildren().add(noSel); + inner.getChildren().add(emitterDynamicContent); + + inner.getChildren().addAll( + new Separator(), + styledHint("Linksklick → platzieren / auswählen"), + styledHint("Rechtsklick → Auswahl aufheben"), + styledHint("Entf → Emitter löschen")); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(280); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void applyEmitterPreset(int preset) { + input.emitterPreset = preset; + if (input.selectedEmitterInfo == null) return; + // Position aus aktueller Selektion nehmen und Preset-Emitter als Update senden + String[] parts = input.selectedEmitterInfo.split("\\|", -1); + if (parts.length < 4) return; + try { + float x = Float.parseFloat(parts[1]); + float y = Float.parseFloat(parts[2]); + float z = Float.parseFloat(parts[3]); + de.blight.common.PlacedEmitter pe = switch (preset) { + case 1 -> de.blight.common.PlacedEmitter.smoke(x, y, z); + case 2 -> de.blight.common.PlacedEmitter.sparks(x, y, z); + default -> de.blight.common.PlacedEmitter.fire(x, y, z); + }; + input.pendingEmitter.set(pe); + } catch (NumberFormatException ignored) {} + } + + private void updateEmitterPanel(String info) { + if (emitterDynamicContent == null) return; + emitterDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Kein Emitter ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + emitterDynamicContent.getChildren().add(noSel); + return; + } + + // 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 + String[] p = info.split("\\|", -1); + if (p.length < 29) return; + try { + float x = Float.parseFloat(p[1]); + float y = Float.parseFloat(p[2]); + float z = Float.parseFloat(p[3]); + float activRadius = Float.parseFloat(p[4]); + String texPath = p[5]; + int imagesX = Integer.parseInt(p[6]); + int imagesY = Integer.parseInt(p[7]); + // 19 float values: startRGBA(4), endRGBA(4), startSize, endSize, velXYZVar(4), gravXYZ(3), lowLife, highLife + // Index map: 0-3=startRGBA, 4-7=endRGBA, 8=startSize, 9=endSize, + // 10-12=velXYZ, 13=velVar, 14-16=gravXYZ, 17=lowLife, 18=highLife + float[] vals = new float[19]; + for (int i = 0; i < 19; i++) vals[i] = Float.parseFloat(p[8 + i]); + int maxParticles = Integer.parseInt(p[27]); + float emitRate = Float.parseFloat(p[28]); + + Label posLabel = new Label(String.format("X:%.1f Y:%.1f Z:%.1f", x, y, z)); + posLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + Label texLabel = new Label("Textur: " + texPath); + texLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #777;"); + texLabel.setWrapText(true); + emitterDynamicContent.getChildren().addAll(posLabel, texLabel, new Separator()); + + float[] actR = {activRadius}; + int[] mxPart = {maxParticles}; + float[] emit = {emitRate}; + int[] imgX = {imagesX}; + int[] imgY = {imagesY}; + final String[] tPath = {texPath}; + final float[] fx = {x}, fy = {y}, fz = {z}; + + Runnable publish = () -> { + de.blight.common.PlacedEmitter pe = new de.blight.common.PlacedEmitter( + fx[0], fy[0], fz[0], actR[0], + tPath[0], imgX[0], imgY[0], + vals[0], vals[1], vals[2], vals[3], + vals[4], vals[5], vals[6], vals[7], + vals[8], vals[9], + vals[10], vals[11], vals[12], vals[13], + vals[14], vals[15], vals[16], + vals[17], vals[18], + mxPart[0], emit[0]); + input.pendingEmitter.set(pe); + }; + + // Aktivierungsradius + emitterDynamicContent.getChildren().add(paramLabel("Aktivierungsradius")); + emitterDynamicContent.getChildren().add( + buildSlider("Radius", actR, 0, 5, 200, 5, publish)); + + // Farben + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Startfarbe")); + String[] colorLabels = {"Start R","Start G","Start B","Start A", + "Ende R","Ende G","Ende B","Ende A"}; + for (int i = 0; i < 8; i++) { + final int vi = i; + emitterDynamicContent.getChildren().add( + buildSlider(colorLabels[i], vals, vi, 0, 1, 0.01, publish)); + if (i == 3) emitterDynamicContent.getChildren().addAll( + new Separator(), paramLabel("Endfarbe")); + } + + // Größe + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Partikelgröße")); + emitterDynamicContent.getChildren().add( + buildSlider("Start-Größe", vals, 8, 0.01, 5, 0.05, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Ende-Größe", vals, 9, 0.01, 5, 0.05, publish)); + + // Geschwindigkeit + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Geschwindigkeit")); + emitterDynamicContent.getChildren().add( + buildSlider("Vel X", vals, 10, -10, 10, 0.1, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Vel Y", vals, 11, 0, 15, 0.1, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Vel Z", vals, 12, -10, 10, 0.1, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Variation", vals, 13, 0, 1, 0.05, publish)); + + // Gravitation + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Gravitation")); + emitterDynamicContent.getChildren().add( + buildSlider("Grav Y", vals, 15, -20, 20, 0.5, publish)); + + // Lebensdauer + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Lebensdauer (s)")); + emitterDynamicContent.getChildren().add( + buildSlider("Min", vals, 17, 0.1, 10, 0.1, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Max", vals, 18, 0.1, 10, 0.1, publish)); + + // Emission + emitterDynamicContent.getChildren().addAll(new Separator(), paramLabel("Emission")); + emitterDynamicContent.getChildren().add( + buildSlider("Max Partikel", mxPart, 0, 1, 500, 5, publish)); + emitterDynamicContent.getChildren().add( + buildSlider("Rate/s", emit, 0, 0.5, 100, 0.5, publish)); + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteEmitterRequested = true); + emitterDynamicContent.getChildren().addAll(new Separator(), delBtn); + + } catch (NumberFormatException ignored) {} + } + + /** Baut einen beschrifteten Slider für float[]-Werte. */ + private HBox buildSlider(String name, float[] arr, int idx, + double min, double max, double step, Runnable publish) { + Label lbl = new Label(name + ": " + String.format("%.2f", arr[idx])); + lbl.setStyle("-fx-font-size: 10;"); + lbl.setMinWidth(90); + Slider sl = new Slider(min, max, arr[idx]); + sl.setShowTickLabels(false); + sl.setBlockIncrement(step); + HBox.setHgrow(sl, Priority.ALWAYS); + sl.valueProperty().addListener((o, ov, nv) -> { + arr[idx] = nv.floatValue(); + lbl.setText(name + ": " + String.format("%.2f", nv.floatValue())); + publish.run(); + }); + return new HBox(4, lbl, sl); + } + + /** Baut einen beschrifteten Slider für int[]-Werte. */ + private HBox buildSlider(String name, int[] arr, int idx, + double min, double max, double step, Runnable publish) { + Label lbl = new Label(name + ": " + arr[idx]); + lbl.setStyle("-fx-font-size: 10;"); + lbl.setMinWidth(90); + Slider sl = new Slider(min, max, arr[idx]); + sl.setShowTickLabels(false); + sl.setBlockIncrement(step); + HBox.setHgrow(sl, Priority.ALWAYS); + sl.valueProperty().addListener((o, ov, nv) -> { + arr[idx] = nv.intValue(); + lbl.setText(name + ": " + nv.intValue()); + publish.run(); + }); + return new HBox(4, lbl, sl); + } + + private static Label paramLabel(String text) { + Label lbl = new Label(text); + lbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + return lbl; + } + + private void updateObjectPanel(String info) { + if (objDynamicContent == null) return; + + // Null field references first to prevent stale focus-lost events from firing + objXField = objYField = objZField = null; + objRotXField = objRotYField = objRotZField = null; + objSolidCB = null; + objNormalMapLabel = null; + objMatLabel = null; + + objDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Kein Objekt ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + objDynamicContent.getChildren().add(noSel); + if (objMergeBtn != null) objMergeBtn.setDisable(true); + if (objSaveBtn != null) objSaveBtn.setDisable(true); + if (objDeleteBtn != null) objDeleteBtn.setDisable(true); + return; + } + + String[] parts = info.split("\\|", -1); + int count; + try { count = Integer.parseInt(parts[0]); } catch (NumberFormatException e) { return; } + + if (count == 1 && parts.length >= 11) { + // ── Einzelselektion ────────────────────────────────────────────────── + String modelPath = parts[1]; + // Bei importierten Objekten visuell auf "Objekt" zurückschalten + // (JME-Seite setzt objectSelectionMode bereits zurück). + boolean isPrefab = !modelPath.startsWith("@") || modelPath.equals("@group"); + if (isPrefab) resetSelectionModeToObject(); + boolean solid = Boolean.parseBoolean(parts[2]); + float x = parseF(parts[3]); + float y = parseF(parts[4]); + float z = parseF(parts[5]); + float rotXDeg = (float) Math.toDegrees(parseF(parts[6])); + float rotYDeg = (float) Math.toDegrees(parseF(parts[7])); + float rotZDeg = (float) Math.toDegrees(parseF(parts[8])); + String texPath = parts[10]; + String normalPath = parts.length >= 12 ? parts[11] : ""; + String matPath = parts.length >= 13 ? parts[12] : ""; + String animClip = parts.length >= 14 ? parts[13] : ""; + + // Modell-Pfad + String modelName = modelPath.contains("/") + ? modelPath.substring(modelPath.lastIndexOf('/') + 1) : modelPath; + Label modelLbl = new Label("Modell: " + modelName); + modelLbl.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); + modelLbl.setWrapText(true); + + // Textur + objTexLabel = new Label(texPath.isEmpty() ? "(keine)" + : (texPath.contains("/") ? texPath.substring(texPath.lastIndexOf('/') + 1) : texPath)); + objTexLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); + objTexLabel.setWrapText(true); + Button chooseTex = new Button("Wählen…"); + chooseTex.setOnAction(ev -> { + TextureChooser chooser = new TextureChooser(ASSET_ROOT, false); + if (primaryStage != null) chooser.initOwner(primaryStage); + chooser.showAndWait().ifPresent(p -> { + enqueuePropertyChange(p, null, null); + objTexLabel.setText(p.contains("/") ? p.substring(p.lastIndexOf('/') + 1) : p); + }); + }); + Button clearTex = new Button("✕"); + clearTex.setOnAction(ev -> { + enqueuePropertyChange("", null, null); + objTexLabel.setText("(keine)"); + }); + HBox texRow = new HBox(4, objTexLabel, chooseTex, clearTex); + texRow.setAlignment(Pos.CENTER_LEFT); + + // Normal Map + objNormalMapLabel = new Label(normalPath.isEmpty() ? "(keine)" + : (normalPath.contains("/") ? normalPath.substring(normalPath.lastIndexOf('/') + 1) : normalPath)); + objNormalMapLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); + objNormalMapLabel.setWrapText(true); + Button chooseNorm = new Button("Wählen…"); + chooseNorm.setOnAction(ev -> { + TextureChooser chooser = new TextureChooser(ASSET_ROOT, true); + if (primaryStage != null) chooser.initOwner(primaryStage); + chooser.showAndWait().ifPresent(p -> { + enqueuePropertyChange(null, p, null); + objNormalMapLabel.setText(p.contains("/") ? p.substring(p.lastIndexOf('/') + 1) : p); + }); + }); + Button clearNorm = new Button("✕"); + clearNorm.setOnAction(ev -> { + enqueuePropertyChange(null, "", null); + objNormalMapLabel.setText("(keine)"); + }); + HBox normRow = new HBox(4, objNormalMapLabel, chooseNorm, clearNorm); + normRow.setAlignment(Pos.CENTER_LEFT); + + // Material + objMatLabel = new Label(matPath.isEmpty() ? "(Standard)" + : matPath.substring(matPath.lastIndexOf('/') + 1).replace(".j3md", "")); + objMatLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); + objMatLabel.setWrapText(true); + Button chooseMat = new Button("Wählen…"); + chooseMat.setOnAction(ev -> { + de.blight.editor.ui.MaterialChooser chooser = + new de.blight.editor.ui.MaterialChooser(ASSET_ROOT.resolve("MatDefs")); + if (primaryStage != null) chooser.initOwner(primaryStage); + chooser.showAndWait().ifPresent(p -> { + enqueuePropertyChange(null, null, p); + objMatLabel.setText(p.substring(p.lastIndexOf('/') + 1).replace(".j3md", "")); + }); + }); + Button clearMat = new Button("✕"); + clearMat.setOnAction(ev -> { + enqueuePropertyChange(null, null, ""); + objMatLabel.setText("(Standard)"); + }); + HBox matRow = new HBox(4, objMatLabel, chooseMat, clearMat); + matRow.setAlignment(Pos.CENTER_LEFT); + + // Position + objXField = makeFloatField(x); + objYField = makeFloatField(y); + objZField = makeFloatField(z); + hookApply(objXField); hookApply(objYField); hookApply(objZField); + + // Rotation + objRotXField = makeFloatField(rotXDeg); + objRotYField = makeFloatField(rotYDeg); + objRotZField = makeFloatField(rotZDeg); + hookApply(objRotXField); hookApply(objRotYField); hookApply(objRotZField); + + // Solid + objSolidCB = new CheckBox("Solid (Charakter-Kollision)"); + objSolidCB.setSelected(solid); + objSolidCB.setOnAction(ev -> enqueuePropertyChange(null, null, null)); + + // Animation + ComboBox animCombo = new ComboBox<>(); + animCombo.setMaxWidth(Double.MAX_VALUE); + animCombo.getItems().add("(keine)"); + Path animDir = ASSET_ROOT.resolve("animations"); + if (java.nio.file.Files.isDirectory(animDir)) { + try (java.util.stream.Stream walk = java.nio.file.Files.walk(animDir)) { + walk.filter(p -> p.toString().endsWith(".j3o")) + .map(p -> animDir.relativize(p).toString().replace('\\', '/')) + .sorted() + .forEach(animCombo.getItems()::add); + } catch (IOException ignored) {} + } + String currentAnim = (animClip == null || animClip.isEmpty()) ? "(keine)" : animClip; + animCombo.setValue(animCombo.getItems().contains(currentAnim) ? currentAnim : "(keine)"); + animCombo.setOnAction(ev -> { + String val = animCombo.getValue(); + input.pendingAnimClip = "(keine)".equals(val) ? "" : val; + }); + + objDynamicContent.getChildren().addAll( + modelLbl, + bold("Textur:"), texRow, + bold("Normal Map:"), normRow, + bold("Material:"), matRow, new Separator(), + bold("Position (m)"), + labeledRow("X:", objXField, "Y:", objYField, "Z:", objZField), new Separator(), + bold("Rotation (°)"), + labeledRow("X:", objRotXField, "Y:", objRotYField, "Z:", objRotZField), new Separator(), + objSolidCB, new Separator(), + bold("Animation:"), animCombo); + + } else { + // ── Mehrselektion ──────────────────────────────────────────────────── + Label countLbl = new Label(count + " Objekte ausgewählt"); + countLbl.setStyle("-fx-font-weight: bold;"); + objSolidCB = new CheckBox("Solid (alle)"); + objSolidCB.setIndeterminate(true); + objSolidCB.setOnAction(ev -> input.pendingSolidChange = objSolidCB.isSelected()); + objDynamicContent.getChildren().addAll(countLbl, objSolidCB); + } + + if (objMergeBtn != null) objMergeBtn.setDisable(count < 2); + if (objSaveBtn != null) objSaveBtn.setDisable(false); + if (objDeleteBtn != null) objDeleteBtn.setDisable(false); + } + + // ── Objekt-Bearbeiten-Hilfsmethoden ───────────────────────────────────── + + private static float parseF(String s) { + try { return Float.parseFloat(s); } catch (NumberFormatException e) { return 0f; } + } + + private static TextField makeFloatField(float val) { + TextField tf = new TextField(String.format(java.util.Locale.ROOT, "%.2f", val)); + tf.setPrefWidth(62); + tf.setMaxWidth(62); + return tf; + } + + /** Normalisiert einen vom Nutzer eingetippten Dezimalwert (Komma oder Punkt). */ + private static float parseFloatField(String s) { + return Float.parseFloat(s.replace(',', '.')); + } + + private static HBox labeledRow(String l1, javafx.scene.Node f1, + String l2, javafx.scene.Node f2, + String l3, javafx.scene.Node f3) { + HBox row = new HBox(3, + new Label(l1), f1, + new Label(l2), f2, + new Label(l3), f3); + row.setAlignment(Pos.CENTER_LEFT); + return row; + } + + private void hookApply(TextField tf) { + tf.setOnAction(e -> enqueuePropertyChange(null, null, null)); + tf.focusedProperty().addListener((o, ov, nv) -> { if (!nv) enqueuePropertyChange(null, null, null); }); + } + + private void enqueuePropertyChange(String texOverride, String normalOverride, String matOverride) { + if (objXField == null || objYField == null || objZField == null) return; + try { + float x = parseFloatField(objXField.getText()); + float y = parseFloatField(objYField.getText()); + float z = parseFloatField(objZField.getText()); + float rotX = (float) Math.toRadians(parseFloatField(objRotXField.getText())); + float rotY = (float) Math.toRadians(parseFloatField(objRotYField.getText())); + float rotZ = (float) Math.toRadians(parseFloatField(objRotZField.getText())); + boolean solid = objSolidCB != null && objSolidCB.isSelected(); + input.objectPropertyQueue.offer( + new SharedInput.ObjectPropertyChange(x, y, z, rotX, rotY, rotZ, solid, + texOverride, normalOverride, matOverride)); } catch (NumberFormatException ignored) {} } @@ -1208,7 +2253,10 @@ public class EditorApp extends Application { Label nameLabel = new Label(param.getName()); nameLabel.setStyle("-fx-text-fill: #111111;"); - if (param.getImagePaths() != null) { + if (tool instanceof de.blight.editor.tool.TextureTool + && param == ((de.blight.editor.tool.TextureTool) tool).textureIndex) { + panel.getChildren().addAll(nameLabel, buildTextureChoiceUI(param)); + } else if (param.getImagePaths() != null) { String[] paths = param.getImagePaths(); String[] labels = param.getChoices(); ToggleGroup tg = new ToggleGroup(); @@ -1286,6 +2334,218 @@ public class EditorApp extends Application { panel.getChildren().add(new VBox(3, name, slider, valueLabel)); } + + // Textur-Slot-Konfigurator (nur beim TextureTool) + if (tool instanceof de.blight.editor.tool.TextureTool) { + panel.getChildren().addAll(new javafx.scene.control.Separator(), + buildTextureSlotsUI(false), + new javafx.scene.control.Separator(), + buildTextureSlotsUI(true)); + } + } + + // ── Textur-Slot-Konfigurator ────────────────────────────────────────────── + + /** Baut das 8-Slot-Thumbnail-Grid für das TextureTool. */ + private javafx.scene.Node buildTextureChoiceUI(ChoiceToolParameter param) { + String[] defaultsTerrain = { + "Textures/Terrain/splat/grass.jpg", + "Textures/Terrain/Rock2/rock.jpg", + "Textures/Terrain/splat/dirt.jpg", + "" + }; + + String[] allPaths = new String[8]; + String[] allLabels = new String[8]; + for (int i = 0; i < 4; i++) { + String p = input.terrainTexturePaths[i]; + allPaths[i] = (p != null && !p.isEmpty()) ? p : defaultsTerrain[i]; + allLabels[i] = "S" + (i + 1) + " " + labelFromPath(allPaths[i]); + } + for (int i = 0; i < 4; i++) { + String p = input.upperTexturePaths[i]; + allPaths[4 + i] = (p != null && !p.isEmpty()) ? p : ""; + allLabels[4 + i] = "S" + (i + 5) + " " + labelFromPath(allPaths[4 + i]); + } + + ToggleGroup tg = new ToggleGroup(); + javafx.scene.layout.TilePane tile = new javafx.scene.layout.TilePane(); + tile.setHgap(4); + tile.setVgap(4); + tile.setPrefColumns(4); + + for (int j = 0; j < 8; j++) { + final int idx = j; + String path = allPaths[j]; + + ToggleButton btn = new ToggleButton(); + btn.setToggleGroup(tg); + btn.setPrefSize(56, 76); + btn.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + btn.setTooltip(new Tooltip("Slot " + (j + 1) + ": " + (path.isEmpty() ? "(leer)" : path))); + + VBox content = new VBox(2); + content.setAlignment(Pos.CENTER); + + if (!path.isEmpty()) { + String imgUrl = resolveImageUrl(path); + if (imgUrl != null) { + Image img = new Image(imgUrl, 44, 44, true, true); + ImageView iv = new ImageView(img.isError() ? null : img); + iv.setFitWidth(44); + iv.setFitHeight(44); + content.getChildren().add(img.isError() ? emptySlotPlaceholder(44) : iv); + } else { + content.getChildren().add(emptySlotPlaceholder(44)); + } + } else { + content.getChildren().add(emptySlotPlaceholder(44)); + } + + Label lbl = new Label(allLabels[j]); + lbl.setStyle("-fx-font-size: 9; -fx-text-fill: #222;"); + lbl.setMaxWidth(54); + content.getChildren().add(lbl); + + btn.setGraphic(content); + boolean initially = (j == param.getSelectedIndex()); + btn.setSelected(initially); + applyModeButtonStyle(btn, initially); + btn.selectedProperty().addListener((obs, was, isNow) -> { + applyModeButtonStyle(btn, isNow); + if (isNow) param.setSelectedIndex(idx); + }); + tile.getChildren().add(btn); + } + + tg.selectedToggleProperty().addListener((obs, oldT, newT) -> { + if (newT == null) tg.selectToggle(oldT); + }); + return tile; + } + + private javafx.scene.layout.Region emptySlotPlaceholder(double size) { + javafx.scene.layout.Region r = new javafx.scene.layout.Region(); + r.setPrefSize(size, size); + r.setMaxSize(size, size); + r.setStyle("-fx-background-color: #cccccc; -fx-border-color: #999; -fx-border-width: 1;"); + return r; + } + + private String resolveImageUrl(String path) { + if (path == null || path.isEmpty()) return null; + URL res = EditorApp.class.getResource("/" + path); + if (res != null) return res.toString(); + File f = ASSET_ROOT.resolve(path).toFile(); + if (f.exists()) return f.toURI().toString(); + return null; + } + + private static String labelFromPath(String path) { + if (path == null || path.isEmpty()) return "(leer)"; + String name = java.nio.file.Paths.get(path).getFileName().toString(); + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + /** Baut die 4-Slot-Auswahl für Terrain (isUpper=false) oder Gebirge (isUpper=true). */ + private VBox buildTextureSlotsUI(boolean isUpper) { + VBox box = new VBox(5); + box.setPadding(new javafx.geometry.Insets(4, 0, 4, 0)); + Label title = new Label(isUpper ? "Texturen Slots 5-8" : "Texturen Slots 1-4"); + title.setStyle("-fx-font-weight: bold; -fx-text-fill: #111;"); + box.getChildren().add(title); + + String[] paths = isUpper ? input.upperTexturePaths : input.terrainTexturePaths; + String[] nmPaths = isUpper ? input.upperNormalMapPaths : input.terrainNormalMapPaths; + String[] defaults = isUpper + ? new String[]{"Textures/Terrain/Rock2/rock.jpg","","",""} + : new String[]{"Textures/Terrain/splat/grass.jpg","Textures/Terrain/Rock2/rock.jpg","Textures/Terrain/splat/dirt.jpg",""}; + + for (int i = 0; i < 4; i++) { + final int slot = i; + + // ── Diffuse-Zeile ───────────────────────────────────────────────── + String current = (paths[i] != null && !paths[i].isEmpty()) + ? java.nio.file.Paths.get(paths[i]).getFileName().toString() + : (defaults[i].isEmpty() ? "(kein)" : java.nio.file.Paths.get(defaults[i]).getFileName().toString() + " (std)"); + + Label slotLbl = new Label("Slot " + (isUpper ? i + 5 : i + 1) + ": " + current); + slotLbl.setStyle("-fx-text-fill: #333; -fx-font-size: 11;"); + slotLbl.setMaxWidth(Double.MAX_VALUE); + slotLbl.setMinWidth(0); + slotLbl.setEllipsisString("…"); + + javafx.scene.control.Button chooseBtn = new javafx.scene.control.Button("Wählen"); + chooseBtn.setOnAction(ev -> openTexturePicker(slotLbl, slot, isUpper)); + + javafx.scene.control.Button clearBtn = new javafx.scene.control.Button("✕"); + clearBtn.setTooltip(new Tooltip("Auf Standard zurücksetzen")); + clearBtn.setOnAction(ev -> { + String[] p = isUpper ? input.upperTexturePaths : input.terrainTexturePaths; + p[slot] = ""; + if (isUpper) input.upperTexturesChanged = true; + else input.terrainTexturesChanged = true; + showToolParameters(toolPanel, input.activeTool); + }); + + HBox row = new HBox(4, slotLbl, chooseBtn, clearBtn); + HBox.setHgrow(slotLbl, Priority.ALWAYS); + row.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + + // ── Normal-Map-Zeile ────────────────────────────────────────────── + String nmCurrent = (nmPaths[i] != null && !nmPaths[i].isEmpty()) + ? java.nio.file.Paths.get(nmPaths[i]).getFileName().toString() + : "(keine)"; + Label nmLbl = new Label(" Norm: " + nmCurrent); + nmLbl.setStyle("-fx-text-fill: #777; -fx-font-size: 10;"); + nmLbl.setMaxWidth(Double.MAX_VALUE); + nmLbl.setMinWidth(0); + nmLbl.setEllipsisString("…"); + + javafx.scene.control.Button nmChooseBtn = new javafx.scene.control.Button("Wählen"); + nmChooseBtn.setOnAction(ev -> { + TextureChooser chooser = new TextureChooser(ASSET_ROOT, true); + if (primaryStage != null) chooser.initOwner(primaryStage); + chooser.showAndWait().ifPresent(p -> { + String[] nm = isUpper ? input.upperNormalMapPaths : input.terrainNormalMapPaths; + nm[slot] = p; + if (isUpper) input.upperNormalMapsChanged = true; + else input.terrainNormalMapsChanged = true; + showToolParameters(toolPanel, input.activeTool); + }); + }); + + javafx.scene.control.Button nmClearBtn = new javafx.scene.control.Button("✕"); + nmClearBtn.setTooltip(new Tooltip("Normal Map entfernen")); + nmClearBtn.setOnAction(ev -> { + String[] nm = isUpper ? input.upperNormalMapPaths : input.terrainNormalMapPaths; + nm[slot] = ""; + if (isUpper) input.upperNormalMapsChanged = true; + else input.terrainNormalMapsChanged = true; + showToolParameters(toolPanel, input.activeTool); + }); + + HBox nmRow = new HBox(4, nmLbl, nmChooseBtn, nmClearBtn); + HBox.setHgrow(nmLbl, Priority.ALWAYS); + nmRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + + box.getChildren().addAll(row, nmRow); + } + return box; + } + + /** Öffnet den TextureChooser-Dialog und setzt den gewählten Pfad für den Slot. */ + private void openTexturePicker(Label slotLabel, int slot, boolean isUpper) { + TextureChooser chooser = new TextureChooser(ASSET_ROOT, true); + if (primaryStage != null) chooser.initOwner(primaryStage); + chooser.showAndWait().ifPresent(path -> { + String[] paths = isUpper ? input.upperTexturePaths : input.terrainTexturePaths; + paths[slot] = path; + if (isUpper) input.upperTexturesChanged = true; + else input.terrainTexturesChanged = true; + showToolParameters(toolPanel, input.activeTool); + }); } // ── Linke Seite: Asset-Panel (Welteneditor) ────────────────────────────── @@ -1301,22 +2561,38 @@ public class EditorApp extends Application { TreeItem assetRoot = new TreeItem<>("Projekt"); assetRoot.setExpanded(true); - modelsNode = new TreeItem<>("Models"); - texturesNode = new TreeItem<>("Texturen"); - audioNode = new TreeItem<>("Audio"); - assetRoot.getChildren().addAll(modelsNode, texturesNode, audioNode); + modelsNode = new TreeItem<>("Models"); + texturesNode = new TreeItem<>("Texturen"); + audioNode = new TreeItem<>("Audio"); + animationsNode = new TreeItem<>("Animationen"); + assetRoot.getChildren().addAll(modelsNode, texturesNode, audioNode, animationsNode); - itemPaths.put(modelsNode, ASSET_ROOT.resolve("models")); - itemPaths.put(texturesNode, ASSET_ROOT.resolve("textures")); - itemPaths.put(audioNode, ASSET_ROOT.resolve("audio")); + itemPaths.put(modelsNode, ASSET_ROOT.resolve("Models")); + itemPaths.put(texturesNode, ASSET_ROOT.resolve("Textures")); + itemPaths.put(audioNode, ASSET_ROOT.resolve("audio")); + itemPaths.put(animationsNode, ASSET_ROOT.resolve("animations")); - loadAssetsRecursive(modelsNode, ASSET_ROOT.resolve("models"), + loadAssetsRecursive(modelsNode, ASSET_ROOT.resolve("Models"), ".j3o", ".obj", ".fbx", ".gltf", ".glb"); - loadAssetsRecursive(texturesNode, ASSET_ROOT.resolve("textures"), + jmeModelsNode = new TreeItem<>("JME"); + jmeFolderNodes.add(jmeModelsNode); + modelsNode.getChildren().add(jmeModelsNode); + loadJmeModelsInto(jmeModelsNode); + + loadAssetsRecursive(texturesNode, ASSET_ROOT.resolve("Textures"), ".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds"); - loadAssetsRecursive(audioNode, ASSET_ROOT.resolve("audio"), + + TreeItem jmeNode = new TreeItem<>("JME"); + jmeFolderNodes.add(jmeNode); + texturesNode.getChildren().add(jmeNode); + loadJmeTexturesInto(jmeNode); + + loadAssetsRecursive(audioNode, ASSET_ROOT.resolve("audio"), ".ogg", ".wav", ".mp3"); + loadAssetsRecursive(animationsNode, ASSET_ROOT.resolve("animations"), + ".j3o", ".fbx", ".gltf", ".glb"); + TreeView tree = new TreeView<>(assetRoot); tree.setShowRoot(false); VBox.setVgrow(tree, Priority.ALWAYS); @@ -1326,30 +2602,72 @@ public class EditorApp extends Application { tree.setOnKeyPressed(e -> { if (e.getCode() != KeyCode.F2) return; TreeItem sel = tree.getSelectionModel().getSelectedItem(); - if (sel == null || sel == modelsNode || sel == texturesNode || sel == audioNode) return; + if (sel == null || sel == modelsNode || sel == texturesNode + || sel == audioNode || sel == animationsNode) return; renameAsset(sel); e.consume(); }); - // Doppelklick auf ein Modell → als aktives Objekt-Platzierungs-Modell wählen + // Doppelklick auf Modell / Textur / Animation tree.setOnMouseClicked(e -> { - if (e.getClickCount() != 2 || input.activeLayer != SharedInput.LAYER_OBJECTS) return; + if (e.getClickCount() != 2) return; TreeItem sel = tree.getSelectionModel().getSelectedItem(); if (sel == null || isAssetFolder(sel)) return; - if (getCategoryRoot(sel) != modelsNode) return; + + TreeItem cat = getCategoryRoot(sel); Path p = itemPaths.get(sel); - if (p == null) return; - String path = ASSET_ROOT.relativize(p).toString().replace('\\', '/'); - input.pendingModelPath = path; - // Mesh-Primitiv-Auswahl aufheben - if (meshToggleGroup != null) meshToggleGroup.selectToggle(null); - if (objModelLabel != null) objModelLabel.setText(path); - setStatus("Objekt-Modell: " + path + " | Linksklick ins Terrain zum Platzieren"); + String relPath = (p != null) + ? ASSET_ROOT.relativize(p).toString().replace('\\', '/') + : jmePaths.get(sel); + if (relPath == null) return; + + boolean isSkeletal = skeletalModelPaths == null /* noch nicht gescannt – zulassen */ + || skeletalModelPaths.contains(relPath); + + // Im Animationseditor: j3o aus Models/ oder animations/ direkt laden + if ("animpreview".equals(currentTool) + && (cat == modelsNode || cat == animationsNode) + && relPath.endsWith(".j3o")) { + if (!isSkeletal) { setStatus("Kein Skelett: " + relPath); return; } + input.animPreviewLoadPath = relPath; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + if (animClipListView != null) animClipListView.getItems().clear(); + return; + } + + if (cat == modelsNode) { + if (relPath.endsWith(".j3o") && isSkeletal) { + // Skelett-Modell → Animationseditor anbieten; normaler Doppelklick → platzieren + } + input.pendingModelPath = relPath; + input.activeLayer = SharedInput.LAYER_OBJECTS; + if (objPlaceBtn != null) objPlaceBtn.setSelected(true); + root.setRight(buildObjectPlacePanel()); + setStatus("Modell: " + relPath + " | Linksklick ins Terrain zum Platzieren"); + } else if (cat == texturesNode) { + input.pendingTexturePath = relPath; + input.activeLayer = SharedInput.LAYER_OBJECTS; + if (objPlaceBtn != null) objPlaceBtn.setSelected(true); + root.setRight(buildObjectPlacePanel()); + setStatus("Textur: " + relPath + " | Wird auf platzierte Primitive angewendet"); + } else if (cat == animationsNode && relPath.endsWith(".j3o") && isSkeletal) { + switchToAnimPreview(); + input.animPreviewLoadPath = relPath; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + } }); Button importBtn = new Button("⊕ Import…"); importBtn.setMaxWidth(Double.MAX_VALUE); - importBtn.setOnAction(e -> handleImport(tree.getScene().getWindow())); + importBtn.setOnAction(e -> { + TreeItem sel = tree.getSelectionModel().getSelectedItem(); + TreeItem cat = sel != null ? getCategoryRoot(sel) : null; + if (cat == animationsNode || sel == animationsNode) { + handleAnimationImport(tree.getScene().getWindow()); + } else { + handleImport(tree.getScene().getWindow()); + } + }); panel.getChildren().addAll(title, tree, importBtn); return panel; @@ -1368,10 +2686,11 @@ public class EditorApp extends Application { } }; - // ── Drag-Quelle: nur Dateien (keine Ordner) ────────────────────────── + // ── Drag-Quelle: nur Dateien (keine Ordner), JME-Texturen schreibgeschützt ─ cell.setOnDragDetected(e -> { TreeItem item = cell.getTreeItem(); if (item == null || isAssetFolder(item)) return; + if (jmePaths.containsKey(item)) return; draggedItem = item; Dragboard db = cell.startDragAndDrop(TransferMode.MOVE); ClipboardContent cc = new ClipboardContent(); @@ -1433,6 +2752,7 @@ public class EditorApp extends Application { cell.setOnContextMenuRequested(e -> { TreeItem item = cell.getTreeItem(); if (item == null) return; + if (jmeFolderNodes.contains(item) || jmePaths.containsKey(item)) return; ContextMenu ctx = new ContextMenu(); if (isAssetFolder(item)) { @@ -1440,9 +2760,16 @@ public class EditorApp extends Application { newSub.setOnAction(ev -> createSubfolder(item)); ctx.getItems().add(newSub); - boolean isCatRoot = (item == modelsNode || item == texturesNode || item == audioNode); + boolean isCatRoot = (item == modelsNode || item == texturesNode + || item == audioNode || item == animationsNode); Path dir = itemPaths.get(item); + if (item == animationsNode) { + MenuItem importAnim = new MenuItem("⊕ Animation importieren…"); + importAnim.setOnAction(ev -> handleAnimationImport(ctx.getOwnerWindow())); + ctx.getItems().add(importAnim); + } + if (!isCatRoot && dir != null) { MenuItem rename = new MenuItem("✏ Umbenennen…"); rename.setOnAction(ev -> renameAsset(item)); @@ -1468,11 +2795,29 @@ public class EditorApp extends Application { ctx.getItems().addAll(new SeparatorMenuItem(), del); } } else { - // Datei: Umbenennen + Im Dateisystem anzeigen + Löschen + // Datei: Umbenennen + Im Animationseditor öffnen (j3o) + Im Dateisystem anzeigen + Löschen Path p = itemPaths.get(item); if (p != null) { MenuItem rename = new MenuItem("✏ Umbenennen…"); rename.setOnAction(ev -> renameAsset(item)); + + String pStr = p.toString(); + if (pStr.endsWith(".j3o")) { + String relPath = ASSET_ROOT.relativize(p).toString().replace('\\', '/'); + boolean skeletal = skeletalModelPaths == null + || skeletalModelPaths.contains(relPath); + if (skeletal) { + MenuItem openAnim = new MenuItem("▶ Im Animationseditor öffnen"); + openAnim.setOnAction(ev -> { + switchToAnimPreview(); + input.animPreviewLoadPath = relPath; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + if (animClipListView != null) animClipListView.getItems().clear(); + }); + ctx.getItems().addAll(openAnim, new SeparatorMenuItem()); + } + } + MenuItem reveal = new MenuItem("Im Dateisystem anzeigen"); reveal.setOnAction(ev -> { try { Runtime.getRuntime().exec( @@ -1519,14 +2864,15 @@ public class EditorApp extends Application { /** Gibt true zurück wenn das Item ein Verzeichnis ist (inkl. Kategoriewurzeln). */ private boolean isAssetFolder(TreeItem item) { if (item == null) return false; + if (jmeFolderNodes.contains(item)) return true; Path p = itemPaths.get(item); return p != null && Files.isDirectory(p); } - /** Findet die übergeordnete Kategorie (modelsNode / texturesNode / audioNode). */ + /** Findet die übergeordnete Kategorie (modelsNode / texturesNode / audioNode / animationsNode). */ private TreeItem getCategoryRoot(TreeItem item) { for (TreeItem cur = item; cur != null; cur = cur.getParent()) - if (cur == modelsNode || cur == texturesNode || cur == audioNode) return cur; + if (cur == modelsNode || cur == texturesNode || cur == audioNode || cur == animationsNode) return cur; return null; } @@ -1534,6 +2880,7 @@ public class EditorApp extends Application { private boolean isValidDropTarget(TreeItem target) { if (draggedItem == null || target == null) return false; if (!isAssetFolder(target)) return false; + if (jmeFolderNodes.contains(target)) return false; // JME-Ordner sind schreibgeschützt if (target == draggedItem.getParent()) return false; // schon dort TreeItem dragCat = getCategoryRoot(draggedItem); TreeItem dropCat = getCategoryRoot(target); @@ -1630,6 +2977,123 @@ public class EditorApp extends Application { } } + /** Scannt alle JME-JARs im Classpath und baut den Textur-Teilbaum unter root auf. */ + private void loadJmeTexturesInto(TreeItem root) { + String[] texExts = {".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds"}; + java.util.Set seen = new java.util.HashSet<>(); + java.util.List collected = new java.util.ArrayList<>(); + + String cp = System.getProperty("java.class.path", ""); + for (String entry : cp.split(File.pathSeparator)) { + if (!entry.toLowerCase().contains("jme")) continue; + File f = new File(entry); + if (!f.exists() || !f.getName().endsWith(".jar")) continue; + try (java.util.jar.JarFile jar = new java.util.jar.JarFile(f)) { + jar.stream() + .filter(je -> !je.isDirectory()) + .map(java.util.jar.JarEntry::getName) + .filter(name -> name.startsWith("Textures/")) + .filter(name -> { + String lo = name.toLowerCase(); + for (String ext : texExts) if (lo.endsWith(ext)) return true; + return false; + }) + .filter(seen::add) + .forEach(collected::add); + } catch (IOException ignored) {} + } + + java.util.Collections.sort(collected); + for (String path : collected) addJmeTreeEntry(root, path); + } + + private void addJmeTreeEntry(TreeItem root, String fullPath) { + String[] parts = fullPath.split("/"); + TreeItem cur = root; + for (int i = 0; i < parts.length - 1; i++) { + final String dirName = parts[i]; + TreeItem found = null; + for (TreeItem child : cur.getChildren()) { + if (child.getValue().equals(dirName) && jmeFolderNodes.contains(child)) { + found = child; + break; + } + } + if (found == null) { + found = new TreeItem<>(dirName); + jmeFolderNodes.add(found); + cur.getChildren().add(found); + } + cur = found; + } + TreeItem file = new TreeItem<>(parts[parts.length - 1]); + jmePaths.put(file, fullPath); + cur.getChildren().add(file); + } + + /** + * Scannt alle JME-JARs im Classpath und baut den Modell-Teilbaum unter root auf. + * Nur Einträge unter "Models/" werden berücksichtigt; der Präfix wird für die + * Anzeige entfernt, der vollständige Classpath-Pfad in jmePaths hinterlegt. + */ + private void loadJmeModelsInto(TreeItem root) { + // .mesh.xml (OGRE-Format) braucht jme3-plugins, das nicht auf dem Classpath ist + String[] modelExts = {".j3o", ".obj", ".gltf", ".glb"}; + java.util.Set seen = new java.util.HashSet<>(); + java.util.List collected = new java.util.ArrayList<>(); + + String cp = System.getProperty("java.class.path", ""); + for (String entry : cp.split(File.pathSeparator)) { + if (!entry.toLowerCase().contains("jme")) continue; + File f = new File(entry); + if (!f.exists() || !f.getName().endsWith(".jar")) continue; + try (java.util.jar.JarFile jar = new java.util.jar.JarFile(f)) { + jar.stream() + .filter(je -> !je.isDirectory()) + .map(java.util.jar.JarEntry::getName) + .filter(name -> name.startsWith("Models/")) + .filter(name -> { + String lo = name.toLowerCase(); + for (String ext : modelExts) if (lo.endsWith(ext)) return true; + return false; + }) + .filter(seen::add) + .forEach(collected::add); + } catch (IOException ignored) {} + } + + java.util.Collections.sort(collected); + for (String fullPath : collected) { + // Präfix "Models/" für Anzeige entfernen; jmePaths speichert den vollen Pfad + String displayPath = fullPath.substring("Models/".length()); + addJmeModelEntry(root, displayPath, fullPath); + } + } + + /** Fügt einen JME-Modell-Eintrag ein (displayPath für Baum, fullPath für AssetManager). */ + private void addJmeModelEntry(TreeItem root, String displayPath, String fullPath) { + String[] parts = displayPath.split("/"); + TreeItem cur = root; + for (int i = 0; i < parts.length - 1; i++) { + final String dirName = parts[i]; + TreeItem found = null; + for (TreeItem child : cur.getChildren()) { + if (child.getValue().equals(dirName) && jmeFolderNodes.contains(child)) { + found = child; break; + } + } + if (found == null) { + found = new TreeItem<>(dirName); + jmeFolderNodes.add(found); + cur.getChildren().add(found); + } + cur = found; + } + TreeItem file = new TreeItem<>(parts[parts.length - 1]); + jmePaths.put(file, fullPath); + cur.getChildren().add(file); + } + /** Lädt Assets rekursiv: Ordner zuerst (alphabetisch), dann Dateien. */ private void loadAssetsRecursive(TreeItem parent, Path dir, String... exts) { if (!Files.exists(dir)) return; @@ -1665,25 +3129,113 @@ public class EditorApp extends Application { catNode.getChildren().clear(); Path dir = itemPaths.get(catNode); if (dir != null) loadAssetsRecursive(catNode, dir, exts); + // JME-Unterknoten sind nicht dateisystembasiert – nach Clear wieder anhängen + if (catNode == modelsNode && jmeModelsNode != null) + catNode.getChildren().add(jmeModelsNode); } private void clearItemPathsFor(TreeItem item) { for (TreeItem child : item.getChildren()) clearItemPathsFor(child); - if (item != modelsNode && item != texturesNode && item != audioNode) + if (item != modelsNode && item != texturesNode && item != audioNode && item != animationsNode) itemPaths.remove(item); } + /** + * Kopiert MTL-Dateien und darin referenzierte Texturen aus dem Quell-Ordner + * der OBJ-Datei in das Ziel-Verzeichnis (blight-assets/Models/). + */ + private static void copyObjCompanions(File objFile, Path destDir) { + Path srcDir = objFile.toPath().getParent(); + + // MTL-Dateinamen aus dem OBJ selbst lesen (mtllib-Direktiven) + java.util.Set mtlNames = new java.util.LinkedHashSet<>(); + String baseName = objFile.getName().replaceFirst("\\.[^.]+$", ""); + mtlNames.add(baseName + ".mtl"); // Standardname + mtlNames.add(baseName + ".MTL"); + try (java.io.BufferedReader br = java.nio.file.Files.newBufferedReader(objFile.toPath())) { + String line; + while ((line = br.readLine()) != null) { + if (line.startsWith("mtllib ")) mtlNames.add(line.substring(7).trim()); + } + } catch (IOException ignored) {} + + for (String mtlName : mtlNames) { + Path mtlSrc = srcDir.resolve(mtlName); + if (!java.nio.file.Files.exists(mtlSrc)) continue; + copyQuiet(mtlSrc, destDir.resolve(mtlSrc.getFileName())); + copyMtlTextures(mtlSrc, srcDir, destDir); + } + } + + /** Kopiert Texturdateien, die in einer MTL-Datei referenziert werden. */ + private static void copyMtlTextures(Path mtlFile, Path srcDir, Path destDir) { + try (java.io.BufferedReader br = java.nio.file.Files.newBufferedReader(mtlFile)) { + String line; + while ((line = br.readLine()) != null) { + String lo = line.strip().toLowerCase(); + if (!lo.startsWith("map_")) continue; + int sp = line.indexOf(' '); + if (sp < 0) continue; + String ref = line.substring(sp + 1).strip(); + // Optionale -Flags überspringen (z.B. "map_Kd -s 1 1 1 texture.png") + String[] parts = ref.split("\\s+"); + String texName = parts[parts.length - 1]; + if (texName.isEmpty()) continue; + Path texSrc = srcDir.resolve(texName); + if (java.nio.file.Files.exists(texSrc)) { + copyQuiet(texSrc, destDir.resolve(texSrc.getFileName())); + } + } + } catch (IOException ignored) {} + } + + private static void copyQuiet(Path src, Path dst) { + try { Files.copy(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } + catch (IOException ignored) {} + } + + /** Konvertiert eine beliebige Audiodatei mit ffmpeg zu OGG Vorbis. + * Gibt den Pfad zur erzeugten .ogg-Datei zurück. */ + private static Path convertToOgg(Path src, Path destOgg) throws IOException { + try { + Process proc = new ProcessBuilder( + "ffmpeg", "-i", src.toString(), "-q:a", "4", destOgg.toString(), "-y") + .redirectErrorStream(true) + .start(); + proc.getInputStream().transferTo(java.io.OutputStream.nullOutputStream()); + int exit = proc.waitFor(); + if (exit != 0) throw new IOException("ffmpeg fehlgeschlagen (exit " + exit + ")"); + return destOgg; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Konvertierung unterbrochen", e); + } + } + + /** Stellt sicher, dass eine Audiodatei als OGG vorliegt. + * Ist sie bereits OGG, wird sie einfach kopiert. Andernfalls konvertiert. + * Gibt den relativen Asset-Pfad zurück. */ + private static String ensureOgg(File chosen, Path assetRoot) throws IOException { + Path src = chosen.toPath(); + String baseName = chosen.getName().replaceFirst("\\.[^.]+$", ""); + boolean alreadyOgg = chosen.getName().toLowerCase().endsWith(".ogg"); + Path destOgg = src.resolveSibling(baseName + ".ogg"); + if (!alreadyOgg) convertToOgg(src, destOgg); + else if (!src.equals(destOgg)) Files.copy(src, destOgg, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return assetRoot.relativize(destOgg).toString().replace('\\', '/'); + } + private void handleImport(javafx.stage.Window owner) { FileChooser fc = new FileChooser(); fc.setTitle("Assets importieren"); fc.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("Alle unterstützten Dateien", - "*.j3o","*.obj","*.fbx","*.gltf","*.glb", + "*.j3o","*.obj","*.gltf","*.glb", "*.png","*.jpg","*.jpeg","*.bmp","*.tga","*.dds", - "*.ogg","*.wav","*.mp3"), - new FileChooser.ExtensionFilter("Modelle", "*.j3o","*.obj","*.fbx","*.gltf","*.glb"), + "*.ogg","*.wav"), + new FileChooser.ExtensionFilter("Modelle", "*.j3o","*.obj","*.gltf","*.glb"), new FileChooser.ExtensionFilter("Texturen", "*.png","*.jpg","*.jpeg","*.bmp","*.tga","*.dds"), - new FileChooser.ExtensionFilter("Audio", "*.ogg","*.wav","*.mp3") + new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg","*.wav") ); var files = fc.showOpenMultipleDialog(owner); if (files == null) return; @@ -1692,32 +3244,50 @@ public class EditorApp extends Application { String name = file.getName().toLowerCase(); boolean isNativeModel = name.matches(".*\\.(obj|fbx|gltf|glb)"); boolean isJ3o = name.endsWith(".j3o"); - boolean isAudio = name.matches(".*\\.(ogg|wav|mp3)"); + boolean isAudio = name.matches(".*\\.(ogg|wav)"); boolean isModel = isNativeModel || isJ3o; - String subDir = isModel ? "models" : isAudio ? "audio" : "textures"; + String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures"; TreeItem parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode; try { - Path destDir = ASSET_ROOT.resolve(subDir); + Path destDir = ASSET_ROOT.resolve(subDir); Files.createDirectories(destDir); - Path destFile = destDir.resolve(file.getName()); - Files.copy(file.toPath(), destFile, StandardCopyOption.REPLACE_EXISTING); - if (isNativeModel) { - // Asynchron via JME3 zu .j3o konvertieren - String assetPath = subDir + "/" + file.getName(); - String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); - Path destJ3o = destDir.resolve(baseName + ".j3o"); - input.modelConvertQueue.offer( - new SharedInput.ModelConvertRequest(assetPath, destJ3o, destFile)); - setStatus("Konvertiere " + file.getName() + " → .j3o …"); - } else { - TreeItem newItem = new TreeItem<>(file.getName()); - itemPaths.put(newItem, destFile); + if (isAudio) { + String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); + Path destOgg = destDir.resolve(baseName + ".ogg"); + if (name.endsWith(".ogg")) { + Files.copy(file.toPath(), destOgg, StandardCopyOption.REPLACE_EXISTING); + } else { + setStatus("Konvertiere " + file.getName() + " → OGG …"); + convertToOgg(file.toPath(), destOgg); + } + String finalName = baseName + ".ogg"; + TreeItem newItem = new TreeItem<>(finalName); + itemPaths.put(newItem, destOgg); parent.getChildren().add(newItem); parent.setExpanded(true); - setStatus("Importiert: " + file.getName()); + setStatus("Importiert: " + finalName); + } else { + Path destFile = destDir.resolve(file.getName()); + Files.copy(file.toPath(), destFile, StandardCopyOption.REPLACE_EXISTING); + if (isNativeModel) { + if (name.endsWith(".obj")) copyObjCompanions(file, destDir); + String assetPath = subDir + "/" + file.getName(); + String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); + Path destJ3o = destDir.resolve(baseName + ".j3o"); + boolean keepCtrls = name.matches(".*\\.(gltf|glb)"); + input.modelConvertQueue.offer( + new SharedInput.ModelConvertRequest(assetPath, destJ3o, destFile, keepCtrls)); + setStatus("Konvertiere " + file.getName() + " → .j3o …"); + } else { + TreeItem newItem = new TreeItem<>(file.getName()); + itemPaths.put(newItem, destFile); + parent.getChildren().add(newItem); + parent.setExpanded(true); + setStatus("Importiert: " + file.getName()); + } } } catch (IOException ex) { setStatus("Fehler beim Import: " + ex.getMessage()); @@ -1735,13 +3305,21 @@ public class EditorApp extends Application { StackPane pane = new StackPane(viewport); pane.setStyle("-fx-background-color: #1a1a2e;"); + javafx.animation.PauseTransition resizeDebounce = + new javafx.animation.PauseTransition(javafx.util.Duration.millis(150)); + resizeDebounce.setOnFinished(ev -> { + int w = (int) pane.getWidth(); + int h = (int) pane.getHeight(); + if (w > 0 && h > 0) input.resizeRequest.set(new int[]{w, h}); + }); + pane.widthProperty().addListener((o, oldW, newW) -> { viewport.setFitWidth(newW.doubleValue()); - input.viewportScaleX = VP_WIDTH / newW.doubleValue(); + resizeDebounce.playFromStart(); }); pane.heightProperty().addListener((o, oldH, newH) -> { viewport.setFitHeight(newH.doubleValue()); - input.viewportScaleY = VP_HEIGHT / newH.doubleValue(); + resizeDebounce.playFromStart(); }); viewport.setOnMousePressed(e -> { @@ -1756,7 +3334,7 @@ public class EditorApp extends Application { prevDragX = e.getX(); prevDragY = e.getY(); } else if (e.getButton() == MouseButton.PRIMARY) { input.objectClickQueue.offer( - new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), false)); + new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), false, e.isShiftDown())); objDragPrevX = e.getX(); objDragPrevY = e.getY(); objDragging = true; } @@ -1801,12 +3379,21 @@ public class EditorApp extends Application { double dy = e.getY() - prevDragY; input.addMouseDelta((int) dx, (int) dy); prevDragX = e.getX(); prevDragY = e.getY(); + } else if (e.isPrimaryButtonDown() || e.isSecondaryButtonDown()) { + editPressX = e.getX(); + editPressY = e.getY(); + input.mouseScreenX = (float) e.getX(); + input.mouseScreenY = (float) e.getY(); } }); viewport.setOnMouseReleased(e -> { objDragging = false; if (!isObjectMode()) stopEditTimer(); + if (input.vertexSnapEnabled + && input.objectSelectionMode == SharedInput.SEL_MODE_VERTEX) { + input.vertexSnapTrigger = true; + } }); pane.setOnMouseMoved(e -> { @@ -1816,13 +3403,8 @@ public class EditorApp extends Application { pane.setOnMouseExited(e -> input.mouseScreenX = -1f); viewport.setOnScroll(e -> { - double delta = e.getDeltaY(); - input.forward = delta > 0; - input.backward = delta < 0; - javafx.animation.PauseTransition pause = - new javafx.animation.PauseTransition(javafx.util.Duration.millis(150)); - pause.setOnFinished(ev -> { input.forward = false; input.backward = false; }); - pause.play(); + int steps = (int) Math.signum(e.getDeltaY()); + input.scrollAccum.addAndGet(steps); }); return pane; @@ -1857,13 +3439,26 @@ public class EditorApp extends Application { case 0 -> input.editQueue.offer(new SharedInput.TerrainEdit((float) x, (float) y, action)); case 3 -> input.grassEditQueue.offer(new SharedInput.GrassEdit((float) x, (float) y, action)); case 4 -> input.textureEditQueue.offer(new SharedInput.TextureEdit((float) x, (float) y, action)); - default -> input.upperLayerEditQueue.offer(new SharedInput.UpperLayerEdit((float) x, (float) y, action)); + case SharedInput.LAYER_LIGHTS -> + input.lightClickQueue.offer(new SharedInput.LightClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_EMITTERS -> + input.emitterClickQueue.offer(new SharedInput.EmitterClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_WATER -> + input.waterClickQueue.offer(new SharedInput.WaterClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_SOUND_AREAS -> + input.soundAreaClickQueue.offer(new SharedInput.SoundAreaClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_MUSIC_AREAS -> + input.musicAreaClickQueue.offer(new SharedInput.MusicAreaClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_PLAY_TOOL -> { + if (action > 0) // only left-click sets spawn + input.playToolClickQueue.offer(new SharedInput.PlayToolClick((float) x, (float) y)); + } } } // ── Statusleiste ───────────────────────────────────────────────────────── - private VBox buildBottomBox() { + private HBox buildBottomBox() { // Status-Leiste statusLabel = new Label("Bereit | Werkzeug: Höhe | WASD/QE: Bewegen | Mitte-Drag / L+R-Drag: Drehen"); statusLabel.setPadding(new Insets(3, 8, 3, 8)); @@ -1879,52 +3474,7 @@ public class EditorApp extends Application { HBox statusBar = new HBox(statusLabel, spacer, camCoordsLabel); statusBar.setStyle("-fx-background-color: #e8e8e8; -fx-border-color: #bbb; -fx-border-width: 1 0 0 0;"); - // Konsolen-Panel (anfangs ausgeblendet) - Label prompt = new Label(">"); - prompt.setStyle("-fx-text-fill: #7ec8e3; -fx-font-family: monospace; -fx-font-size: 12; -fx-padding: 0 4 0 0;"); - - consoleField = new TextField(); - consoleField.setStyle( - "-fx-background-color: transparent; -fx-text-fill: #f0f0f0; " + - "-fx-font-family: monospace; -fx-font-size: 12; -fx-border-width: 0;"); - consoleField.setPromptText("Befehl eingeben… (Enter = ausführen, Esc = schließen)"); - HBox.setHgrow(consoleField, Priority.ALWAYS); - - consoleField.setOnAction(e -> { - String cmd = consoleField.getText().trim(); - if (!cmd.isEmpty()) input.pendingCommand = cmd; - toggleConsole(); - }); - consoleField.setOnKeyPressed(e -> { - if (e.getCode() == KeyCode.ESCAPE - || e.getCode() == KeyCode.DEAD_CIRCUMFLEX - || e.getCode() == KeyCode.CIRCUMFLEX) { - toggleConsole(); - e.consume(); - } - }); - - consoleBar = new HBox(4, prompt, consoleField); - consoleBar.setStyle( - "-fx-background-color: #1e1e1e; -fx-padding: 3 8 3 8; " + - "-fx-border-color: #555; -fx-border-width: 1 0 0 0;"); - consoleBar.setVisible(false); - consoleBar.setManaged(false); - - return new VBox(statusBar, consoleBar); - } - - private void toggleConsole() { - boolean show = !consoleOpen; - consoleOpen = show; - consoleBar.setVisible(show); - consoleBar.setManaged(show); - if (show) { - // Bewegungstasten loslassen, damit keine Dauerbewegung entsteht - input.forward = input.backward = input.left = input.right = input.up = input.down = false; - consoleField.clear(); - consoleField.requestFocus(); - } + return statusBar; } private void launchGame() { @@ -1937,19 +3487,26 @@ public class EditorApp extends Application { private void startGameProcess() { new Thread(() -> { try { - String javaExe = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + String javaExe = Paths.get(System.getProperty("java.home"), "bin", "java").toString(); String classpath = System.getProperty("java.class.path"); String libPath = System.getProperty("java.library.path", ""); String projRoot = ProjectRoot.PATH.toString(); - new ProcessBuilder( + java.util.List cmd = new java.util.ArrayList<>(List.of( javaExe, "--add-opens", "java.base/java.lang=ALL-UNNAMED", "--add-opens", "java.desktop/sun.awt=ALL-UNNAMED", "-Djava.library.path=" + libPath, - "-Dblight.project.root=" + projRoot, - "-cp", classpath, - "de.blight.game.BlightApp") + "-Dblight.project.root=" + projRoot)); + + if (!Float.isNaN(input.tempSpawnX) && !Float.isNaN(input.tempSpawnZ)) { + cmd.add("-Dblight.temp.spawn.x=" + input.tempSpawnX); + cmd.add("-Dblight.temp.spawn.z=" + input.tempSpawnZ); + } + + cmd.addAll(List.of("-cp", classpath, "de.blight.game.BlightGame")); + + new ProcessBuilder(cmd) .directory(ProjectRoot.PATH.toFile()) .inheritIO() .start(); @@ -1984,22 +3541,1345 @@ public class EditorApp extends Application { // ── Tastatur-Handling ──────────────────────────────────────────────────── private void handleKeyPress(KeyCode code, boolean pressed) { - // ^ öffnet/schließt die Konsole (DEAD_CIRCUMFLEX = deutsche Tastatur) + // ^ öffnet/schließt die JME-Konsole if (code == KeyCode.DEAD_CIRCUMFLEX || code == KeyCode.CIRCUMFLEX) { - if (pressed) toggleConsole(); + if (pressed) { + input.consoleToggle = true; + input.forward = input.backward = input.left = input.right = input.up = input.down = false; + input.shiftHeld = false; + } + return; + } + // Sondertasten an JME-Konsole weiterleiten wenn offen + if (input.consoleIsOpen) { + if (pressed) { + if (code == KeyCode.BACK_SPACE) input.consoleKeys.offer(8); + else if (code == KeyCode.ENTER) input.consoleKeys.offer(10); + else if (code == KeyCode.ESCAPE) input.consoleKeys.offer(27); + } return; } - // Während die Konsole offen ist, keine Editor-Tasten weiterleiten - if (consoleOpen) return; switch (code) { - case W -> input.forward = pressed; - case S -> input.backward = pressed; - case A -> input.left = pressed; - case D -> input.right = pressed; - case Q -> input.up = pressed; - case E -> input.down = pressed; - case F5 -> { if (pressed) onF5.run(); } - case F6 -> { if (pressed) onF6.run(); } + case W -> input.forward = pressed; + case S -> input.backward = pressed; + case A -> input.left = pressed; + case D -> input.right = pressed; + case Q -> input.up = pressed; + case E -> input.down = pressed; + case SHIFT -> input.shiftHeld = pressed; + case ESCAPE -> { + if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS + || input.activeLayer == SharedInput.LAYER_MUSIC_AREAS)) + input.cancelZoneDrawing = true; + } + case DELETE -> { + if (pressed) { + if (input.activeLayer == SharedInput.LAYER_OBJECTS + || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT) + input.deleteSelectedRequested = true; + else if (input.activeLayer == SharedInput.LAYER_SOUND_AREAS) + input.deleteSoundAreaRequested = true; + else if (input.activeLayer == SharedInput.LAYER_MUSIC_AREAS) + input.deleteMusicAreaRequested = true; + } + } + case F1 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> baseBtn.fire()); } + case F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); } + case F5 -> { + if (pressed) { + if ("world".equals(currentTool)) Platform.runLater(() -> textureBtn.fire()); + else onF5.run(); + } + } + case F6 -> { + if (pressed) { + if ("world".equals(currentTool)) Platform.runLater(() -> objPlaceBtn.fire()); + else onF6.run(); + } + } + case F7 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> objEditBtn.fire()); } } } + + // ── Sound-Bereich-Panel ─────────────────────────────────────────────────── + + private VBox buildSoundAreaPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Sound-Bereiche"), + styledHint("L-Klick → Polygon-Punkte setzen"), + styledHint("R-Klick → Polygon schließen / Auswahl aufheben"), + styledHint("Entf → Bereich löschen"), + new Separator(), + sectionTitle("Gewählter Bereich"), + new Separator()); + + soundAreaDynamicContent = new VBox(6); + Label noSel = new Label("Kein Bereich ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + soundAreaDynamicContent.getChildren().add(noSel); + inner.getChildren().add(soundAreaDynamicContent); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateSoundAreaPanel(String info) { + if (soundAreaDynamicContent == null) return; + soundAreaDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Kein Bereich ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + soundAreaDynamicContent.getChildren().add(noSel); + return; + } + // Format: "idx|soundPath|volume|crossfade" + String[] p = info.split("\\|", -1); + if (p.length < 4) return; + try { + int idx = Integer.parseInt(p[0]); + float volume = Float.parseFloat(p[2]); + boolean xfade = Boolean.parseBoolean(p[3]); + final String[] soundPath = {p[1]}; + final float[] vol = {volume}; + final boolean[] cf = {xfade}; + + Runnable publish = () -> { + if (input.selectedSoundAreaInfo == null) return; + String[] cur = input.selectedSoundAreaInfo.split("\\|", -1); + if (cur.length < 1) return; + // Polygon unchanged; only update sound properties via pendingSoundArea + // We need the polygon from the state – use a zero-size placeholder, + // the state will merge only the properties fields. + // Instead we pass through a special sentinel: same polygon, new props. + // Simplification: publish via selectedSoundAreaInfo update + let state re-read + input.selectedSoundAreaInfo = idx + "|" + soundPath[0] + "|" + vol[0] + "|" + cf[0]; + }; + + Label soundLabel = new Label("Sound: " + (soundPath[0].isEmpty() ? "(keine)" : soundPath[0])); + soundLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + soundLabel.setWrapText(true); + + Button browseBtn = new Button("📂 Datei wählen…"); + browseBtn.setMaxWidth(Double.MAX_VALUE); + browseBtn.setOnAction(e -> { + FileChooser fc = new FileChooser(); + fc.setTitle("Sound-Datei wählen"); + fc.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg", "*.wav")); + Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); + if (java.nio.file.Files.isDirectory(assetRoot)) + fc.setInitialDirectory(assetRoot.toFile()); + File chosen = fc.showOpenDialog(primaryStage); + if (chosen != null) { + try { + String rel = ensureOgg(chosen, assetRoot); + soundPath[0] = rel; + soundLabel.setText("Sound: " + rel); + sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); + } catch (Exception ex) { + setStatus("Fehler bei OGG-Konvertierung: " + ex.getMessage()); + } + } + }); + + Label volLbl = new Label("Lautstärke: " + String.format("%.2f", vol[0])); + volLbl.setStyle("-fx-font-size: 11;"); + Slider volSlider = new Slider(0, 1, vol[0]); + volSlider.setShowTickLabels(false); + volSlider.valueProperty().addListener((o, ov, nv) -> { + vol[0] = nv.floatValue(); + volLbl.setText("Lautstärke: " + String.format("%.2f", nv.floatValue())); + sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); + }); + + CheckBox xfadeCB = new CheckBox("Loop-Crossfade"); + xfadeCB.setSelected(cf[0]); + xfadeCB.setOnAction(e -> { + cf[0] = xfadeCB.isSelected(); + sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); + }); + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteSoundAreaRequested = true); + + soundAreaDynamicContent.getChildren().addAll( + soundLabel, browseBtn, new Separator(), volLbl, volSlider, xfadeCB, + new Separator(), delBtn); + + } catch (NumberFormatException ignored) {} + } + + private void sendSoundAreaUpdate(int idx, String soundPath, float volume, boolean crossfade) { + if (input.selectedSoundAreaInfo == null) return; + // We only update metadata (sound path, volume, crossfade). + // The polygon stays as-is in the state. + // Signal the state by setting pendingSoundArea with the current polygon. + // EditorApp doesn't have the polygon points – so we send the info string update + // and let the state handle the property merge via a dedicated flag. + // Simpler: encode the delta into selectedSoundAreaInfo and let state read it. + input.selectedSoundAreaInfo = idx + "|" + soundPath + "|" + volume + "|" + crossfade; + // Build a PlacedSoundArea with zero-length polygon as sentinel – state must check + // pendingSoundArea != null and idx matches to update only the props. + input.pendingSoundArea.set(new de.blight.common.PlacedSoundArea( + new float[0], new float[0], soundPath, volume, crossfade)); + } + + // ── Musik-Bereich-Panel ─────────────────────────────────────────────────── + + private VBox buildMusicAreaPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Musik-Bereiche"), + styledHint("L-Klick → Polygon-Punkte setzen"), + styledHint("R-Klick → Polygon schließen / Auswahl aufheben"), + styledHint("Entf → Bereich löschen"), + new Separator(), + sectionTitle("Gewählter Bereich"), + new Separator()); + + musicAreaDynamicContent = new VBox(6); + Label noSel = new Label("Kein Bereich ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + musicAreaDynamicContent.getChildren().add(noSel); + inner.getChildren().add(musicAreaDynamicContent); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateMusicAreaPanel(String info) { + if (musicAreaDynamicContent == null) return; + musicAreaDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Kein Bereich ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + musicAreaDynamicContent.getChildren().add(noSel); + return; + } + // Format: "idx|dayTrack|nightTrack|combatTrack" + String[] p = info.split("\\|", -1); + if (p.length < 4) return; + try { + int idx = Integer.parseInt(p[0]); + final String[] tracks = {p[1], p[2], p[3]}; + String[] trackLabels = {"☀ Tag-Track", "🌙 Nacht-Track", "⚔ Kampf-Track"}; + + Runnable publish = () -> + input.pendingMusicArea.set(new de.blight.common.PlacedMusicArea( + new float[0], new float[0], tracks[0], tracks[1], tracks[2])); + + for (int ti = 0; ti < 3; ti++) { + final int tIdx = ti; + Label lbl = new Label(trackLabels[ti] + ": " + + (tracks[ti].isEmpty() ? "(keine)" : tracks[ti])); + lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + lbl.setWrapText(true); + + Button btn = new Button("📂 Wählen…"); + btn.setMaxWidth(Double.MAX_VALUE); + btn.setOnAction(e -> { + FileChooser fc = new FileChooser(); + fc.setTitle(trackLabels[tIdx] + " wählen"); + fc.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg", "*.wav")); + Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); + if (java.nio.file.Files.isDirectory(assetRoot)) + fc.setInitialDirectory(assetRoot.toFile()); + File chosen = fc.showOpenDialog(primaryStage); + if (chosen != null) { + try { + Path assetRootPath = ProjectRoot.resolve("blight-assets", "src", "main", "resources"); + tracks[tIdx] = ensureOgg(chosen, assetRootPath); + lbl.setText(trackLabels[tIdx] + ": " + tracks[tIdx]); + publish.run(); + } catch (Exception ex) { + setStatus("Fehler bei OGG-Konvertierung: " + ex.getMessage()); + } + } + }); + + musicAreaDynamicContent.getChildren().addAll(lbl, btn); + if (ti < 2) musicAreaDynamicContent.getChildren().add(new Separator()); + } + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteMusicAreaRequested = true); + musicAreaDynamicContent.getChildren().addAll(new Separator(), delBtn); + + } catch (NumberFormatException ignored) {} + } + + // ── Spiel-Starten-Panel ─────────────────────────────────────────────────── + + private VBox buildPlayToolPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Spiel starten"), + new Separator(), + bold("Temporärer Spawnpunkt:"), + styledHint("L-Klick im Viewport → Spawnpunkt setzen")); + + Label coordHint = new Label("oder manuell eingeben:"); + coordHint.setStyle("-fx-text-fill: #444;"); + inner.getChildren().add(coordHint); + + spawnXField = new TextField(Float.isNaN(input.tempSpawnX) ? "" : String.valueOf(input.tempSpawnX)); + spawnZField = new TextField(Float.isNaN(input.tempSpawnZ) ? "" : String.valueOf(input.tempSpawnZ)); + spawnXField.setPromptText("X"); + spawnZField.setPromptText("Z"); + + Runnable applyFields = () -> { + try { + input.tempSpawnX = Float.parseFloat(spawnXField.getText().trim()); + input.tempSpawnZ = Float.parseFloat(spawnZField.getText().trim()); + } catch (NumberFormatException ignored2) {} + }; + spawnXField.setOnAction(e -> applyFields.run()); + spawnZField.setOnAction(e -> applyFields.run()); + spawnXField.focusedProperty().addListener((o, ov, nv) -> { if (!nv) applyFields.run(); }); + spawnZField.focusedProperty().addListener((o, ov, nv) -> { if (!nv) applyFields.run(); }); + + HBox coordRow = new HBox(6, new Label("X:"), spawnXField, new Label("Z:"), spawnZField); + coordRow.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(spawnXField, Priority.ALWAYS); + HBox.setHgrow(spawnZField, Priority.ALWAYS); + + Button clearSpawn = new Button("✕ Spawnpunkt löschen"); + clearSpawn.setMaxWidth(Double.MAX_VALUE); + clearSpawn.setOnAction(e -> { + input.tempSpawnX = Float.NaN; + input.tempSpawnZ = Float.NaN; + spawnXField.setText(""); + spawnZField.setText(""); + }); + + inner.getChildren().addAll(coordRow, clearSpawn, new Separator()); + + Button playBtn = new Button("▶ Spielen"); + playBtn.setMaxWidth(Double.MAX_VALUE); + playBtn.setStyle( + "-fx-background-color: #2d8a3e; -fx-text-fill: white; " + + "-fx-font-weight: bold; -fx-padding: 6 12 6 12;"); + playBtn.setOnAction(e -> launchGame()); + inner.getChildren().add(playBtn); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateSpawnFields(String info) { + if (spawnXField == null || info == null) return; + String[] p = info.split("\\|", -1); + if (p.length < 2) return; + spawnXField.setText(p[0]); + spawnZField.setText(p[1]); + } + + // ── Tripo3D-Generator ──────────────────────────────────────────────────── + + private ToolBar buildTripoToolBar() { + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("AI Modell-Generator · Tripo3D"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + ToolBar tb = new ToolBar(); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + return tb; + } + + private VBox buildTripoPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + + // ── API-Schlüssel ───────────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Tripo3D API"), new Separator()); + Label apiHint = styledHint("Schlüssel unter platform.tripo3d.ai → API Keys"); + apiHint.setWrapText(true); + TextField apiKeyField = new TextField(loadTripoApiKey()); + apiKeyField.setPromptText("tsk_xxxx…"); + apiKeyField.setStyle("-fx-font-family: monospace; -fx-font-size: 11;"); + inner.getChildren().addAll(new Label("API-Schlüssel:"), apiKeyField, apiHint); + + // ── Prompt ──────────────────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Modell beschreiben"), new Separator()); + TextArea promptArea = new TextArea(); + promptArea.setPromptText("z. B. \"Ein mittelalterlicher Ritter in Ruestung\"\n" + + "Tipp: Englische Prompts liefern meist bessere Ergebnisse."); + promptArea.setPrefRowCount(5); + promptArea.setWrapText(true); + inner.getChildren().addAll(new Label("Prompt:"), promptArea); + + TextField nameField = new TextField(); + nameField.setPromptText("dateiname (optional)"); + inner.getChildren().addAll(new Label("Dateiname:"), nameField); + + // ── Generieren ──────────────────────────────────────────────────────── + inner.getChildren().add(new Separator()); + Button generateBtn = new Button("Modell generieren"); + generateBtn.setMaxWidth(Double.MAX_VALUE); + generateBtn.setStyle("-fx-font-weight: bold;"); + generateBtn.setDisable(tripoGenerating); + + tripoProgressBar = new ProgressBar(tripoGenerating ? ProgressBar.INDETERMINATE_PROGRESS : 0); + tripoProgressBar.setMaxWidth(Double.MAX_VALUE); + tripoStatusLabel = new Label(tripoGenerating ? "Generierung läuft…" : "Bereit"); + tripoStatusLabel.setWrapText(true); + tripoStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #444;"); + inner.getChildren().addAll(generateBtn, tripoProgressBar, tripoStatusLabel); + + // ── Import ──────────────────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Import"), new Separator()); + tripoImportBtn = new Button("Importieren (GLB → j3o)"); + tripoImportRigBtn = new Button("Importieren + Skelett (Humanoid)"); + tripoImportBtn.setMaxWidth(Double.MAX_VALUE); + tripoImportRigBtn.setMaxWidth(Double.MAX_VALUE); + boolean hasPrev = tripoLastModelUrl != null && !tripoGenerating; + tripoImportBtn.setDisable(!hasPrev); + tripoImportRigBtn.setDisable(!hasPrev || tripoLastTaskId == null); + inner.getChildren().addAll(tripoImportBtn, tripoImportRigBtn); + inner.getChildren().add(styledHint("Das Skelett-Modell eignet sich für Humanoid-Animationen.")); + + // ── Verknüpfungen ───────────────────────────────────────────────────── + generateBtn.setOnAction(e -> { + if (tripoGenerating) return; + String apiKey = apiKeyField.getText().trim(); + String prompt = promptArea.getText().trim(); + if (apiKey.isEmpty()) { setStatus("Bitte API-Schlüssel eingeben"); return; } + if (prompt.isEmpty()) { setStatus("Bitte Prompt eingeben"); return; } + + saveTripoApiKey(apiKey); + tripoGenerating = true; + generateBtn.setDisable(true); + tripoImportBtn.setDisable(true); + tripoImportRigBtn.setDisable(true); + tripoProgressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + tripoStatusLabel.setText("Erstelle Aufgabe…"); + if (tripoPreviewView != null) tripoPreviewView.setImage(null); + + new Thread(() -> { + try { + String taskId = TripoGenerator.createTextToModelTask(apiKey, prompt); + tripoLastTaskId = taskId; + Platform.runLater(() -> tripoStatusLabel.setText("Generiere… (ID: " + taskId + ")")); + + pollUntilDone(apiKey, taskId, result -> { + tripoLastModelUrl = result.modelUrl(); + if (result.previewUrl() != null && tripoPreviewView != null) + tripoPreviewView.setImage( + new javafx.scene.image.Image(result.previewUrl(), true)); + tripoProgressBar.setProgress(1.0); + tripoStatusLabel.setText("Fertig! Modell bereit zum Import."); + generateBtn.setDisable(false); + tripoImportBtn.setDisable(false); + tripoImportRigBtn.setDisable(false); + tripoGenerating = false; + }, err -> { + tripoProgressBar.setProgress(0); + tripoStatusLabel.setText("Fehler: " + err); + generateBtn.setDisable(false); + tripoGenerating = false; + }); + } catch (Exception ex) { + Platform.runLater(() -> { + tripoProgressBar.setProgress(0); + tripoStatusLabel.setText("Fehler: " + ex.getMessage()); + generateBtn.setDisable(false); + tripoGenerating = false; + }); + } + }).start(); + }); + + tripoImportBtn.setOnAction(e -> { + if (tripoLastModelUrl == null) return; + String name = nameField.getText().trim(); + if (name.isEmpty()) name = "tripo_" + System.currentTimeMillis(); + importTripoModel(tripoLastModelUrl, name); + }); + + tripoImportRigBtn.setOnAction(e -> { + if (tripoLastTaskId == null || tripoLastModelUrl == null) return; + String apiKey = apiKeyField.getText().trim(); + String baseName = nameField.getText().trim(); + if (baseName.isEmpty()) baseName = "tripo_" + System.currentTimeMillis(); + final String rigName = baseName + "_rigged"; + + tripoImportRigBtn.setDisable(true); + tripoProgressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + tripoStatusLabel.setText("Erstelle Skelett…"); + + final String taskId = tripoLastTaskId; + new Thread(() -> { + try { + String rigTaskId = TripoGenerator.createRigTask(apiKey, taskId); + Platform.runLater(() -> tripoStatusLabel.setText( + "Rigging… (ID: " + rigTaskId + ")")); + + pollUntilDone(apiKey, rigTaskId, result -> { + tripoProgressBar.setProgress(1.0); + tripoStatusLabel.setText("Skelett fertig! Importiere…"); + tripoImportRigBtn.setDisable(false); + importTripoModel(result.modelUrl(), rigName); + }, err -> { + tripoProgressBar.setProgress(0); + tripoStatusLabel.setText("Skelett-Fehler: " + err); + tripoImportRigBtn.setDisable(false); + }); + } catch (Exception ex) { + Platform.runLater(() -> { + tripoProgressBar.setProgress(0); + tripoStatusLabel.setText("Fehler: " + ex.getMessage()); + tripoImportRigBtn.setDisable(false); + }); + } + }).start(); + }); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(280); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + /** + * Blocks (in a background thread) until the task reaches a terminal state, + * then calls onSuccess or onError on the JavaFX thread. + */ + private void pollUntilDone(String apiKey, String taskId, + java.util.function.Consumer onSuccess, + java.util.function.Consumer onError) { + while (true) { + try { Thread.sleep(3_000); } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + Platform.runLater(() -> onError.accept("Unterbrochen")); + return; + } + try { + TripoGenerator.TaskResult r = TripoGenerator.pollTask(apiKey, taskId); + switch (r.status()) { + case "success" -> { Platform.runLater(() -> onSuccess.accept(r)); return; } + case "failed", "cancelled" -> { + Platform.runLater(() -> onError.accept(r.status())); return; + } + default -> { + int p = r.progress(); + Platform.runLater(() -> { + if (tripoProgressBar != null) tripoProgressBar.setProgress(p / 100.0); + if (tripoStatusLabel != null) tripoStatusLabel.setText("Generiere… " + p + "%"); + }); + } + } + } catch (java.io.IOException ex) { + // transient network error – keep retrying + Platform.runLater(() -> { + if (tripoStatusLabel != null) + tripoStatusLabel.setText("Prüfe… (" + ex.getMessage() + ")"); + }); + } + } + } + + /** Downloads the GLB from Tripo, copies to Models/, queues j3o conversion. */ + private void importTripoModel(String url, String name) { + final String safeName = name.replaceAll("[^a-zA-Z0-9_\\-]", "_"); + new Thread(() -> { + try { + Platform.runLater(() -> setStatus("Lade Modell herunter: " + safeName + "…")); + Path destDir = ASSET_ROOT.resolve("Models"); + Files.createDirectories(destDir); + Path destGlb = destDir.resolve(safeName + ".glb"); + TripoGenerator.downloadFile(url, destGlb); + + String assetPath = "Models/" + safeName + ".glb"; + Path destJ3o = destDir.resolve(safeName + ".j3o"); + input.modelConvertQueue.offer( + new SharedInput.ModelConvertRequest(assetPath, destJ3o, destGlb, true)); + + Platform.runLater(() -> setStatus("Konvertiere " + safeName + ".glb → .j3o…")); + } catch (java.io.IOException ex) { + Platform.runLater(() -> setStatus("Import-Fehler: " + ex.getMessage())); + } + }).start(); + } + + private static String loadTripoApiKey() { + return java.util.prefs.Preferences + .userNodeForPackage(EditorApp.class) + .get("tripo_api_key", ""); + } + + private static void saveTripoApiKey(String key) { + java.util.prefs.Preferences + .userNodeForPackage(EditorApp.class) + .put("tripo_api_key", key); + } + + // ── Animations-Vorschau-Werkzeug ───────────────────────────────────────── + + private void switchToAnimPreview() { + currentTool = "animpreview"; + topBar.getChildren().set(1, buildAnimPreviewToolBar()); + + animPreviewView = new ImageView(input.animPreviewImage); + animPreviewView.setPreserveRatio(true); + animPreviewView.setSmooth(true); + + StackPane previewPane = new StackPane(animPreviewView); + previewPane.setStyle("-fx-background-color: #2e2e38;"); + + previewPane.widthProperty().addListener((obs, ov, nv) -> { + animPreviewView.setFitWidth(nv.doubleValue()); + input.animPreviewW = Math.max(64, nv.intValue()); + }); + previewPane.heightProperty().addListener((obs, ov, nv) -> { + animPreviewView.setFitHeight(nv.doubleValue()); + input.animPreviewH = Math.max(64, nv.intValue()); + }); + + previewPane.setOnMousePressed(e -> { + animPrevDragX = e.getSceneX(); + animPrevDragY = e.getSceneY(); + }); + previewPane.setOnMouseDragged(e -> { + double dx = e.getSceneX() - animPrevDragX; + double dy = e.getSceneY() - animPrevDragY; + animPrevDragX = e.getSceneX(); + animPrevDragY = e.getSceneY(); + input.animPreviewRotY += (float) (dx * 0.5); + input.animPreviewRotX = (float) Math.max(-80, Math.min(80, + input.animPreviewRotX - dy * 0.3)); + }); + previewPane.setOnScroll(e -> { + double factor = e.getDeltaY() > 0 ? 0.9 : 1.1; + input.animPreviewZoom = (float) Math.max(0.05, + Math.min(20.0, input.animPreviewZoom * factor)); + }); + + root.setCenter(previewPane); + root.setRight(buildAnimPreviewPanel()); + } + + private ToolBar buildAnimPreviewToolBar() { + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Animationseditor"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + ToolBar tb = new ToolBar(); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + return tb; + } + + private VBox buildAnimPreviewPanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + + // ── Modell-Auswahl ──────────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Modell"), new Separator()); + animPreviewModelCombo = new ComboBox<>(); + animPreviewModelCombo.setMaxWidth(Double.MAX_VALUE); + animPreviewModelCombo.setPromptText("j3o-Datei wählen…"); + for (String dir : new String[]{"Models", "animations"}) { + Path base = ASSET_ROOT.resolve(dir); + if (!java.nio.file.Files.isDirectory(base)) continue; + try (var walk = java.nio.file.Files.walk(base)) { + walk.filter(p -> p.toString().endsWith(".j3o")) + .map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/')) + .sorted() + .forEach(animPreviewModelCombo.getItems()::add); + } catch (IOException ignored) {} + } + + Button loadBtn = new Button("Laden"); + loadBtn.setMaxWidth(Double.MAX_VALUE); + loadBtn.setStyle("-fx-font-weight: bold;"); + loadBtn.setOnAction(e -> { + String path = animPreviewModelCombo.getValue(); + if (path == null || path.isEmpty()) return; + input.animPreviewLoadPath = path; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + if (animClipListView != null) animClipListView.getItems().clear(); + }); + Button reimportBtn = new Button("Modell neu importieren (GLB/GLTF)…"); + reimportBtn.setMaxWidth(Double.MAX_VALUE); + reimportBtn.setOnAction(e -> reimportModelForPreview(reimportBtn.getScene().getWindow())); + inner.getChildren().addAll(new Label("Modell:"), animPreviewModelCombo, loadBtn, reimportBtn); + + // ── Animationen ─────────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Animationen"), new Separator()); + animClipListView = new ListView<>(); + animClipListView.setPrefHeight(200); + animClipListView.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.SINGLE); + + Button playBtn = new Button("▶ Abspielen"); + Button stopBtn = new Button("■ Stop"); + playBtn.setMaxWidth(Double.MAX_VALUE); + stopBtn.setMaxWidth(Double.MAX_VALUE); + playBtn.setStyle("-fx-font-weight: bold;"); + playBtn.setOnAction(e -> { + String clip = animClipListView.getSelectionModel().getSelectedItem(); + if (clip != null) input.animPreviewPlayClip = clip; + }); + stopBtn.setOnAction(e -> input.animPreviewPlayClip = ""); + // Doppelklick auf Clip → direkt abspielen + animClipListView.setOnMouseClicked(e -> { + if (e.getClickCount() == 2) { + String clip = animClipListView.getSelectionModel().getSelectedItem(); + if (clip != null) input.animPreviewPlayClip = clip; + } + }); + HBox btnRow = new HBox(6, playBtn, stopBtn); + HBox.setHgrow(playBtn, Priority.ALWAYS); + HBox.setHgrow(stopBtn, Priority.ALWAYS); + Button removeClipBtn = new Button("Clip entfernen"); + removeClipBtn.setMaxWidth(Double.MAX_VALUE); + removeClipBtn.setStyle("-fx-text-fill: #cc0000;"); + removeClipBtn.setOnAction(e -> { + String clip = animClipListView.getSelectionModel().getSelectedItem(); + if (clip != null) input.animPreviewRemoveClip = clip; + }); + inner.getChildren().addAll(animClipListView, btnRow, removeClipBtn); + + Button renameClipBtn = new Button("Clip umbenennen / exportieren…"); + renameClipBtn.setMaxWidth(Double.MAX_VALUE); + renameClipBtn.setOnAction(e -> { + String clip = animClipListView.getSelectionModel().getSelectedItem(); + if (clip == null) return; + javafx.scene.control.TextInputDialog dlg = new javafx.scene.control.TextInputDialog(clip); + dlg.setTitle("Clip umbenennen"); + dlg.setHeaderText("Clip '" + clip + "' unter neuem Namen speichern"); + dlg.setContentText("Neuer Name:"); + dlg.showAndWait().ifPresent(newName -> { + if (!newName.isBlank()) input.clipRenameRequest.set(new SharedInput.ClipRenameRequest(clip, newName.trim())); + }); + }); + + Button saveSetBtn = new Button("Als Animations-Set speichern…"); + saveSetBtn.setMaxWidth(Double.MAX_VALUE); + saveSetBtn.setStyle("-fx-font-weight: bold;"); + saveSetBtn.setOnAction(e -> showSaveAnimSetDialog()); + inner.getChildren().addAll(renameClipBtn, saveSetBtn); + + // ── Steuerung ───────────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Steuerung"), new Separator()); + + CheckBox loopCB = new CheckBox("Loop"); + loopCB.setSelected(true); + loopCB.setOnAction(e -> input.animPreviewLoop = loopCB.isSelected()); + + Label speedLbl = new Label("Geschwindigkeit: 1.0×"); + Slider speedSlider = new Slider(0.1, 3.0, 1.0); + speedSlider.setShowTickMarks(true); + speedSlider.setShowTickLabels(true); + speedSlider.setMajorTickUnit(1.0); + speedSlider.setMaxWidth(Double.MAX_VALUE); + speedSlider.valueProperty().addListener((obs, ov, nv) -> { + input.animPreviewSpeed = nv.floatValue(); + speedLbl.setText(String.format("Geschwindigkeit: %.1f×", nv.doubleValue())); + }); + inner.getChildren().addAll(loopCB, speedLbl, speedSlider); + + // ── Animation hinzufügen ────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Animation hinzufügen"), new Separator()); + + addAnimComboField = new ComboBox<>(); + ComboBox addAnimCombo = addAnimComboField; + + // Direkt-Import-Button: immer ins animations/-Verzeichnis, nur GLB/GLTF + Label animHint = new Label("Mixamo: Download mit \"With Skin\" wählen"); + animHint.setWrapText(true); + animHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); + Button importAnimBtn = new Button("Animation importieren (GLB/GLTF)…"); + importAnimBtn.setMaxWidth(Double.MAX_VALUE); + importAnimBtn.setOnAction(e -> + handleAnimationImport(importAnimBtn.getScene().getWindow())); + addAnimCombo.setMaxWidth(Double.MAX_VALUE); + addAnimCombo.setPromptText("Animation aus animations/ wählen…"); + refreshAddAnimCombo(addAnimCombo); + + Button addAnimBtn = new Button("Animation hinzufügen"); + addAnimBtn.setMaxWidth(Double.MAX_VALUE); + addAnimBtn.setStyle("-fx-font-weight: bold;"); + addAnimBtn.setOnAction(e -> { + String animPath = addAnimCombo.getValue(); + if (animPath == null || animPath.isEmpty()) { + if (animPreviewStatusLabel != null) + animPreviewStatusLabel.setText("Bitte eine Animation auswählen"); + return; + } + input.animPreviewAddAnimPath = animPath; + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…"); + }); + inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn); + + // ── Diagnose ────────────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Diagnose"), new Separator()); + Button dumpBtn = new Button("Skelett-Info ins Log dumpen"); + dumpBtn.setMaxWidth(Double.MAX_VALUE); + dumpBtn.setOnAction(e -> input.animDumpRequested = true); + inner.getChildren().add(dumpBtn); + + // ── Status ──────────────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator()); + animPreviewStatusLabel = new Label("Kein Modell geladen"); + animPreviewStatusLabel.setWrapText(true); + animPreviewStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + inner.getChildren().add(animPreviewStatusLabel); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void refreshAddAnimCombo(ComboBox combo) { + if (combo == null) return; + String current = combo.getValue(); + combo.getItems().clear(); + Path animBase = ASSET_ROOT.resolve("animations"); + if (java.nio.file.Files.isDirectory(animBase)) { + try (var walk = java.nio.file.Files.walk(animBase)) { + walk.filter(ap -> { + String s = ap.toString(); + return s.endsWith(".j3o") || s.endsWith(".glb") || s.endsWith(".gltf"); + }) + .map(ap -> ASSET_ROOT.relativize(ap).toString().replace('\\', '/')) + .sorted() + .forEach(combo.getItems()::add); + } catch (IOException ignored) {} + } + if (current != null && combo.getItems().contains(current)) combo.setValue(current); + } + + private void handleAnimationImport(javafx.stage.Window owner) { + FileChooser fc = new FileChooser(); + fc.setTitle("Animation importieren (GLB/GLTF)"); + fc.getExtensionFilters().add( + new FileChooser.ExtensionFilter("Animationen (GLTF, GLB)", "*.gltf", "*.glb")); + var files = fc.showOpenMultipleDialog(owner); + if (files == null) return; + for (File file : files) { + try { + Path destDir = ASSET_ROOT.resolve("animations"); + Files.createDirectories(destDir); + Path destFile = destDir.resolve(file.getName()); + Files.copy(file.toPath(), destFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + setStatus("Animation importiert: " + file.getName()); + } catch (IOException ex) { + setStatus("Fehler beim Animations-Import: " + ex.getMessage()); + } + } + // Sofort im JavaFX-Thread aktualisieren – keine Konvertierung nötig + refreshCategoryNode(animationsNode, ".j3o", ".gltf", ".glb"); + refreshAddAnimCombo(addAnimComboField); + } + + private void reimportModelForPreview(javafx.stage.Window owner) { + FileChooser fc = new FileChooser(); + fc.setTitle("Modell (GLB/GLTF) neu importieren"); + fc.getExtensionFilters().add( + new FileChooser.ExtensionFilter("3D-Modelle (GLTF, GLB)", "*.gltf", "*.glb")); + File file = fc.showOpenDialog(owner); + if (file == null) return; + + String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null; + Path destDir; + Path destJ3o; + if (selectedJ3o != null && !selectedJ3o.isEmpty()) { + // Ziel: gleicher Pfad wie das aktuell gewählte j3o + destJ3o = ASSET_ROOT.resolve(selectedJ3o.replace('/', java.io.File.separatorChar)); + destDir = destJ3o.getParent(); + } else { + destDir = ASSET_ROOT.resolve("Models"); + String baseName = file.getName().replaceFirst("\\.[^.]+$", ""); + destJ3o = destDir.resolve(baseName + ".j3o"); + } + // ── Korrektur-Dialog ────────────────────────────────────────────────── + float[] correction = showImportCorrectionDialog(owner); + if (correction == null) return; // Abgebrochen + + final float yRot = correction[0]; + final boolean centerOrg = correction[1] != 0f; + final Path destDirFinal = destDir; + final Path destJ3oFinal = destJ3o; + + try { + Files.createDirectories(destDirFinal); + Path destFile = destDirFinal.resolve(file.getName()); + Files.copy(file.toPath(), destFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + String assetPath = ASSET_ROOT.relativize(destFile).toString().replace('\\', '/'); + input.modelConvertQueue.offer( + new SharedInput.ModelConvertRequest(assetPath, destJ3oFinal, destFile, true, + yRot, centerOrg)); + if (animPreviewStatusLabel != null) + animPreviewStatusLabel.setText("Konvertiere " + file.getName() + " → .j3o…"); + setStatus("Konvertiere " + file.getName() + " → .j3o…"); + // Nach Konvertierung Modell automatisch neu laden + String finalJ3oPath = ASSET_ROOT.relativize(destJ3oFinal).toString().replace('\\', '/'); + new Thread(() -> { + for (int i = 0; i < 300; i++) { + try { Thread.sleep(100); } catch (InterruptedException ignored) {} + if (java.nio.file.Files.exists(destJ3oFinal)) break; + } + javafx.application.Platform.runLater(() -> { + if (animPreviewModelCombo != null && + !animPreviewModelCombo.getItems().contains(finalJ3oPath)) { + animPreviewModelCombo.getItems().add(finalJ3oPath); + } + if (animPreviewModelCombo != null) + animPreviewModelCombo.setValue(finalJ3oPath); + input.animPreviewLoadPath = finalJ3oPath; + if (animPreviewStatusLabel != null) + animPreviewStatusLabel.setText("Lade neu importiertes Modell…"); + }); + }, "reimport-wait").start(); + } catch (IOException ex) { + setStatus("Fehler beim Re-Import: " + ex.getMessage()); + } + } + + // ── Animations-Set-Dialog ───────────────────────────────────────────────── + + private void showSaveAnimSetDialog() { + if (animClipListView == null || animClipListView.getItems().isEmpty()) return; + javafx.scene.control.Dialog dlg = new javafx.scene.control.Dialog<>(); + dlg.setTitle("Animations-Set speichern"); + dlg.setHeaderText("Clips und Aktions-Zuweisung für das Set konfigurieren"); + javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType("Speichern", + javafx.scene.control.ButtonBar.ButtonData.OK_DONE); + dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL); + + javafx.scene.control.TextField nameField = new javafx.scene.control.TextField(); + nameField.setPromptText("Set-Name…"); + + // Wenn bereits ein Set mit dem aktuellen Modell-Namen existiert, vorausfüllen + if (animCurrentModelPath != null) { + String suggested = java.nio.file.Paths.get(animCurrentModelPath) + .getFileName().toString().replaceFirst("\\.j3o$", ""); + nameField.setText(suggested); + } + + javafx.scene.control.ListView clipSel = new javafx.scene.control.ListView<>(); + clipSel.getItems().addAll(animClipListView.getItems()); + clipSel.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); + clipSel.getSelectionModel().selectAll(); + clipSel.setPrefHeight(180); + Button allBtn = new Button("Alle wählen"); + allBtn.setOnAction(ev -> clipSel.getSelectionModel().selectAll()); + + // Aktions-Zuweisung: ComboBox pro AnimationAction + javafx.scene.layout.GridPane actionGrid = new javafx.scene.layout.GridPane(); + actionGrid.setHgap(8); + actionGrid.setVgap(4); + java.util.List allClips = animClipListView.getItems(); + java.util.EnumMap> dlgActionCombos = + new java.util.EnumMap<>(de.blight.game.animation.AnimationAction.class); + + // Vorhandenes .animset.json laden, um bestehende Zuweisungen voreinzustellen + de.blight.game.animation.AnimSet existingSet = null; + if (animCurrentModelPath != null) { + String setName = java.nio.file.Paths.get(animCurrentModelPath) + .getFileName().toString().replaceFirst("\\.j3o$", ""); + Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + try { + existingSet = de.blight.game.animation.AnimSet.load(setDir, setName); + } catch (Exception ignored) {} + } + final de.blight.game.animation.AnimSet preload = existingSet; + + int row = 0; + for (de.blight.game.animation.AnimationAction action + : de.blight.game.animation.AnimationAction.values()) { + ComboBox cb = new ComboBox<>(); + cb.setMaxWidth(Double.MAX_VALUE); + cb.setPromptText("— nicht belegt —"); + cb.getItems().add(""); + cb.getItems().addAll(allClips); + if (preload != null) { + String prev = preload.getActionMap().get(action.name()); + if (prev != null && allClips.contains(prev)) cb.setValue(prev); + else cb.setValue(""); + } else { + cb.setValue(""); + } + dlgActionCombos.put(action, cb); + Label lbl = new Label(action.displayName() + ":"); + lbl.setMinWidth(50); + actionGrid.add(lbl, 0, row); + actionGrid.add(cb, 1, row); + javafx.scene.layout.ColumnConstraints cc = new javafx.scene.layout.ColumnConstraints(); + cc.setHgrow(Priority.ALWAYS); + row++; + } + actionGrid.getColumnConstraints().addAll( + new javafx.scene.layout.ColumnConstraints(), + new javafx.scene.layout.ColumnConstraints() {{ setHgrow(Priority.ALWAYS); }}); + + // Clips-Selektion synchronisiert die ComboBox-Optionen + clipSel.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> { + java.util.List sel = new java.util.ArrayList<>(clipSel.getSelectionModel().getSelectedItems()); + for (ComboBox cb : dlgActionCombos.values()) { + String prev = cb.getValue(); + cb.getItems().setAll(""); + cb.getItems().addAll(sel.isEmpty() ? allClips : sel); + cb.setValue(prev != null && cb.getItems().contains(prev) ? prev : ""); + } + }); + + VBox content = new VBox(6, + new Label("Set-Name:"), nameField, + new Label("Clips:"), clipSel, allBtn, + new Separator(), + sectionTitle("Aktions-Zuweisung"), + actionGrid); + content.setPadding(new Insets(10)); + dlg.getDialogPane().setContent(content); + dlg.getDialogPane().setPrefWidth(380); + + dlg.showAndWait().ifPresent(bt -> { + if (bt != ok) return; + String name = nameField.getText().trim(); + if (name.isBlank()) return; + java.util.List selected = new java.util.ArrayList<>( + clipSel.getSelectionModel().getSelectedItems()); + if (selected.isEmpty()) return; + java.util.Map actionMap = new java.util.LinkedHashMap<>(); + for (var entry : dlgActionCombos.entrySet()) { + String clip = entry.getValue().getValue(); + if (clip != null && !clip.isBlank()) actionMap.put(entry.getKey().name(), clip); + } + input.animSetSaveRequest.set(new SharedInput.AnimSetSaveRequest(selected, name, actionMap)); + }); + } + + // ── Character-Editor ────────────────────────────────────────────────────── + + private void switchToCharacterEditor() { + onF5 = null; + topBar.getChildren().set(1, buildCharacterEditorToolBar()); + root.setCenter(new javafx.scene.layout.StackPane()); + root.setRight(buildCharacterEditorPanel()); + } + + private ToolBar buildCharacterEditorToolBar() { + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Character Editor"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + ToolBar tb = new ToolBar(); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + return tb; + } + + private javafx.scene.layout.VBox buildCharacterEditorPanel() { + Path charDir = ASSET_ROOT.resolve("character"); + Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + + // ── Vorhandene Charaktere ───────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Vorhandene Charaktere"), new Separator()); + charListView = new javafx.scene.control.ListView<>(); + charListView.setPrefHeight(120); + refreshCharacterList(); + Button loadCharBtn = new Button("Laden"); + loadCharBtn.setMaxWidth(Double.MAX_VALUE); + loadCharBtn.setOnAction(e -> loadSelectedCharacter(charDir)); + Button newCharBtn = new Button("Neuer Charakter"); + newCharBtn.setMaxWidth(Double.MAX_VALUE); + newCharBtn.setOnAction(e -> clearCharacterForm()); + inner.getChildren().addAll(charListView, loadCharBtn, newCharBtn); + + // ── Character-Daten ─────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Character-Daten"), new Separator()); + + charIdField = new javafx.scene.control.TextField(); + charIdField.setPromptText("eindeutige ID (Dateiname)"); + charNameField = new javafx.scene.control.TextField(); + charNameField.setPromptText("Anzeigename"); + + charTypeCombo = new ComboBox<>(); + charTypeCombo.getItems().addAll("MainCharacter", "NPC"); + charTypeCombo.setValue("NPC"); + charTypeCombo.setMaxWidth(Double.MAX_VALUE); + + charModelCombo = new ComboBox<>(); + charModelCombo.setMaxWidth(Double.MAX_VALUE); + charModelCombo.setPromptText("Modell wählen…"); + Path modelBase = ASSET_ROOT.resolve("Models"); + if (java.nio.file.Files.isDirectory(modelBase)) { + try (var walk = java.nio.file.Files.walk(modelBase)) { + walk.filter(p -> p.toString().endsWith(".j3o")) + .map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/')) + .sorted().forEach(charModelCombo.getItems()::add); + } catch (IOException ignored) {} + } + + charAnimSetCombo = new ComboBox<>(); + charAnimSetCombo.setMaxWidth(Double.MAX_VALUE); + charAnimSetCombo.setPromptText("Animations-Set wählen…"); + refreshCharAnimSetCombo(); + charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet()); + + inner.getChildren().addAll( + new Label("ID:"), charIdField, + new Label("Name:"), charNameField, + new Label("Typ:"), charTypeCombo, + new Label("Modell:"), charModelCombo, + new Label("Anim-Set:"), charAnimSetCombo + ); + + // ── Aktions-Zuweisung (Schreibgeschützte Anzeige aus .animset.json) ── + inner.getChildren().addAll(new Separator(), sectionTitle("Aktions-Zuweisung"), new Separator()); + charActionLabelsBox = new VBox(4); + Label actionHint = new Label("(wird aus Animations-Set geladen)"); + actionHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); + charActionLabelsBox.getChildren().add(actionHint); + inner.getChildren().add(charActionLabelsBox); + + // ── Speichern ───────────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator()); + Button saveCharBtn = new Button("Charakter speichern"); + saveCharBtn.setMaxWidth(Double.MAX_VALUE); + saveCharBtn.setStyle("-fx-font-weight: bold;"); + saveCharBtn.setOnAction(e -> saveCharacter(charDir)); + inner.getChildren().add(saveCharBtn); + + charEditorStatusLabel = new Label("Kein Charakter geladen"); + charEditorStatusLabel.setWrapText(true); + charEditorStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + inner.getChildren().add(charEditorStatusLabel); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(300); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void refreshCharacterList() { + if (charListView == null) return; + charListView.getItems().clear(); + Path charDir = ASSET_ROOT.resolve("character"); + if (!java.nio.file.Files.isDirectory(charDir)) return; + try (var walk = java.nio.file.Files.list(charDir)) { + walk.filter(p -> p.toString().endsWith(".character")) + .map(p -> p.getFileName().toString().replace(".character", "")) + .sorted() + .forEach(charListView.getItems()::add); + } catch (IOException ignored) {} + } + + private void refreshCharAnimSetCombo() { + if (charAnimSetCombo == null) return; + String cur = charAnimSetCombo.getValue(); + charAnimSetCombo.getItems().clear(); + Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + if (java.nio.file.Files.isDirectory(setDir)) { + try (var walk = java.nio.file.Files.walk(setDir)) { + walk.filter(p -> p.toString().endsWith(".j3o")) + .map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/')) + .sorted().forEach(charAnimSetCombo.getItems()::add); + } catch (IOException ignored) {} + } + if (cur != null && charAnimSetCombo.getItems().contains(cur)) charAnimSetCombo.setValue(cur); + } + + private void updateCharActionCombosFromSet() { + if (charActionLabelsBox == null) return; + charActionLabelsBox.getChildren().clear(); + String setPath = charAnimSetCombo != null ? charAnimSetCombo.getValue() : null; + if (setPath == null || setPath.isBlank()) { + Label hint = new Label("(kein Animations-Set gewählt)"); + hint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); + charActionLabelsBox.getChildren().add(hint); + return; + } + de.blight.game.animation.AnimSet animSet = + de.blight.game.animation.AnimSet.loadByJ3oPath(ASSET_ROOT, setPath); + java.util.Map actionMap = animSet.getActionMap(); + if (actionMap == null || actionMap.isEmpty()) { + Label hint = new Label("(keine Aktions-Zuweisung im Set)"); + hint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); + charActionLabelsBox.getChildren().add(hint); + return; + } + for (de.blight.game.animation.AnimationAction action + : de.blight.game.animation.AnimationAction.values()) { + String clip = actionMap.get(action.name()); + HBox row = new HBox(6); + row.setAlignment(Pos.CENTER_LEFT); + Label lbl = new Label(action.displayName() + ":"); + lbl.setMinWidth(50); + lbl.setStyle("-fx-font-weight: bold;"); + Label val = new Label(clip != null && !clip.isBlank() ? clip : "—"); + val.setStyle("-fx-text-fill: " + (clip != null && !clip.isBlank() ? "#333" : "#aaa") + ";"); + row.getChildren().addAll(lbl, val); + charActionLabelsBox.getChildren().add(row); + } + } + + private void clearCharacterForm() { + if (charIdField != null) charIdField.clear(); + if (charNameField != null) charNameField.clear(); + if (charTypeCombo != null) charTypeCombo.setValue("NPC"); + if (charModelCombo != null) charModelCombo.setValue(null); + if (charAnimSetCombo != null) charAnimSetCombo.setValue(null); + updateCharActionCombosFromSet(); + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Neuer Charakter"); + } + + private void loadSelectedCharacter(Path charDir) { + String id = charListView != null ? charListView.getSelectionModel().getSelectedItem() : null; + if (id == null) return; + try { + de.blight.common.model.GameCharacter c = + de.blight.common.model.CharacterIO.load(charDir.resolve(id + ".character")); + if (charIdField != null) charIdField.setText(c.getCharacterId() != null ? c.getCharacterId() : ""); + if (charNameField != null) charNameField.setText(c.getName() != null ? c.getName().id() : ""); + if (charTypeCombo != null) + charTypeCombo.setValue(c instanceof de.blight.common.model.MainCharacter ? "MainCharacter" : "NPC"); + if (charModelCombo != null) charModelCombo.setValue(c.getModelPath()); + if (charAnimSetCombo != null) { + charAnimSetCombo.setValue(c.getAnimSetPath()); + updateCharActionCombosFromSet(); + } + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Geladen: " + id); + } catch (Exception e) { + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Fehler: " + e.getMessage()); + } + } + + private void saveCharacter(Path charDir) { + String id = charIdField != null ? charIdField.getText().trim() : ""; + if (id.isBlank()) { + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Fehler: ID darf nicht leer sein"); + return; + } + String type = charTypeCombo != null ? charTypeCombo.getValue() : "NPC"; + boolean isMain = "MainCharacter".equals(type); + + if (isMain && de.blight.common.model.CharacterIO.mainCharacterExists(charDir, id)) { + if (charEditorStatusLabel != null) + charEditorStatusLabel.setText("Fehler: Es gibt bereits einen MainCharacter!"); + return; + } + + de.blight.common.model.GameCharacter c = isMain + ? new de.blight.common.model.MainCharacter() + : new de.blight.common.model.NPC(); + c.setCharacterId(id); + String nameId = charNameField != null ? charNameField.getText().trim() : ""; + c.setName(nameId.isBlank() ? null : new de.blight.common.model.TextReference(nameId)); + c.setModelPath(charModelCombo != null ? charModelCombo.getValue() : null); + c.setAnimSetPath(charAnimSetCombo != null ? charAnimSetCombo.getValue() : null); + + try { + de.blight.common.model.CharacterIO.save(c, charDir); + refreshCharacterList(); + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Gespeichert: " + id); + } catch (Exception e) { + if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Fehler: " + e.getMessage()); + } + } + + /** + * Zeigt einen Dialog für Import-Korrekturen. + * @return float[2] { yRotationDeg, centerOrigin(0f/1f) } oder null bei Abbruch + */ + private float[] showImportCorrectionDialog(javafx.stage.Window owner) { + javafx.scene.control.Dialog dlg = new javafx.scene.control.Dialog<>(); + dlg.initOwner(owner); + dlg.setTitle("Import-Korrektur"); + dlg.setHeaderText("Orientierungs-Korrektur beim Import"); + + javafx.scene.control.ButtonType okBtn = + new javafx.scene.control.ButtonType("Importieren", + javafx.scene.control.ButtonBar.ButtonData.OK_DONE); + dlg.getDialogPane().getButtonTypes().addAll(okBtn, + javafx.scene.control.ButtonType.CANCEL); + + // Y-Rotation + Label rotLabel = new Label("Y-Rotation:"); + ComboBox rotBox = new ComboBox<>(); + rotBox.getItems().addAll("0°", "90°", "180°", "-90°", "-180°"); + rotBox.setValue("180°"); // Tripo3D-Standard: Modell steht oft verkehrt herum + rotBox.setMaxWidth(Double.MAX_VALUE); + + // Translation + CheckBox centerCB = new CheckBox("Position auf Ursprung setzen (0 / 0 / 0)"); + centerCB.setSelected(true); + + // Hint + Label hint = new Label( + "Die Korrektur wird einmalig beim Speichern der .j3o-Datei eingebacken.\n" + + "Ändert sich die Ausrichtung, Modell einfach erneut importieren."); + hint.setWrapText(true); + hint.setStyle("-fx-font-size: 11; -fx-text-fill: #666;"); + + VBox content = new VBox(8, rotLabel, rotBox, centerCB, new Separator(), hint); + content.setPadding(new Insets(10)); + content.setPrefWidth(340); + dlg.getDialogPane().setContent(content); + + dlg.setResultConverter(bt -> { + if (bt != okBtn) return null; + String sel = rotBox.getValue(); + float deg = switch (sel) { + case "90°" -> 90f; + case "180°" -> 180f; + case "-90°" -> -90f; + case "-180°" -> -180f; + default -> 0f; + }; + return new float[]{ deg, centerCB.isSelected() ? 1f : 0f }; + }); + + return dlg.showAndWait().orElse(null); + } + } diff --git a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java index e5a324e..129fb14 100644 --- a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java @@ -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 oder goto "; - } - } catch (NumberFormatException e) { - input.consoleOutput = "Fehler: Koordinaten müssen Zahlen sein"; - } - } - case "help" -> input.consoleOutput = - "Befehle: goto | goto | 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 oder goto "; + } catch (NumberFormatException e) { + return "Fehler: Koordinaten müssen Zahlen sein"; + } + }); + + jmeConsole.registerCommand("time", args -> { + if (args.length < 2) return "Syntax: time <0–24> (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(); + } + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 3286eae..9996d19 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -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 editQueue = new ConcurrentLinkedQueue<>(); - // ── Upper-Layer-Edits ───────────────────────────────────────────────────── - public record UpperLayerEdit(float screenX, float screenY, int action) {} - public final ConcurrentLinkedQueue 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 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 resizeRequest = new AtomicReference<>(); + /** JME setzt fertiges WritableImage nach Resize; JavaFX liest per getAndSet(null). */ + public final AtomicReference 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 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 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 objectClickQueue = new ConcurrentLinkedQueue<>(); /** @@ -120,7 +170,9 @@ public class SharedInput { public final ConcurrentLinkedQueue 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 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 consoleChars = new ConcurrentLinkedQueue<>(); + /** Sondertasten: 8=Backspace, 10=Enter, 27=Escape. */ + public final ConcurrentLinkedQueue 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 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 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 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 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 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 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 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 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 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 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 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> + 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> + 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 + 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 = new java.util.concurrent.atomic.AtomicReference<>(); + + // ── Animations-Set speichern ────────────────────────────────────────────── + public record AnimSetSaveRequest(java.util.List clips, String setName, java.util.Map actionMap) {} + public final java.util.concurrent.atomic.AtomicReference + 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 modelConvertQueue = new ConcurrentLinkedQueue<>(); } diff --git a/blight-editor/src/main/java/de/blight/editor/TripoGenerator.java b/blight-editor/src/main/java/de/blight/editor/TripoGenerator.java new file mode 100644 index 0000000..e6b78c4 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/TripoGenerator.java @@ -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, // 0–100 + 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 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 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") + + "\""; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java b/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java index f7f7a91..29fd790 100644 --- a/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java +++ b/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java @@ -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; } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java new file mode 100644 index 0000000..c1367a0 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java @@ -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 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 out) { + AnimComposer ac = s.getControl(AnimComposer.class); + if (ac != null) { + List 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 result = new HashSet<>(); + for (String dir : new String[]{"Models", "animations"}) { + Path base = ASSET_ROOT.resolve(dir); + if (!Files.isDirectory(base)) continue; + try (Stream 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 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 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 clips = new ArrayList<>(); + collectClips(currentModel, clips); + input.animPreviewClips.set(Collections.unmodifiableList(clips)); + input.animPreviewStatus = "Clip entfernt: " + clipName; + saveModel(); + } + + private T findControl(Spatial s, Class 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 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 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 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 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 + buildMS(com.jme3.anim.Armature arm) { + java.util.Map 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 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; + } + +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java b/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java new file mode 100644 index 0000000..428894c --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/EmitterState.java @@ -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 emitters = new ArrayList<>(); + private final List markers = new ArrayList<>(); + private final List particles = new ArrayList<>(); + + private int selectedIdx = -1; + private List 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 getPlacedEmitters() { + return new ArrayList<>(emitters); + } + + public void loadPlacedEmitters(List loaded) { + if (rootNode == null) { + pendingEmitters = new ArrayList<>(loaded); + return; + } + clearAll(); + for (PlacedEmitter pe : loaded) addEmitter(pe); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java index a9d5293..ac28080 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java @@ -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 params) {} + + private static java.util.Map buildJsParams(TreeOptions opts) { + var p = new java.util.LinkedHashMap(); + p.put("seed", opts.seed); + p.put("type", opts.type == de.blight.eztree.TreeType.EVERGREEN ? "evergreen" : "deciduous"); + + // bark + var bark = new java.util.LinkedHashMap(); + 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(); + 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(); } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/LightState.java b/blight-editor/src/main/java/de/blight/editor/state/LightState.java new file mode 100644 index 0000000..58a00e1 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/LightState.java @@ -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 lights = new ArrayList<>(); + private final List markers = new ArrayList<>(); + private final List pointLights = new ArrayList<>(); + + private int selectedIdx = -1; + private List 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 action) { + for (Spatial child : node.getChildren()) { + if (child instanceof Geometry geo && name.equals(geo.getName())) { + action.accept(geo); + return; + } + } + } + + // ── Save / Load ─────────────────────────────────────────────────────────── + + public List getPlacedLights() { + return new ArrayList<>(lights); + } + + public void loadPlacedLights(List loaded) { + if (rootNode == null) { + pendingLights = new ArrayList<>(loaded); + return; + } + clearAll(); + for (PlacedLight pl : loaded) { + addLight(pl); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java b/blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java new file mode 100644 index 0000000..656d7fb --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java @@ -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 areas = new ArrayList<>(); + private final List areaGeos = new ArrayList<>(); + private int selectedIdx = -1; + + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private Geometry inProgGeo = null; + private Geometry lastPointMarker = null; + + private List 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 xs = toList(area.pointsX()); + List 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 xs, List 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 getPlacedAreas() { + return new ArrayList<>(areas); + } + + public void loadAreas(List loaded) { + if (rootNode == null) { + pendingAreas = new ArrayList<>(loaded); + return; + } + clearAll(); + for (PlacedMusicArea a : loaded) addArea(a); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static float[] toArray(List 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 toList(float[] arr) { + List l = new ArrayList<>(arr.length); + for (float f : arr) l.add(f); + return l; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java b/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java index 9a033b2..547e505 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java @@ -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(); } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/PlayToolState.java b/blight-editor/src/main/java/de/blight/editor/state/PlayToolState.java new file mode 100644 index 0000000..0c9e8ca --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/PlayToolState.java @@ -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; + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index da4645e..36a4151 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -4,7 +4,12 @@ import com.jme3.app.Application; import com.jme3.app.SimpleApplication; import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; +import com.jme3.bounding.BoundingBox; +import com.jme3.bounding.BoundingSphere; +import com.jme3.bounding.BoundingVolume; +import com.jme3.collision.CollisionResult; import com.jme3.collision.CollisionResults; +import com.jme3.util.BufferUtils; import com.jme3.material.Material; import com.jme3.material.RenderState; import com.jme3.math.*; @@ -15,9 +20,11 @@ import com.jme3.scene.shape.Box; import com.jme3.scene.shape.Cylinder; import com.jme3.scene.shape.Quad; import com.jme3.scene.shape.Sphere; +import com.jme3.scene.shape.Torus; import com.jme3.texture.Texture; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.export.binary.BinaryExporter; +import de.blight.common.PlacedModel; import de.blight.editor.SharedInput; import de.blight.editor.object.SceneObject; @@ -26,6 +33,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; /** @@ -39,18 +47,25 @@ import java.util.List; */ public class SceneObjectState extends BaseAppState { - private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets"); + private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources"); + private static final Path BLIGHT_ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources"); // ── Gizmo-Farben ───────────────────────────────────────────────────────── - private static final ColorRGBA COL_X = new ColorRGBA(0.9f, 0.1f, 0.1f, 1f); - private static final ColorRGBA COL_Y = new ColorRGBA(0.1f, 0.9f, 0.1f, 1f); - private static final ColorRGBA COL_Z = new ColorRGBA(0.1f, 0.3f, 1.0f, 1f); - private static final ColorRGBA COL_ROT = new ColorRGBA(1.0f, 0.7f, 0.0f, 1f); + private static final ColorRGBA COL_X = new ColorRGBA(0.92f, 0.12f, 0.12f, 1f); + private static final ColorRGBA COL_Y = new ColorRGBA(0.12f, 0.88f, 0.12f, 1f); + private static final ColorRGBA COL_Z = new ColorRGBA(0.12f, 0.35f, 1.00f, 1f); - private static final float ARROW_LEN = 3.0f; - private static final float ARROW_RADIUS = 0.12f; - private static final float PX_PER_WE = 0.15f; // Sensitivität Translation - private static final float ROT_PER_PX = 0.015f; // Sensitivität Rotation + private static final float SHAFT_LEN = 3.2f; + private static final float HEAD_LEN = 0.9f; + private static final float SHAFT_RADIUS = 0.06f; + private static final float HEAD_RADIUS = 0.22f; + private static final float PX_PER_WE = 0.15f; // Sensitivität Translation + private static final float RING_RADIUS = 4.5f; // Radius der Rotationsringe + private static final float RING_TUBE = 0.25f; // Schlauch-Radius (auch für Picking) + private static final float DEG_PER_PX = 0.8f; // Grad pro Maus-Pixel (Rotation) + + private static final String HIGHLIGHT_NAME = "selectionHighlight"; + private static final ColorRGBA HIGHLIGHT_COLOR = new ColorRGBA(0.3f, 0.8f, 1.0f, 1f); // ── Zustand ────────────────────────────────────────────────────────────── private final SharedInput input; @@ -62,27 +77,136 @@ public class SceneObjectState extends BaseAppState { private final List objects = new ArrayList<>(); private final List objNodes = new ArrayList<>(); + private final List animClips = new ArrayList<>(); // parallel zu objects - private Node objectRoot; // hält alle Objekt-Nodes - private Node gizmoNode; // hält Gizmo-Geometrien - private Geometry arrowX, arrowY, arrowZ, ringRot; + private Node objectRoot; + private Node gizmoNode; // Wurzel aller Gizmo-Geometrien (direktes Kind von rootNode) + private Node arrowX, arrowY, arrowZ; // Verschiebe-Pfeile + private Node ringX, ringY, ringZ; // Rotationsringe - private int selectedIdx = -1; - private int activeGizmo = -1; // 0=X,1=Y,2=Z,3=rot; -1=keins + private final List selectedIndices = new ArrayList<>(); + private int activeGizmo = -1; // 0=X,1=Y,2=Z (Verschieben); -1=keins + private int activeRing = -1; // 0=X,1=Y,2=Z (Rotieren); -1=keins private Node previewNode; private String previewModelPath; // gecachter Pfad, um Reload zu vermeiden + // ── Baum-Ordner-Modus ──────────────────────────────────────────────────── + private java.util.List folderTreeAssetPaths = new java.util.ArrayList<>(); + private String activeFolderPath = null; + private float currentRandomRotY = 0f; + private final java.util.Random rng = new java.util.Random(); + + private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt) + private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi) + private int subTriIdx = -1; + private int[] subEdgeVertIdx = null; // [v0, v1] Vertex-Indizes im Mesh (Kanten-Modus) + private int subVertexIdx = -1; // einzelner Vertex-Index (Punkt-Modus) + // ── Konstruktor ────────────────────────────────────────────────────────── public SceneObjectState(SharedInput input) { this.input = input; } + // ── Persistenz-API ──────────────────────────────────────────────────────── + + public List getPlacedModels() { + List list = new ArrayList<>(objects.size()); + int customIdx = 0; + for (int i = 0; i < objects.size(); i++) { + SceneObject so = objects.get(i); + Node node = objNodes.get(i); + String meshFile = ""; + + if (so.modelPath.startsWith("@")) { + // Vertex-Daten und Material als j3o exportieren + String fname = "custom_mesh_" + (customIdx++) + ".j3o"; + Path dest = ASSET_ROOT.resolve("Models").resolve(fname); + try { + Node exportNode = new Node(fname); + for (Spatial child : new java.util.ArrayList<>(node.getChildren())) { + if (HIGHLIGHT_NAME.equals(child.getName())) continue; + exportNode.attachChild(child.clone()); + } + Files.createDirectories(dest.getParent()); + BinaryExporter.getInstance().save(exportNode, dest.toFile()); + meshFile = "Models/" + fname; + } catch (Exception e) { + setStatus("Mesh-Export fehlgeschlagen: " + e.getMessage()); + } + } + + list.add(new PlacedModel( + so.modelPath, + so.getWorldX(), so.getGroundY(), so.getWorldZ(), + so.getRotY(), so.getRotX(), so.getRotZ(), + so.getScale(), so.solid, + so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(), + meshFile, animClips.get(i))); + } + return list; + } + + public void loadPlacedModels(List models) { + if (objectRoot == null) return; + objectRoot.detachAllChildren(); + objects.clear(); + objNodes.clear(); + animClips.clear(); + selectedIndices.clear(); + activeGizmo = -1; + if (gizmoNode != null) + gizmoNode.setCullHint(com.jme3.scene.Spatial.CullHint.Always); + for (PlacedModel pm : models) { + SceneObject so = new SceneObject(pm.modelPath(), pm.x(), pm.z(), pm.y(), pm.solid()); + so.setRotation(pm.rotX(), pm.rotY(), pm.rotZ()); + so.setScale(pm.scale()); + so.setTexturePath(pm.texturePath()); + so.setNormalMapPath(pm.normalMapPath()); + so.setMaterialPath(pm.materialPath()); + objects.add(so); + animClips.add(pm.animClip() != null ? pm.animClip() : ""); + + // meshFile hat Vorrang: enthält exportierte Geometrie inkl. Material + String loadPath = !pm.meshFile().isEmpty() ? pm.meshFile() : pm.modelPath(); + Node node = loadModelNode(loadPath, pm.x(), pm.y(), pm.z()); + + Quaternion q = new Quaternion(); + q.fromAngles(pm.rotX(), pm.rotY(), pm.rotZ()); + node.setLocalRotation(q); + node.setLocalScale(pm.scale()); + + // Textur/Material aus PlacedModel ggf. nochmals auffrischen + // (überschreibt das im j3o gebackene Material, falls Pfade gesetzt sind) + if (!pm.texturePath().isEmpty() || !pm.materialPath().isEmpty()) + applyAppearanceToNode(node, pm.materialPath(), pm.texturePath(), pm.normalMapPath()); + + objNodes.add(node); + objectRoot.attachChild(node); + } + } + public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } + /** Passt die Y-Position aller Objekte im Pinselradius an die aktuelle Terrainhöhe an. */ + public void snapToTerrain(float brushX, float brushZ, float brushRadius) { + if (terrain == null) return; + float r2 = brushRadius * brushRadius; + for (int i = 0; i < objects.size(); i++) { + SceneObject so = objects.get(i); + float dx = so.getWorldX() - brushX; + float dz = so.getWorldZ() - brushZ; + if (dx * dx + dz * dz > r2) continue; + Float h = terrain.getHeight(new com.jme3.math.Vector2f(so.getWorldX(), so.getWorldZ())); + if (h == null) continue; + so.adjustGroundY(h - so.getGroundY()); + objNodes.get(i).setLocalTranslation(so.getWorldX(), h, so.getWorldZ()); + } + } + // ── Lifecycle ───────────────────────────────────────────────────────────── @Override @@ -98,10 +222,15 @@ public class SceneObjectState extends BaseAppState { gizmoNode = new Node("gizmo"); buildGizmo(); gizmoNode.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(gizmoNode); // unabhängig von Objekt-Nodes previewNode = new Node("objectPreview"); previewNode.setCullHint(Spatial.CullHint.Always); rootNode.attachChild(previewNode); + + subOverlay = new Node("subOverlay"); + subOverlay.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(subOverlay); } @Override @@ -109,6 +238,7 @@ public class SceneObjectState extends BaseAppState { objectRoot.removeFromParent(); gizmoNode.removeFromParent(); previewNode.removeFromParent(); + subOverlay.removeFromParent(); } @Override protected void onEnable() {} @@ -128,16 +258,66 @@ public class SceneObjectState extends BaseAppState { createMesh(meshReq); } + // Gizmo-Sichtbarkeit immer aktualisieren (auch wenn Layer wechselt) + updateGizmoVisibility(); + updateGizmoOrientation(); + + // Baum-Ordner-Platzierungsmodus + String treeFolder = input.treeFolderPath; + if (treeFolder != null && !treeFolder.equals(activeFolderPath)) { + reloadTreeFolder(treeFolder); + } + if (treeFolder == null && activeFolderPath != null) { + activeFolderPath = null; + folderTreeAssetPaths.clear(); + } + updatePreview(); boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT; if (!isObjectLayer) return; + // Animation-Clip-Zuweisung von JavaFX + String animClipPending = input.pendingAnimClip; + if (animClipPending != null) { + input.pendingAnimClip = null; + if (!selectedIndices.isEmpty()) { + int idx = selectedIndices.get(0); + animClips.set(idx, animClipPending); + broadcastSelection(); + } + } + // Solid-Flag-Änderung von JavaFX Boolean solidChange = input.pendingSolidChange; if (solidChange != null) { input.pendingSolidChange = null; - if (selectedIdx >= 0) objects.get(selectedIdx).solid = solidChange; + for (int idx : selectedIndices) objects.get(idx).solid = solidChange; + } + + // Property-Änderungen (Position, Rotation, Textur, Solid) + SharedInput.ObjectPropertyChange prop; + while ((prop = input.objectPropertyQueue.poll()) != null) { + handlePropertyChange(prop); + } + + // Löschen + if (input.deleteSelectedRequested) { + input.deleteSelectedRequested = false; + deleteSelected(); + } + + // Zusammenfassen + if (input.mergeSelectedRequested) { + input.mergeSelectedRequested = false; + mergeSelected(); + } + + // Als Vorlage speichern + String templateName = input.saveAsTemplateRequest; + if (templateName != null) { + input.saveAsTemplateRequest = null; + saveAsTemplate(templateName); } // Klick-Events @@ -152,10 +332,19 @@ public class SceneObjectState extends BaseAppState { handleGizmoDrag(drag); } - // Gizmo nachführen - if (selectedIdx >= 0) { - updateGizmoPosition(); + // Vertex-Snap beim Loslassen der Maustaste + if (input.vertexSnapTrigger) { + input.vertexSnapTrigger = false; + if (input.vertexSnapEnabled + && input.objectSelectionMode == SharedInput.SEL_MODE_VERTEX) { + trySnapVertex(); + refreshSubOverlay(); + if (!selectedIndices.isEmpty()) updateGizmoPosition(); + } } + + // Gizmo-Position nachführen + if (!selectedIndices.isEmpty()) updateGizmoPosition(); } // ── Platzierungs-Vorschau ───────────────────────────────────────────────── @@ -197,10 +386,60 @@ public class SceneObjectState extends BaseAppState { } Vector3f pt = hits.getClosestCollision().getContactPoint(); + if (input.vertexSnapEnabled && modelPath.startsWith("@")) { + Vector3f snapped = findNearestVertexWorld(pt, input.vertexSnapRadius); + if (snapped != null) pt = snapped; + } previewNode.setLocalTranslation(pt.x, pt.y, pt.z); + if (input.treeFolderPath != null) { + com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); + rot.fromAngleAxis(currentRandomRotY, com.jme3.math.Vector3f.UNIT_Y); + previewNode.setLocalRotation(rot); + } else { + previewNode.setLocalRotation(com.jme3.math.Quaternion.IDENTITY); + } previewNode.setCullHint(Spatial.CullHint.Inherit); } + // ── Baum-Ordner-Hilfsmethoden ───────────────────────────────────────────── + + private void reloadTreeFolder(String folderPath) { + activeFolderPath = folderPath; + folderTreeAssetPaths.clear(); + input.pendingModelPath = null; + + Path dir = BLIGHT_ASSET_ROOT.resolve(folderPath); + if (java.nio.file.Files.isDirectory(dir)) { + try (var walk = java.nio.file.Files.walk(dir)) { + walk.filter(p -> p.toString().endsWith(".j3o")) + .filter(java.nio.file.Files::isRegularFile) + .map(p -> BLIGHT_ASSET_ROOT.relativize(p).toString().replace(java.io.File.separatorChar, '/')) + .forEach(folderTreeAssetPaths::add); + } catch (Exception e) { + input.randomTreeStatus = "Fehler beim Scannen: " + e.getMessage(); + return; + } + } + + if (!folderTreeAssetPaths.isEmpty()) { + applyRandomTree(); + } else { + input.randomTreeStatus = "Keine .j3o Dateien in: " + folderPath; + } + } + + private void applyRandomTree() { + if (folderTreeAssetPaths.isEmpty()) return; + String picked = folderTreeAssetPaths.get(rng.nextInt(folderTreeAssetPaths.size())); + currentRandomRotY = rng.nextFloat() * com.jme3.math.FastMath.TWO_PI; + input.pendingModelPath = picked; + String filename = java.nio.file.Paths.get(picked).getFileName().toString(); + input.randomTreeStatus = "Baum: " + filename + " Rot: " + Math.round(Math.toDegrees(currentRandomRotY)) + "°"; + com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); + rot.fromAngleAxis(currentRandomRotY, com.jme3.math.Vector3f.UNIT_Y); + previewNode.setLocalRotation(rot); + } + private void applyPreviewMaterial(Spatial s) { if (s instanceof Geometry geo) { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); @@ -219,27 +458,55 @@ public class SceneObjectState extends BaseAppState { // ── Klick-Handling ──────────────────────────────────────────────────────── private void handleClick(SharedInput.ObjectClick click) { - if (click.rightButton()) return; // Rechtsklick reserviert für Kamera + // Rechtsklick: re-randomisiert im Baum-Ordner-Modus, sonst für Kamera + if (click.rightButton()) { + if (input.treeFolderPath != null && !folderTreeAssetPaths.isEmpty()) { + applyRandomTree(); + } + return; + } float jmeX = (float)(click.screenX() * input.viewportScaleX); float jmeY = cam.getHeight() - (float)(click.screenY() * input.viewportScaleY); Ray ray = screenToRay(jmeX, jmeY); - // 1. Gizmo-Test (Priorität) - if (selectedIdx >= 0) { - int hit = pickGizmo(ray); - if (hit >= 0) { activeGizmo = hit; return; } + // 1. Gizmo / Rotationsring-Test + if (!selectedIndices.isEmpty() + && input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT) { + if (input.objectEditTool == SharedInput.EDIT_TOOL_MOVE) { + int hit = pickGizmo(ray); + if (hit >= 0) { activeGizmo = hit; activeRing = -1; return; } + } else if (input.objectEditTool == SharedInput.EDIT_TOOL_ROTATE) { + int hit = pickRing(ray); + if (hit >= 0) { activeRing = hit; activeGizmo = -1; return; } + } } activeGizmo = -1; + activeRing = -1; - // 2. Objekt-Treffer? + // 2. Objekt-Treffer? (Highlight-Geometrien ignorieren) CollisionResults objHits = new CollisionResults(); objectRoot.collideWith(ray, objHits); if (objHits.size() > 0) { - Spatial hit = objHits.getClosestCollision().getGeometry(); - int idx = findObjectIndexByNode(hit); - if (idx >= 0) { selectObject(idx); return; } + CollisionResult closest = null; + for (int ci = 0; ci < objHits.size(); ci++) { + CollisionResult cr = objHits.getCollision(ci); + if (!isInsideHighlight(cr.getGeometry())) { closest = cr; break; } + } + if (closest == null) { deselectAll(); return; } + int idx = findObjectIndexByNode(closest.getGeometry()); + if (idx >= 0) { + selectObject(idx, click.shift()); + // Vorgefertigte Objekte (externe Modelle + Gruppen) können nicht sub-selektiert werden. + String mp = objects.get(idx).modelPath; + boolean isPrefab = !mp.startsWith("@") || mp.equals("@group"); + if (isPrefab && input.objectSelectionMode != SharedInput.SEL_MODE_OBJECT) + input.objectSelectionMode = SharedInput.SEL_MODE_OBJECT; + if (input.objectSelectionMode != SharedInput.SEL_MODE_OBJECT) + applySubSelection(closest); + return; + } } // 3. Terrain-Treffer – Platzieren nur im Platzieren-Modus @@ -254,21 +521,35 @@ public class SceneObjectState extends BaseAppState { if (modelPath == null) { deselectAll(); return; } Vector3f pt = terrHits.getClosestCollision().getContactPoint(); + if (input.vertexSnapEnabled && modelPath.startsWith("@")) { + Vector3f snapped = findNearestVertexWorld(pt, input.vertexSnapRadius); + if (snapped != null) pt = snapped; + } previewNode.setCullHint(Spatial.CullHint.Always); - placeObject(modelPath, pt.x, pt.z, pt.y); + float rotY = input.treeFolderPath != null ? currentRandomRotY : 0f; + placeObject(modelPath, pt.x, pt.z, pt.y, rotY); + // After placing in folder mode, re-randomize for the next placement + if (input.treeFolderPath != null) applyRandomTree(); } // ── Objekt platzieren ──────────────────────────────────────────────────── - private void placeObject(String modelPath, float wx, float wz, float wy) { + private void placeObject(String modelPath, float wx, float wz, float wy, float rotY) { SceneObject so = new SceneObject(modelPath, wx, wz, wy, false); + so.setRotation(0f, rotY, 0f); objects.add(so); + animClips.add(""); Node node = loadModelNode(modelPath, wx, wy, wz); + if (rotY != 0f) { + Quaternion q = new Quaternion(); + q.fromAngleAxis(rotY, Vector3f.UNIT_Y); + node.setLocalRotation(q); + } objNodes.add(node); objectRoot.attachChild(node); - selectObject(objects.size() - 1); + selectObject(objects.size() - 1, false); input.objectJustPlaced = true; setStatus("Platziert: " + modelPath); } @@ -281,8 +562,7 @@ public class SceneObjectState extends BaseAppState { : assets.loadModel(modelPath); if (!modelPath.startsWith("@")) stripControlsRecursive(model); if (modelPath.startsWith("@")) { - Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - mat.setColor("Color", new ColorRGBA(0.75f, 0.75f, 0.75f, 1f)); + Material mat = buildPrimitiveMaterial(); if (model instanceof Geometry g) g.setMaterial(mat); } node.attachChild(model); @@ -311,6 +591,33 @@ public class SceneObjectState extends BaseAppState { }; } + private Material buildPrimitiveMaterial() { + String tex = input.pendingTexturePath; + String nmap = input.pendingNormalMapPath; + boolean hasTex = tex != null && !tex.isEmpty(); + boolean hasNmap = nmap != null && !nmap.isEmpty(); + if (hasTex || hasNmap) { + try { + Material mat = new Material(assets, "Common/MatDefs/Light/Lighting.j3md"); + mat.setBoolean("UseMaterialColors", false); + if (hasTex) { + Texture t = assets.loadTexture(tex); + t.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("DiffuseMap", t); + } + if (hasNmap) { + Texture n = assets.loadTexture(nmap); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("NormalMap", n); + } + return mat; + } catch (Exception ignored) {} + } + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new com.jme3.math.ColorRGBA(0.75f, 0.75f, 0.75f, 1f)); + return mat; + } + /** * Entfernt alle Controls (auch null-Einträge aus fehlgeschlagener Deserialisierung) * rekursiv aus dem Szene-Graphen. Nötig, weil TreeLodControl keinen no-arg @@ -331,125 +638,403 @@ public class SceneObjectState extends BaseAppState { // ── Selektion ───────────────────────────────────────────────────────────── - private void selectObject(int idx) { - selectedIdx = idx; - SceneObject so = objects.get(idx); + private int primaryIdx() { return selectedIndices.isEmpty() ? -1 : selectedIndices.get(0); } - gizmoNode.removeFromParent(); - objNodes.get(idx).attachChild(gizmoNode); - gizmoNode.setLocalTranslation(0, 0, 0); - gizmoNode.setCullHint(Spatial.CullHint.Inherit); - - // Info für JavaFX serialisieren - input.selectedObjectInfo = so.modelPath + "|" + so.solid + "|" - + so.getWorldX() + "|" + so.getGroundY() + "|" + so.getWorldZ() - + "|" + so.getRotY() + "|" + so.getScale(); - input.objectSelectionChanged = true; + private void selectObject(int idx, boolean addToSelection) { + clearSubSelection(); + if (addToSelection) { + Integer boxed = idx; + if (selectedIndices.contains(boxed)) { + selectedIndices.remove(boxed); + removeSelectionHighlight(idx); + } else { + selectedIndices.add(boxed); + addSelectionHighlight(idx); + } + } else { + for (int i : selectedIndices) removeSelectionHighlight(i); + selectedIndices.clear(); + selectedIndices.add(idx); + addSelectionHighlight(idx); + } + updateGizmoVisibility(); + if (!selectedIndices.isEmpty()) updateGizmoPosition(); + broadcastSelection(); } private void deselectAll() { - selectedIdx = -1; + for (int i : selectedIndices) removeSelectionHighlight(i); + selectedIndices.clear(); + clearSubSelection(); activeGizmo = -1; - gizmoNode.removeFromParent(); - gizmoNode.setCullHint(Spatial.CullHint.Always); + activeRing = -1; + updateGizmoVisibility(); input.selectedObjectInfo = null; input.objectSelectionChanged = true; } + private void addSelectionHighlight(int idx) { + Node objNode = objNodes.get(idx); + removeSelectionHighlight(idx); + Node highlight = new Node(HIGHLIGHT_NAME); + for (Spatial child : new java.util.ArrayList<>(objNode.getChildren())) { + if (child == gizmoNode) continue; + Spatial clone = child.clone(); + applySelectionMaterial(clone); + highlight.attachChild(clone); + } + objNode.attachChild(highlight); + } + + private void removeSelectionHighlight(int idx) { + if (idx < 0 || idx >= objNodes.size()) return; + Spatial h = objNodes.get(idx).getChild(HIGHLIGHT_NAME); + if (h != null) h.removeFromParent(); + } + + private void applySelectionMaterial(Spatial s) { + if (s instanceof Geometry geo) { + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", HIGHLIGHT_COLOR); + mat.getAdditionalRenderState().setWireframe(true); + mat.getAdditionalRenderState().setDepthTest(false); + geo.setMaterial(mat); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + } else if (s instanceof Node n) { + for (Spatial child : new java.util.ArrayList<>(n.getChildren())) + applySelectionMaterial(child); + } + } + + /** + * Richtet die drei Translationspfeile so aus, dass sie immer entlang der + * Kamera-Achsen zeigen (screen-right, screen-up, screen-depth). + */ + private void updateGizmoOrientation() { + Vector3f camRight = cam.getLeft().negate(); + Vector3f camUp = cam.getUp(); + Vector3f camForward = cam.getDirection(); + + // arrowX wurde mit dir=(1,0,0) gebaut → drehen auf camRight + arrowX.setLocalRotation(rotFromTo(new Vector3f(1, 0, 0), camRight)); + // arrowY wurde mit dir=(0,1,0) gebaut → drehen auf camUp + arrowY.setLocalRotation(rotFromTo(new Vector3f(0, 1, 0), camUp)); + // arrowZ wurde mit dir=(0,0,-1) gebaut → drehen auf camForward (Tiefe) + arrowZ.setLocalRotation(rotFromTo(new Vector3f(0, 0, -1), camForward)); + } + + /** Gibt die aktuelle Weltrichtung des Gizmo-Pfeils mit Index {@code idx} zurück. */ + private Vector3f getArrowWorldDir(int idx) { + Node node = switch (idx) { case 0 -> arrowX; case 1 -> arrowY; default -> arrowZ; }; + Vector3f localDir = switch (idx) { + case 0 -> new Vector3f(1, 0, 0); + case 1 -> new Vector3f(0, 1, 0); + default -> new Vector3f(0, 0, -1); + }; + return node.getWorldRotation().mult(localDir); + } + + /** Kürzeste Quaternion-Rotation von Einheitsvektor {@code from} nach {@code to}. */ + private static Quaternion rotFromTo(Vector3f from, Vector3f to) { + Vector3f cross = from.cross(to); + float dot = from.dot(to); + if (cross.lengthSquared() < 1e-6f) { + if (dot > 0f) return new Quaternion(); // gleiche Richtung + // Entgegengesetzte Richtung: 180° um eine senkrechte Achse + Vector3f perp = (Math.abs(from.x) < 0.9f) + ? Vector3f.UNIT_X.cross(from).normalizeLocal() + : Vector3f.UNIT_Y.cross(from).normalizeLocal(); + return new Quaternion().fromAngleAxis(FastMath.PI, perp); + } + // Halber-Winkel-Trick für Einheitsvektoren: q = (cross, 1+dot), normalisieren + Quaternion q = new Quaternion(cross.x, cross.y, cross.z, 1f + dot); + return q.normalizeLocal(); + } + + /** Zeigt/versteckt Pfeile oder Ringe abhängig von Selektion und aktivem Werkzeug. */ + private void updateGizmoVisibility() { + boolean sel = !selectedIndices.isEmpty() + && input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT; + boolean move = sel && input.objectEditTool == SharedInput.EDIT_TOOL_MOVE; + boolean rotate = sel && input.objectEditTool == SharedInput.EDIT_TOOL_ROTATE; + + Spatial.CullHint showArrows = move ? Spatial.CullHint.Inherit : Spatial.CullHint.Always; + Spatial.CullHint showRings = rotate ? Spatial.CullHint.Inherit : Spatial.CullHint.Always; + arrowX.setCullHint(showArrows); arrowY.setCullHint(showArrows); arrowZ.setCullHint(showArrows); + ringX.setCullHint(showRings); ringY.setCullHint(showRings); ringZ.setCullHint(showRings); + gizmoNode.setCullHint((move || rotate) ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } + + private void broadcastSelection() { + int n = selectedIndices.size(); + if (n == 0) { + input.selectedObjectInfo = null; + } else if (n == 1) { + int idx = selectedIndices.get(0); + SceneObject so = objects.get(idx); + input.selectedObjectInfo = "1|" + so.modelPath + "|" + so.solid + + "|" + so.getWorldX() + "|" + so.getGroundY() + "|" + so.getWorldZ() + + "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ() + + "|" + so.getScale() + "|" + so.getTexturePath() + + "|" + so.getNormalMapPath() + "|" + so.getMaterialPath() + + "|" + animClips.get(idx); + } else { + input.selectedObjectInfo = String.valueOf(n); + } + input.objectSelectionChanged = true; + } + // ── Gizmo-Drag ─────────────────────────────────────────────────────────── private void handleGizmoDrag(SharedInput.ObjectDrag drag) { - if (selectedIdx < 0 || activeGizmo < 0) return; - SceneObject so = objects.get(selectedIdx); - Node node = objNodes.get(selectedIdx); + if (selectedIndices.isEmpty()) return; + switch (input.objectEditTool) { + case SharedInput.EDIT_TOOL_MOVE -> handleMoveDrag(drag); + case SharedInput.EDIT_TOOL_ROTATE -> handleRotateDrag(drag); + } + } - // dx = horizontale Mausbewegung, dy = vertikale (positiv = nach unten) - float dx = drag.dx() * PX_PER_WE; - float dy = drag.dy() * PX_PER_WE; + private void handleMoveDrag(SharedInput.ObjectDrag drag) { + if (activeGizmo < 0) return; - switch (activeGizmo) { - case 0 -> { so.translate(dx, 0, 0); node.move(dx, 0, 0); } // X - case 1 -> { so.translate(0, -dy, 0); node.move(0, -dy, 0); } // Y (oben = negatives dy) - case 2 -> { so.translate(0, 0, dx); node.move(0, 0, dx); } // Z - case 3 -> { - float rad = drag.dx() * ROT_PER_PX; - so.rotateY(rad); - node.rotate(0, rad, 0); - } + // Weltrichtung des aktiven Pfeils aus seiner aktuellen Node-Rotation + Vector3f arrowDir = getArrowWorldDir(activeGizmo); + + // Pfeilrichtung auf den Bildschirm projizieren: + // Wie viele Screen-Pixel entsprechen 1 Welt-Einheit entlang des Pfeils? + Vector3f origin = gizmoNode.getWorldTranslation(); + Vector3f scrO = cam.getScreenCoordinates(origin); + Vector3f scrT = cam.getScreenCoordinates(origin.add(arrowDir)); + // JME: Y wächst nach oben; JavaFX-Drag: Y wächst nach unten → invertieren + float sdx = scrT.x - scrO.x; + float sdy = -(scrT.y - scrO.y); + float screenLen2 = sdx * sdx + sdy * sdy; + + float magnitude; + if (screenLen2 < 0.01f) { + // Pfeil zeigt direkt in/aus der Kamera → vertikalen Drag als Fallback + magnitude = -drag.dy() * PX_PER_WE; + } else { + // Projektion des Maus-Deltas auf die Bildschirm-Pfeilrichtung → Welt-Einheiten + magnitude = (drag.dx() * sdx + drag.dy() * sdy) / screenLen2; } - updateGizmoPosition(); + Vector3f worldDelta = arrowDir.mult(magnitude); - input.selectedObjectInfo = so.modelPath + "|" + so.solid + "|" - + so.getWorldX() + "|" + so.getGroundY() + "|" + so.getWorldZ() - + "|" + so.getRotY() + "|" + so.getScale(); - input.objectSelectionChanged = true; + int mode = input.objectSelectionMode; + if (mode != SharedInput.SEL_MODE_OBJECT && subSelGeom != null) { + switch (mode) { + case SharedInput.SEL_MODE_POLYGON -> { + // Geometry-Node als Ganzes verschieben + Vector3f localDelta = toParentLocal(worldDelta, subSelGeom.getParent()); + subSelGeom.setLocalTranslation(subSelGeom.getLocalTranslation().add(localDelta)); + } + case SharedInput.SEL_MODE_EDGE -> { + if (subEdgeVertIdx != null) moveVertices(subEdgeVertIdx, worldDelta); + } + case SharedInput.SEL_MODE_VERTEX -> { + if (subVertexIdx >= 0) moveVertices(new int[]{subVertexIdx}, worldDelta); + } + } + refreshSubOverlay(); + updateGizmoPosition(); + return; + } + + for (int idx : selectedIndices) { + SceneObject obj = objects.get(idx); + Node nd = objNodes.get(idx); + obj.translate(worldDelta.x, worldDelta.y, worldDelta.z); + nd.move(worldDelta.x, worldDelta.y, worldDelta.z); + } + updateGizmoPosition(); + broadcastSelection(); + } + + private void handleRotateDrag(SharedInput.ObjectDrag drag) { + if (activeRing < 0) return; + float angle = drag.dx() * DEG_PER_PX * FastMath.DEG_TO_RAD; + + int mode = input.objectSelectionMode; + if (mode != SharedInput.SEL_MODE_OBJECT && subSelGeom != null) { + switch (mode) { + case SharedInput.SEL_MODE_POLYGON -> { + // Weltachsen-Rotation in Elternraum der Geometry umrechnen + Vector3f worldAxis = switch (activeRing) { + case 0 -> new Vector3f(1, 0, 0); + case 1 -> new Vector3f(0, 1, 0); + default -> new Vector3f(0, 0, 1); + }; + Quaternion worldDeltaRot = new Quaternion().fromAngleAxis(angle, worldAxis); + Node parent = subSelGeom.getParent(); + Quaternion parentWorldRot = parent != null ? parent.getWorldRotation() : new Quaternion(); + Quaternion localDeltaRot = parentWorldRot.inverse() + .mult(worldDeltaRot).mult(parentWorldRot); + subSelGeom.setLocalRotation(localDeltaRot.mult(subSelGeom.getLocalRotation())); + } + case SharedInput.SEL_MODE_EDGE -> { + if (subEdgeVertIdx != null) rotateVertices(subEdgeVertIdx, activeRing, angle); + } + // SEL_MODE_VERTEX: einzelner Punkt kann nicht sinnvoll rotiert werden + } + refreshSubOverlay(); + updateGizmoPosition(); + return; + } + + for (int idx : selectedIndices) { + SceneObject so = objects.get(idx); + switch (activeRing) { + case 0 -> so.setRotation(so.getRotX() + angle, so.getRotY(), so.getRotZ()); + case 1 -> so.setRotation(so.getRotX(), so.getRotY() + angle, so.getRotZ()); + case 2 -> so.setRotation(so.getRotX(), so.getRotY(), so.getRotZ() + angle); + } + Quaternion q = new Quaternion(); + q.fromAngles(so.getRotX(), so.getRotY(), so.getRotZ()); + objNodes.get(idx).setLocalRotation(q); + } + broadcastSelection(); } // ── Gizmo-Bau ──────────────────────────────────────────────────────────── private void buildGizmo() { - arrowX = makeArrow(COL_X); - arrowY = makeArrow(COL_Y); - arrowZ = makeArrow(COL_Z); - ringRot = makeRing(COL_ROT); - - // X-Pfeil: entlang +X - arrowX.setLocalRotation(new Quaternion().fromAngleAxis( - -FastMath.HALF_PI, Vector3f.UNIT_Z)); - arrowX.setLocalTranslation(ARROW_LEN * 0.5f, 0, 0); - - // Y-Pfeil: entlang +Y (Standard) - arrowY.setLocalTranslation(0, ARROW_LEN * 0.5f, 0); - - // Z-Pfeil: entlang +Z - arrowZ.setLocalRotation(new Quaternion().fromAngleAxis( - FastMath.HALF_PI, Vector3f.UNIT_X)); - arrowZ.setLocalTranslation(0, 0, ARROW_LEN * 0.5f); - - // Rotationsring: horizontal (XZ-Ebene), leicht oberhalb - ringRot.setLocalTranslation(0, ARROW_LEN + 0.3f, 0); + gizmoNode.detachAllChildren(); + // ── Verschiebe-Pfeile ───────────────────────────────────────────────── + // JME3-3.9 Cylinder-Achse ist +Z. Geometrie-Rotation dreht Z auf Zielachse. + arrowX = buildArrowNode(COL_X, "arrowX", + new Vector3f(1, 0, 0), + new Quaternion().fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_Y)); // Z→+X + arrowY = buildArrowNode(COL_Y, "arrowY", + new Vector3f(0, 1, 0), + new Quaternion().fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X)); // Z→+Y + arrowZ = buildArrowNode(COL_Z, "arrowZ", + new Vector3f(0, 0, -1), + new Quaternion()); // Z→-Z gizmoNode.attachChild(arrowX); gizmoNode.attachChild(arrowY); gizmoNode.attachChild(arrowZ); - gizmoNode.attachChild(ringRot); + + // ── Rotationsringe ─────────────────────────────────────────────────── + // JME3-3.9 Torus liegt in der XY-Ebene (Symmetrieachse = Z). + // Node-Rotation dreht den Ring in die Ziel-Ebene. + ringX = buildRingNode(COL_X, "ringX", + new Quaternion().fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_Y)); // XY→YZ (dreht um X) + ringY = buildRingNode(COL_Y, "ringY", + new Quaternion().fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X)); // XY→XZ (dreht um Y) + ringZ = buildRingNode(COL_Z, "ringZ", + new Quaternion()); // XY-Ebene (dreht um Z) + gizmoNode.attachChild(ringX); + gizmoNode.attachChild(ringY); + gizmoNode.attachChild(ringZ); } - private Geometry makeArrow(ColorRGBA color) { - Cylinder cyl = new Cylinder(2, 6, ARROW_RADIUS, ARROW_LEN, true); - Geometry g = new Geometry("gizmoArrow", cyl); + /** Baut einen Rotationsring (Torus) mit Node-Rotation in die gewünschte Ebene. */ + private Node buildRingNode(ColorRGBA color, String name, Quaternion nodeRot) { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", color); mat.getAdditionalRenderState().setDepthTest(false); - g.setMaterial(mat); - g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent); - return g; + + Geometry ring = new Geometry(name + "_ring", + new Torus(48, 10, RING_TUBE, RING_RADIUS)); + ring.setMaterial(mat); + ring.setQueueBucket(RenderQueue.Bucket.Transparent); + + Node node = new Node(name); + node.setLocalRotation(nodeRot); + node.attachChild(ring); + return node; } - private Geometry makeRing(ColorRGBA color) { - // Einfacher dünner Torus-Ersatz: Kreis aus Liniensegmenten - int segs = 24; - float r = ARROW_LEN * 0.7f; - com.jme3.scene.shape.Torus torus = - new com.jme3.scene.shape.Torus(segs, 6, ARROW_RADIUS, r); - Geometry g = new Geometry("gizmoRing", torus); + /** + * Baut einen Pfeil-Node: Schaft + Spitze entlang {@code dir}. + * {@code geomRot} dreht die Zylinder-Geometrie aus der Standard-Z-Achse in die Zielachse. + * Der Schaft geht von Ursprung bis {@code dir*SHAFT_LEN}, die Spitze schließt sich an. + */ + private Node buildArrowNode(ColorRGBA color, String name, Vector3f dir, Quaternion geomRot) { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", color); mat.getAdditionalRenderState().setDepthTest(false); - g.setMaterial(mat); - g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent); - return g; + + Cylinder shaftMesh = new Cylinder(2, 10, SHAFT_RADIUS, SHAFT_LEN, false); + Geometry shaft = new Geometry(name + "_shaft", shaftMesh); + shaft.setLocalRotation(geomRot); + shaft.setLocalTranslation(dir.mult(SHAFT_LEN * 0.5f)); + shaft.setMaterial(mat); + shaft.setQueueBucket(RenderQueue.Bucket.Transparent); + + Cylinder headMesh = new Cylinder(2, 10, HEAD_RADIUS, HEAD_LEN, true); + Geometry head = new Geometry(name + "_head", headMesh); + head.setLocalRotation(geomRot); + head.setLocalTranslation(dir.mult(SHAFT_LEN + HEAD_LEN * 0.5f)); + head.setMaterial(mat); + head.setQueueBucket(RenderQueue.Bucket.Transparent); + + Node node = new Node(name); + node.attachChild(shaft); + node.attachChild(head); + return node; } + /** Positioniert das Gizmo am Zentrum der Selektion (Objekt oder Sub-Element). */ private void updateGizmoPosition() { - // Gizmo ist Kind des Objekt-Nodes → lokal (0,0,0) ist am Objekt-Ursprung. - // Skalierung: unveränderliche Pixelgröße durch Abstandsskalierung. - if (selectedIdx < 0) return; - Node objNode = objNodes.get(selectedIdx); - float dist = cam.getLocation().distance(objNode.getWorldTranslation()); - float scale = Math.max(1f, dist * 0.1f); - gizmoNode.setLocalScale(scale); + if (selectedIndices.isEmpty()) return; + + int mode = input.objectSelectionMode; + + // Sub-Selektion: Gizmo am Zentrum des ausgewählten Elements + if (mode != SharedInput.SEL_MODE_OBJECT && subSelGeom != null) { + Vector3f center; + float size; + switch (mode) { + case SharedInput.SEL_MODE_POLYGON -> { + BoundingVolume bv = subSelGeom.getWorldBound(); + center = bv.getCenter().clone(); + if (bv instanceof BoundingSphere bs) size = bs.getRadius(); + else if (bv instanceof BoundingBox bb) size = bb.getExtent(null).length(); + else size = 0.5f; + } + case SharedInput.SEL_MODE_EDGE -> { + if (subEdgeVertIdx != null) { + Vector3f[] v = getEdgeWorldVerts(); + center = v[0].add(v[1]).mult(0.5f); + size = v[0].distance(v[1]) * 0.5f; + } else { + center = subSelGeom.getWorldBound().getCenter().clone(); + size = 0.5f; + } + } + case SharedInput.SEL_MODE_VERTEX -> { + Vector3f vPos = getVertexWorldPos(); + center = vPos != null ? vPos : subSelGeom.getWorldBound().getCenter().clone(); + size = 0.3f; + } + default -> { + center = subSelGeom.getWorldBound().getCenter().clone(); + size = 0.5f; + } + } + gizmoNode.setLocalTranslation(center); + gizmoNode.setLocalScale(Math.max(0.01f, size / RING_RADIUS)); + return; + } + + // Objekt-Modus: Gizmo am Schwerpunkt aller selektierten Objekte + float cx = 0, cy = 0, cz = 0, maxR = 0.5f; + for (int i : selectedIndices) { + Vector3f wt = objNodes.get(i).getWorldTranslation(); + cx += wt.x; cy += wt.y; cz += wt.z; + BoundingVolume bv = objNodes.get(i).getWorldBound(); + float r; + if (bv instanceof BoundingSphere bs) r = bs.getRadius(); + else if (bv instanceof BoundingBox bb) r = bb.getExtent(null).length(); + else r = 0.5f; + if (r > maxR) maxR = r; + } + int n = selectedIndices.size(); + gizmoNode.setLocalTranslation(cx / n, cy / n, cz / n); + gizmoNode.setLocalScale(Math.max(0.01f, maxR / RING_RADIUS)); } // ── Gizmo-Picking ───────────────────────────────────────────────────────── @@ -464,9 +1049,19 @@ public class SceneObjectState extends BaseAppState { hits.clear(); arrowZ.collideWith(ray, hits); if (hits.size() > 0) return 2; + return -1; + } + + private int pickRing(Ray ray) { + CollisionResults hits = new CollisionResults(); + ringX.collideWith(ray, hits); + if (hits.size() > 0) return 0; hits.clear(); - ringRot.collideWith(ray, hits); - if (hits.size() > 0) return 3; + ringY.collideWith(ray, hits); + if (hits.size() > 0) return 1; + hits.clear(); + ringZ.collideWith(ray, hits); + if (hits.size() > 0) return 2; return -1; } @@ -495,6 +1090,15 @@ public class SceneObjectState extends BaseAppState { return false; } + private static boolean isInsideHighlight(Spatial s) { + Spatial cur = s.getParent(); + while (cur != null) { + if (HIGHLIGHT_NAME.equals(cur.getName())) return true; + cur = cur.getParent(); + } + return false; + } + // ── Mesh-Erstellung ─────────────────────────────────────────────────────── private void createMesh(SharedInput.MeshCreateRequest req) { @@ -520,7 +1124,7 @@ public class SceneObjectState extends BaseAppState { wrapper.attachChild(geo); try { - Path destDir = ASSET_ROOT.resolve("models"); + Path destDir = ASSET_ROOT.resolve("Models"); Files.createDirectories(destDir); Path dest = destDir.resolve(req.name() + ".j3o"); BinaryExporter.getInstance().save(wrapper, dest.toFile()); @@ -577,7 +1181,18 @@ public class SceneObjectState extends BaseAppState { setStatus("Konvertiere " + req.assetPath() + " …"); try { Spatial model = assets.loadModel(req.assetPath()); - stripControlsRecursive(model); + if (!req.keepControls()) stripControlsRecursive(model); + + if (req.centerOrigin()) { + model.setLocalTranslation(0f, 0f, 0f); + } + if (req.yRotationDeg() != 0f) { + com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); + rot.fromAngleAxis(req.yRotationDeg() * com.jme3.math.FastMath.DEG_TO_RAD, + com.jme3.math.Vector3f.UNIT_Y); + model.setLocalRotation(rot); + } + Files.createDirectories(req.destJ3o().getParent()); BinaryExporter.getInstance().save(model, req.destJ3o().toFile()); if (req.srcToDelete() != null) Files.deleteIfExists(req.srcToDelete()); @@ -589,7 +1204,601 @@ public class SceneObjectState extends BaseAppState { } } + // ── Property-Änderung ───────────────────────────────────────────────────── + + private void handlePropertyChange(SharedInput.ObjectPropertyChange prop) { + int primary = primaryIdx(); + if (primary < 0) return; + SceneObject so = objects.get(primary); + Node node = objNodes.get(primary); + + so.setPosition(prop.x(), prop.y(), prop.z()); + node.setLocalTranslation(prop.x(), prop.y(), prop.z()); + + so.setRotation(prop.rotX(), prop.rotY(), prop.rotZ()); + com.jme3.math.Quaternion q = new com.jme3.math.Quaternion(); + q.fromAngles(prop.rotX(), prop.rotY(), prop.rotZ()); + node.setLocalRotation(q); + + so.solid = prop.solid(); + + boolean appearanceChanged = false; + if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; } + if (prop.normalMapPath()!= null) { so.setNormalMapPath(prop.normalMapPath()); appearanceChanged = true; } + if (prop.matPath() != null) { so.setMaterialPath(prop.matPath()); appearanceChanged = true; } + if (appearanceChanged) + applyAppearanceToNode(node, so.getMaterialPath(), so.getTexturePath(), so.getNormalMapPath()); + } + + /** Wendet Material, Diffuse-Textur und Normal Map in einem Schritt an. */ + private void applyAppearanceToNode(Spatial s, String matPath, String texPath, String nmPath) { + if (s instanceof Geometry geo) { + boolean hasMat = matPath != null && !matPath.isEmpty(); + boolean hasTex = texPath != null && !texPath.isEmpty(); + boolean hasNm = nmPath != null && !nmPath.isEmpty(); + + Material mat; + if (hasMat) { + try { + mat = new Material(assets, matPath); + } catch (Exception e) { + setStatus("Material nicht geladen: " + matPath); + return; + } + } else if (hasTex) { + mat = new Material(assets, "Common/MatDefs/Light/Lighting.j3md"); + } else { + mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(0.75f, 0.75f, 0.75f, 1f)); + geo.setMaterial(mat); + return; + } + + if (hasTex) { + String param = findTextureParam(mat); + if (param != null) { + try { + Texture t = assets.loadTexture(texPath); + t.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(param, t); + } catch (Exception e) { setStatus("Textur nicht geladen: " + texPath); } + } + } + if (hasNm && mat.getMaterialDef().getMaterialParam("NormalMap") != null) { + try { + Texture n = assets.loadTexture(nmPath); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("NormalMap", n); + } catch (Exception e) { setStatus("Normal Map nicht geladen: " + nmPath); } + } + + geo.setMaterial(mat); + } else if (s instanceof Node n) { + for (Spatial child : new java.util.ArrayList<>(n.getChildren())) { + String cn = child.getName(); + if (child != gizmoNode && !HIGHLIGHT_NAME.equals(cn)) + applyAppearanceToNode(child, matPath, texPath, nmPath); + } + } + } + + /** Gibt den ersten bekannten Textur-Parameternam zurück, den dieses Material kennt. */ + private static String findTextureParam(Material mat) { + for (String name : new String[]{"DiffuseMap", "BaseColorMap", "ColorMap", "BarkMap", "LeafMap"}) { + if (mat.getMaterialDef().getMaterialParam(name) != null) return name; + } + return null; + } + + // ── Löschen ─────────────────────────────────────────────────────────────── + + private void deleteSelected() { + if (selectedIndices.isEmpty()) return; + + List sortedDesc = new ArrayList<>(selectedIndices); + sortedDesc.sort(Comparator.reverseOrder()); + + for (int idx : sortedDesc) { + objectRoot.detachChild(objNodes.get(idx)); + objects.remove(idx); + objNodes.remove(idx); + animClips.remove(idx); + } + + selectedIndices.clear(); + clearSubSelection(); + activeGizmo = -1; + updateGizmoVisibility(); + input.selectedObjectInfo = null; + input.objectSelectionChanged = true; + + setStatus("Objekt(e) gelöscht"); + } + + // ── Zusammenfassen ──────────────────────────────────────────────────────── + + private void mergeSelected() { + if (selectedIndices.size() < 2) { setStatus("Mindestens 2 Objekte auswählen"); return; } + + // Centroid der ausgewählten Objekte + float cx = 0, cy = 0, cz = 0; + for (int i : selectedIndices) { + Node n = objNodes.get(i); + Vector3f wt = n.getLocalTranslation(); + cx += wt.x; cy += wt.y; cz += wt.z; + } + int cnt = selectedIndices.size(); + cx /= cnt; cy /= cnt; cz /= cnt; + + // Indizes absteigend sortieren für sicheres Entfernen + List sortedDesc = new ArrayList<>(selectedIndices); + sortedDesc.sort(Comparator.reverseOrder()); + + // Nodes sammeln (aufsteigende Reihenfolge) + List toMerge = new ArrayList<>(cnt); + for (int i = sortedDesc.size() - 1; i >= 0; i--) + toMerge.add(objNodes.get(sortedDesc.get(i))); + + // Originals entfernen + for (int idx : sortedDesc) { + objectRoot.detachChild(objNodes.get(idx)); + objects.remove(idx); + objNodes.remove(idx); + animClips.remove(idx); + } + + // Gruppenknoten aufbauen + Node group = new Node("group"); + group.setLocalTranslation(cx, cy, cz); + for (Node child : toMerge) { + Vector3f lt = child.getLocalTranslation(); + child.setLocalTranslation(lt.x - cx, lt.y - cy, lt.z - cz); + group.attachChild(child); + } + objectRoot.attachChild(group); + + SceneObject mergedSO = new SceneObject("@group", cx, cz, cy, false); + objects.add(mergedSO); + objNodes.add(group); + animClips.add(""); + + selectedIndices.clear(); + selectObject(objects.size() - 1, false); + setStatus("Zusammengefasst: " + cnt + " Objekte"); + } + + // ── Als Vorlage speichern ───────────────────────────────────────────────── + + private void saveAsTemplate(String name) { + if (selectedIndices.isEmpty()) { setStatus("Kein Objekt ausgewählt"); return; } + + Node template = new Node(name); + if (selectedIndices.size() == 1) { + Node src = objNodes.get(selectedIndices.get(0)); + for (Spatial child : new java.util.ArrayList<>(src.getChildren())) { + if (child == gizmoNode) continue; + template.attachChild(child.clone()); + } + } else { + float cx = 0, cy = 0, cz = 0; + for (int i : selectedIndices) { + Vector3f wt = objNodes.get(i).getLocalTranslation(); + cx += wt.x; cy += wt.y; cz += wt.z; + } + cx /= selectedIndices.size(); cy /= selectedIndices.size(); cz /= selectedIndices.size(); + for (int idx : selectedIndices) { + Node src = objNodes.get(idx); + Node copy = new Node("part"); + Vector3f lt = src.getLocalTranslation(); + copy.setLocalTranslation(lt.x - cx, lt.y - cy, lt.z - cz); + for (Spatial child : new java.util.ArrayList<>(src.getChildren())) { + if (child != gizmoNode) copy.attachChild(child.clone()); + } + template.attachChild(copy); + } + } + + try { + Path destDir = ASSET_ROOT.resolve("Models"); + Files.createDirectories(destDir); + Path dest = destDir.resolve(name + ".j3o"); + BinaryExporter.getInstance().save(template, dest.toFile()); + setStatus("Vorlage gespeichert: " + name + ".j3o"); + input.refreshAssets = true; + } catch (IOException e) { + setStatus("Fehler beim Speichern: " + e.getMessage()); + } + } + private void setStatus(String msg) { input.treeGenStatusMsg = msg; // recycled volatile field für Statuszeile } + + // ── Sub-Selektion (Polygon / Kante / Punkt) ─────────────────────────────── + + private void clearSubSelection() { + if (subOverlay == null) return; + subOverlay.detachAllChildren(); + subOverlay.setCullHint(Spatial.CullHint.Always); + subSelGeom = null; + subTriIdx = -1; + subVertexIdx = -1; + subEdgeVertIdx = null; + } + + private void applySubSelection(CollisionResult hit) { + clearSubSelection(); + Geometry geo = hit.getGeometry(); + int tri = hit.getTriangleIndex(); + switch (input.objectSelectionMode) { + case SharedInput.SEL_MODE_POLYGON -> buildFaceOverlay(geo); + case SharedInput.SEL_MODE_EDGE -> buildEdgeOverlay(geo, tri, hit.getContactPoint()); + case SharedInput.SEL_MODE_VERTEX -> buildVertexOverlay(geo, tri, hit.getContactPoint()); + } + if (!subOverlay.getChildren().isEmpty()) + subOverlay.setCullHint(Spatial.CullHint.Inherit); + } + + /** Hebt die gesamte getroffene Geometry (Polygon-Modus) hervor. */ + private void buildFaceOverlay(Geometry geo) { + subSelGeom = geo; + Geometry overlay = geo.clone(); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.85f, 0f, 1f)); + mat.getAdditionalRenderState().setDepthTest(false); + mat.getAdditionalRenderState().setWireframe(true); + overlay.setMaterial(mat); + overlay.setQueueBucket(RenderQueue.Bucket.Transparent); + // subOverlay ist Kind von rootNode (Weltkoordinaten = lokale Koordinaten) + overlay.setLocalTranslation(geo.getWorldTranslation()); + overlay.setLocalRotation(geo.getWorldRotation()); + overlay.setLocalScale(geo.getWorldScale()); + subOverlay.attachChild(overlay); + } + + /** Hebt die nächstgelegene Kante des getroffenen Dreiecks hervor. */ + private void buildEdgeOverlay(Geometry geo, int triIdx, Vector3f contactPt) { + subSelGeom = geo; + int[] vi = getTriangleVertexIndices(geo.getMesh(), triIdx); + Vector3f[] v = getTriangleWorldVerts(geo, triIdx); + if (v == null) return; + float d0 = distToSegment(contactPt, v[0], v[1]); + float d1 = distToSegment(contactPt, v[1], v[2]); + float d2 = distToSegment(contactPt, v[2], v[0]); + Vector3f eA, eB; + if (d0 <= d1 && d0 <= d2) { eA = v[0]; eB = v[1]; subEdgeVertIdx = new int[]{vi[0], vi[1]}; } + else if (d1 <= d2) { eA = v[1]; eB = v[2]; subEdgeVertIdx = new int[]{vi[1], vi[2]}; } + else { eA = v[2]; eB = v[0]; subEdgeVertIdx = new int[]{vi[2], vi[0]}; } + subOverlay.attachChild(buildEdgeCylinder(eA, eB)); + } + + /** Hebt den nächstgelegenen Vertex des getroffenen Dreiecks hervor. */ + private void buildVertexOverlay(Geometry geo, int triIdx, Vector3f contactPt) { + subSelGeom = geo; + int[] vi = getTriangleVertexIndices(geo.getMesh(), triIdx); + Vector3f[] v = getTriangleWorldVerts(geo, triIdx); + if (v == null) return; + float d0 = contactPt.distance(v[0]); + float d1 = contactPt.distance(v[1]); + float d2 = contactPt.distance(v[2]); + if (d0 <= d1 && d0 <= d2) subVertexIdx = vi[0]; + else if (d1 <= d2) subVertexIdx = vi[1]; + else subVertexIdx = vi[2]; + Vector3f pos = getVertexWorldPos(); + if (pos != null) subOverlay.attachChild(buildVertexSphere(pos)); + } + + private Vector3f[] getTriangleWorldVerts(Geometry geo, int triIdx) { + try { + Vector3f v0 = new Vector3f(), v1 = new Vector3f(), v2 = new Vector3f(); + geo.getMesh().getTriangle(triIdx, v0, v1, v2); + return new Vector3f[]{ + geo.localToWorld(v0, null), + geo.localToWorld(v1, null), + geo.localToWorld(v2, null) + }; + } catch (Exception e) { + return null; + } + } + + private float distToSegment(Vector3f p, Vector3f a, Vector3f b) { + Vector3f ab = b.subtract(a); + float len2 = ab.dot(ab); + if (len2 < 1e-10f) return p.distance(a); + float t = FastMath.clamp(p.subtract(a).dot(ab) / len2, 0f, 1f); + return p.distance(a.add(ab.mult(t))); + } + + private Geometry buildEdgeCylinder(Vector3f a, Vector3f b) { + Vector3f mid = a.add(b).mult(0.5f); + float len = a.distance(b); + Vector3f dir = b.subtract(a).normalizeLocal(); + // Cylinder liegt entlang +Z – Z auf dir drehen + Vector3f cross = Vector3f.UNIT_Z.cross(dir); + float dot = Vector3f.UNIT_Z.dot(dir); + Quaternion rot; + if (cross.lengthSquared() < 1e-6f) + rot = dot > 0f ? new Quaternion() : new Quaternion().fromAngleAxis(FastMath.PI, Vector3f.UNIT_X); + else + rot = new Quaternion().fromAngleAxis(FastMath.acos(FastMath.clamp(dot, -1f, 1f)), cross.normalizeLocal()); + + Geometry cyl = new Geometry("edgeLine", new Cylinder(2, 8, 0.04f, len, false)); + cyl.setLocalTranslation(mid); + cyl.setLocalRotation(rot); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(0f, 1f, 1f, 1f)); + mat.getAdditionalRenderState().setDepthTest(false); + cyl.setMaterial(mat); + cyl.setQueueBucket(RenderQueue.Bucket.Transparent); + return cyl; + } + + private Geometry buildTriangleMesh(Vector3f[] verts) { + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, + BufferUtils.createFloatBuffer( + verts[0].x, verts[0].y, verts[0].z, + verts[1].x, verts[1].y, verts[1].z, + verts[2].x, verts[2].y, verts[2].z)); + mesh.setBuffer(VertexBuffer.Type.Index, 3, + BufferUtils.createIntBuffer(0, 1, 2)); + mesh.updateBound(); + + Geometry geo = new Geometry("triOverlay", mesh); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.5f, 0f, 1f)); + mat.getAdditionalRenderState().setDepthTest(false); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + geo.setMaterial(mat); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + return geo; + } + + // ── Vertex-Index / Sub-Selektion Hilfsmethoden ──────────────────────────── + + private static int[] getTriangleVertexIndices(Mesh mesh, int triIdx) { + VertexBuffer ib = mesh.getBuffer(VertexBuffer.Type.Index); + int base = triIdx * 3; + if (ib == null) return new int[]{base, base + 1, base + 2}; + java.nio.Buffer buf = ib.getData(); + if (buf instanceof java.nio.IntBuffer ibuf) { + return new int[]{ibuf.get(base), ibuf.get(base + 1), ibuf.get(base + 2)}; + } else if (buf instanceof java.nio.ShortBuffer sbuf) { + return new int[]{sbuf.get(base) & 0xFFFF, sbuf.get(base + 1) & 0xFFFF, sbuf.get(base + 2) & 0xFFFF}; + } + return new int[]{base, base + 1, base + 2}; + } + + private Vector3f[] getEdgeWorldVerts() { + if (subSelGeom == null || subEdgeVertIdx == null) return null; + try { + java.nio.FloatBuffer pos = (java.nio.FloatBuffer) + subSelGeom.getMesh().getBuffer(VertexBuffer.Type.Position).getData(); + Vector3f[] world = new Vector3f[2]; + for (int i = 0; i < 2; i++) { + int off = subEdgeVertIdx[i] * 3; + world[i] = subSelGeom.localToWorld( + new Vector3f(pos.get(off), pos.get(off + 1), pos.get(off + 2)), null); + } + return world; + } catch (Exception e) { return null; } + } + + private Vector3f getVertexWorldPos() { + if (subSelGeom == null || subVertexIdx < 0) return null; + try { + java.nio.FloatBuffer pos = (java.nio.FloatBuffer) + subSelGeom.getMesh().getBuffer(VertexBuffer.Type.Position).getData(); + int off = subVertexIdx * 3; + return subSelGeom.localToWorld( + new Vector3f(pos.get(off), pos.get(off + 1), pos.get(off + 2)), null); + } catch (Exception e) { return null; } + } + + /** + * Prüft nach einem Vertex-Move, ob ein anderer Vertex im Snap-Radius liegt. + * Wenn ja: Position angleichen UND Index-Buffer schweißen, damit die Dreiecke + * danach topologisch verbunden sind (echter Merge, kein reines Positions-Kopieren). + */ + private void trySnapVertex() { + if (subSelGeom == null || subVertexIdx < 0) return; + Mesh mesh = subSelGeom.getMesh(); + VertexBuffer vb = mesh.getBuffer(VertexBuffer.Type.Position); + java.nio.FloatBuffer pos = (java.nio.FloatBuffer) vb.getData(); + int vertCount = pos.limit() / 3; + + int srcOff = subVertexIdx * 3; + Vector3f curWorld = subSelGeom.localToWorld( + new Vector3f(pos.get(srcOff), pos.get(srcOff + 1), pos.get(srcOff + 2)), null); + + float threshold = input.vertexSnapRadius; + int snapTarget = -1; + float minDist = threshold; + + for (int i = 0; i < vertCount; i++) { + if (i == subVertexIdx) continue; + int off = i * 3; + Vector3f vWorld = subSelGeom.localToWorld( + new Vector3f(pos.get(off), pos.get(off + 1), pos.get(off + 2)), null); + float d = curWorld.distance(vWorld); + if (d < minDist) { minDist = d; snapTarget = i; } + } + + if (snapTarget < 0) return; + + // 1. Position des gezogenen Vertex auf die exakte Lokalposition des Ziel-Vertex setzen + int dstOff = snapTarget * 3; + pos.put(srcOff, pos.get(dstOff)); + pos.put(srcOff + 1, pos.get(dstOff + 1)); + pos.put(srcOff + 2, pos.get(dstOff + 2)); + vb.setUpdateNeeded(); + + // 2. Index-Buffer schweißen: alle Dreiecke, die den alten Index nutzten, + // zeigen jetzt auf den Ziel-Vertex → echter topologischer Merge + weldVertexInMesh(mesh, subVertexIdx, snapTarget); + + mesh.updateBound(); + + // Selektion auf den verbliebenen Vertex umleiten + subVertexIdx = snapTarget; + } + + /** Ersetzt im Index-Buffer alle Vorkommen von {@code fromIdx} durch {@code toIdx}. */ + private static void weldVertexInMesh(Mesh mesh, int fromIdx, int toIdx) { + VertexBuffer ib = mesh.getBuffer(VertexBuffer.Type.Index); + if (ib == null) return; + java.nio.Buffer buf = ib.getData(); + if (buf instanceof java.nio.IntBuffer ibuf) { + for (int i = 0; i < ibuf.limit(); i++) { + if (ibuf.get(i) == fromIdx) ibuf.put(i, toIdx); + } + ib.setUpdateNeeded(); + } else if (buf instanceof java.nio.ShortBuffer sbuf) { + for (int i = 0; i < sbuf.limit(); i++) { + if ((sbuf.get(i) & 0xFFFF) == fromIdx) sbuf.put(i, (short) toIdx); + } + ib.setUpdateNeeded(); + } + } + + private Geometry buildVertexSphere(Vector3f worldPos) { + Geometry sphere = new Geometry("vertexPoint", new Sphere(12, 12, 0.08f)); + sphere.setLocalTranslation(worldPos); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.2f, 1f, 1f)); + mat.getAdditionalRenderState().setDepthTest(false); + sphere.setMaterial(mat); + sphere.setQueueBucket(RenderQueue.Bucket.Transparent); + return sphere; + } + + private void moveVertices(int[] vertIdx, Vector3f worldDelta) { + if (subSelGeom == null) return; + Quaternion invWorldRot = subSelGeom.getWorldRotation().inverse(); + Vector3f worldScale = subSelGeom.getWorldScale(); + Vector3f localDelta = invWorldRot.mult(worldDelta); + localDelta.x /= worldScale.x; + localDelta.y /= worldScale.y; + localDelta.z /= worldScale.z; + + VertexBuffer vb = subSelGeom.getMesh().getBuffer(VertexBuffer.Type.Position); + java.nio.FloatBuffer pos = (java.nio.FloatBuffer) vb.getData(); + for (int vi : vertIdx) { + int off = vi * 3; + pos.put(off, pos.get(off) + localDelta.x); + pos.put(off + 1, pos.get(off + 1) + localDelta.y); + pos.put(off + 2, pos.get(off + 2) + localDelta.z); + } + vb.setUpdateNeeded(); + subSelGeom.getMesh().updateBound(); + } + + private void rotateVertices(int[] vertIdx, int axis, float angle) { + if (subSelGeom == null || vertIdx == null || vertIdx.length == 0) return; + + VertexBuffer vb = subSelGeom.getMesh().getBuffer(VertexBuffer.Type.Position); + java.nio.FloatBuffer pos = (java.nio.FloatBuffer) vb.getData(); + + // Schwerpunkt der selektierten Vertices (Lokal-Raum) + Vector3f centroid = new Vector3f(); + for (int vi : vertIdx) { + int off = vi * 3; + centroid.addLocal(pos.get(off), pos.get(off + 1), pos.get(off + 2)); + } + centroid.divideLocal(vertIdx.length); + + // Weltachse → Lokal-Achse der Geometry + Vector3f worldAxis = axis == 0 ? new Vector3f(1, 0, 0) + : axis == 2 ? new Vector3f(0, 0, 1) + : new Vector3f(0, 1, 0); + Vector3f localAxis = subSelGeom.getWorldRotation().inverse().mult(worldAxis).normalizeLocal(); + + Quaternion rot = new Quaternion().fromAngleAxis(angle, localAxis); + for (int vi : vertIdx) { + int off = vi * 3; + Vector3f v = new Vector3f(pos.get(off), pos.get(off + 1), pos.get(off + 2)); + v.subtractLocal(centroid); + rot.mult(v, v); + v.addLocal(centroid); + pos.put(off, v.x); + pos.put(off + 1, v.y); + pos.put(off + 2, v.z); + } + vb.setUpdateNeeded(); + subSelGeom.getMesh().updateBound(); + } + + /** Sucht in allen custom-mesh-Objekten den nächsten Vertex innerhalb von {@code radius}. */ + private Vector3f findNearestVertexWorld(Vector3f pos, float radius) { + float minDist = radius; + Vector3f best = null; + for (int i = 0; i < objects.size(); i++) { + if (!objects.get(i).modelPath.startsWith("@")) continue; + Vector3f v = findNearestVertexInSpatial(objNodes.get(i), pos, minDist); + if (v != null) { + float d = pos.distance(v); + if (d < minDist) { minDist = d; best = v; } + } + } + return best; + } + + private Vector3f findNearestVertexInSpatial(Spatial s, Vector3f pos, float radius) { + if (s instanceof Geometry geo) { + VertexBuffer vb = geo.getMesh().getBuffer(VertexBuffer.Type.Position); + if (vb == null) return null; + java.nio.FloatBuffer fb = (java.nio.FloatBuffer) vb.getData(); + int vertCount = fb.limit() / 3; + float best = radius; Vector3f bestV = null; + for (int i = 0; i < vertCount; i++) { + int off = i * 3; + Vector3f world = geo.localToWorld( + new Vector3f(fb.get(off), fb.get(off + 1), fb.get(off + 2)), null); + float d = pos.distance(world); + if (d < best) { best = d; bestV = world; } + } + return bestV; + } else if (s instanceof Node n) { + float best = radius; Vector3f bestV = null; + for (Spatial child : n.getChildren()) { + Vector3f v = findNearestVertexInSpatial(child, pos, best); + if (v != null) { + float d = pos.distance(v); + if (d < best) { best = d; bestV = v; } + } + } + return bestV; + } + return null; + } + + private void refreshSubOverlay() { + if (subSelGeom == null) return; + subOverlay.detachAllChildren(); + switch (input.objectSelectionMode) { + case SharedInput.SEL_MODE_EDGE -> { + Vector3f[] v = getEdgeWorldVerts(); + if (v != null) subOverlay.attachChild(buildEdgeCylinder(v[0], v[1])); + } + case SharedInput.SEL_MODE_VERTEX -> { + Vector3f vPos = getVertexWorldPos(); + if (vPos != null) subOverlay.attachChild(buildVertexSphere(vPos)); + } + default -> buildFaceOverlay(subSelGeom); + } + if (!subOverlay.getChildren().isEmpty()) + subOverlay.setCullHint(Spatial.CullHint.Inherit); + } + + private Vector3f toParentLocal(Vector3f worldDelta, Node parent) { + if (parent == null) return worldDelta.clone(); + Quaternion invRot = parent.getWorldRotation().inverse(); + Vector3f wScale = parent.getWorldScale(); + Vector3f local = invRot.mult(worldDelta); + local.x /= wScale.x; + local.y /= wScale.y; + local.z /= wScale.z; + return local; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java b/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java new file mode 100644 index 0000000..6b4bc31 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/SoundAreaState.java @@ -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 areas = new ArrayList<>(); + private final List areaGeos = new ArrayList<>(); + private int selectedIdx = -1; + + // polygon being placed + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private Geometry inProgGeo = null; + private Geometry lastPointMarker = null; + + private List 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 xs = toList(area.pointsX()); + List 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 xs, List 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 getPlacedAreas() { + return new ArrayList<>(areas); + } + + public void loadAreas(List 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 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 toList(float[] arr) { + List l = new ArrayList<>(arr.length); + for (float f : arr) l.add(f); + return l; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 2a83db6..48306c8 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -25,8 +25,14 @@ import com.jme3.texture.Texture; import com.jme3.texture.Texture2D; import com.jme3.util.BufferUtils; import com.jme3.util.SkyFactory; +import de.blight.common.EmitterIO; +import de.blight.common.LightIO; +import de.blight.common.MusicAreaIO; +import de.blight.common.SoundAreaIO; +import de.blight.common.WaterBodyIO; import de.blight.common.MapData; import de.blight.common.MapIO; +import de.blight.common.PlacedModelIO; import de.blight.editor.SharedInput; import de.blight.editor.tool.HeightTool; @@ -69,17 +75,30 @@ public class TerrainEditorState extends BaseAppState { private final SharedInput input; private TerrainQuad terrain; + private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten private Geometry brushIndicator; - private UpperLayerState upperLayerState; private PlacedObjectState placedObjectState; + private SceneObjectState sceneObjState; + private LightState lightState; + private EmitterState emitterState; + private WaterBodyState waterBodyState; + private SoundAreaState soundAreaState; + private MusicAreaState musicAreaState; private MapData loadedMapData; + private Node axesGizmo; - // ── Splatmap ───────────────────────────────────────────────────────────── - private byte[] splatR, splatG, splatB; + // ── Splatmap (Slots 1-4) ───────────────────────────────────────────────── + private byte[] splatR, splatG, splatB, splatA; private ByteBuffer splatBuf; private Image splatImage; private Texture2D splatTex; + // ── Zweite Splatmap (Slots 5-8, AlphaMap_1) ─────────────────────────────── + private byte[] upperSplatR, upperSplatG, upperSplatB, upperSplatA; + private ByteBuffer upperSplatBuf; + private Image upperSplatImage; + private Texture2D upperSplatTex; + // ── Kameraposition ──────────────────────────────────────────────────────── private static final Path EDITOR_PREFS = de.blight.editor.ProjectRoot.resolve("config", "editor.prefs"); private static final float DEFAULT_CAM_Y = 50f; @@ -95,6 +114,8 @@ public class TerrainEditorState extends BaseAppState { // ── Lifecycle ───────────────────────────────────────────────────────────── + private de.blight.game.state.DayNightState dayNightState; + @Override protected void initialize(Application app) { this.app = (SimpleApplication) app; @@ -112,6 +133,13 @@ public class TerrainEditorState extends BaseAppState { } } loadCameraPrefs(); + + // Tag/Nacht-Zyklus (kein Shadow-Renderer, Zeit steht still – nur per Konsolenbefehl steuerbar) + // Start bei Mittag (t=0.5): Sonne steht hoch → beleuchtete Materialien gut sichtbar + dayNightState = new de.blight.game.state.DayNightState(false); + dayNightState.getDayTime().setTimeOfDay(0.5f); + dayNightState.setPaused(true); + app.getStateManager().attach(dayNightState); } private void loadCameraPrefs() { @@ -139,6 +167,8 @@ public class TerrainEditorState extends BaseAppState { protected void onEnable() { buildScene(); applyCameraTransform(); + // JavaFX signalisieren dass Texturpfade jetzt bekannt sind + input.texturePathsLoaded = true; } @Override @@ -146,36 +176,90 @@ public class TerrainEditorState extends BaseAppState { rootNode.detachAllChildren(); } - @Override protected void cleanup(Application app) {} + @Override + protected void cleanup(Application app) { + if (dayNightState != null) app.getStateManager().detach(dayNightState); + } // ── Szene aufbauen ──────────────────────────────────────────────────────── private void buildScene() { - DirectionalLight sun = new DirectionalLight(); - sun.setDirection(new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal()); - sun.setColor(new ColorRGBA(1.2f, 1.1f, 0.9f, 1f)); - rootNode.addLight(sun); - - DirectionalLight topLight = new DirectionalLight(); - topLight.setDirection(Vector3f.UNIT_Y.negate()); - topLight.setColor(new ColorRGBA(0.8f, 0.8f, 0.8f, 1f)); - rootNode.addLight(topLight); - - AmbientLight ambient = new AmbientLight(new ColorRGBA(0.35f, 0.38f, 0.45f, 1f)); - rootNode.addLight(ambient); - terrain = buildTerrain(); + cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder rootNode.attachChild(terrain); - upperLayerState = new UpperLayerState(input, loadedMapData); - app.getStateManager().attach(upperLayerState); - placedObjectState = new PlacedObjectState(input, loadedMapData); placedObjectState.setTerrain(terrain); app.getStateManager().attach(placedObjectState); - SceneObjectState sceneObjState = app.getStateManager().getState(SceneObjectState.class); - if (sceneObjState != null) sceneObjState.setTerrain(terrain); + sceneObjState = app.getStateManager().getState(SceneObjectState.class); + if (sceneObjState != null) { + sceneObjState.setTerrain(terrain); + try { + var placed = PlacedModelIO.load(); + if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed); + } catch (IOException e) { + System.err.println("[TerrainEditor] Objekte nicht ladbar: " + e.getMessage()); + } + } + + lightState = app.getStateManager().getState(LightState.class); + if (lightState != null) { + lightState.setTerrain(terrain); + try { + var lights = LightIO.load(); + if (!lights.isEmpty()) lightState.loadPlacedLights(lights); + } catch (IOException e) { + System.err.println("[TerrainEditor] Lichter nicht ladbar: " + e.getMessage()); + } + } + + emitterState = app.getStateManager().getState(EmitterState.class); + if (emitterState != null) { + emitterState.setTerrain(terrain); + try { + var emitters = EmitterIO.load(); + if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters); + } catch (IOException e) { + System.err.println("[TerrainEditor] Emitter nicht ladbar: " + e.getMessage()); + } + } + + waterBodyState = app.getStateManager().getState(WaterBodyState.class); + if (waterBodyState != null) { + waterBodyState.setTerrain(terrain); + try { + var waters = WaterBodyIO.load(); + if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters); + } catch (IOException e) { + System.err.println("[TerrainEditor] Wasseroberflächen nicht ladbar: " + e.getMessage()); + } + } + + soundAreaState = app.getStateManager().getState(SoundAreaState.class); + if (soundAreaState != null) { + soundAreaState.setTerrain(terrain); + try { + var soundAreas = SoundAreaIO.load(); + if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas); + } catch (IOException e) { + System.err.println("[TerrainEditor] Sound-Bereiche nicht ladbar: " + e.getMessage()); + } + } + + musicAreaState = app.getStateManager().getState(MusicAreaState.class); + if (musicAreaState != null) { + musicAreaState.setTerrain(terrain); + try { + var musicAreas = MusicAreaIO.load(); + if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas); + } catch (IOException e) { + System.err.println("[TerrainEditor] Musik-Bereiche nicht ladbar: " + e.getMessage()); + } + } + + PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class); + if (playToolState != null) playToolState.setTerrain(terrain); rootNode.attachChild(buildWater()); rootNode.attachChild(buildGrid()); @@ -183,12 +267,8 @@ public class TerrainEditorState extends BaseAppState { brushIndicator = buildBrushIndicator(); rootNode.attachChild(brushIndicator); - try { - rootNode.attachChild(SkyFactory.createSky(assets, - "Textures/Sky/Bright/BrightSky.dds", SkyFactory.EnvMapType.CubeMap)); - } catch (Exception e) { - app.getViewPort().setBackgroundColor(new ColorRGBA(0.45f, 0.60f, 0.80f, 1f)); - } + axesGizmo = buildAxesGizmo(); + rootNode.attachChild(axesGizmo); } // ── Terrain ─────────────────────────────────────────────────────────────── @@ -196,7 +276,8 @@ public class TerrainEditorState extends BaseAppState { private TerrainQuad buildTerrain() { float[] heights; if (loadedMapData != null) { - heights = loadedMapData.terrainHeight; + heights = loadedMapData.terrainHeight.clone(); + mergeUpperHeights(heights, loadedMapData); } else { heights = new float[TOTAL_SIZE * TOTAL_SIZE]; Arrays.fill(heights, 1f); @@ -213,61 +294,171 @@ public class TerrainEditorState extends BaseAppState { return tq; } + private void mergeUpperHeights(float[] heights, MapData map) { + for (int tz = 0; tz < TOTAL_SIZE; tz++) { + for (int tx = 0; tx < TOTAL_SIZE; tx++) { + float gi = (float) tx / 8f; + float gj = (float) tz / 8f; + float upperH = bilinearSample(map.upperTop, MapData.UPPER_VERTS, gi, gj); + int idx = tz * TOTAL_SIZE + tx; + if (upperH > 0f && upperH > heights[idx]) heights[idx] = upperH; + } + } + } + + private static float bilinearSample(float[] arr, int stride, float x, float y) { + int x0 = Math.min((int) x, stride - 1); + int y0 = Math.min((int) y, stride - 1); + int x1 = Math.min(x0 + 1, stride - 1); + int y1 = Math.min(y0 + 1, stride - 1); + float fx = x - (int) x, fy = y - (int) y; + float h00 = arr[y0 * stride + x0], h10 = arr[y0 * stride + x1]; + float h01 = arr[y1 * stride + x0], h11 = arr[y1 * stride + x1]; + return h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy; + } + private void initSplatmap() { if (loadedMapData != null) { splatR = loadedMapData.splatR.clone(); splatG = loadedMapData.splatG.clone(); splatB = loadedMapData.splatB.clone(); - // Ältere Maps haben splatR noch auf 0 (altes falsches Mapping). - // Alpha.R=0 → tex1*0=schwarz. Wenn R überall Null, auf 255 (=Gras) initialisieren. + splatA = loadedMapData.splatA.clone(); + // Ältere Maps haben splatR noch auf 0 → auf 255 (=Gras) initialisieren. boolean rAllZero = true; for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } } if (rAllZero) Arrays.fill(splatR, (byte) 255); + // Gespeicherte Texturpfade in SharedInput übernehmen + System.arraycopy(loadedMapData.terrainTextures, 0, + input.terrainTexturePaths, 0, MapData.TEXTURE_SLOTS); + // Zweite Splatmap (Slots 5-8) + upperSplatR = loadedMapData.upperSplatR.clone(); + upperSplatG = loadedMapData.upperSplatG.clone(); + upperSplatB = loadedMapData.upperSplatB.clone(); + upperSplatA = loadedMapData.upperSplatA.clone(); + System.arraycopy(loadedMapData.upperTextures, 0, + input.upperTexturePaths, 0, MapData.TEXTURE_SLOTS); + // Alte Gebirge-Splatmap-Migration: R=255 überall war der Gebirge-Standard. + // Im neuen 1-Terrain-System bedeutet das: Slot-5-Textur deckt alles ab → auf 0 setzen. + boolean upperRAllMax = true; + for (byte b : upperSplatR) { if ((b & 0xFF) != 255) { upperRAllMax = false; break; } } + if (upperRAllMax) Arrays.fill(upperSplatR, (byte) 0); } else { splatR = new byte[SPLAT_SIZE * SPLAT_SIZE]; splatG = new byte[SPLAT_SIZE * SPLAT_SIZE]; splatB = new byte[SPLAT_SIZE * SPLAT_SIZE]; - Arrays.fill(splatR, (byte) 255); // R=1 → Tex1 (Gras) überall sichtbar + splatA = new byte[SPLAT_SIZE * SPLAT_SIZE]; + Arrays.fill(splatR, (byte) 255); + 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]; } splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4); for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) { - splatBuf.put(splatR[i]); - splatBuf.put(splatG[i]); - splatBuf.put(splatB[i]); - splatBuf.put((byte) 0); + splatBuf.put(splatR[i]).put(splatG[i]).put(splatB[i]).put(splatA[i]); } splatBuf.flip(); - splatImage = new Image(Image.Format.RGBA8, SPLAT_SIZE, SPLAT_SIZE, splatBuf); splatTex = new Texture2D(splatImage); splatTex.setWrap(Texture.WrapMode.EdgeClamp); splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); splatTex.setMagFilter(Texture.MagFilter.Bilinear); + + upperSplatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4); + for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) { + upperSplatBuf.put(upperSplatR[i]).put(upperSplatG[i]).put(upperSplatB[i]).put(upperSplatA[i]); + } + upperSplatBuf.flip(); + upperSplatImage = new Image(Image.Format.RGBA8, SPLAT_SIZE, SPLAT_SIZE, upperSplatBuf); + upperSplatTex = new Texture2D(upperSplatImage); + upperSplatTex.setWrap(Texture.WrapMode.EdgeClamp); + upperSplatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); + upperSplatTex.setMagFilter(Texture.MagFilter.Bilinear); } + // Standard-Texturpfade (Fallback wenn kein benutzerdefinierter Pfad gesetzt) + private static final String[] DEFAULT_TERRAIN_TEXTURES = { + "Textures/Terrain/splat/grass.jpg", + "Textures/Terrain/Rock2/rock.jpg", + "Textures/Terrain/splat/dirt.jpg", + "" // Slot 4: kein Standard → nur wenn konfiguriert + }; + private static final ColorRGBA[] DEFAULT_TERRAIN_COLORS = { + new ColorRGBA(0.28f, 0.58f, 0.18f, 1f), + new ColorRGBA(0.45f, 0.32f, 0.25f, 1f), + new ColorRGBA(0.55f, 0.45f, 0.30f, 1f), + new ColorRGBA(0.80f, 0.72f, 0.50f, 1f), + }; + private static final ColorRGBA[] DEFAULT_UPPER_COLORS = { + new ColorRGBA(0.45f, 0.32f, 0.25f, 1f), new ColorRGBA(0.18f, 0.12f, 0.08f, 1f), + new ColorRGBA(0.55f, 0.45f, 0.30f, 1f), new ColorRGBA(0.80f, 0.72f, 0.50f, 1f), + }; + private Material buildTerrainMaterial() { - Material mat = new Material(assets, "Common/MatDefs/Terrain/Terrain.j3md"); + Material mat = new Material(assets, "Common/MatDefs/Terrain/TerrainLighting.j3md"); + mat.setBoolean("useTriPlanarMapping", false); + mat.setFloat("Shininess", 0f); - // Terrain.j3md Shader: outColor = tex1*alpha.r → mix(tex2,g) → mix(tex3,b) - // d.h. Alpha.R = Tex1-Helligkeit (immer 1), Alpha.G = Tex2-Blend, Alpha.B = Tex3-Blend - Texture tex1 = loadOrFallback("Textures/Terrain/splat/grass.jpg", - new ColorRGBA(0.28f, 0.58f, 0.18f, 1f)); - Texture tex2 = loadOrFallback("Textures/Terrain/splat/road.jpg", - new ColorRGBA(0.55f, 0.50f, 0.40f, 1f)); - Texture tex3 = loadOrFallback("Textures/Terrain/splat/Gravel.jpg", - new ColorRGBA(0.45f, 0.35f, 0.25f, 1f)); + String[] paths = input.terrainTexturePaths; + String[] matParams = {"DiffuseMap", "DiffuseMap_1", "DiffuseMap_2", "DiffuseMap_3"}; + String[] scaleParams = {"DiffuseMap_0_scale", "DiffuseMap_1_scale", "DiffuseMap_2_scale", "DiffuseMap_3_scale"}; - for (Texture t : List.of(tex1, tex2, tex3)) { - t.setWrap(Texture.WrapMode.Repeat); + for (int i = 0; i < 4; i++) { + String path = (paths[i] != null && !paths[i].isEmpty()) ? paths[i] : DEFAULT_TERRAIN_TEXTURES[i]; + if (path == null || path.isEmpty()) continue; // Slot 4 ohne Textur überspringen + Texture tex = loadOrFallback(path, DEFAULT_TERRAIN_COLORS[i]); + tex.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(matParams[i], tex); + mat.setFloat(scaleParams[i], 512f); } - // Skalierung: 512 Kacheln über 4096 WE = 1 Kachel pro 8 WE (Zellgröße) - mat.setTexture("Tex1", tex1); mat.setFloat("Tex1Scale", 512f); - mat.setTexture("Tex2", tex2); mat.setFloat("Tex2Scale", 512f); - mat.setTexture("Tex3", tex3); mat.setFloat("Tex3Scale", 512f); - mat.setTexture("Alpha", splatTex); + String[] norms = input.terrainNormalMapPaths; + String[] nmParams = {"NormalMap", "NormalMap_1", "NormalMap_2", "NormalMap_3"}; + for (int i = 0; i < 4; i++) { + if (norms[i] != null && !norms[i].isEmpty()) { + try { + Texture n = assets.loadTexture(norms[i]); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(nmParams[i], n); + } catch (Exception e) { + mat.clearParam(nmParams[i]); + } + } else { + mat.clearParam(nmParams[i]); + } + } + mat.setTexture("AlphaMap", splatTex); + + // Zweite Splatmap → Slots 5-8 (AlphaMap_1), nur wenn Texturen konfiguriert + boolean hasUpperTex = false; + for (String s : input.upperTexturePaths) if (s != null && !s.isEmpty()) { hasUpperTex = true; break; } + if (upperSplatTex != null && hasUpperTex) { + mat.setTexture("AlphaMap_1", upperSplatTex); + String[] upperPaths = input.upperTexturePaths; + String[] upperNorms = input.upperNormalMapPaths; + String[] upperMatP = {"DiffuseMap_4","DiffuseMap_5","DiffuseMap_6","DiffuseMap_7"}; + String[] upperScaleP = {"DiffuseMap_4_scale","DiffuseMap_5_scale","DiffuseMap_6_scale","DiffuseMap_7_scale"}; + String[] upperNormP = {"NormalMap_4","NormalMap_5","NormalMap_6","NormalMap_7"}; + for (int i = 0; i < 4; i++) { + String path = upperPaths[i]; + if (path == null || path.isEmpty()) continue; + Texture tex = loadOrFallback(path, DEFAULT_UPPER_COLORS[i]); + tex.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(upperMatP[i], tex); + mat.setFloat(upperScaleP[i], 512f); + if (upperNorms[i] != null && !upperNorms[i].isEmpty()) { + try { + Texture n = assets.loadTexture(upperNorms[i]); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(upperNormP[i], n); + } catch (Exception e) { mat.clearParam(upperNormP[i]); } + } else { + mat.clearParam(upperNormP[i]); + } + } + } return mat; } @@ -289,14 +480,15 @@ public class TerrainEditorState extends BaseAppState { /** * Malt Textur-Gewichte auf die Splatmap. - * Shader-Mapping: Alpha.R=Tex1-Helligkeit (fest 1), Alpha.G=Tex2(Fels)-Blend, Alpha.B=Tex3(Erde)-Blend. - * @param textureIndex 0=Gras(Reset: G→0,B→0), 1=Fels(G→1,B→0), 2=Erde(G→0,B→1) + * Shader-Mapping: R=Tex1-Helligkeit (fest 1), G=Tex2-Blend, B=Tex3-Blend, A=Tex4-Blend. + * @param textureIndex 0=Slot1/Reset, 1=Slot2(G→1), 2=Slot3(B→1), 3=Slot4(A→1) */ private void applyTexturePaint(Vector3f contact, float strength, int textureIndex) { float radius = (float) input.textureTool.brushRadius.getValue(); int centerPX = Math.round((contact.x + WORLD_HALF) / SPLAT_WE_PER_PX); - int centerPZ = Math.round((contact.z + WORLD_HALF) / SPLAT_WE_PER_PX); + // JME3-Terrain UV: V=0 → worldZ=+2048, V=1 → worldZ=-2048 (Z-Achse gespiegelt) + int centerPZ = (SPLAT_SIZE - 1) - Math.round((contact.z + WORLD_HALF) / SPLAT_WE_PER_PX); int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX); boolean changed = false; @@ -312,29 +504,28 @@ public class TerrainEditorState extends BaseAppState { float t = distWE / radius; float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f; - float blend = strength * falloff; + // Slot 1 (textureIndex==0) = Zurücksetzen: volle Stärke damit ein Strich reicht + float blend = (textureIndex == 0) ? falloff : strength * falloff; int idx = pz * SPLAT_SIZE + px; - // R bleibt immer 1 (tex1*R = tex1); G und B sind unabhängige mix()-Faktoren float curG = (splatG[idx] & 0xFF) / 255f; float curB = (splatB[idx] & 0xFF) / 255f; + float curA = (splatA[idx] & 0xFF) / 255f; - // Zielwerte je Textur - float tgG = (textureIndex == 1) ? 1f : 0f; // Fels: G→1 - float tgB = (textureIndex == 2) ? 1f : 0f; // Erde: B→1 - // textureIndex==0 (Gras/Reset): beide Ziele 0 + float tgG = (textureIndex == 1) ? 1f : 0f; + float tgB = (textureIndex == 2) ? 1f : 0f; + float tgA = (textureIndex == 3) ? 1f : 0f; - float newG = curG + (tgG - curG) * blend; - float newB = curB + (tgB - curB) * blend; - - splatR[idx] = (byte) 255; // R immer voll - splatG[idx] = (byte) Math.round(newG * 255f); - splatB[idx] = (byte) Math.round(newB * 255f); + splatR[idx] = (byte) 255; + splatG[idx] = (byte) Math.round((curG + (tgG - curG) * blend) * 255f); + splatB[idx] = (byte) Math.round((curB + (tgB - curB) * blend) * 255f); + splatA[idx] = (byte) Math.round((curA + (tgA - curA) * blend) * 255f); int bi = idx * 4; splatBuf.put(bi, splatR[idx]); splatBuf.put(bi + 1, splatG[idx]); splatBuf.put(bi + 2, splatB[idx]); + splatBuf.put(bi + 3, splatA[idx]); changed = true; } } @@ -344,15 +535,93 @@ public class TerrainEditorState extends BaseAppState { } } + /** + * Malt Upper-Texturen (Slots 5-8) auf die zweite Splatmap. + * Jeder Kanal ist unabhängig: R=Slot5, G=Slot6, B=Slot7, A=Slot8. + * @param textureIndex 0=Slot5(R), 1=Slot6(G), 2=Slot7(B), 3=Slot8(A), -1=Löschen(alle→0) + */ + private void applyUpperTexturePaint(Vector3f contact, float strength, int textureIndex) { + float radius = (float) input.textureTool.brushRadius.getValue(); + + int centerPX = Math.round((contact.x + WORLD_HALF) / SPLAT_WE_PER_PX); + int centerPZ = (SPLAT_SIZE - 1) - Math.round((contact.z + WORLD_HALF) / SPLAT_WE_PER_PX); + int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX); + + boolean changed = false; + for (int dz = -pixR; dz <= pixR; dz++) { + int pz = centerPZ + dz; + if (pz < 0 || pz >= SPLAT_SIZE) continue; + for (int dx = -pixR; dx <= pixR; dx++) { + int px = centerPX + dx; + if (px < 0 || px >= SPLAT_SIZE) continue; + + float distWE = FastMath.sqrt(dx * dx + dz * dz) * SPLAT_WE_PER_PX; + if (distWE >= radius) continue; + + float t = distWE / radius; + float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f; + int idx = pz * SPLAT_SIZE + px; + + if (textureIndex < 0) { + // Rechtsklick: Upper-Texturen löschen + float curR = (upperSplatR[idx] & 0xFF) / 255f; + float curG = (upperSplatG[idx] & 0xFF) / 255f; + float curB = (upperSplatB[idx] & 0xFF) / 255f; + float curA = (upperSplatA[idx] & 0xFF) / 255f; + upperSplatR[idx] = (byte) Math.round(curR * (1f - falloff) * 255f); + upperSplatG[idx] = (byte) Math.round(curG * (1f - falloff) * 255f); + upperSplatB[idx] = (byte) Math.round(curB * (1f - falloff) * 255f); + upperSplatA[idx] = (byte) Math.round(curA * (1f - falloff) * 255f); + } else { + // Linksklick: Ziel-Kanal erhöhen, andere auf 0 ziehen + float blend = strength * falloff; + float curR = (upperSplatR[idx] & 0xFF) / 255f; + float curG = (upperSplatG[idx] & 0xFF) / 255f; + float curB = (upperSplatB[idx] & 0xFF) / 255f; + float curA = (upperSplatA[idx] & 0xFF) / 255f; + float tgR = (textureIndex == 0) ? 1f : 0f; + float tgG = (textureIndex == 1) ? 1f : 0f; + float tgB = (textureIndex == 2) ? 1f : 0f; + float tgA = (textureIndex == 3) ? 1f : 0f; + upperSplatR[idx] = (byte) Math.round((curR + (tgR - curR) * blend) * 255f); + upperSplatG[idx] = (byte) Math.round((curG + (tgG - curG) * blend) * 255f); + upperSplatB[idx] = (byte) Math.round((curB + (tgB - curB) * blend) * 255f); + upperSplatA[idx] = (byte) Math.round((curA + (tgA - curA) * blend) * 255f); + } + + int bi = idx * 4; + upperSplatBuf.put(bi, upperSplatR[idx]); + upperSplatBuf.put(bi + 1, upperSplatG[idx]); + upperSplatBuf.put(bi + 2, upperSplatB[idx]); + upperSplatBuf.put(bi + 3, upperSplatA[idx]); + changed = true; + } + } + if (changed) { + upperSplatBuf.rewind(); + upperSplatImage.setUpdateNeeded(); + } + } + // ── Update-Schleife ─────────────────────────────────────────────────────── @Override public void update(float tpf) { updateCamera(tpf); processEdits(); - processUpperLayerEdits(); processTextureEdits(); updateBrushIndicator(); + updateAxesGizmo(); + + // Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert + if (input.terrainTexturesChanged || input.terrainNormalMapsChanged + || input.upperTexturesChanged || input.upperNormalMapsChanged) { + input.terrainTexturesChanged = false; + input.terrainNormalMapsChanged = false; + input.upperTexturesChanged = false; + input.upperNormalMapsChanged = false; + terrain.setMaterial(buildTerrainMaterial()); + } if (input.saveRequested) { input.saveRequested = false; @@ -377,12 +646,23 @@ public class TerrainEditorState extends BaseAppState { CollisionResults hits = new CollisionResults(); terrain.collideWith(ray, hits); if (hits.size() == 0) continue; - Vector3f contact = hits.getClosestCollision().getContactPoint(); - int texIdx = (edit.action() > 0) - ? input.textureTool.textureIndex.getSelectedIndex() - : 0; // Rechtsklick = Gras (zurücksetzen) - applyTexturePaint(contact, (float) input.textureTool.brushStrength.getValue(), texIdx); + + int selIdx = input.textureTool.textureIndex.getSelectedIndex(); + float str = (float) input.textureTool.brushStrength.getValue(); + + if (edit.action() > 0) { + // Linksklick: Slot malen + if (selIdx >= 4) { + applyUpperTexturePaint(contact, str, selIdx - 4); // 0=R(Slot5),1=G(Slot6),... + } else { + applyTexturePaint(contact, str, selIdx); + } + } else { + // Rechtsklick: immer auf Basis (Slot 1) zurücksetzen + applyTexturePaint(contact, str, 0); // Base → Slot 1 (Gras) + applyUpperTexturePaint(contact, str, -1); // Upper-Kanäle → 0 + } } } @@ -399,25 +679,26 @@ public class TerrainEditorState extends BaseAppState { try { MapData data = new MapData(); - float[] hmap = terrain.getHeightMap(); - if (hmap != null) { - System.arraycopy(hmap, 0, data.terrainHeight, 0, - Math.min(hmap.length, data.terrainHeight.length)); - } - - if (upperLayerState != null) { - UpperLayerData ud = upperLayerState.data; - System.arraycopy(ud.topHeight, 0, data.upperTop, 0, data.upperTop.length); - System.arraycopy(ud.bottomHeight, 0, data.upperBottom, 0, data.upperBottom.length); - for (int i = 0; i < data.upperHole.length; i++) { - data.upperHole[i] = ud.hole[i] ? (byte) 1 : (byte) 0; - } + if (cachedHeightMap != null) { + System.arraycopy(cachedHeightMap, 0, data.terrainHeight, 0, + Math.min(cachedHeightMap.length, data.terrainHeight.length)); } if (splatR != null) { System.arraycopy(splatR, 0, data.splatR, 0, data.splatR.length); System.arraycopy(splatG, 0, data.splatG, 0, data.splatG.length); System.arraycopy(splatB, 0, data.splatB, 0, data.splatB.length); + System.arraycopy(splatA, 0, data.splatA, 0, data.splatA.length); + System.arraycopy(input.terrainTexturePaths, 0, + data.terrainTextures, 0, MapData.TEXTURE_SLOTS); + } + if (upperSplatR != null) { + System.arraycopy(upperSplatR, 0, data.upperSplatR, 0, data.upperSplatR.length); + System.arraycopy(upperSplatG, 0, data.upperSplatG, 0, data.upperSplatG.length); + System.arraycopy(upperSplatB, 0, data.upperSplatB, 0, data.upperSplatB.length); + System.arraycopy(upperSplatA, 0, data.upperSplatA, 0, data.upperSplatA.length); + System.arraycopy(input.upperTexturePaths, 0, + data.upperTextures, 0, MapData.TEXTURE_SLOTS); } if (placedObjectState != null) { @@ -426,6 +707,24 @@ public class TerrainEditorState extends BaseAppState { } MapIO.save(data); + if (sceneObjState != null) { + PlacedModelIO.save(sceneObjState.getPlacedModels()); + } + if (lightState != null) { + LightIO.save(lightState.getPlacedLights()); + } + if (emitterState != null) { + EmitterIO.save(emitterState.getPlacedEmitters()); + } + if (waterBodyState != null) { + WaterBodyIO.save(waterBodyState.getPlacedBodies()); + } + if (soundAreaState != null) { + SoundAreaIO.save(soundAreaState.getPlacedAreas()); + } + if (musicAreaState != null) { + MusicAreaIO.save(musicAreaState.getPlacedAreas()); + } input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath(); System.out.println("[TerrainEditor] " + input.saveStatusMsg); } catch (IOException e) { @@ -437,12 +736,20 @@ public class TerrainEditorState extends BaseAppState { // ── Brush-Indikator ─────────────────────────────────────────────────────── private void updateBrushIndicator() { - float mx = input.mouseScreenX; - float my = input.mouseScreenY; - if (mx < 0) { + float mx = input.mouseScreenX; + float my = input.mouseScreenY; + int layer = input.activeLayer; + + // Kein Marker beim Objekt-Platzieren/-Bearbeiten, Licht-, Emitter- und Wasser-Modus + if (layer == SharedInput.LAYER_OBJECTS || layer == SharedInput.LAYER_OBJECTS_EDIT + || layer == SharedInput.LAYER_LIGHTS || layer == SharedInput.LAYER_EMITTERS + || layer == SharedInput.LAYER_WATER + || layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_MUSIC_AREAS + || layer == SharedInput.LAYER_PLAY_TOOL || mx < 0) { brushIndicator.setCullHint(Spatial.CullHint.Always); return; } + float jmeX = mx * (float) input.viewportScaleX; float jmeY = cam.getHeight() - my * (float) input.viewportScaleY; Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); @@ -452,7 +759,6 @@ public class TerrainEditorState extends BaseAppState { Vector3f contactPoint = null; float brushRadius = 0f; - int layer = input.activeLayer; if (layer == 0 || layer == 4) { CollisionResults hits = new CollisionResults(); terrain.collideWith(ray, hits); @@ -462,22 +768,13 @@ public class TerrainEditorState extends BaseAppState { ? (float) input.heightTool.brushRadius.getValue() : (float) input.textureTool.brushRadius.getValue(); } - } else { + } else if (layer == 3) { CollisionResults hits = new CollisionResults(); - if (upperLayerState != null && input.upperLayerVisible) { - upperLayerState.getUpperNode().collideWith(ray, hits); - } + terrain.collideWith(ray, hits); if (hits.size() > 0) { contactPoint = hits.getClosestCollision().getContactPoint(); - } else { - CollisionResults terrainHits = new CollisionResults(); - terrain.collideWith(ray, terrainHits); - if (terrainHits.size() > 0) - contactPoint = terrainHits.getClosestCollision().getContactPoint(); + brushRadius = (float) input.grassTool.brushRadius.getValue(); } - brushRadius = (layer == 1) - ? (float) input.upperHeightTool.brushRadius.getValue() - : (float) input.holeTool.brushRadius.getValue(); } if (contactPoint != null) { @@ -489,34 +786,17 @@ public class TerrainEditorState extends BaseAppState { } } - // ── Upper-Layer-Edits ───────────────────────────────────────────────────── + // ── Heightmap-Cache ─────────────────────────────────────────────────────── - private void processUpperLayerEdits() { - if (upperLayerState == null) return; - SharedInput.UpperLayerEdit edit; - int processed = 0; - while ((edit = input.upperLayerEditQueue.poll()) != null && processed < MAX_EDITS_PER_FRAME) { - processed++; - float jmeX = (float)(edit.screenX() * input.viewportScaleX); - float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY); - - Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); - Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); - com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal()); - - CollisionResults hits = new CollisionResults(); - if (input.upperLayerVisible) { - upperLayerState.getUpperNode().collideWith(ray, hits); - } - if (hits.size() == 0) terrain.collideWith(ray, hits); - if (hits.size() == 0) continue; - - Vector3f contact = hits.getClosestCollision().getContactPoint(); - if (input.activeLayer == 1) { - upperLayerState.applyHeightEdit(contact.x, contact.z, edit.action()); - } else if (input.activeLayer == 2) { - upperLayerState.applyHoleEdit(contact.x, contact.z); - } + /** Überträgt dieselben Deltas auf den Cache, die gerade per adjustHeight ans Terrain geschickt wurden. */ + private void syncHeightCache(List locs, List deltas) { + if (cachedHeightMap == null) return; + for (int i = 0; i < locs.size(); i++) { + Vector2f loc = locs.get(i); + int vx = Math.round(loc.x + TERRAIN_SIZE * 0.5f); + int vz = Math.round(loc.y + TERRAIN_SIZE * 0.5f); + if (vx >= 0 && vx < TOTAL_SIZE && vz >= 0 && vz < TOTAL_SIZE) + cachedHeightMap[vz * TOTAL_SIZE + vx] += deltas.get(i); } } @@ -542,6 +822,18 @@ public class TerrainEditorState extends BaseAppState { int mode = input.heightTool.mode.getSelectedIndex(); if (mode == HeightTool.MODE_SMOOTH) { smoothHeight(contact); + } else if (mode == HeightTool.MODE_PLATEAU) { + if (edit.action() < 0) { + // Rechtsklick: Terrain-Höhe sampeln und als Plateau-Ziel übernehmen + float h = sampleTerrainHeight(contact); + if (Float.isFinite(h)) { + input.heightTool.plateauHeight.setValue(h); + input.heightTool.plateauHeightChanged = true; + } + } else { + // Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen + flattenToPlateauHeight(contact); + } } else { float delta = (float) input.heightTool.brushStrength.getValue() * edit.action(); modifyHeight(contact, delta, mode); @@ -596,8 +888,9 @@ public class TerrainEditorState extends BaseAppState { if (!locs.isEmpty()) { terrain.adjustHeight(locs, deltas); - if (upperLayerState != null) upperLayerState.adjustHeightsWithTerrain(locs, deltas); + syncHeightCache(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); + if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); } } @@ -608,48 +901,110 @@ public class TerrainEditorState extends BaseAppState { int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f); int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f); - float sum = 0f; int count = 0; - for (int dz = -r; dz <= r; dz++) { - for (int dx = -r; dx <= r; dx++) { - int vx = cx + dx, vz = cz + dz; - if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue; - if (FastMath.sqrt(dx * dx + dz * dz) >= radius) continue; - sum += terrain.getHeightmapHeight( - new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f)); - count++; - } - } - if (count == 0) return; - float avg = sum / count; + if (cachedHeightMap == null) return; + float[] hmap = cachedHeightMap; + + List locs = new ArrayList<>(); + List heights = new ArrayList<>(); + List dists = new ArrayList<>(); - List locs = new ArrayList<>(); - List newHts = new ArrayList<>(); - List deltas = new ArrayList<>(); for (int dz = -r; dz <= r; dz++) { for (int dx = -r; dx <= r; dx++) { int vx = cx + dx, vz = cz + dz; if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue; float dist = FastMath.sqrt(dx * dx + dz * dz); if (dist >= radius) continue; - float falloff = 1f - dist / radius; - float wx = vx - TERRAIN_SIZE * 0.5f; - float wz = vz - TERRAIN_SIZE * 0.5f; - float curH = terrain.getHeightmapHeight(new Vector2f(wx, wz)); - float newH = curH + (avg - curH) * falloff * strength * 3f; - locs.add(new Vector2f(wx, wz)); - newHts.add(newH); - deltas.add(newH - curH); + int idx = vz * TOTAL_SIZE + vx; + float h = hmap[idx]; + if (!Float.isFinite(h)) continue; + locs.add(new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f)); + heights.add(h); + dists.add(dist); } } + if (locs.isEmpty()) return; + + // Average of all heights in the brush area + float sum = 0f; + for (float h : heights) sum += h; + float avg = sum / heights.size(); + + // Move each vertex toward the average; use adjustHeight (same API as raise/lower) + List deltas = new ArrayList<>(); + for (int i = 0; i < locs.size(); i++) { + float falloff = 1f - dists.get(i) / radius; + float blend = FastMath.clamp(falloff * (strength / 50f), 0f, 1f); + deltas.add((avg - heights.get(i)) * blend); + } + + terrain.adjustHeight(locs, deltas); + syncHeightCache(locs, deltas); + if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); + if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); + } + + /** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */ + private float sampleTerrainHeight(Vector3f worldContact) { + if (cachedHeightMap == null) return Float.NaN; + int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f); + int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f); + if (cx < 0 || cx >= TOTAL_SIZE || cz < 0 || cz >= TOTAL_SIZE) return Float.NaN; + return cachedHeightMap[cz * TOTAL_SIZE + cx]; + } + + /** Gleicht alle Vertices im Pinselradius schrittweise an die Plateau-Zielhöhe an. */ + private void flattenToPlateauHeight(Vector3f worldContact) { + float radius = (float) input.heightTool.brushRadius.getValue(); + float strength = (float) input.heightTool.brushStrength.getValue(); + float target = (float) input.heightTool.plateauHeight.getValue(); + int r = (int) Math.ceil(radius); + int cx = Math.round(worldContact.x + TERRAIN_SIZE * 0.5f); + int cz = Math.round(worldContact.z + TERRAIN_SIZE * 0.5f); + + if (cachedHeightMap == null) return; + + List locs = new ArrayList<>(); + List deltas = new ArrayList<>(); + + for (int dz = -r; dz <= r; dz++) { + for (int dx = -r; dx <= r; dx++) { + int vx = cx + dx, vz = cz + dz; + if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue; + float dist = FastMath.sqrt(dx * dx + dz * dz); + if (dist >= radius) continue; + + float curH = cachedHeightMap[vz * TOTAL_SIZE + vx]; + if (!Float.isFinite(curH)) continue; + + // Plateau-Falloff: flach im Zentrum, weiche Kante außen + float t = dist / radius; + float edge = 0.85f; + float falloff = (t < edge) ? 1f + : 1f - FastMath.pow((t - edge) / (1f - edge), 2f); + + float blend = FastMath.clamp(falloff * (strength / 50f), 0f, 1f); + locs.add(new Vector2f(vx - TERRAIN_SIZE * 0.5f, vz - TERRAIN_SIZE * 0.5f)); + deltas.add((target - curH) * blend); + } + } + if (!locs.isEmpty()) { - terrain.setHeight(locs, newHts); - if (upperLayerState != null) upperLayerState.adjustHeightsWithTerrain(locs, deltas); + terrain.adjustHeight(locs, deltas); + syncHeightCache(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); + if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); } } // ── Kamera ──────────────────────────────────────────────────────────────── + /** Abstand der Kamera zur Terrain-Oberfläche senkrecht nach unten. */ + private float terrainDistBelow() { + if (terrain == null) return CAM_SPEED; + Float h = terrain.getHeight(new Vector2f(camPos.x, camPos.z)); + return h != null ? Math.max(1f, camPos.y - h) : CAM_SPEED; + } + private void updateCamera(float tpf) { int[] delta = input.consumeMouseDelta(); if (delta[0] != 0 || delta[1] != 0) { @@ -662,9 +1017,14 @@ public class TerrainEditorState extends BaseAppState { applyCameraTransform(); - float speed = CAM_SPEED * tpf; - if (input.forward) camPos.addLocal(cam.getDirection().mult(speed)); - if (input.backward) camPos.addLocal(cam.getDirection().mult(-speed)); + // Geschwindigkeit skaliert linear mit Abstand zum Terrain (näher = langsamer) + float terrainDist = terrainDistBelow(); + float speed = FastMath.clamp(terrainDist, 5f, CAM_SPEED) * tpf; + + float hFwdX = -FastMath.sin(camYaw); + float hFwdZ = -FastMath.cos(camYaw); + if (input.forward) { camPos.x -= hFwdX * speed; camPos.z -= hFwdZ * speed; } + if (input.backward) { camPos.x += hFwdX * speed; camPos.z += hFwdZ * speed; } Vector3f lft = cam.getLeft().clone().setY(0); if (lft.lengthSquared() > 0.001f) lft.normalizeLocal(); @@ -674,6 +1034,10 @@ public class TerrainEditorState extends BaseAppState { if (input.up) orbitAroundTerrain( ORBIT_SPEED * tpf); if (input.down) orbitAroundTerrain(-ORBIT_SPEED * tpf); + int scroll = input.scrollAccum.getAndSet(0); + if (scroll != 0) + camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f)); + cam.setLocation(camPos); } @@ -763,6 +1127,73 @@ public class TerrainEditorState extends BaseAppState { return geo; } + // ── Achsen-Gizmo (untere linke Ecke) ───────────────────────────────────── + + private Node buildAxesGizmo() { + Node n = new Node("axesGizmo"); + n.attachChild(makeGizmoShaft(ColorRGBA.Red, 1f, 0f, 0f, "X")); + n.attachChild(makeGizmoShaft(ColorRGBA.Green, 0f, 1f, 0f, "Y")); + n.attachChild(makeGizmoShaft(ColorRGBA.Blue, 0f, 0f, 1f, "Z")); + n.attachChild(makeGizmoTip(ColorRGBA.Red, 1f, 0f, 0f)); + n.attachChild(makeGizmoTip(ColorRGBA.Green, 0f, 1f, 0f)); + n.attachChild(makeGizmoTip(ColorRGBA.Blue, 0f, 0f, 1f)); + addGizmoLabel(n, new Vector3f(1.20f, 0.00f, 0.00f), "X", ColorRGBA.Red); + addGizmoLabel(n, new Vector3f(0.00f, 1.20f, 0.00f), "Y", ColorRGBA.Green); + addGizmoLabel(n, new Vector3f(0.00f, 0.00f, 1.20f), "Z", ColorRGBA.Blue); + return n; + } + + private void updateAxesGizmo() { + if (axesGizmo == null) return; + float dist = cam.getFrustumNear() * 6f + 1f; + Vector3f pos = cam.getLocation() + .add(cam.getDirection().mult(dist)) + .add(cam.getLeft().mult(dist * 0.65f)) + .add(cam.getUp().mult(-dist * 0.50f)); + axesGizmo.setLocalTranslation(pos); + axesGizmo.setLocalScale(dist * 0.09f); + } + + private Geometry makeGizmoShaft(ColorRGBA color, float ex, float ey, float ez, String name) { + float hw = 0.04f; + 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("gizmo-" + name, new com.jme3.scene.shape.Box(hx, hy, hz)); + g.setLocalTranslation(ex / 2f, ey / 2f, ez / 2f); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", color); + mat.getAdditionalRenderState().setDepthTest(false); + mat.getAdditionalRenderState().setDepthWrite(false); + g.setMaterial(mat); + g.setQueueBucket(RenderQueue.Bucket.Transparent); + return g; + } + + private Geometry makeGizmoTip(ColorRGBA color, float tx, float ty, float tz) { + Geometry g = new Geometry("gizmo-tip", new com.jme3.scene.shape.Box(0.09f, 0.09f, 0.09f)); + g.setLocalTranslation(tx, ty, tz); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", color); + mat.getAdditionalRenderState().setDepthTest(false); + mat.getAdditionalRenderState().setDepthWrite(false); + g.setMaterial(mat); + g.setQueueBucket(RenderQueue.Bucket.Transparent); + return g; + } + + private void addGizmoLabel(Node parent, Vector3f pos, String text, ColorRGBA color) { + try { + com.jme3.font.BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt"); + com.jme3.font.BitmapText label = new com.jme3.font.BitmapText(font, false); + label.setSize(0.40f); + label.setColor(color); + label.setText(text); + label.setLocalTranslation(pos); + parent.attachChild(label); + } catch (Exception ignored) {} + } + private Geometry buildBrushIndicator() { int segments = 48; FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3); diff --git a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java index 7892d5d..7cede4f 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java @@ -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()); } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java new file mode 100644 index 0000000..e6a407f --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java @@ -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 bodies = new ArrayList<>(); + private final List markers = new ArrayList<>(); + + private int selectedIdx = -1; + private List 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 getPlacedBodies() { + return new ArrayList<>(bodies); + } + + public void loadPlacedBodies(List loaded) { + if (rootNode == null) { + pendingBodies = new ArrayList<>(loaded); + return; + } + clearAll(); + for (PlacedWater b : loaded) addBody(b); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/tool/HeightTool.java b/blight-editor/src/main/java/de/blight/editor/tool/HeightTool.java index 25dfbcc..18d5b85 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/HeightTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/HeightTool.java @@ -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"; } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java new file mode 100644 index 0000000..41da28f --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/MaterialChooser.java @@ -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 { + + private static final List 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 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 custom = new ArrayList<>(); + try (Stream 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 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); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java b/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java new file mode 100644 index 0000000..547f346 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/TextureChooser.java @@ -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: + *
+ *   new TextureChooser(assetRoot, true)
+ *       .showAndWait()
+ *       .ifPresent(path -> { ... });
+ * 
+ */ +public class TextureChooser extends Dialog { + + 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 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 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 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 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) + "…"; + } +} diff --git a/blight-editor/src/main/resources/editor.css b/blight-editor/src/main/resources/editor.css new file mode 100644 index 0000000..71e606a --- /dev/null +++ b/blight-editor/src/main/resources/editor.css @@ -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; +} diff --git a/blight-editor/src/main/resources/logback.xml b/blight-editor/src/main/resources/logback.xml new file mode 100644 index 0000000..3f0bc75 --- /dev/null +++ b/blight-editor/src/main/resources/logback.xml @@ -0,0 +1,30 @@ + + + + + %d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex + + + + + logs/blight-editor.log + + logs/blight-editor.%d{yyyy-MM-dd}.log + 7 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex + + + + + + + + + + + + + + diff --git a/blight-game/build.gradle b/blight-game/build.gradle index e63f868..5dcc95f 100644 --- a/blight-game/build.gradle +++ b/blight-game/build.gradle @@ -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) { diff --git a/blight-game/src/main/java/de/blight/game/BlightGame.java b/blight-game/src/main/java/de/blight/game/BlightGame.java index 3a50592..82af370 100644 --- a/blight-game/src/main/java/de/blight/game/BlightGame.java +++ b/blight-game/src/main/java/de/blight/game/BlightGame.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java new file mode 100644 index 0000000..ec5c4e9 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java @@ -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 .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 clips = new ArrayList<>(); + private Map actionMap = new LinkedHashMap<>(); + + public List getClips() { return clips; } + public void setClips(List clips) { this.clips = clips; } + public Map getActionMap() { return actionMap; } + public void setActionMap(Map actionMap) { this.actionMap = actionMap; } + + /** Speichert dieses Set als {@code .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); + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java new file mode 100644 index 0000000..4a5d7ec --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -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"; + }; + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java new file mode 100644 index 0000000..c71942a --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java @@ -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 clips = new LinkedHashMap<>(); + /** clip key → armature the clip was loaded from */ + private final Map 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 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 namesA = a.getJointList().stream().map(Joint::getName).collect(Collectors.toSet()); + Set 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationMap.java b/blight-game/src/main/java/de/blight/game/animation/AnimationMap.java new file mode 100644 index 0000000..afe0cc5 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationMap.java @@ -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 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 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 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>(){}.getType(); + Map 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"); + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/BoneNameMapping.java b/blight-game/src/main/java/de/blight/game/animation/BoneNameMapping.java new file mode 100644 index 0000000..371f2c5 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/BoneNameMapping.java @@ -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 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 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 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 buildMapping(Armature source, Armature target) { + Map result = new HashMap<>(); + + Map 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(); + } +} diff --git a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java new file mode 100644 index 0000000..ab2efd7 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java @@ -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 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 corrections) { + Map 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 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 srcBindMS = buildModelSpaceBind(sourceArmature); + Map dstBindMS = buildModelSpaceBind(targetArmature); + + // ── Source animated model-space per frame ───────────────────────────── + Map 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 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 effectiveCorrections = new HashMap<>(corrections); + + // ── Allocate result arrays for mapped dst joints ────────────────────── + Map 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 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> 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 dstToSrc, + Map srcAnimMS, + Map srcBindMS, + Map dstBindMS, + Map 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 buildModelSpaceBind(Armature arm) { + Map cache = new HashMap<>(); + for (Joint j : arm.getJointList()) + buildModelSpaceBindRec(j, cache); + return cache; + } + + private static Quaternion buildModelSpaceBindRec(Joint j, Map 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 tracks, + int numFrames, + Map 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 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> 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 findControl(Spatial s, Class 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java new file mode 100644 index 0000000..f26a428 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java @@ -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> commands = new LinkedHashMap<>(); + private Consumer onVisibilityChanged; + + // ── Zustand ─────────────────────────────────────────────────────────────── + private boolean open = false; + private StringBuilder inputBuf = new StringBuilder(); + private final Deque 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 handler) { + commands.put(name.toLowerCase(), handler); + } + + /** Callback: wird beim Öffnen (true) und Schließen (false) der Konsole aufgerufen. */ + public void setOnVisibilityChanged(Consumer 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 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())); + } +} diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index ff48f27..7c3ecc0 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/state/AmbientSoundSystem.java b/blight-game/src/main/java/de/blight/game/state/AmbientSoundSystem.java new file mode 100644 index 0000000..29103af --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/AmbientSoundSystem.java @@ -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 data = new ArrayList<>(); + private final List sounds = new ArrayList<>(); + private final List 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/CloudsNode.java b/blight-game/src/main/java/de/blight/game/state/CloudsNode.java new file mode 100644 index 0000000..07a51b3 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/CloudsNode.java @@ -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]); + } + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/DayNightState.java b/blight-game/src/main/java/de/blight/game/state/DayNightState.java new file mode 100644 index 0000000..8dc88e9 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/DayNightState.java @@ -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); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/EmitterSystem.java b/blight-game/src/main/java/de/blight/game/state/EmitterSystem.java new file mode 100644 index 0000000..0bf85e8 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/EmitterSystem.java @@ -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 data = new ArrayList<>(); + private final List effects = new ArrayList<>(); + private final List 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; + } + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/MusicSystem.java b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java new file mode 100644 index 0000000..4e11274 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java @@ -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 data = new ArrayList<>(); + // three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null + private final List tracks = new ArrayList<>(); + private final List 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WeatherState.java b/blight-game/src/main/java/de/blight/game/state/WeatherState.java new file mode 100644 index 0000000..4fba0ff --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WeatherState.java @@ -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); + } +} diff --git a/blight-game/src/main/resources/logback.xml b/blight-game/src/main/resources/logback.xml new file mode 100644 index 0000000..d3b7c42 --- /dev/null +++ b/blight-game/src/main/resources/logback.xml @@ -0,0 +1,30 @@ + + + + + %d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex + + + + + logs/blight-game.log + + logs/blight-game.%d{yyyy-MM-dd}.log + 7 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex + + + + + + + + + + + + + + diff --git a/blight-lang/build.gradle b/blight-lang/build.gradle new file mode 100644 index 0000000..b4d04f7 --- /dev/null +++ b/blight-lang/build.gradle @@ -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' +} diff --git a/blight-lang/src/main/java/de/blight/lang/TextResolver.java b/blight-lang/src/main/java/de/blight/lang/TextResolver.java new file mode 100644 index 0000000..6967ebd --- /dev/null +++ b/blight-lang/src/main/java/de/blight/lang/TextResolver.java @@ -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 + "]"; + } + } + } +} diff --git a/blight-lang/src/main/resources/lang/messages_de.properties b/blight-lang/src/main/resources/lang/messages_de.properties new file mode 100644 index 0000000..f849048 --- /dev/null +++ b/blight-lang/src/main/resources/lang/messages_de.properties @@ -0,0 +1,18 @@ +# ── Items ──────────────────────────────────────────────────────────────────── +item.driftwood.name=Treibholz +item.driftwood.description=Ein ans Ufer gespültes Stück Holz. Könnte nützlich sein. + +item.rope.name=Seil +item.rope.description=Ein ausgefranstes Stück Seil. Hält noch. + +item.knife.name=Messer +item.knife.description=Eine einfache, aber scharfe Klinge. + +# ── Quests ─────────────────────────────────────────────────────────────────── +quest.intro.title=An Land gespült +quest.intro.description=Du hast den Schiffbruch überlebt. Finde Unterschlupf bevor die Nacht anbricht. +quest.intro.success=Du hast einen Unterschlupf gefunden. Ruh dich aus — morgen bringt neue Gefahren. + +# ── Dialog ─────────────────────────────────────────────────────────────────── +dialog.fisherman.greet.hero=Hallo? Ist da jemand? +dialog.fisherman.greet.npc=Hier drüben! Dem Himmel sei Dank, du lebst noch. diff --git a/blight-lang/src/main/resources/lang/messages_en.properties b/blight-lang/src/main/resources/lang/messages_en.properties new file mode 100644 index 0000000..d44e260 --- /dev/null +++ b/blight-lang/src/main/resources/lang/messages_en.properties @@ -0,0 +1,18 @@ +# ── Items ──────────────────────────────────────────────────────────────────── +item.driftwood.name=Driftwood +item.driftwood.description=A piece of wood washed ashore. Could be useful. + +item.rope.name=Rope +item.rope.description=A frayed piece of rope. Still holds. + +item.knife.name=Knife +item.knife.description=A simple but sharp blade. + +# ── Quests ─────────────────────────────────────────────────────────────────── +quest.intro.title=Washed Ashore +quest.intro.description=You survived the shipwreck. Find shelter before nightfall. +quest.intro.success=You have found shelter. Rest now — tomorrow brings new dangers. + +# ── Dialog ─────────────────────────────────────────────────────────────────── +dialog.fisherman.greet.hero=Hello? Is someone there? +dialog.fisherman.greet.npc=Over here! Thank the gods you are alive. diff --git a/blight-map/src/main/map/blight_emitters.bpe b/blight-map/src/main/map/blight_emitters.bpe new file mode 100644 index 0000000..0bfe7c2 --- /dev/null +++ b/blight-map/src/main/map/blight_emitters.bpe @@ -0,0 +1,3 @@ +# x y z activationRadius texturePath imagesX imagesY startR startG startB startA endR endG endB endA startSize endSize velX velY velZ velVariation gravX gravY gravZ lowLife highLife maxParticles emitRate +-350.58115 165.22334 72.31232 20.00000 Effects/Explosion/flame.png 2 2 1.0000 0.7000 0.1000 1.0000 1.0000 0.1000 0.0000 0.0000 0.5000 1.2000 0.0000 2.5000 0.0000 0.5000 0.0000 -0.1000 0.0000 1.0000 3.0000 60 25.0000 +-67.57600 3.71900 -36.32000 20.00000 Effects/Explosion/flame.png 2 2 1.0000 0.7000 0.1000 1.0000 1.0000 0.1000 0.0000 0.0000 0.5000 1.2000 0.0000 2.5000 0.0000 0.5000 0.0000 -0.1000 0.0000 1.0000 3.0000 60 25.0000 diff --git a/blight-map/src/main/map/blight_lights.bll b/blight-map/src/main/map/blight_lights.bll new file mode 100644 index 0000000..1220553 --- /dev/null +++ b/blight-map/src/main/map/blight_lights.bll @@ -0,0 +1,40 @@ +# x y z r g b intensity radius +-351.99496 165.72334 72.11122 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.55142 165.72334 72.58471 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.43768 165.72334 72.54273 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.29898 165.72334 72.48790 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.17831 165.72334 72.50105 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.04108 165.72334 72.53436 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.62204 165.72334 72.48065 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.53287 165.72334 72.41996 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.38269 165.72334 72.27039 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.20273 165.72334 72.12134 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.06265 165.72334 72.04984 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.92236 165.72334 72.02018 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.76706 165.72334 72.01057 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.61569 165.72334 71.96039 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.47086 165.72334 71.83027 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.36353 165.72334 71.73398 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.20959 165.72334 71.58717 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.90756 165.72334 71.23451 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.79315 165.72334 71.05963 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.72208 165.72334 70.80517 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.57535 165.72334 70.41891 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.47836 165.72334 69.83525 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.56183 165.72334 68.33922 1.00000 1.00000 1.00000 1.00000 20.00000 +-348.92676 165.72334 66.96924 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.46359 165.72334 65.80831 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.16031 165.72334 64.75426 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.81577 165.72334 63.87869 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.78735 165.72334 63.35125 1.00000 1.00000 1.00000 1.00000 20.00000 +-352.91010 165.72334 62.80974 1.00000 1.00000 1.00000 1.00000 20.00000 +-353.19580 165.72334 62.27907 1.00000 1.00000 1.00000 1.00000 20.00000 +-353.43668 165.72334 61.80404 1.00000 1.00000 1.00000 1.00000 20.00000 +-353.18146 165.72334 61.10947 1.00000 1.00000 1.00000 1.00000 20.00000 +-352.84583 165.72334 60.76453 1.00000 1.00000 1.00000 1.00000 20.00000 +-352.58203 165.72334 60.53225 1.00000 1.00000 1.00000 1.00000 20.00000 +-352.27545 165.72334 60.25900 1.00000 1.00000 1.00000 1.00000 20.00000 +-351.78003 165.72334 60.08908 1.00000 1.00000 1.00000 1.00000 20.00000 +-350.62851 165.72334 59.92413 1.00000 1.00000 1.00000 1.00000 20.00000 +-349.55182 165.72334 59.88406 1.00000 1.00000 1.00000 1.00000 20.00000 +-67.83228 3.73790 -36.36091 1.00000 1.00000 1.00000 1.00000 20.00000 diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 72af7af..3ec3090 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ diff --git a/blight-map/src/main/map/blight_map.blm.bak b/blight-map/src/main/map/blight_map.blm.bak new file mode 100644 index 0000000..1b01b56 Binary files /dev/null and b/blight-map/src/main/map/blight_map.blm.bak differ diff --git a/blight-map/src/main/map/blight_music_areas.bma b/blight-map/src/main/map/blight_music_areas.bma new file mode 100644 index 0000000..1723ad9 --- /dev/null +++ b/blight-map/src/main/map/blight_music_areas.bma @@ -0,0 +1 @@ +# polygon dayTrack nightTrack combatTrack diff --git a/blight-map/src/main/map/blight_objects.blo b/blight-map/src/main/map/blight_objects.blo new file mode 100644 index 0000000..537757a --- /dev/null +++ b/blight-map/src/main/map/blight_objects.blo @@ -0,0 +1,32 @@ +# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip +Models/Palm_Palme1_20260524_153405.j3o -74.09265 8.19780 -18.47723 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260524_153421.j3o -59.20779 13.84631 -17.90553 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260524_153421.j3o -37.63680 32.63404 -18.65228 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260524_153421.j3o -15.66568 10.46379 -25.60722 0.00000 1.00000 0.00000 0.00000 false +@plane -222.08658 36.63769 -106.48824 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_0.j3o +@box -471.37857 22.27976 -91.47378 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_1.j3o +@box -469.23279 22.49469 -91.51467 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_2.j3o +@group -462.16046 3.59008 -122.32437 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_3.j3o +Models/Palm_Palme1.j3o -245.90610 165.20883 99.17912 -6.43500 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075053.j3o -246.13976 166.24338 89.64748 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075102.j3o -240.90959 166.65724 85.45869 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075134.j3o -254.69368 165.64214 91.69685 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075137.j3o -248.68674 167.64050 76.46214 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1.j3o -205.20802 165.35493 88.46772 -18.77974 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075102.j3o -135.84702 158.69884 84.12026 0.00000 1.00000 0.00000 0.00000 false +@plane -224.78107 164.15782 108.96712 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_4.j3o +@plane -220.53658 165.75740 108.73174 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_5.j3o +@plane -237.96001 165.08000 113.44000 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_6.j3o +@plane -235.81349 164.75165 114.21590 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_7.j3o +Models/Tree/Tree.mesh.j3o -246.86934 164.83850 107.50585 0.00000 1.00000 0.00000 0.00000 false +Models/Boat/boat.j3o -261.74731 164.72397 120.62883 0.00000 1.00000 0.00000 0.00000 false +Models/Campfire.j3o -350.02148 164.72334 72.42865 0.00000 1.00000 0.00000 0.00000 false +Models/Campfire.j3o -67.93591 1.71510 -36.36593 0.00000 1.00000 0.00000 0.00000 false +Models/tree.j3o -28.09666 16.39296 -34.64375 0.00000 1.00000 0.00000 0.00000 false +Models/tree.j3o -83.04567 -2.69246 -39.34207 0.00000 1.00000 0.00000 0.00000 false +Models/Tree/Tree.mesh.j3o 295.18826 1.00000 189.92809 0.00000 1.00000 0.00000 0.00000 false +Models/gltf/duck/Duck.gltf 300.54111 1.00000 198.35368 0.00000 1.00000 0.00000 0.00000 false +Models/Buggy/Buggy.j3o 296.60590 1.00000 201.24153 0.00000 1.00000 0.00000 0.00000 false +Models/Jaime/Jaime.j3o 304.02374 1.00000 199.25398 0.00000 1.00000 0.00000 0.00000 false +Models/tree.j3o 221.97984 1.00000 130.25409 0.00000 1.00000 0.00000 0.00000 false +Models/Palm_Palme1_20260522_075134.j3o 240.61151 1.00000 97.48973 0.00000 1.00000 0.00000 0.00000 false diff --git a/blight-map/src/main/map/blight_sound_areas.bsa b/blight-map/src/main/map/blight_sound_areas.bsa new file mode 100644 index 0000000..6acdd3d --- /dev/null +++ b/blight-map/src/main/map/blight_sound_areas.bsa @@ -0,0 +1,2 @@ +# polygon soundPath volume crossfade +67.278,-201.343;67.278,-201.343;57.509,-236.901;57.509,-236.901 1.0000 false diff --git a/blight-map/src/main/map/blight_water.blw b/blight-map/src/main/map/blight_water.blw new file mode 100644 index 0000000..76960f0 --- /dev/null +++ b/blight-map/src/main/map/blight_water.blw @@ -0,0 +1 @@ +# x y z width depth diff --git a/build.gradle b/build.gradle index 29870a7..f96d044 100644 --- a/build.gradle +++ b/build.gradle @@ -6,19 +6,31 @@ allprojects { } subprojects { - apply plugin: 'java' - - java { - // Toolchain: Source-Kompilierung mit Java 26, Gradle-Daemon läuft auf Java 21 - // (siehe gradle.properties → org.gradle.java.home). - toolchain { - languageVersion = JavaLanguageVersion.of(26) - } - } - - compileJava.options.encoding = 'UTF-8' - repositories { mavenCentral() } + + // Java-Konfiguration nur für Projekte mit Java-Quellcode + if (name != 'blight-assets') { + apply plugin: 'java' + + java { + // Toolchain: Source-Kompilierung mit Java 26, Gradle-Daemon läuft auf Java 21 + // (siehe gradle.properties → org.gradle.java.home). + toolchain { + languageVersion = JavaLanguageVersion.of(26) + } + } + + compileJava.options.encoding = 'UTF-8' + + dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' + } + + test { + useJUnitPlatform() + } + } } diff --git a/ez-tree-jme/src/main/java/de/blight/eztree/LeavesOptions.java b/ez-tree-jme/src/main/java/de/blight/eztree/LeavesOptions.java index 3549b63..488dcbe 100644 --- a/ez-tree-jme/src/main/java/de/blight/eztree/LeavesOptions.java +++ b/ez-tree-jme/src/main/java/de/blight/eztree/LeavesOptions.java @@ -3,27 +3,33 @@ package de.blight.eztree; public final class LeavesOptions { /** Optional asset path for a leaf alpha texture. */ - public String textureFile = null; - public Billboard billboard = Billboard.CROSS; - public int count = 10; + public String textureFile = null; + public Billboard billboard = Billboard.CROSS; + public int count = 10; /** Fraction along the last-level branch at which leaf placement begins. */ - public float start = 0.4f; - public float size = 0.5f; - public float sizeVariance = 0.25f; - public float alphaTest = 0.5f; + public float start = 0.4f; + public float size = 0.5f; + public float sizeVariance = 0.25f; + public float alphaTest = 0.5f; + /** Pitch angle of leaves relative to the branch axis (degrees). Matches JSON "angle". */ + public float angle = 10f; + /** Per-vertex normals point outward from tree centre, giving a rounded canopy look. */ + public boolean roundedNormals = true; /** Base leaf color. */ public float r = 0.13f, g = 0.53f, b = 0.17f; public LeavesOptions copy() { LeavesOptions c = new LeavesOptions(); - c.textureFile = textureFile; - c.billboard = billboard; - c.count = count; - c.start = start; - c.size = size; - c.sizeVariance = sizeVariance; - c.alphaTest = alphaTest; + c.textureFile = textureFile; + c.billboard = billboard; + c.count = count; + c.start = start; + c.size = size; + c.sizeVariance = sizeVariance; + c.alphaTest = alphaTest; + c.angle = angle; + c.roundedNormals = roundedNormals; c.r = r; c.g = g; c.b = b; return c; } diff --git a/ez-tree-jme/src/main/java/de/blight/eztree/Tree.java b/ez-tree-jme/src/main/java/de/blight/eztree/Tree.java index 3a8e55a..6aa85a0 100644 --- a/ez-tree-jme/src/main/java/de/blight/eztree/Tree.java +++ b/ez-tree-jme/src/main/java/de/blight/eztree/Tree.java @@ -115,10 +115,11 @@ public class Tree extends Node { for (int i = 0; i < sections; i++) { float t = (i + 1f) / sections; - // Gnarliness: random XZ perturbation of direction - if (gnarliness > 0f) { - dir.x += rng.range(-gnarliness, gnarliness); - dir.z += rng.range(-gnarliness, gnarliness); + // Gnarliness: random XZ perturbation (abs used so negative JSON values work too) + float absG = Math.abs(gnarliness); + if (absG > 0f) { + dir.x += rng.range(-absG, absG); + dir.z += rng.range(-absG, absG); dir.normalizeLocal(); } @@ -287,7 +288,8 @@ public class Tree extends Node { float size = lo.size * (1f + rng.range(-lo.sizeVariance, lo.sizeVariance)); float yaw = rng.range(0f, FastMath.TWO_PI); - float pitch = rng.range(-FastMath.QUARTER_PI * 0.4f, FastMath.QUARTER_PI * 0.4f); + // Use preset angle as base pitch; add slight random variance + float pitch = lo.angle * FastMath.DEG_TO_RAD + rng.range(-0.25f, 0.25f); float wind = sp.t(); switch (lo.billboard) { @@ -328,7 +330,16 @@ public class Tree extends Node { int base = leafPos.size() / 3; for (int i = 0; i < 4; i++) { leafPos.add(cornerX[i]); leafPos.add(cornerY[i]); leafPos.add(cornerZ[i]); - leafNorm.add(norm.x); leafNorm.add(norm.y); leafNorm.add(norm.z); + if (opts.leaves.roundedNormals) { + float len = FastMath.sqrt(cornerX[i]*cornerX[i] + cornerY[i]*cornerY[i] + cornerZ[i]*cornerZ[i]); + if (len > 1e-6f) { + leafNorm.add(cornerX[i]/len); leafNorm.add(cornerY[i]/len); leafNorm.add(cornerZ[i]/len); + } else { + leafNorm.add(0f); leafNorm.add(1f); leafNorm.add(0f); + } + } else { + leafNorm.add(norm.x); leafNorm.add(norm.y); leafNorm.add(norm.z); + } leafWind.add(wind); } leafUV.add(0f); leafUV.add(0f); diff --git a/ez-tree-jme/src/main/java/de/blight/eztree/TreePresets.java b/ez-tree-jme/src/main/java/de/blight/eztree/TreePresets.java index 1b5f596..e232a1c 100644 --- a/ez-tree-jme/src/main/java/de/blight/eztree/TreePresets.java +++ b/ez-tree-jme/src/main/java/de/blight/eztree/TreePresets.java @@ -42,7 +42,7 @@ public final class TreePresets { b.angle.put(1, 54f); b.angle.put(2, 58f); b.angle.put(3, 32f); b.children.put(0, 4); b.children.put(1, 2); b.children.put(2, 3); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.01f; - b.gnarliness.put(0, 0.07f); b.gnarliness.put(1, 0.08f); + b.gnarliness.put(0, 0.07f); b.gnarliness.put(1, -0.08f); b.gnarliness.put(2, 0.11f); b.gnarliness.put(3, 0.09f); b.length.put(0, 7.02f); b.length.put(1, 0.162f); b.length.put(2, 2.149f); b.length.put(3, 0.732f); b.radius.put(0, 0.25f); b.radius.put(1, 0.60f); b.radius.put(2, 0.55f); b.radius.put(3, 0.50f); @@ -52,7 +52,7 @@ public final class TreePresets { b.taper.put(0, 0.73f); b.taper.put(1, 0.42f); b.taper.put(2, 0.69f); b.taper.put(3, 0.75f); b.twist.put(0, -13.18f); b.twist.put(1, 24.06f); - o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 14, 0.16f, 0.28f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 14, 0.16f, 0.28f, 0.70f, 0.5f, 42f); return o; } @@ -69,8 +69,8 @@ public final class TreePresets { b.angle.put(1, 54f); b.angle.put(2, 58f); b.angle.put(3, 32f); b.children.put(0, 6); b.children.put(1, 4); b.children.put(2, 3); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.02f; - b.gnarliness.put(0, 0.00f); b.gnarliness.put(1, 0.10f); - b.gnarliness.put(2, 0.15f); b.gnarliness.put(3, 0.09f); + b.gnarliness.put(0, 0.00f); b.gnarliness.put(1, -0.10f); + b.gnarliness.put(2, -0.15f); b.gnarliness.put(3, 0.09f); b.length.put(0, 9.31f); b.length.put(1, 0.298f); b.length.put(2, 1.118f); b.length.put(3, 0.578f); b.radius.put(0, 0.35f); b.radius.put(1, 0.60f); b.radius.put(2, 0.55f); b.radius.put(3, 0.50f); b.sections.put(0, 8); b.sections.put(1, 6); b.sections.put(2, 4); @@ -79,7 +79,7 @@ public final class TreePresets { b.taper.put(0, 0.73f); b.taper.put(1, 0.42f); b.taper.put(2, 0.69f); b.taper.put(3, 0.75f); b.twist.put(0, -13.18f); b.twist.put(1, 24.06f); - o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 18, 0.16f, 0.50f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 18, 0.16f, 0.50f, 0.70f, 0.5f, 42f); return o; } @@ -96,8 +96,8 @@ public final class TreePresets { b.angle.put(1, 54f); b.angle.put(2, 43f); b.angle.put(3, 32f); b.children.put(0, 9); b.children.put(1, 5); b.children.put(2, 3); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.02f; - b.gnarliness.put(0, 0.04f); b.gnarliness.put(1, 0.16f); - b.gnarliness.put(2, 0.06f); b.gnarliness.put(3, 0.09f); + b.gnarliness.put(0, -0.04f); b.gnarliness.put(1, 0.16f); + b.gnarliness.put(2, -0.06f); b.gnarliness.put(3, 0.09f); b.length.put(0, 11.93f); b.length.put(1, 0.616f); b.length.put(2, 0.600f); b.length.put(3, 0.406f); b.radius.put(0, 0.75f); b.radius.put(1, 0.58f); b.radius.put(2, 0.55f); b.radius.put(3, 0.50f); b.sections.put(0, 16); b.sections.put(1, 9); b.sections.put(2, 8); b.sections.put(3, 3); @@ -106,7 +106,7 @@ public final class TreePresets { b.taper.put(0, 0.73f); b.taper.put(1, 0.42f); b.taper.put(2, 0.69f); b.taper.put(3, 0.75f); b.twist.put(0, -13.18f); b.twist.put(1, 24.06f); - o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 10, 0.16f, 0.90f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_OAK, 0.835f, 0.835f, 0.804f, Billboard.CROSS, 10, 0.16f, 0.90f, 0.70f, 0.5f, 36f); return o; } @@ -137,7 +137,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); b.twist.put(0, 17.19f); b.twist.put(1, -4.01f); - o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 30, 0.00f, 0.41f, 0.717f, 0.5f); + o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 30, 0.00f, 0.41f, 0.717f, 0.5f, 55f); return o; } @@ -164,7 +164,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); b.twist.put(0, 5.16f); b.twist.put(1, -4.01f); - o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 16, 0.00f, 0.53f, 0.720f, 0.5f); + o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 16, 0.00f, 0.53f, 0.720f, 0.5f, 55f); return o; } @@ -181,7 +181,7 @@ public final class TreePresets { b.angle.put(1, 39f); b.angle.put(2, 39f); b.angle.put(3, 51f); b.children.put(0, 10); b.children.put(1, 4); b.children.put(2, 3); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.01f; - b.gnarliness.put(0, 0.05f); b.gnarliness.put(1, 0.20f); + b.gnarliness.put(0, -0.05f); b.gnarliness.put(1, 0.20f); b.gnarliness.put(2, 0.16f); b.gnarliness.put(3, 0.05f); b.length.put(0, 11.25f); b.length.put(1, 0.654f); b.length.put(2, 0.520f); b.length.put(3, 0.301f); b.radius.put(0, 0.76f); b.radius.put(1, 0.58f); b.radius.put(2, 0.55f); b.radius.put(3, 0.50f); @@ -191,7 +191,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.62f); b.taper.put(2, 0.76f); b.taper.put(3, 0.00f); b.twist.put(0, 5.16f); b.twist.put(1, -4.01f); - o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 10, 0.01f, 0.92f, 0.720f, 0.5f); + o.leaves = leaves(LEAF_ASH, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 10, 0.01f, 0.92f, 0.720f, 0.5f, 30f); return o; } @@ -212,7 +212,7 @@ public final class TreePresets { b.angle.put(1, 70f); b.angle.put(2, 35f); b.angle.put(3, 7f); b.children.put(0, 4); b.children.put(1, 3); b.children.put(2, 3); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.011f; - b.gnarliness.put(0, 0.04f); b.gnarliness.put(1, 0.01f); + b.gnarliness.put(0, 0.04f); b.gnarliness.put(1, -0.01f); b.gnarliness.put(2, 0.12f); b.gnarliness.put(3, 0.02f); b.length.put(0, 6.00f); b.length.put(1, 0.140f); b.length.put(2, 2.292f); b.length.put(3, 0.130f); b.radius.put(0, 0.093f); b.radius.put(1, 0.55f); b.radius.put(2, 0.50f); @@ -221,7 +221,7 @@ public final class TreePresets { b.start.put(1, 0.45f); b.start.put(2, 0.33f); b.taper.put(0, 0.37f); b.taper.put(1, 0.13f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); - o.leaves = leaves(LEAF_ASPEN, 1.000f, 0.980f, 0.384f, Billboard.CROSS, 13, 0.20f, 0.50f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_ASPEN, 1.000f, 0.980f, 0.384f, Billboard.CROSS, 13, 0.20f, 0.50f, 0.70f, 0.5f, 30f); return o; } @@ -247,7 +247,7 @@ public final class TreePresets { b.start.put(1, 0.59f); b.start.put(2, 0.35f); b.taper.put(0, 0.37f); b.taper.put(1, 0.13f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); - o.leaves = leaves(LEAF_ASPEN, 1.000f, 0.980f, 0.384f, Billboard.CROSS, 11, 0.124f, 0.50f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_ASPEN, 1.000f, 0.980f, 0.384f, Billboard.CROSS, 11, 0.124f, 0.50f, 0.70f, 0.5f, 30f); return o; } @@ -264,7 +264,7 @@ public final class TreePresets { b.angle.put(1, 47f); b.angle.put(2, 63f); b.angle.put(3, 7f); b.children.put(0, 10); b.children.put(1, 6); b.children.put(2, 0); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.022f; - b.gnarliness.put(0, 0.05f); b.gnarliness.put(1, 0.03f); + b.gnarliness.put(0, 0.05f); b.gnarliness.put(1, -0.03f); b.gnarliness.put(2, 0.12f); b.gnarliness.put(3, 0.02f); b.length.put(0, 17.40f); b.length.put(1, 0.267f); b.length.put(2, 0.603f); b.length.put(3, 0.089f); b.radius.put(0, 0.28f); b.radius.put(1, 0.55f); b.radius.put(2, 0.50f); @@ -273,7 +273,7 @@ public final class TreePresets { b.start.put(1, 0.62f); b.start.put(2, 0.05f); b.taper.put(0, 0.70f); b.taper.put(1, 0.13f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); - o.leaves = leaves(LEAF_ASPEN, 0.988f, 1.000f, 0.149f, Billboard.CROSS, 20, 0.152f, 0.70f, 0.70f, 0.5f); + o.leaves = leaves(LEAF_ASPEN, 0.988f, 1.000f, 0.149f, Billboard.CROSS, 20, 0.152f, 0.70f, 0.70f, 0.5f, 36f); return o; } @@ -302,7 +302,7 @@ public final class TreePresets { b.start.put(1, 0.16f); b.start.put(2, 0.30f); b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); - o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 21, 0.00f, 0.19f, 0.70f, 0.3f); + o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 21, 0.00f, 0.19f, 0.70f, 0.3f, 10f); return o; } @@ -327,7 +327,7 @@ public final class TreePresets { b.start.put(1, 0.27f); b.start.put(2, 0.14f); b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); - o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 30, 0.09f, 0.29f, 0.201f, 0.3f); + o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 30, 0.09f, 0.29f, 0.201f, 0.3f, 39f); return o; } @@ -352,7 +352,7 @@ public final class TreePresets { b.start.put(1, 0.294f); b.start.put(2, 0.14f); b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); - o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 18, 0.076f, 0.52f, 0.201f, 0.3f); + o.leaves = leaves(LEAF_PINE, 1.0f, 1.0f, 1.0f, Billboard.CROSS, 18, 0.076f, 0.52f, 0.201f, 0.3f, 17f); return o; } @@ -384,7 +384,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); b.twist.put(0, 17.19f); b.twist.put(1, -4.01f); - o.leaves = leaves(LEAF_ASH, 0.878f, 1.000f, 0.835f, Billboard.CROSS, 12, 0.00f, 0.49f, 0.717f, 0.5f); + o.leaves = leaves(LEAF_ASH, 0.878f, 1.000f, 0.835f, Billboard.CROSS, 12, 0.00f, 0.49f, 0.717f, 0.5f, 55f); return o; } @@ -412,7 +412,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); b.twist.put(0, 20.55f); b.twist.put(1, -2.49f); - o.leaves = leaves(LEAF_ASPEN, 0.878f, 1.000f, 0.835f, Billboard.CROSS, 7, 0.00f, 0.49f, 0.717f, 0.5f); + o.leaves = leaves(LEAF_ASPEN, 0.878f, 1.000f, 0.835f, Billboard.CROSS, 7, 0.00f, 0.49f, 0.717f, 0.5f, 55f); return o; } @@ -439,7 +439,7 @@ public final class TreePresets { b.taper.put(0, 0.70f); b.taper.put(1, 0.70f); b.taper.put(2, 0.70f); b.taper.put(3, 0.70f); b.twist.put(0, 17.19f); b.twist.put(1, -1.87f); - o.leaves = leaves(LEAF_PINE, 0.616f, 0.765f, 1.000f, Billboard.CROSS, 3, 0.152f, 0.61f, 0.457f, 0.5f); + o.leaves = leaves(LEAF_PINE, 0.616f, 0.765f, 1.000f, Billboard.CROSS, 3, 0.152f, 0.61f, 0.457f, 0.5f, 54f); return o; } @@ -461,7 +461,7 @@ public final class TreePresets { b.children.put(0, 7); b.children.put(1, 5); b.children.put(2, 1); b.force.direction.set(0f, 1f, 0f); b.force.strength = 0.026f; b.gnarliness.put(0, 0.00f); b.gnarliness.put(1, 0.02f); - b.gnarliness.put(2, 0.41f); b.gnarliness.put(3, 0.09f); + b.gnarliness.put(2, -0.41f); b.gnarliness.put(3, 0.09f); b.length.put(0, 1.20f); b.length.put(1, 3.521f); b.length.put(2, 0.669f); b.length.put(3, 0.982f); b.radius.put(0, 0.068f); b.radius.put(1, 0.60f); b.radius.put(2, 0.55f); b.radius.put(3, 0.50f); b.sections.put(0, 6); b.sections.put(1, 12); b.sections.put(2, 10); b.sections.put(3, 4); @@ -471,7 +471,7 @@ public final class TreePresets { b.twist.put(0, -1.15f); b.twist.put(1, -0.57f); b.twist.put(2, 5.16f); // tint 15204310 = 0xE7D8C6: (231,216,198)/255 - o.leaves = leaves(LEAF_ASH, 0.906f, 0.847f, 0.776f, Billboard.NONE, 13, 0.00f, 0.34f, 0.50f, 0.5f); + o.leaves = leaves(LEAF_ASH, 0.906f, 0.847f, 0.776f, Billboard.NONE, 13, 0.00f, 0.34f, 0.50f, 0.5f, 30f); o.trellis.enabled = true; o.trellis.sections = 8; @@ -522,7 +522,8 @@ public final class TreePresets { private static LeavesOptions leaves(String tex, float r, float g, float b, Billboard bb, int count, - float start, float size, float sizeVar, float alpha) { + float start, float size, float sizeVar, float alpha, + float angle) { LeavesOptions l = new LeavesOptions(); l.textureFile = tex; l.r = r; l.g = g; l.b = b; @@ -532,6 +533,7 @@ public final class TreePresets { l.size = size; l.sizeVariance = sizeVar; l.alphaTest = alpha; + l.angle = angle; return l; } } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index e5c1571..6534dc1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ rootProject.name = 'blight' include 'blight-common' include 'blight-assets' include 'blight-map' +include 'blight-lang' include 'blight-editor' include 'blight-game' include 'simarboreal' diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tools/dom_polyfill.cjs b/tools/dom_polyfill.cjs new file mode 100644 index 0000000..badff93 --- /dev/null +++ b/tools/dom_polyfill.cjs @@ -0,0 +1,21 @@ +// Minimal DOM polyfill so Three.js TextureLoader doesn't crash in Node.js +global.document = { + createElementNS: (ns, name) => ({ + style: {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + src: '', + onload: null, + onerror: null + }), + createElement: (name) => ({ + style: {}, + addEventListener: () => {}, + removeEventListener: () => {}, + src: '', + onload: null, + onerror: null + }) +}; +global.window = global; diff --git a/tools/ez_tree_generate.mjs b/tools/ez_tree_generate.mjs new file mode 100644 index 0000000..2696200 --- /dev/null +++ b/tools/ez_tree_generate.mjs @@ -0,0 +1,123 @@ +/** + * Headless ez-tree geometry generator. + * Usage: node --require ./dom_polyfill.cjs ez_tree_generate.mjs '' + * + * Input JSON: { preset: "Oak Medium", params: { seed, type, bark, branch, leaves, trellis } } + * Output JSON: { branches: { positions, normals, uvs, indices }, leaves: { ... } } + */ + +import { Tree, TreePreset } from '@dgreenheck/ez-tree'; + +const input = JSON.parse(process.argv[2] ?? '{}'); +const presetName = input.preset ?? 'Oak Medium'; +const params = input.params ?? {}; + +if (!TreePreset[presetName]) { + process.stderr.write('Unknown preset: ' + presetName + '\n'); + process.exit(1); +} + +const options = structuredClone(TreePreset[presetName]); +applyParams(options, params); + +const tree = new Tree(); +tree.options.copy(options); + +try { + tree.generate(); +} catch (e) { + // generate() may fail on DOM APIs for materials, but geometry arrays are + // already populated at that point — continue. +} + +const branches = tree.branches; +const leaves = tree.leaves; + +if (!branches || !leaves) { + process.stderr.write('Generation produced no geometry\n'); + process.exit(1); +} + +const leafNormals = leaves.normals?.length > 0 + ? leaves.normals + : computeFlatNormals(leaves.verts, leaves.indices); + +const result = { + branches: { + positions: Array.from(branches.verts), + normals: Array.from(branches.normals), + uvs: Array.from(branches.uvs), + indices: Array.from(branches.indices), + }, + leaves: { + positions: Array.from(leaves.verts), + normals: Array.from(leafNormals), + uvs: Array.from(leaves.uvs), + indices: Array.from(leaves.indices), + }, +}; + +process.stdout.write(JSON.stringify(result)); + +// ── Parameter-Overrides ──────────────────────────────────────────────────── + +function applyParams(options, params) { + if (params.seed !== undefined) options.seed = params.seed; + if (params.type !== undefined) options.type = params.type; + + if (params.bark) { + const b = params.bark; + if (b.tint !== undefined) options.bark.tint = b.tint; + if (b.flatShading !== undefined) options.bark.flatShading = b.flatShading; + if (b.textureScale) { + if (b.textureScale.x !== undefined) options.bark.textureScale.x = b.textureScale.x; + if (b.textureScale.y !== undefined) options.bark.textureScale.y = b.textureScale.y; + } + } + + if (params.branch) { + const br = params.branch; + if (br.levels !== undefined) options.branch.levels = br.levels; + if (br.force) { + if (br.force.strength !== undefined) + options.branch.force.strength = br.force.strength; + if (br.force.direction) { + if (br.force.direction.x !== undefined) options.branch.force.direction.x = br.force.direction.x; + if (br.force.direction.y !== undefined) options.branch.force.direction.y = br.force.direction.y; + if (br.force.direction.z !== undefined) options.branch.force.direction.z = br.force.direction.z; + } + } + // Per-level maps: replace entirely when provided (Java sends the full map) + for (const key of ['angle','children','gnarliness','length','radius', + 'sections','segments','start','taper','twist']) { + if (br[key] !== undefined) options.branch[key] = br[key]; + } + } + + if (params.leaves) { + Object.assign(options.leaves, params.leaves); + } + + if (params.trellis) { + Object.assign(options.trellis, params.trellis); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function computeFlatNormals(verts, indices) { + const normals = new Float32Array(verts.length); + for (let i = 0; i < indices.length; i += 3) { + const ia = indices[i] * 3, ib = indices[i + 1] * 3, ic = indices[i + 2] * 3; + const ax = verts[ib] - verts[ia], ay = verts[ib+1] - verts[ia+1], az = verts[ib+2] - verts[ia+2]; + const bx = verts[ic] - verts[ia], by = verts[ic+1] - verts[ia+1], bz = verts[ic+2] - verts[ia+2]; + const nx = ay*bz - az*by, ny = az*bx - ax*bz, nz = ax*by - ay*bx; + const len = Math.sqrt(nx*nx + ny*ny + nz*nz) || 1; + for (const idx of [ia, ib, ic]) { + normals[idx] += nx/len; + normals[idx+1] += ny/len; + normals[idx+2] += nz/len; + } + } + return Array.from(normals); +} diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..91000ab --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "blight-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "blight-tools", + "version": "1.0.0", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + "three": "^0.167.0" + } + }, + "node_modules/@dgreenheck/ez-tree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dgreenheck/ez-tree/-/ez-tree-1.1.0.tgz", + "integrity": "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.167" + } + }, + "node_modules/three": { + "version": "0.167.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.167.1.tgz", + "integrity": "sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==", + "license": "MIT" + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..3f3ff72 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,10 @@ +{ + "name": "blight-tools", + "version": "1.0.0", + "type": "module", + "private": true, + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + "three": "^0.167.0" + } +}