Commit vor großem Terrain refactoring

This commit is contained in:
2026-06-08 08:42:45 +02:00
parent 7faed35287
commit 1297869dfa
119 changed files with 9784 additions and 1614 deletions

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -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<PlacedArea> 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<PlacedArea> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedArea> 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;
}
}

View File

@@ -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) {}

View File

@@ -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.51.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<GrassVertexBlade> 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<GrassVertexBlade> 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<GrassVertexBlade> 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;
}
}
}

View File

@@ -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<Location> 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<Location> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<Location> 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<Trigger> triggers = TriggerIO.deserializeList(f[4].strip());
loc.setTriggers(triggers);
}
list.add(loc);
} catch (NumberFormatException ignored) {}
}
return list;
}
}

View File

@@ -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<PlacedLocationZone> 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<PlacedLocationZone> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedLocationZone> 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<Trigger> 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;
}
}

View File

@@ -88,9 +88,10 @@ public final class MapIO {
public static void save(MapData data) throws IOException { public static void save(MapData data) throws IOException {
Files.createDirectories(MAP_PATH.getParent()); Files.createDirectories(MAP_PATH.getParent());
Path tmp = MAP_PATH.resolveSibling(MAP_PATH.getFileName() + ".tmp");
try (DataOutputStream out = new DataOutputStream( try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream( new BufferedOutputStream(
new GZIPOutputStream(Files.newOutputStream(MAP_PATH))))) { new GZIPOutputStream(Files.newOutputStream(tmp))))) {
out.writeInt(MAGIC); out.writeInt(MAGIC);
out.writeInt(VERSION); out.writeInt(VERSION);
writeFloats(out, data.terrainHeight); 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] : ""); for (int i = 0; i < slotEnd; i++) out.writeUTF(slots[i] != null ? slots[i] : "");
out.write(data.grassTextureMap); 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 { public static MapData load() throws IOException {
@@ -243,17 +252,32 @@ public final class MapIO {
// ── Hilfsmethoden ───────────────────────────────────────────────────────── // ── 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 { private static void writeFloats(DataOutputStream out, float[] arr) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(arr.length * Float.BYTES) byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES];
.order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
buf.asFloatBuffer().put(arr); int offset = 0;
out.write(buf.array()); 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 { private static void readFloats(DataInputStream in, float[] arr) throws IOException {
byte[] bytes = new byte[arr.length * Float.BYTES]; byte[] bytes = new byte[FLOAT_CHUNK * Float.BYTES];
in.readFully(bytes); ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).asFloatBuffer().get(arr); 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 { private static void writeStrings(DataOutputStream out, String[] arr) throws IOException {

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,10 @@
package de.blight.common;
public record PlacedArea(
float[] pointsX,
float[] pointsZ,
String nameId,
String dayTrack,
String nightTrack,
String combatTrack
) {}

View File

@@ -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<Trigger> triggers
) {
/** Rückwärtskompatibel: kein Trigger. */
public PlacedLocationZone(float[] pointsX, float[] pointsZ, String nameId) {
this(pointsX, pointsZ, nameId, List.of());
}
}

View File

@@ -1,8 +1,6 @@
package de.blight.common; package de.blight.common;
/** /**
* Platzierte Wasserfläche. * Platzierte Wasserfläche, definiert durch ein Benutzer-Polygon, eine Höhe und eine Fließrichtung.
* Die Form wird zur Laufzeit per Flood-Fill aus dem Geländenetz berechnet
* gespeichert werden nur Saatpunkt und Wasserstand.
*/ */
public record PlacedWater(float seedX, float seedZ, float waterHeight) {} public record PlacedWater(float[] pointsX, float[] pointsZ, float waterHeight, float flowDegrees) {}

View File

@@ -5,11 +5,9 @@ import java.nio.file.*;
import java.util.*; import java.util.*;
/** /**
* Liest und schreibt platzierte Wasserflächen als tab-separierte Textdatei * Liest und schreibt platzierte Wasserflächen ({@code blight_water.blw}) neben der Kartendatei.
* ({@code blight_water.blw}) neben der Kartendatei.
* *
* Format: seedX seedZ waterHeight * Format (je Zeile): x1,z1;x2,z2;x3,z3;... TAB waterHeight [TAB flowDegrees]
* Die Form des Wasserkörpers wird per Flood-Fill zur Laufzeit rekonstruiert.
*/ */
public final class WaterBodyIO { public final class WaterBodyIO {
@@ -23,11 +21,20 @@ public final class WaterBodyIO {
Path p = getPath(); Path p = getPath();
Files.createDirectories(p.getParent()); Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) { try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# seedX\tseedZ\twaterHeight"); w.write("# polygon_points\twaterHeight\tflowDegrees");
w.newLine(); w.newLine();
for (PlacedWater b : bodies) { for (PlacedWater b : bodies) {
w.write(String.format(Locale.ROOT, "%.5f\t%.5f\t%.5f%n", StringBuilder sb = new StringBuilder();
b.seedX(), b.seedZ(), b.waterHeight())); 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)) { for (String line : Files.readAllLines(p)) {
line = line.strip(); line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue; if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1); String[] parts = line.split("\t", -1);
if (f.length < 3) continue; if (parts.length < 2) continue;
try { try {
list.add(new PlacedWater( float wh = Float.parseFloat(parts[1].strip());
Float.parseFloat(f[0]), float fd = parts.length >= 3 ? Float.parseFloat(parts[2].strip()) : 0f;
Float.parseFloat(f[1]), String[] pts = parts[0].split(";", -1);
Float.parseFloat(f[2]))); float[] xs = new float[pts.length];
} catch (NumberFormatException ignored) {} 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; return list;
} }

View File

@@ -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;
}

View File

@@ -16,4 +16,14 @@ public class CraftingTable {
private TextReference name; private TextReference name;
private ObjectReference object; private ObjectReference object;
private CraftingTableType type;
public enum CraftingTableType {
AlchemyTable,
EnchantmentTable,
Smithy,
Goldsmiths,
Workshop;
}
} }

View File

@@ -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 <TypeName>.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<CraftingTable> 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<CraftingTable.CraftingTableType, CraftingTable> loadAll(Path dir) {
Map<CraftingTable.CraftingTableType, CraftingTable> 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;
}
}

View File

@@ -2,8 +2,8 @@ package de.blight.common.model;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import de.blight.common.model.quests.Quest;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@@ -11,9 +11,12 @@ import lombok.Setter;
@Setter @Setter
public class DialogOption { public class DialogOption {
private String id = UUID.randomUUID().toString();
private String label = "";
private int requiresChapter; private int requiresChapter;
private Quest requiresQuestOpen; private QuestRef requiresQuestOpen;
private Quest requiresQuestComplete; private QuestRef requiresQuestComplete;
private Status requiresStatus; private Status requiresStatus;
private TextReference textHero; private TextReference textHero;
@@ -27,9 +30,9 @@ public class DialogOption {
private RequiredItem requiredItem; private RequiredItem requiredItem;
private RecievesItem recievesItem; private RecievesItem recievesItem;
private Quest recievesQuest; private QuestRef recievesQuest;
private Quest fulfillsQuest; private QuestRef fulfillsQuest;
private List<Quest> abortsQuests = new ArrayList<Quest>(); private List<QuestRef> abortsQuests = new ArrayList<>();
private boolean enablesTrade; private boolean enablesTrade;

View File

@@ -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;
}

View File

@@ -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 <fractionId>.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<Fraction> 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<Fraction> loadAll(Path dir) {
List<Fraction> result = new ArrayList<>();
if (!Files.isDirectory(dir)) return result;
try (Stream<Path> 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;
}
}

View File

@@ -2,4 +2,5 @@ package de.blight.common.model;
public interface Interactable { public interface Interactable {
public String getDisplayText();
} }

View File

@@ -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 : "";
}
}

View File

@@ -5,16 +5,21 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class Item { public class Item implements Interactable {
private String itemId; private String itemId;
private ItemCategory category; private ItemCategory category;
private TextReference name; private TextReference name;
private TextReference description; private TextReference description;
private int worthGold; private int worthGold;
private ObjectReference modelRef;
public void use(MainCharacter character) { public void use(MainCharacter character) {
} }
@Override
public String getDisplayText() {
return TextRegistry.resolve(name, itemId != null ? itemId : "?");
}
} }

View File

@@ -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 <itemId>.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<Item> 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<Item> loadAll(Path itemDir) {
List<Item> result = new ArrayList<>();
if (!Files.isDirectory(itemDir)) return result;
try (Stream<Path> 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));
}
}

View File

@@ -1,5 +1,33 @@
package de.blight.common.model; 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<Trigger> 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));
}
} }

View File

@@ -28,6 +28,9 @@ public class MainCharacter extends GameCharacter {
private List<Quest> openQuests; private List<Quest> openQuests;
private List<Quest> completedQuests; private List<Quest> completedQuests;
private List<Quest> abortedQuests;
private de.blight.common.model.abilities.Abilities abilities;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
@@ -46,6 +49,10 @@ public class MainCharacter extends GameCharacter {
option.getAbortsQuests().forEach(this::abortQuest); option.getAbortsQuests().forEach(this::abortQuest);
} }
public void startQuest(Quest quest) {
if (isQuestNew(quest)) openQuests.add(quest);
}
public void fullfillQuest(Quest quest) { public void fullfillQuest(Quest quest) {
openQuests.remove(quest); openQuests.remove(quest);
completedQuests.add(quest); completedQuests.add(quest);
@@ -65,9 +72,14 @@ public class MainCharacter extends GameCharacter {
private void abortQuest(Quest quest) { private void abortQuest(Quest quest) {
openQuests.remove(quest); openQuests.remove(quest);
abortedQuests.add(quest);
listeners.forEach(listener -> listener.questAborted(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) { public void removeListener(CharacterListener listener) {
listeners.remove(listener); listeners.remove(listener);
} }

View File

@@ -16,6 +16,8 @@ public class NPC extends GameCharacter {
private Status status; private Status status;
private boolean trader; private boolean trader;
private Fraction fraction;
private List<Item> items; private List<Item> items;
private List<DialogOption> currentOptions; private List<DialogOption> currentOptions;

View File

@@ -1,5 +1,19 @@
package de.blight.common.model; 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;
} }

View File

@@ -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.
}

View File

@@ -11,5 +11,10 @@ public class Recipe {
private Item creates; private Item creates;
private Map<Item, Integer> components; private Map<Item, Integer> components;
private Interactable requires; private CraftingTable table;
private Integer requiresLvlAlchemy;
private Integer requiresLvlEngineering;
private Integer requiresLvlSmithery;
private Integer requiresLvlEnchanting;
} }

View File

@@ -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 <createsItemId>.recipe} im recipes/-Verzeichnis.
*
* {@code Map<Item,Integer>} 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<Recipe> 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<Recipe> loadAll(Path recipeDir) {
List<Recipe> result = new ArrayList<>();
if (!Files.isDirectory(recipeDir)) return result;
try (Stream<Path> 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<Item, Integer> 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<ComponentDto> 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 : ""; }
}

View File

@@ -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<String, String> entries = new LinkedHashMap<>();
public TextBundle(String language) {
this.language = language;
}
}

View File

@@ -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 <lang>.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<LinkedHashMap<String, String>>(){}.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<String, String> 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<TextBundle> loadAll(Path dir) {
List<TextBundle> result = new ArrayList<>();
if (!Files.isDirectory(dir)) return result;
try (Stream<Path> 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<String> availableLanguages(Path dir) {
List<String> langs = new ArrayList<>();
if (!Files.isDirectory(dir)) return langs;
try (Stream<Path> 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));
}
}

View File

@@ -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<String, String> entries = new HashMap<>();
private TextRegistry() {}
public static void registerAll(Map<String, String> 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<String, String> getAll() {
return new HashMap<>(entries);
}
}

View File

@@ -37,6 +37,16 @@ public class Abilities {
*/ */
private int lvlEngineering; // 1-3 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 { public enum StaffAbilities {
BASE_ATTACK(1), // Eine Basisattacke mit einem Stab BASE_ATTACK(1), // Eine Basisattacke mit einem Stab

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class BringQuest { public class BringQuest extends Quest {
private NPC bring; private NPC bring;
private Location bringTo; private Location bringTo;

View File

@@ -7,7 +7,7 @@ import lombok.Setter;
@Setter @Setter
@Getter @Getter
public class FollowQuest implements QuestType { public class FollowQuest extends Quest {
private NPC follow; private NPC follow;
private Location followTo; private Location followTo;

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class InteractQuest implements QuestType { public class InteractQuest extends Quest {
private Interactable interactWith; private Interactable interactWith;
} }

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class ItemQuest implements QuestType { public class ItemQuest extends Quest {
private Item item; private Item item;
private int count; private int count;

View File

@@ -6,13 +6,11 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class Quest { public abstract class Quest {
private int xp; private int xp;
private String questId; private String questId;
private TextReference text; private TextReference text;
private TextReference description; private TextReference description;
private TextReference successText; private TextReference successText;
private QuestType questType;
} }

View File

@@ -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 <questId>.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<Quest> loadAll(Path questDir) {
List<Quest> result = new ArrayList<>();
if (!Files.isDirectory(questDir)) return result;
try (Stream<Path> 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<Quest>, JsonSerializer<Quest> {
@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<Interactable>, JsonSerializer<Interactable> {
@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);
}
}
}

View File

@@ -1,5 +0,0 @@
package de.blight.common.model.quests;
public interface QuestType {
}

View File

@@ -6,7 +6,7 @@ import lombok.Setter;
@Getter @Getter
@Setter @Setter
public class TalkQuest implements QuestType { public class TalkQuest extends Quest {
private NPC talkTo; private NPC talkTo;
} }

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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:
* <ul>
* <li>{@code QUEST_START} → {@link QuestStartTrigger}</li>
* <li>{@code NPC_STATUS} → {@link NpcStatusTrigger}</li>
* <li>{@code FRACTION_STATUS} → {@link FractionStatusTrigger}</li>
* </ul>
*/
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<Trigger> 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<Trigger> deserializeList(String json) {
if (json == null || json.isBlank() || "[]".equals(json.strip())) return new ArrayList<>();
try {
JsonArray arr = JsonParser.parseString(json).getAsJsonArray();
List<Trigger> 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<Trigger>, JsonDeserializer<Trigger> {
@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; }
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,10 @@ import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image; import com.jme3.texture.Image;
import com.jme3.texture.Texture2D; import com.jme3.texture.Texture2D;
import de.blight.editor.state.AnimPreviewState; 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.EmitterState;
import de.blight.editor.state.MusicAreaState; import de.blight.editor.state.LocationZoneState;
import de.blight.editor.state.RiverEditorState; import de.blight.editor.state.RiverEditorState;
import de.blight.editor.state.PlayToolState; import de.blight.editor.state.PlayToolState;
import de.blight.editor.state.SoundAreaState; import de.blight.editor.state.SoundAreaState;
@@ -93,10 +95,12 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new EmitterState(input)); stateManager.attach(new EmitterState(input));
stateManager.attach(new WaterBodyState(input)); stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(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 RiverEditorState(input));
stateManager.attach(new PlayToolState(input)); stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input)); stateManager.attach(new AnimPreviewState(input));
stateManager.attach(new ModelEditorState(input));
input.loadingStatus = "Initialisiere Konsole..."; input.loadingStatus = "Initialisiere Konsole...";
jmeConsole = new JmeConsole(false); jmeConsole = new JmeConsole(false);

View File

@@ -2,6 +2,7 @@ package de.blight.editor;
import de.blight.editor.tool.EditorTool; import de.blight.editor.tool.EditorTool;
import de.blight.editor.tool.GrassTool; import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.GrassVertexTool;
import de.blight.editor.tool.HeightTool; import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool; import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.TextureTool; import de.blight.editor.tool.TextureTool;
@@ -21,6 +22,7 @@ public class SharedInput {
public final HeightTool heightTool = new HeightTool(); public final HeightTool heightTool = new HeightTool();
public final UpperHeightTool upperHeightTool = new UpperHeightTool(); public final UpperHeightTool upperHeightTool = new UpperHeightTool();
public final GrassTool grassTool = new GrassTool(); public final GrassTool grassTool = new GrassTool();
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TextureTool textureTool = new TextureTool(); public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool(); public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool; public volatile EditorTool activeTool = heightTool;
@@ -62,11 +64,18 @@ public class SharedInput {
public record TerrainEdit(float screenX, float screenY, int action) {} public record TerrainEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TerrainEdit> editQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<TerrainEdit> editQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Edits ──────────────────────────────────────────────────────────── // ── Gras (Textur) Edits ───────────────────────────────────────────────────
/** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */ /** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */
public record GrassEdit(float screenX, float screenY, int action) {} public record GrassEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassEdit> grassEditQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<GrassEdit> 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<GrassVertexEdit> grassVertexEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Einstellungen (JavaFX → JME3) ─────────────────────────────────── // ── Gras-Einstellungen (JavaFX → JME3) ───────────────────────────────────
/** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */ /** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */
public volatile String grassTexturePath = ""; public volatile String grassTexturePath = "";
@@ -125,6 +134,10 @@ public class SharedInput {
// 0 = keine Änderung, 1 = Drahtgitter aktivieren, 2 = Textur-Modus aktivieren // 0 = keine Änderung, 1 = Drahtgitter aktivieren, 2 = Textur-Modus aktivieren
public volatile int wireframeRequest = 0; 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) ─────────── // ── Vorschau-Viewport (gemeinsam für Baum-Generator & EZ-Tree) ───────────
public volatile float treePreviewRotY = 0f; // Yaw-Winkel in Grad public volatile float treePreviewRotY = 0f; // Yaw-Winkel in Grad
public volatile float treePreviewRotX = 30f; // Elevation in Grad [5, 80] 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 */ /** activeLayer==10 → Sound-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_SOUND_AREAS = 10; public static final int LAYER_SOUND_AREAS = 10;
// ── Musik-Bereiche ──────────────────────────────────────────────────────── // ── Bereiche (Areas) ──────────────────────────────────────────────────────
/** activeLayer==11 → Musik-Bereiche (Polygon) platzieren und bearbeiten */ /** activeLayer==11 → Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_MUSIC_AREAS = 11; public static final int LAYER_AREAS = 11;
// ── Spiel-Starten-Werkzeug ──────────────────────────────────────────────── // ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */ /** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
public static final int LAYER_PLAY_TOOL = 12; public static final int LAYER_PLAY_TOOL = 12;
/** activeLayer==13 → Flüsse platzieren */ /** activeLayer==13 → Wasserfälle platzieren */
public static final int LAYER_RIVERS = 13; public static final int LAYER_WATERFALL = 13;
// ── Fluss-Werkzeug ───────────────────────────────────────────────────────── // ── Wasserfall-Werkzeug ────────────────────────────────────────────────────
public record RiverClick(float screenX, float screenY, boolean rightButton) {} public record WaterfallClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<RiverClick> riverClickQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<WaterfallClick> waterfallClickQueue = new ConcurrentLinkedQueue<>();
public volatile float riverNewWidth = 8.0f; // Breite des nächsten Punktes (Minimum 8m) public volatile float waterfallNewWidth = 8.0f;
public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall) public volatile boolean undoWaterfallPointRequested = false;
public volatile boolean undoRiverPointRequested = false;
public volatile String riverHint = null;
/** JME → JavaFX: Info des selektierten Flusses. Format: "idx|numPoints|totalLengthM" oder null. */ /** JME → JavaFX: Info des selektierten Wasserfalls. Format: "idx|numPoints|totalLengthM" oder null. */
public volatile String selectedRiverInfo = null; public volatile String selectedWaterfallInfo = null;
public volatile boolean riverSelectionChanged = false; public volatile boolean waterfallSelectionChanged = false;
/** JavaFX → JME: Selektierten Fluss löschen. */ /** JavaFX → JME: Selektierten Wasserfall löschen. */
public volatile boolean deleteRiverRequested = false; 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 record WaterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>();
/** /**
* JME → JavaFX: Info der selektierten Wasseroberfläche. * JME → JavaFX: Info der selektierten Wasserfläche.
* Format: "idx|seedX|seedZ|waterHeight|cellCount" oder null. * Format: "idx|waterHeight|pointCount" oder null.
*/ */
public volatile String selectedWaterInfo = null; public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false; public volatile boolean waterSelectionChanged = false;
/** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */ /** JavaFX → JME: Spacetaste → Terrain-Höhe an Cursor-Position als aktuelle Wasserhöhe übernehmen. */
public volatile String waterHint = null; 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. */ /** JavaFX → JME: neue Höhe r selektierte Wasserfläche. NaN = kein Auftrag. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>(); public final AtomicReference<Float> pendingWaterHeight = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */ /** JavaFX → JME: neue Fließrichtung (Grad 0359) für selektierte Wasserfläche. null = kein Auftrag. */
public final AtomicReference<Float> pendingWaterFlowDegrees = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasserfläche löschen. */
public volatile boolean deleteWaterRequested = false; public volatile boolean deleteWaterRequested = false;
// ── Sound-Bereich-Werkzeug ──────────────────────────────────────────────── // ── Sound-Bereich-Werkzeug ────────────────────────────────────────────────
@@ -398,19 +417,38 @@ public class SharedInput {
/** JavaFX → JME: Selektierten Sound-Bereich löschen. */ /** JavaFX → JME: Selektierten Sound-Bereich löschen. */
public volatile boolean deleteSoundAreaRequested = false; public volatile boolean deleteSoundAreaRequested = false;
// ── Musik-Bereich-Werkzeug ─────────────────────────────────────────────── // ── Bereich-Werkzeug (Area) ───────────────────────────────────────────────
public record MusicAreaClick(float screenX, float screenY, boolean rightButton) {} public record AreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<MusicAreaClick> musicAreaClickQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<AreaClick> areaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Musik-Bereichs. Format: "idx|dayTrack|nightTrack|combatTrack" oder null. */ /** JME → JavaFX: Info des selektierten Bereichs. Format: "idx|nameId|dayTrack|nightTrack|combatTrack" oder null. */
public volatile String selectedMusicAreaInfo = null; public volatile String selectedAreaInfo = null;
public volatile boolean musicAreaSelectionChanged = false; 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. */ /** JavaFX → JME: aktualisierte Parameter des selektierten Bereichs. */
public final AtomicReference<de.blight.common.PlacedMusicArea> pendingMusicArea = new AtomicReference<>(); public final AtomicReference<de.blight.common.PlacedArea> pendingArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Musik-Bereich löschen. */ /** JavaFX → JME: Selektierten Bereich löschen. */
public volatile boolean deleteMusicAreaRequested = false; 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<LocationZoneClick> 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<de.blight.common.PlacedLocationZone> pendingLocationZone = new AtomicReference<>();
/** JavaFX → JME: Selektierte Location-Zone löschen. */
public volatile boolean deleteLocationZoneRequested = false;
/** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */ /** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */
public volatile boolean cancelZoneDrawing = false; public volatile boolean cancelZoneDrawing = false;
@@ -518,4 +556,30 @@ public class SharedInput {
} }
public final ConcurrentLinkedQueue<ModelConvertRequest> modelConvertQueue = public final ConcurrentLinkedQueue<ModelConvertRequest> modelConvertQueue =
new ConcurrentLinkedQueue<>(); 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;
} }

View File

@@ -12,20 +12,21 @@ import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer; import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad; import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils; import com.jme3.util.BufferUtils;
import de.blight.common.PlacedMusicArea; import de.blight.common.PlacedArea;
import de.blight.editor.SharedInput; import de.blight.editor.SharedInput;
import java.nio.FloatBuffer; import java.nio.FloatBuffer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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 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_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_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_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 final SharedInput input;
private SimpleApplication app; private SimpleApplication app;
@@ -34,7 +35,7 @@ public class MusicAreaState extends BaseAppState {
private Node rootNode; private Node rootNode;
private TerrainQuad terrain; private TerrainQuad terrain;
private final List<PlacedMusicArea> areas = new ArrayList<>(); private final List<PlacedArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>(); private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1; private int selectedIdx = -1;
@@ -44,9 +45,9 @@ public class MusicAreaState extends BaseAppState {
private Geometry inProgGeo = null; private Geometry inProgGeo = null;
private Geometry lastPointMarker = null; private Geometry lastPointMarker = null;
private List<PlacedMusicArea> pendingAreas = null; private List<PlacedArea> pendingAreas = null;
public MusicAreaState(SharedInput input) { public AreaState(SharedInput input) {
this.input = input; this.input = input;
} }
@@ -78,17 +79,17 @@ public class MusicAreaState extends BaseAppState {
@Override @Override
public void update(float tpf) { public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_MUSIC_AREAS) { if (input.activeLayer != SharedInput.LAYER_AREAS) {
if (placing) cancelPoly(); if (placing) cancelPoly();
return; return;
} }
SharedInput.MusicAreaClick click; SharedInput.AreaClick click;
while ((click = input.musicAreaClickQueue.poll()) != null) { while ((click = input.areaClickQueue.poll()) != null) {
handleClick(click); handleClick(click);
} }
PlacedMusicArea pending = input.pendingMusicArea.getAndSet(null); PlacedArea pending = input.pendingArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) { if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending); applyProperty(selectedIdx, pending);
} }
@@ -98,15 +99,15 @@ public class MusicAreaState extends BaseAppState {
if (placing) cancelPoly(); if (placing) cancelPoly();
} }
if (input.deleteMusicAreaRequested) { if (input.deleteAreaRequested) {
input.deleteMusicAreaRequested = false; input.deleteAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx); if (selectedIdx >= 0) removeArea(selectedIdx);
} }
} }
// Click handling // Click handling
private void handleClick(SharedInput.MusicAreaClick click) { private void handleClick(SharedInput.AreaClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX; float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
@@ -146,7 +147,7 @@ public class MusicAreaState extends BaseAppState {
updateInProgressGeo(); updateInProgressGeo();
} else { } else {
for (int i = 0; i < areas.size(); i++) { 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())) { if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i); selectArea(i);
return; return;
@@ -165,7 +166,7 @@ public class MusicAreaState extends BaseAppState {
private float[] snapVertex(float x, float z) { private float[] snapVertex(float x, float z) {
float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST; float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST;
float bx = x, bz = z; float bx = x, bz = z;
for (PlacedMusicArea a : areas) { for (PlacedArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) { for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i]; float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i]; float dz = z - a.pointsZ()[i];
@@ -186,7 +187,15 @@ public class MusicAreaState extends BaseAppState {
if (currX.size() < 3) { cancelPoly(); return; } if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX); float[] xs = toArray(currX);
float[] zs = toArray(currZ); 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); addArea(area);
selectArea(areas.size() - 1); selectArea(areas.size() - 1);
cancelPoly(); cancelPoly();
@@ -204,7 +213,7 @@ public class MusicAreaState extends BaseAppState {
if (inProgGeo != null) rootNode.detachChild(inProgGeo); if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size(); int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; } 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); rootNode.attachChild(inProgGeo);
updateLastPointMarker(); updateLastPointMarker();
} }
@@ -232,7 +241,7 @@ public class MusicAreaState extends BaseAppState {
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f)); mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f); mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("music_lastpoint", mesh); lastPointMarker = new Geometry("area_lastpoint", mesh);
lastPointMarker.setMaterial(mat); lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker); rootNode.attachChild(lastPointMarker);
} }
@@ -253,23 +262,23 @@ public class MusicAreaState extends BaseAppState {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL); areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
} }
selectedIdx = -1; selectedIdx = -1;
input.selectedMusicAreaInfo = null; input.selectedAreaInfo = null;
input.musicAreaSelectionChanged = true; input.areaSelectionChanged = true;
} }
private void publishSelection(int idx) { private void publishSelection(int idx) {
PlacedMusicArea a = areas.get(idx); PlacedArea a = areas.get(idx);
input.selectedMusicAreaInfo = idx + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack(); input.selectedAreaInfo = idx + "|" + a.nameId() + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack();
input.musicAreaSelectionChanged = true; input.areaSelectionChanged = true;
} }
// Add / Remove / Apply // Add / Remove / Apply
private void addArea(PlacedMusicArea area) { private void addArea(PlacedArea area) {
areas.add(area); areas.add(area);
List<Float> xs = toList(area.pointsX()); List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ()); List<Float> 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); rootNode.attachChild(geo);
areaGeos.add(geo); areaGeos.add(geo);
} }
@@ -279,8 +288,8 @@ public class MusicAreaState extends BaseAppState {
areas.remove(idx); areas.remove(idx);
areaGeos.remove(idx); areaGeos.remove(idx);
selectedIdx = -1; selectedIdx = -1;
input.selectedMusicAreaInfo = null; input.selectedAreaInfo = null;
input.musicAreaSelectionChanged = true; input.areaSelectionChanged = true;
} }
private void clearAll() { private void clearAll() {
@@ -291,12 +300,12 @@ public class MusicAreaState extends BaseAppState {
selectedIdx = -1; selectedIdx = -1;
} }
private void applyProperty(int idx, PlacedMusicArea updated) { private void applyProperty(int idx, PlacedArea updated) {
if (updated.pointsX().length == 0) { if (updated.pointsX().length == 0) {
PlacedMusicArea existing = areas.get(idx); PlacedArea existing = areas.get(idx);
areas.set(idx, new PlacedMusicArea( areas.set(idx, new PlacedArea(
existing.pointsX(), existing.pointsZ(), existing.pointsX(), existing.pointsZ(),
updated.dayTrack(), updated.nightTrack(), updated.combatTrack())); updated.nameId(), updated.dayTrack(), updated.nightTrack(), updated.combatTrack()));
} else { } else {
areas.set(idx, updated); areas.set(idx, updated);
} }
@@ -327,19 +336,60 @@ public class MusicAreaState extends BaseAppState {
return geo; 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 // Save / Load
public List<PlacedMusicArea> getPlacedAreas() { public List<PlacedArea> getPlacedAreas() {
return new ArrayList<>(areas); return new ArrayList<>(areas);
} }
public void loadAreas(List<PlacedMusicArea> loaded) { public void loadAreas(List<PlacedArea> loaded) {
if (rootNode == null) { if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded); pendingAreas = new ArrayList<>(loaded);
return; return;
} }
clearAll(); clearAll();
for (PlacedMusicArea a : loaded) addArea(a); for (PlacedArea a : loaded) addArea(a);
} }
// Helpers // Helpers

View File

@@ -546,6 +546,8 @@ public class EzTreeState extends BaseAppState {
private void exportTree(Node treeNode, String fileName, String subPath) { private void exportTree(Node treeNode, String fileName, String subPath) {
try { try {
treeNode.setLocalScale(0.33f);
treeNode.updateGeometricState();
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath); Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
Files.createDirectories(baseDir); Files.createDirectories(baseDir);
File out = baseDir.resolve(fileName + ".j3o").toFile(); File out = baseDir.resolve(fileName + ".j3o").toFile();

View File

@@ -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<GrassVertexBlade>[] 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<GrassVertexBlade> getAllBlades() {
List<GrassVertexBlade> all = new ArrayList<>();
for (List<GrassVertexBlade> 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<GrassVertexBlade> 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<GrassVertexBlade> 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<GrassVertexBlade> 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;
}
}

View File

@@ -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<PlacedLocationZone> zones = new ArrayList<>();
private final List<Geometry> zoneGeos = new ArrayList<>();
private int selectedIdx = -1;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedLocationZone> 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<Float> xs = toList(zone.pointsX());
List<Float> 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<Float> xs, List<Float> 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<PlacedLocationZone> getPlacedZones() {
return new ArrayList<>(zones);
}
public void loadZones(List<PlacedLocationZone> loaded) {
if (rootNode == null) {
pendingZones = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedLocationZone z : loaded) addZone(z);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> 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<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -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; }
}

View File

@@ -35,9 +35,8 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Editor-State für das Fluss-Werkzeug. * Editor-State für das Wasserfall-Werkzeug.
* Erlaubt das interaktive Platzieren von Fluss-Kontrollpunkten auf dem Terrain * Erlaubt das interaktive Platzieren von Wasserfall-Kontrollpunkten auf dem Terrain.
* und zeigt eine Live-Ribbon-Vorschau.
*/ */
public class RiverEditorState extends BaseAppState { public class RiverEditorState extends BaseAppState {
@@ -52,19 +51,16 @@ public class RiverEditorState extends BaseAppState {
private Node rootNode; private Node rootNode;
private TerrainQuad terrain; private TerrainQuad terrain;
// ── Zustand ───────────────────────────────────────────────────────────────
private final List<List<RiverPoint>> rivers = new ArrayList<>(); private final List<List<RiverPoint>> rivers = new ArrayList<>();
private final List<List<Geometry>> pointGeos = new ArrayList<>(); private final List<List<Geometry>> pointGeos = new ArrayList<>();
private final List<Geometry> ribbonGeos = new ArrayList<>(); private final List<Geometry> ribbonGeos = new ArrayList<>();
private int activeRiver = -1; // -1 = kein aktiver Fluss (wird gebaut) private int activeRiver = -1;
private int selectedRiver = -1; // -1 = keine Selektion private int selectedRiver = -1;
public RiverEditorState(SharedInput input) { public RiverEditorState(SharedInput input) {
this.input = input; this.input = input;
} }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
protected void initialize(Application app) { protected void initialize(Application app) {
this.app = (SimpleApplication) app; this.app = (SimpleApplication) app;
@@ -76,76 +72,48 @@ public class RiverEditorState extends BaseAppState {
List<List<RiverPoint>> saved = RiverIO.load(); List<List<RiverPoint>> saved = RiverIO.load();
if (!saved.isEmpty()) loadPlacedRivers(saved); if (!saved.isEmpty()) loadPlacedRivers(saved);
} catch (Exception e) { } catch (Exception e) {
log.error("Flüsse nicht ladbar", e); log.error("Wasserfälle nicht ladbar", e);
} }
} }
@Override @Override
protected void cleanup(Application app) { protected void cleanup(Application app) { clearAll(); }
clearAll();
}
@Override @Override
protected void onEnable() { protected void onEnable() { setCullHintAll(Spatial.CullHint.Inherit); }
setCullHintAll(Spatial.CullHint.Inherit);
}
@Override @Override
protected void onDisable() { protected void onDisable() { setCullHintAll(Spatial.CullHint.Always); }
setCullHintAll(Spatial.CullHint.Always);
}
// ── Terrain ─────────────────────────────────────────────────────────────── public void setTerrain(TerrainQuad t) { this.terrain = t; }
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 ────────────────────────────────────────────────────────────────
@Override @Override
public void update(float tpf) { 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.undoWaterfallPointRequested) {
if (input.undoRiverPointRequested) { input.undoWaterfallPointRequested = false;
input.undoRiverPointRequested = false;
undoLastPoint(); undoLastPoint();
} }
// Selektierten Fluss löschen if (input.deleteWaterfallRequested) {
if (input.deleteRiverRequested) { input.deleteWaterfallRequested = false;
input.deleteRiverRequested = false;
if (selectedRiver >= 0) { if (selectedRiver >= 0) {
removeRiver(selectedRiver); removeRiver(selectedRiver);
selectedRiver = -1; selectedRiver = -1;
input.selectedRiverInfo = null; input.selectedWaterfallInfo = null;
input.riverSelectionChanged = true; input.waterfallSelectionChanged = true;
} }
} }
// Click-Queue verarbeiten SharedInput.WaterfallClick click;
SharedInput.RiverClick click; while ((click = input.waterfallClickQueue.poll()) != null) {
while ((click = input.riverClickQueue.poll()) != null) {
handleClick(click); handleClick(click);
} }
} }
// ── Click-Verarbeitung ──────────────────────────────────────────────────── private void handleClick(SharedInput.WaterfallClick click) {
private void handleClick(SharedInput.RiverClick click) {
if (click.rightButton()) { if (click.rightButton()) {
// Rechtsklick: aktiven Fluss abschließen
finalizeActiveRiver(); finalizeActiveRiver();
return; return;
} }
@@ -165,26 +133,20 @@ public class RiverEditorState extends BaseAppState {
Vector3f hit = hits.getClosestCollision().getContactPoint(); Vector3f hit = hits.getClosestCollision().getContactPoint();
// Kein aktiver Fluss: prüfen ob ein bestehender Fluss in der Nähe liegt → selektieren
if (activeRiver < 0) { if (activeRiver < 0) {
int nearby = findNearestRiver(hit, 8f); int nearby = findNearestRiver(hit, 8f);
if (nearby >= 0) { if (nearby >= 0) {
selectRiver(nearby); selectRiver(nearby);
return; return;
} }
// Klick ins Leere → Selektion aufheben
selectRiver(-1); selectRiver(-1);
} }
float w = input.riverNewWidth; RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, input.waterfallNewWidth, RiverPoint.WATERFALL_SPEED);
float speed = input.riverNewSpeed;
RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, w, speed);
addPoint(pt); addPoint(pt);
} }
private void addPoint(RiverPoint pt) { private void addPoint(RiverPoint pt) {
// Neuen Fluss starten wenn kein aktiver
if (activeRiver < 0 || activeRiver >= rivers.size()) { if (activeRiver < 0 || activeRiver >= rivers.size()) {
rivers.add(new ArrayList<>()); rivers.add(new ArrayList<>());
pointGeos.add(new ArrayList<>()); pointGeos.add(new ArrayList<>());
@@ -192,38 +154,24 @@ public class RiverEditorState extends BaseAppState {
activeRiver = rivers.size() - 1; activeRiver = rivers.size() - 1;
} }
List<RiverPoint> 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); rivers.get(activeRiver).add(pt);
// Kontrollpunkt-Geo
Geometry sphere = buildPointGeo(pt); Geometry sphere = buildPointGeo(pt);
rootNode.attachChild(sphere); rootNode.attachChild(sphere);
pointGeos.get(activeRiver).add(sphere); pointGeos.get(activeRiver).add(sphere);
// Ribbon neu aufbauen
rebuildActiveRibbon(); rebuildActiveRibbon();
} }
private void finalizeActiveRiver() { private void finalizeActiveRiver() {
if (activeRiver >= 0 && activeRiver < rivers.size()) { if (activeRiver >= 0 && activeRiver < rivers.size()) {
if (rivers.get(activeRiver).size() < 2) { if (rivers.get(activeRiver).size() < 2) {
removeRiver(activeRiver); // löst intern reapplyAllRivers aus removeRiver(activeRiver);
activeRiver = -1; activeRiver = -1;
return; return;
} }
} }
activeRiver = -1; activeRiver = -1;
// Terrain erst jetzt graben — vollständiger Spline, non-destructive
if (terrainEditor != null) {
terrainEditor.reapplyAllRivers(getPlacedRivers());
}
} }
private void undoLastPoint() { private void undoLastPoint() {
@@ -244,12 +192,9 @@ public class RiverEditorState extends BaseAppState {
} }
} }
// ── Ribbon-Vorschau ───────────────────────────────────────────────────────
private void rebuildActiveRibbon() { private void rebuildActiveRibbon() {
if (activeRiver < 0 || activeRiver >= rivers.size()) return; if (activeRiver < 0 || activeRiver >= rivers.size()) return;
// Altes Ribbon entfernen
Geometry old = ribbonGeos.get(activeRiver); Geometry old = ribbonGeos.get(activeRiver);
if (old != null) rootNode.detachChild(old); if (old != null) rootNode.detachChild(old);
@@ -264,14 +209,8 @@ public class RiverEditorState extends BaseAppState {
rootNode.attachChild(ribbon); rootNode.attachChild(ribbon);
} }
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Lädt bereits gespeicherte Flüsse und baut deren Visualisierungen auf.
*/
public void loadPlacedRivers(List<List<RiverPoint>> loaded) { public void loadPlacedRivers(List<List<RiverPoint>> loaded) {
clearAll(); clearAll();
log.info("Lade {} Fluss/Flüsse aus Datei", loaded.size());
for (List<RiverPoint> river : loaded) { for (List<RiverPoint> river : loaded) {
if (river == null || river.isEmpty()) continue; if (river == null || river.isEmpty()) continue;
int idx = rivers.size(); int idx = rivers.size();
@@ -294,28 +233,19 @@ public class RiverEditorState extends BaseAppState {
activeRiver = -1; activeRiver = -1;
} }
/**
* Gibt eine Kopie der aktuell platzierten Flüsse zurück.
*/
public List<List<RiverPoint>> getPlacedRivers() { public List<List<RiverPoint>> getPlacedRivers() {
List<List<RiverPoint>> copy = new ArrayList<>(); List<List<RiverPoint>> copy = new ArrayList<>();
for (List<RiverPoint> river : rivers) { for (List<RiverPoint> river : rivers) {
if (river != null && river.size() >= 2) { if (river != null && river.size() >= 2) copy.add(new ArrayList<>(river));
copy.add(new ArrayList<>(river));
}
} }
return copy; return copy;
} }
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void clearAll() { private void clearAll() {
for (List<Geometry> geos : pointGeos) { for (List<Geometry> geos : pointGeos)
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g); if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
} for (Geometry ribbon : ribbonGeos)
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) rootNode.detachChild(ribbon); if (ribbon != null) rootNode.detachChild(ribbon);
}
rivers.clear(); rivers.clear();
pointGeos.clear(); pointGeos.clear();
ribbonGeos.clear(); ribbonGeos.clear();
@@ -335,32 +265,28 @@ public class RiverEditorState extends BaseAppState {
else if (activeRiver == idx) activeRiver = -1; else if (activeRiver == idx) activeRiver = -1;
if (selectedRiver > idx) selectedRiver--; if (selectedRiver > idx) selectedRiver--;
else if (selectedRiver == idx) selectedRiver = -1; 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) { private void selectRiver(int idx) {
if (selectedRiver == idx) return; if (selectedRiver == idx) return;
// Altes Highlight zurücksetzen
if (selectedRiver >= 0 && selectedRiver < ribbonGeos.size()) { if (selectedRiver >= 0 && selectedRiver < ribbonGeos.size()) {
Geometry old = ribbonGeos.get(selectedRiver); Geometry old = ribbonGeos.get(selectedRiver);
if (old != null) 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; selectedRiver = idx;
if (idx >= 0 && idx < rivers.size()) { if (idx >= 0 && idx < rivers.size()) {
Geometry ribbon = ribbonGeos.get(idx); Geometry ribbon = ribbonGeos.get(idx);
if (ribbon != null) 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<RiverPoint> pts = rivers.get(idx); List<RiverPoint> pts = rivers.get(idx);
float len = computeLength(pts); 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 { } else {
input.selectedRiverInfo = null; input.selectedWaterfallInfo = null;
} }
input.riverSelectionChanged = true; input.waterfallSelectionChanged = true;
} }
private static float computeLength(List<RiverPoint> pts) { private static float computeLength(List<RiverPoint> pts) {
@@ -373,7 +299,6 @@ public class RiverEditorState extends BaseAppState {
return len; 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) { private int findNearestRiver(Vector3f worldPos, float threshold) {
float minDist = threshold * threshold; float minDist = threshold * threshold;
int best = -1; int best = -1;
@@ -391,25 +316,18 @@ public class RiverEditorState extends BaseAppState {
} }
private void setCullHintAll(Spatial.CullHint hint) { private void setCullHintAll(Spatial.CullHint hint) {
for (List<Geometry> geos : pointGeos) { for (List<Geometry> geos : pointGeos)
if (geos != null) for (Geometry g : geos) g.setCullHint(hint); if (geos != null) for (Geometry g : geos) g.setCullHint(hint);
} for (Geometry ribbon : ribbonGeos)
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) ribbon.setCullHint(hint); if (ribbon != null) ribbon.setCullHint(hint);
} }
}
private Geometry buildPointGeo(RiverPoint pt) { 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); float r = Math.max(0.4f, pt.width() * 0.5f);
Sphere sphere = new Sphere(10, 10, r); 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"); 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)); 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.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
geo.setQueueBucket(RenderQueue.Bucket.Transparent); geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setShadowMode(RenderQueue.ShadowMode.Off); geo.setShadowMode(RenderQueue.ShadowMode.Off);
@@ -418,9 +336,6 @@ public class RiverEditorState extends BaseAppState {
return geo; return geo;
} }
/**
* Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau).
*/
Geometry buildRibbon(List<RiverPoint> pts) { Geometry buildRibbon(List<RiverPoint> pts) {
pts = RiverSpline.subdivide(pts); pts = RiverSpline.subdivide(pts);
int n = pts.size(); int n = pts.size();
@@ -437,11 +352,8 @@ public class RiverEditorState extends BaseAppState {
float[] arcLen = new float[n]; float[] arcLen = new float[n];
arcLen[0] = 0f; arcLen[0] = 0f;
for (int i = 1; i < n; i++) { for (int i = 1; i < n; i++) {
RiverPoint a = pts.get(i - 1); RiverPoint a = pts.get(i - 1), b = pts.get(i);
RiverPoint b = pts.get(i); float dx = b.x() - a.x(), dz = b.z() - a.z(), dy = b.y() - a.y();
float dx = b.x() - a.x();
float dz = b.z() - a.z();
float dy = b.y() - a.y();
arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz); 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); RiverPoint prev = pts.get(n - 2);
tangent = new Vector3f(pt.x() - prev.x(), pt.y() - prev.y(), pt.z() - prev.z()); tangent = new Vector3f(pt.x() - prev.x(), pt.y() - prev.y(), pt.z() - prev.z());
} else { } else {
RiverPoint prev = pts.get(i - 1); RiverPoint prev = pts.get(i - 1), next = pts.get(i + 1);
RiverPoint next = pts.get(i + 1);
tangent = new Vector3f(next.x() - prev.x(), next.y() - prev.y(), next.z() - prev.z()); 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); 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); if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f; 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); pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f); norm.put(0f).put(1f).put(0f);
@@ -478,11 +389,10 @@ public class RiverEditorState extends BaseAppState {
} }
for (int i = 0; i < n - 1; i++) { 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(v1).put(v3);
idx.put(v0).put(v3).put(v2); idx.put(v0).put(v3).put(v2);
} }
pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind(); pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind();
Mesh mesh = new Mesh(); Mesh mesh = new Mesh();
@@ -493,9 +403,9 @@ public class RiverEditorState extends BaseAppState {
mesh.updateBound(); mesh.updateBound();
mesh.updateCounts(); mesh.updateCounts();
Geometry geo = new Geometry("riverRibbon", mesh); Geometry geo = new Geometry("waterfallRibbon", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); 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().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off); mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent); geo.setQueueBucket(RenderQueue.Bucket.Transparent);

View File

@@ -538,12 +538,43 @@ public class SceneObjectState extends BaseAppState {
// ── Objekt platzieren ──────────────────────────────────────────────────── // ── Objekt platzieren ────────────────────────────────────────────────────
private void placeObject(String modelPath, float wx, float wz, float wy, float rotY) { 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.setRotation(0f, rotY, 0f);
so.setScale(defaultScale);
so.castShadow = defaultCast;
so.receiveShadow = defaultReceive;
objects.add(so); objects.add(so);
animClips.add(""); animClips.add("");
Node node = loadModelNode(modelPath, wx, wy, wz); Node node = loadModelNode(modelPath, wx, wy + placementOffY, wz);
node.setLocalScale(defaultScale);
if (rotY != 0f) { if (rotY != 0f) {
Quaternion q = new Quaternion(); Quaternion q = new Quaternion();
q.fromAngleAxis(rotY, Vector3f.UNIT_Y); q.fromAngleAxis(rotY, Vector3f.UNIT_Y);

View File

@@ -27,8 +27,18 @@ import com.jme3.util.BufferUtils;
import com.jme3.util.SkyFactory; import com.jme3.util.SkyFactory;
import de.blight.common.EmitterIO; import de.blight.common.EmitterIO;
import de.blight.common.GrassTuftIO; import de.blight.common.GrassTuftIO;
import de.blight.common.GrassVertexIO;
import de.blight.common.LightIO; 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.SoundAreaIO;
import de.blight.common.WaterBodyIO; import de.blight.common.WaterBodyIO;
import de.blight.common.MapData; import de.blight.common.MapData;
@@ -50,15 +60,8 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.Set;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
public class TerrainEditorState extends BaseAppState { public class TerrainEditorState extends BaseAppState {
@@ -89,22 +92,29 @@ public class TerrainEditorState extends BaseAppState {
private TerrainQuad terrain; private TerrainQuad terrain;
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten 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<Integer> modifiedVertices = new HashSet<>(); // Alle durch Flüsse veränderten Vertices
private Geometry brushIndicator; private Geometry brushIndicator;
private Geometry livePlayerMarker; private Geometry livePlayerMarker;
private PlacedObjectState placedObjectState; private PlacedObjectState placedObjectState;
private GrassVertexState grassVertexState;
private SceneObjectState sceneObjState; private SceneObjectState sceneObjState;
private LightState lightState; private LightState lightState;
private EmitterState emitterState; private EmitterState emitterState;
private WaterBodyState waterBodyState; private WaterBodyState waterBodyState;
private SoundAreaState soundAreaState; private SoundAreaState soundAreaState;
private MusicAreaState musicAreaState; private AreaState areaState;
private LocationZoneState locationZoneState;
private RiverEditorState riverEditorState; private RiverEditorState riverEditorState;
private MapData loadedMapData; private MapData loadedMapData;
private Node axesGizmo; 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) ───────────────────────────────────────────────── // ── Splatmap (Slots 1-4) ─────────────────────────────────────────────────
private byte[] splatR, splatG, splatB, splatA; private byte[] splatR, splatG, splatB, splatA;
@@ -205,8 +215,7 @@ public class TerrainEditorState extends BaseAppState {
private void buildScene() { private void buildScene() {
input.loadingStatus = "Lade Terrain..."; input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain(); terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder cachedHeightMap = terrain.getHeightMap();
originalHeightMap = cachedHeightMap.clone(); // Snapshot vor allen Fluss-Modifikationen
rootNode.attachChild(terrain); rootNode.attachChild(terrain);
input.loadingStatus = "Lade platzierte Objekte..."; input.loadingStatus = "Lade platzierte Objekte...";
@@ -214,6 +223,11 @@ public class TerrainEditorState extends BaseAppState {
placedObjectState.setTerrain(terrain); placedObjectState.setTerrain(terrain);
app.getStateManager().attach(placedObjectState); 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); sceneObjState = app.getStateManager().getState(SceneObjectState.class);
if (sceneObjState != null) { if (sceneObjState != null) {
sceneObjState.setTerrain(terrain); sceneObjState.setTerrain(terrain);
@@ -253,7 +267,6 @@ public class TerrainEditorState extends BaseAppState {
waterBodyState = app.getStateManager().getState(WaterBodyState.class); waterBodyState = app.getStateManager().getState(WaterBodyState.class);
if (waterBodyState != null) { if (waterBodyState != null) {
waterBodyState.setTerrain(terrain); waterBodyState.setTerrain(terrain);
waterBodyState.setHeightMap(cachedHeightMap);
try { try {
var waters = WaterBodyIO.load(); var waters = WaterBodyIO.load();
if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters); if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters);
@@ -274,22 +287,33 @@ public class TerrainEditorState extends BaseAppState {
} }
} }
input.loadingStatus = "Lade Musikbereiche..."; input.loadingStatus = "Lade Bereiche...";
musicAreaState = app.getStateManager().getState(MusicAreaState.class); areaState = app.getStateManager().getState(AreaState.class);
if (musicAreaState != null) { if (areaState != null) {
musicAreaState.setTerrain(terrain); areaState.setTerrain(terrain);
try { try {
var musicAreas = MusicAreaIO.load(); var areas = AreaIO.load();
if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas); if (!areas.isEmpty()) areaState.loadAreas(areas);
} catch (IOException e) { } 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); riverEditorState = app.getStateManager().getState(RiverEditorState.class);
if (riverEditorState != null) { if (riverEditorState != null) {
riverEditorState.setTerrain(terrain); riverEditorState.setTerrain(terrain);
riverEditorState.setTerrainEditor(this);
} }
input.loadingStatus = "Baue Szene..."; input.loadingStatus = "Baue Szene...";
@@ -401,28 +425,8 @@ public class TerrainEditorState extends BaseAppState {
upperSplatA = new byte[SPLAT_SIZE * SPLAT_SIZE]; upperSplatA = new byte[SPLAT_SIZE * SPLAT_SIZE];
} }
// Snapshot vor allen Fluss-Modifikationen // A-Kanal aus alten Saves bereinigen (war für Fluss-Textur; nicht mehr verwendet)
// Fluss-Farbe aus dem A-Kanal rückrechnen: A-Kanal ist ausschließlich für Flüsse. Arrays.fill(splatA, (byte) 0);
// 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;
}
}
splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4); splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4);
for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) { for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) {
@@ -694,6 +698,14 @@ public class TerrainEditorState extends BaseAppState {
terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode); 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 // Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
|| input.upperTexturesChanged || input.upperNormalMapsChanged) { || input.upperTexturesChanged || input.upperNormalMapsChanged) {
@@ -712,6 +724,48 @@ public class TerrainEditorState extends BaseAppState {
private static final int MAX_EDITS_PER_FRAME = 2; 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() { private void processTextureEdits() {
SharedInput.TextureEdit edit; SharedInput.TextureEdit edit;
int processed = 0; int processed = 0;
@@ -754,526 +808,99 @@ public class TerrainEditorState extends BaseAppState {
return h != null ? h : 0f; 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<List<RiverPoint>> allRivers) {
if (terrain == null || cachedHeightMap == null) return;
// 1. Alle zuvor durch Flüsse veränderten Vertices auf Original zurücksetzen
if (!modifiedVertices.isEmpty()) {
List<Vector2f> resetLocs = new ArrayList<>(modifiedVertices.size());
List<Float> 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<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> bankTargets = new HashMap<>();
List<List<RiverPoint>> allSplined = new ArrayList<>(allRivers.size());
for (List<RiverPoint> river : allRivers) {
if (river == null || river.size() < 2) continue;
List<RiverPoint> 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<RiverPoint> 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<RiverPoint> splined,
HashMap<Integer, Float> channelTargets,
HashMap<Integer, Float> 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<Integer, Float> channelTargets) {
List<Vector2f> locs = new ArrayList<>(channelTargets.size());
List<Float> deltas = new ArrayList<>(channelTargets.size());
for (Map.Entry<Integer, Float> 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<Integer, Float> bankTargets, Set<Integer> channelVerts) {
List<Vector2f> locs = new ArrayList<>(bankTargets.size());
List<Float> deltas = new ArrayList<>(bankTargets.size());
for (Map.Entry<Integer, Float> 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<RiverPoint> controlPts) {
if (terrain == null || cachedHeightMap == null || controlPts.size() < 2) return;
List<RiverPoint> splined = RiverSpline.subdivide(controlPts);
HashMap<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> 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<RiverPoint> 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<Vector2f> locs = new ArrayList<>();
List<Float> 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 ───────────────────────────────────────────────────────────── // ── Speichern ─────────────────────────────────────────────────────────────
private void performSave() { private void performSave() {
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<PlacedModel> models = sceneObjState != null ? sceneObjState.getPlacedModels() : null;
final List<PlacedLight> lights = lightState != null ? lightState.getPlacedLights() : null;
final List<PlacedEmitter> emitters = emitterState != null ? emitterState.getPlacedEmitters() : null;
final List<PlacedWater> waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null;
final List<List<RiverPoint>> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null;
final List<PlacedSoundArea> soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null;
final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null;
final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null;
// ── Schwere Arbeit (Upsample + Datei-I/O) auf Hintergrund-Thread ─────
saveExecutor.submit(() -> {
try { try {
MapData data = new MapData(); MapData data = new MapData();
// Post-River-Terrain speichern: Das Spiel erhält korrekt eingegrabene Flussbetten. if (heightSnap != null) {
// originalHeightMap bleibt in-session als Rücksetz-Basis erhalten. if (heightSnap.length == data.terrainHeight.length) {
float[] saveHeight = (cachedHeightMap != null) ? cachedHeightMap : originalHeightMap; System.arraycopy(heightSnap, 0, data.terrainHeight, 0, heightSnap.length);
if (saveHeight != null) {
if (saveHeight.length == data.terrainHeight.length) {
System.arraycopy(saveHeight, 0, data.terrainHeight, 0, saveHeight.length);
} else { } else {
upsampleHeights(saveHeight, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS); upsampleHeights(heightSnap, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
} }
} }
// Uferkante mit 0,25 m Präzision exakt auf Wasseroberfläche setzen if (snapR != null) {
if (riverEditorState != null) { System.arraycopy(snapR, 0, data.splatR, 0, data.splatR.length);
applyHighResBankLeveling(data.terrainHeight, riverEditorState.getPlacedRivers()); 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 (splatR != null) { if (grassData != null) {
// Bemalte Splatmap speichern (mit Fluss-Textur) → Spiel zeigt Fluss-Textur korrekt. try { GrassTuftIO.save(grassData); }
// Beim nächsten Laden strippt initSplatmap() die Fluss-Farbe für originalSplatX, catch (IOException e) { log.error("Gras nicht speicherbar", e); }
// 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) { if (grassVertexBlades != null) {
System.arraycopy(upperSplatR, 0, data.upperSplatR, 0, data.upperSplatR.length); try { GrassVertexIO.save(grassVertexBlades); }
System.arraycopy(upperSplatG, 0, data.upperSplatG, 0, data.upperSplatG.length); catch (IOException e) { log.error("Vertex-Gras nicht speicherbar", e); }
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); MapIO.save(data);
if (sceneObjState != null) { if (models != null) PlacedModelIO.save(models);
PlacedModelIO.save(sceneObjState.getPlacedModels()); if (lights != null) LightIO.save(lights);
} if (emitters != null) EmitterIO.save(emitters);
if (lightState != null) { if (waters != null) WaterBodyIO.save(waters);
LightIO.save(lightState.getPlacedLights()); if (rivers != null) de.blight.common.RiverIO.save(rivers);
} if (soundAreas != null) SoundAreaIO.save(soundAreas);
if (emitterState != null) { if (areas != null) AreaIO.save(areas);
EmitterIO.save(emitterState.getPlacedEmitters()); if (locationZones != null) LocationZoneIO.save(locationZones);
}
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(); input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
log.info("{}", input.saveStatusMsg); log.info("{}", input.saveStatusMsg);
} catch (IOException e) { } catch (IOException e) {
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage(); input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
log.error("Speichern fehlgeschlagen", e); 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 ─────────────────────────────────────────────────────── // ── Brush-Indikator ───────────────────────────────────────────────────────
@@ -1287,7 +914,8 @@ public class TerrainEditorState extends BaseAppState {
if (layer == SharedInput.LAYER_OBJECTS || layer == SharedInput.LAYER_OBJECTS_EDIT if (layer == SharedInput.LAYER_OBJECTS || layer == SharedInput.LAYER_OBJECTS_EDIT
|| layer == SharedInput.LAYER_LIGHTS || layer == SharedInput.LAYER_EMITTERS || layer == SharedInput.LAYER_LIGHTS || layer == SharedInput.LAYER_EMITTERS
|| layer == SharedInput.LAYER_WATER || 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) { || layer == SharedInput.LAYER_PLAY_TOOL || mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always); brushIndicator.setCullHint(Spatial.CullHint.Always);
return; return;
@@ -1318,6 +946,13 @@ public class TerrainEditorState extends BaseAppState {
contactPoint = hits.getClosestCollision().getContactPoint(); contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (float) input.grassTool.brushRadius.getValue(); 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) { if (contactPoint != null) {
@@ -1383,10 +1018,6 @@ public class TerrainEditorState extends BaseAppState {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action(); float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode); modifyHeight(contact, delta, mode);
} }
if (terrainChanged && waterBodyState != null) {
float r = (float) input.heightTool.brushRadius.getValue();
waterBodyState.invalidateNear(contact.x, contact.z, r);
}
} }
if (processed > 0) terrain.updateModelBound(); if (processed > 0) terrain.updateModelBound();
} }
@@ -1441,6 +1072,7 @@ public class TerrainEditorState extends BaseAppState {
syncHeightCache(locs, deltas); syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); 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); syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); 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. */ /** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */
@@ -1545,6 +1178,7 @@ public class TerrainEditorState extends BaseAppState {
syncHeightCache(locs, deltas); syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas); if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius); 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) { private void updateCamera(float tpf) {
if (input.activeLayer == SharedInput.LAYER_MODEL_EDITOR) {
input.consumeMouseDelta(); // konsumieren ohne zu verarbeiten
return;
}
int[] delta = input.consumeMouseDelta(); int[] delta = input.consumeMouseDelta();
if (delta[0] != 0 || delta[1] != 0) { if (delta[0] != 0 || delta[1] != 0) {
camYaw += delta[0] * MOUSE_SENS; 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<List<RiverPoint>> 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<RiverPoint> controlPts : rivers) {
if (controlPts == null || controlPts.size() < 2) continue;
List<RiverPoint> 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;
}
}
}
}
}
} }

View File

@@ -24,22 +24,25 @@ import java.nio.IntBuffer;
import java.util.*; 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). * Bedienung:
* BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen. * 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 { public class WaterBodyState extends BaseAppState {
// Flood-Fill-Raster mit 1 WE Auflösung (= volle HeightMap-Auflösung) private static final float SNAP_DIST = 8f;
private static final int TOTAL_VERTS = 4097; private static final float LINE_OFFSET = 0.1f;
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 ColorRGBA COLOR_WATER = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.50f); 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_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 final SharedInput input;
private SimpleApplication app; private SimpleApplication app;
@@ -47,22 +50,28 @@ public class WaterBodyState extends BaseAppState {
private AssetManager assets; private AssetManager assets;
private Node rootNode; private Node rootNode;
private TerrainQuad terrain; private TerrainQuad terrain;
private float[] heightMap;
private final List<PlacedWater> bodies = new ArrayList<>(); private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Set<Integer>> cellSets = new ArrayList<>(); private final List<Geometry> fillGeos = new ArrayList<>();
private final List<Geometry> geos = new ArrayList<>(); private final List<Geometry> outlineGeos = new ArrayList<>();
private final List<float[]> bodyBounds = new ArrayList<>(); // {minX,minZ,maxX,maxZ} private final List<Geometry> flowArrowGeos = new ArrayList<>();
private int selectedIdx = -1; private int selectedIdx = -1;
// in-progress polygon
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private float currentWaterHeight = 0f;
private Geometry inProgGeo = null;
private Geometry lastMarker = null;
private Geometry pillarGeo = null;
private Geometry cursorPillar = null;
private List<PlacedWater> pendingLoad = null; private List<PlacedWater> pendingLoad = null;
public WaterBodyState(SharedInput input) { this.input = input; } public WaterBodyState(SharedInput input) { this.input = input; }
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; } public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override @Override
protected void initialize(Application application) { protected void initialize(Application application) {
@@ -85,70 +94,371 @@ public class WaterBodyState extends BaseAppState {
@Override @Override
public void update(float tpf) { 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; // Spacebar: sample terrain height at cursor
while ((click = input.waterClickQueue.poll()) != null) handleClick(click); 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); // Live cursor pillar while placing
if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight()); 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) { if (input.deleteWaterRequested) {
input.deleteWaterRequested = false; input.deleteWaterRequested = false;
if (selectedIdx >= 0) removeBody(selectedIdx); 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) { private void handleClick(SharedInput.WaterClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX; float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY; float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
if (click.rightButton()) { deselect(); return; } if (click.rightButton()) {
if (placing) {
int hit = pickBody(ray); if (!currX.isEmpty()) {
if (hit >= 0) { selectBody(hit); return; } currX.remove(currX.size() - 1);
currZ.remove(currZ.size() - 1);
updateInProgressGeo();
if (currX.isEmpty()) cancelPoly();
}
} else {
deselect();
}
return;
}
if (terrain == null) return; if (terrain == null) return;
CollisionResults hits = new CollisionResults(); CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits); terrain.collideWith(ray, hits);
if (hits.size() == 0) return; if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint(); Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
Set<Integer> cells = floodFill(pt.x, pt.z, pt.y); if (placing) {
if (cells == null) { if (currX.size() >= 3) {
input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle."; float dx = hitX - currX.get(0), dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) {
closePoly();
return; return;
} }
addBody(new PlacedWater(pt.x, pt.z, pt.y), cells); }
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();
}
}
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); selectBody(bodies.size() - 1);
cancelPoly();
} }
private int pickBody(Ray ray) { private void cancelPoly() {
for (int i = 0; i < geos.size(); i++) { placing = false;
CollisionResults res = new CollisionResults(); currX.clear();
geos.get(i).collideWith(ray, res); currZ.clear();
if (res.size() > 0) return i; if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
} if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; }
return -1; if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; }
if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; }
} }
// ── Selektion ───────────────────────────────────────────────────────────── // ── 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);
}
updateLastMarker();
updatePillarGeo();
}
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<float[]> 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) { private void selectBody(int idx) {
deselect(); deselect();
selectedIdx = idx; 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); publishSelection(idx);
} }
private void deselect() { private void deselect() {
if (selectedIdx >= 0 && selectedIdx < geos.size()) if (selectedIdx >= 0 && selectedIdx < fillGeos.size()) {
geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER); fillGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
outlineGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_OUTLINE);
}
selectedIdx = -1; selectedIdx = -1;
input.selectedWaterInfo = null; input.selectedWaterInfo = null;
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
@@ -157,171 +467,111 @@ public class WaterBodyState extends BaseAppState {
private void publishSelection(int idx) { private void publishSelection(int idx) {
PlacedWater b = bodies.get(idx); PlacedWater b = bodies.get(idx);
input.selectedWaterInfo = String.format(java.util.Locale.ROOT, input.selectedWaterInfo = String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%d", "%d|%.3f|%d|%.1f", idx, b.waterHeight(), b.pointsX().length, b.flowDegrees());
idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size());
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
} }
// ── Hinzufügen / Entfernen ──────────────────────────────────────────────── // ── Add / Remove ──────────────────────────────────────────────────────────
private void addBody(PlacedWater body, Set<Integer> cells) { private void addBody(PlacedWater body) {
Geometry geo = buildWaterGeo(cells, body.waterHeight()); int idx = bodies.size();
rootNode.attachChild(geo); 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); bodies.add(body);
cellSets.add(cells); fillGeos.add(fill);
geos.add(geo); outlineGeos.add(outline);
bodyBounds.add(computeBounds(cells)); flowArrowGeos.add(arrow);
} }
private void removeBody(int idx) { 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); bodies.remove(idx);
cellSets.remove(idx); fillGeos.remove(idx);
geos.remove(idx); outlineGeos.remove(idx);
bodyBounds.remove(idx); flowArrowGeos.remove(idx);
selectedIdx = -1; selectedIdx = -1;
input.selectedWaterInfo = null; input.selectedWaterInfo = null;
input.waterSelectionChanged = true; input.waterSelectionChanged = true;
} }
private void clearAll() { 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(); bodies.clear();
cellSets.clear(); fillGeos.clear();
geos.clear(); outlineGeos.clear();
bodyBounds.clear(); flowArrowGeos.clear();
cancelPoly();
selectedIdx = -1; 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<Integer> 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) { private void applyHeightChange(int idx, float newHeight) {
PlacedWater b = bodies.get(idx); PlacedWater b = bodies.get(idx);
Set<Integer> newCells = floodFill(b.seedX(), b.seedZ(), newHeight); PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), newHeight, b.flowDegrees());
if (newCells == null) { rootNode.detachChild(fillGeos.get(idx));
input.waterHint = "Ungültige Höhe Becken bei dieser Höhe nicht eingeschlossen."; rootNode.detachChild(outlineGeos.get(idx));
return; 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)); rootNode.attachChild(fill);
Geometry newGeo = buildWaterGeo(newCells, newHeight); rootNode.attachChild(outline);
newGeo.getMaterial().setColor("Color", COLOR_SELECTED); rootNode.attachChild(arrow);
rootNode.attachChild(newGeo); bodies.set(idx, updated);
bodies.set(idx, new PlacedWater(b.seedX(), b.seedZ(), newHeight)); fillGeos.set(idx, fill);
cellSets.set(idx, newCells); outlineGeos.set(idx, outline);
geos.set(idx, newGeo); flowArrowGeos.set(idx, arrow);
publishSelection(idx); publishSelection(idx);
} }
// ── Flood-Fill ──────────────────────────────────────────────────────────── private void applyFlowChange(int idx, float newDegrees) {
PlacedWater b = bodies.get(idx);
private Set<Integer> floodFill(float seedWorldX, float seedWorldZ, float waterHeight) { PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), b.waterHeight(), newDegrees);
int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP); bodies.set(idx, updated);
int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP); rootNode.detachChild(flowArrowGeos.get(idx));
seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX)); Geometry arrow = buildFlowArrowGeo(updated, idx);
seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ)); rootNode.attachChild(arrow);
flowArrowGeos.set(idx, arrow);
if (sampleHeight(seedPX, seedPZ) > waterHeight + 0.05f) return null; publishSelection(idx);
Set<Integer> visited = new HashSet<>();
Deque<int[]> queue = new ArrayDeque<>();
visited.add(seedPZ * WATER_GRID + seedPX);
queue.add(new int[]{seedPX, seedPZ});
final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] c = queue.poll();
int px = c[0], pz = c[1];
if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1)
return null;
for (int[] d : dirs) {
int nx = px + d[0], nz = pz + d[1];
int nIdx = nz * WATER_GRID + nx;
if (visited.contains(nIdx)) continue;
if (sampleHeight(nx, nz) <= waterHeight) {
visited.add(nIdx);
if (visited.size() > MAX_CELLS) return null;
queue.add(new int[]{nx, nz});
}
}
}
return visited.isEmpty() ? null : visited;
} }
private float sampleHeight(int px, int pz) { // ── Geometry builders ─────────────────────────────────────────────────────
if (heightMap != null) {
int vx = Math.min(px * STEP, TOTAL_VERTS - 1);
int vz = Math.min(pz * STEP, TOTAL_VERTS - 1);
return heightMap[vz * TOTAL_VERTS + vx];
}
if (terrain != null) {
float worldX = px * STEP - WORLD_HALF;
float worldZ = pz * STEP - WORLD_HALF;
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
return (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
}
return Float.MAX_VALUE;
}
// ── Mesh-Aufbau (für Editor-Vorschau) ──────────────────────────────────── private Geometry 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<Integer> 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 mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos); mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx); mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound(); 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"); Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", COLOR_WATER); mat.setColor("Color", COLOR_WATER);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
@@ -332,21 +582,61 @@ public class WaterBodyState extends BaseAppState {
return geo; return geo;
} }
// ── Speichern / Laden ───────────────────────────────────────────────────── private Geometry buildLineGeo(String name, List<Float> xs, List<Float> 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<PlacedWater> getPlacedBodies() { return new ArrayList<>(bodies); } public List<PlacedWater> getPlacedBodies() { return new ArrayList<>(bodies); }
public void loadPlacedBodies(List<PlacedWater> loaded) { public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; } if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; }
clearAll(); clearAll();
for (PlacedWater b : loaded) { for (PlacedWater b : loaded) addBody(b);
Set<Integer> 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());
} }
// ── 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<Float> 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<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
} }
} }

View File

@@ -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 grassHeight = new ToolParameter("Grashöhe", 1.5, 0.1, 10.0);
public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 200.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 @Override
public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); } public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }

View File

@@ -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<ChoiceToolParameter> getChoiceParameters() { return List.of(); }
@Override
public List<ToolParameter> getParameters() { return List.of(brushRadius, bladeHeight, density, dryness); }
}

View File

@@ -9,8 +9,8 @@ import java.util.List;
*/ */
public class TextureTool extends EditorTool { public class TextureTool extends EditorTool {
// Terrain.j3md (unlit) hat nur Tex1Tex3; Slot 0=Gras(Base), 1=Fels(R), 2=Erde(G) // Slots 1-4 = lower splatmap, Slots 5-8 = upper splatmap
public static final String[] TEXTURE_NAMES = {"Gras", "Fels", "Erde"}; public static final String[] TEXTURE_NAMES = {"S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8"};
public final ChoiceToolParameter textureIndex = new ChoiceToolParameter( public final ChoiceToolParameter textureIndex = new ChoiceToolParameter(
"Textur", TEXTURE_NAMES, 0 "Textur", TEXTURE_NAMES, 0

View File

@@ -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<CraftingTable.CraftingTableType, CraftingTable> 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<CraftingTable.CraftingTableType, CraftingTable> shared;
private final Path tableDir;
private final Runnable onSaved;
private final ListView<CraftingTable.CraftingTableType> 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<CraftingTable.CraftingTableType, CraftingTable> 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 : ""; }
}
}

View File

@@ -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<String, DialogOption> allOptions = new LinkedHashMap<>();
private final Set<String> rootIds = new LinkedHashSet<>();
private String selectedId = null;
// ── Top-bar ───────────────────────────────────────────────────────────────
private ToggleButton listBtn;
private ToggleButton graphBtn;
// ── List-mode ─────────────────────────────────────────────────────────────
private SplitPane splitPane;
private ListView<String> optionListView;
private ScrollPane detailScroll;
// ── Detail-form fields ────────────────────────────────────────────────────
private TextField labelField;
private Label idLabel;
private CheckBox rootCheck;
private Spinner<Integer> chapterSpinner;
private ComboBox<String> statusCombo;
private TextField questOpenField;
private TextField questCompleteField;
private TextField textHeroField;
private TextField textNpcField;
private TextField reqItemIdField;
private Spinner<Integer> reqItemCount;
private TextField recvItemIdField;
private Spinner<Integer> recvItemCount;
private TextField recvQuestField;
private TextField fulfillsQuestField;
private ListView<String> abortsQuestsView;
private CheckBox enablesTradeCheck;
private ListView<String> nextOptionsView;
private ListView<String> 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<DialogOption> 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<String> buildRefList() {
ListView<String> 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<QuestRef> 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<String> target) {
Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Option auswählen");
dlg.initModality(Modality.APPLICATION_MODAL);
ListView<String> 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<String> 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<String> 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<String, Integer> layer = new LinkedHashMap<>();
Queue<String> 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<Integer, List<String>> groups = new TreeMap<>();
layer.forEach((id, lyr) -> groups.computeIfAbsent(lyr, k -> new ArrayList<>()).add(id));
// Assign x/y positions
Map<String, double[]> 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<String, double[]> 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<DialogOption> 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<DialogOption> 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<DialogOption> resolveRefs(List<String> ids) {
List<DialogOption> 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<Integer> 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<Integer> 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<Integer> 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;
}
}

View File

@@ -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<Fraction> 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<Fraction> fractions;
private final Path fractionDir;
private final Runnable onSaved;
private final SortedList<Fraction> sortedFractions;
private final ListView<Fraction> 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<Fraction> 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;
}
}
}

View File

@@ -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<Item> 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<Item> 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<Item> items;
private final Path itemDir;
private final Runnable onSaved;
private final SortedList<Item> sortedItems;
private final ListView<Item> listView;
private Item current = null;
// Form fields
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private TextField nameField;
private TextField descField;
private Spinner<Integer> goldSpinner;
private TextField modelRefField;
// Form container
private VBox formContainer;
private Button deleteBtn;
ItemPanel(String title, ObservableList<Item> 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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More