diff --git a/assets/imported/animations/get_up_sitting.glb b/assets/imported/animations/get_up_sitting.glb new file mode 100644 index 0000000..938f52f Binary files /dev/null and b/assets/imported/animations/get_up_sitting.glb differ diff --git a/blight-assets/src/main/resources/animations/idle.glb b/assets/imported/animations/idle.glb similarity index 100% rename from blight-assets/src/main/resources/animations/idle.glb rename to assets/imported/animations/idle.glb diff --git a/blight-assets/src/main/resources/animations/idle_jump.glb b/assets/imported/animations/idle_jump.glb similarity index 100% rename from blight-assets/src/main/resources/animations/idle_jump.glb rename to assets/imported/animations/idle_jump.glb diff --git a/blight-assets/src/main/resources/animations/pickup.glb b/assets/imported/animations/pickup.glb similarity index 100% rename from blight-assets/src/main/resources/animations/pickup.glb rename to assets/imported/animations/pickup.glb diff --git a/blight-assets/src/main/resources/animations/running.glb b/assets/imported/animations/running.glb similarity index 100% rename from blight-assets/src/main/resources/animations/running.glb rename to assets/imported/animations/running.glb diff --git a/blight-assets/src/main/resources/animations/running_jump.glb b/assets/imported/animations/running_jump.glb similarity index 100% rename from blight-assets/src/main/resources/animations/running_jump.glb rename to assets/imported/animations/running_jump.glb diff --git a/assets/imported/animations/sit_down.glb b/assets/imported/animations/sit_down.glb new file mode 100644 index 0000000..f5a92bd Binary files /dev/null and b/assets/imported/animations/sit_down.glb differ diff --git a/assets/imported/animations/sitting.glb b/assets/imported/animations/sitting.glb new file mode 100644 index 0000000..527905a Binary files /dev/null and b/assets/imported/animations/sitting.glb differ diff --git a/assets/imported/animations/sitting_floor.glb b/assets/imported/animations/sitting_floor.glb new file mode 100644 index 0000000..f408997 Binary files /dev/null and b/assets/imported/animations/sitting_floor.glb differ diff --git a/blight-assets/src/main/resources/animations/sprinting.glb b/assets/imported/animations/sprinting.glb similarity index 100% rename from blight-assets/src/main/resources/animations/sprinting.glb rename to assets/imported/animations/sprinting.glb diff --git a/blight-assets/src/main/resources/animations/stand_up.glb b/assets/imported/animations/stand_up.glb similarity index 100% rename from blight-assets/src/main/resources/animations/stand_up.glb rename to assets/imported/animations/stand_up.glb diff --git a/assets/imported/animations/standup.glb b/assets/imported/animations/standup.glb new file mode 100644 index 0000000..a8f4b47 Binary files /dev/null and b/assets/imported/animations/standup.glb differ diff --git a/blight-assets/src/main/resources/animations/tpose.glb b/assets/imported/animations/tpose.glb similarity index 100% rename from blight-assets/src/main/resources/animations/tpose.glb rename to assets/imported/animations/tpose.glb diff --git a/blight-assets/src/main/resources/animations/walking.glb b/assets/imported/animations/walking.glb similarity index 100% rename from blight-assets/src/main/resources/animations/walking.glb rename to assets/imported/animations/walking.glb diff --git a/blight-assets/src/main/resources/.thumbnails/Models/imported/Höhlenkristall1.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/imported/Höhlenkristall1.j3o.thumb.png new file mode 100644 index 0000000..4c4da7e Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/imported/Höhlenkristall1.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/imported/bank1.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/imported/bank1.j3o.thumb.png new file mode 100644 index 0000000..89fe00a Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/imported/bank1.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/imported/crystal+cluster+3d+model.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/imported/crystal+cluster+3d+model.j3o.thumb.png new file mode 100644 index 0000000..396a871 Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/imported/crystal+cluster+3d+model.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/imported/rustic+wooden+bench+3d+model.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/imported/rustic+wooden+bench+3d+model.j3o.thumb.png new file mode 100644 index 0000000..847fae1 Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/imported/rustic+wooden+bench+3d+model.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/MatDefs/Voxel.j3md b/blight-assets/src/main/resources/MatDefs/Voxel.j3md index aa3e395..c4dcb99 100644 --- a/blight-assets/src/main/resources/MatDefs/Voxel.j3md +++ b/blight-assets/src/main/resources/MatDefs/Voxel.j3md @@ -3,13 +3,10 @@ MaterialDef Voxel { MaterialParameters { Texture2D TexFlat Texture2D TexSteep - Texture2D TexCeil Texture2D NormalMapFlat Texture2D NormalMapSteep - Texture2D NormalMapCeil Texture2D DisplacementMapFlat Texture2D DisplacementMapSteep - Texture2D DisplacementMapCeil Float TexScale : 8.0 Float DisplacementScale : 0.3 Float TessellationLevel : 4.0 @@ -32,7 +29,6 @@ MaterialDef Voxel { Defines { HAS_NM_FLAT : NormalMapFlat HAS_NM_STEEP : NormalMapSteep - HAS_NM_CEIL : NormalMapCeil HAS_LIGHTDIR : LightDir HAS_SCENE_LIGHT : SunColor DEBUG_NO_LIGHT : DebugNoLight @@ -60,10 +56,8 @@ MaterialDef Voxel { Defines { HAS_NM_FLAT : NormalMapFlat HAS_NM_STEEP : NormalMapSteep - HAS_NM_CEIL : NormalMapCeil HAS_DISP_FLAT : DisplacementMapFlat HAS_DISP_STEEP : DisplacementMapSteep - HAS_DISP_CEIL : DisplacementMapCeil HAS_LIGHTDIR : LightDir HAS_SCENE_LIGHT : SunColor } diff --git a/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o b/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o new file mode 100644 index 0000000..f221a1c Binary files /dev/null and b/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o differ diff --git a/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o.meta b/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o.meta new file mode 100644 index 0000000..dd333f8 --- /dev/null +++ b/blight-assets/src/main/resources/Models/imported/Höhlenkristall1.j3o.meta @@ -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 diff --git a/blight-assets/src/main/resources/Models/imported/bank.j3o.meta b/blight-assets/src/main/resources/Models/imported/bank.j3o.meta new file mode 100644 index 0000000..9778d93 --- /dev/null +++ b/blight-assets/src/main/resources/Models/imported/bank.j3o.meta @@ -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 diff --git a/blight-assets/src/main/resources/Models/imported/bank1.j3o b/blight-assets/src/main/resources/Models/imported/bank1.j3o new file mode 100644 index 0000000..216a821 Binary files /dev/null and b/blight-assets/src/main/resources/Models/imported/bank1.j3o differ diff --git a/blight-assets/src/main/resources/Models/imported/bank1.j3o.meta b/blight-assets/src/main/resources/Models/imported/bank1.j3o.meta new file mode 100644 index 0000000..a075fec --- /dev/null +++ b/blight-assets/src/main/resources/Models/imported/bank1.j3o.meta @@ -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 diff --git a/blight-assets/src/main/resources/Models/imported/crystal+cluster+3d+model.j3o b/blight-assets/src/main/resources/Models/imported/crystal+cluster+3d+model.j3o new file mode 100644 index 0000000..d37a571 Binary files /dev/null and b/blight-assets/src/main/resources/Models/imported/crystal+cluster+3d+model.j3o differ diff --git a/blight-assets/src/main/resources/Shaders/Voxel.frag b/blight-assets/src/main/resources/Shaders/Voxel.frag index 891bd0c..8e04c4c 100644 --- a/blight-assets/src/main/resources/Shaders/Voxel.frag +++ b/blight-assets/src/main/resources/Shaders/Voxel.frag @@ -1,6 +1,5 @@ uniform sampler2D m_TexFlat; uniform sampler2D m_TexSteep; -uniform sampler2D m_TexCeil; uniform float m_TexScale; #ifdef HAS_NM_FLAT @@ -9,9 +8,6 @@ uniform sampler2D m_NormalMapFlat; #ifdef HAS_NM_STEEP uniform sampler2D m_NormalMapSteep; #endif -#ifdef HAS_NM_CEIL -uniform sampler2D m_NormalMapCeil; -#endif in vec3 vWorldPos; in vec3 vNormal; @@ -55,27 +51,24 @@ void main() { vec2 uvY = vWorldPos.xz / m_TexScale; vec2 uvZ = vWorldPos.xy / m_TexScale; - // Flach ab ~11° Gefälle (20% grade, normal.y≈0.98); Fels darunter. + // Flach ab ~11° Gefälle (20% grade, normal.y≈0.98); alles andere Fels. float flatBlend = smoothstep(0.94, 0.99, vNormal.y); - float ceilBlend = 1.0 - smoothstep(-0.6, -0.3, vNormal.y); - float steepBlend = max(0.0, 1.0 - flatBlend - ceilBlend); + float steepBlend = 1.0 - flatBlend; // Flat: reines XZ-UV wie das Terrain (uvY = worldPos.xz / texScale), kein Triplanar. - // Steep/Ceil: Triplanar bleibt, da es dort keine eindeutige Projektion gibt. + // Steep: Triplanar für alle nicht-flachen Flächen inkl. Decken und Tunnelwände. vec4 col = texture(m_TexFlat, uvY) * flatBlend - + triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend - + triplanar(m_TexCeil, uvX, uvY, uvZ, bw) * ceilBlend; + + triplanar(m_TexSteep, uvX, uvY, uvZ, bw) * steepBlend; // Geometrie-Normale für Beleuchtung, ggf. durch Normal-Map ersetzt. vec3 N = normalize(vNormal); -#if defined(HAS_NM_FLAT) || defined(HAS_NM_STEEP) || defined(HAS_NM_CEIL) +#if defined(HAS_NM_FLAT) || defined(HAS_NM_STEEP) vec3 pertN = vec3(0.0); float totalBlend = 0.0; #ifdef HAS_NM_FLAT if (flatBlend > 0.001) { vec3 nmFlat = texture(m_NormalMapFlat, uvY).rgb * 2.0 - 1.0; - // Y-Projektion RNM (identisch mit triplanarNormal Y-Achse bei bw.y=1) nmFlat = vec3(nmFlat.xy + N.xz, abs(nmFlat.z) * N.y); pertN += normalize(nmFlat.xzy) * flatBlend; totalBlend += flatBlend; @@ -86,12 +79,6 @@ void main() { pertN += triplanarNormal(m_NormalMapSteep, uvX, uvY, uvZ, bw, N) * steepBlend; totalBlend += steepBlend; } -#endif -#ifdef HAS_NM_CEIL - if (ceilBlend > 0.001) { - pertN += triplanarNormal(m_NormalMapCeil, uvX, uvY, uvZ, bw, N) * ceilBlend; - totalBlend += ceilBlend; - } #endif if (totalBlend > 0.001) { N = normalize(pertN / totalBlend); diff --git a/blight-assets/src/main/resources/animations/clips/get_up_sitting.j3o b/blight-assets/src/main/resources/animations/clips/get_up_sitting.j3o new file mode 100644 index 0000000..d74e132 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/get_up_sitting.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/idle.j3o b/blight-assets/src/main/resources/animations/clips/idle.j3o index ebb57a3..a3c91f9 100644 Binary files a/blight-assets/src/main/resources/animations/clips/idle.j3o and b/blight-assets/src/main/resources/animations/clips/idle.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/idle_jump.j3o b/blight-assets/src/main/resources/animations/clips/idle_jump.j3o index 3715aa6..7a83452 100644 Binary files a/blight-assets/src/main/resources/animations/clips/idle_jump.j3o and b/blight-assets/src/main/resources/animations/clips/idle_jump.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/pickup.j3o b/blight-assets/src/main/resources/animations/clips/pickup.j3o new file mode 100644 index 0000000..5d3d03d Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/pickup.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/running.j3o b/blight-assets/src/main/resources/animations/clips/running.j3o index fc8c239..fd4e64b 100644 Binary files a/blight-assets/src/main/resources/animations/clips/running.j3o and b/blight-assets/src/main/resources/animations/clips/running.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/running_jump.j3o b/blight-assets/src/main/resources/animations/clips/running_jump.j3o index 1577afe..0c73e01 100644 Binary files a/blight-assets/src/main/resources/animations/clips/running_jump.j3o and b/blight-assets/src/main/resources/animations/clips/running_jump.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sit_down.j3o b/blight-assets/src/main/resources/animations/clips/sit_down.j3o new file mode 100644 index 0000000..8753809 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sit_down.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sitting.j3o b/blight-assets/src/main/resources/animations/clips/sitting.j3o new file mode 100644 index 0000000..460dd45 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sitting.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sitting_floor.j3o b/blight-assets/src/main/resources/animations/clips/sitting_floor.j3o new file mode 100644 index 0000000..c0360e9 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sitting_floor.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sprint.j3o b/blight-assets/src/main/resources/animations/clips/sprint.j3o deleted file mode 100644 index 4e005a4..0000000 Binary files a/blight-assets/src/main/resources/animations/clips/sprint.j3o and /dev/null differ diff --git a/blight-assets/src/main/resources/animations/clips/sprinting.j3o b/blight-assets/src/main/resources/animations/clips/sprinting.j3o new file mode 100644 index 0000000..a7204e4 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sprinting.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/stand_up.j3o b/blight-assets/src/main/resources/animations/clips/stand_up.j3o index 6d9ebbb..12e168a 100644 Binary files a/blight-assets/src/main/resources/animations/clips/stand_up.j3o and b/blight-assets/src/main/resources/animations/clips/stand_up.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/standup.j3o b/blight-assets/src/main/resources/animations/clips/standup.j3o new file mode 100644 index 0000000..305a0ae Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/standup.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/tpose.j3o b/blight-assets/src/main/resources/animations/clips/tpose.j3o index 5abd9e5..05d5a81 100644 Binary files a/blight-assets/src/main/resources/animations/clips/tpose.j3o and b/blight-assets/src/main/resources/animations/clips/tpose.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/walking.j3o b/blight-assets/src/main/resources/animations/clips/walking.j3o index fef5865..dbae7d0 100644 Binary files a/blight-assets/src/main/resources/animations/clips/walking.j3o and b/blight-assets/src/main/resources/animations/clips/walking.j3o differ diff --git a/blight-assets/src/main/resources/animations/sets/human.animset.json b/blight-assets/src/main/resources/animations/sets/human.animset.json index b2e4b6d..e865669 100644 --- a/blight-assets/src/main/resources/animations/sets/human.animset.json +++ b/blight-assets/src/main/resources/animations/sets/human.animset.json @@ -1,23 +1,33 @@ { "clips": [ + "get_up_sitting", "idle", "idle_jump", + "pickup", "running", "running_jump", - "sprint", + "sit_down", + "sitting", + "sitting_floor", + "sprinting", "stand_up", "tpose", - "walking", - "pickup" + "walking" ], "actionMap": { "DEFAULT": "tpose", "IDLE": "idle", "WALK": "walking", "RUN": "running", - "SPRINT": "sprint", - "JUMP": "idle_jump", + "SPRINT": "sprinting", "RUNNING_JUMP": "running_jump", - "PICK_UP": "pickup" - } + "JUMP": "idle_jump", + "PICK_UP": "pickup", + "SIT_DOWN": "sit_down", + "SIT_UP": "stand_up", + "SITTING": "sitting" + }, + "previewModelPath": "Models/Chars/mainchar.j3o", + "sinkMap": {}, + "anchorBoneMap": {} } \ No newline at end of file diff --git a/blight-assets/src/main/resources/animations/sets/mainchar.animset.json b/blight-assets/src/main/resources/animations/sets/mainchar.animset.json deleted file mode 100644 index 6cf451d..0000000 --- a/blight-assets/src/main/resources/animations/sets/mainchar.animset.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/blight-common/src/main/java/de/blight/common/ModelMeta.java b/blight-common/src/main/java/de/blight/common/ModelMeta.java index fe7f82b..69949b9 100644 --- a/blight-common/src/main/java/de/blight/common/ModelMeta.java +++ b/blight-common/src/main/java/de/blight/common/ModelMeta.java @@ -27,7 +27,12 @@ public record ModelMeta( float lod2Distance, float cullDistance, List attachedLights, - List attachedEmitters + List attachedEmitters, + de.blight.common.model.InteractableType interactableType, + float interactableOffsetX, + float interactableOffsetY, + float interactableOffsetZ, + float interactableRotY ) { /** Lichtquelle relativ zum Modell-Ursprung. */ public record AttachedLight( @@ -45,6 +50,8 @@ public record ModelMeta( return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f, false, true, true, 1f, 1f, "", "", 30f, 80f, 120f, - List.of(), List.of()); + List.of(), List.of(), + de.blight.common.model.InteractableType.NONE, + 0f, 0.5f, 0f, 0f); } } diff --git a/blight-common/src/main/java/de/blight/common/ModelMetaIO.java b/blight-common/src/main/java/de/blight/common/ModelMetaIO.java index 2dd0328..9e1cef8 100644 --- a/blight-common/src/main/java/de/blight/common/ModelMetaIO.java +++ b/blight-common/src/main/java/de/blight/common/ModelMetaIO.java @@ -34,6 +34,11 @@ public final class ModelMetaIO { p.setProperty("lod1Distance", String.valueOf(m.lod1Distance())); p.setProperty("lod2Distance", String.valueOf(m.lod2Distance())); p.setProperty("cullDistance", String.valueOf(m.cullDistance())); + p.setProperty("interactableType", m.interactableType().name()); + p.setProperty("interactableOffsetX", String.valueOf(m.interactableOffsetX())); + p.setProperty("interactableOffsetY", String.valueOf(m.interactableOffsetY())); + p.setProperty("interactableOffsetZ", String.valueOf(m.interactableOffsetZ())); + p.setProperty("interactableRotY", String.valueOf(m.interactableRotY())); // Anhänge: Lichter List lights = m.attachedLights(); @@ -127,7 +132,13 @@ public final class ModelMetaIO { parseFloat(p, "lod2Distance", 80f), parseFloat(p, "cullDistance", 120f), Collections.unmodifiableList(lights), - Collections.unmodifiableList(emitters) + Collections.unmodifiableList(emitters), + de.blight.common.model.InteractableType.fromString( + p.getProperty("interactableType", "NONE")), + parseFloat(p, "interactableOffsetX", 0f), + parseFloat(p, "interactableOffsetY", 0.5f), + parseFloat(p, "interactableOffsetZ", 0f), + parseFloat(p, "interactableRotY", 0f) ); } diff --git a/blight-common/src/main/java/de/blight/common/PlacedModel.java b/blight-common/src/main/java/de/blight/common/PlacedModel.java index 01a476a..2d03f01 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedModel.java +++ b/blight-common/src/main/java/de/blight/common/PlacedModel.java @@ -18,5 +18,9 @@ public record PlacedModel( String lod2Path, float lod1Distance, // ab dieser Distanz LOD1 anzeigen float lod2Distance, // ab dieser Distanz LOD2 anzeigen - float cullDistance // ab dieser Distanz ausblenden + float cullDistance, // ab dieser Distanz ausblenden + /** "CRAFTING_TABLE" / "BED" / "" für kein Interactable. */ + String interactableType, + /** ID des verknüpften Interactables (CraftingTableType-Name oder Bett-UUID); "" wenn nicht gesetzt. */ + String interactableId ) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedModelIO.java b/blight-common/src/main/java/de/blight/common/PlacedModelIO.java index 0ef3325..3767bca 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedModelIO.java +++ b/blight-common/src/main/java/de/blight/common/PlacedModelIO.java @@ -8,11 +8,11 @@ import java.util.*; * Liest und schreibt platzierte Modelle als tab-separierte Textdatei * ({@code blight_objects.blo}) neben der Kartendatei. * - * Spalten (seit v4): + * Spalten (seit v5): * modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow - * lod1Path lod2Path lod1Distance lod2Distance cullDistance + * lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId * - * Alte Dateien mit 6 Spalten (v1/v2/v3) werden gelesen; fehlende Felder erhalten Standardwerte. + * Alte Dateien mit 6 Spalten (v1–v4) werden gelesen; fehlende Felder erhalten Standardwerte. */ public final class PlacedModelIO { @@ -26,11 +26,11 @@ public final class PlacedModelIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance"); + w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance\tinteractableType\tinteractableId"); w.newLine(); for (PlacedModel m : models) { w.write(String.format(Locale.ROOT, - "%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f%n", + "%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f\t%s\t%s%n", m.modelPath(), m.x(), m.y(), m.z(), m.rotY(), m.scale(), @@ -40,7 +40,8 @@ public final class PlacedModelIO { nvl(m.meshFile()), nvl(m.animClip()), m.castShadow(), m.receiveShadow(), nvl(m.lod1Path()), nvl(m.lod2Path()), - m.lod1Distance(), m.lod2Distance(), m.cullDistance())); + m.lod1Distance(), m.lod2Distance(), m.cullDistance(), + nvl(m.interactableType()), nvl(m.interactableId()))); } } } @@ -75,12 +76,15 @@ public final class PlacedModelIO { String lod2Path = f.length > 17 ? f[17] : ""; float lod1Distance = f.length > 18 ? parseFloat(f[18], 30f) : 30f; float lod2Distance = f.length > 19 ? parseFloat(f[19], 80f) : 80f; - float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f; + float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f; + String interactableType = f.length > 21 ? f[21] : ""; + String interactableId = f.length > 22 ? f[22] : ""; list.add(new PlacedModel(modelPath, x, y, z, rotY, rotX, rotZ, scale, solid, texPath, nmPath, matPath, meshFile, animClip, castShadow, receiveShadow, - lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance)); + lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance, + interactableType, interactableId)); } catch (NumberFormatException ignored) {} } return list; diff --git a/blight-common/src/main/java/de/blight/common/PlacedStone.java b/blight-common/src/main/java/de/blight/common/PlacedStone.java new file mode 100644 index 0000000..d7237fd --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedStone.java @@ -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 +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedStoneIO.java b/blight-common/src/main/java/de/blight/common/PlacedStoneIO.java new file mode 100644 index 0000000..0033f6b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedStoneIO.java @@ -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 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 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 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); + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/SculptedMesh.java b/blight-common/src/main/java/de/blight/common/SculptedMesh.java new file mode 100644 index 0000000..568b21b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/SculptedMesh.java @@ -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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/SculptedMeshIO.java b/blight-common/src/main/java/de/blight/common/SculptedMeshIO.java new file mode 100644 index 0000000..6caf064 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/SculptedMeshIO.java @@ -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 findAllBakedChunks() { + List result = new ArrayList<>(); + Path dir = ChunkTerrainIO.chunksDir(); + if (!Files.isDirectory(dir)) return result; + try (DirectoryStream 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/VoxelChunk.java b/blight-common/src/main/java/de/blight/common/VoxelChunk.java index eb48dea..ee48988 100644 --- a/blight-common/src/main/java/de/blight/common/VoxelChunk.java +++ b/blight-common/src/main/java/de/blight/common/VoxelChunk.java @@ -67,6 +67,26 @@ public final class VoxelChunk { public boolean isEmpty() { return density == null; } + public void clear() { density = null; material = null; dirty = true; } + + /** + * Gibt die Y-Ausdehnung (in Voxel) der soliden Voxel zurück. + * 0 = keine soliden Voxel; 1 = alle soliden Voxel auf einer Y-Ebene (flache Schicht). + * Chunks mit Span < 2 erzeugen nur eine flache Mesh-Fläche und sollen nicht gerendert werden. + */ + public int solidYSpan() { + if (density == null) return 0; + int minY = SIZE, maxY = -1; + for (int i = 0; i < density.length; i++) { + if (density[i] > 0) { + int y = i / (SIZE * SIZE); + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + return maxY < 0 ? 0 : maxY - minY + 1; + } + // ── Kugelförmiger Pinsel ────────────────────────────────────────────────── /** @@ -99,6 +119,92 @@ public final class VoxelChunk { } } + /** + * Verringert die Dichte aller soliden Voxel innerhalb des Radius graduell um {@code step}. + * Voxel mit Dichte <= 0 werden nicht berührt. Gedacht für das Tunnel-Werkzeug. + */ + public void reduceDensity(float localX, float localY, float localZ, float radius, int step) { + if (density == null) return; + int x0 = Math.max(0, (int)(localX - radius)); + int x1 = Math.min(SIZE-1, (int)Math.ceil(localX + radius)); + int y0 = Math.max(0, (int)(localY - radius)); + int y1 = Math.min(SIZE-1, (int)Math.ceil(localY + radius)); + int z0 = Math.max(0, (int)(localZ - radius)); + int z1 = Math.min(SIZE-1, (int)Math.ceil(localZ + radius)); + float r2 = radius * radius; + boolean changed = false; + for (int y = y0; y <= y1; y++) { + float dy = y - localY; + for (int z = z0; z <= z1; z++) { + float dz = z - localZ; + for (int x = x0; x <= x1; x++) { + float dx = x - localX; + if (dx*dx + dy*dy + dz*dz > r2) continue; + int i = idx(x, y, z); + int d = density[i]; + if (d <= 0) continue; + density[i] = (byte) Math.max(Byte.MIN_VALUE, d - step); + changed = true; + } + } + } + if (changed) dirty = true; + } + + /** + * Entfernt vollständig isolierte solide Voxel (alle 6 Flächennachbarn == Luft) + * in der gegebenen Region + 1 Voxel Rand. Randvoxel des Chunks (Index 0/SIZE-1) + * werden übersprungen um Cross-Chunk-Artefakte zu vermeiden. + */ + public void pruneIsolated(float localX, float localY, float localZ, float radius) { + if (density == null) return; + int x0 = Math.max(1, (int)(localX - radius) - 1); + int x1 = Math.min(SIZE-2, (int)Math.ceil(localX + radius) + 1); + int y0 = Math.max(1, (int)(localY - radius) - 1); + int y1 = Math.min(SIZE-2, (int)Math.ceil(localY + radius) + 1); + int z0 = Math.max(1, (int)(localZ - radius) - 1); + int z1 = Math.min(SIZE-2, (int)Math.ceil(localZ + radius) + 1); + boolean changed = false; + for (int y = y0; y <= y1; y++) { + for (int z = z0; z <= z1; z++) { + for (int x = x0; x <= x1; x++) { + if (density[idx(x, y, z)] <= 0) continue; + if (density[idx(x+1,y,z)] <= 0 && density[idx(x-1,y,z)] <= 0 && + density[idx(x,y+1,z)] <= 0 && density[idx(x,y-1,z)] <= 0 && + density[idx(x,y,z+1)] <= 0 && density[idx(x,y,z-1)] <= 0) { + density[idx(x, y, z)] = Byte.MIN_VALUE; + changed = true; + } + } + } + } + if (changed) dirty = true; + } + + /** Gibt eine Kopie des Dichte-Arrays zurück, oder null wenn der Chunk leer ist. */ + public byte[] getDensityCopy() { + return density != null ? density.clone() : null; + } + + /** Setzt das Dichte-Array direkt (für Undo/Redo). */ + public void setDensityArray(byte[] d) { + this.density = d; + dirty = true; + } + + /** + * Füllt eine dünne horizontale Platte (ly0..ly1) als Solid (127), alles andere Luft. + * Setzt dirty nicht. + */ + public void fillThinSlab(int ly0, int ly1) { + if (density == null) density = new byte[SIZE * SIZE * SIZE]; + Arrays.fill(density, Byte.MIN_VALUE); + for (int y = ly0; y <= ly1; y++) + for (int z = 0; z < SIZE; z++) + for (int x = 0; x < SIZE; x++) + density[idx(x, y, z)] = (byte) 127; + } + // ── Serialisierung ──────────────────────────────────────────────────────── public byte[] serialize() throws IOException { diff --git a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java index ab82477..02fadcf 100644 --- a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java +++ b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java @@ -25,6 +25,10 @@ public final class VoxelChunkIO { return Files.exists(getPath(cx, cy, cz)); } + public static void delete(int cx, int cy, int cz) throws IOException { + Files.deleteIfExists(getPath(cx, cy, cz)); + } + public static void save(VoxelChunk chunk) throws IOException { Path p = getPath(chunk.cx, chunk.cy, chunk.cz); Path tmp = p.resolveSibling(p.getFileName() + ".tmp"); @@ -54,6 +58,22 @@ public final class VoxelChunkIO { return Files.exists(getBakedPath(cx, cy, cz, 0)); } + /** + * true wenn das gebackene LOD0-Mesh existiert UND nicht älter als die + * .blvc-Quelldatei ist. Verhindert, dass veraltete Bakes nach einer + * Editor-Bearbeitung weiter genutzt werden. + */ + public static boolean bakedIsFresh(int cx, int cy, int cz) { + try { + Path baked = getBakedPath(cx, cy, cz, 0); + if (!Files.exists(baked)) return false; + return Files.getLastModifiedTime(baked).compareTo( + Files.getLastModifiedTime(getPath(cx, cy, cz))) >= 0; + } catch (IOException e) { + return false; + } + } + /** * Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis. * Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert. diff --git a/blight-common/src/main/java/de/blight/common/model/Bed.java b/blight-common/src/main/java/de/blight/common/model/Bed.java new file mode 100644 index 0000000..0e6e2d9 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Bed.java @@ -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"); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/BedIO.java b/blight-common/src/main/java/de/blight/common/model/BedIO.java new file mode 100644 index 0000000..49e3b5e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/BedIO.java @@ -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/.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 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/Bench.java b/blight-common/src/main/java/de/blight/common/model/Bench.java new file mode 100644 index 0000000..38150e3 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Bench.java @@ -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"); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/BenchIO.java b/blight-common/src/main/java/de/blight/common/model/BenchIO.java new file mode 100644 index 0000000..1c74fd1 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/BenchIO.java @@ -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/.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 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/CraftingTable.java b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java index 08d3aac..0b94f1f 100644 --- a/blight-common/src/main/java/de/blight/common/model/CraftingTable.java +++ b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java @@ -12,18 +12,25 @@ import lombok.Setter; */ @Getter @Setter -public class CraftingTable { +public class CraftingTable implements Interactable { private TextReference name; private ObjectReference object; private CraftingTableType type; - + + @Override + public String getDisplayText() { + return TextRegistry.resolve(name, type != null ? type.name() : "?"); + } + public enum CraftingTableType { AlchemyTable, EnchantmentTable, Smithy, Goldsmiths, - Workshop; + Workshop, + Fireplace, + Kitchen; } } diff --git a/blight-common/src/main/java/de/blight/common/model/InteractableType.java b/blight-common/src/main/java/de/blight/common/model/InteractableType.java new file mode 100644 index 0000000..5adca77 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/InteractableType.java @@ -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; } + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/NPC.java b/blight-common/src/main/java/de/blight/common/model/NPC.java index 633cba2..c1e9f82 100644 --- a/blight-common/src/main/java/de/blight/common/model/NPC.java +++ b/blight-common/src/main/java/de/blight/common/model/NPC.java @@ -1,5 +1,6 @@ package de.blight.common.model; +import java.util.ArrayList; import java.util.List; import org.slf4j.Logger; @@ -17,51 +18,88 @@ public class NPC extends GameCharacter { private Status status; private boolean trader; private Fraction fraction; - + private List items; - + private List currentOptions; - + + /** Tagesabläufe dieses NPCs. Die erste Routine gilt standardmäßig als aktiv. */ + private List 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 getAvailableOptions(MainCharacter character) { - return currentOptions.stream().filter(option -> + return currentOptions.stream().filter(option -> option.getRequiresChapter() < character.getChapter() && option.getRequiresStatus().requirementFulfilled(status) && (option.getRequiresQuestOpen() == null || character.getOpenQuests().contains(option.getRequiresQuestOpen())) && (option.getRequiredItem() == null || character.getInventar().hasItem(option.getRequiredItem())) && (option.getRequiresQuestComplete() == null || character.getCompletedQuests().contains(option.getRequiresQuestComplete()))).toList(); } - + public boolean chooseDialogOption(DialogOption option, MainCharacter character) { if (!currentOptions.contains(option)) { - LOG.warn("Dialog Option was choosen but is not available"); + LOG.warn("Dialog Option was choosen but is not available"); return false; } if (option.getRequiredItem() != null && !character.getInventar().hasItem(option.getRequiredItem())) { - LOG.warn("Dialog Option was choosen but required item is not in Inventar"); - return false; + LOG.warn("Dialog Option was choosen but required item is not in Inventar"); + return false; } if (option.getRequiresQuestOpen() != null && !character.getOpenQuests().contains(option.getRequiresQuestOpen())) { - LOG.warn("Dialog Option was choosen but required quest is not open"); + LOG.warn("Dialog Option was choosen but required quest is not open"); return false; } if (option.getRequiredStatus().requirementFulfilled(status)) { - LOG.warn("Dialog Option was choosen but required Status is not fulfilled"); + LOG.warn("Dialog Option was choosen but required Status is not fulfilled"); return false; } if (option.getRequiresQuestComplete() != null && !character.getCompletedQuests().contains(option.getRequiresQuestComplete())) { - LOG.warn("Dialog Option was choosen but required Quest is not complete"); + LOG.warn("Dialog Option was choosen but required Quest is not complete"); return false; } currentOptions.remove(option); currentOptions.removeAll(option.getDisablesOptions()); currentOptions.addAll(option.getNextOptions()); - + character.handleDialogOption(option); - + if (option.isEnablesTrade()) { this.trader = true; } - + return true; } } diff --git a/blight-common/src/main/java/de/blight/common/model/NpcRoutine.java b/blight-common/src/main/java/de/blight/common/model/NpcRoutine.java new file mode 100644 index 0000000..aebb10c --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/NpcRoutine.java @@ -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 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 getBlocks() { + if (blocks == null) blocks = new ArrayList<>(); + return blocks; + } + public void setBlocks(List 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/RoutineActivity.java b/blight-common/src/main/java/de/blight/common/model/RoutineActivity.java new file mode 100644 index 0000000..762b7cd --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/RoutineActivity.java @@ -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 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 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 getWaypoints() { return waypoints; } + public void setWaypoints(List 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 "?"; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/RoutineBlock.java b/blight-common/src/main/java/de/blight/common/model/RoutineBlock.java new file mode 100644 index 0000000..2aca461 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/RoutineBlock.java @@ -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); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/TextRegistry.java b/blight-common/src/main/java/de/blight/common/model/TextRegistry.java index 7eb6b85..4b22731 100644 --- a/blight-common/src/main/java/de/blight/common/model/TextRegistry.java +++ b/blight-common/src/main/java/de/blight/common/model/TextRegistry.java @@ -32,6 +32,18 @@ public final class TextRegistry { return entries.getOrDefault(ref.id(), fallback); } + /** + * Löst zuerst {@code ref} auf; fehlt die Ref, wird {@code key} direkt nachgeschlagen; + * fehlt auch der Eintrag für {@code key}, wird {@code fallback} zurückgegeben. + */ + public static String resolve(TextReference ref, String key, String fallback) { + if (ref != null && ref.id() != null && entries.containsKey(ref.id())) + return entries.get(ref.id()); + if (key != null && entries.containsKey(key)) + return entries.get(key); + return fallback; + } + /** Direkter Zugriff für den Editor (alle Einträge). */ public static Map getAll() { return new HashMap<>(entries); diff --git a/blight-common/src/main/java/de/blight/common/model/WorldPoint.java b/blight-common/src/main/java/de/blight/common/model/WorldPoint.java new file mode 100644 index 0000000..8bd47a8 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/WorldPoint.java @@ -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); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/ChangeRoutineTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/ChangeRoutineTrigger.java new file mode 100644 index 0000000..72498ef --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/ChangeRoutineTrigger.java @@ -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. + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java index 9fa5bc7..61e495c 100644 --- a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java +++ b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java @@ -27,6 +27,7 @@ public final class TriggerIO { public static final String TYPE_QUEST_START = "QUEST_START"; public static final String TYPE_NPC_STATUS = "NPC_STATUS"; public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS"; + public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE"; private static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter()) @@ -78,6 +79,9 @@ public final class TriggerIO { obj.addProperty("fractionId", f.getFractionId().toString()); if (f.getTargetStatus() != null) obj.addProperty("targetStatus", f.getTargetStatus().name()); + } else if (src instanceof ChangeRoutineTrigger r) { + if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId()); + if (r.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName()); } return obj; } @@ -113,6 +117,12 @@ public final class TriggerIO { if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus"))); yield f; } + case TYPE_CHANGE_ROUTINE -> { + ChangeRoutineTrigger r = new ChangeRoutineTrigger(); + if (obj.has("npcId")) r.setNpcId(obj.get("npcId").getAsString()); + if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString()); + yield r; + } default -> null; }; @@ -125,6 +135,7 @@ public final class TriggerIO { if (t instanceof QuestStartTrigger) return TYPE_QUEST_START; if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS; if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS; + if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE; return "UNKNOWN"; } diff --git a/blight-common/src/main/java/de/blight/common/path/PathEdge.java b/blight-common/src/main/java/de/blight/common/path/PathEdge.java new file mode 100644 index 0000000..a30624d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/path/PathEdge.java @@ -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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/path/PathNetwork.java b/blight-common/src/main/java/de/blight/common/path/PathNetwork.java new file mode 100644 index 0000000..2dbcb8b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/path/PathNetwork.java @@ -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}). + * + *

Pathfinding-Modell: + *

    + *
  1. Nächsten Netzknoten zu {@code from} suchen → Off-Netz-Segment 1
  2. + *
  3. A* entlang der Kanten zum Netzknoten nächst {@code to} → Netz-Segment
  4. + *
  5. Off-Netz-Segment 2: letzter Netzknoten → {@code to}
  6. + *
+ * Die Hindernisvermeidung auf Segmenten 1 und 3 obliegt dem Bewegungssystem + * der Game-Engine (z. B. Steering-Behaviors + Raycasting). + */ +public class PathNetwork { + + private final List nodes = new ArrayList<>(); + private final List edges = new ArrayList<>(); + + // ── Nodes ────────────────────────────────────────────────────────────────── + + public List getNodes() { return nodes; } + public List 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 neighbors(String uuid) { + List 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}. + * + *

Rückgabe: geordnete Liste von Weltpunkten, die der NPC abläuft: + * [from, ...Netzknoten..., to]. + * + *

Wenn das Netz leer ist, wird [from, to] zurückgegeben (direkter Weg). + */ + public List findPath(WorldPoint from, WorldPoint to) { + List 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 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 offStart = List.of(from, startNode.getPosition()); + List offEnd = List.of(endNode.getPosition(), to); + + if (startNode == endNode) { + return new PathResult(offStart, List.of(startNode.getPosition()), offEnd); + } + + List networkPts = new ArrayList<>(); + for (PathNode n : astar(startNode, endNode)) + networkPts.add(n.getPosition()); + + return new PathResult(offStart, networkPts, offEnd); + } + + // ── A* ───────────────────────────────────────────────────────────────────── + + private List astar(PathNode start, PathNode goal) { + Map gScore = new HashMap<>(); + Map fScore = new HashMap<>(); + Map cameFrom = new HashMap<>(); + + PriorityQueue 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 reconstructPath(Map cameFrom, PathNode current) { + Deque 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: + *

    + *
  • {@link #offNetworkStart}: from → erster Netzknoten (Hindernisvermeidung per Steering)
  • + *
  • {@link #networkPath}: Knotenfolge auf dem Wegnetz (A*)
  • + *
  • {@link #offNetworkEnd}: letzter Netzknoten → to (Hindernisvermeidung per Steering)
  • + *
+ */ + public record PathResult( + List offNetworkStart, + List networkPath, + List offNetworkEnd + ) { + /** Alle Punkte als flache Liste (für einfache Agenten). */ + public List flatten() { + List 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; + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/path/PathNetworkIO.java b/blight-common/src/main/java/de/blight/common/path/PathNetworkIO.java new file mode 100644 index 0000000..e7cc716 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/path/PathNetworkIO.java @@ -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): + *
+ * # Blight Path Network
+ * NODE  uuid  name  x  y  z
+ * EDGE  uuid1  uuid2
+ * 
+ * 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; + } +} diff --git a/blight-common/src/main/java/de/blight/common/path/PathNode.java b/blight-common/src/main/java/de/blight/common/path/PathNode.java new file mode 100644 index 0000000..ce1cfd1 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/path/PathNode.java @@ -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; + } +} diff --git a/blight-editor/build.gradle b/blight-editor/build.gradle index 2fc487f..49e547c 100644 --- a/blight-editor/build.gradle +++ b/blight-editor/build.gradle @@ -15,6 +15,7 @@ application { '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED', "-Djava.library.path=${buildDir}/natives", + '-Xmx3g', ] } diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index cd2dd9e..8d3a854 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -80,7 +80,7 @@ public class EditorApp extends Application { private VBox assetPanel; private MapObjectsView mapObjectsView; private StackPane worldViewport; - private javafx.scene.canvas.Canvas minimapCanvas; + private javafx.scene.canvas.Canvas compassCanvas; private VBox topBar; // MenuBar + aktuelle Toolbar private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons) private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt @@ -151,11 +151,15 @@ public class EditorApp extends Application { // AnimSet-Editor private ListView animSetClipListView; private ListView animSetActionListView; + private ListView animSetSinkListView; + private ListView animSetAnchorBoneListView; private String animSetPendingPlayClip = null; private ComboBox animSetModelCombo; - private boolean animSetDirty = false; - private String animSetCurrentName = null; - private Path animSetCurrentDir = null; + private boolean animSetDirty = false; + private String animSetCurrentName = null; + private Path animSetCurrentDir = null; + private java.util.List animJointNames = new java.util.ArrayList<>(); + private Label animSetBonesLabel; // Character-Editor-Zustand private de.blight.editor.ui.DialogEditorView dialogEditorView; @@ -238,6 +242,11 @@ public class EditorApp extends Application { private final java.util.List modelEditorEmitters = new java.util.ArrayList<>(); private javafx.scene.layout.VBox modelEditorLightBox = null; private javafx.scene.layout.VBox modelEditorEmitterBox = null; + private ComboBox modelEditorInteractableCB = null; + private Spinner modelEditorInteractableXSpin = null; + private Spinner modelEditorInteractableYSpin = null; + private Spinner modelEditorInteractableZSpin = null; + private boolean updatingInteractableSpinnersFromJme = false; // Modell-Import-Zustand private Label modelImportLod1StatusLabel; @@ -443,6 +452,19 @@ public class EditorApp extends Application { animClipListView.getItems().setAll(newClips); if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst(); } + java.util.List newJoints = input.animPreviewJointNames.getAndSet(null); + if (newJoints != null) { + animJointNames = new java.util.ArrayList<>(newJoints); + if (animSetBonesLabel != null) { + if (animJointNames.isEmpty()) { + animSetBonesLabel.setText("Kein Armature gefunden"); + animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #c66;"); + } else { + animSetBonesLabel.setText(animJointNames.size() + " Joints geladen"); + animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #6a6;"); + } + } + } // AnimSet-Editor: nach Clip-Load automatisch abspielen if (newClips != null && animSetPendingPlayClip != null) { input.animPreviewPlayClip = animSetPendingPlayClip; @@ -457,6 +479,11 @@ public class EditorApp extends Application { input.animPreviewStatus = null; if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus); } + if (input.animImportCompleted) { + input.animImportCompleted = false; + refreshAddAnimCombo(addAnimComboField); + refreshCategoryNode(animationsNode, shouldPreserveExpansion()); + } String animOp = input.animOpStatus; if (animOp != null) { @@ -475,6 +502,19 @@ public class EditorApp extends Application { randomTreeStatusLabel.setText(rts); } + // Modell-Editor: Ruhepunkt-Spinner nach Raycast-Klick (JME→JFX) + if (input.modelInteractablePosSetFromJme && modelEditorInteractableXSpin != null) { + input.modelInteractablePosSetFromJme = false; + updatingInteractableSpinnersFromJme = true; + modelEditorInteractableXSpin.getValueFactory().setValue( + Math.round(input.modelInteractableOffsetX * 100.0) / 100.0); + modelEditorInteractableYSpin.getValueFactory().setValue( + Math.round(input.modelInteractableOffsetY * 100.0) / 100.0); + modelEditorInteractableZSpin.getValueFactory().setValue( + Math.round(input.modelInteractableOffsetZ * 100.0) / 100.0); + updatingInteractableSpinnersFromJme = false; + } + if (input.refreshAssets) { input.refreshAssets = false; boolean pe = shouldPreserveExpansion(); @@ -527,7 +567,7 @@ public class EditorApp extends Application { updateWaterHeightDisplay(input.waterCurrentHeight); } - drawMinimap(); + drawCompass(); // Spiel-Konsole: gepufferte Zeilen gebündelt ausgeben (max 200 auf einmal) if (!consoleBuffer.isEmpty() && gameConsoleArea != null) { @@ -4762,6 +4802,38 @@ public class EditorApp extends Application { } catch (IOException ignored) {} } + /** Liest die AnimClip-Namen aus einer J3O-Datei, ohne den JME3-Thread zu benötigen. */ + private List 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 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 node) { + for (TreeItem child : node.getChildren()) { + Path p = itemPaths.get(child); + if (p != null && !Files.isDirectory(p) + && p.getFileName().toString().toLowerCase().endsWith(".j3o")) { + child.getChildren().clear(); + for (String clip : readAnimClipNames(p)) { + child.getChildren().add(new TreeItem<>(clip)); + } + } else if (p != null && Files.isDirectory(p)) { + addAnimClipSubNodes(child); + } + } + } + /** * Löscht Thumbnail und Impostor-Textur, die zu einer .j3o-Datei gehören. * Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt. @@ -4835,7 +4907,10 @@ public class EditorApp extends Application { loadJmeTexturesInto(jmeTexturesNode); } case "audio" -> audioNode = node; - case "animations" -> animationsNode = node; + case "animations" -> { + animationsNode = node; + addAnimClipSubNodes(node); + } case "items" -> itemsNode = node; } } @@ -4849,6 +4924,7 @@ public class EditorApp extends Application { catNode.getChildren().clear(); Path dir = itemPaths.get(catNode); if (dir != null) loadAssetsRecursive(catNode, dir); + if (catNode == animationsNode) addAnimClipSubNodes(catNode); if (catNode == modelsNode && jmeModelsNode != null) catNode.getChildren().add(jmeModelsNode); if (catNode == texturesNode && jmeTexturesNode != null) @@ -5025,6 +5101,7 @@ public class EditorApp extends Application { String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures"; TreeItem parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode; + archiveOriginal(file, isModel ? "models" : isAudio ? "audio" : "textures"); try { Path destDir = ASSET_ROOT.resolve(subDir); @@ -5663,7 +5740,15 @@ public class EditorApp extends Application { input.modelEditorPivotY = meta.pivotOffsetY(); input.modelEditorOpenPath = relPath; - root.setRight(buildModelEditorPanel(relPath, absolutePath, meta)); + VBox modelEditorInner = buildModelEditorPanel(relPath, absolutePath, meta); + ScrollPane modelEditorScroll = new ScrollPane(modelEditorInner); + modelEditorScroll.setFitToWidth(true); + modelEditorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + modelEditorScroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;"); + VBox modelEditorOuter = new VBox(modelEditorScroll); + VBox.setVgrow(modelEditorScroll, Priority.ALWAYS); + modelEditorOuter.setPrefWidth(300); + root.setRight(modelEditorOuter); setStatus("Modell-Editor: " + relPath); } @@ -5895,6 +5980,150 @@ public class EditorApp extends Application { // Initiale Gizmos pushen pushAttachmentsToJme(); + // ── Interaktivität ──────────────────────────────────────────────────── + Label interactTitle = new Label("Interaktivität:"); + interactTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); + + modelEditorInteractableCB = new ComboBox<>(); + modelEditorInteractableCB.getItems().addAll(de.blight.common.model.InteractableType.values()); + modelEditorInteractableCB.setValue(meta.interactableType()); + modelEditorInteractableCB.setMaxWidth(Double.MAX_VALUE); + modelEditorInteractableCB.setConverter(new javafx.util.StringConverter<>() { + @Override public String toString(de.blight.common.model.InteractableType t) { + return t == null ? "" : t.getLabel(); + } + @Override public de.blight.common.model.InteractableType fromString(String s) { + return de.blight.common.model.InteractableType.fromString(s); + } + }); + + // Ruhepunkt-Controls (nur sichtbar wenn BED oder BENCH gewählt) + // SharedInput mit bestehenden Meta-Werten initialisieren + input.modelInteractableOffsetX = meta.interactableOffsetX(); + input.modelInteractableOffsetY = meta.interactableOffsetY(); + input.modelInteractableOffsetZ = meta.interactableOffsetZ(); + input.modelInteractableRotY = meta.interactableRotY(); + input.modelInteractableOffsetChanged = true; + + Label restPosHint = new Label("Klicke auf das Modell um den Ruhepunkt zu setzen:"); + restPosHint.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;"); + restPosHint.setWrapText(true); + + Button setRestBtn = new Button("⊕ Im Modell klicken"); + setRestBtn.setMaxWidth(Double.MAX_VALUE); + setRestBtn.setStyle("-fx-background-color:#1a5276; -fx-text-fill:#fff;"); + setRestBtn.setOnAction(ev -> { + input.activeLayer = SharedInput.LAYER_MODEL_INTERACTABLE; + setStatus("Klicke auf das Modell um den Ruhepunkt zu setzen"); + }); + + // Position-Spinner (X / Y / Z) + Label posLabel = new Label("Position (Modell-Koordinaten):"); + posLabel.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;"); + + modelEditorInteractableXSpin = new Spinner<>( + new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0, + Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05)); + modelEditorInteractableXSpin.setEditable(true); + modelEditorInteractableXSpin.setPrefWidth(100); + modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> { + if (updatingInteractableSpinnersFromJme) return; + input.modelInteractableOffsetX = nv.floatValue(); + input.modelInteractableOffsetChanged = true; + }); + + modelEditorInteractableYSpin = new Spinner<>( + new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0, + Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05)); + modelEditorInteractableYSpin.setEditable(true); + modelEditorInteractableYSpin.setPrefWidth(100); + modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> { + if (updatingInteractableSpinnersFromJme) return; + input.modelInteractableOffsetY = nv.floatValue(); + input.modelInteractableOffsetChanged = true; + }); + + modelEditorInteractableZSpin = new Spinner<>( + new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0, + Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05)); + modelEditorInteractableZSpin.setEditable(true); + modelEditorInteractableZSpin.setPrefWidth(100); + modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> { + if (updatingInteractableSpinnersFromJme) return; + input.modelInteractableOffsetZ = nv.floatValue(); + input.modelInteractableOffsetChanged = true; + }); + + javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane(); + posGrid.setHgap(4); posGrid.setVgap(2); + posGrid.add(new Label("X:"), 0, 0); posGrid.add(modelEditorInteractableXSpin, 1, 0); + posGrid.add(new Label("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1); + posGrid.add(new Label("Z:"), 0, 2); posGrid.add(modelEditorInteractableZSpin, 1, 2); + posGrid.getChildren().stream() + .filter(n -> n instanceof Label) + .forEach(n -> ((Label) n).setStyle("-fx-text-fill:#aaa;")); + + Label rotLabel = new Label("Blickrichtung (°):"); + rotLabel.setStyle("-fx-text-fill:#aaa;"); + + double initDeg = Math.toDegrees(meta.interactableRotY()); + Slider rotSlider = new Slider(0, 360, initDeg); + rotSlider.setShowTickMarks(true); + rotSlider.setMajorTickUnit(90); + rotSlider.setPrefWidth(180); + + TextField rotField = new TextField(String.format("%.1f", initDeg)); + rotField.setPrefWidth(60); + rotField.setStyle("-fx-background-color:#333; -fx-text-fill:#eee;"); + + boolean[] syncingRot = {false}; + + rotSlider.valueProperty().addListener((obs, ov, nv) -> { + if (syncingRot[0]) return; + syncingRot[0] = true; + rotField.setText(String.format("%.1f", nv.doubleValue())); + syncingRot[0] = false; + input.modelInteractableRotY = (float) Math.toRadians(nv.doubleValue()); + input.modelInteractableOffsetChanged = true; + }); + + Runnable applyRotField = () -> { + try { + double deg = Double.parseDouble(rotField.getText().replace(',', '.')); + deg = ((deg % 360) + 360) % 360; + if (syncingRot[0]) return; + syncingRot[0] = true; + rotSlider.setValue(deg); + syncingRot[0] = false; + input.modelInteractableRotY = (float) Math.toRadians(deg); + input.modelInteractableOffsetChanged = true; + } catch (NumberFormatException ignored) {} + }; + rotField.setOnAction(e -> applyRotField.run()); + rotField.focusedProperty().addListener((obs, ov, nv) -> { if (!nv) applyRotField.run(); }); + + HBox rotRow = new HBox(6, rotSlider, rotField); + rotRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + + javafx.scene.layout.VBox restPointBox = new javafx.scene.layout.VBox(4, + restPosHint, setRestBtn, posLabel, posGrid, + rotLabel, rotRow); + restPointBox.setStyle("-fx-padding: 4 0 0 0;"); + + boolean isBedOrBench = meta.interactableType() == de.blight.common.model.InteractableType.BED + || meta.interactableType() == de.blight.common.model.InteractableType.BENCH; + restPointBox.setVisible(isBedOrBench); + restPointBox.setManaged(isBedOrBench); + + modelEditorInteractableCB.valueProperty().addListener((obs, ov, nv) -> { + boolean show = nv == de.blight.common.model.InteractableType.BED + || nv == de.blight.common.model.InteractableType.BENCH; + restPointBox.setVisible(show); + restPointBox.setManaged(show); + // Pfeil ein-/ausblenden — über SharedInput-Flag signalisieren + input.modelInteractableOffsetChanged = show; + }); + // ── Buttons ─────────────────────────────────────────────────────────── Button saveBtn = new Button("💾 Speichern"); saveBtn.setMaxWidth(Double.MAX_VALUE); @@ -5926,7 +6155,14 @@ public class EditorApp extends Application { (float)(double) rndMaxSpin.getValue(), modelEditorLod1Path, modelEditorLod2Path, new java.util.ArrayList<>(modelEditorLights), - new java.util.ArrayList<>(modelEditorEmitters))); + new java.util.ArrayList<>(modelEditorEmitters), + modelEditorInteractableCB != null + ? modelEditorInteractableCB.getValue() + : de.blight.common.model.InteractableType.NONE, + input.modelInteractableOffsetX, + input.modelInteractableOffsetY, + input.modelInteractableOffsetZ, + input.modelInteractableRotY)); placeBtn.setOnAction(e -> { input.modelEditorCloseRequest = true; @@ -5967,6 +6203,8 @@ public class EditorApp extends Application { lightSectionLbl, modelEditorLightBox, addLightBtn, emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn, new Separator(), + interactTitle, modelEditorInteractableCB, restPointBox, + new Separator(), saveBtn, placeBtn, closeBtn ); return panel; @@ -5994,6 +6232,7 @@ public class EditorApp extends Application { } private void startImportFile(File file) { + archiveOriginal(file, "models"); String name = file.getName(); String baseName = name.replaceFirst("\\.[^.]+$", ""); Path destDir = ASSET_ROOT.resolve("Models").resolve("imported"); @@ -6415,12 +6654,17 @@ public class EditorApp extends Application { float rndMin, float rndMax, String lod1Path, String lod2Path, java.util.List lights, - java.util.List emitters) { + java.util.List emitters, + de.blight.common.model.InteractableType interactableType, + float interactableOffsetX, float interactableOffsetY, + float interactableOffsetZ, float interactableRotY) { de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta( name, category, tags, sx, sy, sz, uniform, pivotY, placeY, solid, cast, receive, rndMin, rndMax, lod1Path, lod2Path, 30f, 80f, 120f, - lights, emitters); + lights, emitters, + interactableType != null ? interactableType : de.blight.common.model.InteractableType.NONE, + interactableOffsetX, interactableOffsetY, interactableOffsetZ, interactableRotY); if (absolutePath == null || !absolutePath.toFile().exists()) { setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert"); @@ -6659,13 +6903,13 @@ public class EditorApp extends Application { viewport.setPreserveRatio(false); viewport.setFocusTraversable(true); - minimapCanvas = new javafx.scene.canvas.Canvas(164, 164); - minimapCanvas.setMouseTransparent(true); - StackPane.setAlignment(minimapCanvas, javafx.geometry.Pos.BOTTOM_RIGHT); - StackPane.setMargin(minimapCanvas, new Insets(0, 10, 10, 0)); - drawMinimap(); // Initiales Zeichnen (leer) + compassCanvas = new javafx.scene.canvas.Canvas(100, 100); + compassCanvas.setMouseTransparent(true); + StackPane.setAlignment(compassCanvas, javafx.geometry.Pos.BOTTOM_RIGHT); + StackPane.setMargin(compassCanvas, new Insets(0, 10, 10, 0)); + drawCompass(); - StackPane pane = new StackPane(viewport, minimapCanvas); + StackPane pane = new StackPane(viewport, compassCanvas); pane.setStyle("-fx-background-color: #1a1a2e;"); javafx.animation.PauseTransition resizeDebounce = @@ -6846,6 +7090,11 @@ public class EditorApp extends Application { } case SharedInput.LAYER_VOXEL -> input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y, action)); + case SharedInput.LAYER_MODEL_INTERACTABLE -> { + if (action > 0) + input.modelInteractableClickQueue.offer( + new SharedInput.ModelInteractableClick((float) x, (float) y)); + } } } @@ -7006,61 +7255,72 @@ public class EditorApp extends Application { gameConsoleArea.setScrollTop(Double.MAX_VALUE); } - // ── Minimap ─────────────────────────────────────────────────────────────── + // ── Kompass ─────────────────────────────────────────────────────────────── - private static final float WORLD_HALF = 2048f; // Welt geht von -2048 bis +2048 + private void drawCompass() { + if (compassCanvas == null) return; + final double S = compassCanvas.getWidth(); // 100 + final double cx = S / 2.0; + final double cy = S / 2.0; + final double R = cx - 3; // Radius bis zum Rand - private void drawMinimap() { - if (minimapCanvas == null) return; - final double SIZE = minimapCanvas.getWidth(); // 164 - final double INNER = SIZE - 4; // 160 innere Kartenfläche - final double OFFSET = 2; + javafx.scene.canvas.GraphicsContext gc = compassCanvas.getGraphicsContext2D(); + gc.clearRect(0, 0, S, S); - javafx.scene.canvas.GraphicsContext gc = minimapCanvas.getGraphicsContext2D(); - gc.clearRect(0, 0, SIZE, SIZE); + // Hintergrund: dunkler Kreis + gc.setFill(javafx.scene.paint.Color.rgb(12, 12, 24, 0.82)); + gc.fillOval(2, 2, S - 4, S - 4); + gc.setStroke(javafx.scene.paint.Color.rgb(100, 100, 160, 0.85)); + gc.setLineWidth(1.5); + gc.strokeOval(2, 2, S - 4, S - 4); - // Hintergrund + Rahmen - gc.setFill(javafx.scene.paint.Color.rgb(10, 10, 20, 0.75)); - gc.fillRoundRect(0, 0, SIZE, SIZE, 6, 6); - gc.setStroke(javafx.scene.paint.Color.rgb(120, 120, 180, 0.8)); - gc.setLineWidth(1); - gc.strokeRoundRect(0.5, 0.5, SIZE - 1, SIZE - 1, 6, 6); + // Rotierende Rose: -camYaw so dass die aktuelle Blickrichtung oben erscheint + gc.save(); + gc.translate(cx, cy); + gc.rotate(-input.camYaw); - // Hilfslinien (Weltmitte) - gc.setStroke(javafx.scene.paint.Color.rgb(80, 80, 110, 0.5)); - gc.setLineWidth(0.5); - double mid = OFFSET + INNER / 2.0; - gc.strokeLine(mid, OFFSET, mid, OFFSET + INNER); - gc.strokeLine(OFFSET, mid, OFFSET + INNER, mid); - - // Kameraposition (blauer Pfeil/Dreieck) - if (Float.isFinite(input.camX) && Float.isFinite(input.camZ)) { - double mx = worldToMap(input.camX, INNER, OFFSET); - double mz = worldToMap(input.camZ, INNER, OFFSET); - double yaw = Math.toRadians(input.camYaw); // Yaw: 0 = Norden, positiv = Uhrzeigersinn + // Tick-Striche an 8 Positionen (45°-Schritte) + for (int i = 0; i < 8; i++) { + double a = Math.toRadians(i * 45.0); + double len = (i % 2 == 0) ? 9 : 5; + gc.setStroke(javafx.scene.paint.Color.rgb(160, 160, 200, 0.65)); + gc.setLineWidth(i % 2 == 0 ? 1.5 : 1.0); + gc.strokeLine( + Math.sin(a) * (R - len), -Math.cos(a) * (R - len), + Math.sin(a) * R, -Math.cos(a) * R); + } + // Himmelsrichtungen (N/E/S/W), Text durch Gegenrotation immer lesbar + String[] dirs = {"N", "E", "S", "W"}; + double[] angles = {0.0, 90.0, 180.0, 270.0}; + for (int i = 0; i < 4; i++) { + double a = Math.toRadians(angles[i]); + double tx = Math.sin(a) * (R - 16); + double ty = -Math.cos(a) * (R - 16); gc.save(); - gc.translate(mx, mz); - gc.rotate(Math.toDegrees(yaw)); - gc.setFill(javafx.scene.paint.Color.rgb(80, 160, 255, 0.95)); - gc.setStroke(javafx.scene.paint.Color.WHITE); - gc.setLineWidth(0.8); - // Kleines Dreieck zeigt Blickrichtung - double[] px = { 0, -4.5, 4.5 }; - double[] pz = { -7, 5, 5 }; - gc.fillPolygon(px, pz, 3); - gc.strokePolygon(px, pz, 3); + gc.translate(tx, ty); + gc.rotate(input.camYaw); // Gegenrotation zur Rose → Buchstabe immer aufrecht + gc.setFill(i == 0 + ? javafx.scene.paint.Color.rgb(255, 80, 80) + : javafx.scene.paint.Color.rgb(210, 210, 230)); + gc.setFont(javafx.scene.text.Font.font( + "System", javafx.scene.text.FontWeight.BOLD, i == 0 ? 13 : 11)); + gc.fillText(dirs[i], -4.5, 5.0); gc.restore(); } - // Beschriftung - gc.setFill(javafx.scene.paint.Color.rgb(180, 180, 220, 0.7)); - gc.setFont(javafx.scene.text.Font.font(8)); - gc.fillText("N", mid - 3, OFFSET + 9); - } + gc.restore(); // Ende rotierende Rose - private static double worldToMap(float world, double innerSize, double offset) { - return offset + (world + WORLD_HALF) / (WORLD_HALF * 2) * innerSize; + // Fixer Richtungszeiger oben (gelbes Dreieck, zeigt immer die Blickrichtung) + gc.setFill(javafx.scene.paint.Color.rgb(255, 220, 60, 0.95)); + gc.fillPolygon( + new double[]{cx, cx - 5, cx + 5}, + new double[]{cy - R + 5, cy - R + 16, cy - R + 16}, + 3); + + // Mittelpunkt + gc.setFill(javafx.scene.paint.Color.rgb(200, 200, 240, 0.85)); + gc.fillOval(cx - 2.5, cy - 2.5, 5, 5); } private void saveCameraPrefs() { @@ -7970,6 +8230,10 @@ public class EditorApp extends Application { .forEach(animSetModelCombo.getItems()::add); } catch (IOException ignored) {} } + // Gespeicherten Modell-Pfad vorauswählen + if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) { + animSetModelCombo.setValue(animSet.getPreviewModelPath()); + } Button loadModelBtn = new Button("Laden"); loadModelBtn.setMaxWidth(Double.MAX_VALUE); loadModelBtn.setOnAction(e -> { @@ -7977,9 +8241,18 @@ public class EditorApp extends Application { if (path == null || path.isBlank()) return; input.animPreviewLoadPath = path; if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + // Pfad im AnimSet merken und sofort speichern + animSet.setPreviewModelPath(path); + animSetDirty = true; }); inner.getChildren().addAll(animSetModelCombo, loadModelBtn); + // Modell beim Öffnen automatisch laden, wenn Pfad bekannt + if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) { + input.animPreviewLoadPath = animSet.getPreviewModelPath(); + if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + } + // ── Clips im Set ───────────────────────────────────────────────────── inner.getChildren().addAll(new Separator(), sectionTitle("Clips im Set"), new Separator()); @@ -8045,14 +8318,15 @@ public class EditorApp extends Application { ListView list = new ListView<>(); list.getItems().addAll(notYetAdded); list.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE); - list.setPrefHeight(Math.min(notYetAdded.size() * 26 + 4, 320)); + list.setPrefSize(460, 320); + list.setMinSize(460, 320); list.getSelectionModel().selectFirst(); javafx.scene.control.Dialog> dlg = new javafx.scene.control.Dialog<>(); dlg.setTitle("Animation(en) hinzufügen"); dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:"); dlg.getDialogPane().setContent(list); - dlg.getDialogPane().setPrefWidth(360); + dlg.getDialogPane().setPrefWidth(500); javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType("Hinzufügen", javafx.scene.control.ButtonBar.ButtonData.OK_DONE); dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL); @@ -8114,6 +8388,197 @@ public class EditorApp extends Application { HBox.setHgrow(removeActionBtn, Priority.ALWAYS); inner.getChildren().addAll(animSetActionListView, actionBtns); + // ── Bone-Anchoring ──────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Bone-Anchoring"), new Separator()); + + Label anchorHint = new Label("Pro Aktion: Knochen angeben, der auf seiner Welt-Y fixiert bleibt (z. B. SIT_DOWN → foot.l). Hat Vorrang vor manuellem Sink."); + anchorHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); + anchorHint.setWrapText(true); + animSetBonesLabel = new Label(animJointNames.isEmpty() ? "Kein Modell geladen" : animJointNames.size() + " Joints geladen"); + animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#888;" : "#6a6;")); + + animSetAnchorBoneListView = new ListView<>(); + animSetAnchorBoneListView.setPrefHeight(110); + if (animSet.getAnchorBoneMap() != null) { + for (var e2 : animSet.getAnchorBoneMap().entrySet()) { + animSetAnchorBoneListView.getItems().add(e2.getKey() + " → " + e2.getValue()); + } + } + + Button addAnchorBtn = new Button("+ Hinzufügen…"); + Button removeAnchorBtn = new Button("- Entfernen"); + addAnchorBtn.setMaxWidth(Double.MAX_VALUE); + removeAnchorBtn.setMaxWidth(Double.MAX_VALUE); + removeAnchorBtn.setDisable(true); + animSetAnchorBoneListView.getSelectionModel().selectedItemProperty() + .addListener((obs, ov, nv) -> removeAnchorBtn.setDisable(nv == null)); + + addAnchorBtn.setOnAction(e -> { + // Pending joint names aus JME3-Thread abholen (falls Timer sie noch nicht konsumiert hat) + java.util.List 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 anchorActionCombo = new ComboBox<>(); + anchorActionCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values()); + javafx.util.Callback, + javafx.scene.control.ListCell> 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 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 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 actionSinkCombo = new ComboBox<>(); + actionSinkCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values()); + javafx.util.Callback, + javafx.scene.control.ListCell> 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 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 sinkDlg = new javafx.scene.control.Dialog<>(); + sinkDlg.setTitle("Sink-Wert setzen"); + javafx.scene.control.ButtonType okSink = new javafx.scene.control.ButtonType("Setzen", + javafx.scene.control.ButtonBar.ButtonData.OK_DONE); + sinkDlg.getDialogPane().getButtonTypes().addAll(okSink, javafx.scene.control.ButtonType.CANCEL); + sinkDlg.getDialogPane().setContent(sinkGrid); + sinkDlg.showAndWait().ifPresent(bt -> { + if (bt != okSink) { + return; + } + var selAction = actionSinkCombo.getValue(); + if (selAction == null) { + return; + } + double val = sinkSpinner.getValue(); + String newEntry = selAction.name() + " → " + val; + // Bestehenden Eintrag für diese Aktion ersetzen + animSetSinkListView.getItems().removeIf(it -> it.startsWith(selAction.name() + " → ")); + animSetSinkListView.getItems().add(newEntry); + animSetDirty = true; + }); + }); + + removeSinkBtn.setOnAction(e -> { + String sel = animSetSinkListView.getSelectionModel().getSelectedItem(); + if (sel != null) { + animSetSinkListView.getItems().remove(sel); + animSetDirty = true; + } + }); + + HBox sinkBtns = new HBox(6, addSinkBtn, removeSinkBtn); + HBox.setHgrow(addSinkBtn, Priority.ALWAYS); + HBox.setHgrow(removeSinkBtn, Priority.ALWAYS); + inner.getChildren().addAll(sinkHint, animSetSinkListView, sinkBtns); + // ── Vorschau ───────────────────────────────────────────────────────── inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator()); @@ -8182,13 +8647,21 @@ public class EditorApp extends Application { return; } animSetPendingPlayClip = clip; - // Clip zur aktuell geladenen Figur hinzufügen (nicht als Modell laden). - // Nach Abschluss setzt AnimPreviewState animPreviewClips, das den - // animSetPendingPlayClip-Trigger auslöst und den Clip abspielt. - input.animPreviewAddAnimPath = "animations/clips/" + clip + ".j3o"; + input.animImportQueue.offer(findAnimClipPath(clip)); if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade Clip " + clip + "…"); } + private String findAnimClipPath(String clipName) { + Path animDir = ASSET_ROOT.resolve("animations"); + for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) { + if (java.nio.file.Files.exists(animDir.resolve("clips").resolve(clipName + ext))) + return "animations/clips/" + clipName + ext; + if (java.nio.file.Files.exists(animDir.resolve(clipName + ext))) + return "animations/" + clipName + ext; + } + return "animations/clips/" + clipName + ".j3o"; + } + private void showAddActionToSetDialog() { if (animSetClipListView == null || animSetClipListView.getItems().isEmpty()) { setStatus("Keine Clips im Set — erst Clips hinzufügen."); @@ -8254,16 +8727,47 @@ public class EditorApp extends Application { } private void saveCurrentAnimSet(String setName, Path setDir) { - if (animSetClipListView == null) return; + if (animSetClipListView == null) { + return; + } de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet(); animSet.setClips(new java.util.ArrayList<>(animSetClipListView.getItems())); java.util.Map actionMap = new java.util.LinkedHashMap<>(); - if (animSetActionListView != null) + if (animSetActionListView != null) { for (String it : animSetActionListView.getItems()) { String[] parts = it.split(" → ", 2); - if (parts.length == 2) actionMap.put(parts[0], parts[1]); + if (parts.length == 2) { + actionMap.put(parts[0], parts[1]); + } } + } animSet.setActionMap(actionMap); + java.util.Map 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 anchorBoneMap = new java.util.LinkedHashMap<>(); + if (animSetAnchorBoneListView != null) { + for (String it : animSetAnchorBoneListView.getItems()) { + String[] parts = it.split(" → ", 2); + if (parts.length == 2) { + anchorBoneMap.put(parts[0], parts[1]); + } + } + } + animSet.setAnchorBoneMap(anchorBoneMap); + // Vorschau-Modell-Pfad beibehalten + if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) { + animSet.setPreviewModelPath(animSetModelCombo.getValue()); + } try { animSet.save(setDir, setName); setStatus("AnimSet gespeichert: " + setName + ".animset.json"); @@ -8443,7 +8947,7 @@ public class EditorApp extends Application { animPreviewStatusLabel.setText("Bitte eine Animation auswählen"); return; } - input.animPreviewAddAnimPath = animPath; + input.animImportQueue.offer(animPath); if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…"); }); inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn); @@ -8492,6 +8996,23 @@ public class EditorApp extends Application { if (current != null && combo.getItems().contains(current)) combo.setValue(current); } + /** + * Kopiert die Original-Quelldatei nach {@code /assets/imported//}. + * Dient als Archiv vor jeder Konvertierung; Fehler werden nur geloggt. + */ + private void archiveOriginal(File source, String assetType) { + Path dest = ProjectRoot.PATH.resolve("assets").resolve("imported").resolve(assetType); + try { + Files.createDirectories(dest); + Files.copy(source.toPath(), dest.resolve(source.getName()), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + log.info("[Import] Archiviert: {}/{}", assetType, source.getName()); + } catch (IOException ex) { + log.warn("[Import] Archivierung fehlgeschlagen ({}/{}): {}", + assetType, source.getName(), ex.getMessage()); + } + } + private void handleAnimationImport(javafx.stage.Window owner) { FileChooser fc = new FileChooser(); fc.setTitle("Animation importieren (GLB/GLTF)"); @@ -8500,19 +9021,12 @@ public class EditorApp extends Application { var files = fc.showOpenMultipleDialog(owner); if (files == null) return; for (File file : files) { - try { - Path destDir = ASSET_ROOT.resolve("animations").resolve("clips"); - Files.createDirectories(destDir); - Path destFile = destDir.resolve(file.getName()); - Files.copy(file.toPath(), destFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - setStatus("Animation importiert: " + file.getName()); - } catch (IOException ex) { - setStatus("Fehler beim Animations-Import: " + ex.getMessage()); - } + archiveOriginal(file, "animations"); + // Absoluten Pfad übergeben – kein Kopieren nötig. + // addAnimation() lädt direkt vom Ursprungsort und speichert nur J3O nach clips/. + input.animImportQueue.offer(file.getAbsolutePath()); + setStatus("Importiere: " + file.getName() + " …"); } - // Sofort im JavaFX-Thread aktualisieren – keine Konvertierung nötig - refreshCategoryNode(animationsNode, shouldPreserveExpansion()); - refreshAddAnimCombo(addAnimComboField); } private void reimportModelForPreview(javafx.stage.Window owner) { @@ -8522,6 +9036,7 @@ public class EditorApp extends Application { new FileChooser.ExtensionFilter("3D-Modelle (GLTF, GLB)", "*.gltf", "*.glb")); File file = fc.showOpenDialog(owner); if (file == null) return; + archiveOriginal(file, "models"); String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null; Path destDir; diff --git a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java index 7b044a1..08fb9b2 100644 --- a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java @@ -35,7 +35,10 @@ import de.blight.editor.state.SceneObjectState; import de.blight.editor.state.TerrainEditorState; import de.blight.editor.state.TreeGeneratorState; import de.blight.editor.state.VoxelEditorState; +import de.blight.editor.state.SculptedMeshEditorState; import de.blight.editor.state.ModelImportState; +import de.blight.editor.state.PathNetworkEditorState; +import de.blight.editor.state.RoutineMapState; import de.blight.game.console.JmeConsole; import de.blight.game.state.DayNightState; import javafx.scene.image.WritableImage; @@ -192,10 +195,13 @@ public class JmeEditorApp extends SimpleApplication { stateManager.attach(new LocationZoneState(input)); stateManager.attach(new RiverEditorState(input)); stateManager.attach(new PlayToolState(input)); + stateManager.attach(new RoutineMapState(input)); + stateManager.attach(new PathNetworkEditorState(input)); stateManager.attach(new AnimPreviewState(input)); stateManager.attach(new ModelEditorState(input)); stateManager.attach(new ItemPlacementState(input)); stateManager.attach(new VoxelEditorState(input)); + stateManager.attach(new SculptedMeshEditorState(input)); stateManager.attach(new ModelImportState(input)); // NaN-sichere Comparatoren einsetzen (verhindern den TimSort-Crash bei kaputten Bounds) diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 2b16a51..5cf7587 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -5,8 +5,10 @@ import de.blight.editor.tool.GrassTool; import de.blight.editor.tool.GrassVertexTool; import de.blight.editor.tool.HeightTool; import de.blight.editor.tool.HoleTool; +import de.blight.editor.tool.StoneTool; import de.blight.editor.tool.TextureTool; import de.blight.editor.tool.UpperHeightTool; +import de.blight.editor.tool.SculptMeshTool; import de.blight.editor.tool.VoxelTool; import de.blight.editor.tree.PalmOptions; import de.blight.editor.tree.TreeParams; @@ -26,8 +28,10 @@ public class SharedInput { public final GrassVertexTool grassVertexTool = new GrassVertexTool(); public final TextureTool textureTool = new TextureTool(); public final HoleTool holeTool = new HoleTool(); - public final VoxelTool voxelTool = new VoxelTool(); - public volatile EditorTool activeTool = heightTool; + public final VoxelTool voxelTool = new VoxelTool(); + public final StoneTool stoneTool = new StoneTool(); + public final SculptMeshTool sculptTool = new SculptMeshTool(); + public volatile EditorTool activeTool = heightTool; // ── Initialisierungs-Status ─────────────────────────────────────────────── public volatile boolean jmeReady = false; @@ -273,6 +277,11 @@ public class SharedInput { /** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */ public volatile String pendingAnimClip = null; + /** JavaFX → JME: Interactable-Typ des selektierten Objekts ("CRAFTING_TABLE"/"BED"/""). null = kein Auftrag. */ + public volatile String pendingInteractableType = null; + /** JavaFX → JME: Interactable-ID des selektierten Objekts. null = kein Auftrag. */ + public volatile String pendingInteractableId = null; + // Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen public record ObjectPropertyChange( float x, float y, float z, @@ -541,7 +550,7 @@ public class SharedInput { /** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */ public volatile String animPreviewPlayClip = null; /** JavaFX → JME3: Animation-j3o-Pfad zum Retargeting + Hinzufügen. null = kein Auftrag. */ - public volatile String animPreviewAddAnimPath = null; + public final ConcurrentLinkedQueue animImportQueue = new ConcurrentLinkedQueue<>(); /** JavaFX → JME3: Clip-Name zum Entfernen aus dem geladenen Modell. null = kein Auftrag. */ public volatile String animPreviewRemoveClip = null; /** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */ @@ -553,9 +562,14 @@ public class SharedInput { public volatile boolean animPreviewLoop = true; /** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */ public volatile String animPreviewStatus = null; + /** JME3 → JavaFX: Signalisiert, dass ein Import abgeschlossen wurde → Combo neu laden. */ + public volatile boolean animImportCompleted = false; /** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */ public final java.util.concurrent.atomic.AtomicReference> 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> + animPreviewJointNames = new java.util.concurrent.atomic.AtomicReference<>(); /** * JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells. @@ -656,15 +670,29 @@ public class SharedInput { public volatile String modelEditorLod2Path = ""; public volatile boolean modelEditorLodChanged = false; + /** JFX → JME: Richtung der Hauptlichtquelle in der Vorschau. + * Azimut 0–360° (Kompassrichtung), Elevation 0–90° (Höhe über Horizont). */ + public volatile float modelEditorLightAzimuth = 51f; + public volatile float modelEditorLightElevation = 57f; + public volatile boolean modelEditorLightChanged = false; + // ── Voxel-Werkzeug ──────────────────────────────────────────────────────── /** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */ public static final int LAYER_VOXEL = 16; - /** Klick/Drag im Viewport im Voxel-Modus. */ + // Klick/Drag im Viewport im Voxel-Modus. /** action +1 = Linksklick (erhöhen/hinzufügen), -1 = Rechtsklick (senken/entfernen). */ public record VoxelEdit(float screenX, float screenY, int action) {} public final ConcurrentLinkedQueue voxelEditQueue = new ConcurrentLinkedQueue<>(); + + /** JFX → JME: Aktions-Grenzen für Undo-Snapshots. */ + public volatile boolean voxelActionStarted = false; + public volatile boolean voxelActionFinished = false; + /** JFX → JME: Undo/Redo-Anfragen (Ctrl+Z / Ctrl+Shift+Z). */ + public volatile boolean voxelUndoRequested = false; + public volatile boolean voxelRedoRequested = false; + /** JFX → JME: alle Voxel-Chunks als geglättete J3O-Meshes backen. */ public volatile boolean bakeVoxelsRequested = false; /** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */ @@ -673,6 +701,8 @@ public class SharedInput { public volatile int bakeTotal = 0; /** JME → JFX: Status-Meldung nach Abschluss des Backens. */ public volatile String bakeStatusMsg = null; + /** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */ + public volatile int blurIterDone = 0; /** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */ public volatile int voxelFlatSlot = -1; @@ -683,6 +713,9 @@ public class SharedInput { /** JFX setzt true wenn Voxel-Texturen geändert wurden; JME liest + resettet. */ public volatile boolean voxelTexturesChanged = false; + /** Wenn true, werden beim Aktivieren des Voxel-Layers alle anderen Objekte als Wireframe gerendert. */ + public volatile boolean voxelWireframeEnabled = true; + // ── Item-Platzierung ────────────────────────────────────────────────────── /** activeLayer==21 → Item-Pickup auf die Karte platzieren */ public static final int LAYER_ITEMS = 21; @@ -720,6 +753,22 @@ public class SharedInput { /** JME → JFX: Status-Meldung nach LOD-Generierung. */ public volatile String modelLodGenStatus = null; + // ── Steine ──────────────────────────────────────────────────────────────── + /** activeLayer==23 → Steine setzen/entfernen */ + public static final int LAYER_STONE = 23; + + public record StoneEdit(float screenX, float screenY, int action) {} + public final ConcurrentLinkedQueue 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 terrainEditedAreas = new ConcurrentLinkedQueue<>(); + /** JFX → JME: LOD-Reduktions-Algorithmus. "blight" = Dihedral Edge Collapse, "jme" = JME3 Progressive Mesh. */ public volatile String modelLodAlgorithm = "blight"; @@ -731,4 +780,124 @@ public class SharedInput { public volatile String modelImportExportName = null; /** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */ public volatile String modelImportExportStatus = null; + + // ── Mesh-Sculpting ─────────────────────────────────────────────────────── + /** activeLayer==24 → gebackene Voxel-Meshes direkt sculpten */ + public static final int LAYER_SCULPT = 24; + + // ── Tagesablauf-Editor ──────────────────────────────────────────────────── + /** activeLayer==25 → Tagesablauf-Karte (nur Kamera + Punktauswahl) */ + public static final int LAYER_ROUTINE_EDITOR = 25; + + /** + * JFX → JME: Punkt auf dem Terrain abgreifen. + * Wert: screenX|screenY. JME liest via Queue, setzt routinePickedPoint. + */ + public record RoutinePointClick(float screenX, float screenY) {} + public final ConcurrentLinkedQueue 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 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 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 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 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 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; } diff --git a/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java b/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java index 0057883..e9fad45 100644 --- a/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java +++ b/blight-editor/src/main/java/de/blight/editor/object/SceneObject.java @@ -24,6 +24,8 @@ public class SceneObject extends PlacedObject { public float lod1Distance = 30f; public float lod2Distance = 80f; public float cullDistance = 120f; + public String interactableType = ""; + public String interactableId = ""; public SceneObject(String modelPath, float worldX, float worldZ, float groundY, boolean solid) { diff --git a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java index e586c9c..8ce4637 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AnimPreviewState.java @@ -148,9 +148,8 @@ public class AnimPreviewState extends BaseAppState { else playClip(playClip); } - String addAnimPath = input.animPreviewAddAnimPath; + String addAnimPath = input.animImportQueue.poll(); if (addAnimPath != null) { - input.animPreviewAddAnimPath = null; addAnimation(addAnimPath); } @@ -276,6 +275,22 @@ public class AnimPreviewState extends BaseAppState { collectClips(model, clips); input.animPreviewClips.set(Collections.unmodifiableList(clips)); input.animPreviewLoadedPath.set(assetPath); + + // Joint-Namen aus dem Armature sammeln und melden + SkinningControl sc = findControl(model, SkinningControl.class); + if (sc != null && sc.getArmature() != null) { + com.jme3.anim.Armature arm = sc.getArmature(); + List joints = new ArrayList<>(); + for (int ji = 0; ji < arm.getJointCount(); ji++) { + joints.add(arm.getJoint(ji).getName()); + } + Collections.sort(joints); + LOG.info("[AnimPreview] Armature: {} joints gefunden", joints.size()); + input.animPreviewJointNames.set(Collections.unmodifiableList(joints)); + } else { + LOG.warn("[AnimPreview] Kein SkinningControl/Armature gefunden in: {}", assetPath); + input.animPreviewJointNames.set(List.of()); + } if (clips.isEmpty()) { if (!hasSkeleton(model)) { input.animPreviewStatus = @@ -289,8 +304,10 @@ public class AnimPreviewState extends BaseAppState { input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)"; } } catch (Exception e) { + LOG.error("[AnimPreview] Ladefehler: {}", assetPath, e); input.animPreviewStatus = "Ladefehler: " + e.getMessage(); input.animPreviewClips.set(List.of()); + input.animPreviewJointNames.set(List.of()); } } @@ -397,13 +414,10 @@ public class AnimPreviewState extends BaseAppState { // ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ─────────── private void addAnimation(String animAssetPath) { - if (currentModel == null) { - input.animPreviewStatus = "Fehler: zuerst ein Modell laden"; - return; - } - AnimComposer targetAC = findControl(currentModel, AnimComposer.class); - SkinningControl targetSC = findControl(currentModel, SkinningControl.class); - if (targetAC == null) { + // Kein Modell geladen → kein Retargeting, aber Clip trotzdem als J3O speichern + AnimComposer targetAC = currentModel != null ? findControl(currentModel, AnimComposer.class) : null; + SkinningControl targetSC = currentModel != null ? findControl(currentModel, SkinningControl.class) : null; + if (currentModel != null && targetAC == null) { input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer"; return; } @@ -428,14 +442,14 @@ public class AnimPreviewState extends BaseAppState { com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null; if (srcArm != null) { - LOG.info("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount()); - for (var j : srcArm.getJointList()) LOG.info(" src: {}", j.getName()); + LOG.trace("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount()); + for (var j : srcArm.getJointList()) LOG.trace(" src: {}", j.getName()); } else { LOG.warn("[Retarget] Keine SkinningControl in Quelle!"); } if (dstArm != null) { - LOG.info("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount()); - for (var j : dstArm.getJointList()) LOG.info(" dst: {}", j.getName()); + LOG.trace("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount()); + for (var j : dstArm.getJointList()) LOG.trace(" dst: {}", j.getName()); } else { LOG.warn("[Retarget] Keine SkinningControl im Modell!"); } @@ -443,7 +457,7 @@ public class AnimPreviewState extends BaseAppState { boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm; if (retarget) { var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm); - LOG.info("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping); + LOG.trace("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping); } java.util.Set srcNames = new java.util.HashSet<>(); @@ -452,6 +466,15 @@ public class AnimPreviewState extends BaseAppState { java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips"); java.nio.file.Files.createDirectories(clipsDir); + // Blender exportiert GLB-Dateien oft mit internem Namen "Action" statt dem Dateinamen. + // Bei einer GLB/GLTF-Datei mit genau einem Clip: Dateinamen als Clip-Namen verwenden. + boolean isSingleClipGlb = animAssetPath.matches(".*\\.(glb|gltf)$") + && sourceAC.getAnimClips().size() == 1; + String fileBaseName = isSingleClipGlb + ? java.nio.file.Paths.get(animAssetPath).getFileName().toString() + .replaceFirst("\\.(glb|gltf)$", "") + : null; + int saved = 0; for (AnimClip clip : sourceAC.getAnimClips()) { String name = clip.getName(); @@ -467,19 +490,38 @@ public class AnimPreviewState extends BaseAppState { : clip; if (result == null) continue; + String saveName = (fileBaseName != null) ? fileBaseName : name; + AnimClip toSave = result; + if (!saveName.equals(result.getName())) { + toSave = new AnimClip(saveName); + toSave.setTracks(result.getTracks()); + LOG.info("[AnimPreview] Clip '{}' als '{}' gespeichert (Dateiname-Alias)", name, saveName); + } + // Direkt in die Clip-Bibliothek speichern – das Modell wird nicht modifiziert - saveClipToFile(result, dstArm != null ? dstArm : srcArm, - clipsDir.resolve(name + ".j3o")); - // Für den aktuellen Preview-Session auch auf das Modell anwenden - targetAC.addAnimClip(result); + saveClipToFile(toSave, dstArm != null ? dstArm : srcArm, + clipsDir.resolve(saveName + ".j3o")); + // Für den aktuellen Preview-Session auch auf das Modell anwenden (wenn geladen) + if (targetAC != null) targetAC.addAnimClip(toSave); saved++; } + // Temporäre GLB aus clips/ löschen (nur wenn sie dort drin liegt – nicht externe Dateien) + if (saved > 0 && animAssetPath.matches(".*\\.(glb|gltf)$") + && !Path.of(animAssetPath).isAbsolute()) { + Path srcGlb = ASSET_ROOT.resolve(animAssetPath.replace('/', java.io.File.separatorChar)); + try { + java.nio.file.Files.deleteIfExists(srcGlb); + LOG.info("[AnimPreview] Temporäre GLB entfernt: {}", animAssetPath); + } catch (Exception ignored) {} + } List clips = new ArrayList<>(); - collectClips(currentModel, clips); + if (currentModel != null) collectClips(currentModel, clips); input.animPreviewClips.set(Collections.unmodifiableList(clips)); input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert" + (retarget ? " (retargeted)" : " (direkt)"); + if (saved > 0) input.animImportCompleted = true; } catch (Exception e) { + LOG.error("[AnimPreview] Fehler beim Importieren von {}", animAssetPath, e); input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage(); } } @@ -576,7 +618,7 @@ public class AnimPreviewState extends BaseAppState { private void addAxisLabel(Node parent, Vector3f pos, String text, ColorRGBA color) { try { BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt"); - BitmapText label = new BitmapText(font, false); + BitmapText label = new BitmapText(font); label.setSize(0.18f); label.setColor(color); label.setText(text); @@ -625,8 +667,23 @@ public class AnimPreviewState extends BaseAppState { } } - /** Lädt eine j3o-Datei direkt von Disk (BinaryImporter), ohne AssetManager-Cache. */ + /** Lädt eine Spatial ohne AssetManager-Cache. Unterstützt asset-relative und absolute Pfade. */ private Spatial loadFresh(String assetPath) throws Exception { + // Absoluter Pfad (z. B. externe GLB vom Dateisystem): + // Verzeichnis als FileLocator registrieren, dann per Dateiname laden. + Path absFile = Path.of(assetPath); + if (absFile.isAbsolute()) { + if (!Files.exists(absFile)) + throw new java.io.FileNotFoundException("Datei nicht gefunden: " + assetPath); + assets.registerLocator( + absFile.getParent().toAbsolutePath().toString(), + com.jme3.asset.plugins.FileLocator.class); + String fileName = absFile.getFileName().toString(); + assets.deleteFromCache(new ModelKey(fileName)); + LOG.info("[AnimPreview] Lade extern: {} aus {}", fileName, absFile.getParent()); + return assets.loadModel(fileName); + } + // Asset-relativer Pfad: Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar)); if (assetPath.endsWith(".j3o") && Files.exists(file)) { BinaryImporter bi = BinaryImporter.getInstance(); @@ -827,12 +884,13 @@ public class AnimPreviewState extends BaseAppState { int embedded = 0; for (String clipName : set.getClips()) { - if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) { + String clipRelPath = resolveClipFile(clipsDir, clipName); + if (clipRelPath == null) { LOG.warn("[AnimEmbed] Clip nicht gefunden: {}", clipName); continue; } try { - Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o"); + Spatial clipSpatial = loadFresh(clipRelPath); AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class); SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class); if (clipAC == null) continue; @@ -878,6 +936,15 @@ public class AnimPreviewState extends BaseAppState { BinaryExporter.getInstance().save(holder, outFile.toFile()); } + /** Returns "animations/clips/." for the first matching file, or null if not found. */ + private static String resolveClipFile(java.nio.file.Path clipsDir, String clipName) { + for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) { + if (Files.exists(clipsDir.resolve(clipName + ext))) + return "animations/clips/" + clipName + ext; + } + return null; + } + private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) { if (a.getJointCount() != b.getJointCount()) return false; java.util.Set namesA = new java.util.HashSet<>(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java index f504ef3..e5fede1 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java @@ -59,12 +59,17 @@ public class GrassVertexState extends BaseAppState { static final ColorRGBA VERY_DRY_ROOT_COLOR = new ColorRGBA(0.18f, 0.09f, 0.02f, 1f); static final ColorRGBA VERY_DRY_TIP_COLOR = new ColorRGBA(0.38f, 0.20f, 0.05f, 1f); + // ── LOD ─────────────────────────────────────────────────────────────────── + private static final float CULL_DIST = 150f; + private static final float CULL_DIST_SQ = CULL_DIST * CULL_DIST; + // ── Zustand ─────────────────────────────────────────────────────────────── - private final SharedInput input; - private AssetManager assetManager; - private TerrainQuad terrain; - private Node grassNode; - private Material material; + private final SharedInput input; + private AssetManager assetManager; + private com.jme3.renderer.Camera cam; + private TerrainQuad terrain; + private Node grassNode; + private Material material; @SuppressWarnings("unchecked") private final List[] chunkBlades = new List[CHUNK_COUNT]; @@ -89,6 +94,7 @@ public class GrassVertexState extends BaseAppState { @Override protected void initialize(Application app) { this.assetManager = app.getAssetManager(); + this.cam = app.getCamera(); grassNode = new Node("grassVertexNode"); ((SimpleApplication) app).getRootNode().attachChild(grassNode); material = buildMaterial(); @@ -115,6 +121,22 @@ public class GrassVertexState extends BaseAppState { public void update(float tpf) { processBrushEdits(); rebuildDirtyChunks(); + updateChunkVisibility(); + } + + private void updateChunkVisibility() { + if (cam == null) return; + Vector3f camPos = cam.getLocation(); + for (int ci = 0; ci < CHUNK_COUNT; ci++) { + if (chunkNodes[ci] == null) continue; + int cx = ci % CHUNKS_PER_AXIS; + int cz = ci / CHUNKS_PER_AXIS; + float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f; + float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f; + float dx = camPos.x - wx, dz = camPos.z - wz; + boolean visible = dx*dx + dz*dz <= CULL_DIST_SQ; + chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } } // ── Material ────────────────────────────────────────────────────────────── diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java index 98dbf53..cc79805 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java @@ -14,8 +14,11 @@ import com.jme3.material.Material; import com.jme3.math.*; import com.jme3.renderer.Camera; import com.jme3.scene.*; +import com.jme3.collision.CollisionResults; +import com.jme3.math.Ray; import com.jme3.scene.shape.Box; import com.jme3.scene.shape.Cylinder; +import com.jme3.scene.shape.Dome; import com.jme3.scene.shape.Sphere; import com.jme3.util.BufferUtils; import de.blight.editor.SharedInput; @@ -67,6 +70,9 @@ public class ModelEditorState extends BaseAppState { /** Node, der alle Anhang-Gizmos (Lichter + Emitter) enthält. */ private Node attachmentGizmos = null; + /** Hauptlichtquelle der Vorschau (per UI steuerbar). */ + private DirectionalLight mainLight = null; + // gespeicherter Kamerazustand aus dem Editor-Modus private Vector3f savedCamPos; private Quaternion savedCamRot; @@ -75,6 +81,12 @@ public class ModelEditorState extends BaseAppState { private String currentPath = null; private String mainModelPath = null; + private Node interactableArrowNode = null; + private float interactableOffsetX = 0f; + private float interactableOffsetY = 0.5f; + private float interactableOffsetZ = 0f; + private float interactableRotY = 0f; + /** Originales Spatial wie vom Asset-Manager geladen – wird durch LOD-Previews nicht überschrieben. */ private Spatial originalSpatial = null; @@ -190,6 +202,15 @@ public class ModelEditorState extends BaseAppState { applyPivot(input.modelEditorPivotY); } + // Lichtrichtung + if (input.modelEditorLightChanged) { + input.modelEditorLightChanged = false; + if (mainLight != null) { + mainLight.setDirection(computeLightDirection( + input.modelEditorLightAzimuth, input.modelEditorLightElevation)); + } + } + // Anhang-Gizmos aktualisieren if (input.modelEditorAttachmentsChanged) { input.modelEditorAttachmentsChanged = false; @@ -233,6 +254,43 @@ public class ModelEditorState extends BaseAppState { repositionCompCylinder(); } + // Interactable-Ruhepunkt: Position + RotY aus JavaFX übernehmen + if (input.modelInteractableOffsetChanged) { + input.modelInteractableOffsetChanged = false; + interactableOffsetX = input.modelInteractableOffsetX; + interactableOffsetY = input.modelInteractableOffsetY; + interactableOffsetZ = input.modelInteractableOffsetZ; + interactableRotY = input.modelInteractableRotY; + rebuildInteractableArrow(); + } + + // Interactable-Ruhepunkt: Klick → Raycast gegen Modell + SharedInput.ModelInteractableClick click; + while ((click = input.modelInteractableClickQueue.poll()) != null) { + if (previewRoot == null || modelWrapper == null) break; + float jmeX = click.screenX() * (float) input.viewportScaleX; + float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; + Ray ray = screenToRay(jmeX, jmeY); + CollisionResults hits = new CollisionResults(); + previewRoot.collideWith(ray, hits); + if (hits.size() > 0) { + Vector3f pt = hits.getClosestCollision().getContactPoint(); + // Modell-Ursprung berücksichtigen (Pivot-Versatz) + Vector3f modelOrig = modelWrapper.getWorldTranslation(); + interactableOffsetX = pt.x - modelOrig.x; + interactableOffsetY = pt.y - modelOrig.y; + interactableOffsetZ = pt.z - modelOrig.z; + input.modelInteractableOffsetX = interactableOffsetX; + input.modelInteractableOffsetY = interactableOffsetY; + input.modelInteractableOffsetZ = interactableOffsetZ; + input.modelInteractableOffsetChanged = true; + input.modelInteractablePosSetFromJme = true; + // Layer zurücksetzen + input.activeLayer = SharedInput.LAYER_MODEL_EDITOR; + rebuildInteractableArrow(); + } + } + applyOrbitCamera(); previewRoot.updateLogicalState(tpf); @@ -382,9 +440,10 @@ public class ModelEditorState extends BaseAppState { app.getRootNode().setCullHint(Spatial.CullHint.Always); previewRoot = new Node("model_editor_preview"); - previewRoot.addLight(new DirectionalLight( - new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(), - new ColorRGBA(1.8f, 1.7f, 1.4f, 1f))); + mainLight = new DirectionalLight( + computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation), + new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)); + previewRoot.addLight(mainLight); previewRoot.addLight(new DirectionalLight( new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(), new ColorRGBA(0.5f, 0.55f, 0.75f, 1f))); @@ -393,11 +452,18 @@ public class ModelEditorState extends BaseAppState { new ColorRGBA(0.4f, 0.4f, 0.5f, 1f))); previewRoot.addLight(new AmbientLight(new ColorRGBA(0.65f, 0.65f, 0.7f, 1f))); - // Hintergrundfarbe setzen; Viewport-Attach erfolgt NACH dem Modell-Load - // (in loadModel / showSpatialDirectly), damit JME3 beim ersten Render - // eine vollständig initialisierte Szene vorfindet und nicht schwarz rendert. app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f)); + interactableArrowNode = new Node("interactableArrow"); + interactableArrowNode.setCullHint(Spatial.CullHint.Always); + previewRoot.attachChild(interactableArrowNode); + + // Offset aus SharedInput übernehmen (gesetzt beim Öffnen des Panels) + interactableOffsetX = input.modelInteractableOffsetX; + interactableOffsetY = input.modelInteractableOffsetY; + interactableOffsetZ = input.modelInteractableOffsetZ; + interactableRotY = input.modelInteractableRotY; + orbitYaw = 30f; orbitPitch = 25f; } @@ -418,6 +484,7 @@ public class ModelEditorState extends BaseAppState { hasEmbeddedLods = false; embeddedLodSpatials = null; originalSpatial = null; + interactableArrowNode = null; input.modelEditorHasEmbeddedLods = false; app.getRootNode().setCullHint(Spatial.CullHint.Inherit); @@ -730,6 +797,81 @@ public class ModelEditorState extends BaseAppState { } } + /** Zeichnet / aktualisiert den Ruhepunkt-Pfeil im Modell-Editor. */ + public void rebuildInteractableArrow() { + if (interactableArrowNode == null) return; + interactableArrowNode.detachAllChildren(); + + float shaftLen = 0.8f; + float shaftRad = 0.04f; + float headRad = 0.12f; + + Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(0f, 0.8f, 1f, 1f)); + Material matHead = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); + matHead.setColor("Color", new ColorRGBA(0f, 0.5f, 1f, 1f)); + + // Träger-Node an der Ruheposition; alle Kinder liegen lokal entlang +Z + Node group = new Node("arrowGroup"); + group.setLocalTranslation(interactableOffsetX, interactableOffsetY, interactableOffsetZ); + + float dx = (float) Math.cos(interactableRotY); + float dz = (float) Math.sin(interactableRotY); + Quaternion groupRot = new Quaternion(); + groupRot.lookAt(new Vector3f(dx, 0f, dz), Vector3f.UNIT_Y); + group.setLocalRotation(groupRot); + + // Marker-Würfel am Ruhepunkt (Ursprung der Gruppe) + Geometry marker = new Geometry("iMarker", new Box(0.06f, 0.06f, 0.06f)); + marker.setMaterial(mat); + + // Schaft: JME3-Cylinder liegt auf der Z-Achse; Mitte bei shaftLen/2 → geht von 0 bis shaftLen + Geometry shaft = new Geometry("iShaft", new Cylinder(4, 8, shaftRad, shaftLen, true)); + shaft.setMaterial(mat); + shaft.setLocalTranslation(0f, 0f, shaftLen * 0.5f); + + // Kegelspitze: Dome-Spitze zeigt per Default nach +Y → +90° um X dreht sie nach +Z + Geometry head = new Geometry("iHead", new Dome(Vector3f.ZERO, 2, 8, headRad, false)); + head.setMaterial(matHead); + head.setLocalTranslation(0f, 0f, shaftLen); + Quaternion headRot = new Quaternion(); + headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X); + head.setLocalRotation(headRot); + + group.attachChild(marker); + group.attachChild(shaft); + group.attachChild(head); + interactableArrowNode.attachChild(group); + interactableArrowNode.setCullHint(Spatial.CullHint.Inherit); + } + + /** Setzt den Pfeil sichtbar/unsichtbar. */ + public void setInteractableArrowVisible(boolean visible) { + if (interactableArrowNode != null) + interactableArrowNode.setCullHint( + visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } + + /** Initialisiert Offsets aus dem ModelMeta wenn ein neues Modell geöffnet wird. */ + public void applyInteractableOffset(float ox, float oy, float oz, float rotY) { + interactableOffsetX = ox; + interactableOffsetY = oy; + interactableOffsetZ = oz; + interactableRotY = rotY; + input.modelInteractableOffsetX = ox; + input.modelInteractableOffsetY = oy; + input.modelInteractableOffsetZ = oz; + input.modelInteractableRotY = rotY; + rebuildInteractableArrow(); + } + + private Ray screenToRay(float screenX, float screenY) { + Vector3f origin = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 0f); + Vector3f dir = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 1f) + .subtractLocal(origin).normalizeLocal(); + return new Ray(origin, dir); + } + public String getCurrentPath() { return currentPath; } /** @@ -753,4 +895,18 @@ public class ModelEditorState extends BaseAppState { public BoundingBox getCurrentBounds() { return getBoundingBox(); } + + /** + * Berechnet den normalisierten Richtungsvektor der Lichtquelle aus Azimut und Elevation. + * Azimut 0° = Licht kommt aus Z+; Elevation 90° = Licht kommt senkrecht von oben. + */ + private static Vector3f computeLightDirection(float azimuthDeg, float elevationDeg) { + float azi = (float) Math.toRadians(azimuthDeg); + float ele = (float) Math.toRadians(elevationDeg); + float cosEle = (float) Math.cos(ele); + float dx = -cosEle * (float) Math.sin(azi); + float dy = -(float) Math.sin(ele); + float dz = -cosEle * (float) Math.cos(azi); + return new Vector3f(dx, dy, dz).normalizeLocal(); + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java new file mode 100644 index 0000000..bc5a85c --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java @@ -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. + * + *

Interaktionsmodell: + *

    + *
  • Linksklick auf leeres Terrain → neuen Knoten platzieren
  • + *
  • Linksklick auf Knoten → Knoten auswählen; ist bereits einer gewählt, + * wird eine Kante zwischen beiden erzeugt
  • + *
  • Linksklick auf Terrain mit gewähltem Knoten → neuen Knoten platzieren + * UND mit dem gewählten Knoten verbinden
  • + *
  • Rechtsklick auf Knoten → Knoten und alle seine Kanten löschen
  • + *
  • Rechtsklick auf Terrain → Auswahl aufheben
  • + *
+ * + * Ä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(); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/RoutineMapState.java b/blight-editor/src/main/java/de/blight/editor/state/RoutineMapState.java new file mode 100644 index 0000000..90bba10 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/RoutineMapState.java @@ -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 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 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(); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index 6adc5ae..4ff41e5 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -24,7 +24,12 @@ import com.jme3.scene.shape.Torus; import com.jme3.texture.Texture; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.export.binary.BinaryExporter; +import com.jme3.scene.shape.Dome; import de.blight.common.PlacedModel; +import de.blight.common.model.Bed; +import de.blight.common.model.BedIO; +import de.blight.common.model.Bench; +import de.blight.common.model.BenchIO; import de.blight.editor.SharedInput; import de.blight.editor.object.SceneObject; @@ -104,6 +109,20 @@ public class SceneObjectState extends BaseAppState { private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt) private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi) + + /** Kleiner Cache: modelPath → ModelMeta, damit nicht pro Frame geladen werden muss. */ + private final java.util.Map metaCache = new java.util.HashMap<>(); + + // ── Bett-Liegefläche ───────────────────────────────────────────────────── + /** Visualisierungspfeil für die Liegefläche (1,8 m); wird nur gezeigt wenn Bett-Objekt gewählt. */ + private Node bedArrowNode = null; + /** UUID des Bettes, für das der Pfeil gerade angezeigt wird. */ + private String bedArrowBedId = null; + + /** Visualisierungspfeil für die Sitzfläche (0,5 m); wird nur gezeigt wenn Bank-Objekt gewählt. */ + private Node benchArrowNode = null; + /** UUID der Bank, für die der Pfeil gerade angezeigt wird. */ + private String benchArrowBenchId = null; private int subTriIdx = -1; private int[] subEdgeVertIdx = null; // [v0, v1] Vertex-Indizes im Mesh (Kanten-Modus) private int subVertexIdx = -1; // einzelner Vertex-Index (Punkt-Modus) @@ -151,13 +170,15 @@ public class SceneObjectState extends BaseAppState { meshFile, animClips.get(i), so.castShadow, so.receiveShadow, so.lod1Path, so.lod2Path, - so.lod1Distance, so.lod2Distance, so.cullDistance)); + so.lod1Distance, so.lod2Distance, so.cullDistance, + so.interactableType, so.interactableId)); } return list; } public void loadPlacedModels(List models) { if (objectRoot == null) return; + metaCache.clear(); objectRoot.detachAllChildren(); objects.clear(); objNodes.clear(); @@ -177,9 +198,11 @@ public class SceneObjectState extends BaseAppState { so.receiveShadow = pm.receiveShadow(); so.lod1Path = pm.lod1Path() != null ? pm.lod1Path() : ""; so.lod2Path = pm.lod2Path() != null ? pm.lod2Path() : ""; - so.lod1Distance = pm.lod1Distance(); - so.lod2Distance = pm.lod2Distance(); - so.cullDistance = pm.cullDistance(); + so.lod1Distance = pm.lod1Distance(); + so.lod2Distance = pm.lod2Distance(); + so.cullDistance = pm.cullDistance(); + so.interactableType = pm.interactableType() != null ? pm.interactableType() : ""; + so.interactableId = pm.interactableId() != null ? pm.interactableId() : ""; objects.add(so); animClips.add(pm.animClip() != null ? pm.animClip() : ""); @@ -246,6 +269,14 @@ public class SceneObjectState extends BaseAppState { subOverlay = new Node("subOverlay"); subOverlay.setCullHint(Spatial.CullHint.Always); rootNode.attachChild(subOverlay); + + bedArrowNode = new Node("bedArrow"); + bedArrowNode.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(bedArrowNode); + + benchArrowNode = new Node("benchArrow"); + benchArrowNode.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(benchArrowNode); } @Override @@ -254,6 +285,8 @@ public class SceneObjectState extends BaseAppState { gizmoNode.removeFromParent(); previewNode.removeFromParent(); subOverlay.removeFromParent(); + bedArrowNode.removeFromParent(); + benchArrowNode.removeFromParent(); } @Override protected void onEnable() {} @@ -294,6 +327,11 @@ public class SceneObjectState extends BaseAppState { try { loadPlacedModels(de.blight.common.PlacedModelIO.load()); } catch (Exception ignored) {} } + // Bett-Liegefläche platzieren + handleBedLiegeLayer(); + // Bank-Sitzfläche platzieren + handleBenchSitzLayer(); + boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT; if (!isObjectLayer) return; @@ -309,6 +347,21 @@ public class SceneObjectState extends BaseAppState { } } + // Interactable-Zuweisung von JavaFX + String pendingIType = input.pendingInteractableType; + String pendingIId = input.pendingInteractableId; + if (pendingIType != null && pendingIId != null) { + input.pendingInteractableType = null; + input.pendingInteractableId = null; + if (!selectedIndices.isEmpty()) { + SceneObject so = objects.get(selectedIndices.get(0)); + so.interactableType = pendingIType; + so.interactableId = pendingIId; + } + refreshBedArrow(); + refreshBenchArrow(); + } + // Solid-Flag-Änderung von JavaFX Boolean solidChange = input.pendingSolidChange; if (solidChange != null) { @@ -590,6 +643,33 @@ public class SceneObjectState extends BaseAppState { so.lod1Distance = meta.lod1Distance(); so.lod2Distance = meta.lod2Distance(); so.cullDistance = meta.cullDistance(); + if (meta.interactableType() != null + && meta.interactableType() != de.blight.common.model.InteractableType.NONE) { + so.interactableType = meta.interactableType().name(); + // Bed/Bench-Instanz mit Welt-Koordinaten (Modellpos + rotierter Offset) anlegen + float cos = (float) Math.cos(rotY); + float sin = (float) Math.sin(rotY); + float ox = meta.interactableOffsetX(); + float oy = meta.interactableOffsetY(); + float oz = meta.interactableOffsetZ(); + float wx2 = wx + ox * cos - oz * sin; + float wy2 = wy + placementOffY + oy; + float wz2 = wz + ox * sin + oz * cos; + float roty2 = rotY + meta.interactableRotY(); + if (meta.interactableType() == de.blight.common.model.InteractableType.BED) { + Bed bed = new Bed(); + bed.setLiegeX(wx2); bed.setLiegeY(wy2); bed.setLiegeZ(wz2); + bed.setLiegeRotY(roty2); bed.setLiegeSet(true); + try { BedIO.save(bed); so.interactableId = bed.getId(); } + catch (java.io.IOException e) { log.error("BedIO save: {}", e.getMessage()); } + } else if (meta.interactableType() == de.blight.common.model.InteractableType.BENCH) { + Bench bench = new Bench(); + bench.setSitzX(wx2); bench.setSitzY(wy2); bench.setSitzZ(wz2); + bench.setSitzRotY(roty2); bench.setSitzSet(true); + try { BenchIO.save(bench); so.interactableId = bench.getId(); } + catch (java.io.IOException e) { log.error("BenchIO save: {}", e.getMessage()); } + } + } } so.setRotation(0f, rotY, 0f); so.setScale(defaultScale); @@ -885,11 +965,14 @@ public class SceneObjectState extends BaseAppState { + "|" + so.getScale() + "|" + so.getTexturePath() + "|" + so.getNormalMapPath() + "|" + so.getMaterialPath() + "|" + animClips.get(idx) - + "|" + so.castShadow + "|" + so.receiveShadow; + + "|" + so.castShadow + "|" + so.receiveShadow + + "|" + so.interactableType + "|" + so.interactableId; } else { input.selectedObjectInfo = String.valueOf(n); } input.objectSelectionChanged = true; + refreshBedArrow(); + refreshBenchArrow(); } // ── Gizmo-Drag ─────────────────────────────────────────────────────────── @@ -1430,6 +1513,8 @@ public class SceneObjectState extends BaseAppState { sortedDesc.sort(Comparator.reverseOrder()); for (int idx : sortedDesc) { + SceneObject so = objects.get(idx); + deleteInteractableFile(so); objectRoot.detachChild(objNodes.get(idx)); objects.remove(idx); objNodes.remove(idx); @@ -1446,6 +1531,69 @@ public class SceneObjectState extends BaseAppState { setStatus("Objekt(e) gelöscht"); } + private void deleteInteractableFile(SceneObject so) { + if (so.interactableId == null || so.interactableId.isEmpty()) return; + try { + if ("BED".equalsIgnoreCase(so.interactableType)) BedIO.delete(so.interactableId); + else if ("BENCH".equalsIgnoreCase(so.interactableType)) BenchIO.delete(so.interactableId); + } catch (java.io.IOException e) { + log.warn("[SceneObject] Interactable-Datei nicht gelöscht: {}", e.getMessage()); + } + } + + /** + * Synchronisiert alle Bed/Bench-JSON-Dateien mit den aktuellen + * Positionen und Rotationen der platzierten Objekte. + * Wird vor dem Karten-Speichern aufgerufen. + */ + public void syncInteractables() { + for (SceneObject so : objects) { + if (so.interactableId == null || so.interactableId.isEmpty()) continue; + String itype = so.interactableType; + if (itype == null || itype.isEmpty()) continue; + boolean isBed = "BED".equalsIgnoreCase(itype); + boolean isBench = "BENCH".equalsIgnoreCase(itype); + if (!isBed && !isBench) continue; + + // Modell-Meta laden (enthält lokale Offsets des Ruhepunkts) + Path modelPath = ASSET_ROOT.resolve(so.modelPath); + if (!java.nio.file.Files.exists(modelPath)) continue; + de.blight.common.ModelMeta meta = de.blight.common.ModelMetaIO.load(modelPath); + + float rotY = so.getRotY(); + float cos = (float) Math.cos(rotY); + float sin = (float) Math.sin(rotY); + float ox = meta.interactableOffsetX(); + float oy = meta.interactableOffsetY(); + float oz = meta.interactableOffsetZ(); + + float wx = so.getWorldX() + ox * cos - oz * sin; + float wy = so.getGroundY() + oy; + float wz = so.getWorldZ() + ox * sin + oz * cos; + float iRotY = rotY + meta.interactableRotY(); + + try { + if (isBed) { + BedIO.load(so.interactableId).ifPresent(bed -> { + bed.setLiegeX(wx); bed.setLiegeY(wy); bed.setLiegeZ(wz); + bed.setLiegeRotY(iRotY); bed.setLiegeSet(true); + try { BedIO.save(bed); } + catch (java.io.IOException e) { log.error("[SceneObject] Bett sync: {}", e.getMessage()); } + }); + } else { + BenchIO.load(so.interactableId).ifPresent(bench -> { + bench.setSitzX(wx); bench.setSitzY(wy); bench.setSitzZ(wz); + bench.setSitzRotY(iRotY); bench.setSitzSet(true); + try { BenchIO.save(bench); } + catch (java.io.IOException e) { log.error("[SceneObject] Bank sync: {}", e.getMessage()); } + }); + } + } catch (Exception e) { + log.error("[SceneObject] syncInteractables Fehler: {}", e.getMessage()); + } + } + } + // ── Zusammenfassen ──────────────────────────────────────────────────────── private void mergeSelected() { @@ -1932,4 +2080,242 @@ public class SceneObjectState extends BaseAppState { local.z /= wScale.z; return local; } + + // ── Bett-Liegefläche ───────────────────────────────────────────────────── + + /** + * Verarbeitet Terrain-Klicks im LAYER_BED_LIEGE-Modus und Rotations-Änderungen. + * Wird in update() immer aufgerufen (unabhängig von isObjectLayer). + */ + private void handleBedLiegeLayer() { + // Rotations-Update von JavaFX (kann immer ankommen, auch ohne Klick) + Float rotPending = input.pendingBedLiegeRotY; + if (rotPending != null) { + input.pendingBedLiegeRotY = null; + String bedId = input.bedLiegeTargetId; + if (bedId != null && !bedId.isBlank()) { + Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId)); + bed.setLiegeRotY(rotPending); + if (!bed.isLiegeSet()) { bed.setLiegeSet(true); } + try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); } + placeBedArrow(bed); + } + } + + if (input.activeLayer != SharedInput.LAYER_BED_LIEGE) return; + + SharedInput.BedLiegeClick click; + while ((click = input.bedLiegeClickQueue.poll()) != null) { + if (terrain == null) continue; + float jmeX = click.screenX() * (float) input.viewportScaleX; + float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; + Ray ray = screenToRay(jmeX, jmeY); + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() == 0) continue; + Vector3f pt = hits.getClosestCollision().getContactPoint(); + + String bedId = input.bedLiegeTargetId; + if (bedId == null || bedId.isBlank()) continue; + + Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId)); + bed.setLiegeX(pt.x); + bed.setLiegeY(pt.y); + bed.setLiegeZ(pt.z); + bed.setLiegeSet(true); + try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); } + + input.bedLiegePickResult = pt.x + "|" + pt.y + "|" + pt.z; + input.bedLiegePickChanged = true; + + // Zurück zu Objekt-Bearbeitung + input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT; + placeBedArrow(bed); + } + } + + private de.blight.common.ModelMeta loadMetaCached(String modelPath) { + return metaCache.computeIfAbsent(modelPath, p -> { + Path full = ASSET_ROOT.resolve(p); + if (!java.nio.file.Files.exists(full)) return null; + return de.blight.common.ModelMetaIO.load(full); + }); + } + + /** Gibt [wx, wy, wz, totalRotY] aus SceneObject-Transform + ModelMeta zurück, oder null wenn kein Meta. */ + private float[] computeInteractableWorldPos(SceneObject so) { + de.blight.common.ModelMeta meta = loadMetaCached(so.modelPath); + if (meta == null) return null; + float rotY = so.getRotY(); + float cos = (float) Math.cos(rotY); + float sin = (float) Math.sin(rotY); + float ox = meta.interactableOffsetX(); + float oy = meta.interactableOffsetY(); + float oz = meta.interactableOffsetZ(); + float wx = so.getWorldX() + ox * cos - oz * sin; + float wy = so.getGroundY() + oy; + float wz = so.getWorldZ() + ox * sin + oz * cos; + return new float[]{wx, wy, wz, rotY + meta.interactableRotY()}; + } + + /** + * Aktualisiert die Bett-Pfeil-Visualisierung für das aktuell gewählte Objekt. + * Zeigt den Pfeil wenn genau ein Objekt gewählt ist, es ein Bett ist und die Liegefläche gesetzt. + */ + private void refreshBedArrow() { + if (selectedIndices.size() != 1) { hideBedArrow(); return; } + SceneObject so = objects.get(selectedIndices.get(0)); + if (!"BED".equals(so.interactableType) || so.interactableId.isBlank()) { hideBedArrow(); return; } + bedArrowBedId = so.interactableId; + // Weltkoordinaten live aus aktuellem Transform + Meta berechnen + float[] wp = computeInteractableWorldPos(so); + if (wp != null) { + placeBedArrow(wp[0], wp[1], wp[2], wp[3]); + } else { + // Fallback: aus JSON (z.B. manuell gesetztes Bett ohne Meta-Offset) + Bed bed = BedIO.load(so.interactableId).orElse(null); + if (bed == null || !bed.isLiegeSet()) { hideBedArrow(); return; } + placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY()); + } + } + + private void hideBedArrow() { + bedArrowNode.detachAllChildren(); + bedArrowNode.setCullHint(Spatial.CullHint.Always); + bedArrowBedId = null; + } + + private void placeBedArrow(Bed bed) { + placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY()); + } + + private void placeBedArrow(float wx, float wy, float wz, float rotY) { + bedArrowNode.detachAllChildren(); + Node g = buildDirectionalArrow(1.5f, 0.06f, 0.18f, + new ColorRGBA(1f, 0.5f, 0f, 1f), + new ColorRGBA(1f, 0.2f, 0f, 1f)); + g.setLocalTranslation(wx, wy + 0.05f, wz); + Quaternion q = new Quaternion(); + q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y); + g.setLocalRotation(q); + bedArrowNode.attachChild(g); + bedArrowNode.setCullHint(Spatial.CullHint.Inherit); + } + + /** + * Baut einen Pfeil entlang der lokalen +Z-Achse: + * Schaft (Cylinder, zentriert bei z=0) + Kegelspitze am positiven Ende. + * Der Aufrufer positioniert und rotiert den zurückgegebenen Node. + */ + private Node buildDirectionalArrow(float shaftLen, float shaftRad, float headRad, + ColorRGBA shaftColor, ColorRGBA headColor) { + Node group = new Node("arrowGroup"); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", shaftColor); + Material matH = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + matH.setColor("Color", headColor); + + // Schaft: JME3-Cylinder liegt auf der Z-Achse, zentriert bei z=0 + Geometry shaft = new Geometry("shaft", new Cylinder(4, 8, shaftRad, shaftLen, true)); + shaft.setMaterial(mat); + + // Kegelspitze am positiven Ende: Dome-Spitze zeigt +Y per Default; + // +90° um X dreht sie nach +Z (vorwärts) + Geometry head = new Geometry("head", new Dome(Vector3f.ZERO, 2, 8, headRad, false)); + head.setMaterial(matH); + head.setLocalTranslation(0f, 0f, shaftLen * 0.5f); + Quaternion headRot = new Quaternion(); + headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X); + head.setLocalRotation(headRot); + + group.attachChild(shaft); + group.attachChild(head); + return group; + } + + // ── Bank-Sitzfläche ─────────────────────────────────────────────────────── + + private void handleBenchSitzLayer() { + Float rotPending = input.pendingBenchSitzRotY; + if (rotPending != null) { + input.pendingBenchSitzRotY = null; + String benchId = input.benchSitzTargetId; + if (benchId != null && !benchId.isBlank()) { + Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId)); + bench.setSitzRotY(rotPending); + if (!bench.isSitzSet()) bench.setSitzSet(true); + try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); } + placeBenchArrow(bench); + } + } + + if (input.activeLayer != SharedInput.LAYER_BENCH_SITZ) return; + + SharedInput.BenchSitzClick click; + while ((click = input.benchSitzClickQueue.poll()) != null) { + if (terrain == null) continue; + float jmeX = click.screenX() * (float) input.viewportScaleX; + float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; + Ray ray = screenToRay(jmeX, jmeY); + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() == 0) continue; + Vector3f pt = hits.getClosestCollision().getContactPoint(); + + String benchId = input.benchSitzTargetId; + if (benchId == null || benchId.isBlank()) continue; + + Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId)); + bench.setSitzX(pt.x); + bench.setSitzY(pt.y); + bench.setSitzZ(pt.z); + bench.setSitzSet(true); + try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); } + + input.benchSitzPickResult = pt.x + "|" + pt.y + "|" + pt.z; + input.benchSitzPickChanged = true; + + input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT; + placeBenchArrow(bench); + } + } + + private void refreshBenchArrow() { + if (selectedIndices.size() != 1) { hideBenchArrow(); return; } + SceneObject so = objects.get(selectedIndices.get(0)); + if (!"BENCH".equals(so.interactableType) || so.interactableId.isBlank()) { hideBenchArrow(); return; } + benchArrowBenchId = so.interactableId; + float[] wp = computeInteractableWorldPos(so); + if (wp != null) { + placeBenchArrow(wp[0], wp[1], wp[2], wp[3]); + } else { + Bench bench = BenchIO.load(so.interactableId).orElse(null); + if (bench == null || !bench.isSitzSet()) { hideBenchArrow(); return; } + placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY()); + } + } + + private void hideBenchArrow() { + benchArrowNode.detachAllChildren(); + benchArrowNode.setCullHint(Spatial.CullHint.Always); + benchArrowBenchId = null; + } + + private void placeBenchArrow(Bench bench) { + placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY()); + } + + private void placeBenchArrow(float wx, float wy, float wz, float rotY) { + benchArrowNode.detachAllChildren(); + Node g = buildDirectionalArrow(0.4f, 0.04f, 0.10f, + new ColorRGBA(0.2f, 0.6f, 1f, 1f), + new ColorRGBA(0f, 0.3f, 1f, 1f)); + g.setLocalTranslation(wx, wy + 0.05f, wz); + Quaternion q = new Quaternion(); + q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y); + g.setLocalRotation(q); + benchArrowNode.attachChild(g); + benchArrowNode.setCullHint(Spatial.CullHint.Inherit); + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java new file mode 100644 index 0000000..a7a70c3 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java @@ -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 meshes = new LinkedHashMap<>(); + private final Map sessionSnapshot = new HashMap<>(); + private final Deque> undoStack = new ArrayDeque<>(); + private final Deque> 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 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[] 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 snap = undoStack.pollLast(); + Map redoSnap = new HashMap<>(); + for (Map.Entry 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 snap = redoStack.pollLast(); + Map undoSnap = new HashMap<>(); + for (Map.Entry 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 snap) { + for (Map.Entry 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); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java new file mode 100644 index 0000000..d1a98b4 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/StoneEditorState.java @@ -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[] 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 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 verts = new ArrayList<>(Arrays.asList(ICO_V)); + List faces = new ArrayList<>(Arrays.asList(ICO_F)); + + for (int s = 0; s < subdivisions; s++) { + List nv = new ArrayList<>(verts); + List nf = new ArrayList<>(); + Map 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 verts, Map 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 all = new ArrayList<>(); + for (List 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()); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 0d4c9f5..b95b752 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -46,6 +46,8 @@ import de.blight.common.MapData; import de.blight.common.MapIO; import de.blight.common.PlacedModelIO; import de.blight.editor.SharedInput; +import de.blight.editor.state.PathNetworkEditorState; +import de.blight.editor.state.RoutineMapState; import de.blight.editor.tool.HeightTool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -81,6 +83,7 @@ public class TerrainEditorState extends BaseAppState { // ── Kamera ──────────────────────────────────────────────────────────────── private static final float CAM_SPEED = 300f; + private static final float MAX_CAM_Y = 1500f; private static final float ORBIT_SPEED = 1.5f; private static final float MOUSE_SENS = 0.003f; @@ -96,6 +99,7 @@ public class TerrainEditorState extends BaseAppState { private Geometry brushIndicator; private PlacedObjectState placedObjectState; private GrassVertexState grassVertexState; + private StoneEditorState stoneEditorState; private SceneObjectState sceneObjState; private ItemPlacementState itemPlacementState; private LightState lightState; @@ -238,6 +242,11 @@ public class TerrainEditorState extends BaseAppState { grassVertexState.setTerrain(terrain); app.getStateManager().attach(grassVertexState); + input.loadingStatus = "Lade Steine..."; + stoneEditorState = new StoneEditorState(input); + stoneEditorState.setTerrain(terrain); + app.getStateManager().attach(stoneEditorState); + sceneObjState = app.getStateManager().getState(SceneObjectState.class); if (sceneObjState != null) { sceneObjState.setTerrain(terrain); @@ -338,6 +347,12 @@ public class TerrainEditorState extends BaseAppState { PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class); if (playToolState != null) playToolState.setTerrain(terrain); + RoutineMapState routineMapState = app.getStateManager().getState(RoutineMapState.class); + if (routineMapState != null) routineMapState.setTerrain(terrain); + + PathNetworkEditorState pathNetState = app.getStateManager().getState(PathNetworkEditorState.class); + if (pathNetState != null) pathNetState.setTerrain(terrain); + rootNode.attachChild(buildWater()); rootNode.attachChild(buildGrid()); @@ -1143,6 +1158,7 @@ public class TerrainEditorState extends BaseAppState { // ── Platzierte Objekte synchron speichern (kleine Textdateien) ────────── // Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen // Löschoperationen aus dem JavaFX-Thread entsteht. + if (sceneObjState != null) sceneObjState.syncInteractables(); try { if (models != null) PlacedModelIO.save(models); } catch (IOException e) { log.error("Modelle speichern", e); } try { if (lights != null) LightIO.save(lights); } catch (IOException e) { log.error("Lichter speichern", e); } try { if (emitters != null) EmitterIO.save(emitters); } catch (IOException e) { log.error("Emitter speichern", e); } @@ -1151,6 +1167,7 @@ public class TerrainEditorState extends BaseAppState { try { if (soundAreas != null) SoundAreaIO.save(soundAreas); } catch (IOException e) { log.error("Soundbereiche speichern", e); } try { if (areas != null) AreaIO.save(areas); } catch (IOException e) { log.error("Bereiche speichern", e); } try { if (locationZones != null) LocationZoneIO.save(locationZones); } catch (IOException e) { log.error("Zonen speichern", e); } + if (stoneEditorState != null) stoneEditorState.saveIfModified(); // ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─ saveExecutor.submit(() -> { @@ -1240,7 +1257,10 @@ public class TerrainEditorState extends BaseAppState { || layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS || layer == SharedInput.LAYER_LOCATION_ZONES || layer == SharedInput.LAYER_PLAY_TOOL - || layer == SharedInput.LAYER_VOXEL || mx < 0) { + || layer == SharedInput.LAYER_VOXEL || layer == SharedInput.LAYER_STONE + || layer == SharedInput.LAYER_BED_LIEGE + || layer == SharedInput.LAYER_BENCH_SITZ + || mx < 0) { brushIndicator.setCullHint(Spatial.CullHint.Always); return; } @@ -1347,6 +1367,10 @@ public class TerrainEditorState extends BaseAppState { float delta = (float) input.heightTool.brushStrength.getValue() * edit.action(); modifyHeight(contact, delta, mode); } + if (terrainChanged) { + float br = (float) input.heightTool.brushRadius.getValue(); + input.terrainEditedAreas.offer(new float[]{contact.x, contact.z, br}); + } } if (processed > 0) terrain.updateModelBound(); } @@ -1517,7 +1541,9 @@ public class TerrainEditorState extends BaseAppState { private float terrainDistBelow() { if (terrain == null) return CAM_SPEED; Float h = terrain.getHeight(new Vector2f(camPos.x, camPos.z)); - return h != null ? Math.max(1f, camPos.y - h) : CAM_SPEED; + if (h == null || !Float.isFinite(h)) return CAM_SPEED; + float dist = camPos.y - h; + return Float.isFinite(dist) ? Math.max(1f, dist) : CAM_SPEED; } private void updateCamera(float tpf) { @@ -1567,6 +1593,12 @@ public class TerrainEditorState extends BaseAppState { if (scroll != 0) camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f)); + // NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert) + if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) { + camPos.set(0f, DEFAULT_CAM_Y, 0f); + } + camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y); + cam.setLocation(camPos); } diff --git a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java index c284049..07fabcf 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java @@ -10,6 +10,7 @@ import com.jme3.material.Material; import com.jme3.material.RenderState; import com.jme3.math.ColorRGBA; import com.jme3.math.FastMath; +import com.jme3.math.Quaternion; import com.jme3.math.Ray; import com.jme3.math.Vector2f; import com.jme3.math.Vector3f; @@ -19,6 +20,7 @@ import com.jme3.scene.Geometry; import com.jme3.scene.Mesh; import com.jme3.scene.Node; import com.jme3.scene.Spatial; +import com.jme3.scene.shape.Quad; import com.jme3.scene.VertexBuffer; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.texture.Texture; @@ -40,6 +42,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.*; +import java.util.ArrayDeque; +import java.util.Deque; import java.util.concurrent.ConcurrentHashMap; /** @@ -117,6 +121,23 @@ public class VoxelEditorState extends BaseAppState { private Geometry brushIndicator; + // ── Basis-Terrain-Referenzebene (y = -10) ──────────────────────────────── + + /** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */ + private Node basePlaneNode; + private Geometry basePlane; + /** Chunk-Gitter (128 × 128 Einheiten) als Debugging-Hilfe. */ + private Geometry chunkGrid; + + // ── Wireframe-Modus ─────────────────────────────────────────────────────── + + /** true wenn im Moment Wireframe aktiv ist. */ + private boolean wireframeActive = false; + /** Alle Materialien, die durch Wireframe verändert wurden (zum Zurücksetzen). */ + private final Set wireframedMaterials = new HashSet<>(); + /** Vorheriger Layer-Zustand für Einstieg/Ausstieg-Erkennung. */ + private boolean prevLayerWasVoxel = false; + // ── LOD-Rebuild-Queue ───────────────────────────────────────────────────── /** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */ @@ -124,6 +145,17 @@ public class VoxelEditorState extends BaseAppState { /** Chunks mit fertigen LOD1/2-Meshes, die im JME-Thread übernommen werden. */ private final ConcurrentLinkedQueue lodResultQueue = new ConcurrentLinkedQueue<>(); + // ── Undo / Redo ─────────────────────────────────────────────────────────── + + private static final int MAX_UNDO = 10; + + private record UndoEntry(Map before, Map after) {} + + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + private final Map actionBefore = new HashMap<>(); + private boolean actionInProgress = false; + // ── Konstruktor ─────────────────────────────────────────────────────────── public VoxelEditorState(SharedInput input) { @@ -153,6 +185,14 @@ public class VoxelEditorState extends BaseAppState { brushIndicator = buildBrushIndicator(); app.getRootNode().attachChild(brushIndicator); + // Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter + basePlaneNode = new Node("voxelBasePlaneNode"); + basePlane = buildBasePlane(); + chunkGrid = buildChunkGrid(); + basePlaneNode.attachChild(basePlane); + basePlaneNode.attachChild(chunkGrid); + app.getRootNode().attachChild(basePlaneNode); + // Alle vorhandenen .blvc-Dateien laden List loaded = VoxelChunkIO.loadAll(); for (VoxelChunk chunk : loaded) { @@ -167,7 +207,9 @@ public class VoxelEditorState extends BaseAppState { protected void cleanup(Application app) { executor.shutdownNow(); voxelRoot.removeFromParent(); - if (brushIndicator != null) brushIndicator.removeFromParent(); + if (brushIndicator != null) brushIndicator.removeFromParent(); + if (basePlaneNode != null) basePlaneNode.removeFromParent(); + if (wireframeActive) applyWireframe(false); nodes.clear(); chunks.clear(); } @@ -188,7 +230,14 @@ public class VoxelEditorState extends BaseAppState { if (input.bakeVoxelsRequested) { input.bakeVoxelsRequested = false; List snapshot = new ArrayList<>(chunks.values()); - executor.submit(() -> bakeAll(snapshot)); + executor.submit(() -> { + try { + bakeAll(snapshot); + } catch (Throwable e) { + log.error("Bake fehlgeschlagen: {}", e.getMessage(), e); + input.bakeStatusMsg = "FEHLER: " + e.getMessage(); + } + }); } // Voxel-Texturen aktualisiert? @@ -197,6 +246,17 @@ public class VoxelEditorState extends BaseAppState { applyTextures(voxelMaterial); } + // Layer-Wechsel erkennen → Referenzebene und Wireframe steuern + boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL; + if (isVoxelLayer != prevLayerWasVoxel) { + prevLayerWasVoxel = isVoxelLayer; + onVoxelLayerChanged(isVoxelLayer); + } + // Wireframe-Toggle während LAYER_VOXEL (Button-Klick im Panel) + if (isVoxelLayer && wireframeActive != input.voxelWireframeEnabled) { + applyWireframe(input.voxelWireframeEnabled); + } + // Nur aktiv wenn LAYER_VOXEL gesetzt if (input.activeLayer != SharedInput.LAYER_VOXEL) { idleSinceEdit = 0f; @@ -205,6 +265,12 @@ public class VoxelEditorState extends BaseAppState { return; } + // Undo/Redo-Aktionsgrenzen und Anfragen verarbeiten + if (input.voxelActionStarted) { input.voxelActionStarted = false; beginVoxelAction(); } + if (input.voxelActionFinished) { input.voxelActionFinished = false; finishVoxelAction(); } + if (input.voxelUndoRequested) { input.voxelUndoRequested = false; applyUndo(); } + if (input.voxelRedoRequested) { input.voxelRedoRequested = false; applyRedo(); } + // Edit-Queue verarbeiten (max. MAX_EDITS_PER_FRAME) // Edits nur akkumulieren; Mesh-Rebuild am Frameende einmal pro Chunk. int processed = 0; @@ -322,7 +388,9 @@ public class VoxelEditorState extends BaseAppState { Vector3f bestPos = null; Vector3f bestNorm = new Vector3f(0, 1, 0); - if (terrainNode != null) { + // Im Voxel-Layer nur Voxel-Geometrie und die Basis-Referenzebene treffen, + // nicht das Heightmap-Terrain (das würde Voxel auf der falschen Höhe erzeugen). + if (terrainNode != null && input.activeLayer != SharedInput.LAYER_VOXEL) { terrainNode.collideWith(ray, results); if (results.size() > 0) { CollisionResult cr = results.getClosestCollision(); @@ -341,11 +409,26 @@ public class VoxelEditorState extends BaseAppState { if (results.size() > 0) { CollisionResult cr = results.getClosestCollision(); if (cr.getDistance() < bestDist) { + bestDist = cr.getDistance(); bestPos = cr.getContactPoint(); bestNorm = cr.getContactNormal() != null ? cr.getContactNormal().normalize() : new Vector3f(0, 1, 0); } + results.clear(); + } + + // Basis-Terrain-Referenzebene (y = -10) als Raycast-Ziel + if (basePlaneNode != null && basePlane != null + && basePlane.getCullHint() != Spatial.CullHint.Always) { + basePlaneNode.collideWith(ray, results); + if (results.size() > 0) { + CollisionResult cr = results.getClosestCollision(); + if (cr.getDistance() < bestDist) { + bestPos = cr.getContactPoint(); + bestNorm = new Vector3f(0, 1, 0); + } + } } return bestPos != null ? new Hit(bestPos, bestNorm) : null; @@ -354,28 +437,36 @@ public class VoxelEditorState extends BaseAppState { /** * Wendet den gewählten Modus an. * - * Modi 0-3 (Sinus/Spike/Plateau/Smooth): Spalten ab Terrain-Oberfläche nach oben (links) - * bzw. Abtragen nach unten (rechts). Verhalten analog zum Terrain-Tool. + * Vertikal (horizontal=false): + * Modi 0-3: Säulen ab Terrain-Oberfläche nach oben/unten. Analog zum Terrain-Tool. + * Modus 4 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum. * - * Modus 4 (Klippe): Scheiben-Pinsel entlang der Oberflächennormale – rechts = entfernen. - * - * Modus 5 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum. + * Horizontal (horizontal=true): + * Modi 0-3: Brush entlang der Flächennormale; bei flacher Fläche kein Effekt. + * Modus 4 (Aushöhlen): wie vertikal (Kugel-Entfernen). */ private void applyEdit(Hit hit, int action) { float radius = (float) input.voxelTool.brushRadius.getValue(); float strength = (float) input.voxelTool.brushStrength.getValue(); int modeIdx = input.voxelTool.mode.getSelectedIndex(); + boolean isHorizontal = input.voxelTool.horizontal; - boolean isSlab = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_ADD); boolean isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE); - boolean isColumn = !isSlab && !isCave; + boolean isColumn = !isCave; boolean lower = action < 0; - Vector3f N = hit.normal; + Vector3f N = hit.normal(); float wx = hit.pos.x, wy = hit.pos.y, wz = hit.pos.z; - // Plateau-Rechtsklick: Voxel- und Terrain-Höhe sampeln, Maximum als Ziel speichern - if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) { + // Horizontaler Modus: bei flacher Fläche (Normal.y > 0.7) nichts tun + if (isHorizontal && isColumn) { + if (Math.abs(N.y) > 0.7f) return; + float nhLen = (float) Math.sqrt(N.x*N.x + N.z*N.z); + if (nhLen < 0.1f) return; + } + + // Plateau-Rechtsklick (nur vertikal): Voxel- und Terrain-Höhe sampeln + if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) { float h = columnTopWorldY(wx, wz); TerrainEditorState tes = getStateManager().getState(TerrainEditorState.class); if (tes != null) { @@ -396,8 +487,8 @@ public class VoxelEditorState extends BaseAppState { wz -= N.z * radius * 0.6f; } - // Spalten-Modi brauchen nur XZ-Radius; Slab/Cave brauchen auch Stärke in Y - float worldExtent = isColumn ? radius + 2f : radius + strength + 2f; + // Vertikale Spalten-Modi brauchen nur XZ-Radius; alle anderen auch Stärke + float worldExtent = (isColumn && !isHorizontal) ? radius + 2f : radius + strength + 2f; int cxMin = VoxelChunk.worldXToCx(wx - worldExtent); int cxMax = VoxelChunk.worldXToCx(wx + worldExtent); @@ -406,29 +497,48 @@ public class VoxelEditorState extends BaseAppState { int cyMin = VoxelChunk.worldYToCy(wy - worldExtent); int cyMax = VoxelChunk.worldYToCy(wy + worldExtent); - // Smooth-Modus: Slope-Parameter vorab berechnen (für beide Klick-Varianten) + // Smooth-Modus: Slope-Parameter vorab berechnen (nur vertikal) float[] slopeParams = null; - if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) { + if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) { slopeParams = computeSlopeParams(wx, wz, radius); } float plateauTargetH = (float) input.voxelTool.plateauTarget.getValue(); + // Zurücksetzen-Modus: separate Behandlung ohne Chunk-Erzeugung + if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_RESET) { + applyReset(wx, wz, radius); + return; + } + for (int cz = czMin; cz <= czMax; cz++) { for (int cx = cxMin; cx <= cxMax; cx++) { for (int cy = cyMin; cy <= cyMax; cy++) { - VoxelChunk chunk = getOrCreateChunk(cx, cy, cz); + // Aushöhlen: nur existierende Chunks bearbeiten, keine neuen anlegen + VoxelChunk chunk = isCave + ? chunks.get(chunkKey(cx, cy, cz)) + : getOrCreateChunk(cx, cy, cz); + if (chunk == null || (isCave && chunk.isEmpty())) continue; - float lx = VoxelChunk.worldXToLocal(wx, cx); - float ly = VoxelChunk.worldYToLocal(wy, cy); - float lz = VoxelChunk.worldZToLocal(wz, cz); + long key = chunkKey(cx, cy, cz); + float lx = VoxelChunk.worldXToLocal(wx, cx); + float ly = VoxelChunk.worldYToLocal(wy, cy); + float lz = VoxelChunk.worldZToLocal(wz, cz); + + snapshotChunkBefore(key, chunk); if (isCave) { - chunk.applyBrush(lx, ly, lz, radius, Byte.MIN_VALUE, (byte)0); - } else if (isSlab) { - applySlabBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, lower); + // Graduelles Aushöhlen mit vollem Radius (passt zum Indikator). + // Stärke bestimmt Dichte-Abbau pro Tick (je höher, desto aggressiver). + int step = Math.max(8, (int)(strength * 2f)); + chunk.reduceDensity(lx, ly, lz, radius, step); + chunk.pruneIsolated(lx, ly, lz, radius); + } else if (isHorizontal) { + applyHorizontalBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, modeIdx, lower); } else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU) { - applyPlateauColumn(chunk, cx, cy, cz, wx, wz, radius, plateauTargetH); + // Stärke steuert wie schnell sich die Spalten dem Ziel annähern + final float target = plateauTargetH; + applyColumnToTarget(chunk, cx, cy, cz, wx, wz, radius, strength, coord -> target); } else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) { if (lower) { applyCliffColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams); @@ -439,7 +549,6 @@ public class VoxelEditorState extends BaseAppState { applyColumnBrush(chunk, cx, cy, cz, wx, wz, radius, strength, modeIdx, lower); } - long key = chunkKey(cx, cy, cz); // Node erst anlegen wenn tatsächlich Daten vorhanden if (!chunk.isEmpty() && !nodes.containsKey(key)) { addNodeForChunk(key, chunk); @@ -451,15 +560,29 @@ public class VoxelEditorState extends BaseAppState { } /** - * Scheiben-Pinsel für den Klippe-Modus. - * remove=false: Voxel senkrecht zur Normalen aufbauen. - * remove=true: dieselbe Form entfernen (Rechtsklick). + * Horizontaler Brush: baut Voxel in Richtung der Flächennormale auf (oder ab). + * Das Profil (Sinus/Spike/Plateau/Smooth) bestimmt die Tiefe entlang der Normalen + * abhängig vom Abstand zur Brush-Mitte in der Flächen-Ebene (Y + tangential). + * remove=true: Rechtsklick – Voxel entfernen. */ - private void applySlabBrush(VoxelChunk chunk, int cx, int cy, int cz, - float hitWX, float hitWY, float hitWZ, - Vector3f N, float radius, float strength, - boolean remove) { - float extent = radius + strength + 1f; + private void applyHorizontalBrush(VoxelChunk chunk, int cx, int cy, int cz, + float hitWX, float hitWY, float hitWZ, + Vector3f N, float radius, float strength, + int mode, boolean remove) { + // Horizontale Normalenkomponente normalisieren + float nhx = N.x, nhz = N.z; + float nhLen = (float) Math.sqrt(nhx*nhx + nhz*nhz); + if (nhLen < 0.1f) return; + nhx /= nhLen; nhz /= nhLen; + + // Tangente in XZ (senkrecht zur Normalen) + float tanx = -nhz, tanz = nhx; + + float maxDepth = Math.max(1f, strength / 10f); + float overlap = 1.0f; + float r2 = radius * radius; + float extent = radius + maxDepth + 1f; + float lhX = VoxelChunk.worldXToLocal(hitWX, cx); float lhY = VoxelChunk.worldYToLocal(hitWY, cy); float lhZ = VoxelChunk.worldZToLocal(hitWZ, cz); @@ -471,10 +594,6 @@ public class VoxelEditorState extends BaseAppState { int z0 = Math.max(0, (int)(lhZ - extent)); int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lhZ + extent)); - float nx = N.x, ny = N.y, nz = N.z; - float r2 = radius * radius; - float overlap = 1.0f; - for (int ly = y0; ly <= y1; ly++) { float wy = VoxelChunk.toWorldY(cy, ly); float dy = wy - hitWY; @@ -485,12 +604,20 @@ public class VoxelEditorState extends BaseAppState { float wx = VoxelChunk.toWorldX(cx, lx); float dx = wx - hitWX; - float along = dx*nx + dy*ny + dz*nz; - float slabThick = Math.max(1f, strength / 10f); - if (along < -overlap || along > slabThick) continue; - float perpSq = dx*dx + dy*dy + dz*dz - along*along; + // Abstand in der Flächen-Ebene (Y + tangential in XZ) + float projTan = dx*tanx + dz*tanz; + float perpSq = projTan*projTan + dy*dy; if (perpSq > r2) continue; + // Position entlang der Normalen + float projN = dx*nhx + dz*nhz; + + float t = (float) Math.sqrt(perpSq) / radius; + float falloff = computeFalloff(mode, t); + float currDepth = maxDepth * falloff; + + if (projN < -overlap || projN > currDepth) continue; + if (remove) { chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE); } else { @@ -542,26 +669,35 @@ public class VoxelEditorState extends BaseAppState { int colStep = (int)(stepBase * falloff); if (colStep < 1) continue; - // Terrain-Höhe: ceil stellt sicher, dass Voxel nie unterhalb Terrain beginnen - float terrainH = terrainH(wx, wz); - int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1, - (int) Math.ceil(VoxelChunk.worldYToLocal(terrainH, cy)))); - // Unterirdische Voxel löschen (kein Überhang > 90°) - for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE); - if (!lower) { - // Aktuellen Säulen-Top finden (höchster Solid-Voxel ≥ terrainLY) - int currentTop = terrainLY; // Fallback: direkt auf Terrain starten - for (int ly = VoxelChunk.SIZE - 1; ly >= terrainLY; ly--) { + // Höchsten Solid-Voxel in dieser Spalte suchen + int currentTop = -1; + for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) { if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; } } - // Säule um colStep erhöhen, dabei Basis ab terrainLY immer füllen + if (currentTop < 0) { + if (cy == -1) { + // Basis-Ebene: Ankerpunkt bei ly=118 (Welt-Y = -10) + currentTop = 118; + } else { + // Nur weiterwachsen wenn der darunterliegende Chunk bis an die + // Grenze reicht (ly ≥ SIZE-3), sonst würde ein losgelöster Klumpen entstehen + VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); + if (below == null) continue; + boolean belowAtBoundary = false; + for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) { + if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; } + } + if (!belowAtBoundary) continue; + currentTop = 0; + } + } int newTop = Math.min(VoxelChunk.SIZE - 1, currentTop + colStep); - for (int ly = terrainLY; ly <= newTop; ly++) { + for (int ly = currentTop; ly <= newTop; ly++) { chunk.setDensity(lx, ly, lz, (byte) 127); } } else { - // Höchsten Solid-Voxel finden (egal ob über oder unter terrainLY) + // Höchsten Solid-Voxel finden int currentTop = -1; for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) { if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; } @@ -576,6 +712,48 @@ public class VoxelEditorState extends BaseAppState { } } + // ── Zurücksetzen-Pinsel ─────────────────────────────────────────────────── + + /** Setzt alle vorhandenen Chunks im Pinselbereich auf das Basis-Terrain zurück (y=-10). */ + private void applyReset(float brushWX, float brushWZ, float radius) { + float halfChunk = VoxelChunk.CELLS / 2f; + for (VoxelChunk chunk : new ArrayList<>(chunks.values())) { + float ccx = chunk.cx * VoxelChunk.CELLS - 2048f + halfChunk; + float ccz = chunk.cz * VoxelChunk.CELLS - 2048f + halfChunk; + if (Math.abs(ccx - brushWX) > radius + halfChunk) continue; + if (Math.abs(ccz - brushWZ) > radius + halfChunk) continue; + applyResetBrush(chunk, chunk.cx, chunk.cy, chunk.cz, brushWX, brushWZ, radius); + long key = chunkKey(chunk.cx, chunk.cy, chunk.cz); + if (!chunk.isEmpty() && !nodes.containsKey(key)) addNodeForChunk(key, chunk); + dirtyChunksThisFrame.add(key); + } + } + + /** Löscht alle Voxel im Pinselbereich (setzt auf Luft). Die basePlane zeigt das Basis-Terrain. */ + private void applyResetBrush(VoxelChunk chunk, int cx, int cy, int cz, + float brushWX, float brushWZ, float radius) { + float lxC = VoxelChunk.worldXToLocal(brushWX, cx); + float lzC = VoxelChunk.worldZToLocal(brushWZ, cz); + int x0 = Math.max(0, (int)(lxC - radius)); + int x1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lxC + radius)); + int z0 = Math.max(0, (int)(lzC - radius)); + int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lzC + radius)); + float r2 = radius * radius; + for (int lz = z0; lz <= z1; lz++) { + float wz = VoxelChunk.toWorldZ(cz, lz); + float dz = wz - brushWZ; + for (int lx = x0; lx <= x1; lx++) { + float wx = VoxelChunk.toWorldX(cx, lx); + float dx = wx - brushWX; + if (dx*dx + dz*dz > r2) continue; + for (int ly = 0; ly < VoxelChunk.SIZE; ly++) { + if (chunk.getDensity(lx, ly, lz) != Byte.MIN_VALUE) + chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE); + } + } + } + } + // ── Terrain-Höhe (schneller O(1)-Zugriff) ───────────────────────────────── private float terrainH(float worldX, float worldZ) { @@ -643,7 +821,7 @@ public class VoxelEditorState extends BaseAppState { } } } - return terrainH(worldX, worldZ); + return -10f; // Kein Voxel vorhanden → Basis-Niveau } /** @@ -687,19 +865,30 @@ public class VoxelEditorState extends BaseAppState { } float currentTopWY = currentTopLY >= 0 ? VoxelChunk.toWorldY(cy, currentTopLY) - : terrainH(wx, wz); + : -10f; // Keine Voxel → Basis-Niveau als Referenz float diff = targetH - currentTopWY; if (Math.abs(diff) < 0.5f) continue; - int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1, - (int) Math.ceil(VoxelChunk.worldYToLocal(terrainH(wx, wz), cy)))); - // Unterirdische Voxel immer leeren (kein Überhang > 90°) - for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE); if (diff > 0) { - int startLY = currentTopLY >= 0 ? Math.max(currentTopLY, terrainLY) : terrainLY; + // Erhöhen + int startLY; + if (currentTopLY >= 0) { + startLY = currentTopLY; + } else if (cy == -1) { + startLY = 118; + } else { + VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); + if (below == null) continue; + boolean belowAtBoundary = false; + for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) { + if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; } + } + if (!belowAtBoundary) continue; + startLY = 0; + } int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step); - for (int ly = terrainLY; ly <= newTop; ly++) { + for (int ly = startLY; ly <= newTop; ly++) { chunk.setDensity(lx, ly, lz, (byte) 127); } } else { @@ -758,14 +947,9 @@ public class VoxelEditorState extends BaseAppState { float dx = wx - brushWX; if (dx*dx + dz*dz > r2) continue; - float th = terrainH(wx, wz); for (int ly = 0; ly < VoxelChunk.SIZE; ly++) { float wy = VoxelChunk.toWorldY(cy, ly); - if (wy < th) { - // Unterhalb Terrain: immer leeren, kein Überhang möglich - chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE); - continue; - } + if (wy < -10f) continue; // Fundament unterhalb y=-10 nicht antasten if (wy <= targetH) { chunk.setDensity(lx, ly, lz, (byte) 127); } else { @@ -812,9 +996,21 @@ public class VoxelEditorState extends BaseAppState { List nonEmpty = new java.util.ArrayList<>(); for (VoxelChunk c : toProcess) { if (!c.isEmpty()) nonEmpty.add(c); } + // Chunks ohne .blvc-Datei überspringen (noch nie gespeichert). + nonEmpty.removeIf(c -> !VoxelChunkIO.exists(c.cx, c.cy, c.cz)); + // Chunks ohne nennenswerte Geometrie überspringen (nur Brush-Randberührungen, + // solidYSpan < 2 → gleiche Schwelle wie der Game-Loader). + nonEmpty.removeIf(c -> c.solidYSpan() < 2); + + if (nonEmpty.isEmpty()) { + input.bakeStatusMsg = "Nichts zu backen (keine nicht-leeren Chunks)."; + return; + } + // bakeTotal sofort setzen, damit die Fortschrittsanzeige schon während des Blurs läuft input.bakeDone = 0; input.bakeTotal = nonEmpty.size(); + log.info("Bake gestartet: {} Chunks", nonEmpty.size()); Map allOriginal = new HashMap<>(); for (VoxelChunk c : nonEmpty) allOriginal.put(chunkKey(c.cx, c.cy, c.cz), c); @@ -863,6 +1059,7 @@ public class VoxelEditorState extends BaseAppState { cBuf[c.idx(bx, VoxelChunk.CELLS, bz)] = tBuf[c.idx(bx, 0, bz)]; } + input.blurIterDone = 0; for (int iter = 0; iter < 7; iter++) { Map nextBufs = new HashMap<>(); for (VoxelChunk c : nonEmpty) { @@ -889,6 +1086,7 @@ public class VoxelEditorState extends BaseAppState { nextBufs.put(k, next); } curBufs = nextBufs; + input.blurIterDone = iter + 1; } // Blur-Ergebnisse in VoxelChunks umwandeln @@ -934,9 +1132,26 @@ public class VoxelEditorState extends BaseAppState { // NICHT ENTFERNEN! Wurde schon einmal versehentlich rückgängig gemacht. // Ohne diesen Block entsteht am Terrain-Übergang ein hässlicher Überhang. for (VoxelChunk blurred : blurredMap.values()) { + // Extrapolation nur sinnvoll, wenn der Chunk darunter (cy-1) ebenfalls + // solide Voxel an seiner Oberkante hat. Andernfalls handelt es sich um + // einen freistehenden Überhang, einen Höhleneingang oder eine Bergkante + // über Luft – dort würde die Extrapolation fälschlicherweise die Luft + // unter der Geometrie mit Solid-Dichte füllen und die Unterseite zerstören. + long belowKey = chunkKey(blurred.cx, blurred.cy - 1, blurred.cz); + VoxelChunk blurredBelow = blurredMap.get(belowKey); + for (int lz = 0; lz < blurN; lz++) { for (int lx = 0; lx < blurN; lx++) { + // Spalte überspringen, wenn der Chunk darunter hier oben keine + // soliden Voxel hat (kein durchgehender Terrain-Block nach unten). + if (blurredBelow == null) continue; + boolean colBelowHasSolid = false; + for (int ly2 = VoxelChunk.CELLS - 3; ly2 <= VoxelChunk.CELLS; ly2++) { + if (blurredBelow.getDensity(lx, ly2, lz) > 0) { colBelowHasSolid = true; break; } + } + if (!colBelowHasSolid) continue; + // Untersten festen Voxel in dieser Spalte finden int yBot = -1; for (int ly = 0; ly < blurN; ly++) { @@ -944,6 +1159,17 @@ public class VoxelEditorState extends BaseAppState { } if (yBot < 0 || yBot + 2 >= blurN) continue; + // Interne Lücke prüfen: Solid → Luft → Solid von yBot aufwärts + // bedeutet Höhle oder Decken-Überhang → nicht extrapolieren. + boolean hasVoid = false; + boolean inAirAbove = false; + for (int ly = yBot + 1; ly < blurN; ly++) { + boolean s = blurred.getDensity(lx, ly, lz) > 0; + if (!s) { inAirAbove = true; } + else if (inAirAbove) { hasVoid = true; break; } + } + if (hasVoid) continue; + // Steigung aus den zwei Voxeln darüber bestimmen. // slope < 0: Dichte nimmt nach unten ab (typisch für eine Oberfläche). float d1 = blurred.getDensity(lx, yBot + 1, lz); @@ -979,9 +1205,26 @@ public class VoxelEditorState extends BaseAppState { baked++; input.bakeDone = baked; } + // Voxel-Daten löschen: Disk-Dateien entfernen, In-Memory leeren, Szene-Nodes entfernen + for (VoxelChunk chunk : nonEmpty) { + chunk.clear(); + try { VoxelChunkIO.delete(chunk.cx, chunk.cy, chunk.cz); } + catch (Exception e) { log.warn("Voxel-Datei löschen fehlgeschlagen ({},{},{}): {}", + chunk.cx, chunk.cy, chunk.cz, e.getMessage()); } + } + app.enqueue(() -> { + for (VoxelChunk chunk : nonEmpty) { + long key = chunkKey(chunk.cx, chunk.cy, chunk.cz); + VoxelChunkNode node = nodes.remove(key); + if (node != null) node.removeFromParent(); + chunks.remove(key); + } + }); + String msg = "Fertig: " + baked + " Chunk" + (baked != 1 ? "s" : "") + " gebacken."; // bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt - input.bakeStatusMsg = msg; + input.bakeStatusMsg = msg; + input.sculptRescanNeeded = true; log.info("Voxel-Bake abgeschlossen – {}.", msg); } @@ -1063,9 +1306,11 @@ public class VoxelEditorState extends BaseAppState { log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}", cx, cy, cz, e.getMessage()); chunk = new VoxelChunk(cx, cy, cz); + fillBaseTerrainChunk(chunk); } } else { chunk = new VoxelChunk(cx, cy, cz); + fillBaseTerrainChunk(chunk); } chunks.put(key, chunk); return chunk; @@ -1084,6 +1329,93 @@ public class VoxelEditorState extends BaseAppState { return node; } + // ── Intern: Undo / Redo ─────────────────────────────────────────────────── + + private void beginVoxelAction() { + if (actionInProgress) return; + actionInProgress = true; + actionBefore.clear(); + redoStack.clear(); + } + + private void snapshotChunkBefore(long key, VoxelChunk chunk) { + if (!actionInProgress || actionBefore.containsKey(key)) return; + actionBefore.put(key, chunk.getDensityCopy()); + } + + private void finishVoxelAction() { + if (!actionInProgress) return; + actionInProgress = false; + if (actionBefore.isEmpty()) return; + + Map changedBefore = new HashMap<>(); + Map changedAfter = new HashMap<>(); + + for (Map.Entry 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 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 snapshot) { + for (Map.Entry e : snapshot.entrySet()) { + long key = e.getKey(); + byte[] d = e.getValue(); + if (d == null) { + chunks.remove(key); + VoxelChunkNode n = nodes.remove(key); + if (n != null) n.removeFromParent(); + } else { + VoxelChunk c = chunks.get(key); + if (c == null) { + int cx = (int)(key & 0xFFFF); if (cx >= 0x8000) cx -= 0x10000; + int cy = (int)((key >> 16) & 0xFFFF); if (cy >= 0x8000) cy -= 0x10000; + int cz = (int)((key >> 32) & 0xFFFF); if (cz >= 0x8000) cz -= 0x10000; + c = new VoxelChunk(cx, cy, cz); + chunks.put(key, c); + } + c.setDensityArray(d.clone()); + c.dirty = true; + dirtyChunksThisFrame.add(key); + if (!nodes.containsKey(key)) addNodeForChunk(key, c); + } + } + } + // ── Intern: Hintergrund-LOD ─────────────────────────────────────────────── private void scheduleLodRebuild() { @@ -1138,17 +1470,16 @@ public class VoxelEditorState extends BaseAppState { private void applyTextures(Material mat) { mat.setFloat("TexScale", 8f); - int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot, input.voxelCeilSlot }; - String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" }; - String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" }; - String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep","DisplacementMapCeil" }; + int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot }; + String[] colSlots = { "TexFlat", "TexSteep" }; + String[] normSlots = { "NormalMapFlat", "NormalMapSteep" }; + String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep" }; int[][] fallbackRgb = { {100, 130, 60}, {110, 100, 90}, - { 70, 55, 45}, }; boolean anyDisp = false; - for (int i = 0; i < 3; i++) { + for (int i = 0; i < 2; i++) { String texPath = slotTexPath(slotIdxs[i]); String normPath = slotNormPath(slotIdxs[i]); String dispPath = slotDispPath(slotIdxs[i]); @@ -1256,11 +1587,27 @@ public class VoxelEditorState extends BaseAppState { float jmeX = mx * (float) input.viewportScaleX; float jmeY = cam.getHeight() - my * (float) input.viewportScaleY; Hit hit = raycastHit(jmeX, jmeY); - Vector3f pos = hit != null ? hit.pos : null; - if (pos != null) { + if (hit != null) { float r = (float) input.voxelTool.brushRadius.getValue(); - brushIndicator.setLocalTranslation(pos.x, pos.y + 0.3f, pos.z); + + // Leicht entlang der Flächen-Normalen versetzt, um Z-Fighting zu vermeiden + brushIndicator.setLocalTranslation(hit.pos.add(hit.normal().mult(0.05f))); brushIndicator.setLocalScale(r, 1f, r); + + // Disc-Normale (lokales Y) auf hit.normal() ausrichten + Vector3f axis = Vector3f.UNIT_Y.cross(hit.normal()); + Quaternion rot = new Quaternion(); + if (axis.lengthSquared() < 1e-6f) { + // Parallel oder antiparallel → Identität oder 180°-Kipp um X + rot.fromAngleNormalAxis( + Vector3f.UNIT_Y.dot(hit.normal()) > 0 ? 0f : FastMath.PI, + Vector3f.UNIT_X); + } else { + float angle = FastMath.acos(FastMath.clamp(Vector3f.UNIT_Y.dot(hit.normal()), -1f, 1f)); + rot.fromAngleNormalAxis(angle, axis.normalizeLocal()); + } + brushIndicator.setLocalRotation(rot); + brushIndicator.setCullHint(Spatial.CullHint.Inherit); } else { brushIndicator.setCullHint(Spatial.CullHint.Always); @@ -1320,4 +1667,124 @@ public class VoxelEditorState extends BaseAppState { public static long chunkKey(int cx, int cy, int cz) { return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32); } + + // ── Basis-Terrain-Füllung ───────────────────────────────────────────────── + + /** + * Neu erstellte Chunks starten leer (Luft). + * Das Basis-Terrain bei y=-10 wird durch die basePlane visualisiert; + * Voxel entstehen nur dort, wo der Nutzer sculpted. + */ + private static void fillBaseTerrainChunk(VoxelChunk chunk) { + // absichtlich leer – kein Prefill + } + + // ── Referenzebene bei y = -10 ───────────────────────────────────────────── + + private Geometry buildBasePlane() { + // Welt-Ausdehnung: X und Z von -2048 bis +2048 → Größe 4096×4096 + Quad quad = new Quad(4096f, 4096f); + Geometry geo = new Geometry("voxelBasePlane", quad); + // Quad liegt im XY-Raum; nach -90° um X rotieren → horizontale XZ-Ebene + Quaternion rot = new Quaternion(); + rot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X); + geo.setLocalRotation(rot); + // Nach Rotation: x 0..4096 → x -2048..2048, z geht von +2048 nach -2048 + geo.setLocalTranslation(-2048f, -10f, 2048f); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(0.15f, 0.55f, 0.15f, 0.22f)); + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + geo.setMaterial(mat); + geo.setCullHint(Spatial.CullHint.Always); // standardmäßig versteckt + return geo; + } + + private Geometry buildChunkGrid() { + float half = 2048f; + float step = VoxelChunk.CELLS; // 128 Einheiten pro Chunk + int divs = (int)(half * 2 / step); // 32 Chunks pro Achse + float y = -9.5f; // knapp über der basePlane bei -10 + + // Linien entlang Z (für jeden X-Abschnitt) + Linien entlang X (für jeden Z-Abschnitt) + int totalLines = (divs + 1) * 2; + FloatBuffer pos = BufferUtils.createFloatBuffer(totalLines * 2 * 3); + IntBuffer idx = BufferUtils.createIntBuffer(totalLines * 2); + + int v = 0; + for (int i = 0; i <= divs; i++) { + float x = -half + i * step; + pos.put(x).put(y).put(-half); + pos.put(x).put(y).put( half); + idx.put(v++).put(v++); + } + for (int i = 0; i <= divs; i++) { + float z = -half + i * step; + pos.put(-half).put(y).put(z); + pos.put( half).put(y).put(z); + idx.put(v++).put(v++); + } + pos.rewind(); idx.rewind(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Index, 2, idx); + mesh.updateBound(); + + Geometry geo = new Geometry("voxelChunkGrid", mesh); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.35f, 0f, 1f)); + geo.setMaterial(mat); + geo.setCullHint(Spatial.CullHint.Always); + return geo; + } + + // ── Layer-Wechsel-Reaktion ───────────────────────────────────────────────── + + private void onVoxelLayerChanged(boolean entered) { + if (basePlane != null) + basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + if (chunkGrid != null) + chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + applyWireframe(entered && input.voxelWireframeEnabled); + } + + /** Zeigt/verbirgt die Voxel-Chunk-Nodes (wird vom SculptedMeshEditorState gesteuert). */ + public void setChunksVisible(boolean visible) { + if (voxelRoot != null) + voxelRoot.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } + + /** Gibt das Voxel-Material zurück, das SculptedMeshEditorState wiederverwenden kann. */ + public Material getVoxelMaterial() { return voxelMaterial; } + + // ── Wireframe-Hilfsmethoden ─────────────────────────────────────────────── + + /** Schaltet Wireframe für die gesamte Szene (außer Voxel-Root und Hilfsgeos) ein oder aus. */ + private void applyWireframe(boolean enable) { + if (enable == wireframeActive) return; + wireframeActive = enable; + if (enable) { + wireframedMaterials.clear(); + applyWireframeRecursive(app.getRootNode()); + } else { + for (Material m : wireframedMaterials) + m.getAdditionalRenderState().setWireframe(false); + wireframedMaterials.clear(); + } + } + + private void applyWireframeRecursive(Spatial s) { + // Voxel-eigene Objekte nicht wireframen + if (s == voxelRoot || s == brushIndicator || s == basePlaneNode) return; + if (s instanceof Geometry geo && geo.getMaterial() != null) { + geo.getMaterial().getAdditionalRenderState().setWireframe(true); + wireframedMaterials.add(geo.getMaterial()); + } else if (s instanceof Node node) { + for (Spatial child : new java.util.ArrayList<>(node.getChildren())) + applyWireframeRecursive(child); + } + } } diff --git a/blight-editor/src/main/java/de/blight/editor/tool/SculptMeshTool.java b/blight-editor/src/main/java/de/blight/editor/tool/SculptMeshTool.java new file mode 100644 index 0000000..9664990 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/tool/SculptMeshTool.java @@ -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 getChoiceParameters() { return List.of(mode); } + + @Override + public List getParameters() { return List.of(brushRadius, brushStrength); } +} diff --git a/blight-editor/src/main/java/de/blight/editor/tool/StoneTool.java b/blight-editor/src/main/java/de/blight/editor/tool/StoneTool.java new file mode 100644 index 0000000..8177bca --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/tool/StoneTool.java @@ -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 getChoiceParameters() { return List.of(); } + + @Override public List getParameters() { + return List.of(brushRadius, minSize, maxSize, density); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java index f935c71..90bdc69 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java @@ -3,11 +3,14 @@ package de.blight.editor.tool; import java.util.List; /** - * Voxel-Werkzeug für Klippen und Höhlen. + * Voxel-Werkzeug. * - * Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben – für Felstürme, Plateaus. - * Modus 4 (Klippe): Kugel-Pinsel ohne Terrain-Cleanup. - * Modus 5 (Aushöhlen): Entfernt Voxel. + * Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten – für Felstürme, Plateaus. + * Modus 4 (Aushöhlen): Kugel-Entfernen. + * Modus 5 (Zurücksetzen): Setzt Voxel im Bereich auf das Basis-Niveau y=-10 zurück. + * + * horizontal=false (Vertikal): Säulen entlang Y-Achse. + * horizontal=true (Horizontal): Brush entlang der Flächennormale; bei flacher Fläche kein Effekt. * * Texturierung erfolgt automatisch anhand der Flächennormale: * Normal.y > 0.5 → TexFlat (flache Flächen) @@ -20,13 +23,13 @@ public class VoxelTool extends EditorTool { public static final int MODE_SPIKE = 1; public static final int MODE_PLATEAU = 2; public static final int MODE_SMOOTH = 3; - public static final int MODE_ADD = 4; - public static final int MODE_REMOVE = 5; + public static final int MODE_REMOVE = 4; + public static final int MODE_RESET = 5; public final ChoiceToolParameter mode = new ChoiceToolParameter( "Modus", - new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Klippe", "Aushöhlen"}, - MODE_ADD, + new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen"}, + MODE_SINUS, new String[]{ "img/editor/terraintool_sinus.png", "img/editor/terraintool_spike.png", @@ -37,9 +40,12 @@ public class VoxelTool extends EditorTool { } ); - public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0); - public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0); - public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0); + public volatile boolean modeChanged = false; + public volatile boolean horizontal = false; + + public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0); + public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0); + public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0); public volatile boolean plateauTargetChanged = false; @Override public String getName() { return "Voxel"; } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java index ac7ef32..3fc43f6 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java @@ -268,6 +268,8 @@ public class CraftingTableEditorView extends BorderPane { case Smithy -> "#cc8833"; case Goldsmiths -> "#ddbb22"; case Workshop -> "#4488cc"; + case Fireplace -> "#ee6633"; + case Kitchen -> "#88aa44"; }; } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java index a4aed04..3908b13 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java @@ -94,7 +94,7 @@ public class LocalizationEditorView extends BorderPane { table.refresh(); }); - table.getColumns().addAll(keyCol, valCol); + table.getColumns().addAll(List.of(keyCol, valCol)); VBox.setVgrow(table, Priority.ALWAYS); Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags."); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java index 0dbe5f8..9e4af00 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java @@ -458,6 +458,8 @@ public class RecipeEditorView extends BorderPane { case Smithy -> "#cc8833"; case Goldsmiths -> "#ddbb22"; case Workshop -> "#4488cc"; + case Fireplace -> "#ee6633"; + case Kitchen -> "#88aa44"; }; } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java new file mode 100644 index 0000000..b43980d --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/RoutineEditorView.java @@ -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 routines = new ArrayList<>(); + private NpcRoutine activeRoutine; + private RoutineBlock activeBlock; + + /** Wird aufgerufen, sobald ein Punkt auf der Karte gepickt wurde. */ + private Consumer pendingPick; + private int savedLayer; + + // ── Controls ─────────────────────────────────────────────────────────────── + + private ListView routineList; + private Label coverageLabel; + private ListView blockList; + private Label blockStatusLabel; + private VBox blockFormBox; + + // Block-Formular + private Spinner startSpin, endSpin; + private ComboBox typeCombo; + private VBox dynamicBox; + + // Dynamische Felder je Aktivitätstyp + private Label pointLabel; // zeigt gepickten Punkt + private ComboBox interactableCombo; // UUID-basiert (PlacedItems) + private ComboBox npcCombo; // für TALK + private ListView waypointList; // für PATROL + private RadioButton rbPoint, rbInteractable; // für SIT + + // Geladene Hilfsdaten + private final List placedItems = new ArrayList<>(); + private final List 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 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 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 callback) { + pendingPick = callback; + savedLayer = input.activeLayer; + input.activeLayer = SharedInput.LAYER_ROUTINE_EDITOR; + } + + private Button pickButton(Consumer 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 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 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 hourSpinner() { + Spinner 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 itemCombo() { + ComboBox 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 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 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 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); } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java index b38c4db..7ea0ecd 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java @@ -28,6 +28,7 @@ public class TriggerDialog extends Dialog { private static final String TYPE_QUEST = "Quest starten"; private static final String TYPE_NPC = "NPC-Status ändern"; private static final String TYPE_FRACTION = "Fraktions-Status ändern"; + private static final String TYPE_ROUTINE = "Routine ändern"; // Gemeinsam private final ComboBox typeCombo = new ComboBox<>(); @@ -45,6 +46,10 @@ public class TriggerDialog extends Dialog { private TextField fractionIdField; private ComboBox fractionStatusCombo; + // Routine ändern + private TextField routineNpcIdField; + private TextField routineNameField; + /** Öffnet den Dialog für einen neuen Trigger. */ public TriggerDialog() { this(null); @@ -56,7 +61,7 @@ public class TriggerDialog extends Dialog { initModality(Modality.APPLICATION_MODAL); setResizable(true); - typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION); + typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE); typeCombo.setMaxWidth(Double.MAX_VALUE); typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); @@ -100,6 +105,7 @@ public class TriggerDialog extends Dialog { case TYPE_QUEST -> buildQuestFields(); case TYPE_NPC -> buildNpcFields(); case TYPE_FRACTION -> buildFractionFields(); + case TYPE_ROUTINE -> buildRoutineFields(); } } @@ -131,6 +137,16 @@ public class TriggerDialog extends Dialog { ); } + private void buildRoutineFields() { + routineNpcIdField = field("Character-ID des NPCs"); + routineNameField = field("Name der Routine"); + dynamicArea.getChildren().addAll( + sectionTitle("Routine ändern"), + row("NPC-ID:", routineNpcIdField), + row("Routine-Name:", routineNameField) + ); + } + // ── Trigger bauen ───────────────────────────────────────────────────────── private Trigger buildTrigger() { @@ -161,6 +177,12 @@ public class TriggerDialog extends Dialog { if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue()); yield f; } + case TYPE_ROUTINE -> { + ChangeRoutineTrigger r = new ChangeRoutineTrigger(); + if (routineNpcIdField != null) r.setNpcId(routineNpcIdField.getText().trim()); + if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim()); + yield r; + } default -> null; }; if (t != null) t.setRequiresChapter(chapterSpinner.getValue()); @@ -186,6 +208,12 @@ public class TriggerDialog extends Dialog { fractionIdField.setText(f.getFractionId().toString()); if (fractionStatusCombo != null && f.getTargetStatus() != null) fractionStatusCombo.setValue(f.getTargetStatus()); + } else if (t instanceof ChangeRoutineTrigger r) { + typeCombo.setValue(TYPE_ROUTINE); + if (routineNpcIdField != null && r.getNpcId() != null) + routineNpcIdField.setText(r.getNpcId()); + if (routineNameField != null && r.getRoutineName() != null) + routineNameField.setText(r.getRoutineName()); } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java index 14a146e..064cde6 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java @@ -105,6 +105,9 @@ public class TriggerListEditor extends VBox { return "Fraktion-Status: " + (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?") + " → " + statusName(f.getTargetStatus()) + chapter; + if (t instanceof ChangeRoutineTrigger r) + return "Routine ändern: " + nullSafe(r.getNpcId()) + + " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter; return t.getClass().getSimpleName() + chapter; } diff --git a/blight-editor/src/main/resources/logback.xml b/blight-editor/src/main/resources/logback.xml index fc8e942..0ab35b2 100644 --- a/blight-editor/src/main/resources/logback.xml +++ b/blight-editor/src/main/resources/logback.xml @@ -23,6 +23,8 @@ + + diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java index 0f44272..57cbad0 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimSet.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimSet.java @@ -24,11 +24,28 @@ public class AnimSet { private List clips = new ArrayList<>(); private Map 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 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 anchorBoneMap = new LinkedHashMap<>(); public List getClips() { return clips; } public void setClips(List clips) { this.clips = clips; } public Map getActionMap() { return actionMap; } public void setActionMap(Map actionMap) { this.actionMap = actionMap; } + public String getPreviewModelPath() { return previewModelPath; } + public void setPreviewModelPath(String previewModelPath) { this.previewModelPath = previewModelPath; } + public Map getSinkMap() { return sinkMap != null ? sinkMap : new LinkedHashMap<>(); } + public void setSinkMap(Map sinkMap) { this.sinkMap = sinkMap; } + public Map getAnchorBoneMap() { return anchorBoneMap != null ? anchorBoneMap : new LinkedHashMap<>(); } + public void setAnchorBoneMap(Map anchorBoneMap) { this.anchorBoneMap = anchorBoneMap; } /** Speichert dieses Set als {@code .animset.json} im Verzeichnis {@code setDir}. */ public void save(Path setDir, String setName) throws IOException { diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index a9fdeb0..c15d515 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -1,33 +1,53 @@ package de.blight.game.animation; +import de.blight.common.model.TextRegistry; + /** * Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann. * Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen. */ public enum AnimationAction { DEFAULT, - IDLE, + IDLE, WALK, RUN, SPRINT, JUMP, RUNNING_JUMP, DUCK, - PICK_UP; - + PICK_UP, + LIE_DOWN, + LIE_UP, + LYING, + SIT_DOWN, + SIT_UP, + SITTING, + SIT_DOWN_FLOOR, + SITTING_FLOOR, + GET_UP_FLOOR; - /** Lesbare Bezeichnung für UI-Anzeige. */ + /** Lesbare Bezeichnung für UI-Anzeige, via TextRegistry aufgelöst. */ public String displayName() { + String key = "animation.action." + name().toLowerCase(); return switch (this) { - case DEFAULT -> "Default"; - case IDLE -> "Idle"; - case WALK -> "Walk"; - case RUN -> "Run"; - case SPRINT -> "Sprint"; - case JUMP -> "Jump"; - case RUNNING_JUMP -> "Running Jump"; - case DUCK -> "Duck"; - case PICK_UP -> "Pick up"; + case DEFAULT -> TextRegistry.resolve(null, key, "Default"); + case IDLE -> TextRegistry.resolve(null, key, "Idle"); + case WALK -> TextRegistry.resolve(null, key, "Walk"); + case RUN -> TextRegistry.resolve(null, key, "Run"); + case SPRINT -> TextRegistry.resolve(null, key, "Sprint"); + case JUMP -> TextRegistry.resolve(null, key, "Jump"); + case RUNNING_JUMP -> TextRegistry.resolve(null, key, "Running Jump"); + case DUCK -> TextRegistry.resolve(null, key, "Duck"); + case PICK_UP -> TextRegistry.resolve(null, key, "Pick up"); + case LIE_DOWN -> TextRegistry.resolve(null, key, "Hinlegen"); + case LIE_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bett)"); + case LYING -> TextRegistry.resolve(null, key, "Liegen"); + case SIT_DOWN -> TextRegistry.resolve(null, key, "Hinsetzen"); + case SIT_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bank)"); + case SITTING -> TextRegistry.resolve(null, key, "Sitzen"); + case SIT_DOWN_FLOOR -> TextRegistry.resolve(null, key, "Hinsetzen (Boden)"); + case SITTING_FLOOR -> TextRegistry.resolve(null, key, "Sitzen (Boden)"); + case GET_UP_FLOOR -> TextRegistry.resolve(null, key, "Aufstehen (Boden)"); }; } } diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java index 68ce6fc..5aba7f7 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java @@ -102,6 +102,14 @@ public class AnimationLibrary extends BaseAppState { } else { target = src; } + // Der interne Clip-Name kann vom Library-Schlüssel abweichen (z. B. Blender-Default + // "Action" statt "sit_down_new"). AnimComposer.setCurrentAction() sucht per Name, + // daher muss der Name des gespeicherten Clips dem clipName entsprechen. + if (target != null && !clipName.equals(target.getName())) { + AnimClip renamed = new AnimClip(clipName); + renamed.setTracks(target.getTracks()); + target = renamed; + } if (target == null) { log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName); return false; @@ -109,6 +117,9 @@ public class AnimationLibrary extends BaseAppState { ac.addAnimClip(target); log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName()); + if (clipName.equals("sit_down")) { + dumpClipTracks(target); + } return true; } @@ -204,8 +215,9 @@ public class AnimationLibrary extends BaseAppState { } private void loadClipFromFile(Path file) { - String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", ""); - String assetKey = "animations/clips/" + clipName + ".j3o"; + String fileName = file.getFileName().toString(); + String clipName = fileName.replaceFirst("\\.j3o$", ""); + String assetKey = "animations/clips/" + fileName; try { Spatial loaded = assetManager.loadModel(assetKey); @@ -218,7 +230,8 @@ public class AnimationLibrary extends BaseAppState { Armature armature = sc != null ? sc.getArmature() : null; for (String name : ac.getAnimClipsNames()) { - clips.put(name, ac.getAnimClip(name)); + com.jme3.anim.AnimClip animClip = ac.getAnimClip(name); + clips.put(name, animClip); if (armature != null) armatures.put(name, armature); log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey); } @@ -244,4 +257,29 @@ public class AnimationLibrary extends BaseAppState { return null; } + /** Loggt alle Tracks eines Clips: Bone-Name, hat Translation (T), Rotation (R), Scale (S). */ + private void dumpClipTracks(com.jme3.anim.AnimClip clip) { + log.info("[ClipDump] '{}' length={:.3f}s tracks={}", + clip.getName(), clip.getLength(), clip.getTracks().length); + int tracksWithTranslation = 0; + for (com.jme3.anim.AnimTrack t : clip.getTracks()) { + if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue; + boolean hasT = tt.getTranslations() != null && tt.getTranslations().length > 0; + boolean hasR = tt.getRotations() != null && tt.getRotations().length > 0; + boolean hasS = tt.getScales() != null && tt.getScales().length > 0; + String target = tt.getTarget() instanceof com.jme3.anim.Joint j ? j.getName() : "?"; + if (hasT) { + tracksWithTranslation++; + com.jme3.math.Vector3f t0 = tt.getTranslations()[0]; + com.jme3.math.Vector3f tN = tt.getTranslations()[tt.getTranslations().length - 1]; + log.info("[ClipDump] TRANSLATE '{}' frames={} start=({:.3f},{:.3f},{:.3f}) end=({:.3f},{:.3f},{:.3f}) deltaY={:.4f}", + target, tt.getTranslations().length, + t0.x, t0.y, t0.z, tN.x, tN.y, tN.z, tN.y - t0.y); + } else { + log.info("[ClipDump] rot-only '{}' T={} R={} S={}", target, hasT, hasR, hasS); + } + } + log.info("[ClipDump] Gesamt: {} Tracks mit Translation (von {})", tracksWithTranslation, clip.getTracks().length); + } + } diff --git a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java index a2700ec..676b41c 100644 --- a/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java +++ b/blight-game/src/main/java/de/blight/game/animation/RetargetingSystem.java @@ -17,6 +17,7 @@ import com.jme3.anim.Joint; import com.jme3.anim.SkinningControl; import com.jme3.anim.TransformTrack; import com.jme3.math.Quaternion; +import com.jme3.math.Vector3f; import com.jme3.scene.Node; import com.jme3.scene.Spatial; import com.jme3.scene.control.Control; @@ -69,7 +70,7 @@ public final class RetargetingSystem { // Mixamo-Clips, deren Knochen in Blender nur umbenannt (nicht retargeted) wurden, // haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel. if (isSameRig(nameMap, sourceArmature, targetArmature)) { - log.warn("[Retarget] '{}' same-rig detected – fast path (redirect only)", sourceClip.getName()); + log.debug("[Retarget] '{}' same-rig detected – fast path (redirect only)", sourceClip.getName()); return redirectTracks(sourceClip, targetArmature); } @@ -116,7 +117,7 @@ public final class RetargetingSystem { float[] msA = ms != null ? ms[0].toAngles(null) : new float[3]; float[] sbsA = srcBindMS.get(srcJ).toAngles(null); float[] dbsA = dstBindMS.get(dstJ).toAngles(null); - log.warn("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°", + log.trace("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°", sourceClip.getName(), e.getKey(), dstName, String.format("%.1f", Math.toDegrees(loc[0])), String.format("%.1f", Math.toDegrees(loc[1])), @@ -180,7 +181,7 @@ public final class RetargetingSystem { Quaternion bind = dstBindMS.get(d); float[] a = ams.toAngles(null); float[] b = bind != null ? bind.toAngles(null) : new float[3]; - log.warn("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°", + log.trace("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°", sourceClip.getName(), d.getName(), String.format("%.1f", Math.toDegrees(a[0])), String.format("%.1f", Math.toDegrees(a[1])), @@ -197,7 +198,7 @@ public final class RetargetingSystem { float[] cb = cbind != null ? cbind.toAngles(null) : new float[3]; Quaternion cl = ams.inverse().mult(cms); float[] cl_ = cl.toAngles(null); - log.warn("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°", + log.trace("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°", sourceClip.getName(), child.getName(), String.format("%.1f", Math.toDegrees(ca[0])), String.format("%.1f", Math.toDegrees(ca[1])), @@ -221,7 +222,7 @@ public final class RetargetingSystem { Quaternion local0 = pms.inverse().mult(ams); float[] a = ams.toAngles(null); float[] l = local0.toAngles(null); - log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°", + log.trace("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°", sourceClip.getName(), dName, String.format("%.1f", Math.toDegrees(a[0])), String.format("%.1f", Math.toDegrees(a[1])), @@ -248,13 +249,37 @@ public final class RetargetingSystem { log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName()); return null; } + // Collect translation tracks from source joints. + // The full-retarget path converts rotations to model-space; translations are in + // the bone's local (parent) space and are transferred directly because for same-rig + // retargeting the parent coordinate frames are identical or near-identical. + Map 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> newTracks = new ArrayList<>(); - for (var entry : dstLocalArrays.entrySet()) - newTracks.add(new TransformTrack(entry.getKey(), times, null, entry.getValue(), null)); + for (var entry : dstLocalArrays.entrySet()) { + Joint dst = entry.getKey(); + Joint srcJoint = dstToSrc.get(dst.getName()); + // Only copy translations when the frame count matches to avoid stride errors. + Vector3f[] translations = null; + if (srcJoint != null) { + Vector3f[] srcT = srcTransMap.get(srcJoint.getName()); + if (srcT != null && srcT.length == numFrames) { + translations = srcT; + } + } + newTracks.add(new TransformTrack(entry.getKey(), times, translations, entry.getValue(), null)); + } AnimClip result = new AnimClip(sourceClip.getName()); result.setTracks(newTracks.toArray(new AnimTrack[0])); - log.warn("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size()); + log.debug("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size()); return result; } @@ -401,7 +426,7 @@ public final class RetargetingSystem { if (newTracks.isEmpty()) return null; AnimClip result = new AnimClip(sourceClip.getName()); result.setTracks(newTracks.toArray(new AnimTrack[0])); - log.warn("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size()); + log.debug("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size()); return result; } diff --git a/blight-game/src/main/java/de/blight/game/config/ConfigScreen.java b/blight-game/src/main/java/de/blight/game/config/ConfigScreen.java index db51a4c..2fb61b9 100644 --- a/blight-game/src/main/java/de/blight/game/config/ConfigScreen.java +++ b/blight-game/src/main/java/de/blight/game/config/ConfigScreen.java @@ -292,7 +292,7 @@ public class ConfigScreen extends BaseAppState implements RawInputListener { } private BitmapText text(String content, int size, ColorRGBA color) { - BitmapText t = new BitmapText(font, false); + BitmapText t = new BitmapText(font); t.setSize(size); t.setColor(color); t.setText(content); diff --git a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java index 917bed7..99614bd 100644 --- a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java +++ b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java @@ -275,7 +275,7 @@ public class GraphicsScreen extends BaseAppState { } private BitmapText txt(String s, int size, ColorRGBA color) { - BitmapText t = new BitmapText(font, false); + BitmapText t = new BitmapText(font); t.setSize(size); t.setColor(color); t.setText(s); return t; } diff --git a/blight-game/src/main/java/de/blight/game/config/PauseMenu.java b/blight-game/src/main/java/de/blight/game/config/PauseMenu.java index 77b88fd..e87a34d 100644 --- a/blight-game/src/main/java/de/blight/game/config/PauseMenu.java +++ b/blight-game/src/main/java/de/blight/game/config/PauseMenu.java @@ -159,7 +159,7 @@ public class PauseMenu extends BaseAppState { } private BitmapText txt(String s, int size, ColorRGBA color) { - BitmapText t = new BitmapText(font, false); + BitmapText t = new BitmapText(font); t.setSize(size); t.setColor(color); t.setText(s); return t; } diff --git a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java index ed465cf..02c5f76 100644 --- a/blight-game/src/main/java/de/blight/game/console/JmeConsole.java +++ b/blight-game/src/main/java/de/blight/game/console/JmeConsole.java @@ -221,7 +221,7 @@ public class JmeConsole extends BaseAppState { } private BitmapText makeLine(BitmapFont font, ColorRGBA color, float x, float y) { - BitmapText t = new BitmapText(font, false); + BitmapText t = new BitmapText(font); t.setSize(LINE_H - 2f); t.setColor(color); t.setLocalTranslation(x, y, 0f); diff --git a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java index 43f94a0..7d39665 100644 --- a/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java +++ b/blight-game/src/main/java/de/blight/game/control/PlayerInputControl.java @@ -49,10 +49,44 @@ public class PlayerInputControl { private AnimComposer animComposer; private String runningClip; + private java.util.Map animSinkMap = java.util.Map.of(); + private java.util.Map animAnchorBoneMap = java.util.Map.of(); + + /** Bone-Anchoring: SkinningControl + Referenz-Position vor der Animation (Model-Space, alle Achsen). */ + private com.jme3.anim.SkinningControl skinningControl = null; + private Vector3f preAnimAnchorBoneModel = null; + private Vector3f preAnimVisualTranslation = null; + private String currentAnchorBone = null; + private boolean boneAnchorWarnLogged = false; + private int boneAnchorLogFrames = 0; private int jumpFrames = 0; private boolean pickupActive = false; private float pickupRemaining = 0f; + /** Allgemeine blockierende Animation (z. B. LIE_DOWN, SIT_DOWN). */ + private boolean blockingAnimActive = false; + private float blockingAnimRemaining = 0f; + private float blockingAnimTotal = 0f; + private Runnable blockingAnimCallback = null; + + /** + * Vertikaler Versatz des Visual-Nodes während einer blockierenden Animation + * (Root-Motion-Ersatz: Körper senkt sich beim Setzen, hebt sich beim Aufstehen). + * visualSinkCurrent wird pro Frame interpoliert. + */ + private float visualSinkStart = 0f; + private float visualSinkTarget = 0f; + private float visualSinkCurrent = 0f; + + /** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */ + private boolean turnActive = false; + private float turnRemaining = 0f; + private Vector3f turnDir = null; + private Runnable turnCallback = null; + + /** Sperrt Bewegungseingabe dauerhaft (Ruhezustand: liegen/sitzen). */ + private boolean lockedInPlace = false; + /** Autopilot: wenn gesetzt, geht der Charakter automatisch in diese (normalisierte) Richtung. */ private Vector3f autopilotDir = null; @@ -91,6 +125,21 @@ public class PlayerInputControl { this.runningClip = null; this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null; log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null); + // SinkMap + AnchorBoneMap aus AnimSet laden + if (animSetName != null && assetRoot != null) { + try { + java.nio.file.Path setDir = assetRoot.resolve("animations").resolve("sets"); + de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, animSetName); + animSinkMap = set.getSinkMap(); + animAnchorBoneMap = set.getAnchorBoneMap(); + } catch (Exception e) { + animSinkMap = java.util.Map.of(); + animAnchorBoneMap = java.util.Map.of(); + } + } + skinningControl = findSkinningControl(visual); + log.info("[AnimCtx] SkinningControl gefunden: {}, AnchorBoneMap: {}", + skinningControl != null, animAnchorBoneMap); if (animSetName != null) { String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE); if (clip != null && tryPlay(clip)) { @@ -142,6 +191,116 @@ public class PlayerInputControl { currentAnim = AnimationAction.PICK_UP; } + /** + * Spielt eine beliebige Animations-Aktion einmalig ab, blockiert Bewegung für {@code duration} + * Sekunden und ruft danach {@code onComplete} auf (im Update-Thread). + * Bei {@code duration <= 0} wird die echte Clip-Länge aus dem AnimComposer abgefragt. + */ + public void requestAnimation(AnimationAction action, float duration, Runnable onComplete) { + if (duration <= 0f) { + duration = resolveClipLength(action, 1.5f); + } + // Bone-Anchoring: pro Aktion konfigurierten Knochen laden und Referenz-Y einfrieren + currentAnchorBone = animAnchorBoneMap.get(action.name()); + if (currentAnchorBone != null && !currentAnchorBone.isBlank()) { + preAnimAnchorBoneModel = getBoneModelPos(currentAnchorBone); + preAnimVisualTranslation = visual != null ? visual.getLocalTranslation().clone() : new Vector3f(); + boneAnchorWarnLogged = false; + boneAnchorLogFrames = 0; + log.info("[BoneAnchor] Aktion={} Knochen='{}' preModelY={} (null={})", + action.name(), currentAnchorBone, + preAnimAnchorBoneModel != null ? preAnimAnchorBoneModel.y : Float.NaN, + preAnimAnchorBoneModel == null); + } else { + currentAnchorBone = null; + preAnimAnchorBoneModel = null; + preAnimVisualTranslation = null; + // Fallback: manuellen Sink aus AnimSet-Konfiguration laden + if (animSinkMap.containsKey(action.name())) { + visualSinkTarget = animSinkMap.get(action.name()); + } + } + blockingAnimActive = true; + blockingAnimRemaining = duration; + blockingAnimTotal = duration; + blockingAnimCallback = onComplete; + visualSinkStart = visualSinkCurrent; + autopilotDir = null; + forward = backward = left = right = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + playAction(action); + currentAnim = action; + } + + /** + * Überschreibt das Sink-Ziel für die nächste {@link #requestAnimation}-Animation manuell. + * Hat Vorrang vor der AnimSet-Konfiguration wenn VOR requestAnimation aufgerufen. + */ + public void setNextAnimationSink(float targetY) { + this.visualSinkTarget = targetY; + } + + /** Liefert die Länge des Clips für {@code action} in Sekunden, oder {@code fallback} wenn nicht ermittelbar. */ + private float resolveClipLength(AnimationAction action, float fallback) { + if (animComposer == null || animLib == null || animSetName == null) { + return fallback; + } + String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action); + if (clip == null) { + return fallback; + } + animLib.ensureApplied(clip, visual); + com.jme3.anim.AnimClip ac = animComposer.getAnimClip(clip); + float len = ac != null ? (float) ac.getLength() : 0f; + return len > 0f ? len : fallback; + } + + /** + * Dreht den Charakter ohne Fortbewegung in {@code dir} und ruft nach + * {@code duration} Sekunden {@code onComplete} auf. + * Am Ende wird die Zielrotation exakt eingestellt. + */ + public void requestTurn(Vector3f dir, float duration, Runnable onComplete) { + turnActive = true; + turnRemaining = duration; + turnDir = dir.normalize(); + turnCallback = onComplete; + autopilotDir = null; + forward = backward = left = right = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + if (currentAnim != AnimationAction.IDLE) { + playAction(AnimationAction.IDLE); + currentAnim = AnimationAction.IDLE; + } + } + + /** + * Sperrt Bewegungseingaben dauerhaft (Ruhezustand: Charakter liegt oder sitzt). + * Muss mit {@link #unlockFromPlace()} wieder aufgehoben werden. + */ + public void lockInPlace() { + lockedInPlace = true; + autopilotDir = null; + forward = backward = left = right = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + } + + /** Hebt den durch {@link #lockInPlace()} gesetzten Ruhezustand auf. */ + public void unlockFromPlace() { + lockedInPlace = false; + } + + public boolean isLockedInPlace() { return lockedInPlace; } + + /** + * Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands. + * Nur sinnvoll nach {@link #lockInPlace()}. + */ + public void playLockedAnimation(AnimationAction action) { + playAction(action); + currentAnim = action; + } + private void registerMappings(KeyBindings kb) { inputManager.addMapping("Forward", new KeyTrigger(kb.forward)); inputManager.addMapping("Backward", new KeyTrigger(kb.backward)); @@ -177,6 +336,75 @@ public class PlayerInputControl { } } + // Drehung auf der Stelle (kein Vorwärtsbewegen) + if (turnActive) { + turnRemaining -= tpf; + physicsChar.setWalkDirection(Vector3f.ZERO); + if (visual != null && turnDir != null) { + Quaternion targetRot = new Quaternion(); + targetRot.lookAt(turnDir, Vector3f.UNIT_Y); + Quaternion current = visual.getLocalRotation().clone(); + current.slerp(targetRot, ROTATE_SPEED * tpf); + visual.setLocalRotation(current); + } + if (turnRemaining <= 0f) { + turnActive = false; + // Zielrotation exakt einrasten + if (visual != null && turnDir != null) { + Quaternion snap = new Quaternion(); + snap.lookAt(turnDir, Vector3f.UNIT_Y); + visual.setLocalRotation(snap); + } + Runnable cb = turnCallback; + turnCallback = null; + if (cb != null) cb.run(); + } + return; + } + + // Blockierende Einmal-Animation (lie_down, sit_down, lie_up, sit_up …) + if (blockingAnimActive) { + blockingAnimRemaining -= tpf; + physicsChar.setWalkDirection(Vector3f.ZERO); + + // Visuellen Versatz anpassen: Foot-Anchoring hat Vorrang vor manuellem Sink + if (visual != null) { + if (currentAnchorBone != null && preAnimAnchorBoneModel != null) { + // Bone-Anchoring: 3D-Delta im Model-Space messen und als Visual-Offset anwenden. + // Model-Space ist unabhängig vom Visual-Shift → keine Rückkopplung. + applyBoneAnchorOffset(currentAnchorBone); + } else if (blockingAnimTotal > 0f) { + // Fallback: manueller Sink interpoliert + float t = Math.max(0f, Math.min(1f, 1f - blockingAnimRemaining / blockingAnimTotal)); + visualSinkCurrent = visualSinkStart + (visualSinkTarget - visualSinkStart) * t; + applyVisualSink(); + } + } + + if (blockingAnimRemaining <= 0f) { + blockingAnimActive = false; + if (currentAnchorBone != null && preAnimAnchorBoneModel != null) { + // Bone-Anchoring: letzten Kompensationswert einrasten + applyBoneAnchorOffset(currentAnchorBone); + } else { + // Fallback: Zielwert einrasten + visualSinkCurrent = visualSinkTarget; + applyVisualSink(); + } + Runnable cb = blockingAnimCallback; + blockingAnimCallback = null; + if (cb != null) cb.run(); + } else { + return; + } + } + + // Ruhezustand (liegen / sitzen) – blockiert alle Bewegungseingaben + if (lockedInPlace) { + physicsChar.setWalkDirection(Vector3f.ZERO); + return; + } + // Autopilot: Charakter läuft automatisch in eine vorgegebene Richtung (WALK-Animation) if (autopilotDir != null) { physicsChar.setWalkDirection(autopilotDir.mult(MOVE_SPEED * WALK_MULT)); @@ -261,6 +489,95 @@ public class PlayerInputControl { } } + /** + * Liefert die aktuelle Welt-Y des angegebenen Joints, oder NaN wenn nicht ermittelbar. + * Liest den Joint aus dem SkinningControl (nach AnimComposer-Update = aktueller Frame). + */ + /** + * Gibt die Position des Joints im Model-Space des Armatures zurück. + * Bewusst KEIN Welt-Transform: sonst entsteht eine Rückkopplung mit dem Visual-Offset, + * weil der Visual-Node-Shift den Welt-Transform des Knochens beeinflusst. + */ + private Vector3f getBoneModelPos(String boneName) { + if (skinningControl == null || boneName == null || boneName.isBlank()) { + return null; + } + com.jme3.anim.Armature armature = skinningControl.getArmature(); + if (armature == null) { + return null; + } + com.jme3.anim.Joint joint = armature.getJoint(boneName); + if (joint == null) { + return null; + } + return joint.getModelTransform().getTranslation().clone(); + } + + /** + * Berechnet den Y-Offset des Anchor-Knochens gegenüber seiner Startposition + * (in Model-Space, keine Rückkopplung mit dem Visual-Shift) und setzt die + * Local-Y des Visual-Nodes so, dass der Knochen vertikal fixiert bleibt. + * + * Nur Y wird kompensiert. X/Z-Drift im Model-Space liegt in einem anderen + * Koordinatensystem als der Visual-Node (Blender-Export-Rotation) und würde + * den Charakter horizontal verschieben — das ist falsch. + * + * Formel: visual.localY = preAnimVisualY + (preAnimBone.y - currentBone.y) * scale + */ + private void applyBoneAnchorOffset(String boneName) { + if (visual == null || preAnimAnchorBoneModel == null || preAnimVisualTranslation == null) { + if (!boneAnchorWarnLogged) { + log.warn("[BoneAnchor] applyBoneAnchorOffset abgebrochen: visual={} preModel={} preVis={}", + visual != null, preAnimAnchorBoneModel, preAnimVisualTranslation); + boneAnchorWarnLogged = true; + } + return; + } + Vector3f current = getBoneModelPos(boneName); + if (current == null) { + if (!boneAnchorWarnLogged) { + log.warn("[BoneAnchor] Knochen '{}' nicht im Armature gefunden (skinningControl={})", + boneName, skinningControl != null); + boneAnchorWarnLogged = true; + } + return; + } + float scale = skinningControl != null && skinningControl.getSpatial() != null + ? skinningControl.getSpatial().getWorldScale().y : 1f; + float newY = preAnimVisualTranslation.y + (preAnimAnchorBoneModel.y - current.y) * scale; + visualSinkCurrent = newY; + com.jme3.math.Vector3f t = visual.getLocalTranslation(); + visual.setLocalTranslation(t.x, newY, t.z); + } + + /** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */ + private com.jme3.anim.SkinningControl findSkinningControl(Spatial s) { + if (s == null) { + return null; + } + com.jme3.anim.SkinningControl sc = s.getControl(com.jme3.anim.SkinningControl.class); + if (sc != null) { + return sc; + } + if (s instanceof com.jme3.scene.Node n) { + for (Spatial child : n.getChildren()) { + sc = findSkinningControl(child); + if (sc != null) { + return sc; + } + } + } + return null; + } + + private void applyVisualSink() { + if (visual == null) { + return; + } + com.jme3.math.Vector3f t = visual.getLocalTranslation(); + visual.setLocalTranslation(t.x, visualSinkCurrent, t.z); + } + private boolean tryPlay(String clip) { if (animComposer == null || !animLib.ensureApplied(clip, visual)) { log.info("[Anim] tryPlay('{}') → ensureApplied FAILED", clip); diff --git a/blight-game/src/main/java/de/blight/game/navigation/PathFinder.java b/blight-game/src/main/java/de/blight/game/navigation/PathFinder.java new file mode 100644 index 0000000..d00484c --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/navigation/PathFinder.java @@ -0,0 +1,111 @@ +package de.blight.game.navigation; + +import com.jme3.math.Ray; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +import com.jme3.scene.Spatial; +import com.jme3.collision.CollisionResults; +import de.blight.common.model.WorldPoint; +import de.blight.common.path.PathNetwork; +import de.blight.common.path.PathNetworkIO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; + +/** + * Zentrale Klasse für NPC-Navigation. + * + *

Dreisegmentiges Pfad-Modell

+ *
+ *   A ──[Off-Netz]──► nächster Netzknoten
+ *      ──[A* Netz]──► letzter Netzknoten nahe B
+ *      ──[Off-Netz]──► B
+ * 
+ * + *

Hindernisvermeidung

+ *
    + *
  • Netz-Segment: keine weiteren Prüfungen – das Netz ist per Konstruktion + * um Hindernisse herum verlegt.
  • + *
  • Off-Netz-Segmente: Raycasting gegen {@code obstacleRoot}. + * Ist der direkte Weg blockiert, springt der Finder auf den nächsten + * freien Netzknoten zurück (Fallback: inkrementelles Steering durch + * die aufrufende Bewegungssteuerung, z. B. via {@link SteeringHelper}).
  • + *
+ */ +public class PathFinder { + + private static final Logger log = LoggerFactory.getLogger(PathFinder.class); + + private final PathNetwork network; + + /** + * Optionale Szene-Geometrie, gegen die der Off-Netz-Weg auf Hindernisse + * geprüft wird. Kann null sein (dann kein Obstacle-Check). + */ + private Spatial obstacleRoot; + + public PathFinder(PathNetwork network) { + this.network = network; + } + + /** Lädt das Wegnetz aus der Standard-Datei. */ + public static PathFinder load() throws IOException { + return new PathFinder(PathNetworkIO.load()); + } + + public void setObstacleRoot(Spatial root) { this.obstacleRoot = root; } + + // ── Haupt-API ────────────────────────────────────────────────────────────── + + /** + * Berechnet den vollständigen Pfad von {@code from} nach {@code to}. + * + * Rückgabe: flache Liste von Weltpunkten, die der NPC der Reihe nach + * anläuft. Wenn das Netz leer ist, wird [from, to] zurückgegeben. + */ + public List findPath(WorldPoint from, WorldPoint to) { + return network.findPath(from, to); + } + + /** + * Wie {@link #findPath}, gibt aber die drei Segmente getrennt zurück. + * Segment {@code offNetworkStart} und {@code offNetworkEnd} können + * durch {@link SteeringHelper} mit Hindernisvermeidung traversiert werden. + */ + public PathNetwork.PathResult findPathSegmented(WorldPoint from, WorldPoint to) { + return network.findPathSegmented(from, to); + } + + // ── Obstacle-Check ───────────────────────────────────────────────────────── + + /** + * Prüft, ob der direkte Weg zwischen zwei Punkten durch die Szene-Geometrie + * blockiert ist. + * + * @return {@code true} wenn der Weg frei ist (oder kein obstacleRoot gesetzt). + */ + public boolean isDirectPathClear(WorldPoint a, WorldPoint b) { + if (obstacleRoot == null) return true; + + Vector3f from3 = new Vector3f(a.x, a.y + 1f, a.z); // etwas über Boden + Vector3f to3 = new Vector3f(b.x, b.y + 1f, b.z); + Vector3f dir = to3.subtract(from3); + float dist = dir.length(); + if (dist < 0.001f) return true; + dir.divideLocal(dist); + + Ray ray = new Ray(from3, dir); + CollisionResults hits = new CollisionResults(); + obstacleRoot.collideWith(ray, hits); + + // Nur Treffer vor dem Ziel zählen + for (int i = 0; i < hits.size(); i++) { + if (hits.getCollision(i).getDistance() < dist) return false; + } + return true; + } + + public PathNetwork getNetwork() { return network; } +} diff --git a/blight-game/src/main/java/de/blight/game/navigation/SteeringHelper.java b/blight-game/src/main/java/de/blight/game/navigation/SteeringHelper.java new file mode 100644 index 0000000..e1997bc --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/navigation/SteeringHelper.java @@ -0,0 +1,121 @@ +package de.blight.game.navigation; + +import com.jme3.math.Vector3f; +import de.blight.common.model.WorldPoint; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hilfsmethoden für Off-Netz-Bewegung mit einfacher Hindernisvermeidung. + * + *

Prinzip (Bug-Algorithmus Lite)

+ * Ist der direkte Weg von {@code current} nach {@code target} frei, geht der + * NPC direkt dorthin. Ist er blockiert, weicht er seitlich aus (Tangente + * senkrecht zur Bewegungsrichtung) und versucht dann erneut. + * + * Die aufwendige Geometrie-Prüfung delegiert an {@link PathFinder#isDirectPathClear}. + * Diese Klasse liefert nur Richtungsvektoren – die eigentliche Physik-/Animations- + * Steuerung übernimmt das NPC-Control. + */ +public final class SteeringHelper { + + private SteeringHelper() {} + + /** Maximale Anzahl von Ausweich-Versuchen pro Frame/Schritt. */ + private static final int MAX_SIDESTEP_TRIES = 8; + private static final float SIDESTEP_DISTANCE = 2f; + + /** + * Gibt einen Zwischen-Wegpunkt zurück, der den NPC um ein Hindernis leitet. + * + *

Algorithmus: + *

    + *
  1. Direkter Weg frei? → Ziel direkt zurückgeben.
  2. + *
  3. Seitlich ausweichen (8 Richtungen in 45°-Schritten), erste freie + * Richtung → Zwischen-Ziel zurückgeben.
  4. + *
  5. Kein Ausweg gefunden → Fallback: direktes Ziel (Bewegungsystem + * darf dann selbst abbremsen / warten).
  6. + *
+ * + * @param current Aktuelle NPC-Position + * @param target Gewünschtes Ziel + * @param finder PathFinder für Obstacle-Check + * @return Nächster anzulaufender Punkt (kann {@code target} selbst sein) + */ + public static WorldPoint nextSteeringPoint(WorldPoint current, + WorldPoint target, + PathFinder finder) { + if (finder.isDirectPathClear(current, target)) return target; + + float dx = target.x - current.x; + float dz = target.z - current.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len < 0.001f) return target; + + dx /= len; dz /= len; + + // Senkrechter Vektor (links / rechts) + float perpX = dz; + float perpZ = -dx; + + for (int i = 1; i <= MAX_SIDESTEP_TRIES; i++) { + float angle = (float) (i * Math.PI / MAX_SIDESTEP_TRIES); + // Wechselnde Seiten + int sign = (i % 2 == 0) ? 1 : -1; + float sideX = perpX * sign * (float) Math.cos(angle) * SIDESTEP_DISTANCE; + float sideZ = perpZ * sign * (float) Math.sin(angle) * SIDESTEP_DISTANCE; + + WorldPoint candidate = new WorldPoint( + current.x + dx * SIDESTEP_DISTANCE + sideX, + current.y, + current.z + dz * SIDESTEP_DISTANCE + sideZ); + + if (finder.isDirectPathClear(current, candidate)) return candidate; + } + + return target; // Fallback + } + + /** + * Verfeinert einen Off-Netz-Pfad so, dass blockierte Segmente durch + * Ausweichpunkte ersetzt werden. + * + * @param waypoints Ursprüngliche Punktliste (z. B. aus PathResult.offNetworkStart) + * @param finder Obstacle-Checker + * @return Neue Punktliste mit Ausweichpunkten wo nötig + */ + public static List resolveObstacles(List waypoints, + PathFinder finder) { + List result = new ArrayList<>(); + if (waypoints.isEmpty()) return result; + result.add(waypoints.get(0)); + + for (int i = 0; i < waypoints.size() - 1; i++) { + WorldPoint a = waypoints.get(i); + WorldPoint b = waypoints.get(i + 1); + if (!finder.isDirectPathClear(a, b)) { + WorldPoint detour = nextSteeringPoint(a, b, finder); + result.add(detour); + } + result.add(b); + } + return result; + } + + /** Berechnet die Bewegungsrichtung (normiert, im X/Z-Plane) von {@code from} nach {@code to}. */ + public static Vector3f directionXZ(WorldPoint from, WorldPoint to) { + float dx = to.x - from.x; + float dz = to.z - from.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len < 0.001f) return Vector3f.ZERO; + return new Vector3f(dx / len, 0f, dz / len); + } + + /** Horizontale Distanz zwischen zwei Punkten. */ + public static float dist2D(WorldPoint a, WorldPoint b) { + float dx = a.x - b.x; + float dz = a.z - b.z; + return (float) Math.sqrt(dx * dx + dz * dz); + } +} diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 34009b0..9d6cd91 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -40,14 +40,17 @@ import de.blight.game.state.GrassVertexRenderState; import de.blight.game.state.LocationState; import de.blight.game.state.RiverState; import de.blight.game.state.TerrainChunkState; -import de.blight.game.state.VoxelChunkState; +import de.blight.game.state.SculptedMeshState; import de.blight.game.state.WaterBodyState; import de.blight.game.state.DayNightState; import de.blight.game.state.WeatherState; import de.blight.game.state.InteractionHudState; import de.blight.game.state.InventoryState; +import de.blight.game.state.WorldInteractableState; import de.blight.game.state.WorldItemsState; +import de.blight.game.state.StoneWorldState; import de.blight.game.state.WorldObjectsState; +import de.blight.game.state.WorldLightState; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -147,6 +150,8 @@ public class WorldScene extends BaseAppState { BlightGame.status("Lade Welt-Objekte..."); app.getStateManager().attach(new RiverState()); app.getStateManager().attach(new WorldObjectsState()); + app.getStateManager().attach(new WorldLightState(sharedFPP)); + app.getStateManager().attach(new StoneWorldState()); BlightGame.status("Lade Charakter..."); character = loadOrBuildCharacter(); @@ -181,6 +186,8 @@ public class WorldScene extends BaseAppState { app.getStateManager().attach(new LocationState(mc, character)); app.getStateManager().attach( new WorldItemsState(keyBindings, physicsChar, mc, playerInput)); + app.getStateManager().attach( + new WorldInteractableState(keyBindings, physicsChar, playerInput)); app.getStateManager().attach(new InteractionHudState()); inventoryState = new InventoryState(mc, keyBindings); inventoryState.setEnabled(false); @@ -238,7 +245,6 @@ public class WorldScene extends BaseAppState { */ public void toggleDebugNoLight() { debugMode = (debugMode + 1) % 4; - VoxelChunkState vcs = getApplication().getStateManager().getState(VoxelChunkState.class); switch (debugMode) { case 0 -> { if (terrainMaterial != null) { @@ -246,7 +252,6 @@ public class WorldScene extends BaseAppState { terrainMaterial.setBoolean("DebugSlot0Only", false); terrainMaterial.clearParam("DebugDirectTex"); } - if (vcs != null) vcs.setDebugNoLight(false); log.info("[Debug] Modus 0: normal"); } case 1 -> { @@ -254,8 +259,7 @@ public class WorldScene extends BaseAppState { terrainMaterial.setBoolean("DebugNoLight", true); terrainMaterial.setBoolean("DebugSlot0Only", false); } - if (vcs != null) vcs.setDebugNoLight(true); - log.info("[Debug] Modus 1: kein Licht (Terrain + Voxel)"); + log.info("[Debug] Modus 1: kein Licht"); } case 2 -> { if (terrainMaterial != null) { @@ -263,7 +267,6 @@ public class WorldScene extends BaseAppState { terrainMaterial.setBoolean("DebugSlot0Only", true); terrainMaterial.clearParam("DebugDirectTex"); } - if (vcs != null) vcs.setDebugNoLight(true); log.info("[Debug] Modus 2: nur Slot-0 (kein Blending, kein Licht)"); } case 3 -> { @@ -275,8 +278,7 @@ public class WorldScene extends BaseAppState { t.setWrap(com.jme3.texture.Texture.WrapMode.Repeat); terrainMaterial.setTexture("DebugDirectTex", t); } - if (vcs != null) vcs.setDebugNoLight(true); - log.info("[Debug] Modus 3: direkte Texture2D '{}' (bypasses TextureArray)", debugSlot0Path); + log.info("[Debug] Modus 3: direkte Texture2D '{}'", debugSlot0Path); } } } @@ -480,9 +482,7 @@ public class WorldScene extends BaseAppState { // sofort die Physik für die Spawn-Umgebung aufbaut. terrainChunkState.setSpawnHint(spawnX, spawnZ); - VoxelChunkState voxelState = new VoxelChunkState(bulletAppState, loadedMapData); - terrainChunkState.addChunkListener(voxelState); - app.getStateManager().attach(voxelState); + app.getStateManager().attach(new SculptedMeshState(bulletAppState, loadedMapData)); } // ----------------------------------------------------------------------- diff --git a/blight-game/src/main/java/de/blight/game/state/DayNightState.java b/blight-game/src/main/java/de/blight/game/state/DayNightState.java index 97bf6c5..11d97e0 100644 --- a/blight-game/src/main/java/de/blight/game/state/DayNightState.java +++ b/blight-game/src/main/java/de/blight/game/state/DayNightState.java @@ -3,6 +3,7 @@ package de.blight.game.state; import com.jme3.app.Application; import com.jme3.app.SimpleApplication; import com.jme3.app.state.BaseAppState; +import com.jme3.bullet.BulletAppState; import com.jme3.light.AmbientLight; import com.jme3.light.DirectionalLight; import com.jme3.material.Material; @@ -49,6 +50,24 @@ public class DayNightState extends BaseAppState implements TimeListener { private Spatial sky; private Geometry sunSphere; + // ── Höhlen-Abdunkelung ──────────────────────────────────────────────────── + + /** 0 = im Freien, 1 = vollständig in einer Höhle. */ + private float caveFactor = 0f; + private float targetCaveFactor = 0f; + private float caveCheckTimer = 0f; + /** Sonnenlicht-Basisfarbe (Tag/Nacht ohne Höhlen-Faktor). */ + private ColorRGBA sunBaseColor = new ColorRGBA(1, 1, 1, 1); + /** Schatten-Intensität ohne Höhlen-Faktor. */ + private float shadowBaseIntensity = 0f; + + /** Raycast-Abstand nach oben (m) – trifft Decken bis zu dieser Höhe. */ + private static final float CAVE_RAY_HEIGHT = 12f; + /** Zeitabstand zwischen Raycasts (Sek.). */ + private static final float CAVE_CHECK_INTERVAL = 0.2f; + /** Überblendgeschwindigkeit (Einheit/Sek.) beim Ein- und Ausblenden. */ + private static final float CAVE_FADE_SPEED = 1.2f; + // ── Konstruktoren ───────────────────────────────────────────────────────── /** Game-Modus: Schatten aktiv, 5-Minuten-Tag. */ @@ -183,6 +202,39 @@ public class DayNightState extends BaseAppState implements TimeListener { if (sunSphere != null && sunSphere.getCullHint() != Spatial.CullHint.Always) { sunSphere.setLocalTranslation(camPos.add(sun.getDirection().negate().mult(480f))); } + + updateCaveLighting(tpf, camPos); + } + + /** Prüft per Physik-Raycast ob die Kamera unter einer Decke ist und blendet das Sonnenlicht aus. */ + private void updateCaveLighting(float tpf, Vector3f camPos) { + // Raycast nur alle CAVE_CHECK_INTERVAL Sekunden + caveCheckTimer += tpf; + if (caveCheckTimer >= CAVE_CHECK_INTERVAL) { + caveCheckTimer = 0f; + BulletAppState bullet = app.getStateManager().getState(BulletAppState.class); + if (bullet != null && bullet.getPhysicsSpace() != null) { + Vector3f to = camPos.add(0f, CAVE_RAY_HEIGHT, 0f); + boolean underCover = !bullet.getPhysicsSpace().rayTest(camPos, to).isEmpty(); + targetCaveFactor = underCover ? 1f : 0f; + } + } + + // Glatte Überblendung des Höhlen-Faktors + float prev = caveFactor; + if (caveFactor < targetCaveFactor) { + caveFactor = Math.min(targetCaveFactor, caveFactor + CAVE_FADE_SPEED * tpf); + } else if (caveFactor > targetCaveFactor) { + caveFactor = Math.max(targetCaveFactor, caveFactor - CAVE_FADE_SPEED * tpf); + } + + // Licht nur aktualisieren wenn sich der Faktor geändert hat + if (caveFactor != prev) { + float scale = 1f - caveFactor; + sun.setColor(sunBaseColor.mult(scale)); + if (shadowFilter != null) + shadowFilter.setShadowIntensity(shadowBaseIntensity * scale); + } } // ── Zeit-Callback ───────────────────────────────────────────────────────── @@ -205,7 +257,8 @@ public class DayNightState extends BaseAppState implements TimeListener { // ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) ────────────── float dawnFactor = 1f - FastMath.clamp(elev * 5f, 0f, 1f); ColorRGBA sunColor = SUN_DAWN.clone().interpolateLocal(SUN_DAY, 1f - dawnFactor); - sun.setColor(sunColor.multLocal(elevC * 0.85f)); + sunBaseColor = sunColor.mult(elevC * 0.85f); + sun.setColor(sunBaseColor.mult(1f - caveFactor)); // ── Sonnen-Sphere ausblenden wenn unter Horizont ──────────────────── if (sunSphere != null) { @@ -225,8 +278,9 @@ public class DayNightState extends BaseAppState implements TimeListener { ambient.setColor(AMB_NIGHT.clone().interpolateLocal(AMB_DAY, ambFactor)); // ── Schatten ──────────────────────────────────────────────────────── + shadowBaseIntensity = FastMath.clamp(elev * 0.8f, 0f, 0.5f); if (shadowFilter != null) - shadowFilter.setShadowIntensity(FastMath.clamp(elev * 0.8f, 0f, 0.5f)); + shadowFilter.setShadowIntensity(shadowBaseIntensity * (1f - caveFactor)); // ── Himmel & Hintergrundfarbe ──────────────────────────────────────── float skyFactor = FastMath.clamp((elev + 0.05f) / 0.2f, 0f, 1f); diff --git a/blight-game/src/main/java/de/blight/game/state/GrassState.java b/blight-game/src/main/java/de/blight/game/state/GrassState.java index c9e55b9..6d02198 100644 --- a/blight-game/src/main/java/de/blight/game/state/GrassState.java +++ b/blight-game/src/main/java/de/blight/game/state/GrassState.java @@ -7,15 +7,11 @@ import com.jme3.asset.AssetManager; import com.jme3.material.Material; import com.jme3.material.RenderState; import com.jme3.math.*; -import com.jme3.renderer.Camera; -import com.jme3.renderer.RenderManager; -import com.jme3.renderer.ViewPort; 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.scene.control.AbstractControl; import com.jme3.util.BufferUtils; import de.blight.common.GrassTuft; import de.blight.common.GrassTuftIO; @@ -31,9 +27,10 @@ import java.util.*; /** * Rendert individuell platzierte Gras-Büschel aus blight_grass.blg. * Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME). - * GrassVisibilityControl cullt entfernte Chunks. + * Implementiert ChunkListener: Gras wird nur bei LOD 0 angezeigt. */ -public class GrassState extends BaseAppState { +public class GrassState extends BaseAppState + implements TerrainChunkState.ChunkListener { private static final Logger log = LoggerFactory.getLogger(GrassState.class); @@ -44,17 +41,15 @@ public class GrassState extends BaseAppState { private static final int BLADES_PER_TUFT = 4; private static final float TUFT_SPREAD = 0.5f; private static final float BLADE_WIDTH = 0.18f; - private static final float FAR_DIST = 150f; - private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST; private static final int INIT_PER_FRAME = 4; private final TerrainChunkState terrainChunkState; - private Camera cam; private Node grassNode; @SuppressWarnings("unchecked") private final List[] chunkTufts = new List[CHUNK_COUNT]; + private final Node[] chunkNodes = new Node[CHUNK_COUNT]; private final Map slotMaterials = new LinkedHashMap<>(); private int nextChunk = 0; @@ -66,9 +61,9 @@ public class GrassState extends BaseAppState { @Override protected void initialize(Application app) { - this.cam = app.getCamera(); grassNode = new Node("gameGrass"); ((SimpleApplication) app).getRootNode().attachChild(grassNode); + terrainChunkState.addChunkListener(this); for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>(); @@ -92,6 +87,7 @@ public class GrassState extends BaseAppState { @Override protected void cleanup(Application app) { + terrainChunkState.removeChunkListener(this); ((SimpleApplication) app).getRootNode().detachChild(grassNode); } @@ -201,8 +197,6 @@ public class GrassState extends BaseAppState { if (bySlot.isEmpty()) return; - float chunkCX = wXMin + CHUNK_SIZE * 0.5f; - float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; Node node = new Node("grass_" + idx); for (Map.Entry> entry : bySlot.entrySet()) { if (entry.getValue().isEmpty()) continue; @@ -214,10 +208,34 @@ public class GrassState extends BaseAppState { node.attachChild(geo); } if (node.getChildren().isEmpty()) return; - node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); + node.setCullHint(Spatial.CullHint.Always); // sichtbar erst wenn LOD0 + chunkNodes[idx] = node; grassNode.attachChild(node); } + // ── ChunkListener: Gras nur bei LOD 0 ──────────────────────────────────── + + @Override + public void onChunkVisible(int cx, int cz, int lod) { + setChunkVisible(cx, cz, lod == 0); + } + + @Override + public void onChunkHidden(int cx, int cz) { + setChunkVisible(cx, cz, false); + } + + @Override + public void onChunkLodChanged(int cx, int cz, int oldLod, int newLod) { + setChunkVisible(cx, cz, newLod == 0); + } + + private void setChunkVisible(int cx, int cz, boolean visible) { + int ci = cz * CHUNKS_PER_AXIS + cx; + if (ci < 0 || ci >= CHUNK_COUNT || chunkNodes[ci] == null) return; + chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } + // ── Mesh: Kreuz-Quad mit UV ─────────────────────────────────────────────── private static Mesh buildGrassMesh(List blades) { @@ -256,25 +274,4 @@ public class GrassState extends BaseAppState { return mesh; } - // ── LOD-Control ─────────────────────────────────────────────────────────── - - private static final class GrassVisibilityControl extends AbstractControl { - private final Camera cam; - private final Vector3f center; - - GrassVisibilityControl(Camera cam, Vector3f center) { - this.cam = cam; - this.center = center; - } - - @Override - protected void controlUpdate(float tpf) { - float distSq = cam.getLocation().distanceSquared(center); - spatial.setCullHint(distSq > FAR_DIST_SQ - ? Spatial.CullHint.Always - : Spatial.CullHint.Inherit); - } - - @Override protected void controlRender(RenderManager rm, ViewPort vp) {} - } } diff --git a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java index 831035a..4e49e8a 100644 --- a/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/InteractionHudState.java @@ -38,7 +38,7 @@ public class InteractionHudState extends BaseAppState { this.guiNode = sapp.getGuiNode(); BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt"); - labelText = new BitmapText(font, false); + labelText = new BitmapText(font); labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f); labelText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f)); labelText.setCullHint(Spatial.CullHint.Always); diff --git a/blight-game/src/main/java/de/blight/game/state/InventoryState.java b/blight-game/src/main/java/de/blight/game/state/InventoryState.java index 1818655..b701add 100644 --- a/blight-game/src/main/java/de/blight/game/state/InventoryState.java +++ b/blight-game/src/main/java/de/blight/game/state/InventoryState.java @@ -434,7 +434,7 @@ public class InventoryState extends BaseAppState { } private BitmapText txt(String s, int size, ColorRGBA col) { - BitmapText t = new BitmapText(font, false); + BitmapText t = new BitmapText(font); t.setSize(size); t.setColor(col); t.setText(s); return t; } diff --git a/blight-game/src/main/java/de/blight/game/state/MarchingCubes.java b/blight-game/src/main/java/de/blight/game/state/MarchingCubes.java index c9b2a4b..5ee1c52 100644 --- a/blight-game/src/main/java/de/blight/game/state/MarchingCubes.java +++ b/blight-game/src/main/java/de/blight/game/state/MarchingCubes.java @@ -507,7 +507,8 @@ public final class MarchingCubes { // Vertices gleicher Position zu Gruppen zusammenfassen. // Schlüssel: quantisierte XYZ-Koordinaten (1/2048 Voxel-Auflösung). - HashMap keyToGroup = new HashMap<>(vertCount * 2); + // Initiale Kapazität ~vertCount/3 (je 3 Triangle-Verts teilen sich 1 Unique-Position). + HashMap keyToGroup = new HashMap<>(vertCount / 3 + 16); int[] vertGroup = new int[vertCount]; int[] groupFirst = new int[vertCount]; // ein Repräsentant je Gruppe int groupCount = 0; @@ -545,6 +546,21 @@ public final class MarchingCubes { } } + // Unterste freie Vertices einfrieren: Der Laplacian-Smooth würde die Basis des + // Gebirges nach oben ziehen und einen sichtbaren Spalt zum Terrain erzeugen. + // Vertices innerhalb der untersten 3 lokalen Einheiten (= 3 m) über der + // tiefsten freien Position bleiben fix, damit die Terrain-Anbindung erhalten bleibt. + float minFreeY = Float.MAX_VALUE; + for (int g = 0; g < groupCount; g++) { + if (!pinned[g] && gy[g] < minFreeY) minFreeY = gy[g]; + } + if (minFreeY < Float.MAX_VALUE) { + float basePinY = minFreeY + 3f; + for (int g = 0; g < groupCount; g++) { + if (!pinned[g] && gy[g] <= basePinY) pinned[g] = true; + } + } + float[] ax = new float[groupCount]; float[] ay = new float[groupCount]; float[] az = new float[groupCount]; diff --git a/blight-game/src/main/java/de/blight/game/state/SculptedMeshState.java b/blight-game/src/main/java/de/blight/game/state/SculptedMeshState.java new file mode 100644 index 0000000..54db675 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/SculptedMeshState.java @@ -0,0 +1,309 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.bullet.BulletAppState; +import com.jme3.bullet.collision.shapes.MeshCollisionShape; +import com.jme3.bullet.control.RigidBodyControl; +import com.jme3.export.binary.BinaryImporter; +import com.jme3.material.Material; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.renderer.queue.RenderQueue; +import com.jme3.scene.Geometry; +import com.jme3.scene.Mesh; +import com.jme3.scene.Node; +import com.jme3.scene.VertexBuffer; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.image.ColorSpace; +import de.blight.common.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; + +/** + * Lädt alle gebackenen Voxel-Meshes (+ eventuelle Sculpt-Overlays) beim + * Spielstart statisch in die Szene. Kein Chunk-Listener, kein dynamisches + * Nach-/Entladen – das Mesh ist das finale Asset. + */ +public class SculptedMeshState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(SculptedMeshState.class); + + private final BulletAppState bulletState; + private final MapData mapData; + private SimpleApplication app; + private Node sculptRoot; + private Material material; + + public SculptedMeshState(BulletAppState bulletState, MapData mapData) { + this.bulletState = bulletState; + this.mapData = mapData; + } + + @Override + protected void initialize(Application application) { + app = (SimpleApplication) application; + sculptRoot = new Node("sculptRoot"); + app.getRootNode().attachChild(sculptRoot); + material = buildMaterial(); + loadAll(); + } + + @Override + protected void cleanup(Application application) { + sculptRoot.removeFromParent(); + } + + @Override protected void onEnable() {} + @Override protected void onDisable() {} + + @Override + public void update(float tpf) { + DayNightState dns = app.getStateManager().getState(DayNightState.class); + if (dns == null || dns.getSunLight() == null) return; + material.setVector3("LightDir", dns.getSunDirection().negate()); + ColorRGBA sc = dns.getSunLight().getColor(); + ColorRGBA ac = dns.getAmbientLight().getColor(); + material.setVector3("SunColor", new Vector3f(sc.r, sc.g, sc.b)); + material.setVector3("AmbientColor", new Vector3f(ac.r, ac.g, ac.b)); + } + + // ── Laden ──────────────────────────────────────────────────────────────── + + private void loadAll() { + List chunks = SculptedMeshIO.findAllBakedChunks(); + log.info("[SculptedMesh] {} gebackene Chunks gefunden.", chunks.size()); + for (int[] cxyz : chunks) { + try { + loadChunk(cxyz[0], cxyz[1], cxyz[2]); + } catch (Exception e) { + log.warn("[SculptedMesh] Laden fehlgeschlagen ({},{},{}): {}", + cxyz[0], cxyz[1], cxyz[2], e.getMessage(), e); + } + } + } + + private void loadChunk(int cx, int cy, int cz) throws Exception { + Path p0 = VoxelChunkIO.getBakedPath(cx, cy, cz, 0); + if (!Files.exists(p0)) return; + + BinaryImporter imp = BinaryImporter.getInstance(); + imp.setAssetManager(app.getAssetManager()); + Mesh mesh = (Mesh) imp.load(p0.toFile()); + + // Vertices verschweißen – gleiche Position → selber Welded-Index + FloatBuffer posBuf = mesh.getFloatBuffer(VertexBuffer.Type.Position); + int rawCount = posBuf.limit() / 3; + float[] rawPos = new float[rawCount * 3]; + posBuf.rewind(); + posBuf.get(rawPos); + + int[] v2w = new int[rawCount]; + int wCount = 0; + HashMap 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); v2w[v] = wCount++; } + else { v2w[v] = w; } + } + float[] weldedPos = new float[wCount * 3]; + for (int v = 0; v < rawCount; v++) { + int w = v2w[v]; + weldedPos[w*3] = rawPos[v*3]; + weldedPos[w*3+1] = rawPos[v*3+1]; + weldedPos[w*3+2] = rawPos[v*3+2]; + } + + // Sculpt-Overlay anwenden (welded Positionen aus .blsm auf raw aufächern) + if (SculptedMeshIO.exists(cx, cy, cz)) { + try { + SculptedMesh overlay = SculptedMeshIO.load(cx, cy, cz); + if (overlay.positions.length / 3 == wCount) { + weldedPos = overlay.positions; + float[] expandedPos = new float[rawCount * 3]; + for (int v = 0; v < rawCount; v++) { + int w = v2w[v]; + expandedPos[v*3] = weldedPos[w*3]; + expandedPos[v*3+1] = weldedPos[w*3+1]; + expandedPos[v*3+2] = weldedPos[w*3+2]; + } + posBuf.rewind(); + posBuf.put(expandedPos); + posBuf.rewind(); + mesh.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded(); + } else { + log.warn("[SculptedMesh] Overlay ({},{},{}) Größe passt nicht: {} vs {}", + cx, cy, cz, overlay.positions.length / 3, wCount); + } + } catch (Exception e) { + log.warn("[SculptedMesh] Overlay ({},{},{}) fehlerhaft: {}", cx, cy, cz, e.getMessage()); + } + } + + // Smooth-Normalen im verschweißten Raum neu berechnen, damit vNormal.y + // zwischen Vertices interpoliert und der Flat↔Steep-Blend im Shader weich ist. + recomputeSmoothedNormals(mesh, v2w, weldedPos, rawCount, wCount); + + float ox = cx * VoxelChunk.CELLS - 2048f; + float oy = cy * (float) VoxelChunk.CELLS; + float oz = cz * VoxelChunk.CELLS - 2048f; + + com.jme3.bounding.BoundingBox bb = (com.jme3.bounding.BoundingBox) mesh.getBound(); + log.info("[SculptedMesh] Chunk ({},{},{}) geladen: nodeOrigin=({},{},{}) meshBounds={} vertices={}", + cx, cy, cz, ox, oy, oz, bb, mesh.getVertexCount()); + + Geometry geo = new Geometry("sculpted_" + cx + "_" + cy + "_" + cz, mesh); + geo.setMaterial(material); + geo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive); + + Node node = new Node(); + node.setLocalTranslation(ox, oy, oz); + node.attachChild(geo); + sculptRoot.attachChild(node); + + // Physics NACH Einbinden in den Szenengraphen, damit die Welt-Transform korrekt ist + MeshCollisionShape shape = new MeshCollisionShape(mesh); + RigidBodyControl rbc = new RigidBodyControl(shape, 0f); + geo.addControl(rbc); + bulletState.getPhysicsSpace().add(rbc); + } + + /** + * Berechnet Smooth-Normalen im verschweißten Raum: + * Flächennormalen aller Dreiecke werden pro Welded-Vertex akkumuliert, + * normalisiert und dann auf die rohen Vertices aufgeächert. + * Das entspricht dem Verfahren im Editor und in MarchingCubes.smooth(). + */ + private static void recomputeSmoothedNormals(Mesh mesh, int[] v2w, float[] weldedPos, + int rawCount, int wCount) { + float[] wn = new float[wCount * 3]; + // Triangle-Soup: Vertices liegen immer in Gruppen von 3 aufeinander + for (int i = 0; i < rawCount; i += 3) { + int w0 = v2w[i], w1 = v2w[i+1], w2 = v2w[i+2]; + float p0x=weldedPos[w0*3], p0y=weldedPos[w0*3+1], p0z=weldedPos[w0*3+2]; + float p1x=weldedPos[w1*3], p1y=weldedPos[w1*3+1], p1z=weldedPos[w1*3+2]; + float p2x=weldedPos[w2*3], p2y=weldedPos[w2*3+1], p2z=weldedPos[w2*3+2]; + float e1x=p1x-p0x, e1y=p1y-p0y, e1z=p1z-p0z; + float e2x=p2x-p0x, e2y=p2y-p0y, e2z=p2z-p0z; + float nx=e1y*e2z-e1z*e2y, ny=e1z*e2x-e1x*e2z, nz=e1x*e2y-e1y*e2x; + wn[w0*3]+=nx; wn[w0*3+1]+=ny; wn[w0*3+2]+=nz; + wn[w1*3]+=nx; wn[w1*3+1]+=ny; wn[w1*3+2]+=nz; + wn[w2*3]+=nx; wn[w2*3+1]+=ny; wn[w2*3+2]+=nz; + } + for (int w = 0; w < wCount; w++) { + float len=(float)Math.sqrt(wn[w*3]*wn[w*3]+wn[w*3+1]*wn[w*3+1]+wn[w*3+2]*wn[w*3+2]); + if (len>1e-4f) { wn[w*3]/=len; wn[w*3+1]/=len; wn[w*3+2]/=len; } + else { wn[w*3+1]=1f; } + } + float[] rawNormals = new float[rawCount * 3]; + for (int v = 0; v < rawCount; v++) { + int w = v2w[v]; + rawNormals[v*3] = wn[w*3]; + rawNormals[v*3+1] = wn[w*3+1]; + rawNormals[v*3+2] = wn[w*3+2]; + } + VertexBuffer nb = mesh.getBuffer(VertexBuffer.Type.Normal); + if (nb != null) { + FloatBuffer nbf = (FloatBuffer) nb.getData(); + nbf.clear(); + nbf.put(rawNormals); + nbf.rewind(); + nb.setUpdateNeeded(); + } else { + mesh.setBuffer(VertexBuffer.Type.Normal, 3, rawNormals); + } + mesh.updateBound(); + } + + 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); + } + + // ── Material ───────────────────────────────────────────────────────────── + + private Material buildMaterial() { + Material mat = new Material(app.getAssetManager(), "MatDefs/Voxel.j3md"); + mat.setFloat("TexScale", 8f); + int[] slots = { + mapData != null ? mapData.voxelFlatSlot : -1, + mapData != null ? mapData.voxelSteepSlot : -1, + }; + String[] colSlots = {"TexFlat", "TexSteep" }; + String[] normSlots = {"NormalMapFlat", "NormalMapSteep" }; + // Fallback-Farben wenn kein Slot konfiguriert: grünlich-grau (flach), felsgrau (steil) + int[][] fallbackRgb = { + {100, 130, 60}, + {110, 100, 90}, + }; + for (int i = 0; i < 2; i++) { + String tex = resolveSlotTex(slots[i]); + String norm = resolveSlotNorm(slots[i]); + if (!tex.isEmpty()) { + try { + Texture t = app.getAssetManager().loadTexture(tex); + t.getImage().setColorSpace(ColorSpace.Linear); + t.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(colSlots[i], t); + } catch (Exception e) { + log.warn("Textur {} nicht ladbar: {}", tex, e.getMessage()); + mat.setTexture(colSlots[i], solidColorTexture(fallbackRgb[i])); + } + } else { + mat.setTexture(colSlots[i], solidColorTexture(fallbackRgb[i])); + } + if (!norm.isEmpty()) { + try { + Texture n = app.getAssetManager().loadTexture(norm); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(normSlots[i], n); + } catch (Exception e) { + mat.clearParam(normSlots[i]); + } + } else { + mat.clearParam(normSlots[i]); + } + } + return mat; + } + + private Texture solidColorTexture(int[] rgb) { + ByteBuffer buf = ByteBuffer.allocate(4); + buf.put((byte) rgb[0]).put((byte) rgb[1]).put((byte) rgb[2]).put((byte) 255); + buf.flip(); + Texture2D tex = new Texture2D( + new com.jme3.texture.Image(com.jme3.texture.Image.Format.RGBA8, 1, 1, buf)); + tex.setWrap(Texture.WrapMode.Repeat); + return tex; + } + + private String resolveSlotTex(int slot) { + if (slot < 0 || mapData == null) return ""; + if (slot < 4) return s(mapData.terrainTextures[slot]); + if (slot < 8) return s(mapData.upperTextures[slot-4]); + if (slot < 12) return s(mapData.thirdTextures[slot-8]); + return ""; + } + + private String resolveSlotNorm(int slot) { + if (slot < 0 || mapData == null) return ""; + if (slot < 4) return s(mapData.terrainNormalMaps[slot]); + if (slot < 8) return s(mapData.upperNormalMaps[slot-4]); + if (slot < 12) return s(mapData.thirdNormalMaps[slot-8]); + return ""; + } + + private static String s(String v) { return v != null ? v : ""; } +} diff --git a/blight-game/src/main/java/de/blight/game/state/StoneWorldState.java b/blight-game/src/main/java/de/blight/game/state/StoneWorldState.java new file mode 100644 index 0000000..4a108df --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/StoneWorldState.java @@ -0,0 +1,264 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.asset.AssetManager; +import com.jme3.bullet.BulletAppState; +import com.jme3.bullet.control.RigidBodyControl; +import com.jme3.bullet.util.CollisionShapeFactory; +import com.jme3.material.Material; +import com.jme3.math.*; +import com.jme3.renderer.queue.RenderQueue; +import com.jme3.scene.*; +import com.jme3.scene.VertexBuffer.Type; +import com.jme3.util.BufferUtils; +import de.blight.common.PlacedStone; +import de.blight.common.PlacedStoneIO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.FloatBuffer; +import java.util.*; + +public class StoneWorldState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(StoneWorldState.class); + + private SimpleApplication app; + private AssetManager assets; + private BulletAppState bulletAppState; + private TerrainChunkState terrainChunkState; + private Node stoneRoot; + + @Override + protected void initialize(Application app) { + this.app = (SimpleApplication) app; + this.assets = app.getAssetManager(); + this.bulletAppState = app.getStateManager().getState(BulletAppState.class); + this.terrainChunkState = app.getStateManager().getState(TerrainChunkState.class); + + stoneRoot = new Node("stoneRoot"); + this.app.getRootNode().attachChild(stoneRoot); + } + + @Override + protected void onEnable() { + PlacedStoneIO.StoneData data; + try { + data = PlacedStoneIO.load(); + } catch (Exception e) { + log.warn("[StoneWorld] Laden fehlgeschlagen: {}", e.getMessage()); + return; + } + if (data == null || data.stones().isEmpty()) { + log.info("[StoneWorld] Keine Steine vorhanden."); + return; + } + + Material[] slotMat = buildMaterials(data.slotPaths()); + Material defMat = buildDefaultMat(); + + int count = 0; + for (PlacedStone s : data.stones()) { + float worldY = terrainChunkState != null + ? terrainChunkState.getHeightAt(s.x(), s.z()) + : 0f; + float yCenter = worldY + s.radius() * (1f - 2f * s.sinkFraction()); + + Mesh mesh = buildStoneMesh(s.radius(), s.noiseSeed(), 2); + Geometry geo = new Geometry("stone", mesh); + Material mat = (s.textureSlot() >= 0 && s.textureSlot() < slotMat.length && slotMat[s.textureSlot()] != null) + ? slotMat[s.textureSlot()] : defMat; + geo.setMaterial(mat); + geo.setLocalTranslation(s.x(), yCenter, s.z()); + geo.rotate(0f, s.rotY() * FastMath.DEG_TO_RAD, 0f); + geo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive); + + if (bulletAppState != null) { + try { + RigidBodyControl rbc = new RigidBodyControl( + CollisionShapeFactory.createMeshShape(geo), 0f); + geo.addControl(rbc); + bulletAppState.getPhysicsSpace().add(rbc); + } catch (Exception e) { + log.warn("[StoneWorld] Physik für Stein nicht erzeugbar: {}", e.getMessage()); + } + } + + stoneRoot.attachChild(geo); + count++; + } + log.info("[StoneWorld] {} Steine geladen.", count); + } + + @Override + protected void cleanup(Application app) { + this.app.getRootNode().detachChild(stoneRoot); + } + + @Override protected void onDisable() {} + + // ── Materialien ─────────────────────────────────────────────────────────── + + private Material buildDefaultMat() { + Material m = new Material(assets, "Common/MatDefs/Light/Lighting.j3md"); + m.setBoolean("UseMaterialColors", true); + m.setColor("Diffuse", new ColorRGBA(0.55f, 0.52f, 0.48f, 1f)); + m.setColor("Ambient", new ColorRGBA(0.15f, 0.14f, 0.13f, 1f)); + m.setColor("Specular", ColorRGBA.Black); + return m; + } + + private Material[] buildMaterials(String[] paths) { + Material[] mats = new Material[PlacedStoneIO.SLOT_COUNT]; + for (int i = 0; i < PlacedStoneIO.SLOT_COUNT; i++) { + String p = (paths != null && i < paths.length && paths[i] != null) ? paths[i] : ""; + if (!p.isEmpty()) { + try { + Material m = new Material(assets, "Common/MatDefs/Light/Lighting.j3md"); + m.setTexture("DiffuseMap", assets.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); + mats[i] = m; + } catch (Exception e) { + log.warn("[StoneWorld] Textur nicht ladbar: {}", p); + } + } + } + return mats; + } + + // ── Icosphere-Mesh (deterministisch, identisch zum Editor) ──────────────── + + private static final float PHI = (1f + (float) Math.sqrt(5)) / 2f; + + 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} + }); + + 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}; + } + + private static Mesh buildStoneMesh(float radius, int noiseSeed, int subdivisions) { + List verts = new ArrayList<>(Arrays.asList(ICO_V)); + List faces = new ArrayList<>(Arrays.asList(ICO_F)); + + for (int s = 0; s < subdivisions; s++) { + List nv = new ArrayList<>(verts); + List nf = new ArrayList<>(); + Map 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; + } + + 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); + float amp = 0.22f; + + int nv2 = verts.size(); + float[] pos = new float[nv2 * 3]; + float[] nor = new float[nv2 * 3]; + float[] uv = new float[nv2 * 2]; + + for (int i = 0; i < nv2; 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[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); + } + + 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 cx = pos[ci], cy = pos[ci+1], cz = pos[ci+2]; + float nx = (by-ay)*(cz-az) - (bz-az)*(cy-ay); + float ny = (bz-az)*(cx-ax) - (bx-ax)*(cz-az); + float nz = (bx-ax)*(cy-ay) - (by-ay)*(cx-ax); + for (int vi : f) { nor[vi*3] += nx; nor[vi*3+1] += ny; nor[vi*3+2] += nz; } + } + for (int i = 0; i < nv2; 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; } + } + + 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 verts, Map 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; + }); + } + + 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; + } + + private static float lerp(float a, float b, float t) { return a + (b-a)*t; } +} diff --git a/blight-game/src/main/java/de/blight/game/state/TerrainChunkState.java b/blight-game/src/main/java/de/blight/game/state/TerrainChunkState.java index 0edabe0..679d4a0 100644 --- a/blight-game/src/main/java/de/blight/game/state/TerrainChunkState.java +++ b/blight-game/src/main/java/de/blight/game/state/TerrainChunkState.java @@ -396,15 +396,25 @@ public class TerrainChunkState extends BaseAppState { private void addPhysics(int ci) { if (chunkNodes[ci] == null || chunkHeights[ci] == null) return; - HeightfieldCollisionShape shape = new HeightfieldCollisionShape( - chunkHeights[ci], new Vector3f(1f, 1f, 1f)); + + // jme3-jbullet HeightfieldCollisionShape-Bug: wenn alle Höhen identisch sind + // (min == max), setzt der Konstruktor max = -min statt min = -max → minHeight > + // maxHeight → invertierte AABB → Bullet findet in der Breitphase keine Überlappung + // → keine Kollision → Spieler fällt durch. Workaround: minimale Höhendifferenz + // sicherstellen, indem ein Eckwert um 0.0001 angehoben wird. Die Kollisionsfläche + // bleibt dadurch für alle praktischen Zwecke unverändert. + float[] h = chunkHeights[ci]; + float min = h[0], max = h[0]; + for (float v : h) { if (v < min) min = v; if (v > max) max = v; } + if (min == max) { + h = h.clone(); + h[h.length - 1] += 0.0001f; + } + + HeightfieldCollisionShape shape = new HeightfieldCollisionShape(h, new Vector3f(1f, 1f, 1f)); RigidBodyControl rbc = new RigidBodyControl(shape, 0f); chunkNodes[ci].addControl(rbc); bulletAppState.getPhysicsSpace().add(rbc); - // jme3-jbullet's HeightfieldCollisionShape macht die AABB symmetrisch um 0 - // (min = -max), nicht um (min+max)/2. Die Höhenwerte h[i] werden direkt als - // lokale Y-Koordinaten verwendet. Der Body muss deshalb bei Y=0 liegen (Node-Y=0), - // damit Kollisionsfläche = 0 + h[i] = h[i]. Kein setPhysicsLocation nötig. physics[ci] = rbc; } diff --git a/blight-game/src/main/java/de/blight/game/state/VoxelChunkNode.java b/blight-game/src/main/java/de/blight/game/state/VoxelChunkNode.java index 5669385..5079d37 100644 --- a/blight-game/src/main/java/de/blight/game/state/VoxelChunkNode.java +++ b/blight-game/src/main/java/de/blight/game/state/VoxelChunkNode.java @@ -12,7 +12,7 @@ import de.blight.common.VoxelChunk; /** * JME-Node für einen VoxelChunk mit 3 LOD-Geometrien. - * Wird von VoxelChunkState und VoxelEditorState verwaltet. + * Wird von VoxelEditorState verwaltet. * * Position im Weltraum: Translation = (cx*128-2048, cy*128, cz*128-2048). */ @@ -29,6 +29,7 @@ public class VoxelChunkNode extends Node { private RigidBodyControl physics; private BulletAppState bulletState; + private Mesh physicsMeshOverride; public VoxelChunkNode(VoxelChunk chunk, Material material) { super("voxel_" + chunk.cx + "_" + chunk.cy + "_" + chunk.cz); @@ -112,15 +113,27 @@ public class VoxelChunkNode extends Node { } } - /** Erzeugt / aktualisiert die Physik-Kollision (LOD0-Mesh). */ + /** + * Setzt ein explizites Physics-Mesh, das Vorrang vor lodGeos[0] hat. + * Für gebackene Nodes: rohes (ungesmoothtes) Mesh, das keinen Overlap + * mit dem Terrain-Heightfield erzeugt. + */ + public void setPhysicsMeshOverride(Mesh mesh) { + this.physicsMeshOverride = mesh; + } + + /** Erzeugt / aktualisiert die Physik-Kollision. */ public void updatePhysics(BulletAppState bullet) { this.bulletState = bullet; if (physics != null) { bullet.getPhysicsSpace().remove(physics); removeControl(physics); + physics = null; } - if (lodGeos[0] == null || lodGeos[0].getMesh() == null) return; - MeshCollisionShape shape = new MeshCollisionShape(lodGeos[0].getMesh()); + Mesh mesh = physicsMeshOverride != null ? physicsMeshOverride + : (lodGeos[0] != null ? lodGeos[0].getMesh() : null); + if (mesh == null) return; + MeshCollisionShape shape = new MeshCollisionShape(mesh); physics = new RigidBodyControl(shape, 0f); addControl(physics); bullet.getPhysicsSpace().add(physics); @@ -135,6 +148,8 @@ public class VoxelChunkNode extends Node { public VoxelChunk getChunk() { return chunk; } + public boolean hasPhysics() { return physics != null; } + /** Gibt true zurück wenn mindestens ein LOD ein Mesh hat. */ public boolean hasMesh() { for (Geometry g : lodGeos) if (g != null) return true; diff --git a/blight-game/src/main/java/de/blight/game/state/VoxelChunkState.java b/blight-game/src/main/java/de/blight/game/state/VoxelChunkState.java deleted file mode 100644 index 12a4937..0000000 --- a/blight-game/src/main/java/de/blight/game/state/VoxelChunkState.java +++ /dev/null @@ -1,265 +0,0 @@ -package de.blight.game.state; - -import com.jme3.app.Application; -import com.jme3.app.SimpleApplication; -import com.jme3.app.state.BaseAppState; -import com.jme3.asset.AssetManager; -import com.jme3.bullet.BulletAppState; -import com.jme3.export.binary.BinaryImporter; -import com.jme3.material.Material; -import com.jme3.math.ColorRGBA; -import com.jme3.math.Vector3f; -import com.jme3.scene.Mesh; -import com.jme3.scene.Node; -import com.jme3.texture.Texture; -import de.blight.common.MapData; -import de.blight.common.VoxelChunk; -import de.blight.common.VoxelChunkIO; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; - -/** - * Verwaltet die Voxel-Geometrie im Spiel als {@link TerrainChunkState.ChunkListener}. - * - * - Lädt VoxelChunks bei Sichtbarkeit (onChunkVisible) - * - Baut LOD-Meshes gemäß TerrainChunk-LOD - * - Aktiviert Physik-Collider bei LOD0 (PHYSICS_RANGE) - * - Nutzt Texture2DArray für 4 Voxel-Texturen (aus terrainTexturePaths) - */ -public class VoxelChunkState extends BaseAppState - implements TerrainChunkState.ChunkListener { - - private static final Logger log = LoggerFactory.getLogger(VoxelChunkState.class); - - private final BulletAppState bulletState; - private final MapData mapData; - - private SimpleApplication app; - private AssetManager assets; - private Node voxelRoot; - private Material voxelMaterial; - - // key = cx | ((long)cy << 16) | ((long)cz << 32) - private final Map nodes = new HashMap<>(); - - public VoxelChunkState(BulletAppState bulletState, MapData mapData) { - this.bulletState = bulletState; - this.mapData = mapData; - } - - @Override - protected void initialize(Application application) { - this.app = (SimpleApplication) application; - this.assets = app.getAssetManager(); - voxelRoot = new Node("voxelRoot"); - app.getRootNode().attachChild(voxelRoot); - voxelMaterial = buildMaterial(); - } - - @Override - protected void cleanup(Application app) { - voxelRoot.removeFromParent(); - for (VoxelChunkNode n : nodes.values()) n.removePhysics(); - nodes.clear(); - } - - @Override protected void onEnable() {} - @Override protected void onDisable() {} - - public void setDebugNoLight(boolean enabled) { - if (voxelMaterial != null) voxelMaterial.setBoolean("DebugNoLight", enabled); - } - - @Override - public void update(float tpf) { - DayNightState dns = getApplication().getStateManager().getState(DayNightState.class); - if (dns == null || voxelMaterial == null || dns.getSunLight() == null) return; - voxelMaterial.setVector3("LightDir", dns.getSunDirection().negate()); - ColorRGBA sc = dns.getSunLight().getColor(); - ColorRGBA ac = dns.getAmbientLight().getColor(); - voxelMaterial.setVector3("SunColor", new Vector3f(sc.r, sc.g, sc.b)); - voxelMaterial.setVector3("AmbientColor", new Vector3f(ac.r, ac.g, ac.b)); - } - - // ── ChunkListener ───────────────────────────────────────────────────────── - - @Override - public void onChunkVisible(int cx, int cz, int lod) { - // Alle cy-Layers für diesen cx/cz laden - loadLayersForXZ(cx, cz, lod); - } - - @Override - public void onChunkHidden(int cx, int cz) { - // Alle cy-Layers für diesen cx/cz entfernen - List toRemove = new ArrayList<>(); - for (Map.Entry e : nodes.entrySet()) { - VoxelChunkNode n = e.getValue(); - if (n.getChunk().cx == cx && n.getChunk().cz == cz) toRemove.add(e.getKey()); - } - for (Long key : toRemove) removeNode(key); - } - - @Override - public void onChunkLodChanged(int cx, int cz, int oldLod, int newLod) { - for (VoxelChunkNode n : nodes.values()) { - VoxelChunk c = n.getChunk(); - if (c.cx != cx || c.cz != cz) continue; - n.setActiveLod(newLod); - // Physik nur bei LOD0 (nahes Terrain) - if (newLod == 0) { - n.updatePhysics(bulletState); - } else { - n.removePhysics(); - } - } - } - - // ── Intern ──────────────────────────────────────────────────────────────── - - private void loadLayersForXZ(int cx, int cz, int lod) { - for (int cy = -8; cy <= 8; cy++) { - long key = chunkKey(cx, cy, cz); - if (nodes.containsKey(key)) continue; - - // Gebackene J3O-Meshes bevorzugt laden — nur wenn auch .blvc-Quelldaten existieren - // (sonst werden veraltete Meshes von gelöschten Voxeln als Geisterflächen angezeigt) - if (VoxelChunkIO.bakedExists(cx, cy, cz) && VoxelChunkIO.exists(cx, cy, cz)) { - try { - addBakedNode(key, cx, cy, cz, lod); - } catch (Exception e) { - log.warn("Gebackenen Voxel-Chunk laden fehlgeschlagen ({},{},{}): {}", - cx, cy, cz, e.getMessage()); - } - continue; - } - - // Fallback: .blvc laden + Marching Cubes zur Laufzeit - if (!VoxelChunkIO.exists(cx, cy, cz)) continue; - try { - VoxelChunk chunk = VoxelChunkIO.load(cx, cy, cz); - addNode(key, chunk, lod); - } catch (IOException e) { - log.warn("Voxel-Chunk laden fehlgeschlagen ({},{},{}): {}", cx, cy, cz, e.getMessage()); - } - } - } - - /** - * Lädt vorgebackene LOD-Meshes aus den .j3o-Dateien und hängt sie als - * VoxelChunkNode in die Szene ein (kein Marching Cubes zur Laufzeit). - */ - private void addBakedNode(long key, int cx, int cy, int cz, int lod) throws Exception { - BinaryImporter importer = BinaryImporter.getInstance(); - importer.setAssetManager(assets); - - VoxelChunk dummy = new VoxelChunk(cx, cy, cz); - VoxelChunkNode node = new VoxelChunkNode(dummy, voxelMaterial); - - for (int l = 0; l < 3; l++) { - Path p = VoxelChunkIO.getBakedPath(cx, cy, cz, l); - if (Files.exists(p)) { - Mesh m = (Mesh) importer.load(p.toFile()); - node.setLodMesh(l, m); - } - } - - node.setActiveLod(lod); - if (lod == 0) node.updatePhysics(bulletState); - voxelRoot.attachChild(node); - nodes.put(key, node); - } - - private void addNode(long key, VoxelChunk chunk, int lod) { - VoxelChunkNode node = new VoxelChunkNode(chunk, voxelMaterial); - for (int l = 0; l < 3; l++) node.rebuildMesh(l); - node.setActiveLod(lod); - if (lod == 0) node.updatePhysics(bulletState); - voxelRoot.attachChild(node); - nodes.put(key, node); - } - - private void removeNode(long key) { - VoxelChunkNode n = nodes.remove(key); - if (n == null) return; - n.removePhysics(); - n.removeFromParent(); - } - - /** Fügt einen extern geladenen VoxelChunk zur Szene hinzu (z.B. aus dem Editor). */ - public void addOrUpdateChunk(VoxelChunk chunk, int lod) { - long key = chunkKey(chunk.cx, chunk.cy, chunk.cz); - VoxelChunkNode existing = nodes.get(key); - if (existing != null) { - for (int l = 0; l < 3; l++) existing.rebuildMesh(l); - existing.setActiveLod(lod); - if (lod == 0) existing.updatePhysics(bulletState); - } else { - addNode(key, chunk, lod); - } - } - - private Material buildMaterial() { - Material mat = new Material(assets, "MatDefs/Voxel.j3md"); - mat.setFloat("TexScale", 8f); - int[] slotIdxs = { - mapData != null ? mapData.voxelFlatSlot : -1, - mapData != null ? mapData.voxelSteepSlot : -1, - mapData != null ? mapData.voxelCeilSlot : -1, - }; - log.info("[Voxel] FlatSlot={} → '{}', SteepSlot={} → '{}', TexScale=8.0", - slotIdxs[0], resolveSlotTex(slotIdxs[0]), - slotIdxs[1], resolveSlotTex(slotIdxs[1])); - String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" }; - String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" }; - for (int i = 0; i < 3; i++) { - String tex = resolveSlotTex(slotIdxs[i]); - String norm = resolveSlotNorm(slotIdxs[i]); - if (!tex.isEmpty()) { - try { - Texture t = assets.loadTexture(tex); - t.getImage().setColorSpace(com.jme3.texture.image.ColorSpace.Linear); - t.setWrap(Texture.WrapMode.Repeat); - mat.setTexture(colSlots[i], t); - } catch (Exception e) { - log.warn("Voxel-Textur {} nicht ladbar: {}", tex, e.getMessage()); - } - } - if (!norm.isEmpty()) { - try { - Texture n = assets.loadTexture(norm); - n.setWrap(Texture.WrapMode.Repeat); - mat.setTexture(normSlots[i], n); - } catch (Exception e) { - log.warn("Voxel-NormalMap {} nicht ladbar: {}", norm, e.getMessage()); - } - } - } - return mat; - } - - private String resolveSlotTex(int slot) { - if (slot < 0 || mapData == null) return ""; - if (slot < 4) { String p = mapData.terrainTextures[slot]; return p != null ? p : ""; } - if (slot < 8) { String p = mapData.upperTextures[slot - 4]; return p != null ? p : ""; } - if (slot < 12) { String p = mapData.thirdTextures[slot - 8]; return p != null ? p : ""; } - return ""; - } - - private String resolveSlotNorm(int slot) { - if (slot < 0 || mapData == null) return ""; - if (slot < 4) { String p = mapData.terrainNormalMaps[slot]; return p != null ? p : ""; } - if (slot < 8) { String p = mapData.upperNormalMaps[slot - 4]; return p != null ? p : ""; } - if (slot < 12) { String p = mapData.thirdNormalMaps[slot - 8]; return p != null ? p : ""; } - return ""; - } - - public static long chunkKey(int cx, int cy, int cz) { - return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32); - } -} diff --git a/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java new file mode 100644 index 0000000..7046b21 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WorldInteractableState.java @@ -0,0 +1,479 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.state.BaseAppState; +import com.jme3.bullet.control.CharacterControl; +import com.jme3.input.InputManager; +import com.jme3.input.MouseInput; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.KeyTrigger; +import com.jme3.input.controls.MouseButtonTrigger; +import com.jme3.math.Vector3f; +import de.blight.common.PlacedModel; +import de.blight.common.PlacedModelIO; +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.common.model.InteractableType; +import de.blight.common.model.WorldPoint; +import de.blight.game.animation.AnimationAction; +import de.blight.game.config.KeyBindings; +import de.blight.game.control.PlayerInputControl; +import de.blight.game.navigation.PathFinder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Steuert die Interaktion des Hauptcharakters mit Betten und Bänken. + * + *

Ablauf Bett

+ *
+ *   Interact-Taste (E)
+ *   → WALKING  : walk zu Punkt neben dem Bett (via PathFinder)
+ *   → LIE_ANIM : lie_down Animation (Charakter gleitet in Liegeposition)
+ *   → RESTING  : Charakter liegt; alle Eingaben gesperrt
+ *   → GET_UP   : Rechtsklick startet lie_up Animation
+ *   → WALKING_BACK : Charakter kehrt zur Ausgangsposition zurück
+ * 
+ * + * Bank läuft analog mit sit_down / sit_up und einem 0,5m-Pfeil. + */ +public class WorldInteractableState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WorldInteractableState.class); + + private static final float INTERACT_RANGE = 6f; + private static final float REACH_DIST = 0.35f; + private static final float WALK_TIMEOUT = 12f; + + // ── Abhängigkeiten ──────────────────────────────────────────────────────── + + private final KeyBindings keyBindings; + private final CharacterControl physicsChar; + private final PlayerInputControl playerInput; + + private InputManager inputManager; + + // ── Interactable-Daten aus der Karte ───────────────────────────────────── + + /** Beschreibt ein platziertes Interactable-Objekt. */ + private record InteractableEntry( + float worldX, float worldY, float worldZ, + InteractableType type, + String interactableId + ) {} + + private final List entries = new ArrayList<>(); + + // ── Zustandsmaschine ────────────────────────────────────────────────────── + + private enum Phase { + IDLE, + WALKING, // Annäherung an Interactable + PLAY_ANIM, // Einlege-/Sitz-Animation läuft + RESTING, // Charakter liegt/sitzt; nur Rechtsklick erlaubt + GET_UP_ANIM, // Aufsteh-Animation läuft + WALKING_BACK // Rückkehr zur Ausgangsposition + } + + private Phase phase = Phase.IDLE; + private int targetIdx = -1; + private float walkTimer = 0f; + + /** Ziel-Weltpunkt, zu dem der Charakter laufen soll (neben dem Objekt). */ + private Vector3f approachTarget = null; + /** Position des Charakters vor der Interaktion (für Rückkehr). */ + private Vector3f originPos = null; + /** Aktive Pfadliste für Annäherung / Rückkehr. */ + private List currentPath = new ArrayList<>(); + private int pathStep = 0; + + private PathFinder pathFinder = null; + + /** Sitz-/Liegeposition des aktuell angesteuerten Interactables (für Bypass-Berechnung). */ + private Vector3f interactableSitPt = null; + + // ── Eingabe-Mapping ─────────────────────────────────────────────────────── + + private static final String INTERACT_ACTION = "InteractInteractable"; + private static final String GET_UP_ACTION = "GetUpFromRest"; + + public WorldInteractableState(KeyBindings keyBindings, + CharacterControl physicsChar, + PlayerInputControl playerInput) { + this.keyBindings = keyBindings; + this.physicsChar = physicsChar; + this.playerInput = playerInput; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + this.inputManager = app.getInputManager(); + + try { pathFinder = PathFinder.load(); } + catch (IOException e) { log.warn("[WorldInteractable] Wegnetz nicht ladbar: {}", e.getMessage()); } + + try { + List models = PlacedModelIO.load(); + for (PlacedModel m : models) { + if (m.interactableType() == null || m.interactableType().isBlank()) continue; + InteractableType t = InteractableType.fromString(m.interactableType()); + if (t == InteractableType.BED || t == InteractableType.BENCH) { + entries.add(new InteractableEntry(m.x(), m.y(), m.z(), t, m.interactableId())); + } + } + log.info("[WorldInteractable] {} Interactables geladen.", entries.size()); + } catch (IOException e) { + log.warn("[WorldInteractable] PlacedModels nicht ladbar: {}", e.getMessage()); + } + } + + @Override + protected void onEnable() { + inputManager.addMapping(INTERACT_ACTION, new KeyTrigger(keyBindings.interact)); + inputManager.addMapping(GET_UP_ACTION, new MouseButtonTrigger(MouseInput.BUTTON_RIGHT)); + inputManager.addListener(interactListener, INTERACT_ACTION); + inputManager.addListener(getUpListener, GET_UP_ACTION); + } + + @Override + protected void onDisable() { + cancelInteraction(); + inputManager.removeListener(interactListener); + inputManager.removeListener(getUpListener); + try { inputManager.deleteMapping(INTERACT_ACTION); } catch (Exception ignored) {} + try { inputManager.deleteMapping(GET_UP_ACTION); } catch (Exception ignored) {} + } + + @Override protected void cleanup(Application app) {} + + // ── Update ──────────────────────────────────────────────────────────────── + + @Override + public void update(float tpf) { + switch (phase) { + case WALKING -> updateWalking(tpf); + case WALKING_BACK -> updateWalkingBack(tpf); + default -> {} + } + } + + private void updateWalking(float tpf) { + if (playerInput.isPaused()) { cancelInteraction(); return; } + + walkTimer += tpf; + if (walkTimer > WALK_TIMEOUT) { + log.info("[WorldInteractable] Annäherung abgebrochen (Timeout)."); + cancelInteraction(); + return; + } + if (targetIdx < 0 || targetIdx >= entries.size()) { cancelInteraction(); return; } + + Vector3f pos = physicsChar.getPhysicsLocation(); + Vector3f dest = approachTarget; + + float dx = dest.x - pos.x; + float dz = dest.z - pos.z; + float distSq = dx * dx + dz * dz; + + if (distSq <= REACH_DIST * REACH_DIST) { + startRestAnim(); + } else { + // Nächsten Wegpunkt aus dem Pfad verwenden + advancePath(pos); + } + } + + private void advancePath(Vector3f pos) { + if (currentPath.isEmpty()) { + // Direkt zum Ziel + float dx = approachTarget.x - pos.x; + float dz = approachTarget.z - pos.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len)); + return; + } + // Zum aktuellen Wegpunkt steuern; wenn nah genug → weiter + while (pathStep < currentPath.size()) { + WorldPoint wp = currentPath.get(pathStep); + float dx = wp.x - pos.x; + float dz = wp.z - pos.z; + float d2 = dx * dx + dz * dz; + if (d2 < 0.8f * 0.8f) { pathStep++; continue; } + float len = (float) Math.sqrt(d2); + playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len)); + return; + } + // Pfad fertig → direkt zum Annäherungs-Ziel + float dx = approachTarget.x - pos.x; + float dz = approachTarget.z - pos.z; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len)); + } + + private void updateWalkingBack(float tpf) { + if (originPos == null) { phase = Phase.IDLE; return; } + + walkTimer += tpf; + if (walkTimer > WALK_TIMEOUT) { + playerInput.setAutopilotDirection(null); + phase = Phase.IDLE; + return; + } + + Vector3f pos = physicsChar.getPhysicsLocation(); + float dx = originPos.x - pos.x; + float dz = originPos.z - pos.z; + float distSq = dx * dx + dz * dz; + + if (distSq <= REACH_DIST * REACH_DIST) { + playerInput.setAutopilotDirection(null); + phase = Phase.IDLE; + } else { + float len = (float) Math.sqrt(distSq); + playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len)); + } + } + + // ── Listener ────────────────────────────────────────────────────────────── + + private final ActionListener interactListener = (name, isPressed, tpf) -> { + if (!isPressed || phase != Phase.IDLE) return; + int idx = findNearestInRange(); + if (idx >= 0) startApproach(idx); + }; + + private final ActionListener getUpListener = (name, isPressed, tpf) -> { + if (!isPressed || phase != Phase.RESTING) return; + startGetUp(); + }; + + // ── Logik ───────────────────────────────────────────────────────────────── + + private int findNearestInRange() { + Vector3f pos = physicsChar.getPhysicsLocation(); + int bestIdx = -1; + float bestDist = INTERACT_RANGE; + for (int i = 0; i < entries.size(); i++) { + InteractableEntry e = entries.get(i); + float dx = e.worldX() - pos.x; + float dz = e.worldZ() - pos.z; + float d = (float) Math.sqrt(dx * dx + dz * dz); + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + return bestIdx; + } + + private void startApproach(int idx) { + targetIdx = idx; + walkTimer = 0f; + originPos = physicsChar.getPhysicsLocation().clone(); + + InteractableEntry entry = entries.get(idx); + + // Kollision des Zielobjekts deaktivieren, damit der Charakter hindurchgehen kann + setTargetPhysicsEnabled(entry, false); + + approachTarget = computeApproachTarget(entry); + + // Pfad berechnen (PathFinder falls vorhanden) + WorldPoint from = new WorldPoint(originPos.x, originPos.y, originPos.z); + WorldPoint to = new WorldPoint(approachTarget.x, approachTarget.y, approachTarget.z); + if (pathFinder != null) { + currentPath = new ArrayList<>(pathFinder.findPath(from, to)); + } else { + currentPath = new ArrayList<>(List.of(to)); + } + + // Bypass-Punkt einfügen wenn Sitzpunkt auf dem Weg liegt + insertBypassIfNeeded(from); + pathStep = 0; + + phase = Phase.WALKING; + log.info("[WorldInteractable] Annäherung an {} [{}]", entry.type(), entry.interactableId()); + } + + /** + * Berechnet den Punkt, zu dem der Charakter läuft. + * Bank: direkt zum Sitzpunkt (Pfeilspitze). + * Bett: 1m in Pfeilrichtung vor dem Liegepunkt (Anfahrt von vorne). + */ + private Vector3f computeApproachTarget(InteractableEntry entry) { + if (entry.type() == InteractableType.BED) { + Bed bed = BedIO.load(entry.interactableId()).orElse(null); + if (bed != null && bed.isLiegeSet()) { + float rotY = bed.getLiegeRotY(); + interactableSitPt = new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ()); + // Bett: 1m in Pfeilrichtung vor dem Liegepunkt anfahren + return new Vector3f( + bed.getLiegeX() + (float) Math.cos(rotY), + bed.getLiegeY(), + bed.getLiegeZ() + (float) Math.sin(rotY)); + } + } else if (entry.type() == InteractableType.BENCH) { + Bench bench = BenchIO.load(entry.interactableId()).orElse(null); + if (bench != null && bench.isSitzSet()) { + interactableSitPt = new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ()); + // Bank: direkt zum Sitzpunkt (Pfeilspitze) laufen + return new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ()); + } + } + // Fallback: 1m östlich des Objekts + interactableSitPt = null; + return new Vector3f(entry.worldX() + 1f, entry.worldY(), entry.worldZ()); + } + + /** + * Prüft ob der direkte Weg von {@code from} zum approachTarget durch den + * Sitzpunkt führt (nur relevant wenn Annährungspunkt ≠ Sitzpunkt, d.h. Bett). + * Falls ja, wird ein Bypass-Punkt senkrecht eingefügt. + */ + private void insertBypassIfNeeded(WorldPoint from) { + if (interactableSitPt == null || approachTarget == null) return; + + float sx = interactableSitPt.x, sz = interactableSitPt.z; + float tx = approachTarget.x, tz = approachTarget.z; + + // Wenn Annährungsziel = Sitzpunkt, ist kein Bypass nötig + float aDx = tx - sx, aDz = tz - sz; + if (aDx * aDx + aDz * aDz < 0.1f) return; + + float fx = from.x, fz = from.z; + + // Projektion des Sitzpunkts auf die direkte Linie from→approachTarget + float dx = tx - fx, dz = tz - fz; + float lenSq = dx * dx + dz * dz; + if (lenSq < 0.001f) return; + + float t = ((sx - fx) * dx + (sz - fz) * dz) / lenSq; + if (t < 0.05f || t > 0.95f) return; + + float closestX = fx + t * dx; + float closestZ = fz + t * dz; + float distToPath = (float) Math.sqrt((sx - closestX) * (sx - closestX) + + (sz - closestZ) * (sz - closestZ)); + if (distToPath > 1.2f) return; + + float faceX = sx - tx; + float faceZ = sz - tz; + float faceLen = (float) Math.sqrt(faceX * faceX + faceZ * faceZ); + if (faceLen > 0.001f) { faceX /= faceLen; faceZ /= faceLen; } + + float perpX = -faceZ; + float perpZ = faceX; + + float dot = (fx - sx) * perpX + (fz - sz) * perpZ; + float sign = dot >= 0f ? 1f : -1f; + + WorldPoint bypass = new WorldPoint( + sx + perpX * sign * 2.5f, + from.y, + sz + perpZ * sign * 2.5f); + + currentPath.add(currentPath.size() - 1, bypass); + log.info("[WorldInteractable] Bypass-Punkt eingefügt: ({}, {})", bypass.x, bypass.z); + } + + private void startRestAnim() { + playerInput.setAutopilotDirection(null); + phase = Phase.PLAY_ANIM; + + InteractableEntry entry = entries.get(targetIdx); + float rotY = getRestRotY(entry); + + // Zuerst Rücken zur Bank drehen, dann Sitz-/Liegeanimation + Vector3f sitDir = new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)); + playerInput.requestTurn(sitDir, 0.4f, () -> startSitAnim(entry)); + } + + private void startSitAnim(InteractableEntry entry) { + boolean isBed = entry.type() == InteractableType.BED; + AnimationAction action = isBed ? AnimationAction.LIE_DOWN : AnimationAction.SIT_DOWN; + AnimationAction idleAction = isBed ? AnimationAction.LYING : AnimationAction.SITTING; + + // duration=0 → PlayerInputControl ermittelt die echte Clip-Länge automatisch + // Sink-Wert kommt aus AnimSet-Konfiguration (Animationseditor) + playerInput.requestAnimation(action, 0f, () -> { + teleportToRestPos(entry); + playerInput.lockInPlace(); + playerInput.playLockedAnimation(idleAction); + phase = Phase.RESTING; + log.info("[WorldInteractable] Ruhezustand aktiv: {}", entry.type()); + }); + } + + private void startGetUp() { + if (targetIdx < 0 || targetIdx >= entries.size()) { phase = Phase.IDLE; return; } + + InteractableEntry entry = entries.get(targetIdx); + boolean isBed = entry.type() == InteractableType.BED; + AnimationAction action = isBed ? AnimationAction.LIE_UP : AnimationAction.SIT_UP; + + playerInput.unlockFromPlace(); + phase = Phase.GET_UP_ANIM; + + // Sink-Wert für SIT_UP/LIE_UP kommt ebenfalls aus AnimSet-Konfiguration + playerInput.requestAnimation(action, 0f, () -> { + // Kollision des Objekts nach dem Aufstehen wieder aktivieren + if (targetIdx >= 0 && targetIdx < entries.size()) { + setTargetPhysicsEnabled(entries.get(targetIdx), true); + } + phase = Phase.WALKING_BACK; + walkTimer = 0f; + log.info("[WorldInteractable] Rückkehr zur Ausgangsposition."); + }); + } + + private void teleportToRestPos(InteractableEntry entry) { + if (physicsChar == null) return; + if (entry.type() == InteractableType.BED) { + Bed bed = BedIO.load(entry.interactableId()).orElse(null); + if (bed != null && bed.isLiegeSet()) + physicsChar.setPhysicsLocation(new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ())); + } else if (entry.type() == InteractableType.BENCH) { + // X/Z aus dem Sitzpunkt, Y bleibt bei der aktuellen Physik-Position (Charakter ist + // bereits auf Bodenhöhe und durch Terrain geerdet — kein Sprung nach oben) + Bench bench = BenchIO.load(entry.interactableId()).orElse(null); + if (bench != null && bench.isSitzSet()) { + float currentY = physicsChar.getPhysicsLocation().y; + physicsChar.setPhysicsLocation(new Vector3f(bench.getSitzX(), currentY, bench.getSitzZ())); + } + } + } + + private float getRestRotY(InteractableEntry entry) { + if (entry.type() == InteractableType.BED) { + Bed bed = BedIO.load(entry.interactableId()).orElse(null); + if (bed != null) return bed.getLiegeRotY(); + } else if (entry.type() == InteractableType.BENCH) { + Bench bench = BenchIO.load(entry.interactableId()).orElse(null); + if (bench != null) return bench.getSitzRotY(); + } + return 0f; + } + + private void setTargetPhysicsEnabled(InteractableEntry entry, boolean enabled) { + WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class); + if (wos != null) wos.setInteractablePhysicsEnabled(entry.interactableId(), enabled); + } + + private void cancelInteraction() { + playerInput.setAutopilotDirection(null); + if (phase == Phase.RESTING || phase == Phase.GET_UP_ANIM) { + playerInput.unlockFromPlace(); + } + // Kollision bei Abbruch immer wieder aktivieren + if (targetIdx >= 0 && targetIdx < entries.size()) { + setTargetPhysicsEnabled(entries.get(targetIdx), true); + } + phase = Phase.IDLE; + targetIdx = -1; + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WorldLightState.java b/blight-game/src/main/java/de/blight/game/state/WorldLightState.java new file mode 100644 index 0000000..35c023a --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WorldLightState.java @@ -0,0 +1,154 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.asset.AssetManager; +import com.jme3.light.PointLight; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.post.FilterPostProcessor; +import com.jme3.renderer.Camera; +import com.jme3.scene.Node; +import com.jme3.shadow.EdgeFilteringMode; +import com.jme3.shadow.PointLightShadowFilter; +import de.blight.common.LightIO; +import de.blight.common.PlacedLight; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public class WorldLightState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WorldLightState.class); + + /** Anzahl gleichzeitig aktiver Schatten-Filter. */ + private static final int MAX_SHADOW_LIGHTS = 4; + /** Schattenmap-Auflösung pro Lichtquelle. */ + private static final int SHADOW_MAP_SIZE = 512; + /** Sekunden zwischen Neuberechnung welche Lichter Schatten werfen. */ + private static final float SHADOW_UPDATE_INTERVAL = 0.5f; + + private final FilterPostProcessor fpp; + private AssetManager assetManager; + private Camera cam; + private Node rootNode; + + private final List pointLights = new ArrayList<>(); + private final List shadowFilters = new ArrayList<>(); + + private float shadowUpdateTimer = 0f; + + /** + * @param fpp gemeinsamer FilterPostProcessor; null → keine Schatten. + */ + public WorldLightState(FilterPostProcessor fpp) { + this.fpp = fpp; + } + + @Override + protected void initialize(Application app) { + assetManager = app.getAssetManager(); + cam = app.getCamera(); + rootNode = ((SimpleApplication) app).getRootNode(); + } + + @Override + protected void onEnable() { + List placed; + try { + placed = LightIO.load(); + } catch (Exception e) { + log.warn("[WorldLight] Lichtquellen nicht ladbar: {}", e.getMessage()); + return; + } + if (placed.isEmpty()) return; + + for (PlacedLight pl : placed) { + PointLight pt = new PointLight(); + pt.setColor(new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f).mult(pl.intensity())); + pt.setRadius(pl.radius()); + pt.setPosition(new Vector3f(pl.x(), pl.y(), pl.z())); + rootNode.addLight(pt); + pointLights.add(pt); + } + + if (fpp != null) { + int filterCount = Math.min(MAX_SHADOW_LIGHTS, pointLights.size()); + for (int i = 0; i < filterCount; i++) { + PointLightShadowFilter psf = + new PointLightShadowFilter(assetManager, SHADOW_MAP_SIZE); + psf.setLight(pointLights.get(i)); + psf.setEdgeFilteringMode(EdgeFilteringMode.PCF4); + psf.setShadowIntensity(0.6f); + psf.setEnabled(false); // initial deaktiviert; update() aktiviert bei Bedarf + fpp.addFilter(psf); + shadowFilters.add(psf); + } + } + + log.info("[WorldLight] {} Lichtquellen geladen, {} Schatten-Filter bereit.", + pointLights.size(), shadowFilters.size()); + + // Sofortige erste Zuweisung + updateShadowAssignments(); + } + + @Override + public void update(float tpf) { + if (shadowFilters.isEmpty()) return; + shadowUpdateTimer += tpf; + if (shadowUpdateTimer >= SHADOW_UPDATE_INTERVAL) { + shadowUpdateTimer = 0f; + updateShadowAssignments(); + } + } + + /** + * Weist den Filter-Slots die jeweils nächsten Lichtquellen zu. + * Lichtquellen außerhalb ihres eigenen Radius deaktivieren ihren Slot. + */ + private void updateShadowAssignments() { + if (shadowFilters.isEmpty() || pointLights.isEmpty()) return; + + Vector3f camPos = cam.getLocation(); + + // Lichter nach Distanz zur Kamera sortieren (nächste zuerst) + List byDist = new ArrayList<>(pointLights); + byDist.sort(Comparator.comparingDouble( + l -> l.getPosition().distanceSquared(camPos))); + + for (int i = 0; i < shadowFilters.size(); i++) { + PointLightShadowFilter psf = shadowFilters.get(i); + if (i < byDist.size()) { + PointLight light = byDist.get(i); + float dist = light.getPosition().distance(camPos); + float threshold = light.getRadius(); // nur innerhalb des Leuchtradius + if (dist <= threshold) { + psf.setLight(light); + psf.setEnabled(true); + } else { + psf.setEnabled(false); + } + } else { + psf.setEnabled(false); + } + } + } + + @Override + protected void onDisable() { + for (PointLightShadowFilter f : shadowFilters) { + f.setEnabled(false); + if (fpp != null) fpp.removeFilter(f); + } + shadowFilters.clear(); + for (PointLight pl : pointLights) rootNode.removeLight(pl); + pointLights.clear(); + } + + @Override protected void cleanup(Application app) { onDisable(); } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java index d3bfde8..fafe8bb 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java @@ -23,7 +23,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class WorldObjectsState extends BaseAppState { @@ -34,6 +36,9 @@ public class WorldObjectsState extends BaseAppState { private BulletAppState bulletAppState; private final List sceneLitMaterials = new ArrayList<>(); + /** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */ + private final Map interactableRbcs = new HashMap<>(); + @Override protected void initialize(Application app) { this.app = (SimpleApplication) app; @@ -88,6 +93,10 @@ public class WorldObjectsState extends BaseAppState { CollisionShapeFactory.createMeshShape(s), 0f); s.addControl(rbc); bulletAppState.getPhysicsSpace().add(rbc); + // Interactable-Objekte (Bank/Bett) in Map speichern für späteren Toggle + if (m.interactableId() != null && !m.interactableId().isBlank()) { + interactableRbcs.put(m.interactableId(), rbc); + } } catch (Exception pe) { log.warn("[WorldObjects] Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage()); } @@ -276,4 +285,17 @@ public class WorldObjectsState extends BaseAppState { mat.setColor("Color", ColorRGBA.Gray); g.setMaterial(mat); } + + /** Aktiviert oder deaktiviert die Physik-Kollision für ein Interactable-Objekt. */ + public void setInteractablePhysicsEnabled(String interactableId, boolean enabled) { + RigidBodyControl rbc = interactableRbcs.get(interactableId); + if (rbc == null) { + return; + } + try { + rbc.setEnabled(enabled); + } catch (Exception e) { + // PhysicsSpace kann beim App-Shutdown bereits zerstört sein + } + } } diff --git a/blight-map/src/main/map/benches/1c43e5dc-ddde-4f21-a876-8e778cf54da7.bench b/blight-map/src/main/map/benches/1c43e5dc-ddde-4f21-a876-8e778cf54da7.bench new file mode 100644 index 0000000..0d76ee3 --- /dev/null +++ b/blight-map/src/main/map/benches/1c43e5dc-ddde-4f21-a876-8e778cf54da7.bench @@ -0,0 +1,9 @@ +{ + "id": "1c43e5dc-ddde-4f21-a876-8e778cf54da7", + "benchType": "Simple", + "sitzX": 110.8, + "sitzY": 3.4299998, + "sitzZ": 77.67, + "sitzRotY": 1.5707964, + "sitzSet": true +} \ No newline at end of file diff --git a/blight-map/src/main/map/benches/5be97ffb-e413-42e5-9815-9d14d1e3b93f.bench b/blight-map/src/main/map/benches/5be97ffb-e413-42e5-9815-9d14d1e3b93f.bench new file mode 100644 index 0000000..2d7adb0 --- /dev/null +++ b/blight-map/src/main/map/benches/5be97ffb-e413-42e5-9815-9d14d1e3b93f.bench @@ -0,0 +1,9 @@ +{ + "id": "5be97ffb-e413-42e5-9815-9d14d1e3b93f", + "benchType": "Simple", + "sitzX": 106.9624, + "sitzY": 3.39958, + "sitzZ": 70.01086, + "sitzRotY": 1.5707964, + "sitzSet": true +} \ No newline at end of file diff --git a/blight-map/src/main/map/benches/d0adac15-2c08-4bd2-9a2b-ce3aad6d25d4.bench b/blight-map/src/main/map/benches/d0adac15-2c08-4bd2-9a2b-ce3aad6d25d4.bench new file mode 100644 index 0000000..a10aa40 --- /dev/null +++ b/blight-map/src/main/map/benches/d0adac15-2c08-4bd2-9a2b-ce3aad6d25d4.bench @@ -0,0 +1,9 @@ +{ + "id": "d0adac15-2c08-4bd2-9a2b-ce3aad6d25d4", + "benchType": "Simple", + "sitzX": 99.24746, + "sitzY": 6.18361, + "sitzZ": 64.59041, + "sitzRotY": 1.5707964, + "sitzSet": true +} \ No newline at end of file diff --git a/blight-map/src/main/map/blight_grass_vertex.blgv b/blight-map/src/main/map/blight_grass_vertex.blgv index 0fce0dc..c072005 100644 Binary files a/blight-map/src/main/map/blight_grass_vertex.blgv and b/blight-map/src/main/map/blight_grass_vertex.blgv differ diff --git a/blight-map/src/main/map/blight_lights.bll b/blight-map/src/main/map/blight_lights.bll index 36a87bd..9c57f3b 100644 --- a/blight-map/src/main/map/blight_lights.bll +++ b/blight-map/src/main/map/blight_lights.bll @@ -1 +1,4 @@ # x y z r g b intensity radius +138.57341 1.10000 209.24336 0.80000 1.00000 0.80000 12.00000 50.00000 +131.40373 1.10000 196.12698 0.80000 1.00000 0.80000 12.00000 50.00000 +143.96100 1.10000 194.25346 0.80000 1.00000 0.80000 12.00000 50.00000 diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index d16faf4..d701846 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ diff --git a/blight-map/src/main/map/blight_objects.blo b/blight-map/src/main/map/blight_objects.blo index a2dd243..b6d7db8 100644 --- a/blight-map/src/main/map/blight_objects.blo +++ b/blight-map/src/main/map/blight_objects.blo @@ -1,4 +1,8 @@ -# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance -Models/imported/wooden+cabin+3d+model.j3o 105.61634 5.26108 58.69108 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 -Models/trees/pine/medium/pine_medium_20260615_221703.j3o 96.87341 6.23885 51.96483 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 -Models/trees/pine/medium/pine_medium_20260615_221707.j3o 110.91007 4.47205 50.56980 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId +Models/imported/wooden+cabin+3d+model.j3o 105.61634 4.85395 58.69108 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260615_221703.j3o 96.87341 6.11813 51.96483 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260615_221707.j3o 110.91007 4.33700 50.56980 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/imported/Höhlenkristall1.j3o 138.57341 1.00000 209.24336 0.64251 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 +Models/imported/Höhlenkristall1.j3o 131.40373 1.00000 196.12698 3.95943 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 +Models/imported/Höhlenkristall1.j3o 143.96100 1.00000 194.25346 4.02822 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 +Models/imported/bank1.j3o 106.96240 2.89958 70.01086 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 BENCH 5be97ffb-e413-42e5-9815-9d14d1e3b93f diff --git a/blight-map/src/main/map/blight_paths.blp b/blight-map/src/main/map/blight_paths.blp new file mode 100644 index 0000000..9a06c94 --- /dev/null +++ b/blight-map/src/main/map/blight_paths.blp @@ -0,0 +1,3 @@ +# Blight Path Network +# NODE uuid name x y z +# EDGE uuid1 uuid2 diff --git a/blight-map/src/main/map/blight_stones.bls b/blight-map/src/main/map/blight_stones.bls new file mode 100644 index 0000000..0aec51e Binary files /dev/null and b/blight-map/src/main/map/blight_stones.bls differ diff --git a/blight-map/src/main/map/chunks/chunk_16_16.blc b/blight-map/src/main/map/chunks/chunk_16_16.blc index 4eebe2c..1b2f537 100644 Binary files a/blight-map/src/main/map/chunks/chunk_16_16.blc and b/blight-map/src/main/map/chunks/chunk_16_16.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_16_17.blc b/blight-map/src/main/map/chunks/chunk_16_17.blc index 7791b47..6495b64 100644 Binary files a/blight-map/src/main/map/chunks/chunk_16_17.blc and b/blight-map/src/main/map/chunks/chunk_16_17.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_17_16.blc b/blight-map/src/main/map/chunks/chunk_17_16.blc index 5666106..372419f 100644 Binary files a/blight-map/src/main/map/chunks/chunk_17_16.blc and b/blight-map/src/main/map/chunks/chunk_17_16.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_17_17.blc b/blight-map/src/main/map/chunks/chunk_17_17.blc index 4e1747d..58c4fc2 100644 Binary files a/blight-map/src/main/map/chunks/chunk_17_17.blc and b/blight-map/src/main/map/chunks/chunk_17_17.blc differ diff --git a/blight-map/src/main/map/chunks/patch_8c7c2435cb97.blsp b/blight-map/src/main/map/chunks/patch_8c7c2435cb97.blsp new file mode 100644 index 0000000..46db4b3 Binary files /dev/null and b/blight-map/src/main/map/chunks/patch_8c7c2435cb97.blsp differ diff --git a/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm b/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm new file mode 100644 index 0000000..9ffafab Binary files /dev/null and b/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm differ diff --git a/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm b/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm new file mode 100644 index 0000000..c4a5afb Binary files /dev/null and b/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm differ diff --git a/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm b/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm new file mode 100644 index 0000000..e2c7fe4 Binary files /dev/null and b/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm differ diff --git a/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm b/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm new file mode 100644 index 0000000..03f1291 Binary files /dev/null and b/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm differ diff --git a/blight-map/src/main/map/chunks/voxel_13_0_16.blvc b/blight-map/src/main/map/chunks/voxel_13_0_16.blvc deleted file mode 100644 index 0a2aa54..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_13_0_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_13_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_13_m1_16.blvc deleted file mode 100644 index 2f06391..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_13_m1_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_15.blvc b/blight-map/src/main/map/chunks/voxel_14_0_15.blvc deleted file mode 100644 index f6c9c26..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_16.blvc b/blight-map/src/main/map/chunks/voxel_14_0_16.blvc deleted file mode 100644 index 0690fc9..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_14_m1_15.blvc deleted file mode 100644 index 4378f42..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_m1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_14_m1_16.blvc deleted file mode 100644 index 244fcdd..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_m1_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_15.blvc b/blight-map/src/main/map/chunks/voxel_15_0_15.blvc deleted file mode 100644 index 0868d9f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod0.j3o deleted file mode 100644 index b6a2b6a..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod1.j3o deleted file mode 100644 index 09a822b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod2.j3o deleted file mode 100644 index d835aa6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_15_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_16.blvc b/blight-map/src/main/map/chunks/voxel_15_0_16.blvc deleted file mode 100644 index 1acd58d..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod0.j3o deleted file mode 100644 index 1a77cbf..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod1.j3o deleted file mode 100644 index dcac75f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod2.j3o deleted file mode 100644 index 984aeeb..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_17.blvc b/blight-map/src/main/map/chunks/voxel_15_0_17.blvc deleted file mode 100644 index d307e85..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod0.j3o deleted file mode 100644 index 6192fc3..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod1.j3o deleted file mode 100644 index dc83802..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod2.j3o deleted file mode 100644 index f0028f4..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_17_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_18.blvc b/blight-map/src/main/map/chunks/voxel_15_0_18.blvc deleted file mode 100644 index 345de80..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_15_m1_15.blvc deleted file mode 100644 index 1b1cbb8..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_15_m1_16.blvc deleted file mode 100644 index e048255..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod0.j3o deleted file mode 100644 index 59f949b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod1.j3o deleted file mode 100644 index 9a3a593..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod2.j3o deleted file mode 100644 index 1d777b6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_17.blvc b/blight-map/src/main/map/chunks/voxel_15_m1_17.blvc deleted file mode 100644 index 1aa0863..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod0.j3o deleted file mode 100644 index f9136a9..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod1.j3o deleted file mode 100644 index ede5440..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod2.j3o deleted file mode 100644 index 7b2bb71..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_17_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_m1_18.blvc b/blight-map/src/main/map/chunks/voxel_15_m1_18.blvc deleted file mode 100644 index ac76838..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_m1_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_14.blvc b/blight-map/src/main/map/chunks/voxel_16_0_14.blvc deleted file mode 100644 index fb5eb3d..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod0.j3o deleted file mode 100644 index 9517f37..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod1.j3o deleted file mode 100644 index 09a822b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod2.j3o deleted file mode 100644 index d835aa6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_14_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_15.blvc b/blight-map/src/main/map/chunks/voxel_16_0_15.blvc deleted file mode 100644 index a96dffa..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod0.j3o deleted file mode 100644 index 8077499..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod1.j3o deleted file mode 100644 index fd09c37..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod2.j3o deleted file mode 100644 index d835aa6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_15_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_16.blvc b/blight-map/src/main/map/chunks/voxel_16_0_16.blvc deleted file mode 100644 index c9db711..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod0.j3o deleted file mode 100644 index b6b47a2..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod1.j3o deleted file mode 100644 index f98792b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod2.j3o deleted file mode 100644 index f9ec1f1..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17.blvc b/blight-map/src/main/map/chunks/voxel_16_0_17.blvc deleted file mode 100644 index 08ae4f8..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o index c11e0ff..1ade98b 100644 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o index 221d900..a0f3ba6 100644 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod2.j3o deleted file mode 100644 index be79db8..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_18.blvc b/blight-map/src/main/map/chunks/voxel_16_0_18.blvc deleted file mode 100644 index 134ccae..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_1_15.blvc b/blight-map/src/main/map/chunks/voxel_16_1_15.blvc deleted file mode 100644 index 93368db..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_14.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_14.blvc deleted file mode 100644 index 2483be0..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc deleted file mode 100644 index e8c9c34..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc deleted file mode 100644 index bfe7c14..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod0.j3o deleted file mode 100644 index 28e0fb0..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod1.j3o deleted file mode 100644 index fd2cdb2..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod2.j3o deleted file mode 100644 index f808e71..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_17.blvc deleted file mode 100644 index bf857f6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o index 96a921c..13a1000 100644 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o index 91d0bb3..d19c985 100644 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod2.j3o deleted file mode 100644 index ca35fa1..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_18.blvc b/blight-map/src/main/map/chunks/voxel_16_m1_18.blvc deleted file mode 100644 index bbc53b3..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_14.blvc b/blight-map/src/main/map/chunks/voxel_17_0_14.blvc deleted file mode 100644 index cdace8f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod0.j3o deleted file mode 100644 index 35c80e5..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod1.j3o deleted file mode 100644 index 1a56c7f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod2.j3o deleted file mode 100644 index d835aa6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_14_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_15.blvc b/blight-map/src/main/map/chunks/voxel_17_0_15.blvc deleted file mode 100644 index d4fa0dd..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod0.j3o deleted file mode 100644 index f7f705a..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod1.j3o deleted file mode 100644 index f484080..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod2.j3o deleted file mode 100644 index aaf6378..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_15_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_16.blvc b/blight-map/src/main/map/chunks/voxel_17_0_16.blvc deleted file mode 100644 index 0ecf6c3..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod0.j3o deleted file mode 100644 index 213bf01..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod1.j3o deleted file mode 100644 index d760640..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod2.j3o deleted file mode 100644 index 880da6f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17.blvc b/blight-map/src/main/map/chunks/voxel_17_0_17.blvc deleted file mode 100644 index 5819684..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o index b878971..b597190 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o index 6a4ae45..e4d1d2f 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o index f429887..f6bb421 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o and b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_18.blvc b/blight-map/src/main/map/chunks/voxel_17_0_18.blvc deleted file mode 100644 index 70b3247..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_1_14.blvc b/blight-map/src/main/map/chunks/voxel_17_1_14.blvc deleted file mode 100644 index 053664b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_1_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_1_15.blvc b/blight-map/src/main/map/chunks/voxel_17_1_15.blvc deleted file mode 100644 index 8e91299..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_1_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_1_15_baked_lod0.j3o deleted file mode 100644 index 829ae6f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_1_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_14.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_14.blvc deleted file mode 100644 index dd77056..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_15.blvc deleted file mode 100644 index 65a337f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod0.j3o deleted file mode 100644 index f21a0f0..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod1.j3o deleted file mode 100644 index 5a86e3d..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod2.j3o deleted file mode 100644 index ae215b9..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_15_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc deleted file mode 100644 index 6da4af0..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_16.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod0.j3o deleted file mode 100644 index 9d79c32..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod1.j3o deleted file mode 100644 index 0c0081b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod2.j3o deleted file mode 100644 index 40f928d..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_16_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_17.blvc deleted file mode 100644 index 04e0a05..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o index c10d337..4572d35 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o index deaa594..4def65a 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o index 28a34ee..2eaea37 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_18.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_18.blvc deleted file mode 100644 index 8883057..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_18.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_0_14.blvc b/blight-map/src/main/map/chunks/voxel_18_0_14.blvc deleted file mode 100644 index c339eb1..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_0_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_0_15.blvc b/blight-map/src/main/map/chunks/voxel_18_0_15.blvc deleted file mode 100644 index bab3a73..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_0_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod0.j3o deleted file mode 100644 index 9517f37..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod1.j3o deleted file mode 100644 index 09a822b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod2.j3o deleted file mode 100644 index d835aa6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_0_15_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_1_14.blvc b/blight-map/src/main/map/chunks/voxel_18_1_14.blvc deleted file mode 100644 index 43118b6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_1_14.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_1_15.blvc b/blight-map/src/main/map/chunks/voxel_18_1_15.blvc deleted file mode 100644 index 924218d..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_1_15.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_18_m1_15.blvc b/blight-map/src/main/map/chunks/voxel_18_m1_15.blvc deleted file mode 100644 index 01c7f21..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_18_m1_15.blvc and /dev/null differ