diff --git a/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md b/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md new file mode 100644 index 0000000..3054296 --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md @@ -0,0 +1,25 @@ +MaterialDef GrassVertex { + + MaterialParameters { + Float WindSpeed : 1.0 + Float WindStrength : 0.15 + Vector3 SunDir : 0.35 0.8 0.45 + Color SunColor : 0.95 0.90 0.75 1.0 + } + + Technique { + VertexShader GLSL150: Shaders/GrassVertex.vert + FragmentShader GLSL150: Shaders/GrassVertex.frag + + WorldParameters { + WorldViewProjectionMatrix + WorldMatrix + Time + AmbientLightColor + } + + RenderState { + FaceCull Off + } + } +} diff --git a/blight-assets/src/main/resources/MatDefs/Topology.j3md b/blight-assets/src/main/resources/MatDefs/Topology.j3md new file mode 100644 index 0000000..7cf1568 --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/Topology.j3md @@ -0,0 +1,19 @@ +MaterialDef Topology { + MaterialParameters { + Float Interval : 10.0 + Float LineWidth : 0.12 + Float Opacity : 0.55 + } + Technique { + VertexShader GLSL150: Shaders/Topology.vert + FragmentShader GLSL150: Shaders/Topology.frag + WorldParameters { + WorldViewProjectionMatrix + WorldMatrix + } + RenderState { + Blend Alpha + DepthWrite Off + } + } +} diff --git a/blight-assets/src/main/resources/MatDefs/WaterPolygon.j3md b/blight-assets/src/main/resources/MatDefs/WaterPolygon.j3md new file mode 100644 index 0000000..e0d5529 --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/WaterPolygon.j3md @@ -0,0 +1,80 @@ +MaterialDef Advanced Water Polygon { + + MaterialParameters { + Int BoundDrawBuffer + Int NumSamples + Int NumSamplesDepth + Texture2D FoamMap + Texture2D CausticsMap + Texture2D NormalMap -LINEAR + Texture2D ReflectionMap + Texture2D HeightMap -LINEAR + Texture2D Texture + Texture2D DepthTexture + Vector3 CameraPosition + Float Time + Vector3 frustumCorner + Matrix4 TextureProjMatrix + Float WaterHeight + Vector3 LightDir + Float WaterTransparency + Float NormalScale + Float R0 + Float MaxAmplitude + Color LightColor + Float ShoreHardness + Float FoamHardness + Float RefractionStrength + Float WaveScale + Vector3 FoamExistence + Float SunScale + Vector3 ColorExtinction + Float Shininess + Color WaterColor + Color DeepWaterColor + Vector2 WindDirection + Float ReflectionDisplace + Float FoamIntensity + Float CausticsIntensity + Float UnderWaterFogDistance + + Boolean UseRipples + Boolean UseHQShoreline + Boolean UseSpecular + Boolean UseFoam + Boolean UseCaustics + Boolean UseRefraction + + Float Radius + Vector3 Center + Boolean SquareArea + + Vector2Array Points + Int NumPoints : 0 + } + + Technique { + VertexShader GLSL310 GLSL300 GLSL150 GLSL120 : Common/MatDefs/Post/Post.vert + FragmentShader GLSL310 GLSL300 GLSL150 GLSL120 : Shaders/WaterPolygon.frag + + WorldParameters { + ViewProjectionMatrixInverse + } + + Defines { + BOUND_DRAW_BUFFER: BoundDrawBuffer + RESOLVE_MS : NumSamples + RESOLVE_DEPTH_MS : NumSamplesDepth + ENABLE_RIPPLES : UseRipples + ENABLE_HQ_SHORELINE : UseHQShoreline + ENABLE_SPECULAR : UseSpecular + ENABLE_FOAM : UseFoam + ENABLE_CAUSTICS : UseCaustics + ENABLE_REFRACTION : UseRefraction + ENABLE_AREA : Center + SQUARE_AREA : SquareArea + POLYGON_AREA : Points + } + } + +} 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 6d32f7c..d0f4ede 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 506be8e..6cdf04f 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 92244e6..7bf35ea 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 03a1b1a..6d2ab2a 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 22e7c57..3a89225 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 eb7ec92..60ee292 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 ce73c21..39ad8d9 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/trees/oak/large/oak_large_20260606_163631.j3o b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163631.j3o new file mode 100644 index 0000000..df717c3 Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163631.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163636.j3o b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163636.j3o new file mode 100644 index 0000000..6190289 Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163636.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163819.j3o b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163819.j3o new file mode 100644 index 0000000..cd090fa Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/large/oak_large_20260606_163819.j3o differ diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_162818.j3o similarity index 99% rename from blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o rename to blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_162818.j3o index 521bdd0..6403e19 100644 Binary files a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160809.j3o and b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_162818.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_163554.j3o b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_163554.j3o new file mode 100644 index 0000000..34c57f0 Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_163554.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/medium/oak_medium_20260606_190655.j3o similarity index 99% rename from blight-assets/src/main/resources/Models/trees/oak/oak_medium_20260603_210903.j3o rename to blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_190655.j3o index 0e11c2d..0a12c7d 100644 Binary files a/blight-assets/src/main/resources/Models/trees/oak/oak_medium_20260603_210903.j3o and b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260606_190655.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260607_221049.j3o b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260607_221049.j3o new file mode 100644 index 0000000..469f13b Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/oak/medium/oak_medium_20260607_221049.j3o differ diff --git a/blight-assets/src/main/resources/Models/trees/pine/large/pine_large_20260606_190805.j3o b/blight-assets/src/main/resources/Models/trees/pine/large/pine_large_20260606_190805.j3o new file mode 100644 index 0000000..1cb0271 Binary files /dev/null and b/blight-assets/src/main/resources/Models/trees/pine/large/pine_large_20260606_190805.j3o differ diff --git a/blight-assets/src/main/resources/Shaders/GrassVertex.frag b/blight-assets/src/main/resources/Shaders/GrassVertex.frag new file mode 100644 index 0000000..e69b280 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/GrassVertex.frag @@ -0,0 +1,34 @@ +uniform vec4 g_AmbientLightColor; + +uniform vec3 m_SunDir; // Richtung von der Fläche zur Sonne (world-space, normiert) +uniform vec4 m_SunColor; // Sonnenfarbe RGB + +in vec4 varColor; +in vec3 varNormal; + +out vec4 outFragColor; + +void main() { + // Normale für Rück- und Vorderseite korrekt ausrichten + vec3 n = normalize(varNormal); + if (!gl_FrontFacing) n = -n; + + vec3 L = normalize(m_SunDir); + + // Diffuses Licht + float nDotL = max(dot(n, L), 0.0); + + // Subsurface-Scatter-Approximation: etwas Licht scheint durch den Halm + float sss = max(dot(-n, L), 0.0) * 0.25; + + float light = nDotL + sss; + + vec3 ambient = g_AmbientLightColor.rgb * varColor.rgb; + vec3 diffuse = m_SunColor.rgb * varColor.rgb * light; + + // Farbe nicht über die Vertex-Color-Helligkeit hinaus aufhellen + vec3 result = ambient + diffuse; + result = min(result, varColor.rgb * 1.5); + + outFragColor = vec4(result, 1.0); +} diff --git a/blight-assets/src/main/resources/Shaders/GrassVertex.vert b/blight-assets/src/main/resources/Shaders/GrassVertex.vert new file mode 100644 index 0000000..692c80e --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/GrassVertex.vert @@ -0,0 +1,39 @@ +uniform mat4 g_WorldViewProjectionMatrix; +uniform mat4 g_WorldMatrix; +uniform float g_Time; + +uniform float m_WindSpeed; +uniform float m_WindStrength; + +in vec3 inPosition; +in vec3 inNormal; +in vec4 inColor; +in vec2 inTexCoord; // .x = windFactor (0 = Wurzel, 1 = Spitze) + +out vec4 varColor; +out vec3 varNormal; + +void main() { + vec4 pos = vec4(inPosition, 1.0); + float wf = inTexCoord.x; + + if (wf > 0.001) { + // Weltposition als Phasenbasis → jeder Halm schwingt anders + vec2 worldXZ = (g_WorldMatrix * pos).xz; + float t = g_Time * m_WindSpeed; + + float sway = sin(t * 2.1 + worldXZ.x * 0.08 + worldXZ.y * 0.06) * 0.6 + + sin(t * 1.4 - worldXZ.x * 0.05 + worldXZ.y * 0.09) * 0.4; + + // Quadratische Gewichtung: Spitze biegt sich mehr als Basis + float bend = sway * m_WindStrength * wf * wf; + pos.x += bend; + pos.z += bend * 0.3; + } + + varColor = inColor; + // Normal in Weltkoordinaten (WorldMatrix ist für Gras typischerweise Identität) + varNormal = normalize(mat3(g_WorldMatrix) * inNormal); + + gl_Position = g_WorldViewProjectionMatrix * pos; +} diff --git a/blight-assets/src/main/resources/Shaders/Topology.frag b/blight-assets/src/main/resources/Shaders/Topology.frag new file mode 100644 index 0000000..53cd852 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/Topology.frag @@ -0,0 +1,38 @@ +uniform float m_Interval; +uniform float m_LineWidth; +uniform float m_Opacity; + +in float vWorldY; + +out vec4 outColor; + +// Maps height to a blue→green→yellow→red gradient +vec3 heightColor(float t) { + t = clamp(t, 0.0, 1.0); + vec3 c; + if (t < 0.25) { + c = mix(vec3(0.0, 0.0, 0.8), vec3(0.0, 0.7, 0.7), t * 4.0); + } else if (t < 0.5) { + c = mix(vec3(0.0, 0.7, 0.7), vec3(0.2, 0.8, 0.1), (t - 0.25) * 4.0); + } else if (t < 0.75) { + c = mix(vec3(0.2, 0.8, 0.1), vec3(0.9, 0.8, 0.0), (t - 0.5) * 4.0); + } else { + c = mix(vec3(0.9, 0.8, 0.0), vec3(0.8, 0.1, 0.0), (t - 0.75) * 4.0); + } + return c; +} + +void main() { + float minH = -20.0; + float maxH = 300.0; + float t = (vWorldY - minH) / (maxH - minH); + vec3 band = heightColor(t); + + // Contour lines: sharp pulse near each interval boundary + float phase = fract(vWorldY / m_Interval); + float fw = fwidth(vWorldY / m_Interval); + float line = 1.0 - smoothstep(m_LineWidth - fw, m_LineWidth + fw, min(phase, 1.0 - phase)); + + vec3 color = mix(band, vec3(0.0), line * 0.75); + outColor = vec4(color, m_Opacity); +} diff --git a/blight-assets/src/main/resources/Shaders/Topology.vert b/blight-assets/src/main/resources/Shaders/Topology.vert new file mode 100644 index 0000000..21cc74c --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/Topology.vert @@ -0,0 +1,12 @@ +uniform mat4 g_WorldViewProjectionMatrix; +uniform mat4 g_WorldMatrix; + +in vec3 inPosition; + +out float vWorldY; + +void main() { + vec4 worldPos = g_WorldMatrix * vec4(inPosition, 1.0); + vWorldY = worldPos.y; + gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0); +} diff --git a/blight-assets/src/main/resources/Shaders/WaterPolygon.frag b/blight-assets/src/main/resources/Shaders/WaterPolygon.frag new file mode 100644 index 0000000..51f6066 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/WaterPolygon.frag @@ -0,0 +1,483 @@ +#import "Common/ShaderLib/GLSLCompat.glsllib" +#import "Common/ShaderLib/MultiSample.glsllib" +#import "Common/ShaderLib/WaterUtil.glsllib" + +// Water pixel shader +// Copyright (C) JMonkeyEngine 3.0 +// by Remy Bouquet (nehon) for JMonkeyEngine 3.0 +// original HLSL version by Wojciech Toman 2009 + +uniform COLORTEXTURE m_Texture; +uniform DEPTHTEXTURE m_DepthTexture; + + +uniform sampler2D m_HeightMap; +uniform sampler2D m_NormalMap; +uniform sampler2D m_FoamMap; +uniform sampler2D m_CausticsMap; +uniform sampler2D m_ReflectionMap; + +uniform mat4 g_ViewProjectionMatrixInverse; +uniform mat4 m_TextureProjMatrix; +uniform vec3 m_CameraPosition; + +uniform float m_WaterHeight; +uniform float m_Time; +uniform float m_WaterTransparency; +uniform float m_NormalScale; +uniform float m_R0; +uniform float m_MaxAmplitude; +uniform vec3 m_LightDir; +uniform vec4 m_LightColor; +uniform float m_ShoreHardness; +uniform float m_FoamHardness; +uniform float m_RefractionStrength; +uniform vec3 m_FoamExistence; +uniform vec3 m_ColorExtinction; +uniform float m_Shininess; +uniform vec4 m_WaterColor; +uniform vec4 m_DeepWaterColor; +uniform vec2 m_WindDirection; +uniform float m_SunScale; +uniform float m_WaveScale; +uniform float m_UnderWaterFogDistance; +uniform float m_CausticsIntensity; + +#ifdef ENABLE_AREA +uniform vec3 m_Center; +uniform float m_Radius; +#endif + +#ifdef POLYGON_AREA +uniform vec2 m_Points[64]; +uniform int m_NumPoints; + +bool polygonContains(float px, float pz) { + bool inside = false; + int j = m_NumPoints - 1; + for (int i = 0; i < m_NumPoints; i++) { + float xi = m_Points[i].x, zi = m_Points[i].y; + float xj = m_Points[j].x, zj = m_Points[j].y; + if ((zi > pz) != (zj > pz) && + px < (xj - xi) * (pz - zi) / (zj - zi) + xi) + inside = !inside; + j = i; + } + return inside; +} +#endif + +vec2 scale; // = vec2(m_WaveScale, m_WaveScale); +float refractionScale; // = m_WaveScale; + +// Modifies 4 sampled normals. Increase first values to have more +// smaller "waves" or last to have more bigger "waves" +const vec4 normalModifier = vec4(3.0, 2.0, 4.0, 10.0); +// Strength of displacement along normal. +uniform float m_ReflectionDisplace; +// Water transparency along eye vector. +const float visibility = 3.0; +// foam intensity +uniform float m_FoamIntensity ; + +vec2 m_FrustumNearFar; //=vec2(1.0,m_UnderWaterFogDistance); +const float LOG2 = 1.442695; + + +varying vec2 texCoord; + +void setGlobals(){ +scale = vec2(m_WaveScale, m_WaveScale); +refractionScale = m_WaveScale; +m_FrustumNearFar=vec2(1.0,m_UnderWaterFogDistance); +} + +mat3 MatrixInverse(in mat3 inMatrix){ + float det = dot(cross(inMatrix[0], inMatrix[1]), inMatrix[2]); + mat3 T = transpose(inMatrix); + return mat3(cross(T[1], T[2]), + cross(T[2], T[0]), + cross(T[0], T[1])) / det; +} + + +mat3 computeTangentFrame(in vec3 N, in vec3 P, in vec2 UV) { + vec3 dp1 = dFdx(P); + vec3 dp2 = dFdy(P); + vec2 duv1 = dFdx(UV); + vec2 duv2 = dFdy(UV); + + // solve the linear system + vec3 dp1xdp2 = cross(dp1, dp2); + mat2x3 inverseM = mat2x3(cross(dp2, dp1xdp2), cross(dp1xdp2, dp1)); + + vec3 T = inverseM * vec2(duv1.x, duv2.x); + vec3 B = inverseM * vec2(duv1.y, duv2.y); + + // construct tangent frame + float maxLength = max(length(T), length(B)); + T = T / maxLength; + B = B / maxLength; + + return mat3(T, B, N); +} + +float saturate(in float val){ + return clamp(val,0.0,1.0); +} + +vec3 saturate(in vec3 val){ + return clamp(val,vec3(0.0),vec3(1.0)); +} + +vec3 getPosition(in float depth, in vec2 uv){ + vec4 pos = vec4(uv, depth, 1.0) * 2.0 - 1.0; + pos = g_ViewProjectionMatrixInverse * pos; + return pos.xyz / pos.w; +} + +// Function calculating fresnel term. +// - normal - normalized normal vector +// - eyeVec - normalized eye vector +float fresnelTerm(in vec3 normal,in vec3 eyeVec){ + float angle = 1.0 - max(0.0, dot(normal, eyeVec)); + float fresnel = angle * angle; + fresnel = fresnel * fresnel; + fresnel = fresnel * angle; + return saturate(fresnel * (1.0 - saturate(m_R0)) + m_R0 - m_RefractionStrength); +} + +vec4 underWater(int sampleNum){ + + + float sceneDepth = fetchTextureSample(m_DepthTexture, texCoord, sampleNum).r; + vec3 color2 = fetchTextureSample(m_Texture, texCoord, sampleNum).rgb; + + vec3 position = getPosition(sceneDepth, texCoord); + float level = m_WaterHeight; + + vec3 eyeVec = position - m_CameraPosition; + + // Find intersection with water surface + vec3 eyeVecNorm = normalize(eyeVec); + float t = (level - m_CameraPosition.y) / eyeVecNorm.y; + vec3 surfacePoint = m_CameraPosition + eyeVecNorm * t; + + vec2 texC = vec2(0.0); + + float cameraDepth = length(m_CameraPosition - surfacePoint); + texC = (surfacePoint.xz + eyeVecNorm.xz) * scale + m_Time * 0.03 * m_WindDirection; + float bias = texture2D(m_HeightMap, texC).r; + level += bias * m_MaxAmplitude; + t = (level - m_CameraPosition.y) / eyeVecNorm.y; + surfacePoint = m_CameraPosition + eyeVecNorm * t; + eyeVecNorm = normalize(m_CameraPosition - surfacePoint); + + #if __VERSION__ >= 130 + // Find normal of water surface + float normal1 = textureOffset(m_HeightMap, texC, ivec2(-1.0, 0.0)).r; + float normal2 = textureOffset(m_HeightMap, texC, ivec2( 1.0, 0.0)).r; + float normal3 = textureOffset(m_HeightMap, texC, ivec2( 0.0, -1.0)).r; + float normal4 = textureOffset(m_HeightMap, texC, ivec2( 0.0, 1.0)).r; + #else + // Find normal of water surface + float normal1 = texture2D(m_HeightMap, (texC + vec2(-1.0, 0.0) / 256.0)).r; + float normal2 = texture2D(m_HeightMap, (texC + vec2(1.0, 0.0) / 256.0)).r; + float normal3 = texture2D(m_HeightMap, (texC + vec2(0.0, -1.0) / 256.0)).r; + float normal4 = texture2D(m_HeightMap, (texC + vec2(0.0, 1.0) / 256.0)).r; + #endif + + vec3 myNormal = normalize(vec3((normal1 - normal2) * m_MaxAmplitude,m_NormalScale,(normal3 - normal4) * m_MaxAmplitude)); + vec3 normal = myNormal*-1.0; + float fresnel = fresnelTerm(normal, eyeVecNorm); + + vec3 refraction = color2; + #ifdef ENABLE_REFRACTION + texC = texCoord.xy *sin (fresnel+1.0); + texC = clamp(texC,0.0,1.0); + refraction = fetchTextureSample(m_Texture, texC, sampleNum).rgb; + #endif + + float waterCol = saturate(length(m_LightColor.rgb) / m_SunScale); + refraction = mix(mix(refraction, m_DeepWaterColor.rgb * waterCol, m_WaterTransparency), m_WaterColor.rgb* waterCol,m_WaterTransparency); + + vec3 foam = vec3(0.0); + #ifdef ENABLE_FOAM + texC = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.05 * m_WindDirection + sin(m_Time * 0.001 + position.x) * 0.005; + vec2 texCoord2 = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.1 * m_WindDirection + sin(m_Time * 0.001 + position.z) * 0.005; + + if(m_MaxAmplitude - m_FoamExistence.z> 0.0001){ + foam += ((texture2D(m_FoamMap, texC) + texture2D(m_FoamMap, texCoord2)) * m_FoamIntensity * m_FoamIntensity * 0.3 * + saturate((level - (m_WaterHeight + m_FoamExistence.z)) / (m_MaxAmplitude - m_FoamExistence.z))).rgb; + } + foam *= m_LightColor.rgb; + #endif + + + + vec3 specular = vec3(0.0); + vec3 color ; + float fogFactor; + + if(position.y>level){ + #ifdef ENABLE_SPECULAR + if(step(0.9999,sceneDepth)==1.0){ + vec3 lightDir=normalize(m_LightDir); + vec3 mirrorEye = (2.0 * dot(eyeVecNorm, normal) * normal - eyeVecNorm); + float dotSpec = saturate(dot(mirrorEye.xyz, -lightDir) * 0.5 + 0.5); + specular = vec3((1.0 - fresnel) * saturate(-lightDir.y) * ((pow(dotSpec, 512.0)) * (m_Shininess * 1.8 + 0.2))); + specular += specular * 25.0 * saturate(m_Shininess - 0.05); + specular=specular * m_LightColor.rgb * 100.0; + } + #endif + float fogIntensity= 8.0 * m_WaterTransparency; + fogFactor = exp2( -fogIntensity * fogIntensity * cameraDepth * 0.03 * LOG2 ); + fogFactor = clamp(fogFactor, 0.0, 1.0); + color =mix(m_DeepWaterColor.rgb,refraction,fogFactor); + specular=specular*fogFactor; + color = saturate(color + max(specular, foam )); + }else{ + vec3 caustics = vec3(0.0); + #ifdef ENABLE_CAUSTICS + vec2 windDirection=m_WindDirection; + texC = (position.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.05 * windDirection + sin(m_Time + position.x) * 0.01; + vec2 texCoord2 = (position.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.05 * windDirection + sin(m_Time + position.z) * 0.01; + caustics += (texture2D(m_CausticsMap, texC)+ texture2D(m_CausticsMap, texCoord2)).rgb; + caustics=saturate(mix(m_WaterColor.rgb,caustics,m_CausticsIntensity)); + color=mix(color2,caustics,m_CausticsIntensity); + #else + color=color2; + #endif + + float fogDepth= (2.0 * m_FrustumNearFar.x) / (m_FrustumNearFar.y + m_FrustumNearFar.x - sceneDepth* (m_FrustumNearFar.y-m_FrustumNearFar.x)); + float fogIntensity= 18.0 * m_WaterTransparency; + fogFactor = exp2( -fogIntensity * fogIntensity * fogDepth * fogDepth * LOG2 ); + fogFactor = clamp(fogFactor, 0.0, 1.0); + color =mix(m_DeepWaterColor.rgb,color,fogFactor); + } + + return vec4(color, 1.0); +} + + +// NOTE: This will be called even for single-sampling +vec4 main_multiSample(int sampleNum){ + // If we are underwater let's call the underwater function + if(m_WaterHeight >= m_CameraPosition.y){ + #ifdef ENABLE_AREA + if(isOverExtent(m_CameraPosition, m_Center, m_Radius)){ + return fetchTextureSample(m_Texture, texCoord, sampleNum); + } + #endif + #ifdef POLYGON_AREA + if(!polygonContains(m_CameraPosition.x, m_CameraPosition.z)){ + return fetchTextureSample(m_Texture, texCoord, sampleNum); + } + #endif + return underWater(sampleNum); + } + + float sceneDepth = fetchTextureSample(m_DepthTexture, texCoord, sampleNum).r; + vec3 color2 = fetchTextureSample(m_Texture, texCoord, sampleNum).rgb; + + vec3 color = color2; + vec3 position = getPosition(sceneDepth, texCoord); + + #ifdef ENABLE_AREA + if(isOverExtent(position, m_Center, m_Radius)){ + return vec4(color2, 1.0); + } + #endif + #ifdef POLYGON_AREA + if(!polygonContains(position.x, position.z)){ + return vec4(color2, 1.0); + } + #endif + + float level = m_WaterHeight; + + float isAtFarPlane = step(0.99998, sceneDepth); + //#ifndef ENABLE_RIPPLES + // This optimization won't work on NVIDIA cards if ripples are enabled + if(position.y > level + m_MaxAmplitude + isAtFarPlane * 100.0){ + + return vec4(color2, 1.0); + } + //#endif + + vec3 eyeVec = position - m_CameraPosition; + float cameraDepth = m_CameraPosition.y - position.y; + + // Find intersection with water surface + vec3 eyeVecNorm = normalize(eyeVec); + float t = (level - m_CameraPosition.y) / eyeVecNorm.y; + vec3 surfacePoint = m_CameraPosition + eyeVecNorm * t; + + vec2 texC = vec2(0.0); + int samples = 1; + #ifdef ENABLE_HQ_SHORELINE + samples = 10; + #endif + + float biasFactor = 1.0 / float(samples); + for (int i = 0; i < samples; i++){ + texC = (surfacePoint.xz + eyeVecNorm.xz * biasFactor) * scale + m_Time * 0.03 * m_WindDirection; + + float bias = texture2D(m_HeightMap, texC).r; + + bias *= biasFactor; + level += bias * m_MaxAmplitude; + t = (level - m_CameraPosition.y) / eyeVecNorm.y; + surfacePoint = m_CameraPosition + eyeVecNorm * t; + } + + float depth = length(position - surfacePoint); + float depth2 = surfacePoint.y - position.y; + + // XXX: HACK ALERT: Increase water depth to infinity if at far plane + // Prevents "foam on horizon" issue + // For best results, replace the "100.0" below with the + // highest value in the m_ColorExtinction vec3 + depth += isAtFarPlane * 100.0; + depth2 += isAtFarPlane * 100.0; + + eyeVecNorm = normalize(m_CameraPosition - surfacePoint); + + #if __VERSION__ >= 130 + // Find normal of water surface + float normal1 = textureOffset(m_HeightMap, texC, ivec2(-1.0, 0.0)).r; + float normal2 = textureOffset(m_HeightMap, texC, ivec2( 1.0, 0.0)).r; + float normal3 = textureOffset(m_HeightMap, texC, ivec2( 0.0, -1.0)).r; + float normal4 = textureOffset(m_HeightMap, texC, ivec2( 0.0, 1.0)).r; + #else + // Find normal of water surface + float normal1 = texture2D(m_HeightMap, (texC + vec2(-1.0, 0.0) / 256.0)).r; + float normal2 = texture2D(m_HeightMap, (texC + vec2(1.0, 0.0) / 256.0)).r; + float normal3 = texture2D(m_HeightMap, (texC + vec2(0.0, -1.0) / 256.0)).r; + float normal4 = texture2D(m_HeightMap, (texC + vec2(0.0, 1.0) / 256.0)).r; + #endif + + vec3 myNormal = normalize(vec3((normal1 - normal2) * m_MaxAmplitude,m_NormalScale,(normal3 - normal4) * m_MaxAmplitude)); + vec3 normal = vec3(0.0); + + #ifdef ENABLE_RIPPLES + texC = surfacePoint.xz * 0.8 + m_WindDirection * m_Time* 1.6; + mat3 tangentFrame = computeTangentFrame(myNormal, eyeVecNorm, texC); + vec3 normal0a = normalize(tangentFrame*(2.0 * texture2D(m_NormalMap, texC).xyz - 1.0)); + + texC = surfacePoint.xz * 0.4 + m_WindDirection * m_Time* 0.8; + tangentFrame = computeTangentFrame(myNormal, eyeVecNorm, texC); + vec3 normal1a = normalize(tangentFrame*(2.0 * texture2D(m_NormalMap, texC).xyz - 1.0)); + + texC = surfacePoint.xz * 0.2 + m_WindDirection * m_Time * 0.4; + tangentFrame = computeTangentFrame(myNormal, eyeVecNorm, texC); + vec3 normal2a = normalize(tangentFrame*(2.0 * texture2D(m_NormalMap, texC).xyz - 1.0)); + + texC = surfacePoint.xz * 0.1 + m_WindDirection * m_Time * 0.2; + tangentFrame = computeTangentFrame(myNormal, eyeVecNorm, texC); + vec3 normal3a = normalize(tangentFrame*(2.0 * texture2D(m_NormalMap, texC).xyz - 1.0)); + + normal = normalize(normal0a * normalModifier.x + normal1a * normalModifier.y +normal2a * normalModifier.z + normal3a * normalModifier.w); + + #if __VERSION__ >= 130 && !defined GL_ES + // XXX: Here's another way to fix the terrain edge issue, + // But it requires GLSL 1.3 and still looks kinda incorrect + // around edges + normal = isnan(normal.x) ? myNormal : normal; + #else + // To make the shader 1.2 compatible we use a trick : + // we clamp the x value of the normal and compare it to it's former value instead of using isnan. + normal = clamp(normal.x,0.0,1.0)!=normal.x ? myNormal : normal; + #endif + + #else + normal = myNormal; + #endif + + vec3 refraction = color2; + #ifdef ENABLE_REFRACTION + // texC = texCoord.xy+ m_ReflectionDisplace * normal.x; + texC = texCoord.xy; + texC += sin(m_Time*1.8 + 3.0 * abs(position.y))* (refractionScale * min(depth2, 1.0)); + texC = clamp(texC,vec2(0.0),vec2(0.999)); + refraction = fetchTextureSample(m_Texture, texC, sampleNum).rgb; + #endif + vec3 waterPosition = surfacePoint.xyz; + waterPosition.y -= (level - m_WaterHeight); + vec4 texCoordProj = m_TextureProjMatrix * vec4(waterPosition, 1.0); + + texCoordProj.x = texCoordProj.x + m_ReflectionDisplace * normal.x; + texCoordProj.z = texCoordProj.z + m_ReflectionDisplace * normal.z; + texCoordProj /= texCoordProj.w; + texCoordProj.y = 1.0 - texCoordProj.y; + + vec3 reflection = texture2D(m_ReflectionMap, texCoordProj.xy).rgb; + + float fresnel = fresnelTerm(normal, eyeVecNorm); + + float depthN = depth * m_WaterTransparency; + float waterCol = saturate(length(m_LightColor.rgb) / m_SunScale); + refraction = mix(mix(refraction, m_WaterColor.rgb * waterCol, saturate(depthN / visibility)), + m_DeepWaterColor.rgb * waterCol, saturate(depth2 / m_ColorExtinction)); + + + vec3 foam = vec3(0.0); + #ifdef ENABLE_FOAM + texC = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.05 * m_WindDirection + sin(m_Time * 0.001 + position.x) * 0.005; + vec2 texCoord2 = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.1 * m_WindDirection + sin(m_Time * 0.001 + position.z) * 0.005; + + vec4 foam1 = texture2D(m_FoamMap, texC); + vec4 foam2 = texture2D(m_FoamMap, texCoord2); + + if(depth2 < m_FoamExistence.x){ + foam = (foam1.r + foam2).rgb * vec3(m_FoamIntensity); + }else if(depth2 < m_FoamExistence.y){ + foam = mix((foam1 + foam2) * m_FoamIntensity , vec4(0.0), + (depth2 - m_FoamExistence.x) / (m_FoamExistence.y - m_FoamExistence.x)).rgb; + } + + + if(m_MaxAmplitude - m_FoamExistence.z> 0.0001){ + foam += ((foam1 + foam2) * m_FoamIntensity * m_FoamIntensity * 0.3 * + saturate((level - (m_WaterHeight + m_FoamExistence.z)) / (m_MaxAmplitude - m_FoamExistence.z))).rgb; + } + foam *= m_LightColor.rgb; + #endif + + vec3 specular = vec3(0.0); + #ifdef ENABLE_SPECULAR + vec3 lightDir=normalize(m_LightDir); + vec3 mirrorEye = (2.0 * dot(eyeVecNorm, normal) * normal - eyeVecNorm); + float dotSpec = saturate(dot(mirrorEye.xyz, -lightDir) * 0.5 + 0.5); + specular = vec3((1.0 - fresnel) * saturate(-lightDir.y) * ((pow(dotSpec, 512.0)) * (m_Shininess * 1.8 + 0.2))); + specular += specular * 25.0 * saturate(m_Shininess - 0.05); + //foam does not shine + specular=specular * m_LightColor.rgb - (5.0 * foam); + #endif + + color = mix(refraction, reflection, fresnel); + color = mix(refraction, color, saturate(depth * m_ShoreHardness)); + color = saturate(color + max(specular, foam )); + color = mix(refraction, color, saturate(depth* m_FoamHardness)); + + + // XXX: HACK ALERT: + // We trick the GeForces to think they have + // to calculate the derivatives for all these pixels by using step()! + // That way we won't get pixels around the edges of the terrain, + // Where the derivatives are undefined + return vec4(mix(color, color2, step(level, position.y)), 1.0); +} + +void main(){ + setGlobals(); + #ifdef RESOLVE_MS + vec4 color = vec4(0.0); + for (int i = 0; i < m_NumSamples; i++){ + color += main_multiSample(i); + } + gl_FragColor = color / float(m_NumSamples); + #else + gl_FragColor = main_multiSample(0); + #endif +} diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163631.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163631.png new file mode 100644 index 0000000..e36ba98 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163631.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163636.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163636.png new file mode 100644 index 0000000..9aabc6c Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_large_20260606_163636.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_162818.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_162818.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_20260606_162818.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_163554.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_163554.png new file mode 100644 index 0000000..e127e97 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_163554.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_190655.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260606_190655.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_20260606_190655.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260607_221049.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260607_221049.png new file mode 100644 index 0000000..eaab7f4 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_oak_medium_20260607_221049.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/ez_impostor_pine_large_20260606_190805.png b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_pine_large_20260606_190805.png new file mode 100644 index 0000000..6f7cdca Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/ez_impostor_pine_large_20260606_190805.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163802.png b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163802.png new file mode 100644 index 0000000..ba1e54b Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163802.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163809.png b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163809.png new file mode 100644 index 0000000..41ad3ad Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163809.png differ diff --git a/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163819.png b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163819.png new file mode 100644 index 0000000..57eeb31 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/impostor/impostor_oak_20260606_163819.png differ diff --git a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o b/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o deleted file mode 100644 index 9cdfe9f..0000000 Binary files a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1.j3o and /dev/null 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 deleted file mode 100644 index 9cdfe9f..0000000 Binary files a/blight-assets/src/main/resources/trees/oak/medium/EzBaum1_20260603_160907.j3o and /dev/null differ diff --git a/blight-common/src/main/java/de/blight/common/AreaIO.java b/blight-common/src/main/java/de/blight/common/AreaIO.java new file mode 100644 index 0000000..cbf530d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/AreaIO.java @@ -0,0 +1,53 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public final class AreaIO { + + private AreaIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_areas.bla"); + } + + public static void save(List areas) throws IOException { + Path p = getPath(); + Files.createDirectories(p.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(p)) { + w.write("# polygon\tnameId\tdayTrack\tnightTrack\tcombatTrack"); + w.newLine(); + for (PlacedArea a : areas) { + w.write(SoundAreaIO.encodePolygon(a.pointsX(), a.pointsZ())); + w.write('\t'); + w.write(a.nameId()); + w.write('\t'); + w.write(a.dayTrack()); + w.write('\t'); + w.write(a.nightTrack()); + w.write('\t'); + w.write(a.combatTrack()); + w.newLine(); + } + } + } + + public static List load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List list = new ArrayList<>(); + for (String line : Files.readAllLines(p)) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] f = line.split("\t", -1); + if (f.length < 5) continue; + try { + float[][] pts = SoundAreaIO.decodePolygon(f[0]); + if (pts[0].length < 3) continue; + list.add(new PlacedArea(pts[0], pts[1], f[1], f[2], f[3], f[4])); + } catch (Exception ignored) {} + } + return list; + } +} diff --git a/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java b/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java new file mode 100644 index 0000000..125727b --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java @@ -0,0 +1,4 @@ +package de.blight.common; + +/** Einzeln platzierter Vertex-Gras-Halm. Y-Position wird beim Platzieren aus dem Terrain gebacken. */ +public record GrassVertexBlade(float x, float y, float z, float height, float dryness) {} diff --git a/blight-common/src/main/java/de/blight/common/GrassVertexIO.java b/blight-common/src/main/java/de/blight/common/GrassVertexIO.java new file mode 100644 index 0000000..d31dc6a --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/GrassVertexIO.java @@ -0,0 +1,66 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; + +/** + * Liest und schreibt Vertex-Gras-Halme als komprimierte Binärdatei + * ({@code blight_grass_vertex.blgv}) neben der Kartendatei. + * + * Format v1: int MAGIC, int VERSION, int count, N × (float x, float y, float z, float height) + * Format v2: wie v1 + float dryness pro Halm (0=grün, 0.5–1.0=gelb/braun) + */ +public final class GrassVertexIO { + + private static final int MAGIC = 0x47565458; // "GVTX" + private static final int VERSION = 2; + + private GrassVertexIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_grass_vertex.blgv"); + } + + public static void save(List blades) 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); + out.writeInt(blades.size()); + for (GrassVertexBlade b : blades) { + out.writeFloat(b.x()); + out.writeFloat(b.y()); + out.writeFloat(b.z()); + out.writeFloat(b.height()); + out.writeFloat(b.dryness()); + } + } + } + + public static List load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + try (DataInputStream in = new DataInputStream( + new BufferedInputStream(new GZIPInputStream(Files.newInputStream(p))))) { + int magic = in.readInt(); + if (magic != MAGIC) throw new IOException("Ungültiges Dateiformat (MAGIC)"); + int version = in.readInt(); + if (version < 1 || version > VERSION) throw new IOException("Unbekannte Version: " + version); + int count = in.readInt(); + List list = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + float x = in.readFloat(); + float y = in.readFloat(); + float z = in.readFloat(); + float h = in.readFloat(); + float dr = version >= 2 ? in.readFloat() : 0f; + list.add(new GrassVertexBlade(x, y, z, h, dr)); + } + return list; + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/LocationIO.java b/blight-common/src/main/java/de/blight/common/LocationIO.java new file mode 100644 index 0000000..3295e5d --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/LocationIO.java @@ -0,0 +1,70 @@ +package de.blight.common; + +import de.blight.common.model.Location; +import de.blight.common.model.TextReference; +import de.blight.common.model.trigger.Trigger; +import de.blight.common.model.trigger.TriggerIO; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +/** + * Liest und schreibt Locations ({@code blight_locations.blo}) neben der Kartendatei. + * + * Format (je Zeile): nameId TAB centerX TAB centerZ TAB radius TAB triggersJson + */ +public final class LocationIO { + + private LocationIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_locations.blo"); + } + + public static void save(List locations) throws IOException { + Path p = getPath(); + Files.createDirectories(p.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(p)) { + w.write("# nameId\tcenterX\tcenterZ\tradius\ttriggersJson"); + w.newLine(); + for (Location loc : locations) { + w.write(loc.getId()); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", loc.getCenterX())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", loc.getCenterZ())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", loc.getRadius())); + w.write('\t'); + w.write(TriggerIO.serializeList(loc.getTriggers())); + w.newLine(); + } + } + } + + public static List load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List list = new ArrayList<>(); + for (String line : Files.readAllLines(p)) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] f = line.split("\t", -1); + if (f.length < 4) continue; + try { + Location loc = new Location(); + loc.setName(new TextReference(f[0].strip())); + loc.setCenterX(Float.parseFloat(f[1].strip())); + loc.setCenterZ(Float.parseFloat(f[2].strip())); + loc.setRadius(Float.parseFloat(f[3].strip())); + if (f.length > 4) { + List triggers = TriggerIO.deserializeList(f[4].strip()); + loc.setTriggers(triggers); + } + list.add(loc); + } catch (NumberFormatException ignored) {} + } + return list; + } +} diff --git a/blight-common/src/main/java/de/blight/common/LocationZoneIO.java b/blight-common/src/main/java/de/blight/common/LocationZoneIO.java new file mode 100644 index 0000000..de737e9 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/LocationZoneIO.java @@ -0,0 +1,53 @@ +package de.blight.common; + +import de.blight.common.model.trigger.Trigger; +import de.blight.common.model.trigger.TriggerIO; + +import java.io.*; +import java.nio.file.*; +import java.util.*; + +public final class LocationZoneIO { + + private LocationZoneIO() {} + + public static Path getPath() { + return MapIO.getMapPath().resolveSibling("blight_location_zones.blz"); + } + + public static void save(List zones) throws IOException { + Path p = getPath(); + Files.createDirectories(p.getParent()); + try (BufferedWriter w = Files.newBufferedWriter(p)) { + w.write("# polygon\tnameId\ttriggersJson"); + w.newLine(); + for (PlacedLocationZone z : zones) { + w.write(SoundAreaIO.encodePolygon(z.pointsX(), z.pointsZ())); + w.write('\t'); + w.write(z.nameId()); + w.write('\t'); + w.write(TriggerIO.serializeList(z.triggers())); + w.newLine(); + } + } + } + + public static List load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List list = new ArrayList<>(); + for (String line : Files.readAllLines(p)) { + line = line.strip(); + if (line.isEmpty() || line.startsWith("#")) continue; + String[] f = line.split("\t", -1); + if (f.length < 2) continue; + try { + float[][] pts = SoundAreaIO.decodePolygon(f[0]); + if (pts[0].length < 3) continue; + List triggers = f.length > 2 ? TriggerIO.deserializeList(f[2]) : new ArrayList<>(); + list.add(new PlacedLocationZone(pts[0], pts[1], f[1], triggers)); + } catch (Exception ignored) {} + } + return list; + } +} 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 de5373c..df7fc94 100644 --- a/blight-common/src/main/java/de/blight/common/MapIO.java +++ b/blight-common/src/main/java/de/blight/common/MapIO.java @@ -88,9 +88,10 @@ public final class MapIO { public static void save(MapData data) throws IOException { Files.createDirectories(MAP_PATH.getParent()); + Path tmp = MAP_PATH.resolveSibling(MAP_PATH.getFileName() + ".tmp"); try (DataOutputStream out = new DataOutputStream( new BufferedOutputStream( - new GZIPOutputStream(Files.newOutputStream(MAP_PATH))))) { + new GZIPOutputStream(Files.newOutputStream(tmp))))) { out.writeInt(MAGIC); out.writeInt(VERSION); writeFloats(out, data.terrainHeight); @@ -127,6 +128,14 @@ public final class MapIO { for (int i = 0; i < slotEnd; i++) out.writeUTF(slots[i] != null ? slots[i] : ""); out.write(data.grassTextureMap); } + // Atomares Umbenennen: erst wenn die Datei vollständig ist ersetzen wir die alte. + // ATOMIC_MOVE schlägt auf manchen Systemen cross-device fehl → Fallback auf REPLACE_EXISTING. + try { + Files.move(tmp, MAP_PATH, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, MAP_PATH, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } } public static MapData load() throws IOException { @@ -243,17 +252,32 @@ public final class MapIO { // ── Hilfsmethoden ───────────────────────────────────────────────────────── + private static final int FLOAT_CHUNK = 16384; // floats per I/O chunk (64 KB) + private static void writeFloats(DataOutputStream out, float[] arr) throws IOException { - ByteBuffer buf = ByteBuffer.allocate(arr.length * Float.BYTES) - .order(ByteOrder.BIG_ENDIAN); - buf.asFloatBuffer().put(arr); - out.write(buf.array()); + byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES]; + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + int offset = 0; + while (offset < arr.length) { + int count = Math.min(FLOAT_CHUNK, arr.length - offset); + bb.clear(); + for (int i = 0; i < count; i++) bb.putFloat(arr[offset + i]); + out.write(bytes, 0, count * Float.BYTES); + offset += count; + } } private static void readFloats(DataInputStream in, float[] arr) throws IOException { - byte[] bytes = new byte[arr.length * Float.BYTES]; - in.readFully(bytes); - ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).asFloatBuffer().get(arr); + byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES]; + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); + int offset = 0; + while (offset < arr.length) { + int count = Math.min(FLOAT_CHUNK, arr.length - offset); + in.readFully(bytes, 0, count * Float.BYTES); + bb.clear(); + bb.asFloatBuffer().get(arr, offset, count); + offset += count; + } } private static void writeStrings(DataOutputStream out, String[] arr) throws IOException { diff --git a/blight-common/src/main/java/de/blight/common/ModelMeta.java b/blight-common/src/main/java/de/blight/common/ModelMeta.java new file mode 100644 index 0000000..e91bd95 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/ModelMeta.java @@ -0,0 +1,28 @@ +package de.blight.common; + +/** + * Metadaten einer Model-Asset-Datei (.j3o.meta neben dem .j3o). + * Skalierung ist per-Achse; uniformScale=true → X/Y/Z werden gemeinsam angepasst. + */ +public record ModelMeta( + String name, + String category, + String tags, + float scaleX, + float scaleY, + float scaleZ, + boolean uniformScale, + float pivotOffsetY, + float placementOffsetY, + boolean solid, + boolean castShadow, + boolean receiveShadow, + float randomScaleMin, + float randomScaleMax +) { + public static ModelMeta defaults(String j3oFileName) { + String name = j3oFileName.replaceFirst("\\.j3o$", ""); + return new ModelMeta(name, "", "", 1f, 1f, 1f, true, 0f, 0f, + false, true, true, 1f, 1f); + } +} diff --git a/blight-common/src/main/java/de/blight/common/ModelMetaIO.java b/blight-common/src/main/java/de/blight/common/ModelMetaIO.java new file mode 100644 index 0000000..6047b44 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/ModelMetaIO.java @@ -0,0 +1,68 @@ +package de.blight.common; + +import java.io.*; +import java.nio.file.*; +import java.util.Properties; + +/** Liest und schreibt {@code .j3o.meta}-Dateien neben dem jeweiligen {@code .j3o}. */ +public final class ModelMetaIO { + + private ModelMetaIO() {} + + public static Path metaPath(Path j3oPath) { + return j3oPath.resolveSibling(j3oPath.getFileName() + ".meta"); + } + + public static void save(ModelMeta m, Path j3oPath) throws IOException { + Properties p = new Properties(); + p.setProperty("name", m.name()); + p.setProperty("category", m.category()); + p.setProperty("tags", m.tags()); + p.setProperty("scaleX", String.valueOf(m.scaleX())); + p.setProperty("scaleY", String.valueOf(m.scaleY())); + p.setProperty("scaleZ", String.valueOf(m.scaleZ())); + p.setProperty("uniformScale", String.valueOf(m.uniformScale())); + p.setProperty("pivotOffsetY", String.valueOf(m.pivotOffsetY())); + p.setProperty("placementOffsetY", String.valueOf(m.placementOffsetY())); + p.setProperty("solid", String.valueOf(m.solid())); + p.setProperty("castShadow", String.valueOf(m.castShadow())); + p.setProperty("receiveShadow", String.valueOf(m.receiveShadow())); + p.setProperty("randomScaleMin", String.valueOf(m.randomScaleMin())); + p.setProperty("randomScaleMax", String.valueOf(m.randomScaleMax())); + try (Writer w = Files.newBufferedWriter(metaPath(j3oPath))) { + p.store(w, null); + } + } + + public static ModelMeta load(Path j3oPath) { + Path mp = metaPath(j3oPath); + Properties p = new Properties(); + if (Files.exists(mp)) { + try (Reader r = Files.newBufferedReader(mp)) { + p.load(r); + } catch (IOException ignored) {} + } + String defName = j3oPath.getFileName().toString().replaceFirst("\\.j3o$", ""); + return new ModelMeta( + p.getProperty("name", defName), + p.getProperty("category", ""), + p.getProperty("tags", ""), + parseFloat(p, "scaleX", 1f), + parseFloat(p, "scaleY", 1f), + parseFloat(p, "scaleZ", 1f), + Boolean.parseBoolean(p.getProperty("uniformScale", "true")), + parseFloat(p, "pivotOffsetY", 0f), + parseFloat(p, "placementOffsetY", 0f), + Boolean.parseBoolean(p.getProperty("solid", "false")), + Boolean.parseBoolean(p.getProperty("castShadow", "true")), + Boolean.parseBoolean(p.getProperty("receiveShadow", "true")), + parseFloat(p, "randomScaleMin", 1f), + parseFloat(p, "randomScaleMax", 1f) + ); + } + + private static float parseFloat(Properties p, String key, float def) { + try { return Float.parseFloat(p.getProperty(key, String.valueOf(def))); } + catch (NumberFormatException e) { return def; } + } +} diff --git a/blight-common/src/main/java/de/blight/common/PlacedArea.java b/blight-common/src/main/java/de/blight/common/PlacedArea.java new file mode 100644 index 0000000..1c0a0a2 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedArea.java @@ -0,0 +1,10 @@ +package de.blight.common; + +public record PlacedArea( + float[] pointsX, + float[] pointsZ, + String nameId, + String dayTrack, + String nightTrack, + String combatTrack +) {} diff --git a/blight-common/src/main/java/de/blight/common/PlacedLocationZone.java b/blight-common/src/main/java/de/blight/common/PlacedLocationZone.java new file mode 100644 index 0000000..0dc9d51 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedLocationZone.java @@ -0,0 +1,17 @@ +package de.blight.common; + +import de.blight.common.model.trigger.Trigger; + +import java.util.List; + +public record PlacedLocationZone( + float[] pointsX, + float[] pointsZ, + String nameId, + List triggers +) { + /** Rückwärtskompatibel: kein Trigger. */ + public PlacedLocationZone(float[] pointsX, float[] pointsZ, String nameId) { + this(pointsX, pointsZ, nameId, List.of()); + } +} 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 a82b36a..7d7e034 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedWater.java +++ b/blight-common/src/main/java/de/blight/common/PlacedWater.java @@ -1,8 +1,6 @@ package de.blight.common; /** - * Platzierte Wasserfläche. - * Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet – - * gespeichert werden nur Saatpunkt und Wasserstand. + * Platzierte Wasserfläche, definiert durch ein Benutzer-Polygon, eine Höhe und eine Fließrichtung. */ -public record PlacedWater(float seedX, float seedZ, float waterHeight) {} +public record PlacedWater(float[] pointsX, float[] pointsZ, float waterHeight, float flowDegrees) {} 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 3fa7434..62d3430 100644 --- a/blight-common/src/main/java/de/blight/common/WaterBodyIO.java +++ b/blight-common/src/main/java/de/blight/common/WaterBodyIO.java @@ -5,11 +5,9 @@ import java.nio.file.*; import java.util.*; /** - * Liest und schreibt platzierte Wasserflächen als tab-separierte Textdatei - * ({@code blight_water.blw}) neben der Kartendatei. + * Liest und schreibt platzierte Wasserflächen ({@code blight_water.blw}) neben der Kartendatei. * - * Format: seedX seedZ waterHeight - * Die Form des Wasserkörpers wird per Flood-Fill zur Laufzeit rekonstruiert. + * Format (je Zeile): x1,z1;x2,z2;x3,z3;... TAB waterHeight [TAB flowDegrees] */ public final class WaterBodyIO { @@ -23,11 +21,20 @@ public final class WaterBodyIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# seedX\tseedZ\twaterHeight"); + w.write("# polygon_points\twaterHeight\tflowDegrees"); w.newLine(); for (PlacedWater b : bodies) { - w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n", - b.seedX(), b.seedZ(), b.waterHeight())); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < b.pointsX().length; i++) { + if (i > 0) sb.append(';'); + sb.append(String.format(Locale.ROOT, "%.5f,%.5f", b.pointsX()[i], b.pointsZ()[i])); + } + sb.append('\t'); + sb.append(String.format(Locale.ROOT, "%.5f", b.waterHeight())); + sb.append('\t'); + sb.append(String.format(Locale.ROOT, "%.1f", b.flowDegrees())); + w.write(sb.toString()); + w.newLine(); } } } @@ -39,14 +46,21 @@ public final class WaterBodyIO { for (String line : Files.readAllLines(p)) { line = line.strip(); if (line.isEmpty() || line.startsWith("#")) continue; - String[] f = line.split("\t", -1); - if (f.length < 3) continue; + String[] parts = line.split("\t", -1); + if (parts.length < 2) continue; try { - list.add(new PlacedWater( - Float.parseFloat(f[0]), - Float.parseFloat(f[1]), - Float.parseFloat(f[2]))); - } catch (NumberFormatException ignored) {} + float wh = Float.parseFloat(parts[1].strip()); + float fd = parts.length >= 3 ? Float.parseFloat(parts[2].strip()) : 0f; + String[] pts = parts[0].split(";", -1); + float[] xs = new float[pts.length]; + float[] zs = new float[pts.length]; + for (int i = 0; i < pts.length; i++) { + String[] c = pts[i].split(",", -1); + xs[i] = Float.parseFloat(c[0].strip()); + zs[i] = Float.parseFloat(c[1].strip()); + } + if (xs.length >= 3) list.add(new PlacedWater(xs, zs, wh, fd)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException ignored) {} } return list; } diff --git a/blight-common/src/main/java/de/blight/common/model/Collectable.java b/blight-common/src/main/java/de/blight/common/model/Collectable.java deleted file mode 100644 index 6b645f1..0000000 --- a/blight-common/src/main/java/de/blight/common/model/Collectable.java +++ /dev/null @@ -1,12 +0,0 @@ -package de.blight.common.model; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class Collectable implements Interactable { - - private Item item; - private ObjectReference objectReference; -} diff --git a/blight-common/src/main/java/de/blight/common/model/CraftingTable.java b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java index 9a1ee90..08d3aac 100644 --- a/blight-common/src/main/java/de/blight/common/model/CraftingTable.java +++ b/blight-common/src/main/java/de/blight/common/model/CraftingTable.java @@ -16,4 +16,14 @@ public class CraftingTable { private TextReference name; private ObjectReference object; + + private CraftingTableType type; + + public enum CraftingTableType { + AlchemyTable, + EnchantmentTable, + Smithy, + Goldsmiths, + Workshop; + } } diff --git a/blight-common/src/main/java/de/blight/common/model/CraftingTableIO.java b/blight-common/src/main/java/de/blight/common/model/CraftingTableIO.java new file mode 100644 index 0000000..50606d6 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/CraftingTableIO.java @@ -0,0 +1,92 @@ +package de.blight.common.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * Speichert genau eine {@link CraftingTable}-Instanz pro {@link CraftingTable.CraftingTableType} + * als {@code .craftingtable}-JSON-Datei. + */ +public final class CraftingTableIO { + + private static final Logger log = LoggerFactory.getLogger(CraftingTableIO.class); + private static final String EXTENSION = ".craftingtable"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private CraftingTableIO() {} + + public static void save(CraftingTable table, Path dir) throws IOException { + if (table.getType() == null) throw new IOException("CraftingTable ohne Typ kann nicht gespeichert werden."); + Files.createDirectories(dir); + Files.writeString(dir.resolve(table.getType().name() + EXTENSION), + GSON.toJson(toDto(table)), StandardCharsets.UTF_8); + log.debug("[CraftingTableIO] Gespeichert: {}", table.getType().name()); + } + + public static Optional load(CraftingTable.CraftingTableType type, Path dir) { + Path file = dir.resolve(type.name() + EXTENSION); + if (!Files.exists(file)) return Optional.empty(); + try { + Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class); + return Optional.of(fromDto(dto, type)); + } catch (IOException e) { + log.warn("[CraftingTableIO] Fehler beim Laden von {}: {}", type.name(), e.getMessage()); + return Optional.empty(); + } + } + + /** + * Lädt alle konfigurierten Tische. Nicht vorhandene Typen fehlen in der Map. + */ + public static Map loadAll(Path dir) { + Map result = + new EnumMap<>(CraftingTable.CraftingTableType.class); + for (CraftingTable.CraftingTableType type : CraftingTable.CraftingTableType.values()) + load(type, dir).ifPresent(t -> result.put(type, t)); + return result; + } + + public static void delete(CraftingTable.CraftingTableType type, Path dir) throws IOException { + Files.deleteIfExists(dir.resolve(type.name() + EXTENSION)); + log.debug("[CraftingTableIO] Gelöscht: {}", type.name()); + } + + // ── DTO ─────────────────────────────────────────────────────────────────── + + private static Dto toDto(CraftingTable t) { + Dto dto = new Dto(); + dto.type = t.getType() != null ? t.getType().name() : null; + dto.nameId = t.getName() != null ? t.getName().id() : null; + dto.objectPath = t.getObject() != null ? t.getObject().getPath() : null; + return dto; + } + + private static CraftingTable fromDto(Dto dto, CraftingTable.CraftingTableType fallbackType) { + CraftingTable t = new CraftingTable(); + if (dto.type != null) { + try { t.setType(CraftingTable.CraftingTableType.valueOf(dto.type)); } + catch (IllegalArgumentException ignored) { t.setType(fallbackType); } + } else { + t.setType(fallbackType); + } + if (dto.nameId != null && !dto.nameId.isBlank()) + t.setName(new TextReference(dto.nameId)); + if (dto.objectPath != null && !dto.objectPath.isBlank()) + t.setObject(new ObjectReference(dto.objectPath)); + return t; + } + + private static class Dto { + String type; + String nameId; + String objectPath; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/DialogOption.java b/blight-common/src/main/java/de/blight/common/model/DialogOption.java index 873ac53..41e454b 100644 --- a/blight-common/src/main/java/de/blight/common/model/DialogOption.java +++ b/blight-common/src/main/java/de/blight/common/model/DialogOption.java @@ -2,8 +2,8 @@ package de.blight.common.model; import java.util.ArrayList; import java.util.List; +import java.util.UUID; -import de.blight.common.model.quests.Quest; import lombok.Getter; import lombok.Setter; @@ -11,25 +11,28 @@ import lombok.Setter; @Setter public class DialogOption { + private String id = UUID.randomUUID().toString(); + private String label = ""; + private int requiresChapter; - private Quest requiresQuestOpen; - private Quest requiresQuestComplete; + private QuestRef requiresQuestOpen; + private QuestRef requiresQuestComplete; private Status requiresStatus; - + private TextReference textHero; private AudioReference audioHero; private TextReference textNpc; private AudioReference audioNpc; - + private List nextOptions; private List disablesOptions; - + private RequiredItem requiredItem; private RecievesItem recievesItem; - - private Quest recievesQuest; - private Quest fulfillsQuest; - private List abortsQuests = new ArrayList(); + + private QuestRef recievesQuest; + private QuestRef fulfillsQuest; + private List abortsQuests = new ArrayList<>(); private boolean enablesTrade; diff --git a/blight-common/src/main/java/de/blight/common/model/Fraction.java b/blight-common/src/main/java/de/blight/common/model/Fraction.java new file mode 100644 index 0000000..0f3c69f --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Fraction.java @@ -0,0 +1,19 @@ +package de.blight.common.model; + +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Fraction { + + private UUID fractionId; + private TextReference name; + private TextReference maleMemberName; + private TextReference femaleMemberName; + private TextReference rank1Name; + private TextReference rank2Name; + private TextReference rank3Name; +} diff --git a/blight-common/src/main/java/de/blight/common/model/FractionIO.java b/blight-common/src/main/java/de/blight/common/model/FractionIO.java new file mode 100644 index 0000000..963bba7 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/FractionIO.java @@ -0,0 +1,109 @@ +package de.blight.common.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +/** + * Lädt und speichert {@link Fraction}-Instanzen als JSON. + * Dateiformat: {@code .fraction} im fractions/-Verzeichnis. + */ +public final class FractionIO { + + private static final Logger log = LoggerFactory.getLogger(FractionIO.class); + private static final String EXTENSION = ".fraction"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public static final Comparator SORT_ORDER = Comparator + .comparing((Fraction f) -> f.getName() != null ? f.getName().id() : "") + .thenComparing(f -> f.getFractionId() != null ? f.getFractionId().toString() : ""); + + private FractionIO() {} + + public static void save(Fraction fraction, Path dir) throws IOException { + if (fraction.getFractionId() == null) + throw new IOException("Fraction ohne ID kann nicht gespeichert werden."); + Files.createDirectories(dir); + Files.writeString(dir.resolve(fraction.getFractionId() + EXTENSION), + GSON.toJson(toDto(fraction)), StandardCharsets.UTF_8); + log.debug("[FractionIO] Gespeichert: {}", fraction.getFractionId()); + } + + public static Fraction load(Path file) throws IOException { + Dto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Dto.class); + return fromDto(dto); + } + + public static List loadAll(Path dir) { + List result = new ArrayList<>(); + if (!Files.isDirectory(dir)) return result; + try (Stream walk = Files.list(dir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .sorted() + .forEach(p -> { + try { result.add(load(p)); } + catch (IOException e) { log.warn("[FractionIO] Fehler: {}", e.getMessage()); } + }); + } catch (IOException e) { + log.warn("[FractionIO] Scan-Fehler: {}", e.getMessage()); + } + result.sort(SORT_ORDER); + return result; + } + + public static void delete(UUID fractionId, Path dir) throws IOException { + if (fractionId != null) + Files.deleteIfExists(dir.resolve(fractionId + EXTENSION)); + } + + // ── DTO ─────────────────────────────────────────────────────────────────── + + private static Dto toDto(Fraction f) { + Dto dto = new Dto(); + dto.fractionId = f.getFractionId() != null ? f.getFractionId().toString() : null; + dto.name = f.getName() != null ? f.getName().id() : null; + dto.maleMemberName = f.getMaleMemberName() != null ? f.getMaleMemberName().id() : null; + dto.femaleMemberName = f.getFemaleMemberName() != null ? f.getFemaleMemberName().id() : null; + dto.rank1Name = f.getRank1Name() != null ? f.getRank1Name().id() : null; + dto.rank2Name = f.getRank2Name() != null ? f.getRank2Name().id() : null; + dto.rank3Name = f.getRank3Name() != null ? f.getRank3Name().id() : null; + return dto; + } + + private static Fraction fromDto(Dto dto) { + Fraction f = new Fraction(); + if (dto.fractionId != null) { + try { f.setFractionId(UUID.fromString(dto.fractionId)); } + catch (IllegalArgumentException ignored) {} + } + f.setName(ref(dto.name)); + f.setMaleMemberName(ref(dto.maleMemberName)); + f.setFemaleMemberName(ref(dto.femaleMemberName)); + f.setRank1Name(ref(dto.rank1Name)); + f.setRank2Name(ref(dto.rank2Name)); + f.setRank3Name(ref(dto.rank3Name)); + return f; + } + + private static TextReference ref(String id) { + return (id != null && !id.isBlank()) ? new TextReference(id) : null; + } + + private static class Dto { + String fractionId; + String name; + String maleMemberName; + String femaleMemberName; + String rank1Name; + String rank2Name; + String rank3Name; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/Interactable.java b/blight-common/src/main/java/de/blight/common/model/Interactable.java index 6045ae8..b8b01db 100644 --- a/blight-common/src/main/java/de/blight/common/model/Interactable.java +++ b/blight-common/src/main/java/de/blight/common/model/Interactable.java @@ -2,4 +2,5 @@ package de.blight.common.model; public interface Interactable { + public String getDisplayText(); } diff --git a/blight-common/src/main/java/de/blight/common/model/InteractableRef.java b/blight-common/src/main/java/de/blight/common/model/InteractableRef.java new file mode 100644 index 0000000..d84f3fb --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/InteractableRef.java @@ -0,0 +1,18 @@ +package de.blight.common.model; + +import lombok.Getter; +import lombok.Setter; + +/** + * Platzhalter-Implementierung von Interactable mit einer ID für den Editor. + */ +@Getter +@Setter +public class InteractableRef implements Interactable { + private String id; + + @Override + public String getDisplayText() { + return id != null ? id : ""; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/Item.java b/blight-common/src/main/java/de/blight/common/model/Item.java index 078cf4e..431fc7b 100644 --- a/blight-common/src/main/java/de/blight/common/model/Item.java +++ b/blight-common/src/main/java/de/blight/common/model/Item.java @@ -5,16 +5,21 @@ import lombok.Setter; @Getter @Setter -public class Item { +public class Item implements Interactable { private String itemId; private ItemCategory category; private TextReference name; private TextReference description; private int worthGold; - + private ObjectReference modelRef; public void use(MainCharacter character) { } + + @Override + public String getDisplayText() { + return TextRegistry.resolve(name, itemId != null ? itemId : "?"); + } } diff --git a/blight-common/src/main/java/de/blight/common/model/ItemIO.java b/blight-common/src/main/java/de/blight/common/model/ItemIO.java new file mode 100644 index 0000000..8a9f172 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/ItemIO.java @@ -0,0 +1,69 @@ +package de.blight.common.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +/** + * Lädt und speichert {@link Item}-Instanzen als JSON. + * Dateiformat: {@code .item} im items/-Verzeichnis. + * Liste wird nach Kategorie-Ordinal, dann nach Name (TextReference-ID) sortiert. + */ +public final class ItemIO { + + private static final Logger log = LoggerFactory.getLogger(ItemIO.class); + private static final String EXTENSION = ".item"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public static final Comparator SORT_ORDER = Comparator + .comparingInt((Item i) -> i.getCategory() != null ? i.getCategory().ordinal() : Integer.MAX_VALUE) + .thenComparing(i -> i.getName() != null ? i.getName().id() : (i.getItemId() != null ? i.getItemId() : "")); + + private ItemIO() {} + + // ── Public API ──────────────────────────────────────────────────────────── + + public static void save(Item item, Path itemDir) throws IOException { + if (item.getItemId() == null || item.getItemId().isBlank()) + throw new IllegalArgumentException("itemId darf nicht leer sein"); + Files.createDirectories(itemDir); + Files.writeString(itemDir.resolve(item.getItemId() + EXTENSION), + GSON.toJson(item), StandardCharsets.UTF_8); + log.debug("[ItemIO] Gespeichert: {}", item.getItemId()); + } + + public static Item load(Path file) throws IOException { + return GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), Item.class); + } + + public static List loadAll(Path itemDir) { + List result = new ArrayList<>(); + if (!Files.isDirectory(itemDir)) return result; + try (Stream walk = Files.list(itemDir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .sorted() + .forEach(p -> { + try { result.add(load(p)); } + catch (IOException e) { log.warn("[ItemIO] Fehler: {}", e.getMessage()); } + }); + } catch (IOException e) { + log.warn("[ItemIO] Scan-Fehler: {}", e.getMessage()); + } + result.sort(SORT_ORDER); + return result; + } + + public static void delete(String itemId, Path itemDir) throws IOException { + Files.deleteIfExists(itemDir.resolve(itemId + EXTENSION)); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/Location.java b/blight-common/src/main/java/de/blight/common/model/Location.java index 8d3971a..915d3cc 100644 --- a/blight-common/src/main/java/de/blight/common/model/Location.java +++ b/blight-common/src/main/java/de/blight/common/model/Location.java @@ -1,5 +1,33 @@ package de.blight.common.model; -public interface Location { +import java.util.ArrayList; +import java.util.List; +import de.blight.common.model.trigger.Trigger; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Location { + + private TextReference name; + private float centerX; + private float centerZ; + private float radius; + private List triggers = new ArrayList<>(); + + /** Leitet die ID aus dem TextReference-Schlüssel ab – eindeutiger Bezeichner der Location. */ + public String getId() { return name != null ? name.id() : ""; } + + public boolean contains(float x, float z) { + float dx = x - centerX, dz = z - centerZ; + return dx * dx + dz * dz <= radius * radius; + } + + public void entered(MainCharacter character) { + triggers.stream() + .filter(t -> t.isTriggarable(character)) + .forEach(t -> t.trigger(character)); + } } diff --git a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java index d26fb1b..538a475 100644 --- a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java +++ b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java @@ -28,6 +28,9 @@ public class MainCharacter extends GameCharacter { private List openQuests; private List completedQuests; + private List abortedQuests; + + private de.blight.common.model.abilities.Abilities abilities; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -46,6 +49,10 @@ public class MainCharacter extends GameCharacter { option.getAbortsQuests().forEach(this::abortQuest); } + public void startQuest(Quest quest) { + if (isQuestNew(quest)) openQuests.add(quest); + } + public void fullfillQuest(Quest quest) { openQuests.remove(quest); completedQuests.add(quest); @@ -65,9 +72,14 @@ public class MainCharacter extends GameCharacter { private void abortQuest(Quest quest) { openQuests.remove(quest); + abortedQuests.add(quest); listeners.forEach(listener -> listener.questAborted(quest)); } + public boolean isQuestNew(Quest quest) { + return !openQuests.contains(quest) && !completedQuests.contains(quest) && !abortedQuests.contains(quest); + } + public void removeListener(CharacterListener listener) { listeners.remove(listener); } diff --git a/blight-common/src/main/java/de/blight/common/model/NPC.java b/blight-common/src/main/java/de/blight/common/model/NPC.java index 7e9b0b6..633cba2 100644 --- a/blight-common/src/main/java/de/blight/common/model/NPC.java +++ b/blight-common/src/main/java/de/blight/common/model/NPC.java @@ -13,9 +13,11 @@ import lombok.Setter; public class NPC extends GameCharacter { private static final Logger LOG = LoggerFactory.getLogger(NPC.class); - + private Status status; private boolean trader; + private Fraction fraction; + private List items; private List currentOptions; diff --git a/blight-common/src/main/java/de/blight/common/model/ObjectReference.java b/blight-common/src/main/java/de/blight/common/model/ObjectReference.java index cb1f054..717d64c 100644 --- a/blight-common/src/main/java/de/blight/common/model/ObjectReference.java +++ b/blight-common/src/main/java/de/blight/common/model/ObjectReference.java @@ -1,5 +1,19 @@ package de.blight.common.model; -public interface ObjectReference { +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +/** + * Referenz auf ein 3D-Asset (z. B. ein .j3o-Modell). + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ObjectReference { + + /** Relativer Asset-Pfad (z. B. {@code Models/Items/sword.j3o}). */ + private String path; } diff --git a/blight-common/src/main/java/de/blight/common/model/QuestRef.java b/blight-common/src/main/java/de/blight/common/model/QuestRef.java new file mode 100644 index 0000000..ba018e9 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/QuestRef.java @@ -0,0 +1,12 @@ +package de.blight.common.model; + +import de.blight.common.model.quests.Quest; + +/** + * Minimale Quest-Referenz (nur questId) für Verweise in DialogOption. + * Kein vollständiger Quest-Datensatz. + */ +public class QuestRef extends Quest { + // Nur die gemeinsamen Felder von Quest (questId, xp, texte) werden verwendet. + // Typspezifische Felder sind nicht vorhanden. +} diff --git a/blight-common/src/main/java/de/blight/common/model/Recipe.java b/blight-common/src/main/java/de/blight/common/model/Recipe.java index 918cbf0..3684b08 100644 --- a/blight-common/src/main/java/de/blight/common/model/Recipe.java +++ b/blight-common/src/main/java/de/blight/common/model/Recipe.java @@ -11,5 +11,10 @@ public class Recipe { private Item creates; private Map components; - private Interactable requires; + private CraftingTable table; + + private Integer requiresLvlAlchemy; + private Integer requiresLvlEngineering; + private Integer requiresLvlSmithery; + private Integer requiresLvlEnchanting; } diff --git a/blight-common/src/main/java/de/blight/common/model/RecipeIO.java b/blight-common/src/main/java/de/blight/common/model/RecipeIO.java new file mode 100644 index 0000000..46ee2a7 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/RecipeIO.java @@ -0,0 +1,152 @@ +package de.blight.common.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +/** + * Lädt und speichert {@link Recipe}-Instanzen als JSON. + * Dateiformat: {@code .recipe} im recipes/-Verzeichnis. + * + * {@code Map} wird als Array von {@code {itemId, count}}-Einträgen + * gespeichert, da Gson keine komplexen Map-Keys unterstützt. + */ +public final class RecipeIO { + + private static final Logger log = LoggerFactory.getLogger(RecipeIO.class); + private static final String EXTENSION = ".recipe"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public static final Comparator SORT_ORDER = Comparator + .comparing((Recipe r) -> { + if (r.getTable() == null) return ""; + CraftingTable.CraftingTableType t = r.getTable().getType(); + return t != null ? t.name() : ""; + }) + .thenComparing(r -> r.getCreates() != null ? safe(r.getCreates().getItemId()) : ""); + + private RecipeIO() {} + + // ── Public API ──────────────────────────────────────────────────────────── + + public static void save(Recipe recipe, Path recipeDir) throws IOException { + String fileId = fileId(recipe); + Files.createDirectories(recipeDir); + Files.writeString(recipeDir.resolve(fileId + EXTENSION), + GSON.toJson(toDto(recipe)), StandardCharsets.UTF_8); + log.debug("[RecipeIO] Gespeichert: {}", fileId); + } + + public static Recipe load(Path file) throws IOException { + RecipeDto dto = GSON.fromJson(Files.readString(file, StandardCharsets.UTF_8), RecipeDto.class); + return fromDto(dto); + } + + public static List loadAll(Path recipeDir) { + List result = new ArrayList<>(); + if (!Files.isDirectory(recipeDir)) return result; + try (Stream walk = Files.list(recipeDir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .sorted() + .forEach(p -> { + try { result.add(load(p)); } + catch (IOException e) { log.warn("[RecipeIO] Fehler: {}", e.getMessage()); } + }); + } catch (IOException e) { + log.warn("[RecipeIO] Scan-Fehler: {}", e.getMessage()); + } + result.sort(SORT_ORDER); + return result; + } + + /** Löscht die Datei des alten fileId (vor einer Umbenennung). */ + public static void delete(String oldCreatesItemId, Path recipeDir) throws IOException { + if (oldCreatesItemId != null && !oldCreatesItemId.isBlank()) + Files.deleteIfExists(recipeDir.resolve(oldCreatesItemId + EXTENSION)); + } + + public static String fileId(Recipe r) { + if (r.getCreates() != null && r.getCreates().getItemId() != null + && !r.getCreates().getItemId().isBlank()) + return r.getCreates().getItemId(); + return "unbenanntes_rezept"; + } + + // ── DTO conversion ──────────────────────────────────────────────────────── + + private static RecipeDto toDto(Recipe r) { + RecipeDto dto = new RecipeDto(); + dto.createsItemId = r.getCreates() != null ? r.getCreates().getItemId() : null; + if (r.getComponents() != null) { + dto.components = new ArrayList<>(); + r.getComponents().forEach((item, count) -> + dto.components.add(new ComponentDto(item.getItemId(), count))); + dto.components.sort(Comparator.comparing(c -> safe(c.itemId))); + } + if (r.getTable() != null && r.getTable().getType() != null) + dto.tableType = r.getTable().getType().name(); + dto.requiresLvlAlchemy = r.getRequiresLvlAlchemy(); + dto.requiresLvlEngineering = r.getRequiresLvlEngineering(); + dto.requiresLvlSmithery = r.getRequiresLvlSmithery(); + dto.requiresLvlEnchanting = r.getRequiresLvlEnchanting(); + return dto; + } + + static Recipe fromDto(RecipeDto dto) { + Recipe r = new Recipe(); + if (dto.createsItemId != null) { + Item creates = new Item(); + creates.setItemId(dto.createsItemId); + r.setCreates(creates); + } + if (dto.components != null) { + Map map = new LinkedHashMap<>(); + for (ComponentDto c : dto.components) { + Item item = new Item(); + item.setItemId(c.itemId); + map.put(item, c.count); + } + r.setComponents(map); + } + if (dto.tableType != null) { + try { + CraftingTable table = new CraftingTable(); + table.setType(CraftingTable.CraftingTableType.valueOf(dto.tableType)); + r.setTable(table); + } catch (IllegalArgumentException ignored) {} + } + r.setRequiresLvlAlchemy(dto.requiresLvlAlchemy); + r.setRequiresLvlEngineering(dto.requiresLvlEngineering); + r.setRequiresLvlSmithery(dto.requiresLvlSmithery); + r.setRequiresLvlEnchanting(dto.requiresLvlEnchanting); + return r; + } + + // ── DTO classes ─────────────────────────────────────────────────────────── + + static class RecipeDto { + String createsItemId; + List components; + String tableType; + Integer requiresLvlAlchemy; + Integer requiresLvlEngineering; + Integer requiresLvlSmithery; + Integer requiresLvlEnchanting; + } + + static class ComponentDto { + String itemId; + int count; + ComponentDto(String itemId, int count) { this.itemId = itemId; this.count = count; } + } + + private static String safe(String s) { return s != null ? s : ""; } +} diff --git a/blight-common/src/main/java/de/blight/common/model/TextBundle.java b/blight-common/src/main/java/de/blight/common/model/TextBundle.java new file mode 100644 index 0000000..83da8c6 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/TextBundle.java @@ -0,0 +1,20 @@ +package de.blight.common.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Ein Sprachpaket: Sprach-Code + Schlüssel→Text-Map. */ +@Getter +@Setter +public class TextBundle { + + private String language; + private Map entries = new LinkedHashMap<>(); + + public TextBundle(String language) { + this.language = language; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/TextBundleIO.java b/blight-common/src/main/java/de/blight/common/model/TextBundleIO.java new file mode 100644 index 0000000..7447b54 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/TextBundleIO.java @@ -0,0 +1,77 @@ +package de.blight.common.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +/** + * Lädt und speichert {@link TextBundle}-Instanzen als JSON. + * Dateiformat: {@code .json} im localization/-Verzeichnis. + */ +public final class TextBundleIO { + + private static final Logger log = LoggerFactory.getLogger(TextBundleIO.class); + private static final String EXTENSION = ".json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Type MAP_TYPE = new TypeToken>(){}.getType(); + + private TextBundleIO() {} + + public static void save(TextBundle bundle, Path dir) throws IOException { + Files.createDirectories(dir); + Files.writeString(dir.resolve(bundle.getLanguage() + EXTENSION), + GSON.toJson(bundle.getEntries()), StandardCharsets.UTF_8); + log.debug("[TextBundleIO] Gespeichert: {}", bundle.getLanguage()); + } + + public static TextBundle load(Path file) throws IOException { + String lang = file.getFileName().toString().replace(EXTENSION, ""); + Map map = GSON.fromJson( + Files.readString(file, StandardCharsets.UTF_8), MAP_TYPE); + TextBundle bundle = new TextBundle(lang); + if (map != null) bundle.setEntries(new LinkedHashMap<>(map)); + return bundle; + } + + public static List loadAll(Path dir) { + List result = new ArrayList<>(); + if (!Files.isDirectory(dir)) return result; + try (Stream walk = Files.list(dir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .sorted() + .forEach(p -> { + try { result.add(load(p)); } + catch (IOException e) { log.warn("[TextBundleIO] Fehler: {}", e.getMessage()); } + }); + } catch (IOException e) { + log.warn("[TextBundleIO] Scan-Fehler: {}", e.getMessage()); + } + return result; + } + + public static List availableLanguages(Path dir) { + List langs = new ArrayList<>(); + if (!Files.isDirectory(dir)) return langs; + try (Stream walk = Files.list(dir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .map(p -> p.getFileName().toString().replace(EXTENSION, "")) + .sorted() + .forEach(langs::add); + } catch (IOException ignored) {} + return langs; + } + + public static void delete(String language, Path dir) throws IOException { + Files.deleteIfExists(dir.resolve(language + EXTENSION)); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/TextRegistry.java b/blight-common/src/main/java/de/blight/common/model/TextRegistry.java new file mode 100644 index 0000000..7eb6b85 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/TextRegistry.java @@ -0,0 +1,39 @@ +package de.blight.common.model; + +import java.util.HashMap; +import java.util.Map; + +/** + * Statische Auflösungstabelle für {@link TextReference}-Schlüssel zur Laufzeit und im Editor. + * Wird durch {@code TextBundleIO} mit der aktiven Sprachversion befüllt. + */ +public final class TextRegistry { + + private static final Map entries = new HashMap<>(); + + private TextRegistry() {} + + public static void registerAll(Map map) { + entries.putAll(map); + } + + public static void clear() { + entries.clear(); + } + + /** Löst eine TextReference auf. Gibt den Schlüssel zurück wenn kein Eintrag vorhanden. */ + public static String resolve(TextReference ref) { + if (ref == null || ref.id() == null) return ""; + return entries.getOrDefault(ref.id(), ref.id()); + } + + public static String resolve(TextReference ref, String fallback) { + if (ref == null) return fallback; + return entries.getOrDefault(ref.id(), fallback); + } + + /** Direkter Zugriff für den Editor (alle Einträge). */ + public static Map getAll() { + return new HashMap<>(entries); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java b/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java index ec98c59..ad2587c 100644 --- a/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java +++ b/blight-common/src/main/java/de/blight/common/model/abilities/Abilities.java @@ -37,6 +37,16 @@ public class Abilities { */ private int lvlEngineering; // 1-3 + /** + * Levels ermöglichen das Schmieden von immer besseren Waffen und Ausrüstungen + */ + private int lvlSmithery; // 1-3 + + /** + * Levels ermöglichen das HErstellen von mächtigeren Gegenständen + */ + private int lvlEnchanting; // 1-3 + public enum StaffAbilities { BASE_ATTACK(1), // Eine Basisattacke mit einem Stab diff --git a/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java index c57c756..b06913a 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/BringQuest.java @@ -7,7 +7,7 @@ import lombok.Setter; @Getter @Setter -public class BringQuest { +public class BringQuest extends Quest { private NPC bring; private Location bringTo; diff --git a/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java index a5baa3f..cc8d551 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/FollowQuest.java @@ -7,7 +7,7 @@ import lombok.Setter; @Setter @Getter -public class FollowQuest implements QuestType { +public class FollowQuest extends Quest { private NPC follow; private Location followTo; diff --git a/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java index fc48fe3..ce1abd6 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/InteractQuest.java @@ -6,7 +6,7 @@ import lombok.Setter; @Getter @Setter -public class InteractQuest implements QuestType { +public class InteractQuest extends Quest { private Interactable interactWith; } diff --git a/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java index f68a961..530aafc 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/ItemQuest.java @@ -6,7 +6,7 @@ import lombok.Setter; @Getter @Setter -public class ItemQuest implements QuestType { +public class ItemQuest extends Quest { private Item item; private int count; diff --git a/blight-common/src/main/java/de/blight/common/model/quests/Quest.java b/blight-common/src/main/java/de/blight/common/model/quests/Quest.java index 9d0b047..672e7f1 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/Quest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/Quest.java @@ -6,13 +6,11 @@ import lombok.Setter; @Getter @Setter -public class Quest { +public abstract class Quest { private int xp; private String questId; private TextReference text; private TextReference description; private TextReference successText; - - private QuestType questType; } diff --git a/blight-common/src/main/java/de/blight/common/model/quests/QuestIO.java b/blight-common/src/main/java/de/blight/common/model/quests/QuestIO.java new file mode 100644 index 0000000..190db3e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/quests/QuestIO.java @@ -0,0 +1,138 @@ +package de.blight.common.model.quests; + +import com.google.gson.*; +import de.blight.common.model.Interactable; +import de.blight.common.model.InteractableRef; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Lädt und speichert {@link Quest}-Instanzen als JSON. + * Dateiformat: {@code .quest} im quests/-Verzeichnis. + * + * Typ-Diskriminator im JSON: {@code "type": "BRING" | "FOLLOW" | "INTERACT" | "ITEM" | "TALK"} + */ +public final class QuestIO { + + private static final Logger log = LoggerFactory.getLogger(QuestIO.class); + private static final String EXTENSION = ".quest"; + + private static final Gson GSON = new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(Quest.class, new QuestAdapter()) + .registerTypeAdapter(Interactable.class, new InteractableAdapter()) + .create(); + + private QuestIO() {} + + // ── Public API ──────────────────────────────────────────────────────────── + + public static void save(Quest quest, Path questDir) throws IOException { + if (quest.getQuestId() == null || quest.getQuestId().isBlank()) + throw new IllegalArgumentException("questId darf nicht leer sein"); + Files.createDirectories(questDir); + JsonObject obj = serializeWithType(quest); + Files.writeString(questDir.resolve(quest.getQuestId() + EXTENSION), + GSON.toJson(obj), StandardCharsets.UTF_8); + log.debug("[QuestIO] Gespeichert: {}", quest.getQuestId()); + } + + public static Quest load(Path file) throws IOException { + String json = Files.readString(file, StandardCharsets.UTF_8); + return GSON.fromJson(json, Quest.class); + } + + public static List loadAll(Path questDir) { + List result = new ArrayList<>(); + if (!Files.isDirectory(questDir)) return result; + try (Stream walk = Files.list(questDir)) { + walk.filter(p -> p.toString().endsWith(EXTENSION)) + .sorted() + .forEach(p -> { + try { result.add(load(p)); } + catch (IOException e) { log.warn("[QuestIO] Fehler beim Laden: {}", e.getMessage()); } + }); + } catch (IOException e) { + log.warn("[QuestIO] Verzeichnis-Scan fehlgeschlagen: {}", e.getMessage()); + } + return result; + } + + public static void delete(String questId, Path questDir) throws IOException { + Files.deleteIfExists(questDir.resolve(questId + EXTENSION)); + } + + // ── Serialisation helper ────────────────────────────────────────────────── + + public static String typeOf(Quest q) { + if (q instanceof BringQuest) return "BRING"; + if (q instanceof FollowQuest) return "FOLLOW"; + if (q instanceof InteractQuest) return "INTERACT"; + if (q instanceof ItemQuest) return "ITEM"; + if (q instanceof TalkQuest) return "TALK"; + return "UNKNOWN"; + } + + private static JsonObject serializeWithType(Quest quest) { + // Serialize using the concrete type to capture all fields + Gson plain = new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(Interactable.class, new InteractableAdapter()) + .create(); + JsonObject obj = plain.toJsonTree(quest, quest.getClass()).getAsJsonObject(); + obj.addProperty("type", typeOf(quest)); + return obj; + } + + // ── Type adapters ───────────────────────────────────────────────────────── + + static class QuestAdapter implements JsonDeserializer, JsonSerializer { + + @Override + public Quest deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx) + throws JsonParseException { + JsonObject obj = json.getAsJsonObject(); + String type = obj.has("type") ? obj.get("type").getAsString() : "UNKNOWN"; + // Deserialize as the concrete type (no type adapter registered for subtypes → no recursion) + Gson plain = new GsonBuilder() + .registerTypeAdapter(Interactable.class, new InteractableAdapter()) + .create(); + return switch (type) { + case "BRING" -> plain.fromJson(json, BringQuest.class); + case "FOLLOW" -> plain.fromJson(json, FollowQuest.class); + case "INTERACT" -> plain.fromJson(json, InteractQuest.class); + case "ITEM" -> plain.fromJson(json, ItemQuest.class); + case "TALK" -> plain.fromJson(json, TalkQuest.class); + default -> plain.fromJson(json, TalkQuest.class); + }; + } + + @Override + public JsonElement serialize(Quest src, Type typeOfSrc, JsonSerializationContext ctx) { + return serializeWithType(src); + } + } + + static class InteractableAdapter implements JsonDeserializer, JsonSerializer { + + @Override + public Interactable deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx) + throws JsonParseException { + return new Gson().fromJson(json, InteractableRef.class); + } + + @Override + public JsonElement serialize(Interactable src, Type typeOfSrc, JsonSerializationContext ctx) { + return new Gson().toJsonTree(src, InteractableRef.class); + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java b/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java deleted file mode 100644 index e249d04..0000000 --- a/blight-common/src/main/java/de/blight/common/model/quests/QuestType.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.blight.common.model.quests; - -public interface QuestType { - -} diff --git a/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java b/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java index 0ccdc7f..d821494 100644 --- a/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java +++ b/blight-common/src/main/java/de/blight/common/model/quests/TalkQuest.java @@ -6,7 +6,7 @@ import lombok.Setter; @Getter @Setter -public class TalkQuest implements QuestType { +public class TalkQuest extends Quest { private NPC talkTo; } diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/FractionStatusTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/FractionStatusTrigger.java new file mode 100644 index 0000000..36821ef --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/FractionStatusTrigger.java @@ -0,0 +1,29 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import de.blight.common.model.Status; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +/** + * Ändert den Status aller NPCs einer Fraktion (per Fraktions-UUID) wenn betreten. + */ +@Getter +@Setter +public class FractionStatusTrigger extends Trigger { + + private UUID fractionId; + private Status targetStatus; + + @Override + public boolean isTriggarableDelegate(MainCharacter character) { + return fractionId != null && targetStatus != null; + } + + @Override + public void trigger(MainCharacter character) { + // Laufzeit: alle NPCs der Fraktion suchen und Status setzen + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/NpcStatusTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/NpcStatusTrigger.java new file mode 100644 index 0000000..62c47f2 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/NpcStatusTrigger.java @@ -0,0 +1,28 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import de.blight.common.model.Status; +import lombok.Getter; +import lombok.Setter; + +/** + * Ändert den Status eines bestimmten NPCs (per Character-ID) wenn betreten. + * Die eigentliche NPC-Suche zur Laufzeit obliegt der Game-Registry. + */ +@Getter +@Setter +public class NpcStatusTrigger extends Trigger { + + private String npcId; + private Status targetStatus; + + @Override + public boolean isTriggarableDelegate(MainCharacter character) { + return npcId != null && !npcId.isBlank() && targetStatus != null; + } + + @Override + public void trigger(MainCharacter character) { + // Laufzeit: NPC per ID suchen und Status setzen + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/QuestStartTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/QuestStartTrigger.java new file mode 100644 index 0000000..945b803 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/QuestStartTrigger.java @@ -0,0 +1,23 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import de.blight.common.model.quests.Quest; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QuestStartTrigger extends Trigger { + + private Quest quest; + + @Override + public boolean isTriggarableDelegate(MainCharacter character) { + return quest != null && character.isQuestNew(quest); + } + + @Override + public void trigger(MainCharacter character) { + if (quest != null) character.startQuest(quest); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java new file mode 100644 index 0000000..34ca2f0 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/Trigger.java @@ -0,0 +1,20 @@ +package de.blight.common.model.trigger; + +import de.blight.common.model.MainCharacter; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public abstract class Trigger { + + private int requiresChapter; + + public boolean isTriggarable(MainCharacter character) { + return character.getChapter() >= requiresChapter && isTriggarableDelegate(character); + } + + public abstract boolean isTriggarableDelegate(MainCharacter character); + + public abstract void trigger(MainCharacter character); +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java new file mode 100644 index 0000000..9fa5bc7 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java @@ -0,0 +1,136 @@ +package de.blight.common.model.trigger; + +import com.google.gson.*; +import de.blight.common.model.QuestRef; +import de.blight.common.model.Status; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Gson-Serialisierung für {@link Trigger}-Instanzen mit Typ-Diskriminator. + * + * JSON-Format (kompakt, kein Pretty-Print für Inline-Verwendung in z. B. LocationZoneIO): + * {@code [{"type":"QUEST_START","requiresChapter":0,"questId":"my_quest"}, ...]} + * + * Bekannte Typen: + *
    + *
  • {@code QUEST_START} → {@link QuestStartTrigger}
  • + *
  • {@code NPC_STATUS} → {@link NpcStatusTrigger}
  • + *
  • {@code FRACTION_STATUS} → {@link FractionStatusTrigger}
  • + *
+ */ +public final class TriggerIO { + + public static final String TYPE_QUEST_START = "QUEST_START"; + public static final String TYPE_NPC_STATUS = "NPC_STATUS"; + public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS"; + + private static final Gson GSON = new GsonBuilder() + .registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter()) + .create(); + + private TriggerIO() {} + + /** Serialisiert eine Trigger-Liste als kompaktes JSON (kein Zeilenumbruch). */ + public static String serializeList(List triggers) { + if (triggers == null || triggers.isEmpty()) return "[]"; + return GSON.toJson(triggers); + } + + /** Deserialisiert eine Trigger-Liste aus JSON. Gibt leere Liste bei Fehler zurück. */ + public static List deserializeList(String json) { + if (json == null || json.isBlank() || "[]".equals(json.strip())) return new ArrayList<>(); + try { + JsonArray arr = JsonParser.parseString(json).getAsJsonArray(); + List result = new ArrayList<>(); + for (JsonElement el : arr) { + Trigger t = GSON.fromJson(el, Trigger.class); + if (t != null) result.add(t); + } + return result; + } catch (Exception e) { + return new ArrayList<>(); + } + } + + // ── Adapter ─────────────────────────────────────────────────────────────── + + static class TriggerAdapter implements JsonSerializer, JsonDeserializer { + + @Override + public JsonElement serialize(Trigger src, Type typeOfSrc, JsonSerializationContext ctx) { + JsonObject obj = new JsonObject(); + obj.addProperty("type", typeOf(src)); + obj.addProperty("requiresChapter", src.getRequiresChapter()); + + if (src instanceof QuestStartTrigger q) { + if (q.getQuest() != null && q.getQuest().getQuestId() != null) + obj.addProperty("questId", q.getQuest().getQuestId()); + } else if (src instanceof NpcStatusTrigger n) { + if (n.getNpcId() != null) obj.addProperty("npcId", n.getNpcId()); + if (n.getTargetStatus() != null) + obj.addProperty("targetStatus", n.getTargetStatus().name()); + } else if (src instanceof FractionStatusTrigger f) { + if (f.getFractionId() != null) + obj.addProperty("fractionId", f.getFractionId().toString()); + if (f.getTargetStatus() != null) + obj.addProperty("targetStatus", f.getTargetStatus().name()); + } + return obj; + } + + @Override + public Trigger deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext ctx) + throws JsonParseException { + JsonObject obj = json.getAsJsonObject(); + String type = obj.has("type") ? obj.get("type").getAsString() : ""; + + Trigger t = switch (type) { + case TYPE_QUEST_START -> { + QuestStartTrigger q = new QuestStartTrigger(); + if (obj.has("questId")) { + QuestRef ref = new QuestRef(); + ref.setQuestId(obj.get("questId").getAsString()); + q.setQuest(ref); + } + yield q; + } + case TYPE_NPC_STATUS -> { + NpcStatusTrigger n = new NpcStatusTrigger(); + if (obj.has("npcId")) n.setNpcId(obj.get("npcId").getAsString()); + if (obj.has("targetStatus")) n.setTargetStatus(parseStatus(obj.get("targetStatus"))); + yield n; + } + case TYPE_FRACTION_STATUS -> { + FractionStatusTrigger f = new FractionStatusTrigger(); + if (obj.has("fractionId")) { + try { f.setFractionId(UUID.fromString(obj.get("fractionId").getAsString())); } + catch (IllegalArgumentException ignored) {} + } + if (obj.has("targetStatus")) f.setTargetStatus(parseStatus(obj.get("targetStatus"))); + yield f; + } + default -> null; + }; + + if (t != null && obj.has("requiresChapter")) + t.setRequiresChapter(obj.get("requiresChapter").getAsInt()); + return t; + } + + private static String typeOf(Trigger t) { + if (t instanceof QuestStartTrigger) return TYPE_QUEST_START; + if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS; + if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS; + return "UNKNOWN"; + } + + private static Status parseStatus(JsonElement el) { + try { return Status.valueOf(el.getAsString()); } + catch (IllegalArgumentException ignored) { return null; } + } + } +} 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 8513ed5..95546ad 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -120,11 +120,25 @@ public class EditorApp extends Application { private String animCurrentModelPath = null; // Character-Editor-Zustand + private de.blight.editor.ui.DialogEditorView dialogEditorView; + private javafx.scene.layout.VBox charEditContainer; // deaktiviert solange kein Character geladen + private javafx.scene.layout.VBox charFractionSection; // sichtbar nur für NPCs + private javafx.scene.layout.VBox charNpcSection; // sichtbar nur für NPCs + private javafx.scene.layout.VBox charMainCharSection; // sichtbar nur für MainCharacter + private javafx.scene.control.ComboBox charStatusCombo; + private javafx.scene.control.CheckBox charTraderCheck; + private javafx.scene.control.ListView charTraderItemsView; + // Abilities (MainCharacter) + private javafx.scene.control.Spinner abMagicSpin, abStaffSpin, abSwordSpin, + abArcherySpin, abHeavySpin, abCrossbowSpin, abThieverySpin, + abAlchemySpin, abEngineeringSpin, abSmitherySpin, abEnchantingSpin; private javafx.scene.control.TextField charNameField; private javafx.scene.control.TextField charIdField; private javafx.scene.control.ComboBox charTypeCombo; private javafx.scene.control.ComboBox charModelCombo; private javafx.scene.control.ComboBox charAnimSetCombo; + private javafx.scene.control.ComboBox charFractionCombo; + private final java.util.List loadedFractions = new java.util.ArrayList<>(); /** Read-only labels for action assignments shown in character editor. */ private VBox charActionLabelsBox; private Label charEditorStatusLabel; @@ -138,13 +152,16 @@ public class EditorApp extends Application { // Wasser-Werkzeug-Zustand private VBox waterDynamicContent; - private javafx.scene.control.Label waterHintLabel; + private Label waterCurrentHeightLabel; // Sound-Bereich-Werkzeug-Zustand private VBox soundAreaDynamicContent; - // Musik-Bereich-Werkzeug-Zustand - private VBox musicAreaDynamicContent; + // Bereich-Werkzeug-Zustand + private VBox areaDynamicContent; + + // Location-Zonen-Werkzeug-Zustand + private VBox locationZoneDynamicContent; // Spiel-Starten-Werkzeug-Zustand private TextField spawnXField; @@ -164,9 +181,23 @@ public class EditorApp extends Application { private String tripoLastModelUrl; private String tripoLastTaskId; + // Modell-Editor-Zustand + private Label modelEditorDimLabel; + private Spinner modelEditorSpinX, modelEditorSpinY, modelEditorSpinZ; + private CheckBox modelEditorUniformCB; + private boolean modelEditorSuppressListeners = false; + private String modelEditorCurrentPath = null; + + // Asset-Overlay-Zustand + private boolean assetOverlayPinned = false; + private boolean assetOverlayOpen = false; + private Button assetTabBtn; + private javafx.animation.PauseTransition assetCloseDelay; + // Toolbar-Buttons (müssen vom Status-Poller erreichbar sein) private ToggleButton baseBtn; private ToggleButton grassBtn; + private ToggleButton grassVertexBtn; private ToggleButton textureBtn; private ToggleButton objPlaceBtn; private ToggleButton objEditBtn; @@ -175,7 +206,8 @@ public class EditorApp extends Application { private ToggleButton waterBtn; private ToggleButton riverBtn; private ToggleButton soundAreaBtn; - private ToggleButton musicAreaBtn; + private ToggleButton areaBtn; + private ToggleButton locationZoneBtn; private ToggleButton playToolBtn; // "Objekt"-Button in der Selektionsleiste (zum Zurückschalten bei importierten Objekten) @@ -249,7 +281,7 @@ public class EditorApp extends Application { topBar = buildTop(); root.setTop(topBar); worldViewport = buildViewport(); - root.setLeft(assetPanel); + setupAssetOverlay(); root.setCenter(worldViewport); root.setRight(toolPanel); root.setBottom(buildBottomBox()); @@ -401,35 +433,37 @@ public class EditorApp extends Application { updateWaterPanel(input.selectedWaterInfo); } - if (input.riverSelectionChanged) { - input.riverSelectionChanged = false; - updateRiverPanel(input.selectedRiverInfo); + if (input.waterfallSelectionChanged) { + input.waterfallSelectionChanged = false; + updateWaterfallPanel(input.selectedWaterfallInfo); + } + + if (input.waterHeightChanged) { + input.waterHeightChanged = false; + updateWaterHeightDisplay(input.waterCurrentHeight); } // 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); } - if (input.musicAreaSelectionChanged) { - input.musicAreaSelectionChanged = false; - updateMusicAreaPanel(input.selectedMusicAreaInfo); + if (input.areaSelectionChanged) { + input.areaSelectionChanged = false; + updateAreaPanel(input.selectedAreaInfo); + } + + if (input.areaOverlapRejected) { + input.areaOverlapRejected = false; + setStatus("Bereich abgelehnt: überschneidet einen bestehenden Bereich."); + } + + if (input.locationZoneSelectionChanged) { + input.locationZoneSelectionChanged = false; + updateLocationZonePanel(input.selectedLocationZoneInfo); } if (input.spawnPickChanged) { @@ -437,6 +471,15 @@ public class EditorApp extends Application { updateSpawnFields(input.pickedSpawnInfo); } + // Modell-Editor: Bounds-Aktualisierung + if (input.modelEditorBoundsReady) { + input.modelEditorBoundsReady = false; + updateModelEditorDimensions( + input.modelEditorBoundsW, + input.modelEditorBoundsH, + input.modelEditorBoundsD); + } + // Kamera-Koordinaten aktualisieren camCoordsLabel.setText(String.format( "X:%.1f Y:%.1f Z:%.1f Yaw:%.0f° Pitch:%.0f°", @@ -497,10 +540,64 @@ public class EditorApp extends Application { } private void switchToWorldEditor() { + boolean wasObjEditor = "objecteditor".equals(currentTool); currentTool = "world"; topBar.getChildren().set(1, worldToolBar); root.setCenter(worldViewport); root.setRight(toolPanel); + if (wasObjEditor && (input.activeLayer == SharedInput.LAYER_OBJECTS + || input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT)) { + input.activeLayer = 0; + if (baseBtn != null) baseBtn.setSelected(true); + } + } + + private void switchToObjectEditor(boolean placeMode) { + currentTool = "objecteditor"; + topBar.getChildren().set(1, buildObjectEditorToolBar(placeMode)); + root.setCenter(worldViewport); + if (placeMode) { + input.activeLayer = SharedInput.LAYER_OBJECTS; + root.setRight(buildObjectPlacePanel()); + } else { + input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT; + input.pendingModelPath = null; + root.setRight(buildObjectEditPanel()); + } + } + + private ToolBar buildObjectEditorToolBar(boolean placeMode) { + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + + objPlaceBtn = new ToggleButton("📦 Platzieren"); + objEditBtn = new ToggleButton("🔧 Bearbeiten"); + objPlaceBtn.setStyle("-fx-font-weight: bold;"); + objEditBtn.setStyle("-fx-font-weight: bold;"); + + ToggleGroup objGroup = new ToggleGroup(); + objPlaceBtn.setToggleGroup(objGroup); + objEditBtn.setToggleGroup(objGroup); + if (placeMode) objPlaceBtn.setSelected(true); + else objEditBtn.setSelected(true); + + objPlaceBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_OBJECTS; + root.setRight(buildObjectPlacePanel()); + }); + objEditBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT; + input.pendingModelPath = null; + root.setRight(buildObjectEditPanel()); + }); + + Label label = new Label("Objekt Editor"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + ToolBar tb = new ToolBar(); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), + objPlaceBtn, objEditBtn, + new Separator(Orientation.VERTICAL), label); + return tb; } private void switchToEzTree() { @@ -554,44 +651,68 @@ public class EditorApp extends Application { MenuItem palmItem = new MenuItem("Baum Generator (Palme)"); MenuItem tripoItem = new MenuItem("AI Modell-Generator (Tripo3D)"); MenuItem animPrevItem = new MenuItem("Animationseditor"); + MenuItem objEditorItem = new MenuItem("Objekt Editor"); MenuItem worldEditItem = new MenuItem("Welteneditor"); - MenuItem charEditItem = new MenuItem("Character Editor"); + MenuItem charEditItem = new MenuItem("Character Editor"); + MenuItem questEditItem = new MenuItem("Quest-Manager"); + MenuItem itemEditItem = new MenuItem("Item-Manager"); + MenuItem recipeEditItem = new MenuItem("Rezept-Manager"); + MenuItem ctEditItem = new MenuItem("Crafting-Table-Manager"); + MenuItem fractionEditItem = new MenuItem("Fraktionen-Manager"); + MenuItem locationEditItem = new MenuItem("Locations-Manager"); + MenuItem localizationEditItem = new MenuItem("Lokalisierungs-Editor"); treeGenItem.setOnAction(e -> switchToTreeGenerator()); ezTreeItem.setOnAction(e -> switchToEzTree()); palmItem.setOnAction(e -> switchToPalm()); tripoItem.setOnAction(e -> switchToTripo()); animPrevItem.setOnAction(e -> switchToAnimPreview()); + objEditorItem.setOnAction(e -> switchToObjectEditor(true)); worldEditItem.setOnAction(e -> switchToWorldEditor()); charEditItem.setOnAction(e -> switchToCharacterEditor()); + questEditItem.setOnAction(e -> switchToQuestEditor()); + itemEditItem.setOnAction(e -> switchToItemEditor()); + recipeEditItem.setOnAction(e -> switchToRecipeEditor()); + ctEditItem.setOnAction(e -> switchToCraftingTableEditor()); + fractionEditItem.setOnAction(e -> switchToFractionEditor()); + locationEditItem.setOnAction(e -> switchToLocationEditor()); + localizationEditItem.setOnAction(e -> switchToLocalizationEditor()); toolsMenu.getItems().addAll(treeGenItem, ezTreeItem, palmItem, tripoItem, - animPrevItem, worldEditItem, charEditItem); + animPrevItem, objEditorItem, worldEditItem, charEditItem, + questEditItem, itemEditItem, recipeEditItem, ctEditItem, fractionEditItem, + locationEditItem, localizationEditItem); Menu viewMenu = new Menu("Ansicht"); MenuItem resetCam = new MenuItem("Kamera zurücksetzen"); resetCam.setOnAction(e -> input.addMouseDelta(0, 0)); MenuItem viewTexture = new MenuItem("Textur"); MenuItem viewWireframe = new MenuItem("Drahtgitter"); + CheckMenuItem viewTopology = new CheckMenuItem("Topologie-Overlay"); viewTexture.setOnAction(e -> input.wireframeRequest = 2); viewWireframe.setOnAction(e -> input.wireframeRequest = 1); - viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe); + viewTopology.setOnAction(e -> input.topologyRequest = viewTopology.isSelected() ? 1 : 2); + viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe, + new SeparatorMenuItem(), viewTopology); menuBar.getMenus().addAll(fileMenu, toolsMenu, viewMenu); ToolBar toolBar = new ToolBar(); - baseBtn = new ToggleButton("▲▼ Basis-Terrain"); - grassBtn = new ToggleButton("🌿 Gras"); - textureBtn = new ToggleButton("🎨 Textur"); + baseBtn = new ToggleButton("▲▼ Basis-Terrain"); + grassBtn = new ToggleButton("🌿 Gras (Textur)"); + grassVertexBtn = new ToggleButton("🌱 Gras (Vertices)"); + textureBtn = new ToggleButton("🎨 Textur"); objPlaceBtn = new ToggleButton("📦 Platzieren"); objEditBtn = new ToggleButton("🔧 Bearbeiten"); 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"); + riverBtn = new ToggleButton("↯ Wasserfall"); + soundAreaBtn = new ToggleButton("🔊 Sound"); + areaBtn = new ToggleButton("🗺 Bereiche"); + locationZoneBtn = new ToggleButton("📍 Locations"); + playToolBtn = new ToggleButton("🎮 Spielen"); baseBtn.setStyle("-fx-font-weight:bold;"); grassBtn.setStyle("-fx-font-weight:bold;"); + grassVertexBtn.setStyle("-fx-font-weight:bold;"); textureBtn.setStyle("-fx-font-weight:bold;"); objPlaceBtn.setStyle("-fx-font-weight:bold;"); objEditBtn.setStyle("-fx-font-weight:bold;"); @@ -600,12 +721,14 @@ public class EditorApp extends Application { waterBtn.setStyle("-fx-font-weight:bold;"); riverBtn.setStyle("-fx-font-weight:bold;"); soundAreaBtn.setStyle("-fx-font-weight:bold;"); - musicAreaBtn.setStyle("-fx-font-weight:bold;"); + areaBtn.setStyle("-fx-font-weight:bold;"); + locationZoneBtn.setStyle("-fx-font-weight:bold;"); playToolBtn.setStyle("-fx-font-weight:bold;"); ToggleGroup layerGroup = new ToggleGroup(); baseBtn.setToggleGroup(layerGroup); grassBtn.setToggleGroup(layerGroup); + grassVertexBtn.setToggleGroup(layerGroup); textureBtn.setToggleGroup(layerGroup); objPlaceBtn.setToggleGroup(layerGroup); objEditBtn.setToggleGroup(layerGroup); @@ -614,7 +737,8 @@ public class EditorApp extends Application { waterBtn.setToggleGroup(layerGroup); riverBtn.setToggleGroup(layerGroup); soundAreaBtn.setToggleGroup(layerGroup); - musicAreaBtn.setToggleGroup(layerGroup); + areaBtn.setToggleGroup(layerGroup); + locationZoneBtn.setToggleGroup(layerGroup); playToolBtn.setToggleGroup(layerGroup); baseBtn.setSelected(true); @@ -628,6 +752,12 @@ public class EditorApp extends Application { root.setRight(toolPanel); showToolParameters(toolPanel, input.activeTool); }); + grassVertexBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_GRASS_VERTEX; + input.activeTool = input.grassVertexTool; + root.setRight(toolPanel); + showToolParameters(toolPanel, input.activeTool); + }); textureBtn.setOnAction(e -> { input.activeLayer = 4; input.activeTool = input.textureTool; root.setRight(toolPanel); @@ -655,16 +785,20 @@ public class EditorApp extends Application { root.setRight(buildWaterPanel()); }); riverBtn.setOnAction(e -> { - input.activeLayer = SharedInput.LAYER_RIVERS; - root.setRight(buildRiverPanel()); + input.activeLayer = SharedInput.LAYER_WATERFALL; + root.setRight(buildWaterfallPanel()); }); soundAreaBtn.setOnAction(e -> { input.activeLayer = SharedInput.LAYER_SOUND_AREAS; root.setRight(buildSoundAreaPanel()); }); - musicAreaBtn.setOnAction(e -> { - input.activeLayer = SharedInput.LAYER_MUSIC_AREAS; - root.setRight(buildMusicAreaPanel()); + areaBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_AREAS; + root.setRight(buildAreaPanel()); + }); + locationZoneBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_LOCATION_ZONES; + root.setRight(buildLocationZonePanel()); }); playToolBtn.setOnAction(e -> { input.activeLayer = SharedInput.LAYER_PLAY_TOOL; @@ -674,13 +808,13 @@ public class EditorApp extends Application { Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief"); hint.setStyle("-fx-text-fill: #555;"); - toolBar.getItems().addAll(baseBtn, grassBtn, textureBtn, + toolBar.getItems().addAll(baseBtn, grassBtn, grassVertexBtn, textureBtn, new Separator(Orientation.VERTICAL), objPlaceBtn, objEditBtn, 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), soundAreaBtn, areaBtn, locationZoneBtn, new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), hint); @@ -873,6 +1007,34 @@ public class EditorApp extends Application { return l; } + /** + * Wraps a Slider in an HBox with a synced TextField on the right. + * The slider's own valueProperty listeners fire normally; this just adds a text field. + */ + private static HBox withField(Slider slider, String fmt) { + slider.setMaxWidth(Double.MAX_VALUE); + TextField tf = new TextField(String.format(fmt, slider.getValue())); + tf.setPrefWidth(62); + tf.setMaxWidth(62); + tf.setStyle("-fx-font-size: 11;"); + slider.valueProperty().addListener((o, ov, nv) -> + tf.setText(String.format(fmt, nv.doubleValue()))); + Runnable commit = () -> { + try { + double v = Math.max(slider.getMin(), Math.min(slider.getMax(), + Double.parseDouble(tf.getText().replace(',', '.')))); + slider.setValue(v); + } catch (NumberFormatException ignored) { + tf.setText(String.format(fmt, slider.getValue())); + } + }; + tf.setOnAction(e -> commit.run()); + tf.focusedProperty().addListener((o, ov, focused) -> { if (!focused) commit.run(); }); + HBox row = new HBox(5, slider, tf); + HBox.setHgrow(slider, Priority.ALWAYS); + return row; + } + private static VBox paramSlider(String label, double min, double max, double init, Consumer setter) { Label name = new Label(label); @@ -881,14 +1043,8 @@ public class EditorApp extends Application { slider.setShowTickMarks(true); slider.setShowTickLabels(true); slider.setMajorTickUnit((max - min) / 2.0); - slider.setMaxWidth(Double.MAX_VALUE); - Label val = new Label(String.format("%.3f", init)); - val.setStyle("-fx-text-fill: #444; -fx-font-size: 11;"); - slider.valueProperty().addListener((o, a, b) -> { - setter.accept(b.floatValue()); - val.setText(String.format("%.3f", b.doubleValue())); - }); - return new VBox(3, name, slider, val); + slider.valueProperty().addListener((o, a, b) -> setter.accept(b.floatValue())); + return new VBox(3, name, withField(slider, "%.3f")); } // ── EZ-Tree – Toolbar ──────────────────────────────────────────────────── @@ -1284,16 +1440,8 @@ public class EditorApp extends Application { name.setStyle("-fx-font-weight: bold; -fx-text-fill: #111111;"); Slider slider = new Slider(min, max, Math.max(min, Math.min(max, init))); slider.setShowTickMarks(false); - slider.setMaxWidth(Double.MAX_VALUE); - Label val = new Label(String.format("%.3f", init)); - val.setStyle("-fx-text-fill: #444; -fx-font-size: 11;"); - slider.valueProperty().addListener((o, a, b) -> { - setter.accept(b.floatValue()); - val.setText(String.format("%.3f", b.doubleValue())); - }); - HBox row = new HBox(6, slider, val); - HBox.setHgrow(slider, Priority.ALWAYS); - return new VBox(2, name, row); + slider.valueProperty().addListener((o, a, b) -> setter.accept(b.floatValue())); + return new VBox(2, name, withField(slider, "%.3f")); } private static String pathToName(String path, String[] paths, String[] names) { @@ -1452,15 +1600,10 @@ public class EditorApp extends Application { Slider placeSnapSlider = new Slider(0.05, 2.0, input.vertexSnapRadius); placeSnapSlider.setShowTickLabels(false); placeSnapSlider.setBlockIncrement(0.05); - Label placeSnapLbl = new Label(String.format("Radius: %.2f", input.vertexSnapRadius)); - placeSnapLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #555;"); - placeSnapSlider.valueProperty().addListener((o, ov, nv) -> { - input.vertexSnapRadius = nv.floatValue(); - placeSnapLbl.setText(String.format("Radius: %.2f", nv.floatValue())); - }); - placeSnapSlider.disableProperty().bind(placeSnapCB.selectedProperty().not()); - placeSnapLbl.disableProperty().bind(placeSnapCB.selectedProperty().not()); - inner.getChildren().addAll(new VBox(3, placeSnapCB, placeSnapSlider, placeSnapLbl)); + placeSnapSlider.valueProperty().addListener((o, ov, nv) -> input.vertexSnapRadius = nv.floatValue()); + HBox placeSnapRow = withField(placeSnapSlider, "%.2f"); + placeSnapRow.disableProperty().bind(placeSnapCB.selectedProperty().not()); + inner.getChildren().addAll(new VBox(3, placeSnapCB, new Label("Radius"), placeSnapRow)); inner.getChildren().add(styledHint("Snap aktiv: Platzierung rastet am nächsten Vertex ein")); ScrollPane scroll = new ScrollPane(inner); @@ -1579,15 +1722,10 @@ public class EditorApp extends Application { Slider snapRadiusSlider = new Slider(0.05, 2.0, input.vertexSnapRadius); snapRadiusSlider.setShowTickLabels(false); snapRadiusSlider.setBlockIncrement(0.05); - Label snapRadiusLbl = new Label(String.format("Radius: %.2f", input.vertexSnapRadius)); - snapRadiusLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #555;"); - snapRadiusSlider.valueProperty().addListener((o, ov, nv) -> { - input.vertexSnapRadius = nv.floatValue(); - snapRadiusLbl.setText(String.format("Radius: %.2f", nv.floatValue())); - }); - snapRadiusSlider.disableProperty().bind(snapCB.selectedProperty().not()); - snapRadiusLbl.disableProperty().bind(snapCB.selectedProperty().not()); - VBox snapBox = new VBox(3, snapCB, snapRadiusSlider, snapRadiusLbl); + snapRadiusSlider.valueProperty().addListener((o, ov, nv) -> input.vertexSnapRadius = nv.floatValue()); + HBox snapRadiusRow = withField(snapRadiusSlider, "%.2f"); + snapRadiusRow.disableProperty().bind(snapCB.selectedProperty().not()); + VBox snapBox = new VBox(3, snapCB, new Label("Radius"), snapRadiusRow); inner.getChildren().addAll( sectionTitle("Bearbeiten"), @@ -1720,17 +1858,16 @@ public class EditorApp extends Application { for (int i = 0; i < 5; i++) { final int idx = i; - Label lbl = new Label(labels[i] + ": " + String.format("%.2f", cur[i])); + Label lbl = new Label(labels[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[idx] = nv.floatValue(); - lbl.setText(labels[idx] + ": " + String.format("%.2f", nv.floatValue())); publish.run(); }); - lightDynamicContent.getChildren().addAll(lbl, sl); + lightDynamicContent.getChildren().addAll(lbl, withField(sl, "%.2f")); } Button delBtn = new Button("🗑 Löschen"); @@ -1745,9 +1882,15 @@ public class EditorApp extends Application { private VBox buildWaterPanel() { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); + + waterCurrentHeightLabel = new Label("Höhe: –"); + waterCurrentHeightLabel.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + inner.getChildren().addAll( sectionTitle("Wasseroberflächen"), new Separator(), + waterCurrentHeightLabel, + new Separator(), sectionTitle("Gewählte Fläche"), new Separator()); @@ -1757,16 +1900,13 @@ 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(), - waterHintLabel, - styledHint("Linksklick → Becken markieren / auswählen"), - styledHint("Rechtsklick → Auswahl aufheben"), + styledHint("L-Klick → Polygon-Punkt setzen"), + styledHint("R-Klick → letzten Punkt entfernen"), + styledHint("Leertaste → Höhe vom Terrain übernehmen"), + styledHint("Erster Punkt bestimmt die Höhe"), + styledHint("ESC → Polygon abbrechen"), styledHint("Entf → Fläche löschen")); ScrollPane scroll = new ScrollPane(inner); @@ -1781,53 +1921,30 @@ public class EditorApp extends Application { return panel; } - private VBox buildRiverPanel() { + private VBox buildWaterfallPanel() { VBox inner = new VBox(10); inner.setPadding(new Insets(10)); - // Width slider — setzt die Breite des NÄCHSTEN platzierten Punktes Label widthTitle = sectionTitle("Breite (nächster Punkt)"); - Label widthVal = new Label(String.format("%.1f m", input.riverNewWidth)); - widthVal.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); - Slider widthSlider = new Slider(8.0, 40.0, input.riverNewWidth); + Slider widthSlider = new Slider(8.0, 40.0, input.waterfallNewWidth); 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)); - }); + widthSlider.valueProperty().addListener((o, ov, nv) -> input.waterfallNewWidth = nv.floatValue()); - // 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); + undoBtn.setOnAction(e -> input.undoWaterfallPointRequested = true); inner.getChildren().addAll( - sectionTitle("Flüsse"), + sectionTitle("Wasserfall"), new Separator(), - widthTitle, widthSlider, widthVal, - new Separator(), - typeTitle, rbRiver, rbWaterfall, + widthTitle, withField(widthSlider, "%.1f"), new Separator(), undoBtn, new Separator(), styledHint("L-Klick → Punkt setzen"), - styledHint("Breite ändern → nächsten Punkt setzen"), - styledHint("R-Klick → Fluss abschließen"), + styledHint("R-Klick → Wasserfall abschließen"), styledHint("Backspace → letzten Punkt löschen")); ScrollPane scroll = new ScrollPane(inner); @@ -1842,12 +1959,12 @@ public class EditorApp extends Application { return panel; } - private void updateRiverPanel(String info) { - if (input.activeLayer != SharedInput.LAYER_RIVERS) return; - root.setRight(info != null ? buildRiverSelectionPanel(info) : buildRiverPanel()); + private void updateWaterfallPanel(String info) { + if (input.activeLayer != SharedInput.LAYER_WATERFALL) return; + root.setRight(info != null ? buildWaterfallSelectionPanel(info) : buildWaterfallPanel()); } - private VBox buildRiverSelectionPanel(String info) { + private VBox buildWaterfallSelectionPanel(String info) { // info = "idx|numPoints|totalLengthM" String[] parts = info.split("\\|"); int numPts = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; @@ -1856,25 +1973,23 @@ public class EditorApp extends Application { 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"); + Button deleteBtn = new Button("Wasserfall löschen"); deleteBtn.setMaxWidth(Double.MAX_VALUE); deleteBtn.setStyle("-fx-background-color: #c0392b; -fx-text-fill: white;"); - deleteBtn.setOnAction(e -> input.deleteRiverRequested = true); + deleteBtn.setOnAction(e -> input.deleteWaterfallRequested = true); inner.getChildren().addAll( - title, + sectionTitle("Wasserfall selektiert"), new Separator(), infoLabel, new Separator(), deleteBtn, new Separator(), styledHint("L-Klick ins Leere → abwählen"), - styledHint("L-Klick → anderen Fluss wählen")); + styledHint("L-Klick → anderen Wasserfall wählen")); ScrollPane scroll = new ScrollPane(inner); scroll.setFitToWidth(true); @@ -1888,6 +2003,11 @@ public class EditorApp extends Application { return panel; } + private void updateWaterHeightDisplay(float h) { + if (waterCurrentHeightLabel != null) + waterCurrentHeightLabel.setText(String.format("Höhe: %.2f m", h)); + } + private void updateWaterPanel(String info) { if (waterDynamicContent == null) return; waterDynamicContent.getChildren().clear(); @@ -1899,32 +2019,36 @@ public class EditorApp extends Application { return; } - // Format: "idx|seedX|seedZ|waterHeight|cellCount" + // Format: "idx|waterHeight|pointCount|flowDegrees" String[] p = info.split("\\|", -1); - if (p.length < 5) return; + if (p.length < 3) return; try { - float seedX = Float.parseFloat(p[1]); - float seedZ = Float.parseFloat(p[2]); - float waterHeight = Float.parseFloat(p[3]); - int cellCount = Integer.parseInt(p[4]); + float waterHeight = Float.parseFloat(p[1]); + int pointCount = Integer.parseInt(p[2]); + float flowDeg = p.length >= 4 ? Float.parseFloat(p[3]) : 0f; - Label infoLabel = new Label(String.format( - "Seed %.1f / %.1f | %d Pixel", seedX, seedZ, cellCount)); + Label infoLabel = new Label(String.format("Punkte: %d | Höhe: %.2f m", pointCount, waterHeight)); infoLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); - waterDynamicContent.getChildren().addAll(infoLabel, new Separator()); - float[] curH = {waterHeight}; - Label heightLabel = new Label(String.format("Höhe: %.2f", waterHeight)); + Label heightLabel = new Label("Höhe (m)"); heightLabel.setStyle("-fx-font-size: 11;"); Slider heightSlider = new Slider(-50, 500, waterHeight); - heightSlider.setBlockIncrement(0.5); + heightSlider.setBlockIncrement(0.1); 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])); + heightSlider.valueProperty().addListener((o, ov, nv) -> input.pendingWaterHeight.set(nv.floatValue())); + + Label flowLabel = new Label("Fließrichtung"); + flowLabel.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + Label flowDegLabel = new Label(String.format("%.0f°", flowDeg)); + flowDegLabel.setStyle("-fx-font-size: 11;"); + + Spinner flowSpinner = new Spinner<>(0, 359, Math.round(flowDeg)); + flowSpinner.setEditable(true); + flowSpinner.setMaxWidth(Double.MAX_VALUE); + flowSpinner.valueProperty().addListener((o, ov, nv) -> { + flowDegLabel.setText(nv + "°"); + input.pendingWaterFlowDegrees.set(nv.floatValue()); }); Button delBtn = new Button("Löschen"); @@ -1933,7 +2057,12 @@ public class EditorApp extends Application { delBtn.setOnAction(e -> input.deleteWaterRequested = true); waterDynamicContent.getChildren().addAll( - heightLabel, heightSlider, new Separator(), delBtn); + infoLabel, new Separator(), + heightLabel, withField(heightSlider, "%.2f"), + new Separator(), + flowLabel, flowSpinner, + new Separator(), + delBtn); } catch (NumberFormatException ignored) {} } @@ -2134,37 +2263,37 @@ public class EditorApp extends Application { /** Baut einen beschrifteten Slider für float[]-Werte. */ private HBox buildSlider(String name, float[] arr, int idx, double min, double max, double step, Runnable publish) { - Label lbl = new Label(name + ": " + String.format("%.2f", arr[idx])); + Label lbl = new Label(name); lbl.setStyle("-fx-font-size: 10;"); lbl.setMinWidth(90); Slider sl = new Slider(min, max, arr[idx]); sl.setShowTickLabels(false); sl.setBlockIncrement(step); - HBox.setHgrow(sl, Priority.ALWAYS); sl.valueProperty().addListener((o, ov, nv) -> { arr[idx] = nv.floatValue(); - lbl.setText(name + ": " + String.format("%.2f", nv.floatValue())); publish.run(); }); - return new HBox(4, lbl, sl); + HBox slRow = withField(sl, "%.2f"); + HBox.setHgrow(slRow, Priority.ALWAYS); + return new HBox(4, lbl, slRow); } /** Baut einen beschrifteten Slider für int[]-Werte. */ private HBox buildSlider(String name, int[] arr, int idx, double min, double max, double step, Runnable publish) { - Label lbl = new Label(name + ": " + arr[idx]); + Label lbl = new Label(name); lbl.setStyle("-fx-font-size: 10;"); lbl.setMinWidth(90); Slider sl = new Slider(min, max, arr[idx]); sl.setShowTickLabels(false); sl.setBlockIncrement(step); - HBox.setHgrow(sl, Priority.ALWAYS); sl.valueProperty().addListener((o, ov, nv) -> { arr[idx] = nv.intValue(); - lbl.setText(name + ": " + nv.intValue()); publish.run(); }); - return new HBox(4, lbl, sl); + HBox slRow = withField(sl, "%.0f"); + HBox.setHgrow(slRow, Priority.ALWAYS); + return new HBox(4, lbl, slRow); } private static Label paramLabel(String text) { @@ -2512,17 +2641,8 @@ public class EditorApp extends Application { slider.setShowTickMarks(true); slider.setShowTickLabels(true); slider.setMajorTickUnit((param.getMax() - param.getMin()) / 2); - slider.setMaxWidth(Double.MAX_VALUE); - - Label valueLabel = new Label(String.format("%.3f", param.getValue())); - valueLabel.setStyle("-fx-text-fill: #444; -fx-font-size: 11;"); - - slider.valueProperty().addListener((obs, oldV, newV) -> { - param.setValue(newV.doubleValue()); - valueLabel.setText(String.format("%.3f", newV.doubleValue())); - }); - - panel.getChildren().add(new VBox(3, name, slider, valueLabel)); + slider.valueProperty().addListener((obs, oldV, newV) -> param.setValue(newV.doubleValue())); + panel.getChildren().add(new VBox(3, name, withField(slider, "%.3f"))); } // Textur-Slot-Konfigurator (nur beim TextureTool) @@ -2854,12 +2974,25 @@ public class EditorApp extends Application { private VBox buildAssetPanel() { VBox panel = new VBox(6); panel.setPadding(new Insets(8)); - panel.setPrefWidth(210); - panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 1 0 0;"); + panel.setPrefWidth(420); + panel.setStyle("-fx-background-color: #f0f0f0;"); Label title = new Label("Assets"); title.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + ToggleButton pinBtn = new ToggleButton("📌"); + pinBtn.setStyle("-fx-font-size: 10; -fx-background-radius: 4; -fx-padding: 2 5 2 5;"); + pinBtn.setTooltip(new Tooltip("Panel geöffnet lassen")); + pinBtn.selectedProperty().addListener((obs, old, nv) -> { + assetOverlayPinned = nv; + if (nv && assetCloseDelay != null) assetCloseDelay.stop(); + }); + + Region titleSpacer = new Region(); + HBox.setHgrow(titleSpacer, Priority.ALWAYS); + HBox titleBar = new HBox(4, title, titleSpacer, pinBtn); + titleBar.setAlignment(Pos.CENTER_LEFT); + assetTreeRoot = new TreeItem<>("Projekt"); assetTreeRoot.setExpanded(true); populateAssetTree(assetTreeRoot); @@ -2907,8 +3040,9 @@ public class EditorApp extends Application { } if (cat == modelsNode) { - if (relPath.endsWith(".j3o") && isSkeletal) { - // Skelett-Modell → Animationseditor anbieten; normaler Doppelklick → platzieren + if (relPath.endsWith(".j3o")) { + openModelEditor(relPath, p); + return; } input.pendingModelPath = relPath; input.activeLayer = SharedInput.LAYER_OBJECTS; @@ -2958,10 +3092,75 @@ public class EditorApp extends Application { HBox.setHgrow(importBtn, Priority.ALWAYS); bottomBar.setMaxWidth(Double.MAX_VALUE); - panel.getChildren().addAll(title, tree, bottomBar); + panel.getChildren().addAll(titleBar, tree, bottomBar); return panel; } + // ── Asset-Overlay ───────────────────────────────────────────────────────── + + private void setupAssetOverlay() { + assetCloseDelay = new javafx.animation.PauseTransition(javafx.util.Duration.millis(350)); + assetCloseDelay.setOnFinished(e -> closeAssetOverlay()); + + assetPanel.setMaxWidth(420); + assetPanel.setMaxHeight(Double.MAX_VALUE); + assetPanel.setTranslateX(-420); + StackPane.setAlignment(assetPanel, Pos.TOP_LEFT); + assetPanel.setEffect(new javafx.scene.effect.DropShadow( + 14, 5, 0, javafx.scene.paint.Color.rgb(0, 0, 0, 0.55))); + + assetTabBtn = new Button("›"); + assetTabBtn.setPrefSize(18, 60); + assetTabBtn.setStyle( + "-fx-background-color: #4a4a5a; -fx-text-fill: #ddd; -fx-font-size: 14; " + + "-fx-background-radius: 0 5 5 0; -fx-border-width: 0; -fx-cursor: hand; -fx-padding: 0;"); + StackPane.setAlignment(assetTabBtn, Pos.CENTER_LEFT); + assetTabBtn.setTranslateX(0); + + assetPanel.translateXProperty().addListener((obs, old, nv) -> + assetTabBtn.setTranslateX(nv.doubleValue() + 420)); + + assetTabBtn.setOnMouseClicked(e -> { + assetCloseDelay.stop(); + if (assetOverlayOpen) closeAssetOverlay(); + else openAssetOverlay(); + }); + assetTabBtn.setOnMouseEntered(e -> { + assetCloseDelay.stop(); + if (!assetOverlayOpen) openAssetOverlay(); + }); + assetTabBtn.setOnMouseExited(e -> { + if (!assetOverlayPinned) assetCloseDelay.playFromStart(); + }); + + assetPanel.setOnMouseEntered(e -> assetCloseDelay.stop()); + assetPanel.setOnMouseExited(e -> { + if (!assetOverlayPinned) assetCloseDelay.playFromStart(); + }); + + worldViewport.getChildren().addAll(assetPanel, assetTabBtn); + } + + private void openAssetOverlay() { + if (assetOverlayOpen) return; + assetOverlayOpen = true; + assetTabBtn.setText("‹"); + javafx.animation.TranslateTransition tt = new javafx.animation.TranslateTransition( + javafx.util.Duration.millis(200), assetPanel); + tt.setToX(0); + tt.play(); + } + + private void closeAssetOverlay() { + if (!assetOverlayOpen) return; + assetOverlayOpen = false; + assetTabBtn.setText("›"); + javafx.animation.TranslateTransition tt = new javafx.animation.TranslateTransition( + javafx.util.Duration.millis(200), assetPanel); + tt.setToX(-420); + tt.play(); + } + // ── Asset-Tree-Cell (DnD + Kontextmenü) ────────────────────────────────── private TreeCell buildAssetCell() { @@ -3639,6 +3838,349 @@ public class EditorApp extends Application { } } + // ── Modell-Editor ──────────────────────────────────────────────────────── + + private void openModelEditor(String relPath, java.nio.file.Path absolutePath) { + modelEditorCurrentPath = relPath; + + // Meta laden + de.blight.common.ModelMeta meta = absolutePath != null && absolutePath.toFile().exists() + ? de.blight.common.ModelMetaIO.load(absolutePath) + : de.blight.common.ModelMeta.defaults(relPath.replaceAll(".*/", "")); + + // JME-State anweisen + input.modelEditorScaleX = meta.scaleX(); + input.modelEditorScaleY = meta.scaleY(); + input.modelEditorScaleZ = meta.scaleZ(); + input.modelEditorPivotY = meta.pivotOffsetY(); + input.modelEditorOpenPath = relPath; + input.activeLayer = SharedInput.LAYER_MODEL_EDITOR; + + root.setRight(buildModelEditorPanel(relPath, absolutePath, meta)); + setStatus("Modell-Editor: " + relPath); + } + + private VBox buildModelEditorPanel(String relPath, + java.nio.file.Path absolutePath, + de.blight.common.ModelMeta meta) { + VBox panel = new VBox(8); + panel.setPadding(new javafx.geometry.Insets(10)); + panel.setPrefWidth(300); + panel.setStyle("-fx-background-color: #2a2a3e;"); + + // ── Titel ──────────────────────────────────────────────────────────── + Label title = new Label("Modell-Editor"); + title.setStyle("-fx-font-weight:bold; -fx-font-size:14; -fx-text-fill:#ddd;"); + + // ── Name ────────────────────────────────────────────────────────────── + Label nameLabel = new Label("Name:"); + nameLabel.setStyle("-fx-text-fill:#aaa;"); + TextField nameTF = new TextField(meta.name()); + nameTF.setStyle("-fx-background-color:#3a3a4e; -fx-text-fill:#eee;"); + + // ── Kategorie / Unterordner ──────────────────────────────────────────── + Label catLabel = new Label("Kategorie / Unterordner:"); + catLabel.setStyle("-fx-text-fill:#aaa;"); + ComboBox catCB = new ComboBox<>(); + catCB.setEditable(true); + catCB.getItems().addAll("", "vegetation/trees", "vegetation/bushes", + "vegetation/rocks", "buildings", "props", "characters", + "terrain", "effects", "misc"); + catCB.setValue(meta.category()); + catCB.setMaxWidth(Double.MAX_VALUE); + catCB.setStyle("-fx-background-color:#3a3a4e;"); + + // ── Tags ────────────────────────────────────────────────────────────── + Label tagsLabel = new Label("Tags (kommagetrennt):"); + tagsLabel.setStyle("-fx-text-fill:#aaa;"); + TextField tagsTF = new TextField(meta.tags()); + tagsTF.setStyle("-fx-background-color:#3a3a4e; -fx-text-fill:#eee;"); + tagsTF.setPromptText("z.B. tree, oak, large"); + + // ── Skalierung ─────────────────────────────────────────────────────── + Label scaleTitle = new Label("Skalierung:"); + scaleTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); + + modelEditorUniformCB = new CheckBox("Gleichmäßig skalieren (X=Y=Z)"); + modelEditorUniformCB.setSelected(meta.uniformScale()); + modelEditorUniformCB.setStyle("-fx-text-fill:#ccc;"); + + modelEditorSpinX = buildScaleSpinner(meta.scaleX()); + modelEditorSpinY = buildScaleSpinner(meta.scaleY()); + modelEditorSpinZ = buildScaleSpinner(meta.scaleZ()); + + GridPane scaleGrid = new GridPane(); + scaleGrid.setHgap(6); scaleGrid.setVgap(4); + addLabeledRow(scaleGrid, 0, "X:", modelEditorSpinX); + addLabeledRow(scaleGrid, 1, "Y:", modelEditorSpinY); + addLabeledRow(scaleGrid, 2, "Z:", modelEditorSpinZ); + + // Uniform-Listener + modelEditorSpinX.valueProperty().addListener((o, ov, nv) -> { + if (modelEditorSuppressListeners) return; + if (modelEditorUniformCB.isSelected()) { + modelEditorSuppressListeners = true; + modelEditorSpinY.getValueFactory().setValue(nv); + modelEditorSpinZ.getValueFactory().setValue(nv); + modelEditorSuppressListeners = false; + } + pushScaleToJme(); + }); + modelEditorSpinY.valueProperty().addListener((o, ov, nv) -> { + if (modelEditorSuppressListeners) return; + if (modelEditorUniformCB.isSelected()) { + modelEditorSuppressListeners = true; + modelEditorSpinX.getValueFactory().setValue(nv); + modelEditorSpinZ.getValueFactory().setValue(nv); + modelEditorSuppressListeners = false; + } + pushScaleToJme(); + }); + modelEditorSpinZ.valueProperty().addListener((o, ov, nv) -> { + if (modelEditorSuppressListeners) return; + if (modelEditorUniformCB.isSelected()) { + modelEditorSuppressListeners = true; + modelEditorSpinX.getValueFactory().setValue(nv); + modelEditorSpinY.getValueFactory().setValue(nv); + modelEditorSuppressListeners = false; + } + pushScaleToJme(); + }); + modelEditorUniformCB.setOnAction(e -> { + if (modelEditorUniformCB.isSelected()) { + // Y und Z auf X-Wert angleichen + double xVal = modelEditorSpinX.getValue(); + modelEditorSuppressListeners = true; + modelEditorSpinY.getValueFactory().setValue(xVal); + modelEditorSpinZ.getValueFactory().setValue(xVal); + modelEditorSuppressListeners = false; + pushScaleToJme(); + } + }); + + // ── Maße (read-only, von JME aktualisiert) ─────────────────────────── + modelEditorDimLabel = new Label("Maße: – × – × – m"); + modelEditorDimLabel.setStyle("-fx-text-fill:#8cf; -fx-font-family:monospace;"); + + // ── Pivot & Platzierungs-Versatz ───────────────────────────────────── + Label offsetTitle = new Label("Versatz:"); + offsetTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); + + Spinner pivotSpin = buildOffsetSpinner(meta.pivotOffsetY()); + pivotSpin.valueProperty().addListener((o, ov, nv) -> { + input.modelEditorPivotY = nv.floatValue(); + input.modelEditorPivotChanged = true; + }); + + Spinner placeSpin = buildOffsetSpinner(meta.placementOffsetY()); + + GridPane offsetGrid = new GridPane(); + offsetGrid.setHgap(6); offsetGrid.setVgap(4); + addLabeledRow(offsetGrid, 0, "Pivot Y:", pivotSpin); + addLabeledRow(offsetGrid, 1, "Platzierung Y:", placeSpin); + + // ── Standardwerte beim Platzieren ──────────────────────────────────── + Label defaultsTitle = new Label("Standard beim Platzieren:"); + defaultsTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); + + CheckBox solidCB = new CheckBox("Kollision (solid)"); + CheckBox castCB = new CheckBox("Schatten werfen"); + CheckBox receiveCB = new CheckBox("Schatten empfangen"); + solidCB.setSelected(meta.solid()); + castCB.setSelected(meta.castShadow()); + receiveCB.setSelected(meta.receiveShadow()); + for (CheckBox cb : new CheckBox[]{solidCB, castCB, receiveCB}) + cb.setStyle("-fx-text-fill:#ccc;"); + + // ── Zufällige Skalierung beim Multi-Place ───────────────────────────── + Label rndTitle = new Label("Zufallsskalierung (Multi-Place):"); + rndTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); + + Spinner rndMinSpin = buildScaleSpinner(meta.randomScaleMin()); + Spinner rndMaxSpin = buildScaleSpinner(meta.randomScaleMax()); + + GridPane rndGrid = new GridPane(); + rndGrid.setHgap(6); rndGrid.setVgap(4); + addLabeledRow(rndGrid, 0, "Min:", rndMinSpin); + addLabeledRow(rndGrid, 1, "Max:", rndMaxSpin); + + // ── Buttons ─────────────────────────────────────────────────────────── + Button saveBtn = new Button("💾 Speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-background-color:#2e7d32; -fx-text-fill:#fff; -fx-font-weight:bold;"); + + Button placeBtn = new Button("⊕ Platzieren"); + placeBtn.setMaxWidth(Double.MAX_VALUE); + placeBtn.setStyle("-fx-background-color:#1565c0; -fx-text-fill:#fff;"); + + Button closeBtn = new Button("✕ Schließen"); + closeBtn.setMaxWidth(Double.MAX_VALUE); + closeBtn.setStyle("-fx-background-color:#555; -fx-text-fill:#ccc;"); + + saveBtn.setOnAction(e -> saveModelMeta( + relPath, absolutePath, + nameTF.getText().trim(), + catCB.getValue() != null ? catCB.getValue().trim() : "", + tagsTF.getText().trim(), + (float)(double) modelEditorSpinX.getValue(), + (float)(double) modelEditorSpinY.getValue(), + (float)(double) modelEditorSpinZ.getValue(), + modelEditorUniformCB.isSelected(), + (float)(double) pivotSpin.getValue(), + (float)(double) placeSpin.getValue(), + solidCB.isSelected(), + castCB.isSelected(), + receiveCB.isSelected(), + (float)(double) rndMinSpin.getValue(), + (float)(double) rndMaxSpin.getValue())); + + placeBtn.setOnAction(e -> { + input.modelEditorCloseRequest = true; + input.pendingModelPath = modelEditorCurrentPath; + input.activeLayer = SharedInput.LAYER_OBJECTS; + if (objPlaceBtn != null) objPlaceBtn.setSelected(true); + root.setRight(buildObjectPlacePanel()); + }); + + closeBtn.setOnAction(e -> { + input.modelEditorCloseRequest = true; + input.activeLayer = 0; + if (baseBtn != null) baseBtn.setSelected(true); + root.setRight(toolPanel); + }); + + panel.getChildren().addAll( + title, + new Separator(), + nameLabel, nameTF, + catLabel, catCB, + tagsLabel, tagsTF, + new Separator(), + scaleTitle, modelEditorUniformCB, scaleGrid, + modelEditorDimLabel, + new Separator(), + offsetTitle, offsetGrid, + new Separator(), + defaultsTitle, solidCB, castCB, receiveCB, + new Separator(), + rndTitle, rndGrid, + new Separator(), + saveBtn, placeBtn, closeBtn + ); + return panel; + } + + private Spinner buildScaleSpinner(double initial) { + SpinnerValueFactory.DoubleSpinnerValueFactory f = + new SpinnerValueFactory.DoubleSpinnerValueFactory(0.001, 1000.0, initial, 0.1); + f.setConverter(new javafx.util.converter.DoubleStringConverter() { + @Override public Double fromString(String s) { + try { return Double.parseDouble(s.replace(',', '.')); } + catch (Exception ex) { return initial; } + } + }); + Spinner s = new Spinner<>(f); + s.setEditable(true); + s.setMaxWidth(Double.MAX_VALUE); + s.setStyle("-fx-background-color:#3a3a4e;"); + return s; + } + + private Spinner buildOffsetSpinner(double initial) { + SpinnerValueFactory.DoubleSpinnerValueFactory f = + new SpinnerValueFactory.DoubleSpinnerValueFactory(-100.0, 100.0, initial, 0.1); + Spinner s = new Spinner<>(f); + s.setEditable(true); + s.setMaxWidth(Double.MAX_VALUE); + s.setStyle("-fx-background-color:#3a3a4e;"); + return s; + } + + private void addLabeledRow(GridPane grid, int row, String labelText, javafx.scene.Node ctrl) { + Label l = new Label(labelText); + l.setStyle("-fx-text-fill:#aaa;"); + l.setMinWidth(90); + grid.add(l, 0, row); + grid.add(ctrl, 1, row); + javafx.scene.layout.GridPane.setHgrow(ctrl, javafx.scene.layout.Priority.ALWAYS); + } + + private void pushScaleToJme() { + if (modelEditorSpinX == null) return; + input.modelEditorScaleX = (float)(double) modelEditorSpinX.getValue(); + input.modelEditorScaleY = (float)(double) modelEditorSpinY.getValue(); + input.modelEditorScaleZ = (float)(double) modelEditorSpinZ.getValue(); + input.modelEditorScaleChanged = true; + } + + private void updateModelEditorDimensions(float w, float h, float d) { + if (modelEditorDimLabel != null) + modelEditorDimLabel.setText(String.format( + "Maße: B %.2f m × H %.2f m × T %.2f m", w, h, d)); + } + + private void saveModelMeta(String relPath, + java.nio.file.Path absolutePath, + String name, String category, String tags, + float sx, float sy, float sz, boolean uniform, + float pivotY, float placeY, + boolean solid, boolean cast, boolean receive, + float rndMin, float rndMax) { + de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta( + name, category, tags, sx, sy, sz, uniform, + pivotY, placeY, solid, cast, receive, rndMin, rndMax); + + if (absolutePath == null || !absolutePath.toFile().exists()) { + setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert"); + return; + } + + // ── Meta-Datei speichern ────────────────────────────────────────────── + try { + de.blight.common.ModelMetaIO.save(meta, absolutePath); + } catch (java.io.IOException ex) { + setStatus("Fehler beim Speichern der Meta-Datei: " + ex.getMessage()); + return; + } + + // ── Unterordner: Datei verschieben wenn Kategorie gesetzt ────────────── + if (!category.isEmpty()) { + java.nio.file.Path newDir = ASSET_ROOT.resolve("Models") + .resolve(java.nio.file.Path.of(category.replace('/', java.io.File.separatorChar))); + java.nio.file.Path newJ3o = newDir.resolve( + (name.isEmpty() ? absolutePath.getFileName().toString() + : name.replaceAll("[\\\\/:*?\"<>|]", "_") + ".j3o")); + + if (!newJ3o.equals(absolutePath)) { + try { + java.nio.file.Files.createDirectories(newDir); + java.nio.file.Path metaSrc = de.blight.common.ModelMetaIO.metaPath(absolutePath); + java.nio.file.Files.move(absolutePath, newJ3o, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + if (java.nio.file.Files.exists(metaSrc)) { + java.nio.file.Files.move(metaSrc, + de.blight.common.ModelMetaIO.metaPath(newJ3o), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + // Pfad für Platzierungsmodus aktualisieren + modelEditorCurrentPath = ASSET_ROOT.relativize(newJ3o) + .toString().replace('\\', '/'); + setStatus("Gespeichert und verschoben → " + modelEditorCurrentPath); + } catch (java.io.IOException ex) { + setStatus("Meta gespeichert, Verschieben fehlgeschlagen: " + ex.getMessage()); + return; + } + } else { + setStatus("Meta gespeichert: " + relPath); + } + } else { + setStatus("Meta gespeichert: " + relPath); + } + + // Asset-Tree aktualisieren + input.refreshAssets = true; + } + // ── Zentraler Bereich: JME3-Viewport ──────────────────────────────────── private StackPane buildViewport() { @@ -3666,17 +4208,31 @@ public class EditorApp extends Application { resizeDebounce.playFromStart(); }); - viewport.setOnMousePressed(e -> { - viewport.requestFocus(); - if (e.getButton() == MouseButton.MIDDLE) { + // Camera-Rotation: Filter am StackPane (Capture-Phase) → feuert immer, + // auch wenn Overlay-Nodes (Tab-Button, Asset-Panel) das Event abfangen würden. + pane.addEventFilter(javafx.scene.input.MouseEvent.MOUSE_PRESSED, e -> { + if (e.getButton() == MouseButton.MIDDLE + || (e.isPrimaryButtonDown() && e.isSecondaryButtonDown())) { prevDragX = e.getX(); prevDragY = e.getY(); } + }); + pane.addEventFilter(javafx.scene.input.MouseEvent.MOUSE_DRAGGED, e -> { + boolean bothDown = e.isPrimaryButtonDown() && e.isSecondaryButtonDown(); + if (e.isMiddleButtonDown() || bothDown) { + double dx = e.getX() - prevDragX; + double dy = e.getY() - prevDragY; + input.addMouseDelta((int) dx, (int) dy); + prevDragX = e.getX(); prevDragY = e.getY(); + if (bothDown) stopEditTimer(); + } + }); + + viewport.setOnMousePressed(e -> { + viewport.requestFocus(); boolean bothDown = e.isPrimaryButtonDown() && e.isSecondaryButtonDown(); if (isObjectMode()) { - if (e.getButton() == MouseButton.MIDDLE || bothDown) { - prevDragX = e.getX(); prevDragY = e.getY(); - } else if (e.getButton() == MouseButton.PRIMARY) { + if (e.getButton() == MouseButton.PRIMARY && !bothDown) { input.objectClickQueue.offer( new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), false, e.isShiftDown())); objDragPrevX = e.getX(); objDragPrevY = e.getY(); @@ -3685,20 +4241,19 @@ public class EditorApp extends Application { return; } - if (input.activeLayer == SharedInput.LAYER_RIVERS) { + if (input.activeLayer == SharedInput.LAYER_WATERFALL) { if (e.getButton() == MouseButton.PRIMARY) { - input.riverClickQueue.offer( - new SharedInput.RiverClick((float)e.getX(), (float)e.getY(), false)); + input.waterfallClickQueue.offer( + new SharedInput.WaterfallClick((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)); + input.waterfallClickQueue.offer( + new SharedInput.WaterfallClick((float)e.getX(), (float)e.getY(), true)); } return; } if (bothDown) { stopEditTimer(); - prevDragX = e.getX(); prevDragY = e.getY(); } else if (e.getButton() == MouseButton.PRIMARY) { editPressX = e.getX(); editPressY = e.getY(); editPressAction = +1; submitEdit(editPressX, editPressY, editPressAction); @@ -3711,15 +4266,9 @@ public class EditorApp extends Application { }); viewport.setOnMouseDragged(e -> { - boolean bothDown = e.isPrimaryButtonDown() && e.isSecondaryButtonDown(); - if (isObjectMode()) { - if (e.isMiddleButtonDown() || bothDown) { - double dx = e.getX() - prevDragX; - double dy = e.getY() - prevDragY; - input.addMouseDelta((int) dx, (int) dy); - prevDragX = e.getX(); prevDragY = e.getY(); - } else if (objDragging && e.isPrimaryButtonDown()) { + if (objDragging && e.isPrimaryButtonDown() + && !e.isSecondaryButtonDown() && !e.isMiddleButtonDown()) { float dx = (float)(e.getX() - objDragPrevX); float dy = (float)(e.getY() - objDragPrevY); input.objectDragQueue.offer(new SharedInput.ObjectDrag(dx, dy)); @@ -3728,13 +4277,7 @@ public class EditorApp extends Application { return; } - if (e.isMiddleButtonDown() || bothDown) { - stopEditTimer(); - double dx = e.getX() - prevDragX; - double dy = e.getY() - prevDragY; - input.addMouseDelta((int) dx, (int) dy); - prevDragX = e.getX(); prevDragY = e.getY(); - } else if (e.isPrimaryButtonDown() || e.isSecondaryButtonDown()) { + if (e.isPrimaryButtonDown() || e.isSecondaryButtonDown()) { editPressX = e.getX(); editPressY = e.getY(); input.mouseScreenX = (float) e.getX(); @@ -3793,6 +4336,8 @@ public class EditorApp extends Application { switch (input.activeLayer) { case 0 -> input.editQueue.offer(new SharedInput.TerrainEdit((float) x, (float) y, action)); case 3 -> input.grassEditQueue.offer(new SharedInput.GrassEdit((float) x, (float) y, action)); + case SharedInput.LAYER_GRASS_VERTEX -> + input.grassVertexEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action)); case 4 -> input.textureEditQueue.offer(new SharedInput.TextureEdit((float) x, (float) y, action)); case SharedInput.LAYER_LIGHTS -> input.lightClickQueue.offer(new SharedInput.LightClick((float) x, (float) y, action < 0)); @@ -3802,8 +4347,10 @@ public class EditorApp extends Application { input.waterClickQueue.offer(new SharedInput.WaterClick((float) x, (float) y, action < 0)); case SharedInput.LAYER_SOUND_AREAS -> input.soundAreaClickQueue.offer(new SharedInput.SoundAreaClick((float) x, (float) y, action < 0)); - case SharedInput.LAYER_MUSIC_AREAS -> - input.musicAreaClickQueue.offer(new SharedInput.MusicAreaClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_AREAS -> + input.areaClickQueue.offer(new SharedInput.AreaClick((float) x, (float) y, action < 0)); + case SharedInput.LAYER_LOCATION_ZONES -> + input.locationZoneClickQueue.offer(new SharedInput.LocationZoneClick((float) x, (float) y, action < 0)); case SharedInput.LAYER_PLAY_TOOL -> { if (action > 0) // only left-click sets spawn input.playToolClickQueue.offer(new SharedInput.PlayToolClick((float) x, (float) y)); @@ -4001,7 +4548,9 @@ public class EditorApp extends Application { case SHIFT -> input.shiftHeld = pressed; case ESCAPE -> { if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS - || input.activeLayer == SharedInput.LAYER_MUSIC_AREAS)) + || input.activeLayer == SharedInput.LAYER_AREAS + || input.activeLayer == SharedInput.LAYER_LOCATION_ZONES + || input.activeLayer == SharedInput.LAYER_WATER)) input.cancelZoneDrawing = true; } case DELETE -> { @@ -4011,13 +4560,21 @@ public class EditorApp extends Application { input.deleteSelectedRequested = true; else if (input.activeLayer == SharedInput.LAYER_SOUND_AREAS) input.deleteSoundAreaRequested = true; - else if (input.activeLayer == SharedInput.LAYER_MUSIC_AREAS) - input.deleteMusicAreaRequested = true; + else if (input.activeLayer == SharedInput.LAYER_AREAS) + input.deleteAreaRequested = true; + else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES) + input.deleteLocationZoneRequested = true; + else if (input.activeLayer == SharedInput.LAYER_WATER) + input.deleteWaterRequested = true; } } case BACK_SPACE -> { - if (pressed && input.activeLayer == SharedInput.LAYER_RIVERS) - input.undoRiverPointRequested = true; + if (pressed && input.activeLayer == SharedInput.LAYER_WATERFALL) + input.undoWaterfallPointRequested = true; + } + case SPACE -> { + if (pressed && input.activeLayer == SharedInput.LAYER_WATER) + input.waterSampleHeightRequested = true; } case F1 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> baseBtn.fire()); } case F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); } @@ -4029,11 +4586,16 @@ public class EditorApp extends Application { } case F6 -> { if (pressed) { - if ("world".equals(currentTool)) Platform.runLater(() -> objPlaceBtn.fire()); - else onF6.run(); + if ("world".equals(currentTool) || "objecteditor".equals(currentTool)) { + if (objPlaceBtn != null) Platform.runLater(() -> objPlaceBtn.fire()); + } else onF6.run(); + } + } + case F7 -> { + if (pressed && ("world".equals(currentTool) || "objecteditor".equals(currentTool))) { + if (objEditBtn != null) Platform.runLater(() -> objEditBtn.fire()); } } - case F7 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> objEditBtn.fire()); } } } @@ -4128,13 +4690,12 @@ public class EditorApp extends Application { } }); - Label volLbl = new Label("Lautstärke: " + String.format("%.2f", vol[0])); + Label volLbl = new Label("Lautstärke"); volLbl.setStyle("-fx-font-size: 11;"); Slider volSlider = new Slider(0, 1, vol[0]); volSlider.setShowTickLabels(false); volSlider.valueProperty().addListener((o, ov, nv) -> { vol[0] = nv.floatValue(); - volLbl.setText("Lautstärke: " + String.format("%.2f", nv.floatValue())); sendSoundAreaUpdate(idx, soundPath[0], vol[0], cf[0]); }); @@ -4151,7 +4712,7 @@ public class EditorApp extends Application { delBtn.setOnAction(e -> input.deleteSoundAreaRequested = true); soundAreaDynamicContent.getChildren().addAll( - soundLabel, browseBtn, new Separator(), volLbl, volSlider, xfadeCB, + soundLabel, browseBtn, new Separator(), volLbl, withField(volSlider, "%.2f"), xfadeCB, new Separator(), delBtn); } catch (NumberFormatException ignored) {} @@ -4174,23 +4735,25 @@ public class EditorApp extends Application { // ── Musik-Bereich-Panel ─────────────────────────────────────────────────── - private VBox buildMusicAreaPanel() { + private VBox buildAreaPanel() { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); inner.getChildren().addAll( - sectionTitle("Musik-Bereiche"), + sectionTitle("Bereiche"), styledHint("L-Klick → Polygon-Punkte setzen"), styledHint("R-Klick → Polygon schließen / Auswahl aufheben"), + styledHint("ESC → Zeichnen abbrechen"), styledHint("Entf → Bereich löschen"), + styledHint("Bereiche dürfen sich nicht überschneiden"), new Separator(), sectionTitle("Gewählter Bereich"), new Separator()); - musicAreaDynamicContent = new VBox(6); + areaDynamicContent = new VBox(6); Label noSel = new Label("Kein Bereich ausgewählt"); noSel.setStyle("-fx-text-fill: #888;"); - musicAreaDynamicContent.getChildren().add(noSel); - inner.getChildren().add(musicAreaDynamicContent); + areaDynamicContent.getChildren().add(noSel); + inner.getChildren().add(areaDynamicContent); ScrollPane scroll = new ScrollPane(inner); scroll.setFitToWidth(true); @@ -4203,27 +4766,36 @@ public class EditorApp extends Application { return panel; } - private void updateMusicAreaPanel(String info) { - if (musicAreaDynamicContent == null) return; - musicAreaDynamicContent.getChildren().clear(); + private void updateAreaPanel(String info) { + if (areaDynamicContent == null) return; + areaDynamicContent.getChildren().clear(); if (info == null) { Label noSel = new Label("Kein Bereich ausgewählt"); noSel.setStyle("-fx-text-fill: #888;"); - musicAreaDynamicContent.getChildren().add(noSel); + areaDynamicContent.getChildren().add(noSel); return; } - // Format: "idx|dayTrack|nightTrack|combatTrack" + // Format: "idx|nameId|dayTrack|nightTrack|combatTrack" String[] p = info.split("\\|", -1); - if (p.length < 4) return; + if (p.length < 5) return; try { int idx = Integer.parseInt(p[0]); - final String[] tracks = {p[1], p[2], p[3]}; + final String[] nameRef = {p[1]}; + final String[] tracks = {p[2], p[3], p[4]}; String[] trackLabels = {"☀ Tag-Track", "🌙 Nacht-Track", "⚔ Kampf-Track"}; Runnable publish = () -> - input.pendingMusicArea.set(new de.blight.common.PlacedMusicArea( - new float[0], new float[0], tracks[0], tracks[1], tracks[2])); + input.pendingArea.set(new de.blight.common.PlacedArea( + new float[0], new float[0], nameRef[0], tracks[0], tracks[1], tracks[2])); + + // Name (TextReference-ID) + Label nameLbl = new Label("Name (TextReference):"); + nameLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + TextField nameTf = new TextField(nameRef[0]); + nameTf.setPromptText("z.B. area.village"); + nameTf.textProperty().addListener((obs, o, n) -> { nameRef[0] = n; publish.run(); }); + areaDynamicContent.getChildren().addAll(nameLbl, nameTf, new Separator()); for (int ti = 0; ti < 3; ti++) { final int tIdx = ti; @@ -4255,15 +4827,126 @@ public class EditorApp extends Application { } }); - musicAreaDynamicContent.getChildren().addAll(lbl, btn); - if (ti < 2) musicAreaDynamicContent.getChildren().add(new Separator()); + areaDynamicContent.getChildren().addAll(lbl, btn); + if (ti < 2) areaDynamicContent.getChildren().add(new Separator()); } Button delBtn = new Button("🗑 Löschen"); delBtn.setMaxWidth(Double.MAX_VALUE); delBtn.setStyle("-fx-text-fill: #c0392b;"); - delBtn.setOnAction(e -> input.deleteMusicAreaRequested = true); - musicAreaDynamicContent.getChildren().addAll(new Separator(), delBtn); + delBtn.setOnAction(e -> input.deleteAreaRequested = true); + areaDynamicContent.getChildren().addAll(new Separator(), delBtn); + + } catch (NumberFormatException ignored) {} + } + + private VBox buildLocationZonePanel() { + VBox inner = new VBox(8); + inner.setPadding(new Insets(10)); + inner.getChildren().addAll( + sectionTitle("Locations"), + styledHint("L-Klick → Polygon-Punkte setzen"), + styledHint("R-Klick → Polygon schließen / Auswahl aufheben"), + styledHint("ESC → Zeichnen abbrechen"), + styledHint("Entf → Location löschen"), + styledHint("Locations dürfen sich überschneiden"), + new Separator(), + sectionTitle("Gewählte Location"), + new Separator()); + + locationZoneDynamicContent = new VBox(6); + Label noSel = new Label("Keine Location ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + locationZoneDynamicContent.getChildren().add(noSel); + inner.getChildren().add(locationZoneDynamicContent); + + 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(270); + panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;"); + return panel; + } + + private void updateLocationZonePanel(String info) { + if (locationZoneDynamicContent == null) return; + locationZoneDynamicContent.getChildren().clear(); + + if (info == null) { + Label noSel = new Label("Keine Location ausgewählt"); + noSel.setStyle("-fx-text-fill: #888;"); + locationZoneDynamicContent.getChildren().add(noSel); + return; + } + // Format: "idx|nameId|triggersJson" + String[] p = info.split("\\|", 3); + if (p.length < 1) return; + try { + int idx = Integer.parseInt(p[0]); + String nameId = p.length > 1 ? p[1] : ""; + java.util.List initTriggers = + p.length > 2 + ? de.blight.common.model.trigger.TriggerIO.deserializeList(p[2]) + : new java.util.ArrayList<>(); + + // Holder damit triggerEditor und nameTf sich gegenseitig referenzieren können + final de.blight.editor.ui.TriggerListEditor[] teHolder = {null}; + + // Verfügbare Locations aus LocationIO laden + java.util.List locationIds = new java.util.ArrayList<>(); + locationIds.add(""); + try { + de.blight.common.LocationIO.load().stream() + .map(de.blight.common.model.Location::getId) + .filter(s -> !s.isBlank()) + .forEach(locationIds::add); + } catch (Exception ignored) {} + + Label nameLbl = new Label("Verknüpfte Location:"); + nameLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + ComboBox locationCombo = new ComboBox<>(); + locationCombo.getItems().setAll(locationIds); + locationCombo.setValue(locationIds.contains(nameId) ? nameId : ""); + locationCombo.setMaxWidth(Double.MAX_VALUE); + locationCombo.setPromptText("Location wählen…"); + // Fallback: Freitext falls Location nicht in Liste + TextField nameTf = new TextField(nameId); + nameTf.setPromptText("oder manuell: location.village"); + locationCombo.valueProperty().addListener((obs, o, n) -> { + if (n != null && !n.isBlank()) nameTf.setText(n); + input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( + new float[0], new float[0], + n != null && !n.isBlank() ? n : nameTf.getText(), + teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers)); + }); + nameTf.textProperty().addListener((obs, o, n) -> + input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( + new float[0], new float[0], n, + teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers))); + + Label triggerLbl = new Label("Trigger:"); + triggerLbl.setStyle("-fx-font-size: 11; -fx-font-weight: bold;"); + de.blight.editor.ui.TriggerListEditor triggerEditor = + new de.blight.editor.ui.TriggerListEditor(initTriggers, () -> + input.pendingLocationZone.set(new de.blight.common.PlacedLocationZone( + new float[0], new float[0], nameTf.getText(), + teHolder[0] != null ? teHolder[0].getTriggers() : initTriggers))); + teHolder[0] = triggerEditor; + + Button delBtn = new Button("🗑 Löschen"); + delBtn.setMaxWidth(Double.MAX_VALUE); + delBtn.setStyle("-fx-text-fill: #c0392b;"); + delBtn.setOnAction(e -> input.deleteLocationZoneRequested = true); + + locationZoneDynamicContent.getChildren().addAll( + nameLbl, locationCombo, nameTf, + new Separator(), + triggerLbl, triggerEditor, + new Separator(), + delBtn); } catch (NumberFormatException ignored) {} } @@ -4731,17 +5414,13 @@ public class EditorApp extends Application { loopCB.setSelected(true); loopCB.setOnAction(e -> input.animPreviewLoop = loopCB.isSelected()); - Label speedLbl = new Label("Geschwindigkeit: 1.0×"); + Label speedLbl = new Label("Geschwindigkeit"); Slider speedSlider = new Slider(0.1, 3.0, 1.0); speedSlider.setShowTickMarks(true); speedSlider.setShowTickLabels(true); speedSlider.setMajorTickUnit(1.0); - speedSlider.setMaxWidth(Double.MAX_VALUE); - speedSlider.valueProperty().addListener((obs, ov, nv) -> { - input.animPreviewSpeed = nv.floatValue(); - speedLbl.setText(String.format("Geschwindigkeit: %.1f×", nv.doubleValue())); - }); - inner.getChildren().addAll(loopCB, speedLbl, speedSlider); + speedSlider.valueProperty().addListener((obs, ov, nv) -> input.animPreviewSpeed = nv.floatValue()); + inner.getChildren().addAll(loopCB, speedLbl, withField(speedSlider, "%.2f")); // ── Clip importieren ────────────────────────────────────────────────── inner.getChildren().addAll(new Separator(), sectionTitle("Clip importieren"), new Separator()); @@ -5067,11 +5746,108 @@ public class EditorApp extends Application { private void switchToCharacterEditor() { onF5 = null; + currentTool = "charEditor"; topBar.getChildren().set(1, buildCharacterEditorToolBar()); - root.setCenter(new javafx.scene.layout.StackPane()); + dialogEditorView = new de.blight.editor.ui.DialogEditorView(); + dialogEditorView.setDisable(true); + root.setCenter(dialogEditorView); root.setRight(buildCharacterEditorPanel()); } + private void switchToRecipeEditor() { + onF5 = null; + currentTool = "recipeEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Rezept-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.RecipeEditorView(ASSET_ROOT.resolve("recipes"))); + root.setRight(null); + } + + private void switchToCraftingTableEditor() { + onF5 = null; + currentTool = "craftingTableEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Crafting-Table-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.CraftingTableEditorView(ASSET_ROOT.resolve("craftingtables"))); + root.setRight(null); + } + + private void switchToFractionEditor() { + onF5 = null; + currentTool = "fractionEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Fraktionen-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.FractionEditorView(ASSET_ROOT.resolve("fractions"))); + root.setRight(null); + } + + private void refreshFractionCombo() { + loadedFractions.clear(); + loadedFractions.addAll( + de.blight.common.model.FractionIO.loadAll(ASSET_ROOT.resolve("fractions"))); + if (charFractionCombo == null) return; + de.blight.common.model.Fraction current = charFractionCombo.getValue(); + charFractionCombo.getItems().clear(); + charFractionCombo.getItems().add(null); // "— keine Fraktion —" + charFractionCombo.getItems().addAll(loadedFractions); + // Selektion wiederherstellen per fractionId-Vergleich + if (current != null && current.getFractionId() != null) { + loadedFractions.stream() + .filter(f -> current.getFractionId().equals(f.getFractionId())) + .findFirst() + .ifPresentOrElse(charFractionCombo::setValue, () -> charFractionCombo.setValue(null)); + } + } + + private String fractionDisplay(de.blight.common.model.Fraction f) { + if (f == null) return "— keine Fraktion —"; + return f.getName() != null ? f.getName().id() : (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?"); + } + + private void switchToItemEditor() { + onF5 = null; + currentTool = "itemEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Item-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.ItemEditorView(ASSET_ROOT.resolve("items"))); + root.setRight(null); + } + + private void switchToQuestEditor() { + onF5 = null; + currentTool = "questEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Quest-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + Path questDir = ASSET_ROOT.resolve("quests"); + root.setCenter(new de.blight.editor.ui.QuestEditorView(questDir)); + root.setRight(null); + } + private ToolBar buildCharacterEditorToolBar() { Button backBtn = new Button("← Welteneditor"); backBtn.setOnAction(e -> switchToWorldEditor()); @@ -5102,8 +5878,16 @@ public class EditorApp extends Application { newCharBtn.setOnAction(e -> clearCharacterForm()); inner.getChildren().addAll(charListView, loadCharBtn, newCharBtn); - // ── Character-Daten ─────────────────────────────────────────────────── - inner.getChildren().addAll(new Separator(), sectionTitle("Character-Daten"), new Separator()); + charEditorStatusLabel = new Label("Kein Charakter geladen – bitte laden oder neu erstellen"); + charEditorStatusLabel.setWrapText(true); + charEditorStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #888;"); + inner.getChildren().add(charEditorStatusLabel); + + // ── Editierbarer Bereich (deaktiviert bis Character geladen) ────────── + charEditContainer = new VBox(8); + charEditContainer.setDisable(true); + + charEditContainer.getChildren().addAll(new Separator(), sectionTitle("Character-Daten"), new Separator()); charIdField = new javafx.scene.control.TextField(); charIdField.setPromptText("eindeutige ID (Dateiname)"); @@ -5136,7 +5920,6 @@ public class EditorApp extends Application { 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(); @@ -5153,35 +5936,136 @@ public class EditorApp extends Application { input.animEmbedRequest.set(new SharedInput.AnimEmbedRequest(modelPath, setName)); }); - inner.getChildren().addAll( + charEditContainer.getChildren().addAll( new Label("ID:"), charIdField, new Label("Name:"), charNameField, - new Label("Typ:"), charTypeCombo, + new Label("Typ:"), charTypeCombo + ); + + // ── Fraktion (NPC-only) ─────────────────────────────────────────────── + charFractionCombo = new javafx.scene.control.ComboBox<>(); + charFractionCombo.setMaxWidth(Double.MAX_VALUE); + charFractionCombo.setPromptText("— keine Fraktion —"); + charFractionCombo.setCellFactory(lv -> new javafx.scene.control.ListCell<>() { + @Override protected void updateItem(de.blight.common.model.Fraction f, boolean empty) { + super.updateItem(f, empty); + setText(empty ? null : fractionDisplay(f)); + } + }); + charFractionCombo.setButtonCell(new javafx.scene.control.ListCell<>() { + @Override protected void updateItem(de.blight.common.model.Fraction f, boolean empty) { + super.updateItem(f, empty); + setText(fractionDisplay(f)); + } + }); + refreshFractionCombo(); + Label fractionLbl = new Label("Fraktion:"); + charFractionSection = new VBox(2, fractionLbl, charFractionCombo); + boolean initiallyNpc = "NPC".equals(charTypeCombo.getValue()); + charFractionSection.setVisible(initiallyNpc); + charFractionSection.setManaged(initiallyNpc); + charTypeCombo.valueProperty().addListener((obs, ov, nv) -> { + boolean isNpc = "NPC".equals(nv); + charFractionSection.setVisible(isNpc); + charFractionSection.setManaged(isNpc); + }); + charEditContainer.getChildren().add(charFractionSection); + + // ── NPC-spezifische Felder ──────────────────────────────────────────── + charStatusCombo = new ComboBox<>(); + charStatusCombo.getItems().addAll("FRIENDLY", "NEUTRAL", "ENRAGED", "ENEMY"); + charStatusCombo.setValue("NEUTRAL"); + charStatusCombo.setMaxWidth(Double.MAX_VALUE); + + charTraderCheck = new CheckBox("Ist Händler"); + + charTraderItemsView = new javafx.scene.control.ListView<>(); + charTraderItemsView.setPrefHeight(80); + Button addTraderItemBtn = new Button("+"); + Button delTraderItemBtn = new Button("−"); + addTraderItemBtn.setOnAction(e -> { + javafx.scene.control.TextInputDialog d = new javafx.scene.control.TextInputDialog(); + d.setTitle("Item-ID"); + d.setHeaderText("Item-ID der Handelsware:"); + d.showAndWait().ifPresent(id -> { if (!id.isBlank()) charTraderItemsView.getItems().add(id.trim()); }); + }); + delTraderItemBtn.setOnAction(e -> { + String sel = charTraderItemsView.getSelectionModel().getSelectedItem(); + if (sel != null) charTraderItemsView.getItems().remove(sel); + }); + HBox traderBtns = new HBox(4, addTraderItemBtn, delTraderItemBtn); + + Label npcStatusLbl = new Label("Status:"); + Label npcTraderLbl = new Label("Handel:"); + Label npcItemsLbl = new Label("Waren:"); + + charNpcSection = new VBox(4, + npcStatusLbl, charStatusCombo, + npcTraderLbl, charTraderCheck, + npcItemsLbl, charTraderItemsView, traderBtns); + boolean npcInitial = "NPC".equals(charTypeCombo.getValue()); + charNpcSection.setVisible(npcInitial); + charNpcSection.setManaged(npcInitial); + + // ── MainCharacter-spezifische Felder (Abilities) ───────────────────── + abMagicSpin = abSpinner(); abStaffSpin = abSpinner(); + abSwordSpin = abSpinner(); abArcherySpin = abSpinner(); + abHeavySpin = abSpinner(); abCrossbowSpin = abSpinner(); + abThieverySpin = ab3Spinner(); abAlchemySpin = ab3Spinner(); + abEngineeringSpin = ab3Spinner(); abSmitherySpin = ab3Spinner(); + abEnchantingSpin = ab3Spinner(); + + charMainCharSection = new VBox(4, + sectionTitle("Fähigkeiten"), + abRow("Magie (1-10):", abMagicSpin), + abRow("Stab-Kampf (1-10):", abStaffSpin), + abRow("Schwertkampf (1-10):",abSwordSpin), + abRow("Bogenschießen:", abArcherySpin), + abRow("Schwere Waffen:", abHeavySpin), + abRow("Armbrust:", abCrossbowSpin), + abRow("Diebstahl (1-3):", abThieverySpin), + abRow("Alchemie (1-3):", abAlchemySpin), + abRow("Engineering (1-3):", abEngineeringSpin), + abRow("Schmieden (1-3):", abSmitherySpin), + abRow("Verzauberung (1-3):", abEnchantingSpin) + ); + boolean mainInitial = "MainCharacter".equals(charTypeCombo.getValue()); + charMainCharSection.setVisible(mainInitial); + charMainCharSection.setManaged(mainInitial); + + // Typ-Listener für NPC/MainChar-Sichtbarkeit (ergänzen was schon für Fraktion vorhanden) + charTypeCombo.valueProperty().addListener((obs, ov, nv) -> { + boolean isNpc = "NPC".equals(nv); + boolean isMain = "MainCharacter".equals(nv); + charNpcSection.setVisible(isNpc); charNpcSection.setManaged(isNpc); + charMainCharSection.setVisible(isMain); charMainCharSection.setManaged(isMain); + }); + + charEditContainer.getChildren().addAll(charNpcSection, charMainCharSection); + + charEditContainer.getChildren().addAll( new Label("Modell:"), charModelCombo, new Label("Anim-Set:"), charAnimSetCombo, embedAnimBtn ); - // ── Aktions-Zuweisung (Schreibgeschützte Anzeige aus .animset.json) ── - inner.getChildren().addAll(new Separator(), sectionTitle("Aktions-Zuweisung"), new Separator()); + // ── Aktions-Zuweisung ────────────────────────────────────────────────── + charEditContainer.getChildren().addAll(new Separator(), sectionTitle("Aktions-Zuweisung"), new Separator()); charActionLabelsBox = new VBox(4); Label actionHint = new Label("(wird aus Animations-Set geladen)"); actionHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;"); charActionLabelsBox.getChildren().add(actionHint); - inner.getChildren().add(charActionLabelsBox); + charEditContainer.getChildren().add(charActionLabelsBox); // ── Speichern ───────────────────────────────────────────────────────── - inner.getChildren().addAll(new Separator()); + charEditContainer.getChildren().add(new Separator()); Button saveCharBtn = new Button("Charakter speichern"); saveCharBtn.setMaxWidth(Double.MAX_VALUE); saveCharBtn.setStyle("-fx-font-weight: bold;"); saveCharBtn.setOnAction(e -> saveCharacter(charDir)); - inner.getChildren().add(saveCharBtn); + charEditContainer.getChildren().add(saveCharBtn); - charEditorStatusLabel = new Label("Kein Charakter geladen"); - charEditorStatusLabel.setWrapText(true); - charEditorStatusLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); - inner.getChildren().add(charEditorStatusLabel); + inner.getChildren().add(charEditContainer); ScrollPane scroll = new ScrollPane(inner); scroll.setFitToWidth(true); @@ -5261,12 +6145,19 @@ public class EditorApp extends Application { } private void clearCharacterForm() { - if (charIdField != null) charIdField.clear(); - if (charNameField != null) charNameField.clear(); - if (charTypeCombo != null) charTypeCombo.setValue("NPC"); - if (charModelCombo != null) charModelCombo.setValue(null); + if (charIdField != null) charIdField.clear(); + if (charNameField != null) charNameField.clear(); + if (charTypeCombo != null) charTypeCombo.setValue("NPC"); + if (charModelCombo != null) charModelCombo.setValue(null); if (charAnimSetCombo != null) charAnimSetCombo.setValue(null); + if (charFractionCombo != null) charFractionCombo.setValue(null); + if (charStatusCombo != null) charStatusCombo.setValue("NEUTRAL"); + if (charTraderCheck != null) charTraderCheck.setSelected(false); + if (charTraderItemsView != null) charTraderItemsView.getItems().clear(); + if (abMagicSpin != null) resetAbilities(); updateCharActionCombosFromSet(); + if (charEditContainer != null) charEditContainer.setDisable(false); + if (dialogEditorView != null) { dialogEditorView.setDisable(false); dialogEditorView.clear(); } if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Neuer Charakter"); } @@ -5285,6 +6176,38 @@ public class EditorApp extends Application { charAnimSetCombo.setValue(c.getAnimSetPath()); updateCharActionCombosFromSet(); } + if (charFractionCombo != null) { + de.blight.common.model.Fraction npcFraction = + (c instanceof de.blight.common.model.NPC npc) ? npc.getFraction() : null; + if (npcFraction != null && npcFraction.getFractionId() != null) { + final java.util.UUID fid = npcFraction.getFractionId(); + charFractionCombo.getItems().stream() + .filter(f -> f != null && fid.equals(f.getFractionId())) + .findFirst() + .ifPresentOrElse(charFractionCombo::setValue, () -> charFractionCombo.setValue(null)); + } else { + charFractionCombo.setValue(null); + } + } + if (c instanceof de.blight.common.model.NPC npc) { + if (charStatusCombo != null && npc.getStatus() != null) + charStatusCombo.setValue(npc.getStatus().name()); + if (charTraderCheck != null) charTraderCheck.setSelected(npc.isTrader()); + if (charTraderItemsView != null) { + charTraderItemsView.getItems().clear(); + if (npc.getItems() != null) + npc.getItems().forEach(it -> charTraderItemsView.getItems().add(it.getItemId())); + } + } + if (c instanceof de.blight.common.model.MainCharacter mc) { + loadAbilities(mc.getAbilities()); + } + if (charEditContainer != null) charEditContainer.setDisable(false); + if (dialogEditorView != null) { + dialogEditorView.setDisable(false); + if (c instanceof de.blight.common.model.NPC npc) dialogEditorView.loadNpc(npc); + else dialogEditorView.clear(); + } if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Geladen: " + id); } catch (Exception e) { if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Fehler: " + e.getMessage()); @@ -5314,6 +6237,29 @@ public class EditorApp extends Application { c.setName(nameId.isBlank() ? null : new de.blight.common.model.TextReference(nameId)); c.setModelPath(charModelCombo != null ? charModelCombo.getValue() : null); c.setAnimSetPath(charAnimSetCombo != null ? charAnimSetCombo.getValue() : null); + if (c instanceof de.blight.common.model.NPC npc) { + if (dialogEditorView != null) dialogEditorView.exportToNpc(npc); + npc.setFraction(charFractionCombo != null ? charFractionCombo.getValue() : null); + if (charStatusCombo != null && charStatusCombo.getValue() != null) { + try { npc.setStatus(de.blight.common.model.Status.valueOf(charStatusCombo.getValue())); } + catch (IllegalArgumentException ignored) {} + } + if (charTraderCheck != null) npc.setTrader(charTraderCheck.isSelected()); + if (charTraderItemsView != null && !charTraderItemsView.getItems().isEmpty()) { + java.util.List items = new java.util.ArrayList<>(); + charTraderItemsView.getItems().forEach(id2 -> { + de.blight.common.model.Item it = new de.blight.common.model.Item(); + it.setItemId(id2); + items.add(it); + }); + npc.setItems(items); + } else { + npc.setItems(null); + } + } + if (c instanceof de.blight.common.model.MainCharacter mc) { + mc.setAbilities(buildAbilities()); + } try { de.blight.common.model.CharacterIO.save(c, charDir); @@ -5379,4 +6325,94 @@ public class EditorApp extends Application { return dlg.showAndWait().orElse(null); } + private static javafx.scene.layout.HBox abRow(String label, javafx.scene.control.Spinner s) { + javafx.scene.control.Label l = new javafx.scene.control.Label(label); + l.setMinWidth(160); + javafx.scene.layout.HBox.setHgrow(s, javafx.scene.layout.Priority.ALWAYS); + javafx.scene.layout.HBox row = new javafx.scene.layout.HBox(6, l, s); + row.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + return row; + } + + private static javafx.scene.control.Spinner abSpinner() { + var s = new javafx.scene.control.Spinner(1, 10, 1); + s.setEditable(true); return s; + } + + private static javafx.scene.control.Spinner ab3Spinner() { + var s = new javafx.scene.control.Spinner(0, 3, 0); + s.setEditable(true); return s; + } + + private void resetAbilities() { + if (abMagicSpin == null) return; + abMagicSpin.getValueFactory().setValue(1); abStaffSpin.getValueFactory().setValue(1); + abSwordSpin.getValueFactory().setValue(1); abArcherySpin.getValueFactory().setValue(1); + abHeavySpin.getValueFactory().setValue(1); abCrossbowSpin.getValueFactory().setValue(1); + abThieverySpin.getValueFactory().setValue(0); abAlchemySpin.getValueFactory().setValue(0); + abEngineeringSpin.getValueFactory().setValue(0); abSmitherySpin.getValueFactory().setValue(0); + abEnchantingSpin.getValueFactory().setValue(0); + } + + private void loadAbilities(de.blight.common.model.abilities.Abilities ab) { + if (ab == null) { resetAbilities(); return; } + if (abMagicSpin == null) return; + abMagicSpin.getValueFactory().setValue(Math.max(1, ab.getLvlMagic())); + abStaffSpin.getValueFactory().setValue(Math.max(1, ab.getLvlStaffCombat())); + abSwordSpin.getValueFactory().setValue(Math.max(1, ab.getLvlSwordsmanship())); + abArcherySpin.getValueFactory().setValue(Math.max(1, ab.getLvlArchery())); + abHeavySpin.getValueFactory().setValue(Math.max(1, ab.getLvlHeavyWeapons())); + abCrossbowSpin.getValueFactory().setValue(Math.max(1, ab.getLvlCrossbow())); + abThieverySpin.getValueFactory().setValue(ab.getLvlThievery()); + abAlchemySpin.getValueFactory().setValue(ab.getLvlAlchemy()); + abEngineeringSpin.getValueFactory().setValue(ab.getLvlEngineering()); + abSmitherySpin.getValueFactory().setValue(ab.getLvlSmithery()); + abEnchantingSpin.getValueFactory().setValue(ab.getLvlEnchanting()); + } + + private de.blight.common.model.abilities.Abilities buildAbilities() { + if (abMagicSpin == null) return null; + de.blight.common.model.abilities.Abilities ab = new de.blight.common.model.abilities.Abilities(); + ab.setLvlMagic(abMagicSpin.getValue()); + ab.setLvlStaffCombat(abStaffSpin.getValue()); + ab.setLvlSwordsmanship(abSwordSpin.getValue()); + ab.setLvlArchery(abArcherySpin.getValue()); + ab.setLvlHeavyWeapons(abHeavySpin.getValue()); + ab.setLvlCrossbow(abCrossbowSpin.getValue()); + ab.setLvlThievery(abThieverySpin.getValue()); + ab.setLvlAlchemy(abAlchemySpin.getValue()); + ab.setLvlEngineering(abEngineeringSpin.getValue()); + ab.setLvlSmithery(abSmitherySpin.getValue()); + ab.setLvlEnchanting(abEnchantingSpin.getValue()); + return ab; + } + + private void switchToLocationEditor() { + onF5 = null; + currentTool = "locationEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Locations-Manager"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.LocationEditorView()); + root.setRight(null); + } + + private void switchToLocalizationEditor() { + onF5 = null; + currentTool = "localizationEditor"; + ToolBar tb = new ToolBar(); + Button backBtn = new Button("← Welteneditor"); + backBtn.setOnAction(e -> switchToWorldEditor()); + Label label = new Label("Lokalisierungs-Editor"); + label.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"); + tb.getItems().addAll(backBtn, new Separator(Orientation.VERTICAL), label); + topBar.getChildren().set(1, tb); + root.setCenter(new de.blight.editor.ui.LocalizationEditorView(ASSET_ROOT.resolve("localization"))); + root.setRight(null); + } + } 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 14e59f0..22d62ec 100644 --- a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java @@ -8,8 +8,10 @@ import com.jme3.texture.FrameBuffer; import com.jme3.texture.Image; import com.jme3.texture.Texture2D; import de.blight.editor.state.AnimPreviewState; +import de.blight.editor.state.AreaState; +import de.blight.editor.state.ModelEditorState; import de.blight.editor.state.EmitterState; -import de.blight.editor.state.MusicAreaState; +import de.blight.editor.state.LocationZoneState; import de.blight.editor.state.RiverEditorState; import de.blight.editor.state.PlayToolState; import de.blight.editor.state.SoundAreaState; @@ -93,10 +95,12 @@ public class JmeEditorApp extends SimpleApplication { stateManager.attach(new EmitterState(input)); stateManager.attach(new WaterBodyState(input)); stateManager.attach(new SoundAreaState(input)); - stateManager.attach(new MusicAreaState(input)); + stateManager.attach(new AreaState(input)); + stateManager.attach(new LocationZoneState(input)); stateManager.attach(new RiverEditorState(input)); stateManager.attach(new PlayToolState(input)); stateManager.attach(new AnimPreviewState(input)); + stateManager.attach(new ModelEditorState(input)); input.loadingStatus = "Initialisiere Konsole..."; jmeConsole = new JmeConsole(false); 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 8a52707..63f0b61 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -2,6 +2,7 @@ package de.blight.editor; import de.blight.editor.tool.EditorTool; import de.blight.editor.tool.GrassTool; +import de.blight.editor.tool.GrassVertexTool; import de.blight.editor.tool.HeightTool; import de.blight.editor.tool.HoleTool; import de.blight.editor.tool.TextureTool; @@ -21,6 +22,7 @@ public class SharedInput { public final HeightTool heightTool = new HeightTool(); public final UpperHeightTool upperHeightTool = new UpperHeightTool(); public final GrassTool grassTool = new GrassTool(); + public final GrassVertexTool grassVertexTool = new GrassVertexTool(); public final TextureTool textureTool = new TextureTool(); public final HoleTool holeTool = new HoleTool(); public volatile EditorTool activeTool = heightTool; @@ -62,11 +64,18 @@ public class SharedInput { public record TerrainEdit(float screenX, float screenY, int action) {} public final ConcurrentLinkedQueue editQueue = new ConcurrentLinkedQueue<>(); - // ── Gras-Edits ──────────────────────────────────────────────────────────── + // ── Gras (Textur) Edits ─────────────────────────────────────────────────── /** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */ public record GrassEdit(float screenX, float screenY, int action) {} public final ConcurrentLinkedQueue grassEditQueue = new ConcurrentLinkedQueue<>(); + // ── Gras (Vertices) ─────────────────────────────────────────────────────── + /** activeLayer==15 → Vertex-Gras (X-Quad-Halme) platzieren und entfernen */ + public static final int LAYER_GRASS_VERTEX = 15; + + public record GrassVertexEdit(float screenX, float screenY, int action) {} + public final ConcurrentLinkedQueue grassVertexEditQueue = new ConcurrentLinkedQueue<>(); + // ── Gras-Einstellungen (JavaFX → JME3) ─────────────────────────────────── /** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */ public volatile String grassTexturePath = ""; @@ -125,6 +134,10 @@ public class SharedInput { // 0 = keine Änderung, 1 = Drahtgitter aktivieren, 2 = Textur-Modus aktivieren public volatile int wireframeRequest = 0; + // ── Topologie-Overlay ───────────────────────────────────────────────────── + // 0 = keine Änderung, 1 = aktivieren, 2 = deaktivieren + public volatile int topologyRequest = 0; + // ── Vorschau-Viewport (gemeinsam für Baum-Generator & EZ-Tree) ─────────── public volatile float treePreviewRotY = 0f; // Yaw-Winkel in Grad public volatile float treePreviewRotX = 30f; // Elevation in Grad [5, 80] @@ -339,49 +352,55 @@ public class SharedInput { /** activeLayer==10 → Sound-Bereiche (Polygon) platzieren und bearbeiten */ public static final int LAYER_SOUND_AREAS = 10; - // ── Musik-Bereiche ──────────────────────────────────────────────────────── - /** activeLayer==11 → Musik-Bereiche (Polygon) platzieren und bearbeiten */ - public static final int LAYER_MUSIC_AREAS = 11; + // ── Bereiche (Areas) ────────────────────────────────────────────────────── + /** activeLayer==11 → Bereiche (Polygon) platzieren und bearbeiten */ + public static final int LAYER_AREAS = 11; // ── Spiel-Starten-Werkzeug ──────────────────────────────────────────────── /** 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; + /** activeLayer==13 → Wasserfälle platzieren */ + public static final int LAYER_WATERFALL = 13; - // ── Fluss-Werkzeug ───────────────────────────────────────────────────────── - public record RiverClick(float screenX, float screenY, boolean rightButton) {} - public final ConcurrentLinkedQueue riverClickQueue = new ConcurrentLinkedQueue<>(); - public volatile float riverNewWidth = 8.0f; // Breite des nächsten Punktes (Minimum 8m) - public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall) - public volatile boolean undoRiverPointRequested = false; - public volatile String riverHint = null; + // ── Wasserfall-Werkzeug ──────────────────────────────────────────────────── + public record WaterfallClick(float screenX, float screenY, boolean rightButton) {} + public final ConcurrentLinkedQueue waterfallClickQueue = new ConcurrentLinkedQueue<>(); + public volatile float waterfallNewWidth = 8.0f; + public volatile boolean undoWaterfallPointRequested = false; - /** 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; + /** JME → JavaFX: Info des selektierten Wasserfalls. Format: "idx|numPoints|totalLengthM" oder null. */ + public volatile String selectedWaterfallInfo = null; + public volatile boolean waterfallSelectionChanged = false; + /** JavaFX → JME: Selektierten Wasserfall löschen. */ + public volatile boolean deleteWaterfallRequested = false; - /** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */ + // ── Wasser-Werkzeug (Polygon) ───────────────────────────────────────────── + /** Klick im Viewport im Wasser-Modus: Punkt setzen 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|seedX|seedZ|waterHeight|cellCount" oder null. + * JME → JavaFX: Info der selektierten Wasserfläche. + * Format: "idx|waterHeight|pointCount" oder null. */ public volatile String selectedWaterInfo = null; public volatile boolean waterSelectionChanged = false; - /** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */ - public volatile String waterHint = null; + /** JavaFX → JME: Spacetaste → Terrain-Höhe an Cursor-Position als aktuelle Wasserhöhe übernehmen. */ + public volatile boolean waterSampleHeightRequested = false; + /** JME → JavaFX: Aktuelle Platzierungs-Höhe (wird per Space aktualisiert oder beim ersten Punkt gesetzt). */ + public volatile float waterCurrentHeight = 0f; + /** JME → JavaFX: Höhe wurde aktualisiert → JavaFX soll UI-Label aktualisieren. */ + public volatile boolean waterHeightChanged = false; - /** JavaFX → JME: aktualisierte Wasserhöhe der selektierten Fläche. */ - public final AtomicReference pendingWater = new AtomicReference<>(); + /** JavaFX → JME: neue Höhe für selektierte Wasserfläche. NaN = kein Auftrag. */ + public final AtomicReference pendingWaterHeight = new AtomicReference<>(); - /** JavaFX → JME: Selektierte Wasseroberfläche löschen. */ + /** JavaFX → JME: neue Fließrichtung (Grad 0–359) für selektierte Wasserfläche. null = kein Auftrag. */ + public final AtomicReference pendingWaterFlowDegrees = new AtomicReference<>(); + + /** JavaFX → JME: Selektierte Wasserfläche löschen. */ public volatile boolean deleteWaterRequested = false; // ── Sound-Bereich-Werkzeug ──────────────────────────────────────────────── @@ -398,19 +417,38 @@ public class SharedInput { /** JavaFX → JME: Selektierten Sound-Bereich löschen. */ public volatile boolean deleteSoundAreaRequested = false; - // ── Musik-Bereich-Werkzeug ──────────────────────────────────────────────── - public record MusicAreaClick(float screenX, float screenY, boolean rightButton) {} - public final ConcurrentLinkedQueue musicAreaClickQueue = new ConcurrentLinkedQueue<>(); + // ── Bereich-Werkzeug (Area) ─────────────────────────────────────────────── + public record AreaClick(float screenX, float screenY, boolean rightButton) {} + public final ConcurrentLinkedQueue areaClickQueue = new ConcurrentLinkedQueue<>(); - /** JME → JavaFX: Info des selektierten Musik-Bereichs. Format: "idx|dayTrack|nightTrack|combatTrack" oder null. */ - public volatile String selectedMusicAreaInfo = null; - public volatile boolean musicAreaSelectionChanged = false; + /** JME → JavaFX: Info des selektierten Bereichs. Format: "idx|nameId|dayTrack|nightTrack|combatTrack" oder null. */ + public volatile String selectedAreaInfo = null; + public volatile boolean areaSelectionChanged = false; + /** JME → JavaFX: letztes Polygon wurde wegen Überschneidung abgelehnt. */ + public volatile boolean areaOverlapRejected = false; - /** JavaFX → JME: aktualisierte Parameter des selektierten Musik-Bereichs. */ - public final AtomicReference pendingMusicArea = new AtomicReference<>(); + /** JavaFX → JME: aktualisierte Parameter des selektierten Bereichs. */ + public final AtomicReference pendingArea = new AtomicReference<>(); - /** JavaFX → JME: Selektierten Musik-Bereich löschen. */ - public volatile boolean deleteMusicAreaRequested = false; + /** JavaFX → JME: Selektierten Bereich löschen. */ + public volatile boolean deleteAreaRequested = false; + + // ── Location-Zonen-Werkzeug ─────────────────────────────────────────────── + /** activeLayer==14 → Location-Zonen (Polygon) platzieren und bearbeiten */ + public static final int LAYER_LOCATION_ZONES = 14; + + public record LocationZoneClick(float screenX, float screenY, boolean rightButton) {} + public final ConcurrentLinkedQueue locationZoneClickQueue = new ConcurrentLinkedQueue<>(); + + /** JME → JavaFX: Info der selektierten Location-Zone. Format: "idx|nameId" oder null. */ + public volatile String selectedLocationZoneInfo = null; + public volatile boolean locationZoneSelectionChanged = false; + + /** JavaFX → JME: aktualisierte Parameter der selektierten Location-Zone. */ + public final AtomicReference pendingLocationZone = new AtomicReference<>(); + + /** JavaFX → JME: Selektierte Location-Zone löschen. */ + public volatile boolean deleteLocationZoneRequested = false; /** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */ public volatile boolean cancelZoneDrawing = false; @@ -518,4 +556,30 @@ public class SharedInput { } public final ConcurrentLinkedQueue modelConvertQueue = new ConcurrentLinkedQueue<>(); + + // ── Modell-Editor ───────────────────────────────────────────────────────── + /** activeLayer==20 → Modell-Editor (3-D-Vorschau + Metadaten-Konfiguration) */ + public static final int LAYER_MODEL_EDITOR = 20; + + /** JFX → JME: Pfad des zu ladenden Modells (relativ zu blight-assets/src/main/resources/). */ + public volatile String modelEditorOpenPath = null; + + /** JME → JFX: Boundingbox des skalierten Modells (W=X, H=Y, D=Z in Metern). */ + public volatile float modelEditorBoundsW = 0f; + public volatile float modelEditorBoundsH = 0f; + public volatile float modelEditorBoundsD = 0f; + public volatile boolean modelEditorBoundsReady = false; + + /** JFX → JME: aktuelle Skalierung (Echtzeit-Vorschau). */ + public volatile float modelEditorScaleX = 1f; + public volatile float modelEditorScaleY = 1f; + public volatile float modelEditorScaleZ = 1f; + public volatile boolean modelEditorScaleChanged = false; + + /** JFX → JME: Pivot-Y-Versatz (Echtzeit). */ + public volatile float modelEditorPivotY = 0f; + public volatile boolean modelEditorPivotChanged = false; + + /** JFX → JME: Model-Editor schließen. */ + public volatile boolean modelEditorCloseRequest = false; } diff --git a/blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java similarity index 71% rename from blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java rename to blight-editor/src/main/java/de/blight/editor/state/AreaState.java index 656d7fb..adf3cf9 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/MusicAreaState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java @@ -12,20 +12,21 @@ import com.jme3.scene.*; import com.jme3.scene.VertexBuffer; import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.util.BufferUtils; -import de.blight.common.PlacedMusicArea; +import de.blight.common.PlacedArea; import de.blight.editor.SharedInput; import java.nio.FloatBuffer; import java.util.ArrayList; import java.util.List; -public class MusicAreaState extends BaseAppState { +public class AreaState extends BaseAppState { private static final float LINE_OFFSET_Y = 0.35f; private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.5f, 0.3f, 1f, 1f); private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f); private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.8f, 0.5f, 1f, 1f); + private static final ColorRGBA COLOR_OVERLAP = new ColorRGBA(1f, 0.2f, 0.2f, 1f); private final SharedInput input; private SimpleApplication app; @@ -34,19 +35,19 @@ public class MusicAreaState extends BaseAppState { private Node rootNode; private TerrainQuad terrain; - private final List areas = new ArrayList<>(); - private final List areaGeos = new ArrayList<>(); - private int selectedIdx = -1; + private final List areas = new ArrayList<>(); + private final List areaGeos = new ArrayList<>(); + private int selectedIdx = -1; - private boolean placing = false; - private final List currX = new ArrayList<>(); - private final List currZ = new ArrayList<>(); - private Geometry inProgGeo = null; + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private Geometry inProgGeo = null; private Geometry lastPointMarker = null; - private List pendingAreas = null; + private List pendingAreas = null; - public MusicAreaState(SharedInput input) { + public AreaState(SharedInput input) { this.input = input; } @@ -78,17 +79,17 @@ public class MusicAreaState extends BaseAppState { @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_MUSIC_AREAS) { + if (input.activeLayer != SharedInput.LAYER_AREAS) { if (placing) cancelPoly(); return; } - SharedInput.MusicAreaClick click; - while ((click = input.musicAreaClickQueue.poll()) != null) { + SharedInput.AreaClick click; + while ((click = input.areaClickQueue.poll()) != null) { handleClick(click); } - PlacedMusicArea pending = input.pendingMusicArea.getAndSet(null); + PlacedArea pending = input.pendingArea.getAndSet(null); if (pending != null && selectedIdx >= 0) { applyProperty(selectedIdx, pending); } @@ -98,15 +99,15 @@ public class MusicAreaState extends BaseAppState { if (placing) cancelPoly(); } - if (input.deleteMusicAreaRequested) { - input.deleteMusicAreaRequested = false; + if (input.deleteAreaRequested) { + input.deleteAreaRequested = false; if (selectedIdx >= 0) removeArea(selectedIdx); } } // ── Click handling ──────────────────────────────────────────────────────── - private void handleClick(SharedInput.MusicAreaClick click) { + private void handleClick(SharedInput.AreaClick click) { float jmeX = click.screenX() * (float) input.viewportScaleX; float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; @@ -146,7 +147,7 @@ public class MusicAreaState extends BaseAppState { updateInProgressGeo(); } else { for (int i = 0; i < areas.size(); i++) { - PlacedMusicArea a = areas.get(i); + PlacedArea a = areas.get(i); if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) { selectArea(i); return; @@ -165,7 +166,7 @@ public class MusicAreaState extends BaseAppState { private float[] snapVertex(float x, float z) { float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST; float bx = x, bz = z; - for (PlacedMusicArea a : areas) { + for (PlacedArea a : areas) { for (int i = 0; i < a.pointsX().length; i++) { float dx = x - a.pointsX()[i]; float dz = z - a.pointsZ()[i]; @@ -186,7 +187,15 @@ public class MusicAreaState extends BaseAppState { if (currX.size() < 3) { cancelPoly(); return; } float[] xs = toArray(currX); float[] zs = toArray(currZ); - PlacedMusicArea area = new PlacedMusicArea(xs, zs, "", "", ""); + + if (overlapsExistingAreas(xs, zs)) { + // Flash the in-progress polygon red briefly to indicate rejection + if (inProgGeo != null) inProgGeo.getMaterial().setColor("Color", COLOR_OVERLAP); + input.areaOverlapRejected = true; + return; + } + + PlacedArea area = new PlacedArea(xs, zs, "", "", "", ""); addArea(area); selectArea(areas.size() - 1); cancelPoly(); @@ -204,7 +213,7 @@ public class MusicAreaState extends BaseAppState { if (inProgGeo != null) rootNode.detachChild(inProgGeo); int n = currX.size(); if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; } - inProgGeo = buildLineGeo("music_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip); + inProgGeo = buildLineGeo("area_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip); rootNode.attachChild(inProgGeo); updateLastPointMarker(); } @@ -232,7 +241,7 @@ public class MusicAreaState extends BaseAppState { mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f)); mat.getAdditionalRenderState().setLineWidth(3f); - lastPointMarker = new Geometry("music_lastpoint", mesh); + lastPointMarker = new Geometry("area_lastpoint", mesh); lastPointMarker.setMaterial(mat); rootNode.attachChild(lastPointMarker); } @@ -253,23 +262,23 @@ public class MusicAreaState extends BaseAppState { areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); } selectedIdx = -1; - input.selectedMusicAreaInfo = null; - input.musicAreaSelectionChanged = true; + input.selectedAreaInfo = null; + input.areaSelectionChanged = true; } private void publishSelection(int idx) { - PlacedMusicArea a = areas.get(idx); - input.selectedMusicAreaInfo = idx + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack(); - input.musicAreaSelectionChanged = true; + PlacedArea a = areas.get(idx); + input.selectedAreaInfo = idx + "|" + a.nameId() + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack(); + input.areaSelectionChanged = true; } // ── Add / Remove / Apply ────────────────────────────────────────────────── - private void addArea(PlacedMusicArea area) { + private void addArea(PlacedArea area) { areas.add(area); List xs = toList(area.pointsX()); List zs = toList(area.pointsZ()); - Geometry geo = buildLineGeo("music_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop); + Geometry geo = buildLineGeo("area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop); rootNode.attachChild(geo); areaGeos.add(geo); } @@ -279,8 +288,8 @@ public class MusicAreaState extends BaseAppState { areas.remove(idx); areaGeos.remove(idx); selectedIdx = -1; - input.selectedMusicAreaInfo = null; - input.musicAreaSelectionChanged = true; + input.selectedAreaInfo = null; + input.areaSelectionChanged = true; } private void clearAll() { @@ -291,12 +300,12 @@ public class MusicAreaState extends BaseAppState { selectedIdx = -1; } - private void applyProperty(int idx, PlacedMusicArea updated) { + private void applyProperty(int idx, PlacedArea updated) { if (updated.pointsX().length == 0) { - PlacedMusicArea existing = areas.get(idx); - areas.set(idx, new PlacedMusicArea( + PlacedArea existing = areas.get(idx); + areas.set(idx, new PlacedArea( existing.pointsX(), existing.pointsZ(), - updated.dayTrack(), updated.nightTrack(), updated.combatTrack())); + updated.nameId(), updated.dayTrack(), updated.nightTrack(), updated.combatTrack())); } else { areas.set(idx, updated); } @@ -327,19 +336,60 @@ public class MusicAreaState extends BaseAppState { return geo; } + // ── Overlap detection ───────────────────────────────────────────────────── + + private boolean overlapsExistingAreas(float[] xs, float[] zs) { + for (PlacedArea existing : areas) { + float[] ex = existing.pointsX(); + float[] ez = existing.pointsZ(); + if (polygonsIntersect(xs, zs, ex, ez)) return true; + if (xs.length > 0 && SoundAreaState.pointInPolygon(xs[0], zs[0], ex, ez)) return true; + if (ex.length > 0 && SoundAreaState.pointInPolygon(ex[0], ez[0], xs, zs)) return true; + } + return false; + } + + private static boolean polygonsIntersect(float[] axs, float[] azs, float[] bxs, float[] bzs) { + int na = axs.length; + int nb = bxs.length; + for (int i = 0; i < na; i++) { + int i2 = (i + 1) % na; + for (int j = 0; j < nb; j++) { + int j2 = (j + 1) % nb; + if (segmentsIntersect(axs[i], azs[i], axs[i2], azs[i2], + bxs[j], bzs[j], bxs[j2], bzs[j2])) return true; + } + } + return false; + } + + private static boolean segmentsIntersect(float ax, float ay, float bx, float by, + float cx, float cy, float dx, float dy) { + float d1 = cross(cx, cy, dx, dy, ax, ay); + float d2 = cross(cx, cy, dx, dy, bx, by); + float d3 = cross(ax, ay, bx, by, cx, cy); + float d4 = cross(ax, ay, bx, by, dx, dy); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) + && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); + } + + private static float cross(float ax, float ay, float bx, float by, float px, float py) { + return (bx - ax) * (py - ay) - (by - ay) * (px - ax); + } + // ── Save / Load ─────────────────────────────────────────────────────────── - public List getPlacedAreas() { + public List getPlacedAreas() { return new ArrayList<>(areas); } - public void loadAreas(List loaded) { + public void loadAreas(List loaded) { if (rootNode == null) { pendingAreas = new ArrayList<>(loaded); return; } clearAll(); - for (PlacedMusicArea a : loaded) addArea(a); + for (PlacedArea a : loaded) addArea(a); } // ── Helpers ─────────────────────────────────────────────────────────────── 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 353e287..754836d 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 @@ -546,6 +546,8 @@ public class EzTreeState extends BaseAppState { private void exportTree(Node treeNode, String fileName, String subPath) { try { + treeNode.setLocalScale(0.33f); + treeNode.updateGeometricState(); Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath); Files.createDirectories(baseDir); File out = baseDir.resolve(fileName + ".j3o").toFile(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java new file mode 100644 index 0000000..b385bc5 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java @@ -0,0 +1,392 @@ +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.Ray; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +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.terrain.geomipmap.TerrainQuad; +import com.jme3.util.BufferUtils; +import de.blight.common.GrassVertexBlade; +import de.blight.common.GrassVertexIO; +import de.blight.editor.SharedInput; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * Rendert Vertex-Gras im Editor: 3 leicht geneigte, verjüngte Halme pro Büschel, + * mit korrekten Normalen für Beleuchtung und deterministischer Rotation. + */ +public class GrassVertexState extends BaseAppState { + + // ── Chunks ──────────────────────────────────────────────────────────────── + 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; + private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; + private static final int MAX_REBUILDS_PER_FRAME = 3; + + // ── Geometrie ───────────────────────────────────────────────────────────── + static final int BLADES_PER_TUFT = 3; // Halme pro Büschel + static final int SEGMENTS = 5; // Segmente pro Halm (5 → 6 Reihen) + static final float WIDTH_FACTOR = 0.05f; // Basis-Halbbreite = Höhe × WIDTH_FACTOR + static final float BEND_FACTOR = 0.15f; // max. Krümmungsversatz an der Spitze + + static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f); + static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f); + static final ColorRGBA DRY_ROOT_COLOR = new ColorRGBA(0.45f, 0.35f, 0.08f, 1f); + static final ColorRGBA DRY_TIP_COLOR = new ColorRGBA(0.82f, 0.74f, 0.16f, 1f); + + // ── Zustand ─────────────────────────────────────────────────────────────── + private final SharedInput input; + private AssetManager assetManager; + private TerrainQuad terrain; + private Node grassNode; + private Material material; + + @SuppressWarnings("unchecked") + private final List[] chunkBlades = new List[CHUNK_COUNT]; + private final Node[] chunkNodes = new Node[CHUNK_COUNT]; + private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT]; + + public GrassVertexState(SharedInput input) { + this.input = input; + for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>(); + } + + public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } + + public List getAllBlades() { + List all = new ArrayList<>(); + for (List list : chunkBlades) all.addAll(list); + return all; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + this.assetManager = app.getAssetManager(); + grassNode = new Node("grassVertexNode"); + ((SimpleApplication) app).getRootNode().attachChild(grassNode); + material = buildMaterial(); + + try { + for (GrassVertexBlade b : GrassVertexIO.load()) { + int ci = chunkIndex(b.x(), b.z()); + if (ci >= 0) { chunkBlades[ci].add(b); dirtyChunks[ci] = true; } + } + } catch (Exception e) { + System.err.println("[GrassVertexState] Daten nicht ladbar: " + e.getMessage()); + } + } + + @Override + protected void cleanup(Application app) { + ((SimpleApplication) app).getRootNode().detachChild(grassNode); + } + + @Override protected void onEnable() { grassNode.setCullHint(Spatial.CullHint.Inherit); } + @Override protected void onDisable() { grassNode.setCullHint(Spatial.CullHint.Always); } + + @Override + public void update(float tpf) { + processBrushEdits(); + rebuildDirtyChunks(); + } + + // ── Material ────────────────────────────────────────────────────────────── + + private Material buildMaterial() { + try { + Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md"); + mat.setFloat("WindSpeed", 1.0f); + mat.setFloat("WindStrength", 0.15f); + mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal()); + mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f)); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + return mat; + } catch (Exception e) { + System.err.println("[GrassVertexState] Material nicht ladbar: " + e.getMessage()); + 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-Interaktion ──────────────────────────────────────────────────── + + private void processBrushEdits() { + SharedInput.GrassVertexEdit edit; + while ((edit = input.grassVertexEditQueue.poll()) != null) { + if (terrain == null) continue; + float jmeX = (float) (edit.screenX() * input.viewportScaleX); + float jmeY = (float) (edit.screenY() * input.viewportScaleY); + Vector3f hit = raycastTerrain(jmeX, jmeY, getApplication().getCamera()); + if (hit == null) continue; + if (edit.action() > 0) addBlades(hit); + else removeBlades(hit); + } + } + + private Vector3f raycastTerrain(float screenX, float screenY, com.jme3.renderer.Camera cam) { + float flippedY = cam.getHeight() - screenY; + Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f); + Vector3f direction = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f) + .subtractLocal(origin).normalizeLocal(); + Ray ray = new Ray(origin, direction); + CollisionResults res = new CollisionResults(); + terrain.collideWith(ray, res); + return res.size() == 0 ? null : res.getClosestCollision().getContactPoint(); + } + + private final Random rng = new Random(); + + private void addBlades(Vector3f center) { + float radius = (float) input.grassVertexTool.brushRadius.getValue(); + float height = (float) input.grassVertexTool.bladeHeight.getValue(); + int density = (int) input.grassVertexTool.density.getValue(); + + for (int i = 0; i < density; i++) { + float angle = rng.nextFloat() * (float) (Math.PI * 2); + float r = rng.nextFloat() * radius; + float bx = center.x + (float) Math.cos(angle) * r; + float bz = center.z + (float) Math.sin(angle) * r; + float by = terrain.getHeight(new Vector2f(bx, bz)); + if (Float.isNaN(by)) continue; + float h = height * (0.75f + rng.nextFloat() * 0.5f); + float witherPct = (float) input.grassVertexTool.dryness.getValue() / 100f; + float bladeDryness = rng.nextFloat() < witherPct + ? 0.5f + rng.nextFloat() * 0.5f : 0f; + GrassVertexBlade blade = new GrassVertexBlade(bx, by, bz, h, bladeDryness); + int ci = chunkIndex(bx, bz); + if (ci >= 0) { chunkBlades[ci].add(blade); dirtyChunks[ci] = true; } + } + } + + public void adjustBladeHeights(Vector3f center, float radius) { + if (terrain == null) return; + float radSq = radius * radius; + for (int ci = 0; ci < CHUNK_COUNT; ci++) { + List list = chunkBlades[ci]; + boolean changed = false; + for (int i = 0; i < list.size(); i++) { + GrassVertexBlade b = list.get(i); + float dx = b.x() - center.x, dz = b.z() - center.z; + if (dx*dx + dz*dz > radSq) continue; + float newY = terrain.getHeight(new Vector2f(b.x(), b.z())); + if (!Float.isNaN(newY)) { + list.set(i, new GrassVertexBlade(b.x(), newY, b.z(), b.height(), b.dryness())); + changed = true; + } + } + if (changed) dirtyChunks[ci] = true; + } + } + + private void removeBlades(Vector3f center) { + float radSq = (float) Math.pow(input.grassVertexTool.brushRadius.getValue(), 2); + for (int ci = 0; ci < CHUNK_COUNT; ci++) { + List list = chunkBlades[ci]; + int before = list.size(); + list.removeIf(b -> { + float dx = b.x() - center.x, dz = b.z() - center.z; + return dx*dx + dz*dz <= radSq; + }); + if (list.size() != before) dirtyChunks[ci] = true; + } + } + + // ── Chunk-Verwaltung ────────────────────────────────────────────────────── + + private 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; + } + + private void rebuildDirtyChunks() { + int rebuilt = 0; + for (int ci = 0; ci < CHUNK_COUNT && rebuilt < MAX_REBUILDS_PER_FRAME; ci++) { + if (!dirtyChunks[ci]) continue; + dirtyChunks[ci] = false; + rebuilt++; + rebuildChunk(ci); + } + } + + private void rebuildChunk(int ci) { + if (chunkNodes[ci] != null) { + grassNode.detachChild(chunkNodes[ci]); + chunkNodes[ci] = null; + } + List blades = chunkBlades[ci]; + if (blades.isEmpty()) return; + + // 3 Halme × (SEGMENTS+1) Reihen × 2 Verts; 3 × SEGMENTS × 6 Indices + int bladeCount = blades.size(); + int vertCount = bladeCount * BLADES_PER_TUFT * (SEGMENTS + 1) * 2; + int indexCount = bladeCount * BLADES_PER_TUFT * SEGMENTS * 6; + + float[] positions = new float[vertCount * 3]; + float[] normals = new float[vertCount * 3]; + float[] colors = new float[vertCount * 4]; + float[] texCoords = new float[vertCount * 2]; + int[] indices = new int[indexCount]; + + int vi = 0, ii = 0; + for (GrassVertexBlade blade : blades) { + buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade); + vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2; + ii += BLADES_PER_TUFT * SEGMENTS * 6; + } + + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(positions)); + mesh.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(normals)); + mesh.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(colors)); + mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords)); + mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices)); + mesh.updateBound(); + + Geometry geo = new Geometry("gv_" + ci, mesh); + geo.setMaterial(material); + + Node node = new Node("gvc_" + ci); + node.attachChild(geo); + chunkNodes[ci] = node; + grassNode.attachChild(node); + } + + // ── Mesh-Generierung (gemeinsame Logik, auch von GrassVertexRenderState genutzt) ── + + static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx, + int vi, int ii, GrassVertexBlade blade) { + float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height(); + float baseHW = h * WIDTH_FACTOR * 0.5f; + float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)); + + for (int b = 0; b < BLADES_PER_TUFT; b++) { + float bf = b; + + float jitter = (hash(x + bf * 13.7f, z + bf * 19.3f) - 0.5f) * 0.4f; + float bladeAngle = tAngle + b * (float) (Math.PI * 2.0 / BLADES_PER_TUFT) + jitter; + float leanMag = 0.25f + hash(x + bf * 7.3f, z + bf * 3.1f) * 0.55f; + float leanDir = bladeAngle + (float) (Math.PI * 0.5) * (b % 2 == 0 ? 1f : -1f); + + float offR = hash(x + bf * 2.9f, z + bf * 5.3f) * 0.28f; + float offA = hash(x + bf * 3.7f, z + bf * 1.7f) * (float) (Math.PI * 2); + float bx = x + offR * (float) Math.cos(offA); + float bz = z + offR * (float) Math.sin(offA); + + float cosA = (float) Math.cos(bladeAngle); + float sinA = (float) Math.sin(bladeAngle); + + float lnX = (float) (Math.sin(leanMag) * Math.cos(leanDir)); + float lnY = (float) Math.cos(leanMag); + float lnZ = (float) (Math.sin(leanMag) * Math.sin(leanDir)); + + // Quadratischer Biegungsversatz senkrecht zur Breitenrichtung + float bendStr = (hash(x + bf * 5.1f, z + bf * 8.7f) - 0.5f) * 2f * BEND_FACTOR * h; + float bendX = -sinA * bendStr; + float bendZ = cosA * bendStr; + + int bladeVi = vi + b * (SEGMENTS + 1) * 2; + int bladeIi = ii + b * SEGMENTS * 6; + + for (int s = 0; s <= SEGMENTS; s++) { + float t = (float) s / SEGMENTS; + float hw = baseHW * (float) Math.pow(1.0 - t, 1.4); + + // Wirbelsäulenposition: lineare Neigung + quadratische Biegung + float spX = bx + lnX * h * t + bendX * t * t; + float spY = y + lnY * h * t; + float spZ = bz + lnZ * h * t + bendZ * t * t; + + // Tangente = Ableitung der Wirbelsäule + float tgX = lnX + 2f * bendX * t; + float tgY = lnY; + float tgZ = lnZ + 2f * bendZ * t; + float tgLen = (float) Math.sqrt(tgX*tgX + tgY*tgY + tgZ*tgZ); + if (tgLen > 1e-6f) { tgX /= tgLen; tgY /= tgLen; tgZ /= tgLen; } + + // Normale = Breitenvektor(cosA,0,sinA) × Tangente + float nx = -sinA * tgY; + float ny = sinA * tgX - cosA * tgZ; + float nz = cosA * tgY; + float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; } + + // Normalen Richtung Weltauf kippen → weiches Overhead-Licht + float blend = 0.30f; + nx *= (1f - blend); + ny = ny * (1f - blend) + blend; + nz *= (1f - blend); + nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; } + + int svi = bladeVi + s * 2; + float dry = blade.dryness(); + setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry); + setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry); + + if (s < SEGMENTS) { + int sii = bladeIi + s * 6; + idx[sii] = svi; idx[sii+1] = svi+1; idx[sii+2] = svi+3; + idx[sii+3] = svi; idx[sii+4] = svi+3; idx[sii+5] = svi+2; + } + } + } + } + + /** Deterministischer Pseudo-Zufallswert [0, 1] aus zwei float-Koordinaten. */ + static float hash(float a, float b) { + int ia = Float.floatToRawIntBits(a); + int ib = Float.floatToRawIntBits(b); + int h = ia ^ (ib * 0x9e3779b9); + h ^= h >>> 16; + h *= 0x45d9f3b; + h ^= h >>> 16; + return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF; + } + + private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi, + float x, float y, float z, float nx, float ny, float nz, + float wf, float dryness) { + int pi = vi * 3; + pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z; + + int ni = vi * 3; + nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz; + + int ci = vi * 4; + float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf; + float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf; + float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf; + float dr = DRY_ROOT_COLOR.r + (DRY_TIP_COLOR.r - DRY_ROOT_COLOR.r) * wf; + float dg = DRY_ROOT_COLOR.g + (DRY_TIP_COLOR.g - DRY_ROOT_COLOR.g) * wf; + float db = DRY_ROOT_COLOR.b + (DRY_TIP_COLOR.b - DRY_ROOT_COLOR.b) * wf; + col[ci] = gr + (dr - gr) * dryness; + col[ci+1] = gg + (dg - gg) * dryness; + col[ci+2] = gb + (db - gb) * dryness; + col[ci+3] = 1f; + + int ti = vi * 2; + tex[ti] = wf; tex[ti+1] = 0f; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java b/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java new file mode 100644 index 0000000..5a5df16 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/LocationZoneState.java @@ -0,0 +1,360 @@ +package de.blight.editor.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.asset.AssetManager; +import com.jme3.collision.CollisionResults; +import com.jme3.material.Material; +import com.jme3.math.*; +import com.jme3.renderer.Camera; +import com.jme3.scene.*; +import com.jme3.scene.VertexBuffer; +import com.jme3.terrain.geomipmap.TerrainQuad; +import com.jme3.util.BufferUtils; +import de.blight.common.PlacedLocationZone; +import de.blight.editor.SharedInput; + +import java.nio.FloatBuffer; +import java.util.ArrayList; +import java.util.List; + +public class LocationZoneState extends BaseAppState { + + private static final float LINE_OFFSET_Y = 0.40f; + + private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.2f, 0.8f, 0.4f, 1f); + private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f); + private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.5f, 1f, 0.6f, 1f); + + private final SharedInput input; + private SimpleApplication app; + private Camera cam; + private AssetManager assets; + private Node rootNode; + private TerrainQuad terrain; + + private final List zones = new ArrayList<>(); + private final List zoneGeos = new ArrayList<>(); + private int selectedIdx = -1; + + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private Geometry inProgGeo = null; + private Geometry lastPointMarker = null; + + private List pendingZones = null; + + public LocationZoneState(SharedInput input) { + this.input = input; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application application) { + app = (SimpleApplication) application; + cam = app.getCamera(); + assets = app.getAssetManager(); + rootNode = app.getRootNode(); + } + + @Override protected void cleanup(Application application) { clearAll(); } + + @Override + protected void onEnable() { + if (pendingZones != null) { + loadZones(pendingZones); + pendingZones = null; + } + } + + @Override protected void onDisable() {} + + public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } + + // ── Update ──────────────────────────────────────────────────────────────── + + @Override + public void update(float tpf) { + if (input.activeLayer != SharedInput.LAYER_LOCATION_ZONES) { + if (placing) cancelPoly(); + return; + } + + SharedInput.LocationZoneClick click; + while ((click = input.locationZoneClickQueue.poll()) != null) { + handleClick(click); + } + + PlacedLocationZone pending = input.pendingLocationZone.getAndSet(null); + if (pending != null && selectedIdx >= 0) { + applyProperty(selectedIdx, pending); + } + + if (input.cancelZoneDrawing) { + input.cancelZoneDrawing = false; + if (placing) cancelPoly(); + } + + if (input.deleteLocationZoneRequested) { + input.deleteLocationZoneRequested = false; + if (selectedIdx >= 0) removeZone(selectedIdx); + } + } + + // ── Click handling ──────────────────────────────────────────────────────── + + private void handleClick(SharedInput.LocationZoneClick 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()); + + if (click.rightButton()) { + if (placing) closePoly(); + else deselect(); + return; + } + + if (terrain == null) return; + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() == 0) return; + Vector3f pt = hits.getClosestCollision().getContactPoint(); + float hitX = pt.x, hitZ = pt.z; + + if (placing) { + float[] snapped = snapVertex(hitX, hitZ); + hitX = snapped[0]; + hitZ = snapped[1]; + + if (currX.size() >= 3) { + float dx = hitX - currX.get(0); + float dz = hitZ - currZ.get(0); + if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) { + closePoly(); + return; + } + } + + currX.add(hitX); + currZ.add(hitZ); + updateInProgressGeo(); + } else { + for (int i = 0; i < zones.size(); i++) { + PlacedLocationZone z = zones.get(i); + if (SoundAreaState.pointInPolygon(hitX, hitZ, z.pointsX(), z.pointsZ())) { + selectZone(i); + return; + } + } + deselect(); + placing = true; + currX.clear(); + currZ.clear(); + currX.add(hitX); + currZ.add(hitZ); + updateInProgressGeo(); + } + } + + private float[] snapVertex(float x, float z) { + float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST; + float bx = x, bz = z; + for (PlacedLocationZone zone : zones) { + for (int i = 0; i < zone.pointsX().length; i++) { + float dx = x - zone.pointsX()[i]; + float dz = z - zone.pointsZ()[i]; + float d2 = dx * dx + dz * dz; + if (d2 < bestDist2) { bestDist2 = d2; bx = zone.pointsX()[i]; bz = zone.pointsZ()[i]; } + } + } + for (int i = 0; i < currX.size(); i++) { + float dx = x - currX.get(i); + float dz = z - currZ.get(i); + float d2 = dx * dx + dz * dz; + if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); } + } + return new float[]{bx, bz}; + } + + private void closePoly() { + if (currX.size() < 3) { cancelPoly(); return; } + float[] xs = toArray(currX); + float[] zs = toArray(currZ); + PlacedLocationZone zone = new PlacedLocationZone(xs, zs, ""); + addZone(zone); + selectZone(zones.size() - 1); + cancelPoly(); + } + + private void cancelPoly() { + placing = false; + currX.clear(); + currZ.clear(); + if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; } + if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; } + } + + private void updateInProgressGeo() { + if (inProgGeo != null) rootNode.detachChild(inProgGeo); + int n = currX.size(); + if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; } + inProgGeo = buildLineGeo("loczone_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip); + rootNode.attachChild(inProgGeo); + updateLastPointMarker(); + } + + private void updateLastPointMarker() { + if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; } + if (currX.isEmpty()) return; + + float x = currX.get(currX.size() - 1); + float z = currZ.get(currZ.size() - 1); + float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f; + float s = 1.5f; + + FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3); + buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s); + buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s); + buf.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, buf); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f)); + mat.getAdditionalRenderState().setLineWidth(3f); + + lastPointMarker = new Geometry("loczone_lastpoint", mesh); + lastPointMarker.setMaterial(mat); + rootNode.attachChild(lastPointMarker); + } + + // ── Selection ───────────────────────────────────────────────────────────── + + private void selectZone(int idx) { + if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) { + zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + } + selectedIdx = idx; + zoneGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + publishSelection(idx); + } + + private void deselect() { + if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) { + zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); + } + selectedIdx = -1; + input.selectedLocationZoneInfo = null; + input.locationZoneSelectionChanged = true; + } + + private void publishSelection(int idx) { + PlacedLocationZone z = zones.get(idx); + String triggersJson = de.blight.common.model.trigger.TriggerIO.serializeList(z.triggers()); + input.selectedLocationZoneInfo = idx + "|" + z.nameId() + "|" + triggersJson; + input.locationZoneSelectionChanged = true; + } + + // ── Add / Remove / Apply ────────────────────────────────────────────────── + + private void addZone(PlacedLocationZone zone) { + zones.add(zone); + List xs = toList(zone.pointsX()); + List zs = toList(zone.pointsZ()); + Geometry geo = buildLineGeo("loczone_" + (zones.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop); + rootNode.attachChild(geo); + zoneGeos.add(geo); + } + + private void removeZone(int idx) { + rootNode.detachChild(zoneGeos.get(idx)); + zones.remove(idx); + zoneGeos.remove(idx); + selectedIdx = -1; + input.selectedLocationZoneInfo = null; + input.locationZoneSelectionChanged = true; + } + + private void clearAll() { + for (Geometry g : zoneGeos) rootNode.detachChild(g); + zones.clear(); + zoneGeos.clear(); + cancelPoly(); + selectedIdx = -1; + } + + private void applyProperty(int idx, PlacedLocationZone updated) { + if (updated.pointsX().length == 0) { + PlacedLocationZone existing = zones.get(idx); + zones.set(idx, new PlacedLocationZone( + existing.pointsX(), existing.pointsZ(), + updated.nameId(), + updated.triggers() != null ? updated.triggers() : existing.triggers())); + } else { + zones.set(idx, updated); + } + publishSelection(idx); + } + + private Geometry buildLineGeo(String name, List xs, List zs, + ColorRGBA color, Mesh.Mode mode) { + int n = xs.size(); + FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3); + for (int i = 0; i < n; i++) { + float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f; + posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i)); + } + posBuffer.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(mode); + mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", color); + mat.getAdditionalRenderState().setLineWidth(2f); + + Geometry geo = new Geometry(name, mesh); + geo.setMaterial(mat); + return geo; + } + + // ── Save / Load ─────────────────────────────────────────────────────────── + + public List getPlacedZones() { + return new ArrayList<>(zones); + } + + public void loadZones(List loaded) { + if (rootNode == null) { + pendingZones = new ArrayList<>(loaded); + return; + } + clearAll(); + for (PlacedLocationZone z : loaded) addZone(z); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static float[] toArray(List list) { + float[] a = new float[list.size()]; + for (int i = 0; i < list.size(); i++) a[i] = list.get(i); + return a; + } + + private static List toList(float[] arr) { + List l = new ArrayList<>(arr.length); + for (float f : arr) l.add(f); + return l; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java new file mode 100644 index 0000000..496d9e6 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java @@ -0,0 +1,353 @@ +package de.blight.editor.state; + +import com.jme3.app.Application; +import com.jme3.app.SimpleApplication; +import com.jme3.app.state.BaseAppState; +import com.jme3.bounding.BoundingBox; +import com.jme3.light.AmbientLight; +import com.jme3.light.DirectionalLight; +import com.jme3.material.Material; +import com.jme3.math.*; +import com.jme3.renderer.Camera; +import com.jme3.scene.*; +import com.jme3.scene.shape.Box; +import com.jme3.util.BufferUtils; +import de.blight.editor.SharedInput; + +import java.nio.FloatBuffer; + +/** + * Modell-Editor-Modus: zeigt ein einzelnes Modell in isolierter Vorschau + * mit Referenzgitter, Orbit-Kamera und Echtzeit-Skalierung. + * + * Aktivierung: activeLayer == LAYER_MODEL_EDITOR + modelEditorOpenPath setzen. + */ +public class ModelEditorState extends BaseAppState { + + // ── Orbit-Kamera ───────────────────────────────────────────────────────── + private static final float ORBIT_SENS = 0.4f; // Grad / Pixel Maus-Delta + private static final float ZOOM_FACTOR = 0.1f; + private static final float PITCH_MIN = 2f; + private static final float PITCH_MAX = 88f; + + private float orbitYaw = 30f; + private float orbitPitch = 25f; + private float orbitDist = 5f; + + // ── Szene ───────────────────────────────────────────────────────────────── + private SimpleApplication app; + private Camera cam; + + /** Root aller Preview-Objekte (an rootNode gehängt). */ + private Node previewRoot; + /** Der geladene / skalierte Modell-Node. */ + private Node modelWrapper; + /** Gitter-Geometry. */ + private Geometry gridGeo; + + // gespeicherter Kamerazustand aus dem Editor-Modus + private Vector3f savedCamPos; + private Quaternion savedCamRot; + + private final SharedInput input; + private String currentPath = null; + + // Mittelpunkt für Orbit (Bounding-Box-Zentrum des Modells) + private Vector3f orbitCenter = Vector3f.ZERO.clone(); + + public ModelEditorState(SharedInput input) { + this.input = input; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @Override + protected void initialize(Application application) { + app = (SimpleApplication) application; + cam = app.getCamera(); + } + + @Override + protected void cleanup(Application application) { + exitPreview(); + } + + @Override + protected void onEnable() {} + + @Override + protected void onDisable() {} + + // ── Update ──────────────────────────────────────────────────────────────── + + @Override + public void update(float tpf) { + if (input.activeLayer != SharedInput.LAYER_MODEL_EDITOR) { + if (previewRoot != null) exitPreview(); + return; + } + + // Modell laden + String openPath = input.modelEditorOpenPath; + if (openPath != null) { + input.modelEditorOpenPath = null; + loadModel(openPath); + } + + if (previewRoot == null) return; + + // Schließen + if (input.modelEditorCloseRequest) { + input.modelEditorCloseRequest = false; + exitPreview(); + input.activeLayer = 0; + return; + } + + // Skalierung + if (input.modelEditorScaleChanged) { + input.modelEditorScaleChanged = false; + applyScale(input.modelEditorScaleX, + input.modelEditorScaleY, + input.modelEditorScaleZ); + } + + // Pivot-Y + if (input.modelEditorPivotChanged) { + input.modelEditorPivotChanged = false; + applyPivot(input.modelEditorPivotY); + } + + // Orbit-Kamera: Maus-Delta (Middle-Button zieht Kamera, Right-Button dreht Orbit) + int[] delta = input.consumeMouseDelta(); + if (delta[0] != 0 || delta[1] != 0) { + orbitYaw += delta[0] * ORBIT_SENS; + orbitPitch -= delta[1] * ORBIT_SENS; + orbitPitch = FastMath.clamp(orbitPitch, PITCH_MIN, PITCH_MAX); + } + + // Scroll → Zoom + int scroll = input.scrollAccum.getAndSet(0); + if (scroll != 0) { + orbitDist = Math.max(0.5f, orbitDist - scroll * orbitDist * ZOOM_FACTOR); + } + + applyOrbitCamera(); + + previewRoot.updateLogicalState(tpf); + previewRoot.updateGeometricState(); + } + + // ── Modell laden ────────────────────────────────────────────────────────── + + private void loadModel(String assetPath) { + if (previewRoot == null) enterPreview(); + + // Altes Modell entfernen + if (modelWrapper != null) { + previewRoot.detachChild(modelWrapper); + modelWrapper = null; + } + + currentPath = assetPath; + + modelWrapper = new Node("model_wrapper"); + try { + Spatial model = app.getAssetManager().loadModel(assetPath); + stripControls(model); + modelWrapper.attachChild(model); + } catch (Exception e) { + // Fallback: roter Quader + Geometry box = new Geometry("error_box", new Box(0.5f, 0.5f, 0.5f)); + Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", ColorRGBA.Red); + box.setMaterial(mat); + modelWrapper.attachChild(box); + } + + // Skalierung aus SharedInput anwenden + applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ); + applyPivot(input.modelEditorPivotY); + + previewRoot.attachChild(modelWrapper); + rebuildGrid(); + updateBounds(); + + // Orbit-Distanz automatisch auf Modellgröße setzen + BoundingBox bb = getBoundingBox(); + if (bb != null) { + float maxExt = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())); + orbitDist = maxExt * 3f; + orbitCenter = bb.getCenter().clone(); + } + } + + private void applyScale(float sx, float sy, float sz) { + if (modelWrapper == null) return; + if (!modelWrapper.getChildren().isEmpty()) { + Spatial child = modelWrapper.getChild(0); + child.setLocalScale(sx, sy, sz); + child.updateGeometricState(); + } + updateBounds(); + rebuildGrid(); + } + + private void applyPivot(float pivotY) { + if (modelWrapper == null) return; + if (!modelWrapper.getChildren().isEmpty()) { + Spatial child = modelWrapper.getChild(0); + // Boundingbox nach Skalierung + child.updateGeometricState(); + BoundingBox bb = getBoundingBox(); + if (bb != null) { + // Modell so verschieben, dass sein Boden auf Y=0 liegt, + Pivot-Versatz + float bottomY = bb.getCenter().y - bb.getYExtent(); + child.setLocalTranslation(0, -bottomY + pivotY, 0); + } + } + updateBounds(); + } + + // ── Preview ein-/ausschalten ────────────────────────────────────────────── + + private void enterPreview() { + savedCamPos = cam.getLocation().clone(); + savedCamRot = cam.getRotation().clone(); + + app.getRootNode().setCullHint(Spatial.CullHint.Always); + + previewRoot = new Node("model_editor_preview"); + // Beleuchtung analog zum Animations-Editor + previewRoot.addLight(new DirectionalLight( + new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(), + new ColorRGBA(1.8f, 1.7f, 1.4f, 1f))); + previewRoot.addLight(new DirectionalLight( + new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(), + new ColorRGBA(0.45f, 0.5f, 0.7f, 1f))); + previewRoot.addLight(new AmbientLight(new ColorRGBA(0.4f, 0.4f, 0.45f, 1f))); + + // Direkt am Viewport registrieren – NICHT als Kind von rootNode, + // da rootNode.CullHint=Always die komplette Child-Hierarchie ausblendet. + app.getViewPort().attachScene(previewRoot); + app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f)); + + orbitYaw = 30f; + orbitPitch = 25f; + } + + private void exitPreview() { + if (previewRoot != null) { + app.getViewPort().detachScene(previewRoot); + previewRoot = null; + modelWrapper = null; + gridGeo = null; + } + app.getRootNode().setCullHint(Spatial.CullHint.Inherit); + + if (savedCamPos != null) { + cam.setLocation(savedCamPos); + cam.setRotation(savedCamRot); + } + app.getViewPort().setBackgroundColor(ColorRGBA.Black); + currentPath = null; + } + + // ── Gitter ──────────────────────────────────────────────────────────────── + + private void rebuildGrid() { + if (gridGeo != null) { + previewRoot.detachChild(gridGeo); + gridGeo = null; + } + + BoundingBox bb = getBoundingBox(); + float halfSize = 5f; + if (bb != null) { + float maxExt = Math.max(bb.getXExtent(), bb.getZExtent()); + halfSize = Math.max(5f, (float) Math.ceil(maxExt * 1.5f / 5f) * 5f); + } + + int majorStep = 1; + int n = (int)(halfSize * 2 / majorStep); + int lines = (n + 1) * 2; // horizontal + vertikal + FloatBuffer pos = BufferUtils.createFloatBuffer(lines * 2 * 3); + FloatBuffer col = BufferUtils.createFloatBuffer(lines * 2 * 4); + + for (int i = 0; i <= n; i++) { + float coord = -halfSize + i * majorStep; + boolean isMajor5 = (Math.abs(Math.round(coord)) % 5 == 0); + boolean isMajor10 = (Math.abs(Math.round(coord)) % 10 == 0); + float bright = isMajor10 ? 0.70f : isMajor5 ? 0.45f : 0.22f; + + // Linie parallel zur Z-Achse + pos.put(coord).put(0).put(-halfSize); + pos.put(coord).put(0).put( halfSize); + col.put(bright).put(bright).put(bright).put(1f); + col.put(bright).put(bright).put(bright).put(1f); + + // Linie parallel zur X-Achse + pos.put(-halfSize).put(0).put(coord); + pos.put( halfSize).put(0).put(coord); + col.put(bright).put(bright).put(bright).put(1f); + col.put(bright).put(bright).put(bright).put(1f); + } + pos.flip(); + col.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Color, 4, col); + mesh.updateBound(); + + Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setBoolean("VertexColor", true); + + gridGeo = new Geometry("grid", mesh); + gridGeo.setMaterial(mat); + previewRoot.attachChild(gridGeo); + } + + // ── Bounds publizieren ───────────────────────────────────────────────────── + + private void updateBounds() { + BoundingBox bb = getBoundingBox(); + if (bb != null) { + input.modelEditorBoundsW = bb.getXExtent() * 2f; + input.modelEditorBoundsH = bb.getYExtent() * 2f; + input.modelEditorBoundsD = bb.getZExtent() * 2f; + input.modelEditorBoundsReady = true; + orbitCenter = bb.getCenter().clone(); + } + } + + private BoundingBox getBoundingBox() { + if (modelWrapper == null) return null; + modelWrapper.updateGeometricState(); + com.jme3.bounding.BoundingVolume bv = modelWrapper.getWorldBound(); + return (bv instanceof BoundingBox) ? (BoundingBox) bv : null; + } + + // ── Orbit-Kamera ───────────────────────────────────────────────────────── + + private void applyOrbitCamera() { + float yawRad = (float) Math.toRadians(orbitYaw); + float pitchRad = (float) Math.toRadians(orbitPitch); + float cosPitch = (float) Math.cos(pitchRad); + float x = orbitCenter.x + orbitDist * cosPitch * (float) Math.sin(yawRad); + float y = orbitCenter.y + orbitDist * (float) Math.sin(pitchRad); + float z = orbitCenter.z + orbitDist * cosPitch * (float) Math.cos(yawRad); + cam.setLocation(new Vector3f(x, y, z)); + cam.lookAt(orbitCenter, Vector3f.UNIT_Y); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static void stripControls(Spatial s) { + while (s.getNumControls() > 0) s.removeControl(s.getControl(0)); + if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls); + } + + public String getCurrentPath() { return currentPath; } +} 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 index 7f82419..d6422d2 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/RiverEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/RiverEditorState.java @@ -35,9 +35,8 @@ 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. + * Editor-State für das Wasserfall-Werkzeug. + * Erlaubt das interaktive Platzieren von Wasserfall-Kontrollpunkten auf dem Terrain. */ public class RiverEditorState extends BaseAppState { @@ -52,19 +51,16 @@ public class RiverEditorState extends BaseAppState { 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 + private final List> rivers = new ArrayList<>(); + private final List> pointGeos = new ArrayList<>(); + private final List ribbonGeos = new ArrayList<>(); + private int activeRiver = -1; + private int selectedRiver = -1; public RiverEditorState(SharedInput input) { this.input = input; } - // ── Lifecycle ───────────────────────────────────────────────────────────── - @Override protected void initialize(Application app) { this.app = (SimpleApplication) app; @@ -76,76 +72,48 @@ public class RiverEditorState extends BaseAppState { List> saved = RiverIO.load(); if (!saved.isEmpty()) loadPlacedRivers(saved); } catch (Exception e) { - log.error("Flüsse nicht ladbar", e); + log.error("Wasserfälle nicht ladbar", e); } } @Override - protected void cleanup(Application app) { - clearAll(); - } + protected void cleanup(Application app) { clearAll(); } @Override - protected void onEnable() { - setCullHintAll(Spatial.CullHint.Inherit); - } + protected void onEnable() { setCullHintAll(Spatial.CullHint.Inherit); } @Override - protected void onDisable() { - setCullHintAll(Spatial.CullHint.Always); - } + 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; - // Beim ersten Setzen sofort alle geladenen Flüsse ins Terrain graben - if (te != null && !rivers.isEmpty()) { - te.reapplyAllRivers(getPlacedRivers()); - } - } - - // ── Update ──────────────────────────────────────────────────────────────── + public void setTerrain(TerrainQuad t) { this.terrain = t; } @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_RIVERS) return; + if (input.activeLayer != SharedInput.LAYER_WATERFALL) return; - // Undo: letzten Punkt des aktiven Flusses entfernen - if (input.undoRiverPointRequested) { - input.undoRiverPointRequested = false; + if (input.undoWaterfallPointRequested) { + input.undoWaterfallPointRequested = false; undoLastPoint(); } - // Selektierten Fluss löschen - if (input.deleteRiverRequested) { - input.deleteRiverRequested = false; + if (input.deleteWaterfallRequested) { + input.deleteWaterfallRequested = false; if (selectedRiver >= 0) { removeRiver(selectedRiver); selectedRiver = -1; - input.selectedRiverInfo = null; - input.riverSelectionChanged = true; + input.selectedWaterfallInfo = null; + input.waterfallSelectionChanged = true; } } - // Click-Queue verarbeiten - SharedInput.RiverClick click; - while ((click = input.riverClickQueue.poll()) != null) { + SharedInput.WaterfallClick click; + while ((click = input.waterfallClickQueue.poll()) != null) { handleClick(click); } } - // ── Click-Verarbeitung ──────────────────────────────────────────────────── - - private void handleClick(SharedInput.RiverClick click) { + private void handleClick(SharedInput.WaterfallClick click) { if (click.rightButton()) { - // Rechtsklick: aktiven Fluss abschließen finalizeActiveRiver(); return; } @@ -165,26 +133,20 @@ public class RiverEditorState extends BaseAppState { 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); + RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, input.waterfallNewWidth, RiverPoint.WATERFALL_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<>()); @@ -192,38 +154,24 @@ public class RiverEditorState extends BaseAppState { activeRiver = rivers.size() - 1; } - List current = rivers.get(activeRiver); - if (!current.isEmpty()) { - 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()); - } - 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() { if (activeRiver >= 0 && activeRiver < rivers.size()) { if (rivers.get(activeRiver).size() < 2) { - removeRiver(activeRiver); // löst intern reapplyAllRivers aus + removeRiver(activeRiver); activeRiver = -1; return; } } activeRiver = -1; - // Terrain erst jetzt graben — vollständiger Spline, non-destructive - if (terrainEditor != null) { - terrainEditor.reapplyAllRivers(getPlacedRivers()); - } } private void undoLastPoint() { @@ -244,12 +192,9 @@ public class RiverEditorState extends BaseAppState { } } - // ── 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); @@ -264,14 +209,8 @@ public class RiverEditorState extends BaseAppState { 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(); @@ -294,28 +233,19 @@ public class RiverEditorState extends BaseAppState { 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)); - } + if (river != null && river.size() >= 2) copy.add(new ArrayList<>(river)); } return copy; } - // ── Hilfsmethoden ───────────────────────────────────────────────────────── - private void clearAll() { - for (List geos : pointGeos) { + for (List geos : pointGeos) if (geos != null) for (Geometry g : geos) rootNode.detachChild(g); - } - for (Geometry ribbon : ribbonGeos) { + for (Geometry ribbon : ribbonGeos) if (ribbon != null) rootNode.detachChild(ribbon); - } rivers.clear(); pointGeos.clear(); ribbonGeos.clear(); @@ -335,32 +265,28 @@ public class RiverEditorState extends BaseAppState { else if (activeRiver == idx) activeRiver = -1; if (selectedRiver > idx) selectedRiver--; else if (selectedRiver == idx) selectedRiver = -1; - // Terrain revertieren und verbleibende Flüsse neu graben - if (terrainEditor != null) { - terrainEditor.reapplyAllRivers(getPlacedRivers()); - } } 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)); + old.getMaterial().setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 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)); + ribbon.getMaterial().setColor("Color", new ColorRGBA(1.0f, 0.9f, 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); + input.selectedWaterfallInfo = idx + "|" + pts.size() + "|" + + String.format(java.util.Locale.ROOT, "%.1f", len); } else { - input.selectedRiverInfo = null; + input.selectedWaterfallInfo = null; } - input.riverSelectionChanged = true; + input.waterfallSelectionChanged = true; } private static float computeLength(List pts) { @@ -373,7 +299,6 @@ public class RiverEditorState extends BaseAppState { 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; @@ -391,25 +316,18 @@ public class RiverEditorState extends BaseAppState { } private void setCullHintAll(Spatial.CullHint hint) { - for (List geos : pointGeos) { + for (List geos : pointGeos) if (geos != null) for (Geometry g : geos) g.setCullHint(hint); - } - for (Geometry ribbon : ribbonGeos) { + for (Geometry ribbon : ribbonGeos) if (ribbon != null) ribbon.setCullHint(hint); - } } private Geometry buildPointGeo(RiverPoint pt) { - // Radius = halbe Flussbreite, damit der Marker die Breite sichtbar macht float r = Math.max(0.4f, pt.width() * 0.5f); Sphere sphere = new Sphere(10, 10, r); - Geometry geo = new Geometry("riverPoint", sphere); + Geometry geo = new Geometry("waterfallPoint", 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, 0.7f)); - } else { - mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 0.7f)); - } + mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 0.7f)); mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); geo.setQueueBucket(RenderQueue.Bucket.Transparent); geo.setShadowMode(RenderQueue.ShadowMode.Off); @@ -418,9 +336,6 @@ public class RiverEditorState extends BaseAppState { return geo; } - /** - * Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau). - */ Geometry buildRibbon(List pts) { pts = RiverSpline.subdivide(pts); int n = pts.size(); @@ -437,11 +352,8 @@ public class RiverEditorState extends BaseAppState { 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(); + 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); } @@ -455,8 +367,7 @@ public class RiverEditorState extends BaseAppState { 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); + 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); @@ -466,7 +377,7 @@ public class RiverEditorState extends BaseAppState { if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f); float halfW = pt.width() * 0.5f; - float px = pt.x(), py = pt.y() - 0.45f, pz = pt.z(); // 0,5m Absenkung + 0,05m Offset + float px = pt.x(), py = pt.y() - 0.45f, pz = pt.z(); pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW); norm.put(0f).put(1f).put(0f); @@ -478,11 +389,10 @@ public class RiverEditorState extends BaseAppState { } for (int i = 0; i < n - 1; i++) { - int v0 = 2 * i, v1 = 2 * i + 1, v2 = 2 * i + 2, v3 = 2 * i + 3; + 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(); @@ -493,9 +403,9 @@ public class RiverEditorState extends BaseAppState { mesh.updateBound(); mesh.updateCounts(); - Geometry geo = new Geometry("riverRibbon", mesh); + Geometry geo = new Geometry("waterfallRibbon", 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.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 0.6f)); mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); geo.setQueueBucket(RenderQueue.Bucket.Transparent); 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 cbb3294..b7063d9 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 @@ -538,12 +538,43 @@ public class SceneObjectState extends BaseAppState { // ── Objekt platzieren ──────────────────────────────────────────────────── private void placeObject(String modelPath, float wx, float wz, float wy, float rotY) { - SceneObject so = new SceneObject(modelPath, wx, wz, wy, false); + // Meta-Defaults anwenden wenn vorhanden + de.blight.common.ModelMeta meta = null; + if (!modelPath.startsWith("@")) { + Path j3o = ASSET_ROOT.resolve(modelPath); + if (j3o.toFile().exists()) + meta = de.blight.common.ModelMetaIO.load(j3o); + } + + float defaultScale = 1f; + float placementOffY = 0f; + boolean defaultSolid = false; + boolean defaultCast = true; + boolean defaultReceive= true; + if (meta != null) { + // Zufällige Skalierung wenn Min ≠ Max + if (meta.randomScaleMin() != meta.randomScaleMax()) { + float range = meta.randomScaleMax() - meta.randomScaleMin(); + defaultScale = meta.randomScaleMin() + (float) Math.random() * range; + } else { + defaultScale = meta.scaleX(); // uniform assumed + } + placementOffY = meta.placementOffsetY(); + defaultSolid = meta.solid(); + defaultCast = meta.castShadow(); + defaultReceive = meta.receiveShadow(); + } + + SceneObject so = new SceneObject(modelPath, wx, wz, wy + placementOffY, defaultSolid); so.setRotation(0f, rotY, 0f); + so.setScale(defaultScale); + so.castShadow = defaultCast; + so.receiveShadow = defaultReceive; objects.add(so); animClips.add(""); - Node node = loadModelNode(modelPath, wx, wy, wz); + Node node = loadModelNode(modelPath, wx, wy + placementOffY, wz); + node.setLocalScale(defaultScale); if (rotY != 0f) { Quaternion q = new Quaternion(); q.fromAngleAxis(rotY, Vector3f.UNIT_Y); 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 5675dd1..3b8715a 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 @@ -27,8 +27,18 @@ import com.jme3.util.BufferUtils; import com.jme3.util.SkyFactory; import de.blight.common.EmitterIO; import de.blight.common.GrassTuftIO; +import de.blight.common.GrassVertexIO; import de.blight.common.LightIO; -import de.blight.common.MusicAreaIO; +import de.blight.common.AreaIO; +import de.blight.common.LocationZoneIO; +import de.blight.common.PlacedArea; +import de.blight.common.PlacedEmitter; +import de.blight.common.PlacedLight; +import de.blight.common.PlacedLocationZone; +import de.blight.common.PlacedModel; +import de.blight.common.PlacedSoundArea; +import de.blight.common.PlacedWater; +import de.blight.common.RiverPoint; import de.blight.common.SoundAreaIO; import de.blight.common.WaterBodyIO; import de.blight.common.MapData; @@ -50,15 +60,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Properties; -import java.util.Set; - -import de.blight.common.RiverPoint; -import de.blight.common.RiverSpline; public class TerrainEditorState extends BaseAppState { @@ -89,22 +92,29 @@ public class TerrainEditorState extends BaseAppState { private TerrainQuad terrain; private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten - private float[] originalHeightMap; // Unveränderter Stand vor allen Fluss-Modifikationen - private byte[] originalSplatR, originalSplatG, originalSplatB, originalSplatA; - private final HashSet modifiedVertices = new HashSet<>(); // Alle durch Flüsse veränderten Vertices private Geometry brushIndicator; private Geometry livePlayerMarker; private PlacedObjectState placedObjectState; + private GrassVertexState grassVertexState; private SceneObjectState sceneObjState; private LightState lightState; private EmitterState emitterState; private WaterBodyState waterBodyState; private SoundAreaState soundAreaState; - private MusicAreaState musicAreaState; + private AreaState areaState; + private LocationZoneState locationZoneState; private RiverEditorState riverEditorState; private MapData loadedMapData; private Node axesGizmo; - private boolean wireframeMode = false; + private boolean wireframeMode = false; + private boolean topologyMode = false; + private final java.util.concurrent.ExecutorService saveExecutor = + java.util.concurrent.Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "blight-save"); + t.setDaemon(true); + return t; + }); + private volatile boolean saveInProgress = false; // ── Splatmap (Slots 1-4) ───────────────────────────────────────────────── private byte[] splatR, splatG, splatB, splatA; @@ -205,8 +215,7 @@ public class TerrainEditorState extends BaseAppState { private void buildScene() { input.loadingStatus = "Lade Terrain..."; terrain = buildTerrain(); - cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder - originalHeightMap = cachedHeightMap.clone(); // Snapshot vor allen Fluss-Modifikationen + cachedHeightMap = terrain.getHeightMap(); rootNode.attachChild(terrain); input.loadingStatus = "Lade platzierte Objekte..."; @@ -214,6 +223,11 @@ public class TerrainEditorState extends BaseAppState { placedObjectState.setTerrain(terrain); app.getStateManager().attach(placedObjectState); + input.loadingStatus = "Lade Vertex-Gras..."; + grassVertexState = new GrassVertexState(input); + grassVertexState.setTerrain(terrain); + app.getStateManager().attach(grassVertexState); + sceneObjState = app.getStateManager().getState(SceneObjectState.class); if (sceneObjState != null) { sceneObjState.setTerrain(terrain); @@ -253,7 +267,6 @@ public class TerrainEditorState extends BaseAppState { 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); @@ -274,22 +287,33 @@ public class TerrainEditorState extends BaseAppState { } } - input.loadingStatus = "Lade Musikbereiche..."; - musicAreaState = app.getStateManager().getState(MusicAreaState.class); - if (musicAreaState != null) { - musicAreaState.setTerrain(terrain); + input.loadingStatus = "Lade Bereiche..."; + areaState = app.getStateManager().getState(AreaState.class); + if (areaState != null) { + areaState.setTerrain(terrain); try { - var musicAreas = MusicAreaIO.load(); - if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas); + var areas = AreaIO.load(); + if (!areas.isEmpty()) areaState.loadAreas(areas); } catch (IOException e) { - log.error("Musik-Bereiche nicht ladbar", e); + log.error("Bereiche nicht ladbar", e); + } + } + + input.loadingStatus = "Lade Location-Zonen..."; + locationZoneState = app.getStateManager().getState(LocationZoneState.class); + if (locationZoneState != null) { + locationZoneState.setTerrain(terrain); + try { + var zones = LocationZoneIO.load(); + if (!zones.isEmpty()) locationZoneState.loadZones(zones); + } catch (IOException e) { + log.error("Location-Zonen nicht ladbar", e); } } riverEditorState = app.getStateManager().getState(RiverEditorState.class); if (riverEditorState != null) { riverEditorState.setTerrain(terrain); - riverEditorState.setTerrainEditor(this); } input.loadingStatus = "Baue Szene..."; @@ -401,28 +425,8 @@ public class TerrainEditorState extends BaseAppState { upperSplatA = new byte[SPLAT_SIZE * SPLAT_SIZE]; } - // Snapshot vor allen Fluss-Modifikationen - // Fluss-Farbe aus dem A-Kanal rückrechnen: A-Kanal ist ausschließlich für Flüsse. - // Alte Saves können dort nicht-null-Werte haben. Diese jetzt aus dem Original entfernen, - // damit reapplyAllRivers immer von einer sauberen Basis aus malt. - originalSplatR = splatR.clone(); - originalSplatG = splatG.clone(); - originalSplatB = splatB.clone(); - originalSplatA = splatA.clone(); - for (int i = 0; i < originalSplatA.length; i++) { - int a = originalSplatA[i] & 0xFF; - if (a > 0) { - float ps = a / 255f; - float denom = 1f - ps; - // R-Kanal zurückrechnen (war gedimmt: R_painted = R_orig * (1-ps)) - originalSplatR[i] = denom < 0.01f ? (byte) 255 - : (byte) Math.min(255, Math.round((originalSplatR[i] & 0xFF) / denom)); - // G/B wurden von alten Fluss-Saves ggf. auf Nicht-Null gesetzt → bereinigen - originalSplatG[i] = 0; - originalSplatB[i] = 0; - originalSplatA[i] = 0; - } - } + // A-Kanal aus alten Saves bereinigen (war für Fluss-Textur; nicht mehr verwendet) + Arrays.fill(splatA, (byte) 0); splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4); for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) { @@ -694,6 +698,14 @@ public class TerrainEditorState extends BaseAppState { terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode); } + // Topologie-Overlay + int topoReq = input.topologyRequest; + if (topoReq != 0) { + input.topologyRequest = 0; + topologyMode = (topoReq == 1); + setTopologyOverlay(topologyMode); + } + // Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert if (input.terrainTexturesChanged || input.terrainNormalMapsChanged || input.upperTexturesChanged || input.upperNormalMapsChanged) { @@ -712,6 +724,48 @@ public class TerrainEditorState extends BaseAppState { private static final int MAX_EDITS_PER_FRAME = 2; + private Node topoOverlayNode = null; + + private void setTopologyOverlay(boolean enable) { + SimpleApplication sa = (SimpleApplication) getApplication(); + if (!enable) { + if (topoOverlayNode != null) { + sa.getRootNode().detachChild(topoOverlayNode); + topoOverlayNode = null; + } + return; + } + if (terrain == null) return; + if (topoOverlayNode != null) return; + + Material mat = new Material(sa.getAssetManager(), "MatDefs/Topology.j3md"); + mat.setFloat("Interval", 10f); + mat.setFloat("LineWidth", 0.12f); + mat.setFloat("Opacity", 0.55f); + mat.getAdditionalRenderState().setDepthTest(true); + mat.getAdditionalRenderState().setDepthWrite(false); + mat.getAdditionalRenderState().setFaceCullMode(com.jme3.material.RenderState.FaceCullMode.Off); + + topoOverlayNode = new Node("topologyOverlay"); + buildTopoOverlay(terrain, topoOverlayNode, mat); + sa.getRootNode().attachChild(topoOverlayNode); + } + + private static void buildTopoOverlay(com.jme3.scene.Spatial spatial, Node target, Material mat) { + if (spatial instanceof Geometry geo) { + Geometry overlay = new Geometry("topo_" + geo.getName(), geo.getMesh()); + overlay.setLocalTransform(geo.getWorldTransform()); + overlay.setMaterial(mat); + overlay.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent); + overlay.setShadowMode(com.jme3.renderer.queue.RenderQueue.ShadowMode.Off); + target.attachChild(overlay); + } else if (spatial instanceof Node node) { + for (Spatial child : node.getChildren()) { + buildTopoOverlay(child, target, mat); + } + } + } + private void processTextureEdits() { SharedInput.TextureEdit edit; int processed = 0; @@ -754,526 +808,99 @@ public class TerrainEditorState extends BaseAppState { return h != null ? h : 0f; } - // ── Flussbett graben ────────────────────────────────────────────────────── - - // Wasser liegt 50 cm unter den Kontrollpunkten - private static final float WATER_SINK = 0.5f; - // Tiefste Stelle des Flussbetts: 1 m unter Wasseroberfläche - private static final float BED_DEPTH = 1.0f; - // Bettbreite = Flussbreite × (1 + 2×0.25) = 1.5× — je 25 % der Breite als Böschung - private static final float BED_EXTRA = 0.25f; - // Mindest-Halbbreite 4 m (= 8 m gesamt) - private static final float MIN_HALF_WIDTH = 4.0f; - - /** - * Setzt das Terrain auf den Original-Zustand zurück und gräbt ALLE übergebenen - * Flüsse in einem einzigen terrain.adjustHeight()-Aufruf neu. - * Aufzurufen wenn ein Fluss fertiggestellt oder gelöscht wird. - */ - public void reapplyAllRivers(List> allRivers) { - if (terrain == null || cachedHeightMap == null) return; - - // 1. Alle zuvor durch Flüsse veränderten Vertices auf Original zurücksetzen - if (!modifiedVertices.isEmpty()) { - List resetLocs = new ArrayList<>(modifiedVertices.size()); - List resetDeltas = new ArrayList<>(modifiedVertices.size()); - for (int vidx : modifiedVertices) { - float orig = originalHeightMap[vidx]; - float curH = cachedHeightMap[vidx]; - if (curH != orig) { - int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE; - resetLocs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f, - vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f)); - resetDeltas.add(orig - curH); - } - cachedHeightMap[vidx] = orig; - } - modifiedVertices.clear(); - if (!resetLocs.isEmpty()) { - terrain.adjustHeight(resetLocs, resetDeltas); - terrain.updateModelBound(); - } - } - - // 2. Splatmap auf Original zurücksetzen - if (splatR != null) { - System.arraycopy(originalSplatR, 0, splatR, 0, splatR.length); - System.arraycopy(originalSplatG, 0, splatG, 0, splatG.length); - System.arraycopy(originalSplatB, 0, splatB, 0, splatB.length); - System.arraycopy(originalSplatA, 0, splatA, 0, splatA.length); - for (int i = 0; i < splatR.length; i++) { - int bi = i * 4; - splatBuf.put(bi, splatR[i]); - splatBuf.put(bi + 1, splatG[i]); - splatBuf.put(bi + 2, splatB[i]); - splatBuf.put(bi + 3, splatA[i]); - } - splatBuf.rewind(); - splatImage.setUpdateNeeded(); - } - - if (allRivers == null || allRivers.isEmpty()) return; - - // 3. Alle Flüsse in einem Batch berechnen und anwenden - HashMap channelTargets = new HashMap<>(); - HashMap bankTargets = new HashMap<>(); - List> allSplined = new ArrayList<>(allRivers.size()); - - for (List river : allRivers) { - if (river == null || river.size() < 2) continue; - List splined = RiverSpline.subdivide(river); - allSplined.add(splined); - collectRiverTargets(splined, channelTargets, bankTargets); - } - - applyTargets(channelTargets); - applyBankTargets(bankTargets, channelTargets.keySet()); - - // 4. Splatmap für alle Flüsse neu malen - if (splatR != null) { - for (List splined : allSplined) paintRiverSplat(splined); - } - } - - /** - * Berechnet Tiefenziele für alle Vertices entlang eines gesplineten Flusses. - * - * Profil (gemessen von Wasseroberfläche = waterY − WATER_SINK): - * dist ≤ halfWidth → flacher Kanalboden, BED_DEPTH tief - * halfWidth < dist ≤ bedHW → linearer Anstieg von BED_DEPTH → 0 - * dist > bedHW → kein Eingriff - */ - private void collectRiverTargets(List splined, - HashMap channelTargets, - HashMap bankTargets) { - for (int si = 1; si < splined.size(); si++) { - RiverPoint pa = splined.get(si - 1); - RiverPoint pb = splined.get(si); - - float ax = pa.x(), ay = pa.y(), az = pa.z(); - float bx = pb.x(), by = pb.y(), bz = pb.z(); - float halfWidth = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f); - float bedHW = halfWidth * (1f + 2f * BED_EXTRA); // 1.5 × halfWidth - float paintHW = bedHW + SPLAT_WE_PER_PX; // Texturrand (+2 m) - - float segDx = bx - ax, segDz = bz - az; - float segLen2 = segDx*segDx + segDz*segDz; - if (segLen2 < 0.001f) continue; - - float scanHW = paintHW + 1f; - int vxMin = Math.max(0, (int)((Math.min(ax, bx) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vzMin = Math.max(0, (int)((Math.min(az, bz) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - - for (int vz = vzMin; vz <= vzMax; vz++) { - for (int vx = vxMin; vx <= vxMax; vx++) { - float worldX = vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f; - float worldZ = vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f; - - float t = FastMath.clamp( - ((worldX - ax)*segDx + (worldZ - az)*segDz) / segLen2, 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; - - float waterY = ay + t * (by - ay); - int vidx = vz * TOTAL_SIZE + vx; - - if (dist <= bedHW) { - float depth; - if (dist <= halfWidth) { - depth = BED_DEPTH; - } else { - depth = BED_DEPTH * (1f - (dist - halfWidth) / (bedHW - halfWidth)); - } - float target = waterY - WATER_SINK - depth; - if (originalHeightMap != null && originalHeightMap[vidx] - target > 20f) continue; - channelTargets.merge(vidx, target, Math::min); - } else { - // Uferzone: Höhe exakt auf Wasseroberfläche setzen - bankTargets.merge(vidx, waterY - WATER_SINK, Math::min); - } - } - } - } - } - - /** Wendet vorberechnete Kanalziele in einem adjustHeight()-Aufruf an. */ - private void applyTargets(HashMap channelTargets) { - List locs = new ArrayList<>(channelTargets.size()); - List deltas = new ArrayList<>(channelTargets.size()); - - for (Map.Entry e : channelTargets.entrySet()) { - int vidx = e.getKey(); - float target = e.getValue(); - float curH = cachedHeightMap[vidx]; - if (curH > target) { - int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE; - locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f, - vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f)); - deltas.add(target - curH); - cachedHeightMap[vidx] = target; - modifiedVertices.add(vidx); - } - } - - if (!locs.isEmpty()) { - terrain.adjustHeight(locs, deltas); - terrain.updateModelBound(); - } - } - - /** Setzt Ufer-Vertices exakt auf die Wasseroberfläche (hebt und senkt). Channel-Vertices haben Vorrang. */ - private void applyBankTargets(HashMap bankTargets, Set channelVerts) { - List locs = new ArrayList<>(bankTargets.size()); - List deltas = new ArrayList<>(bankTargets.size()); - - for (Map.Entry e : bankTargets.entrySet()) { - int vidx = e.getKey(); - if (channelVerts.contains(vidx)) continue; - float target = e.getValue(); - float curH = cachedHeightMap[vidx]; - float delta = target - curH; - if (Math.abs(delta) < 0.01f) continue; - int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE; - locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f, - vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f)); - deltas.add(delta); - cachedHeightMap[vidx] = target; - modifiedVertices.add(vidx); - } - - if (!locs.isEmpty()) { - terrain.adjustHeight(locs, deltas); - terrain.updateModelBound(); - } - } - - /** - * Einzelnen Fluss graben (ohne Reset). Nur für isolierte Fälle — - * für Editor-Finalisierung/Löschen immer reapplyAllRivers() verwenden. - */ - public void carveRiver(List controlPts) { - if (terrain == null || cachedHeightMap == null || controlPts.size() < 2) return; - List splined = RiverSpline.subdivide(controlPts); - HashMap channelTargets = new HashMap<>(); - HashMap bankTargets = new HashMap<>(); - collectRiverTargets(splined, channelTargets, bankTargets); - applyTargets(channelTargets); - applyBankTargets(bankTargets, channelTargets.keySet()); - if (splatR != null) paintRiverSplat(splined); - } - - /** - * Malt Textur 4 (Kanal A) entlang des gesplineten Flusses. - * - * Für jeden Splatmap-Pixel im AABB des Flusses wird der minimale Abstand - * zur gesamten Pfadlinie berechnet — kein per-Segment-Clipping, daher - * folgt die Bemalung exakt dem Kurvenverlauf. - * - * Stärke maximal 70 % (0,3 × Gras bleibt immer), damit kein dunkler - * Schatten-Effekt entsteht wenn die Textur dunkler als Gras ist. - */ - private void paintRiverSplat(List splined) { - if (splined.size() < 2) return; - - // Globales AABB über alle Punkte inkl. Clearing-Zone (+16 m über Bettkante) - // Die Clearing-Zone entfernt G/B-Reste älterer Algorithmen auch außerhalb der Bemalung. - final float CLEAR_EXTRA = 16f; - float globalMinX = Float.MAX_VALUE, globalMaxX = -Float.MAX_VALUE; - float globalMinZ = Float.MAX_VALUE, globalMaxZ = -Float.MAX_VALUE; - for (RiverPoint pt : splined) { - float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f); - float pad = hw * (1f + 2f * BED_EXTRA) + CLEAR_EXTRA + SPLAT_WE_PER_PX; - if (pt.x() - pad < globalMinX) globalMinX = pt.x() - pad; - if (pt.x() + pad > globalMaxX) globalMaxX = pt.x() + pad; - if (pt.z() - pad < globalMinZ) globalMinZ = pt.z() - pad; - if (pt.z() + pad > globalMaxZ) globalMaxZ = pt.z() + pad; - } - - int pxMin = Math.max(0, (int)((globalMinX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1); - int pxMax = Math.min(SPLAT_SIZE-1, (int)((globalMaxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1); - int pzMin = Math.max(0, (SPLAT_SIZE-1) - (int)((globalMaxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1); - int pzMax = Math.min(SPLAT_SIZE-1, (SPLAT_SIZE-1) - (int)((globalMinZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1); - - boolean changed = false; - for (int pz = pzMin; pz <= pzMax; pz++) { - // Pixel-Position = Vertex-Raster (kein +0,5 Offset) - 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; - - // Minimalen Abstand zur gesamten Pfadlinie bestimmen - float minDist = Float.MAX_VALUE; - float nearHalfW = MIN_HALF_WIDTH; - float nearBedHW = MIN_HALF_WIDTH * (1f + 2f * BED_EXTRA); - - for (int si = 1; si < splined.size(); si++) { - RiverPoint pa = splined.get(si - 1); - RiverPoint pb = splined.get(si); - float ax = pa.x(), az = pa.z(); - float segDx = pb.x() - ax, segDz = pb.z() - az; - float segLen2 = segDx*segDx + segDz*segDz; - if (segLen2 < 0.001f) continue; - - float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f); - float projX = ax + t*segDx, projZ = az + t*segDz; - float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ)); - if (d < minDist) { - minDist = d; - float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f); - nearHalfW = hw; - nearBedHW = hw * (1f + 2f * BED_EXTRA); - } - } - - float paintHW = nearBedHW + SPLAT_WE_PER_PX; - float clearHW = nearBedHW + CLEAR_EXTRA; - if (minDist > clearHW) continue; - - int sidx = pz * SPLAT_SIZE + px; - int bi = sidx * 4; - - // Immer G/B in der gesamten Clearing-Zone nullen (entfernt Reste alter Algorithmen) - splatG[sidx] = 0; - splatB[sidx] = 0; - splatBuf.put(bi + 1, (byte) 0); - splatBuf.put(bi + 2, (byte) 0); - changed = true; - - if (minDist > paintHW) continue; // nur räumen, nicht bemalen - - float strength; - if (minDist <= nearHalfW) { - strength = 1.0f; - } else if (minDist <= nearBedHW) { - strength = 1.0f - (minDist - nearHalfW) / (nearBedHW - nearHalfW); - } else { - strength = (paintHW - minDist) / SPLAT_WE_PER_PX * 0.15f; - } - if (strength <= 0f) continue; - - // Maximal 70 % River-Textur → immer 30 % Gras → kein Schatten-Effekt - float ps = Math.min(strength, 0.7f); - splatR[sidx] = (byte) Math.round((originalSplatR[sidx] & 0xFF) * (1f - ps)); - splatA[sidx] = (byte) Math.round(ps * 255); - splatBuf.put(bi, splatR[sidx]); - splatBuf.put(bi + 3, splatA[sidx]); - } - } - if (changed) { - splatBuf.rewind(); - splatImage.setUpdateNeeded(); - } - } - - /** - * 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 - // + 0,5m Wasserabsenkung (Wasseroberfläche sitzt 0,5m unter Kontrollpunkten) - float width = halfWidth * 2f; - float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f)); - float WATER_SINK = 0.5f; - float BANK_WIDTH = 1.5f; // Breite der Böschungszone außerhalb des Kanals - float BANK_HEIGHT = 0.5f; // Höhe der Böschung über Wasseroberfläche - - // Scan-Bereich inklusive Böschungszone erweitern - int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING)); - - 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 * VERTEX_SPACING - TERRAIN_SIZE * 0.5f; - float worldZ = vz * VERTEX_SPACING - 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)); - - float waterY = ay + t * (by - ay); - int idx = vz * TOTAL_SIZE + vx; - float curH = cachedHeightMap[idx]; - - if (dist <= halfWidth) { - // ── Kanal graben (Wasseroberfläche + U-förmiges Bett) ────── - float norm = dist / halfWidth; - float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f); - float target = waterY - WATER_SINK - maxDepth * uShape; - if (curH > target) { - deltas.add(target - curH); - locs.add(new Vector2f(worldX, worldZ)); - cachedHeightMap[idx] = target; - } - } else if (dist <= halfWidth + BANK_WIDTH) { - // ── Böschung aufschütten ────────────────────────────────── - // Quadratischer Abfall: volle Höhe am Kanalrand, 0 am Außenrand - float bankT = 1.0f - (dist - halfWidth) / BANK_WIDTH; - float target = waterY + BANK_HEIGHT * bankT * bankT; - 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() { - try { - MapData data = new MapData(); - - // Post-River-Terrain speichern: Das Spiel erhält korrekt eingegrabene Flussbetten. - // originalHeightMap bleibt in-session als Rücksetz-Basis erhalten. - float[] saveHeight = (cachedHeightMap != null) ? cachedHeightMap : originalHeightMap; - if (saveHeight != null) { - if (saveHeight.length == data.terrainHeight.length) { - System.arraycopy(saveHeight, 0, data.terrainHeight, 0, saveHeight.length); - } else { - upsampleHeights(saveHeight, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS); - } - } - // Uferkante mit 0,25 m Präzision exakt auf Wasseroberfläche setzen - if (riverEditorState != null) { - applyHighResBankLeveling(data.terrainHeight, riverEditorState.getPlacedRivers()); - } - - if (splatR != null) { - // Bemalte Splatmap speichern (mit Fluss-Textur) → Spiel zeigt Fluss-Textur korrekt. - // Beim nächsten Laden strippt initSplatmap() die Fluss-Farbe für originalSplatX, - // sodass reapplyAllRivers ohne doppeltes Malen neu aufträgt. - System.arraycopy(splatR, 0, data.splatR, 0, data.splatR.length); - System.arraycopy(splatG, 0, data.splatG, 0, data.splatG.length); - System.arraycopy(splatB, 0, data.splatB, 0, data.splatB.length); - System.arraycopy(splatA, 0, data.splatA, 0, data.splatA.length); - System.arraycopy(input.terrainTexturePaths, 0, - data.terrainTextures, 0, MapData.TEXTURE_SLOTS); - } - if (upperSplatR != null) { - System.arraycopy(upperSplatR, 0, data.upperSplatR, 0, data.upperSplatR.length); - System.arraycopy(upperSplatG, 0, data.upperSplatG, 0, data.upperSplatG.length); - System.arraycopy(upperSplatB, 0, data.upperSplatB, 0, data.upperSplatB.length); - System.arraycopy(upperSplatA, 0, data.upperSplatA, 0, data.upperSplatA.length); - System.arraycopy(input.upperTexturePaths, 0, - data.upperTextures, 0, MapData.TEXTURE_SLOTS); - } - - if (placedObjectState != null) { - try { - GrassTuftIO.save(new GrassTuftIO.GrassData( - placedObjectState.getSlotPaths(), - placedObjectState.getAllTufts())); - } catch (IOException e) { - log.error("Gras nicht speicherbar", e); - } - } - - MapIO.save(data); - if (sceneObjState != null) { - PlacedModelIO.save(sceneObjState.getPlacedModels()); - } - if (lightState != null) { - LightIO.save(lightState.getPlacedLights()); - } - if (emitterState != null) { - EmitterIO.save(emitterState.getPlacedEmitters()); - } - if (waterBodyState != null) { - WaterBodyIO.save(waterBodyState.getPlacedBodies()); - } - if (riverEditorState != null) { - de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers()); - } - if (soundAreaState != null) { - SoundAreaIO.save(soundAreaState.getPlacedAreas()); - } - if (musicAreaState != null) { - MusicAreaIO.save(musicAreaState.getPlacedAreas()); - } - input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath(); - log.info("{}", input.saveStatusMsg); - } catch (IOException e) { - input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage(); - log.error("Speichern fehlgeschlagen", e); + if (saveInProgress) { + log.warn("Speicherung noch aktiv, überspringe."); + return; } + saveInProgress = true; + + // ── Snapshot aller Daten auf dem JME3-Thread (schnelle Arraykopien) ── + final float[] heightSnap = cachedHeightMap != null ? cachedHeightMap.clone() : null; + final byte[] snapR = splatR != null ? splatR.clone() : null; + final byte[] snapG = splatG != null ? splatG.clone() : null; + final byte[] snapB = splatB != null ? splatB.clone() : null; + final byte[] snapA = splatA != null ? splatA.clone() : null; + final byte[] upR = upperSplatR != null ? upperSplatR.clone() : null; + final byte[] upG = upperSplatG != null ? upperSplatG.clone() : null; + final byte[] upB = upperSplatB != null ? upperSplatB.clone() : null; + final byte[] upA = upperSplatA != null ? upperSplatA.clone() : null; + final String[] texPaths = input.terrainTexturePaths.clone(); + final String[] upperPaths = input.upperTexturePaths.clone(); + + final GrassTuftIO.GrassData grassData = placedObjectState != null + ? new GrassTuftIO.GrassData(placedObjectState.getSlotPaths(), placedObjectState.getAllTufts()) + : null; + final var grassVertexBlades = grassVertexState != null ? grassVertexState.getAllBlades() : null; + final List models = sceneObjState != null ? sceneObjState.getPlacedModels() : null; + final List lights = lightState != null ? lightState.getPlacedLights() : null; + final List emitters = emitterState != null ? emitterState.getPlacedEmitters() : null; + final List waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null; + final List> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null; + final List soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null; + final List areas = areaState != null ? areaState.getPlacedAreas() : null; + final List locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null; + + // ── Schwere Arbeit (Upsample + Datei-I/O) auf Hintergrund-Thread ───── + saveExecutor.submit(() -> { + try { + MapData data = new MapData(); + + if (heightSnap != null) { + if (heightSnap.length == data.terrainHeight.length) { + System.arraycopy(heightSnap, 0, data.terrainHeight, 0, heightSnap.length); + } else { + upsampleHeights(heightSnap, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS); + } + } + if (snapR != null) { + System.arraycopy(snapR, 0, data.splatR, 0, data.splatR.length); + System.arraycopy(snapG, 0, data.splatG, 0, data.splatG.length); + System.arraycopy(snapB, 0, data.splatB, 0, data.splatB.length); + System.arraycopy(snapA, 0, data.splatA, 0, data.splatA.length); + System.arraycopy(texPaths, 0, data.terrainTextures, 0, MapData.TEXTURE_SLOTS); + } + if (upR != null) { + System.arraycopy(upR, 0, data.upperSplatR, 0, data.upperSplatR.length); + System.arraycopy(upG, 0, data.upperSplatG, 0, data.upperSplatG.length); + System.arraycopy(upB, 0, data.upperSplatB, 0, data.upperSplatB.length); + System.arraycopy(upA, 0, data.upperSplatA, 0, data.upperSplatA.length); + System.arraycopy(upperPaths, 0, data.upperTextures, 0, MapData.TEXTURE_SLOTS); + } + + if (grassData != null) { + try { GrassTuftIO.save(grassData); } + catch (IOException e) { log.error("Gras nicht speicherbar", e); } + } + if (grassVertexBlades != null) { + try { GrassVertexIO.save(grassVertexBlades); } + catch (IOException e) { log.error("Vertex-Gras nicht speicherbar", e); } + } + MapIO.save(data); + if (models != null) PlacedModelIO.save(models); + if (lights != null) LightIO.save(lights); + if (emitters != null) EmitterIO.save(emitters); + if (waters != null) WaterBodyIO.save(waters); + if (rivers != null) de.blight.common.RiverIO.save(rivers); + if (soundAreas != null) SoundAreaIO.save(soundAreas); + if (areas != null) AreaIO.save(areas); + if (locationZones != null) LocationZoneIO.save(locationZones); + + input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath(); + log.info("{}", input.saveStatusMsg); + } catch (IOException e) { + input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage(); + log.error("Speichern fehlgeschlagen", e); + } catch (Throwable t) { + input.saveStatusMsg = "Speichern fehlgeschlagen: " + t; + log.error("Speichern fehlgeschlagen (unerwarteter Fehler)", t); + } finally { + saveInProgress = false; + } + }); } // ── Brush-Indikator ─────────────────────────────────────────────────────── @@ -1287,7 +914,8 @@ public class TerrainEditorState extends BaseAppState { if (layer == SharedInput.LAYER_OBJECTS || layer == SharedInput.LAYER_OBJECTS_EDIT || layer == SharedInput.LAYER_LIGHTS || layer == SharedInput.LAYER_EMITTERS || layer == SharedInput.LAYER_WATER - || layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_MUSIC_AREAS + || layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS + || layer == SharedInput.LAYER_LOCATION_ZONES || layer == SharedInput.LAYER_PLAY_TOOL || mx < 0) { brushIndicator.setCullHint(Spatial.CullHint.Always); return; @@ -1318,6 +946,13 @@ public class TerrainEditorState extends BaseAppState { contactPoint = hits.getClosestCollision().getContactPoint(); brushRadius = (float) input.grassTool.brushRadius.getValue(); } + } else if (layer == SharedInput.LAYER_GRASS_VERTEX) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + contactPoint = hits.getClosestCollision().getContactPoint(); + brushRadius = (float) input.grassVertexTool.brushRadius.getValue(); + } } if (contactPoint != null) { @@ -1383,10 +1018,6 @@ 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(); } @@ -1441,6 +1072,7 @@ public class TerrainEditorState extends BaseAppState { syncHeightCache(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); + if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius); } } @@ -1492,6 +1124,7 @@ public class TerrainEditorState extends BaseAppState { syncHeightCache(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); + if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius); } /** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */ @@ -1545,6 +1178,7 @@ public class TerrainEditorState extends BaseAppState { syncHeightCache(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); + if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius); } } @@ -1558,6 +1192,10 @@ public class TerrainEditorState extends BaseAppState { } private void updateCamera(float tpf) { + if (input.activeLayer == SharedInput.LAYER_MODEL_EDITOR) { + input.consumeMouseDelta(); // konsumieren ohne zu verarbeiten + return; + } int[] delta = input.consumeMouseDelta(); if (delta[0] != 0 || delta[1] != 0) { camYaw += delta[0] * MOUSE_SENS; @@ -1830,73 +1468,4 @@ public class TerrainEditorState extends BaseAppState { } } - /** - * Setzt Uferkanten-Vertices (bedHW < dist ≤ paintHW) in der 16385²-Heightmap - * exakt auf die Wasseroberfläche (0,25 m Präzision). - * Wird nach dem Hochskalieren in performSave() aufgerufen. - */ - private static void applyHighResBankLeveling(float[] heights, List> rivers) { - if (heights == null || rivers == null || rivers.isEmpty()) return; - final int HR_VERTS = MapData.TERRAIN_VERTS; // 16385 - final float HR_SPACING = 4096f / (HR_VERTS - 1); // 0.25 m/Vertex - final float HR_HALF = 2048f; - - for (List controlPts : rivers) { - if (controlPts == null || controlPts.size() < 2) continue; - List splined = RiverSpline.subdivide(controlPts); - if (splined.size() < 2) continue; - - // AABB (inkl. paintHW-Rand) - float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE; - float minZ = Float.MAX_VALUE, maxZ = -Float.MAX_VALUE; - for (RiverPoint pt : splined) { - float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f); - float pad = hw * (1f + 2f * BED_EXTRA) + SPLAT_WE_PER_PX + 1f; - if (pt.x() - pad < minX) minX = pt.x() - pad; - if (pt.x() + pad > maxX) maxX = pt.x() + pad; - if (pt.z() - pad < minZ) minZ = pt.z() - pad; - if (pt.z() + pad > maxZ) maxZ = pt.z() + pad; - } - int vxMin = Math.max(0, (int)((minX + HR_HALF) / HR_SPACING)); - int vxMax = Math.min(HR_VERTS-1, (int)((maxX + HR_HALF) / HR_SPACING) + 1); - int vzMin = Math.max(0, (int)((minZ + HR_HALF) / HR_SPACING)); - int vzMax = Math.min(HR_VERTS-1, (int)((maxZ + HR_HALF) / HR_SPACING) + 1); - - for (int vz = vzMin; vz <= vzMax; vz++) { - for (int vx = vxMin; vx <= vxMax; vx++) { - float worldX = vx * HR_SPACING - HR_HALF; - float worldZ = vz * HR_SPACING - HR_HALF; - - float minDist = Float.MAX_VALUE; - float bestWaterY = 0f; - float bestBedHW = 0f; - float bestPaintHW = 0f; - - for (int si = 1; si < splined.size(); si++) { - RiverPoint pa = splined.get(si - 1); - RiverPoint pb = splined.get(si); - float ax = pa.x(), ay = pa.y(), az = pa.z(); - float bx = pb.x(), by = pb.y(), bz = pb.z(); - float segDx = bx - ax, segDz = bz - az; - float segLen2 = segDx*segDx + segDz*segDz; - if (segLen2 < 0.001f) continue; - float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f); - float projX = ax + t*segDx, projZ = az + t*segDz; - float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ)); - if (d < minDist) { - minDist = d; - float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f); - bestBedHW = hw * (1f + 2f * BED_EXTRA); - bestPaintHW = bestBedHW + SPLAT_WE_PER_PX; - bestWaterY = ay + t * (by - ay); - } - } - - if (minDist > bestBedHW && minDist <= bestPaintHW) { - heights[vz * HR_VERTS + vx] = bestWaterY - WATER_SINK; - } - } - } - } - } } 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 335525c..ff1d98f 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 @@ -24,22 +24,25 @@ import java.nio.IntBuffer; import java.util.*; /** - * Platziert und visualisiert Wasserflächen per Flood-Fill aus dem Gelände. + * Platziert und visualisiert Wasserflächen als frei definiertes Polygon. * - * Raster: 2 WE pro Pixel (WATER_GRID = 2049, STEP = 2). - * BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen. + * Bedienung: + * L-Klick → Polygon-Punkt setzen (erster Punkt definiert Standard-Höhe) + * R-Klick → letzten Punkt entfernen (beim Zeichnen) / Auswahl aufheben + * Leertaste → Terrain-Höhe an Cursor-Position als Wasserhöhe übernehmen + * ESC → Zeichnen abbrechen + * Nahe am ersten Punkt klicken (≥3 Punkte) → Polygon schließen */ public class WaterBodyState extends BaseAppState { - // 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 float SNAP_DIST = 8f; + private static final float LINE_OFFSET = 0.1f; 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 static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.3f, 0.7f, 1.0f, 1f); + private static final ColorRGBA COLOR_OUTLINE = new ColorRGBA(0.1f, 0.5f, 0.9f, 1f); + private static final ColorRGBA COLOR_ARROW = new ColorRGBA(1f, 0.85f, 0f, 1f); private final SharedInput input; private SimpleApplication app; @@ -47,22 +50,28 @@ public class WaterBodyState extends BaseAppState { private AssetManager assets; private Node rootNode; private TerrainQuad terrain; - private float[] heightMap; - 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 final List bodies = new ArrayList<>(); + private final List fillGeos = new ArrayList<>(); + private final List outlineGeos = new ArrayList<>(); + private final List flowArrowGeos = new ArrayList<>(); + private int selectedIdx = -1; + + // in-progress polygon + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private float currentWaterHeight = 0f; + private Geometry inProgGeo = null; + private Geometry lastMarker = null; + private Geometry pillarGeo = null; + private Geometry cursorPillar = null; - private int selectedIdx = -1; private List pendingLoad = null; public WaterBodyState(SharedInput input) { this.input = input; } public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } - public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; } - - // ── Lifecycle ───────────────────────────────────────────────────────────── @Override protected void initialize(Application application) { @@ -85,70 +94,371 @@ public class WaterBodyState extends BaseAppState { @Override public void update(float tpf) { - if (input.activeLayer != SharedInput.LAYER_WATER) return; + if (input.activeLayer != SharedInput.LAYER_WATER) { + if (placing) cancelPoly(); + return; + } - SharedInput.WaterClick click; - while ((click = input.waterClickQueue.poll()) != null) handleClick(click); + // Spacebar: sample terrain height at cursor + if (input.waterSampleHeightRequested) { + input.waterSampleHeightRequested = false; + float h = sampleTerrainAtCursor(); + if (Float.isFinite(h)) { + currentWaterHeight = h; + input.waterCurrentHeight = h; + input.waterHeightChanged = true; + if (placing) updateInProgressGeo(); + else if (selectedIdx >= 0) applyHeightChange(selectedIdx, h); + } + } - PlacedWater pending = input.pendingWater.getAndSet(null); - if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight()); + // Live cursor pillar while placing + if (placing) { + updateCursorPillar(); + } else { + removeCursorPillar(); + } + + // Height change for selected body + Float newH = input.pendingWaterHeight.getAndSet(null); + if (newH != null && selectedIdx >= 0) applyHeightChange(selectedIdx, newH); + + // Flow direction change for selected body + Float newFlow = input.pendingWaterFlowDegrees.getAndSet(null); + if (newFlow != null && selectedIdx >= 0) applyFlowChange(selectedIdx, newFlow); + + if (input.cancelZoneDrawing) { + input.cancelZoneDrawing = false; + if (placing) cancelPoly(); + } if (input.deleteWaterRequested) { input.deleteWaterRequested = false; if (selectedIdx >= 0) removeBody(selectedIdx); } + + SharedInput.WaterClick click; + while ((click = input.waterClickQueue.poll()) != null) handleClick(click); } - // ── Click-Handling ──────────────────────────────────────────────────────── + // ── Click ───────────────────────────────────────────────────────────────── 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()); - if (click.rightButton()) { deselect(); return; } - - int hit = pickBody(ray); - if (hit >= 0) { selectBody(hit); return; } + if (click.rightButton()) { + if (placing) { + if (!currX.isEmpty()) { + currX.remove(currX.size() - 1); + currZ.remove(currZ.size() - 1); + updateInProgressGeo(); + if (currX.isEmpty()) cancelPoly(); + } + } else { + deselect(); + } + return; + } if (terrain == null) return; CollisionResults hits = new CollisionResults(); terrain.collideWith(ray, hits); if (hits.size() == 0) return; Vector3f pt = hits.getClosestCollision().getContactPoint(); + float hitX = pt.x, hitZ = pt.z; - Set cells = floodFill(pt.x, pt.z, pt.y); - if (cells == null) { - input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle."; - return; + if (placing) { + if (currX.size() >= 3) { + float dx = hitX - currX.get(0), dz = hitZ - currZ.get(0); + if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) { + closePoly(); + return; + } + } + currX.add(hitX); + currZ.add(hitZ); + updateInProgressGeo(); + } else { + for (int i = 0; i < bodies.size(); i++) { + PlacedWater b = bodies.get(i); + if (pointInPolygon(hitX, hitZ, b.pointsX(), b.pointsZ())) { + selectBody(i); + return; + } + } + deselect(); + placing = true; + currX.clear(); + currZ.clear(); + currentWaterHeight = pt.y; + input.waterCurrentHeight = pt.y; + input.waterHeightChanged = true; + currX.add(hitX); + currZ.add(hitZ); + updateInProgressGeo(); } - addBody(new PlacedWater(pt.x, pt.z, pt.y), cells); + } + + private void closePoly() { + if (currX.size() < 3) { cancelPoly(); return; } + float[] xs = toArray(currX); + float[] zs = toArray(currZ); + PlacedWater body = new PlacedWater(xs, zs, currentWaterHeight, 0f); + addBody(body); selectBody(bodies.size() - 1); + cancelPoly(); } - private int pickBody(Ray ray) { - for (int i = 0; i < geos.size(); i++) { - CollisionResults res = new CollisionResults(); - geos.get(i).collideWith(ray, res); - if (res.size() > 0) return i; + private void cancelPoly() { + placing = false; + currX.clear(); + currZ.clear(); + if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; } + if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; } + if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; } + if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; } + } + + // ── In-progress visual ──────────────────────────────────────────────────── + + private void updateInProgressGeo() { + if (inProgGeo != null) rootNode.detachChild(inProgGeo); + inProgGeo = null; + int n = currX.size(); + if (n > 0) { + inProgGeo = buildLineGeo("water_inprog", currX, currZ, + currentWaterHeight + LINE_OFFSET, COLOR_INPROG, Mesh.Mode.LineStrip); + rootNode.attachChild(inProgGeo); } - return -1; + updateLastMarker(); + updatePillarGeo(); } - // ── Selektion ───────────────────────────────────────────────────────────── + private void updateLastMarker() { + if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; } + if (currX.isEmpty()) return; + float x = currX.get(currX.size() - 1); + float z = currZ.get(currZ.size() - 1); + float y = currentWaterHeight + LINE_OFFSET + 0.1f; + float s = 1.5f; + + FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3); + buf.put(x-s).put(y).put(z-s); buf.put(x+s).put(y).put(z+s); + buf.put(x-s).put(y).put(z+s); buf.put(x+s).put(y).put(z-s); + buf.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, buf); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f)); + mat.getAdditionalRenderState().setLineWidth(3f); + lastMarker = new Geometry("water_lastpoint", mesh); + lastMarker.setMaterial(mat); + rootNode.attachChild(lastMarker); + } + + // ── Height-delta pillars ────────────────────────────────────────────────── + + private static final ColorRGBA PILLAR_SUB = new ColorRGBA(0.2f, 0.85f, 1.0f, 1f); + private static final ColorRGBA PILLAR_DRY = new ColorRGBA(1.0f, 0.50f, 0.1f, 1f); + + private void updatePillarGeo() { + if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; } + int n = currX.size(); + if (n == 0 || terrain == null) return; + + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3); + FloatBuffer col = BufferUtils.createFloatBuffer(n * 2 * 4); + + for (int i = 0; i < n; i++) { + float x = currX.get(i), z = currZ.get(i); + Float th = terrain.getHeight(new com.jme3.math.Vector2f(x, z)); + float ty = th != null ? th : currentWaterHeight; + float wy = currentWaterHeight; + ColorRGBA c = (ty < wy) ? PILLAR_SUB : PILLAR_DRY; + + pos.put(x).put(ty).put(z); + pos.put(x).put(wy + LINE_OFFSET).put(z); + col.put(c.r).put(c.g).put(c.b).put(c.a); + col.put(c.r).put(c.g).put(c.b).put(c.a); + } + pos.rewind(); col.rewind(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Color, 4, col); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setBoolean("VertexColor", true); + mat.getAdditionalRenderState().setLineWidth(3f); + pillarGeo = new Geometry("water_pillars", mesh); + pillarGeo.setMaterial(mat); + rootNode.attachChild(pillarGeo); + } + + private void updateCursorPillar() { + if (terrain == null || input.mouseScreenX < 0) { removeCursorPillar(); return; } + + float jmeX = input.mouseScreenX * (float) input.viewportScaleX; + float jmeY = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY; + Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); + Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); + Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() == 0) { removeCursorPillar(); return; } + + Vector3f pt = hits.getClosestCollision().getContactPoint(); + float ty = pt.y, wy = currentWaterHeight; + float delta = wy - ty; + ColorRGBA c = (delta > 0) ? PILLAR_SUB : PILLAR_DRY; + + float lo = Math.min(ty, wy); + float hi = Math.max(ty, wy) + LINE_OFFSET; + + FloatBuffer pos = BufferUtils.createFloatBuffer(2 * 3); + FloatBuffer col = BufferUtils.createFloatBuffer(2 * 4); + pos.put(pt.x).put(lo).put(pt.z); + pos.put(pt.x).put(hi).put(pt.z); + col.put(c.r).put(c.g).put(c.b).put(0.5f); + col.put(c.r).put(c.g).put(c.b).put(0.5f); + pos.rewind(); col.rewind(); + + if (cursorPillar == null) { + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Color, 4, col); + mesh.updateBound(); + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setBoolean("VertexColor", true); + mat.getAdditionalRenderState().setLineWidth(2f); + cursorPillar = new Geometry("water_cursor_pillar", mesh); + cursorPillar.setMaterial(mat); + rootNode.attachChild(cursorPillar); + } else { + Mesh mesh = cursorPillar.getMesh(); + mesh.clearBuffer(VertexBuffer.Type.Position); + mesh.clearBuffer(VertexBuffer.Type.Color); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.setBuffer(VertexBuffer.Type.Color, 4, col); + mesh.updateBound(); + cursorPillar.getMaterial().setBoolean("VertexColor", true); + } + + input.waterCurrentHeight = currentWaterHeight; + } + + private void removeCursorPillar() { + if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; } + } + + // ── Flow arrow ──────────────────────────────────────────────────────────── + + private Geometry buildFlowArrowGeo(PlacedWater body, int idx) { + float[] xs = body.pointsX(), zs = body.pointsZ(); + int n = xs.length; + + float minX = xs[0], maxX = xs[0], minZ = zs[0], maxZ = zs[0]; + for (int i = 1; i < n; i++) { + if (xs[i] < minX) minX = xs[i]; if (xs[i] > maxX) maxX = xs[i]; + if (zs[i] < minZ) minZ = zs[i]; if (zs[i] > maxZ) maxZ = zs[i]; + } + float diag = (float) Math.sqrt((double)(maxX-minX)*(maxX-minX) + (double)(maxZ-minZ)*(maxZ-minZ)); + float spacing = Math.max(4f, Math.min(diag / 8f, 30f)); + float arrowLen = spacing * 0.5f; + float hlen = arrowLen * 0.35f; + float y = body.waterHeight() + 0.3f; + + double rad = Math.toRadians(body.flowDegrees()); + float dx = (float) Math.sin(rad); + float dz = (float) Math.cos(rad); + // head barb offsets (pre-computed, same for every arrow) + float b1x = (float) Math.sin(Math.toRadians(body.flowDegrees() + 150)) * hlen; + float b1z = (float) Math.cos(Math.toRadians(body.flowDegrees() + 150)) * hlen; + float b2x = (float) Math.sin(Math.toRadians(body.flowDegrees() - 150)) * hlen; + float b2z = (float) Math.cos(Math.toRadians(body.flowDegrees() - 150)) * hlen; + + // collect grid points inside polygon (centered in each cell) + List pts = new ArrayList<>(); + for (float gx = minX + spacing * 0.5f; gx < maxX; gx += spacing) + for (float gz = minZ + spacing * 0.5f; gz < maxZ; gz += spacing) + if (pointInPolygon(gx, gz, xs, zs)) + pts.add(new float[]{gx, gz}); + + // fallback: polygon centroid + if (pts.isEmpty()) { + float cx = 0, cz = 0; + for (int i = 0; i < n; i++) { cx += xs[i]; cz += zs[i]; } + pts.add(new float[]{cx / n, cz / n}); + } + + // 3 lines × 2 verts × 3 floats per arrow + FloatBuffer pos = BufferUtils.createFloatBuffer(pts.size() * 6 * 3); + for (float[] pt : pts) { + float px = pt[0] - dx * arrowLen * 0.5f; // center arrow on grid point + float pz = pt[1] - dz * arrowLen * 0.5f; + float tipX = px + dx * arrowLen, tipZ = pz + dz * arrowLen; + pos.put(px).put(y).put(pz); pos.put(tipX).put(y).put(tipZ); + pos.put(tipX).put(y).put(tipZ); pos.put(tipX + b1x).put(y).put(tipZ + b1z); + pos.put(tipX).put(y).put(tipZ); pos.put(tipX + b2x).put(y).put(tipZ + b2z); + } + pos.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(Mesh.Mode.Lines); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", COLOR_ARROW); + mat.getAdditionalRenderState().setLineWidth(2f); + + Geometry geo = new Geometry("water_flow_" + idx, mesh); + geo.setMaterial(mat); + return geo; + } + + // ── Height sampling ─────────────────────────────────────────────────────── + + private float sampleTerrainAtCursor() { + if (terrain == null) return Float.NaN; + float jmeX = input.mouseScreenX * (float) input.viewportScaleX; + float jmeY = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY; + Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); + Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); + Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() == 0) return Float.NaN; + return hits.getClosestCollision().getContactPoint().y; + } + + // ── Selection ───────────────────────────────────────────────────────────── private void selectBody(int idx) { deselect(); selectedIdx = idx; - geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + fillGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED); + outlineGeos.get(idx).getMaterial().setColor("Color", new ColorRGBA(0.8f, 0.9f, 1f, 1f)); publishSelection(idx); } private void deselect() { - if (selectedIdx >= 0 && selectedIdx < geos.size()) - geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER); + if (selectedIdx >= 0 && selectedIdx < fillGeos.size()) { + fillGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER); + outlineGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_OUTLINE); + } selectedIdx = -1; input.selectedWaterInfo = null; input.waterSelectionChanged = true; @@ -157,171 +467,111 @@ 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|%d", - idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size()); + "%d|%.3f|%d|%.1f", idx, b.waterHeight(), b.pointsX().length, b.flowDegrees()); input.waterSelectionChanged = true; } - // ── Hinzufügen / Entfernen ──────────────────────────────────────────────── + // ── Add / Remove ────────────────────────────────────────────────────────── - private void addBody(PlacedWater body, Set cells) { - Geometry geo = buildWaterGeo(cells, body.waterHeight()); - rootNode.attachChild(geo); + private void addBody(PlacedWater body) { + int idx = bodies.size(); + Geometry fill = buildFillGeo(body); + Geometry outline = buildLineGeo("water_outline_" + idx, + toList(body.pointsX()), toList(body.pointsZ()), + body.waterHeight() + LINE_OFFSET, COLOR_OUTLINE, Mesh.Mode.LineLoop); + Geometry arrow = buildFlowArrowGeo(body, idx); + rootNode.attachChild(fill); + rootNode.attachChild(outline); + rootNode.attachChild(arrow); bodies.add(body); - cellSets.add(cells); - geos.add(geo); - bodyBounds.add(computeBounds(cells)); + fillGeos.add(fill); + outlineGeos.add(outline); + flowArrowGeos.add(arrow); } private void removeBody(int idx) { - rootNode.detachChild(geos.get(idx)); + rootNode.detachChild(fillGeos.get(idx)); + rootNode.detachChild(outlineGeos.get(idx)); + rootNode.detachChild(flowArrowGeos.get(idx)); bodies.remove(idx); - cellSets.remove(idx); - geos.remove(idx); - bodyBounds.remove(idx); + fillGeos.remove(idx); + outlineGeos.remove(idx); + flowArrowGeos.remove(idx); selectedIdx = -1; input.selectedWaterInfo = null; input.waterSelectionChanged = true; } private void clearAll() { - for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g); + for (Geometry g : fillGeos) if (rootNode != null) rootNode.detachChild(g); + for (Geometry g : outlineGeos) if (rootNode != null) rootNode.detachChild(g); + for (Geometry g : flowArrowGeos) if (rootNode != null) rootNode.detachChild(g); bodies.clear(); - cellSets.clear(); - geos.clear(); - bodyBounds.clear(); + fillGeos.clear(); + outlineGeos.clear(); + flowArrowGeos.clear(); + cancelPoly(); selectedIdx = -1; } - /** - * 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); - } - } - } - - 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}; - } - - // ── 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; + PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), newHeight, b.flowDegrees()); + rootNode.detachChild(fillGeos.get(idx)); + rootNode.detachChild(outlineGeos.get(idx)); + rootNode.detachChild(flowArrowGeos.get(idx)); + Geometry fill = buildFillGeo(updated); + Geometry outline = buildLineGeo("water_outline_" + idx, + toList(updated.pointsX()), toList(updated.pointsZ()), + newHeight + LINE_OFFSET, COLOR_OUTLINE, Mesh.Mode.LineLoop); + Geometry arrow = buildFlowArrowGeo(updated, idx); + boolean sel = (selectedIdx == idx); + if (sel) { + fill.getMaterial().setColor("Color", COLOR_SELECTED); + outline.getMaterial().setColor("Color", new ColorRGBA(0.8f, 0.9f, 1f, 1f)); } - 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); + rootNode.attachChild(fill); + rootNode.attachChild(outline); + rootNode.attachChild(arrow); + bodies.set(idx, updated); + fillGeos.set(idx, fill); + outlineGeos.set(idx, outline); + flowArrowGeos.set(idx, arrow); 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 void applyFlowChange(int idx, float newDegrees) { + PlacedWater b = bodies.get(idx); + PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), b.waterHeight(), newDegrees); + bodies.set(idx, updated); + rootNode.detachChild(flowArrowGeos.get(idx)); + Geometry arrow = buildFlowArrowGeo(updated, idx); + rootNode.attachChild(arrow); + flowArrowGeos.set(idx, arrow); + publishSelection(idx); } - 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; - } + // ── Geometry builders ───────────────────────────────────────────────────── - // ── Mesh-Aufbau (für Editor-Vorschau) ──────────────────────────────────── + private Geometry buildFillGeo(PlacedWater body) { + float[] xs = body.pointsX(); + float[] zs = body.pointsZ(); + int n = xs.length; + float h = body.waterHeight() + LINE_OFFSET * 0.5f; + + int triCount = n - 2; + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 3); + IntBuffer idx = BufferUtils.createIntBuffer(triCount * 3); + for (int i = 0; i < n; i++) pos.put(xs[i]).put(h).put(zs[i]); + for (int i = 1; i <= triCount; i++) idx.put(0).put(i).put(i + 1); + pos.rewind(); idx.rewind(); - 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); + Geometry geo = new Geometry("water_fill", mesh); Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); mat.setColor("Color", COLOR_WATER); mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); @@ -332,21 +582,61 @@ public class WaterBodyState extends BaseAppState { return geo; } - // ── Speichern / Laden ───────────────────────────────────────────────────── + private Geometry buildLineGeo(String name, List xs, List zs, + float height, ColorRGBA color, Mesh.Mode mode) { + int n = xs.size(); + FloatBuffer pos = BufferUtils.createFloatBuffer(n * 3); + for (int i = 0; i < n; i++) pos.put(xs.get(i)).put(height).put(zs.get(i)); + pos.flip(); + + Mesh mesh = new Mesh(); + mesh.setMode(mode); + mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); + mesh.updateBound(); + + Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + mat.setColor("Color", color); + mat.getAdditionalRenderState().setLineWidth(2f); + + Geometry geo = new Geometry(name, mesh); + geo.setMaterial(mat); + return geo; + } + + // ── Save / Load ─────────────────────────────────────────────────────────── 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) { - 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()); - } + for (PlacedWater b : loaded) addBody(b); + } + + // ── Point-in-polygon ────────────────────────────────────────────────────── + + private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) { + int n = xs.length; + boolean inside = false; + for (int i = 0, j = n - 1; i < n; j = i++) { + if ((zs[i] > pz) != (zs[j] > pz) + && (px < (xs[j] - xs[i]) * (pz - zs[i]) / (zs[j] - zs[i]) + xs[i])) + inside = !inside; } + return inside; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static float[] toArray(List list) { + float[] a = new float[list.size()]; + for (int i = 0; i < list.size(); i++) a[i] = list.get(i); + return a; + } + + private static List toList(float[] arr) { + List l = new ArrayList<>(arr.length); + for (float f : arr) l.add(f); + return l; } } 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 20266c3..99ea6b2 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 @@ -11,7 +11,7 @@ public class GrassTool extends EditorTool { 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, 200.0); - @Override public String getName() { return "Gras"; } + @Override public String getName() { return "Gras (Textur)"; } @Override public List getChoiceParameters() { return List.of(); } diff --git a/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java b/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java new file mode 100644 index 0000000..ce26af6 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java @@ -0,0 +1,19 @@ +package de.blight.editor.tool; + +import java.util.List; + +public class GrassVertexTool extends EditorTool { + + public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 50.0); + public final ToolParameter bladeHeight = new ToolParameter("Halmhöhe", 0.6, 0.1, 2.0); + public final ToolParameter density = new ToolParameter("Dichte", 5.0, 1.0, 100.0); + public final ToolParameter dryness = new ToolParameter("Vertrocknet %", 0.0, 0.0, 100.0); + + @Override public String getName() { return "Gras (Vertices)"; } + + @Override + public List getChoiceParameters() { return List.of(); } + + @Override + public List getParameters() { return List.of(brushRadius, bladeHeight, density, dryness); } +} diff --git a/blight-editor/src/main/java/de/blight/editor/tool/TextureTool.java b/blight-editor/src/main/java/de/blight/editor/tool/TextureTool.java index 512ee95..f1b2090 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/TextureTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/TextureTool.java @@ -9,8 +9,8 @@ import java.util.List; */ public class TextureTool extends EditorTool { - // Terrain.j3md (unlit) hat nur Tex1–Tex3; Slot 0=Gras(Base), 1=Fels(R), 2=Erde(G) - public static final String[] TEXTURE_NAMES = {"Gras", "Fels", "Erde"}; + // Slots 1-4 = lower splatmap, Slots 5-8 = upper splatmap + public static final String[] TEXTURE_NAMES = {"S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8"}; public final ChoiceToolParameter textureIndex = new ChoiceToolParameter( "Textur", TEXTURE_NAMES, 0 diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java new file mode 100644 index 0000000..ac7ef32 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/CraftingTableEditorView.java @@ -0,0 +1,292 @@ +package de.blight.editor.ui; + +import de.blight.common.model.*; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; + +/** + * Crafting-Table-Verwaltung: zwei identische TablePanel-Instanzen nebeneinander. + * Pro CraftingTableType kann genau ein Eintrag existieren — die Liste zeigt + * immer alle 5 Typen; nicht konfigurierte Einträge erscheinen grau. + */ +public class CraftingTableEditorView extends BorderPane { + + private final Map shared = + new EnumMap<>(CraftingTable.CraftingTableType.class); + private final Path tableDir; + + private TablePanel left; + private TablePanel right; + + public CraftingTableEditorView(Path tableDir) { + this.tableDir = tableDir; + setStyle("-fx-background-color: #1e1e2e;"); + reloadMap(); + + left = new TablePanel("Liste 1", shared, tableDir, this::onSaved); + right = new TablePanel("Liste 2", shared, tableDir, this::onSaved); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + private void reloadMap() { + shared.clear(); + shared.putAll(CraftingTableIO.loadAll(tableDir)); + } + + private void onSaved() { + reloadMap(); + left.refresh(); + right.refresh(); + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class TablePanel extends VBox { + + private final Map shared; + private final Path tableDir; + private final Runnable onSaved; + + private final ListView listView; + + private CraftingTable.CraftingTableType currentType = null; + + // Form fields + private TextField nameIdField; + private TextField objectPathField; + + private VBox formContainer; + private Button deleteBtn; + private Label formTypeLabel; + + TablePanel(String title, + Map shared, + Path tableDir, Runnable onSaved) { + this.shared = shared; + this.tableDir = tableDir; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + + // ── Header ──────────────────────────────────────────────────────── + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;" + + " -fx-border-width: 0 0 1 0;"); + + // ── Type list (always 5 fixed entries) ─────────────────────────── + listView = new ListView<>(); + listView.getItems().setAll(CraftingTable.CraftingTableType.values()); + listView.setPrefHeight(160); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(CraftingTable.CraftingTableType type, boolean empty) { + super.updateItem(type, empty); + if (empty || type == null) { setText(null); setStyle(""); return; } + boolean configured = shared.containsKey(type); + String color = typeColor(type); + String suffix = configured ? " ✓" : " —"; + setText(type.name() + suffix); + String fillColor = configured ? "#dddddd" : "#666666"; + setStyle("-fx-text-fill: " + fillColor + ";" + + " -fx-border-color: transparent transparent transparent " + color + ";" + + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); + setTooltip(new Tooltip(type.name() + (configured ? " – konfiguriert" : " – nicht konfiguriert"))); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onTypeSelected(old, nw)); + + deleteBtn = new Button("Konfiguration löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + VBox listSection = new VBox(listView, deleteBtn); + listSection.setPadding(new Insets(0, 0, 4, 0)); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + VBox.setMargin(deleteBtn, new Insets(4, 8, 4, 8)); + + // ── Form ────────────────────────────────────────────────────────── + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + // ── Form construction ───────────────────────────────────────────────── + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + formTypeLabel = new Label("—"); + formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #ccddff;"); + + nameIdField = new TextField(); + nameIdField.setPromptText("Text-Referenz ID (z. B. ui.crafting.alchemy_table)"); + + objectPathField = new TextField(); + objectPathField.setPromptText("Asset-Pfad zum 3D-Objekt (z. B. Models/crafting/alchemy_table.j3o)"); + + Button saveBtn = new Button("Crafting Table speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrentTable()); + + form.getChildren().addAll( + formTypeLabel, + new Separator(), + sectionTitle("Bezeichnung"), + row("Name-ID:", nameIdField), + new Separator(), + sectionTitle("3D-Objekt"), + row("Pfad:", objectPathField), + new Separator(), + saveBtn + ); + return form; + } + + // ── Form load / save ────────────────────────────────────────────────── + + private void onTypeSelected(CraftingTable.CraftingTableType old, CraftingTable.CraftingTableType nw) { + currentType = nw; + if (nw == null) { + formContainer.setDisable(true); + deleteBtn.setDisable(true); + clearForm(); + } else { + formContainer.setDisable(false); + loadFormFromType(nw); + deleteBtn.setDisable(!shared.containsKey(nw)); + } + } + + private void loadFormFromType(CraftingTable.CraftingTableType type) { + String color = typeColor(type); + formTypeLabel.setText(type.name()); + formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13;" + + " -fx-text-fill: " + color + ";"); + + CraftingTable t = shared.get(type); + if (t != null) { + nameIdField.setText(t.getName() != null ? t.getName().id() : ""); + objectPathField.setText(t.getObject() != null ? safe(t.getObject().getPath()) : ""); + } else { + clearFormFields(); + } + } + + private void saveCurrentTable() { + if (currentType == null) return; + + CraftingTable t = shared.getOrDefault(currentType, new CraftingTable()); + t.setType(currentType); + + String nameId = nameIdField.getText().trim(); + t.setName(nameId.isBlank() ? null : new TextReference(nameId)); + + String objPath = objectPathField.getText().trim(); + t.setObject(objPath.isBlank() ? null : new ObjectReference(objPath)); + + try { + CraftingTableIO.save(t, tableDir); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + listView.getSelectionModel().select(currentType); + } + + private void deleteSelected() { + if (currentType == null) return; + try { + CraftingTableIO.delete(currentType, tableDir); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + listView.getSelectionModel().select(currentType); + } + + /** Called by the parent view when shared data has been reloaded. */ + void refresh() { + listView.refresh(); + if (currentType != null) { + deleteBtn.setDisable(!shared.containsKey(currentType)); + if (formContainer.isDisable()) return; + loadFormFromType(currentType); + } + } + + private void clearForm() { + formTypeLabel.setText("—"); + formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #ccddff;"); + clearFormFields(); + } + + private void clearFormFields() { + nameIdField.clear(); + objectPathField.clear(); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + static String typeColor(CraftingTable.CraftingTableType type) { + if (type == null) return "#666666"; + return switch (type) { + case AlchemyTable -> "#44bb88"; + case EnchantmentTable -> "#aa55ee"; + case Smithy -> "#cc8833"; + case Goldsmiths -> "#ddbb22"; + case Workshop -> "#4488cc"; + }; + } + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(60); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static String safe(String s) { return s != null ? s : ""; } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java new file mode 100644 index 0000000..0f43db7 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java @@ -0,0 +1,831 @@ +package de.blight.editor.ui; + +import de.blight.common.model.*; +import de.blight.common.model.quests.Quest; +import de.blight.common.model.QuestRef; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.shape.Line; +import javafx.scene.shape.Polygon; +import javafx.scene.shape.Rectangle; +import javafx.scene.text.Text; +import javafx.stage.Modality; + +import java.util.*; + +/** + * Vollbild-Ansicht für Dialog-Option-Editor im CharacterEditor. + * Zeigt alle DialogOptions eines NPC in einer Listen- oder Graph-Ansicht. + */ +public class DialogEditorView extends BorderPane { + + // ── State ───────────────────────────────────────────────────────────────── + + private final Map allOptions = new LinkedHashMap<>(); + private final Set rootIds = new LinkedHashSet<>(); + private String selectedId = null; + + // ── Top-bar ─────────────────────────────────────────────────────────────── + + private ToggleButton listBtn; + private ToggleButton graphBtn; + + // ── List-mode ───────────────────────────────────────────────────────────── + + private SplitPane splitPane; + private ListView optionListView; + private ScrollPane detailScroll; + + // ── Detail-form fields ──────────────────────────────────────────────────── + + private TextField labelField; + private Label idLabel; + private CheckBox rootCheck; + private Spinner chapterSpinner; + private ComboBox statusCombo; + private TextField questOpenField; + private TextField questCompleteField; + private TextField textHeroField; + private TextField textNpcField; + private TextField reqItemIdField; + private Spinner reqItemCount; + private TextField recvItemIdField; + private Spinner recvItemCount; + private TextField recvQuestField; + private TextField fulfillsQuestField; + private ListView abortsQuestsView; + private CheckBox enablesTradeCheck; + private ListView nextOptionsView; + private ListView disablesOptionsView; + + // ── Graph-mode ──────────────────────────────────────────────────────────── + + private Pane graphCanvas; + private ScrollPane graphScroll; + + // ── Construction ────────────────────────────────────────────────────────── + + public DialogEditorView() { + setStyle("-fx-background-color: #252535;"); + setTop(buildTopBar()); + splitPane = buildListPane(); + setCenter(splitPane); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + public void loadNpc(NPC npc) { + saveCurrentForm(); + allOptions.clear(); + rootIds.clear(); + selectedId = null; + if (npc.getCurrentOptions() != null) { + for (DialogOption opt : npc.getCurrentOptions()) { + collectOptions(opt); + if (opt.getId() != null) rootIds.add(opt.getId()); + } + normalizeReferences(); + } + refreshOptionList(); + clearDetailForm(); + } + + public void clear() { + saveCurrentForm(); + allOptions.clear(); + rootIds.clear(); + selectedId = null; + refreshOptionList(); + clearDetailForm(); + } + + public void exportToNpc(NPC npc) { + saveCurrentForm(); + List roots = new ArrayList<>(); + for (String id : rootIds) { + DialogOption opt = allOptions.get(id); + if (opt != null) roots.add(opt); + } + npc.setCurrentOptions(roots.isEmpty() ? null : roots); + } + + // ── Top-bar ──────────────────────────────────────────────────────────────── + + private HBox buildTopBar() { + listBtn = new ToggleButton("Liste"); + graphBtn = new ToggleButton("Graph"); + ToggleGroup tg = new ToggleGroup(); + listBtn.setToggleGroup(tg); + graphBtn.setToggleGroup(tg); + listBtn.setSelected(true); + listBtn.setOnAction(e -> { if (listBtn.isSelected()) switchToListMode(); }); + graphBtn.setOnAction(e -> { if (graphBtn.isSelected()) switchToGraphMode(); }); + + Button newRootBtn = new Button("+ Root-Option"); + newRootBtn.setStyle("-fx-background-color: #3a7a3a; -fx-text-fill: white;"); + newRootBtn.setOnAction(e -> createOption(true)); + + Button newBtn = new Button("+ Option"); + newBtn.setOnAction(e -> createOption(false)); + + HBox bar = new HBox(8, listBtn, graphBtn, + new Separator(Orientation.VERTICAL), newRootBtn, newBtn); + bar.setPadding(new Insets(8)); + bar.setAlignment(Pos.CENTER_LEFT); + bar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #555;" + + " -fx-border-width: 0 0 1 0;"); + return bar; + } + + // ── List mode ───────────────────────────────────────────────────────────── + + private SplitPane buildListPane() { + // ── Left: option list ───────────────────────────────────────────────── + optionListView = new ListView<>(); + optionListView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(String id, boolean empty) { + super.updateItem(id, empty); + if (empty || id == null) { setText(null); setStyle(""); return; } + DialogOption opt = allOptions.get(id); + String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank()) + ? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "…"; + setText((rootIds.contains(id) ? "★ " : " ") + lbl); + setStyle("-fx-text-fill: " + (rootIds.contains(id) ? "#ffdd88" : "#cccccc") + ";"); + } + }); + optionListView.getSelectionModel().selectedItemProperty().addListener( + (obs, oldId, newId) -> onOptionSelected(oldId, newId)); + VBox.setVgrow(optionListView, Priority.ALWAYS); + + Button rootToggleBtn = new Button("★ Root togglen"); + rootToggleBtn.setMaxWidth(Double.MAX_VALUE); + rootToggleBtn.setOnAction(e -> toggleRoot()); + + Button deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #7a2a2a; -fx-text-fill: white;"); + deleteBtn.setOnAction(e -> deleteSelected()); + + VBox leftBox = new VBox(6, optionListView, rootToggleBtn, deleteBtn); + leftBox.setPadding(new Insets(8)); + leftBox.setStyle("-fx-background-color: #1e1e2e;"); + leftBox.setPrefWidth(230); + + // ── Right: detail form ──────────────────────────────────────────────── + detailScroll = new ScrollPane(buildDetailForm()); + detailScroll.setFitToWidth(true); + detailScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + + SplitPane sp = new SplitPane(leftBox, detailScroll); + sp.setDividerPositions(0.27); + sp.setStyle("-fx-background-color: #252535;"); + return sp; + } + + private void switchToListMode() { + setCenter(splitPane); + } + + // ── Graph mode ──────────────────────────────────────────────────────────── + + private void switchToGraphMode() { + saveCurrentForm(); + graphCanvas = new Pane(); + graphCanvas.setStyle("-fx-background-color: #1a1a2a;"); + rebuildGraph(); + graphScroll = new ScrollPane(graphCanvas); + graphScroll.setStyle("-fx-background-color: #1a1a2a; -fx-background: #1a1a2a;"); + setCenter(graphScroll); + } + + // ── Detail form ─────────────────────────────────────────────────────────── + + private VBox buildDetailForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(12)); + form.setStyle("-fx-background-color: #252535;"); + + // Identity + labelField = new TextField(); + labelField.setPromptText("Bezeichnung (nur im Editor)"); + idLabel = new Label("—"); + idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #777;"); + rootCheck = new CheckBox("Root-Option (initial im Dialog sichtbar)"); + rootCheck.setStyle("-fx-text-fill: #ccc;"); + + form.getChildren().addAll( + sectionTitle("Option"), new Separator(), + row("Bezeichnung:", labelField), + row("ID:", idLabel), + rootCheck, new Separator() + ); + + // Voraussetzungen + chapterSpinner = new Spinner<>(0, 99, 0); + chapterSpinner.setEditable(true); + chapterSpinner.setPrefWidth(80); + + statusCombo = new ComboBox<>(); + statusCombo.getItems().addAll("— (keine)", "FRIENDLY", "NEUTRAL", "ENRAGED", "ENEMY"); + statusCombo.setValue("— (keine)"); + statusCombo.setMaxWidth(Double.MAX_VALUE); + + questOpenField = new TextField(); + questOpenField.setPromptText("Quest-ID"); + questCompleteField = new TextField(); + questCompleteField.setPromptText("Quest-ID"); + + form.getChildren().addAll( + sectionTitle("Voraussetzungen"), + row("Kapitel ≥:", chapterSpinner), + row("Status ≥:", statusCombo), + row("Quest offen:", questOpenField), + row("Quest abgeschl.:", questCompleteField), + new Separator() + ); + + // Texte + textHeroField = new TextField(); + textHeroField.setPromptText("TextReference-Schlüssel"); + textNpcField = new TextField(); + textNpcField.setPromptText("TextReference-Schlüssel"); + + form.getChildren().addAll( + sectionTitle("Texte"), + row("Text Held:", textHeroField), + row("Text NPC:", textNpcField), + row("Audio Held:", placeholder("(wird später implementiert)")), + row("Audio NPC:", placeholder("(wird später implementiert)")), + new Separator() + ); + + // Items + reqItemIdField = new TextField(); + reqItemIdField.setPromptText("Item-ID"); + reqItemCount = new Spinner<>(1, 999, 1); + reqItemCount.setPrefWidth(70); + + recvItemIdField = new TextField(); + recvItemIdField.setPromptText("Item-ID"); + recvItemCount = new Spinner<>(1, 999, 1); + recvItemCount.setPrefWidth(70); + + form.getChildren().addAll( + sectionTitle("Items"), + itemRow("Benötigt:", reqItemIdField, reqItemCount), + itemRow("Erhält:", recvItemIdField, recvItemCount), + new Separator() + ); + + // Quests + recvQuestField = new TextField(); + recvQuestField.setPromptText("Quest-ID"); + fulfillsQuestField = new TextField(); + fulfillsQuestField.setPromptText("Quest-ID"); + + abortsQuestsView = new ListView<>(); + abortsQuestsView.setPrefHeight(80); + abortsQuestsView.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;"); + Button addAbortsBtn = smallBtn("+"); + Button delAbortsBtn = smallBtn("−"); + addAbortsBtn.setOnAction(e -> promptQuestId(abortsQuestsView)); + delAbortsBtn.setOnAction(e -> removeSelected(abortsQuestsView)); + + form.getChildren().addAll( + sectionTitle("Quests"), + row("Erhält Quest:", recvQuestField), + row("Erfüllt Quest:", fulfillsQuestField), + sectionTitle("Bricht Quests ab:"), + abortsQuestsView, + new HBox(4, addAbortsBtn, delAbortsBtn), + new Separator() + ); + + // Effekte + enablesTradeCheck = new CheckBox("Ermöglicht Handel"); + enablesTradeCheck.setStyle("-fx-text-fill: #ccc;"); + form.getChildren().addAll( + sectionTitle("Effekte"), + enablesTradeCheck, + new Separator() + ); + + // Verbindungen: nextOptions + nextOptionsView = buildRefList(); + Button addNextBtn = smallBtn("+"); + Button delNextBtn = smallBtn("−"); + addNextBtn.setOnAction(e -> pickOptionRef(nextOptionsView)); + delNextBtn.setOnAction(e -> removeSelected(nextOptionsView)); + + // Verbindungen: disablesOptions + disablesOptionsView = buildRefList(); + Button addDisBtn = smallBtn("+"); + Button delDisBtn = smallBtn("−"); + addDisBtn.setOnAction(e -> pickOptionRef(disablesOptionsView)); + delDisBtn.setOnAction(e -> removeSelected(disablesOptionsView)); + + form.getChildren().addAll( + sectionTitle("Nächste Optionen (nextOptions):"), + nextOptionsView, + new HBox(4, addNextBtn, delNextBtn), + sectionTitle("Deaktiviert Optionen (disablesOptions):"), + disablesOptionsView, + new HBox(4, addDisBtn, delDisBtn) + ); + + return form; + } + + private ListView buildRefList() { + ListView lv = new ListView<>(); + lv.setPrefHeight(90); + lv.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;"); + lv.setCellFactory(v -> new ListCell<>() { + @Override protected void updateItem(String id, boolean empty) { + super.updateItem(id, empty); + if (empty || id == null) { setText(null); return; } + DialogOption opt = allOptions.get(id); + String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank()) + ? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "…"; + setText(lbl); + setStyle("-fx-text-fill: #cccccc;"); + } + }); + return lv; + } + + // ── Form load / save ────────────────────────────────────────────────────── + + private void onOptionSelected(String oldId, String newId) { + if (oldId != null) saveFormToOption(oldId); + selectedId = newId; + if (newId != null) loadFormFromOption(newId); + else clearDetailForm(); + } + + private void loadFormFromOption(String id) { + DialogOption opt = allOptions.get(id); + if (opt == null) { clearDetailForm(); return; } + + idLabel.setText(id); + labelField.setText(opt.getLabel() != null ? opt.getLabel() : ""); + rootCheck.setSelected(rootIds.contains(id)); + chapterSpinner.getValueFactory().setValue(opt.getRequiresChapter()); + + Status s = opt.getRequiresStatus(); + statusCombo.setValue(s == null ? "— (keine)" : s.name()); + + questOpenField.setText(opt.getRequiresQuestOpen() != null + ? safe(opt.getRequiresQuestOpen().getQuestId()) : ""); + questCompleteField.setText(opt.getRequiresQuestComplete() != null + ? safe(opt.getRequiresQuestComplete().getQuestId()) : ""); + + textHeroField.setText(opt.getTextHero() != null ? opt.getTextHero().id() : ""); + textNpcField.setText(opt.getTextNpc() != null ? opt.getTextNpc().id() : ""); + + if (opt.getRequiredItem() != null && opt.getRequiredItem().getItem() != null) { + reqItemIdField.setText(safe(opt.getRequiredItem().getItem().getItemId())); + reqItemCount.getValueFactory().setValue(opt.getRequiredItem().getCount()); + } else { + reqItemIdField.clear(); + reqItemCount.getValueFactory().setValue(1); + } + if (opt.getRecievesItem() != null && opt.getRecievesItem().getItem() != null) { + recvItemIdField.setText(safe(opt.getRecievesItem().getItem().getItemId())); + recvItemCount.getValueFactory().setValue(opt.getRecievesItem().getCount()); + } else { + recvItemIdField.clear(); + recvItemCount.getValueFactory().setValue(1); + } + + recvQuestField.setText(opt.getRecievesQuest() != null + ? safe(opt.getRecievesQuest().getQuestId()) : ""); + fulfillsQuestField.setText(opt.getFulfillsQuest() != null + ? safe(opt.getFulfillsQuest().getQuestId()) : ""); + + abortsQuestsView.getItems().clear(); + if (opt.getAbortsQuests() != null) + opt.getAbortsQuests().stream() + .filter(q -> q != null && q.getQuestId() != null) + .map(Quest::getQuestId) + .forEach(abortsQuestsView.getItems()::add); + + enablesTradeCheck.setSelected(opt.isEnablesTrade()); + + nextOptionsView.getItems().clear(); + if (opt.getNextOptions() != null) + opt.getNextOptions().stream() + .filter(o -> o != null && o.getId() != null) + .map(DialogOption::getId) + .forEach(nextOptionsView.getItems()::add); + + disablesOptionsView.getItems().clear(); + if (opt.getDisablesOptions() != null) + opt.getDisablesOptions().stream() + .filter(o -> o != null && o.getId() != null) + .map(DialogOption::getId) + .forEach(disablesOptionsView.getItems()::add); + } + + private void saveFormToOption(String id) { + DialogOption opt = allOptions.get(id); + if (opt == null) return; + + opt.setLabel(labelField.getText()); + + if (rootCheck.isSelected()) rootIds.add(id); + else rootIds.remove(id); + + opt.setRequiresChapter(chapterSpinner.getValue()); + + String sv = statusCombo.getValue(); + if (sv == null || sv.startsWith("—")) { + opt.setRequiresStatus(null); + } else { + try { opt.setRequiresStatus(Status.valueOf(sv)); } + catch (IllegalArgumentException ex) { opt.setRequiresStatus(null); } + } + + opt.setRequiresQuestOpen(questFromField(questOpenField)); + opt.setRequiresQuestComplete(questFromField(questCompleteField)); + + opt.setTextHero(refFromField(textHeroField)); + opt.setTextNpc(refFromField(textNpcField)); + + opt.setRequiredItem(requiredItemFromFields(reqItemIdField, reqItemCount)); + opt.setRecievesItem(recievesItemFromFields(recvItemIdField, recvItemCount)); + + opt.setRecievesQuest(questFromField(recvQuestField)); + opt.setFulfillsQuest(questFromField(fulfillsQuestField)); + + List aborts = new ArrayList<>(); + for (String qId : abortsQuestsView.getItems()) { + QuestRef q = new QuestRef(); q.setQuestId(qId); aborts.add(q); + } + opt.setAbortsQuests(aborts); + + opt.setEnablesTrade(enablesTradeCheck.isSelected()); + + opt.setNextOptions(resolveRefs(nextOptionsView.getItems())); + opt.setDisablesOptions(resolveRefs(disablesOptionsView.getItems())); + + optionListView.refresh(); + } + + private void saveCurrentForm() { + if (selectedId != null) saveFormToOption(selectedId); + } + + private void clearDetailForm() { + if (idLabel != null) idLabel.setText("—"); + if (labelField != null) labelField.clear(); + if (rootCheck != null) rootCheck.setSelected(false); + if (chapterSpinner != null) chapterSpinner.getValueFactory().setValue(0); + if (statusCombo != null) statusCombo.setValue("— (keine)"); + if (questOpenField != null) questOpenField.clear(); + if (questCompleteField != null) questCompleteField.clear(); + if (textHeroField != null) textHeroField.clear(); + if (textNpcField != null) textNpcField.clear(); + if (reqItemIdField != null) reqItemIdField.clear(); + if (reqItemCount != null) reqItemCount.getValueFactory().setValue(1); + if (recvItemIdField != null) recvItemIdField.clear(); + if (recvItemCount != null) recvItemCount.getValueFactory().setValue(1); + if (recvQuestField != null) recvQuestField.clear(); + if (fulfillsQuestField != null) fulfillsQuestField.clear(); + if (abortsQuestsView != null) abortsQuestsView.getItems().clear(); + if (enablesTradeCheck != null) enablesTradeCheck.setSelected(false); + if (nextOptionsView != null) nextOptionsView.getItems().clear(); + if (disablesOptionsView != null) disablesOptionsView.getItems().clear(); + } + + // ── List management ─────────────────────────────────────────────────────── + + private void refreshOptionList() { + if (optionListView == null) return; + String prev = selectedId; + optionListView.getItems().setAll(allOptions.keySet()); + if (prev != null && allOptions.containsKey(prev)) + optionListView.getSelectionModel().select(prev); + } + + private void createOption(boolean asRoot) { + saveCurrentForm(); + DialogOption opt = new DialogOption(); + opt.setLabel("Neue Option"); + allOptions.put(opt.getId(), opt); + if (asRoot) rootIds.add(opt.getId()); + refreshOptionList(); + optionListView.getSelectionModel().select(opt.getId()); + } + + private void toggleRoot() { + String id = optionListView.getSelectionModel().getSelectedItem(); + if (id == null) return; + if (rootIds.contains(id)) rootIds.remove(id); else rootIds.add(id); + optionListView.refresh(); + if (id.equals(selectedId)) rootCheck.setSelected(rootIds.contains(id)); + } + + private void deleteSelected() { + String id = optionListView.getSelectionModel().getSelectedItem(); + if (id == null) return; + allOptions.remove(id); + rootIds.remove(id); + for (DialogOption opt : allOptions.values()) { + if (opt.getNextOptions() != null) opt.getNextOptions().removeIf(o -> id.equals(o.getId())); + if (opt.getDisablesOptions() != null) opt.getDisablesOptions().removeIf(o -> id.equals(o.getId())); + } + selectedId = null; + refreshOptionList(); + clearDetailForm(); + } + + private void pickOptionRef(ListView target) { + Dialog dlg = new Dialog<>(); + dlg.setTitle("Option auswählen"); + dlg.initModality(Modality.APPLICATION_MODAL); + + ListView chooser = new ListView<>(); + chooser.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(String id, boolean empty) { + super.updateItem(id, empty); + if (empty || id == null) { setText(null); return; } + DialogOption opt = allOptions.get(id); + String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank()) + ? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "…"; + setText((rootIds.contains(id) ? "★ " : " ") + lbl); + } + }); + chooser.getItems().addAll(allOptions.keySet()); + chooser.setPrefSize(320, 280); + + dlg.getDialogPane().setContent(chooser); + dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + Button okBtn = (Button) dlg.getDialogPane().lookupButton(ButtonType.OK); + okBtn.setDisable(true); + chooser.getSelectionModel().selectedItemProperty() + .addListener((obs, o, n) -> okBtn.setDisable(n == null)); + chooser.setOnMouseClicked(e -> { + if (e.getClickCount() == 2 && !chooser.getSelectionModel().isEmpty()) okBtn.fire(); + }); + dlg.setResultConverter(bt -> bt == ButtonType.OK + ? chooser.getSelectionModel().getSelectedItem() : null); + dlg.showAndWait().ifPresent(id -> { + if (!target.getItems().contains(id)) target.getItems().add(id); + }); + } + + private void promptQuestId(ListView target) { + TextInputDialog dlg = new TextInputDialog(); + dlg.setTitle("Quest-ID"); + dlg.setHeaderText("Quest-ID eingeben:"); + dlg.initModality(Modality.APPLICATION_MODAL); + dlg.showAndWait().ifPresent(id -> { + if (!id.isBlank() && !target.getItems().contains(id)) target.getItems().add(id); + }); + } + + private static void removeSelected(ListView list) { + String sel = list.getSelectionModel().getSelectedItem(); + if (sel != null) list.getItems().remove(sel); + } + + // ── Graph ───────────────────────────────────────────────────────────────── + + private void rebuildGraph() { + graphCanvas.getChildren().clear(); + if (allOptions.isEmpty()) { + graphCanvas.setPrefSize(600, 200); + Text hint = new Text("Keine Dialog-Optionen vorhanden"); + hint.setFill(Color.web("#666")); + hint.setLayoutX(200); hint.setLayoutY(100); + graphCanvas.getChildren().add(hint); + return; + } + + double nodeW = 160, nodeH = 44, hGap = 24, vGap = 70; + + // BFS layer assignment starting from root options + Map layer = new LinkedHashMap<>(); + Queue queue = new ArrayDeque<>(rootIds); + for (String id : rootIds) layer.put(id, 0); + while (!queue.isEmpty()) { + String id = queue.poll(); + DialogOption opt = allOptions.get(id); + if (opt == null || opt.getNextOptions() == null) continue; + int lyr = layer.get(id); + for (DialogOption next : opt.getNextOptions()) { + if (next != null && !layer.containsKey(next.getId())) { + layer.put(next.getId(), lyr + 1); + queue.add(next.getId()); + } + } + } + int maxLayer = layer.values().stream().mapToInt(v -> v).max().orElse(0); + for (String id : allOptions.keySet()) + if (!layer.containsKey(id)) layer.put(id, maxLayer + 1); + + // Group by layer + Map> groups = new TreeMap<>(); + layer.forEach((id, lyr) -> groups.computeIfAbsent(lyr, k -> new ArrayList<>()).add(id)); + + // Assign x/y positions + Map pos = new HashMap<>(); + groups.forEach((lyr, ids) -> { + double totalW = ids.size() * nodeW + (ids.size() - 1) * hGap; + double startX = Math.max(20, (900 - totalW) / 2.0); + double y = 24 + lyr * (nodeH + vGap); + for (int i = 0; i < ids.size(); i++) + pos.put(ids.get(i), new double[]{ startX + i * (nodeW + hGap), y }); + }); + + // Draw edges first (behind nodes) + for (String id : allOptions.keySet()) { + DialogOption opt = allOptions.get(id); + double[] from = pos.get(id); + if (from == null) continue; + double fx = from[0] + nodeW / 2, fy = from[1] + nodeH; + if (opt.getNextOptions() != null) { + for (DialogOption next : opt.getNextOptions()) { + double[] to = pos.get(next.getId()); + if (to != null) drawArrow(fx, fy, to[0] + nodeW / 2, to[1], "#5588cc", false); + } + } + if (opt.getDisablesOptions() != null) { + for (DialogOption dis : opt.getDisablesOptions()) { + double[] to = pos.get(dis.getId()); + if (to != null) drawArrow(fx, fy, to[0] + nodeW / 2, to[1], "#cc4444", true); + } + } + } + + // Draw nodes + for (Map.Entry entry : pos.entrySet()) { + String id = entry.getKey(); + double[] p = entry.getValue(); + DialogOption opt = allOptions.get(id); + boolean isRoot = rootIds.contains(id); + String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank()) + ? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "…"; + + Rectangle rect = new Rectangle(p[0], p[1], nodeW, nodeH); + rect.setArcWidth(8); rect.setArcHeight(8); + rect.setFill(Color.web(isRoot ? "#253a5a" : "#2a3040")); + rect.setStroke(Color.web(isRoot ? "#5599ee" : "#445566")); + rect.setStrokeWidth(isRoot ? 2 : 1); + + Text txt = new Text(truncate(lbl, 21)); + txt.setFill(Color.web(isRoot ? "#ddeeff" : "#aabbcc")); + txt.setLayoutX(p[0] + 8); + txt.setLayoutY(p[1] + nodeH / 2.0 + 5); + + graphCanvas.getChildren().addAll(rect, txt); + } + + double maxX = pos.values().stream().mapToDouble(p -> p[0] + nodeW + 30).max().orElse(400); + double maxY = pos.values().stream().mapToDouble(p -> p[1] + nodeH + 30).max().orElse(200); + graphCanvas.setPrefSize(Math.max(900, maxX), Math.max(300, maxY)); + } + + private void drawArrow(double x1, double y1, double x2, double y2, + String colorHex, boolean dashed) { + Line line = new Line(x1, y1, x2, y2); + line.setStroke(Color.web(colorHex)); + line.setStrokeWidth(1.5); + if (dashed) line.getStrokeDashArray().addAll(6.0, 4.0); + + double angle = Math.atan2(y2 - y1, x2 - x1); + double as = 9; + Polygon head = new Polygon( + x2, y2, + x2 - as * Math.cos(angle - 0.45), y2 - as * Math.sin(angle - 0.45), + x2 - as * Math.cos(angle + 0.45), y2 - as * Math.sin(angle + 0.45) + ); + head.setFill(Color.web(colorHex)); + graphCanvas.getChildren().addAll(line, head); + } + + // ── Data helpers ────────────────────────────────────────────────────────── + + private void collectOptions(DialogOption opt) { + if (opt == null || allOptions.containsKey(opt.getId())) return; + allOptions.put(opt.getId(), opt); + if (opt.getNextOptions() != null) + for (DialogOption next : opt.getNextOptions()) collectOptions(next); + if (opt.getDisablesOptions() != null) + for (DialogOption dis : opt.getDisablesOptions()) collectOptions(dis); + } + + private void normalizeReferences() { + for (DialogOption opt : allOptions.values()) { + if (opt.getNextOptions() != null) { + List norm = new ArrayList<>(); + for (DialogOption o : opt.getNextOptions()) { + DialogOption c = allOptions.get(o.getId()); + if (c != null) norm.add(c); + } + opt.setNextOptions(norm); + } + if (opt.getDisablesOptions() != null) { + List norm = new ArrayList<>(); + for (DialogOption o : opt.getDisablesOptions()) { + DialogOption c = allOptions.get(o.getId()); + if (c != null) norm.add(c); + } + opt.setDisablesOptions(norm); + } + } + } + + private List resolveRefs(List ids) { + List result = new ArrayList<>(); + for (String id : ids) { + DialogOption opt = allOptions.get(id); + if (opt != null) result.add(opt); + } + return result.isEmpty() ? null : result; + } + + // ── Form value helpers ──────────────────────────────────────────────────── + + private static QuestRef questFromField(TextField f) { + String s = f.getText().trim(); + if (s.isBlank()) return null; + QuestRef q = new QuestRef(); q.setQuestId(s); return q; + } + + private static TextReference refFromField(TextField f) { + String s = f.getText().trim(); + return s.isBlank() ? null : new TextReference(s); + } + + private static RequiredItem requiredItemFromFields(TextField idField, Spinner count) { + String s = idField.getText().trim(); + if (s.isBlank()) return null; + RequiredItem ri = new RequiredItem(); + Item item = new Item(); item.setItemId(s); + ri.setItem(item); ri.setCount(count.getValue()); return ri; + } + + private static RecievesItem recievesItemFromFields(TextField idField, Spinner count) { + String s = idField.getText().trim(); + if (s.isBlank()) return null; + RecievesItem rv = new RecievesItem(); + Item item = new Item(); item.setItemId(s); + rv.setItem(item); rv.setCount(count.getValue()); return rv; + } + + private static String safe(String s) { return s != null ? s : ""; } + + private static String truncate(String s, int max) { + if (s == null || s.length() <= max) return s != null ? s : ""; + return s.substring(0, max - 1) + "…"; + } + + // ── Widget helpers ──────────────────────────────────────────────────────── + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(150); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static HBox itemRow(String labelText, TextField idField, Spinner count) { + Label lbl = new Label(labelText); + lbl.setMinWidth(65); + lbl.setStyle("-fx-text-fill: #aaa;"); + Label cntLbl = new Label("Anz:"); + cntLbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(idField, Priority.ALWAYS); + HBox box = new HBox(8, lbl, idField, cntLbl, count); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static Label placeholder(String text) { + Label l = new Label(text); + l.setStyle("-fx-text-fill: #555; -fx-font-style: italic;"); + return l; + } + + private static Button smallBtn(String text) { + Button b = new Button(text); + b.setPrefWidth(28); + return b; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java new file mode 100644 index 0000000..2cf26e0 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/FractionEditorView.java @@ -0,0 +1,302 @@ +package de.blight.editor.ui; + +import de.blight.common.model.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.SortedList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.UUID; + +/** + * Fraktions-Verwaltung: zwei identische FractionPanel-Instanzen nebeneinander. + * Sortiert nach Name-ID, dann nach UUID. + */ +public class FractionEditorView extends BorderPane { + + private final ObservableList sharedFractions = FXCollections.observableArrayList(); + private final Path fractionDir; + + public FractionEditorView(Path fractionDir) { + this.fractionDir = fractionDir; + setStyle("-fx-background-color: #1e1e2e;"); + reload(); + + FractionPanel left = new FractionPanel("Liste 1", sharedFractions, fractionDir, this::reload); + FractionPanel right = new FractionPanel("Liste 2", sharedFractions, fractionDir, this::reload); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + public void reload() { + sharedFractions.setAll(FractionIO.loadAll(fractionDir)); + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class FractionPanel extends VBox { + + private final ObservableList fractions; + private final Path fractionDir; + private final Runnable onSaved; + + private final SortedList sortedFractions; + private final ListView listView; + + private Fraction current = null; + + // Form fields + private Label idLabel; + private TextField nameField; + private TextField maleMemberField; + private TextField femaleMemberField; + private TextField rank1Field; + private TextField rank2Field; + private TextField rank3Field; + + private VBox formContainer; + private Button deleteBtn; + + FractionPanel(String title, ObservableList fractions, Path fractionDir, Runnable onSaved) { + this.fractions = fractions; + this.fractionDir = fractionDir; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + + // ── Header ──────────────────────────────────────────────────────── + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;" + + " -fx-border-width: 0 0 1 0;"); + + // ── Fraction list ───────────────────────────────────────────────── + sortedFractions = new SortedList<>(fractions, FractionIO.SORT_ORDER); + listView = new ListView<>(sortedFractions); + listView.setPrefHeight(180); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Fraction f, boolean empty) { + super.updateItem(f, empty); + if (empty || f == null) { setText(null); setStyle(""); return; } + String name = f.getName() != null ? f.getName().id() : "—"; + String uuid = f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?"; + setText(name); + setTooltip(new Tooltip("ID: " + (f.getFractionId() != null ? f.getFractionId() : "?"))); + setStyle("-fx-text-fill: #dddddd;" + + " -fx-border-color: transparent transparent transparent #6699cc;" + + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onFractionSelected(old, nw)); + + Button newBtn = new Button("Neue Fraktion"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createFraction()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + + VBox listSection = new VBox(listView, listButtons); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + + // ── Form ────────────────────────────────────────────────────────── + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + // ── Form construction ───────────────────────────────────────────────── + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + idLabel = new Label("—"); + idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-font-family: monospace;"); + + nameField = field("z. B. faction.guards"); + maleMemberField = field("z. B. faction.guards.member.male"); + femaleMemberField = field("z. B. faction.guards.member.female"); + rank1Field = field("z. B. faction.guards.rank1"); + rank2Field = field("z. B. faction.guards.rank2"); + rank3Field = field("z. B. faction.guards.rank3"); + + Button saveBtn = new Button("Fraktion speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrentFraction()); + + form.getChildren().addAll( + sectionTitle("Kennung"), + new Separator(), + row("UUID:", idLabel), + sectionTitle("Text-Referenzen"), + new Separator(), + row("Name:", nameField), + row("Mitglied (m):", maleMemberField), + row("Mitglied (w):", femaleMemberField), + sectionTitle("Ränge"), + new Separator(), + row("Rang 1:", rank1Field), + row("Rang 2:", rank2Field), + row("Rang 3:", rank3Field), + new Separator(), + saveBtn + ); + return form; + } + + // ── Form load / save ────────────────────────────────────────────────── + + private void onFractionSelected(Fraction old, Fraction nw) { + if (old != null) saveFormToFraction(old); + current = nw; + deleteBtn.setDisable(nw == null); + if (nw != null) { + formContainer.setDisable(false); + loadFormFromFraction(nw); + } else { + formContainer.setDisable(true); + clearForm(); + } + } + + private void loadFormFromFraction(Fraction f) { + idLabel.setText(f.getFractionId() != null ? f.getFractionId().toString() : "—"); + nameField.setText(textId(f.getName())); + maleMemberField.setText(textId(f.getMaleMemberName())); + femaleMemberField.setText(textId(f.getFemaleMemberName())); + rank1Field.setText(textId(f.getRank1Name())); + rank2Field.setText(textId(f.getRank2Name())); + rank3Field.setText(textId(f.getRank3Name())); + } + + private void saveFormToFraction(Fraction f) { + f.setName(ref(nameField.getText())); + f.setMaleMemberName(ref(maleMemberField.getText())); + f.setFemaleMemberName(ref(femaleMemberField.getText())); + f.setRank1Name(ref(rank1Field.getText())); + f.setRank2Name(ref(rank2Field.getText())); + f.setRank3Name(ref(rank3Field.getText())); + } + + private void clearForm() { + idLabel.setText("—"); + nameField.clear(); + maleMemberField.clear(); + femaleMemberField.clear(); + rank1Field.clear(); + rank2Field.clear(); + rank3Field.clear(); + } + + // ── List operations ─────────────────────────────────────────────────── + + private void createFraction() { + Fraction f = new Fraction(); + f.setFractionId(UUID.randomUUID()); + fractions.add(f); + listView.getSelectionModel().select(f); + } + + private void deleteSelected() { + Fraction sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null) return; + UUID id = sel.getFractionId(); + fractions.remove(sel); + try { FractionIO.delete(id, fractionDir); } catch (IOException ignored) {} + current = null; + clearForm(); + formContainer.setDisable(true); + deleteBtn.setDisable(true); + onSaved.run(); + } + + private void saveCurrentFraction() { + if (current == null) return; + saveFormToFraction(current); + + if (current.getFractionId() == null) { + new Alert(Alert.AlertType.ERROR, + "Fraktion hat keine UUID – bitte neu erstellen.", ButtonType.OK).showAndWait(); + return; + } + try { + FractionIO.save(current, fractionDir); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + final UUID fid = current.getFractionId(); + fractions.stream() + .filter(f -> fid.equals(f.getFractionId())) + .findFirst() + .ifPresent(listView.getSelectionModel()::select); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static TextReference ref(String text) { + String t = text == null ? "" : text.trim(); + return t.isBlank() ? null : new TextReference(t); + } + + private static String textId(TextReference r) { return r != null ? r.id() : ""; } + + private static TextField field(String prompt) { + TextField tf = new TextField(); + tf.setPromptText(prompt); + return tf; + } + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(100); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java new file mode 100644 index 0000000..e13c20a --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/ItemEditorView.java @@ -0,0 +1,367 @@ +package de.blight.editor.ui; + +import de.blight.common.model.Item; +import de.blight.common.model.ItemCategory; +import de.blight.common.model.ItemIO; +import de.blight.common.model.ObjectReference; +import de.blight.common.model.TextReference; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.SortedList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * Item-Verwaltung: zwei identische ItemPanel-Instanzen nebeneinander. + * Liste sortiert nach Kategorie, dann nach Name. + */ +public class ItemEditorView extends BorderPane { + + private final ObservableList sharedItems = FXCollections.observableArrayList(); + private final Path itemDir; + + public ItemEditorView(Path itemDir) { + this.itemDir = itemDir; + setStyle("-fx-background-color: #1e1e2e;"); + reload(); + + ItemPanel left = new ItemPanel("Liste 1", sharedItems, itemDir, this::reload); + ItemPanel right = new ItemPanel("Liste 2", sharedItems, itemDir, this::reload); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + public void reload() { + List loaded = ItemIO.loadAll(itemDir); + sharedItems.setAll(loaded); + } + + // ── Category colors ─────────────────────────────────────────────────────── + + static String categoryColor(ItemCategory cat) { + if (cat == null) return "#666666"; + return switch (cat) { + case WEAPON -> "#cc5555"; + case GEAR -> "#5588cc"; + case CONSUMABLES -> "#55aa55"; + case QUEST_ITEMS -> "#ccaa33"; + case USABLES -> "#aa55cc"; + case MISC -> "#778899"; + }; + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class ItemPanel extends VBox { + + private final ObservableList items; + private final Path itemDir; + private final Runnable onSaved; + + private final SortedList sortedItems; + private final ListView listView; + private Item current = null; + + // Form fields + private TextField idField; + private ComboBox catCombo; + private TextField nameField; + private TextField descField; + private Spinner goldSpinner; + private TextField modelRefField; + + // Form container + private VBox formContainer; + private Button deleteBtn; + + ItemPanel(String title, ObservableList items, Path itemDir, Runnable onSaved) { + this.items = items; + this.itemDir = itemDir; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + setSpacing(0); + + // ── Header ──────────────────────────────────────────────────────── + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;" + + " -fx-border-width: 0 0 1 0;"); + + // ── Item list (sorted) ───────────────────────────────────────────── + sortedItems = new SortedList<>(items, ItemIO.SORT_ORDER); + listView = new ListView<>(sortedItems); + listView.setPrefHeight(200); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Item item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null) { setText(null); setStyle(""); return; } + + String catName = item.getCategory() != null ? item.getCategory().name() : "—"; + String name = item.getName() != null ? item.getName().id() + : (item.getItemId() != null ? item.getItemId() : "—"); + setText(name); + String color = categoryColor(item.getCategory()); + setStyle("-fx-text-fill: #dddddd;" + + " -fx-border-color: transparent transparent transparent " + color + ";" + + " -fx-border-width: 0 0 0 3;" + + " -fx-padding: 3 6 3 8;"); + setTooltip(new Tooltip("[" + catName + "] " + item.getItemId())); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onItemSelected(old, nw)); + + // Category legend + HBox legend = buildLegend(); + legend.setPadding(new Insets(4, 8, 4, 8)); + legend.setStyle("-fx-background-color: #1a1a2a;"); + + Button newBtn = new Button("Neues Item"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createItem()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + + VBox listSection = new VBox(listView, legend, listButtons); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + + // ── Form ────────────────────────────────────────────────────────── + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + // ── Legend ──────────────────────────────────────────────────────────── + + private HBox buildLegend() { + HBox box = new HBox(10); + box.setAlignment(Pos.CENTER_LEFT); + for (ItemCategory cat : ItemCategory.values()) { + Label dot = new Label("■"); + dot.setStyle("-fx-text-fill: " + categoryColor(cat) + "; -fx-font-size: 10;"); + Label lbl = new Label(cat.name()); + lbl.setStyle("-fx-text-fill: #888; -fx-font-size: 10;"); + box.getChildren().addAll(dot, lbl); + } + return box; + } + + // ── Form construction ───────────────────────────────────────────────── + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + idField = new TextField(); + idField.setPromptText("eindeutige ID"); + + catCombo = new ComboBox<>(); + catCombo.getItems().addAll(ItemCategory.values()); + catCombo.setMaxWidth(Double.MAX_VALUE); + catCombo.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(ItemCategory cat, boolean empty) { + super.updateItem(cat, empty); + if (empty || cat == null) { setText(null); return; } + setText(cat.name()); + setStyle("-fx-text-fill: " + categoryColor(cat) + ";"); + } + }); + catCombo.setButtonCell(new ListCell<>() { + @Override protected void updateItem(ItemCategory cat, boolean empty) { + super.updateItem(cat, empty); + if (empty || cat == null) { setText(null); return; } + setText(cat.name()); + setStyle("-fx-text-fill: " + categoryColor(cat) + ";"); + } + }); + + nameField = new TextField(); + nameField.setPromptText("TextReference-Schlüssel"); + descField = new TextField(); + descField.setPromptText("TextReference-Schlüssel"); + goldSpinner = new Spinner<>(0, 999999, 0); + goldSpinner.setEditable(true); + goldSpinner.setMaxWidth(Double.MAX_VALUE); + modelRefField = new TextField(); + modelRefField.setPromptText("Modell-Pfad (z.B. Models/Items/sword.j3o)"); + + Button saveBtn = new Button("Item speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrentItem()); + + form.getChildren().addAll( + sectionTitle("Item"), + new Separator(), + row("Item-ID:", idField), + row("Kategorie:", catCombo), + new Separator(), + sectionTitle("Texte"), + row("Name:", nameField), + row("Beschreibung:", descField), + new Separator(), + sectionTitle("Werte"), + row("Wert (Gold):", goldSpinner), + row("Modell:", modelRefField), + new Separator(), + saveBtn + ); + return form; + } + + // ── Form load / save ────────────────────────────────────────────────── + + private void onItemSelected(Item old, Item nw) { + if (old != null) saveFormToItem(old); + current = nw; + deleteBtn.setDisable(nw == null); + if (nw != null) { + formContainer.setDisable(false); + loadFormFromItem(nw); + } else { + formContainer.setDisable(true); + clearForm(); + } + } + + private void loadFormFromItem(Item item) { + idField.setText(safe(item.getItemId())); + catCombo.setValue(item.getCategory()); + nameField.setText(item.getName() != null ? item.getName().id() : ""); + descField.setText(item.getDescription() != null ? item.getDescription().id() : ""); + goldSpinner.getValueFactory().setValue(item.getWorthGold()); + ObjectReference ref = item.getModelRef(); + modelRefField.setText(ref != null && ref.getPath() != null ? ref.getPath() : ""); + } + + private void saveFormToItem(Item item) { + item.setItemId(idField.getText().trim()); + item.setCategory(catCombo.getValue()); + item.setName(ref(nameField)); + item.setDescription(ref(descField)); + item.setWorthGold(goldSpinner.getValue()); + String mr = modelRefField.getText().trim(); + item.setModelRef(mr.isBlank() ? null : new ObjectReference(mr)); + } + + private void clearForm() { + idField.clear(); + catCombo.setValue(null); + nameField.clear(); + descField.clear(); + goldSpinner.getValueFactory().setValue(0); + modelRefField.clear(); + } + + // ── List operations ─────────────────────────────────────────────────── + + private void createItem() { + Item item = new Item(); + item.setItemId("neues_item_" + System.currentTimeMillis()); + item.setCategory(ItemCategory.MISC); + items.add(item); + // Select the new item in the sorted view + listView.getSelectionModel().select(item); + } + + private void deleteSelected() { + Item sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null) return; + String iId = sel.getItemId(); + items.remove(sel); + if (iId != null && !iId.isBlank()) { + try { ItemIO.delete(iId, itemDir); } + catch (IOException e) { /* ignore */ } + } + current = null; + clearForm(); + formContainer.setDisable(true); + deleteBtn.setDisable(true); + onSaved.run(); + } + + private void saveCurrentItem() { + if (current == null) return; + saveFormToItem(current); + if (current.getItemId() == null || current.getItemId().isBlank()) { + new Alert(Alert.AlertType.ERROR, "Item-ID darf nicht leer sein.", ButtonType.OK).showAndWait(); + return; + } + try { + ItemIO.save(current, itemDir); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + // Re-select after reload (list re-sorts) + String savedId = current.getItemId(); + items.stream() + .filter(i -> savedId.equals(i.getItemId())) + .findFirst() + .ifPresent(listView.getSelectionModel()::select); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(120); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static String safe(String s) { return s != null ? s : ""; } + + private static TextReference ref(TextField f) { + String s = f.getText().trim(); + return s.isBlank() ? null : new TextReference(s); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java new file mode 100644 index 0000000..a4aed04 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java @@ -0,0 +1,204 @@ +package de.blight.editor.ui; + +import de.blight.common.model.TextBundle; +import de.blight.common.model.TextBundleIO; +import de.blight.common.model.TextRegistry; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.scene.layout.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; + +/** + * Lokalisierungs-Editor: verwaltet Sprach-Bundles (de.json, en.json …) + * und deren Schlüssel→Text-Einträge. + * + * Speicherort: {@code ASSET_ROOT/localization/.json} + */ +public class LocalizationEditorView extends BorderPane { + + private final Path locDir; + + // Sprach-Auswahl + private final ComboBox langCombo = new ComboBox<>(); + private final ObservableList tableData = FXCollections.observableArrayList(); + private final TableView table = new TableView<>(tableData); + + private TextBundle currentBundle = null; + + public LocalizationEditorView(Path locDir) { + this.locDir = locDir; + setStyle("-fx-background-color: #1e1e2e;"); + buildUI(); + refreshLangList(); + } + + private void buildUI() { + // ── Kopfzeile ───────────────────────────────────────────────────────── + Label langLbl = new Label("Sprache:"); + langLbl.setStyle("-fx-text-fill: #aaa;"); + langCombo.setPrefWidth(120); + langCombo.setOnAction(e -> loadLanguage(langCombo.getValue())); + + Button addLangBtn = new Button("+ Sprache"); + addLangBtn.setOnAction(e -> addLanguage()); + + Button delLangBtn = new Button("− Sprache"); + delLangBtn.setOnAction(e -> deleteLanguage()); + + Button addKeyBtn = new Button("+ Schlüssel"); + addKeyBtn.setOnAction(e -> addKey()); + + Button delKeyBtn = new Button("− Entfernen"); + delKeyBtn.setOnAction(e -> deleteKey()); + + Button saveBtn = new Button("Speichern"); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrent()); + + HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn, + new Separator(javafx.geometry.Orientation.VERTICAL), + addKeyBtn, delKeyBtn, + new Separator(javafx.geometry.Orientation.VERTICAL), + saveBtn); + toolbar.setPadding(new Insets(8, 12, 8, 12)); + toolbar.setAlignment(Pos.CENTER_LEFT); + toolbar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;"); + + // ── Tabelle ─────────────────────────────────────────────────────────── + table.setEditable(true); + table.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + + TableColumn keyCol = new TableColumn<>("Schlüssel"); + keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0])); + keyCol.setCellFactory(TextFieldTableCell.forTableColumn()); + keyCol.setOnEditCommit(e -> { + e.getRowValue()[0] = e.getNewValue().trim(); + table.refresh(); + }); + keyCol.setPrefWidth(280); + + TableColumn valCol = new TableColumn<>("Text"); + valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1])); + valCol.setCellFactory(TextFieldTableCell.forTableColumn()); + valCol.setOnEditCommit(e -> { + e.getRowValue()[1] = e.getNewValue(); + table.refresh(); + }); + + table.getColumns().addAll(keyCol, valCol); + VBox.setVgrow(table, Priority.ALWAYS); + + Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags."); + hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;"); + + VBox center = new VBox(table, hint); + VBox.setVgrow(table, Priority.ALWAYS); + + setTop(toolbar); + setCenter(center); + } + + // ── Sprach-Verwaltung ───────────────────────────────────────────────────── + + private void refreshLangList() { + String selected = langCombo.getValue(); + langCombo.getItems().setAll(TextBundleIO.availableLanguages(locDir)); + if (selected != null && langCombo.getItems().contains(selected)) + langCombo.setValue(selected); + else if (!langCombo.getItems().isEmpty()) + langCombo.setValue(langCombo.getItems().get(0)); + else + tableData.clear(); + } + + private void loadLanguage(String lang) { + if (lang == null) return; + try { + currentBundle = TextBundleIO.load(locDir.resolve(lang + ".json")); + } catch (IOException e) { + currentBundle = new TextBundle(lang); + } + tableData.clear(); + currentBundle.getEntries().forEach((k, v) -> tableData.add(new String[]{k, v})); + TextRegistry.clear(); + TextRegistry.registerAll(currentBundle.getEntries()); + } + + private void addLanguage() { + TextInputDialog dlg = new TextInputDialog(); + dlg.setTitle("Sprache hinzufügen"); + dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):"); + dlg.showAndWait().ifPresent(lang -> { + lang = lang.trim().toLowerCase(); + if (lang.isBlank()) return; + TextBundle bundle = new TextBundle(lang); + try { + TextBundleIO.save(bundle, locDir); + refreshLangList(); + langCombo.setValue(lang); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + }); + } + + private void deleteLanguage() { + String lang = langCombo.getValue(); + if (lang == null) return; + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION, + "Sprache '" + lang + "' wirklich löschen?", ButtonType.YES, ButtonType.NO); + confirm.showAndWait().ifPresent(bt -> { + if (bt == ButtonType.YES) { + try { + TextBundleIO.delete(lang, locDir); + currentBundle = null; + tableData.clear(); + refreshLangList(); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } + }); + } + + // ── Eintrags-Verwaltung ─────────────────────────────────────────────────── + + private void addKey() { + tableData.add(new String[]{"neuer.schluessel", ""}); + table.scrollTo(tableData.size() - 1); + table.getSelectionModel().select(tableData.size() - 1); + } + + private void deleteKey() { + String[] sel = table.getSelectionModel().getSelectedItem(); + if (sel != null) tableData.remove(sel); + } + + private void saveCurrent() { + if (currentBundle == null) { + new Alert(Alert.AlertType.WARNING, "Keine Sprache ausgewählt.", ButtonType.OK).showAndWait(); + return; + } + Map entries = new LinkedHashMap<>(); + for (String[] row : tableData) { + if (!row[0].isBlank()) entries.put(row[0].trim(), row[1]); + } + currentBundle.setEntries(entries); + try { + TextBundleIO.save(currentBundle, locDir); + TextRegistry.clear(); + TextRegistry.registerAll(entries); + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java new file mode 100644 index 0000000..6866991 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java @@ -0,0 +1,251 @@ +package de.blight.editor.ui; + +import de.blight.common.LocationIO; +import de.blight.common.model.Location; +import de.blight.common.model.TextReference; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.io.IOException; +import java.util.List; + +/** + * Locations-Verwaltung: zwei identische LocationPanel-Instanzen nebeneinander. + * Alle Locations werden gemeinsam in einer Datei gespeichert (LocationIO). + */ +public class LocationEditorView extends BorderPane { + + private final ObservableList sharedLocations = FXCollections.observableArrayList(); + + public LocationEditorView() { + setStyle("-fx-background-color: #1e1e2e;"); + reload(); + + LocationPanel left = new LocationPanel("Liste 1", sharedLocations, this::saveAll); + LocationPanel right = new LocationPanel("Liste 2", sharedLocations, this::saveAll); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + private void reload() { + try { sharedLocations.setAll(LocationIO.load()); } + catch (IOException e) { sharedLocations.clear(); } + } + + private void saveAll() { + try { LocationIO.save(sharedLocations); } + catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + } + reload(); + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class LocationPanel extends VBox { + + private final ObservableList locations; + private final Runnable onSaved; + + private final ListView listView; + private Location current = null; + + // Form fields + private TextField nameIdField; + private TextField centerXField; + private TextField centerZField; + private TextField radiusField; + private TriggerListEditor triggerEditor; + + private VBox formContainer; + private Button deleteBtn; + + LocationPanel(String title, ObservableList locations, Runnable onSaved) { + this.locations = locations; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;"); + + listView = new ListView<>(locations); + listView.setPrefHeight(180); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Location loc, boolean empty) { + super.updateItem(loc, empty); + if (empty || loc == null) { setText(null); setStyle(""); return; } + setText(loc.getId().isBlank() ? "—" : loc.getId()); + setStyle("-fx-text-fill: #dddddd;" + + " -fx-border-color: transparent transparent transparent #66aacc;" + + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onSelected(old, nw)); + + Button newBtn = new Button("Neue Location"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createLocation()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + + VBox listSection = new VBox(listView, listButtons); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + nameIdField = field("z. B. location.village"); + centerXField = field("X-Koordinate"); + centerZField = field("Z-Koordinate"); + radiusField = field("Radius in Meter"); + + // TriggerEditor — placeholder; rebuilt when item selected + triggerEditor = new TriggerListEditor(List.of(), () -> {}); + + Button saveBtn = new Button("Location speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrent()); + + form.getChildren().addAll( + sectionTitle("Kennung & Position"), + new Separator(), + row("Name-ID:", nameIdField), + row("Mitte X:", centerXField), + row("Mitte Z:", centerZField), + row("Radius:", radiusField), + sectionTitle("Trigger"), + new Separator(), + triggerEditor, + new Separator(), + saveBtn + ); + return form; + } + + private void onSelected(Location old, Location nw) { + if (old != null) saveFormToLocation(old); + current = nw; + deleteBtn.setDisable(nw == null); + if (nw != null) { formContainer.setDisable(false); loadForm(nw); } + else { formContainer.setDisable(true); clearForm(); } + } + + private void loadForm(Location loc) { + nameIdField.setText(loc.getId()); + centerXField.setText(String.valueOf(loc.getCenterX())); + centerZField.setText(String.valueOf(loc.getCenterZ())); + radiusField.setText(String.valueOf(loc.getRadius())); + + // Rebuild trigger editor + int idx = formContainer.getChildren().indexOf(triggerEditor); + triggerEditor = new TriggerListEditor( + loc.getTriggers() != null ? loc.getTriggers() : List.of(), () -> {}); + if (idx >= 0) formContainer.getChildren().set(idx, triggerEditor); + } + + private void saveFormToLocation(Location loc) { + String nameId = nameIdField.getText().trim(); + loc.setName(nameId.isBlank() ? null : new TextReference(nameId)); + loc.setCenterX(parseFloat(centerXField.getText())); + loc.setCenterZ(parseFloat(centerZField.getText())); + loc.setRadius(parseFloat(radiusField.getText())); + loc.setTriggers(triggerEditor.getTriggers()); + } + + private void clearForm() { + nameIdField.clear(); centerXField.clear(); centerZField.clear(); radiusField.clear(); + } + + private void createLocation() { + Location loc = new Location(); + loc.setName(new TextReference("location.neu_" + System.currentTimeMillis())); + locations.add(loc); + listView.getSelectionModel().select(loc); + } + + private void deleteSelected() { + if (current == null) return; + locations.remove(current); + current = null; clearForm(); formContainer.setDisable(true); deleteBtn.setDisable(true); + onSaved.run(); + } + + private void saveCurrent() { + if (current == null) return; + saveFormToLocation(current); + if (current.getId().isBlank()) { + new Alert(Alert.AlertType.ERROR, "Name-ID darf nicht leer sein.", ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + listView.refresh(); + } + + private static float parseFloat(String s) { + try { return Float.parseFloat(s.trim().replace(',', '.')); } + catch (NumberFormatException ignored) { return 0f; } + } + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(80); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static TextField field(String prompt) { + TextField tf = new TextField(); tf.setPromptText(prompt); return tf; + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java new file mode 100644 index 0000000..8ebbb7a --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/QuestEditorView.java @@ -0,0 +1,487 @@ +package de.blight.editor.ui; + +import de.blight.common.model.Interactable; +import de.blight.common.model.InteractableRef; +import de.blight.common.model.Item; +import de.blight.common.model.NPC; +import de.blight.common.model.Location; +import de.blight.common.model.TextReference; +import de.blight.common.model.quests.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * Quest-Verwaltung: zwei identische QuestPanel-Instanzen nebeneinander. + * Beide Panels teilen die gleiche ObservableList und speichern in dasselbe Verzeichnis. + */ +public class QuestEditorView extends BorderPane { + + private final ObservableList sharedQuests = FXCollections.observableArrayList(); + private final Path questDir; + + public QuestEditorView(Path questDir) { + this.questDir = questDir; + setStyle("-fx-background-color: #1e1e2e;"); + + reload(); + + QuestPanel left = new QuestPanel("Liste 1", sharedQuests, questDir, this::reload); + QuestPanel right = new QuestPanel("Liste 2", sharedQuests, questDir, this::reload); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + public void reload() { + List loaded = QuestIO.loadAll(questDir); + sharedQuests.setAll(loaded); + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class QuestPanel extends VBox { + + private final ObservableList quests; + private final Path questDir; + private final Runnable onSaved; + + private final ListView listView; + private Quest current = null; + + // Common fields + private TextField idField; + private Spinner xpSpinner; + private TextField textField; + private TextField descField; + private TextField successField; + + // Type selection + private ComboBox typeCombo; + + // Dynamic area + private VBox dynamicArea; + + // Type-specific fields (lazily filled) + private TextField f1, f2, f3; + private Spinner countSpinner; + + // Form container (disabled when nothing loaded) + private VBox formContainer; + private Button saveBtn; + private Button deleteBtn; + + QuestPanel(String title, ObservableList quests, Path questDir, Runnable onSaved) { + this.quests = quests; + this.questDir = questDir; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + setSpacing(0); + + // ── Header ──────────────────────────────────────────────────────── + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;" + + " -fx-border-width: 0 0 1 0;"); + + // ── Quest list ──────────────────────────────────────────────────── + listView = new ListView<>(quests); + listView.setPrefHeight(160); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Quest q, boolean empty) { + super.updateItem(q, empty); + if (empty || q == null) { setText(null); setStyle(""); return; } + String id = q.getQuestId() != null ? q.getQuestId() : "—"; + String type = QuestIO.typeOf(q); + setText("[" + type + "] " + id); + setStyle("-fx-text-fill: #cccccc;"); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onQuestSelected(old, nw)); + + Button newBtn = new Button("Neue Quest"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createQuest()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + + VBox listSection = new VBox(listView, listButtons); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + + // ── Form ────────────────────────────────────────────────────────── + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + // ── Form construction ───────────────────────────────────────────────── + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + // Common fields + idField = new TextField(); + idField.setPromptText("eindeutige ID"); + xpSpinner = new Spinner<>(0, 99999, 0); + xpSpinner.setEditable(true); + xpSpinner.setMaxWidth(Double.MAX_VALUE); + textField = new TextField(); + textField.setPromptText("TextReference-Schlüssel"); + descField = new TextField(); + descField.setPromptText("TextReference-Schlüssel"); + successField = new TextField(); + successField.setPromptText("TextReference-Schlüssel"); + + form.getChildren().addAll( + sectionTitle("Quest"), + new Separator(), + row("Quest-ID:", idField), + row("XP:", xpSpinner), + new Separator(), + sectionTitle("Texte"), + row("Text:", textField), + row("Beschreibung:", descField), + row("Erfolgstext:", successField), + new Separator() + ); + + // Type selection + typeCombo = new ComboBox<>(); + typeCombo.getItems().addAll("BringQuest", "FollowQuest", "InteractQuest", "ItemQuest", "TalkQuest"); + typeCombo.setPromptText("Typ auswählen…"); + typeCombo.setMaxWidth(Double.MAX_VALUE); + typeCombo.setOnAction(e -> rebuildDynamicArea(typeCombo.getValue())); + + dynamicArea = new VBox(6); + + form.getChildren().addAll( + sectionTitle("Typ"), + typeCombo, + dynamicArea, + new Separator() + ); + + // Save button + saveBtn = new Button("Quest speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrentQuest()); + form.getChildren().add(saveBtn); + + return form; + } + + private void rebuildDynamicArea(String type) { + dynamicArea.getChildren().clear(); + f1 = null; f2 = null; f3 = null; countSpinner = null; + if (type == null) return; + + switch (type) { + case "BringQuest" -> { + f1 = tf("NPC-ID (bringen)"); + f2 = tf("Location-ID (Ziel)"); + dynamicArea.getChildren().addAll( + sectionTitle("BringQuest"), + row("NPC:", f1), + row("Ziel-Location:", f2) + ); + } + case "FollowQuest" -> { + f1 = tf("NPC-ID (folgen)"); + f2 = tf("Location-ID (Ziel)"); + dynamicArea.getChildren().addAll( + sectionTitle("FollowQuest"), + row("NPC:", f1), + row("Ziel-Location:", f2) + ); + } + case "InteractQuest" -> { + f1 = tf("Interactable-ID"); + dynamicArea.getChildren().addAll( + sectionTitle("InteractQuest"), + row("Interactable:", f1) + ); + } + case "ItemQuest" -> { + f1 = tf("Item-ID"); + countSpinner = new Spinner<>(1, 9999, 1); + countSpinner.setEditable(true); + countSpinner.setMaxWidth(Double.MAX_VALUE); + dynamicArea.getChildren().addAll( + sectionTitle("ItemQuest"), + row("Item:", f1), + row("Anzahl:", countSpinner) + ); + } + case "TalkQuest" -> { + f1 = tf("NPC-ID"); + dynamicArea.getChildren().addAll( + sectionTitle("TalkQuest"), + row("NPC:", f1) + ); + } + } + } + + // ── Form load / save ────────────────────────────────────────────────── + + private void onQuestSelected(Quest old, Quest nw) { + if (old != null) saveFormToQuest(old); + current = nw; + deleteBtn.setDisable(nw == null); + if (nw != null) { + formContainer.setDisable(false); + loadFormFromQuest(nw); + } else { + formContainer.setDisable(true); + clearForm(); + } + } + + private void loadFormFromQuest(Quest q) { + idField.setText(safe(q.getQuestId())); + xpSpinner.getValueFactory().setValue(q.getXp()); + textField.setText(q.getText() != null ? q.getText().id() : ""); + descField.setText(q.getDescription() != null ? q.getDescription().id() : ""); + successField.setText(q.getSuccessText() != null ? q.getSuccessText().id() : ""); + + String type = QuestIO.typeOf(q); + typeCombo.setValue(switch (type) { + case "BRING" -> "BringQuest"; + case "FOLLOW" -> "FollowQuest"; + case "INTERACT" -> "InteractQuest"; + case "ITEM" -> "ItemQuest"; + case "TALK" -> "TalkQuest"; + default -> null; + }); + rebuildDynamicArea(typeCombo.getValue()); + + // Fill type-specific fields + switch (q) { + case BringQuest bq -> { + if (f1 != null) f1.setText(bq.getBring() != null ? safe(bq.getBring().getCharacterId()) : ""); + if (f2 != null) f2.setText(bq.getBringTo() != null ? safe(bq.getBringTo().getId()) : ""); + } + case FollowQuest fq -> { + if (f1 != null) f1.setText(fq.getFollow() != null ? safe(fq.getFollow().getCharacterId()) : ""); + if (f2 != null) f2.setText(fq.getFollowTo() != null ? safe(fq.getFollowTo().getId()) : ""); + } + case InteractQuest iq -> { + if (f1 != null && iq.getInteractWith() instanceof InteractableRef ir) + f1.setText(safe(ir.getId())); + } + case ItemQuest iq -> { + if (f1 != null) f1.setText(iq.getItem() != null ? safe(iq.getItem().getItemId()) : ""); + if (countSpinner != null) countSpinner.getValueFactory().setValue(iq.getCount()); + } + case TalkQuest tq -> { + if (f1 != null) f1.setText(tq.getTalkTo() != null ? safe(tq.getTalkTo().getCharacterId()) : ""); + } + default -> {} + } + } + + private void saveFormToQuest(Quest q) { + q.setQuestId(idField.getText().trim()); + q.setXp(xpSpinner.getValue()); + q.setText(ref(textField)); + q.setDescription(ref(descField)); + q.setSuccessText(ref(successField)); + + // Type-specific fields written when actually saving (buildQuestFromForm) + } + + private Quest buildQuestFromForm() { + String type = typeCombo.getValue(); + if (type == null) return null; + + Quest q = switch (type) { + case "BringQuest" -> { + BringQuest bq = new BringQuest(); + if (f1 != null && !f1.getText().isBlank()) { + NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); bq.setBring(n); + } + if (f2 != null && !f2.getText().isBlank()) { + Location l = new Location(); l.setName(new de.blight.common.model.TextReference(f2.getText().trim())); bq.setBringTo(l); + } + yield bq; + } + case "FollowQuest" -> { + FollowQuest fq = new FollowQuest(); + if (f1 != null && !f1.getText().isBlank()) { + NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); fq.setFollow(n); + } + if (f2 != null && !f2.getText().isBlank()) { + Location l = new Location(); l.setName(new de.blight.common.model.TextReference(f2.getText().trim())); fq.setFollowTo(l); + } + yield fq; + } + case "InteractQuest" -> { + InteractQuest iq = new InteractQuest(); + if (f1 != null && !f1.getText().isBlank()) { + InteractableRef ir = new InteractableRef(); ir.setId(f1.getText().trim()); + iq.setInteractWith(ir); + } + yield iq; + } + case "ItemQuest" -> { + ItemQuest iq = new ItemQuest(); + if (f1 != null && !f1.getText().isBlank()) { + Item item = new Item(); item.setItemId(f1.getText().trim()); iq.setItem(item); + } + iq.setCount(countSpinner != null ? countSpinner.getValue() : 1); + yield iq; + } + case "TalkQuest" -> { + TalkQuest tq = new TalkQuest(); + if (f1 != null && !f1.getText().isBlank()) { + NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); tq.setTalkTo(n); + } + yield tq; + } + default -> new TalkQuest(); + }; + + q.setQuestId(idField.getText().trim()); + q.setXp(xpSpinner.getValue()); + q.setText(ref(textField)); + q.setDescription(ref(descField)); + q.setSuccessText(ref(successField)); + return q; + } + + private void clearForm() { + idField.clear(); + xpSpinner.getValueFactory().setValue(0); + textField.clear(); + descField.clear(); + successField.clear(); + typeCombo.setValue(null); + dynamicArea.getChildren().clear(); + } + + // ── List operations ─────────────────────────────────────────────────── + + private void createQuest() { + TalkQuest q = new TalkQuest(); + q.setQuestId("neue_quest_" + System.currentTimeMillis()); + quests.add(q); + listView.getSelectionModel().select(q); + } + + private void deleteSelected() { + Quest sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null) return; + String qId = sel.getQuestId(); + quests.remove(sel); + if (qId != null && !qId.isBlank()) { + try { QuestIO.delete(qId, questDir); } + catch (IOException e) { /* ignore */ } + } + current = null; + clearForm(); + formContainer.setDisable(true); + deleteBtn.setDisable(true); + onSaved.run(); + } + + private void saveCurrentQuest() { + Quest built = buildQuestFromForm(); + if (built == null) { + showError("Bitte einen Typ wählen."); + return; + } + if (built.getQuestId() == null || built.getQuestId().isBlank()) { + showError("Quest-ID darf nicht leer sein."); + return; + } + // Replace or add in shared list + int idx = quests.indexOf(current); + if (idx >= 0) quests.set(idx, built); + else quests.add(built); + current = built; + listView.getSelectionModel().select(built); + try { + QuestIO.save(built, questDir); + } catch (IOException e) { + showError("Fehler beim Speichern: " + e.getMessage()); + return; + } + onSaved.run(); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(130); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static TextField tf(String prompt) { + TextField f = new TextField(); + f.setPromptText(prompt); + return f; + } + + private static String safe(String s) { return s != null ? s : ""; } + + private static TextReference ref(TextField f) { + String s = f.getText().trim(); + return s.isBlank() ? null : new TextReference(s); + } + + private void showError(String msg) { + Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK); + a.showAndWait(); + } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java new file mode 100644 index 0000000..0dbe5f8 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/RecipeEditorView.java @@ -0,0 +1,506 @@ +package de.blight.editor.ui; + +import de.blight.common.model.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.SortedList; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.stage.Modality; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; + +/** + * Rezept-Verwaltung: zwei identische RecipePanel-Instanzen nebeneinander. + * Sortiert nach CraftingTableType, dann nach erstelltem Item-ID. + */ +public class RecipeEditorView extends BorderPane { + + private final ObservableList sharedRecipes = FXCollections.observableArrayList(); + private final Path recipeDir; + + public RecipeEditorView(Path recipeDir) { + this.recipeDir = recipeDir; + setStyle("-fx-background-color: #1e1e2e;"); + reload(); + + RecipePanel left = new RecipePanel("Liste 1", sharedRecipes, recipeDir, this::reload); + RecipePanel right = new RecipePanel("Liste 2", sharedRecipes, recipeDir, this::reload); + + HBox panels = new HBox(1, left, right); + HBox.setHgrow(left, Priority.ALWAYS); + HBox.setHgrow(right, Priority.ALWAYS); + setCenter(panels); + } + + public void reload() { + sharedRecipes.setAll(RecipeIO.loadAll(recipeDir)); + } + + // ── Single panel ────────────────────────────────────────────────────────── + + static class RecipePanel extends VBox { + + private static final String NO_TABLE = "— kein Tisch —"; + + private final ObservableList recipes; + private final Path recipeDir; + private final Runnable onSaved; + + private final SortedList sortedRecipes; + private final ListView listView; + + private Recipe current = null; + private String oldFileId = null; // for rename-on-save detection + + // Form fields + private TextField createsField; + private ListView componentsList; // "itemId × count" + private ComboBox tableCombo; + + // Level-Anforderungs-Zeilen (jeweils Label + Spinner) + private HBox alchemyRow; + private HBox enchantingRow; + private HBox smitheryRow; + private HBox engineeringRow; + private Spinner alchemySpinner; + private Spinner enchantingSpinner; + private Spinner smitherySpinner; + private Spinner engineeringSpinner; + + private VBox formContainer; + private Button deleteBtn; + + RecipePanel(String title, ObservableList recipes, Path recipeDir, Runnable onSaved) { + this.recipes = recipes; + this.recipeDir = recipeDir; + this.onSaved = onSaved; + + setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;"); + + // ── Header ──────────────────────────────────────────────────────── + Label titleLbl = new Label(title); + titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;"); + Button refreshBtn = new Button("↺"); + refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;"); + refreshBtn.setOnAction(e -> onSaved.run()); + HBox header = new HBox(8, titleLbl, refreshBtn); + header.setPadding(new Insets(8, 10, 8, 10)); + header.setAlignment(Pos.CENTER_LEFT); + header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;" + + " -fx-border-width: 0 0 1 0;"); + + // ── Recipe list ─────────────────────────────────────────────────── + sortedRecipes = new SortedList<>(recipes, RecipeIO.SORT_ORDER); + listView = new ListView<>(sortedRecipes); + listView.setPrefHeight(180); + listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Recipe r, boolean empty) { + super.updateItem(r, empty); + if (empty || r == null) { setText(null); setStyle(""); return; } + String creates = r.getCreates() != null ? safe(r.getCreates().getItemId()) : "—"; + String table = r.getTable() != null && r.getTable().getType() != null + ? r.getTable().getType().name() : "Handwerk"; + setText(creates); + setTooltip(new Tooltip("[" + table + "] erstellt: " + creates)); + String color = tableColor(r.getTable()); + setStyle("-fx-text-fill: #dddddd;" + + " -fx-border-color: transparent transparent transparent " + color + ";" + + " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;"); + } + }); + listView.getSelectionModel().selectedItemProperty() + .addListener((obs, old, nw) -> onRecipeSelected(old, nw)); + + Button newBtn = new Button("Neues Rezept"); + newBtn.setMaxWidth(Double.MAX_VALUE); + newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;"); + newBtn.setOnAction(e -> createRecipe()); + + deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + deleteBtn.setOnAction(e -> deleteSelected()); + + HBox listButtons = new HBox(6, newBtn, deleteBtn); + listButtons.setPadding(new Insets(6, 8, 6, 8)); + HBox.setHgrow(newBtn, Priority.ALWAYS); + HBox.setHgrow(deleteBtn, Priority.ALWAYS); + + VBox listSection = new VBox(listView, listButtons); + listSection.setStyle("-fx-background-color: #1a1a2a;"); + + // ── Form ────────────────────────────────────────────────────────── + formContainer = buildForm(); + formContainer.setDisable(true); + + ScrollPane formScroll = new ScrollPane(formContainer); + formScroll.setFitToWidth(true); + formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;"); + VBox.setVgrow(formScroll, Priority.ALWAYS); + + getChildren().addAll(header, listSection, new Separator(), formScroll); + } + + // ── Form construction ───────────────────────────────────────────────── + + private VBox buildForm() { + VBox form = new VBox(6); + form.setPadding(new Insets(10)); + form.setStyle("-fx-background-color: #252535;"); + + // Erstellt + createsField = new TextField(); + createsField.setPromptText("Item-ID des erstellten Items"); + + // Zutaten + componentsList = new ListView<>(); + componentsList.setPrefHeight(110); + componentsList.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;"); + componentsList.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(String s, boolean empty) { + super.updateItem(s, empty); + setText(empty || s == null ? null : s); + setStyle(empty ? "" : "-fx-text-fill: #cccccc;"); + } + }); + Button addCompBtn = smallBtn("+"); + Button delCompBtn = smallBtn("−"); + addCompBtn.setOnAction(e -> addComponent()); + delCompBtn.setOnAction(e -> { + String sel = componentsList.getSelectionModel().getSelectedItem(); + if (sel != null) componentsList.getItems().remove(sel); + }); + HBox compButtons = new HBox(4, addCompBtn, delCompBtn); + + form.getChildren().addAll( + sectionTitle("Ergebnis"), + new Separator(), + row("Erstellt:", createsField), + sectionTitle("Zutaten"), + componentsList, + compButtons, + new Separator() + ); + + // Crafting Table + tableCombo = new ComboBox<>(); + tableCombo.getItems().add(NO_TABLE); + for (CraftingTable.CraftingTableType t : CraftingTable.CraftingTableType.values()) + tableCombo.getItems().add(t.name()); + tableCombo.setValue(NO_TABLE); + tableCombo.setMaxWidth(Double.MAX_VALUE); + tableCombo.setOnAction(e -> updateRequirementRows(tableCombo.getValue())); + + // Level-Anforderungen (werden je nach Tischtyp aktiviert) + alchemySpinner = lvlSpinner(); + enchantingSpinner = lvlSpinner(); + smitherySpinner = lvlSpinner(); + engineeringSpinner = lvlSpinner(); + + alchemyRow = requirementRow("Lvl Alchemie:", alchemySpinner); + enchantingRow = requirementRow("Lvl Verzauberung:", enchantingSpinner); + smitheryRow = requirementRow("Lvl Schmieden:", smitherySpinner); + engineeringRow = requirementRow("Lvl Engineering:", engineeringSpinner); + + form.getChildren().addAll( + sectionTitle("Crafting Table"), + tableCombo, + alchemyRow, + enchantingRow, + smitheryRow, + engineeringRow + ); + + // Anfangs alle Anforderungen deaktiviert + updateRequirementRows(NO_TABLE); + + form.getChildren().add(new Separator()); + + Button saveBtn = new Button("Rezept speichern"); + saveBtn.setMaxWidth(Double.MAX_VALUE); + saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); + saveBtn.setOnAction(e -> saveCurrentRecipe()); + form.getChildren().add(saveBtn); + + return form; + } + + private void updateRequirementRows(String tableValue) { + boolean noTable = tableValue == null || tableValue.equals(NO_TABLE); + + // Alle ausblenden wenn kein Tisch + setRowVisible(alchemyRow, false); + setRowVisible(enchantingRow, false); + setRowVisible(smitheryRow, false); + setRowVisible(engineeringRow, false); + + if (noTable) return; + + switch (tableValue) { + case "AlchemyTable" -> setRowVisible(alchemyRow, true); + case "EnchantmentTable" -> setRowVisible(enchantingRow, true); + case "Smithy", + "Goldsmiths" -> setRowVisible(smitheryRow, true); + case "Workshop" -> setRowVisible(engineeringRow, true); + } + } + + private static void setRowVisible(HBox row, boolean visible) { + row.setVisible(visible); + row.setManaged(visible); + } + + // ── Form load / save ────────────────────────────────────────────────── + + private void onRecipeSelected(Recipe old, Recipe nw) { + if (old != null) saveFormToRecipe(old); + current = nw; + oldFileId = nw != null ? RecipeIO.fileId(nw) : null; + deleteBtn.setDisable(nw == null); + if (nw != null) { + formContainer.setDisable(false); + loadFormFromRecipe(nw); + } else { + formContainer.setDisable(true); + clearForm(); + } + } + + private void loadFormFromRecipe(Recipe r) { + createsField.setText(r.getCreates() != null ? safe(r.getCreates().getItemId()) : ""); + + componentsList.getItems().clear(); + if (r.getComponents() != null) { + r.getComponents().forEach((item, count) -> + componentsList.getItems().add(item.getItemId() + " × " + count)); + } + + String tableVal = NO_TABLE; + if (r.getTable() != null && r.getTable().getType() != null) + tableVal = r.getTable().getType().name(); + tableCombo.setValue(tableVal); + updateRequirementRows(tableVal); + + alchemySpinner.getValueFactory().setValue( + r.getRequiresLvlAlchemy() != null ? r.getRequiresLvlAlchemy() : 1); + enchantingSpinner.getValueFactory().setValue( + r.getRequiresLvlEnchanting() != null ? r.getRequiresLvlEnchanting() : 1); + smitherySpinner.getValueFactory().setValue( + r.getRequiresLvlSmithery() != null ? r.getRequiresLvlSmithery() : 1); + engineeringSpinner.getValueFactory().setValue( + r.getRequiresLvlEngineering() != null ? r.getRequiresLvlEngineering() : 1); + } + + private void saveFormToRecipe(Recipe r) { + // creates + String cId = createsField.getText().trim(); + if (!cId.isBlank()) { + Item creates = r.getCreates() != null ? r.getCreates() : new Item(); + creates.setItemId(cId); + r.setCreates(creates); + } else { + r.setCreates(null); + } + + // components + Map comps = new LinkedHashMap<>(); + for (String entry : componentsList.getItems()) { + int sep = entry.lastIndexOf(" × "); + if (sep < 0) continue; + String itemId = entry.substring(0, sep).trim(); + int count = 1; + try { count = Integer.parseInt(entry.substring(sep + 3).trim()); } + catch (NumberFormatException ignored) {} + Item item = new Item(); item.setItemId(itemId); + comps.put(item, count); + } + r.setComponents(comps.isEmpty() ? null : comps); + + // table + String tv = tableCombo.getValue(); + if (tv == null || tv.equals(NO_TABLE)) { + r.setTable(null); + r.setRequiresLvlAlchemy(null); + r.setRequiresLvlEngineering(null); + r.setRequiresLvlSmithery(null); + r.setRequiresLvlEnchanting(null); + } else { + CraftingTable table = r.getTable() != null ? r.getTable() : new CraftingTable(); + table.setType(CraftingTable.CraftingTableType.valueOf(tv)); + r.setTable(table); + // Nur das relevante Level-Feld setzen, alle anderen null + r.setRequiresLvlAlchemy(null); + r.setRequiresLvlEngineering(null); + r.setRequiresLvlSmithery(null); + r.setRequiresLvlEnchanting(null); + switch (tv) { + case "AlchemyTable" -> r.setRequiresLvlAlchemy(alchemySpinner.getValue()); + case "EnchantmentTable" -> r.setRequiresLvlEnchanting(enchantingSpinner.getValue()); + case "Smithy", + "Goldsmiths" -> r.setRequiresLvlSmithery(smitherySpinner.getValue()); + case "Workshop" -> r.setRequiresLvlEngineering(engineeringSpinner.getValue()); + } + } + } + + private void clearForm() { + createsField.clear(); + componentsList.getItems().clear(); + tableCombo.setValue(NO_TABLE); + updateRequirementRows(NO_TABLE); + alchemySpinner.getValueFactory().setValue(1); + enchantingSpinner.getValueFactory().setValue(1); + smitherySpinner.getValueFactory().setValue(1); + engineeringSpinner.getValueFactory().setValue(1); + } + + // ── List operations ─────────────────────────────────────────────────── + + private void createRecipe() { + Recipe r = new Recipe(); + Item creates = new Item(); + creates.setItemId("neues_rezept_" + System.currentTimeMillis()); + r.setCreates(creates); + recipes.add(r); + listView.getSelectionModel().select(r); + } + + private void deleteSelected() { + Recipe sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null) return; + String fid = RecipeIO.fileId(sel); + recipes.remove(sel); + try { RecipeIO.delete(fid, recipeDir); } catch (IOException ignored) {} + current = null; + oldFileId = null; + clearForm(); + formContainer.setDisable(true); + deleteBtn.setDisable(true); + onSaved.run(); + } + + private void saveCurrentRecipe() { + if (current == null) return; + saveFormToRecipe(current); + + String newFileId = RecipeIO.fileId(current); + if (newFileId.startsWith("unbenanntes")) { + new Alert(Alert.AlertType.ERROR, + "Item-ID des erstellten Items darf nicht leer sein.", ButtonType.OK).showAndWait(); + return; + } + try { + // Datei umbenennen: alten Eintrag löschen wenn ID sich geändert hat + if (oldFileId != null && !oldFileId.equals(newFileId)) + RecipeIO.delete(oldFileId, recipeDir); + RecipeIO.save(current, recipeDir); + oldFileId = newFileId; + } catch (IOException e) { + new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); + return; + } + onSaved.run(); + // Re-select nach Reload + final String fid = newFileId; + recipes.stream() + .filter(r -> fid.equals(RecipeIO.fileId(r))) + .findFirst() + .ifPresent(listView.getSelectionModel()::select); + } + + private void addComponent() { + Dialog dlg = new Dialog<>(); + dlg.setTitle("Zutat hinzufügen"); + dlg.initModality(Modality.APPLICATION_MODAL); + + TextField itemIdField = new TextField(); + itemIdField.setPromptText("Item-ID"); + Spinner countSpin = new Spinner<>(1, 9999, 1); + countSpin.setEditable(true); + + GridPane grid = new GridPane(); + grid.setHgap(10); grid.setVgap(8); + grid.setPadding(new Insets(12)); + grid.add(new Label("Item-ID:"), 0, 0); grid.add(itemIdField, 1, 0); + grid.add(new Label("Anzahl:"), 0, 1); grid.add(countSpin, 1, 1); + GridPane.setHgrow(itemIdField, Priority.ALWAYS); + + dlg.getDialogPane().setContent(grid); + dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + Button okBtn = (Button) dlg.getDialogPane().lookupButton(ButtonType.OK); + okBtn.setDisable(true); + itemIdField.textProperty().addListener((obs, o, n) -> okBtn.setDisable(n.isBlank())); + + dlg.setResultConverter(bt -> bt == ButtonType.OK + ? itemIdField.getText().trim() + " × " + countSpin.getValue() : null); + dlg.showAndWait().ifPresent(entry -> { + if (!componentsList.getItems().contains(entry)) + componentsList.getItems().add(entry); + }); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static String tableColor(CraftingTable t) { + if (t == null || t.getType() == null) return "#666666"; + return switch (t.getType()) { + case AlchemyTable -> "#44bb88"; + case EnchantmentTable -> "#aa55ee"; + case Smithy -> "#cc8833"; + case Goldsmiths -> "#ddbb22"; + case Workshop -> "#4488cc"; + }; + } + + private static HBox requirementRow(String labelText, Spinner spinner) { + Label lbl = new Label(labelText); + lbl.setMinWidth(140); + lbl.setStyle("-fx-text-fill: #aaa;"); + spinner.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(spinner, Priority.ALWAYS); + HBox row = new HBox(8, lbl, spinner); + row.setAlignment(Pos.CENTER_LEFT); + row.setPadding(new Insets(2, 0, 2, 0)); + return row; + } + + private static Spinner lvlSpinner() { + Spinner s = new Spinner<>(1, 100, 1); + s.setEditable(true); + return s; + } + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(80); + lbl.setStyle("-fx-text-fill: #aaa;"); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static Button smallBtn(String text) { + Button b = new Button(text); + b.setPrefWidth(28); + return b; + } + + private static String safe(String s) { return s != null ? s : ""; } + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java new file mode 100644 index 0000000..b38c4db --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java @@ -0,0 +1,224 @@ +package de.blight.editor.ui; + +import de.blight.common.model.QuestRef; +import de.blight.common.model.Status; +import de.blight.common.model.trigger.*; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.stage.Modality; + +import java.util.UUID; + +/** + * Modal-Dialog zum Anlegen oder Bearbeiten eines {@link Trigger}. + * + *

Ablauf: + *

    + *
  1. Typ-ComboBox wählen
  2. + *
  3. Typ-spezifische Felder füllen
  4. + *
  5. Kapitel-Anforderung (optional)
  6. + *
+ * Rückgabewert: der fertig gebaute {@link Trigger} oder {@code null} bei Abbruch. + */ +public class TriggerDialog extends Dialog { + + private static final String TYPE_QUEST = "Quest starten"; + private static final String TYPE_NPC = "NPC-Status ändern"; + private static final String TYPE_FRACTION = "Fraktions-Status ändern"; + + // Gemeinsam + private final ComboBox typeCombo = new ComboBox<>(); + private final Spinner chapterSpinner = new Spinner<>(0, 99, 0); + private final VBox dynamicArea = new VBox(6); + + // Quest + private TextField questIdField; + + // NPC-Status + private TextField npcIdField; + private ComboBox npcStatusCombo; + + // Fraktions-Status + private TextField fractionIdField; + private ComboBox fractionStatusCombo; + + /** Öffnet den Dialog für einen neuen Trigger. */ + public TriggerDialog() { + this(null); + } + + /** Öffnet den Dialog im Bearbeitungsmodus mit einem vorhandenen Trigger. */ + public TriggerDialog(Trigger existing) { + setTitle(existing == null ? "Trigger hinzufügen" : "Trigger bearbeiten"); + initModality(Modality.APPLICATION_MODAL); + setResizable(true); + + typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION); + typeCombo.setMaxWidth(Double.MAX_VALUE); + typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); + + chapterSpinner.setEditable(true); + chapterSpinner.setPrefWidth(80); + + VBox content = new VBox(10); + content.setPadding(new Insets(16)); + content.setPrefWidth(400); + + content.getChildren().addAll( + row("Trigger-Typ:", typeCombo), + row("Kapitel (mind.):", chapterSpinner), + new Separator(), + dynamicArea + ); + + getDialogPane().setContent(content); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK); + okBtn.setDisable(true); + + // Validierung: OK nur wenn Typ gewählt und Pflichtfelder gefüllt + typeCombo.valueProperty().addListener((obs, o, n) -> okBtn.setDisable(n == null)); + + // Ergebnis-Konverter + setResultConverter(bt -> bt == ButtonType.OK ? buildTrigger() : null); + + // Vorhandenen Trigger laden + if (existing != null) preload(existing); + else typeCombo.setValue(TYPE_QUEST); + } + + // ── Typ-spezifische Felder ──────────────────────────────────────────────── + + private void rebuildDynamic(String type) { + dynamicArea.getChildren().clear(); + if (type == null) return; + switch (type) { + case TYPE_QUEST -> buildQuestFields(); + case TYPE_NPC -> buildNpcFields(); + case TYPE_FRACTION -> buildFractionFields(); + } + } + + private void buildQuestFields() { + questIdField = field("Quest-ID (z. B. q_main_001)"); + dynamicArea.getChildren().addAll( + sectionTitle("Quest"), + row("Quest-ID:", questIdField) + ); + } + + private void buildNpcFields() { + npcIdField = field("Character-ID des NPCs"); + npcStatusCombo = statusCombo(); + dynamicArea.getChildren().addAll( + sectionTitle("NPC-Status"), + row("NPC-ID:", npcIdField), + row("Neuer Status:", npcStatusCombo) + ); + } + + private void buildFractionFields() { + fractionIdField = field("UUID der Fraktion"); + fractionStatusCombo = statusCombo(); + dynamicArea.getChildren().addAll( + sectionTitle("Fraktions-Status"), + row("Fraktions-UUID:", fractionIdField), + row("Neuer Status:", fractionStatusCombo) + ); + } + + // ── Trigger bauen ───────────────────────────────────────────────────────── + + private Trigger buildTrigger() { + String type = typeCombo.getValue(); + if (type == null) return null; + Trigger t = switch (type) { + case TYPE_QUEST -> { + QuestStartTrigger q = new QuestStartTrigger(); + if (questIdField != null && !questIdField.getText().isBlank()) { + QuestRef ref = new QuestRef(); + ref.setQuestId(questIdField.getText().trim()); + q.setQuest(ref); + } + yield q; + } + case TYPE_NPC -> { + NpcStatusTrigger n = new NpcStatusTrigger(); + if (npcIdField != null) n.setNpcId(npcIdField.getText().trim()); + if (npcStatusCombo != null) n.setTargetStatus(npcStatusCombo.getValue()); + yield n; + } + case TYPE_FRACTION -> { + FractionStatusTrigger f = new FractionStatusTrigger(); + if (fractionIdField != null && !fractionIdField.getText().isBlank()) { + try { f.setFractionId(UUID.fromString(fractionIdField.getText().trim())); } + catch (IllegalArgumentException ignored) {} + } + if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue()); + yield f; + } + default -> null; + }; + if (t != null) t.setRequiresChapter(chapterSpinner.getValue()); + return t; + } + + // ── Vorhandenen Trigger vorfüllen ───────────────────────────────────────── + + private void preload(Trigger t) { + chapterSpinner.getValueFactory().setValue(t.getRequiresChapter()); + if (t instanceof QuestStartTrigger q) { + typeCombo.setValue(TYPE_QUEST); + if (questIdField != null && q.getQuest() != null) + questIdField.setText(nullSafe(q.getQuest().getQuestId())); + } else if (t instanceof NpcStatusTrigger n) { + typeCombo.setValue(TYPE_NPC); + if (npcIdField != null) npcIdField.setText(nullSafe(n.getNpcId())); + if (npcStatusCombo != null && n.getTargetStatus() != null) + npcStatusCombo.setValue(n.getTargetStatus()); + } else if (t instanceof FractionStatusTrigger f) { + typeCombo.setValue(TYPE_FRACTION); + if (fractionIdField != null && f.getFractionId() != null) + fractionIdField.setText(f.getFractionId().toString()); + if (fractionStatusCombo != null && f.getTargetStatus() != null) + fractionStatusCombo.setValue(f.getTargetStatus()); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ComboBox statusCombo() { + ComboBox cb = new ComboBox<>(); + cb.getItems().addAll(Status.values()); + cb.setValue(Status.NEUTRAL); + cb.setMaxWidth(Double.MAX_VALUE); + return cb; + } + + private static TextField field(String prompt) { + TextField tf = new TextField(); + tf.setPromptText(prompt); + return tf; + } + + private static Label sectionTitle(String text) { + Label l = new Label(text); + l.setStyle("-fx-font-weight: bold; -fx-font-size: 12;"); + return l; + } + + private static HBox row(String labelText, Node control) { + Label lbl = new Label(labelText); + lbl.setMinWidth(120); + HBox.setHgrow(control, Priority.ALWAYS); + HBox box = new HBox(8, lbl, control); + box.setAlignment(Pos.CENTER_LEFT); + return box; + } + + private static String nullSafe(String s) { return s != null ? s : ""; } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java new file mode 100644 index 0000000..14a146e --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java @@ -0,0 +1,116 @@ +package de.blight.editor.ui; + +import de.blight.common.model.trigger.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * Wiederverwendbare Komponente zur Verwaltung einer {@link Trigger}-Liste. + * + *

Enthält: + *

    + *
  • ListView mit Kurzanzeige je Trigger
  • + *
  • Schaltflächen: Hinzufügen, Bearbeiten, Entfernen
  • + *
+ * {@code onChange} wird nach jeder Listenänderung aufgerufen. + */ +public class TriggerListEditor extends VBox { + + private final ObservableList triggers; + private final Runnable onChange; + private final ListView listView; + + public TriggerListEditor(List initial, Runnable onChange) { + this.triggers = FXCollections.observableArrayList( + initial != null ? initial : List.of()); + this.onChange = onChange; + + setSpacing(4); + + listView = new ListView<>(triggers); + listView.setPrefHeight(100); + listView.setCellFactory(lv -> new ListCell<>() { + @Override protected void updateItem(Trigger t, boolean empty) { + super.updateItem(t, empty); + setText(empty || t == null ? null : describe(t)); + setStyle(empty ? "" : "-fx-font-size: 11;"); + } + }); + + Button addBtn = new Button("+ Hinzufügen"); + Button editBtn = new Button("✎ Bearbeiten"); + Button delBtn = new Button("− Entfernen"); + + editBtn.setDisable(true); + delBtn.setDisable(true); + + listView.getSelectionModel().selectedItemProperty().addListener((obs, o, nw) -> { + boolean sel = nw != null; + editBtn.setDisable(!sel); + delBtn.setDisable(!sel); + }); + + addBtn.setOnAction(e -> { + new TriggerDialog().showAndWait().ifPresent(t -> { + triggers.add(t); + onChange.run(); + }); + }); + + editBtn.setOnAction(e -> { + Trigger sel = listView.getSelectionModel().getSelectedItem(); + if (sel == null) return; + new TriggerDialog(sel).showAndWait().ifPresent(updated -> { + int idx = triggers.indexOf(sel); + if (idx >= 0) triggers.set(idx, updated); + onChange.run(); + }); + }); + + delBtn.setOnAction(e -> { + Trigger sel = listView.getSelectionModel().getSelectedItem(); + if (sel != null) { triggers.remove(sel); onChange.run(); } + }); + + HBox buttons = new HBox(4, addBtn, editBtn, delBtn); + buttons.setPadding(new Insets(2, 0, 0, 0)); + + getChildren().addAll(listView, buttons); + } + + /** Gibt die aktuelle Trigger-Liste zurück (Kopie). */ + public List getTriggers() { + return new ArrayList<>(triggers); + } + + // ── Kurzanzeige ─────────────────────────────────────────────────────────── + + static String describe(Trigger t) { + if (t == null) return "—"; + String chapter = t.getRequiresChapter() > 0 + ? " [Kap. " + t.getRequiresChapter() + "]" : ""; + if (t instanceof QuestStartTrigger q) + return "Quest starten: " + + (q.getQuest() != null ? q.getQuest().getQuestId() : "?") + chapter; + if (t instanceof NpcStatusTrigger n) + return "NPC-Status: " + nullSafe(n.getNpcId()) + + " → " + statusName(n.getTargetStatus()) + chapter; + if (t instanceof FractionStatusTrigger f) + return "Fraktion-Status: " + + (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "…" : "?") + + " → " + statusName(f.getTargetStatus()) + chapter; + return t.getClass().getSimpleName() + chapter; + } + + private static String statusName(de.blight.common.model.Status s) { + return s != null ? s.name() : "?"; + } + + private static String nullSafe(String s) { return s != null && !s.isBlank() ? s : "?"; } +} 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 800439f..a249965 100644 --- a/blight-game/src/main/java/de/blight/game/BlightGame.java +++ b/blight-game/src/main/java/de/blight/game/BlightGame.java @@ -14,6 +14,7 @@ import de.blight.game.scene.WorldScene; import javax.imageio.ImageIO; import javax.swing.*; +import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; @@ -35,6 +36,12 @@ public class BlightGame extends SimpleApplication { private JWindow splashWindow; + // ── Splash-Status (von JME-Thread geschrieben, EDT liest) ───────────────── + static volatile String loadingStatus = "Initialisiere..."; + static volatile boolean gameReady = false; + private static JLabel splashLabel; + private static Timer splashTimer; + public static void main(String[] args) { SLF4JBridgeHandler.removeHandlersForRootLogger(); SLF4JBridgeHandler.install(); @@ -42,6 +49,7 @@ public class BlightGame extends SimpleApplication { BlightGame app = new BlightGame(); app.splashWindow = showSplash(); + status("Lade Grafikeinstellungen..."); GraphicsSettings gs = GraphicsStore.load(); AppSettings settings = new AppSettings(true); settings.setTitle("Blight"); @@ -53,21 +61,61 @@ public class BlightGame extends SimpleApplication { settings.setVSync(gs.vsync); settings.setSamples(gs.samples); + status("Initialisiere Renderer..."); app.setSettings(settings); app.setShowSettings(false); app.start(); } + /** Setzt den Ladetext, der im Splash-Screen angezeigt wird. Thread-sicher. */ + public static void status(String msg) { + loadingStatus = msg; + } + private static JWindow showSplash() { try { - BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png")); - BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png")); + // Logo skaliert auf 700 px Breite (Seitenverhältnis beibehalten) + BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png")); + int tw = 700; + int th = (int) (img.getHeight() * (tw / (double) img.getWidth())); + Image scaled = img.getScaledInstance(tw, th, Image.SCALE_SMOOTH); + + // Dunkles Panel + JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(new Color(0x1c1c1c)); + panel.setOpaque(true); + panel.add(new JLabel(new ImageIcon(scaled)), BorderLayout.CENTER); + + splashLabel = new JLabel(loadingStatus); + splashLabel.setForeground(new Color(0xbb, 0xbb, 0xbb)); + splashLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 13)); + splashLabel.setBorder(BorderFactory.createEmptyBorder(8, 16, 12, 16)); + splashLabel.setOpaque(false); + panel.add(splashLabel, BorderLayout.SOUTH); + JWindow win = new JWindow(); - if (icon != null) win.setIconImages(java.util.List.of(icon)); - win.getContentPane().add(new JLabel(new ImageIcon(img))); + try { + BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png")); + if (icon != null) win.setIconImages(java.util.List.of(icon)); + } catch (Exception ignored) {} + win.setContentPane(panel); win.pack(); win.setLocationRelativeTo(null); win.setVisible(true); + + // Pollt loadingStatus alle 100 ms – analog zum Editor-Splash + splashTimer = new Timer(100, null); + splashTimer.addActionListener(e -> { + splashLabel.setText(loadingStatus); + if (gameReady) { + splashTimer.stop(); + Timer closeDelay = new Timer(400, ev -> win.dispose()); + closeDelay.setRepeats(false); + closeDelay.start(); + } + }); + splashTimer.start(); + return win; } catch (IOException | NullPointerException ignored) { return null; @@ -76,16 +124,14 @@ public class BlightGame extends SimpleApplication { @Override public void simpleInitApp() { - if (splashWindow != null) { - SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; }); - } - + status("Lade Tastenbelegung..."); flyCam.setEnabled(false); inputManager.deleteMapping(INPUT_MAPPING_EXIT); keyBindings = KeyBindingStore.load(); graphicsSettings = GraphicsStore.load(); + status("Baue Spielwelt..."); worldScene = new WorldScene(keyBindings); stateManager.attach(worldScene); @@ -147,7 +193,12 @@ public class BlightGame extends SimpleApplication { } @Override - public void simpleUpdate(float tpf) {} + public void simpleUpdate(float tpf) { + if (!gameReady) { + gameReady = true; // erstes gerendtertes Frame → Splash schließen + status("Bereit"); + } + } private static Path findProjectRoot() { String prop = System.getProperty("blight.project.root"); 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 5e425a3..2505941 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,6 +6,7 @@ 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; @@ -28,6 +29,7 @@ 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.BlightGame; import de.blight.game.animation.AnimationLibrary; import de.blight.game.config.KeyBindings; import de.blight.game.control.PlayerInputControl; @@ -36,6 +38,8 @@ 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.GrassVertexRenderState; +import de.blight.game.state.LocationState; import de.blight.game.state.RiverState; import de.blight.game.state.WaterBodyState; import de.blight.game.state.WeatherState; @@ -91,6 +95,7 @@ public class WorldScene extends BaseAppState { this.rootNode = this.app.getRootNode(); this.assetManager = app.getAssetManager(); + BlightGame.status("Initialisiere Physik-Engine..."); bulletAppState = new BulletAppState(); app.getStateManager().attach(bulletAppState); @@ -100,17 +105,28 @@ public class WorldScene extends BaseAppState { @Override protected void onEnable() { + BlightGame.status("Baue Beleuchtung und Himmel..."); buildLighting(); + + BlightGame.status("Lade Terrain..."); TerrainQuad terrain = buildTerrain(); + BlightGame.status("Lade Vegetation..."); app.getStateManager().attach(new GrassState(terrain)); - app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP)); + app.getStateManager().attach(new GrassVertexRenderState()); + + BlightGame.status("Lade Wasserflächen..."); + app.getStateManager().attach(new WaterBodyState(sharedFPP)); + + BlightGame.status("Lade Welt-Objekte..."); app.getStateManager().attach(new RiverState()); app.getStateManager().attach(new WorldObjectsState()); + BlightGame.status("Lade Charakter..."); character = loadOrBuildCharacter(); rootNode.attachChild(character); + BlightGame.status("Initialisiere Spieler-Physik..."); // Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1) CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1); physicsChar = new CharacterControl(capsule, 0.05f); @@ -128,6 +144,11 @@ public class WorldScene extends BaseAppState { thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager()); thirdPersonCam.setTarget(character); + MainCharacter mc = findMainCharacter(); + if (mc != null) { + app.getStateManager().attach(new LocationState(mc, character)); + } + // Maus einfangen – keine Klick-Pflicht für Kamerasteuerung app.getInputManager().setCursorVisible(false); } @@ -324,15 +345,17 @@ public class WorldScene extends BaseAppState { /** * Erstellt ein Terrain aus der gespeicherten {@link MapData}. - * Die 16385×16385 Editor-Daten werden auf 513×513 heruntergesampelt - * (jeder 32. Vertex), mit Scale (8, 1, 8) auf die gleiche Weltgröße - * 4096 × 4096 WE gebracht. + * Visuell: 4097×4097 Vertices (jeder 4. Editor-Vertex), Scale (1, 1, 1) = 1 m/Vertex. + * Physik: 513×513 Vertices (jeder 32. Editor-Vertex), Scale (8, 1, 8) – unsichtbar, + * nur für Bullet-Kollision (33,5M Dreiecke bei 4097² wären zu viel für Bullet). */ private TerrainQuad buildTerrainFromMap(MapData map) { - final int GAME_VERTS = 513; // 512 Zellen à 8 WE = 4096 WE - final int STEP = (MapData.TERRAIN_VERTS - 1) / (GAME_VERTS - 1); // 32 final int SRC_VERTS = MapData.TERRAIN_VERTS; // 16385 + // ── Visuelles Terrain (4097 Vertices, 1 m/Vertex) ──────────────────── + final int GAME_VERTS = 4097; + final int STEP = (SRC_VERTS - 1) / (GAME_VERTS - 1); // 4 + float[] heights = new float[GAME_VERTS * GAME_VERTS]; for (int gz = 0; gz < GAME_VERTS; gz++) { int sz = gz * STEP; @@ -350,13 +373,13 @@ public class WorldScene extends BaseAppState { + " → X=" + spawnX + " Z=" + spawnZ); TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights); - terrain.setLocalScale(8f, 1f, 8f); + terrain.setLocalScale(1f, 1f, 1f); terrain.setShadowMode(RenderQueue.ShadowMode.Receive); applyTerrainMaterial(terrain, map); rootNode.attachChild(terrain); - // Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ - float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f)); + // Terrain-Höhe am Spawnpunkt (scale=1 → lokale Koordinaten = Weltkoordinaten) + float terrainH = terrain.getHeight(new Vector2f(spawnX, spawnZ)); if (Float.isNaN(terrainH)) { float maxH = -Float.MAX_VALUE; for (float h : heights) { if (h > maxH) maxH = h; } @@ -364,8 +387,12 @@ public class WorldScene extends BaseAppState { } spawnY = terrainH + 10f; + // ── Physik-Terrain: HeightfieldCollisionShape mit identischem heights[]-Array ─ + // Gleiche 4097×4097-Daten wie das visuelle Terrain → pixel-genaue Übereinstimmung. + // HeightfieldCollisionShape ist für Terrain optimiert (kein BVH, O(log n) Queries) + // und braucht keine separate TerrainQuad-Instanz. RigidBodyControl terrainPhysics = new RigidBodyControl( - CollisionShapeFactory.createMeshShape(terrain), 0f); + new HeightfieldCollisionShape(heights, terrain.getLocalScale()), 0f); terrain.addControl(terrainPhysics); bulletAppState.getPhysicsSpace().add(terrainPhysics); diff --git a/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java b/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java new file mode 100644 index 0000000..dd379a1 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java @@ -0,0 +1,305 @@ +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.ColorRGBA; +import com.jme3.math.Vector3f; +import com.jme3.renderer.Camera; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.Geometry; +import com.jme3.scene.Mesh; +import com.jme3.scene.Node; +import com.jme3.scene.Spatial; +import com.jme3.scene.VertexBuffer; +import com.jme3.scene.control.AbstractControl; +import com.jme3.util.BufferUtils; +import de.blight.common.GrassVertexBlade; +import de.blight.common.GrassVertexIO; + +import java.util.ArrayList; +import java.util.List; + +/** + * Rendert Vertex-Gras-Büschel im Spiel: 3 geneigte, verjüngte Halme pro Büschel, + * beleuchtet über NormalBuffer + Custom-Shader, chunk-basiertes Lazy-Loading. + */ +public class GrassVertexRenderState extends BaseAppState { + + // ── Chunks ──────────────────────────────────────────────────────────────── + 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; + private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; + private static final int INIT_PER_FRAME = 4; + private static final float FAR_DIST_SQ = 200f * 200f; + + // ── Geometrie (identisch zu GrassVertexState) ───────────────────────────── + private static final int BLADES_PER_TUFT = 3; + private static final int SEGMENTS = 5; + private static final float WIDTH_FACTOR = 0.05f; + private static final float BEND_FACTOR = 0.15f; + private static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f); + private static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f); + private static final ColorRGBA DRY_ROOT_COLOR = new ColorRGBA(0.45f, 0.35f, 0.08f, 1f); + private static final ColorRGBA DRY_TIP_COLOR = new ColorRGBA(0.82f, 0.74f, 0.16f, 1f); + + // ── Zustand ─────────────────────────────────────────────────────────────── + private Camera cam; + private AssetManager assetManager; + private Node grassNode; + private Material material; + private int nextChunk = 0; + + @SuppressWarnings("unchecked") + private final List[] chunkBlades = new List[CHUNK_COUNT]; + private final Node[] chunkNodes = new Node[CHUNK_COUNT]; + + @Override + protected void initialize(Application app) { + this.cam = app.getCamera(); + this.assetManager = app.getAssetManager(); + grassNode = new Node("grassVertexNode"); + ((SimpleApplication) app).getRootNode().attachChild(grassNode); + + for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>(); + + try { + for (GrassVertexBlade b : GrassVertexIO.load()) { + int ci = chunkIndex(b.x(), b.z()); + if (ci >= 0) chunkBlades[ci].add(b); + } + } catch (Exception e) { + System.err.println("[GrassVertexRenderState] Daten nicht ladbar: " + e.getMessage()); + } + + material = buildMaterial(); + } + + @Override + protected void cleanup(Application app) { + ((SimpleApplication) app).getRootNode().detachChild(grassNode); + } + + @Override protected void onEnable() { grassNode.setCullHint(Spatial.CullHint.Inherit); } + @Override protected void onDisable() { grassNode.setCullHint(Spatial.CullHint.Always); } + + @Override + public void update(float tpf) { + int built = 0; + while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) { + buildChunk(nextChunk++); + built++; + } + } + + // ── Material ────────────────────────────────────────────────────────────── + + private Material buildMaterial() { + try { + Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md"); + mat.setFloat("WindSpeed", 1.0f); + mat.setFloat("WindStrength", 0.15f); + mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal()); + mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f)); + mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); + return mat; + } catch (Exception e) { + System.err.println("[GrassVertexRenderState] Material nicht ladbar: " + e.getMessage()); + 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; + } + } + + // ── Chunk-Verwaltung ────────────────────────────────────────────────────── + + private 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; + } + + private void buildChunk(int ci) { + List blades = chunkBlades[ci]; + if (blades.isEmpty()) return; + + int bladeCount = blades.size(); + int vertCount = bladeCount * BLADES_PER_TUFT * (SEGMENTS + 1) * 2; + int indexCount = bladeCount * BLADES_PER_TUFT * SEGMENTS * 6; + + float[] positions = new float[vertCount * 3]; + float[] normals = new float[vertCount * 3]; + float[] colors = new float[vertCount * 4]; + float[] texCoords = new float[vertCount * 2]; + int[] indices = new int[indexCount]; + + int vi = 0, ii = 0; + for (GrassVertexBlade blade : blades) { + buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade); + vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2; + ii += BLADES_PER_TUFT * SEGMENTS * 6; + } + + Mesh mesh = new Mesh(); + mesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(positions)); + mesh.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(normals)); + mesh.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(colors)); + mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords)); + mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices)); + mesh.updateBound(); + + Geometry geo = new Geometry("gv_" + ci, mesh); + geo.setMaterial(material); + + int cx = ci % CHUNKS_PER_AXIS; + int cz = ci / CHUNKS_PER_AXIS; + float chunkCX = -TERRAIN_HALF + cx * CHUNK_SIZE + CHUNK_SIZE * 0.5f; + float chunkCZ = -TERRAIN_HALF + cz * CHUNK_SIZE + CHUNK_SIZE * 0.5f; + + Node node = new Node("gvc_" + ci); + node.attachChild(geo); + node.addControl(new VisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ))); + chunkNodes[ci] = node; + grassNode.attachChild(node); + } + + // ── Mesh-Logik (gespiegelt zu GrassVertexState) ─────────────────────────── + + private static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx, + int vi, int ii, GrassVertexBlade blade) { + float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height(); + float baseHW = h * WIDTH_FACTOR * 0.5f; + float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)); + + for (int b = 0; b < BLADES_PER_TUFT; b++) { + float bf = b; + + float jitter = (hash(x + bf * 13.7f, z + bf * 19.3f) - 0.5f) * 0.4f; + float bladeAngle = tAngle + b * (float) (Math.PI * 2.0 / BLADES_PER_TUFT) + jitter; + float leanMag = 0.25f + hash(x + bf * 7.3f, z + bf * 3.1f) * 0.55f; + float leanDir = bladeAngle + (float) (Math.PI * 0.5) * (b % 2 == 0 ? 1f : -1f); + + float offR = hash(x + bf * 2.9f, z + bf * 5.3f) * 0.28f; + float offA = hash(x + bf * 3.7f, z + bf * 1.7f) * (float) (Math.PI * 2); + float bx = x + offR * (float) Math.cos(offA); + float bz = z + offR * (float) Math.sin(offA); + + float cosA = (float) Math.cos(bladeAngle); + float sinA = (float) Math.sin(bladeAngle); + + float lnX = (float) (Math.sin(leanMag) * Math.cos(leanDir)); + float lnY = (float) Math.cos(leanMag); + float lnZ = (float) (Math.sin(leanMag) * Math.sin(leanDir)); + + float bendStr = (hash(x + bf * 5.1f, z + bf * 8.7f) - 0.5f) * 2f * BEND_FACTOR * h; + float bendX = -sinA * bendStr; + float bendZ = cosA * bendStr; + + int bladeVi = vi + b * (SEGMENTS + 1) * 2; + int bladeIi = ii + b * SEGMENTS * 6; + + for (int s = 0; s <= SEGMENTS; s++) { + float t = (float) s / SEGMENTS; + float hw = baseHW * (float) Math.pow(1.0 - t, 1.4); + + float spX = bx + lnX * h * t + bendX * t * t; + float spY = y + lnY * h * t; + float spZ = bz + lnZ * h * t + bendZ * t * t; + + float tgX = lnX + 2f * bendX * t; + float tgY = lnY; + float tgZ = lnZ + 2f * bendZ * t; + float tgLen = (float) Math.sqrt(tgX*tgX + tgY*tgY + tgZ*tgZ); + if (tgLen > 1e-6f) { tgX /= tgLen; tgY /= tgLen; tgZ /= tgLen; } + + float nx = -sinA * tgY; + float ny = sinA * tgX - cosA * tgZ; + float nz = cosA * tgY; + float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; } + + float blend = 0.30f; + nx *= (1f - blend); + ny = ny * (1f - blend) + blend; + nz *= (1f - blend); + nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; } + + int svi = bladeVi + s * 2; + float dry = blade.dryness(); + setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry); + setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry); + + if (s < SEGMENTS) { + int sii = bladeIi + s * 6; + idx[sii] = svi; idx[sii+1] = svi+1; idx[sii+2] = svi+3; + idx[sii+3] = svi; idx[sii+4] = svi+3; idx[sii+5] = svi+2; + } + } + } + } + + private static float hash(float a, float b) { + int ia = Float.floatToRawIntBits(a); + int ib = Float.floatToRawIntBits(b); + int h = ia ^ (ib * 0x9e3779b9); + h ^= h >>> 16; + h *= 0x45d9f3b; + h ^= h >>> 16; + return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF; + } + + private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi, + float x, float y, float z, float nx, float ny, float nz, + float wf, float dryness) { + int pi = vi * 3; + pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z; + + int ni = vi * 3; + nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz; + + int ci = vi * 4; + float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf; + float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf; + float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf; + float dr = DRY_ROOT_COLOR.r + (DRY_TIP_COLOR.r - DRY_ROOT_COLOR.r) * wf; + float dg = DRY_ROOT_COLOR.g + (DRY_TIP_COLOR.g - DRY_ROOT_COLOR.g) * wf; + float db = DRY_ROOT_COLOR.b + (DRY_TIP_COLOR.b - DRY_ROOT_COLOR.b) * wf; + col[ci] = gr + (dr - gr) * dryness; + col[ci+1] = gg + (dg - gg) * dryness; + col[ci+2] = gb + (db - gb) * dryness; + col[ci+3] = 1f; + + int ti = vi * 2; + tex[ti] = wf; tex[ti+1] = 0f; + } + + // ── LOD-Culling ─────────────────────────────────────────────────────────── + + private static final class VisibilityControl extends AbstractControl { + private final Camera cam; + private final Vector3f center; + + VisibilityControl(Camera cam, Vector3f center) { + this.cam = cam; + this.center = center; + } + + @Override + protected void controlUpdate(float tpf) { + float dx = cam.getLocation().x - center.x; + float dz = cam.getLocation().z - center.z; + spatial.setCullHint(dx*dx + dz*dz <= FAR_DIST_SQ + ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + } + + @Override protected void controlRender(RenderManager rm, ViewPort vp) {} + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/LocationState.java b/blight-game/src/main/java/de/blight/game/state/LocationState.java new file mode 100644 index 0000000..44cf19e --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/LocationState.java @@ -0,0 +1,69 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.state.BaseAppState; +import com.jme3.math.Vector3f; +import de.blight.common.LocationIO; +import de.blight.common.model.Location; +import de.blight.common.model.MainCharacter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Verfolgt die Spieler-Position und feuert Location-Trigger wenn der Charakter + * eine Location betritt (Übergang outside→inside). + */ +public class LocationState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(LocationState.class); + + private final MainCharacter character; + private List locations; + private final Set active = new HashSet<>(); + + /** Referenz auf die Node/Control, von der wir die Spieler-Position lesen. */ + private final com.jme3.scene.Node playerNode; + + public LocationState(MainCharacter character, com.jme3.scene.Node playerNode) { + this.character = character; + this.playerNode = playerNode; + } + + @Override + protected void initialize(Application app) { + try { + locations = LocationIO.load(); + log.info("{} Location(s) geladen.", locations.size()); + } catch (Exception e) { + log.error("Locations nicht ladbar", e); + locations = List.of(); + } + } + + @Override + public void update(float tpf) { + if (locations.isEmpty() || playerNode == null) return; + Vector3f pos = playerNode.getWorldTranslation(); + float px = pos.x, pz = pos.z; + + for (Location loc : locations) { + boolean inside = loc.contains(px, pz); + boolean wasInside = active.contains(loc.getId()); + if (inside && !wasInside) { + active.add(loc.getId()); + loc.entered(character); + log.debug("Location betreten: {}", loc.getId()); + } else if (!inside) { + active.remove(loc.getId()); + } + } + } + + @Override protected void cleanup(Application app) {} + @Override protected void onEnable() {} + @Override protected void onDisable() {} +} diff --git a/blight-game/src/main/java/de/blight/game/state/MusicSystem.java b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java index 4e11274..94411af 100644 --- a/blight-game/src/main/java/de/blight/game/state/MusicSystem.java +++ b/blight-game/src/main/java/de/blight/game/state/MusicSystem.java @@ -8,8 +8,8 @@ import com.jme3.audio.AudioData; import com.jme3.audio.AudioNode; import com.jme3.math.Vector3f; import com.jme3.scene.Node; -import de.blight.common.MusicAreaIO; -import de.blight.common.PlacedMusicArea; +import de.blight.common.AreaIO; +import de.blight.common.PlacedArea; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,7 +36,7 @@ public class MusicSystem extends BaseAppState { private AssetManager assets; private Node rootNode; - private final List data = new ArrayList<>(); + private final List data = new ArrayList<>(); // three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null private final List tracks = new ArrayList<>(); private final List fadeStates = new ArrayList<>(); @@ -51,7 +51,7 @@ public class MusicSystem extends BaseAppState { rootNode = app.getRootNode(); try { - for (PlacedMusicArea area : MusicAreaIO.load()) { + for (PlacedArea area : AreaIO.load()) { AudioNode[] arr = { loadAmbient(area.dayTrack()), loadAmbient(area.nightTrack()), @@ -131,7 +131,7 @@ public class MusicSystem extends BaseAppState { checkTimer = 0f; for (int i = 0; i < data.size(); i++) { - PlacedMusicArea area = data.get(i); + PlacedArea area = data.get(i); boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ()); FadeState fs = fadeStates.get(i); 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 index a4fe401..95ac5ee 100644 --- a/blight-game/src/main/java/de/blight/game/state/WaterBodyState.java +++ b/blight-game/src/main/java/de/blight/game/state/WaterBodyState.java @@ -3,57 +3,38 @@ 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.math.ColorRGBA; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; 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.*; +import java.util.ArrayList; +import java.util.List; /** - * 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. + * Rendert Polygon-Wasserflächen per WaterPolygonFilter (ein Filter pro Fläche). + * Identisches Aussehen wie WaterFilter, aber auf das eingezeichnete Polygon beschränkt. */ 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<>(); + private static final Vector3f SUN_DIR = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal(); - public WaterBodyState(TerrainQuad terrain, FilterPostProcessor fpp) { - this.terrain = terrain; - this.fpp = fpp; + private final FilterPostProcessor fpp; + private final List filters = new ArrayList<>(); + + public WaterBodyState(FilterPostProcessor fpp) { + 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 { @@ -64,214 +45,37 @@ public class WaterBodyState extends BaseAppState { } if (bodies.isEmpty()) return; - Vector3f sunDir = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal(); - for (PlacedWater body : bodies) { + float[] xs = body.pointsX(); + float[] zs = body.pointsZ(); + if (xs.length < 3) continue; 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); + WaterPolygonFilter f = new WaterPolygonFilter( + sa.getRootNode(), SUN_DIR, body.waterHeight(), xs, zs); + f.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f)); + f.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f)); + f.setWaterTransparency(0.15f); + f.setMaxAmplitude(0.1f); // reduced to keep visual height close to waterHeight + f.setWaveScale(0.008f); + f.setSpeed(0.5f); + float rad = (float) Math.toRadians(body.flowDegrees()); + f.setWindDirection(new Vector2f((float) Math.sin(rad), (float) Math.cos(rad))); + fpp.addFilter(f); + filters.add(f); + log.info("Wasserfläche geladen: {} Punkte, h={}", xs.length, body.waterHeight()); } catch (Exception e) { - log.error("Fehler bei Becken {}/{}", body.seedX(), body.seedZ(), e); + log.error("Fehler beim Laden einer Wasserfläche", e); } } - log.info("{}/{} Wasserfläche(n) geladen.", waterFilters.size(), bodies.size()); + log.info("{}/{} Wasserfläche(n) geladen.", filters.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); + for (WaterPolygonFilter f : filters) fpp.removeFilter(f); + filters.clear(); } - @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; - } + @Override protected void onEnable() {} + @Override protected void onDisable() {} } diff --git a/blight-game/src/main/java/de/blight/game/state/WaterPolygonFilter.java b/blight-game/src/main/java/de/blight/game/state/WaterPolygonFilter.java new file mode 100644 index 0000000..2548120 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WaterPolygonFilter.java @@ -0,0 +1,63 @@ +package de.blight.game.state; + +import com.jme3.asset.AssetManager; +import com.jme3.material.MatParam; +import com.jme3.material.Material; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.Node; +import com.jme3.shader.VarType; +import com.jme3.water.WaterFilter; + +import java.util.ArrayList; +import java.util.List; + +/** + * WaterFilter mit Polygon-Beschränkung. + * Verwendet exakt denselben Shader wie WaterFilter, erweitert um einen + * GLSL-Point-in-Polygon-Test der das Wasser auf den eingezeichneten Bereich begrenzt. + */ +public class WaterPolygonFilter extends WaterFilter { + + private static final int MAX_POINTS = 64; + + private final Vector2f[] polygonPoints; + + public WaterPolygonFilter(Node scene, Vector3f sunDir, + float waterHeight, float[] pointsX, float[] pointsZ) { + super(scene, sunDir); + setWaterHeight(waterHeight); + int n = Math.min(pointsX.length, MAX_POINTS); + polygonPoints = new Vector2f[n]; + for (int i = 0; i < n; i++) + polygonPoints[i] = new Vector2f(pointsX[i], pointsZ[i]); + } + + @Override + protected void initFilter(AssetManager manager, RenderManager renderManager, + ViewPort vp, int w, int h) { + super.initFilter(manager, renderManager, vp, w, h); + + // WaterFilter set up `material` from Water.j3md — swap to our extended version + // that adds POLYGON_AREA check in the fragment shader. + Material newMat = new Material(manager, "MatDefs/WaterPolygon.j3md"); + + // Copy every parameter WaterFilter set (textures, floats, vectors, booleans…) + for (MatParam p : new ArrayList<>(material.getParams())) { + try { + newMat.setParam(p.getName(), p.getVarType(), p.getValue()); + } catch (Exception ignored) { + // Skip params the new j3md doesn't know about + } + } + + // Inject polygon points + newMat.setParam("Points", VarType.Vector2Array, polygonPoints); + newMat.setInt("NumPoints", polygonPoints.length); + + // Replace the protected field — all subsequent WaterFilter setters use this field directly + material = newMat; + } +} diff --git a/blight-map/src/main/map/blight_areas.bla b/blight-map/src/main/map/blight_areas.bla new file mode 100644 index 0000000..40997eb --- /dev/null +++ b/blight-map/src/main/map/blight_areas.bla @@ -0,0 +1 @@ +# polygon nameId dayTrack nightTrack combatTrack diff --git a/blight-map/src/main/map/blight_grass_vertex.blgv b/blight-map/src/main/map/blight_grass_vertex.blgv new file mode 100644 index 0000000..60e7fd7 Binary files /dev/null and b/blight-map/src/main/map/blight_grass_vertex.blgv differ diff --git a/blight-map/src/main/map/blight_location_zones.blz b/blight-map/src/main/map/blight_location_zones.blz new file mode 100644 index 0000000..a7e9071 --- /dev/null +++ b/blight-map/src/main/map/blight_location_zones.blz @@ -0,0 +1 @@ +# polygon nameId triggersJson diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index ace61c0..8deed5b 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_music_areas.bma b/blight-map/src/main/map/blight_music_areas.bma index 1723ad9..62a18f1 100644 --- a/blight-map/src/main/map/blight_music_areas.bma +++ b/blight-map/src/main/map/blight_music_areas.bma @@ -1 +1,4 @@ # polygon dayTrack nightTrack combatTrack +17.980,-379.565;17.980,-379.565;17.980,-379.565 +16.540,-386.189;16.540,-386.189;16.540,-386.189 +16.737,-386.357;16.737,-386.357;16.737,-386.357 diff --git a/blight-map/src/main/map/blight_water.blw b/blight-map/src/main/map/blight_water.blw index dc927bb..9d61ca9 100644 --- a/blight-map/src/main/map/blight_water.blw +++ b/blight-map/src/main/map/blight_water.blw @@ -1,2 +1 @@ -# seedX seedZ waterHeight --187.54634 -189.42381 5.33836 +# polygon_points waterHeight flowDegrees