Commit vor großem Terrain refactoring
25
blight-assets/src/main/resources/MatDefs/GrassVertex.j3md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
blight-assets/src/main/resources/MatDefs/Topology.j3md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
blight-assets/src/main/resources/MatDefs/WaterPolygon.j3md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
34
blight-assets/src/main/resources/Shaders/GrassVertex.frag
Normal 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);
|
||||||
|
}
|
||||||
39
blight-assets/src/main/resources/Shaders/GrassVertex.vert
Normal 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;
|
||||||
|
}
|
||||||
38
blight-assets/src/main/resources/Shaders/Topology.frag
Normal 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);
|
||||||
|
}
|
||||||
12
blight-assets/src/main/resources/Shaders/Topology.vert
Normal 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);
|
||||||
|
}
|
||||||
483
blight-assets/src/main/resources/Shaders/WaterPolygon.frag
Normal 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
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 207 KiB |
53
blight-common/src/main/java/de/blight/common/AreaIO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.blight.common;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.zip.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest und schreibt Vertex-Gras-Halme als komprimierte Binärdatei
|
||||||
|
* ({@code blight_grass_vertex.blgv}) neben der Kartendatei.
|
||||||
|
*
|
||||||
|
* Format v1: int MAGIC, int VERSION, int count, N × (float x, float y, float z, float height)
|
||||||
|
* Format v2: wie v1 + float dryness pro Halm (0=grün, 0.5–1.0=gelb/braun)
|
||||||
|
*/
|
||||||
|
public final class GrassVertexIO {
|
||||||
|
|
||||||
|
private static final int MAGIC = 0x47565458; // "GVTX"
|
||||||
|
private static final int VERSION = 2;
|
||||||
|
|
||||||
|
private GrassVertexIO() {}
|
||||||
|
|
||||||
|
public static Path getPath() {
|
||||||
|
return MapIO.getMapPath().resolveSibling("blight_grass_vertex.blgv");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void save(List<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
blight-common/src/main/java/de/blight/common/LocationIO.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
28
blight-common/src/main/java/de/blight/common/ModelMeta.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
blight-common/src/main/java/de/blight/common/PlacedArea.java
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package de.blight.common;
|
||||||
|
|
||||||
|
public record PlacedArea(
|
||||||
|
float[] pointsX,
|
||||||
|
float[] pointsZ,
|
||||||
|
String nameId,
|
||||||
|
String dayTrack,
|
||||||
|
String nightTrack,
|
||||||
|
String combatTrack
|
||||||
|
) {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,25 +11,28 @@ 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;
|
||||||
private AudioReference audioHero;
|
private AudioReference audioHero;
|
||||||
private TextReference textNpc;
|
private TextReference textNpc;
|
||||||
private AudioReference audioNpc;
|
private AudioReference audioNpc;
|
||||||
|
|
||||||
private List<DialogOption> nextOptions;
|
private List<DialogOption> nextOptions;
|
||||||
private List<DialogOption> disablesOptions;
|
private List<DialogOption> disablesOptions;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ package de.blight.common.model;
|
|||||||
|
|
||||||
public interface Interactable {
|
public interface Interactable {
|
||||||
|
|
||||||
|
public String getDisplayText();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "?");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import lombok.Setter;
|
|||||||
public class NPC extends GameCharacter {
|
public class NPC extends GameCharacter {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(NPC.class);
|
private static final Logger LOG = LoggerFactory.getLogger(NPC.class);
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
152
blight-common/src/main/java/de/blight/common/model/RecipeIO.java
Normal 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 : ""; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package de.blight.common.model.quests;
|
|
||||||
|
|
||||||
public interface QuestType {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 fü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 0–359) 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +35,19 @@ 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;
|
||||||
|
|
||||||
private boolean placing = false;
|
private boolean placing = false;
|
||||||
private final List<Float> currX = new ArrayList<>();
|
private final List<Float> currX = new ArrayList<>();
|
||||||
private final List<Float> currZ = new ArrayList<>();
|
private final List<Float> currZ = new ArrayList<>();
|
||||||
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 ───────────────────────────────────────────────────────────────
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
private int activeRiver = -1; // -1 = kein aktiver Fluss (wird gebaut)
|
private int selectedRiver = -1;
|
||||||
private int selectedRiver = -1; // -1 = keine Selektion
|
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
try {
|
if (saveInProgress) {
|
||||||
MapData data = new MapData();
|
log.warn("Speicherung noch aktiv, überspringe.");
|
||||||
|
return;
|
||||||
// Post-River-Terrain speichern: Das Spiel erhält korrekt eingegrabene Flussbetten.
|
|
||||||
// originalHeightMap bleibt in-session als Rücksetz-Basis erhalten.
|
|
||||||
float[] saveHeight = (cachedHeightMap != null) ? cachedHeightMap : originalHeightMap;
|
|
||||||
if (saveHeight != null) {
|
|
||||||
if (saveHeight.length == data.terrainHeight.length) {
|
|
||||||
System.arraycopy(saveHeight, 0, data.terrainHeight, 0, saveHeight.length);
|
|
||||||
} else {
|
|
||||||
upsampleHeights(saveHeight, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Uferkante mit 0,25 m Präzision exakt auf Wasseroberfläche setzen
|
|
||||||
if (riverEditorState != null) {
|
|
||||||
applyHighResBankLeveling(data.terrainHeight, riverEditorState.getPlacedRivers());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (splatR != null) {
|
|
||||||
// Bemalte Splatmap speichern (mit Fluss-Textur) → Spiel zeigt Fluss-Textur korrekt.
|
|
||||||
// Beim nächsten Laden strippt initSplatmap() die Fluss-Farbe für originalSplatX,
|
|
||||||
// sodass reapplyAllRivers ohne doppeltes Malen neu aufträgt.
|
|
||||||
System.arraycopy(splatR, 0, data.splatR, 0, data.splatR.length);
|
|
||||||
System.arraycopy(splatG, 0, data.splatG, 0, data.splatG.length);
|
|
||||||
System.arraycopy(splatB, 0, data.splatB, 0, data.splatB.length);
|
|
||||||
System.arraycopy(splatA, 0, data.splatA, 0, data.splatA.length);
|
|
||||||
System.arraycopy(input.terrainTexturePaths, 0,
|
|
||||||
data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
|
|
||||||
}
|
|
||||||
if (upperSplatR != null) {
|
|
||||||
System.arraycopy(upperSplatR, 0, data.upperSplatR, 0, data.upperSplatR.length);
|
|
||||||
System.arraycopy(upperSplatG, 0, data.upperSplatG, 0, data.upperSplatG.length);
|
|
||||||
System.arraycopy(upperSplatB, 0, data.upperSplatB, 0, data.upperSplatB.length);
|
|
||||||
System.arraycopy(upperSplatA, 0, data.upperSplatA, 0, data.upperSplatA.length);
|
|
||||||
System.arraycopy(input.upperTexturePaths, 0,
|
|
||||||
data.upperTextures, 0, MapData.TEXTURE_SLOTS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (placedObjectState != null) {
|
|
||||||
try {
|
|
||||||
GrassTuftIO.save(new GrassTuftIO.GrassData(
|
|
||||||
placedObjectState.getSlotPaths(),
|
|
||||||
placedObjectState.getAllTufts()));
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("Gras nicht speicherbar", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MapIO.save(data);
|
|
||||||
if (sceneObjState != null) {
|
|
||||||
PlacedModelIO.save(sceneObjState.getPlacedModels());
|
|
||||||
}
|
|
||||||
if (lightState != null) {
|
|
||||||
LightIO.save(lightState.getPlacedLights());
|
|
||||||
}
|
|
||||||
if (emitterState != null) {
|
|
||||||
EmitterIO.save(emitterState.getPlacedEmitters());
|
|
||||||
}
|
|
||||||
if (waterBodyState != null) {
|
|
||||||
WaterBodyIO.save(waterBodyState.getPlacedBodies());
|
|
||||||
}
|
|
||||||
if (riverEditorState != null) {
|
|
||||||
de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers());
|
|
||||||
}
|
|
||||||
if (soundAreaState != null) {
|
|
||||||
SoundAreaIO.save(soundAreaState.getPlacedAreas());
|
|
||||||
}
|
|
||||||
if (musicAreaState != null) {
|
|
||||||
MusicAreaIO.save(musicAreaState.getPlacedAreas());
|
|
||||||
}
|
|
||||||
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
|
|
||||||
log.info("{}", input.saveStatusMsg);
|
|
||||||
} catch (IOException e) {
|
|
||||||
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
|
|
||||||
log.error("Speichern fehlgeschlagen", e);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
MapData data = new MapData();
|
||||||
|
|
||||||
|
if (heightSnap != null) {
|
||||||
|
if (heightSnap.length == data.terrainHeight.length) {
|
||||||
|
System.arraycopy(heightSnap, 0, data.terrainHeight, 0, heightSnap.length);
|
||||||
|
} else {
|
||||||
|
upsampleHeights(heightSnap, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapR != null) {
|
||||||
|
System.arraycopy(snapR, 0, data.splatR, 0, data.splatR.length);
|
||||||
|
System.arraycopy(snapG, 0, data.splatG, 0, data.splatG.length);
|
||||||
|
System.arraycopy(snapB, 0, data.splatB, 0, data.splatB.length);
|
||||||
|
System.arraycopy(snapA, 0, data.splatA, 0, data.splatA.length);
|
||||||
|
System.arraycopy(texPaths, 0, data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
|
||||||
|
}
|
||||||
|
if (upR != null) {
|
||||||
|
System.arraycopy(upR, 0, data.upperSplatR, 0, data.upperSplatR.length);
|
||||||
|
System.arraycopy(upG, 0, data.upperSplatG, 0, data.upperSplatG.length);
|
||||||
|
System.arraycopy(upB, 0, data.upperSplatB, 0, data.upperSplatB.length);
|
||||||
|
System.arraycopy(upA, 0, data.upperSplatA, 0, data.upperSplatA.length);
|
||||||
|
System.arraycopy(upperPaths, 0, data.upperTextures, 0, MapData.TEXTURE_SLOTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grassData != null) {
|
||||||
|
try { GrassTuftIO.save(grassData); }
|
||||||
|
catch (IOException e) { log.error("Gras nicht speicherbar", e); }
|
||||||
|
}
|
||||||
|
if (grassVertexBlades != null) {
|
||||||
|
try { GrassVertexIO.save(grassVertexBlades); }
|
||||||
|
catch (IOException e) { log.error("Vertex-Gras nicht speicherbar", e); }
|
||||||
|
}
|
||||||
|
MapIO.save(data);
|
||||||
|
if (models != null) PlacedModelIO.save(models);
|
||||||
|
if (lights != null) LightIO.save(lights);
|
||||||
|
if (emitters != null) EmitterIO.save(emitters);
|
||||||
|
if (waters != null) WaterBodyIO.save(waters);
|
||||||
|
if (rivers != null) de.blight.common.RiverIO.save(rivers);
|
||||||
|
if (soundAreas != null) SoundAreaIO.save(soundAreas);
|
||||||
|
if (areas != null) AreaIO.save(areas);
|
||||||
|
if (locationZones != null) LocationZoneIO.save(locationZones);
|
||||||
|
|
||||||
|
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
|
||||||
|
log.info("{}", input.saveStatusMsg);
|
||||||
|
} catch (IOException e) {
|
||||||
|
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
|
||||||
|
log.error("Speichern fehlgeschlagen", e);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
input.saveStatusMsg = "Speichern fehlgeschlagen: " + t;
|
||||||
|
log.error("Speichern fehlgeschlagen (unerwarteter Fehler)", t);
|
||||||
|
} finally {
|
||||||
|
saveInProgress = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Brush-Indikator ───────────────────────────────────────────────────────
|
// ── 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
// 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 int selectedIdx = -1;
|
|
||||||
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);
|
||||||
return;
|
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) {
|
||||||
|
closePoly();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currX.add(hitX);
|
||||||
|
currZ.add(hitZ);
|
||||||
|
updateInProgressGeo();
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < bodies.size(); i++) {
|
||||||
|
PlacedWater b = bodies.get(i);
|
||||||
|
if (pointInPolygon(hitX, hitZ, b.pointsX(), b.pointsZ())) {
|
||||||
|
selectBody(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deselect();
|
||||||
|
placing = true;
|
||||||
|
currX.clear();
|
||||||
|
currZ.clear();
|
||||||
|
currentWaterHeight = pt.y;
|
||||||
|
input.waterCurrentHeight = pt.y;
|
||||||
|
input.waterHeightChanged = true;
|
||||||
|
currX.add(hitX);
|
||||||
|
currZ.add(hitZ);
|
||||||
|
updateInProgressGeo();
|
||||||
}
|
}
|
||||||
addBody(new PlacedWater(pt.x, pt.z, pt.y), cells);
|
}
|
||||||
|
|
||||||
|
private void closePoly() {
|
||||||
|
if (currX.size() < 3) { cancelPoly(); return; }
|
||||||
|
float[] xs = toArray(currX);
|
||||||
|
float[] zs = toArray(currZ);
|
||||||
|
PlacedWater body = new PlacedWater(xs, zs, currentWaterHeight, 0f);
|
||||||
|
addBody(body);
|
||||||
selectBody(bodies.size() - 1);
|
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; }
|
||||||
|
if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; }
|
||||||
|
if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-progress visual ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void updateInProgressGeo() {
|
||||||
|
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
|
||||||
|
inProgGeo = null;
|
||||||
|
int n = currX.size();
|
||||||
|
if (n > 0) {
|
||||||
|
inProgGeo = buildLineGeo("water_inprog", currX, currZ,
|
||||||
|
currentWaterHeight + LINE_OFFSET, COLOR_INPROG, Mesh.Mode.LineStrip);
|
||||||
|
rootNode.attachChild(inProgGeo);
|
||||||
}
|
}
|
||||||
return -1;
|
updateLastMarker();
|
||||||
|
updatePillarGeo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Selektion ─────────────────────────────────────────────────────────────
|
private void updateLastMarker() {
|
||||||
|
if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; }
|
||||||
|
if (currX.isEmpty()) return;
|
||||||
|
float x = currX.get(currX.size() - 1);
|
||||||
|
float z = currZ.get(currZ.size() - 1);
|
||||||
|
float y = currentWaterHeight + LINE_OFFSET + 0.1f;
|
||||||
|
float s = 1.5f;
|
||||||
|
|
||||||
|
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
|
||||||
|
buf.put(x-s).put(y).put(z-s); buf.put(x+s).put(y).put(z+s);
|
||||||
|
buf.put(x-s).put(y).put(z+s); buf.put(x+s).put(y).put(z-s);
|
||||||
|
buf.flip();
|
||||||
|
|
||||||
|
Mesh mesh = new Mesh();
|
||||||
|
mesh.setMode(Mesh.Mode.Lines);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
|
||||||
|
mesh.updateBound();
|
||||||
|
|
||||||
|
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||||
|
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
|
||||||
|
mat.getAdditionalRenderState().setLineWidth(3f);
|
||||||
|
lastMarker = new Geometry("water_lastpoint", mesh);
|
||||||
|
lastMarker.setMaterial(mat);
|
||||||
|
rootNode.attachChild(lastMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Height-delta pillars ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final ColorRGBA PILLAR_SUB = new ColorRGBA(0.2f, 0.85f, 1.0f, 1f);
|
||||||
|
private static final ColorRGBA PILLAR_DRY = new ColorRGBA(1.0f, 0.50f, 0.1f, 1f);
|
||||||
|
|
||||||
|
private void updatePillarGeo() {
|
||||||
|
if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; }
|
||||||
|
int n = currX.size();
|
||||||
|
if (n == 0 || terrain == null) return;
|
||||||
|
|
||||||
|
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3);
|
||||||
|
FloatBuffer col = BufferUtils.createFloatBuffer(n * 2 * 4);
|
||||||
|
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float x = currX.get(i), z = currZ.get(i);
|
||||||
|
Float th = terrain.getHeight(new com.jme3.math.Vector2f(x, z));
|
||||||
|
float ty = th != null ? th : currentWaterHeight;
|
||||||
|
float wy = currentWaterHeight;
|
||||||
|
ColorRGBA c = (ty < wy) ? PILLAR_SUB : PILLAR_DRY;
|
||||||
|
|
||||||
|
pos.put(x).put(ty).put(z);
|
||||||
|
pos.put(x).put(wy + LINE_OFFSET).put(z);
|
||||||
|
col.put(c.r).put(c.g).put(c.b).put(c.a);
|
||||||
|
col.put(c.r).put(c.g).put(c.b).put(c.a);
|
||||||
|
}
|
||||||
|
pos.rewind(); col.rewind();
|
||||||
|
|
||||||
|
Mesh mesh = new Mesh();
|
||||||
|
mesh.setMode(Mesh.Mode.Lines);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
|
||||||
|
mesh.updateBound();
|
||||||
|
|
||||||
|
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||||
|
mat.setBoolean("VertexColor", true);
|
||||||
|
mat.getAdditionalRenderState().setLineWidth(3f);
|
||||||
|
pillarGeo = new Geometry("water_pillars", mesh);
|
||||||
|
pillarGeo.setMaterial(mat);
|
||||||
|
rootNode.attachChild(pillarGeo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateCursorPillar() {
|
||||||
|
if (terrain == null || input.mouseScreenX < 0) { removeCursorPillar(); return; }
|
||||||
|
|
||||||
|
float jmeX = input.mouseScreenX * (float) input.viewportScaleX;
|
||||||
|
float jmeY = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY;
|
||||||
|
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
|
||||||
|
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
|
||||||
|
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
|
||||||
|
CollisionResults hits = new CollisionResults();
|
||||||
|
terrain.collideWith(ray, hits);
|
||||||
|
if (hits.size() == 0) { removeCursorPillar(); return; }
|
||||||
|
|
||||||
|
Vector3f pt = hits.getClosestCollision().getContactPoint();
|
||||||
|
float ty = pt.y, wy = currentWaterHeight;
|
||||||
|
float delta = wy - ty;
|
||||||
|
ColorRGBA c = (delta > 0) ? PILLAR_SUB : PILLAR_DRY;
|
||||||
|
|
||||||
|
float lo = Math.min(ty, wy);
|
||||||
|
float hi = Math.max(ty, wy) + LINE_OFFSET;
|
||||||
|
|
||||||
|
FloatBuffer pos = BufferUtils.createFloatBuffer(2 * 3);
|
||||||
|
FloatBuffer col = BufferUtils.createFloatBuffer(2 * 4);
|
||||||
|
pos.put(pt.x).put(lo).put(pt.z);
|
||||||
|
pos.put(pt.x).put(hi).put(pt.z);
|
||||||
|
col.put(c.r).put(c.g).put(c.b).put(0.5f);
|
||||||
|
col.put(c.r).put(c.g).put(c.b).put(0.5f);
|
||||||
|
pos.rewind(); col.rewind();
|
||||||
|
|
||||||
|
if (cursorPillar == null) {
|
||||||
|
Mesh mesh = new Mesh();
|
||||||
|
mesh.setMode(Mesh.Mode.Lines);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
|
||||||
|
mesh.updateBound();
|
||||||
|
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||||
|
mat.setBoolean("VertexColor", true);
|
||||||
|
mat.getAdditionalRenderState().setLineWidth(2f);
|
||||||
|
cursorPillar = new Geometry("water_cursor_pillar", mesh);
|
||||||
|
cursorPillar.setMaterial(mat);
|
||||||
|
rootNode.attachChild(cursorPillar);
|
||||||
|
} else {
|
||||||
|
Mesh mesh = cursorPillar.getMesh();
|
||||||
|
mesh.clearBuffer(VertexBuffer.Type.Position);
|
||||||
|
mesh.clearBuffer(VertexBuffer.Type.Color);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
|
||||||
|
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
|
||||||
|
mesh.updateBound();
|
||||||
|
cursorPillar.getMaterial().setBoolean("VertexColor", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.waterCurrentHeight = currentWaterHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeCursorPillar() {
|
||||||
|
if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flow arrow ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Geometry buildFlowArrowGeo(PlacedWater body, int idx) {
|
||||||
|
float[] xs = body.pointsX(), zs = body.pointsZ();
|
||||||
|
int n = xs.length;
|
||||||
|
|
||||||
|
float minX = xs[0], maxX = xs[0], minZ = zs[0], maxZ = zs[0];
|
||||||
|
for (int i = 1; i < n; i++) {
|
||||||
|
if (xs[i] < minX) minX = xs[i]; if (xs[i] > maxX) maxX = xs[i];
|
||||||
|
if (zs[i] < minZ) minZ = zs[i]; if (zs[i] > maxZ) maxZ = zs[i];
|
||||||
|
}
|
||||||
|
float diag = (float) Math.sqrt((double)(maxX-minX)*(maxX-minX) + (double)(maxZ-minZ)*(maxZ-minZ));
|
||||||
|
float spacing = Math.max(4f, Math.min(diag / 8f, 30f));
|
||||||
|
float arrowLen = spacing * 0.5f;
|
||||||
|
float hlen = arrowLen * 0.35f;
|
||||||
|
float y = body.waterHeight() + 0.3f;
|
||||||
|
|
||||||
|
double rad = Math.toRadians(body.flowDegrees());
|
||||||
|
float dx = (float) Math.sin(rad);
|
||||||
|
float dz = (float) Math.cos(rad);
|
||||||
|
// head barb offsets (pre-computed, same for every arrow)
|
||||||
|
float b1x = (float) Math.sin(Math.toRadians(body.flowDegrees() + 150)) * hlen;
|
||||||
|
float b1z = (float) Math.cos(Math.toRadians(body.flowDegrees() + 150)) * hlen;
|
||||||
|
float b2x = (float) Math.sin(Math.toRadians(body.flowDegrees() - 150)) * hlen;
|
||||||
|
float b2z = (float) Math.cos(Math.toRadians(body.flowDegrees() - 150)) * hlen;
|
||||||
|
|
||||||
|
// collect grid points inside polygon (centered in each cell)
|
||||||
|
List<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);
|
// ── Point-in-polygon ──────────────────────────────────────────────────────
|
||||||
} else {
|
|
||||||
System.err.println("[WaterBodyState] Becken nicht rekonstruierbar: "
|
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||||
+ b.seedX() + "/" + b.seedZ());
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public class TextureTool extends EditorTool {
|
public class TextureTool extends EditorTool {
|
||||||
|
|
||||||
// Terrain.j3md (unlit) hat nur Tex1–Tex3; 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
|
||||||
|
|||||||
@@ -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 : ""; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||