Animations-Import, Massenimport-Queue, Asset-Archivierung, Voxel-Refactor

- Animations-Import: GLB wird direkt vom Ursprungspfad geladen (kein Zwischenkopieren), J3O in clips/ gespeichert
- RetargetingSystem: Translations-Tracks im Full-Retarget-Pfad erhalten (Hips-Y für sit_down)
- AnimationLibrary: lädt nur J3O, Clip-Name wird bei applyTo() auf Library-Key umbenannt
- SharedInput: animPreviewAddAnimPath → ConcurrentLinkedQueue animImportQueue (Massenimport-Fix)
- EditorApp: archiveOriginal() archiviert Originaldateien nach assets/imported/<assettyp>/
- EditorApp: Animations-Unterknoten im Asset-Baum zeigen enthaltene Clip-Namen
- Neue Animations-Clips: sit_down, get_up_sitting, sitting, pickup, sprinting u.a.
- Voxel: VoxelChunkState entfernt, VoxelChunkNode/MarchingCubes überarbeitet
- Map: Voxel-Chunks bereinigt, Terrain-Chunks aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:52:04 +02:00
parent a369647e9c
commit c8f1dd9432
239 changed files with 8234 additions and 658 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -3,13 +3,10 @@ MaterialDef Voxel {
MaterialParameters {
Texture2D TexFlat
Texture2D TexSteep
Texture2D TexCeil
Texture2D NormalMapFlat
Texture2D NormalMapSteep
Texture2D NormalMapCeil
Texture2D DisplacementMapFlat
Texture2D DisplacementMapSteep
Texture2D DisplacementMapCeil
Float TexScale : 8.0
Float DisplacementScale : 0.3
Float TessellationLevel : 4.0
@@ -32,7 +29,6 @@ MaterialDef Voxel {
Defines {
HAS_NM_FLAT : NormalMapFlat
HAS_NM_STEEP : NormalMapSteep
HAS_NM_CEIL : NormalMapCeil
HAS_LIGHTDIR : LightDir
HAS_SCENE_LIGHT : SunColor
DEBUG_NO_LIGHT : DebugNoLight
@@ -60,10 +56,8 @@ MaterialDef Voxel {
Defines {
HAS_NM_FLAT : NormalMapFlat
HAS_NM_STEEP : NormalMapSteep
HAS_NM_CEIL : NormalMapCeil
HAS_DISP_FLAT : DisplacementMapFlat
HAS_DISP_STEEP : DisplacementMapSteep
HAS_DISP_CEIL : DisplacementMapCeil
HAS_LIGHTDIR : LightDir
HAS_SCENE_LIGHT : SunColor
}

View File

@@ -0,0 +1,23 @@
#Thu Jun 18 17:47:21 CEST 2026
attachedEmitters.count=0
attachedLight.0=0.00000|0.10000|0.00000|0.80000|1.00000|0.80000|12.00000|50.00000
attachedLights.count=1
castShadow=true
category=
cullDistance=120.0
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=Höhlenkristall1
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,27 @@
#Sat Jun 20 13:06:13 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
castShadow=true
category=
cullDistance=120.0
interactableOffsetX=0.0
interactableOffsetY=0.6
interactableOffsetZ=0.0
interactableRotY=1.5707964
interactableType=BENCH
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=bank
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=true
tags=
uniformScale=true

View File

@@ -0,0 +1,27 @@
#Sat Jun 20 14:30:44 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
castShadow=true
category=
cullDistance=120.0
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=1.5707964
interactableType=BENCH
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=bank1
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=false
tags=
uniformScale=true

View File

@@ -1,6 +1,5 @@
uniform sampler2D m_TexFlat;
uniform sampler2D m_TexSteep;
uniform sampler2D m_TexCeil;
uniform float m_TexScale;
#ifdef HAS_NM_FLAT
@@ -9,9 +8,6 @@ uniform sampler2D m_NormalMapFlat;
#ifdef HAS_NM_STEEP
uniform sampler2D m_NormalMapSteep;
#endif
#ifdef HAS_NM_CEIL
uniform sampler2D m_NormalMapCeil;
#endif
in vec3 vWorldPos;
in vec3 vNormal;
@@ -55,27 +51,24 @@ void main() {
vec2 uvY = vWorldPos.xz / m_TexScale;
vec2 uvZ = vWorldPos.xy / m_TexScale;
// Flach ab ~11° Gefälle (20% grade, normal.y≈0.98); Fels darunter.
// Flach ab ~11° Gefälle (20% grade, normal.y≈0.98); alles andere Fels.
float flatBlend = smoothstep(0.94, 0.99, vNormal.y);
float ceilBlend = 1.0 - smoothstep(-0.6, -0.3, vNormal.y);
float steepBlend = max(0.0, 1.0 - flatBlend - ceilBlend);
float steepBlend = 1.0 - flatBlend;
// Flat: reines XZ-UV wie das Terrain (uvY = worldPos.xz / texScale), kein Triplanar.
// Steep/Ceil: Triplanar bleibt, da es dort keine eindeutige Projektion gibt.
// Steep: Triplanar für alle nicht-flachen Flächen inkl. Decken und Tunnelwände.
vec4 col = texture(m_TexFlat, uvY) * flatBlend
+ triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend
+ triplanar(m_TexCeil, uvX, uvY, uvZ, bw) * ceilBlend;
+ triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend;
// Geometrie-Normale für Beleuchtung, ggf. durch Normal-Map ersetzt.
vec3 N = normalize(vNormal);
#if defined(HAS_NM_FLAT) || defined(HAS_NM_STEEP) || defined(HAS_NM_CEIL)
#if defined(HAS_NM_FLAT) || defined(HAS_NM_STEEP)
vec3 pertN = vec3(0.0);
float totalBlend = 0.0;
#ifdef HAS_NM_FLAT
if (flatBlend > 0.001) {
vec3 nmFlat = texture(m_NormalMapFlat, uvY).rgb * 2.0 - 1.0;
// Y-Projektion RNM (identisch mit triplanarNormal Y-Achse bei bw.y=1)
nmFlat = vec3(nmFlat.xy + N.xz, abs(nmFlat.z) * N.y);
pertN += normalize(nmFlat.xzy) * flatBlend;
totalBlend += flatBlend;
@@ -86,12 +79,6 @@ void main() {
pertN += triplanarNormal(m_NormalMapSteep, uvX, uvY, uvZ, bw, N) * steepBlend;
totalBlend += steepBlend;
}
#endif
#ifdef HAS_NM_CEIL
if (ceilBlend > 0.001) {
pertN += triplanarNormal(m_NormalMapCeil, uvX, uvY, uvZ, bw, N) * ceilBlend;
totalBlend += ceilBlend;
}
#endif
if (totalBlend > 0.001) {
N = normalize(pertN / totalBlend);

View File

@@ -1,23 +1,33 @@
{
"clips": [
"get_up_sitting",
"idle",
"idle_jump",
"pickup",
"running",
"running_jump",
"sprint",
"sit_down",
"sitting",
"sitting_floor",
"sprinting",
"stand_up",
"tpose",
"walking",
"pickup"
"walking"
],
"actionMap": {
"DEFAULT": "tpose",
"IDLE": "idle",
"WALK": "walking",
"RUN": "running",
"SPRINT": "sprint",
"JUMP": "idle_jump",
"SPRINT": "sprinting",
"RUNNING_JUMP": "running_jump",
"PICK_UP": "pickup"
}
"JUMP": "idle_jump",
"PICK_UP": "pickup",
"SIT_DOWN": "sit_down",
"SIT_UP": "stand_up",
"SITTING": "sitting"
},
"previewModelPath": "Models/Chars/mainchar.j3o",
"sinkMap": {},
"anchorBoneMap": {}
}

View File

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

View File

@@ -27,7 +27,12 @@ public record ModelMeta(
float lod2Distance,
float cullDistance,
List<AttachedLight> attachedLights,
List<AttachedEmitter> attachedEmitters
List<AttachedEmitter> attachedEmitters,
de.blight.common.model.InteractableType interactableType,
float interactableOffsetX,
float interactableOffsetY,
float interactableOffsetZ,
float interactableRotY
) {
/** Lichtquelle relativ zum Modell-Ursprung. */
public record AttachedLight(
@@ -45,6 +50,8 @@ public record ModelMeta(
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
false, true, true, 1f, 1f,
"", "", 30f, 80f, 120f,
List.of(), List.of());
List.of(), List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f);
}
}

View File

@@ -34,6 +34,11 @@ public final class ModelMetaIO {
p.setProperty("lod1Distance", String.valueOf(m.lod1Distance()));
p.setProperty("lod2Distance", String.valueOf(m.lod2Distance()));
p.setProperty("cullDistance", String.valueOf(m.cullDistance()));
p.setProperty("interactableType", m.interactableType().name());
p.setProperty("interactableOffsetX", String.valueOf(m.interactableOffsetX()));
p.setProperty("interactableOffsetY", String.valueOf(m.interactableOffsetY()));
p.setProperty("interactableOffsetZ", String.valueOf(m.interactableOffsetZ()));
p.setProperty("interactableRotY", String.valueOf(m.interactableRotY()));
// Anhänge: Lichter
List<ModelMeta.AttachedLight> lights = m.attachedLights();
@@ -127,7 +132,13 @@ public final class ModelMetaIO {
parseFloat(p, "lod2Distance", 80f),
parseFloat(p, "cullDistance", 120f),
Collections.unmodifiableList(lights),
Collections.unmodifiableList(emitters)
Collections.unmodifiableList(emitters),
de.blight.common.model.InteractableType.fromString(
p.getProperty("interactableType", "NONE")),
parseFloat(p, "interactableOffsetX", 0f),
parseFloat(p, "interactableOffsetY", 0.5f),
parseFloat(p, "interactableOffsetZ", 0f),
parseFloat(p, "interactableRotY", 0f)
);
}

View File

@@ -18,5 +18,9 @@ public record PlacedModel(
String lod2Path,
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
float lod2Distance, // ab dieser Distanz LOD2 anzeigen
float cullDistance // ab dieser Distanz ausblenden
float cullDistance, // ab dieser Distanz ausblenden
/** "CRAFTING_TABLE" / "BED" / "" für kein Interactable. */
String interactableType,
/** ID des verknüpften Interactables (CraftingTableType-Name oder Bett-UUID); "" wenn nicht gesetzt. */
String interactableId
) {}

View File

@@ -8,11 +8,11 @@ import java.util.*;
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei
* ({@code blight_objects.blo}) neben der Kartendatei.
*
* Spalten (seit v4):
* Spalten (seit v5):
* modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow
* lod1Path lod2Path lod1Distance lod2Distance cullDistance
* lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId
*
* Alte Dateien mit 6 Spalten (v1/v2/v3) werden gelesen; fehlende Felder erhalten Standardwerte.
* Alte Dateien mit 6 Spalten (v1v4) werden gelesen; fehlende Felder erhalten Standardwerte.
*/
public final class PlacedModelIO {
@@ -26,11 +26,11 @@ public final class PlacedModelIO {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance");
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance\tinteractableType\tinteractableId");
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\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f%n",
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f\t%s\t%s%n",
m.modelPath(),
m.x(), m.y(), m.z(),
m.rotY(), m.scale(),
@@ -40,7 +40,8 @@ public final class PlacedModelIO {
nvl(m.meshFile()), nvl(m.animClip()),
m.castShadow(), m.receiveShadow(),
nvl(m.lod1Path()), nvl(m.lod2Path()),
m.lod1Distance(), m.lod2Distance(), m.cullDistance()));
m.lod1Distance(), m.lod2Distance(), m.cullDistance(),
nvl(m.interactableType()), nvl(m.interactableId())));
}
}
}
@@ -75,12 +76,15 @@ public final class PlacedModelIO {
String lod2Path = f.length > 17 ? f[17] : "";
float lod1Distance = f.length > 18 ? parseFloat(f[18], 30f) : 30f;
float lod2Distance = f.length > 19 ? parseFloat(f[19], 80f) : 80f;
float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
String interactableType = f.length > 21 ? f[21] : "";
String interactableId = f.length > 22 ? f[22] : "";
list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow,
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance));
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance,
interactableType, interactableId));
} catch (NumberFormatException ignored) {}
}
return list;

View File

@@ -0,0 +1,18 @@
package de.blight.common;
/**
* Prozedural generierter Stein.
* Y-Position wird zur Laufzeit aus dem Terrain berechnet.
*
* sinkFraction: Anteil des Durchmessers, der unter der Terrainoberfläche liegt (0.20.5).
* noiseSeed: Seed für die deterministisch reproduzierbare Verformung.
*/
public record PlacedStone(
float x,
float z,
float radius,
float rotY,
int textureSlot,
float sinkFraction,
int noiseSeed
) {}

View File

@@ -0,0 +1,92 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
/**
* Liest und schreibt platzierte Steine als komprimierte Binärdatei
* ({@code blight_stones.bls}) neben der Kartendatei.
*
* Format v1:
* int MAGIC 0x53544E53 ("STNS")
* int VERSION 1
* int SLOT_COUNT 3
* 3× UTF Texturpfad pro Slot ("" = kein)
* int stoneCount
* N× float x, float z, float radius, float rotY,
* byte textureSlot, float sinkFraction, int noiseSeed
*/
public final class PlacedStoneIO {
private static final int MAGIC = 0x53544E53;
private static final int VERSION = 1;
public static final int SLOT_COUNT = 3;
private PlacedStoneIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_stones.bls");
}
public record StoneData(String[] slotPaths, List<PlacedStone> stones) {}
public static void save(StoneData data) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(p))))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
String[] paths = data.slotPaths() != null ? data.slotPaths() : new String[0];
out.writeInt(SLOT_COUNT);
for (int i = 0; i < SLOT_COUNT; i++) {
String s = (i < paths.length && paths[i] != null) ? paths[i] : "";
out.writeUTF(s);
}
List<PlacedStone> stones = data.stones() != null ? data.stones() : List.of();
out.writeInt(stones.size());
for (PlacedStone s : stones) {
out.writeFloat(s.x());
out.writeFloat(s.z());
out.writeFloat(s.radius());
out.writeFloat(s.rotY());
out.writeByte(s.textureSlot());
out.writeFloat(s.sinkFraction());
out.writeInt(s.noiseSeed());
}
}
}
public static StoneData load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return null;
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) {
if (in.readInt() != MAGIC) throw new IOException("Kein PlacedStone-Magic");
int ver = in.readInt();
if (ver > VERSION) throw new IOException("Unbekannte Stone-Version: " + ver);
int slotCount = in.readInt();
String[] paths = new String[SLOT_COUNT];
Arrays.fill(paths, "");
for (int i = 0; i < slotCount; i++) {
String s = in.readUTF();
if (i < SLOT_COUNT) paths[i] = s;
}
int count = in.readInt();
List<PlacedStone> stones = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
float x = in.readFloat();
float z = in.readFloat();
float r = in.readFloat();
float rotY = in.readFloat();
int slot = in.readUnsignedByte();
float sink = in.readFloat();
int seed = in.readInt();
stones.add(new PlacedStone(x, z, r, rotY, slot, sink, seed));
}
return new StoneData(paths, stones);
}
}
}

View File

@@ -0,0 +1,20 @@
package de.blight.common;
/**
* Gespeicherte Vertex-Positionen eines gebackenen Voxel-Chunks nach dem Sculpten.
* Vertex-Anzahl und -Reihenfolge entsprechen dem LOD0-Bake
* ({@code voxel_CX_CY_CZ_baked_lod0.j3o}).
*/
public class SculptedMesh {
public final int cx, cy, cz;
/** Vertex-Positionen im lokalen Chunk-Raum (xyz je Vertex). */
public final float[] positions;
public SculptedMesh(int cx, int cy, int cz, float[] positions) {
this.cx = cx;
this.cy = cy;
this.cz = cz;
this.positions = positions;
}
}

View File

@@ -0,0 +1,100 @@
package de.blight.common;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
/**
* Lesen und Schreiben von {@link SculptedMesh}-Daten als {@code .blsm}-Dateien.
*
* Format: MAGIC (4), VERSION (4), cx (4), cy (4), cz (4), vertexCount (4),
* positions[vertexCount*3] (float32 LE)
*/
public final class SculptedMeshIO {
private static final int MAGIC = 0x424C534D; // 'BLSM'
private static final int VERSION = 1;
private SculptedMeshIO() {}
public static Path getPath(int cx, int cy, int cz) {
String cyStr = cy < 0 ? "m" + (-cy) : String.valueOf(cy);
return ChunkTerrainIO.chunksDir()
.resolve(String.format("sculpt_%02d_%s_%02d.blsm", cx, cyStr, cz));
}
public static boolean exists(int cx, int cy, int cz) {
return Files.exists(getPath(cx, cy, cz));
}
public static void save(SculptedMesh mesh) throws IOException {
int vcount = mesh.positions.length / 3;
ByteBuffer buf = ByteBuffer.allocate(6 * 4 + vcount * 3 * 4)
.order(ByteOrder.LITTLE_ENDIAN);
buf.putInt(MAGIC);
buf.putInt(VERSION);
buf.putInt(mesh.cx);
buf.putInt(mesh.cy);
buf.putInt(mesh.cz);
buf.putInt(vcount);
for (float f : mesh.positions) buf.putFloat(f);
Path p = getPath(mesh.cx, mesh.cy, mesh.cz);
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
Files.createDirectories(p.getParent());
Files.write(tmp, buf.array());
try {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, p, StandardCopyOption.REPLACE_EXISTING);
}
}
public static SculptedMesh load(int cx, int cy, int cz) throws IOException {
byte[] data = Files.readAllBytes(getPath(cx, cy, cz));
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
if (buf.getInt() != MAGIC) throw new IOException("Ungültiger MAGIC");
if (buf.getInt() != VERSION) throw new IOException("Ungültige VERSION");
int lcx = buf.getInt();
int lcy = buf.getInt();
int lcz = buf.getInt();
int vcount = buf.getInt();
float[] positions = new float[vcount * 3];
for (int i = 0; i < positions.length; i++) positions[i] = buf.getFloat();
return new SculptedMesh(lcx, lcy, lcz, positions);
}
public static void delete(int cx, int cy, int cz) throws IOException {
Files.deleteIfExists(getPath(cx, cy, cz));
}
/**
* Gibt alle Chunks zurück, für die ein gebackenes LOD0-Mesh ({@code voxel_*_baked_lod0.j3o})
* existiert. Jeder Eintrag ist ein int[]{cx, cy, cz}.
*/
public static List<int[]> findAllBakedChunks() {
List<int[]> result = new ArrayList<>();
Path dir = ChunkTerrainIO.chunksDir();
if (!Files.isDirectory(dir)) return result;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "voxel_*_baked_lod0.j3o")) {
for (Path p : ds) {
String name = p.getFileName().toString()
.replace("voxel_", "").replace("_baked_lod0.j3o", "");
String[] parts = name.split("_");
if (parts.length != 3) continue;
try {
int cx = Integer.parseInt(parts[0]);
int cy = parts[1].startsWith("m")
? -Integer.parseInt(parts[1].substring(1))
: Integer.parseInt(parts[1]);
int cz = Integer.parseInt(parts[2]);
result.add(new int[]{cx, cy, cz});
} catch (NumberFormatException ignored) {}
}
} catch (IOException ignored) {}
return result;
}
}

View File

@@ -67,6 +67,26 @@ public final class VoxelChunk {
public boolean isEmpty() { return density == null; }
public void clear() { density = null; material = null; dirty = true; }
/**
* Gibt die Y-Ausdehnung (in Voxel) der soliden Voxel zurück.
* 0 = keine soliden Voxel; 1 = alle soliden Voxel auf einer Y-Ebene (flache Schicht).
* Chunks mit Span < 2 erzeugen nur eine flache Mesh-Fläche und sollen nicht gerendert werden.
*/
public int solidYSpan() {
if (density == null) return 0;
int minY = SIZE, maxY = -1;
for (int i = 0; i < density.length; i++) {
if (density[i] > 0) {
int y = i / (SIZE * SIZE);
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
return maxY < 0 ? 0 : maxY - minY + 1;
}
// ── Kugelförmiger Pinsel ──────────────────────────────────────────────────
/**
@@ -99,6 +119,92 @@ public final class VoxelChunk {
}
}
/**
* Verringert die Dichte aller soliden Voxel innerhalb des Radius graduell um {@code step}.
* Voxel mit Dichte <= 0 werden nicht berührt. Gedacht für das Tunnel-Werkzeug.
*/
public void reduceDensity(float localX, float localY, float localZ, float radius, int step) {
if (density == null) return;
int x0 = Math.max(0, (int)(localX - radius));
int x1 = Math.min(SIZE-1, (int)Math.ceil(localX + radius));
int y0 = Math.max(0, (int)(localY - radius));
int y1 = Math.min(SIZE-1, (int)Math.ceil(localY + radius));
int z0 = Math.max(0, (int)(localZ - radius));
int z1 = Math.min(SIZE-1, (int)Math.ceil(localZ + radius));
float r2 = radius * radius;
boolean changed = false;
for (int y = y0; y <= y1; y++) {
float dy = y - localY;
for (int z = z0; z <= z1; z++) {
float dz = z - localZ;
for (int x = x0; x <= x1; x++) {
float dx = x - localX;
if (dx*dx + dy*dy + dz*dz > r2) continue;
int i = idx(x, y, z);
int d = density[i];
if (d <= 0) continue;
density[i] = (byte) Math.max(Byte.MIN_VALUE, d - step);
changed = true;
}
}
}
if (changed) dirty = true;
}
/**
* Entfernt vollständig isolierte solide Voxel (alle 6 Flächennachbarn == Luft)
* in der gegebenen Region + 1 Voxel Rand. Randvoxel des Chunks (Index 0/SIZE-1)
* werden übersprungen um Cross-Chunk-Artefakte zu vermeiden.
*/
public void pruneIsolated(float localX, float localY, float localZ, float radius) {
if (density == null) return;
int x0 = Math.max(1, (int)(localX - radius) - 1);
int x1 = Math.min(SIZE-2, (int)Math.ceil(localX + radius) + 1);
int y0 = Math.max(1, (int)(localY - radius) - 1);
int y1 = Math.min(SIZE-2, (int)Math.ceil(localY + radius) + 1);
int z0 = Math.max(1, (int)(localZ - radius) - 1);
int z1 = Math.min(SIZE-2, (int)Math.ceil(localZ + radius) + 1);
boolean changed = false;
for (int y = y0; y <= y1; y++) {
for (int z = z0; z <= z1; z++) {
for (int x = x0; x <= x1; x++) {
if (density[idx(x, y, z)] <= 0) continue;
if (density[idx(x+1,y,z)] <= 0 && density[idx(x-1,y,z)] <= 0 &&
density[idx(x,y+1,z)] <= 0 && density[idx(x,y-1,z)] <= 0 &&
density[idx(x,y,z+1)] <= 0 && density[idx(x,y,z-1)] <= 0) {
density[idx(x, y, z)] = Byte.MIN_VALUE;
changed = true;
}
}
}
}
if (changed) dirty = true;
}
/** Gibt eine Kopie des Dichte-Arrays zurück, oder null wenn der Chunk leer ist. */
public byte[] getDensityCopy() {
return density != null ? density.clone() : null;
}
/** Setzt das Dichte-Array direkt (für Undo/Redo). */
public void setDensityArray(byte[] d) {
this.density = d;
dirty = true;
}
/**
* Füllt eine dünne horizontale Platte (ly0..ly1) als Solid (127), alles andere Luft.
* Setzt dirty nicht.
*/
public void fillThinSlab(int ly0, int ly1) {
if (density == null) density = new byte[SIZE * SIZE * SIZE];
Arrays.fill(density, Byte.MIN_VALUE);
for (int y = ly0; y <= ly1; y++)
for (int z = 0; z < SIZE; z++)
for (int x = 0; x < SIZE; x++)
density[idx(x, y, z)] = (byte) 127;
}
// ── Serialisierung ────────────────────────────────────────────────────────
public byte[] serialize() throws IOException {

View File

@@ -25,6 +25,10 @@ public final class VoxelChunkIO {
return Files.exists(getPath(cx, cy, cz));
}
public static void delete(int cx, int cy, int cz) throws IOException {
Files.deleteIfExists(getPath(cx, cy, cz));
}
public static void save(VoxelChunk chunk) throws IOException {
Path p = getPath(chunk.cx, chunk.cy, chunk.cz);
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
@@ -54,6 +58,22 @@ public final class VoxelChunkIO {
return Files.exists(getBakedPath(cx, cy, cz, 0));
}
/**
* true wenn das gebackene LOD0-Mesh existiert UND nicht älter als die
* .blvc-Quelldatei ist. Verhindert, dass veraltete Bakes nach einer
* Editor-Bearbeitung weiter genutzt werden.
*/
public static boolean bakedIsFresh(int cx, int cy, int cz) {
try {
Path baked = getBakedPath(cx, cy, cz, 0);
if (!Files.exists(baked)) return false;
return Files.getLastModifiedTime(baked).compareTo(
Files.getLastModifiedTime(getPath(cx, cy, cz))) >= 0;
} catch (IOException e) {
return false;
}
}
/**
* Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis.
* Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert.

View File

@@ -0,0 +1,43 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
public class Bed implements Interactable {
private String id;
private TextReference name;
private BedType bedType = BedType.Single;
/** Liegeposition Mittelpunkt der 1,8m Fläche. */
private float liegeX = 0f;
private float liegeZ = 0f;
/** Terrain-Höhe am Mittelpunkt (wird beim Platzieren gesetzt). */
private float liegeY = 0f;
/** Rotation um die Y-Achse in Radiant; Pfeilspitze = Kopfende. */
private float liegeRotY = 0f;
/** Gibt an, ob eine Liegefläche bereits definiert wurde. */
private boolean liegeSet = false;
public enum BedType {
Single,
Double;
}
public Bed() {
this.id = UUID.randomUUID().toString();
}
public Bed(String id) {
this.id = id;
}
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, id != null ? id : "Bett");
}
}

View File

@@ -0,0 +1,95 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import de.blight.common.MapIO;
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.Optional;
/**
* Speichert Bett-Daten als {@code beds/<uuid>.bed}-JSON neben der Kartendatei.
*/
public final class BedIO {
private static final Logger log = LoggerFactory.getLogger(BedIO.class);
private static final String EXTENSION = ".bed";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private BedIO() {}
public static Path getDir() {
return MapIO.getMapPath().resolveSibling("beds");
}
public static void save(Bed bed) throws IOException {
if (bed.getId() == null) throw new IOException("Bett ohne ID kann nicht gespeichert werden.");
Path dir = getDir();
Files.createDirectories(dir);
Files.writeString(dir.resolve(bed.getId() + EXTENSION),
GSON.toJson(toDto(bed)), StandardCharsets.UTF_8);
}
public static Optional<Bed> load(String id) {
Path file = getDir().resolve(id + EXTENSION);
if (!Files.exists(file)) return Optional.empty();
try {
Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class);
return Optional.of(fromDto(dto, id));
} catch (IOException e) {
log.warn("[BedIO] Fehler beim Laden von {}: {}", id, e.getMessage());
return Optional.empty();
}
}
public static void delete(String id) throws IOException {
Files.deleteIfExists(getDir().resolve(id + EXTENSION));
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(Bed b) {
Dto dto = new Dto();
dto.id = b.getId();
dto.bedType = b.getBedType() != null ? b.getBedType().name() : null;
dto.nameId = b.getName() != null ? b.getName().id() : null;
dto.liegeX = b.getLiegeX();
dto.liegeY = b.getLiegeY();
dto.liegeZ = b.getLiegeZ();
dto.liegeRotY = b.getLiegeRotY();
dto.liegeSet = b.isLiegeSet();
return dto;
}
private static Bed fromDto(Dto dto, String fallbackId) {
Bed b = new Bed(dto.id != null ? dto.id : fallbackId);
if (dto.bedType != null) {
try { b.setBedType(Bed.BedType.valueOf(dto.bedType)); }
catch (IllegalArgumentException ignored) {}
}
if (dto.nameId != null && !dto.nameId.isBlank())
b.setName(new TextReference(dto.nameId));
b.setLiegeX(dto.liegeX);
b.setLiegeY(dto.liegeY);
b.setLiegeZ(dto.liegeZ);
b.setLiegeRotY(dto.liegeRotY);
b.setLiegeSet(dto.liegeSet);
return b;
}
private static class Dto {
String id;
String bedType;
String nameId;
float liegeX;
float liegeY;
float liegeZ;
float liegeRotY;
boolean liegeSet;
}
}

View File

@@ -0,0 +1,44 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
public class Bench implements Interactable {
private String id;
private TextReference name;
public enum BenchType {
Simple,
Long;
}
private BenchType benchType = BenchType.Simple;
/** Sitzposition Mittelpunkt der 0,5m Fläche. */
private float sitzX = 0f;
private float sitzZ = 0f;
/** Terrain-Höhe am Mittelpunkt. */
private float sitzY = 0f;
/** Rotation um die Y-Achse in Radiant; Pfeilspitze = Blickrichtung beim Sitzen. */
private float sitzRotY = 0f;
/** Gibt an, ob eine Sitzfläche bereits definiert wurde. */
private boolean sitzSet = false;
public Bench() {
this.id = UUID.randomUUID().toString();
}
public Bench(String id) {
this.id = id;
}
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, id != null ? id : "Bank");
}
}

View File

@@ -0,0 +1,95 @@
package de.blight.common.model;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import de.blight.common.MapIO;
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.Optional;
/**
* Speichert Bank-Daten als {@code benches/<uuid>.bench}-JSON neben der Kartendatei.
*/
public final class BenchIO {
private static final Logger log = LoggerFactory.getLogger(BenchIO.class);
private static final String EXTENSION = ".bench";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private BenchIO() {}
public static Path getDir() {
return MapIO.getMapPath().resolveSibling("benches");
}
public static void save(Bench bench) throws IOException {
if (bench.getId() == null) throw new IOException("Bank ohne ID kann nicht gespeichert werden.");
Path dir = getDir();
Files.createDirectories(dir);
Files.writeString(dir.resolve(bench.getId() + EXTENSION),
GSON.toJson(toDto(bench)), StandardCharsets.UTF_8);
}
public static Optional<Bench> load(String id) {
Path file = getDir().resolve(id + EXTENSION);
if (!Files.exists(file)) return Optional.empty();
try {
Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class);
return Optional.of(fromDto(dto, id));
} catch (IOException e) {
log.warn("[BenchIO] Fehler beim Laden von {}: {}", id, e.getMessage());
return Optional.empty();
}
}
public static void delete(String id) throws IOException {
Files.deleteIfExists(getDir().resolve(id + EXTENSION));
}
// ── DTO ───────────────────────────────────────────────────────────────────
private static Dto toDto(Bench b) {
Dto dto = new Dto();
dto.id = b.getId();
dto.benchType = b.getBenchType() != null ? b.getBenchType().name() : null;
dto.nameId = b.getName() != null ? b.getName().id() : null;
dto.sitzX = b.getSitzX();
dto.sitzY = b.getSitzY();
dto.sitzZ = b.getSitzZ();
dto.sitzRotY = b.getSitzRotY();
dto.sitzSet = b.isSitzSet();
return dto;
}
private static Bench fromDto(Dto dto, String fallbackId) {
Bench b = new Bench(dto.id != null ? dto.id : fallbackId);
if (dto.benchType != null) {
try { b.setBenchType(Bench.BenchType.valueOf(dto.benchType)); }
catch (IllegalArgumentException ignored) {}
}
if (dto.nameId != null && !dto.nameId.isBlank())
b.setName(new TextReference(dto.nameId));
b.setSitzX(dto.sitzX);
b.setSitzY(dto.sitzY);
b.setSitzZ(dto.sitzZ);
b.setSitzRotY(dto.sitzRotY);
b.setSitzSet(dto.sitzSet);
return b;
}
private static class Dto {
String id;
String benchType;
String nameId;
float sitzX;
float sitzY;
float sitzZ;
float sitzRotY;
boolean sitzSet;
}
}

View File

@@ -12,18 +12,25 @@ import lombok.Setter;
*/
@Getter
@Setter
public class CraftingTable {
public class CraftingTable implements Interactable {
private TextReference name;
private ObjectReference object;
private CraftingTableType type;
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, type != null ? type.name() : "?");
}
public enum CraftingTableType {
AlchemyTable,
EnchantmentTable,
Smithy,
Goldsmiths,
Workshop;
Workshop,
Fireplace,
Kitchen;
}
}

View File

@@ -0,0 +1,22 @@
package de.blight.common.model;
public enum InteractableType {
NONE("Keines"),
CRAFTING_TABLE("Handwerkstisch"),
BED("Bett"),
BENCH("Bank");
private final String label;
InteractableType(String label) {
this.label = label;
}
public String getLabel() { return label; }
public static InteractableType fromString(String s) {
if (s == null || s.isBlank()) return NONE;
try { return valueOf(s); }
catch (IllegalArgumentException e) { return NONE; }
}
}

View File

@@ -1,5 +1,6 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
@@ -17,51 +18,88 @@ public class NPC extends GameCharacter {
private Status status;
private boolean trader;
private Fraction fraction;
private List<Item> items;
private List<DialogOption> currentOptions;
/** Tagesabläufe dieses NPCs. Die erste Routine gilt standardmäßig als aktiv. */
private List<NpcRoutine> routines = new ArrayList<>();
/**
* Name der aktuell aktiven Routine. {@code null} oder kein Treffer
* → erste Routine in der Liste ist aktiv.
* Wird zur Laufzeit per {@link de.blight.common.model.trigger.ChangeRoutineTrigger} gesetzt.
*/
private String activeRoutineName;
// ── Routine-Hilfsmethoden ────────────────────────────────────────────────
/**
* Liefert die aktive Routine: die mit {@link #activeRoutineName} oder,
* als Fallback, die erste in der Liste.
*/
public NpcRoutine getActiveRoutine() {
if (routines == null || routines.isEmpty()) return null;
if (activeRoutineName != null) {
for (NpcRoutine r : routines)
if (activeRoutineName.equals(r.getName())) return r;
}
return routines.get(0);
}
/** Setzt die aktive Routine per Objekt. */
public void setCurrentRoutine(NpcRoutine routine) {
this.activeRoutineName = (routine != null) ? routine.getName() : null;
}
/** Setzt die aktive Routine direkt per Name (Kurzform für Trigger-Laufzeit). */
public void setCurrentRoutine(String routineName) {
this.activeRoutineName = routineName;
}
// ── Dialog-Methoden ──────────────────────────────────────────────────────
public List<DialogOption> getAvailableOptions(MainCharacter character) {
return currentOptions.stream().filter(option ->
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");
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;
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");
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");
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");
LOG.warn("Dialog Option was choosen but required Quest is not complete");
return false;
}
currentOptions.remove(option);
currentOptions.removeAll(option.getDisablesOptions());
currentOptions.addAll(option.getNextOptions());
character.handleDialogOption(option);
if (option.isEnablesTrade()) {
this.trader = true;
}
return true;
}
}

View File

@@ -0,0 +1,67 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
/**
* Ein benannter 24-Stunden-Tagesablauf für einen NPC.
*
* Mehrere Routinen können pro NPC definiert werden; welche aktiv ist,
* bestimmt die Spielmechanik zur Laufzeit.
*
* Gültigkeit: Die Blöcke müssen zusammen alle 24 Stunden abdecken
* (ohne Lücken, ohne Überlappung). Dies wird durch {@link #validate()}
* geprüft.
*/
public class NpcRoutine {
private String name;
private List<RoutineBlock> blocks = new ArrayList<>();
public NpcRoutine() { this.name = "Routine"; }
public NpcRoutine(String name) { this.name = name; }
// ── Getter / Setter ──────────────────────────────────────────────────────
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public List<RoutineBlock> getBlocks() {
if (blocks == null) blocks = new ArrayList<>();
return blocks;
}
public void setBlocks(List<RoutineBlock> b) { this.blocks = b; }
/**
* Prüft ob alle 24 Stunden abgedeckt sind (keine Lücken, keine Überlappungen).
*
* @return null wenn gültig, sonst Fehlermeldung
*/
public String validate() {
if (blocks == null) return "Keine Blöcke definiert.";
boolean[] covered = new boolean[24];
for (RoutineBlock b : blocks) {
for (int h = 0; h < 24; h++) {
if (!b.covers(h)) continue;
if (covered[h]) return "Stunde " + h + ":00 ist mehrfach belegt.";
covered[h] = true;
}
}
for (int h = 0; h < 24; h++) {
if (!covered[h]) return "Stunde " + h + ":00 ist nicht belegt.";
}
return null;
}
/** Anzahl abgedeckter Stunden (0-24). */
public int coveredHours() {
if (blocks == null) return 0;
boolean[] covered = new boolean[24];
for (RoutineBlock b : blocks)
for (int h = 0; h < 24; h++)
if (b.covers(h)) covered[h] = true;
int count = 0;
for (boolean c : covered) if (c) count++;
return count;
}
}

View File

@@ -0,0 +1,133 @@
package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
/**
* Beschreibt was ein NPC zu einem bestimmten Zeitblock tut.
* Alle Felder ausser {@code type} sind optional welche genutzt werden
* hängt vom Typ ab.
*/
public class RoutineActivity {
public enum Type {
/** Sitzen an einem Interactable oder Bodenpunkt. */
SIT,
/** Stehen an einem Weltpunkt. */
STAND,
/** Gespräch mit einem anderen NPC an einem Weltpunkt. */
TALK,
/** Patrouille entlang mehrerer Wegpunkte. */
PATROL,
/** Arbeiten an einem Interactable. */
WORK,
/** Schlafen an einem Interactable. */
SLEEP
}
private Type type;
/** Bodenpunkt (SIT-Boden, STAND, TALK). */
private WorldPoint position;
/** UUID eines platzierten Objekts (SIT-Interactable, WORK, SLEEP). */
private String objectUuid;
/** Anzeigename / Beschreibung des Objekts nur für UI, nicht normativ. */
private String objectLabel;
/** Ziel-NPC-ID für TALK. */
private String talkNpcId;
/** Wegpunkte für PATROL. */
private List<WorldPoint> waypoints;
// ── Factory-Methoden ─────────────────────────────────────────────────────
public static RoutineActivity sit(WorldPoint groundPos) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SIT;
a.position = groundPos;
return a;
}
public static RoutineActivity sitInteractable(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SIT;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
public static RoutineActivity stand(WorldPoint pos) {
RoutineActivity a = new RoutineActivity();
a.type = Type.STAND;
a.position = pos;
return a;
}
public static RoutineActivity talk(WorldPoint pos, String npcId) {
RoutineActivity a = new RoutineActivity();
a.type = Type.TALK;
a.position = pos;
a.talkNpcId = npcId;
return a;
}
public static RoutineActivity patrol(List<WorldPoint> pts) {
RoutineActivity a = new RoutineActivity();
a.type = Type.PATROL;
a.waypoints = new ArrayList<>(pts);
return a;
}
public static RoutineActivity work(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.WORK;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
public static RoutineActivity sleep(String uuid, String label) {
RoutineActivity a = new RoutineActivity();
a.type = Type.SLEEP;
a.objectUuid = uuid;
a.objectLabel = label;
return a;
}
// ── Getter / Setter ──────────────────────────────────────────────────────
public Type getType() { return type; }
public void setType(Type t) { this.type = t; }
public WorldPoint getPosition() { return position; }
public void setPosition(WorldPoint p) { this.position = p; }
public String getObjectUuid() { return objectUuid; }
public void setObjectUuid(String uuid) { this.objectUuid = uuid; }
public String getObjectLabel() { return objectLabel; }
public void setObjectLabel(String l) { this.objectLabel = l; }
public String getTalkNpcId() { return talkNpcId; }
public void setTalkNpcId(String id) { this.talkNpcId = id; }
public List<WorldPoint> getWaypoints() { return waypoints; }
public void setWaypoints(List<WorldPoint> wp) { this.waypoints = wp; }
/** Menschenlesbare Kurzdarstellung für Listen-Einträge. */
public String summary() {
if (type == null) return "";
return switch (type) {
case SIT -> objectUuid != null ? "Sitzen @ " + shortLabel() : "Sitzen " + posStr();
case STAND -> "Stehen " + posStr();
case TALK -> "Reden mit " + (talkNpcId != null ? talkNpcId : "?") + " " + posStr();
case PATROL -> "Patrouille (" + (waypoints != null ? waypoints.size() : 0) + " Punkte)";
case WORK -> "Arbeiten @ " + shortLabel();
case SLEEP -> "Schlafen @ " + shortLabel();
};
}
private String posStr() {
return position != null ? position.toString() : "(?)";
}
private String shortLabel() {
if (objectLabel != null && !objectLabel.isBlank()) return objectLabel;
if (objectUuid != null && objectUuid.length() >= 8)
return objectUuid.substring(0, 8) + "";
return "?";
}
}

View File

@@ -0,0 +1,56 @@
package de.blight.common.model;
/**
* Zeitblock innerhalb eines Tagesablaufs.
*
* {@code startHour} und {@code endHour} sind ganzzahlige Stunden (023).
* Ein Block, der über Mitternacht geht (z.B. 2206), ist erlaubt:
* in diesem Fall ist {@code endHour < startHour}.
*
* Die Dauer berechnet sich als:
* endHour >= startHour → endHour - startHour
* endHour < startHour → 24 - startHour + endHour
*/
public class RoutineBlock {
private int startHour; // 0-23
private int endHour; // 0-23 (exklusiv: Block endet um endHour:00)
private RoutineActivity activity;
public RoutineBlock() {}
public RoutineBlock(int startHour, int endHour, RoutineActivity activity) {
this.startHour = startHour;
this.endHour = endHour;
this.activity = activity;
}
// ── Getter / Setter ──────────────────────────────────────────────────────
public int getStartHour() { return startHour; }
public void setStartHour(int h) { this.startHour = h; }
public int getEndHour() { return endHour; }
public void setEndHour(int h) { this.endHour = h; }
public RoutineActivity getActivity() { return activity; }
public void setActivity(RoutineActivity a) { this.activity = a; }
/** Anzahl der durch diesen Block abgedeckten Stunden. */
public int durationHours() {
if (endHour > startHour) return endHour - startHour;
if (endHour < startHour) return 24 - startHour + endHour;
return 0;
}
/** Gibt true wenn die gegebene Stunde (0-23) in diesem Block liegt. */
public boolean covers(int hour) {
if (endHour > startHour) return hour >= startHour && hour < endHour;
// Über-Mitternacht-Block
return hour >= startHour || hour < endHour;
}
/** Formatiert den Block als "HH:00 HH:00 | Aktivität". */
public String displayLabel() {
String act = activity != null ? activity.summary() : "";
return String.format("%02d:00 %02d:00 %s", startHour, endHour, act);
}
}

View File

@@ -32,6 +32,18 @@ public final class TextRegistry {
return entries.getOrDefault(ref.id(), fallback);
}
/**
* Löst zuerst {@code ref} auf; fehlt die Ref, wird {@code key} direkt nachgeschlagen;
* fehlt auch der Eintrag für {@code key}, wird {@code fallback} zurückgegeben.
*/
public static String resolve(TextReference ref, String key, String fallback) {
if (ref != null && ref.id() != null && entries.containsKey(ref.id()))
return entries.get(ref.id());
if (key != null && entries.containsKey(key))
return entries.get(key);
return fallback;
}
/** Direkter Zugriff für den Editor (alle Einträge). */
public static Map<String, String> getAll() {
return new HashMap<>(entries);

View File

@@ -0,0 +1,17 @@
package de.blight.common.model;
/** Weltposition (x, y, z in Metern). y = 0 bedeutet "Terrain-Höhe verwenden". */
public class WorldPoint {
public float x, y, z;
public WorldPoint() {}
public WorldPoint(float x, float y, float z) {
this.x = x; this.y = y; this.z = z;
}
@Override
public String toString() {
return String.format("(%.1f, %.1f, %.1f)", x, y, z);
}
}

View File

@@ -0,0 +1,33 @@
package de.blight.common.model.trigger;
import de.blight.common.model.MainCharacter;
import lombok.Getter;
import lombok.Setter;
/**
* Ändert den aktiven Tagesablauf eines NPCs.
*
* Die eigentliche NPC-Suche zur Laufzeit obliegt der Game-Registry.
* Gespeichert werden nur IDs, keine Objektreferenzen.
*/
@Getter
@Setter
public class ChangeRoutineTrigger extends Trigger {
/** Character-ID des NPCs, dessen Routine geändert werden soll. */
private String npcId;
/** Name der Routine, die aktiviert werden soll. */
private String routineName;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
return npcId != null && !npcId.isBlank()
&& routineName != null && !routineName.isBlank();
}
@Override
public void trigger(MainCharacter character) {
// Laufzeit: NPC per ID aus der Game-Registry holen und
// npc.setCurrentRoutine(routineName) aufrufen.
}
}

View File

@@ -27,6 +27,7 @@ public final class TriggerIO {
public static final String TYPE_QUEST_START = "QUEST_START";
public static final String TYPE_NPC_STATUS = "NPC_STATUS";
public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS";
public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE";
private static final Gson GSON = new GsonBuilder()
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
@@ -78,6 +79,9 @@ public final class TriggerIO {
obj.addProperty("fractionId", f.getFractionId().toString());
if (f.getTargetStatus() != null)
obj.addProperty("targetStatus", f.getTargetStatus().name());
} else if (src instanceof ChangeRoutineTrigger r) {
if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId());
if (r.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName());
}
return obj;
}
@@ -113,6 +117,12 @@ public final class TriggerIO {
if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus")));
yield f;
}
case TYPE_CHANGE_ROUTINE -> {
ChangeRoutineTrigger r = new ChangeRoutineTrigger();
if (obj.has("npcId")) r.setNpcId(obj.get("npcId").getAsString());
if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString());
yield r;
}
default -> null;
};
@@ -125,6 +135,7 @@ public final class TriggerIO {
if (t instanceof QuestStartTrigger) return TYPE_QUEST_START;
if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS;
if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS;
if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE;
return "UNKNOWN";
}

View File

@@ -0,0 +1,32 @@
package de.blight.common.path;
/** Ungerichtete Kante zwischen zwei Knoten des Wegnetzes. */
public class PathEdge {
private final String nodeUuidA;
private final String nodeUuidB;
public PathEdge(String nodeUuidA, String nodeUuidB) {
this.nodeUuidA = nodeUuidA;
this.nodeUuidB = nodeUuidB;
}
public String getNodeUuidA() { return nodeUuidA; }
public String getNodeUuidB() { return nodeUuidB; }
public boolean connects(String uuidA, String uuidB) {
return (nodeUuidA.equals(uuidA) && nodeUuidB.equals(uuidB))
|| (nodeUuidA.equals(uuidB) && nodeUuidB.equals(uuidA));
}
public boolean involves(String uuid) {
return nodeUuidA.equals(uuid) || nodeUuidB.equals(uuid);
}
/** Gibt den jeweils anderen Endpunkt zurück, oder null wenn uuid nicht enthalten. */
public String other(String uuid) {
if (nodeUuidA.equals(uuid)) return nodeUuidB;
if (nodeUuidB.equals(uuid)) return nodeUuidA;
return null;
}
}

View File

@@ -0,0 +1,227 @@
package de.blight.common.path;
import de.blight.common.model.WorldPoint;
import java.util.*;
/**
* Wegnetz bestehend aus Knoten ({@link PathNode}) und Kanten ({@link PathEdge}).
*
* <p>Pathfinding-Modell:
* <ol>
* <li>Nächsten Netzknoten zu {@code from} suchen → Off-Netz-Segment 1</li>
* <li>A* entlang der Kanten zum Netzknoten nächst {@code to} → Netz-Segment</li>
* <li>Off-Netz-Segment 2: letzter Netzknoten → {@code to}</li>
* </ol>
* Die Hindernisvermeidung auf Segmenten 1 und 3 obliegt dem Bewegungssystem
* der Game-Engine (z. B. Steering-Behaviors + Raycasting).
*/
public class PathNetwork {
private final List<PathNode> nodes = new ArrayList<>();
private final List<PathEdge> edges = new ArrayList<>();
// ── Nodes ──────────────────────────────────────────────────────────────────
public List<PathNode> getNodes() { return nodes; }
public List<PathEdge> getEdges() { return edges; }
public PathNode nodeById(String uuid) {
for (PathNode n : nodes)
if (n.getUuid().equals(uuid)) return n;
return null;
}
public void addNode(PathNode node) {
nodes.add(node);
}
public void removeNode(String uuid) {
nodes.removeIf(n -> n.getUuid().equals(uuid));
edges.removeIf(e -> e.involves(uuid));
}
// ── Edges ──────────────────────────────────────────────────────────────────
/** Fügt eine Kante ein. Duplikate (gleiche Knotenpaare) werden ignoriert. */
public boolean addEdge(String uuidA, String uuidB) {
if (uuidA.equals(uuidB)) return false;
for (PathEdge e : edges)
if (e.connects(uuidA, uuidB)) return false;
edges.add(new PathEdge(uuidA, uuidB));
return true;
}
public void removeEdge(String uuidA, String uuidB) {
edges.removeIf(e -> e.connects(uuidA, uuidB));
}
public List<PathNode> neighbors(String uuid) {
List<PathNode> result = new ArrayList<>();
for (PathEdge e : edges) {
String other = e.other(uuid);
if (other != null) {
PathNode n = nodeById(other);
if (n != null) result.add(n);
}
}
return result;
}
// ── Nächster Knoten ────────────────────────────────────────────────────────
/**
* Liefert den Knoten mit der geringsten horizontalen (X/Z) Distanz zu
* {@code point}, oder {@code null} wenn das Netz leer ist.
*/
public PathNode nearestNode(WorldPoint point) {
PathNode best = null;
float bestDist = Float.MAX_VALUE;
for (PathNode n : nodes) {
float d = n.dist2D(point);
if (d < bestDist) { bestDist = d; best = n; }
}
return best;
}
// ── Pathfinding ────────────────────────────────────────────────────────────
/**
* Berechnet den vollständigen Pfad von {@code from} nach {@code to}.
*
* <p>Rückgabe: geordnete Liste von Weltpunkten, die der NPC abläuft:
* [from, ...Netzknoten..., to].
*
* <p>Wenn das Netz leer ist, wird [from, to] zurückgegeben (direkter Weg).
*/
public List<WorldPoint> findPath(WorldPoint from, WorldPoint to) {
List<WorldPoint> result = new ArrayList<>();
result.add(from);
if (nodes.isEmpty()) {
result.add(to);
return result;
}
PathNode startNode = nearestNode(from);
PathNode endNode = nearestNode(to);
if (startNode == endNode) {
// Start und Ziel sind am gleichen Netzpunkt
result.add(startNode.getPosition());
result.add(to);
return result;
}
List<PathNode> networkPath = astar(startNode, endNode);
for (PathNode n : networkPath)
result.add(n.getPosition());
result.add(to);
return result;
}
/**
* Wie {@link #findPath}, gibt aber die drei Segmente getrennt zurück.
* Nützlich für die Game-Engine, die Off-Netz- und Netz-Bewegung
* unterschiedlich behandelt.
*/
public PathResult findPathSegmented(WorldPoint from, WorldPoint to) {
if (nodes.isEmpty()) {
return new PathResult(List.of(from), List.of(), List.of(to));
}
PathNode startNode = nearestNode(from);
PathNode endNode = nearestNode(to);
List<WorldPoint> offStart = List.of(from, startNode.getPosition());
List<WorldPoint> offEnd = List.of(endNode.getPosition(), to);
if (startNode == endNode) {
return new PathResult(offStart, List.of(startNode.getPosition()), offEnd);
}
List<WorldPoint> networkPts = new ArrayList<>();
for (PathNode n : astar(startNode, endNode))
networkPts.add(n.getPosition());
return new PathResult(offStart, networkPts, offEnd);
}
// ── A* ─────────────────────────────────────────────────────────────────────
private List<PathNode> astar(PathNode start, PathNode goal) {
Map<String, Float> gScore = new HashMap<>();
Map<String, Float> fScore = new HashMap<>();
Map<String, PathNode> cameFrom = new HashMap<>();
PriorityQueue<PathNode> open = new PriorityQueue<>(
Comparator.comparingDouble(n -> fScore.getOrDefault(n.getUuid(), Float.MAX_VALUE)));
gScore.put(start.getUuid(), 0f);
fScore.put(start.getUuid(), start.dist3D(goal));
open.add(start);
while (!open.isEmpty()) {
PathNode current = open.poll();
if (current.getUuid().equals(goal.getUuid()))
return reconstructPath(cameFrom, current);
for (PathNode neighbor : neighbors(current.getUuid())) {
float tentativeG = gScore.getOrDefault(current.getUuid(), Float.MAX_VALUE)
+ current.dist3D(neighbor);
if (tentativeG < gScore.getOrDefault(neighbor.getUuid(), Float.MAX_VALUE)) {
cameFrom.put(neighbor.getUuid(), current);
gScore.put(neighbor.getUuid(), tentativeG);
fScore.put(neighbor.getUuid(), tentativeG + neighbor.dist3D(goal));
open.remove(neighbor);
open.add(neighbor);
}
}
}
// Kein Pfad gefunden direkte Verbindung start→goal als Fallback
return List.of(start, goal);
}
private List<PathNode> reconstructPath(Map<String, PathNode> cameFrom, PathNode current) {
Deque<PathNode> path = new ArrayDeque<>();
path.addFirst(current);
while (cameFrom.containsKey(current.getUuid())) {
current = cameFrom.get(current.getUuid());
path.addFirst(current);
}
return new ArrayList<>(path);
}
// ── Ergebnis-Record ────────────────────────────────────────────────────────
/**
* Dreisegmentiger Pfad:
* <ul>
* <li>{@link #offNetworkStart}: from → erster Netzknoten (Hindernisvermeidung per Steering)</li>
* <li>{@link #networkPath}: Knotenfolge auf dem Wegnetz (A*)</li>
* <li>{@link #offNetworkEnd}: letzter Netzknoten → to (Hindernisvermeidung per Steering)</li>
* </ul>
*/
public record PathResult(
List<WorldPoint> offNetworkStart,
List<WorldPoint> networkPath,
List<WorldPoint> offNetworkEnd
) {
/** Alle Punkte als flache Liste (für einfache Agenten). */
public List<WorldPoint> flatten() {
List<WorldPoint> all = new ArrayList<>();
all.addAll(offNetworkStart);
// Ersten Netzpunkt nicht doppeln (ist bereits letzter Punkt von offNetworkStart)
for (int i = 1; i < networkPath.size(); i++) all.add(networkPath.get(i));
// Letzten Netzpunkt nicht doppeln (ist bereits erster Punkt von offNetworkEnd)
if (!offNetworkEnd.isEmpty() && !networkPath.isEmpty())
for (int i = 1; i < offNetworkEnd.size(); i++) all.add(offNetworkEnd.get(i));
else all.addAll(offNetworkEnd);
return all;
}
}
}

View File

@@ -0,0 +1,85 @@
package de.blight.common.path;
import de.blight.common.MapIO;
import de.blight.common.model.WorldPoint;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Locale;
/**
* Liest und schreibt das Wegnetz.
*
* Datei: {@code blight_paths.blp} neben {@code blight_map.blm}.
*
* Format (TSV):
* <pre>
* # Blight Path Network
* NODE uuid name x y z
* EDGE uuid1 uuid2
* </pre>
* Leerzeilen und Zeilen die mit # beginnen werden ignoriert.
* Name-Feld darf leer sein, wird dann als "-" gespeichert.
*/
public final class PathNetworkIO {
private PathNetworkIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_paths.blp");
}
public static void save(PathNetwork net) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p, StandardCharsets.UTF_8)) {
w.write("# Blight Path Network"); w.newLine();
w.write("# NODE uuid name x y z"); w.newLine();
w.write("# EDGE uuid1 uuid2"); w.newLine();
for (PathNode n : net.getNodes()) {
String name = (n.getName() == null || n.getName().isBlank()) ? "-" : n.getName();
w.write(String.format(Locale.ROOT, "NODE\t%s\t%s\t%.5f\t%.5f\t%.5f%n",
n.getUuid(), name,
n.getPosition().x, n.getPosition().y, n.getPosition().z));
}
for (PathEdge e : net.getEdges()) {
w.write(String.format("EDGE\t%s\t%s%n", e.getNodeUuidA(), e.getNodeUuidB()));
}
}
}
public static PathNetwork load() throws IOException {
PathNetwork net = new PathNetwork();
Path p = getPath();
if (!Files.exists(p)) return net;
for (String line : Files.readAllLines(p, StandardCharsets.UTF_8)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length == 0) continue;
try {
switch (f[0]) {
case "NODE" -> {
if (f.length < 6) break;
String uuid = f[1];
String name = "-".equals(f[2]) ? "" : f[2];
float x = Float.parseFloat(f[3]);
float y = Float.parseFloat(f[4]);
float z = Float.parseFloat(f[5]);
net.addNode(new PathNode(uuid, name, new WorldPoint(x, y, z)));
}
case "EDGE" -> {
if (f.length < 3) break;
net.addEdge(f[1], f[2]);
}
}
} catch (NumberFormatException ignored) {}
}
return net;
}
}

View File

@@ -0,0 +1,48 @@
package de.blight.common.path;
import de.blight.common.model.WorldPoint;
import java.util.UUID;
/** Einzelner Knoten im Wegnetz. Position wird auf Terrain-Höhe gesnapped. */
public class PathNode {
private final String uuid;
private String name;
private WorldPoint position;
public PathNode(WorldPoint position) {
this(UUID.randomUUID().toString(), "", position);
}
public PathNode(String uuid, String name, WorldPoint position) {
this.uuid = uuid;
this.name = name;
this.position = position;
}
public String getUuid() { return uuid; }
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public WorldPoint getPosition() { return position; }
public void setPosition(WorldPoint p) { this.position = p; }
/** Horizontale Distanz (X/Z) zu einem anderen Punkt. */
public float dist2D(WorldPoint other) {
float dx = position.x - other.x;
float dz = position.z - other.z;
return (float) Math.sqrt(dx * dx + dz * dz);
}
/** 3D-Distanz zum anderen Knoten. */
public float dist3D(PathNode other) {
float dx = position.x - other.position.x;
float dy = position.y - other.position.y;
float dz = position.z - other.position.z;
return (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
}
@Override public String toString() {
return name.isBlank() ? uuid.substring(0, 8) + "" : name;
}
}

View File

@@ -15,6 +15,7 @@ application {
'--add-opens', 'java.base/java.lang=ALL-UNNAMED',
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
"-Djava.library.path=${buildDir}/natives",
'-Xmx3g',
]
}

View File

@@ -80,7 +80,7 @@ public class EditorApp extends Application {
private VBox assetPanel;
private MapObjectsView mapObjectsView;
private StackPane worldViewport;
private javafx.scene.canvas.Canvas minimapCanvas;
private javafx.scene.canvas.Canvas compassCanvas;
private VBox topBar; // MenuBar + aktuelle Toolbar
private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons)
private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt
@@ -151,11 +151,15 @@ public class EditorApp extends Application {
// AnimSet-Editor
private ListView<String> animSetClipListView;
private ListView<String> animSetActionListView;
private ListView<String> animSetSinkListView;
private ListView<String> animSetAnchorBoneListView;
private String animSetPendingPlayClip = null;
private ComboBox<String> animSetModelCombo;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
private java.util.List<String> animJointNames = new java.util.ArrayList<>();
private Label animSetBonesLabel;
// Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView;
@@ -238,6 +242,11 @@ public class EditorApp extends Application {
private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>();
private javafx.scene.layout.VBox modelEditorLightBox = null;
private javafx.scene.layout.VBox modelEditorEmitterBox = null;
private ComboBox<de.blight.common.model.InteractableType> modelEditorInteractableCB = null;
private Spinner<Double> modelEditorInteractableXSpin = null;
private Spinner<Double> modelEditorInteractableYSpin = null;
private Spinner<Double> modelEditorInteractableZSpin = null;
private boolean updatingInteractableSpinnersFromJme = false;
// Modell-Import-Zustand
private Label modelImportLod1StatusLabel;
@@ -443,6 +452,19 @@ public class EditorApp extends Application {
animClipListView.getItems().setAll(newClips);
if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst();
}
java.util.List<String> newJoints = input.animPreviewJointNames.getAndSet(null);
if (newJoints != null) {
animJointNames = new java.util.ArrayList<>(newJoints);
if (animSetBonesLabel != null) {
if (animJointNames.isEmpty()) {
animSetBonesLabel.setText("Kein Armature gefunden");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #c66;");
} else {
animSetBonesLabel.setText(animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #6a6;");
}
}
}
// AnimSet-Editor: nach Clip-Load automatisch abspielen
if (newClips != null && animSetPendingPlayClip != null) {
input.animPreviewPlayClip = animSetPendingPlayClip;
@@ -457,6 +479,11 @@ public class EditorApp extends Application {
input.animPreviewStatus = null;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus);
}
if (input.animImportCompleted) {
input.animImportCompleted = false;
refreshAddAnimCombo(addAnimComboField);
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
}
String animOp = input.animOpStatus;
if (animOp != null) {
@@ -475,6 +502,19 @@ public class EditorApp extends Application {
randomTreeStatusLabel.setText(rts);
}
// Modell-Editor: Ruhepunkt-Spinner nach Raycast-Klick (JME→JFX)
if (input.modelInteractablePosSetFromJme && modelEditorInteractableXSpin != null) {
input.modelInteractablePosSetFromJme = false;
updatingInteractableSpinnersFromJme = true;
modelEditorInteractableXSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetX * 100.0) / 100.0);
modelEditorInteractableYSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetY * 100.0) / 100.0);
modelEditorInteractableZSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetZ * 100.0) / 100.0);
updatingInteractableSpinnersFromJme = false;
}
if (input.refreshAssets) {
input.refreshAssets = false;
boolean pe = shouldPreserveExpansion();
@@ -527,7 +567,7 @@ public class EditorApp extends Application {
updateWaterHeightDisplay(input.waterCurrentHeight);
}
drawMinimap();
drawCompass();
// Spiel-Konsole: gepufferte Zeilen gebündelt ausgeben (max 200 auf einmal)
if (!consoleBuffer.isEmpty() && gameConsoleArea != null) {
@@ -4762,6 +4802,38 @@ public class EditorApp extends Application {
} catch (IOException ignored) {}
}
/** Liest die AnimClip-Namen aus einer J3O-Datei, ohne den JME3-Thread zu benötigen. */
private List<String> readAnimClipNames(Path j3oPath) {
try {
com.jme3.export.binary.BinaryImporter imp = new com.jme3.export.binary.BinaryImporter();
com.jme3.scene.Spatial s = (com.jme3.scene.Spatial) imp.load(j3oPath.toFile());
com.jme3.anim.AnimComposer ac =
de.blight.game.animation.RetargetingSystem.findAnimComposer(s);
if (ac == null) return List.of();
List<String> names = new ArrayList<>(ac.getAnimClipsNames());
names.sort(String.CASE_INSENSITIVE_ORDER);
return names;
} catch (Exception e) {
return List.of();
}
}
/** Hängt Clip-Namen als Unterknoten unter jeden J3O-Eintrag im Animations-Teilbaum. */
private void addAnimClipSubNodes(TreeItem<String> node) {
for (TreeItem<String> child : node.getChildren()) {
Path p = itemPaths.get(child);
if (p != null && !Files.isDirectory(p)
&& p.getFileName().toString().toLowerCase().endsWith(".j3o")) {
child.getChildren().clear();
for (String clip : readAnimClipNames(p)) {
child.getChildren().add(new TreeItem<>(clip));
}
} else if (p != null && Files.isDirectory(p)) {
addAnimClipSubNodes(child);
}
}
}
/**
* Löscht Thumbnail und Impostor-Textur, die zu einer .j3o-Datei gehören.
* Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt.
@@ -4835,7 +4907,10 @@ public class EditorApp extends Application {
loadJmeTexturesInto(jmeTexturesNode);
}
case "audio" -> audioNode = node;
case "animations" -> animationsNode = node;
case "animations" -> {
animationsNode = node;
addAnimClipSubNodes(node);
}
case "items" -> itemsNode = node;
}
}
@@ -4849,6 +4924,7 @@ public class EditorApp extends Application {
catNode.getChildren().clear();
Path dir = itemPaths.get(catNode);
if (dir != null) loadAssetsRecursive(catNode, dir);
if (catNode == animationsNode) addAnimClipSubNodes(catNode);
if (catNode == modelsNode && jmeModelsNode != null)
catNode.getChildren().add(jmeModelsNode);
if (catNode == texturesNode && jmeTexturesNode != null)
@@ -5025,6 +5101,7 @@ public class EditorApp extends Application {
String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures";
TreeItem<String> parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode;
archiveOriginal(file, isModel ? "models" : isAudio ? "audio" : "textures");
try {
Path destDir = ASSET_ROOT.resolve(subDir);
@@ -5663,7 +5740,15 @@ public class EditorApp extends Application {
input.modelEditorPivotY = meta.pivotOffsetY();
input.modelEditorOpenPath = relPath;
root.setRight(buildModelEditorPanel(relPath, absolutePath, meta));
VBox modelEditorInner = buildModelEditorPanel(relPath, absolutePath, meta);
ScrollPane modelEditorScroll = new ScrollPane(modelEditorInner);
modelEditorScroll.setFitToWidth(true);
modelEditorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
modelEditorScroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;");
VBox modelEditorOuter = new VBox(modelEditorScroll);
VBox.setVgrow(modelEditorScroll, Priority.ALWAYS);
modelEditorOuter.setPrefWidth(300);
root.setRight(modelEditorOuter);
setStatus("Modell-Editor: " + relPath);
}
@@ -5895,6 +5980,150 @@ public class EditorApp extends Application {
// Initiale Gizmos pushen
pushAttachmentsToJme();
// ── Interaktivität ────────────────────────────────────────────────────
Label interactTitle = new Label("Interaktivität:");
interactTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;");
modelEditorInteractableCB = new ComboBox<>();
modelEditorInteractableCB.getItems().addAll(de.blight.common.model.InteractableType.values());
modelEditorInteractableCB.setValue(meta.interactableType());
modelEditorInteractableCB.setMaxWidth(Double.MAX_VALUE);
modelEditorInteractableCB.setConverter(new javafx.util.StringConverter<>() {
@Override public String toString(de.blight.common.model.InteractableType t) {
return t == null ? "" : t.getLabel();
}
@Override public de.blight.common.model.InteractableType fromString(String s) {
return de.blight.common.model.InteractableType.fromString(s);
}
});
// Ruhepunkt-Controls (nur sichtbar wenn BED oder BENCH gewählt)
// SharedInput mit bestehenden Meta-Werten initialisieren
input.modelInteractableOffsetX = meta.interactableOffsetX();
input.modelInteractableOffsetY = meta.interactableOffsetY();
input.modelInteractableOffsetZ = meta.interactableOffsetZ();
input.modelInteractableRotY = meta.interactableRotY();
input.modelInteractableOffsetChanged = true;
Label restPosHint = new Label("Klicke auf das Modell um den Ruhepunkt zu setzen:");
restPosHint.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;");
restPosHint.setWrapText(true);
Button setRestBtn = new Button("⊕ Im Modell klicken");
setRestBtn.setMaxWidth(Double.MAX_VALUE);
setRestBtn.setStyle("-fx-background-color:#1a5276; -fx-text-fill:#fff;");
setRestBtn.setOnAction(ev -> {
input.activeLayer = SharedInput.LAYER_MODEL_INTERACTABLE;
setStatus("Klicke auf das Modell um den Ruhepunkt zu setzen");
});
// Position-Spinner (X / Y / Z)
Label posLabel = new Label("Position (Modell-Koordinaten):");
posLabel.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;");
modelEditorInteractableXSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05));
modelEditorInteractableXSpin.setEditable(true);
modelEditorInteractableXSpin.setPrefWidth(100);
modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetX = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
modelEditorInteractableYSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0,
Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05));
modelEditorInteractableYSpin.setEditable(true);
modelEditorInteractableYSpin.setPrefWidth(100);
modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetY = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
modelEditorInteractableZSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05));
modelEditorInteractableZSpin.setEditable(true);
modelEditorInteractableZSpin.setPrefWidth(100);
modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetZ = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane();
posGrid.setHgap(4); posGrid.setVgap(2);
posGrid.add(new Label("X:"), 0, 0); posGrid.add(modelEditorInteractableXSpin, 1, 0);
posGrid.add(new Label("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1);
posGrid.add(new Label("Z:"), 0, 2); posGrid.add(modelEditorInteractableZSpin, 1, 2);
posGrid.getChildren().stream()
.filter(n -> n instanceof Label)
.forEach(n -> ((Label) n).setStyle("-fx-text-fill:#aaa;"));
Label rotLabel = new Label("Blickrichtung (°):");
rotLabel.setStyle("-fx-text-fill:#aaa;");
double initDeg = Math.toDegrees(meta.interactableRotY());
Slider rotSlider = new Slider(0, 360, initDeg);
rotSlider.setShowTickMarks(true);
rotSlider.setMajorTickUnit(90);
rotSlider.setPrefWidth(180);
TextField rotField = new TextField(String.format("%.1f", initDeg));
rotField.setPrefWidth(60);
rotField.setStyle("-fx-background-color:#333; -fx-text-fill:#eee;");
boolean[] syncingRot = {false};
rotSlider.valueProperty().addListener((obs, ov, nv) -> {
if (syncingRot[0]) return;
syncingRot[0] = true;
rotField.setText(String.format("%.1f", nv.doubleValue()));
syncingRot[0] = false;
input.modelInteractableRotY = (float) Math.toRadians(nv.doubleValue());
input.modelInteractableOffsetChanged = true;
});
Runnable applyRotField = () -> {
try {
double deg = Double.parseDouble(rotField.getText().replace(',', '.'));
deg = ((deg % 360) + 360) % 360;
if (syncingRot[0]) return;
syncingRot[0] = true;
rotSlider.setValue(deg);
syncingRot[0] = false;
input.modelInteractableRotY = (float) Math.toRadians(deg);
input.modelInteractableOffsetChanged = true;
} catch (NumberFormatException ignored) {}
};
rotField.setOnAction(e -> applyRotField.run());
rotField.focusedProperty().addListener((obs, ov, nv) -> { if (!nv) applyRotField.run(); });
HBox rotRow = new HBox(6, rotSlider, rotField);
rotRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
javafx.scene.layout.VBox restPointBox = new javafx.scene.layout.VBox(4,
restPosHint, setRestBtn, posLabel, posGrid,
rotLabel, rotRow);
restPointBox.setStyle("-fx-padding: 4 0 0 0;");
boolean isBedOrBench = meta.interactableType() == de.blight.common.model.InteractableType.BED
|| meta.interactableType() == de.blight.common.model.InteractableType.BENCH;
restPointBox.setVisible(isBedOrBench);
restPointBox.setManaged(isBedOrBench);
modelEditorInteractableCB.valueProperty().addListener((obs, ov, nv) -> {
boolean show = nv == de.blight.common.model.InteractableType.BED
|| nv == de.blight.common.model.InteractableType.BENCH;
restPointBox.setVisible(show);
restPointBox.setManaged(show);
// Pfeil ein-/ausblenden — über SharedInput-Flag signalisieren
input.modelInteractableOffsetChanged = show;
});
// ── Buttons ───────────────────────────────────────────────────────────
Button saveBtn = new Button("💾 Speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
@@ -5926,7 +6155,14 @@ public class EditorApp extends Application {
(float)(double) rndMaxSpin.getValue(),
modelEditorLod1Path, modelEditorLod2Path,
new java.util.ArrayList<>(modelEditorLights),
new java.util.ArrayList<>(modelEditorEmitters)));
new java.util.ArrayList<>(modelEditorEmitters),
modelEditorInteractableCB != null
? modelEditorInteractableCB.getValue()
: de.blight.common.model.InteractableType.NONE,
input.modelInteractableOffsetX,
input.modelInteractableOffsetY,
input.modelInteractableOffsetZ,
input.modelInteractableRotY));
placeBtn.setOnAction(e -> {
input.modelEditorCloseRequest = true;
@@ -5967,6 +6203,8 @@ public class EditorApp extends Application {
lightSectionLbl, modelEditorLightBox, addLightBtn,
emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn,
new Separator(),
interactTitle, modelEditorInteractableCB, restPointBox,
new Separator(),
saveBtn, placeBtn, closeBtn
);
return panel;
@@ -5994,6 +6232,7 @@ public class EditorApp extends Application {
}
private void startImportFile(File file) {
archiveOriginal(file, "models");
String name = file.getName();
String baseName = name.replaceFirst("\\.[^.]+$", "");
Path destDir = ASSET_ROOT.resolve("Models").resolve("imported");
@@ -6415,12 +6654,17 @@ public class EditorApp extends Application {
float rndMin, float rndMax,
String lod1Path, String lod2Path,
java.util.List<de.blight.common.ModelMeta.AttachedLight> lights,
java.util.List<de.blight.common.ModelMeta.AttachedEmitter> emitters) {
java.util.List<de.blight.common.ModelMeta.AttachedEmitter> emitters,
de.blight.common.model.InteractableType interactableType,
float interactableOffsetX, float interactableOffsetY,
float interactableOffsetZ, float interactableRotY) {
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
name, category, tags, sx, sy, sz, uniform,
pivotY, placeY, solid, cast, receive, rndMin, rndMax,
lod1Path, lod2Path, 30f, 80f, 120f,
lights, emitters);
lights, emitters,
interactableType != null ? interactableType : de.blight.common.model.InteractableType.NONE,
interactableOffsetX, interactableOffsetY, interactableOffsetZ, interactableRotY);
if (absolutePath == null || !absolutePath.toFile().exists()) {
setStatus("Fehler: Modell-Datei nicht gefunden Meta nicht gespeichert");
@@ -6659,13 +6903,13 @@ public class EditorApp extends Application {
viewport.setPreserveRatio(false);
viewport.setFocusTraversable(true);
minimapCanvas = new javafx.scene.canvas.Canvas(164, 164);
minimapCanvas.setMouseTransparent(true);
StackPane.setAlignment(minimapCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
StackPane.setMargin(minimapCanvas, new Insets(0, 10, 10, 0));
drawMinimap(); // Initiales Zeichnen (leer)
compassCanvas = new javafx.scene.canvas.Canvas(100, 100);
compassCanvas.setMouseTransparent(true);
StackPane.setAlignment(compassCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
StackPane.setMargin(compassCanvas, new Insets(0, 10, 10, 0));
drawCompass();
StackPane pane = new StackPane(viewport, minimapCanvas);
StackPane pane = new StackPane(viewport, compassCanvas);
pane.setStyle("-fx-background-color: #1a1a2e;");
javafx.animation.PauseTransition resizeDebounce =
@@ -6846,6 +7090,11 @@ public class EditorApp extends Application {
}
case SharedInput.LAYER_VOXEL ->
input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y, action));
case SharedInput.LAYER_MODEL_INTERACTABLE -> {
if (action > 0)
input.modelInteractableClickQueue.offer(
new SharedInput.ModelInteractableClick((float) x, (float) y));
}
}
}
@@ -7006,61 +7255,72 @@ public class EditorApp extends Application {
gameConsoleArea.setScrollTop(Double.MAX_VALUE);
}
// ── Minimap ───────────────────────────────────────────────────────────────
// ── Kompass ───────────────────────────────────────────────────────────────
private static final float WORLD_HALF = 2048f; // Welt geht von -2048 bis +2048
private void drawCompass() {
if (compassCanvas == null) return;
final double S = compassCanvas.getWidth(); // 100
final double cx = S / 2.0;
final double cy = S / 2.0;
final double R = cx - 3; // Radius bis zum Rand
private void drawMinimap() {
if (minimapCanvas == null) return;
final double SIZE = minimapCanvas.getWidth(); // 164
final double INNER = SIZE - 4; // 160 innere Kartenfläche
final double OFFSET = 2;
javafx.scene.canvas.GraphicsContext gc = compassCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, S, S);
javafx.scene.canvas.GraphicsContext gc = minimapCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, SIZE, SIZE);
// Hintergrund: dunkler Kreis
gc.setFill(javafx.scene.paint.Color.rgb(12, 12, 24, 0.82));
gc.fillOval(2, 2, S - 4, S - 4);
gc.setStroke(javafx.scene.paint.Color.rgb(100, 100, 160, 0.85));
gc.setLineWidth(1.5);
gc.strokeOval(2, 2, S - 4, S - 4);
// Hintergrund + Rahmen
gc.setFill(javafx.scene.paint.Color.rgb(10, 10, 20, 0.75));
gc.fillRoundRect(0, 0, SIZE, SIZE, 6, 6);
gc.setStroke(javafx.scene.paint.Color.rgb(120, 120, 180, 0.8));
gc.setLineWidth(1);
gc.strokeRoundRect(0.5, 0.5, SIZE - 1, SIZE - 1, 6, 6);
// Rotierende Rose: -camYaw so dass die aktuelle Blickrichtung oben erscheint
gc.save();
gc.translate(cx, cy);
gc.rotate(-input.camYaw);
// Hilfslinien (Weltmitte)
gc.setStroke(javafx.scene.paint.Color.rgb(80, 80, 110, 0.5));
gc.setLineWidth(0.5);
double mid = OFFSET + INNER / 2.0;
gc.strokeLine(mid, OFFSET, mid, OFFSET + INNER);
gc.strokeLine(OFFSET, mid, OFFSET + INNER, mid);
// Kameraposition (blauer Pfeil/Dreieck)
if (Float.isFinite(input.camX) && Float.isFinite(input.camZ)) {
double mx = worldToMap(input.camX, INNER, OFFSET);
double mz = worldToMap(input.camZ, INNER, OFFSET);
double yaw = Math.toRadians(input.camYaw); // Yaw: 0 = Norden, positiv = Uhrzeigersinn
// Tick-Striche an 8 Positionen (45°-Schritte)
for (int i = 0; i < 8; i++) {
double a = Math.toRadians(i * 45.0);
double len = (i % 2 == 0) ? 9 : 5;
gc.setStroke(javafx.scene.paint.Color.rgb(160, 160, 200, 0.65));
gc.setLineWidth(i % 2 == 0 ? 1.5 : 1.0);
gc.strokeLine(
Math.sin(a) * (R - len), -Math.cos(a) * (R - len),
Math.sin(a) * R, -Math.cos(a) * R);
}
// Himmelsrichtungen (N/E/S/W), Text durch Gegenrotation immer lesbar
String[] dirs = {"N", "E", "S", "W"};
double[] angles = {0.0, 90.0, 180.0, 270.0};
for (int i = 0; i < 4; i++) {
double a = Math.toRadians(angles[i]);
double tx = Math.sin(a) * (R - 16);
double ty = -Math.cos(a) * (R - 16);
gc.save();
gc.translate(mx, mz);
gc.rotate(Math.toDegrees(yaw));
gc.setFill(javafx.scene.paint.Color.rgb(80, 160, 255, 0.95));
gc.setStroke(javafx.scene.paint.Color.WHITE);
gc.setLineWidth(0.8);
// Kleines Dreieck zeigt Blickrichtung
double[] px = { 0, -4.5, 4.5 };
double[] pz = { -7, 5, 5 };
gc.fillPolygon(px, pz, 3);
gc.strokePolygon(px, pz, 3);
gc.translate(tx, ty);
gc.rotate(input.camYaw); // Gegenrotation zur Rose → Buchstabe immer aufrecht
gc.setFill(i == 0
? javafx.scene.paint.Color.rgb(255, 80, 80)
: javafx.scene.paint.Color.rgb(210, 210, 230));
gc.setFont(javafx.scene.text.Font.font(
"System", javafx.scene.text.FontWeight.BOLD, i == 0 ? 13 : 11));
gc.fillText(dirs[i], -4.5, 5.0);
gc.restore();
}
// Beschriftung
gc.setFill(javafx.scene.paint.Color.rgb(180, 180, 220, 0.7));
gc.setFont(javafx.scene.text.Font.font(8));
gc.fillText("N", mid - 3, OFFSET + 9);
}
gc.restore(); // Ende rotierende Rose
private static double worldToMap(float world, double innerSize, double offset) {
return offset + (world + WORLD_HALF) / (WORLD_HALF * 2) * innerSize;
// Fixer Richtungszeiger oben (gelbes Dreieck, zeigt immer die Blickrichtung)
gc.setFill(javafx.scene.paint.Color.rgb(255, 220, 60, 0.95));
gc.fillPolygon(
new double[]{cx, cx - 5, cx + 5},
new double[]{cy - R + 5, cy - R + 16, cy - R + 16},
3);
// Mittelpunkt
gc.setFill(javafx.scene.paint.Color.rgb(200, 200, 240, 0.85));
gc.fillOval(cx - 2.5, cy - 2.5, 5, 5);
}
private void saveCameraPrefs() {
@@ -7970,6 +8230,10 @@ public class EditorApp extends Application {
.forEach(animSetModelCombo.getItems()::add);
} catch (IOException ignored) {}
}
// Gespeicherten Modell-Pfad vorauswählen
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
animSetModelCombo.setValue(animSet.getPreviewModelPath());
}
Button loadModelBtn = new Button("Laden");
loadModelBtn.setMaxWidth(Double.MAX_VALUE);
loadModelBtn.setOnAction(e -> {
@@ -7977,9 +8241,18 @@ public class EditorApp extends Application {
if (path == null || path.isBlank()) return;
input.animPreviewLoadPath = path;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
// Pfad im AnimSet merken und sofort speichern
animSet.setPreviewModelPath(path);
animSetDirty = true;
});
inner.getChildren().addAll(animSetModelCombo, loadModelBtn);
// Modell beim Öffnen automatisch laden, wenn Pfad bekannt
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
input.animPreviewLoadPath = animSet.getPreviewModelPath();
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
}
// ── Clips im Set ─────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clips im Set"), new Separator());
@@ -8045,14 +8318,15 @@ public class EditorApp extends Application {
ListView<String> list = new ListView<>();
list.getItems().addAll(notYetAdded);
list.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
list.setPrefHeight(Math.min(notYetAdded.size() * 26 + 4, 320));
list.setPrefSize(460, 320);
list.setMinSize(460, 320);
list.getSelectionModel().selectFirst();
javafx.scene.control.Dialog<java.util.List<String>> dlg = new javafx.scene.control.Dialog<>();
dlg.setTitle("Animation(en) hinzufügen");
dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:");
dlg.getDialogPane().setContent(list);
dlg.getDialogPane().setPrefWidth(360);
dlg.getDialogPane().setPrefWidth(500);
javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType("Hinzufügen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
@@ -8114,6 +8388,197 @@ public class EditorApp extends Application {
HBox.setHgrow(removeActionBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetActionListView, actionBtns);
// ── Bone-Anchoring ────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Bone-Anchoring"), new Separator());
Label anchorHint = new Label("Pro Aktion: Knochen angeben, der auf seiner Welt-Y fixiert bleibt (z. B. SIT_DOWN → foot.l). Hat Vorrang vor manuellem Sink.");
anchorHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
anchorHint.setWrapText(true);
animSetBonesLabel = new Label(animJointNames.isEmpty() ? "Kein Modell geladen" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#888;" : "#6a6;"));
animSetAnchorBoneListView = new ListView<>();
animSetAnchorBoneListView.setPrefHeight(110);
if (animSet.getAnchorBoneMap() != null) {
for (var e2 : animSet.getAnchorBoneMap().entrySet()) {
animSetAnchorBoneListView.getItems().add(e2.getKey() + "" + e2.getValue());
}
}
Button addAnchorBtn = new Button("+ Hinzufügen…");
Button removeAnchorBtn = new Button("- Entfernen");
addAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setDisable(true);
animSetAnchorBoneListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeAnchorBtn.setDisable(nv == null));
addAnchorBtn.setOnAction(e -> {
// Pending joint names aus JME3-Thread abholen (falls Timer sie noch nicht konsumiert hat)
java.util.List<String> fresh = input.animPreviewJointNames.getAndSet(null);
if (fresh != null) {
animJointNames = new java.util.ArrayList<>(fresh);
if (animSetBonesLabel != null) {
animSetBonesLabel.setText(animJointNames.isEmpty() ? "Kein Armature gefunden" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#c66;" : "#6a6;"));
}
}
ComboBox<de.blight.game.animation.AnimationAction> anchorActionCombo = new ComboBox<>();
anchorActionCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> acf =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
anchorActionCombo.setCellFactory(acf);
anchorActionCombo.setButtonCell(acf.call(null));
anchorActionCombo.setMaxWidth(Double.MAX_VALUE);
anchorActionCombo.getSelectionModel().selectFirst();
// Joint-Auswahl: ComboBox mit geladenen Namen, editierbar als Fallback
ComboBox<String> boneCombo = new ComboBox<>();
boneCombo.setEditable(true);
boneCombo.setMaxWidth(Double.MAX_VALUE);
if (animJointNames.isEmpty()) {
boneCombo.setPromptText("Joint-Name (erst Modell laden)");
} else {
boneCombo.getItems().addAll(animJointNames);
boneCombo.setPromptText("Joint auswählen…");
}
javafx.scene.layout.GridPane anchorGrid = new javafx.scene.layout.GridPane();
anchorGrid.setHgap(8); anchorGrid.setVgap(6);
anchorGrid.add(new Label("Aktion:"), 0, 0); anchorGrid.add(anchorActionCombo, 1, 0);
anchorGrid.add(new Label("Joint-Name:"), 0, 1); anchorGrid.add(boneCombo, 1, 1);
javafx.scene.layout.ColumnConstraints anchorCc = new javafx.scene.layout.ColumnConstraints();
anchorCc.setHgrow(Priority.ALWAYS);
anchorGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), anchorCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> anchorDlg = new javafx.scene.control.Dialog<>();
anchorDlg.setTitle("Bone-Anchoring konfigurieren");
javafx.scene.control.ButtonType okAnchor = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
anchorDlg.getDialogPane().getButtonTypes().addAll(okAnchor, javafx.scene.control.ButtonType.CANCEL);
anchorDlg.getDialogPane().setContent(anchorGrid);
anchorDlg.showAndWait().ifPresent(bt -> {
if (bt != okAnchor) {
return;
}
var selAction = anchorActionCombo.getValue();
String bone = boneCombo.getEditor().getText();
if (selAction == null || bone == null || bone.isBlank()) {
return;
}
String newEntry = selAction.name() + "" + bone.trim();
animSetAnchorBoneListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetAnchorBoneListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeAnchorBtn.setOnAction(e -> {
String sel = animSetAnchorBoneListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetAnchorBoneListView.getItems().remove(sel);
animSetDirty = true;
}
});
HBox anchorBtns = new HBox(6, addAnchorBtn, removeAnchorBtn);
HBox.setHgrow(addAnchorBtn, Priority.ALWAYS);
HBox.setHgrow(removeAnchorBtn, Priority.ALWAYS);
inner.getChildren().addAll(anchorHint, animSetBonesLabel, animSetAnchorBoneListView, anchorBtns);
// ── Sink-Konfiguration (Fallback) ─────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Manueller Sink-Fallback"), new Separator());
Label sinkHint = new Label("Root-Motion-Ersatz: Körper senkt/hebt sich während der Animation.\nNegativ = nach unten (Setzen), Positiv = nach oben.");
sinkHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
sinkHint.setWrapText(true);
animSetSinkListView = new ListView<>();
animSetSinkListView.setPrefHeight(120);
if (animSet.getSinkMap() != null) {
for (var e2 : animSet.getSinkMap().entrySet()) {
animSetSinkListView.getItems().add(e2.getKey() + "" + e2.getValue());
}
}
Button addSinkBtn = new Button("+ Setzen…");
Button removeSinkBtn = new Button("- Entfernen");
addSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setDisable(true);
animSetSinkListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeSinkBtn.setDisable(nv == null));
addSinkBtn.setOnAction(e -> {
ComboBox<de.blight.game.animation.AnimationAction> actionSinkCombo = new ComboBox<>();
actionSinkCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> cf2 =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
actionSinkCombo.setCellFactory(cf2);
actionSinkCombo.setButtonCell(cf2.call(null));
actionSinkCombo.setMaxWidth(Double.MAX_VALUE);
actionSinkCombo.getSelectionModel().selectFirst();
Spinner<Double> sinkSpinner = new Spinner<>(-3.0, 3.0, 0.0, 0.05);
sinkSpinner.setEditable(true);
sinkSpinner.setMaxWidth(Double.MAX_VALUE);
javafx.scene.layout.GridPane sinkGrid = new javafx.scene.layout.GridPane();
sinkGrid.setHgap(8); sinkGrid.setVgap(6);
sinkGrid.add(new Label("Aktion:"), 0, 0); sinkGrid.add(actionSinkCombo, 1, 0);
sinkGrid.add(new Label("Versatz (m):"), 0, 1); sinkGrid.add(sinkSpinner, 1, 1);
javafx.scene.layout.ColumnConstraints sinkCc = new javafx.scene.layout.ColumnConstraints();
sinkCc.setHgrow(Priority.ALWAYS);
sinkGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), sinkCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> sinkDlg = new javafx.scene.control.Dialog<>();
sinkDlg.setTitle("Sink-Wert setzen");
javafx.scene.control.ButtonType okSink = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
sinkDlg.getDialogPane().getButtonTypes().addAll(okSink, javafx.scene.control.ButtonType.CANCEL);
sinkDlg.getDialogPane().setContent(sinkGrid);
sinkDlg.showAndWait().ifPresent(bt -> {
if (bt != okSink) {
return;
}
var selAction = actionSinkCombo.getValue();
if (selAction == null) {
return;
}
double val = sinkSpinner.getValue();
String newEntry = selAction.name() + "" + val;
// Bestehenden Eintrag für diese Aktion ersetzen
animSetSinkListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetSinkListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeSinkBtn.setOnAction(e -> {
String sel = animSetSinkListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetSinkListView.getItems().remove(sel);
animSetDirty = true;
}
});
HBox sinkBtns = new HBox(6, addSinkBtn, removeSinkBtn);
HBox.setHgrow(addSinkBtn, Priority.ALWAYS);
HBox.setHgrow(removeSinkBtn, Priority.ALWAYS);
inner.getChildren().addAll(sinkHint, animSetSinkListView, sinkBtns);
// ── Vorschau ─────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
@@ -8182,13 +8647,21 @@ public class EditorApp extends Application {
return;
}
animSetPendingPlayClip = clip;
// Clip zur aktuell geladenen Figur hinzufügen (nicht als Modell laden).
// Nach Abschluss setzt AnimPreviewState animPreviewClips, das den
// animSetPendingPlayClip-Trigger auslöst und den Clip abspielt.
input.animPreviewAddAnimPath = "animations/clips/" + clip + ".j3o";
input.animImportQueue.offer(findAnimClipPath(clip));
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade Clip " + clip + "");
}
private String findAnimClipPath(String clipName) {
Path animDir = ASSET_ROOT.resolve("animations");
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (java.nio.file.Files.exists(animDir.resolve("clips").resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
if (java.nio.file.Files.exists(animDir.resolve(clipName + ext)))
return "animations/" + clipName + ext;
}
return "animations/clips/" + clipName + ".j3o";
}
private void showAddActionToSetDialog() {
if (animSetClipListView == null || animSetClipListView.getItems().isEmpty()) {
setStatus("Keine Clips im Set — erst Clips hinzufügen.");
@@ -8254,16 +8727,47 @@ public class EditorApp extends Application {
}
private void saveCurrentAnimSet(String setName, Path setDir) {
if (animSetClipListView == null) return;
if (animSetClipListView == null) {
return;
}
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(new java.util.ArrayList<>(animSetClipListView.getItems()));
java.util.Map<String, String> actionMap = new java.util.LinkedHashMap<>();
if (animSetActionListView != null)
if (animSetActionListView != null) {
for (String it : animSetActionListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) actionMap.put(parts[0], parts[1]);
if (parts.length == 2) {
actionMap.put(parts[0], parts[1]);
}
}
}
animSet.setActionMap(actionMap);
java.util.Map<String, Float> sinkMap = new java.util.LinkedHashMap<>();
if (animSetSinkListView != null) {
for (String it : animSetSinkListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
try {
sinkMap.put(parts[0], Float.parseFloat(parts[1]));
} catch (NumberFormatException ignored) {}
}
}
}
animSet.setSinkMap(sinkMap);
java.util.Map<String, String> anchorBoneMap = new java.util.LinkedHashMap<>();
if (animSetAnchorBoneListView != null) {
for (String it : animSetAnchorBoneListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
anchorBoneMap.put(parts[0], parts[1]);
}
}
}
animSet.setAnchorBoneMap(anchorBoneMap);
// Vorschau-Modell-Pfad beibehalten
if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue());
}
try {
animSet.save(setDir, setName);
setStatus("AnimSet gespeichert: " + setName + ".animset.json");
@@ -8443,7 +8947,7 @@ public class EditorApp extends Application {
animPreviewStatusLabel.setText("Bitte eine Animation auswählen");
return;
}
input.animPreviewAddAnimPath = animPath;
input.animImportQueue.offer(animPath);
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…");
});
inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn);
@@ -8492,6 +8996,23 @@ public class EditorApp extends Application {
if (current != null && combo.getItems().contains(current)) combo.setValue(current);
}
/**
* Kopiert die Original-Quelldatei nach {@code <projekt-root>/assets/imported/<assetType>/}.
* Dient als Archiv vor jeder Konvertierung; Fehler werden nur geloggt.
*/
private void archiveOriginal(File source, String assetType) {
Path dest = ProjectRoot.PATH.resolve("assets").resolve("imported").resolve(assetType);
try {
Files.createDirectories(dest);
Files.copy(source.toPath(), dest.resolve(source.getName()),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
log.info("[Import] Archiviert: {}/{}", assetType, source.getName());
} catch (IOException ex) {
log.warn("[Import] Archivierung fehlgeschlagen ({}/{}): {}",
assetType, source.getName(), ex.getMessage());
}
}
private void handleAnimationImport(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Animation importieren (GLB/GLTF)");
@@ -8500,19 +9021,12 @@ public class EditorApp extends Application {
var files = fc.showOpenMultipleDialog(owner);
if (files == null) return;
for (File file : files) {
try {
Path destDir = ASSET_ROOT.resolve("animations").resolve("clips");
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());
}
archiveOriginal(file, "animations");
// Absoluten Pfad übergeben kein Kopieren nötig.
// addAnimation() lädt direkt vom Ursprungsort und speichert nur J3O nach clips/.
input.animImportQueue.offer(file.getAbsolutePath());
setStatus("Importiere: " + file.getName() + "");
}
// Sofort im JavaFX-Thread aktualisieren keine Konvertierung nötig
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
refreshAddAnimCombo(addAnimComboField);
}
private void reimportModelForPreview(javafx.stage.Window owner) {
@@ -8522,6 +9036,7 @@ public class EditorApp extends Application {
new FileChooser.ExtensionFilter("3D-Modelle (GLTF, GLB)", "*.gltf", "*.glb"));
File file = fc.showOpenDialog(owner);
if (file == null) return;
archiveOriginal(file, "models");
String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null;
Path destDir;

View File

@@ -35,7 +35,10 @@ import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import de.blight.editor.state.VoxelEditorState;
import de.blight.editor.state.SculptedMeshEditorState;
import de.blight.editor.state.ModelImportState;
import de.blight.editor.state.PathNetworkEditorState;
import de.blight.editor.state.RoutineMapState;
import de.blight.game.console.JmeConsole;
import de.blight.game.state.DayNightState;
import javafx.scene.image.WritableImage;
@@ -192,10 +195,13 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new LocationZoneState(input));
stateManager.attach(new RiverEditorState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new RoutineMapState(input));
stateManager.attach(new PathNetworkEditorState(input));
stateManager.attach(new AnimPreviewState(input));
stateManager.attach(new ModelEditorState(input));
stateManager.attach(new ItemPlacementState(input));
stateManager.attach(new VoxelEditorState(input));
stateManager.attach(new SculptedMeshEditorState(input));
stateManager.attach(new ModelImportState(input));
// NaN-sichere Comparatoren einsetzen (verhindern den TimSort-Crash bei kaputten Bounds)

View File

@@ -5,8 +5,10 @@ import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.GrassVertexTool;
import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.StoneTool;
import de.blight.editor.tool.TextureTool;
import de.blight.editor.tool.UpperHeightTool;
import de.blight.editor.tool.SculptMeshTool;
import de.blight.editor.tool.VoxelTool;
import de.blight.editor.tree.PalmOptions;
import de.blight.editor.tree.TreeParams;
@@ -26,8 +28,10 @@ public class SharedInput {
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public final VoxelTool voxelTool = new VoxelTool();
public volatile EditorTool activeTool = heightTool;
public final VoxelTool voxelTool = new VoxelTool();
public final StoneTool stoneTool = new StoneTool();
public final SculptMeshTool sculptTool = new SculptMeshTool();
public volatile EditorTool activeTool = heightTool;
// ── Initialisierungs-Status ───────────────────────────────────────────────
public volatile boolean jmeReady = false;
@@ -273,6 +277,11 @@ public class SharedInput {
/** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */
public volatile String pendingAnimClip = null;
/** JavaFX → JME: Interactable-Typ des selektierten Objekts ("CRAFTING_TABLE"/"BED"/""). null = kein Auftrag. */
public volatile String pendingInteractableType = null;
/** JavaFX → JME: Interactable-ID des selektierten Objekts. null = kein Auftrag. */
public volatile String pendingInteractableId = null;
// Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen
public record ObjectPropertyChange(
float x, float y, float z,
@@ -541,7 +550,7 @@ public class SharedInput {
/** 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;
public final ConcurrentLinkedQueue<String> animImportQueue = new ConcurrentLinkedQueue<>();
/** 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. */
@@ -553,9 +562,14 @@ public class SharedInput {
public volatile boolean animPreviewLoop = true;
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
public volatile String animPreviewStatus = null;
/** JME3 → JavaFX: Signalisiert, dass ein Import abgeschlossen wurde → Combo neu laden. */
public volatile boolean animImportCompleted = false;
/** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewClips = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Joint-Namen des geladenen Armatures (für Bone-Anchoring-Auswahl). */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewJointNames = new java.util.concurrent.atomic.AtomicReference<>();
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
@@ -656,15 +670,29 @@ public class SharedInput {
public volatile String modelEditorLod2Path = "";
public volatile boolean modelEditorLodChanged = false;
/** JFX → JME: Richtung der Hauptlichtquelle in der Vorschau.
* Azimut 0360° (Kompassrichtung), Elevation 090° (Höhe über Horizont). */
public volatile float modelEditorLightAzimuth = 51f;
public volatile float modelEditorLightElevation = 57f;
public volatile boolean modelEditorLightChanged = false;
// ── Voxel-Werkzeug ────────────────────────────────────────────────────────
/** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */
public static final int LAYER_VOXEL = 16;
/** Klick/Drag im Viewport im Voxel-Modus. */
// Klick/Drag im Viewport im Voxel-Modus.
/** action +1 = Linksklick (erhöhen/hinzufügen), -1 = Rechtsklick (senken/entfernen). */
public record VoxelEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<VoxelEdit> voxelEditQueue = new ConcurrentLinkedQueue<>();
/** JFX → JME: Aktions-Grenzen für Undo-Snapshots. */
public volatile boolean voxelActionStarted = false;
public volatile boolean voxelActionFinished = false;
/** JFX → JME: Undo/Redo-Anfragen (Ctrl+Z / Ctrl+Shift+Z). */
public volatile boolean voxelUndoRequested = false;
public volatile boolean voxelRedoRequested = false;
/** JFX → JME: alle Voxel-Chunks als geglättete J3O-Meshes backen. */
public volatile boolean bakeVoxelsRequested = false;
/** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */
@@ -673,6 +701,8 @@ public class SharedInput {
public volatile int bakeTotal = 0;
/** JME → JFX: Status-Meldung nach Abschluss des Backens. */
public volatile String bakeStatusMsg = null;
/** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */
public volatile int blurIterDone = 0;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
public volatile int voxelFlatSlot = -1;
@@ -683,6 +713,9 @@ public class SharedInput {
/** JFX setzt true wenn Voxel-Texturen geändert wurden; JME liest + resettet. */
public volatile boolean voxelTexturesChanged = false;
/** Wenn true, werden beim Aktivieren des Voxel-Layers alle anderen Objekte als Wireframe gerendert. */
public volatile boolean voxelWireframeEnabled = true;
// ── Item-Platzierung ──────────────────────────────────────────────────────
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */
public static final int LAYER_ITEMS = 21;
@@ -720,6 +753,22 @@ public class SharedInput {
/** JME → JFX: Status-Meldung nach LOD-Generierung. */
public volatile String modelLodGenStatus = null;
// ── Steine ────────────────────────────────────────────────────────────────
/** activeLayer==23 → Steine setzen/entfernen */
public static final int LAYER_STONE = 23;
public record StoneEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<StoneEdit> stoneEditQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Rückmeldung nach einem Stein-Operation. */
public volatile String stoneStatusMsg = null;
/** JFX → JME: Reload der Materialien auslösen. */
public volatile boolean stoneTexturesChanged = false;
/** JME → JME: Terrain-Edits, die Stein-Höhen neu berechnen müssen. float[3] = {wx, wz, radius}. */
public final ConcurrentLinkedQueue<float[]> terrainEditedAreas = new ConcurrentLinkedQueue<>();
/** JFX → JME: LOD-Reduktions-Algorithmus. "blight" = Dihedral Edge Collapse, "jme" = JME3 Progressive Mesh. */
public volatile String modelLodAlgorithm = "blight";
@@ -731,4 +780,124 @@ public class SharedInput {
public volatile String modelImportExportName = null;
/** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */
public volatile String modelImportExportStatus = null;
// ── Mesh-Sculpting ───────────────────────────────────────────────────────
/** activeLayer==24 → gebackene Voxel-Meshes direkt sculpten */
public static final int LAYER_SCULPT = 24;
// ── Tagesablauf-Editor ────────────────────────────────────────────────────
/** activeLayer==25 → Tagesablauf-Karte (nur Kamera + Punktauswahl) */
public static final int LAYER_ROUTINE_EDITOR = 25;
/**
* JFX → JME: Punkt auf dem Terrain abgreifen.
* Wert: screenX|screenY. JME liest via Queue, setzt routinePickedPoint.
*/
public record RoutinePointClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<RoutinePointClick> routinePointClickQueue =
new ConcurrentLinkedQueue<>();
/**
* JME → JFX: Welt-Koordinaten des zuletzt geklickten Terrain-Punktes.
* Format: "x|y|z". null = noch kein Ergebnis.
*/
public volatile String routinePickedPoint = null;
public volatile boolean routinePickedChanged = false;
// ── Wegnetz-Editor ────────────────────────────────────────────────────────
/** activeLayer==26 → Wegnetz bearbeiten */
public static final int LAYER_PATH_NETWORK = 26;
/**
* JFX → JME: Klick im Wegnetz-Modus.
* rightClick=true → Löschen, false → Platzieren/Verbinden.
*/
public record PathNetworkClick(float screenX, float screenY, boolean rightClick) {}
public final ConcurrentLinkedQueue<PathNetworkClick> pathNetworkClickQueue =
new ConcurrentLinkedQueue<>();
/** JME → JFX: Wegnetz wurde verändert, UI-Refresh nötig. */
public volatile boolean pathNetworkChanged = false;
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/** activeLayer==27 → Liegefläche eines Bettes platzieren (Terrain-Klick → Mittelpunkt). */
public static final int LAYER_BED_LIEGE = 27;
/** JFX → JME: Klick im Liegeflächen-Platzierungsmodus. */
public record BedLiegeClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<BedLiegeClick> bedLiegeClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Terrain-Punkt, auf den geklickt wurde. Format "x|y|z". null = noch kein Ergebnis. */
public volatile String bedLiegePickResult = null;
public volatile boolean bedLiegePickChanged = false;
/** UUID des Bettes, für das gerade die Liegefläche gesetzt wird. */
public volatile String bedLiegeTargetId = null;
/** JFX → JME: Rotationsänderung der Liegefläche in Radiant. null = kein Auftrag. */
public volatile Float pendingBedLiegeRotY = null;
// ── Bank-Sitzfläche ───────────────────────────────────────────────────────
/** activeLayer==28 → Sitzfläche einer Bank platzieren (Terrain-Klick → Mittelpunkt). */
public static final int LAYER_BENCH_SITZ = 28;
/** JFX → JME: Klick im Sitzflächen-Platzierungsmodus. */
public record BenchSitzClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<BenchSitzClick> benchSitzClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Terrain-Punkt, auf den geklickt wurde. Format "x|y|z". null = noch kein Ergebnis. */
public volatile String benchSitzPickResult = null;
public volatile boolean benchSitzPickChanged = false;
/** UUID der Bank, für die gerade die Sitzfläche gesetzt wird. */
public volatile String benchSitzTargetId = null;
/** JFX → JME: Rotationsänderung der Sitzfläche in Radiant. null = kein Auftrag. */
public volatile Float pendingBenchSitzRotY = null;
// ── Modell-Editor Interactable-Ruhepunkt ──────────────────────────────────
/** activeLayer==29 → Ruhepunkt am Modell platzieren (Klick → lokaler Offset). */
public static final int LAYER_MODEL_INTERACTABLE = 29;
public record ModelInteractableClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<ModelInteractableClick> modelInteractableClickQueue =
new ConcurrentLinkedQueue<>();
/** JME → JFX: aktueller lokaler Offset des Ruhepunkts (Modell-Koordinaten). */
public volatile float modelInteractableOffsetX = 0f;
public volatile float modelInteractableOffsetY = 0.5f;
public volatile float modelInteractableOffsetZ = 0f;
public volatile float modelInteractableRotY = 0f;
public volatile boolean modelInteractableOffsetChanged = false;
/** Gesetzt vom JME-Thread nach Raycast-Klick, damit JFX-Spinner aktualisiert werden. */
public volatile boolean modelInteractablePosSetFromJme = false;
/** Klick/Drag im Viewport im Sculpt-Modus. action 0=links, 1=rechts. */
public record SculptEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<SculptEdit> sculptEditQueue = new ConcurrentLinkedQueue<>();
/** Aktionsgrenzen für Undo-Snapshots. */
public volatile boolean sculptActionStarted = false;
public volatile boolean sculptActionFinished = false;
/** Undo/Redo-Anfragen (Ctrl+Z / Ctrl+Shift+Z). */
public volatile boolean sculptUndoRequested = false;
public volatile boolean sculptRedoRequested = false;
// ── Sculpt-Selektion und -Transform ─────────────────────────────────────
/** -1L = kein Element ausgewählt. JME3 schreibt, JavaFX liest. */
public volatile long selectedSculptKey = -1L;
/** Anzeige-Label für das ausgewählte Element. JME3 schreibt, JavaFX liest. */
public volatile String selectedSculptLabel = null;
/** Verschiebung in Welteinheiten. JavaFX schreibt, JME3 liest+löscht. */
public volatile float sculptTranslateX = 0f;
public volatile float sculptTranslateY = 0f;
public volatile float sculptTranslateZ = 0f;
public volatile boolean sculptApplyTranslate = false;
/** Rotation um Y-Achse in Grad. JavaFX schreibt, JME3 liest+löscht. */
public volatile float sculptRotateDeg = 45f;
public volatile boolean sculptApplyRotate = false;
/** Ausgewähltes Element löschen. JavaFX schreibt, JME3 liest+löscht. */
public volatile boolean sculptDeleteSelected = false;
/** VoxelEditorState setzt true nach Bake-Abschluss → SculptedMeshEditorState scannt neue .j3o-Dateien. */
public volatile boolean sculptRescanNeeded = false;
}

View File

@@ -24,6 +24,8 @@ public class SceneObject extends PlacedObject {
public float lod1Distance = 30f;
public float lod2Distance = 80f;
public float cullDistance = 120f;
public String interactableType = "";
public String interactableId = "";
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
boolean solid) {

View File

@@ -148,9 +148,8 @@ public class AnimPreviewState extends BaseAppState {
else playClip(playClip);
}
String addAnimPath = input.animPreviewAddAnimPath;
String addAnimPath = input.animImportQueue.poll();
if (addAnimPath != null) {
input.animPreviewAddAnimPath = null;
addAnimation(addAnimPath);
}
@@ -276,6 +275,22 @@ public class AnimPreviewState extends BaseAppState {
collectClips(model, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewLoadedPath.set(assetPath);
// Joint-Namen aus dem Armature sammeln und melden
SkinningControl sc = findControl(model, SkinningControl.class);
if (sc != null && sc.getArmature() != null) {
com.jme3.anim.Armature arm = sc.getArmature();
List<String> joints = new ArrayList<>();
for (int ji = 0; ji < arm.getJointCount(); ji++) {
joints.add(arm.getJoint(ji).getName());
}
Collections.sort(joints);
LOG.info("[AnimPreview] Armature: {} joints gefunden", joints.size());
input.animPreviewJointNames.set(Collections.unmodifiableList(joints));
} else {
LOG.warn("[AnimPreview] Kein SkinningControl/Armature gefunden in: {}", assetPath);
input.animPreviewJointNames.set(List.of());
}
if (clips.isEmpty()) {
if (!hasSkeleton(model)) {
input.animPreviewStatus =
@@ -289,8 +304,10 @@ public class AnimPreviewState extends BaseAppState {
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
}
} catch (Exception e) {
LOG.error("[AnimPreview] Ladefehler: {}", assetPath, e);
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
input.animPreviewClips.set(List.of());
input.animPreviewJointNames.set(List.of());
}
}
@@ -397,13 +414,10 @@ public class AnimPreviewState extends BaseAppState {
// ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
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) {
// Kein Modell geladen → kein Retargeting, aber Clip trotzdem als J3O speichern
AnimComposer targetAC = currentModel != null ? findControl(currentModel, AnimComposer.class) : null;
SkinningControl targetSC = currentModel != null ? findControl(currentModel, SkinningControl.class) : null;
if (currentModel != null && targetAC == null) {
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
return;
}
@@ -428,14 +442,14 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
if (srcArm != null) {
LOG.info("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
for (var j : srcArm.getJointList()) LOG.info(" src: {}", j.getName());
LOG.trace("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
for (var j : srcArm.getJointList()) LOG.trace(" src: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl in Quelle!");
}
if (dstArm != null) {
LOG.info("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
for (var j : dstArm.getJointList()) LOG.info(" dst: {}", j.getName());
LOG.trace("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
for (var j : dstArm.getJointList()) LOG.trace(" dst: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl im Modell!");
}
@@ -443,7 +457,7 @@ public class AnimPreviewState extends BaseAppState {
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
if (retarget) {
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
LOG.info("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping);
LOG.trace("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping);
}
java.util.Set<String> srcNames = new java.util.HashSet<>();
@@ -452,6 +466,15 @@ public class AnimPreviewState extends BaseAppState {
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Files.createDirectories(clipsDir);
// Blender exportiert GLB-Dateien oft mit internem Namen "Action" statt dem Dateinamen.
// Bei einer GLB/GLTF-Datei mit genau einem Clip: Dateinamen als Clip-Namen verwenden.
boolean isSingleClipGlb = animAssetPath.matches(".*\\.(glb|gltf)$")
&& sourceAC.getAnimClips().size() == 1;
String fileBaseName = isSingleClipGlb
? java.nio.file.Paths.get(animAssetPath).getFileName().toString()
.replaceFirst("\\.(glb|gltf)$", "")
: null;
int saved = 0;
for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName();
@@ -467,19 +490,38 @@ public class AnimPreviewState extends BaseAppState {
: clip;
if (result == null) continue;
String saveName = (fileBaseName != null) ? fileBaseName : name;
AnimClip toSave = result;
if (!saveName.equals(result.getName())) {
toSave = new AnimClip(saveName);
toSave.setTracks(result.getTracks());
LOG.info("[AnimPreview] Clip '{}' als '{}' gespeichert (Dateiname-Alias)", name, saveName);
}
// Direkt in die Clip-Bibliothek speichern das Modell wird nicht modifiziert
saveClipToFile(result, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(name + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden
targetAC.addAnimClip(result);
saveClipToFile(toSave, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(saveName + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden (wenn geladen)
if (targetAC != null) targetAC.addAnimClip(toSave);
saved++;
}
// Temporäre GLB aus clips/ löschen (nur wenn sie dort drin liegt nicht externe Dateien)
if (saved > 0 && animAssetPath.matches(".*\\.(glb|gltf)$")
&& !Path.of(animAssetPath).isAbsolute()) {
Path srcGlb = ASSET_ROOT.resolve(animAssetPath.replace('/', java.io.File.separatorChar));
try {
java.nio.file.Files.deleteIfExists(srcGlb);
LOG.info("[AnimPreview] Temporäre GLB entfernt: {}", animAssetPath);
} catch (Exception ignored) {}
}
List<String> clips = new ArrayList<>();
collectClips(currentModel, clips);
if (currentModel != null) collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt)");
if (saved > 0) input.animImportCompleted = true;
} catch (Exception e) {
LOG.error("[AnimPreview] Fehler beim Importieren von {}", animAssetPath, e);
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
}
}
@@ -576,7 +618,7 @@ public class AnimPreviewState extends BaseAppState {
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);
BitmapText label = new BitmapText(font);
label.setSize(0.18f);
label.setColor(color);
label.setText(text);
@@ -625,8 +667,23 @@ public class AnimPreviewState extends BaseAppState {
}
}
/** Lädt eine j3o-Datei direkt von Disk (BinaryImporter), ohne AssetManager-Cache. */
/** Lädt eine Spatial ohne AssetManager-Cache. Unterstützt asset-relative und absolute Pfade. */
private Spatial loadFresh(String assetPath) throws Exception {
// Absoluter Pfad (z. B. externe GLB vom Dateisystem):
// Verzeichnis als FileLocator registrieren, dann per Dateiname laden.
Path absFile = Path.of(assetPath);
if (absFile.isAbsolute()) {
if (!Files.exists(absFile))
throw new java.io.FileNotFoundException("Datei nicht gefunden: " + assetPath);
assets.registerLocator(
absFile.getParent().toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
String fileName = absFile.getFileName().toString();
assets.deleteFromCache(new ModelKey(fileName));
LOG.info("[AnimPreview] Lade extern: {} aus {}", fileName, absFile.getParent());
return assets.loadModel(fileName);
}
// Asset-relativer Pfad:
Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar));
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
BinaryImporter bi = BinaryImporter.getInstance();
@@ -827,12 +884,13 @@ public class AnimPreviewState extends BaseAppState {
int embedded = 0;
for (String clipName : set.getClips()) {
if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) {
String clipRelPath = resolveClipFile(clipsDir, clipName);
if (clipRelPath == null) {
LOG.warn("[AnimEmbed] Clip nicht gefunden: {}", clipName);
continue;
}
try {
Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o");
Spatial clipSpatial = loadFresh(clipRelPath);
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
if (clipAC == null) continue;
@@ -878,6 +936,15 @@ public class AnimPreviewState extends BaseAppState {
BinaryExporter.getInstance().save(holder, outFile.toFile());
}
/** Returns "animations/clips/<name>.<ext>" for the first matching file, or null if not found. */
private static String resolveClipFile(java.nio.file.Path clipsDir, String clipName) {
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (Files.exists(clipsDir.resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
}
return null;
}
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
if (a.getJointCount() != b.getJointCount()) return false;
java.util.Set<String> namesA = new java.util.HashSet<>();

View File

@@ -59,12 +59,17 @@ public class GrassVertexState extends BaseAppState {
static final ColorRGBA VERY_DRY_ROOT_COLOR = new ColorRGBA(0.18f, 0.09f, 0.02f, 1f);
static final ColorRGBA VERY_DRY_TIP_COLOR = new ColorRGBA(0.38f, 0.20f, 0.05f, 1f);
// ── LOD ───────────────────────────────────────────────────────────────────
private static final float CULL_DIST = 150f;
private static final float CULL_DIST_SQ = CULL_DIST * CULL_DIST;
// ── Zustand ───────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
private final SharedInput input;
private AssetManager assetManager;
private com.jme3.renderer.Camera cam;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
@SuppressWarnings("unchecked")
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
@@ -89,6 +94,7 @@ public class GrassVertexState extends BaseAppState {
@Override
protected void initialize(Application app) {
this.assetManager = app.getAssetManager();
this.cam = app.getCamera();
grassNode = new Node("grassVertexNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
material = buildMaterial();
@@ -115,6 +121,22 @@ public class GrassVertexState extends BaseAppState {
public void update(float tpf) {
processBrushEdits();
rebuildDirtyChunks();
updateChunkVisibility();
}
private void updateChunkVisibility() {
if (cam == null) return;
Vector3f camPos = cam.getLocation();
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (chunkNodes[ci] == null) continue;
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float dx = camPos.x - wx, dz = camPos.z - wz;
boolean visible = dx*dx + dz*dz <= CULL_DIST_SQ;
chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
}
// ── Material ──────────────────────────────────────────────────────────────

View File

@@ -14,8 +14,11 @@ import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.collision.CollisionResults;
import com.jme3.math.Ray;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Cylinder;
import com.jme3.scene.shape.Dome;
import com.jme3.scene.shape.Sphere;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
@@ -67,6 +70,9 @@ public class ModelEditorState extends BaseAppState {
/** Node, der alle Anhang-Gizmos (Lichter + Emitter) enthält. */
private Node attachmentGizmos = null;
/** Hauptlichtquelle der Vorschau (per UI steuerbar). */
private DirectionalLight mainLight = null;
// gespeicherter Kamerazustand aus dem Editor-Modus
private Vector3f savedCamPos;
private Quaternion savedCamRot;
@@ -75,6 +81,12 @@ public class ModelEditorState extends BaseAppState {
private String currentPath = null;
private String mainModelPath = null;
private Node interactableArrowNode = null;
private float interactableOffsetX = 0f;
private float interactableOffsetY = 0.5f;
private float interactableOffsetZ = 0f;
private float interactableRotY = 0f;
/** Originales Spatial wie vom Asset-Manager geladen wird durch LOD-Previews nicht überschrieben. */
private Spatial originalSpatial = null;
@@ -190,6 +202,15 @@ public class ModelEditorState extends BaseAppState {
applyPivot(input.modelEditorPivotY);
}
// Lichtrichtung
if (input.modelEditorLightChanged) {
input.modelEditorLightChanged = false;
if (mainLight != null) {
mainLight.setDirection(computeLightDirection(
input.modelEditorLightAzimuth, input.modelEditorLightElevation));
}
}
// Anhang-Gizmos aktualisieren
if (input.modelEditorAttachmentsChanged) {
input.modelEditorAttachmentsChanged = false;
@@ -233,6 +254,43 @@ public class ModelEditorState extends BaseAppState {
repositionCompCylinder();
}
// Interactable-Ruhepunkt: Position + RotY aus JavaFX übernehmen
if (input.modelInteractableOffsetChanged) {
input.modelInteractableOffsetChanged = false;
interactableOffsetX = input.modelInteractableOffsetX;
interactableOffsetY = input.modelInteractableOffsetY;
interactableOffsetZ = input.modelInteractableOffsetZ;
interactableRotY = input.modelInteractableRotY;
rebuildInteractableArrow();
}
// Interactable-Ruhepunkt: Klick → Raycast gegen Modell
SharedInput.ModelInteractableClick click;
while ((click = input.modelInteractableClickQueue.poll()) != null) {
if (previewRoot == null || modelWrapper == null) break;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
previewRoot.collideWith(ray, hits);
if (hits.size() > 0) {
Vector3f pt = hits.getClosestCollision().getContactPoint();
// Modell-Ursprung berücksichtigen (Pivot-Versatz)
Vector3f modelOrig = modelWrapper.getWorldTranslation();
interactableOffsetX = pt.x - modelOrig.x;
interactableOffsetY = pt.y - modelOrig.y;
interactableOffsetZ = pt.z - modelOrig.z;
input.modelInteractableOffsetX = interactableOffsetX;
input.modelInteractableOffsetY = interactableOffsetY;
input.modelInteractableOffsetZ = interactableOffsetZ;
input.modelInteractableOffsetChanged = true;
input.modelInteractablePosSetFromJme = true;
// Layer zurücksetzen
input.activeLayer = SharedInput.LAYER_MODEL_EDITOR;
rebuildInteractableArrow();
}
}
applyOrbitCamera();
previewRoot.updateLogicalState(tpf);
@@ -382,9 +440,10 @@ public class ModelEditorState extends BaseAppState {
app.getRootNode().setCullHint(Spatial.CullHint.Always);
previewRoot = new Node("model_editor_preview");
previewRoot.addLight(new DirectionalLight(
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
mainLight = new DirectionalLight(
computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f));
previewRoot.addLight(mainLight);
previewRoot.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.5f, 0.55f, 0.75f, 1f)));
@@ -393,11 +452,18 @@ public class ModelEditorState extends BaseAppState {
new ColorRGBA(0.4f, 0.4f, 0.5f, 1f)));
previewRoot.addLight(new AmbientLight(new ColorRGBA(0.65f, 0.65f, 0.7f, 1f)));
// Hintergrundfarbe setzen; Viewport-Attach erfolgt NACH dem Modell-Load
// (in loadModel / showSpatialDirectly), damit JME3 beim ersten Render
// eine vollständig initialisierte Szene vorfindet und nicht schwarz rendert.
app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f));
interactableArrowNode = new Node("interactableArrow");
interactableArrowNode.setCullHint(Spatial.CullHint.Always);
previewRoot.attachChild(interactableArrowNode);
// Offset aus SharedInput übernehmen (gesetzt beim Öffnen des Panels)
interactableOffsetX = input.modelInteractableOffsetX;
interactableOffsetY = input.modelInteractableOffsetY;
interactableOffsetZ = input.modelInteractableOffsetZ;
interactableRotY = input.modelInteractableRotY;
orbitYaw = 30f;
orbitPitch = 25f;
}
@@ -418,6 +484,7 @@ public class ModelEditorState extends BaseAppState {
hasEmbeddedLods = false;
embeddedLodSpatials = null;
originalSpatial = null;
interactableArrowNode = null;
input.modelEditorHasEmbeddedLods = false;
app.getRootNode().setCullHint(Spatial.CullHint.Inherit);
@@ -730,6 +797,81 @@ public class ModelEditorState extends BaseAppState {
}
}
/** Zeichnet / aktualisiert den Ruhepunkt-Pfeil im Modell-Editor. */
public void rebuildInteractableArrow() {
if (interactableArrowNode == null) return;
interactableArrowNode.detachAllChildren();
float shaftLen = 0.8f;
float shaftRad = 0.04f;
float headRad = 0.12f;
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0f, 0.8f, 1f, 1f));
Material matHead = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
matHead.setColor("Color", new ColorRGBA(0f, 0.5f, 1f, 1f));
// Träger-Node an der Ruheposition; alle Kinder liegen lokal entlang +Z
Node group = new Node("arrowGroup");
group.setLocalTranslation(interactableOffsetX, interactableOffsetY, interactableOffsetZ);
float dx = (float) Math.cos(interactableRotY);
float dz = (float) Math.sin(interactableRotY);
Quaternion groupRot = new Quaternion();
groupRot.lookAt(new Vector3f(dx, 0f, dz), Vector3f.UNIT_Y);
group.setLocalRotation(groupRot);
// Marker-Würfel am Ruhepunkt (Ursprung der Gruppe)
Geometry marker = new Geometry("iMarker", new Box(0.06f, 0.06f, 0.06f));
marker.setMaterial(mat);
// Schaft: JME3-Cylinder liegt auf der Z-Achse; Mitte bei shaftLen/2 → geht von 0 bis shaftLen
Geometry shaft = new Geometry("iShaft", new Cylinder(4, 8, shaftRad, shaftLen, true));
shaft.setMaterial(mat);
shaft.setLocalTranslation(0f, 0f, shaftLen * 0.5f);
// Kegelspitze: Dome-Spitze zeigt per Default nach +Y → +90° um X dreht sie nach +Z
Geometry head = new Geometry("iHead", new Dome(Vector3f.ZERO, 2, 8, headRad, false));
head.setMaterial(matHead);
head.setLocalTranslation(0f, 0f, shaftLen);
Quaternion headRot = new Quaternion();
headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot);
group.attachChild(marker);
group.attachChild(shaft);
group.attachChild(head);
interactableArrowNode.attachChild(group);
interactableArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
/** Setzt den Pfeil sichtbar/unsichtbar. */
public void setInteractableArrowVisible(boolean visible) {
if (interactableArrowNode != null)
interactableArrowNode.setCullHint(
visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
/** Initialisiert Offsets aus dem ModelMeta wenn ein neues Modell geöffnet wird. */
public void applyInteractableOffset(float ox, float oy, float oz, float rotY) {
interactableOffsetX = ox;
interactableOffsetY = oy;
interactableOffsetZ = oz;
interactableRotY = rotY;
input.modelInteractableOffsetX = ox;
input.modelInteractableOffsetY = oy;
input.modelInteractableOffsetZ = oz;
input.modelInteractableRotY = rotY;
rebuildInteractableArrow();
}
private Ray screenToRay(float screenX, float screenY) {
Vector3f origin = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 0f);
Vector3f dir = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 1f)
.subtractLocal(origin).normalizeLocal();
return new Ray(origin, dir);
}
public String getCurrentPath() { return currentPath; }
/**
@@ -753,4 +895,18 @@ public class ModelEditorState extends BaseAppState {
public BoundingBox getCurrentBounds() {
return getBoundingBox();
}
/**
* Berechnet den normalisierten Richtungsvektor der Lichtquelle aus Azimut und Elevation.
* Azimut 0° = Licht kommt aus Z+; Elevation 90° = Licht kommt senkrecht von oben.
*/
private static Vector3f computeLightDirection(float azimuthDeg, float elevationDeg) {
float azi = (float) Math.toRadians(azimuthDeg);
float ele = (float) Math.toRadians(elevationDeg);
float cosEle = (float) Math.cos(ele);
float dx = -cosEle * (float) Math.sin(azi);
float dy = -(float) Math.sin(ele);
float dz = -cosEle * (float) Math.cos(azi);
return new Vector3f(dx, dy, dz).normalizeLocal();
}
}

View File

@@ -0,0 +1,323 @@
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.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.path.PathEdge;
import de.blight.common.path.PathNetwork;
import de.blight.common.path.PathNetworkIO;
import de.blight.common.path.PathNode;
import de.blight.common.model.WorldPoint;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
/**
* JME-AppState für den Wegnetz-Editor.
*
* <p>Interaktionsmodell:
* <ul>
* <li>Linksklick auf leeres Terrain → neuen Knoten platzieren</li>
* <li>Linksklick auf Knoten → Knoten auswählen; ist bereits einer gewählt,
* wird eine Kante zwischen beiden erzeugt</li>
* <li>Linksklick auf Terrain mit gewähltem Knoten → neuen Knoten platzieren
* UND mit dem gewählten Knoten verbinden</li>
* <li>Rechtsklick auf Knoten → Knoten und alle seine Kanten löschen</li>
* <li>Rechtsklick auf Terrain → Auswahl aufheben</li>
* </ul>
*
* Änderungen werden sofort in die Datei geschrieben.
*/
public class PathNetworkEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PathNetworkEditorState.class);
/** Radius in Welt-Einheiten, innerhalb dessen ein Klick einen Knoten trifft. */
private static final float NODE_HIT_RADIUS = 3f;
private static final float NODE_SPHERE_RADIUS = 0.6f;
private static final float EDGE_RADIUS = 0.15f;
private final SharedInput input;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final PathNetwork network = new PathNetwork();
private PathNode selectedNode = null;
// Szene-Graph-Knoten für Visualisierung
private final Node netRoot = new Node("pathnet_root");
private final Node nodeRoot = new Node("pathnet_nodes");
private final Node edgeRoot = new Node("pathnet_edges");
private Material matDefault;
private Material matSelected;
private Material matEdge;
public PathNetworkEditorState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
SimpleApplication app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
matDefault = mat(new ColorRGBA(1f, 0.85f, 0f, 1f)); // gelb
matSelected = mat(new ColorRGBA(1f, 0.4f, 0f, 1f)); // orange
matEdge = mat(new ColorRGBA(0.3f, 0.9f, 0.3f, 1f)); // grün
netRoot.attachChild(nodeRoot);
netRoot.attachChild(edgeRoot);
try {
PathNetwork loaded = PathNetworkIO.load();
network.getNodes().addAll(loaded.getNodes());
network.getEdges().addAll(loaded.getEdges());
rebuildVisuals();
} catch (IOException e) {
log.warn("Wegnetz nicht ladbar: {}", e.getMessage());
}
}
@Override protected void cleanup(Application app) { netRoot.removeFromParent(); }
@Override
protected void onEnable() {
rootNode.attachChild(netRoot);
}
@Override
protected void onDisable() {
netRoot.removeFromParent();
selectedNode = null;
}
public void setTerrain(TerrainQuad t) { this.terrain = t; }
// ── Update-Loop ────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
SharedInput.PathNetworkClick click;
while ((click = input.pathNetworkClickQueue.poll()) != null) {
handleClick(click);
}
}
private void handleClick(SharedInput.PathNetworkClick 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());
// Knoten in der Nähe des Strahls suchen
PathNode hitNode = findNodeNearRay(ray);
if (click.rightClick()) {
handleRightClick(hitNode);
} else {
handleLeftClick(ray, hitNode);
}
}
private void handleLeftClick(Ray ray, PathNode hitNode) {
if (hitNode != null) {
// Klick auf Knoten
if (selectedNode == null) {
select(hitNode);
} else if (selectedNode == hitNode) {
select(null); // Abwählen
} else {
network.addEdge(selectedNode.getUuid(), hitNode.getUuid());
select(hitNode);
saveAndNotify();
rebuildVisuals();
}
} else {
// Klick auf Terrain → neuer Knoten
WorldPoint pt = terrainHit(ray);
if (pt == null) return;
PathNode newNode = new PathNode(pt);
network.addNode(newNode);
if (selectedNode != null) {
network.addEdge(selectedNode.getUuid(), newNode.getUuid());
}
select(newNode);
saveAndNotify();
rebuildVisuals();
}
}
private void handleRightClick(PathNode hitNode) {
if (hitNode != null) {
if (selectedNode == hitNode) selectedNode = null;
network.removeNode(hitNode.getUuid());
saveAndNotify();
rebuildVisuals();
} else {
select(null);
}
}
// ── Visualisierung ─────────────────────────────────────────────────────────
private void rebuildVisuals() {
nodeRoot.detachAllChildren();
edgeRoot.detachAllChildren();
for (PathNode n : network.getNodes()) {
Geometry g = new Geometry("node_" + n.getUuid(),
new Sphere(8, 8, NODE_SPHERE_RADIUS));
g.setMaterial(n == selectedNode ? matSelected : matDefault);
g.setLocalTranslation(n.getPosition().x, n.getPosition().y + NODE_SPHERE_RADIUS, n.getPosition().z);
g.setUserData("nodeUuid", n.getUuid());
nodeRoot.attachChild(g);
}
for (PathEdge e : network.getEdges()) {
PathNode a = network.nodeById(e.getNodeUuidA());
PathNode b = network.nodeById(e.getNodeUuidB());
if (a == null || b == null) continue;
Geometry g = buildEdgeGeometry(a, b);
if (g != null) edgeRoot.attachChild(g);
}
}
private Geometry buildEdgeGeometry(PathNode a, PathNode b) {
Vector3f va = toVec(a.getPosition());
Vector3f vb = toVec(b.getPosition());
float length = va.distance(vb);
if (length < 0.001f) return null;
Vector3f mid = va.add(vb).multLocal(0.5f);
Vector3f dir = vb.subtract(va).divideLocal(length); // normalisiert
Cylinder cyl = new Cylinder(4, 8, EDGE_RADIUS, length, true);
Geometry g = new Geometry("edge_" + a.getUuid() + "_" + b.getUuid(), cyl);
g.setMaterial(matEdge);
g.setLocalTranslation(mid);
// Der JME-Zylinder liegt standardmäßig entlang seiner lokalen Y-Achse.
// Wir brauchen eine Rotation, die UNIT_Y auf `dir` dreht.
// Achse = UNIT_Y × dir, Winkel = arccos(UNIT_Y · dir)
g.setLocalRotation(rotationYToDir(dir));
return g;
}
/**
* Berechnet die Quaternion, die UNIT_Y (0,1,0) auf {@code dir} dreht.
* {@code dir} muss bereits normalisiert sein.
*/
private static Quaternion rotationYToDir(Vector3f dir) {
// Kreuzprodukt liefert die Rotationsachse; Skalarprodukt den cos(Winkel)
Vector3f axis = new Vector3f(-dir.z, 0f, dir.x); // UNIT_Y × dir = (-dz, 0, dx)
float sinA = axis.length(); // |UNIT_Y × dir| = sin(angle)
float cosA = dir.y; // UNIT_Y · dir = cos(angle)
if (sinA < 0.0001f) {
// dir ist (anti-)parallel zu UNIT_Y
return cosA >= 0f
? new Quaternion(0f, 0f, 0f, 1f) // identisch
: new Quaternion().fromAngleNormalAxis(FastMath.PI, Vector3f.UNIT_X); // 180° flip
}
axis.divideLocal(sinA); // normalisieren
float angle = (float) Math.atan2(sinA, cosA);
return new Quaternion().fromAngleNormalAxis(angle, axis);
}
private void updateNodeMaterial(PathNode node) {
Geometry g = (Geometry) nodeRoot.getChild("node_" + node.getUuid());
if (g != null) g.setMaterial(node == selectedNode ? matSelected : matDefault);
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
private PathNode findNodeNearRay(Ray ray) {
PathNode best = null;
float bestDist = NODE_HIT_RADIUS;
for (PathNode n : network.getNodes()) {
Vector3f pos = toVec(n.getPosition());
float d = ray.distanceSquared(pos);
float distFromOrigin = pos.subtract(ray.getOrigin()).length();
float threshold = NODE_HIT_RADIUS * (distFromOrigin / 100f + 1f);
if (d < threshold * threshold && d < bestDist * bestDist) {
bestDist = (float) Math.sqrt(d);
best = n;
}
}
return best;
}
private WorldPoint terrainHit(Ray ray) {
if (terrain == null) return null;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return null;
Vector3f pt = hits.getClosestCollision().getContactPoint();
return new WorldPoint(pt.x, pt.y, pt.z);
}
private void select(PathNode node) {
PathNode prev = selectedNode;
selectedNode = node;
if (prev != null) updateNodeMaterial(prev);
if (node != null) updateNodeMaterial(node);
}
private void saveAndNotify() {
try {
PathNetworkIO.save(network);
} catch (IOException e) {
log.error("Wegnetz speichern fehlgeschlagen: {}", e.getMessage());
}
input.pathNetworkChanged = true;
}
private Material mat(ColorRGBA color) {
Material m = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
m.setColor("Color", color);
return m;
}
private static Vector3f toVec(WorldPoint p) {
return new Vector3f(p.x, p.y, p.z);
}
/** Liefert eine Kopie des aktuellen Netzes (für UI-Anzeige). */
public PathNetwork getNetwork() { return network; }
/** Löscht den gewählten Knoten (aufrufbar aus JavaFX via enqueue). */
public void deleteSelected() {
if (selectedNode == null) return;
network.removeNode(selectedNode.getUuid());
selectedNode = null;
saveAndNotify();
rebuildVisuals();
}
/** Benennt den gewählten Knoten um. */
public void renameSelected(String name) {
if (selectedNode == null) return;
selectedNode.setName(name);
saveAndNotify();
}
}

View File

@@ -0,0 +1,106 @@
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.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
/**
* JME-AppState für den Tagesablauf-Editor.
* Verarbeitet Terrain-Klicks für die Punktauswahl und zeigt
* Waypoint-Marker in der Szene an.
*/
public class RoutineMapState extends BaseAppState {
private final SharedInput input;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<Geometry> waypointMarkers = new ArrayList<>();
private Material markerMat;
public RoutineMapState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
SimpleApplication app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
markerMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
markerMat.setColor("Color", new ColorRGBA(1f, 0.5f, 0f, 1f));
}
@Override protected void cleanup(Application app) { clearMarkers(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad t) { this.terrain = t; }
@Override
public void update(float tpf) {
SharedInput.RoutinePointClick click;
while ((click = input.routinePointClickQueue.poll()) != null) {
handleClick(click);
}
}
private void handleClick(SharedInput.RoutinePointClick click) {
if (terrain == null) return;
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());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
input.routinePickedPoint = pt.x + "|" + pt.y + "|" + pt.z;
input.routinePickedChanged = true;
}
/**
* Aktualisiert die Waypoint-Marker in der Szene.
* Wird von JavaFX-Thread via SharedInput-Koordinaten nicht direkt aufgerufen;
* stattdessen liest die View die Koordinaten und ruft diese Methode
* über Platform.runLater nicht auf die Marker werden im JME-Thread platziert.
*
* Format: Liste von "x|y|z" Strings.
*/
public void showMarkers(List<float[]> points) {
clearMarkers();
for (float[] p : points) {
Geometry g = new Geometry("routine_marker", new Sphere(8, 8, 0.6f));
g.setMaterial(markerMat);
g.setLocalTranslation(p[0], p[1] + 0.6f, p[2]);
rootNode.attachChild(g);
waypointMarkers.add(g);
}
}
public void clearMarkers() {
for (Geometry g : waypointMarkers) rootNode.detachChild(g);
waypointMarkers.clear();
}
}

View File

@@ -24,7 +24,12 @@ import com.jme3.scene.shape.Torus;
import com.jme3.texture.Texture;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.scene.shape.Dome;
import de.blight.common.PlacedModel;
import de.blight.common.model.Bed;
import de.blight.common.model.BedIO;
import de.blight.common.model.Bench;
import de.blight.common.model.BenchIO;
import de.blight.editor.SharedInput;
import de.blight.editor.object.SceneObject;
@@ -104,6 +109,20 @@ public class SceneObjectState extends BaseAppState {
private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt)
private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi)
/** Kleiner Cache: modelPath → ModelMeta, damit nicht pro Frame geladen werden muss. */
private final java.util.Map<String, de.blight.common.ModelMeta> metaCache = new java.util.HashMap<>();
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/** Visualisierungspfeil für die Liegefläche (1,8 m); wird nur gezeigt wenn Bett-Objekt gewählt. */
private Node bedArrowNode = null;
/** UUID des Bettes, für das der Pfeil gerade angezeigt wird. */
private String bedArrowBedId = null;
/** Visualisierungspfeil für die Sitzfläche (0,5 m); wird nur gezeigt wenn Bank-Objekt gewählt. */
private Node benchArrowNode = null;
/** UUID der Bank, für die der Pfeil gerade angezeigt wird. */
private String benchArrowBenchId = null;
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)
@@ -151,13 +170,15 @@ public class SceneObjectState extends BaseAppState {
meshFile, animClips.get(i),
so.castShadow, so.receiveShadow,
so.lod1Path, so.lod2Path,
so.lod1Distance, so.lod2Distance, so.cullDistance));
so.lod1Distance, so.lod2Distance, so.cullDistance,
so.interactableType, so.interactableId));
}
return list;
}
public void loadPlacedModels(List<PlacedModel> models) {
if (objectRoot == null) return;
metaCache.clear();
objectRoot.detachAllChildren();
objects.clear();
objNodes.clear();
@@ -177,9 +198,11 @@ public class SceneObjectState extends BaseAppState {
so.receiveShadow = pm.receiveShadow();
so.lod1Path = pm.lod1Path() != null ? pm.lod1Path() : "";
so.lod2Path = pm.lod2Path() != null ? pm.lod2Path() : "";
so.lod1Distance = pm.lod1Distance();
so.lod2Distance = pm.lod2Distance();
so.cullDistance = pm.cullDistance();
so.lod1Distance = pm.lod1Distance();
so.lod2Distance = pm.lod2Distance();
so.cullDistance = pm.cullDistance();
so.interactableType = pm.interactableType() != null ? pm.interactableType() : "";
so.interactableId = pm.interactableId() != null ? pm.interactableId() : "";
objects.add(so);
animClips.add(pm.animClip() != null ? pm.animClip() : "");
@@ -246,6 +269,14 @@ public class SceneObjectState extends BaseAppState {
subOverlay = new Node("subOverlay");
subOverlay.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(subOverlay);
bedArrowNode = new Node("bedArrow");
bedArrowNode.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(bedArrowNode);
benchArrowNode = new Node("benchArrow");
benchArrowNode.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(benchArrowNode);
}
@Override
@@ -254,6 +285,8 @@ public class SceneObjectState extends BaseAppState {
gizmoNode.removeFromParent();
previewNode.removeFromParent();
subOverlay.removeFromParent();
bedArrowNode.removeFromParent();
benchArrowNode.removeFromParent();
}
@Override protected void onEnable() {}
@@ -294,6 +327,11 @@ public class SceneObjectState extends BaseAppState {
try { loadPlacedModels(de.blight.common.PlacedModelIO.load()); } catch (Exception ignored) {}
}
// Bett-Liegefläche platzieren
handleBedLiegeLayer();
// Bank-Sitzfläche platzieren
handleBenchSitzLayer();
boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT;
if (!isObjectLayer) return;
@@ -309,6 +347,21 @@ public class SceneObjectState extends BaseAppState {
}
}
// Interactable-Zuweisung von JavaFX
String pendingIType = input.pendingInteractableType;
String pendingIId = input.pendingInteractableId;
if (pendingIType != null && pendingIId != null) {
input.pendingInteractableType = null;
input.pendingInteractableId = null;
if (!selectedIndices.isEmpty()) {
SceneObject so = objects.get(selectedIndices.get(0));
so.interactableType = pendingIType;
so.interactableId = pendingIId;
}
refreshBedArrow();
refreshBenchArrow();
}
// Solid-Flag-Änderung von JavaFX
Boolean solidChange = input.pendingSolidChange;
if (solidChange != null) {
@@ -590,6 +643,33 @@ public class SceneObjectState extends BaseAppState {
so.lod1Distance = meta.lod1Distance();
so.lod2Distance = meta.lod2Distance();
so.cullDistance = meta.cullDistance();
if (meta.interactableType() != null
&& meta.interactableType() != de.blight.common.model.InteractableType.NONE) {
so.interactableType = meta.interactableType().name();
// Bed/Bench-Instanz mit Welt-Koordinaten (Modellpos + rotierter Offset) anlegen
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx2 = wx + ox * cos - oz * sin;
float wy2 = wy + placementOffY + oy;
float wz2 = wz + ox * sin + oz * cos;
float roty2 = rotY + meta.interactableRotY();
if (meta.interactableType() == de.blight.common.model.InteractableType.BED) {
Bed bed = new Bed();
bed.setLiegeX(wx2); bed.setLiegeY(wy2); bed.setLiegeZ(wz2);
bed.setLiegeRotY(roty2); bed.setLiegeSet(true);
try { BedIO.save(bed); so.interactableId = bed.getId(); }
catch (java.io.IOException e) { log.error("BedIO save: {}", e.getMessage()); }
} else if (meta.interactableType() == de.blight.common.model.InteractableType.BENCH) {
Bench bench = new Bench();
bench.setSitzX(wx2); bench.setSitzY(wy2); bench.setSitzZ(wz2);
bench.setSitzRotY(roty2); bench.setSitzSet(true);
try { BenchIO.save(bench); so.interactableId = bench.getId(); }
catch (java.io.IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
}
}
}
so.setRotation(0f, rotY, 0f);
so.setScale(defaultScale);
@@ -885,11 +965,14 @@ public class SceneObjectState extends BaseAppState {
+ "|" + so.getScale() + "|" + so.getTexturePath()
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
+ "|" + animClips.get(idx)
+ "|" + so.castShadow + "|" + so.receiveShadow;
+ "|" + so.castShadow + "|" + so.receiveShadow
+ "|" + so.interactableType + "|" + so.interactableId;
} else {
input.selectedObjectInfo = String.valueOf(n);
}
input.objectSelectionChanged = true;
refreshBedArrow();
refreshBenchArrow();
}
// ── Gizmo-Drag ───────────────────────────────────────────────────────────
@@ -1430,6 +1513,8 @@ public class SceneObjectState extends BaseAppState {
sortedDesc.sort(Comparator.reverseOrder());
for (int idx : sortedDesc) {
SceneObject so = objects.get(idx);
deleteInteractableFile(so);
objectRoot.detachChild(objNodes.get(idx));
objects.remove(idx);
objNodes.remove(idx);
@@ -1446,6 +1531,69 @@ public class SceneObjectState extends BaseAppState {
setStatus("Objekt(e) gelöscht");
}
private void deleteInteractableFile(SceneObject so) {
if (so.interactableId == null || so.interactableId.isEmpty()) return;
try {
if ("BED".equalsIgnoreCase(so.interactableType)) BedIO.delete(so.interactableId);
else if ("BENCH".equalsIgnoreCase(so.interactableType)) BenchIO.delete(so.interactableId);
} catch (java.io.IOException e) {
log.warn("[SceneObject] Interactable-Datei nicht gelöscht: {}", e.getMessage());
}
}
/**
* Synchronisiert alle Bed/Bench-JSON-Dateien mit den aktuellen
* Positionen und Rotationen der platzierten Objekte.
* Wird vor dem Karten-Speichern aufgerufen.
*/
public void syncInteractables() {
for (SceneObject so : objects) {
if (so.interactableId == null || so.interactableId.isEmpty()) continue;
String itype = so.interactableType;
if (itype == null || itype.isEmpty()) continue;
boolean isBed = "BED".equalsIgnoreCase(itype);
boolean isBench = "BENCH".equalsIgnoreCase(itype);
if (!isBed && !isBench) continue;
// Modell-Meta laden (enthält lokale Offsets des Ruhepunkts)
Path modelPath = ASSET_ROOT.resolve(so.modelPath);
if (!java.nio.file.Files.exists(modelPath)) continue;
de.blight.common.ModelMeta meta = de.blight.common.ModelMetaIO.load(modelPath);
float rotY = so.getRotY();
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx = so.getWorldX() + ox * cos - oz * sin;
float wy = so.getGroundY() + oy;
float wz = so.getWorldZ() + ox * sin + oz * cos;
float iRotY = rotY + meta.interactableRotY();
try {
if (isBed) {
BedIO.load(so.interactableId).ifPresent(bed -> {
bed.setLiegeX(wx); bed.setLiegeY(wy); bed.setLiegeZ(wz);
bed.setLiegeRotY(iRotY); bed.setLiegeSet(true);
try { BedIO.save(bed); }
catch (java.io.IOException e) { log.error("[SceneObject] Bett sync: {}", e.getMessage()); }
});
} else {
BenchIO.load(so.interactableId).ifPresent(bench -> {
bench.setSitzX(wx); bench.setSitzY(wy); bench.setSitzZ(wz);
bench.setSitzRotY(iRotY); bench.setSitzSet(true);
try { BenchIO.save(bench); }
catch (java.io.IOException e) { log.error("[SceneObject] Bank sync: {}", e.getMessage()); }
});
}
} catch (Exception e) {
log.error("[SceneObject] syncInteractables Fehler: {}", e.getMessage());
}
}
}
// ── Zusammenfassen ────────────────────────────────────────────────────────
private void mergeSelected() {
@@ -1932,4 +2080,242 @@ public class SceneObjectState extends BaseAppState {
local.z /= wScale.z;
return local;
}
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/**
* Verarbeitet Terrain-Klicks im LAYER_BED_LIEGE-Modus und Rotations-Änderungen.
* Wird in update() immer aufgerufen (unabhängig von isObjectLayer).
*/
private void handleBedLiegeLayer() {
// Rotations-Update von JavaFX (kann immer ankommen, auch ohne Klick)
Float rotPending = input.pendingBedLiegeRotY;
if (rotPending != null) {
input.pendingBedLiegeRotY = null;
String bedId = input.bedLiegeTargetId;
if (bedId != null && !bedId.isBlank()) {
Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId));
bed.setLiegeRotY(rotPending);
if (!bed.isLiegeSet()) { bed.setLiegeSet(true); }
try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); }
placeBedArrow(bed);
}
}
if (input.activeLayer != SharedInput.LAYER_BED_LIEGE) return;
SharedInput.BedLiegeClick click;
while ((click = input.bedLiegeClickQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f pt = hits.getClosestCollision().getContactPoint();
String bedId = input.bedLiegeTargetId;
if (bedId == null || bedId.isBlank()) continue;
Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId));
bed.setLiegeX(pt.x);
bed.setLiegeY(pt.y);
bed.setLiegeZ(pt.z);
bed.setLiegeSet(true);
try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); }
input.bedLiegePickResult = pt.x + "|" + pt.y + "|" + pt.z;
input.bedLiegePickChanged = true;
// Zurück zu Objekt-Bearbeitung
input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT;
placeBedArrow(bed);
}
}
private de.blight.common.ModelMeta loadMetaCached(String modelPath) {
return metaCache.computeIfAbsent(modelPath, p -> {
Path full = ASSET_ROOT.resolve(p);
if (!java.nio.file.Files.exists(full)) return null;
return de.blight.common.ModelMetaIO.load(full);
});
}
/** Gibt [wx, wy, wz, totalRotY] aus SceneObject-Transform + ModelMeta zurück, oder null wenn kein Meta. */
private float[] computeInteractableWorldPos(SceneObject so) {
de.blight.common.ModelMeta meta = loadMetaCached(so.modelPath);
if (meta == null) return null;
float rotY = so.getRotY();
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx = so.getWorldX() + ox * cos - oz * sin;
float wy = so.getGroundY() + oy;
float wz = so.getWorldZ() + ox * sin + oz * cos;
return new float[]{wx, wy, wz, rotY + meta.interactableRotY()};
}
/**
* Aktualisiert die Bett-Pfeil-Visualisierung für das aktuell gewählte Objekt.
* Zeigt den Pfeil wenn genau ein Objekt gewählt ist, es ein Bett ist und die Liegefläche gesetzt.
*/
private void refreshBedArrow() {
if (selectedIndices.size() != 1) { hideBedArrow(); return; }
SceneObject so = objects.get(selectedIndices.get(0));
if (!"BED".equals(so.interactableType) || so.interactableId.isBlank()) { hideBedArrow(); return; }
bedArrowBedId = so.interactableId;
// Weltkoordinaten live aus aktuellem Transform + Meta berechnen
float[] wp = computeInteractableWorldPos(so);
if (wp != null) {
placeBedArrow(wp[0], wp[1], wp[2], wp[3]);
} else {
// Fallback: aus JSON (z.B. manuell gesetztes Bett ohne Meta-Offset)
Bed bed = BedIO.load(so.interactableId).orElse(null);
if (bed == null || !bed.isLiegeSet()) { hideBedArrow(); return; }
placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY());
}
}
private void hideBedArrow() {
bedArrowNode.detachAllChildren();
bedArrowNode.setCullHint(Spatial.CullHint.Always);
bedArrowBedId = null;
}
private void placeBedArrow(Bed bed) {
placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY());
}
private void placeBedArrow(float wx, float wy, float wz, float rotY) {
bedArrowNode.detachAllChildren();
Node g = buildDirectionalArrow(1.5f, 0.06f, 0.18f,
new ColorRGBA(1f, 0.5f, 0f, 1f),
new ColorRGBA(1f, 0.2f, 0f, 1f));
g.setLocalTranslation(wx, wy + 0.05f, wz);
Quaternion q = new Quaternion();
q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y);
g.setLocalRotation(q);
bedArrowNode.attachChild(g);
bedArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
/**
* Baut einen Pfeil entlang der lokalen +Z-Achse:
* Schaft (Cylinder, zentriert bei z=0) + Kegelspitze am positiven Ende.
* Der Aufrufer positioniert und rotiert den zurückgegebenen Node.
*/
private Node buildDirectionalArrow(float shaftLen, float shaftRad, float headRad,
ColorRGBA shaftColor, ColorRGBA headColor) {
Node group = new Node("arrowGroup");
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", shaftColor);
Material matH = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
matH.setColor("Color", headColor);
// Schaft: JME3-Cylinder liegt auf der Z-Achse, zentriert bei z=0
Geometry shaft = new Geometry("shaft", new Cylinder(4, 8, shaftRad, shaftLen, true));
shaft.setMaterial(mat);
// Kegelspitze am positiven Ende: Dome-Spitze zeigt +Y per Default;
// +90° um X dreht sie nach +Z (vorwärts)
Geometry head = new Geometry("head", new Dome(Vector3f.ZERO, 2, 8, headRad, false));
head.setMaterial(matH);
head.setLocalTranslation(0f, 0f, shaftLen * 0.5f);
Quaternion headRot = new Quaternion();
headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot);
group.attachChild(shaft);
group.attachChild(head);
return group;
}
// ── Bank-Sitzfläche ───────────────────────────────────────────────────────
private void handleBenchSitzLayer() {
Float rotPending = input.pendingBenchSitzRotY;
if (rotPending != null) {
input.pendingBenchSitzRotY = null;
String benchId = input.benchSitzTargetId;
if (benchId != null && !benchId.isBlank()) {
Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId));
bench.setSitzRotY(rotPending);
if (!bench.isSitzSet()) bench.setSitzSet(true);
try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
placeBenchArrow(bench);
}
}
if (input.activeLayer != SharedInput.LAYER_BENCH_SITZ) return;
SharedInput.BenchSitzClick click;
while ((click = input.benchSitzClickQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f pt = hits.getClosestCollision().getContactPoint();
String benchId = input.benchSitzTargetId;
if (benchId == null || benchId.isBlank()) continue;
Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId));
bench.setSitzX(pt.x);
bench.setSitzY(pt.y);
bench.setSitzZ(pt.z);
bench.setSitzSet(true);
try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
input.benchSitzPickResult = pt.x + "|" + pt.y + "|" + pt.z;
input.benchSitzPickChanged = true;
input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT;
placeBenchArrow(bench);
}
}
private void refreshBenchArrow() {
if (selectedIndices.size() != 1) { hideBenchArrow(); return; }
SceneObject so = objects.get(selectedIndices.get(0));
if (!"BENCH".equals(so.interactableType) || so.interactableId.isBlank()) { hideBenchArrow(); return; }
benchArrowBenchId = so.interactableId;
float[] wp = computeInteractableWorldPos(so);
if (wp != null) {
placeBenchArrow(wp[0], wp[1], wp[2], wp[3]);
} else {
Bench bench = BenchIO.load(so.interactableId).orElse(null);
if (bench == null || !bench.isSitzSet()) { hideBenchArrow(); return; }
placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY());
}
}
private void hideBenchArrow() {
benchArrowNode.detachAllChildren();
benchArrowNode.setCullHint(Spatial.CullHint.Always);
benchArrowBenchId = null;
}
private void placeBenchArrow(Bench bench) {
placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY());
}
private void placeBenchArrow(float wx, float wy, float wz, float rotY) {
benchArrowNode.detachAllChildren();
Node g = buildDirectionalArrow(0.4f, 0.04f, 0.10f,
new ColorRGBA(0.2f, 0.6f, 1f, 1f),
new ColorRGBA(0f, 0.3f, 1f, 1f));
g.setLocalTranslation(wx, wy + 0.05f, wz);
Quaternion q = new Quaternion();
q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y);
g.setLocalRotation(q);
benchArrowNode.attachChild(g);
benchArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
}

View File

@@ -0,0 +1,743 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.collision.CollisionResults;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.util.BufferUtils;
import de.blight.common.SculptedMesh;
import de.blight.common.SculptedMeshIO;
import de.blight.common.VoxelChunk;
import de.blight.common.VoxelChunkIO;
import de.blight.editor.SharedInput;
import de.blight.editor.tool.SculptMeshTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.nio.file.Path;
import java.util.*;
/**
* Ermöglicht das direkte Sculpten der gebackenen Voxel-Meshes.
* Aktiv wenn {@link SharedInput#LAYER_SCULPT} aktiv ist.
*
* Vertices gleicher Position werden beim Laden verschweißt (welded), sodass
* der Pinsel alle Dreiecke, die einen Punkt teilen, zusammen bewegt und keine
* Löcher entstehen. Für den GPU-Upload wird das welded Set wieder auf die
* originale Triangle-Soup aufgefächert.
*/
public class SculptedMeshEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(SculptedMeshEditorState.class);
private static final int MAX_UNDO = 10;
private static final float SAVE_DELAY = 3f;
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private Node sculptRoot;
private Geometry brushIndicator;
private boolean active;
private final Map<Long, EditableMesh> meshes = new LinkedHashMap<>();
private final Map<Long, float[]> sessionSnapshot = new HashMap<>();
private final Deque<Map<Long, float[]>> undoStack = new ArrayDeque<>();
private final Deque<Map<Long, float[]>> redoStack = new ArrayDeque<>();
private float dirtyTimer = 0f;
private boolean hasDirty = false;
private long selectedKey = -1L;
private Material normalMat = null;
private Material highlightMat = null;
// ── innere Klasse ─────────────────────────────────────────────────────────
private static final class EditableMesh {
final int cx, cy, cz;
// Roh-Daten aus dem j3o (Triangle Soup):
final int rawCount; // Anzahl roher Vertices
final int[] v2w; // v2w[rawIdx] = welded Index
// Verschweißte Daten (Sculpt-Ziel):
float[] wp; // weldedCount × 3 Positionen
float[] wn; // weldedCount × 3 Normalen
final int[] wi; // weldedCount Dreiecks-Indices (= rawCount, aber auf welded gemappt)
final int[][] wnb; // Nachbarn pro welded Vertex
// Aufgefächerte Daten für GPU-Upload:
final float[] rp; // rawCount × 3 Positionen
final float[] rn; // rawCount × 3 Normalen
Geometry geo;
Node node;
EditableMesh(int cx, int cy, int cz,
int rawCount, int[] v2w,
float[] wp, float[] wn, int[] wi, int[][] wnb) {
this.cx = cx; this.cy = cy; this.cz = cz;
this.rawCount = rawCount;
this.v2w = v2w;
this.wp = wp; this.wn = wn;
this.wi = wi; this.wnb = wnb;
this.rp = new float[rawCount * 3];
this.rn = new float[rawCount * 3];
}
}
// ── Konstruktor / Lebenszyklus ────────────────────────────────────────────
public SculptedMeshEditorState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
sculptRoot = new Node("sculptRoot");
app.getRootNode().attachChild(sculptRoot);
highlightMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
highlightMat.setColor("Color", new ColorRGBA(1f, 0.65f, 0f, 1f));
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
rescanBakedChunks();
}
@Override
protected void cleanup(Application application) {
saveAllDirty();
sculptRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
meshes.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
boolean shouldBeActive = (input.activeLayer == SharedInput.LAYER_SCULPT);
if (shouldBeActive != active) {
active = shouldBeActive;
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
if (ves != null) ves.setChunksVisible(!active);
if (active) rescanBakedChunks();
}
if (input.sculptRescanNeeded) {
input.sculptRescanNeeded = false;
rescanBakedChunks();
}
updateBrushIndicator();
if (!active) return;
if (input.sculptUndoRequested) { input.sculptUndoRequested = false; doUndo(); }
if (input.sculptRedoRequested) { input.sculptRedoRequested = false; doRedo(); }
if (input.sculptActionStarted) {
input.sculptActionStarted = false;
sessionSnapshot.clear();
redoStack.clear();
}
if (input.sculptActionFinished) {
input.sculptActionFinished = false;
if (!sessionSnapshot.isEmpty()) {
if (undoStack.size() >= MAX_UNDO) undoStack.pollFirst();
undoStack.addLast(new HashMap<>(sessionSnapshot));
}
sessionSnapshot.clear();
}
if (input.sculptApplyTranslate) {
input.sculptApplyTranslate = false;
if (selectedKey != -1L) applyTranslate(selectedKey,
input.sculptTranslateX, input.sculptTranslateY, input.sculptTranslateZ);
}
if (input.sculptApplyRotate) {
input.sculptApplyRotate = false;
if (selectedKey != -1L) applyRotateY(selectedKey, input.sculptRotateDeg);
}
if (input.sculptDeleteSelected) {
input.sculptDeleteSelected = false;
if (selectedKey != -1L) deleteSelected();
}
SharedInput.SculptEdit edit;
while ((edit = input.sculptEditQueue.poll()) != null) {
applyBrushEdit(edit);
}
if (hasDirty) {
dirtyTimer += tpf;
if (dirtyTimer >= SAVE_DELAY) {
saveAllDirty();
hasDirty = false;
dirtyTimer = 0f;
}
}
}
// ── Scan / Laden ──────────────────────────────────────────────────────────
private void rescanBakedChunks() {
for (int[] cxyz : SculptedMeshIO.findAllBakedChunks()) {
long key = chunkKey(cxyz[0], cxyz[1], cxyz[2]);
if (meshes.containsKey(key)) continue;
try {
loadChunk(cxyz[0], cxyz[1], cxyz[2], key);
} catch (Exception e) {
log.warn("Sculpt-Chunk laden fehlgeschlagen ({},{},{}): {}",
cxyz[0], cxyz[1], cxyz[2], e.getMessage());
}
}
}
private void loadChunk(int cx, int cy, int cz, long key) throws Exception {
Path p = VoxelChunkIO.getBakedPath(cx, cy, cz, 0);
BinaryImporter imp = BinaryImporter.getInstance();
imp.setAssetManager(app.getAssetManager());
com.jme3.scene.Mesh m = (com.jme3.scene.Mesh) imp.load(p.toFile());
// Roh-Vertex-Daten aus j3o (Triangle Soup mit sequenziellen Indices)
FloatBuffer posBuf = m.getFloatBuffer(VertexBuffer.Type.Position);
int rawCount = posBuf.limit() / 3;
float[] rawPos = new float[rawCount * 3];
posBuf.rewind(); posBuf.get(rawPos);
// Vertices gleicher Position verschweißen
int[] v2w = new int[rawCount];
float[] tmpWp = new float[rawCount * 3];
int wCount = 0;
HashMap<Long, Integer> key2w = new HashMap<>(rawCount / 3 + 16);
for (int v = 0; v < rawCount; v++) {
long pk = posKey(rawPos, v);
Integer w = key2w.get(pk);
if (w == null) {
key2w.put(pk, wCount);
tmpWp[wCount * 3] = rawPos[v * 3];
tmpWp[wCount * 3 + 1] = rawPos[v * 3 + 1];
tmpWp[wCount * 3 + 2] = rawPos[v * 3 + 2];
v2w[v] = wCount++;
} else {
v2w[v] = w;
}
}
float[] wp = Arrays.copyOf(tmpWp, wCount * 3);
// Welded Dreiecks-Indices (die originalen sind 0,1,2,...,N-1 → einfach v2w anwenden)
int[] wi = new int[rawCount];
for (int v = 0; v < rawCount; v++) wi[v] = v2w[v];
// Sculpt-Overlay laden (wenn vorhanden, enthält welded Positionen)
if (SculptedMeshIO.exists(cx, cy, cz)) {
try {
SculptedMesh overlay = SculptedMeshIO.load(cx, cy, cz);
if (overlay.positions.length == wp.length) {
System.arraycopy(overlay.positions, 0, wp, 0, wp.length);
} else {
log.warn("Sculpt-Overlay ({},{},{}) hat falsches Format ({} statt {}), ignoriert.",
cx, cy, cz, overlay.positions.length, wp.length);
}
} catch (Exception e) {
log.warn("Sculpt-Overlay ({},{},{}) fehlerhaft: {}", cx, cy, cz, e.getMessage());
}
}
// Normalen für welded Mesh berechnen
float[] wn = new float[wCount * 3];
recomputeNormals(wp, wn, wi, wCount);
// Nachbarn für welded Mesh aufbauen
int[][] wnb = buildNeighbors(wCount, wi);
// GPU-Buffers auf Dynamic setzen
m.getBuffer(VertexBuffer.Type.Position).setUsage(VertexBuffer.Usage.Dynamic);
if (m.getBuffer(VertexBuffer.Type.Normal) != null)
m.getBuffer(VertexBuffer.Type.Normal).setUsage(VertexBuffer.Usage.Dynamic);
// Material von VoxelEditorState wiederverwenden
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
Material mat = (ves != null) ? ves.getVoxelMaterial() : null;
if (mat == null) mat = new Material(app.getAssetManager(), "MatDefs/Voxel.j3md");
if (normalMat == null) normalMat = mat;
Geometry geo = new Geometry("sculpt_" + cx + "_" + cy + "_" + cz, m);
geo.setMaterial(mat);
float ox = cx * VoxelChunk.CELLS - 2048f;
float oy = cy * (float) VoxelChunk.CELLS;
float oz = cz * VoxelChunk.CELLS - 2048f;
Node node = new Node("sculptNode_" + cx + "_" + cy + "_" + cz);
node.setLocalTranslation(ox, oy, oz);
node.attachChild(geo);
sculptRoot.attachChild(node);
EditableMesh em = new EditableMesh(cx, cy, cz, rawCount, v2w, wp, wn, wi, wnb);
em.geo = geo;
em.node = node;
// GPU-Buffers initial befüllen
expandAndUpload(em, m);
meshes.put(key, em);
log.info("Sculpt-Chunk geladen ({},{},{}): {} raw / {} welded Vertices",
cx, cy, cz, rawCount, wCount);
}
// ── Pinsel ───────────────────────────────────────────────────────────────
private void applyBrushEdit(SharedInput.SculptEdit edit) {
Camera cam = app.getCamera();
float jmeX = edit.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - edit.screenY() * (float) input.viewportScaleY;
Vector3f ori = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f tgt = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(ori, tgt.subtract(ori).normalizeLocal());
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
if (cr.size() == 0) return;
Geometry hitGeo = cr.getClosestCollision().getGeometry();
Vector3f hitWorld = cr.getClosestCollision().getContactPoint();
EditableMesh hit = null;
for (EditableMesh em : meshes.values()) {
if (em.geo == hitGeo) { hit = em; break; }
}
if (hit == null) return;
int mode = input.sculptTool.mode.getSelectedIndex();
if (mode == SculptMeshTool.MODE_SELECT) {
setSelection(hit);
return;
}
long hitKey = chunkKey(hit.cx, hit.cy, hit.cz);
if (!sessionSnapshot.containsKey(hitKey))
sessionSnapshot.put(hitKey, hit.wp.clone());
// Trefpunkt in lokalen Chunk-Raum transformieren
float ox = hit.cx * VoxelChunk.CELLS - 2048f;
float oy = hit.cy * (float) VoxelChunk.CELLS;
float oz = hit.cz * VoxelChunk.CELLS - 2048f;
float lhx = hitWorld.x - ox;
float lhy = hitWorld.y - oy;
float lhz = hitWorld.z - oz;
float radius = (float) input.sculptTool.brushRadius.getValue();
float strength = (float) input.sculptTool.brushStrength.getValue() * 0.5f;
float dirMul = (edit.action() == 1) ? -1f : 1f;
if (mode == SculptMeshTool.MODE_LOWER) dirMul = -dirMul;
boolean changed = switch (mode) {
case SculptMeshTool.MODE_RAISE, SculptMeshTool.MODE_LOWER ->
brushRaiseLower(hit, lhx, lhy, lhz, radius, strength * dirMul);
case SculptMeshTool.MODE_SMOOTH -> brushSmooth(hit, lhx, lhy, lhz, radius, strength);
case SculptMeshTool.MODE_FLATTEN -> brushFlatten(hit, lhx, lhy, lhz, radius, strength);
default -> false;
};
if (changed) {
recomputeNormals(hit.wp, hit.wn, hit.wi, hit.wp.length / 3);
updateMeshBuffers(hit);
hasDirty = true;
dirtyTimer = 0f;
}
}
// Alle Brush-Methoden operieren auf welded Positionen (hit.wp / hit.wn / hit.wnb)
private boolean brushRaiseLower(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp, n = em.wn;
boolean changed = false;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi] - lhx, dy = p[vi+1] - lhy, dz = p[vi+2] - lhz;
float d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= r2) continue;
float falloff = 1f - d2 / r2;
p[vi] += n[vi] * strength * falloff;
p[vi+1] += n[vi+1] * strength * falloff;
p[vi+2] += n[vi+2] * strength * falloff;
changed = true;
}
return changed;
}
private boolean brushSmooth(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp;
float[] smoothed = p.clone();
boolean changed = false;
for (int i = 0, vi = 0; vi < p.length; i++, vi += 3) {
float dx = p[vi] - lhx, dy = p[vi+1] - lhy, dz = p[vi+2] - lhz;
float d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= r2) continue;
int[] nb = em.wnb[i];
if (nb == null || nb.length == 0) continue;
float ax = 0, ay = 0, az = 0;
for (int ni : nb) { ax += p[ni*3]; ay += p[ni*3+1]; az += p[ni*3+2]; }
ax /= nb.length; ay /= nb.length; az /= nb.length;
float falloff = (1f - d2 / r2) * strength;
smoothed[vi] = p[vi] + (ax - p[vi]) * falloff;
smoothed[vi+1] = p[vi+1] + (ay - p[vi+1]) * falloff;
smoothed[vi+2] = p[vi+2] + (az - p[vi+2]) * falloff;
changed = true;
}
if (changed) System.arraycopy(smoothed, 0, p, 0, p.length);
return changed;
}
private boolean brushFlatten(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp;
float avgY = 0; int count = 0;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi]-lhx, dy = p[vi+1]-lhy, dz = p[vi+2]-lhz;
if (dx*dx+dy*dy+dz*dz < r2) { avgY += p[vi+1]; count++; }
}
if (count == 0) return false;
avgY /= count;
boolean changed = false;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi]-lhx, dy = p[vi+1]-lhy, dz = p[vi+2]-lhz;
float d2 = dx*dx+dy*dy+dz*dz;
if (d2 >= r2) continue;
p[vi+1] += (avgY - p[vi+1]) * (1f - d2/r2) * strength;
changed = true;
}
return changed;
}
// ── Normalen & Mesh-Update ────────────────────────────────────────────────
private static void recomputeNormals(float[] positions, float[] normals,
int[] indices, int wCount) {
Arrays.fill(normals, 0, wCount * 3, 0f);
for (int i = 0; i < indices.length; i += 3) {
int i0 = indices[i]*3, i1 = indices[i+1]*3, i2 = indices[i+2]*3;
float e1x = positions[i1] -positions[i0], e1y = positions[i1+1]-positions[i0+1], e1z = positions[i1+2]-positions[i0+2];
float e2x = positions[i2] -positions[i0], e2y = positions[i2+1]-positions[i0+1], e2z = positions[i2+2]-positions[i0+2];
float nx = e1y*e2z - e1z*e2y;
float ny = e1z*e2x - e1x*e2z;
float nz = e1x*e2y - e1y*e2x;
normals[i0]+=nx; normals[i0+1]+=ny; normals[i0+2]+=nz;
normals[i1]+=nx; normals[i1+1]+=ny; normals[i1+2]+=nz;
normals[i2]+=nx; normals[i2+1]+=ny; normals[i2+2]+=nz;
}
for (int i = 0; i < wCount * 3; i += 3) {
float len = (float)Math.sqrt(normals[i]*normals[i]+normals[i+1]*normals[i+1]+normals[i+2]*normals[i+2]);
if (len > 1e-4f) { normals[i]/=len; normals[i+1]/=len; normals[i+2]/=len; }
}
}
/** Fächert welded Positions/Normals auf rawCount auf und lädt in GPU-Buffers. */
private static void updateMeshBuffers(EditableMesh em) {
// welded → roh aufächern
for (int v = 0; v < em.rawCount; v++) {
int w = em.v2w[v];
em.rp[v*3] = em.wp[w*3];
em.rp[v*3+1] = em.wp[w*3+1];
em.rp[v*3+2] = em.wp[w*3+2];
em.rn[v*3] = em.wn[w*3];
em.rn[v*3+1] = em.wn[w*3+1];
em.rn[v*3+2] = em.wn[w*3+2];
}
com.jme3.scene.Mesh m = em.geo.getMesh();
FloatBuffer pb = (FloatBuffer) m.getBuffer(VertexBuffer.Type.Position).getData();
pb.clear(); pb.put(em.rp); pb.rewind();
m.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded();
VertexBuffer nb = m.getBuffer(VertexBuffer.Type.Normal);
if (nb != null) {
FloatBuffer nbf = (FloatBuffer) nb.getData();
nbf.clear(); nbf.put(em.rn); nbf.rewind();
nb.setUpdateNeeded();
}
m.updateBound();
}
/** Wie updateMeshBuffers, aber schreibt auch in das existierende JME3-Mesh-Objekt (initiales Laden). */
private static void expandAndUpload(EditableMesh em, com.jme3.scene.Mesh m) {
updateMeshBuffers(em);
}
private static int[][] buildNeighbors(int wCount, int[] indices) {
@SuppressWarnings("unchecked")
Set<Integer>[] sets = new Set[wCount];
for (int i = 0; i < wCount; i++) sets[i] = new HashSet<>();
for (int i = 0; i < indices.length; i += 3) {
int a = indices[i], b = indices[i+1], c = indices[i+2];
if (a == b || b == c || a == c) continue; // degeneriertes Dreieck
sets[a].add(b); sets[a].add(c);
sets[b].add(a); sets[b].add(c);
sets[c].add(a); sets[c].add(b);
}
int[][] result = new int[wCount][];
for (int i = 0; i < wCount; i++)
result[i] = sets[i].stream().mapToInt(Integer::intValue).toArray();
return result;
}
/** Quantisierter Positionsschlüssel für das Vertex-Welding (1/2048 Voxel Präzision). */
private static long posKey(float[] pos, int vi) {
long qx = Math.round(pos[vi*3] * 2048f) & 0x3FFFFL;
long qy = Math.round(pos[vi*3+1] * 2048f) & 0x3FFFFL;
long qz = Math.round(pos[vi*3+2] * 2048f) & 0x3FFFFL;
return qx | (qy << 18) | (qz << 36);
}
// ── Selektion ────────────────────────────────────────────────────────────
private void setSelection(EditableMesh em) {
long newKey = (em != null) ? chunkKey(em.cx, em.cy, em.cz) : -1L;
if (newKey == selectedKey) return;
if (selectedKey != -1L) {
EditableMesh prev = meshes.get(selectedKey);
if (prev != null && prev.geo != null && normalMat != null)
prev.geo.setMaterial(normalMat);
}
selectedKey = newKey;
input.selectedSculptKey = newKey;
if (em != null) {
em.geo.setMaterial(highlightMat);
input.selectedSculptLabel = "Chunk (" + em.cx + ", " + em.cy + ", " + em.cz + ")";
} else {
input.selectedSculptLabel = null;
}
}
private void applyTranslate(long key, float dx, float dy, float dz) {
EditableMesh em = meshes.get(key);
if (em == null) return;
float[] p = em.wp;
for (int i = 0; i < p.length; i += 3) {
p[i] += dx;
p[i+1] += dy;
p[i+2] += dz;
}
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
hasDirty = true;
dirtyTimer = 0f;
}
private void applyRotateY(long key, float degrees) {
EditableMesh em = meshes.get(key);
if (em == null) return;
float[] p = em.wp;
float rad = degrees * FastMath.DEG_TO_RAD;
float cos = FastMath.cos(rad);
float sin = FastMath.sin(rad);
int n = p.length / 3;
float cx = 0, cz = 0;
for (int i = 0; i < p.length; i += 3) { cx += p[i]; cz += p[i+2]; }
cx /= n; cz /= n;
for (int i = 0; i < p.length; i += 3) {
float lx = p[i] - cx;
float lz = p[i+2] - cz;
p[i] = cos * lx - sin * lz + cx;
p[i+2] = sin * lx + cos * lz + cz;
}
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
hasDirty = true;
dirtyTimer = 0f;
}
private void deleteSelected() {
EditableMesh em = meshes.get(selectedKey);
if (em == null) return;
em.node.removeFromParent();
meshes.remove(selectedKey);
if (SculptedMeshIO.exists(em.cx, em.cy, em.cz)) {
try { SculptedMeshIO.delete(em.cx, em.cy, em.cz); }
catch (Exception e) { log.warn("Sculpt-Datei löschen fehlgeschlagen: {}", e.getMessage()); }
}
for (int lod = 0; lod < 3; lod++) {
try { java.nio.file.Files.deleteIfExists(VoxelChunkIO.getBakedPath(em.cx, em.cy, em.cz, lod)); }
catch (Exception e) { log.warn("Baked LOD{} löschen fehlgeschlagen: {}", lod, e.getMessage()); }
}
selectedKey = -1L;
input.selectedSculptKey = -1L;
input.selectedSculptLabel = null;
undoStack.clear();
redoStack.clear();
}
// ── Undo / Redo ──────────────────────────────────────────────────────────
private void doUndo() {
if (undoStack.isEmpty()) return;
Map<Long, float[]> snap = undoStack.pollLast();
Map<Long, float[]> redoSnap = new HashMap<>();
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em != null) redoSnap.put(e.getKey(), em.wp.clone());
}
if (redoStack.size() >= MAX_UNDO) redoStack.pollFirst();
redoStack.addLast(redoSnap);
applySnapshot(snap);
}
private void doRedo() {
if (redoStack.isEmpty()) return;
Map<Long, float[]> snap = redoStack.pollLast();
Map<Long, float[]> undoSnap = new HashMap<>();
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em != null) undoSnap.put(e.getKey(), em.wp.clone());
}
if (undoStack.size() >= MAX_UNDO) undoStack.pollFirst();
undoStack.addLast(undoSnap);
applySnapshot(snap);
}
private void applySnapshot(Map<Long, float[]> snap) {
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em == null) continue;
System.arraycopy(e.getValue(), 0, em.wp, 0, em.wp.length);
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
}
hasDirty = true;
dirtyTimer = 0f;
}
// ── Speichern ────────────────────────────────────────────────────────────
private void saveAllDirty() {
for (EditableMesh em : meshes.values()) {
try {
// Welded Positionen speichern — Overlay enthält immer welded Daten
SculptedMeshIO.save(new SculptedMesh(em.cx, em.cy, em.cz, em.wp));
} catch (IOException e) {
log.error("Sculpt-Chunk speichern fehlgeschlagen ({},{},{}): {}",
em.cx, em.cy, em.cz, e.getMessage());
}
}
}
// ── Pinsel-Indikator ─────────────────────────────────────────────────────
private Geometry buildBrushIndicator() {
int segments = 32;
FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3);
pos.put(0f).put(0f).put(0f);
for (int i = 0; i < segments; i++) {
float a = FastMath.TWO_PI * i / segments;
pos.put(FastMath.cos(a)).put(0f).put(FastMath.sin(a));
}
java.nio.IntBuffer idx = BufferUtils.createIntBuffer(segments * 3);
for (int i = 0; i < segments; i++) {
idx.put(0);
idx.put(1 + i);
idx.put(1 + (i + 1) % segments);
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("sculptBrushIndicator", mesh);
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.5f, 0.1f, 0.45f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
private void updateBrushIndicator() {
if (brushIndicator == null) return;
boolean showBrush = active
&& input.sculptTool.mode.getSelectedIndex() != SculptMeshTool.MODE_SELECT;
if (!showBrush) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float mx = input.mouseScreenX;
float my = input.mouseScreenY;
if (mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float jmeX = mx * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Vector3f ori = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f tgt = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(ori, tgt.subtract(ori).normalizeLocal());
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
if (cr.size() == 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
Vector3f hitPos = cr.getClosestCollision().getContactPoint();
Vector3f hitNormal = cr.getClosestCollision().getContactNormal();
if (hitNormal == null || hitNormal.lengthSquared() < 1e-6f) hitNormal = Vector3f.UNIT_Y;
float r = (float) input.sculptTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(hitPos.add(hitNormal.mult(0.05f)));
brushIndicator.setLocalScale(r, 1f, r);
Vector3f axis = Vector3f.UNIT_Y.cross(hitNormal);
Quaternion rot = new Quaternion();
if (axis.lengthSquared() < 1e-6f) {
rot.fromAngleNormalAxis(
Vector3f.UNIT_Y.dot(hitNormal) > 0 ? 0f : FastMath.PI,
Vector3f.UNIT_X);
} else {
float angle = FastMath.acos(FastMath.clamp(Vector3f.UNIT_Y.dot(hitNormal), -1f, 1f));
rot.fromAngleNormalAxis(angle, axis.normalizeLocal());
}
brushIndicator.setLocalRotation(rot);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
}

View File

@@ -0,0 +1,584 @@
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.VertexBuffer.Type;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedStone;
import de.blight.common.PlacedStoneIO;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.util.*;
/**
* Rendert und verwaltet prozedural generierte Steine im Editor.
*
* Chunk-Schema: 128 m × 128 m, identisch mit GrassVertexState.
* LOD:
* - LOD0 (Icosphere subdiv 2, 320 Dreiecke): immer
* - LOD1 (Icosphere subdiv 1, 80 Dreiecke): nur für Steine mit Durchmesser > 1 m
* - LOD2: niemals
* LOD-Wechsel: hängt an Kamera-Distanz zum Chunk (< LOD1_DIST → LOD0, sonst → LOD1, > CULL_DIST → ausgeblendet).
*/
public class StoneEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(StoneEditorState.class);
// ── Chunk-Konstanten (deckungsgleich mit GrassVertexState) ────────────────
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
private static final int MAX_REBUILDS = 2;
// ── LOD-Distanzen ─────────────────────────────────────────────────────────
private static final float LOD1_DIST = 60f; // ab hier LOD1 für große Steine
private static final float CULL_DIST = 200f; // weiter weg: Chunk unsichtbar
// ── Felder ────────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private Camera cam;
private TerrainQuad terrain;
private Node rootNode;
private Node stoneRoot;
@SuppressWarnings("unchecked")
private final List<PlacedStone>[] chunkStones = new List[CHUNK_COUNT];
/** Pro Chunk: Node mit LOD0-Geometrien. */
private final Node[] lod0Nodes = new Node[CHUNK_COUNT];
/** Pro Chunk: Node mit LOD1-Geometrien (nur Steine ≥ 1m Durchmesser). */
private final Node[] lod1Nodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
private final Material[] slotMat = new Material[PlacedStoneIO.SLOT_COUNT];
private Material defaultMat;
private Geometry brushIndicator;
private final Random random = new Random();
private boolean modified = false;
public StoneEditorState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkStones[i] = new ArrayList<>();
}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
assetManager = app.getAssetManager();
cam = app.getCamera();
rootNode = ((SimpleApplication) app).getRootNode();
stoneRoot = new Node("stoneRoot");
rootNode.attachChild(stoneRoot);
reloadMaterials();
loadFromDisk();
brushIndicator = buildBrushIndicator();
rootNode.attachChild(brushIndicator);
}
@Override
protected void cleanup(Application app) {
saveIfModified();
rootNode.detachChild(stoneRoot);
rootNode.detachChild(brushIndicator);
}
@Override protected void onEnable() { stoneRoot.setCullHint(Spatial.CullHint.Inherit); }
@Override protected void onDisable() { stoneRoot.setCullHint(Spatial.CullHint.Always); }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.stoneTexturesChanged) {
input.stoneTexturesChanged = false;
reloadMaterials();
Arrays.fill(dirtyChunks, true);
}
updateBrushIndicator();
processEdits();
processTerrainUpdates();
rebuildDirtyChunks();
updateChunkLOD();
}
// ── Edit-Verarbeitung ─────────────────────────────────────────────────────
private void processTerrainUpdates() {
float[] area;
while ((area = input.terrainEditedAreas.poll()) != null) {
float wx = area[0], wz = area[1], radius = area[2];
float checkR = radius + CHUNK_SIZE;
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
Vector2f center = chunkCenter(ci);
float dx = center.x - wx, dz = center.y - wz;
if (dx*dx + dz*dz <= checkR * checkR) dirtyChunks[ci] = true;
}
}
}
private void processEdits() {
SharedInput.StoneEdit edit;
while ((edit = input.stoneEditQueue.poll()) != null) {
float jx = edit.screenX() * (float) input.viewportScaleX;
float jy = cam.getHeight() - edit.screenY() * (float) input.viewportScaleY;
Vector3f hit = raycastTerrain(jx, jy);
if (hit == null) continue;
if (edit.action() > 0) addStonesAt(hit.x, hit.z);
else removeStonesAt(hit.x, hit.z);
}
}
private void addStonesAt(float wx, float wz) {
if (terrain == null) return;
double brushR = input.stoneTool.brushRadius.getValue();
double minR = input.stoneTool.minSize.getValue() / 2.0;
double maxR = input.stoneTool.maxSize.getValue() / 2.0;
int count = (int) input.stoneTool.density.getValue();
String[] paths = input.stoneTool.texturePaths;
// Anzahl belegter Slots ermitteln (nur existierende Texturen zählen)
int activeSlots = 0;
for (String p : paths) if (p != null && !p.isEmpty()) activeSlots++;
if (activeSlots == 0) activeSlots = 1; // Slot 0 = Default-Grau
for (int i = 0; i < count; i++) {
double angle = random.nextDouble() * Math.PI * 2;
double dist = Math.sqrt(random.nextDouble()) * brushR;
float sx = wx + (float)(Math.cos(angle) * dist);
float sz = wz + (float)(Math.sin(angle) * dist);
float th = terrain.getHeight(new Vector2f(sx, sz));
if (!Float.isFinite(th)) continue;
float radius = (float)(minR + random.nextDouble() * (maxR - minR));
float rotY = random.nextFloat() * 360f;
float sinkFrac = 0.2f + random.nextFloat() * 0.3f;
int slot = random.nextInt(activeSlots);
int seed = random.nextInt();
PlacedStone stone = new PlacedStone(sx, sz, radius, rotY, slot, sinkFrac, seed);
int ci = chunkIndex(sx, sz);
if (ci >= 0) {
chunkStones[ci].add(stone);
dirtyChunks[ci] = true;
modified = true;
}
}
}
private void removeStonesAt(float wx, float wz) {
double brushR = input.stoneTool.brushRadius.getValue();
float r2 = (float)(brushR * brushR);
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
Vector2f center = chunkCenter(ci);
float cdx = center.x - wx, cdz = center.y - wz;
if (cdx*cdx + cdz*cdz > (brushR + CHUNK_SIZE) * (brushR + CHUNK_SIZE)) continue;
boolean removed = chunkStones[ci].removeIf(s -> {
float dx = s.x() - wx, dz = s.z() - wz;
return dx*dx + dz*dz <= r2;
});
if (removed) { dirtyChunks[ci] = true; modified = true; }
}
}
// ── Chunk-Geometrie ───────────────────────────────────────────────────────
private void rebuildDirtyChunks() {
int rebuilt = 0;
for (int ci = 0; ci < CHUNK_COUNT && rebuilt < MAX_REBUILDS; ci++) {
if (!dirtyChunks[ci]) continue;
dirtyChunks[ci] = false;
rebuildChunk(ci);
rebuilt++;
}
}
private void rebuildChunk(int ci) {
// Alte Nodes entfernen
if (lod0Nodes[ci] != null) { lod0Nodes[ci].detachAllChildren(); stoneRoot.detachChild(lod0Nodes[ci]); }
if (lod1Nodes[ci] != null) { lod1Nodes[ci].detachAllChildren(); stoneRoot.detachChild(lod1Nodes[ci]); }
List<PlacedStone> stones = chunkStones[ci];
if (stones.isEmpty()) { lod0Nodes[ci] = null; lod1Nodes[ci] = null; return; }
Node n0 = new Node("stone_lod0_" + ci);
Node n1 = new Node("stone_lod1_" + ci);
for (PlacedStone s : stones) {
float y = stoneWorldY(s);
Geometry g0 = buildStoneGeom(s, y, 2); // LOD0: 2 Subdiv
n0.attachChild(g0);
if (s.radius() * 2f > 1f) { // Durchmesser > 1m → LOD1
Geometry g1 = buildStoneGeom(s, y, 1); // LOD1: 1 Subdiv
n1.attachChild(g1);
}
}
stoneRoot.attachChild(n0);
stoneRoot.attachChild(n1);
lod0Nodes[ci] = n0;
lod1Nodes[ci] = n1;
}
private Geometry buildStoneGeom(PlacedStone s, float worldY, int subdivisions) {
Mesh mesh = buildStoneMesh(s.radius(), s.noiseSeed(), subdivisions);
// Einsinken: Zentrum liegt bei terrain - sinkFraction * radius * 2 + radius (= terrain + radius*(1 - 2*sinkFraction))
float yCenter = worldY + s.radius() * (1f - 2f * s.sinkFraction());
Geometry g = new Geometry("stone", mesh);
g.setMaterial(materialForSlot(s.textureSlot()));
g.setLocalTranslation(s.x(), yCenter, s.z());
g.rotate(0f, s.rotY() * FastMath.DEG_TO_RAD, 0f);
return g;
}
// ── LOD-Umschaltung ───────────────────────────────────────────────────────
private void updateChunkLOD() {
Vector3f camPos = cam.getLocation();
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (lod0Nodes[ci] == null) continue;
Vector2f center = chunkCenter(ci);
float dx = camPos.x - center.x, dz = camPos.z - center.y;
float dist = (float) Math.sqrt(dx*dx + dz*dz);
if (dist > CULL_DIST) {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Always);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Always);
} else if (dist > LOD1_DIST) {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Always);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Inherit);
} else {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Inherit);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Always);
}
}
}
// ── Mesh-Generierung: Icosphere ───────────────────────────────────────────
private static final float PHI = (1f + (float) Math.sqrt(5)) / 2f;
/** Basisvertices des Ikosaeders (auf Einheitskugel normiert). */
private static final float[][] ICO_V = normalize12(new float[][]{
{-1, PHI, 0}, { 1, PHI, 0}, {-1, -PHI, 0}, { 1, -PHI, 0},
{ 0, -1, PHI}, { 0, 1, PHI}, { 0, -1, -PHI}, { 0, 1, -PHI},
{ PHI, 0, -1}, { PHI, 0, 1}, {-PHI, 0, -1}, {-PHI, 0, 1}
});
/** 20 Dreiecke des Ikosaeders. */
private static final int[][] ICO_F = {
{0,11,5},{0,5,1},{0,1,7},{0,7,10},{0,10,11},
{1,5,9},{5,11,4},{11,10,2},{10,7,6},{7,1,8},
{3,9,4},{3,4,2},{3,2,6},{3,6,8},{3,8,9},
{4,9,5},{2,4,11},{6,2,10},{8,6,7},{9,8,1}
};
private static float[][] normalize12(float[][] raw) {
float[][] r = new float[raw.length][3];
for (int i = 0; i < raw.length; i++) r[i] = normalizeV(raw[i]);
return r;
}
private static float[] normalizeV(float[] v) {
float len = (float) Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
return new float[]{v[0]/len, v[1]/len, v[2]/len};
}
/**
* Erzeugt einen Stein-Mesh mit der gegebenen Anzahl an Icosphere-Unterteilungen.
* Noise-Verformung wird durch noiseSeed deterministisch gesteuert.
*/
private static Mesh buildStoneMesh(float radius, int noiseSeed, int subdivisions) {
// 1. Icosphere aufbauen und unterteilen
List<float[]> verts = new ArrayList<>(Arrays.asList(ICO_V));
List<int[]> faces = new ArrayList<>(Arrays.asList(ICO_F));
for (int s = 0; s < subdivisions; s++) {
List<float[]> nv = new ArrayList<>(verts);
List<int[]> nf = new ArrayList<>();
Map<Long, Integer> midCache = new HashMap<>();
for (int[] f : faces) {
int a = f[0], b = f[1], c = f[2];
int ab = getMid(nv, midCache, a, b);
int bc = getMid(nv, midCache, b, c);
int ca = getMid(nv, midCache, c, a);
nf.add(new int[]{a, ab, ca});
nf.add(new int[]{b, bc, ab});
nf.add(new int[]{c, ca, bc});
nf.add(new int[]{ab, bc, ca});
}
verts = nv; faces = nf;
}
// 2. Noise-Verformung (entlang Vertex-Normal = Einheitskugel-Richtung)
Random rng = new Random(noiseSeed);
float ox = rng.nextFloat() * 50f, oy = rng.nextFloat() * 50f, oz = rng.nextFloat() * 50f;
float freq = 2.5f + (Math.abs(noiseSeed) % 3); // 2.54.5
float amp = 0.22f; // ±22 % Verformung
int nv = verts.size();
float[] pos = new float[nv * 3];
float[] nor = new float[nv * 3];
float[] uv = new float[nv * 2];
for (int i = 0; i < nv; i++) {
float[] unit = verts.get(i);
float n = smoothNoise3D(unit[0] * freq + ox, unit[1] * freq + oy, unit[2] * freq + oz);
float r = radius * (1f + n * amp);
pos[i*3] = unit[0] * r;
pos[i*3+1] = unit[1] * r;
pos[i*3+2] = unit[2] * r;
// UV: sphärisch
uv[i*2] = (float)(Math.atan2(unit[2], unit[0]) / (2 * Math.PI) + 0.5);
uv[i*2+1] = (float)(Math.asin(Math.max(-1, Math.min(1, unit[1]))) / Math.PI + 0.5);
}
// 3. Vertex-Normalen aus Dreiecken akkumulieren
for (int[] f : faces) {
int ai = f[0]*3, bi = f[1]*3, ci = f[2]*3;
float ax = pos[ai], ay = pos[ai+1], az = pos[ai+2];
float bx = pos[bi], by = pos[bi+1], bz = pos[bi+2];
float cx2= pos[ci], cy = pos[ci+1], cz = pos[ci+2];
float nx = (by-ay)*(cz-az) - (bz-az)*(cy-ay);
float ny = (bz-az)*(cx2-ax) - (bx-ax)*(cz-az);
float nz = (bx-ax)*(cy-ay) - (by-ay)*(cx2-ax);
for (int vi : f) { nor[vi*3] += nx; nor[vi*3+1] += ny; nor[vi*3+2] += nz; }
}
for (int i = 0; i < nv; i++) {
float len = (float) Math.sqrt(nor[i*3]*nor[i*3] + nor[i*3+1]*nor[i*3+1] + nor[i*3+2]*nor[i*3+2]);
if (len > 0) { nor[i*3] /= len; nor[i*3+1] /= len; nor[i*3+2] /= len; }
}
// 4. JME3-Mesh zusammenbauen
FloatBuffer pb = BufferUtils.createFloatBuffer(pos);
FloatBuffer nb = BufferUtils.createFloatBuffer(nor);
FloatBuffer ub = BufferUtils.createFloatBuffer(uv);
java.nio.IntBuffer ib = BufferUtils.createIntBuffer(faces.size() * 3);
for (int[] f : faces) ib.put(f[0]).put(f[1]).put(f[2]);
ib.flip();
Mesh mesh = new Mesh();
mesh.setBuffer(Type.Position, 3, pb);
mesh.setBuffer(Type.Normal, 3, nb);
mesh.setBuffer(Type.TexCoord, 2, ub);
mesh.setBuffer(Type.Index, 3, ib);
mesh.updateBound();
return mesh;
}
private static int getMid(List<float[]> verts, Map<Long, Integer> cache, int a, int b) {
long key = a < b ? ((long)a << 32 | b) : ((long)b << 32 | a);
return cache.computeIfAbsent(key, k -> {
float[] va = verts.get(a), vb = verts.get(b);
verts.add(normalizeV(new float[]{(va[0]+vb[0])*.5f, (va[1]+vb[1])*.5f, (va[2]+vb[2])*.5f}));
return verts.size() - 1;
});
}
// ── 3D-Rauschen ──────────────────────────────────────────────────────────
private static float valueNoise3D(int x, int y, int z) {
int h = x * 1619 ^ y * 31337 ^ z * 6971 ^ (x * y * z * 1013);
h = h ^ (h >>> 13);
h = h * (h * h * 15731 + 789221) + 1376312589;
return (h & 0x7fffffff) * (1f / 2147483647f);
}
private static float smoothNoise3D(float x, float y, float z) {
int xi = (int) Math.floor(x), yi = (int) Math.floor(y), zi = (int) Math.floor(z);
float fx = x-xi, fy = y-yi, fz = z-zi;
float c000 = valueNoise3D(xi, yi, zi), c100 = valueNoise3D(xi+1, yi, zi);
float c010 = valueNoise3D(xi, yi+1, zi), c110 = valueNoise3D(xi+1, yi+1, zi);
float c001 = valueNoise3D(xi, yi, zi+1), c101 = valueNoise3D(xi+1, yi, zi+1);
float c011 = valueNoise3D(xi, yi+1, zi+1), c111 = valueNoise3D(xi+1, yi+1, zi+1);
float lo = lerp(lerp(c000,c100,fx), lerp(c010,c110,fx), fy);
float hi = lerp(lerp(c001,c101,fx), lerp(c011,c111,fx), fy);
return lerp(lo, hi, fz) * 2f - 1f; // [-1, 1]
}
private static float lerp(float a, float b, float t) { return a + (b-a)*t; }
// ── Materialien ───────────────────────────────────────────────────────────
private void reloadMaterials() {
defaultMat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
defaultMat.setColor("Diffuse", new ColorRGBA(0.55f, 0.52f, 0.48f, 1f));
defaultMat.setColor("Ambient", new ColorRGBA(0.15f, 0.14f, 0.13f, 1f));
defaultMat.setColor("Specular", ColorRGBA.Black);
defaultMat.setBoolean("UseMaterialColors", true);
String[] paths = input.stoneTool.texturePaths;
for (int i = 0; i < PlacedStoneIO.SLOT_COUNT; i++) {
String p = (i < paths.length && paths[i] != null) ? paths[i] : "";
if (!p.isEmpty()) {
try {
Material m = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
m.setTexture("DiffuseMap", assetManager.loadTexture(p));
m.setColor("Diffuse", ColorRGBA.White);
m.setColor("Ambient", new ColorRGBA(0.2f, 0.2f, 0.2f, 1f));
m.setColor("Specular", ColorRGBA.Black);
m.setBoolean("UseMaterialColors", true);
slotMat[i] = m;
} catch (Exception e) {
log.warn("[StoneEditorState] Textur nicht ladbar: {}", p);
slotMat[i] = null;
}
} else {
slotMat[i] = null;
}
}
}
private Material materialForSlot(int slot) {
if (slot >= 0 && slot < slotMat.length && slotMat[slot] != null) return slotMat[slot];
return defaultMat;
}
// ── Brush-Indicator ───────────────────────────────────────────────────────
private void updateBrushIndicator() {
if (input.activeLayer != SharedInput.LAYER_STONE || input.mouseScreenX < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float jx = input.mouseScreenX * (float) input.viewportScaleX;
float jy = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY;
Vector3f hit = raycastTerrain(jx, jy);
if (hit != null) {
float r = (float) input.stoneTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(hit.x, hit.y + 0.15f, hit.z);
brushIndicator.setLocalScale(r, 1f, r);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
} else {
brushIndicator.setCullHint(Spatial.CullHint.Always);
}
}
private Geometry buildBrushIndicator() {
int segments = 48;
java.nio.FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3);
pos.put(0f).put(0f).put(0f);
for (int i = 0; i < segments; i++) {
float a = FastMath.TWO_PI * i / segments;
pos.put(FastMath.cos(a)).put(0f).put(FastMath.sin(a));
}
java.nio.IntBuffer idx = BufferUtils.createIntBuffer(segments * 3);
for (int i = 0; i < segments; i++) {
idx.put(0);
idx.put(1 + i);
idx.put(1 + (i + 1) % segments);
}
Mesh mesh = new Mesh();
mesh.setBuffer(Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("stoneBrushIndicator", mesh);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.9f, 0.5f, 0.1f, 0.4f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Raycast ───────────────────────────────────────────────────────────────
private Vector3f raycastTerrain(float sx, float sy) {
if (terrain == null) return null;
Ray ray = new Ray(cam.getWorldCoordinates(new Vector2f(sx, sy), 0f),
cam.getWorldCoordinates(new Vector2f(sx, sy), 1f));
ray.getDirection().subtractLocal(ray.getOrigin()).normalizeLocal();
CollisionResults res = new CollisionResults();
terrain.collideWith(ray, res);
return res.size() > 0 ? res.getClosestCollision().getContactPoint() : null;
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private int chunkIndex(float wx, float wz) {
int cx = (int)((wx + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((wz + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
private Vector2f chunkCenter(int ci) {
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
return new Vector2f(wx, wz);
}
private float stoneWorldY(PlacedStone s) {
if (terrain == null) return 0f;
float h = terrain.getHeight(new Vector2f(s.x(), s.z()));
if (!Float.isFinite(h)) return 0f;
return h < -1e10f ? 0f : h;
}
// ── Persistenz ───────────────────────────────────────────────────────────
private void loadFromDisk() {
try {
PlacedStoneIO.StoneData data = PlacedStoneIO.load();
if (data == null) return;
// Texturpfade in StoneTool übernehmen
String[] paths = data.slotPaths();
if (paths != null) {
for (int i = 0; i < Math.min(paths.length, input.stoneTool.texturePaths.length); i++)
input.stoneTool.texturePaths[i] = paths[i] != null ? paths[i] : "";
reloadMaterials();
}
for (PlacedStone s : data.stones()) {
int ci = chunkIndex(s.x(), s.z());
if (ci >= 0) { chunkStones[ci].add(s); dirtyChunks[ci] = true; }
}
log.info("[StoneEditorState] {} Steine geladen.", data.stones().size());
} catch (Exception e) {
log.warn("[StoneEditorState] Laden fehlgeschlagen: {}", e.getMessage());
}
}
public void saveIfModified() {
if (!modified) return;
try {
List<PlacedStone> all = new ArrayList<>();
for (List<PlacedStone> list : chunkStones) all.addAll(list);
PlacedStoneIO.save(new PlacedStoneIO.StoneData(input.stoneTool.texturePaths, all));
modified = false;
log.info("[StoneEditorState] {} Steine gespeichert.", all.size());
} catch (Exception e) {
log.error("[StoneEditorState] Speichern fehlgeschlagen: {}", e.getMessage());
}
}
}

View File

@@ -46,6 +46,8 @@ import de.blight.common.MapData;
import de.blight.common.MapIO;
import de.blight.common.PlacedModelIO;
import de.blight.editor.SharedInput;
import de.blight.editor.state.PathNetworkEditorState;
import de.blight.editor.state.RoutineMapState;
import de.blight.editor.tool.HeightTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -81,6 +83,7 @@ public class TerrainEditorState extends BaseAppState {
// ── Kamera ────────────────────────────────────────────────────────────────
private static final float CAM_SPEED = 300f;
private static final float MAX_CAM_Y = 1500f;
private static final float ORBIT_SPEED = 1.5f;
private static final float MOUSE_SENS = 0.003f;
@@ -96,6 +99,7 @@ public class TerrainEditorState extends BaseAppState {
private Geometry brushIndicator;
private PlacedObjectState placedObjectState;
private GrassVertexState grassVertexState;
private StoneEditorState stoneEditorState;
private SceneObjectState sceneObjState;
private ItemPlacementState itemPlacementState;
private LightState lightState;
@@ -238,6 +242,11 @@ public class TerrainEditorState extends BaseAppState {
grassVertexState.setTerrain(terrain);
app.getStateManager().attach(grassVertexState);
input.loadingStatus = "Lade Steine...";
stoneEditorState = new StoneEditorState(input);
stoneEditorState.setTerrain(terrain);
app.getStateManager().attach(stoneEditorState);
sceneObjState = app.getStateManager().getState(SceneObjectState.class);
if (sceneObjState != null) {
sceneObjState.setTerrain(terrain);
@@ -338,6 +347,12 @@ public class TerrainEditorState extends BaseAppState {
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
if (playToolState != null) playToolState.setTerrain(terrain);
RoutineMapState routineMapState = app.getStateManager().getState(RoutineMapState.class);
if (routineMapState != null) routineMapState.setTerrain(terrain);
PathNetworkEditorState pathNetState = app.getStateManager().getState(PathNetworkEditorState.class);
if (pathNetState != null) pathNetState.setTerrain(terrain);
rootNode.attachChild(buildWater());
rootNode.attachChild(buildGrid());
@@ -1143,6 +1158,7 @@ public class TerrainEditorState extends BaseAppState {
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
// Löschoperationen aus dem JavaFX-Thread entsteht.
if (sceneObjState != null) sceneObjState.syncInteractables();
try { if (models != null) PlacedModelIO.save(models); } catch (IOException e) { log.error("Modelle speichern", e); }
try { if (lights != null) LightIO.save(lights); } catch (IOException e) { log.error("Lichter speichern", e); }
try { if (emitters != null) EmitterIO.save(emitters); } catch (IOException e) { log.error("Emitter speichern", e); }
@@ -1151,6 +1167,7 @@ public class TerrainEditorState extends BaseAppState {
try { if (soundAreas != null) SoundAreaIO.save(soundAreas); } catch (IOException e) { log.error("Soundbereiche speichern", e); }
try { if (areas != null) AreaIO.save(areas); } catch (IOException e) { log.error("Bereiche speichern", e); }
try { if (locationZones != null) LocationZoneIO.save(locationZones); } catch (IOException e) { log.error("Zonen speichern", e); }
if (stoneEditorState != null) stoneEditorState.saveIfModified();
// ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─
saveExecutor.submit(() -> {
@@ -1240,7 +1257,10 @@ public class TerrainEditorState extends BaseAppState {
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|| layer == SharedInput.LAYER_LOCATION_ZONES
|| layer == SharedInput.LAYER_PLAY_TOOL
|| layer == SharedInput.LAYER_VOXEL || mx < 0) {
|| layer == SharedInput.LAYER_VOXEL || layer == SharedInput.LAYER_STONE
|| layer == SharedInput.LAYER_BED_LIEGE
|| layer == SharedInput.LAYER_BENCH_SITZ
|| mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
@@ -1347,6 +1367,10 @@ public class TerrainEditorState extends BaseAppState {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode);
}
if (terrainChanged) {
float br = (float) input.heightTool.brushRadius.getValue();
input.terrainEditedAreas.offer(new float[]{contact.x, contact.z, br});
}
}
if (processed > 0) terrain.updateModelBound();
}
@@ -1517,7 +1541,9 @@ public class TerrainEditorState extends BaseAppState {
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;
if (h == null || !Float.isFinite(h)) return CAM_SPEED;
float dist = camPos.y - h;
return Float.isFinite(dist) ? Math.max(1f, dist) : CAM_SPEED;
}
private void updateCamera(float tpf) {
@@ -1567,6 +1593,12 @@ public class TerrainEditorState extends BaseAppState {
if (scroll != 0)
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
// NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert)
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
camPos.set(0f, DEFAULT_CAM_Y, 0f);
}
camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y);
cam.setLocation(camPos);
}

View File

@@ -10,6 +10,7 @@ import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
@@ -19,6 +20,7 @@ import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Texture;
@@ -40,6 +42,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -117,6 +121,23 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator;
// ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */
private Node basePlaneNode;
private Geometry basePlane;
/** Chunk-Gitter (128 × 128 Einheiten) als Debugging-Hilfe. */
private Geometry chunkGrid;
// ── Wireframe-Modus ───────────────────────────────────────────────────────
/** true wenn im Moment Wireframe aktiv ist. */
private boolean wireframeActive = false;
/** Alle Materialien, die durch Wireframe verändert wurden (zum Zurücksetzen). */
private final Set<Material> wireframedMaterials = new HashSet<>();
/** Vorheriger Layer-Zustand für Einstieg/Ausstieg-Erkennung. */
private boolean prevLayerWasVoxel = false;
// ── LOD-Rebuild-Queue ─────────────────────────────────────────────────────
/** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */
@@ -124,6 +145,17 @@ public class VoxelEditorState extends BaseAppState {
/** Chunks mit fertigen LOD1/2-Meshes, die im JME-Thread übernommen werden. */
private final ConcurrentLinkedQueue<Runnable> lodResultQueue = new ConcurrentLinkedQueue<>();
// ── Undo / Redo ───────────────────────────────────────────────────────────
private static final int MAX_UNDO = 10;
private record UndoEntry(Map<Long, byte[]> before, Map<Long, byte[]> after) {}
private final Deque<UndoEntry> undoStack = new ArrayDeque<>();
private final Deque<UndoEntry> redoStack = new ArrayDeque<>();
private final Map<Long, byte[]> actionBefore = new HashMap<>();
private boolean actionInProgress = false;
// ── Konstruktor ───────────────────────────────────────────────────────────
public VoxelEditorState(SharedInput input) {
@@ -153,6 +185,14 @@ public class VoxelEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
// Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter
basePlaneNode = new Node("voxelBasePlaneNode");
basePlane = buildBasePlane();
chunkGrid = buildChunkGrid();
basePlaneNode.attachChild(basePlane);
basePlaneNode.attachChild(chunkGrid);
app.getRootNode().attachChild(basePlaneNode);
// Alle vorhandenen .blvc-Dateien laden
List<VoxelChunk> loaded = VoxelChunkIO.loadAll();
for (VoxelChunk chunk : loaded) {
@@ -167,7 +207,9 @@ public class VoxelEditorState extends BaseAppState {
protected void cleanup(Application app) {
executor.shutdownNow();
voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (wireframeActive) applyWireframe(false);
nodes.clear();
chunks.clear();
}
@@ -188,7 +230,14 @@ public class VoxelEditorState extends BaseAppState {
if (input.bakeVoxelsRequested) {
input.bakeVoxelsRequested = false;
List<VoxelChunk> snapshot = new ArrayList<>(chunks.values());
executor.submit(() -> bakeAll(snapshot));
executor.submit(() -> {
try {
bakeAll(snapshot);
} catch (Throwable e) {
log.error("Bake fehlgeschlagen: {}", e.getMessage(), e);
input.bakeStatusMsg = "FEHLER: " + e.getMessage();
}
});
}
// Voxel-Texturen aktualisiert?
@@ -197,6 +246,17 @@ public class VoxelEditorState extends BaseAppState {
applyTextures(voxelMaterial);
}
// Layer-Wechsel erkennen → Referenzebene und Wireframe steuern
boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL;
if (isVoxelLayer != prevLayerWasVoxel) {
prevLayerWasVoxel = isVoxelLayer;
onVoxelLayerChanged(isVoxelLayer);
}
// Wireframe-Toggle während LAYER_VOXEL (Button-Klick im Panel)
if (isVoxelLayer && wireframeActive != input.voxelWireframeEnabled) {
applyWireframe(input.voxelWireframeEnabled);
}
// Nur aktiv wenn LAYER_VOXEL gesetzt
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
idleSinceEdit = 0f;
@@ -205,6 +265,12 @@ public class VoxelEditorState extends BaseAppState {
return;
}
// Undo/Redo-Aktionsgrenzen und Anfragen verarbeiten
if (input.voxelActionStarted) { input.voxelActionStarted = false; beginVoxelAction(); }
if (input.voxelActionFinished) { input.voxelActionFinished = false; finishVoxelAction(); }
if (input.voxelUndoRequested) { input.voxelUndoRequested = false; applyUndo(); }
if (input.voxelRedoRequested) { input.voxelRedoRequested = false; applyRedo(); }
// Edit-Queue verarbeiten (max. MAX_EDITS_PER_FRAME)
// Edits nur akkumulieren; Mesh-Rebuild am Frameende einmal pro Chunk.
int processed = 0;
@@ -322,7 +388,9 @@ public class VoxelEditorState extends BaseAppState {
Vector3f bestPos = null;
Vector3f bestNorm = new Vector3f(0, 1, 0);
if (terrainNode != null) {
// Im Voxel-Layer nur Voxel-Geometrie und die Basis-Referenzebene treffen,
// nicht das Heightmap-Terrain (das würde Voxel auf der falschen Höhe erzeugen).
if (terrainNode != null && input.activeLayer != SharedInput.LAYER_VOXEL) {
terrainNode.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
@@ -341,11 +409,26 @@ public class VoxelEditorState extends BaseAppState {
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
bestDist = cr.getDistance();
bestPos = cr.getContactPoint();
bestNorm = cr.getContactNormal() != null
? cr.getContactNormal().normalize()
: new Vector3f(0, 1, 0);
}
results.clear();
}
// Basis-Terrain-Referenzebene (y = -10) als Raycast-Ziel
if (basePlaneNode != null && basePlane != null
&& basePlane.getCullHint() != Spatial.CullHint.Always) {
basePlaneNode.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
bestPos = cr.getContactPoint();
bestNorm = new Vector3f(0, 1, 0);
}
}
}
return bestPos != null ? new Hit(bestPos, bestNorm) : null;
@@ -354,28 +437,36 @@ public class VoxelEditorState extends BaseAppState {
/**
* Wendet den gewählten Modus an.
*
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Spalten ab Terrain-Oberfläche nach oben (links)
* bzw. Abtragen nach unten (rechts). Verhalten analog zum Terrain-Tool.
* Vertikal (horizontal=false):
* Modi 0-3: Säulen ab Terrain-Oberfläche nach oben/unten. Analog zum Terrain-Tool.
* Modus 4 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum.
*
* Modus 4 (Klippe): Scheiben-Pinsel entlang der Oberflächennormale rechts = entfernen.
*
* Modus 5 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum.
* Horizontal (horizontal=true):
* Modi 0-3: Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
* Modus 4 (Aushöhlen): wie vertikal (Kugel-Entfernen).
*/
private void applyEdit(Hit hit, int action) {
float radius = (float) input.voxelTool.brushRadius.getValue();
float strength = (float) input.voxelTool.brushStrength.getValue();
int modeIdx = input.voxelTool.mode.getSelectedIndex();
boolean isHorizontal = input.voxelTool.horizontal;
boolean isSlab = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_ADD);
boolean isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE);
boolean isColumn = !isSlab && !isCave;
boolean isColumn = !isCave;
boolean lower = action < 0;
Vector3f N = hit.normal;
Vector3f N = hit.normal();
float wx = hit.pos.x, wy = hit.pos.y, wz = hit.pos.z;
// Plateau-Rechtsklick: Voxel- und Terrain-Höhe sampeln, Maximum als Ziel speichern
if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
// Horizontaler Modus: bei flacher Fläche (Normal.y > 0.7) nichts tun
if (isHorizontal && isColumn) {
if (Math.abs(N.y) > 0.7f) return;
float nhLen = (float) Math.sqrt(N.x*N.x + N.z*N.z);
if (nhLen < 0.1f) return;
}
// Plateau-Rechtsklick (nur vertikal): Voxel- und Terrain-Höhe sampeln
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
float h = columnTopWorldY(wx, wz);
TerrainEditorState tes = getStateManager().getState(TerrainEditorState.class);
if (tes != null) {
@@ -396,8 +487,8 @@ public class VoxelEditorState extends BaseAppState {
wz -= N.z * radius * 0.6f;
}
// Spalten-Modi brauchen nur XZ-Radius; Slab/Cave brauchen auch Stärke in Y
float worldExtent = isColumn ? radius + 2f : radius + strength + 2f;
// Vertikale Spalten-Modi brauchen nur XZ-Radius; alle anderen auch Stärke
float worldExtent = (isColumn && !isHorizontal) ? radius + 2f : radius + strength + 2f;
int cxMin = VoxelChunk.worldXToCx(wx - worldExtent);
int cxMax = VoxelChunk.worldXToCx(wx + worldExtent);
@@ -406,29 +497,48 @@ public class VoxelEditorState extends BaseAppState {
int cyMin = VoxelChunk.worldYToCy(wy - worldExtent);
int cyMax = VoxelChunk.worldYToCy(wy + worldExtent);
// Smooth-Modus: Slope-Parameter vorab berechnen (für beide Klick-Varianten)
// Smooth-Modus: Slope-Parameter vorab berechnen (nur vertikal)
float[] slopeParams = null;
if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
slopeParams = computeSlopeParams(wx, wz, radius);
}
float plateauTargetH = (float) input.voxelTool.plateauTarget.getValue();
// Zurücksetzen-Modus: separate Behandlung ohne Chunk-Erzeugung
if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_RESET) {
applyReset(wx, wz, radius);
return;
}
for (int cz = czMin; cz <= czMax; cz++) {
for (int cx = cxMin; cx <= cxMax; cx++) {
for (int cy = cyMin; cy <= cyMax; cy++) {
VoxelChunk chunk = getOrCreateChunk(cx, cy, cz);
// Aushöhlen: nur existierende Chunks bearbeiten, keine neuen anlegen
VoxelChunk chunk = isCave
? chunks.get(chunkKey(cx, cy, cz))
: getOrCreateChunk(cx, cy, cz);
if (chunk == null || (isCave && chunk.isEmpty())) continue;
float lx = VoxelChunk.worldXToLocal(wx, cx);
float ly = VoxelChunk.worldYToLocal(wy, cy);
float lz = VoxelChunk.worldZToLocal(wz, cz);
long key = chunkKey(cx, cy, cz);
float lx = VoxelChunk.worldXToLocal(wx, cx);
float ly = VoxelChunk.worldYToLocal(wy, cy);
float lz = VoxelChunk.worldZToLocal(wz, cz);
snapshotChunkBefore(key, chunk);
if (isCave) {
chunk.applyBrush(lx, ly, lz, radius, Byte.MIN_VALUE, (byte)0);
} else if (isSlab) {
applySlabBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, lower);
// Graduelles Aushöhlen mit vollem Radius (passt zum Indikator).
// Stärke bestimmt Dichte-Abbau pro Tick (je höher, desto aggressiver).
int step = Math.max(8, (int)(strength * 2f));
chunk.reduceDensity(lx, ly, lz, radius, step);
chunk.pruneIsolated(lx, ly, lz, radius);
} else if (isHorizontal) {
applyHorizontalBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, modeIdx, lower);
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU) {
applyPlateauColumn(chunk, cx, cy, cz, wx, wz, radius, plateauTargetH);
// Stärke steuert wie schnell sich die Spalten dem Ziel annähern
final float target = plateauTargetH;
applyColumnToTarget(chunk, cx, cy, cz, wx, wz, radius, strength, coord -> target);
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
if (lower) {
applyCliffColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
@@ -439,7 +549,6 @@ public class VoxelEditorState extends BaseAppState {
applyColumnBrush(chunk, cx, cy, cz, wx, wz, radius, strength, modeIdx, lower);
}
long key = chunkKey(cx, cy, cz);
// Node erst anlegen wenn tatsächlich Daten vorhanden
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
addNodeForChunk(key, chunk);
@@ -451,15 +560,29 @@ public class VoxelEditorState extends BaseAppState {
}
/**
* Scheiben-Pinsel für den Klippe-Modus.
* remove=false: Voxel senkrecht zur Normalen aufbauen.
* remove=true: dieselbe Form entfernen (Rechtsklick).
* Horizontaler Brush: baut Voxel in Richtung der Flächennormale auf (oder ab).
* Das Profil (Sinus/Spike/Plateau/Smooth) bestimmt die Tiefe entlang der Normalen
* abhängig vom Abstand zur Brush-Mitte in der Flächen-Ebene (Y + tangential).
* remove=true: Rechtsklick Voxel entfernen.
*/
private void applySlabBrush(VoxelChunk chunk, int cx, int cy, int cz,
float hitWX, float hitWY, float hitWZ,
Vector3f N, float radius, float strength,
boolean remove) {
float extent = radius + strength + 1f;
private void applyHorizontalBrush(VoxelChunk chunk, int cx, int cy, int cz,
float hitWX, float hitWY, float hitWZ,
Vector3f N, float radius, float strength,
int mode, boolean remove) {
// Horizontale Normalenkomponente normalisieren
float nhx = N.x, nhz = N.z;
float nhLen = (float) Math.sqrt(nhx*nhx + nhz*nhz);
if (nhLen < 0.1f) return;
nhx /= nhLen; nhz /= nhLen;
// Tangente in XZ (senkrecht zur Normalen)
float tanx = -nhz, tanz = nhx;
float maxDepth = Math.max(1f, strength / 10f);
float overlap = 1.0f;
float r2 = radius * radius;
float extent = radius + maxDepth + 1f;
float lhX = VoxelChunk.worldXToLocal(hitWX, cx);
float lhY = VoxelChunk.worldYToLocal(hitWY, cy);
float lhZ = VoxelChunk.worldZToLocal(hitWZ, cz);
@@ -471,10 +594,6 @@ public class VoxelEditorState extends BaseAppState {
int z0 = Math.max(0, (int)(lhZ - extent));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lhZ + extent));
float nx = N.x, ny = N.y, nz = N.z;
float r2 = radius * radius;
float overlap = 1.0f;
for (int ly = y0; ly <= y1; ly++) {
float wy = VoxelChunk.toWorldY(cy, ly);
float dy = wy - hitWY;
@@ -485,12 +604,20 @@ public class VoxelEditorState extends BaseAppState {
float wx = VoxelChunk.toWorldX(cx, lx);
float dx = wx - hitWX;
float along = dx*nx + dy*ny + dz*nz;
float slabThick = Math.max(1f, strength / 10f);
if (along < -overlap || along > slabThick) continue;
float perpSq = dx*dx + dy*dy + dz*dz - along*along;
// Abstand in der Flächen-Ebene (Y + tangential in XZ)
float projTan = dx*tanx + dz*tanz;
float perpSq = projTan*projTan + dy*dy;
if (perpSq > r2) continue;
// Position entlang der Normalen
float projN = dx*nhx + dz*nhz;
float t = (float) Math.sqrt(perpSq) / radius;
float falloff = computeFalloff(mode, t);
float currDepth = maxDepth * falloff;
if (projN < -overlap || projN > currDepth) continue;
if (remove) {
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
} else {
@@ -542,26 +669,35 @@ public class VoxelEditorState extends BaseAppState {
int colStep = (int)(stepBase * falloff);
if (colStep < 1) continue;
// Terrain-Höhe: ceil stellt sicher, dass Voxel nie unterhalb Terrain beginnen
float terrainH = terrainH(wx, wz);
int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int) Math.ceil(VoxelChunk.worldYToLocal(terrainH, cy))));
// Unterirdische Voxel löschen (kein Überhang > 90°)
for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
if (!lower) {
// Aktuellen Säulen-Top finden (höchster Solid-Voxel ≥ terrainLY)
int currentTop = terrainLY; // Fallback: direkt auf Terrain starten
for (int ly = VoxelChunk.SIZE - 1; ly >= terrainLY; ly--) {
// Höchsten Solid-Voxel in dieser Spalte suchen
int currentTop = -1;
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
}
// Säule um colStep erhöhen, dabei Basis ab terrainLY immer füllen
if (currentTop < 0) {
if (cy == -1) {
// Basis-Ebene: Ankerpunkt bei ly=118 (Welt-Y = -10)
currentTop = 118;
} else {
// Nur weiterwachsen wenn der darunterliegende Chunk bis an die
// Grenze reicht (ly ≥ SIZE-3), sonst würde ein losgelöster Klumpen entstehen
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz));
if (below == null) continue;
boolean belowAtBoundary = false;
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) {
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; }
}
if (!belowAtBoundary) continue;
currentTop = 0;
}
}
int newTop = Math.min(VoxelChunk.SIZE - 1, currentTop + colStep);
for (int ly = terrainLY; ly <= newTop; ly++) {
for (int ly = currentTop; ly <= newTop; ly++) {
chunk.setDensity(lx, ly, lz, (byte) 127);
}
} else {
// Höchsten Solid-Voxel finden (egal ob über oder unter terrainLY)
// Höchsten Solid-Voxel finden
int currentTop = -1;
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
@@ -576,6 +712,48 @@ public class VoxelEditorState extends BaseAppState {
}
}
// ── Zurücksetzen-Pinsel ───────────────────────────────────────────────────
/** Setzt alle vorhandenen Chunks im Pinselbereich auf das Basis-Terrain zurück (y=-10). */
private void applyReset(float brushWX, float brushWZ, float radius) {
float halfChunk = VoxelChunk.CELLS / 2f;
for (VoxelChunk chunk : new ArrayList<>(chunks.values())) {
float ccx = chunk.cx * VoxelChunk.CELLS - 2048f + halfChunk;
float ccz = chunk.cz * VoxelChunk.CELLS - 2048f + halfChunk;
if (Math.abs(ccx - brushWX) > radius + halfChunk) continue;
if (Math.abs(ccz - brushWZ) > radius + halfChunk) continue;
applyResetBrush(chunk, chunk.cx, chunk.cy, chunk.cz, brushWX, brushWZ, radius);
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
if (!chunk.isEmpty() && !nodes.containsKey(key)) addNodeForChunk(key, chunk);
dirtyChunksThisFrame.add(key);
}
}
/** Löscht alle Voxel im Pinselbereich (setzt auf Luft). Die basePlane zeigt das Basis-Terrain. */
private void applyResetBrush(VoxelChunk chunk, int cx, int cy, int cz,
float brushWX, float brushWZ, float radius) {
float lxC = VoxelChunk.worldXToLocal(brushWX, cx);
float lzC = VoxelChunk.worldZToLocal(brushWZ, cz);
int x0 = Math.max(0, (int)(lxC - radius));
int x1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lxC + radius));
int z0 = Math.max(0, (int)(lzC - radius));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lzC + radius));
float r2 = radius * radius;
for (int lz = z0; lz <= z1; lz++) {
float wz = VoxelChunk.toWorldZ(cz, lz);
float dz = wz - brushWZ;
for (int lx = x0; lx <= x1; lx++) {
float wx = VoxelChunk.toWorldX(cx, lx);
float dx = wx - brushWX;
if (dx*dx + dz*dz > r2) continue;
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
if (chunk.getDensity(lx, ly, lz) != Byte.MIN_VALUE)
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
}
}
}
}
// ── Terrain-Höhe (schneller O(1)-Zugriff) ─────────────────────────────────
private float terrainH(float worldX, float worldZ) {
@@ -643,7 +821,7 @@ public class VoxelEditorState extends BaseAppState {
}
}
}
return terrainH(worldX, worldZ);
return -10f; // Kein Voxel vorhanden → Basis-Niveau
}
/**
@@ -687,19 +865,30 @@ public class VoxelEditorState extends BaseAppState {
}
float currentTopWY = currentTopLY >= 0
? VoxelChunk.toWorldY(cy, currentTopLY)
: terrainH(wx, wz);
: -10f; // Keine Voxel → Basis-Niveau als Referenz
float diff = targetH - currentTopWY;
if (Math.abs(diff) < 0.5f) continue;
int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int) Math.ceil(VoxelChunk.worldYToLocal(terrainH(wx, wz), cy))));
// Unterirdische Voxel immer leeren (kein Überhang > 90°)
for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
if (diff > 0) {
int startLY = currentTopLY >= 0 ? Math.max(currentTopLY, terrainLY) : terrainLY;
// Erhöhen
int startLY;
if (currentTopLY >= 0) {
startLY = currentTopLY;
} else if (cy == -1) {
startLY = 118;
} else {
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz));
if (below == null) continue;
boolean belowAtBoundary = false;
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) {
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; }
}
if (!belowAtBoundary) continue;
startLY = 0;
}
int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step);
for (int ly = terrainLY; ly <= newTop; ly++) {
for (int ly = startLY; ly <= newTop; ly++) {
chunk.setDensity(lx, ly, lz, (byte) 127);
}
} else {
@@ -758,14 +947,9 @@ public class VoxelEditorState extends BaseAppState {
float dx = wx - brushWX;
if (dx*dx + dz*dz > r2) continue;
float th = terrainH(wx, wz);
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
float wy = VoxelChunk.toWorldY(cy, ly);
if (wy < th) {
// Unterhalb Terrain: immer leeren, kein Überhang möglich
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
continue;
}
if (wy < -10f) continue; // Fundament unterhalb y=-10 nicht antasten
if (wy <= targetH) {
chunk.setDensity(lx, ly, lz, (byte) 127);
} else {
@@ -812,9 +996,21 @@ public class VoxelEditorState extends BaseAppState {
List<VoxelChunk> nonEmpty = new java.util.ArrayList<>();
for (VoxelChunk c : toProcess) { if (!c.isEmpty()) nonEmpty.add(c); }
// Chunks ohne .blvc-Datei überspringen (noch nie gespeichert).
nonEmpty.removeIf(c -> !VoxelChunkIO.exists(c.cx, c.cy, c.cz));
// Chunks ohne nennenswerte Geometrie überspringen (nur Brush-Randberührungen,
// solidYSpan < 2 → gleiche Schwelle wie der Game-Loader).
nonEmpty.removeIf(c -> c.solidYSpan() < 2);
if (nonEmpty.isEmpty()) {
input.bakeStatusMsg = "Nichts zu backen (keine nicht-leeren Chunks).";
return;
}
// bakeTotal sofort setzen, damit die Fortschrittsanzeige schon während des Blurs läuft
input.bakeDone = 0;
input.bakeTotal = nonEmpty.size();
log.info("Bake gestartet: {} Chunks", nonEmpty.size());
Map<Long, VoxelChunk> allOriginal = new HashMap<>();
for (VoxelChunk c : nonEmpty) allOriginal.put(chunkKey(c.cx, c.cy, c.cz), c);
@@ -863,6 +1059,7 @@ public class VoxelEditorState extends BaseAppState {
cBuf[c.idx(bx, VoxelChunk.CELLS, bz)] = tBuf[c.idx(bx, 0, bz)];
}
input.blurIterDone = 0;
for (int iter = 0; iter < 7; iter++) {
Map<Long, float[]> nextBufs = new HashMap<>();
for (VoxelChunk c : nonEmpty) {
@@ -889,6 +1086,7 @@ public class VoxelEditorState extends BaseAppState {
nextBufs.put(k, next);
}
curBufs = nextBufs;
input.blurIterDone = iter + 1;
}
// Blur-Ergebnisse in VoxelChunks umwandeln
@@ -934,9 +1132,26 @@ public class VoxelEditorState extends BaseAppState {
// NICHT ENTFERNEN! Wurde schon einmal versehentlich rückgängig gemacht.
// Ohne diesen Block entsteht am Terrain-Übergang ein hässlicher Überhang.
for (VoxelChunk blurred : blurredMap.values()) {
// Extrapolation nur sinnvoll, wenn der Chunk darunter (cy-1) ebenfalls
// solide Voxel an seiner Oberkante hat. Andernfalls handelt es sich um
// einen freistehenden Überhang, einen Höhleneingang oder eine Bergkante
// über Luft dort würde die Extrapolation fälschlicherweise die Luft
// unter der Geometrie mit Solid-Dichte füllen und die Unterseite zerstören.
long belowKey = chunkKey(blurred.cx, blurred.cy - 1, blurred.cz);
VoxelChunk blurredBelow = blurredMap.get(belowKey);
for (int lz = 0; lz < blurN; lz++) {
for (int lx = 0; lx < blurN; lx++) {
// Spalte überspringen, wenn der Chunk darunter hier oben keine
// soliden Voxel hat (kein durchgehender Terrain-Block nach unten).
if (blurredBelow == null) continue;
boolean colBelowHasSolid = false;
for (int ly2 = VoxelChunk.CELLS - 3; ly2 <= VoxelChunk.CELLS; ly2++) {
if (blurredBelow.getDensity(lx, ly2, lz) > 0) { colBelowHasSolid = true; break; }
}
if (!colBelowHasSolid) continue;
// Untersten festen Voxel in dieser Spalte finden
int yBot = -1;
for (int ly = 0; ly < blurN; ly++) {
@@ -944,6 +1159,17 @@ public class VoxelEditorState extends BaseAppState {
}
if (yBot < 0 || yBot + 2 >= blurN) continue;
// Interne Lücke prüfen: Solid → Luft → Solid von yBot aufwärts
// bedeutet Höhle oder Decken-Überhang → nicht extrapolieren.
boolean hasVoid = false;
boolean inAirAbove = false;
for (int ly = yBot + 1; ly < blurN; ly++) {
boolean s = blurred.getDensity(lx, ly, lz) > 0;
if (!s) { inAirAbove = true; }
else if (inAirAbove) { hasVoid = true; break; }
}
if (hasVoid) continue;
// Steigung aus den zwei Voxeln darüber bestimmen.
// slope < 0: Dichte nimmt nach unten ab (typisch für eine Oberfläche).
float d1 = blurred.getDensity(lx, yBot + 1, lz);
@@ -979,9 +1205,26 @@ public class VoxelEditorState extends BaseAppState {
baked++;
input.bakeDone = baked;
}
// Voxel-Daten löschen: Disk-Dateien entfernen, In-Memory leeren, Szene-Nodes entfernen
for (VoxelChunk chunk : nonEmpty) {
chunk.clear();
try { VoxelChunkIO.delete(chunk.cx, chunk.cy, chunk.cz); }
catch (Exception e) { log.warn("Voxel-Datei löschen fehlgeschlagen ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage()); }
}
app.enqueue(() -> {
for (VoxelChunk chunk : nonEmpty) {
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
VoxelChunkNode node = nodes.remove(key);
if (node != null) node.removeFromParent();
chunks.remove(key);
}
});
String msg = "Fertig: " + baked + " Chunk" + (baked != 1 ? "s" : "") + " gebacken.";
// bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt
input.bakeStatusMsg = msg;
input.bakeStatusMsg = msg;
input.sculptRescanNeeded = true;
log.info("Voxel-Bake abgeschlossen {}.", msg);
}
@@ -1063,9 +1306,11 @@ public class VoxelEditorState extends BaseAppState {
log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}",
cx, cy, cz, e.getMessage());
chunk = new VoxelChunk(cx, cy, cz);
fillBaseTerrainChunk(chunk);
}
} else {
chunk = new VoxelChunk(cx, cy, cz);
fillBaseTerrainChunk(chunk);
}
chunks.put(key, chunk);
return chunk;
@@ -1084,6 +1329,93 @@ public class VoxelEditorState extends BaseAppState {
return node;
}
// ── Intern: Undo / Redo ───────────────────────────────────────────────────
private void beginVoxelAction() {
if (actionInProgress) return;
actionInProgress = true;
actionBefore.clear();
redoStack.clear();
}
private void snapshotChunkBefore(long key, VoxelChunk chunk) {
if (!actionInProgress || actionBefore.containsKey(key)) return;
actionBefore.put(key, chunk.getDensityCopy());
}
private void finishVoxelAction() {
if (!actionInProgress) return;
actionInProgress = false;
if (actionBefore.isEmpty()) return;
Map<Long, byte[]> changedBefore = new HashMap<>();
Map<Long, byte[]> changedAfter = new HashMap<>();
for (Map.Entry<Long, byte[]> e : actionBefore.entrySet()) {
long key = e.getKey();
VoxelChunk c = chunks.get(key);
byte[] after = (c == null || c.isEmpty()) ? null : c.getDensityCopy();
if (!Arrays.equals(e.getValue(), after)) {
changedBefore.put(key, e.getValue());
changedAfter.put(key, after);
}
}
// Neu angelegte Chunks (gab es vor der Aktion nicht)
for (Map.Entry<Long, VoxelChunk> e : chunks.entrySet()) {
long key = e.getKey();
if (!actionBefore.containsKey(key) && !e.getValue().isEmpty()) {
changedBefore.put(key, null);
changedAfter.put(key, e.getValue().getDensityCopy());
}
}
if (!changedBefore.isEmpty()) {
undoStack.addFirst(new UndoEntry(changedBefore, changedAfter));
while (undoStack.size() > MAX_UNDO) undoStack.removeLast();
}
actionBefore.clear();
}
private void applyUndo() {
if (undoStack.isEmpty()) return;
UndoEntry entry = undoStack.removeFirst();
redoStack.addFirst(entry);
while (redoStack.size() > MAX_UNDO) redoStack.removeLast();
restoreChunkState(entry.before());
}
private void applyRedo() {
if (redoStack.isEmpty()) return;
UndoEntry entry = redoStack.removeFirst();
undoStack.addFirst(entry);
while (undoStack.size() > MAX_UNDO) undoStack.removeLast();
restoreChunkState(entry.after());
}
private void restoreChunkState(Map<Long, byte[]> snapshot) {
for (Map.Entry<Long, byte[]> e : snapshot.entrySet()) {
long key = e.getKey();
byte[] d = e.getValue();
if (d == null) {
chunks.remove(key);
VoxelChunkNode n = nodes.remove(key);
if (n != null) n.removeFromParent();
} else {
VoxelChunk c = chunks.get(key);
if (c == null) {
int cx = (int)(key & 0xFFFF); if (cx >= 0x8000) cx -= 0x10000;
int cy = (int)((key >> 16) & 0xFFFF); if (cy >= 0x8000) cy -= 0x10000;
int cz = (int)((key >> 32) & 0xFFFF); if (cz >= 0x8000) cz -= 0x10000;
c = new VoxelChunk(cx, cy, cz);
chunks.put(key, c);
}
c.setDensityArray(d.clone());
c.dirty = true;
dirtyChunksThisFrame.add(key);
if (!nodes.containsKey(key)) addNodeForChunk(key, c);
}
}
}
// ── Intern: Hintergrund-LOD ───────────────────────────────────────────────
private void scheduleLodRebuild() {
@@ -1138,17 +1470,16 @@ public class VoxelEditorState extends BaseAppState {
private void applyTextures(Material mat) {
mat.setFloat("TexScale", 8f);
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot, input.voxelCeilSlot };
String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" };
String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" };
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep","DisplacementMapCeil" };
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot };
String[] colSlots = { "TexFlat", "TexSteep" };
String[] normSlots = { "NormalMapFlat", "NormalMapSteep" };
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep" };
int[][] fallbackRgb = {
{100, 130, 60},
{110, 100, 90},
{ 70, 55, 45},
};
boolean anyDisp = false;
for (int i = 0; i < 3; i++) {
for (int i = 0; i < 2; i++) {
String texPath = slotTexPath(slotIdxs[i]);
String normPath = slotNormPath(slotIdxs[i]);
String dispPath = slotDispPath(slotIdxs[i]);
@@ -1256,11 +1587,27 @@ public class VoxelEditorState extends BaseAppState {
float jmeX = mx * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Hit hit = raycastHit(jmeX, jmeY);
Vector3f pos = hit != null ? hit.pos : null;
if (pos != null) {
if (hit != null) {
float r = (float) input.voxelTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(pos.x, pos.y + 0.3f, pos.z);
// Leicht entlang der Flächen-Normalen versetzt, um Z-Fighting zu vermeiden
brushIndicator.setLocalTranslation(hit.pos.add(hit.normal().mult(0.05f)));
brushIndicator.setLocalScale(r, 1f, r);
// Disc-Normale (lokales Y) auf hit.normal() ausrichten
Vector3f axis = Vector3f.UNIT_Y.cross(hit.normal());
Quaternion rot = new Quaternion();
if (axis.lengthSquared() < 1e-6f) {
// Parallel oder antiparallel → Identität oder 180°-Kipp um X
rot.fromAngleNormalAxis(
Vector3f.UNIT_Y.dot(hit.normal()) > 0 ? 0f : FastMath.PI,
Vector3f.UNIT_X);
} else {
float angle = FastMath.acos(FastMath.clamp(Vector3f.UNIT_Y.dot(hit.normal()), -1f, 1f));
rot.fromAngleNormalAxis(angle, axis.normalizeLocal());
}
brushIndicator.setLocalRotation(rot);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
} else {
brushIndicator.setCullHint(Spatial.CullHint.Always);
@@ -1320,4 +1667,124 @@ public class VoxelEditorState extends BaseAppState {
public static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
// ── Basis-Terrain-Füllung ─────────────────────────────────────────────────
/**
* Neu erstellte Chunks starten leer (Luft).
* Das Basis-Terrain bei y=-10 wird durch die basePlane visualisiert;
* Voxel entstehen nur dort, wo der Nutzer sculpted.
*/
private static void fillBaseTerrainChunk(VoxelChunk chunk) {
// absichtlich leer kein Prefill
}
// ── Referenzebene bei y = -10 ─────────────────────────────────────────────
private Geometry buildBasePlane() {
// Welt-Ausdehnung: X und Z von -2048 bis +2048 → Größe 4096×4096
Quad quad = new Quad(4096f, 4096f);
Geometry geo = new Geometry("voxelBasePlane", quad);
// Quad liegt im XY-Raum; nach -90° um X rotieren → horizontale XZ-Ebene
Quaternion rot = new Quaternion();
rot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
geo.setLocalRotation(rot);
// Nach Rotation: x 0..4096 → x -2048..2048, z geht von +2048 nach -2048
geo.setLocalTranslation(-2048f, -10f, 2048f);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.15f, 0.55f, 0.15f, 0.22f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always); // standardmäßig versteckt
return geo;
}
private Geometry buildChunkGrid() {
float half = 2048f;
float step = VoxelChunk.CELLS; // 128 Einheiten pro Chunk
int divs = (int)(half * 2 / step); // 32 Chunks pro Achse
float y = -9.5f; // knapp über der basePlane bei -10
// Linien entlang Z (für jeden X-Abschnitt) + Linien entlang X (für jeden Z-Abschnitt)
int totalLines = (divs + 1) * 2;
FloatBuffer pos = BufferUtils.createFloatBuffer(totalLines * 2 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(totalLines * 2);
int v = 0;
for (int i = 0; i <= divs; i++) {
float x = -half + i * step;
pos.put(x).put(y).put(-half);
pos.put(x).put(y).put( half);
idx.put(v++).put(v++);
}
for (int i = 0; i <= divs; i++) {
float z = -half + i * step;
pos.put(-half).put(y).put(z);
pos.put( half).put(y).put(z);
idx.put(v++).put(v++);
}
pos.rewind(); idx.rewind();
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();
Geometry geo = new Geometry("voxelChunkGrid", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.35f, 0f, 1f));
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Layer-Wechsel-Reaktion ─────────────────────────────────────────────────
private void onVoxelLayerChanged(boolean entered) {
if (basePlane != null)
basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
if (chunkGrid != null)
chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
applyWireframe(entered && input.voxelWireframeEnabled);
}
/** Zeigt/verbirgt die Voxel-Chunk-Nodes (wird vom SculptedMeshEditorState gesteuert). */
public void setChunksVisible(boolean visible) {
if (voxelRoot != null)
voxelRoot.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
/** Gibt das Voxel-Material zurück, das SculptedMeshEditorState wiederverwenden kann. */
public Material getVoxelMaterial() { return voxelMaterial; }
// ── Wireframe-Hilfsmethoden ───────────────────────────────────────────────
/** Schaltet Wireframe für die gesamte Szene (außer Voxel-Root und Hilfsgeos) ein oder aus. */
private void applyWireframe(boolean enable) {
if (enable == wireframeActive) return;
wireframeActive = enable;
if (enable) {
wireframedMaterials.clear();
applyWireframeRecursive(app.getRootNode());
} else {
for (Material m : wireframedMaterials)
m.getAdditionalRenderState().setWireframe(false);
wireframedMaterials.clear();
}
}
private void applyWireframeRecursive(Spatial s) {
// Voxel-eigene Objekte nicht wireframen
if (s == voxelRoot || s == brushIndicator || s == basePlaneNode) return;
if (s instanceof Geometry geo && geo.getMaterial() != null) {
geo.getMaterial().getAdditionalRenderState().setWireframe(true);
wireframedMaterials.add(geo.getMaterial());
} else if (s instanceof Node node) {
for (Spatial child : new java.util.ArrayList<>(node.getChildren()))
applyWireframeRecursive(child);
}
}
}

View File

@@ -0,0 +1,37 @@
package de.blight.editor.tool;
import java.util.List;
/** Werkzeug zum direkten Sculpten gebackener Voxel-Meshes. */
public class SculptMeshTool extends EditorTool {
public static final int MODE_RAISE = 0;
public static final int MODE_LOWER = 1;
public static final int MODE_SMOOTH = 2;
public static final int MODE_FLATTEN = 3;
public static final int MODE_SELECT = 4;
public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus",
new String[]{"Anheben", "Absenken", "Glätten", "Abflachen", "Auswählen"},
MODE_RAISE,
new String[]{
"img/editor/terraintool_sinus.png",
"img/editor/terraintool_spike.png",
"img/editor/terraintool_smooth.png",
"img/editor/terraintool_plateau.png",
"img/editor/terraintool_select.png", // nicht vorhanden → zeigt "Au"
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 3.0, 0.5, 20.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 1.0, 0.1, 10.0);
@Override public String getName() { return "Sculpt"; }
@Override
public List<ChoiceToolParameter> getChoiceParameters() { return List.of(mode); }
@Override
public List<ToolParameter> getParameters() { return List.of(brushRadius, brushStrength); }
}

View File

@@ -0,0 +1,23 @@
package de.blight.editor.tool;
import java.util.List;
public class StoneTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 10.0, 1.0, 100.0);
public final ToolParameter minSize = new ToolParameter("Min-Größe (m)", 0.3, 0.1, 5.0);
public final ToolParameter maxSize = new ToolParameter("Max-Größe (m)", 1.5, 0.2, 10.0);
public final ToolParameter density = new ToolParameter("Dichte (Klick)", 4.0, 1.0, 30.0);
/** Texturpfade für bis zu 3 Slots; "" = kein Texture (Fallback: Grau). */
public volatile String[] texturePaths = new String[]{"", "", ""};
public volatile boolean texturesChanged = false;
@Override public String getName() { return "Steine"; }
@Override public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }
@Override public List<ToolParameter> getParameters() {
return List.of(brushRadius, minSize, maxSize, density);
}
}

View File

@@ -3,11 +3,14 @@ package de.blight.editor.tool;
import java.util.List;
/**
* Voxel-Werkzeug für Klippen und Höhlen.
* Voxel-Werkzeug.
*
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben für Felstürme, Plateaus.
* Modus 4 (Klippe): Kugel-Pinsel ohne Terrain-Cleanup.
* Modus 5 (Aushöhlen): Entfernt Voxel.
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten für Felstürme, Plateaus.
* Modus 4 (Aushöhlen): Kugel-Entfernen.
* Modus 5 (Zurücksetzen): Setzt Voxel im Bereich auf das Basis-Niveau y=-10 zurück.
*
* horizontal=false (Vertikal): Säulen entlang Y-Achse.
* horizontal=true (Horizontal): Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
*
* Texturierung erfolgt automatisch anhand der Flächennormale:
* Normal.y > 0.5 → TexFlat (flache Flächen)
@@ -20,13 +23,13 @@ public class VoxelTool extends EditorTool {
public static final int MODE_SPIKE = 1;
public static final int MODE_PLATEAU = 2;
public static final int MODE_SMOOTH = 3;
public static final int MODE_ADD = 4;
public static final int MODE_REMOVE = 5;
public static final int MODE_REMOVE = 4;
public static final int MODE_RESET = 5;
public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus",
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Klippe", "Aushöhlen"},
MODE_ADD,
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen"},
MODE_SINUS,
new String[]{
"img/editor/terraintool_sinus.png",
"img/editor/terraintool_spike.png",
@@ -37,9 +40,12 @@ public class VoxelTool extends EditorTool {
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
public volatile boolean modeChanged = false;
public volatile boolean horizontal = false;
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
public volatile boolean plateauTargetChanged = false;
@Override public String getName() { return "Voxel"; }

View File

@@ -268,6 +268,8 @@ public class CraftingTableEditorView extends BorderPane {
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
case Fireplace -> "#ee6633";
case Kitchen -> "#88aa44";
};
}

View File

@@ -94,7 +94,7 @@ public class LocalizationEditorView extends BorderPane {
table.refresh();
});
table.getColumns().addAll(keyCol, valCol);
table.getColumns().addAll(List.of(keyCol, valCol));
VBox.setVgrow(table, Priority.ALWAYS);
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");

View File

@@ -458,6 +458,8 @@ public class RecipeEditorView extends BorderPane {
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
case Fireplace -> "#ee6633";
case Kitchen -> "#88aa44";
};
}

View File

@@ -0,0 +1,659 @@
package de.blight.editor.ui;
import de.blight.common.PlacedItem;
import de.blight.common.PlacedItemIO;
import de.blight.common.model.*;
import de.blight.editor.SharedInput;
import javafx.animation.AnimationTimer;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Consumer;
/**
* Linke Hälfte des Tagesablauf-Editors.
*
* Zeigt eine Routine-Liste und einen Zeitblock-Editor.
* Raycasting-Ergebnisse werden via {@link SharedInput#routinePickedChanged}
* abgeholt und in die jeweiligen Felder geschrieben.
*
* Einbinden: Wird von EditorApp in einen SplitPane (links) neben
* dem worldViewport (rechts) gelegt, wenn der Nutzer „Tagesabläufe" öffnet.
*/
public class RoutineEditorView extends VBox {
// ── State ──────────────────────────────────────────────────────────────────
private final SharedInput input;
private final Path charDir;
private final List<NpcRoutine> routines = new ArrayList<>();
private NpcRoutine activeRoutine;
private RoutineBlock activeBlock;
/** Wird aufgerufen, sobald ein Punkt auf der Karte gepickt wurde. */
private Consumer<float[]> pendingPick;
private int savedLayer;
// ── Controls ───────────────────────────────────────────────────────────────
private ListView<String> routineList;
private Label coverageLabel;
private ListView<String> blockList;
private Label blockStatusLabel;
private VBox blockFormBox;
// Block-Formular
private Spinner<Integer> startSpin, endSpin;
private ComboBox<String> typeCombo;
private VBox dynamicBox;
// Dynamische Felder je Aktivitätstyp
private Label pointLabel; // zeigt gepickten Punkt
private ComboBox<String> interactableCombo; // UUID-basiert (PlacedItems)
private ComboBox<String> npcCombo; // für TALK
private ListView<String> waypointList; // für PATROL
private RadioButton rbPoint, rbInteractable; // für SIT
// Geladene Hilfsdaten
private final List<PlacedItem> placedItems = new ArrayList<>();
private final List<String> npcIds = new ArrayList<>();
// ── Polling-Timer ──────────────────────────────────────────────────────────
private final AnimationTimer pollTimer = new AnimationTimer() {
@Override public void handle(long now) {
if (pendingPick != null && input.routinePickedChanged) {
input.routinePickedChanged = false;
String raw = input.routinePickedPoint;
if (raw != null) {
String[] p = raw.split("\\|");
if (p.length == 3) {
try {
float x = Float.parseFloat(p[0]);
float y = Float.parseFloat(p[1]);
float z = Float.parseFloat(p[2]);
Consumer<float[]> cb = pendingPick;
pendingPick = null;
input.activeLayer = savedLayer;
cb.accept(new float[]{x, y, z});
} catch (NumberFormatException ignored) {}
}
}
}
}
};
// ══════════════════════════════════════════════════════════════════════════
public RoutineEditorView(SharedInput input, Path charDir) {
this.input = input;
this.charDir = charDir;
setSpacing(0);
setPadding(new Insets(6));
setStyle("-fx-background-color: #2b2b2b;");
loadPlacedItems();
loadNpcIds();
buildUi();
pollTimer.start();
}
// ── Datei-Hilfsmethoden ────────────────────────────────────────────────────
private void loadPlacedItems() {
try {
placedItems.addAll(PlacedItemIO.load());
} catch (IOException ignored) {}
}
private void loadNpcIds() {
if (charDir == null) return;
try (var s = java.nio.file.Files.list(charDir)) {
s.filter(p -> p.toString().endsWith(".character"))
.map(p -> p.getFileName().toString().replace(".character", ""))
.sorted()
.forEach(npcIds::add);
} catch (IOException ignored) {}
}
// ── UI-Aufbau ──────────────────────────────────────────────────────────────
private void buildUi() {
getChildren().addAll(buildRoutineSection(), new Separator(Orientation.HORIZONTAL), buildBlockSection());
}
private VBox buildRoutineSection() {
VBox box = new VBox(4);
box.setPadding(new Insets(0, 0, 6, 0));
Label title = styledLabel("Abläufe", true);
coverageLabel = styledLabel("", false);
coverageLabel.setTextFill(Color.GRAY);
routineList = new ListView<>();
routineList.setPrefHeight(120);
routineList.getSelectionModel().selectedIndexProperty().addListener((obs, ov, nv) -> {
int idx = nv.intValue();
activeRoutine = (idx >= 0 && idx < routines.size()) ? routines.get(idx) : null;
refreshBlockList();
});
Button addR = new Button("+");
Button renR = new Button("Umbenennen");
Button delR = new Button("");
addR.setOnAction(e -> addRoutine());
renR.setOnAction(e -> renameRoutine());
delR.setOnAction(e -> deleteRoutine());
HBox bar = new HBox(4, addR, renR, new Spacer(), delR);
bar.setAlignment(Pos.CENTER_LEFT);
box.getChildren().addAll(title, bar, routineList, coverageLabel);
return box;
}
private VBox buildBlockSection() {
VBox box = new VBox(4);
box.setPadding(new Insets(6, 0, 0, 0));
Label title = styledLabel("Zeitblöcke", true);
blockStatusLabel = styledLabel("— kein Ablauf gewählt —", false);
blockStatusLabel.setTextFill(Color.GRAY);
blockList = new ListView<>();
blockList.setPrefHeight(110);
blockList.getSelectionModel().selectedIndexProperty().addListener((obs, ov, nv) -> {
if (activeRoutine == null) return;
int idx = nv.intValue();
activeBlock = (idx >= 0 && idx < activeRoutine.getBlocks().size())
? activeRoutine.getBlocks().get(idx) : null;
refreshBlockForm();
});
Button addB = new Button("+");
Button delB = new Button("");
Button upB = new Button("");
Button dnB = new Button("");
addB.setOnAction(e -> addBlock());
delB.setOnAction(e -> deleteBlock());
upB .setOnAction(e -> moveBlock(-1));
dnB .setOnAction(e -> moveBlock(+1));
HBox bar = new HBox(4, addB, upB, dnB, new Spacer(), delB);
bar.setAlignment(Pos.CENTER_LEFT);
dynamicBox = new VBox(4);
blockFormBox = buildBlockForm();
box.getChildren().addAll(title, bar, blockList, blockStatusLabel, new Separator(Orientation.HORIZONTAL), blockFormBox, dynamicBox);
return box;
}
private VBox buildBlockForm() {
VBox form = new VBox(4);
form.setPadding(new Insets(4, 0, 4, 0));
form.setDisable(true);
startSpin = hourSpinner();
endSpin = hourSpinner();
startSpin.valueProperty().addListener((o, ov, nv) -> applyBlockTimes());
endSpin .valueProperty().addListener((o, ov, nv) -> applyBlockTimes());
HBox timeRow = new HBox(6,
styledLabel("Von", false), startSpin,
styledLabel("bis", false), endSpin,
styledLabel("Uhr", false));
timeRow.setAlignment(Pos.CENTER_LEFT);
typeCombo = new ComboBox<>();
typeCombo.getItems().addAll(
"Sitzen", "Stehen", "Reden", "Patrullieren", "Arbeiten", "Schlafen");
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> refreshDynamicFields());
form.getChildren().addAll(timeRow, typeCombo);
return form;
}
// ── Dynamische Felder ──────────────────────────────────────────────────────
private void refreshDynamicFields() {
dynamicBox.getChildren().clear();
if (activeBlock == null || typeCombo.getValue() == null) return;
switch (typeCombo.getValue()) {
case "Sitzen" -> buildSitFields();
case "Stehen" -> buildStandFields();
case "Reden" -> buildTalkFields();
case "Patrullieren"-> buildPatrolFields();
case "Arbeiten" -> buildInteractableFields("Arbeitsplatz");
case "Schlafen" -> buildInteractableFields("Schlafplatz");
}
applyActivityToBlock();
}
private void buildSitFields() {
rbPoint = new RadioButton("Punkt auf Karte");
rbInteractable = new RadioButton("Interactable");
ToggleGroup tg = new ToggleGroup();
rbPoint.setToggleGroup(tg);
rbInteractable.setToggleGroup(tg);
rbPoint.setSelected(true);
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
interactableCombo = itemCombo();
VBox pointRow = new VBox(2, pointLabel, pickBtn);
VBox itemRow = new VBox(2, interactableCombo);
itemRow.setManaged(false); itemRow.setVisible(false);
tg.selectedToggleProperty().addListener((o, ov, nv) -> {
boolean useItem = rbInteractable.isSelected();
pointRow.setManaged(!useItem); pointRow.setVisible(!useItem);
itemRow.setManaged(useItem); itemRow.setVisible(useItem);
applyActivityToBlock();
});
dynamicBox.getChildren().addAll(rbPoint, rbInteractable, pointRow, itemRow);
// Pre-fill from existing activity
if (activeBlock.getActivity() instanceof RoutineActivity act) {
if (act.getObjectUuid() != null) {
rbInteractable.setSelected(true);
selectItemCombo(interactableCombo, act.getObjectUuid());
} else if (act.getPosition() != null) {
setPointLabel(pointLabel, act.getPosition());
}
}
}
private void buildStandFields() {
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
dynamicBox.getChildren().addAll(styledLabel("Standpunkt:", false), pointLabel, pickBtn);
if (activeBlock.getActivity() != null && activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
}
private void buildTalkFields() {
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
npcCombo = new ComboBox<>();
npcCombo.getItems().addAll(npcIds);
npcCombo.setMaxWidth(Double.MAX_VALUE);
npcCombo.setPromptText("NPC wählen…");
npcCombo.setOnAction(e -> applyActivityToBlock());
dynamicBox.getChildren().addAll(styledLabel("Gesprächspunkt:", false), pointLabel, pickBtn,
styledLabel("Gesprächspartner:", false), npcCombo);
if (activeBlock.getActivity() != null) {
if (activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
if (activeBlock.getActivity().getTalkNpcId() != null)
npcCombo.setValue(activeBlock.getActivity().getTalkNpcId());
}
}
private void buildPatrolFields() {
waypointList = new ListView<>();
waypointList.setPrefHeight(80);
Button addWp = new Button("Punkt hinzufügen");
Button delWp = new Button("Letzten entfernen");
addWp.setOnAction(e -> startPick(xyz -> {
WorldPoint wp = new WorldPoint(xyz[0], xyz[1], xyz[2]);
if (activeBlock.getActivity() == null)
activeBlock.setActivity(RoutineActivity.patrol(new ArrayList<>()));
activeBlock.getActivity().getWaypoints().add(wp);
refreshWaypointList();
}));
delWp.setOnAction(e -> {
if (activeBlock.getActivity() != null && !activeBlock.getActivity().getWaypoints().isEmpty()) {
List<WorldPoint> wps = activeBlock.getActivity().getWaypoints();
wps.remove(wps.size() - 1);
refreshWaypointList();
}
});
dynamicBox.getChildren().addAll(styledLabel("Wegpunkte:", false), waypointList,
new HBox(6, addWp, delWp));
if (activeBlock.getActivity() != null && activeBlock.getActivity().getWaypoints() != null)
refreshWaypointList();
}
private void buildInteractableFields(String label) {
interactableCombo = itemCombo();
interactableCombo.setOnAction(e -> applyActivityToBlock());
dynamicBox.getChildren().addAll(styledLabel(label + ":", false), interactableCombo);
if (activeBlock.getActivity() != null && activeBlock.getActivity().getObjectUuid() != null)
selectItemCombo(interactableCombo, activeBlock.getActivity().getObjectUuid());
}
// ── Punkte picken ──────────────────────────────────────────────────────────
private void startPick(Consumer<float[]> callback) {
pendingPick = callback;
savedLayer = input.activeLayer;
input.activeLayer = SharedInput.LAYER_ROUTINE_EDITOR;
}
private Button pickButton(Consumer<float[]> callback) {
Button btn = new Button("Punkt wählen…");
btn.setOnAction(e -> startPick(xyz -> {
callback.accept(xyz);
applyActivityToBlock();
}));
return btn;
}
// ── Block-Daten pflegen ────────────────────────────────────────────────────
private void applyBlockTimes() {
if (activeBlock == null) return;
activeBlock.setStartHour(startSpin.getValue());
activeBlock.setEndHour(endSpin.getValue());
refreshBlockList();
refreshCoverage();
}
private void applyActivityToBlock() {
if (activeBlock == null || typeCombo.getValue() == null) return;
switch (typeCombo.getValue()) {
case "Sitzen" -> {
if (rbInteractable != null && rbInteractable.isSelected()) {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.sitInteractable(uuid, lbl));
} else {
WorldPoint pt = parsedPoint(pointLabel);
activeBlock.setActivity(pt != null ? RoutineActivity.sit(pt) : new RoutineActivity());
}
}
case "Stehen" -> {
WorldPoint pt = parsedPoint(pointLabel);
activeBlock.setActivity(pt != null ? RoutineActivity.stand(pt) : new RoutineActivity());
}
case "Reden" -> {
WorldPoint pt = parsedPoint(pointLabel);
String talkNpc = npcCombo != null ? npcCombo.getValue() : null;
activeBlock.setActivity(RoutineActivity.talk(pt, talkNpc));
}
case "Arbeiten" -> {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.work(uuid, lbl));
}
case "Schlafen" -> {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.sleep(uuid, lbl));
}
// PATROL is managed directly in buildPatrolFields
}
refreshBlockList();
}
// ── Routine-Operationen ────────────────────────────────────────────────────
private void addRoutine() {
TextInputDialog dlg = new TextInputDialog("Routine " + (routines.size() + 1));
dlg.setHeaderText("Name des Tagesablaufs:");
dlg.showAndWait().ifPresent(name -> {
if (name.isBlank()) return;
NpcRoutine r = new NpcRoutine(name.trim());
routines.add(r);
refreshRoutineList();
routineList.getSelectionModel().selectLast();
});
}
private void renameRoutine() {
if (activeRoutine == null) return;
TextInputDialog dlg = new TextInputDialog(activeRoutine.getName());
dlg.setHeaderText("Neuer Name:");
dlg.showAndWait().ifPresent(name -> {
if (!name.isBlank()) {
activeRoutine.setName(name.trim());
refreshRoutineList();
}
});
}
private void deleteRoutine() {
if (activeRoutine == null) return;
int idx = routines.indexOf(activeRoutine);
routines.remove(activeRoutine);
activeRoutine = null;
refreshRoutineList();
if (!routines.isEmpty())
routineList.getSelectionModel().select(Math.min(idx, routines.size() - 1));
}
private void addBlock() {
if (activeRoutine == null) return;
RoutineBlock b = new RoutineBlock(0, 1, null);
activeRoutine.getBlocks().add(b);
refreshBlockList();
blockList.getSelectionModel().selectLast();
}
private void deleteBlock() {
if (activeRoutine == null || activeBlock == null) return;
int idx = activeRoutine.getBlocks().indexOf(activeBlock);
activeRoutine.getBlocks().remove(activeBlock);
activeBlock = null;
refreshBlockList();
refreshBlockForm();
refreshCoverage();
if (!activeRoutine.getBlocks().isEmpty())
blockList.getSelectionModel().select(Math.min(idx, activeRoutine.getBlocks().size() - 1));
}
private void moveBlock(int delta) {
if (activeRoutine == null || activeBlock == null) return;
List<RoutineBlock> blocks = activeRoutine.getBlocks();
int idx = blocks.indexOf(activeBlock);
int newIdx = idx + delta;
if (newIdx < 0 || newIdx >= blocks.size()) return;
blocks.remove(idx);
blocks.add(newIdx, activeBlock);
refreshBlockList();
blockList.getSelectionModel().select(newIdx);
}
// ── Refresh-Methoden ───────────────────────────────────────────────────────
private void refreshRoutineList() {
int sel = routineList.getSelectionModel().getSelectedIndex();
routineList.getItems().clear();
for (int i = 0; i < routines.size(); i++) {
String label = routines.get(i).getName();
if (i == 0) label += " [Standard]";
routineList.getItems().add(label);
}
if (sel >= 0 && sel < routineList.getItems().size())
routineList.getSelectionModel().select(sel);
}
private void refreshBlockList() {
if (activeRoutine == null) {
blockList.getItems().clear();
return;
}
int sel = blockList.getSelectionModel().getSelectedIndex();
blockList.getItems().clear();
for (RoutineBlock b : activeRoutine.getBlocks())
blockList.getItems().add(b.displayLabel());
if (sel >= 0 && sel < blockList.getItems().size())
blockList.getSelectionModel().select(sel);
refreshCoverage();
}
private void refreshBlockForm() {
boolean hasBlock = (activeBlock != null);
if (blockFormBox != null) blockFormBox.setDisable(!hasBlock);
dynamicBox.getChildren().clear();
blockStatusLabel.setText(hasBlock ? "" : (activeRoutine != null ? "— Block wählen —" : "— Ablauf wählen —"));
if (!hasBlock) return;
startSpin.getValueFactory().setValue(activeBlock.getStartHour());
endSpin .getValueFactory().setValue(activeBlock.getEndHour());
typeCombo.setValue(activityTypeLabel(activeBlock.getActivity()));
refreshDynamicFields();
}
private void refreshCoverage() {
if (activeRoutine == null) { coverageLabel.setText(""); return; }
int h = activeRoutine.coveredHours();
String err = activeRoutine.validate();
if (err != null) coverageLabel.setTextFill(Color.SALMON);
else coverageLabel.setTextFill(Color.LIGHTGREEN);
coverageLabel.setText(h + "/24 Stunden" + (err != null ? "" + err : ""));
}
private void refreshWaypointList() {
if (waypointList == null || activeBlock == null || activeBlock.getActivity() == null) return;
waypointList.getItems().clear();
List<WorldPoint> wps = activeBlock.getActivity().getWaypoints();
if (wps == null) return;
for (int i = 0; i < wps.size(); i++) {
WorldPoint wp = wps.get(i);
waypointList.getItems().add((i + 1) + ". " + wp);
}
}
// ── Externe API ────────────────────────────────────────────────────────────
/** Lädt einen NPC (und seine Routinen) in die View. */
public void loadNpc(NPC npc) {
routines.clear();
if (npc.getRoutines() != null)
routines.addAll(npc.getRoutines());
activeRoutine = null;
activeBlock = null;
refreshRoutineList();
refreshBlockList();
refreshBlockForm();
}
/** Schreibt die aktuellen Routinen zurück in den NPC. */
public void exportToNpc(NPC npc) {
npc.setRoutines(new ArrayList<>(routines));
}
/** Muss aufgerufen werden, wenn die View geschlossen/versteckt wird. */
public void onHide() {
if (pendingPick != null) {
pendingPick = null;
input.activeLayer = savedLayer;
}
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
private static Spinner<Integer> hourSpinner() {
Spinner<Integer> s = new Spinner<>(0, 23, 0);
s.setEditable(true);
s.setPrefWidth(68);
return s;
}
private Label pointLabel() {
Label l = new Label("— kein Punkt —");
l.setStyle("-fx-font-size: 11; -fx-text-fill: #aaa;");
return l;
}
private void setPointLabel(Label lbl, float[] xyz) {
lbl.setText(String.format("(%.1f, %.1f, %.1f)", xyz[0], xyz[1], xyz[2]));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}
private void setPointLabel(Label lbl, WorldPoint pt) {
if (pt == null) return;
lbl.setText(String.format("(%.1f, %.1f, %.1f)", pt.x, pt.y, pt.z));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}
private WorldPoint parsedPoint(Label lbl) {
if (lbl == null || lbl.getText().startsWith("")) return null;
String t = lbl.getText().replaceAll("[()]", "");
String[] p = t.split(",");
if (p.length != 3) return null;
try {
return new WorldPoint(Float.parseFloat(p[0].trim()),
Float.parseFloat(p[1].trim()),
Float.parseFloat(p[2].trim()));
} catch (NumberFormatException e) { return null; }
}
private ComboBox<String> itemCombo() {
ComboBox<String> c = new ComboBox<>();
c.setMaxWidth(Double.MAX_VALUE);
c.setPromptText("Objekt wählen…");
for (PlacedItem it : placedItems)
c.getItems().add(it.itemId() + " [" + it.uuid().substring(0, 8) + "…]");
return c;
}
private void selectItemCombo(ComboBox<String> c, String uuid) {
if (c == null || uuid == null) return;
c.getItems().stream()
.filter(s -> s.contains(uuid.substring(0, 8)))
.findFirst()
.ifPresent(c::setValue);
}
private String selectedItemUuid(ComboBox<String> c) {
if (c == null || c.getValue() == null) return null;
String v = c.getValue();
int s = v.indexOf('['), e = v.indexOf('…');
if (s < 0 || e < 0) return null;
String prefix = v.substring(s + 1, e);
return placedItems.stream()
.filter(it -> it.uuid().startsWith(prefix))
.findFirst()
.map(PlacedItem::uuid)
.orElse(null);
}
private String selectedItemLabel(ComboBox<String> c) {
if (c == null) return null;
String v = c.getValue();
if (v == null) return null;
int b = v.indexOf(" [");
return b > 0 ? v.substring(0, b) : v;
}
private String activityTypeLabel(RoutineActivity act) {
if (act == null || act.getType() == null) return "Stehen";
return switch (act.getType()) {
case SIT -> "Sitzen";
case STAND -> "Stehen";
case TALK -> "Reden";
case PATROL -> "Patrullieren";
case WORK -> "Arbeiten";
case SLEEP -> "Schlafen";
};
}
private static Label styledLabel(String text, boolean bold) {
Label l = new Label(text);
l.setStyle("-fx-text-fill: #ccc;" + (bold ? "-fx-font-weight: bold;" : ""));
return l;
}
private static class Spacer extends Region {
Spacer() { HBox.setHgrow(this, Priority.ALWAYS); }
}
}

View File

@@ -28,6 +28,7 @@ public class TriggerDialog extends Dialog<Trigger> {
private static final String TYPE_QUEST = "Quest starten";
private static final String TYPE_NPC = "NPC-Status ändern";
private static final String TYPE_FRACTION = "Fraktions-Status ändern";
private static final String TYPE_ROUTINE = "Routine ändern";
// Gemeinsam
private final ComboBox<String> typeCombo = new ComboBox<>();
@@ -45,6 +46,10 @@ public class TriggerDialog extends Dialog<Trigger> {
private TextField fractionIdField;
private ComboBox<Status> fractionStatusCombo;
// Routine ändern
private TextField routineNpcIdField;
private TextField routineNameField;
/** Öffnet den Dialog für einen neuen Trigger. */
public TriggerDialog() {
this(null);
@@ -56,7 +61,7 @@ public class TriggerDialog extends Dialog<Trigger> {
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE);
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
@@ -100,6 +105,7 @@ public class TriggerDialog extends Dialog<Trigger> {
case TYPE_QUEST -> buildQuestFields();
case TYPE_NPC -> buildNpcFields();
case TYPE_FRACTION -> buildFractionFields();
case TYPE_ROUTINE -> buildRoutineFields();
}
}
@@ -131,6 +137,16 @@ public class TriggerDialog extends Dialog<Trigger> {
);
}
private void buildRoutineFields() {
routineNpcIdField = field("Character-ID des NPCs");
routineNameField = field("Name der Routine");
dynamicArea.getChildren().addAll(
sectionTitle("Routine ändern"),
row("NPC-ID:", routineNpcIdField),
row("Routine-Name:", routineNameField)
);
}
// ── Trigger bauen ─────────────────────────────────────────────────────────
private Trigger buildTrigger() {
@@ -161,6 +177,12 @@ public class TriggerDialog extends Dialog<Trigger> {
if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue());
yield f;
}
case TYPE_ROUTINE -> {
ChangeRoutineTrigger r = new ChangeRoutineTrigger();
if (routineNpcIdField != null) r.setNpcId(routineNpcIdField.getText().trim());
if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim());
yield r;
}
default -> null;
};
if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
@@ -186,6 +208,12 @@ public class TriggerDialog extends Dialog<Trigger> {
fractionIdField.setText(f.getFractionId().toString());
if (fractionStatusCombo != null && f.getTargetStatus() != null)
fractionStatusCombo.setValue(f.getTargetStatus());
} else if (t instanceof ChangeRoutineTrigger r) {
typeCombo.setValue(TYPE_ROUTINE);
if (routineNpcIdField != null && r.getNpcId() != null)
routineNpcIdField.setText(r.getNpcId());
if (routineNameField != null && r.getRoutineName() != null)
routineNameField.setText(r.getRoutineName());
}
}

View File

@@ -105,6 +105,9 @@ public class TriggerListEditor extends VBox {
return "Fraktion-Status: "
+ (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "" : "?")
+ "" + statusName(f.getTargetStatus()) + chapter;
if (t instanceof ChangeRoutineTrigger r)
return "Routine ändern: " + nullSafe(r.getNpcId())
+ " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter;
return t.getClass().getSimpleName() + chapter;
}

View File

@@ -23,6 +23,8 @@
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
<!-- TangentBinormalGenerator warnt bei UV-Nähten und harten Kanten erwartet, kein Fehler -->
<logger name="com.jme3.util.TangentBinormalGenerator" level="ERROR"/>
<!-- Material warnt bei linear-color-space Texturen ohne passenden Parameter bekannt, kein Fehler -->
<logger name="com.jme3.material.Material" level="ERROR"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>

View File

@@ -24,11 +24,28 @@ public class AnimSet {
private List<String> clips = new ArrayList<>();
private Map<String, String> actionMap = new LinkedHashMap<>();
/** Zuletzt im Editor verwendeter Modell-Pfad (relativ zu Assets-Root). Wird beim Öffnen auto-geladen. */
private String previewModelPath = null;
/** Vertikaler Versatz des Visual-Nodes während der jeweiligen Animation (Root-Motion-Ersatz, Fallback). */
private Map<String, Float> sinkMap = new LinkedHashMap<>();
/**
* Pro Aktion konfigurierbarer Anchor-Knochen (z. B. SIT_DOWN → "foot.l", PICK_UP → "hand.r").
* Wenn für eine Aktion ein Eintrag vorhanden ist, wird Bone-Anchoring verwendet:
* der Knochen bleibt auf seiner Welt-Y vor der Animation fixiert.
* Überschreibt sinkMap für diese Aktion.
*/
private Map<String, String> anchorBoneMap = new LinkedHashMap<>();
public List<String> getClips() { return clips; }
public void setClips(List<String> clips) { this.clips = clips; }
public Map<String, String> getActionMap() { return actionMap; }
public void setActionMap(Map<String, String> actionMap) { this.actionMap = actionMap; }
public String getPreviewModelPath() { return previewModelPath; }
public void setPreviewModelPath(String previewModelPath) { this.previewModelPath = previewModelPath; }
public Map<String, Float> getSinkMap() { return sinkMap != null ? sinkMap : new LinkedHashMap<>(); }
public void setSinkMap(Map<String, Float> sinkMap) { this.sinkMap = sinkMap; }
public Map<String, String> getAnchorBoneMap() { return anchorBoneMap != null ? anchorBoneMap : new LinkedHashMap<>(); }
public void setAnchorBoneMap(Map<String, String> anchorBoneMap) { this.anchorBoneMap = anchorBoneMap; }
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
public void save(Path setDir, String setName) throws IOException {

View File

@@ -1,33 +1,53 @@
package de.blight.game.animation;
import de.blight.common.model.TextRegistry;
/**
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
*/
public enum AnimationAction {
DEFAULT,
IDLE,
IDLE,
WALK,
RUN,
SPRINT,
JUMP,
RUNNING_JUMP,
DUCK,
PICK_UP;
PICK_UP,
LIE_DOWN,
LIE_UP,
LYING,
SIT_DOWN,
SIT_UP,
SITTING,
SIT_DOWN_FLOOR,
SITTING_FLOOR,
GET_UP_FLOOR;
/** Lesbare Bezeichnung für UI-Anzeige. */
/** Lesbare Bezeichnung für UI-Anzeige, via TextRegistry aufgelöst. */
public String displayName() {
String key = "animation.action." + name().toLowerCase();
return switch (this) {
case DEFAULT -> "Default";
case IDLE -> "Idle";
case WALK -> "Walk";
case RUN -> "Run";
case SPRINT -> "Sprint";
case JUMP -> "Jump";
case RUNNING_JUMP -> "Running Jump";
case DUCK -> "Duck";
case PICK_UP -> "Pick up";
case DEFAULT -> TextRegistry.resolve(null, key, "Default");
case IDLE -> TextRegistry.resolve(null, key, "Idle");
case WALK -> TextRegistry.resolve(null, key, "Walk");
case RUN -> TextRegistry.resolve(null, key, "Run");
case SPRINT -> TextRegistry.resolve(null, key, "Sprint");
case JUMP -> TextRegistry.resolve(null, key, "Jump");
case RUNNING_JUMP -> TextRegistry.resolve(null, key, "Running Jump");
case DUCK -> TextRegistry.resolve(null, key, "Duck");
case PICK_UP -> TextRegistry.resolve(null, key, "Pick up");
case LIE_DOWN -> TextRegistry.resolve(null, key, "Hinlegen");
case LIE_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bett)");
case LYING -> TextRegistry.resolve(null, key, "Liegen");
case SIT_DOWN -> TextRegistry.resolve(null, key, "Hinsetzen");
case SIT_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bank)");
case SITTING -> TextRegistry.resolve(null, key, "Sitzen");
case SIT_DOWN_FLOOR -> TextRegistry.resolve(null, key, "Hinsetzen (Boden)");
case SITTING_FLOOR -> TextRegistry.resolve(null, key, "Sitzen (Boden)");
case GET_UP_FLOOR -> TextRegistry.resolve(null, key, "Aufstehen (Boden)");
};
}
}

View File

@@ -102,6 +102,14 @@ public class AnimationLibrary extends BaseAppState {
} else {
target = src;
}
// Der interne Clip-Name kann vom Library-Schlüssel abweichen (z. B. Blender-Default
// "Action" statt "sit_down_new"). AnimComposer.setCurrentAction() sucht per Name,
// daher muss der Name des gespeicherten Clips dem clipName entsprechen.
if (target != null && !clipName.equals(target.getName())) {
AnimClip renamed = new AnimClip(clipName);
renamed.setTracks(target.getTracks());
target = renamed;
}
if (target == null) {
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
return false;
@@ -109,6 +117,9 @@ public class AnimationLibrary extends BaseAppState {
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
dumpClipTracks(target);
}
return true;
}
@@ -204,8 +215,9 @@ public class AnimationLibrary extends BaseAppState {
}
private void loadClipFromFile(Path file) {
String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
String assetKey = "animations/clips/" + clipName + ".j3o";
String fileName = file.getFileName().toString();
String clipName = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "animations/clips/" + fileName;
try {
Spatial loaded = assetManager.loadModel(assetKey);
@@ -218,7 +230,8 @@ public class AnimationLibrary extends BaseAppState {
Armature armature = sc != null ? sc.getArmature() : null;
for (String name : ac.getAnimClipsNames()) {
clips.put(name, ac.getAnimClip(name));
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
}
@@ -244,4 +257,29 @@ public class AnimationLibrary extends BaseAppState {
return null;
}
/** Loggt alle Tracks eines Clips: Bone-Name, hat Translation (T), Rotation (R), Scale (S). */
private void dumpClipTracks(com.jme3.anim.AnimClip clip) {
log.info("[ClipDump] '{}' length={:.3f}s tracks={}",
clip.getName(), clip.getLength(), clip.getTracks().length);
int tracksWithTranslation = 0;
for (com.jme3.anim.AnimTrack<?> t : clip.getTracks()) {
if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue;
boolean hasT = tt.getTranslations() != null && tt.getTranslations().length > 0;
boolean hasR = tt.getRotations() != null && tt.getRotations().length > 0;
boolean hasS = tt.getScales() != null && tt.getScales().length > 0;
String target = tt.getTarget() instanceof com.jme3.anim.Joint j ? j.getName() : "?";
if (hasT) {
tracksWithTranslation++;
com.jme3.math.Vector3f t0 = tt.getTranslations()[0];
com.jme3.math.Vector3f tN = tt.getTranslations()[tt.getTranslations().length - 1];
log.info("[ClipDump] TRANSLATE '{}' frames={} start=({:.3f},{:.3f},{:.3f}) end=({:.3f},{:.3f},{:.3f}) deltaY={:.4f}",
target, tt.getTranslations().length,
t0.x, t0.y, t0.z, tN.x, tN.y, tN.z, tN.y - t0.y);
} else {
log.info("[ClipDump] rot-only '{}' T={} R={} S={}", target, hasT, hasR, hasS);
}
}
log.info("[ClipDump] Gesamt: {} Tracks mit Translation (von {})", tracksWithTranslation, clip.getTracks().length);
}
}

View File

@@ -17,6 +17,7 @@ import com.jme3.anim.Joint;
import com.jme3.anim.SkinningControl;
import com.jme3.anim.TransformTrack;
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;
@@ -69,7 +70,7 @@ public final class RetargetingSystem {
// 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());
log.debug("[Retarget] '{}' same-rig detected fast path (redirect only)", sourceClip.getName());
return redirectTracks(sourceClip, targetArmature);
}
@@ -116,7 +117,7 @@ public final class RetargetingSystem {
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=[{} {} {}]°",
log.trace("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°",
sourceClip.getName(), e.getKey(), dstName,
String.format("%.1f", Math.toDegrees(loc[0])),
String.format("%.1f", Math.toDegrees(loc[1])),
@@ -180,7 +181,7 @@ public final class RetargetingSystem {
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=[{} {} {}]°",
log.trace("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°",
sourceClip.getName(), d.getName(),
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
@@ -197,7 +198,7 @@ public final class RetargetingSystem {
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=[{} {} {}]°",
log.trace("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
sourceClip.getName(), child.getName(),
String.format("%.1f", Math.toDegrees(ca[0])),
String.format("%.1f", Math.toDegrees(ca[1])),
@@ -221,7 +222,7 @@ public final class RetargetingSystem {
Quaternion local0 = pms.inverse().mult(ams);
float[] a = ams.toAngles(null);
float[] l = local0.toAngles(null);
log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
log.trace("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
sourceClip.getName(), dName,
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
@@ -248,13 +249,37 @@ public final class RetargetingSystem {
log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName());
return null;
}
// Collect translation tracks from source joints.
// The full-retarget path converts rotations to model-space; translations are in
// the bone's local (parent) space and are transferred directly because for same-rig
// retargeting the parent coordinate frames are identical or near-identical.
Map<String, Vector3f[]> srcTransMap = new HashMap<>();
for (AnimTrack<?> t : sourceClip.getTracks()) {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint srcJ) {
Vector3f[] trans = tt.getTranslations();
if (trans != null && trans.length > 0) {
srcTransMap.put(srcJ.getName(), trans);
}
}
}
List<AnimTrack<?>> newTracks = new ArrayList<>();
for (var entry : dstLocalArrays.entrySet())
newTracks.add(new TransformTrack(entry.getKey(), times, null, entry.getValue(), null));
for (var entry : dstLocalArrays.entrySet()) {
Joint dst = entry.getKey();
Joint srcJoint = dstToSrc.get(dst.getName());
// Only copy translations when the frame count matches to avoid stride errors.
Vector3f[] translations = null;
if (srcJoint != null) {
Vector3f[] srcT = srcTransMap.get(srcJoint.getName());
if (srcT != null && srcT.length == numFrames) {
translations = srcT;
}
}
newTracks.add(new TransformTrack(entry.getKey(), times, translations, 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());
log.debug("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}
@@ -401,7 +426,7 @@ public final class RetargetingSystem {
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());
log.debug("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}

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