diff --git a/assets/imported/animations/disappointed.glb b/assets/imported/animations/disappointed.glb new file mode 100644 index 0000000..1450df7 Binary files /dev/null and b/assets/imported/animations/disappointed.glb differ diff --git a/assets/imported/animations/sitting_talking.glb b/assets/imported/animations/sitting_talking.glb new file mode 100644 index 0000000..c7fd791 Binary files /dev/null and b/assets/imported/animations/sitting_talking.glb differ diff --git a/assets/imported/animations/talking1.glb b/assets/imported/animations/talking1.glb new file mode 100644 index 0000000..4c27191 Binary files /dev/null and b/assets/imported/animations/talking1.glb differ diff --git a/assets/imported/animations/talking2.glb b/assets/imported/animations/talking2.glb new file mode 100644 index 0000000..2827832 Binary files /dev/null and b/assets/imported/animations/talking2.glb differ diff --git a/assets/imported/animations/yelling.glb b/assets/imported/animations/yelling.glb new file mode 100644 index 0000000..f2271c1 Binary files /dev/null and b/assets/imported/animations/yelling.glb differ diff --git a/blight-assets/src/main/resources/Common/MatDefs/Water/Water.frag b/blight-assets/src/main/resources/Common/MatDefs/Water/Water.frag new file mode 100644 index 0000000..2c4a425 --- /dev/null +++ b/blight-assets/src/main/resources/Common/MatDefs/Water/Water.frag @@ -0,0 +1,475 @@ +#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 WAVE_INTERACTION +uniform sampler2D m_WaveMap; +uniform vec2 m_WaveAreaCenter; +uniform float m_WaveAreaInvExtent; +uniform float m_WaveStrength; +#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 + 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 + + 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 + + // Overlay dynamic wave normals from CPU simulation (WAVE_INTERACTION define) + #ifdef WAVE_INTERACTION + vec2 waveUV = (surfacePoint.xz - m_WaveAreaCenter) * m_WaveAreaInvExtent + 0.5; + float inWave = step(0.0, waveUV.x) * step(waveUV.x, 1.0) + * step(0.0, waveUV.y) * step(waveUV.y, 1.0); + float ts = 1.0 / 256.0; + float wl = texture2D(m_WaveMap, waveUV + vec2(-ts, 0.0)).r * 2.0 - 1.0; + float wr = texture2D(m_WaveMap, waveUV + vec2( ts, 0.0)).r * 2.0 - 1.0; + float wd = texture2D(m_WaveMap, waveUV + vec2(0.0, -ts)).r * 2.0 - 1.0; + float wu = texture2D(m_WaveMap, waveUV + vec2(0.0, ts)).r * 2.0 - 1.0; + vec3 waveNorm = normalize(vec3((wl - wr) * m_WaveStrength, 1.0, (wd - wu) * m_WaveStrength)); + normal = normalize(mix(normal, normalize(normal + waveNorm), inWave)); + #endif + + vec3 refraction = color2; + #ifdef ENABLE_REFRACTION + // texC = texCoord.xy+ m_ReflectionDisplace * normal.x; + texC = texCoord.xy; + texC += sin(m_Time*1.8 + 3.0 * abs(position.y))* (refractionScale * min(depth2, 1.0)); + texC = clamp(texC,vec2(0.0),vec2(0.999)); + refraction = fetchTextureSample(m_Texture, texC, sampleNum).rgb; + #endif + vec3 waterPosition = surfacePoint.xyz; + waterPosition.y -= (level - m_WaterHeight); + vec4 texCoordProj = m_TextureProjMatrix * vec4(waterPosition, 1.0); + + texCoordProj.x = texCoordProj.x + m_ReflectionDisplace * normal.x; + texCoordProj.z = texCoordProj.z + m_ReflectionDisplace * normal.z; + texCoordProj /= texCoordProj.w; + texCoordProj.y = 1.0 - texCoordProj.y; + + vec3 reflection = texture2D(m_ReflectionMap, texCoordProj.xy).rgb; + + float fresnel = fresnelTerm(normal, eyeVecNorm); + + float depthN = depth * m_WaterTransparency; + float waterCol = saturate(length(m_LightColor.rgb) / m_SunScale); + refraction = mix(mix(refraction, m_WaterColor.rgb * waterCol, saturate(depthN / visibility)), + m_DeepWaterColor.rgb * waterCol, saturate(depth2 / m_ColorExtinction)); + + + vec3 foam = vec3(0.0); + #ifdef ENABLE_FOAM + texC = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.05 * m_WindDirection + sin(m_Time * 0.001 + position.x) * 0.005; + vec2 texCoord2 = (surfacePoint.xz + eyeVecNorm.xz * 0.1) * 0.05 + m_Time * 0.1 * m_WindDirection + sin(m_Time * 0.001 + position.z) * 0.005; + + vec4 foam1 = texture2D(m_FoamMap, texC); + vec4 foam2 = texture2D(m_FoamMap, texCoord2); + + if(depth2 < m_FoamExistence.x){ + foam = (foam1.r + foam2).rgb * vec3(m_FoamIntensity); + }else if(depth2 < m_FoamExistence.y){ + foam = mix((foam1 + foam2) * m_FoamIntensity , vec4(0.0), + (depth2 - m_FoamExistence.x) / (m_FoamExistence.y - m_FoamExistence.x)).rgb; + } + + + if(m_MaxAmplitude - m_FoamExistence.z> 0.0001){ + foam += ((foam1 + foam2) * m_FoamIntensity * m_FoamIntensity * 0.3 * + saturate((level - (m_WaterHeight + m_FoamExistence.z)) / (m_MaxAmplitude - m_FoamExistence.z))).rgb; + } + foam *= m_LightColor.rgb; + #endif + + vec3 specular = vec3(0.0); + #ifdef ENABLE_SPECULAR + vec3 lightDir=normalize(m_LightDir); + vec3 mirrorEye = (2.0 * dot(eyeVecNorm, normal) * normal - eyeVecNorm); + float dotSpec = saturate(dot(mirrorEye.xyz, -lightDir) * 0.5 + 0.5); + specular = vec3((1.0 - fresnel) * saturate(-lightDir.y) * ((pow(dotSpec, 512.0)) * (m_Shininess * 1.8 + 0.2))); + specular += specular * 25.0 * saturate(m_Shininess - 0.05); + //foam does not shine + specular=specular * m_LightColor.rgb - (5.0 * foam); + #endif + + color = mix(refraction, reflection, fresnel); + color = mix(refraction, color, saturate(depth * m_ShoreHardness)); + color = saturate(color + max(specular, foam )); + color = mix(refraction, color, saturate(depth* m_FoamHardness)); + + + // XXX: HACK ALERT: + // We trick the GeForces to think they have + // to calculate the derivatives for all these pixels by using step()! + // That way we won't get pixels around the edges of the terrain, + // Where the derivatives are undefined + return vec4(mix(color, color2, step(level, position.y)), 1.0); +} + +void main(){ + setGlobals(); + #ifdef RESOLVE_MS + vec4 color = vec4(0.0); + for (int i = 0; i < m_NumSamples; i++){ + color += main_multiSample(i); + } + gl_FragColor = color / float(m_NumSamples); + #else + gl_FragColor = main_multiSample(0); + #endif +} diff --git a/blight-assets/src/main/resources/Common/MatDefs/Water/Water.j3md b/blight-assets/src/main/resources/Common/MatDefs/Water/Water.j3md new file mode 100644 index 0000000..8c34374 --- /dev/null +++ b/blight-assets/src/main/resources/Common/MatDefs/Water/Water.j3md @@ -0,0 +1,84 @@ +MaterialDef Advanced Water { + + 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 + + // Dynamic wave interaction + Texture2D WaveMap -LINEAR + Vector2 WaveAreaCenter + Float WaveAreaInvExtent + Float WaveStrength + Boolean WaveInteraction + } + + Technique { + VertexShader GLSL310 GLSL300 GLSL150 GLSL120 : Common/MatDefs/Post/Post.vert + FragmentShader GLSL310 GLSL300 GLSL150 GLSL120: Common/MatDefs/Water/Water.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 + WAVE_INTERACTION : WaveInteraction + } + } + +} diff --git a/blight-assets/src/main/resources/MatDefs/Fern.j3md b/blight-assets/src/main/resources/MatDefs/Fern.j3md index 559055c..d75dba2 100644 --- a/blight-assets/src/main/resources/MatDefs/Fern.j3md +++ b/blight-assets/src/main/resources/MatDefs/Fern.j3md @@ -4,6 +4,7 @@ MaterialDef Fern { Color Diffuse (Color) : 0.18 0.60 0.10 1.0 Float WindStrength : 0.15 Float WindSpeed : 0.6 + Vector2 WindDir : 0.0 1.0 Texture2D DiffuseMap Boolean HasDiffuseMap : false Texture2D NormalMap -LINEAR diff --git a/blight-assets/src/main/resources/MatDefs/GrassSeed.j3md b/blight-assets/src/main/resources/MatDefs/GrassSeed.j3md new file mode 100644 index 0000000..cccb56e --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/GrassSeed.j3md @@ -0,0 +1,27 @@ +MaterialDef GrassSeed { + + MaterialParameters { + Texture2D ColorMap + Float WindSpeed : 1.0 + Float WindStrength : 0.15 + Vector2 WindDir : 0.0 1.0 + Vector3 SunDir : 0.35 0.8 0.45 + Color SunColor : 0.95 0.90 0.75 1.0 + } + + Technique { + VertexShader GLSL150: Shaders/GrassSeed.vert + FragmentShader GLSL150: Shaders/GrassSeed.frag + + WorldParameters { + WorldViewProjectionMatrix + WorldMatrix + Time + AmbientLightColor + } + + RenderState { + FaceCull Off + } + } +} diff --git a/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md b/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md index 3054296..9ccfe77 100644 --- a/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md +++ b/blight-assets/src/main/resources/MatDefs/GrassVertex.j3md @@ -3,6 +3,7 @@ MaterialDef GrassVertex { MaterialParameters { Float WindSpeed : 1.0 Float WindStrength : 0.15 + Vector2 WindDir : 0.0 1.0 Vector3 SunDir : 0.35 0.8 0.45 Color SunColor : 0.95 0.90 0.75 1.0 } diff --git a/blight-assets/src/main/resources/MatDefs/Tree.j3md b/blight-assets/src/main/resources/MatDefs/Tree.j3md index f145101..3fd27ea 100644 --- a/blight-assets/src/main/resources/MatDefs/Tree.j3md +++ b/blight-assets/src/main/resources/MatDefs/Tree.j3md @@ -4,6 +4,7 @@ MaterialDef Tree { Color Diffuse (Color) : 0.42 0.26 0.10 1.0 Float WindStrength : 0.15 Float WindSpeed : 0.5 + Vector2 WindDir : 0.0 1.0 Texture2D BarkMap Boolean HasBarkMap : false Vector3 LightDir diff --git a/blight-assets/src/main/resources/MatDefs/TreeLeaf.j3md b/blight-assets/src/main/resources/MatDefs/TreeLeaf.j3md index fe543fe..7d868ed 100644 --- a/blight-assets/src/main/resources/MatDefs/TreeLeaf.j3md +++ b/blight-assets/src/main/resources/MatDefs/TreeLeaf.j3md @@ -4,6 +4,7 @@ MaterialDef TreeLeaf { Color Diffuse (Color) : 0.18 0.60 0.10 1.0 Float WindStrength : 0.30 Float WindSpeed : 0.7 + Vector2 WindDir : 0.0 1.0 Texture2D LeafMap Boolean HasLeafMap : false diff --git a/blight-assets/src/main/resources/Shaders/Fern.vert b/blight-assets/src/main/resources/Shaders/Fern.vert index 5f607ee..c9721fb 100644 --- a/blight-assets/src/main/resources/Shaders/Fern.vert +++ b/blight-assets/src/main/resources/Shaders/Fern.vert @@ -5,6 +5,7 @@ uniform mat4 g_WorldMatrix; uniform float g_Time; uniform float m_WindStrength; uniform float m_WindSpeed; +uniform vec2 m_WindDir; in vec3 inPosition; in vec3 inNormal; @@ -21,10 +22,21 @@ void main() { float windW = inColor.r; float t = g_Time * m_WindSpeed; vec4 wp = g_WorldMatrix * vec4(inPosition, 1.0); - float phase = wp.x * 0.08 + wp.z * 0.06; - float swayX = sin(t + phase) * windW * m_WindStrength; - float swayZ = cos(t*0.73 + phase) * windW * m_WindStrength * 0.55; - vec3 anim = inPosition + vec3(swayX, 0.0, swayZ); + vec2 worldXZ = wp.xz; + + vec2 windN = (dot(m_WindDir, m_WindDir) > 0.001) ? normalize(m_WindDir) : vec2(0.0, 1.0); + vec2 perpN = vec2(-windN.y, windN.x); + float wavePhase = dot(worldXZ, windN); + float randPhase = fract(sin(dot(worldXZ, vec2(127.1, 311.7))) * 43758.5453) * 6.2832; + + float mainSway = sin(t + wavePhase * 0.08 + randPhase) * windW * m_WindStrength; + float crossSway = cos(t * 0.73 + wavePhase * 0.06 + randPhase) * windW * m_WindStrength * 0.25; + + vec3 anim = inPosition + vec3( + windN.x * mainSway + perpN.x * crossSway, + 0.0, + windN.y * mainSway + perpN.y * crossSway + ); gl_Position = g_WorldViewProjectionMatrix * vec4(anim, 1.0); texCoord = inTexCoord; diff --git a/blight-assets/src/main/resources/Shaders/GrassSeed.frag b/blight-assets/src/main/resources/Shaders/GrassSeed.frag new file mode 100644 index 0000000..abdead2 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/GrassSeed.frag @@ -0,0 +1,19 @@ +uniform sampler2D m_ColorMap; +uniform vec4 g_AmbientLightColor; +uniform vec3 m_SunDir; +uniform vec4 m_SunColor; + +in vec2 varUV; + +out vec4 outFragColor; + +void main() { + vec4 c = texture(m_ColorMap, varUV); + if (c.a < 0.15) discard; + + // Wrapped diffuse – kein Normalvektor nötig für Billboard-Quads + float light = 0.5 + 0.5 * max(dot(m_SunDir, vec3(0.0, 1.0, 0.0)), 0.0); + vec3 ambient = g_AmbientLightColor.rgb * c.rgb; + vec3 diffuse = m_SunColor.rgb * c.rgb * light; + outFragColor = vec4(min(ambient + diffuse, c.rgb * 1.5), c.a); +} diff --git a/blight-assets/src/main/resources/Shaders/GrassSeed.vert b/blight-assets/src/main/resources/Shaders/GrassSeed.vert new file mode 100644 index 0000000..44c4dc3 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/GrassSeed.vert @@ -0,0 +1,34 @@ +uniform mat4 g_WorldViewProjectionMatrix; +uniform mat4 g_WorldMatrix; +uniform float g_Time; + +uniform float m_WindSpeed; +uniform float m_WindStrength; +uniform vec2 m_WindDir; + +in vec3 inPosition; +in vec2 inTexCoord; // echte UV-Koordinaten der Samen-Textur + +out vec2 varUV; + +void main() { + vec4 pos = vec4(inPosition, 1.0); + + vec2 worldXZ = (g_WorldMatrix * pos).xz; + float t = g_Time * m_WindSpeed; + + vec2 windN = (dot(m_WindDir, m_WindDir) > 0.001) ? normalize(m_WindDir) : vec2(0.0, 1.0); + float wavePhase = dot(worldXZ, windN); + float randPhase = fract(sin(dot(worldXZ, vec2(127.1, 311.7))) * 43758.5453) * 6.2832; + + float sway = sin(t * 2.1 + wavePhase * 0.10 + randPhase) * 0.6 + + sin(t * 1.4 + wavePhase * 0.06 + randPhase * 0.73) * 0.4; + + // Samen sitzen immer an der Spitze → voller Windfaktor (wf = 1.0) + float bend = sway * m_WindStrength; + pos.x += windN.x * bend; + pos.z += windN.y * bend; + + varUV = inTexCoord; + gl_Position = g_WorldViewProjectionMatrix * pos; +} diff --git a/blight-assets/src/main/resources/Shaders/GrassVertex.vert b/blight-assets/src/main/resources/Shaders/GrassVertex.vert index 692c80e..da05eb9 100644 --- a/blight-assets/src/main/resources/Shaders/GrassVertex.vert +++ b/blight-assets/src/main/resources/Shaders/GrassVertex.vert @@ -4,6 +4,7 @@ uniform float g_Time; uniform float m_WindSpeed; uniform float m_WindStrength; +uniform vec2 m_WindDir; // normierter XZ-Windvektor in vec3 inPosition; in vec3 inNormal; @@ -18,22 +19,28 @@ void main() { 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; + // Windrichtung (Fallback: Süd) + vec2 windN = (dot(m_WindDir, m_WindDir) > 0.001) ? normalize(m_WindDir) : vec2(0.0, 1.0); + + // Wellenfront: Position entlang der Windachse → Halme in Windrichtung erreicht der Impuls später + float wavePhase = dot(worldXZ, windN); + + // Zufälliger Phasenversatz pro Halm (Spatial-Hash → kein synchrones Schwingen) + float randPhase = fract(sin(dot(worldXZ, vec2(127.1, 311.7))) * 43758.5453) * 6.2832; + + float sway = sin(t * 2.1 + wavePhase * 0.10 + randPhase) * 0.6 + + sin(t * 1.4 + wavePhase * 0.06 + randPhase * 0.73) * 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; + pos.x += windN.x * bend; + pos.z += windN.y * bend; } varColor = inColor; - // Normal in Weltkoordinaten (WorldMatrix ist für Gras typischerweise Identität) varNormal = normalize(mat3(g_WorldMatrix) * inNormal); - gl_Position = g_WorldViewProjectionMatrix * pos; } diff --git a/blight-assets/src/main/resources/Shaders/Tree.vert b/blight-assets/src/main/resources/Shaders/Tree.vert index a8da904..cbfd787 100644 --- a/blight-assets/src/main/resources/Shaders/Tree.vert +++ b/blight-assets/src/main/resources/Shaders/Tree.vert @@ -1,11 +1,11 @@ #import "Common/ShaderLib/GLSLCompat.glsllib" - -uniform mat4 g_WorldViewProjectionMatrix; -uniform mat4 g_WorldMatrix; +uniform mat4 g_WorldViewProjectionMatrix; +uniform mat4 g_WorldMatrix; uniform float g_Time; uniform float m_WindStrength; uniform float m_WindSpeed; +uniform vec2 m_WindDir; in vec3 inPosition; in vec3 inNormal; @@ -16,18 +16,27 @@ out vec2 texCoord; out vec3 worldNormal; void main() { -float windW = inColor.r; -float t = g_Time * m_WindSpeed; + float windW = inColor.r; + float t = g_Time * m_WindSpeed; -// Welt-Position für orts-abhängige Phase (verhindert synchrones Schwingen) -vec4 worldPos = g_WorldMatrix * vec4(inPosition, 1.0); -float phase = worldPos.x * 0.08 + worldPos.z * 0.06; -float swayX = sin(t + phase) * windW * m_WindStrength; -float swayZ = cos(t * 0.73 + phase) * windW * m_WindStrength * 0.55; + vec4 worldPos = g_WorldMatrix * vec4(inPosition, 1.0); + vec2 worldXZ = worldPos.xz; -vec3 animPos = inPosition + vec3(swayX, 0.0, swayZ); + vec2 windN = (dot(m_WindDir, m_WindDir) > 0.001) ? normalize(m_WindDir) : vec2(0.0, 1.0); + vec2 perpN = vec2(-windN.y, windN.x); + float wavePhase = dot(worldXZ, windN); + float randPhase = fract(sin(dot(worldXZ, vec2(127.1, 311.7))) * 43758.5453) * 6.2832; -gl_Position = g_WorldViewProjectionMatrix * vec4(animPos, 1.0); -texCoord = inTexCoord; -worldNormal = normalize((g_WorldMatrix * vec4(inNormal, 0.0)).xyz); -} \ No newline at end of file + float mainSway = sin(t + wavePhase * 0.08 + randPhase) * windW * m_WindStrength; + float crossSway = cos(t * 0.73 + wavePhase * 0.06 + randPhase) * windW * m_WindStrength * 0.25; + + vec3 animPos = inPosition + vec3( + windN.x * mainSway + perpN.x * crossSway, + 0.0, + windN.y * mainSway + perpN.y * crossSway + ); + + gl_Position = g_WorldViewProjectionMatrix * vec4(animPos, 1.0); + texCoord = inTexCoord; + worldNormal = normalize((g_WorldMatrix * vec4(inNormal, 0.0)).xyz); +} diff --git a/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds1.png b/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds1.png new file mode 100644 index 0000000..f269bd7 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds1.png differ diff --git a/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds2.png b/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds2.png new file mode 100644 index 0000000..5f93bf9 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/internal/gras/seeds/seeds2.png differ diff --git a/blight-assets/src/main/resources/animations/clips/disappointed.j3o b/blight-assets/src/main/resources/animations/clips/disappointed.j3o new file mode 100644 index 0000000..71bdf76 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/disappointed.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/sitting_talking.j3o b/blight-assets/src/main/resources/animations/clips/sitting_talking.j3o new file mode 100644 index 0000000..432c04d Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/sitting_talking.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/talking1.j3o b/blight-assets/src/main/resources/animations/clips/talking1.j3o new file mode 100644 index 0000000..9802e45 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/talking1.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/talking2.j3o b/blight-assets/src/main/resources/animations/clips/talking2.j3o new file mode 100644 index 0000000..c0049c6 Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/talking2.j3o differ diff --git a/blight-assets/src/main/resources/animations/clips/yelling.j3o b/blight-assets/src/main/resources/animations/clips/yelling.j3o new file mode 100644 index 0000000..f9f1bbb Binary files /dev/null and b/blight-assets/src/main/resources/animations/clips/yelling.j3o differ diff --git a/blight-assets/src/main/resources/animations/sets/human.animset.json b/blight-assets/src/main/resources/animations/sets/human.animset.json index 961be6e..cd39e4d 100644 --- a/blight-assets/src/main/resources/animations/sets/human.animset.json +++ b/blight-assets/src/main/resources/animations/sets/human.animset.json @@ -13,7 +13,8 @@ "stand_up", "stand_up_bench", "tpose", - "walking" + "walking", + "sitting_talking" ], "actionMap": { "DEFAULT": "tpose", @@ -31,7 +32,22 @@ }, "previewModelPath": "Models/Chars/mainchar.j3o", "animOffsets": { - "sitting": {"tx": 0.0, "ty": 0.0, "tz": -0.5, "rx": 0.0, "ry": 0.0, "rz": 0.0}, - "get_up_sitting": {"tx": 0.0, "ty": 0.0, "tz": -0.5, "rx": 0.0, "ry": 0.0, "rz": 0.0} - } + "sitting": { + "tx": 0.0, + "ty": 0.0, + "tz": -0.5, + "rx": 0.0, + "ry": 0.0, + "rz": 0.0 + }, + "get_up_sitting": { + "tx": 0.0, + "ty": 0.0, + "tz": -0.5, + "rx": 0.0, + "ry": 0.0, + "rz": 0.0 + } + }, + "subClips": {} } \ No newline at end of file diff --git a/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java b/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java index 125727b..c77485d 100644 --- a/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java +++ b/blight-common/src/main/java/de/blight/common/GrassVertexBlade.java @@ -1,4 +1,9 @@ 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) {} +public record GrassVertexBlade(float x, float y, float z, float height, float dryness, int seedIdx) { + /** Rückwärts-kompatibler Konstruktor ohne Samen-Index. */ + public GrassVertexBlade(float x, float y, float z, float height, float dryness) { + this(x, y, z, height, dryness, -1); + } +} diff --git a/blight-common/src/main/java/de/blight/common/GrassVertexIO.java b/blight-common/src/main/java/de/blight/common/GrassVertexIO.java index d31dc6a..dbe485e 100644 --- a/blight-common/src/main/java/de/blight/common/GrassVertexIO.java +++ b/blight-common/src/main/java/de/blight/common/GrassVertexIO.java @@ -11,11 +11,12 @@ import java.util.zip.*; * * 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) + * Format v3: wie v2 + byte seedIdx pro Halm (−1=kein Samen, ≥0=Textur-Index) */ public final class GrassVertexIO { private static final int MAGIC = 0x47565458; // "GVTX" - private static final int VERSION = 2; + private static final int VERSION = 3; private GrassVertexIO() {} @@ -37,6 +38,7 @@ public final class GrassVertexIO { out.writeFloat(b.z()); out.writeFloat(b.height()); out.writeFloat(b.dryness()); + out.writeByte(Math.max(-1, Math.min(126, b.seedIdx()))); } } } @@ -58,7 +60,8 @@ public final class GrassVertexIO { float z = in.readFloat(); float h = in.readFloat(); float dr = version >= 2 ? in.readFloat() : 0f; - list.add(new GrassVertexBlade(x, y, z, h, dr)); + int si = version >= 3 ? in.readByte() : -1; + list.add(new GrassVertexBlade(x, y, z, h, dr, si)); } return list; } diff --git a/blight-common/src/main/java/de/blight/common/model/DialogOption.java b/blight-common/src/main/java/de/blight/common/model/DialogOption.java index 218a4f1..d9be14a 100644 --- a/blight-common/src/main/java/de/blight/common/model/DialogOption.java +++ b/blight-common/src/main/java/de/blight/common/model/DialogOption.java @@ -24,6 +24,12 @@ public class DialogOption { private TextReference textNpc; private AudioReference audioNpc; + /** Animations-Clip, der beim Start der NPC-Antwort einmalig gespielt wird (z. B. "talk1"). */ + private String npcAnimation; + + private List heroSteps = new ArrayList<>(); + private List npcSteps = new ArrayList<>(); + private transient List nextOptions; private transient List disablesOptions; diff --git a/blight-common/src/main/java/de/blight/common/model/DialogStep.java b/blight-common/src/main/java/de/blight/common/model/DialogStep.java new file mode 100644 index 0000000..2c8fa57 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/DialogStep.java @@ -0,0 +1,15 @@ +package de.blight.common.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class DialogStep { + private TextReference text; + private AudioReference audio; +} diff --git a/blight-common/src/main/java/de/blight/common/time/DayTime.java b/blight-common/src/main/java/de/blight/common/time/DayTime.java index ce453d2..f2127f6 100644 --- a/blight-common/src/main/java/de/blight/common/time/DayTime.java +++ b/blight-common/src/main/java/de/blight/common/time/DayTime.java @@ -10,8 +10,8 @@ import java.util.List; */ public class DayTime { - /** Standard-Tagesdauer in Echtzeit-Sekunden (5 Minuten). */ - public static final float DEFAULT_DAY_DURATION = 300f; + /** Standard-Tagesdauer in Echtzeit-Sekunden (15 Minuten). */ + public static final float DEFAULT_DAY_DURATION = 900f; private float timeOfDay; private float timeScale; diff --git a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java index e5fede1..b31e43e 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java @@ -11,12 +11,14 @@ import com.jme3.math.ColorRGBA; import com.jme3.math.Ray; import com.jme3.math.Vector2f; import com.jme3.math.Vector3f; +import com.jme3.renderer.queue.RenderQueue; import com.jme3.scene.Geometry; import com.jme3.scene.Mesh; import com.jme3.scene.Node; import com.jme3.scene.Spatial; import com.jme3.scene.VertexBuffer; import com.jme3.terrain.geomipmap.TerrainQuad; +import com.jme3.texture.Texture; import com.jme3.util.BufferUtils; import de.blight.common.GrassVertexBlade; import de.blight.common.GrassVertexIO; @@ -26,7 +28,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Random; /** @@ -47,7 +51,7 @@ public class GrassVertexState extends BaseAppState { // ── 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 WIDTH_FACTOR = 0.10f; // 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); @@ -63,6 +67,15 @@ public class GrassVertexState extends BaseAppState { private static final float CULL_DIST = 150f; private static final float CULL_DIST_SQ = CULL_DIST * CULL_DIST; + // ── Samen-Texturen ──────────────────────────────────────────────────────── + private static final String SEED_TEX_BASE = "Textures/internal/gras/seeds/seeds"; + private static final String SEED_TEX_EXT = ".png"; + + /** Samen-Größe relativ zur Halmhöhe */ + private static final float SEED_SIZE_FACTOR = 0.45f; + /** Y-Position der Samen-Unterseite relativ zum Halmfuß (0–1) */ + private static final float SEED_Y_FACTOR = 0.78f; + // ── Zustand ─────────────────────────────────────────────────────────────── private final SharedInput input; private AssetManager assetManager; @@ -70,6 +83,8 @@ public class GrassVertexState extends BaseAppState { private TerrainQuad terrain; private Node grassNode; private Material material; + private Material[] seedMaterials = new Material[0]; + private Material seedStalkMaterial; @SuppressWarnings("unchecked") private final List[] chunkBlades = new List[CHUNK_COUNT]; @@ -97,7 +112,9 @@ public class GrassVertexState extends BaseAppState { this.cam = app.getCamera(); grassNode = new Node("grassVertexNode"); ((SimpleApplication) app).getRootNode().attachChild(grassNode); - material = buildMaterial(); + material = buildMaterial(); + seedMaterials = loadSeedMaterials(); + seedStalkMaterial = buildSeedStalkMaterial(); try { for (GrassVertexBlade b : GrassVertexIO.load()) { @@ -109,6 +126,38 @@ public class GrassVertexState extends BaseAppState { } } + private Material buildSeedStalkMaterial() { + Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md"); + mat.setFloat("WindSpeed", 1.0f); + mat.setFloat("WindStrength", 0.15f); + mat.setVector3("SunDir", new com.jme3.math.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; + } + + private Material[] loadSeedMaterials() { + List mats = new ArrayList<>(); + for (int i = 1; i <= 99; i++) { + String path = SEED_TEX_BASE + i + SEED_TEX_EXT; + try { + Texture tex = assetManager.loadTexture(path); + Material mat = new Material(assetManager, "MatDefs/GrassSeed.j3md"); + mat.setTexture("ColorMap", tex); + mat.setFloat("WindSpeed", 1.0f); + mat.setFloat("WindStrength", 0.15f); + mat.setVector3("SunDir", new com.jme3.math.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); + mats.add(mat); + log.info("[GrassVertexState] Samen-Textur geladen: {}", path); + } catch (Exception e) { + break; + } + } + return mats.toArray(new Material[0]); + } + @Override protected void cleanup(Application app) { ((SimpleApplication) app).getRootNode().detachChild(grassNode); @@ -201,7 +250,9 @@ public class GrassVertexState extends BaseAppState { float variation = (1f - uniformity) * 0.25f; // max ±25 % bei uniformity=0 float radSq = radius * radius; - // Kleinere Rand-Halme im Pinselbereich durch größere ersetzen + float seedPct = (float) input.grassVertexTool.seedDensity.getValue() / 100f; + + // Kleinere Rand-Halme im Pinselbereich durch größere ersetzen (seedIdx erhalten) for (int ci = 0; ci < CHUNK_COUNT; ci++) { List list = chunkBlades[ci]; boolean changed = false; @@ -215,7 +266,7 @@ public class GrassVertexState extends BaseAppState { // Halm ist deutlich kleiner als die aktuelle Pinselposition erlaubt → ersetzen if (b.height() < idealH * 0.88f) { float newH = idealH * (1f + variation * (rng.nextFloat() * 2f - 1f)); - list.set(i, new GrassVertexBlade(b.x(), b.y(), b.z(), Math.max(0.05f, newH), b.dryness())); + list.set(i, new GrassVertexBlade(b.x(), b.y(), b.z(), Math.max(0.05f, newH), b.dryness(), b.seedIdx())); changed = true; } } @@ -236,7 +287,11 @@ public class GrassVertexState extends BaseAppState { h = Math.max(0.05f, h); float bladeDryness = rng.nextFloat() < witherPct ? 0.5f + rng.nextFloat() * 0.5f : 0f; - GrassVertexBlade blade = new GrassVertexBlade(bx, by, bz, h, bladeDryness); + int seedIdx = -1; + if (seedMaterials.length > 0 && rng.nextFloat() < seedPct) { + seedIdx = rng.nextInt(seedMaterials.length); + } + GrassVertexBlade blade = new GrassVertexBlade(bx, by, bz, h, bladeDryness, seedIdx); int ci = chunkIndex(bx, bz); if (ci >= 0) { chunkBlades[ci].add(blade); dirtyChunks[ci] = true; } } @@ -254,7 +309,7 @@ public class GrassVertexState extends BaseAppState { 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())); + list.set(i, new GrassVertexBlade(b.x(), newY, b.z(), b.height(), b.dryness(), b.seedIdx())); changed = true; } } @@ -333,10 +388,144 @@ public class GrassVertexState extends BaseAppState { Node node = new Node("gvc_" + ci); node.attachChild(geo); + + // ── Samen-Stiele + Kreuze ───────────────────────────────────────────── + if (seedMaterials.length > 0) { + List seededAll = new ArrayList<>(); + Map> byTex = new HashMap<>(); + for (GrassVertexBlade b : blades) { + if (b.seedIdx() >= 0 && b.seedIdx() < seedMaterials.length) { + seededAll.add(b); + byTex.computeIfAbsent(b.seedIdx(), k -> new ArrayList<>()).add(b); + } + } + if (!seededAll.isEmpty()) { + Geometry stalkGeo = buildSeedStalkMesh("stalk_" + ci, seededAll); + stalkGeo.setMaterial(seedStalkMaterial); + node.attachChild(stalkGeo); + } + for (Map.Entry> e : byTex.entrySet()) { + Geometry seedGeo = buildSeedCrossMesh("seed_" + ci + "_" + e.getKey(), e.getValue()); + seedGeo.setMaterial(seedMaterials[e.getKey()]); + node.attachChild(seedGeo); + } + } + chunkNodes[ci] = node; grassNode.attachChild(node); } + private static Geometry buildSeedStalkMesh(String name, List blades) { + final int SEG = 4; + final float R = 0xbb / 255f, G = 0x90 / 255f, B = 0x59 / 255f; + int n = blades.size(); + int vTotal = n * (SEG + 1) * 2; + float[] pos = new float[vTotal * 3]; + float[] nrm = new float[vTotal * 3]; + float[] col = new float[vTotal * 4]; + float[] tex = new float[vTotal * 2]; + int[] idx = new int [n * SEG * 6]; + + int vi = 0, ii = 0; + for (GrassVertexBlade blade : blades) { + float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height(); + float hw = h * 0.018f; + float ang = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)); + float cA = (float) Math.cos(ang), sA = (float) Math.sin(ang); + + // Normale senkrecht zur Halmbreite, 30 % Richtung Weltauf gekippt (wie Gras-Shader) + float nx = -sA, ny = 0f, nz = cA; + float blend = 0.30f; + nx *= (1f - blend); ny = blend; nz *= (1f - blend); + float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + nx /= nLen; ny /= nLen; nz /= nLen; + + for (int s = 0; s <= SEG; s++) { + float t = (float) s / SEG; + float curHW = hw * (float) Math.pow(1.0 - t, 1.4); + float py = y + h * t; + int sviL = vi + s * 2; + int sviR = sviL + 1; + + // Linkes Vertex + pos[sviL*3] = x - cA*curHW; pos[sviL*3+1] = py; pos[sviL*3+2] = z - sA*curHW; + nrm[sviL*3] = nx; nrm[sviL*3+1] = ny; nrm[sviL*3+2] = nz; + col[sviL*4] = R; col[sviL*4+1] = G; col[sviL*4+2] = B; col[sviL*4+3] = 1f; + tex[sviL*2] = t; tex[sviL*2+1] = 0f; + + // Rechtes Vertex + pos[sviR*3] = x + cA*curHW; pos[sviR*3+1] = py; pos[sviR*3+2] = z + sA*curHW; + nrm[sviR*3] = nx; nrm[sviR*3+1] = ny; nrm[sviR*3+2] = nz; + col[sviR*4] = R; col[sviR*4+1] = G; col[sviR*4+2] = B; col[sviR*4+3] = 1f; + tex[sviR*2] = t; tex[sviR*2+1] = 0f; + } + for (int s = 0; s < SEG; s++) { + int b0 = vi + s * 2; + idx[ii] = b0; idx[ii+1] = b0+1; idx[ii+2] = b0+3; + idx[ii+3] = b0; idx[ii+4] = b0+3; idx[ii+5] = b0+2; + ii += 6; + } + vi += (SEG + 1) * 2; + } + + Mesh m = new Mesh(); + m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos)); + m.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(nrm)); + m.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(col)); + m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex)); + m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx)); + m.updateBound(); + return new Geometry(name, m); + } + + private static Geometry buildSeedCrossMesh(String name, List blades) { + // Pro Halm: 2 Quads (X-Kreuz) × 4 Verts = 8 Verts, 2 × 6 Indices = 12 + int n = blades.size(); + float[] pos = new float[n * 8 * 3]; + float[] tex = new float[n * 8 * 2]; + int[] idx = new int [n * 12]; + + int vi = 0, ii = 0; + for (GrassVertexBlade b : blades) { + float x = b.x(); + float yBot = b.y() + b.height() * SEED_Y_FACTOR; + float z = b.z(); + float size = b.height() * SEED_SIZE_FACTOR; + float hw = size * 0.5f; + + // Quad 1 – entlang Welt-X + setSeedV(pos, tex, vi+0, x-hw, yBot, z, 0,0); + setSeedV(pos, tex, vi+1, x+hw, yBot, z, 1,0); + setSeedV(pos, tex, vi+2, x+hw, yBot+size, z, 1,1); + setSeedV(pos, tex, vi+3, x-hw, yBot+size, z, 0,1); + // Quad 2 – entlang Welt-Z + setSeedV(pos, tex, vi+4, x, yBot, z-hw, 0,0); + setSeedV(pos, tex, vi+5, x, yBot, z+hw, 1,0); + setSeedV(pos, tex, vi+6, x, yBot+size, z+hw, 1,1); + setSeedV(pos, tex, vi+7, x, yBot+size, z-hw, 0,1); + + idx[ii] = vi; idx[ii+1] = vi+1; idx[ii+2] = vi+2; + idx[ii+3] = vi; idx[ii+4] = vi+2; idx[ii+5] = vi+3; + idx[ii+6] = vi+4; idx[ii+7] = vi+5; idx[ii+8] = vi+6; + idx[ii+9] = vi+4; idx[ii+10] = vi+6; idx[ii+11] = vi+7; + + vi += 8; ii += 12; + } + + Mesh m = new Mesh(); + m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos)); + m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex)); + m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx)); + m.updateBound(); + return new Geometry(name, m); + } + + private static void setSeedV(float[] pos, float[] tex, int vi, + float x, float y, float z, float u, float v) { + pos[vi*3] = x; pos[vi*3+1] = y; pos[vi*3+2] = z; + tex[vi*2] = u; tex[vi*2+1] = v; + } + // ── Mesh-Generierung (gemeinsame Logik, auch von GrassVertexRenderState genutzt) ── static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx, diff --git a/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java b/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java index 3294787..1057847 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/GrassVertexTool.java @@ -10,6 +10,8 @@ public class GrassVertexTool extends EditorTool { public final ToolParameter dryness = new ToolParameter("Vertrocknet %", 0.0, 0.0, 100.0); /** 1.0 = exakt gleiche Höhe, 0.0 = ±25 % Zufallsvariation */ public final ToolParameter uniformity = new ToolParameter("Gleichmäßigkeit", 1.0, 0.0, 1.0); + /** Anteil der Halme mit Samenstand (0 = keine, 100 = alle) */ + public final ToolParameter seedDensity = new ToolParameter("Samen %", 0.0, 0.0, 100.0); @Override public String getName() { return "Gras (Vertices)"; } @@ -17,5 +19,5 @@ public class GrassVertexTool extends EditorTool { public List getChoiceParameters() { return List.of(); } @Override - public List getParameters() { return List.of(brushRadius, bladeHeight, density, dryness, uniformity); } + public List getParameters() { return List.of(brushRadius, bladeHeight, density, dryness, uniformity, seedDensity); } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java index 7bc6d59..9ed5a37 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java @@ -52,8 +52,8 @@ public class DialogEditorView extends BorderPane { private TextField idField; private Label derivedLabelKey; - private Label derivedHeroKey; - private Label derivedNpcKey; + private ListView heroStepsView; + private ListView npcStepsView; private CheckBox rootCheck; private Spinner chapterSpinner; private ComboBox statusCombo; @@ -70,6 +70,7 @@ public class DialogEditorView extends BorderPane { private ListView triggersView; private ListView nextOptionsView; private ListView disablesOptionsView; + private ComboBox npcAnimCombo; // ── Graph-mode ──────────────────────────────────────────────────────────── @@ -252,20 +253,16 @@ public class DialogEditorView extends BorderPane { idField.setPromptText("z. B. begruessung"); derivedLabelKey = derivedKeyLabel("—"); - derivedHeroKey = derivedKeyLabel("—"); - derivedNpcKey = derivedKeyLabel("—"); idField.textProperty().addListener((obs, oldVal, newVal) -> { String id = newVal.trim(); if (id.isBlank()) { derivedLabelKey.setText("—"); - derivedHeroKey.setText("—"); - derivedNpcKey.setText("—"); + refreshStepKeys(""); } else { String base = "dialog." + currentNpcId + "." + id; derivedLabelKey.setText(base + ".description"); - derivedHeroKey.setText(base + ".textmainchar"); - derivedNpcKey.setText(base + ".textnpc"); + refreshStepKeys(base); } }); @@ -310,12 +307,31 @@ public class DialogEditorView extends BorderPane { new Separator() ); + heroStepsView = stepsListView(); + npcStepsView = stepsListView(); + Button addHeroBtn = smallBtn("+"); + Button delHeroBtn = smallBtn("−"); + Button addNpcBtn = smallBtn("+"); + Button delNpcBtn = smallBtn("−"); + addHeroBtn.setOnAction(e -> addStep(heroStepsView, ".textmainchar")); + delHeroBtn.setOnAction(e -> removeLastStep(heroStepsView)); + addNpcBtn.setOnAction(e -> addStep(npcStepsView, ".textnpc")); + delNpcBtn.setOnAction(e -> removeLastStep(npcStepsView)); + + npcAnimCombo = new ComboBox<>(); + npcAnimCombo.getItems().addAll("— (keine)", "talk1", "talk2", "disappoint", "secret"); + npcAnimCombo.setValue("— (keine)"); + npcAnimCombo.setMaxWidth(Double.MAX_VALUE); + form.getChildren().addAll( sectionTitle("Texte"), - row("Text Held:", derivedHeroKey), - row("Text NPC:", derivedNpcKey), - row("Audio Held:", placeholder("(wird später implementiert)")), - row("Audio NPC:", placeholder("(wird später implementiert)")), + sectionTitle("Held-Steps:"), + heroStepsView, + new HBox(4, addHeroBtn, delHeroBtn), + sectionTitle("NPC-Steps:"), + npcStepsView, + new HBox(4, addNpcBtn, delNpcBtn), + row("NPC-Animation:", npcAnimCombo), new Separator() ); @@ -469,7 +485,26 @@ public class DialogEditorView extends BorderPane { questCompleteField.setText(opt.getRequiresQuestComplete() != null ? safe(opt.getRequiresQuestComplete().getQuestId()) : ""); - // text keys are derived from ID and shown via derivedHeroKey / derivedNpcKey labels + // Held- und NPC-Steps laden (mit Migration aus Legacy-Feldern) + String base = (!id.isBlank()) ? "dialog." + currentNpcId + "." + id : ""; + heroStepsView.getItems().clear(); + java.util.List hs = opt.getHeroSteps(); + if (hs != null && !hs.isEmpty()) { + for (int i = 0; i < hs.size(); i++) { + heroStepsView.getItems().add(stepKey(base, ".textmainchar", i)); + } + } else if (opt.getTextHero() != null && !opt.getTextHero().id().isBlank()) { + heroStepsView.getItems().add(stepKey(base, ".textmainchar", 0)); + } + npcStepsView.getItems().clear(); + java.util.List ns = opt.getNpcSteps(); + if (ns != null && !ns.isEmpty()) { + for (int i = 0; i < ns.size(); i++) { + npcStepsView.getItems().add(stepKey(base, ".textnpc", i)); + } + } else if (opt.getTextNpc() != null && !opt.getTextNpc().id().isBlank()) { + npcStepsView.getItems().add(stepKey(base, ".textnpc", 0)); + } if (opt.getRequiredItem() != null && opt.getRequiredItem().getItem() != null) { reqItemIdField.setText(safe(opt.getRequiredItem().getItem().getItemId())); @@ -498,6 +533,9 @@ public class DialogEditorView extends BorderPane { .map(Quest::getQuestId) .forEach(abortsQuestsView.getItems()::add); + String anim = opt.getNpcAnimation(); + npcAnimCombo.setValue(anim != null && !anim.isBlank() ? anim : "— (keine)"); + enablesTradeCheck.setSelected(opt.isEnablesTrade()); triggersView.getItems().clear(); @@ -543,8 +581,24 @@ public class DialogEditorView extends BorderPane { if (!currentId.isBlank()) { String base = "dialog." + currentNpcId + "." + currentId; opt.setLabel(new TextReference(base + ".description")); - opt.setTextHero(new TextReference(base + ".textmainchar")); - opt.setTextNpc(new TextReference(base + ".textnpc")); + + List heroList = new ArrayList<>(); + for (int i = 0; i < heroStepsView.getItems().size(); i++) { + heroList.add(new de.blight.common.model.DialogStep( + new TextReference(stepKey(base, ".textmainchar", i)), null)); + } + opt.setHeroSteps(heroList); + opt.setTextHero(heroList.isEmpty() ? null + : new TextReference(stepKey(base, ".textmainchar", 0))); + + List npcList = new ArrayList<>(); + for (int i = 0; i < npcStepsView.getItems().size(); i++) { + npcList.add(new de.blight.common.model.DialogStep( + new TextReference(stepKey(base, ".textnpc", i)), null)); + } + opt.setNpcSteps(npcList); + opt.setTextNpc(npcList.isEmpty() ? null + : new TextReference(stepKey(base, ".textnpc", 0))); } if (rootCheck.isSelected()) { @@ -578,6 +632,9 @@ public class DialogEditorView extends BorderPane { } opt.setAbortsQuests(aborts); + String animVal = npcAnimCombo.getValue(); + opt.setNpcAnimation(animVal == null || animVal.startsWith("—") ? null : animVal); + opt.setEnablesTrade(enablesTradeCheck.isSelected()); opt.setOnChosenTriggers(new ArrayList<>(triggersView.getItems())); @@ -594,8 +651,8 @@ public class DialogEditorView extends BorderPane { private void clearDetailForm() { if (idField != null) idField.clear(); if (derivedLabelKey != null) derivedLabelKey.setText("—"); - if (derivedHeroKey != null) derivedHeroKey.setText("—"); - if (derivedNpcKey != null) derivedNpcKey.setText("—"); + if (heroStepsView != null) heroStepsView.getItems().clear(); + if (npcStepsView != null) npcStepsView.getItems().clear(); if (rootCheck != null) rootCheck.setSelected(false); if (chapterSpinner != null) chapterSpinner.getValueFactory().setValue(0); if (statusCombo != null) statusCombo.setValue("— (keine)"); @@ -608,6 +665,7 @@ public class DialogEditorView extends BorderPane { if (recvQuestField != null) recvQuestField.clear(); if (fulfillsQuestField != null) fulfillsQuestField.clear(); if (abortsQuestsView != null) abortsQuestsView.getItems().clear(); + if (npcAnimCombo != null) npcAnimCombo.setValue("— (keine)"); if (enablesTradeCheck != null) enablesTradeCheck.setSelected(false); if (triggersView != null) triggersView.getItems().clear(); if (nextOptionsView != null) nextOptionsView.getItems().clear(); @@ -649,6 +707,10 @@ public class DialogEditorView extends BorderPane { opt.setLabel(new TextReference(base + ".description")); opt.setTextHero(new TextReference(base + ".textmainchar")); opt.setTextNpc(new TextReference(base + ".textnpc")); + opt.setHeroSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep( + new TextReference(base + ".textmainchar"), null)))); + opt.setNpcSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep( + new TextReference(base + ".textnpc"), null)))); allOptions.put(id, opt); if (asRoot) { rootIds.add(id); @@ -1145,4 +1207,56 @@ public class DialogEditorView extends BorderPane { }); return dlg.showAndWait().orElse(null); } + + // ── Steps helpers ───────────────────────────────────────────────────────── + + private static ListView stepsListView() { + ListView lv = new ListView<>(); + lv.setPrefHeight(72); + lv.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;"); + lv.setCellFactory(v -> new ListCell<>() { + @Override protected void updateItem(String s, boolean empty) { + super.updateItem(s, empty); + setText(empty || s == null ? null : s); + setStyle("-fx-text-fill: #aaddaa;"); + } + }); + return lv; + } + + private void addStep(ListView lv, String suffix) { + String id = idField.getText().trim(); + if (id.isBlank()) { + return; + } + String base = "dialog." + currentNpcId + "." + id; + int idx = lv.getItems().size(); + lv.getItems().add(stepKey(base, suffix, idx)); + } + + private static void removeLastStep(ListView lv) { + if (!lv.getItems().isEmpty()) { + lv.getItems().remove(lv.getItems().size() - 1); + } + } + + private static String stepKey(String base, String suffix, int idx) { + return idx == 0 ? base + suffix : base + suffix + "." + idx; + } + + private void refreshStepKeys(String base) { + if (heroStepsView == null || npcStepsView == null) { + return; + } + refreshListKeys(heroStepsView, base, ".textmainchar"); + refreshListKeys(npcStepsView, base, ".textnpc"); + } + + private static void refreshListKeys(ListView lv, String base, String suffix) { + int count = lv.getItems().size(); + lv.getItems().clear(); + for (int i = 0; i < count; i++) { + lv.getItems().add(stepKey(base, suffix, i)); + } + } } diff --git a/blight-game/build.gradle b/blight-game/build.gradle index b9c3dd8..3b4b298 100644 --- a/blight-game/build.gradle +++ b/blight-game/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation "org.jmonkeyengine:jme3-jogg:${jmeVersion}" implementation "org.jmonkeyengine:jme3-plugins:${jmeVersion}" implementation "org.jmonkeyengine:jme3-testdata:${jmeVersion}" + implementation 'com.github.stephengold:SkyControl:1.1.0' implementation 'com.google.code.gson:gson:2.11.0' implementation 'org.slf4j:slf4j-api:2.0.17' implementation 'org.slf4j:jul-to-slf4j:2.0.17' diff --git a/blight-game/src/main/java/de/blight/game/BlightGame.java b/blight-game/src/main/java/de/blight/game/BlightGame.java index 8a53840..4c1621c 100644 --- a/blight-game/src/main/java/de/blight/game/BlightGame.java +++ b/blight-game/src/main/java/de/blight/game/BlightGame.java @@ -71,6 +71,7 @@ public class BlightGame extends SimpleApplication { } catch (IOException | NullPointerException ignored) {} settings.setResolution(gs.width, gs.height); settings.setFullscreen(gs.fullscreen); + settings.setBitsPerPixel(32); settings.setVSync(gs.vsync); settings.setSamples(gs.samples); diff --git a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java index 5bab08d..52e7436 100644 --- a/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java +++ b/blight-game/src/main/java/de/blight/game/animation/AnimationAction.java @@ -25,7 +25,12 @@ public enum AnimationAction { SIT_DOWN_FLOOR, SITTING_FLOOR, GET_UP_FLOOR, - REVIVE; + REVIVE, + TALK1, + TALK2, + DISAPPOINT, + SECRET, + TALK_SITTING; /** Lesbare Bezeichnung für UI-Anzeige, via TextRegistry aufgelöst. */ public String displayName() { @@ -49,7 +54,12 @@ public enum AnimationAction { case SIT_DOWN_FLOOR -> TextRegistry.resolve(null, key, "Hinsetzen (Boden)"); case SITTING_FLOOR -> TextRegistry.resolve(null, key, "Sitzen (Boden)"); case GET_UP_FLOOR -> TextRegistry.resolve(null, key, "Aufstehen (Boden)"); - case REVIVE -> TextRegistry.resolve(null, key, "Wiederbeleben"); + case REVIVE -> TextRegistry.resolve(null, key, "Wiederbeleben"); + case TALK1 -> TextRegistry.resolve(null, key, "Reden 1"); + case TALK2 -> TextRegistry.resolve(null, key, "Reden 2"); + case DISAPPOINT -> TextRegistry.resolve(null, key, "Enttäuscht"); + case SECRET -> TextRegistry.resolve(null, key, "Geheimnis"); + case TALK_SITTING -> TextRegistry.resolve(null, key, "Reden (Sitzend)"); }; } } diff --git a/blight-game/src/main/java/de/blight/game/audio/DialogTtsService.java b/blight-game/src/main/java/de/blight/game/audio/DialogTtsService.java new file mode 100644 index 0000000..7bd3dcd --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/audio/DialogTtsService.java @@ -0,0 +1,153 @@ +package de.blight.game.audio; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * TTS-Fallback für Dialog-Texte via System-TTS (espeak-ng / say / PowerShell SAPI). + * Benötigt keine externe Dependency. Schlägt lautlos fehl wenn kein TTS verfügbar ist. + * + *

Wird ersetzt sobald echte Audio-Dateien ({@link de.blight.common.model.AudioReference}) vorhanden sind. + */ +public class DialogTtsService { + + private static final Logger log = LoggerFactory.getLogger(DialogTtsService.class); + + /** Basiskommando für die aktuelle Plattform, oder null wenn TTS nicht verfügbar. */ + private String[] baseCmd; + private ExecutorService executor; + private volatile Process current; + + /** + * Erkennt das verfügbare TTS-Programm und startet den Hintergrund-Thread. + * Blockiert den Render-Thread nicht. + */ + public void initialize() { + executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "dialog-tts"); + t.setDaemon(true); + return t; + }); + + String os = System.getProperty("os.name", "").toLowerCase(); + if (os.contains("linux")) { + if (commandAvailable("espeak-ng")) { + // -v de = Deutsche Stimme; -s 150 = etwas langsamer als Standard + baseCmd = new String[]{"espeak-ng", "-v", "de", "-s", "150"}; + log.info("[TTS] espeak-ng (de) wird verwendet."); + } else if (commandAvailable("espeak")) { + baseCmd = new String[]{"espeak"}; + log.info("[TTS] espeak wird verwendet."); + } else if (commandAvailable("festival")) { + baseCmd = new String[]{"festival", "--tts"}; + log.info("[TTS] festival wird verwendet."); + } + } else if (os.contains("mac")) { + baseCmd = new String[]{"say"}; + log.info("[TTS] macOS say wird verwendet."); + } else if (os.contains("win")) { + // PowerShell SAPI – Text wird als letztes Argument angehängt + baseCmd = new String[]{ + "powershell", "-NoProfile", "-Command", + "Add-Type -AssemblyName System.Speech;" + + "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer;" + + "$s.Speak(" + }; + // Sonderbehandlung für Windows im buildCmd() + log.info("[TTS] Windows PowerShell SAPI wird verwendet."); + } + + if (baseCmd == null) { + log.warn("[TTS] Kein TTS-Programm gefunden – Sprachausgabe deaktiviert."); + } + + // Executor-Thread vorab starten; verhindert Verzögerung beim ersten speak()-Aufruf. + // Startet wenn TTS verfügbar ist einmal das Programm mit --version (kein Audio). + executor.submit(() -> { + if (baseCmd == null) return; + try { + new ProcessBuilder(baseCmd[0], "--version") + .redirectErrorStream(true).start().waitFor(); + } catch (Exception ignored) {} + }); + } + + /** + * Spricht den Text asynchron. Läuft als Kindprozess; laufende Ausgabe wird + * zuerst abgebrochen. Tut nichts wenn kein TTS verfügbar. + */ + public void speak(String text) { + if (baseCmd == null || text == null || text.isBlank()) return; + stop(); + String[] cmd = buildCmd(text); + executor.submit(() -> { + try { + Process p = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start(); + current = p; + p.waitFor(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.debug("[TTS] Prozess-Fehler", e); + } finally { + current = null; + } + }); + } + + /** Bricht die laufende Sprachausgabe sofort ab. */ + public void stop() { + Process p = current; + if (p != null) { + p.destroyForcibly(); + current = null; + } + } + + /** Gibt alle Ressourcen frei. Nur beim App-Shutdown aufrufen. */ + public void shutdown() { + stop(); + if (executor != null) executor.shutdownNow(); + baseCmd = null; + } + + // ── Hilfsmethoden ──────────────────────────────────────────────────────── + + private String[] buildCmd(String text) { + // Windows: PowerShell-Kommando braucht Sonderbehandlung + if (isWindows()) { + String escaped = text.replace("\"", "\\\""); + // Letztes Element ist das unvollständige Skript, Text anhängen + schließen + String script = baseCmd[baseCmd.length - 1] + "\"" + escaped + "\")"; + String[] cmd = new String[baseCmd.length]; + System.arraycopy(baseCmd, 0, cmd, 0, baseCmd.length - 1); + cmd[baseCmd.length - 1] = script; + return cmd; + } + // espeak / say / festival: Text als letztes Argument + String[] cmd = new String[baseCmd.length + 1]; + System.arraycopy(baseCmd, 0, cmd, 0, baseCmd.length); + cmd[baseCmd.length] = text; + return cmd; + } + + private static boolean commandAvailable(String cmd) { + try { + Process p = new ProcessBuilder("which", cmd) + .redirectErrorStream(true) + .start(); + return p.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } +} diff --git a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java index 40ee75e..e79b462 100644 --- a/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java +++ b/blight-game/src/main/java/de/blight/game/config/GraphicsScreen.java @@ -252,6 +252,7 @@ public class GraphicsScreen extends BaseAppState { AppSettings s = app.getContext().getSettings(); s.setResolution(live.width, live.height); s.setFullscreen(live.fullscreen); + s.setBitsPerPixel(32); s.setVSync(live.vsync); s.setSamples(live.samples); app.setSettings(s); diff --git a/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java b/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java index 7ee2094..b6f2d89 100644 --- a/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java +++ b/blight-game/src/main/java/de/blight/game/control/ThirdPersonCamera.java @@ -75,7 +75,7 @@ public class ThirdPersonCamera { } public void update(float tpf) { - if (target == null) return; + if (target == null || paused) return; Vector3f pivot = target.getWorldTranslation().add(0, TARGET_HEIGHT, 0); diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 55e0cfa..81ede51 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -35,6 +35,8 @@ import de.blight.game.control.ThirdPersonCamera; import com.jme3.post.FilterPostProcessor; import com.jme3.post.filters.FogFilter; import com.jme3.water.WaterFilter; +import de.blight.game.state.DynamicWaterFilter; +import de.blight.game.state.WaterInteractionState; import de.blight.game.state.GrassState; import de.blight.game.state.GrassVertexRenderState; import de.blight.game.state.LocationState; @@ -93,6 +95,7 @@ public class WorldScene extends BaseAppState { private de.blight.game.state.AmbientSoundSystem ambientSounds; private de.blight.game.audio.FootstepSystem footstepSystem; private de.blight.game.state.DrownState drownState; + private WaterInteractionState waterInteractionState; public WorldScene(KeyBindings keyBindings) { this.keyBindings = keyBindings; @@ -221,6 +224,8 @@ public class WorldScene extends BaseAppState { com.jme3.asset.plugins.FileLocator.class); } catch (Exception ignored) {} + app.getCamera().setFrustumFar(4000f); + BlightGame.status("Baue Beleuchtung und Himmel..."); buildLighting(); @@ -256,6 +261,9 @@ public class WorldScene extends BaseAppState { // Physik-Aktivierung wird in update() verzögert bis BulletAppState und // Terrain-Physics für den Spawn-Bereich bereit sind (verhindert Fall-durch-Terrain). physicsCharPending = true; + if (waterInteractionState != null) { + waterInteractionState.setPlayerControl(physicsChar, 0f); + } playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings); playerInput.setPhysicsCharacter(physicsChar); @@ -620,7 +628,7 @@ public class WorldScene extends BaseAppState { // Globales Wasser bei Y=0 (bedeckt die gesamte Karte unterhalb der Wasserlinie) try { - WaterFilter waterFilter = new WaterFilter(rootNode, sunDir); + DynamicWaterFilter waterFilter = new DynamicWaterFilter(rootNode, sunDir); waterFilter.setWaterHeight(0f); waterFilter.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f)); waterFilter.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f)); @@ -630,6 +638,9 @@ public class WorldScene extends BaseAppState { waterFilter.setSpeed(0.5f); fpp.addFilter(waterFilter); + waterInteractionState = new WaterInteractionState(waterFilter); + app.getStateManager().attach(waterInteractionState); + WeatherState weather = new WeatherState(); weather.setWaterFilter(waterFilter); @@ -640,6 +651,7 @@ public class WorldScene extends BaseAppState { fpp.addFilter(fogFilter); weather.setFogFilter(fogFilter); + weather.setSkyControl(dayNight.getSkyControl()); app.getStateManager().attach(weather); } catch (Exception e) { log.warn("[WorldScene] Post-Processing nicht verfügbar: {}", e.getMessage()); diff --git a/blight-game/src/main/java/de/blight/game/state/CloudsNode.java b/blight-game/src/main/java/de/blight/game/state/CloudsNode.java deleted file mode 100644 index 07a51b3..0000000 --- a/blight-game/src/main/java/de/blight/game/state/CloudsNode.java +++ /dev/null @@ -1,68 +0,0 @@ -package de.blight.game.state; - -import com.jme3.asset.AssetManager; -import com.jme3.material.Material; -import com.jme3.material.RenderState; -import com.jme3.math.ColorRGBA; -import com.jme3.math.Vector3f; -import com.jme3.renderer.queue.RenderQueue; -import com.jme3.scene.Geometry; -import com.jme3.scene.Node; -import com.jme3.scene.shape.Box; - -public class CloudsNode extends Node { - - private static final int COUNT = 14; - private static final float Y = 800f; - private static final float WRAP_HALF = 1800f; - - private final Geometry[] geoms = new Geometry[COUNT]; - private final float[] offsetX = new float[COUNT]; - private final float[] offsetZ = new float[COUNT]; - private final Material mat; - - private static final float[] W = { 350,250,420,180,300,380,220,160,480,260,310,200,440,190 }; - private static final float[] D = { 280,160,300,220,350,200,280,180,320,240,180,350,260,300 }; - private static final float[] H = { 30, 20, 25, 18, 35, 28, 22, 15, 40, 24, 20, 30, 28, 22 }; - private static final float[] IX = {-600,200,-100,500,-800,300,700,-400,0,-200,600,-700,100,-500}; - private static final float[] IZ = { 400,-300,700,-500,100,-700,-200,600,-100,800,-400,200,-600,500}; - - public CloudsNode(AssetManager assetManager) { - super("clouds"); - - mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); - mat.setColor("Color", new ColorRGBA(0.95f, 0.95f, 0.95f, 0.45f)); - mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); - mat.getAdditionalRenderState().setDepthWrite(false); - - for (int i = 0; i < COUNT; i++) { - Box box = new Box(W[i] * 0.5f, H[i] * 0.5f, D[i] * 0.5f); - Geometry g = new Geometry("cloud_" + i, box); - g.setMaterial(mat); - g.setQueueBucket(RenderQueue.Bucket.Transparent); - g.setShadowMode(RenderQueue.ShadowMode.Off); - offsetX[i] = IX[i]; - offsetZ[i] = IZ[i]; - attachChild(g); - geoms[i] = g; - } - } - - public void setCloudColor(ColorRGBA color) { - mat.setColor("Color", color); - } - - public void update(float tpf, Vector3f windDir, float windSpeed, Vector3f camPos) { - float dx = windDir.x * windSpeed * tpf; - float dz = windDir.z * windSpeed * tpf; - for (int i = 0; i < COUNT; i++) { - offsetX[i] += dx; - offsetZ[i] += dz; - if (offsetX[i] > WRAP_HALF) offsetX[i] -= WRAP_HALF * 2f; - if (offsetX[i] < -WRAP_HALF) offsetX[i] += WRAP_HALF * 2f; - if (offsetZ[i] > WRAP_HALF) offsetZ[i] -= WRAP_HALF * 2f; - if (offsetZ[i] < -WRAP_HALF) offsetZ[i] += WRAP_HALF * 2f; - geoms[i].setLocalTranslation(camPos.x + offsetX[i], Y, camPos.z + offsetZ[i]); - } - } -} diff --git a/blight-game/src/main/java/de/blight/game/state/DayNightState.java b/blight-game/src/main/java/de/blight/game/state/DayNightState.java index a6e9245..5c5279a 100644 --- a/blight-game/src/main/java/de/blight/game/state/DayNightState.java +++ b/blight-game/src/main/java/de/blight/game/state/DayNightState.java @@ -6,20 +6,20 @@ import com.jme3.app.state.BaseAppState; import com.jme3.bullet.BulletAppState; import com.jme3.light.AmbientLight; import com.jme3.light.DirectionalLight; -import com.jme3.material.Material; import com.jme3.math.*; -import com.jme3.renderer.queue.RenderQueue; -import com.jme3.scene.*; -import com.jme3.scene.shape.Sphere; +import com.jme3.scene.Node; import com.jme3.shadow.DirectionalLightShadowFilter; import com.jme3.shadow.EdgeFilteringMode; import de.blight.common.time.DayTime; import de.blight.common.time.TimeListener; +import jme3utilities.sky.CloudLayer; +import jme3utilities.sky.SkyControl; +import jme3utilities.sky.StarsOption; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Tag/Nacht-Zyklus: Sonne, Ambiente, Schatten, Himmelsfarbe. + * Tag/Nacht-Zyklus: SkyControl-Himmelskuppel, Sonne, Ambiente, Schatten. * Wiederverwendbar im Game und Editor (withShadows=false für Editor). */ public class DayNightState extends BaseAppState implements TimeListener { @@ -28,12 +28,10 @@ public class DayNightState extends BaseAppState implements TimeListener { // ── Farb-Konstanten ─────────────────────────────────────────────────────── - private static final ColorRGBA SUN_DAY = new ColorRGBA(1.00f, 0.95f, 0.88f, 1f); - private static final ColorRGBA SUN_DAWN = new ColorRGBA(1.00f, 0.55f, 0.20f, 1f); - private static final ColorRGBA AMB_DAY = new ColorRGBA(0.08f, 0.10f, 0.16f, 1f); - private static final ColorRGBA AMB_NIGHT = new ColorRGBA(0.04f, 0.04f, 0.12f, 1f); - private static final ColorRGBA BG_DAY = new ColorRGBA(0.35f, 0.55f, 0.85f, 1f); - private static final ColorRGBA BG_NIGHT = new ColorRGBA(0.01f, 0.01f, 0.06f, 1f); + private static final ColorRGBA SUN_DAY = new ColorRGBA(1.00f, 0.95f, 0.88f, 1f); + private static final ColorRGBA SUN_DAWN = new ColorRGBA(1.00f, 0.55f, 0.20f, 1f); + private static final ColorRGBA AMB_DAY = new ColorRGBA(0.08f, 0.10f, 0.16f, 1f); + private static final ColorRGBA AMB_NIGHT = new ColorRGBA(0.04f, 0.04f, 0.12f, 1f); // ── Konfiguration ───────────────────────────────────────────────────────── @@ -42,13 +40,18 @@ public class DayNightState extends BaseAppState implements TimeListener { // ── JME-Objekte ─────────────────────────────────────────────────────────── - private SimpleApplication app; - private Node rootNode; - private DirectionalLight sun; - private AmbientLight ambient; - private DirectionalLightShadowFilter shadowFilter; - private Spatial sky; - private Geometry sunSphere; + private SimpleApplication app; + private Node rootNode; + private DirectionalLight sun; + private AmbientLight ambient; + private DirectionalLightShadowFilter shadowFilter; + private Node skyNode; + private SkyControl skyControl; + + // ── Sonnenrichtungs-Drosselung ──────────────────────────────────────────── + + private static final float SUN_DIR_INTERVAL = 0.25f; + private float sunDirTimer = SUN_DIR_INTERVAL; // sofort bereit beim ersten Aufruf // ── Höhlen-Abdunkelung ──────────────────────────────────────────────────── @@ -56,16 +59,11 @@ public class DayNightState extends BaseAppState implements TimeListener { private float caveFactor = 0f; private float targetCaveFactor = 0f; private float caveCheckTimer = 0f; - /** Sonnenlicht-Basisfarbe (Tag/Nacht ohne Höhlen-Faktor). */ private ColorRGBA sunBaseColor = new ColorRGBA(1, 1, 1, 1); - /** Schatten-Intensität ohne Höhlen-Faktor. */ private float shadowBaseIntensity = 0f; - /** Raycast-Abstand nach oben (m) – trifft Decken bis zu dieser Höhe. */ private static final float CAVE_RAY_HEIGHT = 12f; - /** Zeitabstand zwischen Raycasts (Sek.). */ private static final float CAVE_CHECK_INTERVAL = 0.2f; - /** Überblendgeschwindigkeit (Einheit/Sek.) beim Ein- und Ausblenden. */ private static final float CAVE_FADE_SPEED = 1.2f; // ── Konstruktoren ───────────────────────────────────────────────────────── @@ -85,17 +83,17 @@ public class DayNightState extends BaseAppState implements TimeListener { this.withShadows = withShadows; } - public DayTime getDayTime() { return dayTime; } - - public void setPaused(boolean paused) { dayTime.setPaused(paused); } + public DayTime getDayTime() { return dayTime; } + public void setPaused(boolean p) { dayTime.setPaused(p); } /** Aktuelle Sonnenrichtung (normalisiert), oder (0,-1,0) falls noch nicht initialisiert. */ public Vector3f getSunDirection() { return sun != null ? sun.getDirection() : new Vector3f(0f, -1f, 0f); } - public DirectionalLight getSunLight() { return sun; } + public DirectionalLight getSunLight() { return sun; } public AmbientLight getAmbientLight() { return ambient; } + public SkyControl getSkyControl() { return skyControl; } /** * Übergibt einen Shadow-Filter an DayNightState, damit dieser die Intensität @@ -113,41 +111,14 @@ public class DayNightState extends BaseAppState implements TimeListener { this.app = (SimpleApplication) app; this.rootNode = this.app.getRootNode(); - // Himmel-Sphere (prozedural, nach innen gerendert) - Sphere skyMesh = new Sphere(32, 32, 450f, true, true); - Geometry skyGeo = new Geometry("sky", skyMesh); - Material skyMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); - skyMat.setColor("Color", BG_DAY.clone()); - skyGeo.setMaterial(skyMat); - skyGeo.setQueueBucket(RenderQueue.Bucket.Sky); - skyGeo.setShadowMode(RenderQueue.ShadowMode.Off); - rootNode.attachChild(skyGeo); - sky = skyGeo; - - // Sonnen-Sphere (sichtbare Sonne am Himmel) - Sphere sphereMesh = new Sphere(16, 16, 18f); - sunSphere = new Geometry("sunSphere", sphereMesh); - Material sunMat = new Material(app.getAssetManager(), - "Common/MatDefs/Misc/Unshaded.j3md"); - sunMat.setColor("Color", new ColorRGBA(1f, 0.92f, 0.65f, 1f)); - sunSphere.setMaterial(sunMat); - sunSphere.setQueueBucket(RenderQueue.Bucket.Sky); - sunSphere.setShadowMode(RenderQueue.ShadowMode.Off); - rootNode.attachChild(sunSphere); - - // Sonne (DirectionalLight) sun = new DirectionalLight(); rootNode.addLight(sun); - // Ambient ambient = new AmbientLight(); rootNode.addLight(ambient); - // Falls WorldScene.buildLighting() schon einen Filter registriert hat (setShadowFilter - // wurde vor initialize() aufgerufen, sun war damals null) — jetzt nachholen. if (shadowFilter != null) shadowFilter.setLight(sun); - // Schatten (nur im Game) – als Filter, damit WorldScene ihn in den FPP einhängen kann if (withShadows) { try { shadowFilter = new DirectionalLightShadowFilter(app.getAssetManager(), 4096, 4); @@ -156,12 +127,33 @@ public class DayNightState extends BaseAppState implements TimeListener { shadowFilter.setShadowZExtend(40f); shadowFilter.setShadowZFadeLength(8f); shadowFilter.setEdgeFilteringMode(EdgeFilteringMode.PCFPOISSON); - // Nicht direkt zum Viewport hinzufügen – WorldScene hängt ihn in den FPP ein } catch (Exception e) { log.error("Shadow-Filter konnte nicht erstellt werden", e); } } + // SkyControl – an eigenem Kindknoten damit re-attach nach detachAllChildren() klappt + skyNode = new Node("skyNode"); + rootNode.attachChild(skyNode); + + SkyControl sc = new SkyControl(app.getAssetManager(), app.getCamera(), + 0.9f, StarsOption.Cube, true); + // SkyControl default: North=+X, but JME3 world: North=-Z, East=+X + sc.getSunAndStars().setAxes(new Vector3f(0f, 0f, -1f), Vector3f.UNIT_Y); + // Deckel-Kuppel leicht unter den Horizont verlängern → kein sichtbarer Saum beim Blick nach unten + sc.setTopVerticalAngle(FastMath.HALF_PI + 0.12f); + skyNode.addControl(sc); + + CloudLayer clouds = sc.getCloudLayer(0); + clouds.setMotion(0.37f, 0f, 0.2f, 0.001f); + clouds.setTexture("Textures/skies/clouds/fbm.png", 0.3f); + clouds.setOpacity(0f); + + // Updater nur für Viewport-Hintergrundfarbe nutzen (Licht wird selbst gesteuert) + sc.getUpdater().addViewPort(this.app.getViewPort()); + sc.setEnabled(true); + skyControl = sc; + dayTime.addListener(this); onTimeChanged(dayTime.getTimeOfDay()); } @@ -174,9 +166,15 @@ public class DayNightState extends BaseAppState implements TimeListener { dayTime.removeListener(this); rootNode.removeLight(sun); rootNode.removeLight(ambient); - shadowFilter = null; // FPP-Cleanup liegt bei WorldScene - if (sky != null && sky.getParent() != null) rootNode.detachChild(sky); - if (sunSphere != null && sunSphere.getParent() != null) rootNode.detachChild(sunSphere); + shadowFilter = null; + if (skyControl != null) { + skyNode.removeControl(SkyControl.class); + skyControl = null; + } + if (skyNode != null && skyNode.getParent() != null) { + rootNode.detachChild(skyNode); + } + skyNode = null; } @Override protected void onEnable() {} @@ -184,31 +182,19 @@ public class DayNightState extends BaseAppState implements TimeListener { @Override public void update(float tpf) { + sunDirTimer += tpf; dayTime.update(tpf); - // Sky/Sonnen-Sphere nach rootNode.detachAllChildren() wiederherstellen - if (sky != null && sky.getParent() == null) - rootNode.attachChild(sky); - if (sunSphere != null && sunSphere.getParent() == null) - rootNode.attachChild(sunSphere); - - Vector3f camPos = app.getCamera().getLocation(); - - // Himmel-Sphere der Kamera folgen lassen - if (sky != null) - sky.setLocalTranslation(camPos); - - // Sonnen-Sphere immer relativ zur Kamera positionieren - if (sunSphere != null && sunSphere.getCullHint() != Spatial.CullHint.Always) { - sunSphere.setLocalTranslation(camPos.add(sun.getDirection().negate().mult(480f))); + // skyNode nach rootNode.detachAllChildren() wiederherstellen + if (skyNode != null && skyNode.getParent() == null) { + rootNode.attachChild(skyNode); } - updateCaveLighting(tpf, camPos); + updateCaveLighting(tpf, app.getCamera().getLocation()); } /** Prüft per Physik-Raycast ob die Kamera unter einer Decke ist und blendet das Sonnenlicht aus. */ private void updateCaveLighting(float tpf, Vector3f camPos) { - // Raycast nur alle CAVE_CHECK_INTERVAL Sekunden caveCheckTimer += tpf; if (caveCheckTimer >= CAVE_CHECK_INTERVAL) { caveCheckTimer = 0f; @@ -220,7 +206,6 @@ public class DayNightState extends BaseAppState implements TimeListener { } } - // Glatte Überblendung des Höhlen-Faktors float prev = caveFactor; if (caveFactor < targetCaveFactor) { caveFactor = Math.min(targetCaveFactor, caveFactor + CAVE_FADE_SPEED * tpf); @@ -228,12 +213,12 @@ public class DayNightState extends BaseAppState implements TimeListener { caveFactor = Math.max(targetCaveFactor, caveFactor - CAVE_FADE_SPEED * tpf); } - // Licht nur aktualisieren wenn sich der Faktor geändert hat if (caveFactor != prev) { float scale = 1f - caveFactor; sun.setColor(sunBaseColor.mult(scale)); - if (shadowFilter != null) + if (shadowFilter != null) { shadowFilter.setShadowIntensity(shadowBaseIntensity * scale); + } } } @@ -241,59 +226,33 @@ public class DayNightState extends BaseAppState implements TimeListener { @Override public void onTimeChanged(float t) { - float elev = sunElevation(t); - float elevC = FastMath.clamp(elev, 0f, 1f); + if (skyControl == null) return; - // ── Sonnenrichtung ────────────────────────────────────────────────── - float angle = t * FastMath.TWO_PI; - // Sonne bewegt sich von Ost (X+) über Süden nach West (X-) - Vector3f sunPos = new Vector3f( - FastMath.sin(angle) * 0.85f, - elev, - -0.15f - ); - sun.setDirection(sunPos.negate().normalizeLocal()); + // Sonne positionieren und Richtung holen + skyControl.getSunAndStars().setHour(t * 24f); + Vector3f toSun = skyControl.getSunAndStars().sunDirection(null); + float elevation = toSun.y; // +1 = Zenit, 0 = Horizont, -1 = Nadir + float elevC = FastMath.clamp(elevation, 0f, 1f); - // ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) ────────────── - float dawnFactor = 1f - FastMath.clamp(elev * 5f, 0f, 1f); + // ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) — jedes Frame ── + float dawnFactor = 1f - FastMath.clamp(elevation * 5f, 0f, 1f); ColorRGBA sunColor = SUN_DAWN.clone().interpolateLocal(SUN_DAY, 1f - dawnFactor); sunBaseColor = sunColor.mult(elevC * 0.68f); sun.setColor(sunBaseColor.mult(1f - caveFactor)); - // ── Sonnen-Sphere ausblenden wenn unter Horizont ──────────────────── - if (sunSphere != null) { - sunSphere.setCullHint(elev > -0.02f - ? Spatial.CullHint.Inherit - : Spatial.CullHint.Always); - // Farbe bei Dämmerung orange, bei Tag weiß-gelb - Material m = sunSphere.getMaterial(); - if (m != null) { - ColorRGBA sphereColor = new ColorRGBA(1f, 0.6f + elevC * 0.32f, 0.3f + elevC * 0.35f, 1f); - m.setColor("Color", sphereColor); - } - } - - // ── Ambient: Nacht → Tag ──────────────────────────────────────────── - float ambFactor = FastMath.clamp((elev + 0.2f) / 0.6f, 0f, 1f); + // ── Ambient: Nacht → Tag — jedes Frame ───────────────────────────── + float ambFactor = FastMath.clamp((elevation + 0.2f) / 0.6f, 0f, 1f); ambient.setColor(AMB_NIGHT.clone().interpolateLocal(AMB_DAY, ambFactor)); - // ── Schatten ──────────────────────────────────────────────────────── - shadowBaseIntensity = FastMath.clamp(elev * 0.8f, 0f, 0.5f); - if (shadowFilter != null) - shadowFilter.setShadowIntensity(shadowBaseIntensity * (1f - caveFactor)); + shadowBaseIntensity = FastMath.clamp(elevation * 0.8f, 0f, 0.5f); - // ── Himmel & Hintergrundfarbe ──────────────────────────────────────── - float skyFactor = FastMath.clamp((elev + 0.05f) / 0.2f, 0f, 1f); - ColorRGBA skyColor = BG_NIGHT.clone().interpolateLocal(BG_DAY, skyFactor); - if (sky instanceof Geometry skyGeo) - skyGeo.getMaterial().setColor("Color", skyColor); - app.getViewPort().setBackgroundColor(skyColor); - } - - // ── Hilfsmethoden ───────────────────────────────────────────────────────── - - /** Sonnenhöhe: −1 = Mitternacht, 0 = Horizont, +1 = Mittag. */ - private static float sunElevation(float t) { - return -FastMath.cos(t * FastMath.TWO_PI); + // Lichtrichtung und Schatten nur 4×/Sek aktualisieren → kein Shadow-Map-Flackern + if (sunDirTimer >= SUN_DIR_INTERVAL) { + sunDirTimer = 0f; + sun.setDirection(toSun.negateLocal()); + if (shadowFilter != null) { + shadowFilter.setShadowIntensity(shadowBaseIntensity * (1f - caveFactor)); + } + } } } diff --git a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java index b2cd9ed..6d8c734 100644 --- a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java @@ -7,9 +7,16 @@ import com.jme3.font.BitmapFont; import com.jme3.font.BitmapText; import com.jme3.input.KeyInput; import com.jme3.input.MouseInput; +import com.jme3.input.RawInputListener; import com.jme3.input.controls.ActionListener; import com.jme3.input.controls.KeyTrigger; import com.jme3.input.controls.MouseButtonTrigger; +import com.jme3.input.event.JoyAxisEvent; +import com.jme3.input.event.JoyButtonEvent; +import com.jme3.input.event.KeyInputEvent; +import com.jme3.input.event.MouseButtonEvent; +import com.jme3.input.event.MouseMotionEvent; +import com.jme3.input.event.TouchEvent; import com.jme3.material.Material; import com.jme3.material.RenderState; import com.jme3.math.ColorRGBA; @@ -20,12 +27,14 @@ import com.jme3.scene.Node; import com.jme3.scene.Spatial; import com.jme3.scene.shape.Quad; import de.blight.common.model.DialogOption; +import de.blight.common.model.DialogStep; import de.blight.common.model.MainCharacter; import de.blight.common.model.NPC; import de.blight.common.model.TextReference; import de.blight.common.model.trigger.ChangeRoutineTrigger; import de.blight.common.model.trigger.NpcStatusTrigger; import de.blight.common.model.trigger.Trigger; +import de.blight.game.audio.DialogTtsService; import de.blight.game.config.MenuCanvas; import de.blight.game.config.NinePatch; import de.blight.lang.TextResolver; @@ -57,12 +66,12 @@ public class DialogHudState extends BaseAppState { // ── Layout-Konstanten (virtuelle Koordinaten 1376 × 768) ───────────────── - private static final float PNL_X = 63f; - private static final float PNL_Y = 20f; - private static final float PNL_W = 1250f; - private static final float PNL_H = 255f; + private static final float PNL_X = 0f; + private static final float PNL_Y = 0f; + private static final float PNL_W = MenuCanvas.REF_W; // volle Breite + private static final float PNL_H = 270f; - private static final float MARGIN_X = 20f; + private static final float MARGIN_X = 24f; private static final float FONT_NAME = 18f; private static final float FONT_TEXT = 16f; private static final float FONT_OPT = 15f; @@ -70,8 +79,9 @@ public class DialogHudState extends BaseAppState { private static final float LINE_H_OPT = 26f; private static final int MAX_TEXT_LINES = 3; - private static final int MAX_CHARS_LINE = 82; + private static final int MAX_CHARS_LINE = 112; // proportional zur vollen Breite private static final int MAX_OPTIONS = 5; + private static final int MAX_OPT_CHARS = 90; // Textkürzung bei langen Hero-Sätzen private static final ColorRGBA COL_NAME = new ColorRGBA(1.00f, 0.90f, 0.55f, 1f); private static final ColorRGBA COL_TEXT = ColorRGBA.White; @@ -87,7 +97,6 @@ public class DialogHudState extends BaseAppState { private static final String ACT_DOWN = "_DlgDown"; private static final String ACT_CONFIRM = "_DlgConfirm"; private static final String ACT_SKIP = "_DlgSkip"; - private static final String ACT_CLICK = "_DlgClick"; // ── Zustände ───────────────────────────────────────────────────────────── @@ -113,9 +122,35 @@ public class DialogHudState extends BaseAppState { /** Verfügbare Optionen (inkl. Exit). */ private List displayOptions = new ArrayList<>(); private int selectedOpt = 0; + /** Erster sichtbarer Options-Index bei mehr Optionen als Slots. */ + private int scrollOffset = 0; - /** Panel-Bounds für Maus-Hit-Tests. */ - private float[][] optBounds = new float[MAX_OPTIONS][4]; // x,y,w,h + /** Sekunden bis zur automatischen Weiterleitung nach der letzten Textseite. */ + private static final float AUTO_ADVANCE_DELAY = 3.0f; + private float autoAdvanceTimer = -1f; + + // LMB-Klick aus RawInputListener – wird in update() verarbeitet. + // Weil RawInputListener VOR Action-Mappings läuft, ist beim dialog-öffnenden LMB + // phase noch HIDDEN → wird nicht gesetzt. Nachfolgende Klicks setzen ihn korrekt. + private boolean pendingClick = false; + + private final RawInputListener rawMouseListener = new RawInputListener() { + @Override public void beginInput() {} + @Override public void endInput() {} + @Override public void onMouseMotionEvent(MouseMotionEvent evt) {} + @Override public void onKeyEvent(KeyInputEvent evt) {} + @Override public void onJoyButtonEvent(JoyButtonEvent evt) {} + @Override public void onJoyAxisEvent(JoyAxisEvent evt) {} + @Override public void onTouchEvent(TouchEvent evt) {} + + @Override + public void onMouseButtonEvent(MouseButtonEvent evt) { + if (evt.getButtonIndex() == MouseInput.BUTTON_LEFT && evt.isPressed() + && phase == Phase.OPTIONS) { + pendingClick = true; + } + } + }; // ── JME3 UI-Knoten ──────────────────────────────────────────────────────── @@ -126,9 +161,15 @@ public class DialogHudState extends BaseAppState { private Node panel; private BitmapText nameText; - private BitmapText[] lineTexts = new BitmapText[MAX_TEXT_LINES]; + private BitmapText[] lineTexts = new BitmapText[MAX_TEXT_LINES]; private BitmapText hintText; - private BitmapText[] optTexts = new BitmapText[MAX_OPTIONS]; + private BitmapText[] optTexts = new BitmapText[MAX_OPTIONS]; + private BitmapText scrollUpText; + private BitmapText scrollDownText; + + // ── TTS ─────────────────────────────────────────────────────────────────── + + private DialogTtsService tts; // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -137,17 +178,79 @@ public class DialogHudState extends BaseAppState { this.app = (SimpleApplication) app; this.guiNode = this.app.getGuiNode(); this.font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt"); + + tts = new DialogTtsService(); + tts.initialize(); + + // Panel einmalig aufbauen, dann Shader sofort vorab kompilieren + buildPanel(); + this.app.getRenderManager().preloadScene(canvasNode); } @Override protected void onEnable() {} + @Override + public void update(float tpf) { + if (pendingClick) { + pendingClick = false; + onMouseClick(app.getInputManager().getCursorPosition()); + } + if (phase == Phase.OPTIONS && !displayOptions.isEmpty()) { + updateHover(); + } + if (autoAdvanceTimer > 0f && (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO)) { + autoAdvanceTimer -= tpf; + if (autoAdvanceTimer <= 0f) { + autoAdvanceTimer = -1f; + advanceText(); + } + } + } + + private void updateHover() { + Vector2f cursor = app.getInputManager().getCursorPosition(); + float scale = Math.min( + app.getCamera().getWidth() / MenuCanvas.REF_W, + app.getCamera().getHeight() / MenuCanvas.REF_H); + float oy2 = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f; + float vy = (cursor.y - oy2) / scale; + + if (vy < PNL_Y || vy > PNL_Y + PNL_H) return; + + float baseY = PNL_Y + PNL_H - 80f; + int total = displayOptions.size(); + for (int slot = 0; slot < MAX_OPTIONS; slot++) { + int optIdx = scrollOffset + slot; + if (optIdx >= total) break; + float ty = baseY - slot * LINE_H_OPT; + if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { + if (selectedOpt != optIdx) { + selectedOpt = optIdx; + renderOptions(); + } + return; + } + } + } + @Override protected void onDisable() { if (phase != Phase.HIDDEN) closePanel(); } - @Override protected void cleanup(Application app) {} + @Override + protected void cleanup(Application app) { + if (tts != null) { + tts.shutdown(); + tts = null; + } + if (canvasNode != null) { + guiNode.detachChild(canvasNode); + canvasNode = null; + panel = null; + } + } // ── Public API ──────────────────────────────────────────────────────────── @@ -166,29 +269,25 @@ public class DialogHudState extends BaseAppState { this.onOptionsShown = onOptionsShown; this.optionsShownFired = false; - buildPanel(); + setHudsVisible(false); + canvasNode.setCullHint(Spatial.CullHint.Inherit); registerInput(); - // Erst: Default-Message anzeigen (falls vorhanden), dann Optionen - TextReference greeting = npc.getDefaultMessage(); - String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null; - List available = resolveOptions(npc, mc); - if (greetText != null && !greetText.isBlank()) { - showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, () -> { - if (available.isEmpty()) { - log.info("[DialogHud] NPC '{}' hat keine Optionen nach Begrüßung.", npc.getCharacterId()); - closeDialog(); - } else { - showOptions(available); - } - }); - } else if (!available.isEmpty()) { + if (!available.isEmpty()) { + // Optionen sofort anzeigen – keine Begrüßungsverzögerung showOptions(available); } else { - log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung – Dialog übersprungen.", npc.getCharacterId()); - closeDialog(); + // Kein Optionen: Begrüßungstext zeigen und dann schließen + TextReference greeting = npc.getDefaultMessage(); + String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null; + if (greetText != null && !greetText.isBlank()) { + showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, this::closeDialog); + } else { + log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung – Dialog übersprungen.", npc.getCharacterId()); + closeDialog(); + } } } @@ -200,12 +299,27 @@ public class DialogHudState extends BaseAppState { private Runnable afterText; + private void playSteps(Phase ph, String speaker, List steps, int idx, Runnable after) { + if (idx >= steps.size()) { + after.run(); + return; + } + DialogStep step = steps.get(idx); + String text = step.getText() != null ? TextResolver.get().resolve(step.getText()) : null; + if (text == null || text.isBlank()) { + playSteps(ph, speaker, steps, idx + 1, after); + return; + } + showText(ph, speaker, text, () -> playSteps(ph, speaker, steps, idx + 1, after)); + } + private void showText(Phase textPhase, String speaker, String rawText, Runnable after) { this.phase = textPhase; this.afterText = after; this.textPages = paginate(wrapText(rawText, MAX_CHARS_LINE), MAX_TEXT_LINES); this.pageIdx = 0; renderCurrentTextPage(speaker); + if (tts != null) tts.speak(rawText); } private void renderCurrentTextPage(String speaker) { @@ -219,9 +333,13 @@ public class DialogHudState extends BaseAppState { } boolean more = (pageIdx + 1) < textPages.size(); - hintText.setText(more - ? t("dialog.hint.advance") - : t("dialog.hint.continue")); + if (more) { + autoAdvanceTimer = -1f; + hintText.setText(t("dialog.hint.advance")); + } else { + autoAdvanceTimer = AUTO_ADVANCE_DELAY; + hintText.setText(""); + } hintText.setCullHint(Spatial.CullHint.Inherit); for (BitmapText o : optTexts) o.setCullHint(Spatial.CullHint.Always); @@ -232,7 +350,8 @@ public class DialogHudState extends BaseAppState { private void showOptions(List options) { phase = Phase.OPTIONS; displayOptions.clear(); - selectedOpt = 0; + selectedOpt = 0; + scrollOffset = 0; if (!optionsShownFired && onOptionsShown != null) { optionsShownFired = true; @@ -240,11 +359,27 @@ public class DialogHudState extends BaseAppState { } for (DialogOption opt : options) { - String label = opt.getLabel() != null - ? TextResolver.get().resolveId(opt.getLabel().id()) : ""; + // Primär: erster Hero-Step oder textHero (Fallback für Legacy-Daten) + String label = ""; + List hs = opt.getHeroSteps(); + if (hs != null && !hs.isEmpty() && hs.get(0).getText() != null) { + label = TextResolver.get().resolve(hs.get(0).getText()); + } + if (label.isBlank() && opt.getTextHero() != null) { + label = TextResolver.get().resolve(opt.getTextHero()); + } + // Fallback: Label-Key + if (label.isBlank() && opt.getLabel() != null) { + label = TextResolver.get().resolveId(opt.getLabel().id()); + } + // Letzter Fallback: Option-ID (gekürzt) if (label.isBlank()) { label = opt.getId() != null - ? opt.getId().substring(0, Math.min(opt.getId().length(), 20)) : "?"; + ? opt.getId().substring(0, Math.min(opt.getId().length(), 40)) : "?"; + } + // Lange Texte kürzen damit sie in eine Zeile passen + if (label.length() > MAX_OPT_CHARS) { + label = label.substring(0, MAX_OPT_CHARS - 1) + "…"; } displayOptions.add(new DisplayOption(label, opt)); } @@ -260,40 +395,51 @@ public class DialogHudState extends BaseAppState { private void renderOptions() { float baseY = PNL_Y + PNL_H - 80f; + int total = displayOptions.size(); - for (int i = 0; i < MAX_OPTIONS; i++) { - if (i < displayOptions.size()) { - DisplayOption do_ = displayOptions.get(i); - boolean sel = (i == selectedOpt); - String prefix = sel ? "► " : " "; - optTexts[i].setText(prefix + (i + 1) + ". " + do_.label()); - optTexts[i].setColor(sel ? COL_OPT_SEL : COL_OPT); - optTexts[i].setCullHint(Spatial.CullHint.Inherit); + boolean canScrollUp = scrollOffset > 0; + boolean canScrollDown = (scrollOffset + MAX_OPTIONS) < total; - float ty = baseY - i * LINE_H_OPT; - optTexts[i].setLocalTranslation(PNL_X + MARGIN_X, ty, 2f); + scrollUpText .setCullHint(canScrollUp ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); + scrollDownText.setCullHint(canScrollDown ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); - optBounds[i][0] = PNL_X + MARGIN_X; - optBounds[i][1] = ty - FONT_OPT; - optBounds[i][2] = PNL_W - MARGIN_X * 2; - optBounds[i][3] = FONT_OPT + 4; + for (int slot = 0; slot < MAX_OPTIONS; slot++) { + int optIdx = scrollOffset + slot; + if (optIdx < total) { + DisplayOption do_ = displayOptions.get(optIdx); + boolean sel = (optIdx == selectedOpt); + optTexts[slot].setText((sel ? "► " : " ") + do_.label()); + optTexts[slot].setColor(sel ? COL_OPT_SEL : COL_OPT); + optTexts[slot].setLocalTranslation(PNL_X + MARGIN_X, baseY - slot * LINE_H_OPT, 2f); + optTexts[slot].setCullHint(Spatial.CullHint.Inherit); } else { - optTexts[i].setCullHint(Spatial.CullHint.Always); + optTexts[slot].setCullHint(Spatial.CullHint.Always); } } } + /** Stellt sicher, dass selectedOpt im sichtbaren Scroll-Fenster liegt. */ + private void ensureVisible() { + if (selectedOpt < scrollOffset) { + scrollOffset = selectedOpt; + } else if (selectedOpt >= scrollOffset + MAX_OPTIONS) { + scrollOffset = selectedOpt - MAX_OPTIONS + 1; + } + } + // ── Input-Handler ───────────────────────────────────────────────────────── private void onUp() { if (phase != Phase.OPTIONS) return; selectedOpt = Math.max(0, selectedOpt - 1); + ensureVisible(); renderOptions(); } private void onDown() { if (phase != Phase.OPTIONS) return; selectedOpt = Math.min(displayOptions.size() - 1, selectedOpt + 1); + ensureVisible(); renderOptions(); } @@ -307,42 +453,71 @@ public class DialogHudState extends BaseAppState { private void onSkip() { if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) { - // Alle verbleibenden Seiten überspringen + autoAdvanceTimer = -1f; + if (tts != null) tts.stop(); pageIdx = textPages.size(); if (afterText != null) { Runnable cb = afterText; afterText = null; cb.run(); } - } else if (phase == Phase.OPTIONS) { - confirmOption(selectedOpt); } + // RMB only skips text – never selects options } private void onMouseClick(Vector2f cursor) { if (phase != Phase.OPTIONS) { onConfirm(); return; } - // Kursorenanpassung auf virtuelle Koordinaten - float ox = (app.getCamera().getWidth() - MenuCanvas.REF_W) / 2f; - float oy = (app.getCamera().getHeight() - MenuCanvas.REF_H) / 2f; float scale = Math.min( app.getCamera().getWidth() / MenuCanvas.REF_W, app.getCamera().getHeight() / MenuCanvas.REF_H); - - float ox2 = (app.getCamera().getWidth() - MenuCanvas.REF_W * scale) / 2f; float oy2 = (app.getCamera().getHeight() - MenuCanvas.REF_H * scale) / 2f; - float vx = (cursor.x - ox2) / scale; - float vy = (cursor.y - oy2) / scale; + float vy = (cursor.y - oy2) / scale; - for (int i = 0; i < displayOptions.size() && i < MAX_OPTIONS; i++) { - float bx = optBounds[i][0], by = optBounds[i][1]; - float bw = optBounds[i][2], bh = optBounds[i][3]; - if (vx >= bx && vx <= bx + bw && vy >= by && vy <= by + bh) { - selectedOpt = i; + log.debug("[DialogHud] Klick vy={} (Panel {}-{})", vy, PNL_Y, PNL_Y + PNL_H); + + if (vy < PNL_Y || vy > PNL_Y + PNL_H) return; + + float baseY = PNL_Y + PNL_H - 80f; + int total = displayOptions.size(); + + // Klick auf Scroll-Pfeil ▲ + if (scrollOffset > 0) { + float ty = baseY + LINE_H_OPT; + if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { + scrollOffset = Math.max(0, scrollOffset - 1); renderOptions(); - confirmOption(i); return; } } + + // Klick auf Scroll-Pfeil ▼ + if (scrollOffset + MAX_OPTIONS < total) { + float ty = baseY - MAX_OPTIONS * LINE_H_OPT; + if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { + scrollOffset = Math.min(total - MAX_OPTIONS, scrollOffset + 1); + renderOptions(); + return; + } + } + + // Klick auf Options-Slot + for (int slot = 0; slot < MAX_OPTIONS; slot++) { + int optIdx = scrollOffset + slot; + if (optIdx >= total) break; + float ty = baseY - slot * LINE_H_OPT; + float by = ty - LINE_H_OPT + 4f; + float byEnd = ty + 4f; + if (vy >= by && vy <= byEnd) { + log.debug("[DialogHud] Treffer: Option {} (slot {} ty={})", optIdx, slot, ty); + selectedOpt = optIdx; + renderOptions(); + confirmOption(optIdx); + return; + } + } + + log.debug("[DialogHud] Klick ohne Treffer: vy={}", vy); } private void advanceText() { + autoAdvanceTimer = -1f; if (pageIdx + 1 < textPages.size()) { pageIdx++; renderCurrentTextPage(nameText.getText()); @@ -365,30 +540,46 @@ public class DialogHudState extends BaseAppState { DialogOption opt = selected.option(); - // Held-Text - String heroText = opt.getTextHero() != null - ? TextResolver.get().resolve(opt.getTextHero()) : null; - // NPC-Text - String npcText = opt.getTextNpc() != null - ? TextResolver.get().resolve(opt.getTextNpc()) : null; + // Steps mit Fallback auf Legacy-Felder + List heroSteps = opt.getHeroSteps(); + List npcSteps = opt.getNpcSteps(); + if (heroSteps == null || heroSteps.isEmpty()) { + if (opt.getTextHero() != null) { + heroSteps = List.of(new DialogStep(opt.getTextHero(), opt.getAudioHero())); + } else { + heroSteps = List.of(); + } + } + if (npcSteps == null || npcSteps.isEmpty()) { + if (opt.getTextNpc() != null) { + npcSteps = List.of(new DialogStep(opt.getTextNpc(), opt.getAudioNpc())); + } else { + npcSteps = List.of(); + } + } + final List fHeroSteps = heroSteps; + final List fNpcSteps = npcSteps; + final String fNpcAnim = opt.getNpcAnimation(); // Option-Effekte anwenden (Optionen aktualisieren, Quest, etc.) applyOption(opt); List nextOpts = resolveOptions(currentNpc, mainChar); - if (heroText != null && !heroText.isBlank()) { - showText(Phase.TEXT_HERO, t("dialog.speaker.player"), heroText, () -> { - if (npcText != null && !npcText.isBlank()) { - showText(Phase.TEXT_NPC, resolveNpcName(currentNpc), npcText, () -> { + if (!fHeroSteps.isEmpty()) { + playSteps(Phase.TEXT_HERO, t("dialog.speaker.player"), fHeroSteps, 0, () -> { + if (!fNpcSteps.isEmpty()) { + triggerNpcDialogAnim(fNpcAnim); + playSteps(Phase.TEXT_NPC, resolveNpcName(currentNpc), fNpcSteps, 0, () -> { if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts); }); } else { if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts); } }); - } else if (npcText != null && !npcText.isBlank()) { - showText(Phase.TEXT_NPC, resolveNpcName(currentNpc), npcText, () -> { + } else if (!fNpcSteps.isEmpty()) { + triggerNpcDialogAnim(fNpcAnim); + playSteps(Phase.TEXT_NPC, resolveNpcName(currentNpc), fNpcSteps, 0, () -> { if (nextOpts.isEmpty()) closeDialog(); else showOptions(nextOpts); }); } else { @@ -396,6 +587,16 @@ public class DialogHudState extends BaseAppState { } } + private void triggerNpcDialogAnim(String anim) { + String effective = (anim != null && !anim.isBlank()) + ? anim + : (Math.random() < 0.5 ? "talk1" : "talk2"); + WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class); + if (npcs != null) { + npcs.playNpcDialogAnim(effective); + } + } + // ── Optionen-Auflösung ──────────────────────────────────────────────────── private List resolveOptions(NPC npc, MainCharacter mc) { @@ -454,7 +655,8 @@ public class DialogHudState extends BaseAppState { // ── UI-Aufbau / -Abbau ──────────────────────────────────────────────────── private void buildPanel() { - canvasNode = MenuCanvas.createFixedCanvas(app.getCamera()); + canvasNode = MenuCanvas.createCanvas(app.getCamera()); + canvasNode.setCullHint(Spatial.CullHint.Always); guiNode.attachChild(canvasNode); panel = new Node("dialog-panel"); @@ -494,16 +696,29 @@ public class DialogHudState extends BaseAppState { panel.attachChild(optTexts[i]); } + // Scroll-Indikatoren (Positionen relativ zu baseY = PNL_Y + PNL_H - 80) + float baseY0 = PNL_Y + PNL_H - 80f; + scrollUpText = makeTxt("▲ mehr", FONT_OPT - 2f, COL_HINT); + scrollUpText.setLocalTranslation(PNL_X + MARGIN_X, baseY0 + LINE_H_OPT, 2f); + scrollUpText.setCullHint(Spatial.CullHint.Always); + panel.attachChild(scrollUpText); + + scrollDownText = makeTxt("▼ mehr", FONT_OPT - 2f, COL_HINT); + scrollDownText.setLocalTranslation(PNL_X + MARGIN_X, baseY0 - MAX_OPTIONS * LINE_H_OPT, 2f); + scrollDownText.setCullHint(Spatial.CullHint.Always); + panel.attachChild(scrollDownText); + canvasNode.attachChild(panel); } private void closePanel() { - phase = Phase.HIDDEN; + phase = Phase.HIDDEN; + autoAdvanceTimer = -1f; + if (tts != null) tts.stop(); + setHudsVisible(true); unregisterInput(); if (canvasNode != null) { - guiNode.detachChild(canvasNode); - canvasNode = null; - panel = null; + canvasNode.setCullHint(Spatial.CullHint.Always); } textPages.clear(); displayOptions.clear(); @@ -522,17 +737,19 @@ public class DialogHudState extends BaseAppState { im.addMapping(ACT_CONFIRM, new KeyTrigger(KeyInput.KEY_RETURN), new KeyTrigger(KeyInput.KEY_NUMPADENTER)); im.addMapping(ACT_SKIP, new MouseButtonTrigger(MouseInput.BUTTON_RIGHT)); - im.addMapping(ACT_CLICK, new MouseButtonTrigger(MouseInput.BUTTON_LEFT)); - im.addListener(inputListener, ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP, ACT_CLICK); + im.addListener(inputListener, ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP); + im.addRawInputListener(rawMouseListener); im.setCursorVisible(true); } private void unregisterInput() { var im = app.getInputManager(); - try { im.removeListener(inputListener); } catch (Exception ignored) {} - for (String a : new String[]{ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP, ACT_CLICK}) { + try { im.removeListener(inputListener); } catch (Exception ignored) {} + try { im.removeRawInputListener(rawMouseListener); } catch (Exception ignored) {} + for (String a : new String[]{ACT_UP, ACT_DOWN, ACT_CONFIRM, ACT_SKIP}) { try { im.deleteMapping(a); } catch (Exception ignored) {} } + pendingClick = false; im.setCursorVisible(false); } @@ -543,7 +760,6 @@ public class DialogHudState extends BaseAppState { case ACT_DOWN -> onDown(); case ACT_CONFIRM -> onConfirm(); case ACT_SKIP -> onSkip(); - case ACT_CLICK -> onMouseClick(app.getInputManager().getCursorPosition()); } }; @@ -580,6 +796,13 @@ public class DialogHudState extends BaseAppState { private static String t(String id) { return TextResolver.get().resolveId(id); } + private void setHudsVisible(boolean visible) { + HudState hud = getStateManager().getState(HudState.class); + if (hud != null) { hud.setEnabled(visible); } + InteractionHudState ihs = getStateManager().getState(InteractionHudState.class); + if (ihs != null) { ihs.setEnabled(visible); } + } + /** Bricht Text an Wortgrenzen auf. */ private static List wrapText(String text, int maxChars) { List lines = new ArrayList<>(); diff --git a/blight-game/src/main/java/de/blight/game/state/DynamicWaterFilter.java b/blight-game/src/main/java/de/blight/game/state/DynamicWaterFilter.java new file mode 100644 index 0000000..71eebac --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/DynamicWaterFilter.java @@ -0,0 +1,66 @@ +package de.blight.game.state; + +import com.jme3.asset.AssetManager; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +import com.jme3.renderer.RenderManager; +import com.jme3.renderer.ViewPort; +import com.jme3.scene.Node; +import com.jme3.texture.Texture2D; +import com.jme3.water.WaterFilter; + +/** + * Extends WaterFilter to support the dynamic wave-interaction texture + * defined in our overridden Water.j3md / Water.frag shaders. + * Handles the case where setWaveMap() is called before the filter is + * initialized by queuing the params and applying them in initFilter(). + */ +public class DynamicWaterFilter extends WaterFilter { + + private Texture2D pendingMap; + private Vector2f pendingCenter; + private float pendingInvExtent; + private float pendingStrength; + + public DynamicWaterFilter(Node scene, Vector3f lightDir) { + super(scene, lightDir); + } + + @Override + protected void initFilter(AssetManager manager, RenderManager rm, ViewPort vp, int w, int h) { + super.initFilter(manager, rm, vp, w, h); + if (pendingMap != null) { + applyNow(pendingMap, pendingCenter, pendingInvExtent, pendingStrength); + pendingMap = null; + } + } + + /** Called once from WaterInteractionState after the texture is ready. */ + public void setWaveMap(Texture2D map, Vector2f center, float invExtent, float strength) { + if (getMaterial() != null) { + applyNow(map, center, invExtent, strength); + } else { + pendingMap = map; + pendingCenter = center; + pendingInvExtent = invExtent; + pendingStrength = strength; + } + } + + /** Called every time the simulation area recenters around the player. */ + public void updateWaveCenter(Vector2f center) { + if (getMaterial() != null) { + getMaterial().setVector2("WaveAreaCenter", center); + } else { + pendingCenter = center; + } + } + + private void applyNow(Texture2D map, Vector2f center, float invExtent, float strength) { + getMaterial().setBoolean("WaveInteraction", true); + getMaterial().setTexture("WaveMap", map); + getMaterial().setVector2("WaveAreaCenter", center); + getMaterial().setFloat("WaveAreaInvExtent", invExtent); + getMaterial().setFloat("WaveStrength", strength); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java b/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java index 2832ea0..5b4bc94 100644 --- a/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java +++ b/blight-game/src/main/java/de/blight/game/state/GrassVertexRenderState.java @@ -7,12 +7,16 @@ import com.jme3.asset.AssetManager; import com.jme3.material.Material; import com.jme3.material.RenderState; import com.jme3.math.ColorRGBA; +import com.jme3.math.FastMath; +import com.jme3.math.Vector2f; import com.jme3.math.Vector3f; +import com.jme3.renderer.queue.RenderQueue; import com.jme3.scene.Geometry; import com.jme3.scene.Mesh; import com.jme3.scene.Node; import com.jme3.scene.Spatial; import com.jme3.scene.VertexBuffer; +import com.jme3.texture.Texture; import com.jme3.util.BufferUtils; import de.blight.common.GrassVertexBlade; import de.blight.common.GrassVertexIO; @@ -21,7 +25,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Rendert Vertex-Gras-Büschel im Spiel: 3 geneigte, verjüngte Halme pro Büschel, @@ -43,7 +49,7 @@ public class GrassVertexRenderState extends BaseAppState // ── Geometrie (identisch zu GrassVertexState) ───────────────────────────── private static final int BLADES_PER_TUFT = 3; private static final int SEGMENTS = 5; - private static final float WIDTH_FACTOR = 0.05f; + private static final float WIDTH_FACTOR = 0.10f; private static final float BEND_FACTOR = 0.15f; private static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f); private static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f); @@ -54,11 +60,19 @@ public class GrassVertexRenderState extends BaseAppState private static final ColorRGBA VERY_DRY_ROOT_COLOR = new ColorRGBA(0.18f, 0.09f, 0.02f, 1f); private static final ColorRGBA VERY_DRY_TIP_COLOR = new ColorRGBA(0.38f, 0.20f, 0.05f, 1f); + // ── Samen-Texturen ──────────────────────────────────────────────────────── + private static final String SEED_TEX_BASE = "Textures/internal/gras/seeds/seeds"; + private static final String SEED_TEX_EXT = ".png"; + private static final float SEED_SIZE_FACTOR = 0.45f; + private static final float SEED_Y_FACTOR = 0.78f; + // ── Zustand ─────────────────────────────────────────────────────────────── private final TerrainChunkState terrainChunkState; private AssetManager assetManager; private Node grassNode; private Material material; + private Material[] seedMaterials = new Material[0]; + private Material seedStalkMaterial; private int nextChunk = 0; @SuppressWarnings("unchecked") @@ -87,7 +101,41 @@ public class GrassVertexRenderState extends BaseAppState log.warn("[GrassVertexRenderState] Daten nicht ladbar: {}", e.getMessage()); } - material = buildMaterial(); + material = buildMaterial(); + seedMaterials = loadSeedMaterials(); + seedStalkMaterial = buildSeedStalkMaterial(); + } + + private Material buildSeedStalkMaterial() { + 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; + } + + private Material[] loadSeedMaterials() { + List mats = new ArrayList<>(); + for (int i = 1; i <= 99; i++) { + String path = SEED_TEX_BASE + i + SEED_TEX_EXT; + try { + Texture tex = assetManager.loadTexture(path); + Material mat = new Material(assetManager, "MatDefs/GrassSeed.j3md"); + mat.setTexture("ColorMap", tex); + 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); + mats.add(mat); + log.info("[GrassVertexRenderState] Samen-Textur geladen: {}", path); + } catch (Exception e) { + break; + } + } + return mats.toArray(new Material[0]); } @Override @@ -110,9 +158,39 @@ public class GrassVertexRenderState extends BaseAppState if (material != null && material.getMaterialDef().getMaterialParam("SunColor") != null) { DayNightState dns = getApplication().getStateManager().getState(DayNightState.class); if (dns != null && dns.getSunLight() != null) { - material.setVector3("SunDir", dns.getSunDirection().negate()); - com.jme3.math.ColorRGBA sc = dns.getSunLight().getColor(); - material.setColor("SunColor", sc); + Vector3f sunDir = dns.getSunDirection().negate(); + ColorRGBA sc = dns.getSunLight().getColor(); + material.setVector3("SunDir", sunDir); + material.setColor("SunColor", sc); + if (seedStalkMaterial != null) { + seedStalkMaterial.setVector3("SunDir", sunDir); + seedStalkMaterial.setColor("SunColor", sc); + } + for (Material sm : seedMaterials) { + sm.setVector3("SunDir", sunDir); + sm.setColor("SunColor", sc); + } + } + } + + WeatherState ws = getApplication().getStateManager().getState(WeatherState.class); + if (ws != null && material != null) { + Vector3f wd3 = ws.getWindDirection(); + Vector2f wDir = new Vector2f(wd3.x, wd3.z); + float speed = Math.max(0.05f, ws.getWindSpeed() * 0.05f); + float strength = FastMath.clamp(ws.getWindSpeed() * 0.009f, 0.01f, 0.45f); + material.setVector2("WindDir", wDir); + material.setFloat("WindSpeed", speed); + material.setFloat("WindStrength", strength); + if (seedStalkMaterial != null) { + seedStalkMaterial.setVector2("WindDir", wDir); + seedStalkMaterial.setFloat("WindSpeed", speed); + seedStalkMaterial.setFloat("WindStrength", strength); + } + for (Material sm : seedMaterials) { + sm.setVector2("WindDir", wDir); + sm.setFloat("WindSpeed", speed); + sm.setFloat("WindStrength", strength); } } } @@ -180,10 +258,141 @@ public class GrassVertexRenderState extends BaseAppState Node node = new Node("gvc_" + ci); node.attachChild(geo); + + // ── Samen-Stiele + Kreuze ───────────────────────────────────────────── + if (seedMaterials.length > 0) { + List seededAll = new ArrayList<>(); + Map> byTex = new HashMap<>(); + for (GrassVertexBlade b : blades) { + if (b.seedIdx() >= 0 && b.seedIdx() < seedMaterials.length) { + seededAll.add(b); + byTex.computeIfAbsent(b.seedIdx(), k -> new ArrayList<>()).add(b); + } + } + if (!seededAll.isEmpty()) { + Geometry stalkGeo = buildSeedStalkMesh("stalk_" + ci, seededAll); + stalkGeo.setMaterial(seedStalkMaterial); + node.attachChild(stalkGeo); + } + for (Map.Entry> e : byTex.entrySet()) { + Geometry seedGeo = buildSeedCrossMesh("seed_" + ci + "_" + e.getKey(), e.getValue()); + seedGeo.setMaterial(seedMaterials[e.getKey()]); + node.attachChild(seedGeo); + } + } + chunkNodes[ci] = node; grassNode.attachChild(node); } + private static Geometry buildSeedStalkMesh(String name, List blades) { + final int SEG = 4; + final float R = 0xbb / 255f, G = 0x90 / 255f, B = 0x59 / 255f; + int n = blades.size(); + int vTotal = n * (SEG + 1) * 2; + float[] pos = new float[vTotal * 3]; + float[] nrm = new float[vTotal * 3]; + float[] col = new float[vTotal * 4]; + float[] tex = new float[vTotal * 2]; + int[] idx = new int [n * SEG * 6]; + + int vi = 0, ii = 0; + for (GrassVertexBlade blade : blades) { + float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height(); + float hw = h * 0.018f; + float ang = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)); + float cA = (float) Math.cos(ang), sA = (float) Math.sin(ang); + + // Normale senkrecht zur Halmbreite, 30 % Richtung Weltauf gekippt (wie Gras-Shader) + float nx = -sA, ny = 0f, nz = cA; + float blend = 0.30f; + nx *= (1f - blend); ny = blend; nz *= (1f - blend); + float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz); + nx /= nLen; ny /= nLen; nz /= nLen; + + for (int s = 0; s <= SEG; s++) { + float t = (float) s / SEG; + float curHW = hw * (float) Math.pow(1.0 - t, 1.4); + float py = y + h * t; + int sviL = vi + s * 2; + int sviR = sviL + 1; + + pos[sviL*3] = x - cA*curHW; pos[sviL*3+1] = py; pos[sviL*3+2] = z - sA*curHW; + nrm[sviL*3] = nx; nrm[sviL*3+1] = ny; nrm[sviL*3+2] = nz; + col[sviL*4] = R; col[sviL*4+1] = G; col[sviL*4+2] = B; col[sviL*4+3] = 1f; + tex[sviL*2] = t; tex[sviL*2+1] = 0f; + + pos[sviR*3] = x + cA*curHW; pos[sviR*3+1] = py; pos[sviR*3+2] = z + sA*curHW; + nrm[sviR*3] = nx; nrm[sviR*3+1] = ny; nrm[sviR*3+2] = nz; + col[sviR*4] = R; col[sviR*4+1] = G; col[sviR*4+2] = B; col[sviR*4+3] = 1f; + tex[sviR*2] = t; tex[sviR*2+1] = 0f; + } + for (int s = 0; s < SEG; s++) { + int b0 = vi + s * 2; + idx[ii] = b0; idx[ii+1] = b0+1; idx[ii+2] = b0+3; + idx[ii+3] = b0; idx[ii+4] = b0+3; idx[ii+5] = b0+2; + ii += 6; + } + vi += (SEG + 1) * 2; + } + + Mesh m = new Mesh(); + m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos)); + m.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(nrm)); + m.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(col)); + m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex)); + m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx)); + m.updateBound(); + return new Geometry(name, m); + } + + private static Geometry buildSeedCrossMesh(String name, List blades) { + int n = blades.size(); + float[] pos = new float[n * 8 * 3]; + float[] tex = new float[n * 8 * 2]; + int[] idx = new int [n * 12]; + + int vi = 0, ii = 0; + for (GrassVertexBlade b : blades) { + float x = b.x(); + float yBot = b.y() + b.height() * SEED_Y_FACTOR; + float z = b.z(); + float size = b.height() * SEED_SIZE_FACTOR; + float hw = size * 0.5f; + + // Quad 1 – entlang Welt-X + setSeedV(pos, tex, vi+0, x-hw, yBot, z, 0,0); + setSeedV(pos, tex, vi+1, x+hw, yBot, z, 1,0); + setSeedV(pos, tex, vi+2, x+hw, yBot+size, z, 1,1); + setSeedV(pos, tex, vi+3, x-hw, yBot+size, z, 0,1); + // Quad 2 – entlang Welt-Z + setSeedV(pos, tex, vi+4, x, yBot, z-hw, 0,0); + setSeedV(pos, tex, vi+5, x, yBot, z+hw, 1,0); + setSeedV(pos, tex, vi+6, x, yBot+size, z+hw, 1,1); + setSeedV(pos, tex, vi+7, x, yBot+size, z-hw, 0,1); + + idx[ii] = vi; idx[ii+1] = vi+1; idx[ii+2] = vi+2; + idx[ii+3] = vi; idx[ii+4] = vi+2; idx[ii+5] = vi+3; + idx[ii+6] = vi+4; idx[ii+7] = vi+5; idx[ii+8] = vi+6; + idx[ii+9] = vi+4; idx[ii+10] = vi+6; idx[ii+11] = vi+7; + + vi += 8; ii += 12; + } + + Mesh m = new Mesh(); + m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos)); + m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(tex)); + m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(idx)); + m.updateBound(); + return new Geometry(name, m); + } + + private static void setSeedV(float[] pos, float[] tex, int vi, + float x, float y, float z, float u, float v) { + pos[vi*3] = x; pos[vi*3+1] = y; pos[vi*3+2] = z; + tex[vi*2] = u; tex[vi*2+1] = v; + } + // ── ChunkListener: Gras ab LOD 1 ausblenden ─────────────────────────────── @Override diff --git a/blight-game/src/main/java/de/blight/game/state/WaterInteractionState.java b/blight-game/src/main/java/de/blight/game/state/WaterInteractionState.java new file mode 100644 index 0000000..9fc7c5c --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/WaterInteractionState.java @@ -0,0 +1,213 @@ +package de.blight.game.state; + +import com.jme3.app.Application; +import com.jme3.app.state.BaseAppState; +import com.jme3.bullet.control.CharacterControl; +import com.jme3.math.FastMath; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; +import com.jme3.texture.Image; +import com.jme3.texture.Texture; +import com.jme3.texture.Texture2D; +import com.jme3.texture.image.ColorSpace; +import com.jme3.util.BufferUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * CPU wave-equation simulation for dynamic water interaction. + * + * Maintains a 256×256 ping-pong height grid covering a 128m×128m area around the player. + * Each frame: + * 1. Injects a blob at the player's feet if they are in water and moving. + * 2. Advances the discrete wave equation: newH = (2*cur - prev + K*laplacian) * damping + * 3. Uploads the result to a Luminance8 Texture2D that the modified Water.frag reads. + * + * The simulation area recenters on the player when they move more than ~29m from the center; + * the buffers are cleared and DynamicWaterFilter.updateWaveCenter() is called so the shader + * samples the correct world region. + */ +public class WaterInteractionState extends BaseAppState { + + private static final Logger log = LoggerFactory.getLogger(WaterInteractionState.class); + + private static final int N = 256; + private static final float HALF_EXTENT = 64f; // half of 128m world coverage + private static final float INV_EXTENT = 1.0f / (HALF_EXTENT * 2f); + private static final float WAVE_K = 0.25f; // wave eq. coefficient (stability: ≤ 0.5) + private static final float DAMPING = 0.995f; + private static final float WAVE_STRENGTH = 6.0f; // normal perturbation scale in shader + private static final float BLOB_RADIUS_TEX = 3.0f; // blob radius in texels + private static final float BLOB_COOLDOWN = 0.07f; // seconds between blob injections + private static final float RECENTER_THRESH = HALF_EXTENT * 0.45f; // ~29m + + // ── Simulation buffers ─────────────────────────────────────────────────── + + private float[] waveCur; + private float[] wavePrev; + private float[] waveNext; + private ByteBuffer waveBuf; + private Image waveImg; + private Texture2D waveTex; + + // ── Simulation state ───────────────────────────────────────────────────── + + private final Vector2f simCenter = new Vector2f(); + private boolean paramsApplied = false; + private float blobTimer = 0f; + + // ── External references ────────────────────────────────────────────────── + + private final DynamicWaterFilter dynFilter; + private CharacterControl playerControl; + private float waterHeight = 0f; + private final Vector3f prevPos = new Vector3f(Float.NaN, 0f, Float.NaN); + + public WaterInteractionState(DynamicWaterFilter filter) { + this.dynFilter = filter; + } + + /** + * Must be called after CharacterControl is available (typically after buildCharacter() in WorldScene). + * Safe to call multiple times. + */ + public void setPlayerControl(CharacterControl ctrl, float waterLevel) { + this.playerControl = ctrl; + this.waterHeight = waterLevel; + prevPos.set(Float.NaN, 0f, Float.NaN); + } + + // ── AppState lifecycle ─────────────────────────────────────────────────── + + @Override + protected void initialize(Application app) { + waveCur = new float[N * N]; + wavePrev = new float[N * N]; + waveNext = new float[N * N]; + + waveBuf = BufferUtils.createByteBuffer(N * N); + for (int i = 0; i < N * N; i++) { + waveBuf.put((byte) 128); // neutral height = 0.5 encoded + } + waveBuf.rewind(); + + waveImg = new Image(Image.Format.Luminance8, N, N, waveBuf, null, ColorSpace.Linear); + waveTex = new Texture2D(waveImg); + waveTex.setMagFilter(Texture.MagFilter.Bilinear); + waveTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); + waveTex.setWrap(Texture.WrapMode.EdgeClamp); + + log.debug("[WaterInteraction] Simulation initialized ({}×{}, {}m coverage)", N, N, HALF_EXTENT * 2f); + } + + @Override + protected void cleanup(Application app) { + waveCur = wavePrev = waveNext = null; + waveBuf = null; + waveImg = null; + waveTex = null; + } + + @Override protected void onEnable() {} + @Override protected void onDisable() {} + + @Override + public void update(float tpf) { + if (tpf < 0.001f) { return; } + + // Register texture with the filter once (handles pre-init via pending queue in DynamicWaterFilter) + if (!paramsApplied) { + dynFilter.setWaveMap(waveTex, + new Vector2f(simCenter.x, simCenter.y), INV_EXTENT, WAVE_STRENGTH); + paramsApplied = true; + } + + if (playerControl != null) { + Vector3f pos = playerControl.getPhysicsLocation(); + + maybeRecenter(pos.x, pos.z); + + float feetY = pos.y - 0.9f; // CharacterControl position = capsule center, ~0.9m above feet + if (feetY < waterHeight + 0.4f) { // player in or just above water + float speed = Float.isNaN(prevPos.x) ? 0f : pos.distance(prevPos) / tpf; + if (speed > 0.3f) { // ignore sub-threshold shuffling + blobTimer -= tpf; + if (blobTimer <= 0f) { + float strength = FastMath.clamp(speed / 8.0f, 0.1f, 1.0f); + addBlob(pos.x, pos.z, strength); + blobTimer = BLOB_COOLDOWN; + } + } + } + prevPos.set(pos); + } + + runSimulation(); + uploadTexture(); + } + + // ── Simulation ─────────────────────────────────────────────────────────── + + private void runSimulation() { + for (int y = 1; y < N - 1; y++) { + for (int x = 1; x < N - 1; x++) { + int i = y * N + x; + float cur = waveCur[i]; + float prev = wavePrev[i]; + float laplacian = waveCur[i - 1] + waveCur[i + 1] + + waveCur[i - N] + waveCur[i + N] + - 4f * cur; + waveNext[i] = FastMath.clamp((2f * cur - prev + WAVE_K * laplacian) * DAMPING, -1f, 1f); + } + } + // Swap buffers: prev ← cur ← next ← (reuse old prev) + float[] tmp = wavePrev; + wavePrev = waveCur; + waveCur = waveNext; + waveNext = tmp; + } + + private void addBlob(float worldX, float worldZ, float strength) { + float left = simCenter.x - HALF_EXTENT; + float top = simCenter.y - HALF_EXTENT; + float span = HALF_EXTENT * 2f; + int cx = (int)((worldX - left) / span * N); + int cz = (int)((worldZ - top) / span * N); + int r = (int)(BLOB_RADIUS_TEX + 1f); + for (int dy = -r; dy <= r; dy++) { + for (int dx = -r; dx <= r; dx++) { + int x = cx + dx; + int z = cz + dy; + if (x < 1 || x >= N - 1 || z < 1 || z >= N - 1) { continue; } + float dist = FastMath.sqrt(dx * dx + dy * dy); + if (dist > BLOB_RADIUS_TEX) { continue; } + float w = (1f - dist / BLOB_RADIUS_TEX) * strength; + waveCur[z * N + x] = FastMath.clamp(waveCur[z * N + x] + w, -1f, 1f); + } + } + } + + private void maybeRecenter(float worldX, float worldZ) { + float dx = worldX - simCenter.x; + float dz = worldZ - simCenter.y; + if (FastMath.abs(dx) > RECENTER_THRESH || FastMath.abs(dz) > RECENTER_THRESH) { + simCenter.set(worldX, worldZ); + Arrays.fill(waveCur, 0f); + Arrays.fill(wavePrev, 0f); + Arrays.fill(waveNext, 0f); + dynFilter.updateWaveCenter(new Vector2f(simCenter.x, simCenter.y)); + } + } + + private void uploadTexture() { + waveBuf.clear(); + for (int i = 0; i < N * N; i++) { + waveBuf.put((byte)((waveCur[i] * 0.5f + 0.5f) * 255f)); + } + waveBuf.rewind(); + waveImg.setUpdateNeeded(); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/WeatherState.java b/blight-game/src/main/java/de/blight/game/state/WeatherState.java index 4fba0ff..b1812ef 100644 --- a/blight-game/src/main/java/de/blight/game/state/WeatherState.java +++ b/blight-game/src/main/java/de/blight/game/state/WeatherState.java @@ -1,13 +1,14 @@ package de.blight.game.state; import com.jme3.app.Application; -import com.jme3.app.SimpleApplication; import com.jme3.app.state.BaseAppState; import com.jme3.math.ColorRGBA; import com.jme3.math.FastMath; +import com.jme3.math.Vector2f; import com.jme3.math.Vector3f; import com.jme3.post.filters.FogFilter; import com.jme3.water.WaterFilter; +import jme3utilities.sky.SkyControl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,6 +28,7 @@ public class WeatherState extends BaseAppState { private static final float[] WAVE_SCALE = { 0.008f, 0.007f, 0.006f, 0.005f}; private static final float[] WATER_TRANS = { 0.15f, 0.10f, 0.07f, 0.02f }; private static final float[] FOAM_INTENSITY= { 0.0f, 0.20f, 0.45f, 0.90f }; + private static final float[] CLOUD_OPACITY = { 0.0f, 0.40f, 0.80f, 1.00f }; private static final ColorRGBA[] FOG_COLOR = { new ColorRGBA(0.75f, 0.80f, 0.88f, 1f), @@ -34,12 +36,6 @@ public class WeatherState extends BaseAppState { new ColorRGBA(0.42f, 0.43f, 0.46f, 1f), new ColorRGBA(0.18f, 0.19f, 0.21f, 1f), }; - private static final ColorRGBA[] CLOUD_COLOR = { - new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f), - new ColorRGBA(0.75f, 0.75f, 0.78f, 0.72f), - new ColorRGBA(0.35f, 0.35f, 0.37f, 0.92f), - new ColorRGBA(0.08f, 0.08f, 0.10f, 0.98f), - }; private static final ColorRGBA[] WATER_COLOR = { new ColorRGBA(0.05f, 0.25f, 0.55f, 1f), new ColorRGBA(0.04f, 0.18f, 0.42f, 1f), @@ -58,7 +54,6 @@ public class WeatherState extends BaseAppState { private Weather active = Weather.SUNNY; private float changeTimer = 120f; - // Aktuell interpolierte Werte private float fogDensity = 0f; private float fogDistance = 600f; private float windSpeed = 4f; @@ -67,10 +62,10 @@ public class WeatherState extends BaseAppState { private float waveScale = 0.008f; private float waterTrans = 0.15f; private float foamIntensity = 0f; + private float cloudOpacity = 0f; private float windAngle = 0f; private float windAngleTgt = 0.4f; private final ColorRGBA fogColor = new ColorRGBA(0.75f, 0.80f, 0.88f, 1f); - private final ColorRGBA cloudColor = new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f); private final ColorRGBA waterColor = new ColorRGBA(0.05f, 0.25f, 0.55f, 1f); private final ColorRGBA deepWaterColor= new ColorRGBA(0.02f, 0.12f, 0.30f, 1f); @@ -78,15 +73,14 @@ public class WeatherState extends BaseAppState { private FogFilter fogFilter; private WaterFilter waterFilter; - private CloudsNode cloudsNode; - private Application app; + private SkyControl skyControl; public void setFogFilter(FogFilter f) { this.fogFilter = f; } public void setWaterFilter(WaterFilter f) { this.waterFilter = f; } - public void setCloudsNode(CloudsNode n) { this.cloudsNode = n; } + public void setSkyControl(SkyControl sc) { this.skyControl = sc; } - public Weather getActiveWeather() { return active; } - public float getWindSpeed() { return windSpeed; } + public Weather getActiveWeather() { return active; } + public float getWindSpeed() { return windSpeed; } /** * Setzt das Wetter sofort; Werte interpolieren sanft zum neuen Ziel. @@ -105,7 +99,7 @@ public class WeatherState extends BaseAppState { // ── Lifecycle ───────────────────────────────────────────────────────────── - @Override protected void initialize(Application app) { this.app = app; } + @Override protected void initialize(Application app) {} @Override protected void cleanup(Application app) {} @Override protected void onEnable() {} @Override protected void onDisable() {} @@ -128,11 +122,11 @@ public class WeatherState extends BaseAppState { waveScale = approach(waveScale, WAVE_SCALE[i], tpf * 0.020f); waterTrans = approach(waterTrans, WATER_TRANS[i], tpf * 0.025f); foamIntensity = approach(foamIntensity, FOAM_INTENSITY[i], tpf * 0.020f); + cloudOpacity = approach(cloudOpacity, CLOUD_OPACITY[i], tpf * 0.025f); windAngle = approachAngle(windAngle, windAngleTgt, tpf * 0.012f); - fogColor.interpolateLocal(FOG_COLOR[i], tpf * 0.025f); - cloudColor.interpolateLocal(CLOUD_COLOR[i], tpf * 0.025f); - waterColor.interpolateLocal(WATER_COLOR[i], tpf * 0.020f); + fogColor.interpolateLocal(FOG_COLOR[i], tpf * 0.025f); + waterColor.interpolateLocal(WATER_COLOR[i], tpf * 0.020f); deepWaterColor.interpolateLocal(DEEP_WATER_COLOR[i], tpf * 0.020f); if (fogFilter != null) { @@ -150,13 +144,11 @@ public class WeatherState extends BaseAppState { waterFilter.setWaterColor(waterColor.clone()); waterFilter.setDeepWaterColor(deepWaterColor.clone()); waterFilter.setWindDirection( - new com.jme3.math.Vector2f(FastMath.sin(windAngle), FastMath.cos(windAngle))); + new Vector2f(FastMath.sin(windAngle), FastMath.cos(windAngle))); } - if (cloudsNode != null) { - cloudsNode.setCloudColor(cloudColor.clone()); - Vector3f camPos = ((SimpleApplication) app).getCamera().getLocation(); - cloudsNode.update(tpf, getWindDirection(), windSpeed * 0.5f, camPos); + if (skyControl != null) { + skyControl.getCloudLayer(0).setOpacity(cloudOpacity); } } diff --git a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java index 5ae9b15..c77fd0a 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldNpcsState.java @@ -17,8 +17,16 @@ import com.jme3.math.*; import com.jme3.renderer.Camera; import com.jme3.scene.*; import com.jme3.scene.shape.Box; +import com.jme3.anim.AnimComposer; +import com.jme3.anim.Armature; +import com.jme3.anim.ArmatureMask; +import com.jme3.anim.Joint; +import com.jme3.anim.SkinningControl; +import com.jme3.anim.tween.Tween; +import com.jme3.anim.tween.action.BaseAction; import de.blight.common.PlacedModel; import de.blight.common.PlacedModelIO; +import de.blight.common.SaveGameIO; import de.blight.common.model.*; import de.blight.common.model.trigger.ChangeRoutineTrigger; import de.blight.common.model.trigger.NpcStatusTrigger; @@ -106,8 +114,17 @@ public class WorldNpcsState extends BaseAppState { private int hoveredIdx = -1; private boolean dialogActive = false; + private SpawnedNpc dialogNpc = null; private ThirdPersonCamera thirdPersonCam; + // ── NPC Head-Look während Dialog ────────────────────────────────────────── + private static final String HEAD_LAYER = "__dlg_headlook__"; + private HeadLookTween dialogHeadTween = null; + + // Einmaliger Warmup: BVH-Baum des rootNode beim ersten NPC-Spawn aufbauen, + // damit der erste Dialog-Raycast nicht stottert. + private boolean sceneBvhWarmedUp = false; + // ── NPC-Rotations-Animation ─────────────────────────────────────────────── private static final float ROT_DURATION = 0.5f; @@ -214,6 +231,9 @@ public class WorldNpcsState extends BaseAppState { } updateNpcRotation(tpf); updateNpcRevert(tpf); + if (dialogActive && dialogHeadTween != null) { + dialogHeadTween.targetPos = physicsChar.getPhysicsLocation(); + } } // ── Spawn-Logik ─────────────────────────────────────────────────────────── @@ -260,6 +280,13 @@ public class WorldNpcsState extends BaseAppState { spawned.add(s); playNpcAction(s, activityActionFor(npc, currentHour)); log.debug("[WorldNpcs] NPC '{}' gespawnt bei ({}, {})", npc.getCharacterId(), pos.x, pos.z); + + // Beim ersten Spawn: BVH-Baum der Szene aufbauen, damit der erste Dialog-Raycast nicht stottert + if (!sceneBvhWarmedUp) { + sceneBvhWarmedUp = true; + com.jme3.collision.CollisionResults warmup = new com.jme3.collision.CollisionResults(); + rootNode.collideWith(new com.jme3.math.Ray(pos.add(0f, 5f, 0f), new com.jme3.math.Vector3f(0f, -1f, 0f)), warmup); + } } } @@ -346,8 +373,26 @@ public class WorldNpcsState extends BaseAppState { startDialog(hoveredIdx); }; + /** + * Spielt einen Dialog-Animations-Clip einmalig auf dem aktuellen Dialog-NPC. + * Falls der NPC sitzt, wird stattdessen "talk_sitting" gespielt. + * Tut nichts wenn kein Dialog aktiv ist oder clipName leer. + */ + public void playNpcDialogAnim(String clipName) { + if (dialogNpc == null || animLib == null) return; + if (clipName == null || clipName.isBlank()) return; + String effective = (dialogNpc.currentAction == AnimationAction.SITTING) ? "talk_sitting" : clipName; + if (animLib.playOn(effective, dialogNpc.visual())) { + dialogNpc.currentAction = null; + } + } + private void startDialog(int idx) { SpawnedNpc entry = spawned.get(idx); + dialogNpc = entry; + if (entry.currentAction == AnimationAction.SITTING) { + startDialogHeadLook(entry); + } DialogHudState dialog = getApplication().getStateManager().getState(DialogHudState.class); if (dialog == null) return; @@ -370,12 +415,11 @@ public class WorldNpcsState extends BaseAppState { Quaternion facingRot = computeFacingQuat(npcPos, playerPos); beginSmoothRotation(entry, origRot, facingRot); - // Kamera wird erst gesetzt wenn Optionen erscheinen + // Dialog-Kamera sofort aktivieren (nicht erst bei Optionen) + enterDialogCamera(npcPos, playerPos); + boolean[] optionsWereShown = {false}; - Runnable onOptions = () -> { - optionsWereShown[0] = true; - enterDialogCamera(npcPos, playerPos); - }; + Runnable onOptions = () -> { optionsWereShown[0] = true; }; dialog.startDialog(entry.npc(), mainCharacter, onOptions, () -> { exitDialogCamera(); @@ -402,6 +446,12 @@ public class WorldNpcsState extends BaseAppState { } private void scheduleRevert(SpawnedNpc npc, Quaternion from, Quaternion to, float delay) { + // Falls die NPC-Rotation noch läuft, sofort auf Zielrotation snappen, + // damit updateNpcRotation und updateNpcRevert nicht gegeneinander arbeiten. + if (rotNpc == npc) { + npc.visual().setLocalRotation(rotTo); + rotNpc = null; + } revertNpc = npc; revertFrom = from.clone(); revertTo = to.clone(); @@ -429,49 +479,80 @@ public class WorldNpcsState extends BaseAppState { } private void enterDialogCamera(Vector3f npcPos, Vector3f playerPos) { - // Mittelpunkt auf Hüfthöhe - Vector3f mid = npcPos.add(playerPos).multLocal(0.5f); - mid.y += 0.9f; + // NPC-Spatial auf Bodenniveau; playerPos = Physik-Kapsel-Mitte → auf NPC-Boden projizieren + float groundY = npcPos.y; + Vector3f npcFeet = new Vector3f(npcPos.x, groundY, npcPos.z); + Vector3f playerFeet = new Vector3f(playerPos.x, groundY, playerPos.z); + float charDist = Math.max(1.0f, npcFeet.distance(playerFeet)); - // Zwei mögliche Seiten (je 90° zur Blicklinie) - Vector3f axis = npcPos.subtract(playerPos).normalizeLocal(); - Vector3f sideA = axis.cross(Vector3f.UNIT_Y).normalizeLocal(); - Vector3f sideB = sideA.negate(); + // Lookat: Brust-/Schulterhöhe zwischen beiden Figuren + Vector3f lookAt = npcFeet.add(playerFeet).multLocal(0.5f); + lookAt.y += 1.2f; - float camSide = 3.5f; - float camUp = 1.2f; + // Senkrechte Seite zur NPC↔Spieler-Achse + Vector3f axis = npcFeet.subtract(playerFeet); + if (axis.lengthSquared() < 0.001f) { axis.set(1f, 0f, 0f); } + axis.normalizeLocal(); + Vector3f side = axis.cross(Vector3f.UNIT_Y).normalizeLocal(); - Vector3f posA = mid.add(sideA.mult(camSide)).add(0, camUp, 0); - Vector3f posB = mid.add(sideB.mult(camSide)).add(0, camUp, 0); + // FOV 45° → tan(22.5°) ≈ 0.41 → für charDist/2 sichtbar: dist = (charDist/2)/0.41 × Puffer + // Faktor 2.5 gibt genug Rand dass Figuren + Umgebung im Bild sind; Minimum 5 m + float dist = Math.max(5.0f, charDist * 2.5f); + float camUp = 1.6f; - posA = avoidClipping(mid, posA); - posB = avoidClipping(mid, posB); + // Seite wählen: erhöhter Horizontalstrahl (2 m über lookAt), um Terrain-Clipping zu vermeiden. + // avoidClipping() wird NICHT für die Distanz genutzt, weil Bodenstrahlen bei großen + // Distanzen häufig auf Geländekanten treffen und die Kamera fälschlich nah platzieren. + Vector3f elevated = lookAt.add(0f, 2f, 0f); + float freeA = openDistance(elevated, side); + float freeB = openDistance(elevated, side.negate()); + Vector3f chosen = freeA >= freeB ? side : side.negate(); - // Seite mit mehr Abstand ist weniger verdeckt - Vector3f camPos = posA.distance(mid) >= posB.distance(mid) ? posA : posB; + Vector3f camPos = lookAt.add(chosen.mult(dist)).add(0f, camUp, 0f); - if (thirdPersonCam != null) { - thirdPersonCam.setPaused(true); - } + if (thirdPersonCam != null) { thirdPersonCam.setPaused(true); } cam.setLocation(camPos); - cam.lookAt(mid, Vector3f.UNIT_Y); + cam.lookAt(lookAt, Vector3f.UNIT_Y); + } + + /** Freier Abstand in {@code dir} ab {@code origin} bis zur ersten Kollision (max Float.MAX_VALUE). */ + private float openDistance(Vector3f origin, Vector3f dir) { + CollisionResults cr = new CollisionResults(); + rootNode.collideWith(new Ray(origin, dir), cr); + return cr.size() == 0 ? Float.MAX_VALUE : cr.getClosestCollision().getDistance(); } private void exitDialogCamera() { - if (thirdPersonCam != null) { - thirdPersonCam.setPaused(false); + stopDialogHeadLook(); + if (thirdPersonCam != null) { thirdPersonCam.setPaused(false); } + if (dialogNpc != null) { + int hour = dayNight != null ? dayNight.getDayTime().getHour() : 12; + playNpcAction(dialogNpc, activityActionFor(dialogNpc.npc(), hour)); + dialogNpc = null; } } + /** Prüft ob zwischen {@code from} und {@code to} kein Hindernis im rootNode liegt. */ + private boolean hasLineOfSight(Vector3f from, Vector3f to) { + Vector3f dir = to.subtract(from); + float dist = dir.length(); + if (dist < 0.001f) { return true; } + dir.normalizeLocal(); + CollisionResults results = new CollisionResults(); + rootNode.collideWith(new Ray(from, dir), results); + if (results.size() == 0) { return true; } + return results.getClosestCollision().getDistance() >= dist - 0.2f; + } + private Vector3f avoidClipping(Vector3f from, Vector3f to) { Vector3f dir = to.subtract(from); float maxDist = dir.length(); dir.normalizeLocal(); CollisionResults results = new CollisionResults(); rootNode.collideWith(new Ray(from, dir), results); - if (results.size() == 0) return to; + if (results.size() == 0) { return to; } CollisionResult nearest = results.getClosestCollision(); - if (nearest.getDistance() >= maxDist) return to; + if (nearest.getDistance() >= maxDist) { return to; } float safeDist = Math.max(1.2f, nearest.getDistance() - 0.3f); return from.add(dir.mult(safeDist)); } @@ -516,17 +597,24 @@ public class WorldNpcsState extends BaseAppState { private void loadAllNpcs() { allNpcs.clear(); SaveGameState saveState = getStateManager().getState(SaveGameState.class); + // blight.new.game=true → Editor startet neues Spiel; autostart überspringt resetForNewGame() + boolean isNewGame = "true".equals(System.getProperty("blight.new.game")); + boolean useSaved = !isNewGame + && saveState != null + && saveState.getSave().character.positionSaved + && SaveGameIO.exists(); try { java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character"); for (GameCharacter gc : CharacterIO.loadAll(charDir)) { if (gc instanceof NPC npc) { - java.util.List savedState = saveState != null + java.util.List savedState = useSaved ? saveState.getDialogState(npc.getCharacterId()) : null; npc.initRuntimeTree(savedState); allNpcs.add(npc); } } - log.info("[WorldNpcs] {} NPCs geladen.", allNpcs.size()); + log.info("[WorldNpcs] {} NPCs geladen (Dialogzustand: {}).", + allNpcs.size(), useSaved ? "gespeichert" : "neu"); } catch (Exception e) { log.warn("[WorldNpcs] Fehler beim Laden der NPCs: {}", e.getMessage()); } @@ -589,6 +677,102 @@ public class WorldNpcsState extends BaseAppState { } } + // ── NPC Head-Look ───────────────────────────────────────────────────────── + + private void startDialogHeadLook(SpawnedNpc npc) { + AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(npc.visual()); + SkinningControl sc = de.blight.game.animation.RetargetingSystem.findSkinningControl(npc.visual()); + if (ac == null || sc == null) { return; } + + Armature arm = sc.getArmature(); + Joint head = findHeadJoint(arm); + if (head == null) { + log.debug("[WorldNpcs] Kein Head-Joint gefunden – Head-Look deaktiviert."); + return; + } + + ArmatureMask mask; + try { + mask = ArmatureMask.createMask(arm, head.getName()); + } catch (IllegalArgumentException e) { + return; + } + + HeadLookTween tween = new HeadLookTween(head, npc.visual()); + BaseAction action = new BaseAction(tween); + dialogHeadTween = tween; + + ac.addAction(HEAD_LAYER, action); + ac.makeLayer(HEAD_LAYER, mask); + ac.setCurrentAction(HEAD_LAYER, HEAD_LAYER); + } + + private void stopDialogHeadLook() { + if (dialogHeadTween == null) { return; } + dialogHeadTween.targetPos = null; + dialogHeadTween = null; + if (dialogNpc == null) { return; } + AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(dialogNpc.visual()); + if (ac != null) { + ac.removeLayer(HEAD_LAYER); + } + } + + private static Joint findHeadJoint(Armature arm) { + for (String name : new String[]{"Head", "head", "HEAD", + "mixamorig:Head", "mixamorig_Head", "Bip001 Head"}) { + Joint j = arm.getJoint(name); + if (j != null) { return j; } + } + for (int i = 0; i < arm.getJointCount(); i++) { + Joint j = arm.getJoint(i); + if (j.getName().toLowerCase(java.util.Locale.ROOT).contains("head")) { return j; } + } + return null; + } + + /** Procedural Tween: dreht den Head-Joint jedes Frame in Richtung Spieler. */ + private static final class HeadLookTween implements Tween { + + final Joint headJoint; + final Spatial npcSpatial; + volatile Vector3f targetPos; + float smoothAngle = 0f; + + HeadLookTween(Joint headJoint, Spatial npcSpatial) { + this.headJoint = headJoint; + this.npcSpatial = npcSpatial; + } + + @Override public double getLength() { return Double.MAX_VALUE; } + + @Override + public boolean interpolate(double t) { + Vector3f target = targetPos; + if (target == null) { return true; } + + // Spielerposition in NPC-Model-Lokalraum umrechnen + Vector3f local = npcSpatial.worldToLocal(target, new Vector3f()); + local.y = 0f; + + float goalAngle = 0f; + if (local.lengthSquared() > 0.001f) { + local.normalizeLocal(); + // NPC +Z = Vorwärts; positiver Winkel = Kopf nach rechts (+X) + goalAngle = FastMath.atan2(local.x, local.z); + goalAngle = FastMath.clamp(goalAngle, -FastMath.HALF_PI * 0.7f, FastMath.HALF_PI * 0.7f); + } + + smoothAngle += (goalAngle - smoothAngle) * 0.12f; + + // Look-Delta in Eltern-Raum vor die Animations-Rotation setzen + Quaternion animRot = headJoint.getLocalRotation().clone(); + Quaternion lookDelta = new Quaternion().fromAngleAxis(smoothAngle, Vector3f.UNIT_Y); + headJoint.setLocalRotation(lookDelta.mult(animRot)); + return true; + } + } + private static float dist2d(Vector3f a, Vector3f b) { float dx = a.x - b.x; float dz = a.z - b.z; diff --git a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java index fafe8bb..4f07594 100644 --- a/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java +++ b/blight-game/src/main/java/de/blight/game/state/WorldObjectsState.java @@ -35,6 +35,7 @@ public class WorldObjectsState extends BaseAppState { private AssetManager assets; private BulletAppState bulletAppState; private final List sceneLitMaterials = new ArrayList<>(); + private final List windMaterials = new ArrayList<>(); /** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */ private final Map interactableRbcs = new HashMap<>(); @@ -117,18 +118,38 @@ public class WorldObjectsState extends BaseAppState { @Override public void update(float tpf) { - if (sceneLitMaterials.isEmpty()) return; - DayNightState dns = getApplication().getStateManager().getState(DayNightState.class); - if (dns == null || dns.getSunLight() == null) return; - Vector3f lightDir = dns.getSunDirection().negate(); - ColorRGBA sc = dns.getSunLight().getColor(); - ColorRGBA ac = dns.getAmbientLight().getColor(); - Vector3f sunVec = new Vector3f(sc.r, sc.g, sc.b); - Vector3f ambVec = new Vector3f(ac.r, ac.g, ac.b); - for (Material mat : sceneLitMaterials) { - mat.setVector3("LightDir", lightDir); - mat.setVector3("SunColor", sunVec); - mat.setVector3("AmbientColor", ambVec); + if (!sceneLitMaterials.isEmpty()) { + DayNightState dns = getApplication().getStateManager().getState(DayNightState.class); + if (dns != null && dns.getSunLight() != null) { + Vector3f lightDir = dns.getSunDirection().negate(); + ColorRGBA sc = dns.getSunLight().getColor(); + ColorRGBA ac = dns.getAmbientLight().getColor(); + Vector3f sunVec = new Vector3f(sc.r, sc.g, sc.b); + Vector3f ambVec = new Vector3f(ac.r, ac.g, ac.b); + for (Material mat : sceneLitMaterials) { + mat.setVector3("LightDir", lightDir); + mat.setVector3("SunColor", sunVec); + mat.setVector3("AmbientColor", ambVec); + } + } + } + + WeatherState ws = getApplication().getStateManager().getState(WeatherState.class); + if (ws != null && (!sceneLitMaterials.isEmpty() || !windMaterials.isEmpty())) { + Vector3f wd3 = ws.getWindDirection(); + Vector2f wDir = new Vector2f(wd3.x, wd3.z); + float speed = Math.max(0.05f, ws.getWindSpeed() * 0.04f); + float strength = com.jme3.math.FastMath.clamp(ws.getWindSpeed() * 0.009f, 0.01f, 0.45f); + for (Material mat : sceneLitMaterials) { + mat.setVector2("WindDir", wDir); + mat.setFloat("WindSpeed", speed); + mat.setFloat("WindStrength", strength); + } + for (Material mat : windMaterials) { + mat.setVector2("WindDir", wDir); + mat.setFloat("WindSpeed", speed); + mat.setFloat("WindStrength", strength); + } } } @@ -244,6 +265,8 @@ public class WorldObjectsState extends BaseAppState { String name = mat.getMaterialDef().getName(); if ("Tree".equals(name) || "TreeLeaf".equals(name)) { sceneLitMaterials.add(mat); + } else if ("Fern".equals(name)) { + windMaterials.add(mat); } } else if (spatial instanceof Node node) { for (Spatial child : node.getChildren()) { diff --git a/blight-map/src/main/map/blight_grass_vertex.blgv b/blight-map/src/main/map/blight_grass_vertex.blgv index c072005..ec9d247 100644 Binary files a/blight-map/src/main/map/blight_grass_vertex.blgv and b/blight-map/src/main/map/blight_grass_vertex.blgv differ