Weiter an den Dialogen gearbeitet
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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)");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user