Dialog Camera und DoF bearbeitet

This commit is contained in:
2026-07-21 22:26:06 +02:00
parent ae244e3969
commit 51b1b53a27
4 changed files with 97 additions and 42 deletions

View File

@@ -38,6 +38,7 @@ public class ThirdPersonCamera {
private float distance = BASE_DISTANCE; private float distance = BASE_DISTANCE;
private boolean paused = false; private boolean paused = false;
public ThirdPersonCamera(Camera cam, InputManager inputManager, KeyBindings keyBindings) { public ThirdPersonCamera(Camera cam, InputManager inputManager, KeyBindings keyBindings) {
this.cam = cam; this.cam = cam;
this.inputManager = inputManager; this.inputManager = inputManager;
@@ -100,6 +101,6 @@ public class ThirdPersonCamera {
cam.lookAt(pivot, Vector3f.UNIT_Y); cam.lookAt(pivot, Vector3f.UNIT_Y);
} }
/** Aktueller Yaw-Winkel (für CharacterControl nutzbar). */ /** Aktueller Yaw-Winkel (für CharacterControl nutzbar). */
public float getYaw() { return yaw; } public float getYaw() { return yaw; }
} }

View File

@@ -503,11 +503,15 @@ public class WorldScene extends BaseAppState {
log.info("[Debug] Nebel {}", nowEnabled ? "AN" : "AUS"); log.info("[Debug] Nebel {}", nowEnabled ? "AN" : "AUS");
} }
/** Tiefenschärfe für Dialogszenen aktivieren. focusDistance in Welt-Einheiten ab Kamera. */ /**
public void focusDof(float focusDistance, float blurScale) { * Tiefenschärfe aktivieren. focusRange definiert den beidseitigen Schärfebereich
* um focusDistance — alles innerhalb [focusDistance-focusRange, focusDistance+focusRange]
* bleibt scharf, der Rest wird geblurt.
*/
public void focusDof(float focusDistance, float focusRange, float blurScale) {
if (dofFilter == null) return; if (dofFilter == null) return;
dofFilter.setFocusDistance(focusDistance); dofFilter.setFocusDistance(focusDistance);
dofFilter.setFocusRange(focusDistance * 0.4f); dofFilter.setFocusRange(focusRange);
dofFilter.setBlurScale(blurScale); dofFilter.setBlurScale(blurScale);
dofFilter.setEnabled(true); dofFilter.setEnabled(true);
} }

View File

@@ -35,6 +35,7 @@ import de.blight.common.model.trigger.ChangeRoutineTrigger;
import de.blight.common.model.trigger.NpcStatusTrigger; import de.blight.common.model.trigger.NpcStatusTrigger;
import de.blight.common.model.trigger.Trigger; import de.blight.common.model.trigger.Trigger;
import de.blight.game.audio.DialogTtsService; import de.blight.game.audio.DialogTtsService;
import de.blight.game.scene.WorldScene;
import de.blight.game.config.MenuCanvas; import de.blight.game.config.MenuCanvas;
import de.blight.game.config.NinePatch; import de.blight.game.config.NinePatch;
import de.blight.lang.TextResolver; import de.blight.lang.TextResolver;
@@ -224,7 +225,7 @@ public class DialogHudState extends BaseAppState {
int optIdx = scrollOffset + slot; int optIdx = scrollOffset + slot;
if (optIdx >= total) break; if (optIdx >= total) break;
float ty = baseY - slot * LINE_H_OPT; float ty = baseY - slot * LINE_H_OPT;
if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { if (vy >= ty - LINE_H_OPT && vy < ty + FONT_OPT) {
if (selectedOpt != optIdx) { if (selectedOpt != optIdx) {
selectedOpt = optIdx; selectedOpt = optIdx;
renderOptions(); renderOptions();
@@ -320,6 +321,10 @@ public class DialogHudState extends BaseAppState {
this.pageIdx = 0; this.pageIdx = 0;
renderCurrentTextPage(speaker); renderCurrentTextPage(speaker);
if (tts != null) tts.speak(rawText); if (tts != null) tts.speak(rawText);
// Shot-Reverse-Shot: NPC spricht → über Schulter des Spielers; Spieler spricht → über NPC-Schulter
WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class);
if (npcs != null) npcs.switchDialogCamera(textPhase == Phase.TEXT_NPC);
} }
private void renderCurrentTextPage(String speaker) { private void renderCurrentTextPage(String speaker) {
@@ -358,6 +363,10 @@ public class DialogHudState extends BaseAppState {
onOptionsShown.run(); onOptionsShown.run();
} }
// Optionen = Spieler ist am Zug → Kamera über Schulter des NPC (blickt auf Spieler)
WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class);
if (npcs != null) npcs.switchDialogCamera(false);
for (DialogOption opt : options) { for (DialogOption opt : options) {
// Primär: erster Hero-Step oder textHero (Fallback für Legacy-Daten) // Primär: erster Hero-Step oder textHero (Fallback für Legacy-Daten)
String label = ""; String label = "";
@@ -480,7 +489,7 @@ public class DialogHudState extends BaseAppState {
// Klick auf Scroll-Pfeil ▲ // Klick auf Scroll-Pfeil ▲
if (scrollOffset > 0) { if (scrollOffset > 0) {
float ty = baseY + LINE_H_OPT; float ty = baseY + LINE_H_OPT;
if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { if (vy >= ty - LINE_H_OPT && vy < ty + FONT_OPT) {
scrollOffset = Math.max(0, scrollOffset - 1); scrollOffset = Math.max(0, scrollOffset - 1);
renderOptions(); renderOptions();
return; return;
@@ -490,7 +499,7 @@ public class DialogHudState extends BaseAppState {
// Klick auf Scroll-Pfeil ▼ // Klick auf Scroll-Pfeil ▼
if (scrollOffset + MAX_OPTIONS < total) { if (scrollOffset + MAX_OPTIONS < total) {
float ty = baseY - MAX_OPTIONS * LINE_H_OPT; float ty = baseY - MAX_OPTIONS * LINE_H_OPT;
if (vy >= ty - LINE_H_OPT + 4f && vy <= ty + 4f) { if (vy >= ty - LINE_H_OPT && vy < ty + FONT_OPT) {
scrollOffset = Math.min(total - MAX_OPTIONS, scrollOffset + 1); scrollOffset = Math.min(total - MAX_OPTIONS, scrollOffset + 1);
renderOptions(); renderOptions();
return; return;
@@ -502,8 +511,8 @@ public class DialogHudState extends BaseAppState {
int optIdx = scrollOffset + slot; int optIdx = scrollOffset + slot;
if (optIdx >= total) break; if (optIdx >= total) break;
float ty = baseY - slot * LINE_H_OPT; float ty = baseY - slot * LINE_H_OPT;
float by = ty - LINE_H_OPT + 4f; float by = ty - LINE_H_OPT;
float byEnd = ty + 4f; float byEnd = ty + FONT_OPT;
if (vy >= by && vy <= byEnd) { if (vy >= by && vy <= byEnd) {
log.debug("[DialogHud] Treffer: Option {} (slot {} ty={})", optIdx, slot, ty); log.debug("[DialogHud] Treffer: Option {} (slot {} ty={})", optIdx, slot, ty);
selectedOpt = optIdx; selectedOpt = optIdx;
@@ -716,6 +725,9 @@ public class DialogHudState extends BaseAppState {
autoAdvanceTimer = -1f; autoAdvanceTimer = -1f;
if (tts != null) tts.stop(); if (tts != null) tts.stop();
setHudsVisible(true); setHudsVisible(true);
WorldScene ws = getStateManager().getState(WorldScene.class);
if (ws != null) ws.clearDof();
unregisterInput(); unregisterInput();
if (canvasNode != null) { if (canvasNode != null) {
canvasNode.setCullHint(Spatial.CullHint.Always); canvasNode.setCullHint(Spatial.CullHint.Always);

View File

@@ -35,6 +35,7 @@ import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings; import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl; import de.blight.game.control.PlayerInputControl;
import de.blight.game.control.ThirdPersonCamera; import de.blight.game.control.ThirdPersonCamera;
import de.blight.game.scene.WorldScene;
import de.blight.game.state.TerrainChunkState; import de.blight.game.state.TerrainChunkState;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -497,40 +498,72 @@ public class WorldNpcsState extends BaseAppState {
} }
private void enterDialogCamera(Vector3f npcPos, Vector3f playerPos) { private void enterDialogCamera(Vector3f npcPos, Vector3f playerPos) {
// 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));
// Lookat: Brust-/Schulterhöhe zwischen beiden Figuren
Vector3f lookAt = npcFeet.add(playerFeet).multLocal(0.5f);
lookAt.y += 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();
// 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;
// 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();
Vector3f camPos = lookAt.add(chosen.mult(dist)).add(0f, camUp, 0f);
if (thirdPersonCam != null) { thirdPersonCam.setPaused(true); } if (thirdPersonCam != null) { thirdPersonCam.setPaused(true); }
// NPC spricht zuerst → Kamera über Schulter des Spielers (Zuhörer)
switchDialogCamera(true);
}
/**
* Kamera für Shot-Reverse-Shot umschalten.
* npcSpeaking=true → Kamera hinter Spieler, blickt auf NPC
* npcSpeaking=false → Kamera hinter NPC, blickt auf Spieler
*/
public void switchDialogCamera(boolean npcSpeaking) {
if (dialogNpc == null || physicsChar == null) return;
// physicsChar.getPhysicsLocation() = Kapsel-Mitte (≈ 0,9 m über Boden)
Vector3f playerCenter = physicsChar.getPhysicsLocation();
Vector3f npcFeet = dialogNpc.visual().getLocalTranslation();
float playerFaceY = playerCenter.y + 0.9f;
float npcFaceY = npcFeet.y + 1.6f;
// Kamera-Abstand über dem Gesicht aus dem NPC-Fall ableiten, damit beide Shots identisch wirken
float camAboveFace = playerFaceY - npcFaceY; // Spielerschulter minus NPC-Gesicht (≈ 0.2 m)
if (npcSpeaking) {
// Listener = Spieler, Speaker = NPC
placeCamera(
playerCenter.x, playerFaceY, playerCenter.z,
npcFeet.x, npcFaceY, npcFeet.z
);
} else {
// Listener = NPC, Speaker = Spieler — gleicher Winkel wie im NPC-Fall
placeCamera(
npcFeet.x, playerFaceY + camAboveFace, npcFeet.z,
playerCenter.x, playerFaceY, playerCenter.z
);
}
}
private void placeCamera(float lisX, float lisY, float lisZ,
float spkX, float spkY, float spkZ) {
Vector3f shoulder = new Vector3f(lisX, lisY, lisZ);
Vector3f speakerFace = new Vector3f(spkX, spkY, spkZ);
// Horizontale Richtung Listener → Sprecher
Vector3f toSpeaker = new Vector3f(spkX - lisX, 0f, spkZ - lisZ);
if (toSpeaker.lengthSquared() < 0.001f) { toSpeaker.set(0f, 0f, 1f); }
toSpeaker.normalizeLocal();
// Schulterseite: die Seite mit mehr Freiraum wählen
Vector3f rightA = toSpeaker.cross(Vector3f.UNIT_Y).normalizeLocal();
Vector3f elevated = shoulder.add(0f, 0.5f, 0f);
Vector3f right = openDistance(elevated, rightA) >= openDistance(elevated, rightA.negate())
? rightA : rightA.negate();
// Kamera: hinter Listener, leicht zur Seite versetzt, weiter zurück für steileren Winkel
Vector3f camPos = shoulder
.add(right.mult(0.45f))
.subtract(toSpeaker.mult(1.5f));
// Gesicht nahe oberem Bildrand: Kamera deutlich unterhalb des Gesichts anzielen
Vector3f lookTarget = new Vector3f(spkX, spkY - 0.9f, spkZ);
cam.setLocation(camPos); cam.setLocation(camPos);
cam.lookAt(lookAt, Vector3f.UNIT_Y); cam.lookAt(lookTarget, Vector3f.UNIT_Y);
// DOF-Fokuspunkt = Gesicht des Sprechers (dynamischer Abstand)
float focusDist = camPos.distance(speakerFace);
WorldScene ws = getStateManager().getState(WorldScene.class);
if (ws != null) { ws.focusDof(focusDist, 10f, 0.8f); }
} }
/** Freier Abstand in {@code dir} ab {@code origin} bis zur ersten Kollision (max Float.MAX_VALUE). */ /** Freier Abstand in {@code dir} ab {@code origin} bis zur ersten Kollision (max Float.MAX_VALUE). */
@@ -597,7 +630,12 @@ public class WorldNpcsState extends BaseAppState {
}); });
} }
// ── Accessor für InteractionHudState ────────────────────────────────────── // ── Accessoren ────────────────────────────────────────────────────────────
/** Liefert das Spatial des aktuellen Dialog-NPCs, oder null wenn kein Dialog aktiv. */
public com.jme3.scene.Spatial getDialogNpcSpatial() {
return dialogNpc != null ? dialogNpc.visual() : null;
}
public String getHoveredLabelKey() { public String getHoveredLabelKey() {
if (hoveredIdx < 0 || hoveredIdx >= spawned.size()) return null; if (hoveredIdx < 0 || hoveredIdx >= spawned.size()) return null;