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:
BIN
assets/imported/animations/get_up_sitting.glb
Normal file
BIN
assets/imported/animations/get_up_sitting.glb
Normal file
Binary file not shown.
BIN
assets/imported/animations/sit_down.glb
Normal file
BIN
assets/imported/animations/sit_down.glb
Normal file
Binary file not shown.
BIN
assets/imported/animations/sitting.glb
Normal file
BIN
assets/imported/animations/sitting.glb
Normal file
Binary file not shown.
BIN
assets/imported/animations/sitting_floor.glb
Normal file
BIN
assets/imported/animations/sitting_floor.glb
Normal file
Binary file not shown.
BIN
assets/imported/animations/standup.glb
Normal file
BIN
assets/imported/animations/standup.glb
Normal file
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 |
@@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
BIN
blight-assets/src/main/resources/Models/imported/bank1.j3o
Normal file
BIN
blight-assets/src/main/resources/Models/imported/bank1.j3o
Normal file
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
@@ -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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
blight-assets/src/main/resources/animations/clips/pickup.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/pickup.j3o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
blight-assets/src/main/resources/animations/clips/sit_down.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/sit_down.j3o
Normal file
Binary file not shown.
BIN
blight-assets/src/main/resources/animations/clips/sitting.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/sitting.j3o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
blight-assets/src/main/resources/animations/clips/sprinting.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/sprinting.j3o
Normal file
Binary file not shown.
Binary file not shown.
BIN
blight-assets/src/main/resources/animations/clips/standup.j3o
Normal file
BIN
blight-assets/src/main/resources/animations/clips/standup.j3o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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 (v1–v4) 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;
|
||||
|
||||
@@ -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.2–0.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
|
||||
) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
100
blight-common/src/main/java/de/blight/common/SculptedMeshIO.java
Normal file
100
blight-common/src/main/java/de/blight/common/SculptedMeshIO.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
43
blight-common/src/main/java/de/blight/common/model/Bed.java
Normal file
43
blight-common/src/main/java/de/blight/common/model/Bed.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "?";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.blight.common.model;
|
||||
|
||||
/**
|
||||
* Zeitblock innerhalb eines Tagesablaufs.
|
||||
*
|
||||
* {@code startHour} und {@code endHour} sind ganzzahlige Stunden (0–23).
|
||||
* Ein Block, der über Mitternacht geht (z.B. 22–06), 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 0–360° (Kompassrichtung), Elevation 0–90° (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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.5–4.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"; }
|
||||
|
||||
@@ -268,6 +268,8 @@ public class CraftingTableEditorView extends BorderPane {
|
||||
case Smithy -> "#cc8833";
|
||||
case Goldsmiths -> "#ddbb22";
|
||||
case Workshop -> "#4488cc";
|
||||
case Fireplace -> "#ee6633";
|
||||
case Kitchen -> "#88aa44";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -458,6 +458,8 @@ public class RecipeEditorView extends BorderPane {
|
||||
case Smithy -> "#cc8833";
|
||||
case Goldsmiths -> "#ddbb22";
|
||||
case Workshop -> "#4488cc";
|
||||
case Fireplace -> "#ee6633";
|
||||
case Kitchen -> "#88aa44";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user