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 {
|
MaterialParameters {
|
||||||
Texture2D TexFlat
|
Texture2D TexFlat
|
||||||
Texture2D TexSteep
|
Texture2D TexSteep
|
||||||
Texture2D TexCeil
|
|
||||||
Texture2D NormalMapFlat
|
Texture2D NormalMapFlat
|
||||||
Texture2D NormalMapSteep
|
Texture2D NormalMapSteep
|
||||||
Texture2D NormalMapCeil
|
|
||||||
Texture2D DisplacementMapFlat
|
Texture2D DisplacementMapFlat
|
||||||
Texture2D DisplacementMapSteep
|
Texture2D DisplacementMapSteep
|
||||||
Texture2D DisplacementMapCeil
|
|
||||||
Float TexScale : 8.0
|
Float TexScale : 8.0
|
||||||
Float DisplacementScale : 0.3
|
Float DisplacementScale : 0.3
|
||||||
Float TessellationLevel : 4.0
|
Float TessellationLevel : 4.0
|
||||||
@@ -32,7 +29,6 @@ MaterialDef Voxel {
|
|||||||
Defines {
|
Defines {
|
||||||
HAS_NM_FLAT : NormalMapFlat
|
HAS_NM_FLAT : NormalMapFlat
|
||||||
HAS_NM_STEEP : NormalMapSteep
|
HAS_NM_STEEP : NormalMapSteep
|
||||||
HAS_NM_CEIL : NormalMapCeil
|
|
||||||
HAS_LIGHTDIR : LightDir
|
HAS_LIGHTDIR : LightDir
|
||||||
HAS_SCENE_LIGHT : SunColor
|
HAS_SCENE_LIGHT : SunColor
|
||||||
DEBUG_NO_LIGHT : DebugNoLight
|
DEBUG_NO_LIGHT : DebugNoLight
|
||||||
@@ -60,10 +56,8 @@ MaterialDef Voxel {
|
|||||||
Defines {
|
Defines {
|
||||||
HAS_NM_FLAT : NormalMapFlat
|
HAS_NM_FLAT : NormalMapFlat
|
||||||
HAS_NM_STEEP : NormalMapSteep
|
HAS_NM_STEEP : NormalMapSteep
|
||||||
HAS_NM_CEIL : NormalMapCeil
|
|
||||||
HAS_DISP_FLAT : DisplacementMapFlat
|
HAS_DISP_FLAT : DisplacementMapFlat
|
||||||
HAS_DISP_STEEP : DisplacementMapSteep
|
HAS_DISP_STEEP : DisplacementMapSteep
|
||||||
HAS_DISP_CEIL : DisplacementMapCeil
|
|
||||||
HAS_LIGHTDIR : LightDir
|
HAS_LIGHTDIR : LightDir
|
||||||
HAS_SCENE_LIGHT : SunColor
|
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_TexFlat;
|
||||||
uniform sampler2D m_TexSteep;
|
uniform sampler2D m_TexSteep;
|
||||||
uniform sampler2D m_TexCeil;
|
|
||||||
uniform float m_TexScale;
|
uniform float m_TexScale;
|
||||||
|
|
||||||
#ifdef HAS_NM_FLAT
|
#ifdef HAS_NM_FLAT
|
||||||
@@ -9,9 +8,6 @@ uniform sampler2D m_NormalMapFlat;
|
|||||||
#ifdef HAS_NM_STEEP
|
#ifdef HAS_NM_STEEP
|
||||||
uniform sampler2D m_NormalMapSteep;
|
uniform sampler2D m_NormalMapSteep;
|
||||||
#endif
|
#endif
|
||||||
#ifdef HAS_NM_CEIL
|
|
||||||
uniform sampler2D m_NormalMapCeil;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
in vec3 vWorldPos;
|
in vec3 vWorldPos;
|
||||||
in vec3 vNormal;
|
in vec3 vNormal;
|
||||||
@@ -55,27 +51,24 @@ void main() {
|
|||||||
vec2 uvY = vWorldPos.xz / m_TexScale;
|
vec2 uvY = vWorldPos.xz / m_TexScale;
|
||||||
vec2 uvZ = vWorldPos.xy / 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 flatBlend = smoothstep(0.94, 0.99, vNormal.y);
|
||||||
float ceilBlend = 1.0 - smoothstep(-0.6, -0.3, vNormal.y);
|
float steepBlend = 1.0 - flatBlend;
|
||||||
float steepBlend = max(0.0, 1.0 - flatBlend - ceilBlend);
|
|
||||||
|
|
||||||
// Flat: reines XZ-UV wie das Terrain (uvY = worldPos.xz / texScale), kein Triplanar.
|
// 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
|
vec4 col = texture(m_TexFlat, uvY) * flatBlend
|
||||||
+ triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend
|
+ triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend;
|
||||||
+ triplanar(m_TexCeil, uvX, uvY, uvZ, bw) * ceilBlend;
|
|
||||||
|
|
||||||
// Geometrie-Normale für Beleuchtung, ggf. durch Normal-Map ersetzt.
|
// Geometrie-Normale für Beleuchtung, ggf. durch Normal-Map ersetzt.
|
||||||
vec3 N = normalize(vNormal);
|
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);
|
vec3 pertN = vec3(0.0);
|
||||||
float totalBlend = 0.0;
|
float totalBlend = 0.0;
|
||||||
#ifdef HAS_NM_FLAT
|
#ifdef HAS_NM_FLAT
|
||||||
if (flatBlend > 0.001) {
|
if (flatBlend > 0.001) {
|
||||||
vec3 nmFlat = texture(m_NormalMapFlat, uvY).rgb * 2.0 - 1.0;
|
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);
|
nmFlat = vec3(nmFlat.xy + N.xz, abs(nmFlat.z) * N.y);
|
||||||
pertN += normalize(nmFlat.xzy) * flatBlend;
|
pertN += normalize(nmFlat.xzy) * flatBlend;
|
||||||
totalBlend += flatBlend;
|
totalBlend += flatBlend;
|
||||||
@@ -86,12 +79,6 @@ void main() {
|
|||||||
pertN += triplanarNormal(m_NormalMapSteep, uvX, uvY, uvZ, bw, N) * steepBlend;
|
pertN += triplanarNormal(m_NormalMapSteep, uvX, uvY, uvZ, bw, N) * steepBlend;
|
||||||
totalBlend += 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
|
#endif
|
||||||
if (totalBlend > 0.001) {
|
if (totalBlend > 0.001) {
|
||||||
N = normalize(pertN / totalBlend);
|
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": [
|
"clips": [
|
||||||
|
"get_up_sitting",
|
||||||
"idle",
|
"idle",
|
||||||
"idle_jump",
|
"idle_jump",
|
||||||
|
"pickup",
|
||||||
"running",
|
"running",
|
||||||
"running_jump",
|
"running_jump",
|
||||||
"sprint",
|
"sit_down",
|
||||||
|
"sitting",
|
||||||
|
"sitting_floor",
|
||||||
|
"sprinting",
|
||||||
"stand_up",
|
"stand_up",
|
||||||
"tpose",
|
"tpose",
|
||||||
"walking",
|
"walking"
|
||||||
"pickup"
|
|
||||||
],
|
],
|
||||||
"actionMap": {
|
"actionMap": {
|
||||||
"DEFAULT": "tpose",
|
"DEFAULT": "tpose",
|
||||||
"IDLE": "idle",
|
"IDLE": "idle",
|
||||||
"WALK": "walking",
|
"WALK": "walking",
|
||||||
"RUN": "running",
|
"RUN": "running",
|
||||||
"SPRINT": "sprint",
|
"SPRINT": "sprinting",
|
||||||
"JUMP": "idle_jump",
|
|
||||||
"RUNNING_JUMP": "running_jump",
|
"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 lod2Distance,
|
||||||
float cullDistance,
|
float cullDistance,
|
||||||
List<AttachedLight> attachedLights,
|
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. */
|
/** Lichtquelle relativ zum Modell-Ursprung. */
|
||||||
public record AttachedLight(
|
public record AttachedLight(
|
||||||
@@ -45,6 +50,8 @@ public record ModelMeta(
|
|||||||
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
|
return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f,
|
||||||
false, true, true, 1f, 1f,
|
false, true, true, 1f, 1f,
|
||||||
"", "", 30f, 80f, 120f,
|
"", "", 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("lod1Distance", String.valueOf(m.lod1Distance()));
|
||||||
p.setProperty("lod2Distance", String.valueOf(m.lod2Distance()));
|
p.setProperty("lod2Distance", String.valueOf(m.lod2Distance()));
|
||||||
p.setProperty("cullDistance", String.valueOf(m.cullDistance()));
|
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
|
// Anhänge: Lichter
|
||||||
List<ModelMeta.AttachedLight> lights = m.attachedLights();
|
List<ModelMeta.AttachedLight> lights = m.attachedLights();
|
||||||
@@ -127,7 +132,13 @@ public final class ModelMetaIO {
|
|||||||
parseFloat(p, "lod2Distance", 80f),
|
parseFloat(p, "lod2Distance", 80f),
|
||||||
parseFloat(p, "cullDistance", 120f),
|
parseFloat(p, "cullDistance", 120f),
|
||||||
Collections.unmodifiableList(lights),
|
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,
|
String lod2Path,
|
||||||
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
|
float lod1Distance, // ab dieser Distanz LOD1 anzeigen
|
||||||
float lod2Distance, // ab dieser Distanz LOD2 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
|
* Liest und schreibt platzierte Modelle als tab-separierte Textdatei
|
||||||
* ({@code blight_objects.blo}) neben der Kartendatei.
|
* ({@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
|
* 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 {
|
public final class PlacedModelIO {
|
||||||
|
|
||||||
@@ -26,11 +26,11 @@ public final class PlacedModelIO {
|
|||||||
Path p = getPath();
|
Path p = getPath();
|
||||||
Files.createDirectories(p.getParent());
|
Files.createDirectories(p.getParent());
|
||||||
try (BufferedWriter w = Files.newBufferedWriter(p)) {
|
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();
|
w.newLine();
|
||||||
for (PlacedModel m : models) {
|
for (PlacedModel m : models) {
|
||||||
w.write(String.format(Locale.ROOT,
|
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.modelPath(),
|
||||||
m.x(), m.y(), m.z(),
|
m.x(), m.y(), m.z(),
|
||||||
m.rotY(), m.scale(),
|
m.rotY(), m.scale(),
|
||||||
@@ -40,7 +40,8 @@ public final class PlacedModelIO {
|
|||||||
nvl(m.meshFile()), nvl(m.animClip()),
|
nvl(m.meshFile()), nvl(m.animClip()),
|
||||||
m.castShadow(), m.receiveShadow(),
|
m.castShadow(), m.receiveShadow(),
|
||||||
nvl(m.lod1Path()), nvl(m.lod2Path()),
|
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] : "";
|
String lod2Path = f.length > 17 ? f[17] : "";
|
||||||
float lod1Distance = f.length > 18 ? parseFloat(f[18], 30f) : 30f;
|
float lod1Distance = f.length > 18 ? parseFloat(f[18], 30f) : 30f;
|
||||||
float lod2Distance = f.length > 19 ? parseFloat(f[19], 80f) : 80f;
|
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,
|
list.add(new PlacedModel(modelPath, x, y, z,
|
||||||
rotY, rotX, rotZ, scale, solid,
|
rotY, rotX, rotZ, scale, solid,
|
||||||
texPath, nmPath, matPath, meshFile, animClip,
|
texPath, nmPath, matPath, meshFile, animClip,
|
||||||
castShadow, receiveShadow,
|
castShadow, receiveShadow,
|
||||||
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance));
|
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance,
|
||||||
|
interactableType, interactableId));
|
||||||
} catch (NumberFormatException ignored) {}
|
} catch (NumberFormatException ignored) {}
|
||||||
}
|
}
|
||||||
return list;
|
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 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 ──────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────────
|
// ── Serialisierung ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public byte[] serialize() throws IOException {
|
public byte[] serialize() throws IOException {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public final class VoxelChunkIO {
|
|||||||
return Files.exists(getPath(cx, cy, cz));
|
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 {
|
public static void save(VoxelChunk chunk) throws IOException {
|
||||||
Path p = getPath(chunk.cx, chunk.cy, chunk.cz);
|
Path p = getPath(chunk.cx, chunk.cy, chunk.cz);
|
||||||
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
|
Path tmp = p.resolveSibling(p.getFileName() + ".tmp");
|
||||||
@@ -54,6 +58,22 @@ public final class VoxelChunkIO {
|
|||||||
return Files.exists(getBakedPath(cx, cy, cz, 0));
|
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.
|
* Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis.
|
||||||
* Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert.
|
* 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
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class CraftingTable {
|
public class CraftingTable implements Interactable {
|
||||||
|
|
||||||
private TextReference name;
|
private TextReference name;
|
||||||
private ObjectReference object;
|
private ObjectReference object;
|
||||||
|
|
||||||
private CraftingTableType type;
|
private CraftingTableType type;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDisplayText() {
|
||||||
|
return TextRegistry.resolve(name, type != null ? type.name() : "?");
|
||||||
|
}
|
||||||
|
|
||||||
public enum CraftingTableType {
|
public enum CraftingTableType {
|
||||||
AlchemyTable,
|
AlchemyTable,
|
||||||
EnchantmentTable,
|
EnchantmentTable,
|
||||||
Smithy,
|
Smithy,
|
||||||
Goldsmiths,
|
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;
|
package de.blight.common.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -22,6 +23,43 @@ public class NPC extends GameCharacter {
|
|||||||
|
|
||||||
private List<DialogOption> currentOptions;
|
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) {
|
public List<DialogOption> getAvailableOptions(MainCharacter character) {
|
||||||
return currentOptions.stream().filter(option ->
|
return currentOptions.stream().filter(option ->
|
||||||
option.getRequiresChapter() < character.getChapter() &&
|
option.getRequiresChapter() < character.getChapter() &&
|
||||||
|
|||||||
@@ -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);
|
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). */
|
/** Direkter Zugriff für den Editor (alle Einträge). */
|
||||||
public static Map<String, String> getAll() {
|
public static Map<String, String> getAll() {
|
||||||
return new HashMap<>(entries);
|
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_QUEST_START = "QUEST_START";
|
||||||
public static final String TYPE_NPC_STATUS = "NPC_STATUS";
|
public static final String TYPE_NPC_STATUS = "NPC_STATUS";
|
||||||
public static final String TYPE_FRACTION_STATUS = "FRACTION_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()
|
private static final Gson GSON = new GsonBuilder()
|
||||||
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
|
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
|
||||||
@@ -78,6 +79,9 @@ public final class TriggerIO {
|
|||||||
obj.addProperty("fractionId", f.getFractionId().toString());
|
obj.addProperty("fractionId", f.getFractionId().toString());
|
||||||
if (f.getTargetStatus() != null)
|
if (f.getTargetStatus() != null)
|
||||||
obj.addProperty("targetStatus", f.getTargetStatus().name());
|
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;
|
return obj;
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,12 @@ public final class TriggerIO {
|
|||||||
if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus")));
|
if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus")));
|
||||||
yield f;
|
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;
|
default -> null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +135,7 @@ public final class TriggerIO {
|
|||||||
if (t instanceof QuestStartTrigger) return TYPE_QUEST_START;
|
if (t instanceof QuestStartTrigger) return TYPE_QUEST_START;
|
||||||
if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS;
|
if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS;
|
||||||
if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS;
|
if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS;
|
||||||
|
if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE;
|
||||||
return "UNKNOWN";
|
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.base/java.lang=ALL-UNNAMED',
|
||||||
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
|
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
|
||||||
"-Djava.library.path=${buildDir}/natives",
|
"-Djava.library.path=${buildDir}/natives",
|
||||||
|
'-Xmx3g',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ public class EditorApp extends Application {
|
|||||||
private VBox assetPanel;
|
private VBox assetPanel;
|
||||||
private MapObjectsView mapObjectsView;
|
private MapObjectsView mapObjectsView;
|
||||||
private StackPane worldViewport;
|
private StackPane worldViewport;
|
||||||
private javafx.scene.canvas.Canvas minimapCanvas;
|
private javafx.scene.canvas.Canvas compassCanvas;
|
||||||
private VBox topBar; // MenuBar + aktuelle Toolbar
|
private VBox topBar; // MenuBar + aktuelle Toolbar
|
||||||
private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons)
|
private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons)
|
||||||
private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt
|
private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt
|
||||||
@@ -151,11 +151,15 @@ public class EditorApp extends Application {
|
|||||||
// AnimSet-Editor
|
// AnimSet-Editor
|
||||||
private ListView<String> animSetClipListView;
|
private ListView<String> animSetClipListView;
|
||||||
private ListView<String> animSetActionListView;
|
private ListView<String> animSetActionListView;
|
||||||
|
private ListView<String> animSetSinkListView;
|
||||||
|
private ListView<String> animSetAnchorBoneListView;
|
||||||
private String animSetPendingPlayClip = null;
|
private String animSetPendingPlayClip = null;
|
||||||
private ComboBox<String> animSetModelCombo;
|
private ComboBox<String> animSetModelCombo;
|
||||||
private boolean animSetDirty = false;
|
private boolean animSetDirty = false;
|
||||||
private String animSetCurrentName = null;
|
private String animSetCurrentName = null;
|
||||||
private Path animSetCurrentDir = null;
|
private Path animSetCurrentDir = null;
|
||||||
|
private java.util.List<String> animJointNames = new java.util.ArrayList<>();
|
||||||
|
private Label animSetBonesLabel;
|
||||||
|
|
||||||
// Character-Editor-Zustand
|
// Character-Editor-Zustand
|
||||||
private de.blight.editor.ui.DialogEditorView dialogEditorView;
|
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 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 modelEditorLightBox = null;
|
||||||
private javafx.scene.layout.VBox modelEditorEmitterBox = 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
|
// Modell-Import-Zustand
|
||||||
private Label modelImportLod1StatusLabel;
|
private Label modelImportLod1StatusLabel;
|
||||||
@@ -443,6 +452,19 @@ public class EditorApp extends Application {
|
|||||||
animClipListView.getItems().setAll(newClips);
|
animClipListView.getItems().setAll(newClips);
|
||||||
if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst();
|
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
|
// AnimSet-Editor: nach Clip-Load automatisch abspielen
|
||||||
if (newClips != null && animSetPendingPlayClip != null) {
|
if (newClips != null && animSetPendingPlayClip != null) {
|
||||||
input.animPreviewPlayClip = animSetPendingPlayClip;
|
input.animPreviewPlayClip = animSetPendingPlayClip;
|
||||||
@@ -457,6 +479,11 @@ public class EditorApp extends Application {
|
|||||||
input.animPreviewStatus = null;
|
input.animPreviewStatus = null;
|
||||||
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus);
|
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus);
|
||||||
}
|
}
|
||||||
|
if (input.animImportCompleted) {
|
||||||
|
input.animImportCompleted = false;
|
||||||
|
refreshAddAnimCombo(addAnimComboField);
|
||||||
|
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
|
||||||
|
}
|
||||||
|
|
||||||
String animOp = input.animOpStatus;
|
String animOp = input.animOpStatus;
|
||||||
if (animOp != null) {
|
if (animOp != null) {
|
||||||
@@ -475,6 +502,19 @@ public class EditorApp extends Application {
|
|||||||
randomTreeStatusLabel.setText(rts);
|
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) {
|
if (input.refreshAssets) {
|
||||||
input.refreshAssets = false;
|
input.refreshAssets = false;
|
||||||
boolean pe = shouldPreserveExpansion();
|
boolean pe = shouldPreserveExpansion();
|
||||||
@@ -527,7 +567,7 @@ public class EditorApp extends Application {
|
|||||||
updateWaterHeightDisplay(input.waterCurrentHeight);
|
updateWaterHeightDisplay(input.waterCurrentHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawMinimap();
|
drawCompass();
|
||||||
|
|
||||||
// Spiel-Konsole: gepufferte Zeilen gebündelt ausgeben (max 200 auf einmal)
|
// Spiel-Konsole: gepufferte Zeilen gebündelt ausgeben (max 200 auf einmal)
|
||||||
if (!consoleBuffer.isEmpty() && gameConsoleArea != null) {
|
if (!consoleBuffer.isEmpty() && gameConsoleArea != null) {
|
||||||
@@ -4762,6 +4802,38 @@ public class EditorApp extends Application {
|
|||||||
} catch (IOException ignored) {}
|
} 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.
|
* Löscht Thumbnail und Impostor-Textur, die zu einer .j3o-Datei gehören.
|
||||||
* Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt.
|
* Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt.
|
||||||
@@ -4835,7 +4907,10 @@ public class EditorApp extends Application {
|
|||||||
loadJmeTexturesInto(jmeTexturesNode);
|
loadJmeTexturesInto(jmeTexturesNode);
|
||||||
}
|
}
|
||||||
case "audio" -> audioNode = node;
|
case "audio" -> audioNode = node;
|
||||||
case "animations" -> animationsNode = node;
|
case "animations" -> {
|
||||||
|
animationsNode = node;
|
||||||
|
addAnimClipSubNodes(node);
|
||||||
|
}
|
||||||
case "items" -> itemsNode = node;
|
case "items" -> itemsNode = node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4849,6 +4924,7 @@ public class EditorApp extends Application {
|
|||||||
catNode.getChildren().clear();
|
catNode.getChildren().clear();
|
||||||
Path dir = itemPaths.get(catNode);
|
Path dir = itemPaths.get(catNode);
|
||||||
if (dir != null) loadAssetsRecursive(catNode, dir);
|
if (dir != null) loadAssetsRecursive(catNode, dir);
|
||||||
|
if (catNode == animationsNode) addAnimClipSubNodes(catNode);
|
||||||
if (catNode == modelsNode && jmeModelsNode != null)
|
if (catNode == modelsNode && jmeModelsNode != null)
|
||||||
catNode.getChildren().add(jmeModelsNode);
|
catNode.getChildren().add(jmeModelsNode);
|
||||||
if (catNode == texturesNode && jmeTexturesNode != null)
|
if (catNode == texturesNode && jmeTexturesNode != null)
|
||||||
@@ -5025,6 +5101,7 @@ public class EditorApp extends Application {
|
|||||||
|
|
||||||
String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures";
|
String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures";
|
||||||
TreeItem<String> parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode;
|
TreeItem<String> parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode;
|
||||||
|
archiveOriginal(file, isModel ? "models" : isAudio ? "audio" : "textures");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Path destDir = ASSET_ROOT.resolve(subDir);
|
Path destDir = ASSET_ROOT.resolve(subDir);
|
||||||
@@ -5663,7 +5740,15 @@ public class EditorApp extends Application {
|
|||||||
input.modelEditorPivotY = meta.pivotOffsetY();
|
input.modelEditorPivotY = meta.pivotOffsetY();
|
||||||
input.modelEditorOpenPath = relPath;
|
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);
|
setStatus("Modell-Editor: " + relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5895,6 +5980,150 @@ public class EditorApp extends Application {
|
|||||||
// Initiale Gizmos pushen
|
// Initiale Gizmos pushen
|
||||||
pushAttachmentsToJme();
|
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 ───────────────────────────────────────────────────────────
|
// ── Buttons ───────────────────────────────────────────────────────────
|
||||||
Button saveBtn = new Button("💾 Speichern");
|
Button saveBtn = new Button("💾 Speichern");
|
||||||
saveBtn.setMaxWidth(Double.MAX_VALUE);
|
saveBtn.setMaxWidth(Double.MAX_VALUE);
|
||||||
@@ -5926,7 +6155,14 @@ public class EditorApp extends Application {
|
|||||||
(float)(double) rndMaxSpin.getValue(),
|
(float)(double) rndMaxSpin.getValue(),
|
||||||
modelEditorLod1Path, modelEditorLod2Path,
|
modelEditorLod1Path, modelEditorLod2Path,
|
||||||
new java.util.ArrayList<>(modelEditorLights),
|
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 -> {
|
placeBtn.setOnAction(e -> {
|
||||||
input.modelEditorCloseRequest = true;
|
input.modelEditorCloseRequest = true;
|
||||||
@@ -5967,6 +6203,8 @@ public class EditorApp extends Application {
|
|||||||
lightSectionLbl, modelEditorLightBox, addLightBtn,
|
lightSectionLbl, modelEditorLightBox, addLightBtn,
|
||||||
emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn,
|
emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn,
|
||||||
new Separator(),
|
new Separator(),
|
||||||
|
interactTitle, modelEditorInteractableCB, restPointBox,
|
||||||
|
new Separator(),
|
||||||
saveBtn, placeBtn, closeBtn
|
saveBtn, placeBtn, closeBtn
|
||||||
);
|
);
|
||||||
return panel;
|
return panel;
|
||||||
@@ -5994,6 +6232,7 @@ public class EditorApp extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void startImportFile(File file) {
|
private void startImportFile(File file) {
|
||||||
|
archiveOriginal(file, "models");
|
||||||
String name = file.getName();
|
String name = file.getName();
|
||||||
String baseName = name.replaceFirst("\\.[^.]+$", "");
|
String baseName = name.replaceFirst("\\.[^.]+$", "");
|
||||||
Path destDir = ASSET_ROOT.resolve("Models").resolve("imported");
|
Path destDir = ASSET_ROOT.resolve("Models").resolve("imported");
|
||||||
@@ -6415,12 +6654,17 @@ public class EditorApp extends Application {
|
|||||||
float rndMin, float rndMax,
|
float rndMin, float rndMax,
|
||||||
String lod1Path, String lod2Path,
|
String lod1Path, String lod2Path,
|
||||||
java.util.List<de.blight.common.ModelMeta.AttachedLight> lights,
|
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(
|
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
|
||||||
name, category, tags, sx, sy, sz, uniform,
|
name, category, tags, sx, sy, sz, uniform,
|
||||||
pivotY, placeY, solid, cast, receive, rndMin, rndMax,
|
pivotY, placeY, solid, cast, receive, rndMin, rndMax,
|
||||||
lod1Path, lod2Path, 30f, 80f, 120f,
|
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()) {
|
if (absolutePath == null || !absolutePath.toFile().exists()) {
|
||||||
setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert");
|
setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert");
|
||||||
@@ -6659,13 +6903,13 @@ public class EditorApp extends Application {
|
|||||||
viewport.setPreserveRatio(false);
|
viewport.setPreserveRatio(false);
|
||||||
viewport.setFocusTraversable(true);
|
viewport.setFocusTraversable(true);
|
||||||
|
|
||||||
minimapCanvas = new javafx.scene.canvas.Canvas(164, 164);
|
compassCanvas = new javafx.scene.canvas.Canvas(100, 100);
|
||||||
minimapCanvas.setMouseTransparent(true);
|
compassCanvas.setMouseTransparent(true);
|
||||||
StackPane.setAlignment(minimapCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
|
StackPane.setAlignment(compassCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
|
||||||
StackPane.setMargin(minimapCanvas, new Insets(0, 10, 10, 0));
|
StackPane.setMargin(compassCanvas, new Insets(0, 10, 10, 0));
|
||||||
drawMinimap(); // Initiales Zeichnen (leer)
|
drawCompass();
|
||||||
|
|
||||||
StackPane pane = new StackPane(viewport, minimapCanvas);
|
StackPane pane = new StackPane(viewport, compassCanvas);
|
||||||
pane.setStyle("-fx-background-color: #1a1a2e;");
|
pane.setStyle("-fx-background-color: #1a1a2e;");
|
||||||
|
|
||||||
javafx.animation.PauseTransition resizeDebounce =
|
javafx.animation.PauseTransition resizeDebounce =
|
||||||
@@ -6846,6 +7090,11 @@ public class EditorApp extends Application {
|
|||||||
}
|
}
|
||||||
case SharedInput.LAYER_VOXEL ->
|
case SharedInput.LAYER_VOXEL ->
|
||||||
input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y, action));
|
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);
|
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() {
|
javafx.scene.canvas.GraphicsContext gc = compassCanvas.getGraphicsContext2D();
|
||||||
if (minimapCanvas == null) return;
|
gc.clearRect(0, 0, S, S);
|
||||||
final double SIZE = minimapCanvas.getWidth(); // 164
|
|
||||||
final double INNER = SIZE - 4; // 160 innere Kartenfläche
|
|
||||||
final double OFFSET = 2;
|
|
||||||
|
|
||||||
javafx.scene.canvas.GraphicsContext gc = minimapCanvas.getGraphicsContext2D();
|
// Hintergrund: dunkler Kreis
|
||||||
gc.clearRect(0, 0, SIZE, SIZE);
|
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
|
// Rotierende Rose: -camYaw so dass die aktuelle Blickrichtung oben erscheint
|
||||||
gc.setFill(javafx.scene.paint.Color.rgb(10, 10, 20, 0.75));
|
gc.save();
|
||||||
gc.fillRoundRect(0, 0, SIZE, SIZE, 6, 6);
|
gc.translate(cx, cy);
|
||||||
gc.setStroke(javafx.scene.paint.Color.rgb(120, 120, 180, 0.8));
|
gc.rotate(-input.camYaw);
|
||||||
gc.setLineWidth(1);
|
|
||||||
gc.strokeRoundRect(0.5, 0.5, SIZE - 1, SIZE - 1, 6, 6);
|
|
||||||
|
|
||||||
// Hilfslinien (Weltmitte)
|
// Tick-Striche an 8 Positionen (45°-Schritte)
|
||||||
gc.setStroke(javafx.scene.paint.Color.rgb(80, 80, 110, 0.5));
|
for (int i = 0; i < 8; i++) {
|
||||||
gc.setLineWidth(0.5);
|
double a = Math.toRadians(i * 45.0);
|
||||||
double mid = OFFSET + INNER / 2.0;
|
double len = (i % 2 == 0) ? 9 : 5;
|
||||||
gc.strokeLine(mid, OFFSET, mid, OFFSET + INNER);
|
gc.setStroke(javafx.scene.paint.Color.rgb(160, 160, 200, 0.65));
|
||||||
gc.strokeLine(OFFSET, mid, OFFSET + INNER, mid);
|
gc.setLineWidth(i % 2 == 0 ? 1.5 : 1.0);
|
||||||
|
gc.strokeLine(
|
||||||
// Kameraposition (blauer Pfeil/Dreieck)
|
Math.sin(a) * (R - len), -Math.cos(a) * (R - len),
|
||||||
if (Float.isFinite(input.camX) && Float.isFinite(input.camZ)) {
|
Math.sin(a) * R, -Math.cos(a) * R);
|
||||||
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
|
|
||||||
|
|
||||||
|
// 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.save();
|
||||||
gc.translate(mx, mz);
|
gc.translate(tx, ty);
|
||||||
gc.rotate(Math.toDegrees(yaw));
|
gc.rotate(input.camYaw); // Gegenrotation zur Rose → Buchstabe immer aufrecht
|
||||||
gc.setFill(javafx.scene.paint.Color.rgb(80, 160, 255, 0.95));
|
gc.setFill(i == 0
|
||||||
gc.setStroke(javafx.scene.paint.Color.WHITE);
|
? javafx.scene.paint.Color.rgb(255, 80, 80)
|
||||||
gc.setLineWidth(0.8);
|
: javafx.scene.paint.Color.rgb(210, 210, 230));
|
||||||
// Kleines Dreieck zeigt Blickrichtung
|
gc.setFont(javafx.scene.text.Font.font(
|
||||||
double[] px = { 0, -4.5, 4.5 };
|
"System", javafx.scene.text.FontWeight.BOLD, i == 0 ? 13 : 11));
|
||||||
double[] pz = { -7, 5, 5 };
|
gc.fillText(dirs[i], -4.5, 5.0);
|
||||||
gc.fillPolygon(px, pz, 3);
|
|
||||||
gc.strokePolygon(px, pz, 3);
|
|
||||||
gc.restore();
|
gc.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beschriftung
|
gc.restore(); // Ende rotierende Rose
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double worldToMap(float world, double innerSize, double offset) {
|
// Fixer Richtungszeiger oben (gelbes Dreieck, zeigt immer die Blickrichtung)
|
||||||
return offset + (world + WORLD_HALF) / (WORLD_HALF * 2) * innerSize;
|
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() {
|
private void saveCameraPrefs() {
|
||||||
@@ -7970,6 +8230,10 @@ public class EditorApp extends Application {
|
|||||||
.forEach(animSetModelCombo.getItems()::add);
|
.forEach(animSetModelCombo.getItems()::add);
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
}
|
}
|
||||||
|
// Gespeicherten Modell-Pfad vorauswählen
|
||||||
|
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
|
||||||
|
animSetModelCombo.setValue(animSet.getPreviewModelPath());
|
||||||
|
}
|
||||||
Button loadModelBtn = new Button("Laden");
|
Button loadModelBtn = new Button("Laden");
|
||||||
loadModelBtn.setMaxWidth(Double.MAX_VALUE);
|
loadModelBtn.setMaxWidth(Double.MAX_VALUE);
|
||||||
loadModelBtn.setOnAction(e -> {
|
loadModelBtn.setOnAction(e -> {
|
||||||
@@ -7977,9 +8241,18 @@ public class EditorApp extends Application {
|
|||||||
if (path == null || path.isBlank()) return;
|
if (path == null || path.isBlank()) return;
|
||||||
input.animPreviewLoadPath = path;
|
input.animPreviewLoadPath = path;
|
||||||
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
|
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
|
||||||
|
// Pfad im AnimSet merken und sofort speichern
|
||||||
|
animSet.setPreviewModelPath(path);
|
||||||
|
animSetDirty = true;
|
||||||
});
|
});
|
||||||
inner.getChildren().addAll(animSetModelCombo, loadModelBtn);
|
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 ─────────────────────────────────────────────────────
|
// ── Clips im Set ─────────────────────────────────────────────────────
|
||||||
inner.getChildren().addAll(new Separator(), sectionTitle("Clips im Set"), new Separator());
|
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<>();
|
ListView<String> list = new ListView<>();
|
||||||
list.getItems().addAll(notYetAdded);
|
list.getItems().addAll(notYetAdded);
|
||||||
list.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
|
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();
|
list.getSelectionModel().selectFirst();
|
||||||
|
|
||||||
javafx.scene.control.Dialog<java.util.List<String>> dlg = new javafx.scene.control.Dialog<>();
|
javafx.scene.control.Dialog<java.util.List<String>> dlg = new javafx.scene.control.Dialog<>();
|
||||||
dlg.setTitle("Animation(en) hinzufügen");
|
dlg.setTitle("Animation(en) hinzufügen");
|
||||||
dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:");
|
dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:");
|
||||||
dlg.getDialogPane().setContent(list);
|
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.ButtonType ok = new javafx.scene.control.ButtonType("Hinzufügen",
|
||||||
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
|
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
|
||||||
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
|
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
|
||||||
@@ -8114,6 +8388,197 @@ public class EditorApp extends Application {
|
|||||||
HBox.setHgrow(removeActionBtn, Priority.ALWAYS);
|
HBox.setHgrow(removeActionBtn, Priority.ALWAYS);
|
||||||
inner.getChildren().addAll(animSetActionListView, actionBtns);
|
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 ─────────────────────────────────────────────────────────
|
// ── Vorschau ─────────────────────────────────────────────────────────
|
||||||
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
|
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
|
||||||
|
|
||||||
@@ -8182,13 +8647,21 @@ public class EditorApp extends Application {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
animSetPendingPlayClip = clip;
|
animSetPendingPlayClip = clip;
|
||||||
// Clip zur aktuell geladenen Figur hinzufügen (nicht als Modell laden).
|
input.animImportQueue.offer(findAnimClipPath(clip));
|
||||||
// Nach Abschluss setzt AnimPreviewState animPreviewClips, das den
|
|
||||||
// animSetPendingPlayClip-Trigger auslöst und den Clip abspielt.
|
|
||||||
input.animPreviewAddAnimPath = "animations/clips/" + clip + ".j3o";
|
|
||||||
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade Clip " + 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() {
|
private void showAddActionToSetDialog() {
|
||||||
if (animSetClipListView == null || animSetClipListView.getItems().isEmpty()) {
|
if (animSetClipListView == null || animSetClipListView.getItems().isEmpty()) {
|
||||||
setStatus("Keine Clips im Set — erst Clips hinzufügen.");
|
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) {
|
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();
|
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
|
||||||
animSet.setClips(new java.util.ArrayList<>(animSetClipListView.getItems()));
|
animSet.setClips(new java.util.ArrayList<>(animSetClipListView.getItems()));
|
||||||
java.util.Map<String, String> actionMap = new java.util.LinkedHashMap<>();
|
java.util.Map<String, String> actionMap = new java.util.LinkedHashMap<>();
|
||||||
if (animSetActionListView != null)
|
if (animSetActionListView != null) {
|
||||||
for (String it : animSetActionListView.getItems()) {
|
for (String it : animSetActionListView.getItems()) {
|
||||||
String[] parts = it.split(" → ", 2);
|
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);
|
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 {
|
try {
|
||||||
animSet.save(setDir, setName);
|
animSet.save(setDir, setName);
|
||||||
setStatus("AnimSet gespeichert: " + setName + ".animset.json");
|
setStatus("AnimSet gespeichert: " + setName + ".animset.json");
|
||||||
@@ -8443,7 +8947,7 @@ public class EditorApp extends Application {
|
|||||||
animPreviewStatusLabel.setText("Bitte eine Animation auswählen");
|
animPreviewStatusLabel.setText("Bitte eine Animation auswählen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.animPreviewAddAnimPath = animPath;
|
input.animImportQueue.offer(animPath);
|
||||||
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…");
|
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…");
|
||||||
});
|
});
|
||||||
inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn);
|
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);
|
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) {
|
private void handleAnimationImport(javafx.stage.Window owner) {
|
||||||
FileChooser fc = new FileChooser();
|
FileChooser fc = new FileChooser();
|
||||||
fc.setTitle("Animation importieren (GLB/GLTF)");
|
fc.setTitle("Animation importieren (GLB/GLTF)");
|
||||||
@@ -8500,19 +9021,12 @@ public class EditorApp extends Application {
|
|||||||
var files = fc.showOpenMultipleDialog(owner);
|
var files = fc.showOpenMultipleDialog(owner);
|
||||||
if (files == null) return;
|
if (files == null) return;
|
||||||
for (File file : files) {
|
for (File file : files) {
|
||||||
try {
|
archiveOriginal(file, "animations");
|
||||||
Path destDir = ASSET_ROOT.resolve("animations").resolve("clips");
|
// Absoluten Pfad übergeben – kein Kopieren nötig.
|
||||||
Files.createDirectories(destDir);
|
// addAnimation() lädt direkt vom Ursprungsort und speichert nur J3O nach clips/.
|
||||||
Path destFile = destDir.resolve(file.getName());
|
input.animImportQueue.offer(file.getAbsolutePath());
|
||||||
Files.copy(file.toPath(), destFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
setStatus("Importiere: " + file.getName() + " …");
|
||||||
setStatus("Animation importiert: " + file.getName());
|
|
||||||
} catch (IOException ex) {
|
|
||||||
setStatus("Fehler beim Animations-Import: " + ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Sofort im JavaFX-Thread aktualisieren – keine Konvertierung nötig
|
|
||||||
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
|
|
||||||
refreshAddAnimCombo(addAnimComboField);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reimportModelForPreview(javafx.stage.Window owner) {
|
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"));
|
new FileChooser.ExtensionFilter("3D-Modelle (GLTF, GLB)", "*.gltf", "*.glb"));
|
||||||
File file = fc.showOpenDialog(owner);
|
File file = fc.showOpenDialog(owner);
|
||||||
if (file == null) return;
|
if (file == null) return;
|
||||||
|
archiveOriginal(file, "models");
|
||||||
|
|
||||||
String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null;
|
String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null;
|
||||||
Path destDir;
|
Path destDir;
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import de.blight.editor.state.SceneObjectState;
|
|||||||
import de.blight.editor.state.TerrainEditorState;
|
import de.blight.editor.state.TerrainEditorState;
|
||||||
import de.blight.editor.state.TreeGeneratorState;
|
import de.blight.editor.state.TreeGeneratorState;
|
||||||
import de.blight.editor.state.VoxelEditorState;
|
import de.blight.editor.state.VoxelEditorState;
|
||||||
|
import de.blight.editor.state.SculptedMeshEditorState;
|
||||||
import de.blight.editor.state.ModelImportState;
|
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.console.JmeConsole;
|
||||||
import de.blight.game.state.DayNightState;
|
import de.blight.game.state.DayNightState;
|
||||||
import javafx.scene.image.WritableImage;
|
import javafx.scene.image.WritableImage;
|
||||||
@@ -192,10 +195,13 @@ public class JmeEditorApp extends SimpleApplication {
|
|||||||
stateManager.attach(new LocationZoneState(input));
|
stateManager.attach(new LocationZoneState(input));
|
||||||
stateManager.attach(new RiverEditorState(input));
|
stateManager.attach(new RiverEditorState(input));
|
||||||
stateManager.attach(new PlayToolState(input));
|
stateManager.attach(new PlayToolState(input));
|
||||||
|
stateManager.attach(new RoutineMapState(input));
|
||||||
|
stateManager.attach(new PathNetworkEditorState(input));
|
||||||
stateManager.attach(new AnimPreviewState(input));
|
stateManager.attach(new AnimPreviewState(input));
|
||||||
stateManager.attach(new ModelEditorState(input));
|
stateManager.attach(new ModelEditorState(input));
|
||||||
stateManager.attach(new ItemPlacementState(input));
|
stateManager.attach(new ItemPlacementState(input));
|
||||||
stateManager.attach(new VoxelEditorState(input));
|
stateManager.attach(new VoxelEditorState(input));
|
||||||
|
stateManager.attach(new SculptedMeshEditorState(input));
|
||||||
stateManager.attach(new ModelImportState(input));
|
stateManager.attach(new ModelImportState(input));
|
||||||
|
|
||||||
// NaN-sichere Comparatoren einsetzen (verhindern den TimSort-Crash bei kaputten Bounds)
|
// 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.GrassVertexTool;
|
||||||
import de.blight.editor.tool.HeightTool;
|
import de.blight.editor.tool.HeightTool;
|
||||||
import de.blight.editor.tool.HoleTool;
|
import de.blight.editor.tool.HoleTool;
|
||||||
|
import de.blight.editor.tool.StoneTool;
|
||||||
import de.blight.editor.tool.TextureTool;
|
import de.blight.editor.tool.TextureTool;
|
||||||
import de.blight.editor.tool.UpperHeightTool;
|
import de.blight.editor.tool.UpperHeightTool;
|
||||||
|
import de.blight.editor.tool.SculptMeshTool;
|
||||||
import de.blight.editor.tool.VoxelTool;
|
import de.blight.editor.tool.VoxelTool;
|
||||||
import de.blight.editor.tree.PalmOptions;
|
import de.blight.editor.tree.PalmOptions;
|
||||||
import de.blight.editor.tree.TreeParams;
|
import de.blight.editor.tree.TreeParams;
|
||||||
@@ -26,8 +28,10 @@ public class SharedInput {
|
|||||||
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
|
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
|
||||||
public final TextureTool textureTool = new TextureTool();
|
public final TextureTool textureTool = new TextureTool();
|
||||||
public final HoleTool holeTool = new HoleTool();
|
public final HoleTool holeTool = new HoleTool();
|
||||||
public final VoxelTool voxelTool = new VoxelTool();
|
public final VoxelTool voxelTool = new VoxelTool();
|
||||||
public volatile EditorTool activeTool = heightTool;
|
public final StoneTool stoneTool = new StoneTool();
|
||||||
|
public final SculptMeshTool sculptTool = new SculptMeshTool();
|
||||||
|
public volatile EditorTool activeTool = heightTool;
|
||||||
|
|
||||||
// ── Initialisierungs-Status ───────────────────────────────────────────────
|
// ── Initialisierungs-Status ───────────────────────────────────────────────
|
||||||
public volatile boolean jmeReady = false;
|
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. */
|
/** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */
|
||||||
public volatile String pendingAnimClip = null;
|
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
|
// Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen
|
||||||
public record ObjectPropertyChange(
|
public record ObjectPropertyChange(
|
||||||
float x, float y, float z,
|
float x, float y, float z,
|
||||||
@@ -541,7 +550,7 @@ public class SharedInput {
|
|||||||
/** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */
|
/** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */
|
||||||
public volatile String animPreviewPlayClip = null;
|
public volatile String animPreviewPlayClip = null;
|
||||||
/** JavaFX → JME3: Animation-j3o-Pfad zum Retargeting + Hinzufügen. null = kein Auftrag. */
|
/** 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. */
|
/** JavaFX → JME3: Clip-Name zum Entfernen aus dem geladenen Modell. null = kein Auftrag. */
|
||||||
public volatile String animPreviewRemoveClip = null;
|
public volatile String animPreviewRemoveClip = null;
|
||||||
/** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */
|
/** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */
|
||||||
@@ -553,9 +562,14 @@ public class SharedInput {
|
|||||||
public volatile boolean animPreviewLoop = true;
|
public volatile boolean animPreviewLoop = true;
|
||||||
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
|
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
|
||||||
public volatile String animPreviewStatus = null;
|
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. */
|
/** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */
|
||||||
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
|
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
|
||||||
animPreviewClips = new java.util.concurrent.atomic.AtomicReference<>();
|
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.
|
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
|
||||||
@@ -656,15 +670,29 @@ public class SharedInput {
|
|||||||
public volatile String modelEditorLod2Path = "";
|
public volatile String modelEditorLod2Path = "";
|
||||||
public volatile boolean modelEditorLodChanged = false;
|
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 ────────────────────────────────────────────────────────
|
// ── Voxel-Werkzeug ────────────────────────────────────────────────────────
|
||||||
/** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */
|
/** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */
|
||||||
public static final int LAYER_VOXEL = 16;
|
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). */
|
/** action +1 = Linksklick (erhöhen/hinzufügen), -1 = Rechtsklick (senken/entfernen). */
|
||||||
public record VoxelEdit(float screenX, float screenY, int action) {}
|
public record VoxelEdit(float screenX, float screenY, int action) {}
|
||||||
public final ConcurrentLinkedQueue<VoxelEdit> voxelEditQueue = new ConcurrentLinkedQueue<>();
|
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. */
|
/** JFX → JME: alle Voxel-Chunks als geglättete J3O-Meshes backen. */
|
||||||
public volatile boolean bakeVoxelsRequested = false;
|
public volatile boolean bakeVoxelsRequested = false;
|
||||||
/** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */
|
/** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */
|
||||||
@@ -673,6 +701,8 @@ public class SharedInput {
|
|||||||
public volatile int bakeTotal = 0;
|
public volatile int bakeTotal = 0;
|
||||||
/** JME → JFX: Status-Meldung nach Abschluss des Backens. */
|
/** JME → JFX: Status-Meldung nach Abschluss des Backens. */
|
||||||
public volatile String bakeStatusMsg = null;
|
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. */
|
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
|
||||||
public volatile int voxelFlatSlot = -1;
|
public volatile int voxelFlatSlot = -1;
|
||||||
@@ -683,6 +713,9 @@ public class SharedInput {
|
|||||||
/** JFX setzt true wenn Voxel-Texturen geändert wurden; JME liest + resettet. */
|
/** JFX setzt true wenn Voxel-Texturen geändert wurden; JME liest + resettet. */
|
||||||
public volatile boolean voxelTexturesChanged = false;
|
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 ──────────────────────────────────────────────────────
|
// ── Item-Platzierung ──────────────────────────────────────────────────────
|
||||||
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */
|
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */
|
||||||
public static final int LAYER_ITEMS = 21;
|
public static final int LAYER_ITEMS = 21;
|
||||||
@@ -720,6 +753,22 @@ public class SharedInput {
|
|||||||
/** JME → JFX: Status-Meldung nach LOD-Generierung. */
|
/** JME → JFX: Status-Meldung nach LOD-Generierung. */
|
||||||
public volatile String modelLodGenStatus = null;
|
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. */
|
/** JFX → JME: LOD-Reduktions-Algorithmus. "blight" = Dihedral Edge Collapse, "jme" = JME3 Progressive Mesh. */
|
||||||
public volatile String modelLodAlgorithm = "blight";
|
public volatile String modelLodAlgorithm = "blight";
|
||||||
|
|
||||||
@@ -731,4 +780,124 @@ public class SharedInput {
|
|||||||
public volatile String modelImportExportName = null;
|
public volatile String modelImportExportName = null;
|
||||||
/** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */
|
/** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */
|
||||||
public volatile String modelImportExportStatus = null;
|
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 lod1Distance = 30f;
|
||||||
public float lod2Distance = 80f;
|
public float lod2Distance = 80f;
|
||||||
public float cullDistance = 120f;
|
public float cullDistance = 120f;
|
||||||
|
public String interactableType = "";
|
||||||
|
public String interactableId = "";
|
||||||
|
|
||||||
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
|
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
|
||||||
boolean solid) {
|
boolean solid) {
|
||||||
|
|||||||
@@ -148,9 +148,8 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
else playClip(playClip);
|
else playClip(playClip);
|
||||||
}
|
}
|
||||||
|
|
||||||
String addAnimPath = input.animPreviewAddAnimPath;
|
String addAnimPath = input.animImportQueue.poll();
|
||||||
if (addAnimPath != null) {
|
if (addAnimPath != null) {
|
||||||
input.animPreviewAddAnimPath = null;
|
|
||||||
addAnimation(addAnimPath);
|
addAnimation(addAnimPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +275,22 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
collectClips(model, clips);
|
collectClips(model, clips);
|
||||||
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
||||||
input.animPreviewLoadedPath.set(assetPath);
|
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 (clips.isEmpty()) {
|
||||||
if (!hasSkeleton(model)) {
|
if (!hasSkeleton(model)) {
|
||||||
input.animPreviewStatus =
|
input.animPreviewStatus =
|
||||||
@@ -289,8 +304,10 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
|
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("[AnimPreview] Ladefehler: {}", assetPath, e);
|
||||||
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
|
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
|
||||||
input.animPreviewClips.set(List.of());
|
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 ───────────
|
// ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
|
||||||
|
|
||||||
private void addAnimation(String animAssetPath) {
|
private void addAnimation(String animAssetPath) {
|
||||||
if (currentModel == null) {
|
// Kein Modell geladen → kein Retargeting, aber Clip trotzdem als J3O speichern
|
||||||
input.animPreviewStatus = "Fehler: zuerst ein Modell laden";
|
AnimComposer targetAC = currentModel != null ? findControl(currentModel, AnimComposer.class) : null;
|
||||||
return;
|
SkinningControl targetSC = currentModel != null ? findControl(currentModel, SkinningControl.class) : null;
|
||||||
}
|
if (currentModel != null && targetAC == null) {
|
||||||
AnimComposer targetAC = findControl(currentModel, AnimComposer.class);
|
|
||||||
SkinningControl targetSC = findControl(currentModel, SkinningControl.class);
|
|
||||||
if (targetAC == null) {
|
|
||||||
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
|
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -428,14 +442,14 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
|
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
|
||||||
|
|
||||||
if (srcArm != null) {
|
if (srcArm != null) {
|
||||||
LOG.info("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
|
LOG.trace("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
|
||||||
for (var j : srcArm.getJointList()) LOG.info(" src: {}", j.getName());
|
for (var j : srcArm.getJointList()) LOG.trace(" src: {}", j.getName());
|
||||||
} else {
|
} else {
|
||||||
LOG.warn("[Retarget] Keine SkinningControl in Quelle!");
|
LOG.warn("[Retarget] Keine SkinningControl in Quelle!");
|
||||||
}
|
}
|
||||||
if (dstArm != null) {
|
if (dstArm != null) {
|
||||||
LOG.info("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
|
LOG.trace("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
|
||||||
for (var j : dstArm.getJointList()) LOG.info(" dst: {}", j.getName());
|
for (var j : dstArm.getJointList()) LOG.trace(" dst: {}", j.getName());
|
||||||
} else {
|
} else {
|
||||||
LOG.warn("[Retarget] Keine SkinningControl im Modell!");
|
LOG.warn("[Retarget] Keine SkinningControl im Modell!");
|
||||||
}
|
}
|
||||||
@@ -443,7 +457,7 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
|
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
|
||||||
if (retarget) {
|
if (retarget) {
|
||||||
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
|
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<>();
|
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.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
|
||||||
java.nio.file.Files.createDirectories(clipsDir);
|
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;
|
int saved = 0;
|
||||||
for (AnimClip clip : sourceAC.getAnimClips()) {
|
for (AnimClip clip : sourceAC.getAnimClips()) {
|
||||||
String name = clip.getName();
|
String name = clip.getName();
|
||||||
@@ -467,19 +490,38 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
: clip;
|
: clip;
|
||||||
if (result == null) continue;
|
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
|
// Direkt in die Clip-Bibliothek speichern – das Modell wird nicht modifiziert
|
||||||
saveClipToFile(result, dstArm != null ? dstArm : srcArm,
|
saveClipToFile(toSave, dstArm != null ? dstArm : srcArm,
|
||||||
clipsDir.resolve(name + ".j3o"));
|
clipsDir.resolve(saveName + ".j3o"));
|
||||||
// Für den aktuellen Preview-Session auch auf das Modell anwenden
|
// Für den aktuellen Preview-Session auch auf das Modell anwenden (wenn geladen)
|
||||||
targetAC.addAnimClip(result);
|
if (targetAC != null) targetAC.addAnimClip(toSave);
|
||||||
saved++;
|
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<>();
|
List<String> clips = new ArrayList<>();
|
||||||
collectClips(currentModel, clips);
|
if (currentModel != null) collectClips(currentModel, clips);
|
||||||
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
input.animPreviewClips.set(Collections.unmodifiableList(clips));
|
||||||
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
|
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
|
||||||
+ (retarget ? " (retargeted)" : " (direkt)");
|
+ (retarget ? " (retargeted)" : " (direkt)");
|
||||||
|
if (saved > 0) input.animImportCompleted = true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("[AnimPreview] Fehler beim Importieren von {}", animAssetPath, e);
|
||||||
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
|
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) {
|
private void addAxisLabel(Node parent, Vector3f pos, String text, ColorRGBA color) {
|
||||||
try {
|
try {
|
||||||
BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt");
|
BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt");
|
||||||
BitmapText label = new BitmapText(font, false);
|
BitmapText label = new BitmapText(font);
|
||||||
label.setSize(0.18f);
|
label.setSize(0.18f);
|
||||||
label.setColor(color);
|
label.setColor(color);
|
||||||
label.setText(text);
|
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 {
|
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));
|
Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar));
|
||||||
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
|
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
|
||||||
BinaryImporter bi = BinaryImporter.getInstance();
|
BinaryImporter bi = BinaryImporter.getInstance();
|
||||||
@@ -827,12 +884,13 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
|
|
||||||
int embedded = 0;
|
int embedded = 0;
|
||||||
for (String clipName : set.getClips()) {
|
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);
|
LOG.warn("[AnimEmbed] Clip nicht gefunden: {}", clipName);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o");
|
Spatial clipSpatial = loadFresh(clipRelPath);
|
||||||
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
|
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
|
||||||
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
|
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
|
||||||
if (clipAC == null) continue;
|
if (clipAC == null) continue;
|
||||||
@@ -878,6 +936,15 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
BinaryExporter.getInstance().save(holder, outFile.toFile());
|
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) {
|
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
|
||||||
if (a.getJointCount() != b.getJointCount()) return false;
|
if (a.getJointCount() != b.getJointCount()) return false;
|
||||||
java.util.Set<String> namesA = new java.util.HashSet<>();
|
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_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);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Zustand ───────────────────────────────────────────────────────────────
|
||||||
private final SharedInput input;
|
private final SharedInput input;
|
||||||
private AssetManager assetManager;
|
private AssetManager assetManager;
|
||||||
private TerrainQuad terrain;
|
private com.jme3.renderer.Camera cam;
|
||||||
private Node grassNode;
|
private TerrainQuad terrain;
|
||||||
private Material material;
|
private Node grassNode;
|
||||||
|
private Material material;
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
|
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
|
||||||
@@ -89,6 +94,7 @@ public class GrassVertexState extends BaseAppState {
|
|||||||
@Override
|
@Override
|
||||||
protected void initialize(Application app) {
|
protected void initialize(Application app) {
|
||||||
this.assetManager = app.getAssetManager();
|
this.assetManager = app.getAssetManager();
|
||||||
|
this.cam = app.getCamera();
|
||||||
grassNode = new Node("grassVertexNode");
|
grassNode = new Node("grassVertexNode");
|
||||||
((SimpleApplication) app).getRootNode().attachChild(grassNode);
|
((SimpleApplication) app).getRootNode().attachChild(grassNode);
|
||||||
material = buildMaterial();
|
material = buildMaterial();
|
||||||
@@ -115,6 +121,22 @@ public class GrassVertexState extends BaseAppState {
|
|||||||
public void update(float tpf) {
|
public void update(float tpf) {
|
||||||
processBrushEdits();
|
processBrushEdits();
|
||||||
rebuildDirtyChunks();
|
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 ──────────────────────────────────────────────────────────────
|
// ── Material ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import com.jme3.material.Material;
|
|||||||
import com.jme3.math.*;
|
import com.jme3.math.*;
|
||||||
import com.jme3.renderer.Camera;
|
import com.jme3.renderer.Camera;
|
||||||
import com.jme3.scene.*;
|
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.Box;
|
||||||
import com.jme3.scene.shape.Cylinder;
|
import com.jme3.scene.shape.Cylinder;
|
||||||
|
import com.jme3.scene.shape.Dome;
|
||||||
import com.jme3.scene.shape.Sphere;
|
import com.jme3.scene.shape.Sphere;
|
||||||
import com.jme3.util.BufferUtils;
|
import com.jme3.util.BufferUtils;
|
||||||
import de.blight.editor.SharedInput;
|
import de.blight.editor.SharedInput;
|
||||||
@@ -67,6 +70,9 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
/** Node, der alle Anhang-Gizmos (Lichter + Emitter) enthält. */
|
/** Node, der alle Anhang-Gizmos (Lichter + Emitter) enthält. */
|
||||||
private Node attachmentGizmos = null;
|
private Node attachmentGizmos = null;
|
||||||
|
|
||||||
|
/** Hauptlichtquelle der Vorschau (per UI steuerbar). */
|
||||||
|
private DirectionalLight mainLight = null;
|
||||||
|
|
||||||
// gespeicherter Kamerazustand aus dem Editor-Modus
|
// gespeicherter Kamerazustand aus dem Editor-Modus
|
||||||
private Vector3f savedCamPos;
|
private Vector3f savedCamPos;
|
||||||
private Quaternion savedCamRot;
|
private Quaternion savedCamRot;
|
||||||
@@ -75,6 +81,12 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
private String currentPath = null;
|
private String currentPath = null;
|
||||||
private String mainModelPath = 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. */
|
/** Originales Spatial wie vom Asset-Manager geladen – wird durch LOD-Previews nicht überschrieben. */
|
||||||
private Spatial originalSpatial = null;
|
private Spatial originalSpatial = null;
|
||||||
|
|
||||||
@@ -190,6 +202,15 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
applyPivot(input.modelEditorPivotY);
|
applyPivot(input.modelEditorPivotY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lichtrichtung
|
||||||
|
if (input.modelEditorLightChanged) {
|
||||||
|
input.modelEditorLightChanged = false;
|
||||||
|
if (mainLight != null) {
|
||||||
|
mainLight.setDirection(computeLightDirection(
|
||||||
|
input.modelEditorLightAzimuth, input.modelEditorLightElevation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Anhang-Gizmos aktualisieren
|
// Anhang-Gizmos aktualisieren
|
||||||
if (input.modelEditorAttachmentsChanged) {
|
if (input.modelEditorAttachmentsChanged) {
|
||||||
input.modelEditorAttachmentsChanged = false;
|
input.modelEditorAttachmentsChanged = false;
|
||||||
@@ -233,6 +254,43 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
repositionCompCylinder();
|
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();
|
applyOrbitCamera();
|
||||||
|
|
||||||
previewRoot.updateLogicalState(tpf);
|
previewRoot.updateLogicalState(tpf);
|
||||||
@@ -382,9 +440,10 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
app.getRootNode().setCullHint(Spatial.CullHint.Always);
|
app.getRootNode().setCullHint(Spatial.CullHint.Always);
|
||||||
|
|
||||||
previewRoot = new Node("model_editor_preview");
|
previewRoot = new Node("model_editor_preview");
|
||||||
previewRoot.addLight(new DirectionalLight(
|
mainLight = new DirectionalLight(
|
||||||
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
|
computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation),
|
||||||
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
|
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f));
|
||||||
|
previewRoot.addLight(mainLight);
|
||||||
previewRoot.addLight(new DirectionalLight(
|
previewRoot.addLight(new DirectionalLight(
|
||||||
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
|
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
|
||||||
new ColorRGBA(0.5f, 0.55f, 0.75f, 1f)));
|
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)));
|
new ColorRGBA(0.4f, 0.4f, 0.5f, 1f)));
|
||||||
previewRoot.addLight(new AmbientLight(new ColorRGBA(0.65f, 0.65f, 0.7f, 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));
|
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;
|
orbitYaw = 30f;
|
||||||
orbitPitch = 25f;
|
orbitPitch = 25f;
|
||||||
}
|
}
|
||||||
@@ -418,6 +484,7 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
hasEmbeddedLods = false;
|
hasEmbeddedLods = false;
|
||||||
embeddedLodSpatials = null;
|
embeddedLodSpatials = null;
|
||||||
originalSpatial = null;
|
originalSpatial = null;
|
||||||
|
interactableArrowNode = null;
|
||||||
input.modelEditorHasEmbeddedLods = false;
|
input.modelEditorHasEmbeddedLods = false;
|
||||||
app.getRootNode().setCullHint(Spatial.CullHint.Inherit);
|
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; }
|
public String getCurrentPath() { return currentPath; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -753,4 +895,18 @@ public class ModelEditorState extends BaseAppState {
|
|||||||
public BoundingBox getCurrentBounds() {
|
public BoundingBox getCurrentBounds() {
|
||||||
return getBoundingBox();
|
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.texture.Texture;
|
||||||
import com.jme3.terrain.geomipmap.TerrainQuad;
|
import com.jme3.terrain.geomipmap.TerrainQuad;
|
||||||
import com.jme3.export.binary.BinaryExporter;
|
import com.jme3.export.binary.BinaryExporter;
|
||||||
|
import com.jme3.scene.shape.Dome;
|
||||||
import de.blight.common.PlacedModel;
|
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.SharedInput;
|
||||||
import de.blight.editor.object.SceneObject;
|
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 Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt)
|
||||||
private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi)
|
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 subTriIdx = -1;
|
||||||
private int[] subEdgeVertIdx = null; // [v0, v1] Vertex-Indizes im Mesh (Kanten-Modus)
|
private int[] subEdgeVertIdx = null; // [v0, v1] Vertex-Indizes im Mesh (Kanten-Modus)
|
||||||
private int subVertexIdx = -1; // einzelner Vertex-Index (Punkt-Modus)
|
private int subVertexIdx = -1; // einzelner Vertex-Index (Punkt-Modus)
|
||||||
@@ -151,13 +170,15 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
meshFile, animClips.get(i),
|
meshFile, animClips.get(i),
|
||||||
so.castShadow, so.receiveShadow,
|
so.castShadow, so.receiveShadow,
|
||||||
so.lod1Path, so.lod2Path,
|
so.lod1Path, so.lod2Path,
|
||||||
so.lod1Distance, so.lod2Distance, so.cullDistance));
|
so.lod1Distance, so.lod2Distance, so.cullDistance,
|
||||||
|
so.interactableType, so.interactableId));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void loadPlacedModels(List<PlacedModel> models) {
|
public void loadPlacedModels(List<PlacedModel> models) {
|
||||||
if (objectRoot == null) return;
|
if (objectRoot == null) return;
|
||||||
|
metaCache.clear();
|
||||||
objectRoot.detachAllChildren();
|
objectRoot.detachAllChildren();
|
||||||
objects.clear();
|
objects.clear();
|
||||||
objNodes.clear();
|
objNodes.clear();
|
||||||
@@ -177,9 +198,11 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
so.receiveShadow = pm.receiveShadow();
|
so.receiveShadow = pm.receiveShadow();
|
||||||
so.lod1Path = pm.lod1Path() != null ? pm.lod1Path() : "";
|
so.lod1Path = pm.lod1Path() != null ? pm.lod1Path() : "";
|
||||||
so.lod2Path = pm.lod2Path() != null ? pm.lod2Path() : "";
|
so.lod2Path = pm.lod2Path() != null ? pm.lod2Path() : "";
|
||||||
so.lod1Distance = pm.lod1Distance();
|
so.lod1Distance = pm.lod1Distance();
|
||||||
so.lod2Distance = pm.lod2Distance();
|
so.lod2Distance = pm.lod2Distance();
|
||||||
so.cullDistance = pm.cullDistance();
|
so.cullDistance = pm.cullDistance();
|
||||||
|
so.interactableType = pm.interactableType() != null ? pm.interactableType() : "";
|
||||||
|
so.interactableId = pm.interactableId() != null ? pm.interactableId() : "";
|
||||||
objects.add(so);
|
objects.add(so);
|
||||||
animClips.add(pm.animClip() != null ? pm.animClip() : "");
|
animClips.add(pm.animClip() != null ? pm.animClip() : "");
|
||||||
|
|
||||||
@@ -246,6 +269,14 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
subOverlay = new Node("subOverlay");
|
subOverlay = new Node("subOverlay");
|
||||||
subOverlay.setCullHint(Spatial.CullHint.Always);
|
subOverlay.setCullHint(Spatial.CullHint.Always);
|
||||||
rootNode.attachChild(subOverlay);
|
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
|
@Override
|
||||||
@@ -254,6 +285,8 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
gizmoNode.removeFromParent();
|
gizmoNode.removeFromParent();
|
||||||
previewNode.removeFromParent();
|
previewNode.removeFromParent();
|
||||||
subOverlay.removeFromParent();
|
subOverlay.removeFromParent();
|
||||||
|
bedArrowNode.removeFromParent();
|
||||||
|
benchArrowNode.removeFromParent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void onEnable() {}
|
@Override protected void onEnable() {}
|
||||||
@@ -294,6 +327,11 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
try { loadPlacedModels(de.blight.common.PlacedModelIO.load()); } catch (Exception ignored) {}
|
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
|
boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS
|
||||||
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT;
|
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT;
|
||||||
if (!isObjectLayer) return;
|
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
|
// Solid-Flag-Änderung von JavaFX
|
||||||
Boolean solidChange = input.pendingSolidChange;
|
Boolean solidChange = input.pendingSolidChange;
|
||||||
if (solidChange != null) {
|
if (solidChange != null) {
|
||||||
@@ -590,6 +643,33 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
so.lod1Distance = meta.lod1Distance();
|
so.lod1Distance = meta.lod1Distance();
|
||||||
so.lod2Distance = meta.lod2Distance();
|
so.lod2Distance = meta.lod2Distance();
|
||||||
so.cullDistance = meta.cullDistance();
|
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.setRotation(0f, rotY, 0f);
|
||||||
so.setScale(defaultScale);
|
so.setScale(defaultScale);
|
||||||
@@ -885,11 +965,14 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
+ "|" + so.getScale() + "|" + so.getTexturePath()
|
+ "|" + so.getScale() + "|" + so.getTexturePath()
|
||||||
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
|
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
|
||||||
+ "|" + animClips.get(idx)
|
+ "|" + animClips.get(idx)
|
||||||
+ "|" + so.castShadow + "|" + so.receiveShadow;
|
+ "|" + so.castShadow + "|" + so.receiveShadow
|
||||||
|
+ "|" + so.interactableType + "|" + so.interactableId;
|
||||||
} else {
|
} else {
|
||||||
input.selectedObjectInfo = String.valueOf(n);
|
input.selectedObjectInfo = String.valueOf(n);
|
||||||
}
|
}
|
||||||
input.objectSelectionChanged = true;
|
input.objectSelectionChanged = true;
|
||||||
|
refreshBedArrow();
|
||||||
|
refreshBenchArrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gizmo-Drag ───────────────────────────────────────────────────────────
|
// ── Gizmo-Drag ───────────────────────────────────────────────────────────
|
||||||
@@ -1430,6 +1513,8 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
sortedDesc.sort(Comparator.reverseOrder());
|
sortedDesc.sort(Comparator.reverseOrder());
|
||||||
|
|
||||||
for (int idx : sortedDesc) {
|
for (int idx : sortedDesc) {
|
||||||
|
SceneObject so = objects.get(idx);
|
||||||
|
deleteInteractableFile(so);
|
||||||
objectRoot.detachChild(objNodes.get(idx));
|
objectRoot.detachChild(objNodes.get(idx));
|
||||||
objects.remove(idx);
|
objects.remove(idx);
|
||||||
objNodes.remove(idx);
|
objNodes.remove(idx);
|
||||||
@@ -1446,6 +1531,69 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
setStatus("Objekt(e) gelöscht");
|
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 ────────────────────────────────────────────────────────
|
// ── Zusammenfassen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void mergeSelected() {
|
private void mergeSelected() {
|
||||||
@@ -1932,4 +2080,242 @@ public class SceneObjectState extends BaseAppState {
|
|||||||
local.z /= wScale.z;
|
local.z /= wScale.z;
|
||||||
return local;
|
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.MapIO;
|
||||||
import de.blight.common.PlacedModelIO;
|
import de.blight.common.PlacedModelIO;
|
||||||
import de.blight.editor.SharedInput;
|
import de.blight.editor.SharedInput;
|
||||||
|
import de.blight.editor.state.PathNetworkEditorState;
|
||||||
|
import de.blight.editor.state.RoutineMapState;
|
||||||
import de.blight.editor.tool.HeightTool;
|
import de.blight.editor.tool.HeightTool;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -81,6 +83,7 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
|
|
||||||
// ── Kamera ────────────────────────────────────────────────────────────────
|
// ── Kamera ────────────────────────────────────────────────────────────────
|
||||||
private static final float CAM_SPEED = 300f;
|
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 ORBIT_SPEED = 1.5f;
|
||||||
private static final float MOUSE_SENS = 0.003f;
|
private static final float MOUSE_SENS = 0.003f;
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
private Geometry brushIndicator;
|
private Geometry brushIndicator;
|
||||||
private PlacedObjectState placedObjectState;
|
private PlacedObjectState placedObjectState;
|
||||||
private GrassVertexState grassVertexState;
|
private GrassVertexState grassVertexState;
|
||||||
|
private StoneEditorState stoneEditorState;
|
||||||
private SceneObjectState sceneObjState;
|
private SceneObjectState sceneObjState;
|
||||||
private ItemPlacementState itemPlacementState;
|
private ItemPlacementState itemPlacementState;
|
||||||
private LightState lightState;
|
private LightState lightState;
|
||||||
@@ -238,6 +242,11 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
grassVertexState.setTerrain(terrain);
|
grassVertexState.setTerrain(terrain);
|
||||||
app.getStateManager().attach(grassVertexState);
|
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);
|
sceneObjState = app.getStateManager().getState(SceneObjectState.class);
|
||||||
if (sceneObjState != null) {
|
if (sceneObjState != null) {
|
||||||
sceneObjState.setTerrain(terrain);
|
sceneObjState.setTerrain(terrain);
|
||||||
@@ -338,6 +347,12 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
|
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
|
||||||
if (playToolState != null) playToolState.setTerrain(terrain);
|
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(buildWater());
|
||||||
rootNode.attachChild(buildGrid());
|
rootNode.attachChild(buildGrid());
|
||||||
|
|
||||||
@@ -1143,6 +1158,7 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
|
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
|
||||||
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
|
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
|
||||||
// Löschoperationen aus dem JavaFX-Thread entsteht.
|
// 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 (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 (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); }
|
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 (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 (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); }
|
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 ─
|
// ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─
|
||||||
saveExecutor.submit(() -> {
|
saveExecutor.submit(() -> {
|
||||||
@@ -1240,7 +1257,10 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|
||||||
|| layer == SharedInput.LAYER_LOCATION_ZONES
|
|| layer == SharedInput.LAYER_LOCATION_ZONES
|
||||||
|| layer == SharedInput.LAYER_PLAY_TOOL
|
|| 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);
|
brushIndicator.setCullHint(Spatial.CullHint.Always);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1347,6 +1367,10 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
|
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
|
||||||
modifyHeight(contact, delta, mode);
|
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();
|
if (processed > 0) terrain.updateModelBound();
|
||||||
}
|
}
|
||||||
@@ -1517,7 +1541,9 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
private float terrainDistBelow() {
|
private float terrainDistBelow() {
|
||||||
if (terrain == null) return CAM_SPEED;
|
if (terrain == null) return CAM_SPEED;
|
||||||
Float h = terrain.getHeight(new Vector2f(camPos.x, camPos.z));
|
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) {
|
private void updateCamera(float tpf) {
|
||||||
@@ -1567,6 +1593,12 @@ public class TerrainEditorState extends BaseAppState {
|
|||||||
if (scroll != 0)
|
if (scroll != 0)
|
||||||
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
|
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);
|
cam.setLocation(camPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.jme3.material.Material;
|
|||||||
import com.jme3.material.RenderState;
|
import com.jme3.material.RenderState;
|
||||||
import com.jme3.math.ColorRGBA;
|
import com.jme3.math.ColorRGBA;
|
||||||
import com.jme3.math.FastMath;
|
import com.jme3.math.FastMath;
|
||||||
|
import com.jme3.math.Quaternion;
|
||||||
import com.jme3.math.Ray;
|
import com.jme3.math.Ray;
|
||||||
import com.jme3.math.Vector2f;
|
import com.jme3.math.Vector2f;
|
||||||
import com.jme3.math.Vector3f;
|
import com.jme3.math.Vector3f;
|
||||||
@@ -19,6 +20,7 @@ import com.jme3.scene.Geometry;
|
|||||||
import com.jme3.scene.Mesh;
|
import com.jme3.scene.Mesh;
|
||||||
import com.jme3.scene.Node;
|
import com.jme3.scene.Node;
|
||||||
import com.jme3.scene.Spatial;
|
import com.jme3.scene.Spatial;
|
||||||
|
import com.jme3.scene.shape.Quad;
|
||||||
import com.jme3.scene.VertexBuffer;
|
import com.jme3.scene.VertexBuffer;
|
||||||
import com.jme3.terrain.geomipmap.TerrainQuad;
|
import com.jme3.terrain.geomipmap.TerrainQuad;
|
||||||
import com.jme3.texture.Texture;
|
import com.jme3.texture.Texture;
|
||||||
@@ -40,6 +42,8 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.*;
|
import java.util.concurrent.*;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Deque;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +121,23 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
|
|
||||||
private Geometry brushIndicator;
|
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 ─────────────────────────────────────────────────────
|
// ── LOD-Rebuild-Queue ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */
|
/** 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. */
|
/** Chunks mit fertigen LOD1/2-Meshes, die im JME-Thread übernommen werden. */
|
||||||
private final ConcurrentLinkedQueue<Runnable> lodResultQueue = new ConcurrentLinkedQueue<>();
|
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 ───────────────────────────────────────────────────────────
|
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public VoxelEditorState(SharedInput input) {
|
public VoxelEditorState(SharedInput input) {
|
||||||
@@ -153,6 +185,14 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
brushIndicator = buildBrushIndicator();
|
brushIndicator = buildBrushIndicator();
|
||||||
app.getRootNode().attachChild(brushIndicator);
|
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
|
// Alle vorhandenen .blvc-Dateien laden
|
||||||
List<VoxelChunk> loaded = VoxelChunkIO.loadAll();
|
List<VoxelChunk> loaded = VoxelChunkIO.loadAll();
|
||||||
for (VoxelChunk chunk : loaded) {
|
for (VoxelChunk chunk : loaded) {
|
||||||
@@ -167,7 +207,9 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
protected void cleanup(Application app) {
|
protected void cleanup(Application app) {
|
||||||
executor.shutdownNow();
|
executor.shutdownNow();
|
||||||
voxelRoot.removeFromParent();
|
voxelRoot.removeFromParent();
|
||||||
if (brushIndicator != null) brushIndicator.removeFromParent();
|
if (brushIndicator != null) brushIndicator.removeFromParent();
|
||||||
|
if (basePlaneNode != null) basePlaneNode.removeFromParent();
|
||||||
|
if (wireframeActive) applyWireframe(false);
|
||||||
nodes.clear();
|
nodes.clear();
|
||||||
chunks.clear();
|
chunks.clear();
|
||||||
}
|
}
|
||||||
@@ -188,7 +230,14 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
if (input.bakeVoxelsRequested) {
|
if (input.bakeVoxelsRequested) {
|
||||||
input.bakeVoxelsRequested = false;
|
input.bakeVoxelsRequested = false;
|
||||||
List<VoxelChunk> snapshot = new ArrayList<>(chunks.values());
|
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?
|
// Voxel-Texturen aktualisiert?
|
||||||
@@ -197,6 +246,17 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
applyTextures(voxelMaterial);
|
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
|
// Nur aktiv wenn LAYER_VOXEL gesetzt
|
||||||
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
|
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
|
||||||
idleSinceEdit = 0f;
|
idleSinceEdit = 0f;
|
||||||
@@ -205,6 +265,12 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
return;
|
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)
|
// Edit-Queue verarbeiten (max. MAX_EDITS_PER_FRAME)
|
||||||
// Edits nur akkumulieren; Mesh-Rebuild am Frameende einmal pro Chunk.
|
// Edits nur akkumulieren; Mesh-Rebuild am Frameende einmal pro Chunk.
|
||||||
int processed = 0;
|
int processed = 0;
|
||||||
@@ -322,7 +388,9 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
Vector3f bestPos = null;
|
Vector3f bestPos = null;
|
||||||
Vector3f bestNorm = new Vector3f(0, 1, 0);
|
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);
|
terrainNode.collideWith(ray, results);
|
||||||
if (results.size() > 0) {
|
if (results.size() > 0) {
|
||||||
CollisionResult cr = results.getClosestCollision();
|
CollisionResult cr = results.getClosestCollision();
|
||||||
@@ -341,11 +409,26 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
if (results.size() > 0) {
|
if (results.size() > 0) {
|
||||||
CollisionResult cr = results.getClosestCollision();
|
CollisionResult cr = results.getClosestCollision();
|
||||||
if (cr.getDistance() < bestDist) {
|
if (cr.getDistance() < bestDist) {
|
||||||
|
bestDist = cr.getDistance();
|
||||||
bestPos = cr.getContactPoint();
|
bestPos = cr.getContactPoint();
|
||||||
bestNorm = cr.getContactNormal() != null
|
bestNorm = cr.getContactNormal() != null
|
||||||
? cr.getContactNormal().normalize()
|
? cr.getContactNormal().normalize()
|
||||||
: new Vector3f(0, 1, 0);
|
: 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;
|
return bestPos != null ? new Hit(bestPos, bestNorm) : null;
|
||||||
@@ -354,28 +437,36 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
/**
|
/**
|
||||||
* Wendet den gewählten Modus an.
|
* Wendet den gewählten Modus an.
|
||||||
*
|
*
|
||||||
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Spalten ab Terrain-Oberfläche nach oben (links)
|
* Vertikal (horizontal=false):
|
||||||
* bzw. Abtragen nach unten (rechts). Verhalten analog zum Terrain-Tool.
|
* 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.
|
* Horizontal (horizontal=true):
|
||||||
*
|
* Modi 0-3: Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
|
||||||
* Modus 5 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum.
|
* Modus 4 (Aushöhlen): wie vertikal (Kugel-Entfernen).
|
||||||
*/
|
*/
|
||||||
private void applyEdit(Hit hit, int action) {
|
private void applyEdit(Hit hit, int action) {
|
||||||
float radius = (float) input.voxelTool.brushRadius.getValue();
|
float radius = (float) input.voxelTool.brushRadius.getValue();
|
||||||
float strength = (float) input.voxelTool.brushStrength.getValue();
|
float strength = (float) input.voxelTool.brushStrength.getValue();
|
||||||
int modeIdx = input.voxelTool.mode.getSelectedIndex();
|
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 isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE);
|
||||||
boolean isColumn = !isSlab && !isCave;
|
boolean isColumn = !isCave;
|
||||||
boolean lower = action < 0;
|
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;
|
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
|
// Horizontaler Modus: bei flacher Fläche (Normal.y > 0.7) nichts tun
|
||||||
if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
|
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);
|
float h = columnTopWorldY(wx, wz);
|
||||||
TerrainEditorState tes = getStateManager().getState(TerrainEditorState.class);
|
TerrainEditorState tes = getStateManager().getState(TerrainEditorState.class);
|
||||||
if (tes != null) {
|
if (tes != null) {
|
||||||
@@ -396,8 +487,8 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
wz -= N.z * radius * 0.6f;
|
wz -= N.z * radius * 0.6f;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spalten-Modi brauchen nur XZ-Radius; Slab/Cave brauchen auch Stärke in Y
|
// Vertikale Spalten-Modi brauchen nur XZ-Radius; alle anderen auch Stärke
|
||||||
float worldExtent = isColumn ? radius + 2f : radius + strength + 2f;
|
float worldExtent = (isColumn && !isHorizontal) ? radius + 2f : radius + strength + 2f;
|
||||||
|
|
||||||
int cxMin = VoxelChunk.worldXToCx(wx - worldExtent);
|
int cxMin = VoxelChunk.worldXToCx(wx - worldExtent);
|
||||||
int cxMax = 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 cyMin = VoxelChunk.worldYToCy(wy - worldExtent);
|
||||||
int cyMax = 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;
|
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);
|
slopeParams = computeSlopeParams(wx, wz, radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
float plateauTargetH = (float) input.voxelTool.plateauTarget.getValue();
|
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 cz = czMin; cz <= czMax; cz++) {
|
||||||
for (int cx = cxMin; cx <= cxMax; cx++) {
|
for (int cx = cxMin; cx <= cxMax; cx++) {
|
||||||
for (int cy = cyMin; cy <= cyMax; cy++) {
|
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);
|
long key = chunkKey(cx, cy, cz);
|
||||||
float ly = VoxelChunk.worldYToLocal(wy, cy);
|
float lx = VoxelChunk.worldXToLocal(wx, cx);
|
||||||
float lz = VoxelChunk.worldZToLocal(wz, cz);
|
float ly = VoxelChunk.worldYToLocal(wy, cy);
|
||||||
|
float lz = VoxelChunk.worldZToLocal(wz, cz);
|
||||||
|
|
||||||
|
snapshotChunkBefore(key, chunk);
|
||||||
|
|
||||||
if (isCave) {
|
if (isCave) {
|
||||||
chunk.applyBrush(lx, ly, lz, radius, Byte.MIN_VALUE, (byte)0);
|
// Graduelles Aushöhlen mit vollem Radius (passt zum Indikator).
|
||||||
} else if (isSlab) {
|
// Stärke bestimmt Dichte-Abbau pro Tick (je höher, desto aggressiver).
|
||||||
applySlabBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, lower);
|
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) {
|
} 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) {
|
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
|
||||||
if (lower) {
|
if (lower) {
|
||||||
applyCliffColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
|
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);
|
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
|
// Node erst anlegen wenn tatsächlich Daten vorhanden
|
||||||
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
|
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
|
||||||
addNodeForChunk(key, chunk);
|
addNodeForChunk(key, chunk);
|
||||||
@@ -451,15 +560,29 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheiben-Pinsel für den Klippe-Modus.
|
* Horizontaler Brush: baut Voxel in Richtung der Flächennormale auf (oder ab).
|
||||||
* remove=false: Voxel senkrecht zur Normalen aufbauen.
|
* Das Profil (Sinus/Spike/Plateau/Smooth) bestimmt die Tiefe entlang der Normalen
|
||||||
* remove=true: dieselbe Form entfernen (Rechtsklick).
|
* 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,
|
private void applyHorizontalBrush(VoxelChunk chunk, int cx, int cy, int cz,
|
||||||
float hitWX, float hitWY, float hitWZ,
|
float hitWX, float hitWY, float hitWZ,
|
||||||
Vector3f N, float radius, float strength,
|
Vector3f N, float radius, float strength,
|
||||||
boolean remove) {
|
int mode, boolean remove) {
|
||||||
float extent = radius + strength + 1f;
|
// 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 lhX = VoxelChunk.worldXToLocal(hitWX, cx);
|
||||||
float lhY = VoxelChunk.worldYToLocal(hitWY, cy);
|
float lhY = VoxelChunk.worldYToLocal(hitWY, cy);
|
||||||
float lhZ = VoxelChunk.worldZToLocal(hitWZ, cz);
|
float lhZ = VoxelChunk.worldZToLocal(hitWZ, cz);
|
||||||
@@ -471,10 +594,6 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
int z0 = Math.max(0, (int)(lhZ - extent));
|
int z0 = Math.max(0, (int)(lhZ - extent));
|
||||||
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(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++) {
|
for (int ly = y0; ly <= y1; ly++) {
|
||||||
float wy = VoxelChunk.toWorldY(cy, ly);
|
float wy = VoxelChunk.toWorldY(cy, ly);
|
||||||
float dy = wy - hitWY;
|
float dy = wy - hitWY;
|
||||||
@@ -485,12 +604,20 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
float wx = VoxelChunk.toWorldX(cx, lx);
|
float wx = VoxelChunk.toWorldX(cx, lx);
|
||||||
float dx = wx - hitWX;
|
float dx = wx - hitWX;
|
||||||
|
|
||||||
float along = dx*nx + dy*ny + dz*nz;
|
// Abstand in der Flächen-Ebene (Y + tangential in XZ)
|
||||||
float slabThick = Math.max(1f, strength / 10f);
|
float projTan = dx*tanx + dz*tanz;
|
||||||
if (along < -overlap || along > slabThick) continue;
|
float perpSq = projTan*projTan + dy*dy;
|
||||||
float perpSq = dx*dx + dy*dy + dz*dz - along*along;
|
|
||||||
if (perpSq > r2) continue;
|
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) {
|
if (remove) {
|
||||||
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
|
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
|
||||||
} else {
|
} else {
|
||||||
@@ -542,26 +669,35 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
int colStep = (int)(stepBase * falloff);
|
int colStep = (int)(stepBase * falloff);
|
||||||
if (colStep < 1) continue;
|
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) {
|
if (!lower) {
|
||||||
// Aktuellen Säulen-Top finden (höchster Solid-Voxel ≥ terrainLY)
|
// Höchsten Solid-Voxel in dieser Spalte suchen
|
||||||
int currentTop = terrainLY; // Fallback: direkt auf Terrain starten
|
int currentTop = -1;
|
||||||
for (int ly = VoxelChunk.SIZE - 1; ly >= terrainLY; ly--) {
|
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
|
||||||
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
|
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);
|
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);
|
chunk.setDensity(lx, ly, lz, (byte) 127);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Höchsten Solid-Voxel finden (egal ob über oder unter terrainLY)
|
// Höchsten Solid-Voxel finden
|
||||||
int currentTop = -1;
|
int currentTop = -1;
|
||||||
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
|
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
|
||||||
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
|
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) ─────────────────────────────────
|
// ── Terrain-Höhe (schneller O(1)-Zugriff) ─────────────────────────────────
|
||||||
|
|
||||||
private float terrainH(float worldX, float worldZ) {
|
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
|
float currentTopWY = currentTopLY >= 0
|
||||||
? VoxelChunk.toWorldY(cy, currentTopLY)
|
? VoxelChunk.toWorldY(cy, currentTopLY)
|
||||||
: terrainH(wx, wz);
|
: -10f; // Keine Voxel → Basis-Niveau als Referenz
|
||||||
|
|
||||||
float diff = targetH - currentTopWY;
|
float diff = targetH - currentTopWY;
|
||||||
if (Math.abs(diff) < 0.5f) continue;
|
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) {
|
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);
|
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);
|
chunk.setDensity(lx, ly, lz, (byte) 127);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -758,14 +947,9 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
float dx = wx - brushWX;
|
float dx = wx - brushWX;
|
||||||
if (dx*dx + dz*dz > r2) continue;
|
if (dx*dx + dz*dz > r2) continue;
|
||||||
|
|
||||||
float th = terrainH(wx, wz);
|
|
||||||
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
|
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
|
||||||
float wy = VoxelChunk.toWorldY(cy, ly);
|
float wy = VoxelChunk.toWorldY(cy, ly);
|
||||||
if (wy < th) {
|
if (wy < -10f) continue; // Fundament unterhalb y=-10 nicht antasten
|
||||||
// Unterhalb Terrain: immer leeren, kein Überhang möglich
|
|
||||||
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (wy <= targetH) {
|
if (wy <= targetH) {
|
||||||
chunk.setDensity(lx, ly, lz, (byte) 127);
|
chunk.setDensity(lx, ly, lz, (byte) 127);
|
||||||
} else {
|
} else {
|
||||||
@@ -812,9 +996,21 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
List<VoxelChunk> nonEmpty = new java.util.ArrayList<>();
|
List<VoxelChunk> nonEmpty = new java.util.ArrayList<>();
|
||||||
for (VoxelChunk c : toProcess) { if (!c.isEmpty()) nonEmpty.add(c); }
|
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
|
// bakeTotal sofort setzen, damit die Fortschrittsanzeige schon während des Blurs läuft
|
||||||
input.bakeDone = 0;
|
input.bakeDone = 0;
|
||||||
input.bakeTotal = nonEmpty.size();
|
input.bakeTotal = nonEmpty.size();
|
||||||
|
log.info("Bake gestartet: {} Chunks", nonEmpty.size());
|
||||||
|
|
||||||
Map<Long, VoxelChunk> allOriginal = new HashMap<>();
|
Map<Long, VoxelChunk> allOriginal = new HashMap<>();
|
||||||
for (VoxelChunk c : nonEmpty) allOriginal.put(chunkKey(c.cx, c.cy, c.cz), c);
|
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)];
|
cBuf[c.idx(bx, VoxelChunk.CELLS, bz)] = tBuf[c.idx(bx, 0, bz)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.blurIterDone = 0;
|
||||||
for (int iter = 0; iter < 7; iter++) {
|
for (int iter = 0; iter < 7; iter++) {
|
||||||
Map<Long, float[]> nextBufs = new HashMap<>();
|
Map<Long, float[]> nextBufs = new HashMap<>();
|
||||||
for (VoxelChunk c : nonEmpty) {
|
for (VoxelChunk c : nonEmpty) {
|
||||||
@@ -889,6 +1086,7 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
nextBufs.put(k, next);
|
nextBufs.put(k, next);
|
||||||
}
|
}
|
||||||
curBufs = nextBufs;
|
curBufs = nextBufs;
|
||||||
|
input.blurIterDone = iter + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blur-Ergebnisse in VoxelChunks umwandeln
|
// Blur-Ergebnisse in VoxelChunks umwandeln
|
||||||
@@ -934,9 +1132,26 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
// NICHT ENTFERNEN! Wurde schon einmal versehentlich rückgängig gemacht.
|
// NICHT ENTFERNEN! Wurde schon einmal versehentlich rückgängig gemacht.
|
||||||
// Ohne diesen Block entsteht am Terrain-Übergang ein hässlicher Überhang.
|
// Ohne diesen Block entsteht am Terrain-Übergang ein hässlicher Überhang.
|
||||||
for (VoxelChunk blurred : blurredMap.values()) {
|
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 lz = 0; lz < blurN; lz++) {
|
||||||
for (int lx = 0; lx < blurN; lx++) {
|
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
|
// Untersten festen Voxel in dieser Spalte finden
|
||||||
int yBot = -1;
|
int yBot = -1;
|
||||||
for (int ly = 0; ly < blurN; ly++) {
|
for (int ly = 0; ly < blurN; ly++) {
|
||||||
@@ -944,6 +1159,17 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
}
|
}
|
||||||
if (yBot < 0 || yBot + 2 >= blurN) continue;
|
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.
|
// Steigung aus den zwei Voxeln darüber bestimmen.
|
||||||
// slope < 0: Dichte nimmt nach unten ab (typisch für eine Oberfläche).
|
// slope < 0: Dichte nimmt nach unten ab (typisch für eine Oberfläche).
|
||||||
float d1 = blurred.getDensity(lx, yBot + 1, lz);
|
float d1 = blurred.getDensity(lx, yBot + 1, lz);
|
||||||
@@ -979,9 +1205,26 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
baked++;
|
baked++;
|
||||||
input.bakeDone = 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.";
|
String msg = "Fertig: " + baked + " Chunk" + (baked != 1 ? "s" : "") + " gebacken.";
|
||||||
// bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt
|
// 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);
|
log.info("Voxel-Bake abgeschlossen – {}.", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,9 +1306,11 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}",
|
log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}",
|
||||||
cx, cy, cz, e.getMessage());
|
cx, cy, cz, e.getMessage());
|
||||||
chunk = new VoxelChunk(cx, cy, cz);
|
chunk = new VoxelChunk(cx, cy, cz);
|
||||||
|
fillBaseTerrainChunk(chunk);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chunk = new VoxelChunk(cx, cy, cz);
|
chunk = new VoxelChunk(cx, cy, cz);
|
||||||
|
fillBaseTerrainChunk(chunk);
|
||||||
}
|
}
|
||||||
chunks.put(key, chunk);
|
chunks.put(key, chunk);
|
||||||
return chunk;
|
return chunk;
|
||||||
@@ -1084,6 +1329,93 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
return node;
|
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 ───────────────────────────────────────────────
|
// ── Intern: Hintergrund-LOD ───────────────────────────────────────────────
|
||||||
|
|
||||||
private void scheduleLodRebuild() {
|
private void scheduleLodRebuild() {
|
||||||
@@ -1138,17 +1470,16 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
|
|
||||||
private void applyTextures(Material mat) {
|
private void applyTextures(Material mat) {
|
||||||
mat.setFloat("TexScale", 8f);
|
mat.setFloat("TexScale", 8f);
|
||||||
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot, input.voxelCeilSlot };
|
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot };
|
||||||
String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" };
|
String[] colSlots = { "TexFlat", "TexSteep" };
|
||||||
String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" };
|
String[] normSlots = { "NormalMapFlat", "NormalMapSteep" };
|
||||||
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep","DisplacementMapCeil" };
|
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep" };
|
||||||
int[][] fallbackRgb = {
|
int[][] fallbackRgb = {
|
||||||
{100, 130, 60},
|
{100, 130, 60},
|
||||||
{110, 100, 90},
|
{110, 100, 90},
|
||||||
{ 70, 55, 45},
|
|
||||||
};
|
};
|
||||||
boolean anyDisp = false;
|
boolean anyDisp = false;
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 2; i++) {
|
||||||
String texPath = slotTexPath(slotIdxs[i]);
|
String texPath = slotTexPath(slotIdxs[i]);
|
||||||
String normPath = slotNormPath(slotIdxs[i]);
|
String normPath = slotNormPath(slotIdxs[i]);
|
||||||
String dispPath = slotDispPath(slotIdxs[i]);
|
String dispPath = slotDispPath(slotIdxs[i]);
|
||||||
@@ -1256,11 +1587,27 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
float jmeX = mx * (float) input.viewportScaleX;
|
float jmeX = mx * (float) input.viewportScaleX;
|
||||||
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
|
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
|
||||||
Hit hit = raycastHit(jmeX, jmeY);
|
Hit hit = raycastHit(jmeX, jmeY);
|
||||||
Vector3f pos = hit != null ? hit.pos : null;
|
if (hit != null) {
|
||||||
if (pos != null) {
|
|
||||||
float r = (float) input.voxelTool.brushRadius.getValue();
|
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);
|
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);
|
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
|
||||||
} else {
|
} else {
|
||||||
brushIndicator.setCullHint(Spatial.CullHint.Always);
|
brushIndicator.setCullHint(Spatial.CullHint.Always);
|
||||||
@@ -1320,4 +1667,124 @@ public class VoxelEditorState extends BaseAppState {
|
|||||||
public static long chunkKey(int cx, int cy, int cz) {
|
public static long chunkKey(int cx, int cy, int cz) {
|
||||||
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
|
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;
|
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.
|
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten – für Felstürme, Plateaus.
|
||||||
* Modus 4 (Klippe): Kugel-Pinsel ohne Terrain-Cleanup.
|
* Modus 4 (Aushöhlen): Kugel-Entfernen.
|
||||||
* Modus 5 (Aushöhlen): Entfernt Voxel.
|
* 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:
|
* Texturierung erfolgt automatisch anhand der Flächennormale:
|
||||||
* Normal.y > 0.5 → TexFlat (flache Flächen)
|
* 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_SPIKE = 1;
|
||||||
public static final int MODE_PLATEAU = 2;
|
public static final int MODE_PLATEAU = 2;
|
||||||
public static final int MODE_SMOOTH = 3;
|
public static final int MODE_SMOOTH = 3;
|
||||||
public static final int MODE_ADD = 4;
|
public static final int MODE_REMOVE = 4;
|
||||||
public static final int MODE_REMOVE = 5;
|
public static final int MODE_RESET = 5;
|
||||||
|
|
||||||
public final ChoiceToolParameter mode = new ChoiceToolParameter(
|
public final ChoiceToolParameter mode = new ChoiceToolParameter(
|
||||||
"Modus",
|
"Modus",
|
||||||
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Klippe", "Aushöhlen"},
|
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen"},
|
||||||
MODE_ADD,
|
MODE_SINUS,
|
||||||
new String[]{
|
new String[]{
|
||||||
"img/editor/terraintool_sinus.png",
|
"img/editor/terraintool_sinus.png",
|
||||||
"img/editor/terraintool_spike.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 volatile boolean modeChanged = false;
|
||||||
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
|
public volatile boolean horizontal = false;
|
||||||
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
|
|
||||||
|
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;
|
public volatile boolean plateauTargetChanged = false;
|
||||||
|
|
||||||
@Override public String getName() { return "Voxel"; }
|
@Override public String getName() { return "Voxel"; }
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ public class CraftingTableEditorView extends BorderPane {
|
|||||||
case Smithy -> "#cc8833";
|
case Smithy -> "#cc8833";
|
||||||
case Goldsmiths -> "#ddbb22";
|
case Goldsmiths -> "#ddbb22";
|
||||||
case Workshop -> "#4488cc";
|
case Workshop -> "#4488cc";
|
||||||
|
case Fireplace -> "#ee6633";
|
||||||
|
case Kitchen -> "#88aa44";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public class LocalizationEditorView extends BorderPane {
|
|||||||
table.refresh();
|
table.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
table.getColumns().addAll(keyCol, valCol);
|
table.getColumns().addAll(List.of(keyCol, valCol));
|
||||||
VBox.setVgrow(table, Priority.ALWAYS);
|
VBox.setVgrow(table, Priority.ALWAYS);
|
||||||
|
|
||||||
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");
|
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");
|
||||||
|
|||||||
@@ -458,6 +458,8 @@ public class RecipeEditorView extends BorderPane {
|
|||||||
case Smithy -> "#cc8833";
|
case Smithy -> "#cc8833";
|
||||||
case Goldsmiths -> "#ddbb22";
|
case Goldsmiths -> "#ddbb22";
|
||||||
case Workshop -> "#4488cc";
|
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_QUEST = "Quest starten";
|
||||||
private static final String TYPE_NPC = "NPC-Status ändern";
|
private static final String TYPE_NPC = "NPC-Status ändern";
|
||||||
private static final String TYPE_FRACTION = "Fraktions-Status ändern";
|
private static final String TYPE_FRACTION = "Fraktions-Status ändern";
|
||||||
|
private static final String TYPE_ROUTINE = "Routine ändern";
|
||||||
|
|
||||||
// Gemeinsam
|
// Gemeinsam
|
||||||
private final ComboBox<String> typeCombo = new ComboBox<>();
|
private final ComboBox<String> typeCombo = new ComboBox<>();
|
||||||
@@ -45,6 +46,10 @@ public class TriggerDialog extends Dialog<Trigger> {
|
|||||||
private TextField fractionIdField;
|
private TextField fractionIdField;
|
||||||
private ComboBox<Status> fractionStatusCombo;
|
private ComboBox<Status> fractionStatusCombo;
|
||||||
|
|
||||||
|
// Routine ändern
|
||||||
|
private TextField routineNpcIdField;
|
||||||
|
private TextField routineNameField;
|
||||||
|
|
||||||
/** Öffnet den Dialog für einen neuen Trigger. */
|
/** Öffnet den Dialog für einen neuen Trigger. */
|
||||||
public TriggerDialog() {
|
public TriggerDialog() {
|
||||||
this(null);
|
this(null);
|
||||||
@@ -56,7 +61,7 @@ public class TriggerDialog extends Dialog<Trigger> {
|
|||||||
initModality(Modality.APPLICATION_MODAL);
|
initModality(Modality.APPLICATION_MODAL);
|
||||||
setResizable(true);
|
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.setMaxWidth(Double.MAX_VALUE);
|
||||||
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
|
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
|
||||||
|
|
||||||
@@ -100,6 +105,7 @@ public class TriggerDialog extends Dialog<Trigger> {
|
|||||||
case TYPE_QUEST -> buildQuestFields();
|
case TYPE_QUEST -> buildQuestFields();
|
||||||
case TYPE_NPC -> buildNpcFields();
|
case TYPE_NPC -> buildNpcFields();
|
||||||
case TYPE_FRACTION -> buildFractionFields();
|
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 ─────────────────────────────────────────────────────────
|
// ── Trigger bauen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Trigger buildTrigger() {
|
private Trigger buildTrigger() {
|
||||||
@@ -161,6 +177,12 @@ public class TriggerDialog extends Dialog<Trigger> {
|
|||||||
if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue());
|
if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue());
|
||||||
yield f;
|
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;
|
default -> null;
|
||||||
};
|
};
|
||||||
if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
|
if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
|
||||||
@@ -186,6 +208,12 @@ public class TriggerDialog extends Dialog<Trigger> {
|
|||||||
fractionIdField.setText(f.getFractionId().toString());
|
fractionIdField.setText(f.getFractionId().toString());
|
||||||
if (fractionStatusCombo != null && f.getTargetStatus() != null)
|
if (fractionStatusCombo != null && f.getTargetStatus() != null)
|
||||||
fractionStatusCombo.setValue(f.getTargetStatus());
|
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: "
|
return "Fraktion-Status: "
|
||||||
+ (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?")
|
+ (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?")
|
||||||
+ " → " + statusName(f.getTargetStatus()) + chapter;
|
+ " → " + statusName(f.getTargetStatus()) + chapter;
|
||||||
|
if (t instanceof ChangeRoutineTrigger r)
|
||||||
|
return "Routine ändern: " + nullSafe(r.getNpcId())
|
||||||
|
+ " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter;
|
||||||
return t.getClass().getSimpleName() + chapter;
|
return t.getClass().getSimpleName() + chapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
|
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
|
||||||
<!-- TangentBinormalGenerator warnt bei UV-Nähten und harten Kanten – erwartet, kein Fehler -->
|
<!-- TangentBinormalGenerator warnt bei UV-Nähten und harten Kanten – erwartet, kein Fehler -->
|
||||||
<logger name="com.jme3.util.TangentBinormalGenerator" level="ERROR"/>
|
<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">
|
<root level="INFO">
|
||||||
<appender-ref ref="CONSOLE"/>
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
|||||||
@@ -24,11 +24,28 @@ public class AnimSet {
|
|||||||
|
|
||||||
private List<String> clips = new ArrayList<>();
|
private List<String> clips = new ArrayList<>();
|
||||||
private Map<String, String> actionMap = new LinkedHashMap<>();
|
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 List<String> getClips() { return clips; }
|
||||||
public void setClips(List<String> clips) { this.clips = clips; }
|
public void setClips(List<String> clips) { this.clips = clips; }
|
||||||
public Map<String, String> getActionMap() { return actionMap; }
|
public Map<String, String> getActionMap() { return actionMap; }
|
||||||
public void setActionMap(Map<String, String> actionMap) { this.actionMap = 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}. */
|
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
|
||||||
public void save(Path setDir, String setName) throws IOException {
|
public void save(Path setDir, String setName) throws IOException {
|
||||||
|
|||||||
@@ -1,33 +1,53 @@
|
|||||||
package de.blight.game.animation;
|
package de.blight.game.animation;
|
||||||
|
|
||||||
|
import de.blight.common.model.TextRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
|
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
|
||||||
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
|
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
|
||||||
*/
|
*/
|
||||||
public enum AnimationAction {
|
public enum AnimationAction {
|
||||||
DEFAULT,
|
DEFAULT,
|
||||||
IDLE,
|
IDLE,
|
||||||
WALK,
|
WALK,
|
||||||
RUN,
|
RUN,
|
||||||
SPRINT,
|
SPRINT,
|
||||||
JUMP,
|
JUMP,
|
||||||
RUNNING_JUMP,
|
RUNNING_JUMP,
|
||||||
DUCK,
|
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, via TextRegistry aufgelöst. */
|
||||||
/** Lesbare Bezeichnung für UI-Anzeige. */
|
|
||||||
public String displayName() {
|
public String displayName() {
|
||||||
|
String key = "animation.action." + name().toLowerCase();
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
case DEFAULT -> "Default";
|
case DEFAULT -> TextRegistry.resolve(null, key, "Default");
|
||||||
case IDLE -> "Idle";
|
case IDLE -> TextRegistry.resolve(null, key, "Idle");
|
||||||
case WALK -> "Walk";
|
case WALK -> TextRegistry.resolve(null, key, "Walk");
|
||||||
case RUN -> "Run";
|
case RUN -> TextRegistry.resolve(null, key, "Run");
|
||||||
case SPRINT -> "Sprint";
|
case SPRINT -> TextRegistry.resolve(null, key, "Sprint");
|
||||||
case JUMP -> "Jump";
|
case JUMP -> TextRegistry.resolve(null, key, "Jump");
|
||||||
case RUNNING_JUMP -> "Running Jump";
|
case RUNNING_JUMP -> TextRegistry.resolve(null, key, "Running Jump");
|
||||||
case DUCK -> "Duck";
|
case DUCK -> TextRegistry.resolve(null, key, "Duck");
|
||||||
case PICK_UP -> "Pick up";
|
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 {
|
} else {
|
||||||
target = src;
|
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) {
|
if (target == null) {
|
||||||
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
|
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
|
||||||
return false;
|
return false;
|
||||||
@@ -109,6 +117,9 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
|
|
||||||
ac.addAnimClip(target);
|
ac.addAnimClip(target);
|
||||||
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
|
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
|
||||||
|
if (clipName.equals("sit_down")) {
|
||||||
|
dumpClipTracks(target);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +215,9 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void loadClipFromFile(Path file) {
|
private void loadClipFromFile(Path file) {
|
||||||
String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
|
String fileName = file.getFileName().toString();
|
||||||
String assetKey = "animations/clips/" + clipName + ".j3o";
|
String clipName = fileName.replaceFirst("\\.j3o$", "");
|
||||||
|
String assetKey = "animations/clips/" + fileName;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Spatial loaded = assetManager.loadModel(assetKey);
|
Spatial loaded = assetManager.loadModel(assetKey);
|
||||||
@@ -218,7 +230,8 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
Armature armature = sc != null ? sc.getArmature() : null;
|
Armature armature = sc != null ? sc.getArmature() : null;
|
||||||
|
|
||||||
for (String name : ac.getAnimClipsNames()) {
|
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);
|
if (armature != null) armatures.put(name, armature);
|
||||||
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
|
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
|
||||||
}
|
}
|
||||||
@@ -244,4 +257,29 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
return null;
|
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.SkinningControl;
|
||||||
import com.jme3.anim.TransformTrack;
|
import com.jme3.anim.TransformTrack;
|
||||||
import com.jme3.math.Quaternion;
|
import com.jme3.math.Quaternion;
|
||||||
|
import com.jme3.math.Vector3f;
|
||||||
import com.jme3.scene.Node;
|
import com.jme3.scene.Node;
|
||||||
import com.jme3.scene.Spatial;
|
import com.jme3.scene.Spatial;
|
||||||
import com.jme3.scene.control.Control;
|
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,
|
// Mixamo-Clips, deren Knochen in Blender nur umbenannt (nicht retargeted) wurden,
|
||||||
// haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel.
|
// haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel.
|
||||||
if (isSameRig(nameMap, sourceArmature, targetArmature)) {
|
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);
|
return redirectTracks(sourceClip, targetArmature);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ public final class RetargetingSystem {
|
|||||||
float[] msA = ms != null ? ms[0].toAngles(null) : new float[3];
|
float[] msA = ms != null ? ms[0].toAngles(null) : new float[3];
|
||||||
float[] sbsA = srcBindMS.get(srcJ).toAngles(null);
|
float[] sbsA = srcBindMS.get(srcJ).toAngles(null);
|
||||||
float[] dbsA = dstBindMS.get(dstJ).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,
|
sourceClip.getName(), e.getKey(), dstName,
|
||||||
String.format("%.1f", Math.toDegrees(loc[0])),
|
String.format("%.1f", Math.toDegrees(loc[0])),
|
||||||
String.format("%.1f", Math.toDegrees(loc[1])),
|
String.format("%.1f", Math.toDegrees(loc[1])),
|
||||||
@@ -180,7 +181,7 @@ public final class RetargetingSystem {
|
|||||||
Quaternion bind = dstBindMS.get(d);
|
Quaternion bind = dstBindMS.get(d);
|
||||||
float[] a = ams.toAngles(null);
|
float[] a = ams.toAngles(null);
|
||||||
float[] b = bind != null ? bind.toAngles(null) : new float[3];
|
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(),
|
sourceClip.getName(), d.getName(),
|
||||||
String.format("%.1f", Math.toDegrees(a[0])),
|
String.format("%.1f", Math.toDegrees(a[0])),
|
||||||
String.format("%.1f", Math.toDegrees(a[1])),
|
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];
|
float[] cb = cbind != null ? cbind.toAngles(null) : new float[3];
|
||||||
Quaternion cl = ams.inverse().mult(cms);
|
Quaternion cl = ams.inverse().mult(cms);
|
||||||
float[] cl_ = cl.toAngles(null);
|
float[] cl_ = cl.toAngles(null);
|
||||||
log.warn("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
|
log.trace("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
|
||||||
sourceClip.getName(), child.getName(),
|
sourceClip.getName(), child.getName(),
|
||||||
String.format("%.1f", Math.toDegrees(ca[0])),
|
String.format("%.1f", Math.toDegrees(ca[0])),
|
||||||
String.format("%.1f", Math.toDegrees(ca[1])),
|
String.format("%.1f", Math.toDegrees(ca[1])),
|
||||||
@@ -221,7 +222,7 @@ public final class RetargetingSystem {
|
|||||||
Quaternion local0 = pms.inverse().mult(ams);
|
Quaternion local0 = pms.inverse().mult(ams);
|
||||||
float[] a = ams.toAngles(null);
|
float[] a = ams.toAngles(null);
|
||||||
float[] l = local0.toAngles(null);
|
float[] l = local0.toAngles(null);
|
||||||
log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
|
log.trace("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
|
||||||
sourceClip.getName(), dName,
|
sourceClip.getName(), dName,
|
||||||
String.format("%.1f", Math.toDegrees(a[0])),
|
String.format("%.1f", Math.toDegrees(a[0])),
|
||||||
String.format("%.1f", Math.toDegrees(a[1])),
|
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());
|
log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName());
|
||||||
return null;
|
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<>();
|
List<AnimTrack<?>> newTracks = new ArrayList<>();
|
||||||
for (var entry : dstLocalArrays.entrySet())
|
for (var entry : dstLocalArrays.entrySet()) {
|
||||||
newTracks.add(new TransformTrack(entry.getKey(), times, null, entry.getValue(), null));
|
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());
|
AnimClip result = new AnimClip(sourceClip.getName());
|
||||||
result.setTracks(newTracks.toArray(new AnimTrack[0]));
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +426,7 @@ public final class RetargetingSystem {
|
|||||||
if (newTracks.isEmpty()) return null;
|
if (newTracks.isEmpty()) return null;
|
||||||
AnimClip result = new AnimClip(sourceClip.getName());
|
AnimClip result = new AnimClip(sourceClip.getName());
|
||||||
result.setTracks(newTracks.toArray(new AnimTrack[0]));
|
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;
|
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