Weiter an den Dialogen gearbeitet

This commit is contained in:
2026-07-09 21:31:37 +02:00
parent 8bcee4491e
commit f109241e1f
50 changed files with 2413 additions and 413 deletions

View File

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

View File

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

View File

@@ -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.
*
* <p>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");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DisplayOption> 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<DialogOption> 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<DialogStep> 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<DialogOption> 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<DialogStep> 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<DialogStep> heroSteps = opt.getHeroSteps();
List<DialogStep> 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<DialogStep> fHeroSteps = heroSteps;
final List<DialogStep> fNpcSteps = npcSteps;
final String fNpcAnim = opt.getNpcAnimation();
// Option-Effekte anwenden (Optionen aktualisieren, Quest, etc.)
applyOption(opt);
List<DialogOption> 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<DialogOption> 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<String> wrapText(String text, int maxChars) {
List<String> lines = new ArrayList<>();

View File

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

View File

@@ -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<Material> 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<GrassVertexBlade> seededAll = new ArrayList<>();
Map<Integer, List<GrassVertexBlade>> 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<Integer, List<GrassVertexBlade>> 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<GrassVertexBlade> 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<GrassVertexBlade> 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

View File

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

View File

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

View File

@@ -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<String> savedState = saveState != null
java.util.List<String> 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;

View File

@@ -35,6 +35,7 @@ public class WorldObjectsState extends BaseAppState {
private AssetManager assets;
private BulletAppState bulletAppState;
private final List<Material> sceneLitMaterials = new ArrayList<>();
private final List<Material> windMaterials = new ArrayList<>();
/** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */
private final Map<String, RigidBodyControl> 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()) {