diff --git a/blight-assets/src/main/resources/MatDefs/FlowingWater.j3md b/blight-assets/src/main/resources/MatDefs/FlowingWater.j3md new file mode 100644 index 0000000..61fccea --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/FlowingWater.j3md @@ -0,0 +1,22 @@ +MaterialDef Flowing Water { + MaterialParameters { + Texture2D NormalMap + Texture2D FoamMap + Color Tint + Float UVScale : 6.0 + Float Time : 0.0 + Float FlowSpeed : 1.0 + Float FoamAmount : 0.0 + } + Technique { + VertexShader GLSL150: Shaders/FlowingWater.vert + FragmentShader GLSL150: Shaders/FlowingWater.frag + WorldParameters { + WorldViewProjectionMatrix + } + Defines { + HAS_NORMALMAP : NormalMap + HAS_FOAMMAP : FoamMap + } + } +} diff --git a/blight-assets/src/main/resources/Models/Chars/mainchar.j3o b/blight-assets/src/main/resources/Models/Chars/mainchar.j3o index b7869e2..607c84f 100644 Binary files a/blight-assets/src/main/resources/Models/Chars/mainchar.j3o and b/blight-assets/src/main/resources/Models/Chars/mainchar.j3o differ diff --git a/blight-assets/src/main/resources/Models/Chars/silas.j3o b/blight-assets/src/main/resources/Models/Chars/silas.j3o index 68fe2a3..551257c 100644 Binary files a/blight-assets/src/main/resources/Models/Chars/silas.j3o and b/blight-assets/src/main/resources/Models/Chars/silas.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_0.j3o b/blight-assets/src/main/resources/Models/custom_mesh_0.j3o index 83ecb88..83c0c97 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_0.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_0.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_1.j3o b/blight-assets/src/main/resources/Models/custom_mesh_1.j3o index 62c2002..a72e2d1 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_1.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_1.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_2.j3o b/blight-assets/src/main/resources/Models/custom_mesh_2.j3o index 81b141e..39ecd36 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_2.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_2.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_3.j3o b/blight-assets/src/main/resources/Models/custom_mesh_3.j3o index 45c61fb..7c03e32 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_3.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_3.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_4.j3o b/blight-assets/src/main/resources/Models/custom_mesh_4.j3o index f5d8464..605e054 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_4.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_4.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_5.j3o b/blight-assets/src/main/resources/Models/custom_mesh_5.j3o index e984854..5860cdf 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_5.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_5.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_6.j3o b/blight-assets/src/main/resources/Models/custom_mesh_6.j3o index 3f87821..a82805d 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_6.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_6.j3o differ diff --git a/blight-assets/src/main/resources/Models/custom_mesh_7.j3o b/blight-assets/src/main/resources/Models/custom_mesh_7.j3o index 38f402c..ab8f287 100644 Binary files a/blight-assets/src/main/resources/Models/custom_mesh_7.j3o and b/blight-assets/src/main/resources/Models/custom_mesh_7.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/oak/oak_medium_20260603_210903.j3o b/blight-assets/src/main/resources/Models/trees/oak/oak_medium_20260603_210903.j3o new file mode 100644 index 0000000..0e11c2d Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/oak_medium_20260603_210903.j3o differ diff --git a/blight-assets/src/main/resources/Shaders/FlowingWater.frag b/blight-assets/src/main/resources/Shaders/FlowingWater.frag new file mode 100644 index 0000000..e9eff5d --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/FlowingWater.frag @@ -0,0 +1,62 @@ +#ifdef HAS_NORMALMAP +uniform sampler2D m_NormalMap; +#endif +#ifdef HAS_FOAMMAP +uniform sampler2D m_FoamMap; +#endif + +uniform vec4 m_Tint; +uniform float m_FoamAmount; + +in vec2 vUV; +in vec2 vTex1; +in vec2 vTex2; + +out vec4 outFragColor; + +void main() { + // ── Randabstand: 0 = Mitte, 1 = Rand ───────────────────────────────────── + float edgeDist = abs(vUV.x * 2.0 - 1.0); + + // ── Tiefengradient ──────────────────────────────────────────────────────── + // Ränder heller/transparenter (flaches Ufer), Mitte dunkler (tief) + vec3 baseColor = m_Tint.rgb * (1.0 - edgeDist * 0.45); + float baseAlpha = m_Tint.a * smoothstep(0.0, 0.18, 1.0 - edgeDist); + + // ── Dual-Layer Normal-Map: Specular + Wellenhelligkeit ──────────────────── + float specular = 0.0; + float ripple = 0.0; +#ifdef HAS_NORMALMAP + vec3 n1 = texture(m_NormalMap, vTex1).rgb * 2.0 - 1.0; + vec3 n2 = texture(m_NormalMap, vTex2).rgb * 2.0 - 1.0; + vec3 n = normalize(n1 + n2); + + // Feste Sonnenrichtung – gut für Bachlauf-Optik + vec3 sunDir = normalize(vec3(0.5, 1.0, 0.3)); + specular = pow(max(0.0, dot(n, sunDir)), 22.0); + // Wellenhelligkeit variiert den Basiston leicht + ripple = dot(n, vec3(0.0, 1.0, 0.0)) * 0.10; +#endif + + // ── Schaum ──────────────────────────────────────────────────────────────── + // Uferschaum wird stärker je näher am Rand; Wasserfälle extra + float foamEdge = smoothstep(0.55, 1.0, edgeDist); + float foamMask = clamp(foamEdge + m_FoamAmount * 0.75, 0.0, 1.0); + +#ifdef HAS_FOAMMAP + float foamSample = texture(m_FoamMap, vTex1 * 0.4).r; + foamMask *= foamSample; +#else + // Prozeduraler Fallback wenn keine Schaumtextur geladen + float procFoam = (sin(vTex1.x * 6.283) * 0.5 + 0.5) + * (sin(vTex1.y * 3.141) * 0.5 + 0.5); + foamMask *= procFoam; +#endif + + // ── Zusammenführen ──────────────────────────────────────────────────────── + vec3 waterColor = baseColor + ripple + vec3(0.35, 0.45, 0.55) * specular; + vec3 finalColor = mix(waterColor, vec3(1.0), foamMask); + float finalAlpha = max(baseAlpha, foamMask * 0.9); + + outFragColor = vec4(clamp(finalColor, 0.0, 1.0), finalAlpha); +} diff --git a/blight-assets/src/main/resources/Shaders/FlowingWater.vert b/blight-assets/src/main/resources/Shaders/FlowingWater.vert new file mode 100644 index 0000000..9776adf --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/FlowingWater.vert @@ -0,0 +1,21 @@ +uniform mat4 g_WorldViewProjectionMatrix; +uniform float m_Time; +uniform float m_UVScale; +uniform float m_FlowSpeed; + +in vec3 inPosition; +in vec2 inTexCoord; + +out vec2 vUV; // raw [0..1] UV für Tiefe / Rand-Logik +out vec2 vTex1; // scrollende Normal-Map-UV (flussabwärts) +out vec2 vTex2; // scrollende Normal-Map-UV (diagonal, gegenläufig) + +void main() { + vUV = inTexCoord; + float t = m_Time * m_FlowSpeed; + vTex1 = vec2(inTexCoord.x * m_UVScale, + inTexCoord.y * m_UVScale - t); + vTex2 = vec2(inTexCoord.x * m_UVScale * 0.7 - t * 0.25, + inTexCoord.y * m_UVScale * 0.7 + t * 0.55); + gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0); +} diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155951.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155951.png new file mode 100644 index 0000000..ff5ba2e Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155951.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155953.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155953.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_155953.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160003.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160003.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160003.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160027.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160027.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160027.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160028.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160028.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160028.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160029.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160029.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160029.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160030.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160030.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160030.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160126.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160126.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160126.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160809.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160809.png new file mode 100644 index 0000000..7fd1bec Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160809.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160907.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160907.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_EzBaum1_20260603_160907.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210057.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210057.png new file mode 100644 index 0000000..018e0ef Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210057.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210112.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210112.png new file mode 100644 index 0000000..a0ef111 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210112.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210903.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210903.png new file mode 100644 index 0000000..7fd1bec Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260603_210903.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1.png b/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1.png new file mode 100644 index 0000000..c785f37 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1_20260603_160925.png b/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1_20260603_160925.png new file mode 100644 index 0000000..ace2a5a Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/impostor_Baum1_20260603_160925.png differ diff --git a/blight-assets/src/main/resources/Textures/vegetation_grass_card_03.png b/blight-assets/src/main/resources/Textures/vegetation_grass_card_03.png new file mode 100644 index 0000000..aca6e12 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/vegetation_grass_card_03.png differ diff --git a/blight-assets/src/main/resources/Textures/water/river.jpg b/blight-assets/src/main/resources/Textures/water/river.jpg new file mode 100644 index 0000000..dc4a73c Binary files /dev/null and b/blight-assets/src/main/resources/Textures/water/river.jpg differ diff --git a/blight-assets/src/main/resources/Textures/water/river_normal.jpg b/blight-assets/src/main/resources/Textures/water/river_normal.jpg new file mode 100644 index 0000000..8b4afd9 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/water/river_normal.jpg differ diff --git a/blight-assets/src/main/resources/Textures/water/waterfall_diffuse.png b/blight-assets/src/main/resources/Textures/water/waterfall_diffuse.png new file mode 100644 index 0000000..34f64a4 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/water/waterfall_diffuse.png differ diff --git a/blight-assets/src/main/resources/Textures/water/waterfall_normal.png b/blight-assets/src/main/resources/Textures/water/waterfall_normal.png new file mode 100644 index 0000000..c5634a4 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/water/waterfall_normal.png differ diff --git a/blight-assets/src/main/resources/animations/clips/idle.j3o b/blight-assets/src/main/resources/animations/clips/idle.j3o new file mode 100644 index 0000000..ebb57a3 Binary files /dev/null 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 new file mode 100644 index 0000000..3715aa6 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/idle_jump.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/running.j3o b/blight-assets/src/main/resources/animations/clips/running.j3o new file mode 100644 index 0000000..fc8c239 Binary files /dev/null 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 new file mode 100644 index 0000000..1577afe Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/running_jump.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sprint.j3o b/blight-assets/src/main/resources/animations/clips/sprint.j3o new file mode 100644 index 0000000..4e005a4 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sprint.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 new file mode 100644 index 0000000..6d9ebbb Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/stand_up.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/tpose.j3o b/blight-assets/src/main/resources/animations/clips/tpose.j3o new file mode 100644 index 0000000..5abd9e5 Binary files /dev/null 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 new file mode 100644 index 0000000..fef5865 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/walking.j3o differ diff --git a/blight-assets/src/main/resources/animations/idle.glb b/blight-assets/src/main/resources/animations/idle.glb new file mode 100644 index 0000000..43ffb01 Binary files /dev/null and b/blight-assets/src/main/resources/animations/idle.glb differ diff --git a/blight-assets/src/main/resources/animations/idle_jump.glb b/blight-assets/src/main/resources/animations/idle_jump.glb new file mode 100644 index 0000000..b9cde1c Binary files /dev/null and b/blight-assets/src/main/resources/animations/idle_jump.glb differ diff --git a/blight-assets/src/main/resources/animations/running_jump.glb b/blight-assets/src/main/resources/animations/running_jump.glb new file mode 100644 index 0000000..696378d Binary files /dev/null and b/blight-assets/src/main/resources/animations/running_jump.glb 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 new file mode 100644 index 0000000..d3790ef --- /dev/null +++ b/blight-assets/src/main/resources/animations/sets/human.animset.json @@ -0,0 +1,21 @@ +{ + "clips": [ + "idle", + "idle_jump", + "running", + "running_jump", + "sprint", + "stand_up", + "tpose", + "walking" + ], + "actionMap": { + "DEFAULT": "tpose", + "IDLE": "idle", + "WALK": "walking", + "RUN": "running", + "SPRINT": "sprint", + "JUMP": "idle_jump", + "RUNNING_JUMP": "running_jump" + } +} diff --git a/blight-assets/src/main/resources/animations/sets/mainchar.animset.json b/blight-assets/src/main/resources/animations/sets/mainchar.animset.json new file mode 100644 index 0000000..6cf451d --- /dev/null +++ b/blight-assets/src/main/resources/animations/sets/mainchar.animset.json @@ -0,0 +1,21 @@ +{ + "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-assets/src/main/resources/animations/sprinting.glb b/blight-assets/src/main/resources/animations/sprinting.glb new file mode 100644 index 0000000..1bb6b59 Binary files /dev/null and b/blight-assets/src/main/resources/animations/sprinting.glb differ diff --git a/blight-assets/src/main/resources/animations/stand_up.glb b/blight-assets/src/main/resources/animations/stand_up.glb new file mode 100644 index 0000000..7ae3c1e Binary files /dev/null and b/blight-assets/src/main/resources/animations/stand_up.glb differ diff --git a/blight-assets/src/main/resources/animations/tpose.glb b/blight-assets/src/main/resources/animations/tpose.glb new file mode 100644 index 0000000..1ddb24a Binary files /dev/null and b/blight-assets/src/main/resources/animations/tpose.glb differ diff --git a/blight-assets/src/main/resources/character/hero.character b/blight-assets/src/main/resources/character/hero.character new file mode 100644 index 0000000..f6a632a --- /dev/null +++ b/blight-assets/src/main/resources/character/hero.character @@ -0,0 +1,19 @@ +{ + "chapter": 0, + "level": 0, + "xp": 0, + "currentHp": 0, + "maxHp": 0, + "currentStamina": 0, + "maxStamina": 0, + "currentMana": 0, + "myMana": 0, + "listeners": [], + "characterId": "hero", + "name": { + "id": "hero.name" + }, + "modelPath": "Models/Chars/mainchar.j3o", + "animSetPath": "human", + "type": "MAIN_CHARACTER" +} \ No newline at end of file diff --git a/blight-assets/src/main/resources/character/silas.character b/blight-assets/src/main/resources/character/silas.character new file mode 100644 index 0000000..be7453b --- /dev/null +++ b/blight-assets/src/main/resources/character/silas.character @@ -0,0 +1,10 @@ +{ + "trader": false, + "characterId": "silas", + "name": { + "id": "silas.name" + }, + "modelPath": "Models/Chars/silas.j3o", + "animSetPath": "human", + "type": "NPC" +} \ No newline at end of file diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o index 827ee0c..9cdfe9f 100644 Binary files a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o and b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o differ diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o new file mode 100644 index 0000000..521bdd0 Binary files /dev/null and b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o differ diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160907.j3o b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160907.j3o new file mode 100644 index 0000000..9cdfe9f Binary files /dev/null and b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160907.j3o differ diff --git a/blight-common/src/main/java/de/blight/common/GrassTuft.java b/blight-common/src/main/java/de/blight/common/GrassTuft.java new file mode 100644 index 0000000..195f43b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/GrassTuft.java @@ -0,0 +1,4 @@ +package de.blight.common; + +/** Einzeln platzierter Gras-Büschel. Y-Position wird zur Renderzeit aus dem Terrain berechnet. */ +public record GrassTuft(float x, float z, float height, int slot) {} diff --git a/blight-common/src/main/java/de/blight/common/GrassTuftIO.java b/blight-common/src/main/java/de/blight/common/GrassTuftIO.java new file mode 100644 index 0000000..0ef3bec --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/GrassTuftIO.java @@ -0,0 +1,86 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; + +/** + * Liest und schreibt individuell platzierte Gras-Büschel als komprimierte Binärdatei + * ({@code blight_grass.blg}) neben der Kartendatei. + * + * Format v1: + * int MAGIC 0x47525A53 ("GRZS") + * int VERSION 1 + * int slotCount (8) + * 8× UTF Texturpfad pro Slot (Slot 0 = Standardtextur) + * int tuftCount + * N× float x, float z, float height, byte slot + */ +public final class GrassTuftIO { + + private static final int MAGIC = 0x47525A53; + private static final int VERSION = 1; + private static final int SLOT_COUNT = 8; + + private GrassTuftIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_grass.blg"); + } + + public record GrassData(String[] slotPaths, List tufts) {} + + public static void save(GrassData 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 tufts = data.tufts() != null ? data.tufts() : List.of(); + out.writeInt(tufts.size()); + for (GrassTuft t : tufts) { + out.writeFloat(t.x()); + out.writeFloat(t.z()); + out.writeFloat(t.height()); + out.writeByte(t.slot()); + } + } + } + + public static GrassData load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return null; + try (DataInputStream in = new DataInputStream( + new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) { + int magic = in.readInt(); + int version = in.readInt(); + if (magic != MAGIC) throw new IOException("Ungültige Grasdatei (Magic)"); + if (version > VERSION) throw new IOException("Unbekannte Gras-Version: " + version); + 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 tuftCount = in.readInt(); + List tufts = new ArrayList<>(tuftCount); + for (int i = 0; i < tuftCount; i++) { + float x = in.readFloat(); + float z = in.readFloat(); + float h = in.readFloat(); + int slot = in.readUnsignedByte(); + tufts.add(new GrassTuft(x, z, h, slot)); + } + return new GrassData(paths, tufts); + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/MapData.java b/blight-common/src/main/java/de/blight/common/MapData.java index 0eec543..5e171b7 100644 --- a/blight-common/src/main/java/de/blight/common/MapData.java +++ b/blight-common/src/main/java/de/blight/common/MapData.java @@ -70,6 +70,25 @@ public final class MapData { /** Gras-Dichte [SPLAT_SIZE²], Bytes 0–255 (0=kein Gras, 255=max Dichte). */ public final byte[] grassDensity; + /** + * Gras-Höhe pro Pixel [SPLAT_SIZE²]. + * 0 = nicht gesetzt → grassDefaultHeight verwenden. + * 1–255 = 0.1 m bis 10.0 m (linear: h = 0.1 + (b-1) * 9.9/254). + */ + public final byte[] grassHeightMap; + + /** Relativer Asset-Pfad der Gras-Textur (z. B. "Textures/grass.png"), "" = Standardfarbe. */ + public String grassTexturePath = ""; + + /** Standard-Blatt-Höhe für den Gras-Renderer (entspricht dem Grashöhe-Slider). */ + public float grassDefaultHeight = 1.5f; + + /** Per-Pixel Textur-Slot-Index [SPLAT_SIZE²]. 0=grassTexturePath, 1–N=grassTextureSlots[slot-1]. */ + public final byte[] grassTextureMap; + + /** Texturpfade für Gras-Slots 1..N. Leerstring = nicht belegt. */ + public String[] grassTextureSlots = new String[0]; + /** Loch-Maske der oberen Schicht [UPPER_CELLS²], != 0 = Loch (Zelle ausgeblendet). */ public final byte[] upperHole; @@ -91,7 +110,9 @@ public final class MapData { upperSplatG = new byte [SPLAT_SIZE * SPLAT_SIZE]; upperSplatB = new byte [SPLAT_SIZE * SPLAT_SIZE]; upperSplatA = new byte [SPLAT_SIZE * SPLAT_SIZE]; - grassDensity = new byte [SPLAT_SIZE * SPLAT_SIZE]; - upperHole = new byte [UPPER_CELLS * UPPER_CELLS]; + grassDensity = new byte [SPLAT_SIZE * SPLAT_SIZE]; + grassHeightMap = new byte [SPLAT_SIZE * SPLAT_SIZE]; + grassTextureMap = new byte [SPLAT_SIZE * SPLAT_SIZE]; + upperHole = new byte [UPPER_CELLS * UPPER_CELLS]; } } diff --git a/blight-common/src/main/java/de/blight/common/MapIO.java b/blight-common/src/main/java/de/blight/common/MapIO.java index d0a3c50..7307885 100644 --- a/blight-common/src/main/java/de/blight/common/MapIO.java +++ b/blight-common/src/main/java/de/blight/common/MapIO.java @@ -19,6 +19,9 @@ import java.util.zip.*; * 4 – wie 3 + Spawnpunkt (2× float) * 5 – wie 4 + Splatmap-Alpha + Texturpfade + Gebirge-Splatmap (RGBA + Pfade) * 6 – wie 5 ohne upperHole; upperTop/Bottom behalten (zukünftige Höhlen-Architektur) + * 7 – wie 6 + Gras-Texturpfad (UTF-String) + Gras-Standardhöhe (float) + * 8 – wie 7 + Gras-Höhen-Map (SPLAT_SIZE² Bytes, 0=ungesetzt 1-255=0.1-10m) + * 9 – wie 8 + Gras-Textur-Map (SPLAT_SIZE² Bytes) + Slots (N×UTF) */ public final class MapIO { @@ -50,7 +53,7 @@ public final class MapIO { } private static final int MAGIC = 0x424C4947; // "BLIG" - private static final int VERSION = 6; + private static final int VERSION = 9; private MapIO() {} @@ -104,6 +107,19 @@ public final class MapIO { out.write(data.upperSplatB); out.write(data.upperSplatA); writeStrings(out, data.upperTextures); + // v7: gras-textur + standardhöhe + out.writeUTF(data.grassTexturePath != null ? data.grassTexturePath : ""); + out.writeFloat(data.grassDefaultHeight); + // v8: gras-höhen-map + out.write(data.grassHeightMap); + // v9: gras-textur-slots + gras-textur-map + String[] slots = data.grassTextureSlots != null ? data.grassTextureSlots : new String[0]; + // trim trailing empty entries + int slotEnd = slots.length; + while (slotEnd > 0 && (slots[slotEnd-1] == null || slots[slotEnd-1].isEmpty())) slotEnd--; + out.writeInt(slotEnd); + for (int i = 0; i < slotEnd; i++) out.writeUTF(slots[i] != null ? slots[i] : ""); + out.write(data.grassTextureMap); } } @@ -150,6 +166,19 @@ public final class MapIO { // Ältere Maps: upperSplatR auf 255 initialisieren (Tex1 immer sichtbar) java.util.Arrays.fill(data.upperSplatR, (byte) 255); } + if (version >= 7) { + data.grassTexturePath = in.readUTF(); + data.grassDefaultHeight = in.readFloat(); + } + if (version >= 8) { + in.readFully(data.grassHeightMap); + } + if (version >= 9) { + int n = in.readInt(); + data.grassTextureSlots = new String[n]; + for (int i = 0; i < n; i++) data.grassTextureSlots[i] = in.readUTF(); + in.readFully(data.grassTextureMap); + } } return data; } 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 f4568f8..52a6101 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedModel.java +++ b/blight-common/src/main/java/de/blight/common/PlacedModel.java @@ -11,5 +11,7 @@ public record PlacedModel( /** Relativer Asset-Pfad zur exportierten j3o-Datei des Custom Meshes; "" wenn nicht verwendet. */ String meshFile, /** AnimationLibrary-Clip-Key (z. B. "walk/Run"); "" = keine Animation. */ - String animClip + String animClip, + boolean castShadow, + boolean receiveShadow ) {} 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 bd072ee..41c7eee 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedModelIO.java +++ b/blight-common/src/main/java/de/blight/common/PlacedModelIO.java @@ -8,10 +8,10 @@ import java.util.*; * Liest und schreibt platzierte Modelle als tab-separierte Textdatei * ({@code blight_objects.blo}) neben der Kartendatei. * - * Spalten (seit v2): - * modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile + * Spalten (seit v3): + * modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow * - * Alte Dateien mit 6 Spalten (v1) werden gelesen; fehlende Felder erhalten Standardwerte. + * Alte Dateien mit 6 Spalten (v1/v2) werden gelesen; fehlende Felder erhalten Standardwerte. */ public final class PlacedModelIO { @@ -25,18 +25,19 @@ 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"); + w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow"); w.newLine(); for (PlacedModel m : models) { w.write(String.format(Locale.ROOT, - "%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s%n", + "%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%n", m.modelPath(), m.x(), m.y(), m.z(), m.rotY(), m.scale(), m.rotX(), m.rotZ(), m.solid(), nvl(m.texturePath()), nvl(m.normalMapPath()), nvl(m.materialPath()), - nvl(m.meshFile()), nvl(m.animClip()))); + nvl(m.meshFile()), nvl(m.animClip()), + m.castShadow(), m.receiveShadow())); } } } @@ -63,11 +64,14 @@ public final class PlacedModelIO { String texPath = f.length > 9 ? f[9] : ""; String nmPath = f.length > 10 ? f[10] : ""; String matPath = f.length > 11 ? f[11] : ""; - String meshFile = f.length > 12 ? f[12] : ""; - String animClip = f.length > 13 ? f[13] : ""; + String meshFile = f.length > 12 ? f[12] : ""; + String animClip = f.length > 13 ? f[13] : ""; + boolean castShadow = f.length > 14 ? Boolean.parseBoolean(f[14]) : true; + boolean receiveShadow = f.length > 15 ? Boolean.parseBoolean(f[15]) : true; list.add(new PlacedModel(modelPath, x, y, z, rotY, rotX, rotZ, scale, solid, - texPath, nmPath, matPath, meshFile, animClip)); + texPath, nmPath, matPath, meshFile, animClip, + castShadow, receiveShadow)); } catch (NumberFormatException ignored) {} } return list; diff --git a/blight-common/src/main/java/de/blight/common/PlacedWater.java b/blight-common/src/main/java/de/blight/common/PlacedWater.java index e4f1f81..a82b36a 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedWater.java +++ b/blight-common/src/main/java/de/blight/common/PlacedWater.java @@ -1,7 +1,8 @@ package de.blight.common; -/** Unveränderliche Daten einer platzierten Wasseroberfläche (Teich, See). */ -public record PlacedWater( - float x, float y, float z, - float width, float depth -) {} +/** + * Platzierte Wasserfläche. + * Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet – + * gespeichert werden nur Saatpunkt und Wasserstand. + */ +public record PlacedWater(float seedX, float seedZ, float waterHeight) {} diff --git a/blight-common/src/main/java/de/blight/common/RiverIO.java b/blight-common/src/main/java/de/blight/common/RiverIO.java new file mode 100644 index 0000000..f232237 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/RiverIO.java @@ -0,0 +1,73 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +/** + * Liest und schreibt platzierte Flüsse als Textdatei + * ({@code blight_rivers.blr}) neben der Kartendatei. + * + * Format: ein Fluss pro Zeile; Punkte getrennt durch {@code |}; + * jeder Punkt: {@code x,y,z,width,uvSpeed} (5 Komma-getrennte Floats). + * Kommentarzeilen beginnen mit {@code #}. + */ +public final class RiverIO { + + private RiverIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_rivers.blr"); + } + + public static void save(List> rivers) throws IOException { + Path p = getPath(); + Files.createDirectories(p.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(p)) { + w.write("# blight_rivers.blr – Flussdaten"); + w.newLine(); + w.write("# Format: x,y,z,width,uvSpeed | x,y,z,width,uvSpeed | ..."); + w.newLine(); + for (List river : rivers) { + if (river == null || river.isEmpty()) continue; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < river.size(); i++) { + if (i > 0) sb.append('|'); + RiverPoint pt = river.get(i); + sb.append(String.format(Locale.ROOT, "%.5f,%.5f,%.5f,%.5f,%.5f", + pt.x(), pt.y(), pt.z(), pt.width(), pt.uvSpeed())); + } + w.write(sb.toString()); + w.newLine(); + } + } + } + + public static List> load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List> result = new ArrayList<>(); + for (String line : Files.readAllLines(p)) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] parts = line.split("\\|", -1); + List river = new ArrayList<>(); + for (String part : parts) { + part = part.strip(); + if (part.isEmpty()) continue; + String[] f = part.split(",", -1); + if (f.length < 5) continue; + try { + river.add(new RiverPoint( + Float.parseFloat(f[0]), + Float.parseFloat(f[1]), + Float.parseFloat(f[2]), + Float.parseFloat(f[3]), + Float.parseFloat(f[4]))); + } catch (NumberFormatException ignored) {} + } + if (!river.isEmpty()) result.add(river); + } + return result; + } +} diff --git a/blight-common/src/main/java/de/blight/common/RiverPoint.java b/blight-common/src/main/java/de/blight/common/RiverPoint.java new file mode 100644 index 0000000..c0eac5e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/RiverPoint.java @@ -0,0 +1,9 @@ +package de.blight.common; + +/** Kontrollpunkt eines Flusses in Weltkoordinaten. */ +public record RiverPoint(float x, float y, float z, float width, float uvSpeed) { + public static final float DEFAULT_WIDTH = 4.0f; + public static final float RIVER_SPEED = 0.4f; + public static final float WATERFALL_SPEED = 3.0f; + public boolean isWaterfall() { return uvSpeed >= 1.5f; } +} diff --git a/blight-common/src/main/java/de/blight/common/RiverSpline.java b/blight-common/src/main/java/de/blight/common/RiverSpline.java new file mode 100644 index 0000000..ce1c020 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/RiverSpline.java @@ -0,0 +1,71 @@ +package de.blight.common; + +import java.util.ArrayList; +import java.util.List; + +/** + * Catmull-Rom-Spline-Glättung für Flusskontrollpunkte. + * Die Originalpunkte bleiben als Kontrollpunkte erhalten; subdivide() + * gibt eine dichtere Punktliste nur für die Visualisierung zurück. + */ +public final class RiverSpline { + + /** Schritte pro Segment zwischen zwei Kontrollpunkten. */ + private static final int STEPS = 8; + + private RiverSpline() {} + + /** + * Gibt eine Catmull-Rom-interpolierte Kopie der Punktliste zurück. + * Weniger als 2 Punkte werden unverändert zurückgegeben. + */ + public static List subdivide(List pts) { + int n = pts.size(); + if (n < 2) return new ArrayList<>(pts); + + List result = new ArrayList<>(n * STEPS); + + for (int i = 0; i < n - 1; i++) { + // Ghost-Punkte an den Enden: Spiegelung des Nachbarn + RiverPoint p0 = (i > 0) ? pts.get(i - 1) : ghost(pts.get(i), pts.get(i + 1)); + RiverPoint p1 = pts.get(i); + RiverPoint p2 = pts.get(i + 1); + RiverPoint p3 = (i < n - 2) ? pts.get(i + 2) : ghost(pts.get(i + 1), pts.get(i)); + + for (int s = 0; s < STEPS; s++) { + result.add(eval(p0, p1, p2, p3, (float) s / STEPS)); + } + } + result.add(pts.get(n - 1)); // letzten Originalpunkt immer anhängen + return result; + } + + // ── Catmull-Rom-Formel ──────────────────────────────────────────────────── + + private static RiverPoint eval(RiverPoint p0, RiverPoint p1, + RiverPoint p2, RiverPoint p3, float t) { + float t2 = t * t, t3 = t2 * t; + float b0 = -t3 + 2*t2 - t; + float b1 = 3*t3 - 5*t2 + 2; + float b2 = -3*t3 + 4*t2 + t; + float b3 = t3 - t2; + + float x = 0.5f * (b0*p0.x() + b1*p1.x() + b2*p2.x() + b3*p3.x()); + float y = 0.5f * (b0*p0.y() + b1*p1.y() + b2*p2.y() + b3*p3.y()); + float z = 0.5f * (b0*p0.z() + b1*p1.z() + b2*p2.z() + b3*p3.z()); + float w = p1.width() + t * (p2.width() - p1.width()); + float s = p1.uvSpeed() + t * (p2.uvSpeed() - p1.uvSpeed()); + return new RiverPoint(x, y, z, w, s); + } + + /** Spiegelung von 'other' durch 'anchor' als Ghost-Kontrollpunkt. */ + private static RiverPoint ghost(RiverPoint anchor, RiverPoint other) { + return new RiverPoint( + 2*anchor.x() - other.x(), + 2*anchor.y() - other.y(), + 2*anchor.z() - other.z(), + anchor.width(), + anchor.uvSpeed() + ); + } +} diff --git a/blight-common/src/main/java/de/blight/common/WaterBodyIO.java b/blight-common/src/main/java/de/blight/common/WaterBodyIO.java index c4ddc3c..3fa7434 100644 --- a/blight-common/src/main/java/de/blight/common/WaterBodyIO.java +++ b/blight-common/src/main/java/de/blight/common/WaterBodyIO.java @@ -5,10 +5,11 @@ import java.nio.file.*; import java.util.*; /** - * Liest und schreibt platzierte Wasseroberflächen als tab-separierte Textdatei + * Liest und schreibt platzierte Wasserflächen als tab-separierte Textdatei * ({@code blight_water.blw}) neben der Kartendatei. * - * Spalten: x y z width depth + * Format: seedX seedZ waterHeight + * Die Form des Wasserkörpers wird per Flood-Fill zur Laufzeit rekonstruiert. */ public final class WaterBodyIO { @@ -22,12 +23,11 @@ public final class WaterBodyIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# x\ty\tz\twidth\tdepth"); + w.write("# seedX\tseedZ\twaterHeight"); w.newLine(); for (PlacedWater b : bodies) { - w.write(String.format(Locale.ROOT, - "%.5f\t%.5f\t%.5f\t%.5f\t%.5f%n", - b.x(), b.y(), b.z(), b.width(), b.depth())); + w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n", + b.seedX(), b.seedZ(), b.waterHeight())); } } } @@ -40,14 +40,12 @@ public final class WaterBodyIO { line = line.strip(); if (line.isEmpty() || line.startsWith("#")) continue; String[] f = line.split("\t", -1); - if (f.length < 5) continue; + if (f.length < 3) continue; try { list.add(new PlacedWater( Float.parseFloat(f[0]), Float.parseFloat(f[1]), - Float.parseFloat(f[2]), - Float.parseFloat(f[3]), - Float.parseFloat(f[4]))); + Float.parseFloat(f[2]))); } catch (NumberFormatException ignored) {} } return list; diff --git a/blight-common/src/main/java/de/blight/common/model/CharacterIO.java b/blight-common/src/main/java/de/blight/common/model/CharacterIO.java index a8642c3..2ba0464 100644 --- a/blight-common/src/main/java/de/blight/common/model/CharacterIO.java +++ b/blight-common/src/main/java/de/blight/common/model/CharacterIO.java @@ -21,11 +21,12 @@ import java.util.stream.Stream; * "characterId": "hero", * "name": "Der Held", * "modelPath": "Models/hero.j3o", - * "animSetPath": "animations/sets/hero.j3o", + * "animSetPath": "human", * ... (subclass fields via Gson) * } - * Die Aktions-Zuweisung (IDLE → Clip-Name usw.) ist im AnimSet gespeichert - * (animSetPath.replaceAll(".j3o", "") + ".animset.json"). + * animSetPath ist der Set-Name (ohne Pfad/Extension). + * Die Aktions-Zuweisung liegt in animations/sets/{animSetPath}.animset.json. + * Die Clip-Dateien liegen in animations/clips/{clipName}.j3o. */ public final class CharacterIO { diff --git a/blight-editor/build.gradle b/blight-editor/build.gradle index 9778131..b6f4b96 100644 --- a/blight-editor/build.gradle +++ b/blight-editor/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'org.slf4j:jul-to-slf4j:2.0.17' + runtimeOnly 'ch.qos.logback:logback-classic:1.5.18' compileOnly 'org.projectlombok:lombok:1.18.38' annotationProcessor 'org.projectlombok:lombok:1.18.38' } 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 2bd3914..15b10c0 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -2,6 +2,7 @@ package de.blight.editor; import de.blight.editor.tool.ChoiceToolParameter; import de.blight.editor.tool.EditorTool; +import de.blight.editor.tool.GrassTool; import de.blight.editor.tool.ToolParameter; import de.blight.editor.tree.PalmOptions; import de.blight.editor.tree.TreeParams; @@ -55,26 +56,27 @@ public class EditorApp extends Application { private boolean launchGameAfterSave = false; private VBox toolPanel; private BorderPane root; + private Stage gameConsoleStage; + private TextArea gameConsoleArea; + private java.nio.file.attribute.FileTime lastLivePosTime = + java.nio.file.attribute.FileTime.fromMillis(0); private VBox assetPanel; private StackPane worldViewport; private VBox topBar; // MenuBar + aktuelle Toolbar private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons) - private final TextField treeNameField = new TextField("Baum1"); private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt private Stage primaryStage; // Baum-Generator-Zustand (wird beim Preset-Wechsel neu gesetzt) - private TreeParams treeParams = TreeParams.oak(); + private TreeParams treeParams = TreeParams.oak(); + private String currentTreePreset = "Eiche"; // EZ-Tree-Zustand private TreeOptions ezTreeOptions = TreePresets.oakMedium(); private String ezTreePresetName = "Oak Medium"; - private final TextField ezTreeNameField = new TextField("EzBaum1"); - private final TextField treeCategoryField = new TextField("oak/medium"); // Palmen-Generator-Zustand private PalmOptions palmOptions = new PalmOptions(); - private final TextField palmNameField = new TextField("Palme1"); // Aktives Tool + Tastenkürzel-Callbacks private String currentTool = "world"; @@ -90,7 +92,9 @@ public class EditorApp extends Application { // Objekt-Werkzeug-Zustand private Label objModelLabel; // zeigt den ausgewählten Modell-Pfad private Label objPosLabel; // zeigt Position/Rotation - private CheckBox objSolidCB; // Solid-Flag des selektierten Objekts + private CheckBox objSolidCB; // Solid-Flag des selektierten Objekts + private CheckBox objCastShadowCB; // Schatten werfen + private CheckBox objReceiveShadowCB; // Schatten empfangen private CheckBox multiPlaceCB; // Mehrfach-Platzierungs-Modus private TextField objXField, objYField, objZField; private TextField objRotXField, objRotYField, objRotZField; @@ -133,7 +137,8 @@ public class EditorApp extends Application { private VBox emitterDynamicContent; // Wasser-Werkzeug-Zustand - private VBox waterDynamicContent; + private VBox waterDynamicContent; + private javafx.scene.control.Label waterHintLabel; // Sound-Bereich-Werkzeug-Zustand private VBox soundAreaDynamicContent; @@ -168,6 +173,7 @@ public class EditorApp extends Application { private ToggleButton lightBtn; private ToggleButton emitterBtn; private ToggleButton waterBtn; + private ToggleButton riverBtn; private ToggleButton soundAreaBtn; private ToggleButton musicAreaBtn; private ToggleButton playToolBtn; @@ -191,18 +197,48 @@ public class EditorApp extends Application { private javafx.animation.Timeline editTimer; // Asset-Tree-Items (müssen beim Refresh-Signal erreichbar sein) + private TreeItem assetTreeRoot; private TreeItem modelsNode; private TreeItem texturesNode; private TreeItem audioNode; private TreeItem jmeModelsNode; + private TreeItem jmeTexturesNode; private TreeItem animationsNode; // ── JavaFX Entry-Point ─────────────────────────────────────────────────── public static void main(String[] args) { launch(args); } + private Stage buildSplash(Label statusLabel) { + Stage splash = new Stage(javafx.stage.StageStyle.UNDECORATED); + try (var is = getClass().getResourceAsStream("/icon_editor.png")) { + if (is != null) splash.getIcons().add(new Image(is)); + } catch (Exception ignored) {} + try (var is = getClass().getResourceAsStream("/logo.png")) { + if (is != null) { + ImageView iv = new ImageView(new Image(is)); + iv.setPreserveRatio(true); + iv.setFitWidth(700); + statusLabel.setStyle( + "-fx-text-fill: #bbbbbb; -fx-font-size: 13px; -fx-padding: 8 16 12 16;"); + statusLabel.setMaxWidth(Double.MAX_VALUE); + statusLabel.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + VBox box = new VBox(iv, statusLabel); + box.setStyle("-fx-background-color: #1c1c1c;"); + splash.setScene(new Scene(box)); + } + } catch (Exception ignored) {} + splash.setAlwaysOnTop(true); + splash.centerOnScreen(); + return splash; + } + @Override public void start(Stage stage) { + Label splashStatus = new Label("Initialisiere..."); + Stage splash = buildSplash(splashStatus); + splash.show(); + jfxImage = new WritableImage(INITIAL_VP_W, INITIAL_VP_H); JmeEditorApp.launch(input, jfxImage, INITIAL_VP_W, INITIAL_VP_H); @@ -235,12 +271,31 @@ public class EditorApp extends Application { primaryStage = stage; input.scanSkeletalRequested = true; // Skelett-Scan beim Start anstoßen stage.setTitle("Blight World Editor"); + try (var is = getClass().getResourceAsStream("/icon_editor.png")) { + if (is != null) stage.getIcons().add(new Image(is)); + } catch (Exception ignored) {} stage.setScene(scene); stage.setMinWidth(900); stage.setMinHeight(600); stage.setOnCloseRequest(e -> { saveCameraPrefs(); Platform.exit(); }); stage.setMaximized(true); - stage.show(); + + javafx.animation.Timeline[] pollerRef = new javafx.animation.Timeline[1]; + pollerRef[0] = new javafx.animation.Timeline( + new javafx.animation.KeyFrame(javafx.util.Duration.millis(100), ev -> { + splashStatus.setText(input.loadingStatus); + if (input.jmeReady) { + pollerRef[0].stop(); + stage.show(); + javafx.animation.PauseTransition closeDelay = + new javafx.animation.PauseTransition(javafx.util.Duration.millis(400)); + closeDelay.setOnFinished(e -> splash.close()); + closeDelay.play(); + } + }) + ); + pollerRef[0].setCycleCount(javafx.animation.Animation.INDEFINITE); + pollerRef[0].play(); javafx.animation.Timeline statusPoller = new javafx.animation.Timeline( new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> { @@ -293,6 +348,12 @@ public class EditorApp extends Application { if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animOp); } + String embedStatus = input.animEmbedStatus; + if (embedStatus != null) { + input.animEmbedStatus = null; + if (charEditorStatusLabel != null) charEditorStatusLabel.setText(embedStatus); + } + String rts = input.randomTreeStatus; if (randomTreeStatusLabel != null && rts != null) { randomTreeStatusLabel.setText(rts); @@ -300,10 +361,9 @@ public class EditorApp extends Application { if (input.refreshAssets) { input.refreshAssets = false; - refreshCategoryNode(modelsNode, - ".j3o", ".obj", ".fbx", ".gltf", ".glb"); + refreshCategoryNode(modelsNode); modelsNode.setExpanded(true); - refreshCategoryNode(animationsNode, ".j3o", ".gltf", ".glb"); + refreshCategoryNode(animationsNode); refreshAddAnimCombo(addAnimComboField); } @@ -341,6 +401,27 @@ public class EditorApp extends Application { updateWaterPanel(input.selectedWaterInfo); } + if (input.riverSelectionChanged) { + input.riverSelectionChanged = false; + updateRiverPanel(input.selectedRiverInfo); + } + + // Live-Spielerposition aus laufendem Spiel + pollLivePlayerPosition(); + + String wHint = input.waterHint; + if (wHint != null) { + input.waterHint = null; + if (waterHintLabel != null) { + waterHintLabel.setText(wHint); + waterHintLabel.setVisible(true); + javafx.animation.PauseTransition hide = + new javafx.animation.PauseTransition(javafx.util.Duration.seconds(4)); + hide.setOnFinished(e -> waterHintLabel.setVisible(false)); + hide.play(); + } + } + if (input.soundAreaSelectionChanged) { input.soundAreaSelectionChanged = false; updateSoundAreaPanel(input.selectedSoundAreaInfo); @@ -501,6 +582,7 @@ public class EditorApp extends Application { lightBtn = new ToggleButton("💡 Licht"); emitterBtn = new ToggleButton("🔥 Emitter"); waterBtn = new ToggleButton("💧 Wasser"); + riverBtn = new ToggleButton("〰 Fluss"); soundAreaBtn = new ToggleButton("🔊 Sound"); musicAreaBtn = new ToggleButton("🎵 Musik"); playToolBtn = new ToggleButton("🎮 Spielen"); @@ -512,6 +594,7 @@ public class EditorApp extends Application { lightBtn.setStyle("-fx-font-weight:bold;"); emitterBtn.setStyle("-fx-font-weight:bold;"); waterBtn.setStyle("-fx-font-weight:bold;"); + riverBtn.setStyle("-fx-font-weight:bold;"); soundAreaBtn.setStyle("-fx-font-weight:bold;"); musicAreaBtn.setStyle("-fx-font-weight:bold;"); playToolBtn.setStyle("-fx-font-weight:bold;"); @@ -525,6 +608,7 @@ public class EditorApp extends Application { lightBtn.setToggleGroup(layerGroup); emitterBtn.setToggleGroup(layerGroup); waterBtn.setToggleGroup(layerGroup); + riverBtn.setToggleGroup(layerGroup); soundAreaBtn.setToggleGroup(layerGroup); musicAreaBtn.setToggleGroup(layerGroup); playToolBtn.setToggleGroup(layerGroup); @@ -566,6 +650,10 @@ public class EditorApp extends Application { input.activeLayer = SharedInput.LAYER_WATER; root.setRight(buildWaterPanel()); }); + riverBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_RIVERS; + root.setRight(buildRiverPanel()); + }); soundAreaBtn.setOnAction(e -> { input.activeLayer = SharedInput.LAYER_SOUND_AREAS; root.setRight(buildSoundAreaPanel()); @@ -587,6 +675,7 @@ public class EditorApp extends Application { new Separator(Orientation.VERTICAL), lightBtn, new Separator(Orientation.VERTICAL), emitterBtn, new Separator(Orientation.VERTICAL), waterBtn, + new Separator(Orientation.VERTICAL), riverBtn, new Separator(Orientation.VERTICAL), soundAreaBtn, musicAreaBtn, new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), hint); @@ -604,17 +693,14 @@ public class EditorApp extends Application { presetBox.getItems().addAll("Eiche", "Birke", "Kiefer", "Weide", "Busch"); presetBox.setValue("Eiche"); presetBox.setOnAction(e -> { - treeParams = presetFromName(presetBox.getValue()); + currentTreePreset = presetBox.getValue(); + treeParams = presetFromName(currentTreePreset); root.setRight(buildTreeParamsPanel()); }); - Label nameLabel = new Label("Export-Name:"); - nameLabel.setStyle("-fx-font-weight: bold;"); - treeNameField.setPrefWidth(130); - onF5 = () -> { - input.treeGenQueue.offer( - new SharedInput.TreeGenRequest(treeParams.copy(), false, treeNameField.getText().trim())); + input.treeGenQueue.offer(new SharedInput.TreeGenRequest( + treeParams.copy(), false, treeTypeFromPreset(currentTreePreset))); setStatus("Generiere Vorschau…"); }; Button previewBtn = new Button("▶ Vorschau [F5]"); @@ -623,8 +709,8 @@ public class EditorApp extends Application { Button exportBtn = new Button("💾 Exportieren als .j3o"); exportBtn.setOnAction(e -> { - input.treeGenQueue.offer( - new SharedInput.TreeGenRequest(treeParams.copy(), true, treeNameField.getText().trim())); + input.treeGenQueue.offer(new SharedInput.TreeGenRequest( + treeParams.copy(), true, treeTypeFromPreset(currentTreePreset))); setStatus("Generiere und exportiere…"); }); @@ -632,7 +718,6 @@ public class EditorApp extends Application { bar.getItems().addAll( presetLabel, presetBox, new Separator(Orientation.VERTICAL), - nameLabel, treeNameField, previewBtn, exportBtn ); return bar; @@ -816,20 +901,9 @@ public class EditorApp extends Application { root.setRight(buildEzTreeParamsPanel()); }); - Label nameLabel = new Label("Export-Name:"); - nameLabel.setStyle("-fx-font-weight: bold;"); - ezTreeNameField.setPrefWidth(130); - - Label categoryLabel = new Label("Kategorie:"); - categoryLabel.setStyle("-fx-font-weight: bold;"); - treeCategoryField.setPrefWidth(120); - treeCategoryField.setPromptText("z.B. oak/small"); - onF5 = () -> { - input.ezTreeGenQueue.offer( - new SharedInput.EzTreeGenRequest( - ezTreeOptions.copy(), ezTreePresetName, false, ezTreeNameField.getText().trim(), - treeCategoryField.getText().trim())); + input.ezTreeGenQueue.offer(new SharedInput.EzTreeGenRequest( + ezTreeOptions.copy(), ezTreePresetName, false)); setStatus("EZ-Tree: generiere Vorschau…"); }; Button previewBtn = new Button("▶ Vorschau [F5]"); @@ -838,10 +912,8 @@ public class EditorApp extends Application { Button exportBtn = new Button("💾 Export .j3o"); exportBtn.setOnAction(e -> { - input.ezTreeGenQueue.offer( - new SharedInput.EzTreeGenRequest( - ezTreeOptions.copy(), ezTreePresetName, true, ezTreeNameField.getText().trim(), - treeCategoryField.getText().trim())); + input.ezTreeGenQueue.offer(new SharedInput.EzTreeGenRequest( + ezTreeOptions.copy(), ezTreePresetName, true)); setStatus("EZ-Tree: generiere und exportiere…"); }); @@ -849,9 +921,6 @@ public class EditorApp extends Application { bar.getItems().addAll( presetLabel, presetBox, new Separator(Orientation.VERTICAL), - nameLabel, ezTreeNameField, - new Separator(Orientation.VERTICAL), - categoryLabel, treeCategoryField, previewBtn, exportBtn ); return bar; @@ -1003,14 +1072,8 @@ public class EditorApp extends Application { // ── Palmen-Generator – Toolbar ─────────────────────────────────────────── private ToolBar buildPalmToolBar() { - Label nameLabel = new Label("Export-Name:"); - nameLabel.setStyle("-fx-font-weight: bold;"); - palmNameField.setPrefWidth(130); - onF5 = () -> { - input.palmGenQueue.offer( - new SharedInput.PalmGenRequest( - palmOptions.copy(), false, palmNameField.getText().trim())); + input.palmGenQueue.offer(new SharedInput.PalmGenRequest(palmOptions.copy(), false)); setStatus("Palme: generiere Vorschau…"); }; Button previewBtn = new Button("▶ Vorschau [F5]"); @@ -1019,17 +1082,12 @@ public class EditorApp extends Application { Button exportBtn = new Button("💾 Export .j3o"); exportBtn.setOnAction(e -> { - input.palmGenQueue.offer( - new SharedInput.PalmGenRequest( - palmOptions.copy(), true, palmNameField.getText().trim())); + input.palmGenQueue.offer(new SharedInput.PalmGenRequest(palmOptions.copy(), true)); setStatus("Palme: generiere und exportiere…"); }); ToolBar bar = new ToolBar(); - bar.getItems().addAll( - nameLabel, palmNameField, - previewBtn, exportBtn - ); + bar.getItems().addAll(previewBtn, exportBtn); return bar; } @@ -1252,6 +1310,16 @@ public class EditorApp extends Application { }; } + private static String treeTypeFromPreset(String preset) { + return switch (preset) { + case "Birke" -> "birch"; + case "Kiefer" -> "pine"; + case "Weide" -> "willow"; + case "Busch" -> "bush"; + default -> "oak"; + }; + } + // ── Objekt-Werkzeug – Platzieren-Panel ────────────────────────────────── private VBox buildObjectPlacePanel() { @@ -1685,9 +1753,15 @@ public class EditorApp extends Application { waterDynamicContent.getChildren().add(noSel); inner.getChildren().add(waterDynamicContent); + waterHintLabel = new javafx.scene.control.Label(); + waterHintLabel.setStyle( + "-fx-text-fill: #c0392b; -fx-font-size: 11; -fx-wrap-text: true;"); + waterHintLabel.setMaxWidth(230); + waterHintLabel.setVisible(false); inner.getChildren().addAll( new Separator(), - styledHint("Linksklick → Fläche platzieren / auswählen"), + waterHintLabel, + styledHint("Linksklick → Becken markieren / auswählen"), styledHint("Rechtsklick → Auswahl aufheben"), styledHint("Entf → Fläche löschen")); @@ -1703,6 +1777,112 @@ public class EditorApp extends Application { return panel; } + private VBox buildRiverPanel() { + VBox inner = new VBox(10); + inner.setPadding(new Insets(10)); + + // Width slider + Label widthTitle = sectionTitle("Flussbreite"); + Label widthVal = new Label(String.format("%.1f m", input.riverNewWidth)); + widthVal.setStyle("-fx-font-size: 11;"); + Slider widthSlider = new Slider(1.0, 20.0, input.riverNewWidth); + widthSlider.setBlockIncrement(0.5); + widthSlider.setShowTickLabels(true); + widthSlider.setMajorTickUnit(5); + widthSlider.valueProperty().addListener((o, ov, nv) -> { + input.riverNewWidth = nv.floatValue(); + widthVal.setText(String.format("%.1f m", input.riverNewWidth)); + }); + + // Waterfall toggle + Label typeTitle = sectionTitle("Punkt-Typ"); + ToggleGroup typeGroup = new ToggleGroup(); + RadioButton rbRiver = new RadioButton("Fluss (normal)"); + RadioButton rbWaterfall = new RadioButton("Wasserfall (schnell)"); + rbRiver.setToggleGroup(typeGroup); + rbWaterfall.setToggleGroup(typeGroup); + rbRiver.setSelected(true); + typeGroup.selectedToggleProperty().addListener((o, ov, nv) -> { + if (nv == rbWaterfall) input.riverNewSpeed = de.blight.common.RiverPoint.WATERFALL_SPEED; + else input.riverNewSpeed = de.blight.common.RiverPoint.RIVER_SPEED; + }); + + // Undo button + Button undoBtn = new Button("Letzten Punkt entfernen"); + undoBtn.setMaxWidth(Double.MAX_VALUE); + undoBtn.setOnAction(e -> input.undoRiverPointRequested = true); + + inner.getChildren().addAll( + sectionTitle("Flüsse"), + new Separator(), + widthTitle, widthSlider, widthVal, + new Separator(), + typeTitle, rbRiver, rbWaterfall, + new Separator(), + undoBtn, + new Separator(), + styledHint("L-Klick → Punkt setzen"), + styledHint("R-Klick → Fluss abschließen"), + styledHint("Backspace → letzten Punkt löschen")); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(260); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateRiverPanel(String info) { + if (input.activeLayer != SharedInput.LAYER_RIVERS) return; + root.setRight(info != null ? buildRiverSelectionPanel(info) : buildRiverPanel()); + } + + private VBox buildRiverSelectionPanel(String info) { + // info = "idx|numPoints|totalLengthM" + String[] parts = info.split("\\|"); + int numPts = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + String length = parts.length > 2 ? parts[2] + " m" : "?"; + + VBox inner = new VBox(10); + inner.setPadding(new Insets(10)); + + Label title = sectionTitle("Fluss selektiert"); + + Label infoLabel = new Label("Punkte: " + numPts + " Länge: ~" + length); + infoLabel.setStyle("-fx-font-size: 11;"); + + Button deleteBtn = new Button("Fluss löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #c0392b; -fx-text-fill: white;"); + deleteBtn.setOnAction(e -> input.deleteRiverRequested = true); + + inner.getChildren().addAll( + title, + new Separator(), + infoLabel, + new Separator(), + deleteBtn, + new Separator(), + styledHint("L-Klick ins Leere → abwählen"), + styledHint("L-Klick → anderen Fluss wählen")); + + ScrollPane scroll = new ScrollPane(inner); + scroll.setFitToWidth(true); + scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;"); + + VBox panel = new VBox(scroll); + VBox.setVgrow(scroll, Priority.ALWAYS); + panel.setPrefWidth(260); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + private void updateWaterPanel(String info) { if (waterDynamicContent == null) return; waterDynamicContent.getChildren().clear(); @@ -1714,51 +1894,41 @@ public class EditorApp extends Application { return; } - // Format: "idx|x|y|z|width|depth" + // Format: "idx|seedX|seedZ|waterHeight|cellCount" String[] p = info.split("\\|", -1); - if (p.length < 6) return; + if (p.length < 5) return; try { - float[] cur = { - Float.parseFloat(p[1]), // x - Float.parseFloat(p[2]), // y - Float.parseFloat(p[3]), // z - Float.parseFloat(p[4]), // width - Float.parseFloat(p[5]) // depth - }; + float seedX = Float.parseFloat(p[1]); + float seedZ = Float.parseFloat(p[2]); + float waterHeight = Float.parseFloat(p[3]); + int cellCount = Integer.parseInt(p[4]); - Label posLabel = new Label(String.format("X:%.1f Y:%.1f Z:%.1f", cur[0], cur[1], cur[2])); - posLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); - waterDynamicContent.getChildren().addAll(posLabel, new Separator()); + Label infoLabel = new Label(String.format( + "Seed %.1f / %.1f | %d Pixel", seedX, seedZ, cellCount)); + infoLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + waterDynamicContent.getChildren().addAll(infoLabel, new Separator()); - Runnable publish = () -> input.pendingWater.set( - new de.blight.common.PlacedWater(cur[0], cur[1], cur[2], cur[3], cur[4])); + float[] curH = {waterHeight}; + Label heightLabel = new Label(String.format("Höhe: %.2f", waterHeight)); + heightLabel.setStyle("-fx-font-size: 11;"); - String[] labels = {"X", "Y (Höhe)", "Z", "Breite", "Tiefe"}; - double[] mins = {-2048, -10, -2048, 5, 5}; - double[] maxs = { 2048, 500, 2048, 500, 500}; - double[] steps = { 1, 0.5, 1, 5, 5}; + Slider heightSlider = new Slider(-50, 500, waterHeight); + heightSlider.setBlockIncrement(0.5); + heightSlider.setShowTickLabels(false); + heightSlider.valueProperty().addListener((o, ov, nv) -> { + curH[0] = nv.floatValue(); + heightLabel.setText(String.format("Höhe: %.2f", curH[0])); + input.pendingWater.set( + new de.blight.common.PlacedWater(seedX, seedZ, curH[0])); + }); - for (int i = 0; i < 5; i++) { - final int vi = i; - Label lbl = new Label(labels[i] + ": " + String.format("%.1f", cur[i])); - lbl.setStyle("-fx-font-size: 11;"); - Slider sl = new Slider(mins[i], maxs[i], cur[i]); - sl.setShowTickLabels(false); - sl.setBlockIncrement(steps[i]); - sl.valueProperty().addListener((o, ov, nv) -> { - cur[vi] = nv.floatValue(); - lbl.setText(labels[vi] + ": " + String.format("%.1f", nv.floatValue())); - publish.run(); - }); - if (i == 2) waterDynamicContent.getChildren().add(new Separator()); - waterDynamicContent.getChildren().addAll(lbl, sl); - } - - Button delBtn = new Button("🗑 Löschen"); + Button delBtn = new Button("Löschen"); delBtn.setMaxWidth(Double.MAX_VALUE); delBtn.setStyle("-fx-text-fill: #c0392b;"); delBtn.setOnAction(e -> input.deleteWaterRequested = true); - waterDynamicContent.getChildren().addAll(new Separator(), delBtn); + + waterDynamicContent.getChildren().addAll( + heightLabel, heightSlider, new Separator(), delBtn); } catch (NumberFormatException ignored) {} } @@ -2005,6 +2175,8 @@ public class EditorApp extends Application { objXField = objYField = objZField = null; objRotXField = objRotYField = objRotZField = null; objSolidCB = null; + objCastShadowCB = null; + objReceiveShadowCB = null; objNormalMapLabel = null; objMatLabel = null; @@ -2041,7 +2213,9 @@ public class EditorApp extends Application { String texPath = parts[10]; String normalPath = parts.length >= 12 ? parts[11] : ""; String matPath = parts.length >= 13 ? parts[12] : ""; - String animClip = parts.length >= 14 ? parts[13] : ""; + String animClip = parts.length >= 14 ? parts[13] : ""; + boolean castShadow = parts.length >= 15 ? Boolean.parseBoolean(parts[14]) : true; + boolean receiveShadow= parts.length >= 16 ? Boolean.parseBoolean(parts[15]) : true; // Modell-Pfad String modelName = modelPath.contains("/") @@ -2129,11 +2303,19 @@ public class EditorApp extends Application { objRotZField = makeFloatField(rotZDeg); hookApply(objRotXField); hookApply(objRotYField); hookApply(objRotZField); - // Solid + // Solid / Schatten objSolidCB = new CheckBox("Solid (Charakter-Kollision)"); objSolidCB.setSelected(solid); objSolidCB.setOnAction(ev -> enqueuePropertyChange(null, null, null)); + objCastShadowCB = new CheckBox("Schatten werfen"); + objCastShadowCB.setSelected(castShadow); + objCastShadowCB.setOnAction(ev -> enqueuePropertyChange(null, null, null)); + + objReceiveShadowCB = new CheckBox("Schatten empfangen"); + objReceiveShadowCB.setSelected(receiveShadow); + objReceiveShadowCB.setOnAction(ev -> enqueuePropertyChange(null, null, null)); + // Animation ComboBox animCombo = new ComboBox<>(); animCombo.setMaxWidth(Double.MAX_VALUE); @@ -2163,7 +2345,7 @@ public class EditorApp extends Application { labeledRow("X:", objXField, "Y:", objYField, "Z:", objZField), new Separator(), bold("Rotation (°)"), labeledRow("X:", objRotXField, "Y:", objRotYField, "Z:", objRotZField), new Separator(), - objSolidCB, new Separator(), + objSolidCB, objCastShadowCB, objReceiveShadowCB, new Separator(), bold("Animation:"), animCombo); } else { @@ -2224,9 +2406,12 @@ public class EditorApp extends Application { float rotX = (float) Math.toRadians(parseFloatField(objRotXField.getText())); float rotY = (float) Math.toRadians(parseFloatField(objRotYField.getText())); float rotZ = (float) Math.toRadians(parseFloatField(objRotZField.getText())); - boolean solid = objSolidCB != null && objSolidCB.isSelected(); + boolean solid = objSolidCB != null && objSolidCB.isSelected(); + boolean castShadow = objCastShadowCB == null || objCastShadowCB.isSelected(); + boolean receiveShadow = objReceiveShadowCB == null || objReceiveShadowCB.isSelected(); input.objectPropertyQueue.offer( - new SharedInput.ObjectPropertyChange(x, y, z, rotX, rotY, rotZ, solid, + new SharedInput.ObjectPropertyChange(x, y, z, rotX, rotY, rotZ, + solid, castShadow, receiveShadow, texOverride, normalOverride, matOverride)); } catch (NumberFormatException ignored) {} } @@ -2342,6 +2527,117 @@ public class EditorApp extends Application { new javafx.scene.control.Separator(), buildTextureSlotsUI(true)); } + + // Textur-Picker (nur beim GrassTool) + if (tool instanceof GrassTool) { + panel.getChildren().addAll(new Separator(), buildGrassTextureUI()); + } + } + + // ── Gras-Textur-Picker (Multi-Slot) ────────────────────────────────────── + + private javafx.scene.Node buildGrassTextureUI() { + Label title = new Label("Textur-Slots"); + title.setStyle("-fx-text-fill: #111111; -fx-font-weight: bold;"); + + ToggleGroup slotGroup = new ToggleGroup(); + VBox slotRows = new VBox(4); + + for (int slotIndex = 0; slotIndex < 8; slotIndex++) { + final int slot = slotIndex; + + RadioButton rb = new RadioButton(); + rb.setToggleGroup(slotGroup); + if (slot == 0) rb.setSelected(true); + rb.setOnAction(e -> input.grassActiveSlot = slot); + + String prefix = slot + " ▸ "; + String currentPath = getGrassSlotPath(slot); + String fname = (currentPath != null && !currentPath.isEmpty()) + ? java.nio.file.Paths.get(currentPath).getFileName().toString() + : "(Leer)"; + + Label nameLabel = new Label(prefix + fname); + nameLabel.setStyle("-fx-font-size: 11;"); + nameLabel.setWrapText(true); + nameLabel.setMaxWidth(Double.MAX_VALUE); + + ImageView thumb = makeSlotThumb(currentPath); + + Button chooseBtn = new Button("..."); + chooseBtn.setOnAction(e -> { + TextureChooser chooser = new TextureChooser(ASSET_ROOT, false); + chooser.showAndWait().ifPresent(path -> { + if (slot == 0) { + input.grassTexturePath = path; + input.grassSettingsChanged = true; + } else { + String[] copy = input.grassTextureSlots.clone(); + copy[slot - 1] = path; + input.grassTextureSlots = copy; + input.grassSlotsChanged = true; + } + String fn = java.nio.file.Paths.get(path).getFileName().toString(); + nameLabel.setText(slot + " ▸ " + fn); + ImageView newThumb = makeSlotThumb(path); + thumb.setImage(newThumb.getImage()); + }); + }); + + Button clearBtn = new Button("X"); + clearBtn.setOnAction(e -> { + if (slot == 0) { + input.grassTexturePath = ""; + input.grassSettingsChanged = true; + } else { + String[] copy = input.grassTextureSlots.clone(); + copy[slot - 1] = ""; + input.grassTextureSlots = copy; + input.grassSlotsChanged = true; + } + nameLabel.setText(slot + " ▸ (Leer)"); + thumb.setImage(null); + }); + + HBox btns = new HBox(4, chooseBtn, clearBtn); + VBox info = new VBox(2, nameLabel, btns); + info.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(info, javafx.scene.layout.Priority.ALWAYS); + + HBox row = new HBox(6, rb, thumb, info); + row.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + row.setPadding(new Insets(2, 4, 2, 4)); + row.setStyle("-fx-background-color: #f8f8f8; -fx-border-color: #ddd; -fx-border-radius: 3;"); + slotRows.getChildren().add(row); + } + + ScrollPane scroll = new ScrollPane(slotRows); + scroll.setFitToWidth(true); + scroll.setMaxHeight(300); + scroll.setStyle("-fx-background-color: transparent;"); + + VBox box = new VBox(5, title, scroll); + box.setPadding(new Insets(4, 0, 0, 0)); + return box; + } + + private String getGrassSlotPath(int slot) { + if (slot == 0) return input.grassTexturePath != null ? input.grassTexturePath : ""; + String[] slots = input.grassTextureSlots; + return (slot - 1 < slots.length && slots[slot - 1] != null) ? slots[slot - 1] : ""; + } + + private ImageView makeSlotThumb(String path) { + ImageView iv = new ImageView(); + iv.setFitWidth(48); iv.setFitHeight(48); iv.setPreserveRatio(true); + if (path != null && !path.isEmpty()) { + java.io.File f = ASSET_ROOT.resolve(path).toFile(); + if (f.exists()) { + try { iv.setImage(new Image(f.toURI().toString(), 48, 48, true, true)); } + catch (Exception ignored) {} + } + } + return iv; } // ── Textur-Slot-Konfigurator ────────────────────────────────────────────── @@ -2559,41 +2855,11 @@ public class EditorApp extends Application { Label title = new Label("Assets"); title.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); - TreeItem assetRoot = new TreeItem<>("Projekt"); - assetRoot.setExpanded(true); - modelsNode = new TreeItem<>("Models"); - texturesNode = new TreeItem<>("Texturen"); - audioNode = new TreeItem<>("Audio"); - animationsNode = new TreeItem<>("Animationen"); - assetRoot.getChildren().addAll(modelsNode, texturesNode, audioNode, animationsNode); + assetTreeRoot = new TreeItem<>("Projekt"); + assetTreeRoot.setExpanded(true); + populateAssetTree(assetTreeRoot); - itemPaths.put(modelsNode, ASSET_ROOT.resolve("Models")); - itemPaths.put(texturesNode, ASSET_ROOT.resolve("Textures")); - itemPaths.put(audioNode, ASSET_ROOT.resolve("audio")); - itemPaths.put(animationsNode, ASSET_ROOT.resolve("animations")); - - loadAssetsRecursive(modelsNode, ASSET_ROOT.resolve("Models"), - ".j3o", ".obj", ".fbx", ".gltf", ".glb"); - jmeModelsNode = new TreeItem<>("JME"); - jmeFolderNodes.add(jmeModelsNode); - modelsNode.getChildren().add(jmeModelsNode); - loadJmeModelsInto(jmeModelsNode); - - loadAssetsRecursive(texturesNode, ASSET_ROOT.resolve("Textures"), - ".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds"); - - TreeItem jmeNode = new TreeItem<>("JME"); - jmeFolderNodes.add(jmeNode); - texturesNode.getChildren().add(jmeNode); - loadJmeTexturesInto(jmeNode); - - loadAssetsRecursive(audioNode, ASSET_ROOT.resolve("audio"), - ".ogg", ".wav", ".mp3"); - - loadAssetsRecursive(animationsNode, ASSET_ROOT.resolve("animations"), - ".j3o", ".fbx", ".gltf", ".glb"); - - TreeView tree = new TreeView<>(assetRoot); + TreeView tree = new TreeView<>(assetTreeRoot); tree.setShowRoot(false); VBox.setVgrow(tree, Priority.ALWAYS); tree.setCellFactory(tv -> buildAssetCell()); @@ -2654,6 +2920,16 @@ public class EditorApp extends Application { switchToAnimPreview(); input.animPreviewLoadPath = relPath; if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…"); + } else if (relPath.endsWith(".animset.json")) { + switchToAnimPreview(); + } else if (relPath.endsWith(".character")) { + String charName = p.getFileName().toString().replace(".character", ""); + Path charDir = ASSET_ROOT.resolve("character"); + switchToCharacterEditor(); + if (charListView != null) { + charListView.getSelectionModel().select(charName); + loadSelectedCharacter(charDir); + } } }); @@ -2669,7 +2945,15 @@ public class EditorApp extends Application { } }); - panel.getChildren().addAll(title, tree, importBtn); + Button refreshBtn = new Button("↻"); + refreshBtn.setTooltip(new javafx.scene.control.Tooltip("Asset-Baum aktualisieren")); + refreshBtn.setOnAction(e -> refreshAllAssets()); + + HBox bottomBar = new HBox(4, importBtn, refreshBtn); + HBox.setHgrow(importBtn, Priority.ALWAYS); + bottomBar.setMaxWidth(Double.MAX_VALUE); + + panel.getChildren().addAll(title, tree, bottomBar); return panel; } @@ -3094,7 +3378,7 @@ public class EditorApp extends Application { cur.getChildren().add(file); } - /** Lädt Assets rekursiv: Ordner zuerst (alphabetisch), dann Dateien. */ + /** Lädt Assets rekursiv: Ordner zuerst (alphabetisch), dann Dateien. Keine exts = alles. */ private void loadAssetsRecursive(TreeItem parent, Path dir, String... exts) { if (!Files.exists(dir)) return; try (var stream = Files.list(dir)) { @@ -3110,28 +3394,83 @@ public class EditorApp extends Application { loadAssetsRecursive(sub, p, exts); } else { String lo = p.getFileName().toString().toLowerCase(); - for (String ext : exts) { - if (lo.endsWith(ext)) { - TreeItem file = new TreeItem<>(p.getFileName().toString()); - itemPaths.put(file, p); - parent.getChildren().add(file); - break; - } + boolean include = exts.length == 0 + || java.util.Arrays.stream(exts).anyMatch(lo::endsWith); + if (include) { + TreeItem file = new TreeItem<>(p.getFileName().toString()); + itemPaths.put(file, p); + parent.getChildren().add(file); } } } } catch (IOException ignored) {} } - /** Baut den Teilbaum einer Kategorie komplett neu auf (nach Export/Import). */ - private void refreshCategoryNode(TreeItem catNode, String... exts) { + /** + * Befüllt den Asset-Baum vollständig aus dem Dateisystem. + * Bekannte Verzeichnisse werden den Kategorie-Feldern zugewiesen; + * alle anderen erscheinen als generische Knoten. + */ + private void populateAssetTree(TreeItem root) { + modelsNode = null; texturesNode = null; + audioNode = null; animationsNode = null; + jmeModelsNode = null; jmeTexturesNode = null; + jmeFolderNodes.clear(); + + List topDirs; + try (var s = Files.list(ASSET_ROOT)) { + topDirs = s.filter(Files::isDirectory) + .sorted(Comparator.comparing(p -> p.getFileName().toString().toLowerCase())) + .collect(java.util.stream.Collectors.toList()); + } catch (IOException e) { topDirs = List.of(); } + + for (Path dir : topDirs) { + String name = dir.getFileName().toString(); + TreeItem node = new TreeItem<>(name); + itemPaths.put(node, dir); + root.getChildren().add(node); + loadAssetsRecursive(node, dir); + + switch (name.toLowerCase()) { + case "models" -> { + modelsNode = node; + jmeModelsNode = new TreeItem<>("JME"); + jmeFolderNodes.add(jmeModelsNode); + node.getChildren().add(jmeModelsNode); + loadJmeModelsInto(jmeModelsNode); + } + case "textures" -> { + texturesNode = node; + jmeTexturesNode = new TreeItem<>("JME"); + jmeFolderNodes.add(jmeTexturesNode); + node.getChildren().add(jmeTexturesNode); + loadJmeTexturesInto(jmeTexturesNode); + } + case "audio" -> audioNode = node; + case "animations" -> animationsNode = node; + } + } + } + + /** Baut den Teilbaum einer Kategorie komplett neu auf. */ + private void refreshCategoryNode(TreeItem catNode) { clearItemPathsFor(catNode); catNode.getChildren().clear(); Path dir = itemPaths.get(catNode); - if (dir != null) loadAssetsRecursive(catNode, dir, exts); - // JME-Unterknoten sind nicht dateisystembasiert – nach Clear wieder anhängen + if (dir != null) loadAssetsRecursive(catNode, dir); if (catNode == modelsNode && jmeModelsNode != null) catNode.getChildren().add(jmeModelsNode); + if (catNode == texturesNode && jmeTexturesNode != null) + catNode.getChildren().add(jmeTexturesNode); + } + + /** Baut den gesamten Asset-Baum neu aus dem Dateisystem auf. */ + private void refreshAllAssets() { + if (assetTreeRoot == null) return; + itemPaths.clear(); + jmePaths.clear(); + assetTreeRoot.getChildren().clear(); + populateAssetTree(assetTreeRoot); } private void clearItemPathsFor(TreeItem item) { @@ -3341,6 +3680,17 @@ public class EditorApp extends Application { return; } + if (input.activeLayer == SharedInput.LAYER_RIVERS) { + if (e.getButton() == MouseButton.PRIMARY) { + input.riverClickQueue.offer( + new SharedInput.RiverClick((float)e.getX(), (float)e.getY(), false)); + } else if (e.getButton() == MouseButton.SECONDARY) { + input.riverClickQueue.offer( + new SharedInput.RiverClick((float)e.getX(), (float)e.getY(), true)); + } + return; + } + if (bothDown) { stopEditTimer(); prevDragX = e.getX(); prevDragY = e.getY(); @@ -3506,17 +3856,94 @@ public class EditorApp extends Application { cmd.addAll(List.of("-cp", classpath, "de.blight.game.BlightGame")); - new ProcessBuilder(cmd) + Process proc = new ProcessBuilder(cmd) .directory(ProjectRoot.PATH.toFile()) - .inheritIO() + .redirectErrorStream(true) .start(); - Platform.runLater(() -> setStatus("Spiel gestartet")); + + Platform.runLater(() -> { + setStatus("Spiel gestartet"); + openGameConsole(); + }); + + // Stdout des Spiels in Konsolen-Fenster streamen + try (java.io.BufferedReader br = new java.io.BufferedReader( + new java.io.InputStreamReader(proc.getInputStream(), + java.nio.charset.StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + final String l = line; + Platform.runLater(() -> appendToConsole(l)); + } + } + // Spiel beendet → Live-Position löschen + input.livePlayerX = Float.NaN; + Platform.runLater(() -> appendToConsole("--- Spiel beendet ---")); + } catch (IOException ex) { Platform.runLater(() -> setStatus("Spielstart fehlgeschlagen: " + ex.getMessage())); } }, "game-launcher").start(); } + private void openGameConsole() { + if (gameConsoleStage == null) { + gameConsoleArea = new TextArea(); + gameConsoleArea.setEditable(false); + gameConsoleArea.setStyle( + "-fx-control-inner-background: #1e1e1e; -fx-text-fill: #d4d4d4;" + + " -fx-font-family: monospace; -fx-font-size: 11;"); + + Button clearBtn = new Button("Löschen"); + clearBtn.setOnAction(e -> gameConsoleArea.clear()); + + HBox toolbar = new HBox(5, clearBtn); + toolbar.setPadding(new Insets(4)); + + VBox layout = new VBox(toolbar, gameConsoleArea); + VBox.setVgrow(gameConsoleArea, javafx.scene.layout.Priority.ALWAYS); + + Scene scene = new Scene(layout, 800, 350); + gameConsoleStage = new Stage(); + gameConsoleStage.setTitle("Spiel-Konsole"); + gameConsoleStage.setScene(scene); + gameConsoleStage.initOwner(primaryStage); + gameConsoleStage.setX(primaryStage.getX()); + gameConsoleStage.setY(primaryStage.getY() + primaryStage.getHeight()); + } else { + gameConsoleArea.clear(); + } + gameConsoleStage.show(); + gameConsoleStage.toFront(); + } + + private void appendToConsole(String line) { + if (gameConsoleArea == null) return; + gameConsoleArea.appendText(line + "\n"); + gameConsoleArea.setScrollTop(Double.MAX_VALUE); + } + + private void pollLivePlayerPosition() { + Path posFile = de.blight.common.MapIO.getMapPath().resolveSibling("blight_live.pos"); + try { + if (Files.exists(posFile)) { + java.nio.file.attribute.FileTime mt = Files.getLastModifiedTime(posFile); + if (mt.compareTo(lastLivePosTime) > 0) { + lastLivePosTime = mt; + String[] parts = Files.readString(posFile).strip().split("\\|"); + if (parts.length == 3) { + input.livePlayerX = Float.parseFloat(parts[0]); + input.livePlayerY = Float.parseFloat(parts[1]); + input.livePlayerZ = Float.parseFloat(parts[2]); + } + } + } else if (!Float.isNaN(input.livePlayerX)) { + input.livePlayerX = Float.NaN; + lastLivePosTime = java.nio.file.attribute.FileTime.fromMillis(0); + } + } catch (Exception ignored) {} + } + private void saveCameraPrefs() { java.util.Properties p = new java.util.Properties(); p.setProperty("cam.x", String.valueOf(input.camX)); @@ -3583,6 +4010,10 @@ public class EditorApp extends Application { input.deleteMusicAreaRequested = true; } } + case BACK_SPACE -> { + if (pressed && input.activeLayer == SharedInput.LAYER_RIVERS) + input.undoRiverPointRequested = true; + } case F1 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> baseBtn.fire()); } case F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); } case F5 -> { @@ -4266,14 +4697,7 @@ public class EditorApp extends Application { HBox btnRow = new HBox(6, playBtn, stopBtn); HBox.setHgrow(playBtn, Priority.ALWAYS); HBox.setHgrow(stopBtn, Priority.ALWAYS); - Button removeClipBtn = new Button("Clip entfernen"); - removeClipBtn.setMaxWidth(Double.MAX_VALUE); - removeClipBtn.setStyle("-fx-text-fill: #cc0000;"); - removeClipBtn.setOnAction(e -> { - String clip = animClipListView.getSelectionModel().getSelectedItem(); - if (clip != null) input.animPreviewRemoveClip = clip; - }); - inner.getChildren().addAll(animClipListView, btnRow, removeClipBtn); + inner.getChildren().addAll(animClipListView, btnRow); Button renameClipBtn = new Button("Clip umbenennen / exportieren…"); renameClipBtn.setMaxWidth(Double.MAX_VALUE); @@ -4314,14 +4738,14 @@ public class EditorApp extends Application { }); inner.getChildren().addAll(loopCB, speedLbl, speedSlider); - // ── Animation hinzufügen ────────────────────────────────────────────── - inner.getChildren().addAll(new Separator(), sectionTitle("Animation hinzufügen"), new Separator()); + // ── Clip importieren ────────────────────────────────────────────────── + inner.getChildren().addAll(new Separator(), sectionTitle("Clip importieren"), new Separator()); addAnimComboField = new ComboBox<>(); ComboBox addAnimCombo = addAnimComboField; - // Direkt-Import-Button: immer ins animations/-Verzeichnis, nur GLB/GLTF - Label animHint = new Label("Mixamo: Download mit \"With Skin\" wählen"); + // Beim Hinzufügen wird der Clip direkt in animations/clips/ gespeichert + Label animHint = new Label("Clip wird retargeted und direkt in animations/clips/ gespeichert. Mixamo: \"With Skin\" wählen."); animHint.setWrapText(true); animHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); Button importAnimBtn = new Button("Animation importieren (GLB/GLTF)…"); @@ -4410,7 +4834,7 @@ public class EditorApp extends Application { } } // Sofort im JavaFX-Thread aktualisieren – keine Konvertierung nötig - refreshCategoryNode(animationsNode, ".j3o", ".gltf", ".glb"); + refreshCategoryNode(animationsNode); refreshAddAnimCombo(addAnimComboField); } @@ -4507,74 +4931,116 @@ public class EditorApp extends Application { Button allBtn = new Button("Alle wählen"); allBtn.setOnAction(ev -> clipSel.getSelectionModel().selectAll()); - // Aktions-Zuweisung: ComboBox pro AnimationAction - javafx.scene.layout.GridPane actionGrid = new javafx.scene.layout.GridPane(); - actionGrid.setHgap(8); - actionGrid.setVgap(4); java.util.List allClips = animClipListView.getItems(); - java.util.EnumMap> dlgActionCombos = - new java.util.EnumMap<>(de.blight.game.animation.AnimationAction.class); - // Vorhandenes .animset.json laden, um bestehende Zuweisungen voreinzustellen - de.blight.game.animation.AnimSet existingSet = null; - if (animCurrentModelPath != null) { - String setName = java.nio.file.Paths.get(animCurrentModelPath) - .getFileName().toString().replaceFirst("\\.j3o$", ""); - Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + + // Aktions-Zuweisung: Liste mit Add/Remove + javafx.scene.control.ListView actionList = new javafx.scene.control.ListView<>(); + actionList.setPrefHeight(130); + + // Lädt bestehende Zuweisungen für den aktuell eingetippten Set-Namen + Runnable reloadActionMap = () -> { + String sn = nameField.getText().trim(); + if (sn.isBlank()) return; + actionList.getItems().clear(); try { - existingSet = de.blight.game.animation.AnimSet.load(setDir, setName); + de.blight.game.animation.AnimSet existing = de.blight.game.animation.AnimSet.load(setDir, sn); + if (existing != null) { + for (var entry : existing.getActionMap().entrySet()) { + if (allClips.contains(entry.getValue())) + actionList.getItems().add(entry.getKey() + " → " + entry.getValue()); + } + } } catch (Exception ignored) {} - } - final de.blight.game.animation.AnimSet preload = existingSet; + }; + reloadActionMap.run(); + nameField.textProperty().addListener((obs, ov, nv) -> reloadActionMap.run()); - int row = 0; - for (de.blight.game.animation.AnimationAction action - : de.blight.game.animation.AnimationAction.values()) { - ComboBox cb = new ComboBox<>(); - cb.setMaxWidth(Double.MAX_VALUE); - cb.setPromptText("— nicht belegt —"); - cb.getItems().add(""); - cb.getItems().addAll(allClips); - if (preload != null) { - String prev = preload.getActionMap().get(action.name()); - if (prev != null && allClips.contains(prev)) cb.setValue(prev); - else cb.setValue(""); - } else { - cb.setValue(""); - } - dlgActionCombos.put(action, cb); - Label lbl = new Label(action.displayName() + ":"); - lbl.setMinWidth(50); - actionGrid.add(lbl, 0, row); - actionGrid.add(cb, 1, row); - javafx.scene.layout.ColumnConstraints cc = new javafx.scene.layout.ColumnConstraints(); - cc.setHgrow(Priority.ALWAYS); - row++; - } - actionGrid.getColumnConstraints().addAll( - new javafx.scene.layout.ColumnConstraints(), - new javafx.scene.layout.ColumnConstraints() {{ setHgrow(Priority.ALWAYS); }}); - - // Clips-Selektion synchronisiert die ComboBox-Optionen - clipSel.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> { - java.util.List sel = new java.util.ArrayList<>(clipSel.getSelectionModel().getSelectedItems()); - for (ComboBox cb : dlgActionCombos.values()) { - String prev = cb.getValue(); - cb.getItems().setAll(""); - cb.getItems().addAll(sel.isEmpty() ? allClips : sel); - cb.setValue(prev != null && cb.getItems().contains(prev) ? prev : ""); - } + Button removeActionBtn = new Button("Entfernen"); + removeActionBtn.setDisable(true); + actionList.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> + removeActionBtn.setDisable(nv == null)); + removeActionBtn.setOnAction(ev -> { + String sel = actionList.getSelectionModel().getSelectedItem(); + if (sel != null) actionList.getItems().remove(sel); }); + Button addActionBtn = new Button("Hinzufügen"); + addActionBtn.setOnAction(ev -> { + java.util.Set usedActions = new java.util.HashSet<>(); + for (String item : actionList.getItems()) + usedActions.add(item.split(" → ")[0]); + java.util.List available = + java.util.Arrays.stream(de.blight.game.animation.AnimationAction.values()) + .filter(a -> !usedActions.contains(a.name())) + .collect(java.util.stream.Collectors.toList()); + if (available.isEmpty()) return; + + javafx.scene.control.Dialog addDlg = + new javafx.scene.control.Dialog<>(); + addDlg.setTitle("Aktion zuweisen"); + javafx.scene.control.ButtonType addOk = new javafx.scene.control.ButtonType("Hinzufügen", + javafx.scene.control.ButtonBar.ButtonData.OK_DONE); + addDlg.getDialogPane().getButtonTypes().addAll(addOk, javafx.scene.control.ButtonType.CANCEL); + + ComboBox actionCombo = new ComboBox<>(); + actionCombo.getItems().addAll(available); + javafx.util.Callback, + javafx.scene.control.ListCell> cellFactory = + 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()); + } + }; + actionCombo.setCellFactory(cellFactory); + actionCombo.setButtonCell(cellFactory.call(null)); + actionCombo.setMaxWidth(Double.MAX_VALUE); + actionCombo.getSelectionModel().selectFirst(); + + java.util.List selClips = new java.util.ArrayList<>( + clipSel.getSelectionModel().getSelectedItems()); + if (selClips.isEmpty()) selClips = allClips; + ComboBox clipCombo = new ComboBox<>(); + clipCombo.getItems().addAll(selClips); + clipCombo.setMaxWidth(Double.MAX_VALUE); + clipCombo.getSelectionModel().selectFirst(); + + javafx.scene.layout.GridPane addGrid = new javafx.scene.layout.GridPane(); + addGrid.setHgap(8); + addGrid.setVgap(6); + addGrid.setPadding(new Insets(10)); + addGrid.add(new Label("Aktion:"), 0, 0); + addGrid.add(actionCombo, 1, 0); + addGrid.add(new Label("Animation:"), 0, 1); + addGrid.add(clipCombo, 1, 1); + addGrid.getColumnConstraints().addAll( + new javafx.scene.layout.ColumnConstraints(), + new javafx.scene.layout.ColumnConstraints() {{ setHgrow(Priority.ALWAYS); }}); + addDlg.getDialogPane().setContent(addGrid); + addDlg.getDialogPane().setPrefWidth(320); + + addDlg.showAndWait().ifPresent(bt -> { + if (bt != addOk) return; + de.blight.game.animation.AnimationAction selAction = actionCombo.getValue(); + String selClip = clipCombo.getValue(); + if (selAction == null || selClip == null || selClip.isBlank()) return; + actionList.getItems().add(selAction.name() + " → " + selClip); + }); + }); + + HBox actionBtns = new HBox(4, addActionBtn, removeActionBtn); + VBox content = new VBox(6, new Label("Set-Name:"), nameField, new Label("Clips:"), clipSel, allBtn, new Separator(), sectionTitle("Aktions-Zuweisung"), - actionGrid); + actionList, actionBtns); content.setPadding(new Insets(10)); dlg.getDialogPane().setContent(content); - dlg.getDialogPane().setPrefWidth(380); + dlg.getDialogPane().setPrefWidth(400); dlg.showAndWait().ifPresent(bt -> { if (bt != ok) return; @@ -4584,9 +5050,9 @@ public class EditorApp extends Application { clipSel.getSelectionModel().getSelectedItems()); if (selected.isEmpty()) return; java.util.Map actionMap = new java.util.LinkedHashMap<>(); - for (var entry : dlgActionCombos.entrySet()) { - String clip = entry.getValue().getValue(); - if (clip != null && !clip.isBlank()) actionMap.put(entry.getKey().name(), clip); + for (String item : actionList.getItems()) { + String[] parts = item.split(" → ", 2); + if (parts.length == 2) actionMap.put(parts[0], parts[1]); } input.animSetSaveRequest.set(new SharedInput.AnimSetSaveRequest(selected, name, actionMap)); }); @@ -4662,12 +5128,33 @@ public class EditorApp extends Application { refreshCharAnimSetCombo(); charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet()); + Button embedAnimBtn = new Button("Animationen einbetten"); + embedAnimBtn.setMaxWidth(Double.MAX_VALUE); + embedAnimBtn.setDisable(true); + // Aktivieren wenn Modell UND Set gewählt sind + javafx.beans.value.ChangeListener embedEnableListener = (obs, ov, nv) -> { + boolean ready = charModelCombo.getValue() != null && !charModelCombo.getValue().isBlank() + && charAnimSetCombo.getValue() != null && !charAnimSetCombo.getValue().isBlank(); + embedAnimBtn.setDisable(!ready); + }; + charModelCombo.valueProperty().addListener(embedEnableListener); + charAnimSetCombo.valueProperty().addListener(embedEnableListener); + embedAnimBtn.setOnAction(e -> { + String modelPath = charModelCombo.getValue(); + String setName = charAnimSetCombo.getValue(); + if (modelPath == null || setName == null) return; + if (charEditorStatusLabel != null) + charEditorStatusLabel.setText("Bette Animationen ein…"); + input.animEmbedRequest.set(new SharedInput.AnimEmbedRequest(modelPath, setName)); + }); + inner.getChildren().addAll( new Label("ID:"), charIdField, new Label("Name:"), charNameField, new Label("Typ:"), charTypeCombo, new Label("Modell:"), charModelCombo, - new Label("Anim-Set:"), charAnimSetCombo + new Label("Anim-Set:"), charAnimSetCombo, + embedAnimBtn ); // ── Aktions-Zuweisung (Schreibgeschützte Anzeige aus .animset.json) ── @@ -4721,8 +5208,8 @@ public class EditorApp extends Application { Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); if (java.nio.file.Files.isDirectory(setDir)) { try (var walk = java.nio.file.Files.walk(setDir)) { - walk.filter(p -> p.toString().endsWith(".j3o")) - .map(p -> ASSET_ROOT.relativize(p).toString().replace('\\', '/')) + walk.filter(p -> p.toString().endsWith(".animset.json")) + .map(p -> p.getFileName().toString().replace(".animset.json", "")) .sorted().forEach(charAnimSetCombo.getItems()::add); } catch (IOException ignored) {} } @@ -4732,15 +5219,20 @@ public class EditorApp extends Application { private void updateCharActionCombosFromSet() { if (charActionLabelsBox == null) return; charActionLabelsBox.getChildren().clear(); - String setPath = charAnimSetCombo != null ? charAnimSetCombo.getValue() : null; - if (setPath == null || setPath.isBlank()) { + String setName = charAnimSetCombo != null ? charAnimSetCombo.getValue() : null; + if (setName == null || setName.isBlank()) { Label hint = new Label("(kein Animations-Set gewählt)"); hint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); charActionLabelsBox.getChildren().add(hint); return; } - de.blight.game.animation.AnimSet animSet = - de.blight.game.animation.AnimSet.loadByJ3oPath(ASSET_ROOT, setPath); + Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + de.blight.game.animation.AnimSet animSet; + try { + animSet = de.blight.game.animation.AnimSet.load(setDir, setName); + } catch (IOException e) { + animSet = new de.blight.game.animation.AnimSet(); + } java.util.Map actionMap = animSet.getActionMap(); if (actionMap == null || actionMap.isEmpty()) { Label hint = new Label("(keine Aktions-Zuweisung im Set)"); diff --git a/blight-editor/src/main/java/de/blight/editor/FrameTransfer.java b/blight-editor/src/main/java/de/blight/editor/FrameTransfer.java index 6ab0312..618c53c 100644 --- a/blight-editor/src/main/java/de/blight/editor/FrameTransfer.java +++ b/blight-editor/src/main/java/de/blight/editor/FrameTransfer.java @@ -32,6 +32,9 @@ public class FrameTransfer implements SceneProcessor { private int[] argbBuf; // gesamtes Bild für einmaligen bulk-Write private final AtomicBoolean jfxBusy = new AtomicBoolean(false); + private volatile Runnable onFirstFrame; + + public void setOnFirstFrame(Runnable cb) { this.onFirstFrame = cb; } public FrameTransfer(WritableImage image) { this.pw = image.getPixelWriter(); @@ -77,6 +80,8 @@ public class FrameTransfer implements SceneProcessor { } } pw.setPixels(0, 0, width, height, fmt, argbBuf, 0, width); + Runnable cb = onFirstFrame; + if (cb != null) { onFirstFrame = null; cb.run(); } } finally { jfxBusy.set(false); } 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 129fb14..14e59f0 100644 --- a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java @@ -10,6 +10,7 @@ import com.jme3.texture.Texture2D; import de.blight.editor.state.AnimPreviewState; import de.blight.editor.state.EmitterState; import de.blight.editor.state.MusicAreaState; +import de.blight.editor.state.RiverEditorState; import de.blight.editor.state.PlayToolState; import de.blight.editor.state.SoundAreaState; import de.blight.editor.state.WaterBodyState; @@ -68,8 +69,7 @@ public class JmeEditorApp extends SimpleApplication { public void simpleInitApp() { flyCam.setEnabled(false); - // Explizit registrieren, falls General.cfg die Klassen beim ersten Start - // noch nicht gefunden hat (jme3-plugins war zuvor nicht auf dem Classpath). + input.loadingStatus = "Registriere Asset-Loader..."; assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf"); assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb"); @@ -78,11 +78,12 @@ public class JmeEditorApp extends SimpleApplication { assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class); } - + input.loadingStatus = "Initialisiere Renderer..."; currentW = vpWidth; currentH = vpHeight; buildFrameBuffer(vpWidth, vpHeight, initialImage); + input.loadingStatus = "Lade Editor-States..."; stateManager.attach(new SceneObjectState(input)); stateManager.attach(new TerrainEditorState(input)); stateManager.attach(new TreeGeneratorState(input)); @@ -93,10 +94,11 @@ public class JmeEditorApp extends SimpleApplication { stateManager.attach(new WaterBodyState(input)); stateManager.attach(new SoundAreaState(input)); stateManager.attach(new MusicAreaState(input)); + stateManager.attach(new RiverEditorState(input)); stateManager.attach(new PlayToolState(input)); stateManager.attach(new AnimPreviewState(input)); - // JME-Konsole (Editor-Modus: kein RawInputListener – Eingabe via SharedInput) + input.loadingStatus = "Initialisiere Konsole..."; jmeConsole = new JmeConsole(false); registerEditorCommands(); jmeConsole.setOnVisibilityChanged(open -> { @@ -158,6 +160,7 @@ public class JmeEditorApp extends SimpleApplication { viewPort.setOutputFrameBuffer(fb); guiViewPort.setOutputFrameBuffer(fb); frameTransfer = new FrameTransfer(image); + frameTransfer.setOnFirstFrame(() -> { input.loadingStatus = "Bereit"; input.jmeReady = true; }); guiViewPort.addProcessor(frameTransfer); } 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 3ab5e78..d44f288 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -25,6 +25,10 @@ public class SharedInput { public final HoleTool holeTool = new HoleTool(); public volatile EditorTool activeTool = heightTool; + // ── Initialisierungs-Status ─────────────────────────────────────────────── + public volatile boolean jmeReady = false; + public volatile String loadingStatus = "Initialisiere..."; + // ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ───────────────────── public volatile int activeLayer = 0; @@ -63,6 +67,18 @@ public class SharedInput { public record GrassEdit(float screenX, float screenY, int action) {} public final ConcurrentLinkedQueue grassEditQueue = new ConcurrentLinkedQueue<>(); + // ── Gras-Einstellungen (JavaFX → JME3) ─────────────────────────────────── + /** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */ + public volatile String grassTexturePath = ""; + /** JFX setzt true wenn Textur geändert; JME liest + resettet. */ + public volatile boolean grassSettingsChanged = false; + /** Texturpfade für Gras-Slots 1..7 (Index 0 = Slot 1). Neues Array zuweisen bei Änderung. */ + public volatile String[] grassTextureSlots = new String[]{"", "", "", "", "", "", ""}; + /** Aktiver Maler-Slot (0 = grassTexturePath/Slot0, 1–7 = grassTextureSlots[slot-1]). */ + public volatile int grassActiveSlot = 0; + /** JFX setzt true wenn Slot-Texturen geändert wurden. */ + public volatile boolean grassSlotsChanged = false; + // ── Textur-Edits ───────────────────────────────────────────────────────── /** action +1 = selektierte Textur malen, -1 = auf Gras zurücksetzen. */ public record TextureEdit(float screenX, float screenY, int action) {} @@ -123,15 +139,15 @@ public class SharedInput { public volatile boolean treePreviewResized = false; // ── Baum-Generator ─────────────────────────────────────────────────────── - public record TreeGenRequest(TreeParams params, boolean exportAfter, String exportName) {} + public record TreeGenRequest(TreeParams params, boolean exportAfter, String treeType) {} public final ConcurrentLinkedQueue treeGenQueue = new ConcurrentLinkedQueue<>(); // ── EZ-Tree-Generator ───────────────────────────────────────────────────── - public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter, String exportName, String treeCategory) {} + public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter) {} public final ConcurrentLinkedQueue ezTreeGenQueue = new ConcurrentLinkedQueue<>(); // ── Palmen-Generator ────────────────────────────────────────────────────── - public record PalmGenRequest(PalmOptions options, boolean exportAfter, String exportName) {} + public record PalmGenRequest(PalmOptions options, boolean exportAfter) {} public final ConcurrentLinkedQueue palmGenQueue = new ConcurrentLinkedQueue<>(); // ── Objekt-Werkzeug ────────────────────────────────────────────────────── @@ -201,6 +217,8 @@ public class SharedInput { float x, float y, float z, float rotX, float rotY, float rotZ, boolean solid, + boolean castShadow, + boolean receiveShadow, String texPath, // null = nicht ändern String normalMapPath, // null = nicht ändern String matPath // null = nicht ändern @@ -325,18 +343,38 @@ public class SharedInput { /** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */ public static final int LAYER_PLAY_TOOL = 12; + /** activeLayer==13 → Flüsse platzieren */ + public static final int LAYER_RIVERS = 13; + + // ── Fluss-Werkzeug ───────────────────────────────────────────────────────── + public record RiverClick(float screenX, float screenY, boolean rightButton) {} + public final ConcurrentLinkedQueue riverClickQueue = new ConcurrentLinkedQueue<>(); + public volatile float riverNewWidth = 4.0f; // Breite des nächsten Punktes + public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall) + public volatile boolean undoRiverPointRequested = false; + public volatile String riverHint = null; + + /** JME → JavaFX: Info des selektierten Flusses. Format: "idx|numPoints|totalLengthM" oder null. */ + public volatile String selectedRiverInfo = null; + public volatile boolean riverSelectionChanged = false; + /** JavaFX → JME: Selektierten Fluss löschen. */ + public volatile boolean deleteRiverRequested = false; + /** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */ public record WaterClick(float screenX, float screenY, boolean rightButton) {} public final ConcurrentLinkedQueue waterClickQueue = new ConcurrentLinkedQueue<>(); /** * JME → JavaFX: Info der selektierten Wasseroberfläche. - * Format: "idx|x|y|z|width|depth" oder null. + * Format: "idx|seedX|seedZ|waterHeight|cellCount" oder null. */ public volatile String selectedWaterInfo = null; public volatile boolean waterSelectionChanged = false; - /** JavaFX → JME: aktualisierte Parameter der selektierten Wasseroberfläche. */ + /** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */ + public volatile String waterHint = null; + + /** JavaFX → JME: aktualisierte Wasserhöhe der selektierten Fläche. */ public final AtomicReference pendingWater = new AtomicReference<>(); /** JavaFX → JME: Selektierte Wasseroberfläche löschen. */ @@ -389,6 +427,11 @@ public class SharedInput { public volatile float tempSpawnX = Float.NaN; public volatile float tempSpawnZ = Float.NaN; + /** Live-Spielerposition aus dem laufenden Spiel (NaN = kein Spiel aktiv). */ + public volatile float livePlayerX = Float.NaN; + public volatile float livePlayerY = Float.NaN; + public volatile float livePlayerZ = Float.NaN; + // ── Animations-Vorschau ────────────────────────────────────────────────── public volatile float animPreviewRotY = 0f; public volatile float animPreviewRotX = 25f; @@ -437,8 +480,15 @@ public class SharedInput { public final java.util.concurrent.atomic.AtomicReference animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>(); + // ── Animationen in Charakter-Modell einbetten ───────────────────────────── + public record AnimEmbedRequest(String characterModelPath, String setName) {} + public final java.util.concurrent.atomic.AtomicReference + animEmbedRequest = new java.util.concurrent.atomic.AtomicReference<>(); + /** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */ - public volatile String animOpStatus = null; + public volatile String animOpStatus = null; + /** JME3 → JavaFX: Status-Meldung für Einbett-Operationen (Character Editor). */ + public volatile String animEmbedStatus = null; // ── Modell-Konvertierung ────────────────────────────────────────────────── /** 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 a51662f..08f355e 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 @@ -12,7 +12,9 @@ public class SceneObject extends PlacedObject { private float rotX; // X-Achsen-Rotation in Radiant private float rotZ; // Z-Achsen-Rotation in Radiant private float scale; - public boolean solid; // Charakter-Kollision + public boolean solid; // Charakter-Kollision + public boolean castShadow = true; + public boolean receiveShadow = true; public String modelPath; // relativ zu blight-assets/src/main/resources/ public String texturePath = ""; public String normalMapPath = ""; 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 c1367a0..e9b4a37 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 @@ -173,6 +173,9 @@ public class AnimPreviewState extends BaseAppState { SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null); if (setReq != null) executeAnimSetSave(setReq); + SharedInput.AnimEmbedRequest embedReq = input.animEmbedRequest.getAndSet(null); + if (embedReq != null) executeAnimEmbed(embedReq); + // Geschwindigkeit live anpassen if (currentAction != null) { try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {} @@ -389,7 +392,7 @@ public class AnimPreviewState extends BaseAppState { return false; } - // ── Animation hinzufügen (Retargeting) ─────────────────────────────────── + // ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ─────────── private void addAnimation(String animAssetPath) { if (currentModel == null) { @@ -422,7 +425,6 @@ public class AnimPreviewState extends BaseAppState { com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null; com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null; - // Diagnose: Knochen-Namen beider Skelette ausgeben if (srcArm != null) { System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):"); for (var j : srcArm.getJointList()) System.err.println(" src: " + j.getName()); @@ -442,15 +444,15 @@ public class AnimPreviewState extends BaseAppState { System.err.println("[Retarget] Mapping (" + mapping.size() + " Treffer): " + mapping); } - // Blender-Duplikate herausfiltern: Clips deren Name mit ".NNN" endet und deren - // Basis-Name (ohne Suffix) ebenfalls in der Quelle vorkommt, werden übersprungen. java.util.Set srcNames = new java.util.HashSet<>(); for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName()); - int added = 0; + java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips"); + java.nio.file.Files.createDirectories(clipsDir); + + int saved = 0; for (AnimClip clip : sourceAC.getAnimClips()) { String name = clip.getName(); - // Prüfen ob Name dem Muster "base.NNN" entspricht (Blender-Duplikat) if (name.matches(".*\\.\\d{3}$")) { String base = name.substring(0, name.length() - 4); if (srcNames.contains(base)) { @@ -461,20 +463,20 @@ public class AnimPreviewState extends BaseAppState { AnimClip result = retarget ? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm) : clip; - if (result != null) { - targetAC.addAnimClip(result); - added++; - } + if (result == null) continue; + + // 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); + saved++; } - // Clip-Liste neu aufbauen List clips = new ArrayList<>(); collectClips(currentModel, clips); input.animPreviewClips.set(Collections.unmodifiableList(clips)); - input.animPreviewStatus = added + " Clip(s) hinzugefügt" - + (retarget ? " (retargeted)" : " (direkt, kein Retargeting)"); - - // Modell mit neuem Clip persistieren, damit der Clip nach Editor-Neustart noch da ist - if (added > 0) saveModel(); + input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert" + + (retarget ? " (retargeted)" : " (direkt)"); } catch (Exception e) { input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage(); } @@ -503,7 +505,6 @@ public class AnimPreviewState extends BaseAppState { collectClips(currentModel, clips); input.animPreviewClips.set(Collections.unmodifiableList(clips)); input.animPreviewStatus = "Clip entfernt: " + clipName; - saveModel(); } private T findControl(Spatial s, Class type) { @@ -755,19 +756,14 @@ public class AnimPreviewState extends BaseAppState { AnimClip renamed = new AnimClip(req.newName()); renamed.setTracks(src.getTracks()); ac.addAnimClip(renamed); - saveModel(); + // kein saveModel() – Quell-Modell bleibt unverändert - // Als eigenständige .j3o nach animations/ exportieren + // Als eigenständige .j3o nach animations/clips/ exportieren try { - com.jme3.scene.Node holder = new com.jme3.scene.Node("animExport"); - AnimComposer expAC = new AnimComposer(); - expAC.addAnimClip(renamed); - holder.addControl(expAC); - java.nio.file.Path outDir = ASSET_ROOT.resolve("animations"); - java.nio.file.Files.createDirectories(outDir); - com.jme3.export.binary.BinaryExporter.getInstance() - .save(holder, outDir.resolve(req.newName() + ".j3o").toFile()); - // Clip-Liste aktualisieren + SkinningControl sc = findControl(currentModel, SkinningControl.class); + java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips"); + saveClipToFile(renamed, sc != null ? sc.getArmature() : null, + clipsDir.resolve(req.newName() + ".j3o")); java.util.List clips = new java.util.ArrayList<>(); collectClips(currentModel, clips); input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips)); @@ -780,37 +776,106 @@ public class AnimPreviewState extends BaseAppState { // ── Animations-Set speichern ────────────────────────────────────────────── private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) { - if (currentModel == null) { input.animOpStatus = "Fehler: kein Modell geladen"; return; } - AnimComposer ac = findControl(currentModel, AnimComposer.class); - if (ac == null) { input.animOpStatus = "Fehler: kein AnimComposer"; return; } - try { - com.jme3.scene.Node holder = new com.jme3.scene.Node("animSet"); - AnimComposer setAC = new AnimComposer(); - int added = 0; - for (String clipName : req.clips()) { - AnimClip clip = ac.getAnimClip(clipName); - if (clip != null) { setAC.addAnimClip(clip); added++; } - } - holder.addControl(setAC); - java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); java.nio.file.Files.createDirectories(setDir); - java.nio.file.Path j3oPath = setDir.resolve(req.setName() + ".j3o"); - com.jme3.export.binary.BinaryExporter.getInstance().save(holder, j3oPath.toFile()); - - // Begleitende .animset.json mit Clip-Namen und Aktions-Zuweisung de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet(); animSet.setClips(req.clips()); animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>()); animSet.save(setDir, req.setName()); - - input.animOpStatus = "Set '" + req.setName() + "' gespeichert (" + added + " Clips)"; + input.animOpStatus = "Animations-Set '" + req.setName() + "' gespeichert"; } catch (Exception e) { input.animOpStatus = "Set-Fehler: " + e.getMessage(); } } + private void executeAnimEmbed(SharedInput.AnimEmbedRequest req) { + java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets"); + java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips"); + + de.blight.game.animation.AnimSet set; + try { + set = de.blight.game.animation.AnimSet.load(setDir, req.setName()); + } catch (Exception e) { + input.animEmbedStatus = "Fehler: Set nicht gefunden – " + e.getMessage(); + return; + } + if (set.getClips().isEmpty()) { + input.animEmbedStatus = "Fehler: Set '" + req.setName() + "' enthält keine Clips"; + return; + } + + try { + Spatial charModel = loadFresh(req.characterModelPath()); + AnimComposer charAC = findControl(charModel, AnimComposer.class); + SkinningControl charSC = findControl(charModel, SkinningControl.class); + if (charAC == null) { + input.animEmbedStatus = "Fehler: Charakter-Modell hat keinen AnimComposer"; + return; + } + com.jme3.anim.Armature dstArm = charSC != null ? charSC.getArmature() : null; + + int embedded = 0; + for (String clipName : set.getClips()) { + if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) { + System.err.println("[AnimEmbed] Clip nicht gefunden: " + clipName); + continue; + } + try { + Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o"); + AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class); + SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class); + if (clipAC == null) continue; + com.jme3.anim.Armature srcArm = clipSC != null ? clipSC.getArmature() : null; + + for (AnimClip clip : clipAC.getAnimClips()) { + if (charAC.getAnimClip(clip.getName()) != null) continue; + AnimClip target; + if (srcArm != null && dstArm != null && !haveSameBoneNames(srcArm, dstArm)) { + target = de.blight.game.animation.RetargetingSystem + .retarget(clip, srcArm, dstArm); + } else { + target = clip; + } + if (target != null) { charAC.addAnimClip(target); embedded++; } + } + } catch (Exception e) { + System.err.println("[AnimEmbed] Fehler bei Clip " + clipName + ": " + e.getMessage()); + } + } + + // Charakter-Modell mit eingebetteten Clips speichern + java.nio.file.Path charFile = ASSET_ROOT.resolve( + req.characterModelPath().replace('/', java.io.File.separatorChar)); + BinaryExporter.getInstance().save(charModel, charFile.toFile()); + assets.deleteFromCache(new com.jme3.asset.ModelKey(req.characterModelPath())); + + input.animEmbedStatus = embedded + " Clip(s) in '" + + req.characterModelPath() + "' eingebettet"; + } catch (Exception e) { + input.animEmbedStatus = "Embed-Fehler: " + e.getMessage(); + } + } + + private void saveClipToFile(AnimClip clip, com.jme3.anim.Armature armature, + java.nio.file.Path outFile) throws Exception { + Node holder = new Node("clip_" + clip.getName()); + AnimComposer expAC = new AnimComposer(); + expAC.addAnimClip(clip); + holder.addControl(expAC); + if (armature != null) holder.addControl(new SkinningControl(armature)); + java.nio.file.Files.createDirectories(outFile.getParent()); + BinaryExporter.getInstance().save(holder, outFile.toFile()); + } + + 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<>(); + for (var j : a.getJointList()) namesA.add(j.getName()); + for (var j : b.getJointList()) if (!namesA.contains(j.getName())) return false; + return true; + } + private static java.util.Map buildMS(com.jme3.anim.Armature arm) { java.util.Map cache = new java.util.HashMap<>(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java index 33e71e7..353e287 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/EzTreeState.java @@ -138,7 +138,7 @@ public class EzTreeState extends BaseAppState { }); if (!req.exportAfter()) { - input.treeGenStatusMsg = "EZ-Tree Vorschau: '" + req.exportName() + "'"; + input.treeGenStatusMsg = "EZ-Tree Vorschau: " + resolveSubPath(req.presetName()); } else { input.treeGenStatusMsg = "EZ-Tree: generiere…"; } @@ -356,10 +356,14 @@ public class EzTreeState extends BaseAppState { Node treeNode = pendingTreeNode; cleanupCapture(); - String exportName = req.exportName() + "_" - + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); + String subPath = resolveSubPath(req.presetName()); + String namePart = req.presetName() != null + ? req.presetName().toLowerCase().replace(" ", "_") + : subPath; + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); + String exportName = namePart + "_" + timestamp; saveImpostor(pixels, "ez_impostor_" + exportName); - exportTree(treeNode, req.exportName(), req.treeCategory()); + exportTree(treeNode, exportName, subPath); pendingRequest = null; pendingTreeNode = null; @@ -540,21 +544,40 @@ public class EzTreeState extends BaseAppState { } } - private void exportTree(Node treeNode, String name, String treeCategory) { + private void exportTree(Node treeNode, String fileName, String subPath) { try { - Path baseDir = (treeCategory != null && !treeCategory.isBlank()) - ? ASSET_ROOT.resolve("trees").resolve(treeCategory) - : ASSET_ROOT.resolve("Models"); + Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath); Files.createDirectories(baseDir); - File out = baseDir.resolve(name + ".j3o").toFile(); + File out = baseDir.resolve(fileName + ".j3o").toFile(); BinaryExporter.getInstance().save(treeNode, out); log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath()); - input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName(); - input.refreshAssets = true; - input.refreshTreeFolders = true; + input.treeGenStatusMsg = "Gespeichert: Models/trees/" + subPath + "/" + fileName + ".j3o"; + input.refreshAssets = true; + input.refreshTreeFolders = true; } catch (IOException e) { log.error("[EZ-Tree] Export-Fehler: {}", e.getMessage()); input.treeGenStatusMsg = "EZ-Tree Export-Fehler: " + e.getMessage(); } } + + private static String resolveSubPath(String presetName) { + if (presetName == null) return "unknown"; + String lo = presetName.toLowerCase(); + + String size = lo.contains(" small") ? "/small" + : lo.contains(" medium") ? "/medium" + : lo.contains(" large") ? "/large" + : lo.contains(" 1") ? "/1" + : lo.contains(" 2") ? "/2" + : lo.contains(" 3") ? "/3" + : ""; + + if (lo.contains("oak")) return "oak" + size; + if (lo.contains("ash")) return "ash" + size; + if (lo.contains("aspen")) return "aspen" + size; + if (lo.contains("pine")) return "pine" + size; + if (lo.contains("bush")) return "bush" + size; + if (lo.contains("trellis")) return "trellis" + size; + return lo.replaceAll("\\s+.*", "") + size; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java b/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java index 7892a62..155f7cd 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/PalmGeneratorState.java @@ -89,17 +89,14 @@ public class PalmGeneratorState extends BaseAppState { final Vector3f finalTarget = target; final Node finalPalm = palm; final PalmOptions finalOpts = req.options(); - final String finalName = req.exportName(); final boolean doExport = req.exportAfter(); app.enqueue(() -> { previewHost.setPreviewContent(finalPalm, finalDist, finalTarget); - if (doExport) exportPalm(finalPalm, finalName); + if (doExport) exportPalm(finalPalm); }); - input.treeGenStatusMsg = doExport - ? "Palme: exportiere…" - : "Palme: Vorschau '" + req.exportName() + "'"; + input.treeGenStatusMsg = doExport ? "Palme: exportiere…" : "Palme: Vorschau"; } private void applyMaterials(Node palm, PalmOptions opts) { @@ -190,16 +187,15 @@ public class PalmGeneratorState extends BaseAppState { } } - private void exportPalm(Node palmNode, String name) { + private void exportPalm(Node palmNode) { try { - Path modelDir = ASSET_ROOT.resolve("trees").resolve("palm"); + Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm"); Files.createDirectories(modelDir); - String stampedName = name + "_" - + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); - File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile(); + String fileName = "palm_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); + File out = modelDir.resolve(fileName + ".j3o").toFile(); BinaryExporter.getInstance().save(palmNode, out); log.info("[Palme] Gespeichert: {}", out.getAbsolutePath()); - input.treeGenStatusMsg = "Palme exportiert: " + out.getName(); + input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o"; input.refreshAssets = true; } catch (IOException e) { log.error("[Palme] Export-Fehler: {}", e.getMessage()); diff --git a/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java index 2992830..5957b88 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java @@ -19,6 +19,8 @@ import com.jme3.scene.VertexBuffer; import com.jme3.scene.control.AbstractControl; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.util.BufferUtils; +import de.blight.common.GrassTuft; +import de.blight.common.GrassTuftIO; import de.blight.common.MapData; import de.blight.editor.SharedInput; @@ -27,31 +29,27 @@ import java.nio.IntBuffer; import java.util.*; /** - * Rendert Gras auf dem Basis-Terrain. + * Rendert individuell platzierte Gras-Büschel im Editor. * - * Datenmodell: Dichte-Map (513×513 Bytes, gleiche Auflösung wie Splatmap). - * Rendering: Pro 128×128-WE-Chunk ein gebatchtes Kreuz-Quad-Mesh. - * LOD: GrassVisibilityControl cullt Chunks jenseits FAR_DIST. - * Wind: MatDefs/Grass.j3md (Vertex-Shader mit Sinus-Wind). + * Jeder Büschel hat eine feste Weltposition (x, z), eine gebackene Höhe und einen Textur-Slot. + * Die Y-Koordinate wird beim Chunk-Rebuild live aus dem Terrain abgelesen. + * + * Chunks: 128×128-WE-Kacheln, lazy rebuild, LOD-Culling via GrassVisibilityControl. */ public class PlacedObjectState extends BaseAppState { - // ── Terrain-Konstanten ──────────────────────────────────────────────────── + // ── Terrain ─────────────────────────────────────────────────────────────── private static final int TERRAIN_HALF = 2048; - private static final float WORLD_SIZE = 4096f; - - // ── Dichte-Map ──────────────────────────────────────────────────────────── - private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; // 513 - private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1); // 8.0 // ── Chunks ──────────────────────────────────────────────────────────────── - 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; + 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 - // ── Gras-Generierung ────────────────────────────────────────────────────── - private static final int MAX_BLADES_PER_PIXEL = 3; - private static final float BLADE_WIDTH_FACTOR = 0.18f; + // ── Rendering ───────────────────────────────────────────────────────────── + private static final float BLADE_WIDTH = 0.18f; + private static final int BLADES_PER_TUFT = 4; // Kreuz-Quads pro Büschel + private static final float TUFT_SPREAD = 0.5f; // Streuradius (WE) // ── LOD ─────────────────────────────────────────────────────────────────── private static final float GRASS_FAR_DIST = 400f; @@ -64,23 +62,49 @@ public class PlacedObjectState extends BaseAppState { private final SharedInput input; private Camera cam; private TerrainQuad terrain; + private AssetManager assetManager; - private Node grassNode; - private Material grassMat; + private Node grassNode; + private final Map slotMaterials = new LinkedHashMap<>(); - private byte[] densityMap; - - private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT]; - private final Geometry[] chunkGeos = new Geometry[CHUNK_COUNT]; + @SuppressWarnings("unchecked") + private final List[] chunkTufts = new List[CHUNK_COUNT]; + private final Node[] chunkNodes = new Node[CHUNK_COUNT]; + private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT]; // ── Konstruktor ─────────────────────────────────────────────────────────── public PlacedObjectState(SharedInput input, MapData loadedData) { - this.input = input; - this.densityMap = new byte[SPLAT_SIZE * SPLAT_SIZE]; - if (loadedData != null && loadedData.grassDensity != null) { - System.arraycopy(loadedData.grassDensity, 0, densityMap, 0, densityMap.length); - Arrays.fill(dirtyChunks, true); + this.input = input; + for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>(); + + try { + GrassTuftIO.GrassData data = GrassTuftIO.load(); + if (data != null) { + String[] paths = data.slotPaths(); + if (paths != null && paths.length > 0) { + input.grassTexturePath = paths[0] != null ? paths[0] : ""; + String[] slots = new String[]{"", "", "", "", "", "", ""}; + for (int i = 0; i < 7; i++) { + int si = i + 1; + slots[i] = (si < paths.length && paths[si] != null) ? paths[si] : ""; + } + input.grassTextureSlots = slots; + } + for (GrassTuft t : data.tufts()) { + int ci = chunkIndex(t.x(), t.z()); + if (ci >= 0) { + chunkTufts[ci].add(t); + dirtyChunks[ci] = true; + } + } + } + } catch (Exception e) { + System.err.println("[PlacedObjectState] Grasdaten nicht ladbar: " + e.getMessage()); + } + + if (loadedData != null) { + input.grassTool.grassHeight.setValue(loadedData.grassDefaultHeight); } } @@ -88,17 +112,36 @@ public class PlacedObjectState extends BaseAppState { this.terrain = terrain; } - /** Gibt die aktuelle Dichte-Map zurück (für performSave). */ - public byte[] getDensityMap() { return densityMap; } + // ── Getters für Save ────────────────────────────────────────────────────── + + public List getAllTufts() { + List all = new ArrayList<>(); + for (List list : chunkTufts) all.addAll(list); + return all; + } + + public String[] getSlotPaths() { + String[] paths = new String[8]; + paths[0] = input.grassTexturePath != null ? input.grassTexturePath : ""; + String[] slots = input.grassTextureSlots; + for (int i = 0; i < 7 && i < slots.length; i++) + paths[i + 1] = slots[i] != null ? slots[i] : ""; + return paths; + } + + public float getGrassDefaultHeight() { + return (float) input.grassTool.grassHeight.getValue(); + } // ── Lifecycle ───────────────────────────────────────────────────────────── @Override protected void initialize(Application app) { - this.cam = app.getCamera(); - grassNode = new Node("grassNode"); + this.cam = app.getCamera(); + this.assetManager = app.getAssetManager(); + grassNode = new Node("grassNode"); ((SimpleApplication) app).getRootNode().attachChild(grassNode); - grassMat = buildGrassMaterial(app.getAssetManager()); + applyAllSlotMaterials(); } @Override @@ -111,30 +154,63 @@ public class PlacedObjectState extends BaseAppState { @Override public void update(float tpf) { + if (input.grassSettingsChanged || input.grassSlotsChanged) { + input.grassSettingsChanged = false; + input.grassSlotsChanged = false; + applyAllSlotMaterials(); + } processGrassEdits(); rebuildDirtyChunks(); } - // ── Material ────────────────────────────────────────────────────────────── + // ── Materialien ─────────────────────────────────────────────────────────── - private Material buildGrassMaterial(AssetManager assets) { + private Material getMaterialForSlot(int slot) { + return slotMaterials.computeIfAbsent(slot, s -> buildFreshGrassMaterial()); + } + + private Material buildFreshGrassMaterial() { try { - Material mat = new Material(assets, "MatDefs/Grass.j3md"); + Material mat = new Material(assetManager, "MatDefs/Grass.j3md"); mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f)); mat.setFloat("WindSpeed", 0.5f); mat.setFloat("WindStrength", 0.12f); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); return mat; } catch (Exception e) { - System.err.println("[PlacedObjectState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage()); - Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f)); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); return mat; } } - // ── Pinsel: Dichte-Map anpassen ─────────────────────────────────────────── + private void applyAllSlotMaterials() { + if (assetManager == null) return; + applyTexToMat(getMaterialForSlot(0), input.grassTexturePath); + String[] slots = input.grassTextureSlots; + for (int i = 0; i < slots.length; i++) { + if (slots[i] != null && !slots[i].isEmpty()) + applyTexToMat(getMaterialForSlot(i + 1), slots[i]); + } + } + + private void applyTexToMat(Material mat, String path) { + if (path != null && !path.isEmpty()) { + try { + mat.setTexture("ColorMap", assetManager.loadTexture(path)); + mat.setColor("Color", ColorRGBA.White); + } catch (Exception e) { + try { mat.clearParam("ColorMap"); } catch (Exception ignored) {} + mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f)); + } + } else { + try { mat.clearParam("ColorMap"); } catch (Exception ignored) {} + mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f)); + } + } + + // ── Pinsel-Interaktion ──────────────────────────────────────────────────── private void processGrassEdits() { SharedInput.GrassEdit edit; @@ -144,60 +220,58 @@ public class PlacedObjectState extends BaseAppState { float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY); Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); - com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal()); + Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); CollisionResults hits = new CollisionResults(); terrain.collideWith(ray, hits); if (hits.size() == 0) continue; Vector3f contact = hits.getClosestCollision().getContactPoint(); float radius = (float) input.grassTool.brushRadius.getValue(); - paintDensity(contact.x, contact.z, radius, edit.action()); + if (edit.action() > 0) paintGrass(contact.x, contact.z, radius); + else eraseGrass(contact.x, contact.z, radius); } } - private void paintDensity(float cx, float cz, float radius, int action) { - int centerPX = Math.round((cx + TERRAIN_HALF) / SPLAT_WE_PER_PX); - int centerPZ = Math.round((cz + TERRAIN_HALF) / SPLAT_WE_PER_PX); - int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX); - float strength = (float) input.grassTool.density.getValue() / 10f; // 0.1–5.0 - - for (int dz = -pixR; dz <= pixR; dz++) { - int pz = centerPZ + dz; - if (pz < 0 || pz >= SPLAT_SIZE) continue; - for (int dx = -pixR; dx <= pixR; dx++) { - int px = centerPX + dx; - if (px < 0 || px >= SPLAT_SIZE) continue; - float distWE = FastMath.sqrt(dx * dx + dz * dz) * SPLAT_WE_PER_PX; - if (distWE >= radius) continue; - float t = distWE / radius; - float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f; - int delta = (int)(strength * falloff * 40f); - int idx = pz * SPLAT_SIZE + px; - int cur = densityMap[idx] & 0xFF; - int nxt = (action > 0) - ? Math.min(255, cur + delta) - : Math.max(0, cur - delta); - if (nxt != cur) { - densityMap[idx] = (byte) nxt; - markChunkDirtyAtPixel(px, pz); - } + private void paintGrass(float cx, float cz, float radius) { + int n = Math.max(1, (int) input.grassTool.density.getValue()); + float baseH = (float) input.grassTool.grassHeight.getValue(); + int slot = input.grassActiveSlot; + Random rng = new Random(); + for (int i = 0; i < n; i++) { + float angle = rng.nextFloat() * FastMath.TWO_PI; + float dist = FastMath.sqrt(rng.nextFloat()) * radius; + float bx = cx + dist * FastMath.cos(angle); + float bz = cz + dist * FastMath.sin(angle); + if (bx < -TERRAIN_HALF || bx > TERRAIN_HALF + || bz < -TERRAIN_HALF || bz > TERRAIN_HALF) continue; + float h = baseH * (0.7f + rng.nextFloat() * 0.6f); + int ci = chunkIndex(bx, bz); + if (ci >= 0) { + chunkTufts[ci].add(new GrassTuft(bx, bz, h, slot)); + dirtyChunks[ci] = true; } } } + private void eraseGrass(float cx, float cz, float radius) { + float rSq = radius * radius; + for (int[] cc : overlappingChunks(cx, cz, radius)) { + int ci = cc[0] + cc[1] * CHUNKS_PER_AXIS; + boolean changed = chunkTufts[ci].removeIf(t -> { + float dx = t.x() - cx, dz = t.z() - cz; + return dx * dx + dz * dz <= rSq; + }); + if (changed) dirtyChunks[ci] = true; + } + } + // ── Höhenanpassung bei Terrain-Edit ─────────────────────────────────────── - /** - * Markiert alle Chunks dirty, deren Fläche eine der übergebenen Terrain-Positionen - * enthält. Die Blatt-Y-Koordinaten werden beim nächsten Rebuild neu von - * terrain.getHeight() abgelesen. - */ public void adjustObjectHeights(List locs, List deltas) { for (Vector2f loc : locs) { - int cx = (int)((loc.x + TERRAIN_HALF) / CHUNK_SIZE); - int cz = (int)((loc.y + TERRAIN_HALF) / CHUNK_SIZE); - if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) { + int cx = (int) Math.floor((loc.x + TERRAIN_HALF) / CHUNK_SIZE); + int cz = (int) Math.floor((loc.y + TERRAIN_HALF) / CHUNK_SIZE); + if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true; - } } } @@ -215,90 +289,76 @@ public class PlacedObjectState extends BaseAppState { private void rebuildChunk(int idx) { if (terrain == null) return; + int cx = idx % CHUNKS_PER_AXIS; + int cz = idx / CHUNKS_PER_AXIS; + float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE; + float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE; - int cx = idx % CHUNKS_PER_AXIS; - int cz = idx / CHUNKS_PER_AXIS; - float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE; - float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE; + if (chunkNodes[idx] != null) { + grassNode.detachChild(chunkNodes[idx]); + chunkNodes[idx] = null; + } + if (chunkTufts[idx].isEmpty()) return; - // Dichte-Pixel-Bereich dieses Chunks - int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pxMax = Math.min(SPLAT_SIZE - 1, (int)((wXMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pzMax = Math.min(SPLAT_SIZE - 1, (int)((wZMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - - float baseH = (float) input.grassTool.grassHeight.getValue(); - - // Blatt-Positionen generieren - List blades = new ArrayList<>(); // [x, y, z, height] - for (int pz = pzMin; pz <= pzMax; pz++) { - for (int px = pxMin; px <= pxMax; px++) { - int d = densityMap[pz * SPLAT_SIZE + px] & 0xFF; - if (d == 0) continue; - int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PIXEL)); - Random rng = new Random((long) px * 100003L + pz); - float pixWorldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF; - float pixWorldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF; - for (int b = 0; b < count; b++) { - float bx = pixWorldX + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX; - float bz = pixWorldZ + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX; - float th = terrain.getHeight(new Vector2f(bx, bz)); - if (Float.isNaN(th)) continue; - float h = baseH * (0.7f + rng.nextFloat() * 0.6f); - blades.add(new float[]{bx, th, bz, h}); - } + Map> bySlot = new LinkedHashMap<>(); + for (GrassTuft t : chunkTufts[idx]) { + long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L + ^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL; + Random rng = new Random(seed); + List blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>()); + for (int b = 0; b < BLADES_PER_TUFT; b++) { + float ox = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f; + float oz = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f; + float bx = t.x() + ox; + float bz = t.z() + oz; + float th = terrain.getHeight(new Vector2f(bx, bz)); + if (Float.isNaN(th)) continue; + float bh = t.height() * (0.7f + rng.nextFloat() * 0.6f); + blades.add(new float[]{bx, th, bz, bh}); } } - // Alte Geometrie entfernen - if (chunkGeos[idx] != null) { - grassNode.detachChild(chunkGeos[idx]); - chunkGeos[idx] = null; + float chunkCX = wXMin + CHUNK_SIZE * 0.5f; + float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; + Node node = new Node("grassChunk_" + idx); + for (Map.Entry> entry : bySlot.entrySet()) { + if (entry.getValue().isEmpty()) continue; + Geometry geo = new Geometry("grassChunk_" + idx + "_s" + entry.getKey(), + buildGrassMesh(entry.getValue())); + geo.setMaterial(getMaterialForSlot(entry.getKey())); + node.attachChild(geo); } - if (blades.isEmpty()) return; - - Mesh mesh = buildGrassMesh(blades); - float chunkCX = wXMin + CHUNK_SIZE * 0.5f; - float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; - Geometry geo = new Geometry("grassChunk_" + idx, mesh); - geo.setMaterial(grassMat); - geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); - grassNode.attachChild(geo); - chunkGeos[idx] = geo; + if (node.getChildren().isEmpty()) return; + node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); + grassNode.attachChild(node); + chunkNodes[idx] = node; } - // ── Mesh: Kreuz-Quad pro Halm mit UV-Koordinaten ────────────────────────── + // ── Mesh ────────────────────────────────────────────────────────────────── private static Mesh buildGrassMesh(List blades) { int n = blades.size(); FloatBuffer pos = BufferUtils.createFloatBuffer(n * 8 * 3); FloatBuffer uv = BufferUtils.createFloatBuffer(n * 8 * 2); IntBuffer idx = BufferUtils.createIntBuffer(n * 12); - int vi = 0; for (float[] blade : blades) { float x = blade[0], y = blade[1], z = blade[2], h = blade[3]; - float w = Math.max(0.05f, h * BLADE_WIDTH_FACTOR); - - // Quad A – Breite entlang X-Achse + float w = Math.max(0.05f, h * BLADE_WIDTH); pos.put(x-w).put(y ).put(z); uv.put(0).put(0); pos.put(x+w).put(y ).put(z); uv.put(1).put(0); pos.put(x+w).put(y+h).put(z); uv.put(1).put(1); pos.put(x-w).put(y+h).put(z); uv.put(0).put(1); - - // Quad B – Breite entlang Z-Achse pos.put(x).put(y ).put(z-w); uv.put(0).put(0); pos.put(x).put(y ).put(z+w); uv.put(1).put(0); pos.put(x).put(y+h).put(z+w); uv.put(1).put(1); pos.put(x).put(y+h).put(z-w); uv.put(0).put(1); - - idx.put(vi ).put(vi+1).put(vi+2); - idx.put(vi ).put(vi+2).put(vi+3); + idx.put(vi).put(vi+1).put(vi+2); + idx.put(vi).put(vi+2).put(vi+3); idx.put(vi+4).put(vi+5).put(vi+6); idx.put(vi+4).put(vi+6).put(vi+7); vi += 8; } - Mesh mesh = new Mesh(); mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv); @@ -310,14 +370,12 @@ public class PlacedObjectState extends BaseAppState { // ── LOD-Control ─────────────────────────────────────────────────────────── private static final class GrassVisibilityControl extends AbstractControl { - private final Camera cam; + 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); @@ -325,19 +383,30 @@ public class PlacedObjectState extends BaseAppState { ? Spatial.CullHint.Always : Spatial.CullHint.Inherit); } - @Override protected void controlRender(RenderManager rm, ViewPort vp) {} } // ── Hilfsmethoden ───────────────────────────────────────────────────────── - private void markChunkDirtyAtPixel(int px, int pz) { - float worldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF; - float worldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF; - int cx = (int)((worldX + TERRAIN_HALF) / CHUNK_SIZE); - int cz = (int)((worldZ + TERRAIN_HALF) / CHUNK_SIZE); - if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) { - dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true; + private int chunkIndex(float wx, float wz) { + int cx = (int) Math.floor((wx + TERRAIN_HALF) / CHUNK_SIZE); + int cz = (int) Math.floor((wz + TERRAIN_HALF) / CHUNK_SIZE); + if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1; + return cx + cz * CHUNKS_PER_AXIS; + } + + private int[][] overlappingChunks(float cx, float cz, float radius) { + int r = (int) Math.ceil(radius / CHUNK_SIZE) + 1; + int ccx = (int) Math.floor((cx + TERRAIN_HALF) / CHUNK_SIZE); + int ccz = (int) Math.floor((cz + TERRAIN_HALF) / CHUNK_SIZE); + List result = new ArrayList<>(); + for (int dz = -r; dz <= r; dz++) { + for (int dx = -r; dx <= r; dx++) { + int nx = ccx + dx, nz = ccz + dz; + if (nx >= 0 && nx < CHUNKS_PER_AXIS && nz >= 0 && nz < CHUNKS_PER_AXIS) + result.add(new int[]{nx, nz}); + } } + return result.toArray(new int[0][]); } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/RiverEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/RiverEditorState.java new file mode 100644 index 0000000..4c45b38 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/RiverEditorState.java @@ -0,0 +1,493 @@ +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.ColorRGBA; +import com.jme3.math.FastMath; +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.scene.shape.Sphere; +import com.jme3.terrain.geomipmap.TerrainQuad; +import com.jme3.util.BufferUtils; +import de.blight.common.RiverIO; +import de.blight.common.RiverPoint; +import de.blight.common.RiverSpline; +import de.blight.editor.SharedInput; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * Editor-State für das Fluss-Werkzeug. + * Erlaubt das interaktive Platzieren von Fluss-Kontrollpunkten auf dem Terrain + * und zeigt eine Live-Ribbon-Vorschau. + */ +public class RiverEditorState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(RiverEditorState.class); + private static final float UV_SCALE = 4.0f; + + private final SharedInput input; + + private SimpleApplication app; + private Camera cam; + private AssetManager assets; + private Node rootNode; + private TerrainQuad terrain; + + // ── Zustand ─────────────────────────────────────────────────────────────── + private final List> rivers = new ArrayList<>(); + private final List> pointGeos = new ArrayList<>(); + private final List ribbonGeos = new ArrayList<>(); + private int activeRiver = -1; // -1 = kein aktiver Fluss (wird gebaut) + private int selectedRiver = -1; // -1 = keine Selektion + + public RiverEditorState(SharedInput input) { + this.input = input; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + this.app = (SimpleApplication) app; + this.cam = app.getCamera(); + this.assets = app.getAssetManager(); + this.rootNode = this.app.getRootNode(); + + try { + List> saved = RiverIO.load(); + if (!saved.isEmpty()) loadPlacedRivers(saved); + } catch (Exception e) { + log.error("Flüsse nicht ladbar", e); + } + } + + @Override + protected void cleanup(Application app) { + clearAll(); + } + + @Override + protected void onEnable() { + setCullHintAll(Spatial.CullHint.Inherit); + } + + @Override + protected void onDisable() { + setCullHintAll(Spatial.CullHint.Always); + } + + // ── Terrain ─────────────────────────────────────────────────────────────── + + private TerrainEditorState terrainEditor; + + public void setTerrain(TerrainQuad t) { + this.terrain = t; + } + + public void setTerrainEditor(TerrainEditorState te) { + this.terrainEditor = te; + } + + // ── Update ──────────────────────────────────────────────────────────────── + + @Override + public void update(float tpf) { + if (input.activeLayer != SharedInput.LAYER_RIVERS) return; + + // Undo: letzten Punkt des aktiven Flusses entfernen + if (input.undoRiverPointRequested) { + input.undoRiverPointRequested = false; + undoLastPoint(); + } + + // Selektierten Fluss löschen + if (input.deleteRiverRequested) { + input.deleteRiverRequested = false; + if (selectedRiver >= 0) { + removeRiver(selectedRiver); + selectedRiver = -1; + input.selectedRiverInfo = null; + input.riverSelectionChanged = true; + } + } + + // Click-Queue verarbeiten + SharedInput.RiverClick click; + while ((click = input.riverClickQueue.poll()) != null) { + handleClick(click); + } + } + + // ── Click-Verarbeitung ──────────────────────────────────────────────────── + + private void handleClick(SharedInput.RiverClick click) { + if (click.rightButton()) { + // Rechtsklick: aktiven Fluss abschließen + finalizeActiveRiver(); + return; + } + + if (terrain == null) return; + + float jmeX = (float)(click.screenX() * input.viewportScaleX); + float jmeY = cam.getHeight() - (float)(click.screenY() * 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 hit = hits.getClosestCollision().getContactPoint(); + + // Kein aktiver Fluss: prüfen ob ein bestehender Fluss in der Nähe liegt → selektieren + if (activeRiver < 0) { + int nearby = findNearestRiver(hit, 8f); + if (nearby >= 0) { + selectRiver(nearby); + return; + } + // Klick ins Leere → Selektion aufheben + selectRiver(-1); + } + + float w = input.riverNewWidth; + float speed = input.riverNewSpeed; + + RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, w, speed); + addPoint(pt); + } + + private void addPoint(RiverPoint pt) { + // Neuen Fluss starten wenn kein aktiver + if (activeRiver < 0 || activeRiver >= rivers.size()) { + rivers.add(new ArrayList<>()); + pointGeos.add(new ArrayList<>()); + ribbonGeos.add(null); + activeRiver = rivers.size() - 1; + } + + List current = rivers.get(activeRiver); + if (!current.isEmpty() && terrainEditor != null) { + RiverPoint prev = current.get(current.size() - 1); + // Gefälle sicherstellen: neuer Punkt darf nicht höher als vorheriger sein + float newY = Math.min(pt.y(), prev.y() - 0.05f); + pt = new RiverPoint(pt.x(), newY, pt.z(), pt.width(), pt.uvSpeed()); + // Flussbett graben + terrainEditor.carveRiverbedSegment( + prev.x(), prev.y(), prev.z(), + pt.x(), pt.y(), pt.z(), + pt.width() * 0.5f + ); + } + + rivers.get(activeRiver).add(pt); + + // Kontrollpunkt-Geo + Geometry sphere = buildPointGeo(pt); + rootNode.attachChild(sphere); + pointGeos.get(activeRiver).add(sphere); + + // Ribbon neu aufbauen + rebuildActiveRibbon(); + } + + private void finalizeActiveRiver() { + // Fluss mit weniger als 2 Punkten verwerfen + if (activeRiver >= 0 && activeRiver < rivers.size()) { + if (rivers.get(activeRiver).size() < 2) { + removeRiver(activeRiver); + } + } + activeRiver = -1; + } + + private void undoLastPoint() { + if (activeRiver < 0 || activeRiver >= rivers.size()) return; + List pts = rivers.get(activeRiver); + List geos = pointGeos.get(activeRiver); + if (pts.isEmpty()) return; + + pts.remove(pts.size() - 1); + Geometry last = geos.remove(geos.size() - 1); + rootNode.detachChild(last); + + if (pts.isEmpty()) { + removeRiver(activeRiver); + activeRiver = -1; + } else { + rebuildActiveRibbon(); + } + } + + // ── Ribbon-Vorschau ─────────────────────────────────────────────────────── + + private void rebuildActiveRibbon() { + if (activeRiver < 0 || activeRiver >= rivers.size()) return; + + // Altes Ribbon entfernen + Geometry old = ribbonGeos.get(activeRiver); + if (old != null) rootNode.detachChild(old); + + List pts = rivers.get(activeRiver); + if (pts.size() < 2) { + ribbonGeos.set(activeRiver, null); + return; + } + + Geometry ribbon = buildRibbon(pts); + ribbonGeos.set(activeRiver, ribbon); + rootNode.attachChild(ribbon); + } + + // ── Öffentliche API ─────────────────────────────────────────────────────── + + /** + * Lädt bereits gespeicherte Flüsse und baut deren Visualisierungen auf. + */ + public void loadPlacedRivers(List> loaded) { + clearAll(); + log.info("Lade {} Fluss/Flüsse aus Datei", loaded.size()); + for (List river : loaded) { + if (river == null || river.isEmpty()) continue; + int idx = rivers.size(); + rivers.add(new ArrayList<>(river)); + pointGeos.add(new ArrayList<>()); + ribbonGeos.add(null); + + for (RiverPoint pt : river) { + Geometry sphere = buildPointGeo(pt); + rootNode.attachChild(sphere); + pointGeos.get(idx).add(sphere); + } + + if (river.size() >= 2) { + Geometry ribbon = buildRibbon(river); + ribbonGeos.set(idx, ribbon); + rootNode.attachChild(ribbon); + } + } + activeRiver = -1; + } + + /** + * Gibt eine Kopie der aktuell platzierten Flüsse zurück. + */ + public List> getPlacedRivers() { + List> copy = new ArrayList<>(); + for (List river : rivers) { + if (river != null && river.size() >= 2) { + copy.add(new ArrayList<>(river)); + } + } + return copy; + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private void clearAll() { + for (List geos : pointGeos) { + if (geos != null) for (Geometry g : geos) rootNode.detachChild(g); + } + for (Geometry ribbon : ribbonGeos) { + if (ribbon != null) rootNode.detachChild(ribbon); + } + rivers.clear(); + pointGeos.clear(); + ribbonGeos.clear(); + activeRiver = -1; + } + + private void removeRiver(int idx) { + if (idx < 0 || idx >= rivers.size()) return; + List geos = pointGeos.get(idx); + if (geos != null) for (Geometry g : geos) rootNode.detachChild(g); + Geometry ribbon = ribbonGeos.get(idx); + if (ribbon != null) rootNode.detachChild(ribbon); + rivers.remove(idx); + pointGeos.remove(idx); + ribbonGeos.remove(idx); + if (activeRiver > idx) activeRiver--; + else if (activeRiver == idx) activeRiver = -1; + if (selectedRiver > idx) selectedRiver--; + else if (selectedRiver == idx) selectedRiver = -1; + } + + private void selectRiver(int idx) { + if (selectedRiver == idx) return; + // Altes Highlight zurücksetzen + if (selectedRiver >= 0 && selectedRiver < ribbonGeos.size()) { + Geometry old = ribbonGeos.get(selectedRiver); + if (old != null) + old.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f)); + } + selectedRiver = idx; + if (idx >= 0 && idx < rivers.size()) { + Geometry ribbon = ribbonGeos.get(idx); + if (ribbon != null) + ribbon.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(1.0f, 0.75f, 0.0f, 0.9f)); + List pts = rivers.get(idx); + float len = computeLength(pts); + input.selectedRiverInfo = idx + "|" + pts.size() + "|" + String.format(java.util.Locale.ROOT, "%.1f", len); + } else { + input.selectedRiverInfo = null; + } + input.riverSelectionChanged = true; + } + + private static float computeLength(List pts) { + float len = 0f; + for (int i = 1; i < pts.size(); i++) { + RiverPoint a = pts.get(i - 1), b = pts.get(i); + float dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z(); + len += FastMath.sqrt(dx * dx + dy * dy + dz * dz); + } + return len; + } + + /** Gibt den Index des Flusses zurück, dessen nächster Punkt < threshold entfernt liegt, sonst -1. */ + private int findNearestRiver(Vector3f worldPos, float threshold) { + float minDist = threshold * threshold; + int best = -1; + for (int i = 0; i < rivers.size(); i++) { + List pts = rivers.get(i); + if (pts == null) continue; + for (RiverPoint p : pts) { + float dx = p.x() - worldPos.x; + float dz = p.z() - worldPos.z; + float d2 = dx * dx + dz * dz; + if (d2 < minDist) { minDist = d2; best = i; } + } + } + return best; + } + + private void setCullHintAll(Spatial.CullHint hint) { + for (List geos : pointGeos) { + if (geos != null) for (Geometry g : geos) g.setCullHint(hint); + } + for (Geometry ribbon : ribbonGeos) { + if (ribbon != null) ribbon.setCullHint(hint); + } + } + + private Geometry buildPointGeo(RiverPoint pt) { + Sphere sphere = new Sphere(8, 8, 0.4f); + Geometry geo = new Geometry("riverPoint", sphere); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + if (pt.isWaterfall()) { + mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 1f)); + } else { + mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 1f)); + } + geo.setMaterial(mat); + geo.setLocalTranslation(pt.x(), pt.y() + 0.4f, pt.z()); + return geo; + } + + /** + * Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau). + */ + Geometry buildRibbon(List pts) { + pts = RiverSpline.subdivide(pts); + int n = pts.size(); + if (n < 2) return null; + + int vertCount = n * 2; + FloatBuffer pos = BufferUtils.createFloatBuffer(vertCount * 3); + FloatBuffer norm = BufferUtils.createFloatBuffer(vertCount * 3); + FloatBuffer uv = BufferUtils.createFloatBuffer(vertCount * 2); + IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3); + + Vector3f UP = Vector3f.UNIT_Y; + + float[] arcLen = new float[n]; + arcLen[0] = 0f; + for (int i = 1; i < n; i++) { + RiverPoint a = pts.get(i - 1); + RiverPoint b = pts.get(i); + float dx = b.x() - a.x(); + float dz = b.z() - a.z(); + float dy = b.y() - a.y(); + arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz); + } + + for (int i = 0; i < n; i++) { + RiverPoint pt = pts.get(i); + Vector3f tangent; + if (i == 0) { + RiverPoint next = pts.get(1); + tangent = new Vector3f(next.x() - pt.x(), next.y() - pt.y(), next.z() - pt.z()); + } else if (i == n - 1) { + RiverPoint prev = pts.get(n - 2); + tangent = new Vector3f(pt.x() - prev.x(), pt.y() - prev.y(), pt.z() - prev.z()); + } else { + RiverPoint prev = pts.get(i - 1); + RiverPoint next = pts.get(i + 1); + tangent = new Vector3f(next.x() - prev.x(), next.y() - prev.y(), next.z() - prev.z()); + } + if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f); + tangent.normalizeLocal(); + + Vector3f right = tangent.cross(UP).normalizeLocal(); + if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f); + + float halfW = pt.width() * 0.5f; + float px = pt.x(), py = pt.y() + 0.05f, pz = pt.z(); + + pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW); + norm.put(0f).put(1f).put(0f); + uv.put(0f).put(arcLen[i] / UV_SCALE); + + pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW); + norm.put(0f).put(1f).put(0f); + uv.put(1f).put(arcLen[i] / UV_SCALE); + } + + for (int i = 0; i < n - 1; i++) { + int v0 = 2 * i, v1 = 2 * i + 1, v2 = 2 * i + 2, v3 = 2 * i + 3; + idx.put(v0).put(v1).put(v3); + idx.put(v0).put(v3).put(v2); + } + + pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind(); + + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Normal, 3, norm); + mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv); + mesh.setBuffer(VertexBuffer.Type.Index, 3, idx); + mesh.updateBound(); + mesh.updateCounts(); + + Geometry geo = new Geometry("riverRibbon", mesh); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f)); + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + geo.setMaterial(mat); + return geo; + } +} 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 36a4151..cbb3294 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 @@ -143,7 +143,8 @@ public class SceneObjectState extends BaseAppState { so.getRotY(), so.getRotX(), so.getRotZ(), so.getScale(), so.solid, so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(), - meshFile, animClips.get(i))); + meshFile, animClips.get(i), + so.castShadow, so.receiveShadow)); } return list; } @@ -165,6 +166,8 @@ public class SceneObjectState extends BaseAppState { so.setTexturePath(pm.texturePath()); so.setNormalMapPath(pm.normalMapPath()); so.setMaterialPath(pm.materialPath()); + so.castShadow = pm.castShadow(); + so.receiveShadow = pm.receiveShadow(); objects.add(so); animClips.add(pm.animClip() != null ? pm.animClip() : ""); @@ -777,7 +780,8 @@ public class SceneObjectState extends BaseAppState { + "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ() + "|" + so.getScale() + "|" + so.getTexturePath() + "|" + so.getNormalMapPath() + "|" + so.getMaterialPath() - + "|" + animClips.get(idx); + + "|" + animClips.get(idx) + + "|" + so.castShadow + "|" + so.receiveShadow; } else { input.selectedObjectInfo = String.valueOf(n); } @@ -1220,7 +1224,9 @@ public class SceneObjectState extends BaseAppState { q.fromAngles(prop.rotX(), prop.rotY(), prop.rotZ()); node.setLocalRotation(q); - so.solid = prop.solid(); + so.solid = prop.solid(); + so.castShadow = prop.castShadow(); + so.receiveShadow = prop.receiveShadow(); boolean appearanceChanged = false; if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; } 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 48306c8..c8efe30 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 @@ -26,6 +26,7 @@ import com.jme3.texture.Texture2D; import com.jme3.util.BufferUtils; import com.jme3.util.SkyFactory; import de.blight.common.EmitterIO; +import de.blight.common.GrassTuftIO; import de.blight.common.LightIO; import de.blight.common.MusicAreaIO; import de.blight.common.SoundAreaIO; @@ -35,6 +36,8 @@ import de.blight.common.MapIO; import de.blight.common.PlacedModelIO; import de.blight.editor.SharedInput; import de.blight.editor.tool.HeightTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.Reader; @@ -52,6 +55,8 @@ import java.util.Properties; public class TerrainEditorState extends BaseAppState { + private static final Logger log = LoggerFactory.getLogger(TerrainEditorState.class); + // ── Terrain-Konstanten ──────────────────────────────────────────────────── private static final int TERRAIN_SIZE = 4096; private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097 @@ -77,6 +82,7 @@ public class TerrainEditorState extends BaseAppState { private TerrainQuad terrain; private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten private Geometry brushIndicator; + private Geometry livePlayerMarker; private PlacedObjectState placedObjectState; private SceneObjectState sceneObjState; private LightState lightState; @@ -84,6 +90,7 @@ public class TerrainEditorState extends BaseAppState { private WaterBodyState waterBodyState; private SoundAreaState soundAreaState; private MusicAreaState musicAreaState; + private RiverEditorState riverEditorState; private MapData loadedMapData; private Node axesGizmo; @@ -127,9 +134,9 @@ public class TerrainEditorState extends BaseAppState { if (MapIO.exists()) { try { loadedMapData = MapIO.load(); - System.out.println("[TerrainEditor] Karte geladen: " + MapIO.getMapPath()); + log.info("Karte geladen: {}", MapIO.getMapPath()); } catch (IOException e) { - System.err.println("[TerrainEditor] Karte nicht ladbar: " + e.getMessage()); + log.error("Karte nicht ladbar", e); } } loadCameraPrefs(); @@ -154,7 +161,7 @@ public class TerrainEditorState extends BaseAppState { camYaw = (float) Math.toRadians(parsePref(p, "cam.yaw", 0f)); camPitch = (float) Math.toRadians(parsePref(p, "cam.pitch", (float) Math.toDegrees(DEFAULT_PITCH))); } catch (IOException e) { - System.err.println("[TerrainEditor] Kamera-Prefs nicht ladbar: " + e.getMessage()); + log.warn("Kamera-Prefs nicht ladbar", e); } } @@ -184,10 +191,12 @@ public class TerrainEditorState extends BaseAppState { // ── Szene aufbauen ──────────────────────────────────────────────────────── private void buildScene() { + input.loadingStatus = "Lade Terrain..."; terrain = buildTerrain(); cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder rootNode.attachChild(terrain); + input.loadingStatus = "Lade platzierte Objekte..."; placedObjectState = new PlacedObjectState(input, loadedMapData); placedObjectState.setTerrain(terrain); app.getStateManager().attach(placedObjectState); @@ -199,10 +208,11 @@ public class TerrainEditorState extends BaseAppState { var placed = PlacedModelIO.load(); if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed); } catch (IOException e) { - System.err.println("[TerrainEditor] Objekte nicht ladbar: " + e.getMessage()); + log.error("Objekte nicht ladbar", e); } } + input.loadingStatus = "Lade Lichter..."; lightState = app.getStateManager().getState(LightState.class); if (lightState != null) { lightState.setTerrain(terrain); @@ -210,10 +220,11 @@ public class TerrainEditorState extends BaseAppState { var lights = LightIO.load(); if (!lights.isEmpty()) lightState.loadPlacedLights(lights); } catch (IOException e) { - System.err.println("[TerrainEditor] Lichter nicht ladbar: " + e.getMessage()); + log.error("Lichter nicht ladbar", e); } } + input.loadingStatus = "Lade Emitter..."; emitterState = app.getStateManager().getState(EmitterState.class); if (emitterState != null) { emitterState.setTerrain(terrain); @@ -221,21 +232,24 @@ public class TerrainEditorState extends BaseAppState { var emitters = EmitterIO.load(); if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters); } catch (IOException e) { - System.err.println("[TerrainEditor] Emitter nicht ladbar: " + e.getMessage()); + log.error("Emitter nicht ladbar", e); } } + input.loadingStatus = "Lade Wasserflächen..."; waterBodyState = app.getStateManager().getState(WaterBodyState.class); if (waterBodyState != null) { waterBodyState.setTerrain(terrain); + waterBodyState.setHeightMap(cachedHeightMap); try { var waters = WaterBodyIO.load(); if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters); } catch (IOException e) { - System.err.println("[TerrainEditor] Wasseroberflächen nicht ladbar: " + e.getMessage()); + log.error("Wasseroberflächen nicht ladbar", e); } } + input.loadingStatus = "Lade Sound-Bereiche..."; soundAreaState = app.getStateManager().getState(SoundAreaState.class); if (soundAreaState != null) { soundAreaState.setTerrain(terrain); @@ -243,10 +257,11 @@ public class TerrainEditorState extends BaseAppState { var soundAreas = SoundAreaIO.load(); if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas); } catch (IOException e) { - System.err.println("[TerrainEditor] Sound-Bereiche nicht ladbar: " + e.getMessage()); + log.error("Sound-Bereiche nicht ladbar", e); } } + input.loadingStatus = "Lade Musikbereiche..."; musicAreaState = app.getStateManager().getState(MusicAreaState.class); if (musicAreaState != null) { musicAreaState.setTerrain(terrain); @@ -254,10 +269,17 @@ public class TerrainEditorState extends BaseAppState { var musicAreas = MusicAreaIO.load(); if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas); } catch (IOException e) { - System.err.println("[TerrainEditor] Musik-Bereiche nicht ladbar: " + e.getMessage()); + log.error("Musik-Bereiche nicht ladbar", e); } } + riverEditorState = app.getStateManager().getState(RiverEditorState.class); + if (riverEditorState != null) { + riverEditorState.setTerrain(terrain); + riverEditorState.setTerrainEditor(this); + } + + input.loadingStatus = "Baue Szene..."; PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class); if (playToolState != null) playToolState.setTerrain(terrain); @@ -267,6 +289,9 @@ public class TerrainEditorState extends BaseAppState { brushIndicator = buildBrushIndicator(); rootNode.attachChild(brushIndicator); + livePlayerMarker = buildLivePlayerMarker(); + rootNode.attachChild(livePlayerMarker); + axesGizmo = buildAxesGizmo(); rootNode.attachChild(axesGizmo); } @@ -612,6 +637,7 @@ public class TerrainEditorState extends BaseAppState { processTextureEdits(); updateBrushIndicator(); updateAxesGizmo(); + updateLivePlayerMarker(); // Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert if (input.terrainTexturesChanged || input.terrainNormalMapsChanged @@ -673,6 +699,120 @@ public class TerrainEditorState extends BaseAppState { return h != null ? h : 0f; } + // ── Flussbett graben ────────────────────────────────────────────────────── + + /** + * Gräbt ein Flussbett zwischen zwei Wasseroberflächenpunkten A und B. + * Alle Terrain-Vertices innerhalb von halfWidth werden auf die linear + * interpolierte Höhe (A→B) minus Kanaltiefen-Offset abgesenkt. + * Am Kanalrand weicher Übergang zurück zur Original-Höhe. + */ + public void carveRiverbedSegment(float ax, float ay, float az, + float bx, float by, float bz, + float halfWidth) { + if (terrain == null || cachedHeightMap == null) return; + + float segDx = bx - ax, segDz = bz - az; + float segLen2 = segDx * segDx + segDz * segDz; + if (segLen2 < 0.001f) return; + + // Tiefe proportional zur Breite: 0,5m bei 4m Breite, 1,0m bei 10m Breite + float width = halfWidth * 2f; + float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f)); + + // ── Terrain-Vertices graben ────────────────────────────────────────── + int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - 1) + TERRAIN_SIZE * 0.5f)); + int vxMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(ax, bx) + halfWidth + 2) + TERRAIN_SIZE * 0.5f)); + int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - 1) + TERRAIN_SIZE * 0.5f)); + int vzMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(az, bz) + halfWidth + 2) + TERRAIN_SIZE * 0.5f)); + + List locs = new ArrayList<>(); + List deltas = new ArrayList<>(); + + for (int vz = vzMin; vz <= vzMax; vz++) { + for (int vx = vxMin; vx <= vxMax; vx++) { + float worldX = vx - TERRAIN_SIZE * 0.5f; + float worldZ = vz - TERRAIN_SIZE * 0.5f; + + float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2; + t = FastMath.clamp(t, 0f, 1f); + + float projX = ax + t * segDx, projZ = az + t * segDz; + float dist = FastMath.sqrt((worldX - projX) * (worldX - projX) + + (worldZ - projZ) * (worldZ - projZ)); + if (dist > halfWidth) continue; + + float waterY = ay + t * (by - ay); + // U-Form: 60% des Kanals als flacher Boden, danach linearer Anstieg + float norm = dist / halfWidth; + float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f); + float depth = maxDepth * uShape; + float target = waterY - depth; + + int idx = vz * TOTAL_SIZE + vx; + float curH = cachedHeightMap[idx]; + if (curH > target) { + deltas.add(target - curH); + locs.add(new Vector2f(worldX, worldZ)); + cachedHeightMap[idx] = target; + } + } + } + + if (!locs.isEmpty()) { + terrain.adjustHeight(locs, deltas); + terrain.updateModelBound(); + } + + // ── Textur 4 (splatA) malen: Flussbett + 25% Breite pro Seite ──────── + if (splatR == null) return; + float paintHW = halfWidth * 1.5f; // +25% Breite auf jeder Seite + + float minX = Math.min(ax, bx) - paintHW; + float maxX = Math.max(ax, bx) + paintHW; + float minZ = Math.min(az, bz) - paintHW; + float maxZ = Math.max(az, bz) + paintHW; + + int pxMin = Math.max(0, (int)((minX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1); + int pxMax = Math.min(SPLAT_SIZE - 1, (int)((maxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1); + // Z-Achse ist in der Splatmap gespiegelt + int pzMin = Math.max(0, (SPLAT_SIZE - 1) - (int)((maxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1); + int pzMax = Math.min(SPLAT_SIZE - 1, (SPLAT_SIZE - 1) - (int)((minZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1); + + boolean splatChanged = false; + for (int pz = pzMin; pz <= pzMax; pz++) { + float worldZ = (SPLAT_SIZE - 1 - pz) * SPLAT_WE_PER_PX - WORLD_HALF; + for (int px = pxMin; px <= pxMax; px++) { + float worldX = px * SPLAT_WE_PER_PX - WORLD_HALF; + + float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2; + t = FastMath.clamp(t, 0f, 1f); + + float projX = ax + t * segDx, projZ = az + t * segDz; + float dist = FastMath.sqrt((worldX - projX) * (worldX - projX) + + (worldZ - projZ) * (worldZ - projZ)); + if (dist > paintHW) continue; + + int sidx = pz * SPLAT_SIZE + px; + splatR[sidx] = (byte) 255; + splatG[sidx] = (byte) 0; + splatB[sidx] = (byte) 0; + splatA[sidx] = (byte) 255; + + int bi = sidx * 4; + splatBuf.put(bi, (byte) 255); + splatBuf.put(bi + 1, (byte) 0); + splatBuf.put(bi + 2, (byte) 0); + splatBuf.put(bi + 3, (byte) 255); + splatChanged = true; + } + } + if (splatChanged) { + splatBuf.rewind(); + splatImage.setUpdateNeeded(); + } + } + // ── Speichern ───────────────────────────────────────────────────────────── private void performSave() { @@ -702,8 +842,13 @@ public class TerrainEditorState extends BaseAppState { } if (placedObjectState != null) { - System.arraycopy(placedObjectState.getDensityMap(), 0, - data.grassDensity, 0, data.grassDensity.length); + try { + GrassTuftIO.save(new GrassTuftIO.GrassData( + placedObjectState.getSlotPaths(), + placedObjectState.getAllTufts())); + } catch (IOException e) { + log.error("Gras nicht speicherbar", e); + } } MapIO.save(data); @@ -719,6 +864,9 @@ public class TerrainEditorState extends BaseAppState { if (waterBodyState != null) { WaterBodyIO.save(waterBodyState.getPlacedBodies()); } + if (riverEditorState != null) { + de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers()); + } if (soundAreaState != null) { SoundAreaIO.save(soundAreaState.getPlacedAreas()); } @@ -726,10 +874,10 @@ public class TerrainEditorState extends BaseAppState { MusicAreaIO.save(musicAreaState.getPlacedAreas()); } input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath(); - System.out.println("[TerrainEditor] " + input.saveStatusMsg); + log.info("{}", input.saveStatusMsg); } catch (IOException e) { input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage(); - System.err.println("[TerrainEditor] " + input.saveStatusMsg); + log.error("Speichern fehlgeschlagen", e); } } @@ -820,6 +968,7 @@ public class TerrainEditorState extends BaseAppState { Vector3f contact = hits.getClosestCollision().getContactPoint(); int mode = input.heightTool.mode.getSelectedIndex(); + boolean terrainChanged = true; if (mode == HeightTool.MODE_SMOOTH) { smoothHeight(contact); } else if (mode == HeightTool.MODE_PLATEAU) { @@ -830,6 +979,7 @@ public class TerrainEditorState extends BaseAppState { input.heightTool.plateauHeight.setValue(h); input.heightTool.plateauHeightChanged = true; } + terrainChanged = false; } else { // Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen flattenToPlateauHeight(contact); @@ -838,6 +988,10 @@ public class TerrainEditorState extends BaseAppState { float delta = (float) input.heightTool.brushStrength.getValue() * edit.action(); modifyHeight(contact, delta, mode); } + if (terrainChanged && waterBodyState != null) { + float r = (float) input.heightTool.brushRadius.getValue(); + waterBodyState.invalidateNear(contact.x, contact.z, r); + } } if (processed > 0) terrain.updateModelBound(); } @@ -1224,4 +1378,28 @@ public class TerrainEditorState extends BaseAppState { geo.setCullHint(Spatial.CullHint.Always); return geo; } + + // ── Live-Spieler-Marker ─────────────────────────────────────────────────── + + private Geometry buildLivePlayerMarker() { + com.jme3.scene.shape.Cylinder cyl = + new com.jme3.scene.shape.Cylinder(8, 8, 0.3f, 1.8f, true); + Geometry geo = new Geometry("livePlayerMarker", cyl); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.45f, 0f, 1f)); + geo.setMaterial(mat); + // Cylinder-Achse liegt entlang Y → keine Rotation nötig + geo.setCullHint(Spatial.CullHint.Always); + return geo; + } + + private void updateLivePlayerMarker() { + float x = input.livePlayerX; + if (Float.isNaN(x)) { + livePlayerMarker.setCullHint(Spatial.CullHint.Always); + } else { + livePlayerMarker.setCullHint(Spatial.CullHint.Inherit); + livePlayerMarker.setLocalTranslation(x, input.livePlayerY + 0.9f, input.livePlayerZ); + } + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java index a55beb2..7a93d6e 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TreeGeneratorState.java @@ -292,14 +292,11 @@ public class TreeGeneratorState extends BaseAppState { app.getRenderer().readFrameBuffer(captureFB, pixels); cleanupCapture(); - String baseName = pendingRequest.exportName(); - String exportName = pendingRequest.exportAfter() - ? baseName + "_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()) - : baseName; + String treeType = pendingRequest.treeType(); + String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now()); - Texture2D impostorTex = saveImpostor(pixels, "impostor_" + exportName); + Texture2D impostorTex = saveImpostor(pixels, "impostor_" + treeType + "_" + timestamp); - // HD-Mesh im Dialog-Preview anzeigen (keine LOD-Umschaltung, kein Welt-Platzierung) Node previewTree = makeTreeNode(pendingHdResult, pendingBarkMat.clone(), pendingLeafMat.clone(), "prev"); previewTreeHolder.detachAllChildren(); @@ -311,10 +308,11 @@ public class TreeGeneratorState extends BaseAppState { Math.max(bb.getYExtent(), bb.getZExtent())) * 3f; if (pendingRequest.exportAfter()) { + float treeHeight = bb.getCenter().y + bb.getYExtent(); Node treeNode = assembleLodNode(impostorTex); - exportTree(treeNode, exportName); + exportTree(treeNode, treeType, timestamp, treeHeight); } else { - input.treeGenStatusMsg = "Vorschau: '" + baseName + "'"; + input.treeGenStatusMsg = "Vorschau: " + treeType; } pendingRequest = null; @@ -328,7 +326,7 @@ public class TreeGeneratorState extends BaseAppState { // ── LOD-Aufbau ──────────────────────────────────────────────────────────── private Node assembleLodNode(Texture2D impostorTex) { - Node root = new Node("GeneratedTree_" + pendingRequest.exportName()); + Node root = new Node(pendingRequest.treeType()); root.attachChild(pendingHdNode); root.attachChild(pendingLdNode); @@ -557,18 +555,19 @@ public class TreeGeneratorState extends BaseAppState { // ── .j3o-Export ─────────────────────────────────────────────────────────── - private void exportTree(Node treeNode, String name) { + private void exportTree(Node treeNode, String treeType, String timestamp, float height) { try { - Path modelDir = ASSET_ROOT.resolve("Models"); - Files.createDirectories(modelDir); - File out = modelDir.resolve("GeneratedTree_" + name + ".j3o").toFile(); - // Strip runtime controls before export — they lack no-arg constructors - // and cannot be deserialized by BinaryImporter. + String sizeClass = height < 6f ? "small" : height < 14f ? "medium" : "large"; + String fileName = treeType + "_" + sizeClass + "_" + timestamp; + Path dir = ASSET_ROOT.resolve("Models").resolve("trees") + .resolve(treeType).resolve(sizeClass); + Files.createDirectories(dir); + File out = dir.resolve(fileName + ".j3o").toFile(); while (treeNode.getNumControls() > 0) treeNode.removeControl(treeNode.getControl(0)); BinaryExporter.getInstance().save(treeNode, out); log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath()); - input.treeGenStatusMsg = "Exportiert: " + out.getName(); + input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o"; input.refreshAssets = true; } catch (IOException e) { log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage()); diff --git a/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java index e6a407f..335525c 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/WaterBodyState.java @@ -10,8 +10,10 @@ import com.jme3.material.RenderState; import com.jme3.math.*; import com.jme3.renderer.Camera; import com.jme3.renderer.queue.RenderQueue; -import com.jme3.scene.*; -import com.jme3.scene.shape.Quad; +import com.jme3.scene.Geometry; +import com.jme3.scene.Mesh; +import com.jme3.scene.Node; +import com.jme3.scene.VertexBuffer; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.util.BufferUtils; import de.blight.common.PlacedWater; @@ -19,17 +21,25 @@ import de.blight.editor.SharedInput; import java.nio.FloatBuffer; import java.nio.IntBuffer; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +/** + * Platziert und visualisiert Wasserflächen per Flood-Fill aus dem Gelände. + * + * Raster: 2 WE pro Pixel (WATER_GRID = 2049, STEP = 2). + * BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen. + */ public class WaterBodyState extends BaseAppState { - private static final ColorRGBA WATER_COLOR = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.52f); - private static final ColorRGBA BORDER_COLOR = new ColorRGBA(0.30f, 0.60f, 1.00f, 0.85f); - private static final ColorRGBA BORDER_SEL = new ColorRGBA(1.00f, 1.00f, 0.00f, 1.00f); + // Flood-Fill-Raster mit 1 WE Auflösung (= volle HeightMap-Auflösung) + private static final int TOTAL_VERTS = 4097; + private static final int WATER_GRID = 4097; // (4096 / STEP) + 1 + private static final int STEP = 1; // WE pro Gitterpixel + private static final int WORLD_HALF = 2048; + private static final int MAX_CELLS = 200_000; - private static final String GEO_SURFACE = "water_surface"; - private static final String GEO_BORDER = "water_border"; + private static final ColorRGBA COLOR_WATER = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.50f); + private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(0.20f, 0.60f, 1.00f, 0.70f); private final SharedInput input; private SimpleApplication app; @@ -37,17 +47,20 @@ public class WaterBodyState extends BaseAppState { private AssetManager assets; private Node rootNode; private TerrainQuad terrain; + private float[] heightMap; - // parallel lists - private final List bodies = new ArrayList<>(); - private final List markers = new ArrayList<>(); + private final List bodies = new ArrayList<>(); + private final List> cellSets = new ArrayList<>(); + private final List geos = new ArrayList<>(); + private final List bodyBounds = new ArrayList<>(); // {minX,minZ,maxX,maxZ} - private int selectedIdx = -1; - private List pendingBodies = null; + private int selectedIdx = -1; + private List pendingLoad = null; - public WaterBodyState(SharedInput input) { - this.input = input; - } + public WaterBodyState(SharedInput input) { this.input = input; } + + public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } + public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; } // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -63,16 +76,11 @@ public class WaterBodyState extends BaseAppState { @Override protected void onEnable() { - if (pendingBodies != null) { - loadPlacedBodies(pendingBodies); - pendingBodies = null; - } + if (pendingLoad != null) { loadPlacedBodies(pendingLoad); pendingLoad = null; } } @Override protected void onDisable() {} - public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } - // ── Update ──────────────────────────────────────────────────────────────── @Override @@ -80,14 +88,10 @@ public class WaterBodyState extends BaseAppState { if (input.activeLayer != SharedInput.LAYER_WATER) return; SharedInput.WaterClick click; - while ((click = input.waterClickQueue.poll()) != null) { - handleClick(click); - } + while ((click = input.waterClickQueue.poll()) != null) handleClick(click); PlacedWater pending = input.pendingWater.getAndSet(null); - if (pending != null && selectedIdx >= 0) { - applyProperty(selectedIdx, pending); - } + if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight()); if (input.deleteWaterRequested) { input.deleteWaterRequested = false; @@ -95,57 +99,56 @@ public class WaterBodyState extends BaseAppState { } } - // ── Click handling ──────────────────────────────────────────────────────── + // ── Click-Handling ──────────────────────────────────────────────────────── private void handleClick(SharedInput.WaterClick click) { float jmeX = click.screenX() * (float) input.viewportScaleX; float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; - Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); - int hit = pickMarker(ray); - if (hit >= 0) { - if (click.rightButton()) deselect(); - else selectBody(hit); - return; - } - if (click.rightButton()) { deselect(); return; } + int hit = pickBody(ray); + if (hit >= 0) { selectBody(hit); return; } + if (terrain == null) return; CollisionResults hits = new CollisionResults(); terrain.collideWith(ray, hits); if (hits.size() == 0) return; Vector3f pt = hits.getClosestCollision().getContactPoint(); - addBody(new PlacedWater(pt.x, pt.y + 0.05f, pt.z, 30f, 30f)); + Set cells = floodFill(pt.x, pt.z, pt.y); + if (cells == null) { + input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle."; + return; + } + addBody(new PlacedWater(pt.x, pt.z, pt.y), cells); selectBody(bodies.size() - 1); } - private int pickMarker(Ray ray) { - for (int i = 0; i < markers.size(); i++) { + private int pickBody(Ray ray) { + for (int i = 0; i < geos.size(); i++) { CollisionResults res = new CollisionResults(); - markers.get(i).collideWith(ray, res); + geos.get(i).collideWith(ray, res); if (res.size() > 0) return i; } return -1; } - // ── Selection ───────────────────────────────────────────────────────────── + // ── Selektion ───────────────────────────────────────────────────────────── private void selectBody(int idx) { deselect(); selectedIdx = idx; - setBorderColor(idx, BORDER_SEL); + geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); publishSelection(idx); } private void deselect() { - if (selectedIdx >= 0 && selectedIdx < bodies.size()) { - setBorderColor(selectedIdx, BORDER_COLOR); - } + if (selectedIdx >= 0 && selectedIdx < geos.size()) + geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER); selectedIdx = -1; input.selectedWaterInfo = null; input.waterSelectionChanged = true; @@ -154,120 +157,196 @@ public class WaterBodyState extends BaseAppState { private void publishSelection(int idx) { PlacedWater b = bodies.get(idx); input.selectedWaterInfo = String.format(java.util.Locale.ROOT, - "%d|%.3f|%.3f|%.3f|%.3f|%.3f", - idx, b.x(), b.y(), b.z(), b.width(), b.depth()); + "%d|%.3f|%.3f|%.3f|%d", + idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size()); input.waterSelectionChanged = true; } - // ── Add / Remove ────────────────────────────────────────────────────────── + // ── Hinzufügen / Entfernen ──────────────────────────────────────────────── - private void addBody(PlacedWater b) { - Node marker = buildMarker(b); - rootNode.attachChild(marker); - markers.add(marker); - bodies.add(b); + private void addBody(PlacedWater body, Set cells) { + Geometry geo = buildWaterGeo(cells, body.waterHeight()); + rootNode.attachChild(geo); + bodies.add(body); + cellSets.add(cells); + geos.add(geo); + bodyBounds.add(computeBounds(cells)); } private void removeBody(int idx) { - rootNode.detachChild(markers.get(idx)); + rootNode.detachChild(geos.get(idx)); bodies.remove(idx); - markers.remove(idx); + cellSets.remove(idx); + geos.remove(idx); + bodyBounds.remove(idx); selectedIdx = -1; input.selectedWaterInfo = null; input.waterSelectionChanged = true; } private void clearAll() { - for (Node m : markers) rootNode.detachChild(m); + for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g); bodies.clear(); - markers.clear(); + cellSets.clear(); + geos.clear(); + bodyBounds.clear(); selectedIdx = -1; } - // ── Property application ────────────────────────────────────────────────── - - private void applyProperty(int idx, PlacedWater updated) { - rootNode.detachChild(markers.get(idx)); - Node newMarker = buildMarker(updated); - setBorderColorOnNode(newMarker, BORDER_SEL); - rootNode.attachChild(newMarker); - markers.set(idx, newMarker); - bodies.set(idx, updated); - publishSelection(idx); - } - - // ── Marker visuals ──────────────────────────────────────────────────────── - - private Node buildMarker(PlacedWater b) { - // Water surface (semi-transparent quad) - Quad quad = new Quad(b.width(), b.depth()); - Geometry surface = new Geometry(GEO_SURFACE, quad); - Material waterMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - waterMat.setColor("Color", WATER_COLOR); - waterMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); - waterMat.getAdditionalRenderState().setDepthWrite(false); - surface.setMaterial(waterMat); - surface.setQueueBucket(RenderQueue.Bucket.Transparent); - surface.rotate(-FastMath.HALF_PI, 0, 0); - surface.setLocalTranslation(-b.width() * 0.5f, 0f, b.depth() * 0.5f); - - // Border outline (Line mesh forming a rectangle) - Geometry border = new Geometry(GEO_BORDER, buildBorderMesh(b.width(), b.depth())); - Material borderMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - borderMat.setColor("Color", BORDER_COLOR); - borderMat.getAdditionalRenderState().setDepthTest(false); - border.setMaterial(borderMat); - - Node node = new Node("water_node"); - node.attachChild(surface); - node.attachChild(border); - node.setLocalTranslation(b.x(), b.y(), b.z()); - return node; - } - - private static Mesh buildBorderMesh(float w, float d) { - // 4 corner points at +0.02 above water surface (local coords, XZ plane) - float hw = w * 0.5f, hd = d * 0.5f, y = 0.02f; - FloatBuffer pos = BufferUtils.createFloatBuffer(4 * 3); - pos.put(-hw).put(y).put(-hd); - pos.put( hw).put(y).put(-hd); - pos.put( hw).put(y).put( hd); - pos.put(-hw).put(y).put( hd); - IntBuffer idx = BufferUtils.createIntBuffer(8); // 4 edges - idx.put(0).put(1).put(1).put(2).put(2).put(3).put(3).put(0); - Mesh mesh = new Mesh(); - mesh.setMode(Mesh.Mode.Lines); - mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); - mesh.setBuffer(VertexBuffer.Type.Index, 2, idx); - mesh.updateBound(); - return mesh; - } - - private void setBorderColor(int idx, ColorRGBA color) { - setBorderColorOnNode(markers.get(idx), color); - } - - private static void setBorderColorOnNode(Node node, ColorRGBA color) { - for (Spatial child : node.getChildren()) { - if (child instanceof Geometry geo && GEO_BORDER.equals(geo.getName())) { - geo.getMaterial().setColor("Color", color); - return; + /** + * Löscht alle Wasserflächen, deren AABB den übergebenen Pinselkreis berührt. + * Wird von TerrainEditorState nach jeder Geländeänderung aufgerufen. + */ + public void invalidateNear(float worldX, float worldZ, float brushRadius) { + for (int i = bodies.size() - 1; i >= 0; i--) { + float[] b = bodyBounds.get(i); + // Nächster Punkt auf AABB zum Kreismittelpunkt + float nearX = Math.max(b[0], Math.min(worldX, b[2])); + float nearZ = Math.max(b[1], Math.min(worldZ, b[3])); + float dx = worldX - nearX, dz = worldZ - nearZ; + if (dx * dx + dz * dz <= brushRadius * brushRadius) { + removeBody(i); } } } - // ── Save / Load ─────────────────────────────────────────────────────────── - - public List getPlacedBodies() { - return new ArrayList<>(bodies); + private static float[] computeBounds(Set cells) { + float minX = Float.MAX_VALUE, minZ = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE, maxZ = -Float.MAX_VALUE; + for (int cell : cells) { + float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF; + float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF; + if (wx < minX) minX = wx; + if (wz < minZ) minZ = wz; + if (wx + STEP > maxX) maxX = wx + STEP; + if (wz + STEP > maxZ) maxZ = wz + STEP; + } + return new float[]{minX, minZ, maxX, maxZ}; } - public void loadPlacedBodies(List loaded) { - if (rootNode == null) { - pendingBodies = new ArrayList<>(loaded); + // ── Höhe ändern ─────────────────────────────────────────────────────────── + + private void applyHeightChange(int idx, float newHeight) { + PlacedWater b = bodies.get(idx); + Set newCells = floodFill(b.seedX(), b.seedZ(), newHeight); + if (newCells == null) { + input.waterHint = "Ungültige Höhe – Becken bei dieser Höhe nicht eingeschlossen."; return; } + rootNode.detachChild(geos.get(idx)); + Geometry newGeo = buildWaterGeo(newCells, newHeight); + newGeo.getMaterial().setColor("Color", COLOR_SELECTED); + rootNode.attachChild(newGeo); + bodies.set(idx, new PlacedWater(b.seedX(), b.seedZ(), newHeight)); + cellSets.set(idx, newCells); + geos.set(idx, newGeo); + publishSelection(idx); + } + + // ── Flood-Fill ──────────────────────────────────────────────────────────── + + private Set floodFill(float seedWorldX, float seedWorldZ, float waterHeight) { + int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP); + int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP); + seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX)); + seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ)); + + if (sampleHeight(seedPX, seedPZ) > waterHeight + 0.05f) return null; + + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + visited.add(seedPZ * WATER_GRID + seedPX); + queue.add(new int[]{seedPX, seedPZ}); + + final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}}; + while (!queue.isEmpty()) { + int[] c = queue.poll(); + int px = c[0], pz = c[1]; + + if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1) + return null; + + for (int[] d : dirs) { + int nx = px + d[0], nz = pz + d[1]; + int nIdx = nz * WATER_GRID + nx; + if (visited.contains(nIdx)) continue; + if (sampleHeight(nx, nz) <= waterHeight) { + visited.add(nIdx); + if (visited.size() > MAX_CELLS) return null; + queue.add(new int[]{nx, nz}); + } + } + } + return visited.isEmpty() ? null : visited; + } + + private float sampleHeight(int px, int pz) { + if (heightMap != null) { + int vx = Math.min(px * STEP, TOTAL_VERTS - 1); + int vz = Math.min(pz * STEP, TOTAL_VERTS - 1); + return heightMap[vz * TOTAL_VERTS + vx]; + } + if (terrain != null) { + float worldX = px * STEP - WORLD_HALF; + float worldZ = pz * STEP - WORLD_HALF; + Float h = terrain.getHeight(new Vector2f(worldX, worldZ)); + return (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE; + } + return Float.MAX_VALUE; + } + + // ── Mesh-Aufbau (für Editor-Vorschau) ──────────────────────────────────── + + private Geometry buildWaterGeo(Set cells, float waterHeight) { + int n = cells.size(); + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3); + IntBuffer idx = BufferUtils.createIntBuffer(n * 6); + int vi = 0; + float h = waterHeight + 0.05f; + for (int cell : cells) { + int pz = cell / WATER_GRID; + int px = cell % WATER_GRID; + float wx = px * STEP - WORLD_HALF; + float wz = pz * STEP - WORLD_HALF; + pos.put(wx ).put(h).put(wz ); + pos.put(wx + STEP).put(h).put(wz ); + pos.put(wx + STEP).put(h).put(wz + STEP); + pos.put(wx ).put(h).put(wz + STEP); + idx.put(vi).put(vi+1).put(vi+2); + idx.put(vi).put(vi+2).put(vi+3); + vi += 4; + } + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Index, 3, idx); + mesh.updateBound(); + + Geometry geo = new Geometry("water_body", mesh); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", COLOR_WATER); + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); + mat.getAdditionalRenderState().setDepthWrite(false); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + geo.setMaterial(mat); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + return geo; + } + + // ── Speichern / Laden ───────────────────────────────────────────────────── + + public List getPlacedBodies() { return new ArrayList<>(bodies); } + + public void loadPlacedBodies(List loaded) { + if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; } clearAll(); - for (PlacedWater b : loaded) addBody(b); + for (PlacedWater b : loaded) { + Set cells = floodFill(b.seedX(), b.seedZ(), b.waterHeight()); + if (cells != null) { + addBody(b, cells); + } else { + System.err.println("[WaterBodyState] Becken nicht rekonstruierbar: " + + b.seedX() + "/" + b.seedZ()); + } + } } } diff --git a/blight-editor/src/main/java/de/blight/editor/tool/GrassTool.java b/blight-editor/src/main/java/de/blight/editor/tool/GrassTool.java index adb803d..20266c3 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/GrassTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/GrassTool.java @@ -9,7 +9,7 @@ public class GrassTool extends EditorTool { public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 40.0, 1.0, 500.0); public final ToolParameter grassHeight = new ToolParameter("Grashöhe", 1.5, 0.1, 10.0); - public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 50.0); + public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 200.0); @Override public String getName() { return "Gras"; } diff --git a/blight-editor/src/main/resources/icon_editor.png b/blight-editor/src/main/resources/icon_editor.png new file mode 100644 index 0000000..cb33e74 Binary files /dev/null and b/blight-editor/src/main/resources/icon_editor.png differ diff --git a/blight-editor/src/main/resources/logo.png b/blight-editor/src/main/resources/logo.png new file mode 100644 index 0000000..f9b3cb7 Binary files /dev/null and b/blight-editor/src/main/resources/logo.png differ diff --git a/blight-game/build.gradle b/blight-game/build.gradle index 5dcc95f..b9c3dd8 100644 --- a/blight-game/build.gradle +++ b/blight-game/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'com.google.code.gson:gson:2.11.0' implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'org.slf4j:jul-to-slf4j:2.0.17' + runtimeOnly 'ch.qos.logback:logback-classic:1.5.18' compileOnly 'org.projectlombok:lombok:1.18.38' annotationProcessor 'org.projectlombok:lombok:1.18.38' } diff --git a/blight-game/src/main/java/de/blight/game/BlightGame.java b/blight-game/src/main/java/de/blight/game/BlightGame.java index 82af370..800439f 100644 --- a/blight-game/src/main/java/de/blight/game/BlightGame.java +++ b/blight-game/src/main/java/de/blight/game/BlightGame.java @@ -1,96 +1,164 @@ -package de.blight.game; - -import com.jme3.app.SimpleApplication; -import com.jme3.input.KeyInput; -import com.jme3.input.controls.ActionListener; -import com.jme3.input.controls.KeyTrigger; -import com.jme3.system.AppSettings; -import de.blight.game.config.*; -import org.slf4j.bridge.SLF4JBridgeHandler; -import de.blight.game.scene.WorldScene; - -public class BlightGame extends SimpleApplication { - - private KeyBindings keyBindings; - private GraphicsSettings graphicsSettings; - private WorldScene worldScene; - private ConfigScreen configScreen; - private GraphicsScreen graphicsScreen; - private PauseMenu pauseMenu; - - public static void main(String[] args) { - SLF4JBridgeHandler.removeHandlersForRootLogger(); - SLF4JBridgeHandler.install(); - - BlightGame app = new BlightGame(); - - GraphicsSettings gs = GraphicsStore.load(); - AppSettings settings = new AppSettings(true); - settings.setTitle("Blight"); - settings.setResolution(gs.width, gs.height); - settings.setFullscreen(gs.fullscreen); - settings.setVSync(gs.vsync); - settings.setSamples(gs.samples); - - app.setSettings(settings); - app.setShowSettings(false); - app.start(); - } - - @Override - public void simpleInitApp() { - flyCam.setEnabled(false); - inputManager.deleteMapping(INPUT_MAPPING_EXIT); - - keyBindings = KeyBindingStore.load(); - graphicsSettings = GraphicsStore.load(); - - worldScene = new WorldScene(keyBindings); - stateManager.attach(worldScene); - - configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings)); - configScreen.setOnClose(() -> pauseMenu.setEnabled(true)); - stateManager.attach(configScreen); - configScreen.setEnabled(false); - - graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true)); - stateManager.attach(graphicsScreen); - graphicsScreen.setEnabled(false); - - pauseMenu = new PauseMenu( - () -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); }, - () -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); } - ); - stateManager.attach(pauseMenu); - pauseMenu.setEnabled(false); - - inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE)); - inputManager.addListener((ActionListener) (name, isPressed, tpf) -> { - if (!isPressed) return; - - if (graphicsScreen.isEnabled()) { - // GraphicsScreen wird nur über seine eigenen Buttons geschlossen - return; - } - if (configScreen.isEnabled()) { - if (configScreen.isWaiting()) { - configScreen.cancelWaiting(); - } else { - configScreen.setEnabled(false); - pauseMenu.setEnabled(true); - } - return; - } - if (pauseMenu.isEnabled()) { - pauseMenu.setEnabled(false); - worldScene.setPaused(false); - return; - } - pauseMenu.setEnabled(true); - worldScene.setPaused(true); - }, "ToggleMenu"); - } - - @Override - public void simpleUpdate(float tpf) {} -} +package de.blight.game; + +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.ScreenshotAppState; +import com.jme3.input.KeyInput; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.KeyTrigger; +import com.jme3.system.AppSettings; +import de.blight.game.config.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.bridge.SLF4JBridgeHandler; +import de.blight.game.scene.WorldScene; + +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class BlightGame extends SimpleApplication { + + private static final Logger log = LoggerFactory.getLogger(BlightGame.class); + + private KeyBindings keyBindings; + private GraphicsSettings graphicsSettings; + private ScreenshotAppState screenshotState; + private WorldScene worldScene; + private ConfigScreen configScreen; + private GraphicsScreen graphicsScreen; + private PauseMenu pauseMenu; + + private JWindow splashWindow; + + public static void main(String[] args) { + SLF4JBridgeHandler.removeHandlersForRootLogger(); + SLF4JBridgeHandler.install(); + + BlightGame app = new BlightGame(); + app.splashWindow = showSplash(); + + GraphicsSettings gs = GraphicsStore.load(); + AppSettings settings = new AppSettings(true); + settings.setTitle("Blight"); + try { + settings.setIcons(new Object[]{ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"))}); + } catch (IOException | NullPointerException ignored) {} + settings.setResolution(gs.width, gs.height); + settings.setFullscreen(gs.fullscreen); + settings.setVSync(gs.vsync); + settings.setSamples(gs.samples); + + app.setSettings(settings); + app.setShowSettings(false); + app.start(); + } + + private static JWindow showSplash() { + try { + BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png")); + BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png")); + JWindow win = new JWindow(); + if (icon != null) win.setIconImages(java.util.List.of(icon)); + win.getContentPane().add(new JLabel(new ImageIcon(img))); + win.pack(); + win.setLocationRelativeTo(null); + win.setVisible(true); + return win; + } catch (IOException | NullPointerException ignored) { + return null; + } + } + + @Override + public void simpleInitApp() { + if (splashWindow != null) { + SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; }); + } + + flyCam.setEnabled(false); + inputManager.deleteMapping(INPUT_MAPPING_EXIT); + + keyBindings = KeyBindingStore.load(); + graphicsSettings = GraphicsStore.load(); + + worldScene = new WorldScene(keyBindings); + stateManager.attach(worldScene); + + configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings)); + configScreen.setOnClose(() -> pauseMenu.setEnabled(true)); + stateManager.attach(configScreen); + configScreen.setEnabled(false); + + graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true)); + stateManager.attach(graphicsScreen); + graphicsScreen.setEnabled(false); + + pauseMenu = new PauseMenu( + () -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); }, + () -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); } + ); + stateManager.attach(pauseMenu); + pauseMenu.setEnabled(false); + + // ── Screenshot (Druck-Taste) ─────────────────────────────────────────── + try { + Path screenshotDir = findProjectRoot().resolve("screenshots"); + Files.createDirectories(screenshotDir); + screenshotState = new ScreenshotAppState(screenshotDir + File.separator, "screenshot"); + stateManager.attach(screenshotState); + log.info("Screenshots werden gespeichert in: {}", screenshotDir.toAbsolutePath()); + } catch (IOException e) { + log.warn("Screenshot-Verzeichnis konnte nicht angelegt werden", e); + } + inputManager.addMapping("Screenshot", new KeyTrigger(KeyInput.KEY_SYSRQ)); + inputManager.addListener((ActionListener) (name, isPressed, tpf) -> { + if (isPressed && screenshotState != null) screenshotState.takeScreenshot(); + }, "Screenshot"); + + inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE)); + inputManager.addListener((ActionListener) (name, isPressed, tpf) -> { + if (!isPressed) return; + + if (graphicsScreen.isEnabled()) { + return; + } + if (configScreen.isEnabled()) { + if (configScreen.isWaiting()) { + configScreen.cancelWaiting(); + } else { + configScreen.setEnabled(false); + pauseMenu.setEnabled(true); + } + return; + } + if (pauseMenu.isEnabled()) { + pauseMenu.setEnabled(false); + worldScene.setPaused(false); + return; + } + pauseMenu.setEnabled(true); + worldScene.setPaused(true); + }, "ToggleMenu"); + } + + @Override + public void simpleUpdate(float tpf) {} + + private static Path findProjectRoot() { + String prop = System.getProperty("blight.project.root"); + if (prop != null) return Paths.get(prop); + File dir = Paths.get(".").toAbsolutePath().normalize().toFile(); + while (dir != null) { + if (new File(dir, "blight-editor").isDirectory() + && new File(dir, "blight-game").isDirectory()) + return dir.toPath(); + dir = dir.getParentFile(); + } + return Paths.get(".").toAbsolutePath().normalize(); + } +} diff --git a/blight-game/src/main/java/de/blight/game/LiveBroadcast.java b/blight-game/src/main/java/de/blight/game/LiveBroadcast.java new file mode 100644 index 0000000..8d339c0 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/LiveBroadcast.java @@ -0,0 +1,29 @@ +package de.blight.game; + +import de.blight.common.MapIO; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Schreibt Live-Daten (Spielerposition) in eine Temp-Datei neben der Karte, + * damit der Editor sie live anzeigen kann. + */ +public final class LiveBroadcast { + + public static final Path POS_FILE = + MapIO.getMapPath().resolveSibling("blight_live.pos"); + + private LiveBroadcast() {} + + public static void writePosition(float x, float y, float z) { + try { + Files.writeString(POS_FILE, x + "|" + y + "|" + z); + } catch (IOException ignored) {} + } + + public static void clear() { + try { Files.deleteIfExists(POS_FILE); } catch (IOException ignored) {} + } +} 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 ec5c4e9..0f44272 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 @@ -14,8 +14,8 @@ import java.util.Map; * Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die * Zuordnung semantischer Aktionen (IDLE, WALK, …) zu Clip-Namen. * - * Wird als {@code .animset.json} neben der {@code .j3o}-Datei gespeichert - * und ersetzt sowohl die alte {@code .clips.json} als auch die {@code .animmap}-Datei. + * Wird als {@code animations/sets/.animset.json} gespeichert. + * Die Clip-Dateien liegen als eigenständige .j3o in {@code animations/clips/}. */ public class AnimSet { @@ -44,26 +44,4 @@ public class AnimSet { if (!Files.exists(f)) return new AnimSet(); return GSON.fromJson(Files.readString(f), AnimSet.class); } - - /** - * Lädt ein AnimSet anhand des Asset-Pfades der zugehörigen {@code .j3o}-Datei. - * - * @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis - * @param j3oAssetPath relativer Pfad zur {@code .j3o}-Datei (z. B. {@code "animations/sets/foo.j3o"}) - */ - public static AnimSet loadByJ3oPath(Path assetRoot, String j3oAssetPath) { - Path j3o = assetRoot.resolve(j3oAssetPath.replace('/', java.io.File.separatorChar)); - String name = j3o.getFileName().toString().replaceFirst("\\.j3o$", ""); - try { - return load(j3o.getParent(), name); - } catch (IOException e) { - return new AnimSet(); - } - } - - /** Gibt den Companion-Pfad der {@code .animset.json}-Datei neben einer {@code .j3o}-Datei zurück. */ - public static Path companionPath(Path j3oPath) { - String name = j3oPath.getFileName().toString().replaceFirst("\\.j3o$", ""); - return j3oPath.getParent().resolve(name + SUFFIX); - } } diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index 4a5d7ec..f0a1969 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 @@ -5,20 +5,27 @@ package de.blight.game.animation; * Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen. */ public enum AnimationAction { - IDLE, + DEFAULT, + IDLE, WALK, RUN, + SPRINT, JUMP, + RUNNING_JUMP, DUCK; + /** Lesbare Bezeichnung für UI-Anzeige. */ public String displayName() { return switch (this) { - case IDLE -> "Idle"; + case DEFAULT -> "Default"; + case IDLE -> "Idle"; case WALK -> "Walk"; case RUN -> "Run"; - case JUMP -> "Jump"; - case DUCK -> "Duck"; + case SPRINT -> "Sprint"; + case JUMP -> "Jump"; + case RUNNING_JUMP -> "Running Jump"; + case DUCK -> "Duck"; }; } } diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java b/blight-game/src/main/java/de/blight/game/animation/AnimationLibrary.java index c71942a..68ce6fc 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 @@ -14,19 +14,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; -import java.util.stream.Collectors; /** - * Loads all .j3o animation files from the animations/ asset folder at startup. - * Provides retargeted animation clips for any model with a SkinningControl. - * - * Clip keys follow the pattern "filename/clipname" (e.g. "walk/Run"). + * Lädt alle Clip-Dateien aus {@code animations/clips/} beim Start. + * Clip-Schlüssel entsprechen dem Dateinamen ohne Extension (= Clip-Name). */ public class AnimationLibrary extends BaseAppState { private static final Logger log = LoggerFactory.getLogger(AnimationLibrary.class); - // Possible base paths for the animations folder (relative to working dir) private static final String[] ASSET_BASES = { "blight-assets/src/main/resources", "assets", @@ -35,9 +31,9 @@ public class AnimationLibrary extends BaseAppState { private AssetManager assetManager; - /** clip key → clip (bound to the SOURCE armature; retargeted before use) */ + /** clip name → clip (an Quell-Armatur gebunden; wird bei Bedarf retargeted) */ private final Map clips = new LinkedHashMap<>(); - /** clip key → armature the clip was loaded from */ + /** clip name → Armatur der Quell-Datei */ private final Map armatures = new LinkedHashMap<>(); // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -45,6 +41,15 @@ public class AnimationLibrary extends BaseAppState { @Override protected void initialize(Application app) { assetManager = ((SimpleApplication) app).getAssetManager(); + Path assetRoot = findAssetRoot(); + try { + assetManager.registerLocator( + assetRoot.toAbsolutePath().toString(), + com.jme3.asset.plugins.FileLocator.class); + log.info("[AnimLib] Asset-Root registriert: {}", assetRoot.toAbsolutePath()); + } catch (Exception e) { + log.warn("[AnimLib] Asset-Root konnte nicht registriert werden: {}", e.getMessage()); + } loadAll(); } @@ -54,51 +59,60 @@ public class AnimationLibrary extends BaseAppState { // ── Public API ──────────────────────────────────────────────────────────── - /** All loaded clip keys (filename/clipname). */ + /** Alle geladenen Clip-Namen. */ public Collection getClipKeys() { return Collections.unmodifiableSet(clips.keySet()); } /** - * Retargets the clip to {@code model}'s skeleton and registers it - * in the model's AnimComposer (idempotent). + * Retargeted den Clip auf das Skeleton von {@code model} und registriert + * ihn im AnimComposer des Modells (idempotent). * - * @return true if the clip was applied successfully + * @return true wenn der Clip erfolgreich angewendet wurde */ - public boolean applyTo(String clipKey, Spatial model) { - AnimClip src = clips.get(clipKey); - Armature srcArm = armatures.get(clipKey); - if (src == null) return false; + public boolean applyTo(String clipName, Spatial model) { + AnimClip src = clips.get(clipName); + Armature srcArm = armatures.get(clipName); + if (src == null) { + log.warn("[AnimLib] applyTo: Clip '{}' nicht in Bibliothek (verfügbar: {})", clipName, clips.keySet()); + return false; + } AnimComposer ac = RetargetingSystem.findAnimComposer(model); SkinningControl sc = RetargetingSystem.findSkinningControl(model); - if (ac == null || sc == null) return false; + if (ac == null) { + log.warn("[AnimLib] applyTo: Kein AnimComposer in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName); + return false; + } + if (sc == null) { + log.warn("[AnimLib] applyTo: Kein SkinningControl in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName); + return false; + } - String shortName = shortName(clipKey); - if (ac.getAnimClip(shortName) != null) return true; // already present + if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden AnimClip target; if (srcArm != null && srcArm != sc.getArmature()) { - // Pre-baked animations (Blender retargeting) have identical bone names → - // copy directly without retargeting. Different skeleton → retarget. - if (haveSameBoneNames(srcArm, sc.getArmature())) { - target = src; - } else { - target = RetargetingSystem.retarget(src, srcArm, sc.getArmature()); - } + // Immer retarget() aufrufen – auch bei gleichen Knochen-Namen. + // retarget() nutzt intern den "redirect"-Schnellpfad für gleiche Rigs, + // erstellt aber korrekte TransformTrack-Referenzen auf die Ziel-Armatur. + // Direkte Nutzung des Quell-Clips würde Transforms auf die falschen + // (entkoppelten) Joint-Objekte der Quell-j3o anwenden. + target = RetargetingSystem.retarget(src, srcArm, sc.getArmature()); } else { target = src; } - if (target == null) return false; + if (target == null) { + log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName); + return false; + } ac.addAnimClip(target); + log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName()); return true; } - /** - * Applies all loaded clips to {@code model} (only if the model has a skinned rig). - * Useful for auto-equipping all available animations on a freshly loaded character. - */ + /** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */ public void applyAllTo(Spatial model) { if (RetargetingSystem.findSkinningControl(model) == null) return; int applied = 0; @@ -110,92 +124,124 @@ public class AnimationLibrary extends BaseAppState { } /** - * Applies the clip and immediately starts playing it. + * Stellt sicher dass der Clip auf das Modell angewendet ist und SkinningControl aktiv ist, + * startet ihn aber NICHT (das übernimmt der Aufrufer via AnimComposer.setCurrentAction). * - * @return true on success + * @return true wenn der Clip bereit ist */ - public boolean playOn(String clipKey, Spatial model) { - if (!applyTo(clipKey, model)) return false; - AnimComposer ac = RetargetingSystem.findAnimComposer(model); - if (ac == null) return false; - ac.setCurrentAction(shortName(clipKey)); + public boolean ensureApplied(String clipName, Spatial model) { + if (!applyTo(clipName, model)) return false; + enableSkinningControls(model); return true; } /** - * Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Animations-Set zugeordnet ist. + * Wendet den Clip an und startet ihn sofort. * - * @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis - * @param j3oAssetPath relativer Asset-Pfad der {@code .j3o}-Set-Datei (z. B. {@code "animations/sets/hero.j3o"}) - * @param action semantische Aktion, z. B. {@code AnimationAction.IDLE} - * @return Clip-Name oder {@code null} wenn keine Zuweisung existiert + * @return true bei Erfolg */ - public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) { - AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath); - return set.getActionMap().get(action.name()); + public boolean playOn(String clipName, Spatial model) { + if (!applyTo(clipName, model)) return false; + AnimComposer ac = RetargetingSystem.findAnimComposer(model); + if (ac == null) return false; + enableSkinningControls(model); + log.info("[AnimLib] setCurrentAction('{}') auf '{}'", clipName, model.getName()); + com.jme3.anim.tween.action.Action action = ac.setCurrentAction(clipName); + log.info("[AnimLib] Action: {} length={}", action, action != null ? action.getLength() : "N/A"); + return action != null; + } + + private static void enableSkinningControls(Spatial s) { + SkinningControl sc = s.getControl(SkinningControl.class); + if (sc != null && !sc.isEnabled()) { + sc.setEnabled(true); + log.info("[AnimLib] SkinningControl aktiviert auf '{}'", s.getName()); + } + if (s instanceof com.jme3.scene.Node n) { + for (Spatial child : n.getChildren()) enableSkinningControls(child); + } + } + + /** + * Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Set zugeordnet ist. + * + * @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis + * @param setName Name des Animations-Sets (ohne Pfad/Extension, z. B. {@code "human"}) + * @param action semantische Aktion + * @return Clip-Name oder {@code null} + */ + public static String getClipForAction(Path assetRoot, String setName, AnimationAction action) { + Path setDir = assetRoot.resolve("animations").resolve("sets"); + try { + AnimSet set = AnimSet.load(setDir, setName); + return set.getActionMap().get(action.name()); + } catch (Exception e) { + return null; + } } // ── Loading ─────────────────────────────────────────────────────────────── private void loadAll() { - Path animDir = findAnimDir(); - if (animDir == null) { - log.info("[AnimLib] Kein Animations-Verzeichnis gefunden – Bibliothek leer."); + Path clipsDir = findClipsDir(); + log.info("[AnimLib] Asset-Root: {}", findAssetRoot().toAbsolutePath()); + if (clipsDir == null) { + log.warn("[AnimLib] Kein clips-Verzeichnis gefunden – Bibliothek leer."); return; } - try (var walk = Files.walk(animDir)) { + log.info("[AnimLib] Scanne clips-Verzeichnis: {}", clipsDir.toAbsolutePath()); + try (var walk = Files.walk(clipsDir, 1)) { walk.filter(p -> p.toString().endsWith(".j3o")) - .forEach(this::loadFromFile); + .forEach(this::loadClipFromFile); } catch (IOException e) { log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage()); } - log.info("[AnimLib] {} Clips geladen.", clips.size()); + if (clips.isEmpty()) { + log.warn("[AnimLib] KEINE Clips geladen! Prüfe ob der Asset-Root korrekt ist."); + } else { + log.info("[AnimLib] {} Clips geladen: {}", clips.size(), clips.keySet()); + } } - private void loadFromFile(Path file) { - Path animDir = findAnimDir(); - if (animDir == null) return; - String relPath = animDir.relativize(file).toString().replace('\\', '/'); - String assetKey = "animations/" + relPath; - String fileBase = relPath.replaceFirst("\\.j3o$", ""); + private void loadClipFromFile(Path file) { + String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", ""); + String assetKey = "animations/clips/" + clipName + ".j3o"; try { - Spatial loaded = assetManager.loadModel(assetKey); + Spatial loaded = assetManager.loadModel(assetKey); AnimComposer ac = RetargetingSystem.findAnimComposer(loaded); SkinningControl sc = RetargetingSystem.findSkinningControl(loaded); if (ac == null) { - log.debug("[AnimLib] Kein AnimComposer in {}", assetKey); + log.warn("[AnimLib] Kein AnimComposer in {} – übersprungen", assetKey); return; } Armature armature = sc != null ? sc.getArmature() : null; - for (String clipName : ac.getAnimClipsNames()) { - String key = fileBase + "/" + clipName; - clips.put(key, ac.getAnimClip(clipName)); - if (armature != null) armatures.put(key, armature); - log.info("[AnimLib] Clip: {}", key); + for (String name : ac.getAnimClipsNames()) { + clips.put(name, ac.getAnimClip(name)); + if (armature != null) armatures.put(name, armature); + log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey); } } catch (Exception e) { log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage()); } } - private static Path findAnimDir() { + /** Gibt den Asset-Wurzelpfad zurück (erstes Verzeichnis mit {@code animations/}-Unterordner). */ + public static Path findAssetRoot() { for (String base : ASSET_BASES) { - Path p = Paths.get(base, "animations"); + Path p = Paths.get(base); + if (Files.isDirectory(p.resolve("animations"))) return p; + } + return Paths.get(ASSET_BASES[0]); + } + + private static Path findClipsDir() { + for (String base : ASSET_BASES) { + Path p = Paths.get(base, "animations", "clips"); if (Files.isDirectory(p)) return p; } return null; } - private static boolean haveSameBoneNames(Armature a, Armature b) { - Set namesA = a.getJointList().stream().map(Joint::getName).collect(Collectors.toSet()); - Set namesB = b.getJointList().stream().map(Joint::getName).collect(Collectors.toSet()); - return namesA.equals(namesB); - } - - private static String shortName(String clipKey) { - int slash = clipKey.lastIndexOf('/'); - return slash >= 0 ? clipKey.substring(slash + 1) : clipKey; - } } diff --git a/blight-game/src/main/java/de/blight/game/config/KeyBindings.java b/blight-game/src/main/java/de/blight/game/config/KeyBindings.java index a64eb2e..f37ded5 100644 --- a/blight-game/src/main/java/de/blight/game/config/KeyBindings.java +++ b/blight-game/src/main/java/de/blight/game/config/KeyBindings.java @@ -11,6 +11,7 @@ public class KeyBindings { public int right = KeyInput.KEY_D; public int jump = KeyInput.KEY_SPACE; public int sprint = KeyInput.KEY_LSHIFT; + public int walk = KeyInput.KEY_LMENU; /** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */ public static final String[][] ENTRIES = { @@ -20,6 +21,7 @@ public class KeyBindings { {"right", "Rechts"}, {"jump", "Springen"}, {"sprint", "Rennen"}, + {"walk", "Gehen"}, }; public int get(String fieldName) { 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 7c3ecc0..322c3a6 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 @@ -1,117 +1,203 @@ -package de.blight.game.control; - -import com.jme3.bullet.control.CharacterControl; -import com.jme3.input.InputManager; -import com.jme3.input.controls.ActionListener; -import com.jme3.input.controls.KeyTrigger; -import com.jme3.math.FastMath; -import com.jme3.math.Quaternion; -import com.jme3.math.Vector3f; -import com.jme3.renderer.Camera; -import com.jme3.scene.Spatial; -import de.blight.game.config.KeyBindings; - -public class PlayerInputControl { - - private static final float MOVE_SPEED = 0.07f; - private static final float SPRINT_MULT = 1.5f; - private static final float ROTATE_SPEED = 10f; - - private static final String[] ACTION_NAMES = - {"Forward", "Backward", "Left", "Right", "Jump", "Sprint"}; - - private final InputManager inputManager; - private final Camera cam; - - private CharacterControl physicsChar; - private Spatial visual; - - private boolean forward, backward, left, right, sprint; - private boolean paused = false; - - // Listener als Feld, damit er bei reload nicht doppelt registriert wird - private final ActionListener actionListener = (name, isPressed, tpf) -> { - if (paused) return; - switch (name) { - case "Forward" -> forward = isPressed; - case "Backward" -> backward = isPressed; - case "Left" -> left = isPressed; - case "Right" -> right = isPressed; - case "Sprint" -> sprint = isPressed; - case "Jump" -> { if (isPressed && physicsChar != null) physicsChar.jump(); } - } - }; - - public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) { - this.inputManager = inputManager; - this.cam = cam; - registerMappings(kb); - } - - public void setPhysicsCharacter(CharacterControl physicsChar) { - this.physicsChar = physicsChar; - } - - public void setVisual(Spatial visual) { - this.visual = visual; - } - - public void setPaused(boolean paused) { - this.paused = paused; - if (paused) { - forward = backward = left = right = sprint = false; - if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); - } - } - - /** Löscht alte Mappings und registriert neue aus den übergebenen KeyBindings. */ - public void reloadBindings(KeyBindings kb) { - for (String a : ACTION_NAMES) inputManager.deleteMapping(a); - registerMappings(kb); - // Zustand zurücksetzen, damit keine Taste „hängt" - forward = backward = left = right = sprint = false; - if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); - } - - private void registerMappings(KeyBindings kb) { - inputManager.addMapping("Forward", new KeyTrigger(kb.forward)); - inputManager.addMapping("Backward", new KeyTrigger(kb.backward)); - inputManager.addMapping("Left", new KeyTrigger(kb.left)); - inputManager.addMapping("Right", new KeyTrigger(kb.right)); - inputManager.addMapping("Jump", new KeyTrigger(kb.jump)); - inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint)); - inputManager.addListener(actionListener, ACTION_NAMES); - } - - public void update(float tpf) { - if (physicsChar == null || paused) return; - - Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal(); - Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal(); - - Vector3f moveDir = new Vector3f(); - if (forward) moveDir.addLocal(camDir); - if (backward) moveDir.subtractLocal(camDir); - if (left) moveDir.addLocal(camLeft); - if (right) moveDir.subtractLocal(camLeft); - - if (moveDir.lengthSquared() > 0.001f) { - moveDir.normalizeLocal(); - float speed = sprint ? MOVE_SPEED * SPRINT_MULT : MOVE_SPEED; - physicsChar.setWalkDirection(moveDir.mult(speed)); - - if (visual != null) { - Quaternion targetRot = new Quaternion(); - targetRot.lookAt(moveDir, Vector3f.UNIT_Y); - // Modell hat +X als Vorwärtsrichtung; lookAt zeigt -Z nach vorne → - // 90°-Y-Versatz korrigiert den Orientierungsunterschied. - targetRot.multLocal(new Quaternion().fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y)); - Quaternion current = visual.getLocalRotation().clone(); - current.slerp(targetRot, ROTATE_SPEED * tpf); - visual.setLocalRotation(current); - } - } else { - physicsChar.setWalkDirection(Vector3f.ZERO); - } - } -} +package de.blight.game.control; + +import com.jme3.anim.AnimComposer; +import com.jme3.bullet.control.CharacterControl; +import com.jme3.input.InputManager; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.KeyTrigger; +import com.jme3.math.FastMath; +import com.jme3.math.Quaternion; +import com.jme3.math.Vector3f; +import com.jme3.renderer.Camera; +import com.jme3.scene.Spatial; +import de.blight.game.animation.AnimationAction; +import de.blight.game.animation.AnimationLibrary; +import de.blight.game.animation.RetargetingSystem; +import de.blight.game.config.KeyBindings; + +import java.nio.file.Path; + +public class PlayerInputControl { + + private static final float MOVE_SPEED = 0.07f; + private static final float SPRINT_MULT = 1.5f; + private static final float WALK_MULT = 0.5f; + private static final float ROTATE_SPEED = 10f; + + private static final String[] ACTION_NAMES = + {"Forward", "Backward", "Left", "Right", "Jump", "Sprint", "Walk"}; + + private final InputManager inputManager; + private final Camera cam; + + private CharacterControl physicsChar; + private Spatial visual; + + private boolean forward, backward, left, right, sprint, walk; + private boolean paused = false; + + private AnimationLibrary animLib; + private String animSetName; + private Path assetRoot; + private AnimationAction currentAnim; + private boolean animCtxLogged = false; + + private AnimComposer animComposer; + /** Letzter gestarteter Clip (nur für tryPlay-Deduplizierung genutzt). */ + private String runningClip; + /** Frames, für die JUMP erzwungen wird (überbrückt onGround()-Lag). */ + private int jumpFrames = 0; + + private final ActionListener actionListener = (name, isPressed, tpf) -> { + if (paused) return; + switch (name) { + case "Forward" -> forward = isPressed; + case "Backward" -> backward = isPressed; + case "Left" -> left = isPressed; + case "Right" -> right = isPressed; + case "Sprint" -> sprint = isPressed; + case "Walk" -> walk = isPressed; + case "Jump" -> { if (isPressed && physicsChar != null) { physicsChar.jump(); jumpFrames = 12; } } + } + }; + + public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) { + this.inputManager = inputManager; + this.cam = cam; + registerMappings(kb); + } + + public void setPhysicsCharacter(CharacterControl physicsChar) { + this.physicsChar = physicsChar; + } + + public void setVisual(Spatial visual) { + this.visual = visual; + } + + public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) { + this.animLib = animLib; + this.animSetName = animSetName; + this.assetRoot = assetRoot; + this.currentAnim = null; + this.runningClip = null; + this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null; + System.out.println("[AnimCtx] AnimComposer gefunden: " + (animComposer != null)); + if (animSetName != null) { + String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE); + if (clip != null && tryPlay(clip)) { + currentAnim = AnimationAction.IDLE; + } + } + } + + public void setPaused(boolean paused) { + this.paused = paused; + if (paused) { + forward = backward = left = right = sprint = walk = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + } + } + + public void reloadBindings(KeyBindings kb) { + for (String a : ACTION_NAMES) inputManager.deleteMapping(a); + registerMappings(kb); + forward = backward = left = right = sprint = walk = false; + if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO); + } + + private void registerMappings(KeyBindings kb) { + inputManager.addMapping("Forward", new KeyTrigger(kb.forward)); + inputManager.addMapping("Backward", new KeyTrigger(kb.backward)); + inputManager.addMapping("Left", new KeyTrigger(kb.left)); + inputManager.addMapping("Right", new KeyTrigger(kb.right)); + inputManager.addMapping("Jump", new KeyTrigger(kb.jump)); + inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint)); + inputManager.addMapping("Walk", new KeyTrigger(kb.walk)); + inputManager.addListener(actionListener, ACTION_NAMES); + } + + public void update(float tpf) { + if (physicsChar == null || paused) return; + + Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal(); + Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal(); + + Vector3f moveDir = new Vector3f(); + if (forward) moveDir.addLocal(camDir); + if (backward) moveDir.subtractLocal(camDir); + if (left) moveDir.addLocal(camLeft); + if (right) moveDir.subtractLocal(camLeft); + + boolean moving = moveDir.lengthSquared() > 0.001f; + + if (moving) { + moveDir.normalizeLocal(); + float speed = walk ? MOVE_SPEED * WALK_MULT + : sprint ? MOVE_SPEED * SPRINT_MULT + : MOVE_SPEED; + physicsChar.setWalkDirection(moveDir.mult(speed)); + + if (visual != null) { + Quaternion targetRot = new Quaternion(); + targetRot.lookAt(moveDir, Vector3f.UNIT_Y); + Quaternion current = visual.getLocalRotation().clone(); + current.slerp(targetRot, ROTATE_SPEED * tpf); + visual.setLocalRotation(current); + } + } else { + physicsChar.setWalkDirection(Vector3f.ZERO); + } + + // Animation + if (jumpFrames > 0) jumpFrames--; + + AnimationAction target; + if (jumpFrames > 0 || !physicsChar.onGround()) { + target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP; + } else if (moving) { + target = walk ? AnimationAction.WALK + : sprint ? AnimationAction.SPRINT + : AnimationAction.RUN; + } else { + target = AnimationAction.IDLE; + } + + if (target != currentAnim) { + playAction(target); + currentAnim = target; + } + } + + private void playAction(AnimationAction action) { + if (animLib == null || visual == null || animSetName == null) { + if (!animCtxLogged) { + animCtxLogged = true; + System.out.println("[Anim] Kein Animations-Kontext:" + + " animLib=" + animLib + " visual=" + visual + " setName=" + animSetName); + } + return; + } + String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action); + System.out.println("[Anim] " + action + " → clip='" + clip + "' (set=" + animSetName + ")"); + if (clip != null && tryPlay(clip)) return; + if (action != AnimationAction.DEFAULT) { + String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT); + if (defClip != null) tryPlay(defClip); + } + } + + private boolean tryPlay(String clip) { + if (animComposer == null || !animLib.ensureApplied(clip, visual)) { + System.out.println("[Anim] tryPlay('" + clip + "') → ensureApplied FAILED"); + return false; + } + com.jme3.anim.tween.action.Action action = animComposer.setCurrentAction(clip); + System.out.println("[Anim] setCurrentAction('" + clip + "') → " + (action != null ? "OK" : "FAILED")); + if (action != null) { + runningClip = clip; + return true; + } + return false; + } +} diff --git a/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java b/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java index b107c89..cbc32f4 100644 --- a/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java +++ b/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java @@ -17,9 +17,10 @@ import com.jme3.scene.Spatial; public class ThirdPersonCamera { private static final float MOUSE_SENSITIVITY = 1.8f; - private static final float MIN_DISTANCE = 3f; - private static final float MAX_DISTANCE = 20f; - private static final float MIN_VERTICAL_ANGLE = -0.3f; + private static final float BASE_DISTANCE = 5f; + private static final float MIN_DISTANCE = BASE_DISTANCE - 3f; + private static final float MAX_DISTANCE = BASE_DISTANCE + 3f; + private static final float MIN_VERTICAL_ANGLE = 0.08f; // Kamera immer leicht über Schulter private static final float MAX_VERTICAL_ANGLE = FastMath.HALF_PI - 0.1f; private static final float TARGET_HEIGHT = 1.6f; @@ -30,7 +31,7 @@ public class ThirdPersonCamera { private float yaw = 0f; private float pitch = 0.4f; - private float distance = 10f; + private float distance = BASE_DISTANCE; private boolean paused = false; public ThirdPersonCamera(Camera cam, InputManager inputManager) { @@ -65,8 +66,8 @@ public class ThirdPersonCamera { case "MouseY" -> pitch = FastMath.clamp(pitch - value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE); case "MouseYNeg" -> pitch = FastMath.clamp(pitch + value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE); // Zoom - case "ZoomIn" -> distance = FastMath.clamp(distance - value * 20f, MIN_DISTANCE, MAX_DISTANCE); - case "ZoomOut" -> distance = FastMath.clamp(distance + value * 20f, MIN_DISTANCE, MAX_DISTANCE); + case "ZoomIn" -> distance = FastMath.clamp(distance - value * 0.5f, MIN_DISTANCE, MAX_DISTANCE); + case "ZoomOut" -> distance = FastMath.clamp(distance + value * 0.5f, MIN_DISTANCE, MAX_DISTANCE); } }; inputManager.addListener(analogListener, 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 7291f14..c7237ff 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 @@ -6,7 +6,6 @@ import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.bullet.BulletAppState; import com.jme3.bullet.collision.shapes.CapsuleCollisionShape; -import com.jme3.bullet.collision.shapes.HeightfieldCollisionShape; import com.jme3.bullet.control.CharacterControl; import com.jme3.bullet.control.RigidBodyControl; import com.jme3.bullet.util.CollisionShapeFactory; @@ -26,27 +25,47 @@ import com.jme3.util.SkyFactory; import java.nio.ByteBuffer; import de.blight.common.MapData; import de.blight.common.MapIO; +import de.blight.common.model.CharacterIO; +import de.blight.common.model.GameCharacter; +import de.blight.common.model.MainCharacter; +import de.blight.game.animation.AnimationLibrary; import de.blight.game.config.KeyBindings; import de.blight.game.control.PlayerInputControl; import de.blight.game.control.ThirdPersonCamera; +import com.jme3.post.FilterPostProcessor; +import com.jme3.post.filters.FogFilter; +import com.jme3.water.WaterFilter; import de.blight.game.state.GrassState; +import de.blight.game.state.RiverState; +import de.blight.game.state.WaterBodyState; +import de.blight.game.state.WeatherState; +import de.blight.game.state.WorldObjectsState; import java.io.IOException; +import java.nio.FloatBuffer; import java.util.ArrayList; import java.util.List; public class WorldScene extends BaseAppState { - private SimpleApplication app; - private Node rootNode; - private AssetManager assetManager; - private BulletAppState bulletAppState; - private MapData loadedMapData; + private SimpleApplication app; + private Node rootNode; + private AssetManager assetManager; + private BulletAppState bulletAppState; + private MapData loadedMapData; + private FilterPostProcessor sharedFPP; - private final KeyBindings keyBindings; - private ThirdPersonCamera thirdPersonCam; - private PlayerInputControl playerInput; - private float spawnY = 5f; // wird in buildTerrain() gesetzt + private final KeyBindings keyBindings; + private ThirdPersonCamera thirdPersonCam; + private PlayerInputControl playerInput; + private AnimationLibrary animLib; + private Node character; + private Spatial characterVisual; + private CharacterControl physicsChar; + private boolean animContextReady = false; + private float spawnX = 0f; + private float spawnY = 5f; + private float spawnZ = 0f; public WorldScene(KeyBindings keyBindings) { this.keyBindings = keyBindings; @@ -74,6 +93,9 @@ public class WorldScene extends BaseAppState { bulletAppState = new BulletAppState(); app.getStateManager().attach(bulletAppState); + + animLib = new AnimationLibrary(); + app.getStateManager().attach(animLib); } @Override @@ -81,27 +103,27 @@ public class WorldScene extends BaseAppState { buildLighting(); TerrainQuad terrain = buildTerrain(); - if (loadedMapData != null) { - rootNode.attachChild(buildGebirge(loadedMapData)); - app.getStateManager().attach(new GrassState(loadedMapData, terrain)); - } + app.getStateManager().attach(new GrassState(terrain)); + app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP)); + app.getStateManager().attach(new RiverState()); + app.getStateManager().attach(new WorldObjectsState()); - Node character = buildCharacter(); + character = loadOrBuildCharacter(); rootNode.attachChild(character); // Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1) CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1); - CharacterControl physicsChar = new CharacterControl(capsule, 0.05f); + physicsChar = new CharacterControl(capsule, 0.05f); physicsChar.setJumpSpeed(12f); physicsChar.setFallSpeed(35f); physicsChar.setGravity(35f); - physicsChar.setPhysicsLocation(new Vector3f(0, spawnY, 0)); character.addControl(physicsChar); bulletAppState.getPhysicsSpace().add(physicsChar); + physicsChar.setPhysicsLocation(new Vector3f(spawnX, spawnY, spawnZ)); playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings); playerInput.setPhysicsCharacter(physicsChar); - playerInput.setVisual(character); + playerInput.setVisual(characterVisual != null ? characterVisual : character); thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager()); thirdPersonCam.setTarget(character); @@ -110,15 +132,110 @@ public class WorldScene extends BaseAppState { app.getInputManager().setCursorVisible(false); } + private float livePosTimer = 0f; + @Override public void update(float tpf) { + if (!animContextReady && animLib != null && animLib.isInitialized()) { + setupAnimationContext(); + animContextReady = true; + } playerInput.update(tpf); thirdPersonCam.update(tpf); + + livePosTimer += tpf; + if (livePosTimer >= 0.2f && physicsChar != null) { + livePosTimer = 0f; + com.jme3.math.Vector3f pos = physicsChar.getPhysicsLocation(); + de.blight.game.LiveBroadcast.writePosition(pos.x, pos.y, pos.z); + } } - @Override protected void cleanup(Application app) {} + @Override protected void cleanup(Application app) { + de.blight.game.LiveBroadcast.clear(); + } @Override protected void onDisable() {} + private void setupAnimationContext() { + animLib.applyAllTo(characterVisual != null ? characterVisual : character); + MainCharacter mc = findMainCharacter(); + String setName = (mc != null) ? mc.getAnimSetPath() : null; + System.out.println("[AnimCtx] MainCharacter: " + (mc != null ? mc.getCharacterId() : "null") + + " animSetPath: " + setName + + " clipCount: " + animLib.getClipKeys().size() + + " clips: " + animLib.getClipKeys()); + // AnimSet-ActionMap ausgeben + if (setName != null) { + java.nio.file.Path setDir = AnimationLibrary.findAssetRoot().resolve("animations").resolve("sets"); + try { + de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, setName); + System.out.println("[AnimCtx] AnimSet '" + setName + "' actionMap: " + set.getActionMap()); + } catch (Exception e) { + System.out.println("[AnimCtx] AnimSet '" + setName + "' nicht ladbar: " + e.getMessage()); + } + } + playerInput.setAnimationContext(animLib, setName, AnimationLibrary.findAssetRoot()); + } + + // CharacterControl setzt den Spatial auf den Kapsel-Mittelpunkt: radius=0.4, halfCyl=0.5 → 0.9m über dem Boden. + // Das Modell hat den Ursprung an den Füßen → wir brauchen einen -0.9m-Versatz im wrapper-Node. + private static final float CAPSULE_VISUAL_OFFSET_Y = -(0.5f + 0.4f); // -(halfCylHeight + radius) + + /** Lädt das Hauptcharakter-Modell, falls im character/-Verzeichnis definiert; sonst Platzhalter. */ + private Node loadOrBuildCharacter() { + MainCharacter mc = findMainCharacter(); + if (mc != null && mc.getModelPath() != null) { + try { + Spatial loaded = assetManager.loadModel(mc.getModelPath()); + loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive); + + // Auf 1.8 m skalieren – Höhe aus Vertex-Daten (zuverlässiger als BoundingBox + // bei Skinned-Meshes, die vor der ersten SkinningControl-Runde falsche Bounds liefern) + float[] yRange = vertexYRange(loaded); + float modelHeight = yRange[1] - yRange[0]; + System.out.println("[WorldScene] Vertex-Y-Range: min=" + yRange[0] + " max=" + yRange[1] + + " height=" + modelHeight); + float offsetY; + if (modelHeight > 0.1f) { + float scale = 1.8f / modelHeight; + loaded.setLocalScale(scale); + // Füße des Modells (scale * minY in loaded-Local) auf Kapsel-Unterkante legen + offsetY = -(0.9f + scale * yRange[0]); + System.out.println("[WorldScene] Charakter skaliert: " + scale + + "x offsetY=" + offsetY); + } else { + offsetY = CAPSULE_VISUAL_OFFSET_Y; + System.out.println("[WorldScene] Kein Scale möglich (height=" + modelHeight + "), Fallback-Offset"); + } + + // rotationNode als Drehpunkt (CharacterControl überschreibt wrapper-Rotation jeden Frame) + Node rotNode = new Node("charRot"); + loaded.setLocalTranslation(0, offsetY, 0); + rotNode.attachChild(loaded); + + Node wrapper = new Node("character"); + wrapper.attachChild(rotNode); + + characterVisual = rotNode; + System.out.println("[WorldScene] Hauptcharakter geladen: " + mc.getModelPath()); + return wrapper; + } catch (Exception e) { + System.err.println("[WorldScene] Modell nicht ladbar (" + mc.getModelPath() + + "): " + e.getMessage() + " – Fallback auf Platzhalter"); + } + } + characterVisual = null; + return buildCharacter(); + } + + private MainCharacter findMainCharacter() { + java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character"); + for (GameCharacter c : CharacterIO.loadAll(charDir)) { + if (c instanceof MainCharacter mc) return mc; + } + return null; + } + // ----------------------------------------------------------------------- // Beleuchtung // ----------------------------------------------------------------------- @@ -145,6 +262,42 @@ public class WorldScene extends BaseAppState { SkyFactory.EnvMapType.CubeMap); rootNode.attachChild(sky); } catch (Exception ignored) {} + + setupPostProcessing(sun.getDirection()); + } + + private void setupPostProcessing(Vector3f sunDir) { + sharedFPP = new FilterPostProcessor(assetManager); + FilterPostProcessor fpp = sharedFPP; + + // Globales Wasser bei Y=0 (bedeckt die gesamte Karte unterhalb der Wasserlinie) + try { + WaterFilter waterFilter = new WaterFilter(rootNode, sunDir); + waterFilter.setWaterHeight(0f); + waterFilter.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f)); + waterFilter.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f)); + waterFilter.setWaterTransparency(0.15f); + waterFilter.setMaxAmplitude(0.3f); + waterFilter.setWaveScale(0.008f); + waterFilter.setSpeed(0.5f); + fpp.addFilter(waterFilter); + + WeatherState weather = new WeatherState(); + weather.setWaterFilter(waterFilter); + + FogFilter fogFilter = new FogFilter(); + fogFilter.setFogColor(new ColorRGBA(0.75f, 0.80f, 0.88f, 1f)); + fogFilter.setFogDensity(0.0f); + fogFilter.setFogDistance(600f); + fpp.addFilter(fogFilter); + + weather.setFogFilter(fogFilter); + app.getStateManager().attach(weather); + } catch (Exception e) { + System.err.println("[WorldScene] Post-Processing nicht verfügbar: " + e.getMessage()); + } + + app.getViewPort().addProcessor(fpp); } // ----------------------------------------------------------------------- @@ -188,13 +341,13 @@ public class WorldScene extends BaseAppState { } } - // Spawn über dem höchsten Punkt – Basis-Terrain UND Gebirge-Oberkante - float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE; - for (float h : heights) { if (h < minH) minH = h; if (h > maxH) maxH = h; } - float midH = (minH + maxH) * 0.5f; - float maxUpperTop = maxH; - for (float h : map.upperTop) { if (h > maxUpperTop) maxUpperTop = h; } - spawnY = maxUpperTop + 20f; + // Temp-Spawn aus Editor-Property überschreibt gespeicherten Karten-Spawn + String propX = System.getProperty("blight.temp.spawn.x"); + String propZ = System.getProperty("blight.temp.spawn.z"); + spawnX = propX != null ? Float.parseFloat(propX) : map.spawnX; + spawnZ = propZ != null ? Float.parseFloat(propZ) : map.spawnZ; + System.out.println("[WorldScene] SpawnXZ Quelle: " + (propX != null ? "Editor-Property" : "Karte") + + " → X=" + spawnX + " Z=" + spawnZ); TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights); terrain.setLocalScale(8f, 1f, 8f); @@ -202,17 +355,22 @@ public class WorldScene extends BaseAppState { applyTerrainMaterial(terrain, map); rootNode.attachChild(terrain); - // jBullet subtrahiert midH intern in getVertex() → Physics-Body bei midH - // damit Kollisionsfläche und sichtbares Terrain übereinstimmen. - HeightfieldCollisionShape hcs = new HeightfieldCollisionShape( - heights, terrain.getLocalScale()); - RigidBodyControl terrainPhysics = new RigidBodyControl(hcs, 0f); + // Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ + float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f)); + if (Float.isNaN(terrainH)) { + float maxH = -Float.MAX_VALUE; + for (float h : heights) { if (h > maxH) maxH = h; } + terrainH = maxH; + } + spawnY = terrainH + 10f; + + RigidBodyControl terrainPhysics = new RigidBodyControl( + CollisionShapeFactory.createMeshShape(terrain), 0f); terrain.addControl(terrainPhysics); bulletAppState.getPhysicsSpace().add(terrainPhysics); - terrainPhysics.setPhysicsLocation(new Vector3f(0f, midH, 0f)); - System.out.println("[WorldScene] Karte geladen, Spawn Y=" + spawnY - + " maxGebirgeH=" + maxUpperTop); + System.out.println("[WorldScene] Karte geladen, SpawnXYZ=(" + + spawnX + ", " + spawnY + ", " + spawnZ + ")"); return terrain; } @@ -389,25 +547,40 @@ public class WorldScene extends BaseAppState { return a; } + // Default-Texturen identisch mit Editor (TerrainEditorState.DEFAULT_TERRAIN_TEXTURES) + private static final String[] DEF_TEX = { + "Textures/Terrain/splat/grass.jpg", + "Textures/Terrain/Rock2/rock.jpg", + "Textures/Terrain/splat/dirt.jpg", + "" + }; + private static final ColorRGBA[] DEF_COLOR = { + new ColorRGBA(0.28f, 0.58f, 0.18f, 1f), + new ColorRGBA(0.45f, 0.32f, 0.25f, 1f), + new ColorRGBA(0.55f, 0.45f, 0.30f, 1f), + new ColorRGBA(0.80f, 0.72f, 0.50f, 1f), + }; + private void applyTerrainMaterial(TerrainQuad terrain, MapData map) { if (map != null) { try { - Material mat = new Material(assetManager, "Common/MatDefs/Terrain/Terrain.j3md"); + Material mat = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md"); + mat.setBoolean("useTriPlanarMapping", false); + mat.setFloat("Shininess", 0f); - Texture tex1 = loadTexOrFallback("Textures/Terrain/splat/grass.jpg", - new ColorRGBA(0.28f, 0.58f, 0.18f, 1f)); - Texture tex2 = loadTexOrFallback("Textures/Terrain/splat/road.jpg", - new ColorRGBA(0.55f, 0.50f, 0.40f, 1f)); - Texture tex3 = loadTexOrFallback("Textures/Terrain/splat/Gravel.jpg", - new ColorRGBA(0.45f, 0.35f, 0.25f, 1f)); - tex1.setWrap(Texture.WrapMode.Repeat); - tex2.setWrap(Texture.WrapMode.Repeat); - tex3.setWrap(Texture.WrapMode.Repeat); - mat.setTexture("Tex1", tex1); mat.setFloat("Tex1Scale", 512f); - mat.setTexture("Tex2", tex2); mat.setFloat("Tex2Scale", 512f); - mat.setTexture("Tex3", tex3); mat.setFloat("Tex3Scale", 512f); + String[] mapTex = map.terrainTextures; + String[] matParams = {"DiffuseMap","DiffuseMap_1","DiffuseMap_2","DiffuseMap_3"}; + String[] scaleP = {"DiffuseMap_0_scale","DiffuseMap_1_scale","DiffuseMap_2_scale","DiffuseMap_3_scale"}; + for (int i = 0; i < 4; i++) { + String path = (mapTex[i] != null && !mapTex[i].isEmpty()) ? mapTex[i] : DEF_TEX[i]; + if (path == null || path.isEmpty()) continue; + Texture tex = loadTexOrFallback(path, DEF_COLOR[i]); + tex.setWrap(Texture.WrapMode.Repeat); + mat.setTexture(matParams[i], tex); + mat.setFloat(scaleP[i], 512f); + } - // Ältere Maps haben splatR=0 → Gras (Tex1) wäre unsichtbar; auf 255 setzen. + // Ältere Maps haben splatR=0 → Gras (Slot 0) wäre unsichtbar; auf 255 setzen. byte[] splatR = map.splatR; boolean rAllZero = true; for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } } @@ -422,14 +595,14 @@ public class WorldScene extends BaseAppState { splatBuf.put(splatR[i]); splatBuf.put(map.splatG[i]); splatBuf.put(map.splatB[i]); - splatBuf.put((byte) 0); + splatBuf.put(map.splatA[i]); } splatBuf.flip(); Texture2D splatTex = new Texture2D(new Image(Image.Format.RGBA8, sz, sz, splatBuf)); splatTex.setWrap(Texture.WrapMode.EdgeClamp); splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); splatTex.setMagFilter(Texture.MagFilter.Bilinear); - mat.setTexture("Alpha", splatTex); + mat.setTexture("AlphaMap", splatTex); terrain.setMaterial(mat); return; @@ -595,6 +768,34 @@ public class WorldScene extends BaseAppState { return character; } + /** Gibt {minY, maxY} aller Vertex-Positionen im Teilbaum zurück. */ + private static float[] vertexYRange(Spatial root) { + float[] r = {Float.MAX_VALUE, -Float.MAX_VALUE}; + collectYRange(root, r); + if (r[0] == Float.MAX_VALUE) return new float[]{0f, 0f}; + return r; + } + + private static void collectYRange(Spatial s, float[] r) { + if (s instanceof Geometry g) { + var buf = g.getMesh().getBuffer(VertexBuffer.Type.Position); + if (buf == null) return; + FloatBuffer fb = (FloatBuffer) buf.getData(); + int saved = fb.position(); + fb.rewind(); + while (fb.remaining() >= 3) { + fb.get(); // x – überspringen + float y = fb.get(); // y + fb.get(); // z – überspringen + if (y < r[0]) r[0] = y; + if (y > r[1]) r[1] = y; + } + fb.position(saved); + } else if (s instanceof Node n) { + for (Spatial c : n.getChildren()) collectYRange(c, r); + } + } + private Geometry buildLimb(Material mat, float radius, float height) { Geometry limb = new Geometry("limb", new Cylinder(6, 12, radius, height, true)); limb.setMaterial(mat); @@ -602,4 +803,5 @@ public class WorldScene extends BaseAppState { limb.setShadowMode(RenderQueue.ShadowMode.CastAndReceive); return limb; } + } 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 887aadc..4ca82b7 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 @@ -18,48 +18,44 @@ import com.jme3.scene.VertexBuffer; import com.jme3.scene.control.AbstractControl; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.util.BufferUtils; -import de.blight.common.MapData; +import de.blight.common.GrassTuft; +import de.blight.common.GrassTuftIO; +import java.io.IOException; import java.nio.FloatBuffer; import java.nio.IntBuffer; import java.util.*; /** - * Rendert Gras im Spiel aus der in MapData gespeicherten Dichte-Map. - * - * Chunks werden gestreckt über mehrere Frames aufgebaut (INIT_PER_FRAME), - * um Startlags zu vermeiden. GrassVisibilityControl cullt entfernte Chunks. + * Rendert individuell platzierte Gras-Büschel aus blight_grass.blg. + * Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME). + * GrassVisibilityControl cullt entfernte Chunks. */ public class GrassState extends BaseAppState { - // ── Konstanten (identisch mit PlacedObjectState im Editor) ──────────────── - private static final int TERRAIN_HALF = 2048; - private static final float WORLD_SIZE = 4096f; - private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; - private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1); - private static final int CHUNK_SIZE = 128; - private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; - private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; - private static final int MAX_BLADES_PER_PX = 3; - private static final float BLADE_WIDTH = 0.18f; - private static final float DEFAULT_HEIGHT = 1.5f; - private static final float FAR_DIST = 150f; // WE (game terrain is 1:1 WE) - private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST; - private static final int INIT_PER_FRAME = 4; + 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 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; - // ── Abhängigkeiten ──────────────────────────────────────────────────────── - private final MapData mapData; private final TerrainQuad terrain; - // ── Runtime-Zustand ─────────────────────────────────────────────────────── - private Camera cam; - private Node grassNode; - private Material grassMat; - private int nextChunk = 0; + private Camera cam; + private Node grassNode; - public GrassState(MapData mapData, TerrainQuad terrain) { - this.mapData = mapData; - this.terrain = terrain; + @SuppressWarnings("unchecked") + private final List[] chunkTufts = new List[CHUNK_COUNT]; + private final Map slotMaterials = new LinkedHashMap<>(); + private int nextChunk = 0; + + public GrassState(TerrainQuad terrain) { + this.terrain = terrain; } // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -69,7 +65,25 @@ public class GrassState extends BaseAppState { this.cam = app.getCamera(); grassNode = new Node("gameGrass"); ((SimpleApplication) app).getRootNode().attachChild(grassNode); - grassMat = buildGrassMaterial(app.getAssetManager()); + + for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>(); + + try { + GrassTuftIO.GrassData data = GrassTuftIO.load(); + if (data != null) { + initSlotMaterials(app.getAssetManager(), data.slotPaths()); + for (GrassTuft t : data.tufts()) { + int ci = chunkIndex(t.x(), t.z()); + if (ci >= 0) chunkTufts[ci].add(t); + } + } + } catch (IOException e) { + System.err.println("[GrassState] Gras nicht ladbar: " + e.getMessage()); + } + + if (slotMaterials.isEmpty()) { + slotMaterials.put(0, buildGrassMat(app.getAssetManager(), "")); + } } @Override @@ -84,20 +98,40 @@ public class GrassState extends BaseAppState { public void update(float tpf) { int built = 0; while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) { - buildChunk(nextChunk++); + if (!chunkTufts[nextChunk].isEmpty()) buildChunk(nextChunk); + nextChunk++; built++; } } // ── Material ────────────────────────────────────────────────────────────── - private Material buildGrassMaterial(AssetManager assets) { + private void initSlotMaterials(AssetManager assets, String[] slotPaths) { + for (int i = 0; i < 8; i++) { + String p = (slotPaths != null && i < slotPaths.length) ? slotPaths[i] : ""; + if (i == 0 || (p != null && !p.isEmpty())) { + slotMaterials.put(i, buildGrassMat(assets, p)); + } + } + } + + private Material buildGrassMat(AssetManager assets, String texPath) { try { Material mat = new Material(assets, "MatDefs/Grass.j3md"); - mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f)); mat.setFloat("WindSpeed", 0.5f); mat.setFloat("WindStrength", 0.14f); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + if (texPath != null && !texPath.isEmpty()) { + try { + mat.setTexture("ColorMap", assets.loadTexture(texPath)); + mat.setColor("Color", ColorRGBA.White); + } catch (Exception te) { + System.err.println("[GrassState] Gras-Textur nicht ladbar '" + texPath + "': " + te.getMessage()); + mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f)); + } + } else { + mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f)); + } return mat; } catch (Exception e) { System.err.println("[GrassState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage()); @@ -108,6 +142,20 @@ public class GrassState extends BaseAppState { } } + private Material getSlotMaterial(int slot) { + Material m = slotMaterials.get(slot); + return m != null ? m : slotMaterials.get(0); + } + + // ── Chunk-Index ─────────────────────────────────────────────────────────── + + private static int chunkIndex(float x, float z) { + int cx = (int) ((x + TERRAIN_HALF) / CHUNK_SIZE); + int cz = (int) ((z + 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; + } + // ── Chunk aufbauen ──────────────────────────────────────────────────────── private void buildChunk(int idx) { @@ -116,47 +164,42 @@ public class GrassState extends BaseAppState { float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE; float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE; - int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pxMax = Math.min(SPLAT_SIZE - 1, (int)((wXMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); - int pzMax = Math.min(SPLAT_SIZE - 1, (int)((wZMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX)); + List tufts = chunkTufts[idx]; + if (tufts.isEmpty()) return; - List blades = new ArrayList<>(); - Vector3f scale = terrain.getWorldScale(); - Vector3f trans = terrain.getWorldTranslation(); - - for (int pz = pzMin; pz <= pzMax; pz++) { - for (int px = pxMin; px <= pxMax; px++) { - int d = mapData.grassDensity[pz * SPLAT_SIZE + px] & 0xFF; - if (d == 0) continue; - int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PX)); - Random rng = new Random((long) px * 100003L + pz); - float pixWorldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF; - float pixWorldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF; - for (int b = 0; b < count; b++) { - float bx = pixWorldX + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX; - float bz = pixWorldZ + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX; - // Welt→lokal→Höhe→Welt - float localX = (bx - trans.x) / scale.x; - float localZ = (bz - trans.z) / scale.z; - float th = terrain.getHeight(new Vector2f(localX, localZ)); - if (Float.isNaN(th)) continue; - float worldY = trans.y + th * scale.y; - float h = DEFAULT_HEIGHT * (0.7f + rng.nextFloat() * 0.6f); - blades.add(new float[]{bx, worldY, bz, h}); - } + Map> bySlot = new LinkedHashMap<>(); + for (GrassTuft t : tufts) { + long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L + ^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL; + Random rng = new Random(seed); + List blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>()); + for (int b = 0; b < BLADES_PER_TUFT; b++) { + float bx = t.x() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f; + float bz = t.z() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f; + float th = terrain.getHeight(new Vector2f(bx, bz)); + if (Float.isNaN(th)) continue; + float h = t.height() * (0.7f + rng.nextFloat() * 0.6f); + blades.add(new float[]{bx, th, bz, h}); } } - if (blades.isEmpty()) return; + if (bySlot.isEmpty()) return; - Mesh mesh = buildGrassMesh(blades); - float chunkCX = wXMin + CHUNK_SIZE * 0.5f; - float chunkCZ = wZMin + CHUNK_SIZE * 0.5f; - Geometry geo = new Geometry("grass_" + idx, mesh); - geo.setMaterial(grassMat); - geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); - grassNode.attachChild(geo); + 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; + Material mat = getSlotMaterial(entry.getKey()); + if (mat == null) continue; + Geometry geo = new Geometry("grass_" + idx + "_s" + entry.getKey(), + buildGrassMesh(entry.getValue())); + geo.setMaterial(mat); + node.attachChild(geo); + } + if (node.getChildren().isEmpty()) return; + node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); + grassNode.attachChild(node); } // ── Mesh: Kreuz-Quad mit UV ─────────────────────────────────────────────── diff --git a/blight-game/src/main/java/de/blight/game/state/RiverState.java b/blight-game/src/main/java/de/blight/game/state/RiverState.java new file mode 100644 index 0000000..90f36fb --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/RiverState.java @@ -0,0 +1,314 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.asset.AssetManager; +import com.jme3.effect.ParticleEmitter; +import com.jme3.effect.ParticleMesh; +import com.jme3.material.Material; +import com.jme3.material.RenderState; +import com.jme3.math.ColorRGBA; +import com.jme3.math.FastMath; +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.Spatial; +import com.jme3.scene.VertexBuffer; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.util.BufferUtils; +import de.blight.common.RiverIO; +import de.blight.common.RiverPoint; +import de.blight.common.RiverSpline; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.util.List; +import java.util.Random; + +/** + * Rendert alle gespeicherten Flüsse als Ribbon-Meshes mit Dual-Layer Normal-Map, + * Tiefengradient, Uferschaum (Worley-Noise) und g_Time-basierter UV-Animation. + */ +public class RiverState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(RiverState.class); + private static final float UV_SCALE = 6.0f; + + private Node riverNode; + private AssetManager assets; + private Texture2D foamTexture; + private final List animatedMaterials = new java.util.ArrayList<>(); + private float time = 0f; + + public RiverState() {} + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + log.info("RiverState: initialisiere"); + this.assets = app.getAssetManager(); + this.foamTexture = generateFoamTexture(); + + riverNode = new Node("rivers"); + ((SimpleApplication) app).getRootNode().attachChild(riverNode); + + List> rivers; + try { + rivers = RiverIO.load(); + } catch (Exception e) { + log.error("Flüsse nicht ladbar", e); + return; + } + log.info("RiverState: {} Fluss/Flüsse in Datei", rivers.size()); + if (rivers.isEmpty()) return; + + int built = 0; + for (List river : rivers) { + if (river == null || river.size() < 2) continue; + try { + buildRiver(river); + built++; + } catch (Exception e) { + log.error("Fehler beim Fluss-Aufbau", e); + } + } + log.info("{}/{} Fluss/Flüsse geladen.", built, rivers.size()); + } + + @Override + protected void cleanup(Application app) { + riverNode.detachAllChildren(); + ((SimpleApplication) app).getRootNode().detachChild(riverNode); + foamTexture = null; + } + + @Override + protected void onEnable() { riverNode.setCullHint(Spatial.CullHint.Inherit); } + + @Override + protected void onDisable() { riverNode.setCullHint(Spatial.CullHint.Always); } + + @Override + public void update(float tpf) { + if (animatedMaterials.isEmpty()) return; + time += tpf; + for (Material m : animatedMaterials) { + m.setFloat("Time", time); + } + } + + // ── Fluss bauen ─────────────────────────────────────────────────────────── + + private void buildRiver(List pts) { + int n = pts.size(); + int i = 0; + while (i < n - 1) { + boolean wf = pts.get(i).isWaterfall(); + int j = i + 1; + while (j < n - 1 && pts.get(j).isWaterfall() == wf) j++; + List run = RiverSpline.subdivide(pts.subList(i, j + 1)); + if (run.size() >= 2) { + buildRibbonSection(run, wf); + if (wf) buildWaterfallParticles(run.get(run.size() - 1)); + } + i = j; + } + } + + private void buildRibbonSection(List pts, boolean isWaterfall) { + Mesh mesh = buildRibbonMesh(pts); + if (mesh == null) return; + Material mat = buildMaterial(isWaterfall); + Geometry geo = new Geometry("river_ribbon", mesh); + geo.setMaterial(mat); + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + riverNode.attachChild(geo); + if (mat.getMaterialDef().getName().equals("Flowing Water")) { + animatedMaterials.add(mat); + } + } + + private Mesh buildRibbonMesh(List pts) { + int n = pts.size(); + if (n < 2) return null; + + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3); + FloatBuffer norm = BufferUtils.createFloatBuffer(n * 2 * 3); + FloatBuffer uv = BufferUtils.createFloatBuffer(n * 2 * 2); + IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3); + + // Kumulierte Bogenlänge für V-Koordinate + float[] arcLen = new float[n]; + for (int i = 1; i < n; i++) { + RiverPoint a = pts.get(i - 1), b = pts.get(i); + float dx = b.x()-a.x(), dz = b.z()-a.z(), dy = b.y()-a.y(); + arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz); + } + + for (int i = 0; i < n; i++) { + RiverPoint pt = pts.get(i); + + Vector3f tangent; + if (i == 0) { + RiverPoint next = pts.get(1); + tangent = new Vector3f(next.x()-pt.x(), next.y()-pt.y(), next.z()-pt.z()); + } else if (i == n - 1) { + RiverPoint prev = pts.get(n - 2); + tangent = new Vector3f(pt.x()-prev.x(), pt.y()-prev.y(), pt.z()-prev.z()); + } else { + RiverPoint prev = pts.get(i - 1), next = pts.get(i + 1); + tangent = new Vector3f(next.x()-prev.x(), next.y()-prev.y(), next.z()-prev.z()); + } + if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f); + tangent.normalizeLocal(); + + Vector3f right = tangent.cross(Vector3f.UNIT_Y).normalizeLocal(); + if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f); + + float halfW = pt.width() * 0.5f; + float px = pt.x(), py = pt.y(), pz = pt.z(); + float vCoord = arcLen[i] / UV_SCALE; + + // Linker Rand (U=0), rechter Rand (U=1) + pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW); + norm.put(0f).put(1f).put(0f); + uv.put(0f).put(vCoord); + + pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW); + norm.put(0f).put(1f).put(0f); + uv.put(1f).put(vCoord); + } + + for (int i = 0; i < n - 1; i++) { + int v0 = 2*i, v1 = 2*i+1, v2 = 2*i+2, v3 = 2*i+3; + idx.put(v0).put(v1).put(v3); + idx.put(v0).put(v3).put(v2); + } + + pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind(); + + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Normal, 3, norm); + mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv); + mesh.setBuffer(VertexBuffer.Type.Index, 3, idx); + mesh.updateBound(); + mesh.updateCounts(); + return mesh; + } + + private Material buildMaterial(boolean isWaterfall) { + ColorRGBA tint = isWaterfall + ? new ColorRGBA(0.65f, 0.82f, 0.95f, 0.80f) + : new ColorRGBA(0.10f, 0.30f, 0.62f, 0.85f); + + Material mat; + try { + mat = new Material(assets, "MatDefs/FlowingWater.j3md"); + try { + Texture nm = assets.loadTexture( + "Common/MatDefs/Water/Textures/water_normalmap.png"); + nm.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("NormalMap", nm); + } catch (Exception e) { + log.warn("Normal-Map nicht ladbar, wird ohne Wellenstruktur gerendert"); + } + if (foamTexture != null) { + mat.setTexture("FoamMap", foamTexture); + } + mat.setColor("Tint", tint); + mat.setFloat("UVScale", UV_SCALE); + mat.setFloat("FlowSpeed", isWaterfall ? RiverPoint.WATERFALL_SPEED + : RiverPoint.RIVER_SPEED); + mat.setFloat("FoamAmount", isWaterfall ? 1.0f : 0.0f); + } catch (Exception e) { + log.warn("FlowingWater-Material nicht ladbar, Fallback auf Unshaded", e); + mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", tint); + } + + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + mat.getAdditionalRenderState().setDepthWrite(false); + return mat; + } + + // ── Worley-Noise Schaum-Textur ──────────────────────────────────────────── + + private Texture2D generateFoamTexture() { + int size = 256; + int nPts = 40; + Random rng = new Random(12345L); + float[] sx = new float[nPts]; + float[] sy = new float[nPts]; + for (int i = 0; i < nPts; i++) { + sx[i] = rng.nextFloat() * size; + sy[i] = rng.nextFloat() * size; + } + + float cellR = size / (float) Math.sqrt(nPts) * 0.55f; + ByteBuffer buf = BufferUtils.createByteBuffer(size * size * 4); + + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float minD = Float.MAX_VALUE; + for (int i = 0; i < nPts; i++) { + float dx = Math.abs(x - sx[i]); + float dy = Math.abs(y - sy[i]); + if (dx > size * 0.5f) dx = size - dx; // Kachelung + if (dy > size * 0.5f) dy = size - dy; + float d = (float) Math.sqrt(dx*dx + dy*dy); + if (d < minD) minD = d; + } + float v = Math.max(0f, 1f - minD / cellR); + v = v * v; // Schaum-Blasen schärfer abgrenzen + byte bv = (byte) Math.round(v * 255); + buf.put(bv).put(bv).put(bv).put((byte) 255); + } + } + buf.flip(); + + Texture2D tex = new Texture2D(new Image(Image.Format.RGBA8, size, size, buf)); + tex.setWrap(Texture.WrapMode.Repeat); + tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); + tex.setMagFilter(Texture.MagFilter.Bilinear); + return tex; + } + + // ── Partikel-Emitter ────────────────────────────────────────────────────── + + private void buildWaterfallParticles(RiverPoint base) { + ParticleEmitter emitter = new ParticleEmitter( + "waterfall_particles", ParticleMesh.Type.Triangle, 30); + Material pMat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md"); + try { + pMat.setTexture("Texture", assets.loadTexture("Effects/Smoke/Smoke.png")); + } catch (Exception e) { + log.warn("Partikel-Textur nicht ladbar", e); + } + emitter.setMaterial(pMat); + emitter.setImagesX(1); + emitter.setImagesY(1); + emitter.setStartColor(new ColorRGBA(1f, 1f, 1f, 0.5f)); + emitter.setEndColor(new ColorRGBA(1f, 1f, 1f, 0f)); + emitter.setStartSize(1.2f); + emitter.setEndSize(2.5f); + emitter.setGravity(0f, -0.5f, 0f); + emitter.setLowLife(0.8f); + emitter.setHighLife(1.2f); + emitter.setInitialVelocity(new Vector3f(0f, 3f, 0f)); + emitter.setVelocityVariation(0.6f); + emitter.setParticlesPerSec(15); + emitter.setLocalTranslation(base.x(), base.y(), base.z()); + riverNode.attachChild(emitter); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WaterBodyState.java b/blight-game/src/main/java/de/blight/game/state/WaterBodyState.java new file mode 100644 index 0000000..a4fe401 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WaterBodyState.java @@ -0,0 +1,277 @@ +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.material.Material; +import com.jme3.material.RenderState; +import com.jme3.math.*; +import com.jme3.post.FilterPostProcessor; +import com.jme3.renderer.queue.RenderQueue; +import com.jme3.scene.*; +import com.jme3.scene.VertexBuffer; +import com.jme3.terrain.geomipmap.TerrainQuad; +import com.jme3.util.BufferUtils; +import com.jme3.water.WaterFilter; +import de.blight.common.PlacedWater; +import de.blight.common.WaterBodyIO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.util.*; + +/** + * Rendert im Editor platzierte Wasserflächen per WaterFilter (visuelle Qualität). + * Ein unsichtbares Komplementär-Mesh (nur Tiefenpuffer) verhindert, dass der + * WaterFilter außerhalb der Flood-Fill-Form Wasser rendert. + */ +public class WaterBodyState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WaterBodyState.class); + private static final int WATER_GRID = 2049; + private static final int STEP = 2; + private static final int WORLD_HALF = 2048; + private static final int MAX_CELLS = 500_000; + private static final float MASK_MARGIN = 20f; // Puffer um den Becken-Radius + + private final TerrainQuad terrain; + private final FilterPostProcessor fpp; + private Node waterNode; + private final List waterFilters = new ArrayList<>(); + + public WaterBodyState(TerrainQuad terrain, FilterPostProcessor fpp) { + this.terrain = terrain; + this.fpp = fpp; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + SimpleApplication sa = (SimpleApplication) app; + waterNode = new Node("waterBodies"); + sa.getRootNode().attachChild(waterNode); + + List bodies; + try { + bodies = WaterBodyIO.load(); + } catch (Exception e) { + log.error("Wasserflächen nicht ladbar", e); + return; + } + if (bodies.isEmpty()) return; + + Vector3f sunDir = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal(); + + for (PlacedWater body : bodies) { + try { + Set cells = floodFill(body.seedX(), body.seedZ(), body.waterHeight()); + if (cells == null || cells.isEmpty()) { + log.warn("Becken nicht rekonstruierbar: {}/{}", body.seedX(), body.seedZ()); + continue; + } + + float[] cr = computeCentroidAndRadius(cells); + float cx = cr[0], cz = cr[1]; + float filterRadius = cr[2] + MASK_MARGIN; + + WaterFilter wf = new WaterFilter(sa.getRootNode(), sunDir); + wf.setWaterHeight(body.waterHeight()); + wf.setCenter(new Vector3f(cx, body.waterHeight(), cz)); + wf.setRadius(filterRadius); + wf.setShapeType(WaterFilter.AreaShape.Circular); + wf.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f)); + wf.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f)); + wf.setWaterTransparency(0.15f); + wf.setMaxAmplitude(0.3f); + wf.setWaveScale(0.008f); + wf.setSpeed(0.5f); + fpp.addFilter(wf); + waterFilters.add(wf); + + // Tiefenpuffer-Maske: alle Zellen im Filterkreis außerhalb des Beckens + Geometry mask = buildDepthMask(cells, cx, cz, filterRadius, body.waterHeight(), + app.getAssetManager()); + if (mask != null) waterNode.attachChild(mask); + + log.info("Becken: cells={} h={} center=({},{}) r={}", + cells.size(), body.waterHeight(), cx, cz, filterRadius); + } catch (Exception e) { + log.error("Fehler bei Becken {}/{}", body.seedX(), body.seedZ(), e); + } + } + log.info("{}/{} Wasserfläche(n) geladen.", waterFilters.size(), bodies.size()); + } + + @Override + protected void cleanup(Application app) { + for (WaterFilter wf : waterFilters) fpp.removeFilter(wf); + waterFilters.clear(); + if (waterNode != null) + ((SimpleApplication) app).getRootNode().detachChild(waterNode); + } + + @Override + protected void onEnable() { + if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Inherit); + } + + @Override + protected void onDisable() { + if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Always); + } + + // ── Flood-Fill ──────────────────────────────────────────────────────────── + + private Set floodFill(float seedWorldX, float seedWorldZ, float waterHeight) { + int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP); + int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP); + seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX)); + seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ)); + + Map heightCache = new HashMap<>(); + float seedH = sampleHeight(seedPX, seedPZ, heightCache); + if (seedH > waterHeight + 0.5f) { + log.warn("Seed-Höhe {} über waterHeight {} – Becken nicht rekonstruierbar", seedH, waterHeight); + return null; + } + + Set visited = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + visited.add(seedPZ * WATER_GRID + seedPX); + queue.add(new int[]{seedPX, seedPZ}); + + final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}}; + + while (!queue.isEmpty()) { + int[] c = queue.poll(); + int px = c[0], pz = c[1]; + + if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1) + return null; + + for (int[] d : dirs) { + int nx = px + d[0], nz = pz + d[1]; + int nIdx = nz * WATER_GRID + nx; + if (visited.contains(nIdx)) continue; + if (sampleHeight(nx, nz, heightCache) <= waterHeight) { + visited.add(nIdx); + if (visited.size() > MAX_CELLS) return null; + queue.add(new int[]{nx, nz}); + } + } + } + return visited.isEmpty() ? null : visited; + } + + private float sampleHeight(int px, int pz, Map cache) { + int key = pz * WATER_GRID + px; + Float cached = cache.get(key); + if (cached != null) return cached; + float worldX = (float)(px * STEP) - WORLD_HALF; + float worldZ = (float)(pz * STEP) - WORLD_HALF; + Float h = terrain.getHeight(new Vector2f(worldX, worldZ)); + float height = (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE; + cache.put(key, height); + return height; + } + + // ── Geometrie-Hilfsmethoden ─────────────────────────────────────────────── + + private static float[] computeCentroidAndRadius(Set cells) { + double sumX = 0, sumZ = 0; + for (int cell : cells) { + sumX += (double)((cell % WATER_GRID) * STEP) - WORLD_HALF; + sumZ += (double)((cell / WATER_GRID) * STEP) - WORLD_HALF; + } + float cx = (float)(sumX / cells.size()); + float cz = (float)(sumZ / cells.size()); + float maxR = 0f; + for (int cell : cells) { + float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF; + float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF; + float dx = wx - cx, dz = wz - cz; + float r = FastMath.sqrt(dx * dx + dz * dz); + if (r > maxR) maxR = r; + } + return new float[]{cx, cz, maxR}; + } + + /** + * Erstellt ein unsichtbares Mesh bei waterHeight+0.01 für alle Zellen im + * Filterkreis, die NICHT zum Becken gehören. + * + * Das Mesh schreibt nur in den Tiefenpuffer (ColorWrite=false, DepthWrite=true). + * Vom WaterFilter aus gesehen liegt diese "Fläche" über dem Terrain (höheres Y = + * näher zur Kamera von oben) und blockiert damit das Wasser-Rendering außerhalb + * des Beckens. + */ + private static Geometry buildDepthMask(Set basinCells, float cx, float cz, + float radius, float waterHeight, + AssetManager assets) { + float h = waterHeight + 0.01f; + float r2 = radius * radius; + + int minPX = Math.max(0, (int) Math.floor(((cx - radius) + WORLD_HALF) / STEP) - 1); + int maxPX = Math.min(WATER_GRID - 1, (int) Math.ceil (((cx + radius) + WORLD_HALF) / STEP) + 1); + int minPZ = Math.max(0, (int) Math.floor(((cz - radius) + WORLD_HALF) / STEP) - 1); + int maxPZ = Math.min(WATER_GRID - 1, (int) Math.ceil (((cz + radius) + WORLD_HALF) / STEP) + 1); + + List maskCells = new ArrayList<>(); + for (int pz = minPZ; pz <= maxPZ; pz++) { + for (int px = minPX; px <= maxPX; px++) { + int cellIdx = pz * WATER_GRID + px; + if (basinCells.contains(cellIdx)) continue; + float wx = (float)(px * STEP) - WORLD_HALF; + float wz = (float)(pz * STEP) - WORLD_HALF; + float dx = wx - cx, dz = wz - cz; + if (dx * dx + dz * dz <= r2) maskCells.add(cellIdx); + } + } + if (maskCells.isEmpty()) return null; + + int n = maskCells.size(); + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3); + IntBuffer idx = BufferUtils.createIntBuffer(n * 6); + int vi = 0; + for (int cell : maskCells) { + int pz = cell / WATER_GRID; + int px = cell % WATER_GRID; + float wx = (float)(px * STEP) - WORLD_HALF; + float wz = (float)(pz * STEP) - WORLD_HALF; + pos.put(wx ).put(h).put(wz ); + pos.put(wx + STEP).put(h).put(wz ); + pos.put(wx + STEP).put(h).put(wz + STEP); + pos.put(wx ).put(h).put(wz + STEP); + // CCW von oben → Normalen zeigen +Y + idx.put(vi).put(vi+2).put(vi+1); + idx.put(vi).put(vi+3).put(vi+2); + vi += 4; + } + + pos.rewind(); idx.rewind(); + + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Index, 3, idx); + mesh.updateBound(); + mesh.updateCounts(); + + Geometry geo = new Geometry("water_mask", mesh); + geo.setShadowMode(RenderQueue.ShadowMode.Off); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", ColorRGBA.Black); + mat.getAdditionalRenderState().setColorWrite(false); + mat.getAdditionalRenderState().setDepthWrite(true); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + geo.setMaterial(mat); + // Transparent: renders after Opaque terrain, so terrain depth is already in buffer. + // The mask (closer to camera = higher Y) passes depth test and overwrites it, + // blocking WaterFilter from reading terrain Y < waterHeight at non-basin pixels. + geo.setQueueBucket(RenderQueue.Bucket.Transparent); + return geo; + } +} 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 new file mode 100644 index 0000000..d601e1e --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java @@ -0,0 +1,168 @@ +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.asset.plugins.FileLocator; +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.shape.*; +import com.jme3.texture.Texture; +import de.blight.common.PlacedModel; +import de.blight.common.PlacedModelIO; +import de.blight.game.animation.AnimationLibrary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class WorldObjectsState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WorldObjectsState.class); + + private SimpleApplication app; + private AssetManager assets; + private BulletAppState bulletAppState; + + @Override + protected void initialize(Application app) { + this.app = (SimpleApplication) app; + this.assets = app.getAssetManager(); + this.bulletAppState = app.getStateManager().getState(BulletAppState.class); + + // Asset-Root registrieren, damit Modell-Pfade auflösbar sind + try { + assets.registerLocator( + AnimationLibrary.findAssetRoot().toAbsolutePath().toString(), + FileLocator.class); + } catch (Exception ignored) {} + } + + @Override + protected void onEnable() { + List models; + try { + models = PlacedModelIO.load(); + } catch (Exception e) { + log.warn("[WorldObjects] Fehler beim Laden der Objekte: {}", e.getMessage()); + return; + } + if (models.isEmpty()) { + log.info("[WorldObjects] Keine platzierten Objekte gefunden."); + return; + } + log.info("[WorldObjects] Lade {} Objekte…", models.size()); + Node root = app.getRootNode(); + int loaded = 0, failed = 0; + for (PlacedModel m : models) { + try { + Spatial s = buildSpatial(m); + s.setLocalTranslation(m.x(), m.y(), m.z()); + Quaternion rot = new Quaternion(); + rot.fromAngles(m.rotX(), m.rotY(), m.rotZ()); + s.setLocalRotation(rot); + s.setLocalScale(m.scale()); + + // Schatten + RenderQueue.ShadowMode shadowMode; + if (m.castShadow() && m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.CastAndReceive; + else if (m.castShadow()) shadowMode = RenderQueue.ShadowMode.Cast; + else if (m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.Receive; + else shadowMode = RenderQueue.ShadowMode.Off; + s.setShadowMode(shadowMode); + + // Physik-Kollision für solide Objekte + if (m.solid() && bulletAppState != null) { + try { + RigidBodyControl rbc = new RigidBodyControl( + CollisionShapeFactory.createMeshShape(s), 0f); + s.addControl(rbc); + bulletAppState.getPhysicsSpace().add(rbc); + } catch (Exception pe) { + log.warn("[WorldObjects] Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage()); + } + } + + root.attachChild(s); + loaded++; + } catch (Exception e) { + log.warn("[WorldObjects] Objekt '{}' nicht ladbar: {}", m.modelPath(), e.getMessage()); + failed++; + } + } + log.info("[WorldObjects] {} geladen, {} fehlgeschlagen.", loaded, failed); + } + + @Override protected void cleanup(Application app) {} + @Override protected void onDisable() {} + + private Spatial buildSpatial(PlacedModel m) { + // Exportiertes Mesh hat Vorrang vor modelPath + String path = (m.meshFile() != null && !m.meshFile().isBlank()) + ? m.meshFile() : m.modelPath(); + + Spatial spatial; + if (path.startsWith("@")) { + spatial = createPrimitive(path.substring(1)); + applyMaterial(spatial, m); + } else { + spatial = assets.loadModel(path); + } + spatial.setName("obj_" + path); + return spatial; + } + + private Spatial createPrimitive(String type) { + return switch (type) { + case "sphere" -> new Geometry("sphere", new Sphere(16, 16, 1f)); + case "cylinder" -> new Geometry("cylinder", new Cylinder(2, 16, 0.5f, 2f, true)); + case "plane" -> { + Geometry g = new Geometry("plane", new Quad(2f, 2f)); + g.rotate(-FastMath.HALF_PI, 0, 0); + g.move(-1f, 0, 1f); + yield g; + } + default -> new Geometry("box", new Box(0.5f, 0.5f, 0.5f)); + }; + } + + private void applyMaterial(Spatial s, PlacedModel m) { + boolean hasTex = m.texturePath() != null && !m.texturePath().isBlank(); + boolean hasNmap = m.normalMapPath() != null && !m.normalMapPath().isBlank(); + boolean hasMat = m.materialPath() != null && !m.materialPath().isBlank(); + if (!(s instanceof Geometry g)) return; + try { + if (hasMat) { + g.setMaterial(assets.loadMaterial(m.materialPath())); + return; + } + if (hasTex || hasNmap) { + Material mat = new Material(assets, "Common/MatDefs/Light/Lighting.j3md"); + mat.setBoolean("UseMaterialColors", false); + if (hasTex) { + Texture t = assets.loadTexture(m.texturePath()); + t.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("DiffuseMap", t); + } + if (hasNmap) { + Texture n = assets.loadTexture(m.normalMapPath()); + n.setWrap(Texture.WrapMode.Repeat); + mat.setTexture("NormalMap", n); + } + g.setMaterial(mat); + return; + } + } catch (Exception e) { + log.warn("[WorldObjects] Material-Fehler: {}", e.getMessage()); + } + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", ColorRGBA.Gray); + g.setMaterial(mat); + } +} diff --git a/blight-game/src/main/resources/icon.png b/blight-game/src/main/resources/icon.png new file mode 100644 index 0000000..7c8556b Binary files /dev/null and b/blight-game/src/main/resources/icon.png differ diff --git a/blight-game/src/main/resources/logo.png b/blight-game/src/main/resources/logo.png new file mode 100644 index 0000000..f9b3cb7 Binary files /dev/null and b/blight-game/src/main/resources/logo.png differ diff --git a/blight-map/src/main/map/blight_grass.blg b/blight-map/src/main/map/blight_grass.blg new file mode 100644 index 0000000..7141e82 Binary files /dev/null and b/blight-map/src/main/map/blight_grass.blg differ diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 3ec3090..c70b8a4 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 537757a..cf9e202 100644 --- a/blight-map/src/main/map/blight_objects.blo +++ b/blight-map/src/main/map/blight_objects.blo @@ -1,32 +1,31 @@ -# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip -Models/Palm_Palme1_20260524_153405.j3o -74.09265 8.19780 -18.47723 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260524_153421.j3o -59.20779 13.84631 -17.90553 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260524_153421.j3o -37.63680 32.63404 -18.65228 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260524_153421.j3o -15.66568 10.46379 -25.60722 0.00000 1.00000 0.00000 0.00000 false -@plane -222.08658 36.63769 -106.48824 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_0.j3o -@box -471.37857 22.27976 -91.47378 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_1.j3o -@box -469.23279 22.49469 -91.51467 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_2.j3o -@group -462.16046 3.59008 -122.32437 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_3.j3o -Models/Palm_Palme1.j3o -245.90610 165.20883 99.17912 -6.43500 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075053.j3o -246.13976 166.24338 89.64748 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075102.j3o -240.90959 166.65724 85.45869 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075134.j3o -254.69368 165.64214 91.69685 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075137.j3o -248.68674 167.64050 76.46214 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1.j3o -205.20802 165.35493 88.46772 -18.77974 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075102.j3o -135.84702 158.69884 84.12026 0.00000 1.00000 0.00000 0.00000 false -@plane -224.78107 164.15782 108.96712 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_4.j3o -@plane -220.53658 165.75740 108.73174 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_5.j3o -@plane -237.96001 165.08000 113.44000 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_6.j3o -@plane -235.81349 164.75165 114.21590 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_7.j3o -Models/Tree/Tree.mesh.j3o -246.86934 164.83850 107.50585 0.00000 1.00000 0.00000 0.00000 false -Models/Boat/boat.j3o -261.74731 164.72397 120.62883 0.00000 1.00000 0.00000 0.00000 false -Models/Campfire.j3o -350.02148 164.72334 72.42865 0.00000 1.00000 0.00000 0.00000 false -Models/Campfire.j3o -67.93591 1.71510 -36.36593 0.00000 1.00000 0.00000 0.00000 false -Models/tree.j3o -28.09666 16.39296 -34.64375 0.00000 1.00000 0.00000 0.00000 false -Models/tree.j3o -83.04567 -2.69246 -39.34207 0.00000 1.00000 0.00000 0.00000 false -Models/Tree/Tree.mesh.j3o 295.18826 1.00000 189.92809 0.00000 1.00000 0.00000 0.00000 false -Models/gltf/duck/Duck.gltf 300.54111 1.00000 198.35368 0.00000 1.00000 0.00000 0.00000 false -Models/Buggy/Buggy.j3o 296.60590 1.00000 201.24153 0.00000 1.00000 0.00000 0.00000 false -Models/Jaime/Jaime.j3o 304.02374 1.00000 199.25398 0.00000 1.00000 0.00000 0.00000 false -Models/tree.j3o 221.97984 1.00000 130.25409 0.00000 1.00000 0.00000 0.00000 false -Models/Palm_Palme1_20260522_075134.j3o 240.61151 1.00000 97.48973 0.00000 1.00000 0.00000 0.00000 false +# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow +Models/Palm_Palme1_20260524_153405.j3o -74.09265 8.19780 -18.47723 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260524_153421.j3o -59.20779 13.84631 -17.90553 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260524_153421.j3o -37.63680 32.63404 -18.65228 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260524_153421.j3o -15.66568 10.46379 -25.60722 0.00000 1.00000 0.00000 0.00000 false true true +@box -471.37857 22.27976 -91.47378 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_0.j3o true true +@box -469.23279 22.49469 -91.51467 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_1.j3o true true +@group -462.16046 3.59008 -122.32437 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_2.j3o true true +Models/Palm_Palme1.j3o -245.90610 165.20883 99.17912 -6.43500 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260522_075053.j3o -246.13976 166.24338 89.64748 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260522_075102.j3o -240.90959 166.65724 85.45869 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260522_075134.j3o -254.69368 165.64214 91.69685 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260522_075137.j3o -248.68674 167.64050 76.46214 0.00000 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1.j3o -205.20802 165.35493 88.46772 -18.77974 1.00000 0.00000 0.00000 false true true +Models/Palm_Palme1_20260522_075102.j3o -135.84702 158.69884 84.12026 0.00000 1.00000 0.00000 0.00000 false true true +@plane -224.78107 164.15782 108.96712 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_3.j3o true true +@plane -220.53658 165.75740 108.73174 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_4.j3o true true +@plane -237.96001 165.08000 113.44000 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_5.j3o true true +@plane -235.81349 164.75165 114.21590 0.00000 1.00000 0.00000 0.00000 false Models/custom_mesh_6.j3o true true +Models/Tree/Tree.mesh.j3o -246.86934 164.83850 107.50585 0.00000 1.00000 0.00000 0.00000 false true true +Models/Boat/boat.j3o -261.74731 164.72397 120.62883 0.00000 1.00000 0.00000 0.00000 false true true +Models/Campfire.j3o -350.02148 164.72334 72.42865 0.00000 1.00000 0.00000 0.00000 false true true +Models/Campfire.j3o -67.93591 1.71510 -36.36593 0.00000 1.00000 0.00000 0.00000 false true true +Models/tree.j3o -28.09666 16.39296 -34.64375 0.00000 1.00000 0.00000 0.00000 false true true +Models/tree.j3o -83.04567 -2.69246 -39.34207 0.00000 1.00000 0.00000 0.00000 false true true +Models/Tree/Tree.mesh.j3o 295.18826 1.00000 189.92809 0.00000 1.00000 0.00000 0.00000 false true true +Models/gltf/duck/Duck.gltf 300.54111 1.00000 198.35368 0.00000 1.00000 0.00000 0.00000 false true true +Models/Buggy/Buggy.j3o 296.60590 1.00000 201.24153 0.00000 1.00000 0.00000 0.00000 false true true +Models/Jaime/Jaime.j3o 304.02374 1.00000 199.25398 0.00000 1.00000 0.00000 0.00000 false true true +Models/tree.j3o 221.98000 1.00000 130.25000 0.00000 1.00000 0.00000 0.00000 true true true +Models/Palm_Palme1_20260522_075134.j3o 240.61151 1.00000 97.48973 0.00000 1.00000 0.00000 0.00000 false true true diff --git a/blight-map/src/main/map/blight_rivers.blr b/blight-map/src/main/map/blight_rivers.blr new file mode 100644 index 0000000..cd09073 --- /dev/null +++ b/blight-map/src/main/map/blight_rivers.blr @@ -0,0 +1,3 @@ +# blight_rivers.blr – Flussdaten +# Format: x,y,z,width,uvSpeed | x,y,z,width,uvSpeed | ... +3.71271,1.45984,-299.11252,8.98643,0.40000|11.98857,1.17747,-290.66177,8.98643,0.40000|31.99506,1.01111,-282.99625,8.98643,0.40000|48.89030,0.96111,-289.70255,8.98643,0.40000|63.18369,0.91111,-300.59106,8.98643,0.40000|72.72055,0.86111,-308.32092,8.98643,0.40000|81.47565,0.81111,-310.34119,8.98643,0.40000|88.85998,0.54894,-310.70313,8.98643,0.40000|95.29650,0.01967,-310.18607,8.98643,0.40000 diff --git a/blight-map/src/main/map/blight_water.blw b/blight-map/src/main/map/blight_water.blw index 76960f0..dc927bb 100644 --- a/blight-map/src/main/map/blight_water.blw +++ b/blight-map/src/main/map/blight_water.blw @@ -1 +1,2 @@ -# x y z width depth +# seedX seedZ waterHeight +-187.54634 -189.42381 5.33836 diff --git a/logo/icon.png b/logo/icon.png new file mode 100644 index 0000000..7c8556b Binary files /dev/null and b/logo/icon.png differ diff --git a/logo/icon_editor.png b/logo/icon_editor.png new file mode 100644 index 0000000..cb33e74 Binary files /dev/null and b/logo/icon_editor.png differ