Animations-Import, Massenimport-Queue, Asset-Archivierung, Voxel-Refactor

- Animations-Import: GLB wird direkt vom Ursprungspfad geladen (kein Zwischenkopieren), J3O in clips/ gespeichert
- RetargetingSystem: Translations-Tracks im Full-Retarget-Pfad erhalten (Hips-Y für sit_down)
- AnimationLibrary: lädt nur J3O, Clip-Name wird bei applyTo() auf Library-Key umbenannt
- SharedInput: animPreviewAddAnimPath → ConcurrentLinkedQueue animImportQueue (Massenimport-Fix)
- EditorApp: archiveOriginal() archiviert Originaldateien nach assets/imported/<assettyp>/
- EditorApp: Animations-Unterknoten im Asset-Baum zeigen enthaltene Clip-Namen
- Neue Animations-Clips: sit_down, get_up_sitting, sitting, pickup, sprinting u.a.
- Voxel: VoxelChunkState entfernt, VoxelChunkNode/MarchingCubes überarbeitet
- Map: Voxel-Chunks bereinigt, Terrain-Chunks aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:52:04 +02:00
parent a369647e9c
commit c8f1dd9432
239 changed files with 8234 additions and 658 deletions

View File

@@ -24,11 +24,28 @@ public class AnimSet {
private List<String> clips = new ArrayList<>();
private Map<String, String> actionMap = new LinkedHashMap<>();
/** Zuletzt im Editor verwendeter Modell-Pfad (relativ zu Assets-Root). Wird beim Öffnen auto-geladen. */
private String previewModelPath = null;
/** Vertikaler Versatz des Visual-Nodes während der jeweiligen Animation (Root-Motion-Ersatz, Fallback). */
private Map<String, Float> sinkMap = new LinkedHashMap<>();
/**
* Pro Aktion konfigurierbarer Anchor-Knochen (z. B. SIT_DOWN → "foot.l", PICK_UP → "hand.r").
* Wenn für eine Aktion ein Eintrag vorhanden ist, wird Bone-Anchoring verwendet:
* der Knochen bleibt auf seiner Welt-Y vor der Animation fixiert.
* Überschreibt sinkMap für diese Aktion.
*/
private Map<String, String> anchorBoneMap = new LinkedHashMap<>();
public List<String> getClips() { return clips; }
public void setClips(List<String> clips) { this.clips = clips; }
public Map<String, String> getActionMap() { return actionMap; }
public void setActionMap(Map<String, String> actionMap) { this.actionMap = actionMap; }
public String getPreviewModelPath() { return previewModelPath; }
public void setPreviewModelPath(String previewModelPath) { this.previewModelPath = previewModelPath; }
public Map<String, Float> getSinkMap() { return sinkMap != null ? sinkMap : new LinkedHashMap<>(); }
public void setSinkMap(Map<String, Float> sinkMap) { this.sinkMap = sinkMap; }
public Map<String, String> getAnchorBoneMap() { return anchorBoneMap != null ? anchorBoneMap : new LinkedHashMap<>(); }
public void setAnchorBoneMap(Map<String, String> anchorBoneMap) { this.anchorBoneMap = anchorBoneMap; }
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
public void save(Path setDir, String setName) throws IOException {

View File

@@ -1,33 +1,53 @@
package de.blight.game.animation;
import de.blight.common.model.TextRegistry;
/**
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
*/
public enum AnimationAction {
DEFAULT,
IDLE,
IDLE,
WALK,
RUN,
SPRINT,
JUMP,
RUNNING_JUMP,
DUCK,
PICK_UP;
PICK_UP,
LIE_DOWN,
LIE_UP,
LYING,
SIT_DOWN,
SIT_UP,
SITTING,
SIT_DOWN_FLOOR,
SITTING_FLOOR,
GET_UP_FLOOR;
/** Lesbare Bezeichnung für UI-Anzeige. */
/** Lesbare Bezeichnung für UI-Anzeige, via TextRegistry aufgelöst. */
public String displayName() {
String key = "animation.action." + name().toLowerCase();
return switch (this) {
case DEFAULT -> "Default";
case IDLE -> "Idle";
case WALK -> "Walk";
case RUN -> "Run";
case SPRINT -> "Sprint";
case JUMP -> "Jump";
case RUNNING_JUMP -> "Running Jump";
case DUCK -> "Duck";
case PICK_UP -> "Pick up";
case DEFAULT -> TextRegistry.resolve(null, key, "Default");
case IDLE -> TextRegistry.resolve(null, key, "Idle");
case WALK -> TextRegistry.resolve(null, key, "Walk");
case RUN -> TextRegistry.resolve(null, key, "Run");
case SPRINT -> TextRegistry.resolve(null, key, "Sprint");
case JUMP -> TextRegistry.resolve(null, key, "Jump");
case RUNNING_JUMP -> TextRegistry.resolve(null, key, "Running Jump");
case DUCK -> TextRegistry.resolve(null, key, "Duck");
case PICK_UP -> TextRegistry.resolve(null, key, "Pick up");
case LIE_DOWN -> TextRegistry.resolve(null, key, "Hinlegen");
case LIE_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bett)");
case LYING -> TextRegistry.resolve(null, key, "Liegen");
case SIT_DOWN -> TextRegistry.resolve(null, key, "Hinsetzen");
case SIT_UP -> TextRegistry.resolve(null, key, "Aufstehen (Bank)");
case SITTING -> TextRegistry.resolve(null, key, "Sitzen");
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)");
};
}
}

View File

@@ -102,6 +102,14 @@ public class AnimationLibrary extends BaseAppState {
} else {
target = src;
}
// Der interne Clip-Name kann vom Library-Schlüssel abweichen (z. B. Blender-Default
// "Action" statt "sit_down_new"). AnimComposer.setCurrentAction() sucht per Name,
// daher muss der Name des gespeicherten Clips dem clipName entsprechen.
if (target != null && !clipName.equals(target.getName())) {
AnimClip renamed = new AnimClip(clipName);
renamed.setTracks(target.getTracks());
target = renamed;
}
if (target == null) {
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
return false;
@@ -109,6 +117,9 @@ public class AnimationLibrary extends BaseAppState {
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
dumpClipTracks(target);
}
return true;
}
@@ -204,8 +215,9 @@ public class AnimationLibrary extends BaseAppState {
}
private void loadClipFromFile(Path file) {
String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
String assetKey = "animations/clips/" + clipName + ".j3o";
String fileName = file.getFileName().toString();
String clipName = fileName.replaceFirst("\\.j3o$", "");
String assetKey = "animations/clips/" + fileName;
try {
Spatial loaded = assetManager.loadModel(assetKey);
@@ -218,7 +230,8 @@ public class AnimationLibrary extends BaseAppState {
Armature armature = sc != null ? sc.getArmature() : null;
for (String name : ac.getAnimClipsNames()) {
clips.put(name, ac.getAnimClip(name));
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
}
@@ -244,4 +257,29 @@ public class AnimationLibrary extends BaseAppState {
return null;
}
/** Loggt alle Tracks eines Clips: Bone-Name, hat Translation (T), Rotation (R), Scale (S). */
private void dumpClipTracks(com.jme3.anim.AnimClip clip) {
log.info("[ClipDump] '{}' length={:.3f}s tracks={}",
clip.getName(), clip.getLength(), clip.getTracks().length);
int tracksWithTranslation = 0;
for (com.jme3.anim.AnimTrack<?> t : clip.getTracks()) {
if (!(t instanceof com.jme3.anim.TransformTrack tt)) continue;
boolean hasT = tt.getTranslations() != null && tt.getTranslations().length > 0;
boolean hasR = tt.getRotations() != null && tt.getRotations().length > 0;
boolean hasS = tt.getScales() != null && tt.getScales().length > 0;
String target = tt.getTarget() instanceof com.jme3.anim.Joint j ? j.getName() : "?";
if (hasT) {
tracksWithTranslation++;
com.jme3.math.Vector3f t0 = tt.getTranslations()[0];
com.jme3.math.Vector3f tN = tt.getTranslations()[tt.getTranslations().length - 1];
log.info("[ClipDump] TRANSLATE '{}' frames={} start=({:.3f},{:.3f},{:.3f}) end=({:.3f},{:.3f},{:.3f}) deltaY={:.4f}",
target, tt.getTranslations().length,
t0.x, t0.y, t0.z, tN.x, tN.y, tN.z, tN.y - t0.y);
} else {
log.info("[ClipDump] rot-only '{}' T={} R={} S={}", target, hasT, hasR, hasS);
}
}
log.info("[ClipDump] Gesamt: {} Tracks mit Translation (von {})", tracksWithTranslation, clip.getTracks().length);
}
}

View File

@@ -17,6 +17,7 @@ import com.jme3.anim.Joint;
import com.jme3.anim.SkinningControl;
import com.jme3.anim.TransformTrack;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.control.Control;
@@ -69,7 +70,7 @@ public final class RetargetingSystem {
// Mixamo-Clips, deren Knochen in Blender nur umbenannt (nicht retargeted) wurden,
// haben denselben Knochennamen aber Mixamo-Bind-Pose → benötigen die volle Formel.
if (isSameRig(nameMap, sourceArmature, targetArmature)) {
log.warn("[Retarget] '{}' same-rig detected fast path (redirect only)", sourceClip.getName());
log.debug("[Retarget] '{}' same-rig detected fast path (redirect only)", sourceClip.getName());
return redirectTracks(sourceClip, targetArmature);
}
@@ -116,7 +117,7 @@ public final class RetargetingSystem {
float[] msA = ms != null ? ms[0].toAngles(null) : new float[3];
float[] sbsA = srcBindMS.get(srcJ).toAngles(null);
float[] dbsA = dstBindMS.get(dstJ).toAngles(null);
log.warn("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°",
log.trace("[ArmDiag] '{}' {} → {} | srcLocal=[{} {} {}]° | srcMS=[{} {} {}]° | srcBindMS=[{} {} {}]° | dstBindMS=[{} {} {}]°",
sourceClip.getName(), e.getKey(), dstName,
String.format("%.1f", Math.toDegrees(loc[0])),
String.format("%.1f", Math.toDegrees(loc[1])),
@@ -180,7 +181,7 @@ public final class RetargetingSystem {
Quaternion bind = dstBindMS.get(d);
float[] a = ams.toAngles(null);
float[] b = bind != null ? bind.toAngles(null) : new float[3];
log.warn("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°",
log.trace("[RootDiag] '{}' root='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]°",
sourceClip.getName(), d.getName(),
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
@@ -197,7 +198,7 @@ public final class RetargetingSystem {
float[] cb = cbind != null ? cbind.toAngles(null) : new float[3];
Quaternion cl = ams.inverse().mult(cms);
float[] cl_ = cl.toAngles(null);
log.warn("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
log.trace("[RootDiag] '{}' child='{}' | dstActualMS=[{} {} {}]° | dstBind=[{} {} {}]° | local=[{} {} {}]°",
sourceClip.getName(), child.getName(),
String.format("%.1f", Math.toDegrees(ca[0])),
String.format("%.1f", Math.toDegrees(ca[1])),
@@ -221,7 +222,7 @@ public final class RetargetingSystem {
Quaternion local0 = pms.inverse().mult(ams);
float[] a = ams.toAngles(null);
float[] l = local0.toAngles(null);
log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
log.trace("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
sourceClip.getName(), dName,
String.format("%.1f", Math.toDegrees(a[0])),
String.format("%.1f", Math.toDegrees(a[1])),
@@ -248,13 +249,37 @@ public final class RetargetingSystem {
log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName());
return null;
}
// Collect translation tracks from source joints.
// The full-retarget path converts rotations to model-space; translations are in
// the bone's local (parent) space and are transferred directly because for same-rig
// retargeting the parent coordinate frames are identical or near-identical.
Map<String, Vector3f[]> srcTransMap = new HashMap<>();
for (AnimTrack<?> t : sourceClip.getTracks()) {
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint srcJ) {
Vector3f[] trans = tt.getTranslations();
if (trans != null && trans.length > 0) {
srcTransMap.put(srcJ.getName(), trans);
}
}
}
List<AnimTrack<?>> newTracks = new ArrayList<>();
for (var entry : dstLocalArrays.entrySet())
newTracks.add(new TransformTrack(entry.getKey(), times, null, entry.getValue(), null));
for (var entry : dstLocalArrays.entrySet()) {
Joint dst = entry.getKey();
Joint srcJoint = dstToSrc.get(dst.getName());
// Only copy translations when the frame count matches to avoid stride errors.
Vector3f[] translations = null;
if (srcJoint != null) {
Vector3f[] srcT = srcTransMap.get(srcJoint.getName());
if (srcT != null && srcT.length == numFrames) {
translations = srcT;
}
}
newTracks.add(new TransformTrack(entry.getKey(), times, translations, entry.getValue(), null));
}
AnimClip result = new AnimClip(sourceClip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
log.warn("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size());
log.debug("[Retarget] '{}' retargeted: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}
@@ -401,7 +426,7 @@ public final class RetargetingSystem {
if (newTracks.isEmpty()) return null;
AnimClip result = new AnimClip(sourceClip.getName());
result.setTracks(newTracks.toArray(new AnimTrack[0]));
log.warn("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size());
log.debug("[Retarget] '{}' redirected: {} Tracks", sourceClip.getName(), newTracks.size());
return result;
}

View File

@@ -292,7 +292,7 @@ public class ConfigScreen extends BaseAppState implements RawInputListener {
}
private BitmapText text(String content, int size, ColorRGBA color) {
BitmapText t = new BitmapText(font, false);
BitmapText t = new BitmapText(font);
t.setSize(size);
t.setColor(color);
t.setText(content);

View File

@@ -275,7 +275,7 @@ public class GraphicsScreen extends BaseAppState {
}
private BitmapText txt(String s, int size, ColorRGBA color) {
BitmapText t = new BitmapText(font, false);
BitmapText t = new BitmapText(font);
t.setSize(size); t.setColor(color); t.setText(s);
return t;
}

View File

@@ -159,7 +159,7 @@ public class PauseMenu extends BaseAppState {
}
private BitmapText txt(String s, int size, ColorRGBA color) {
BitmapText t = new BitmapText(font, false);
BitmapText t = new BitmapText(font);
t.setSize(size); t.setColor(color); t.setText(s);
return t;
}

View File

@@ -221,7 +221,7 @@ public class JmeConsole extends BaseAppState {
}
private BitmapText makeLine(BitmapFont font, ColorRGBA color, float x, float y) {
BitmapText t = new BitmapText(font, false);
BitmapText t = new BitmapText(font);
t.setSize(LINE_H - 2f);
t.setColor(color);
t.setLocalTranslation(x, y, 0f);

View File

@@ -49,10 +49,44 @@ public class PlayerInputControl {
private AnimComposer animComposer;
private String runningClip;
private java.util.Map<String, Float> animSinkMap = java.util.Map.of();
private java.util.Map<String, String> animAnchorBoneMap = java.util.Map.of();
/** Bone-Anchoring: SkinningControl + Referenz-Position vor der Animation (Model-Space, alle Achsen). */
private com.jme3.anim.SkinningControl skinningControl = null;
private Vector3f preAnimAnchorBoneModel = null;
private Vector3f preAnimVisualTranslation = null;
private String currentAnchorBone = null;
private boolean boneAnchorWarnLogged = false;
private int boneAnchorLogFrames = 0;
private int jumpFrames = 0;
private boolean pickupActive = false;
private float pickupRemaining = 0f;
/** Allgemeine blockierende Animation (z. B. LIE_DOWN, SIT_DOWN). */
private boolean blockingAnimActive = false;
private float blockingAnimRemaining = 0f;
private float blockingAnimTotal = 0f;
private Runnable blockingAnimCallback = null;
/**
* Vertikaler Versatz des Visual-Nodes während einer blockierenden Animation
* (Root-Motion-Ersatz: Körper senkt sich beim Setzen, hebt sich beim Aufstehen).
* visualSinkCurrent wird pro Frame interpoliert.
*/
private float visualSinkStart = 0f;
private float visualSinkTarget = 0f;
private float visualSinkCurrent = 0f;
/** Drehung auf der Stelle (kein Vorwärtsbewegen, nur Rotation). */
private boolean turnActive = false;
private float turnRemaining = 0f;
private Vector3f turnDir = null;
private Runnable turnCallback = null;
/** Sperrt Bewegungseingabe dauerhaft (Ruhezustand: liegen/sitzen). */
private boolean lockedInPlace = false;
/** Autopilot: wenn gesetzt, geht der Charakter automatisch in diese (normalisierte) Richtung. */
private Vector3f autopilotDir = null;
@@ -91,6 +125,21 @@ public class PlayerInputControl {
this.runningClip = null;
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
log.info("[AnimCtx] AnimComposer gefunden: {}", animComposer != null);
// SinkMap + AnchorBoneMap aus AnimSet laden
if (animSetName != null && assetRoot != null) {
try {
java.nio.file.Path setDir = assetRoot.resolve("animations").resolve("sets");
de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, animSetName);
animSinkMap = set.getSinkMap();
animAnchorBoneMap = set.getAnchorBoneMap();
} catch (Exception e) {
animSinkMap = java.util.Map.of();
animAnchorBoneMap = java.util.Map.of();
}
}
skinningControl = findSkinningControl(visual);
log.info("[AnimCtx] SkinningControl gefunden: {}, AnchorBoneMap: {}",
skinningControl != null, animAnchorBoneMap);
if (animSetName != null) {
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
if (clip != null && tryPlay(clip)) {
@@ -142,6 +191,116 @@ public class PlayerInputControl {
currentAnim = AnimationAction.PICK_UP;
}
/**
* Spielt eine beliebige Animations-Aktion einmalig ab, blockiert Bewegung für {@code duration}
* Sekunden und ruft danach {@code onComplete} auf (im Update-Thread).
* Bei {@code duration <= 0} wird die echte Clip-Länge aus dem AnimComposer abgefragt.
*/
public void requestAnimation(AnimationAction action, float duration, Runnable onComplete) {
if (duration <= 0f) {
duration = resolveClipLength(action, 1.5f);
}
// Bone-Anchoring: pro Aktion konfigurierten Knochen laden und Referenz-Y einfrieren
currentAnchorBone = animAnchorBoneMap.get(action.name());
if (currentAnchorBone != null && !currentAnchorBone.isBlank()) {
preAnimAnchorBoneModel = getBoneModelPos(currentAnchorBone);
preAnimVisualTranslation = visual != null ? visual.getLocalTranslation().clone() : new Vector3f();
boneAnchorWarnLogged = false;
boneAnchorLogFrames = 0;
log.info("[BoneAnchor] Aktion={} Knochen='{}' preModelY={} (null={})",
action.name(), currentAnchorBone,
preAnimAnchorBoneModel != null ? preAnimAnchorBoneModel.y : Float.NaN,
preAnimAnchorBoneModel == null);
} else {
currentAnchorBone = null;
preAnimAnchorBoneModel = null;
preAnimVisualTranslation = null;
// Fallback: manuellen Sink aus AnimSet-Konfiguration laden
if (animSinkMap.containsKey(action.name())) {
visualSinkTarget = animSinkMap.get(action.name());
}
}
blockingAnimActive = true;
blockingAnimRemaining = duration;
blockingAnimTotal = duration;
blockingAnimCallback = onComplete;
visualSinkStart = visualSinkCurrent;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
playAction(action);
currentAnim = action;
}
/**
* Überschreibt das Sink-Ziel für die nächste {@link #requestAnimation}-Animation manuell.
* Hat Vorrang vor der AnimSet-Konfiguration wenn VOR requestAnimation aufgerufen.
*/
public void setNextAnimationSink(float targetY) {
this.visualSinkTarget = targetY;
}
/** Liefert die Länge des Clips für {@code action} in Sekunden, oder {@code fallback} wenn nicht ermittelbar. */
private float resolveClipLength(AnimationAction action, float fallback) {
if (animComposer == null || animLib == null || animSetName == null) {
return fallback;
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
if (clip == null) {
return fallback;
}
animLib.ensureApplied(clip, visual);
com.jme3.anim.AnimClip ac = animComposer.getAnimClip(clip);
float len = ac != null ? (float) ac.getLength() : 0f;
return len > 0f ? len : fallback;
}
/**
* Dreht den Charakter ohne Fortbewegung in {@code dir} und ruft nach
* {@code duration} Sekunden {@code onComplete} auf.
* Am Ende wird die Zielrotation exakt eingestellt.
*/
public void requestTurn(Vector3f dir, float duration, Runnable onComplete) {
turnActive = true;
turnRemaining = duration;
turnDir = dir.normalize();
turnCallback = onComplete;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
if (currentAnim != AnimationAction.IDLE) {
playAction(AnimationAction.IDLE);
currentAnim = AnimationAction.IDLE;
}
}
/**
* Sperrt Bewegungseingaben dauerhaft (Ruhezustand: Charakter liegt oder sitzt).
* Muss mit {@link #unlockFromPlace()} wieder aufgehoben werden.
*/
public void lockInPlace() {
lockedInPlace = true;
autopilotDir = null;
forward = backward = left = right = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
/** Hebt den durch {@link #lockInPlace()} gesetzten Ruhezustand auf. */
public void unlockFromPlace() {
lockedInPlace = false;
}
public boolean isLockedInPlace() { return lockedInPlace; }
/**
* Spielt eine Animations-Aktion als Dauer-Loop während des Ruhezustands.
* Nur sinnvoll nach {@link #lockInPlace()}.
*/
public void playLockedAnimation(AnimationAction action) {
playAction(action);
currentAnim = action;
}
private void registerMappings(KeyBindings kb) {
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
@@ -177,6 +336,75 @@ public class PlayerInputControl {
}
}
// Drehung auf der Stelle (kein Vorwärtsbewegen)
if (turnActive) {
turnRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
if (visual != null && turnDir != null) {
Quaternion targetRot = new Quaternion();
targetRot.lookAt(turnDir, Vector3f.UNIT_Y);
Quaternion current = visual.getLocalRotation().clone();
current.slerp(targetRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(current);
}
if (turnRemaining <= 0f) {
turnActive = false;
// Zielrotation exakt einrasten
if (visual != null && turnDir != null) {
Quaternion snap = new Quaternion();
snap.lookAt(turnDir, Vector3f.UNIT_Y);
visual.setLocalRotation(snap);
}
Runnable cb = turnCallback;
turnCallback = null;
if (cb != null) cb.run();
}
return;
}
// Blockierende Einmal-Animation (lie_down, sit_down, lie_up, sit_up …)
if (blockingAnimActive) {
blockingAnimRemaining -= tpf;
physicsChar.setWalkDirection(Vector3f.ZERO);
// Visuellen Versatz anpassen: Foot-Anchoring hat Vorrang vor manuellem Sink
if (visual != null) {
if (currentAnchorBone != null && preAnimAnchorBoneModel != null) {
// Bone-Anchoring: 3D-Delta im Model-Space messen und als Visual-Offset anwenden.
// Model-Space ist unabhängig vom Visual-Shift → keine Rückkopplung.
applyBoneAnchorOffset(currentAnchorBone);
} else if (blockingAnimTotal > 0f) {
// Fallback: manueller Sink interpoliert
float t = Math.max(0f, Math.min(1f, 1f - blockingAnimRemaining / blockingAnimTotal));
visualSinkCurrent = visualSinkStart + (visualSinkTarget - visualSinkStart) * t;
applyVisualSink();
}
}
if (blockingAnimRemaining <= 0f) {
blockingAnimActive = false;
if (currentAnchorBone != null && preAnimAnchorBoneModel != null) {
// Bone-Anchoring: letzten Kompensationswert einrasten
applyBoneAnchorOffset(currentAnchorBone);
} else {
// Fallback: Zielwert einrasten
visualSinkCurrent = visualSinkTarget;
applyVisualSink();
}
Runnable cb = blockingAnimCallback;
blockingAnimCallback = null;
if (cb != null) cb.run();
} else {
return;
}
}
// Ruhezustand (liegen / sitzen) blockiert alle Bewegungseingaben
if (lockedInPlace) {
physicsChar.setWalkDirection(Vector3f.ZERO);
return;
}
// Autopilot: Charakter läuft automatisch in eine vorgegebene Richtung (WALK-Animation)
if (autopilotDir != null) {
physicsChar.setWalkDirection(autopilotDir.mult(MOVE_SPEED * WALK_MULT));
@@ -261,6 +489,95 @@ public class PlayerInputControl {
}
}
/**
* Liefert die aktuelle Welt-Y des angegebenen Joints, oder NaN wenn nicht ermittelbar.
* Liest den Joint aus dem SkinningControl (nach AnimComposer-Update = aktueller Frame).
*/
/**
* Gibt die Position des Joints im Model-Space des Armatures zurück.
* Bewusst KEIN Welt-Transform: sonst entsteht eine Rückkopplung mit dem Visual-Offset,
* weil der Visual-Node-Shift den Welt-Transform des Knochens beeinflusst.
*/
private Vector3f getBoneModelPos(String boneName) {
if (skinningControl == null || boneName == null || boneName.isBlank()) {
return null;
}
com.jme3.anim.Armature armature = skinningControl.getArmature();
if (armature == null) {
return null;
}
com.jme3.anim.Joint joint = armature.getJoint(boneName);
if (joint == null) {
return null;
}
return joint.getModelTransform().getTranslation().clone();
}
/**
* Berechnet den Y-Offset des Anchor-Knochens gegenüber seiner Startposition
* (in Model-Space, keine Rückkopplung mit dem Visual-Shift) und setzt die
* Local-Y des Visual-Nodes so, dass der Knochen vertikal fixiert bleibt.
*
* Nur Y wird kompensiert. X/Z-Drift im Model-Space liegt in einem anderen
* Koordinatensystem als der Visual-Node (Blender-Export-Rotation) und würde
* den Charakter horizontal verschieben — das ist falsch.
*
* Formel: visual.localY = preAnimVisualY + (preAnimBone.y - currentBone.y) * scale
*/
private void applyBoneAnchorOffset(String boneName) {
if (visual == null || preAnimAnchorBoneModel == null || preAnimVisualTranslation == null) {
if (!boneAnchorWarnLogged) {
log.warn("[BoneAnchor] applyBoneAnchorOffset abgebrochen: visual={} preModel={} preVis={}",
visual != null, preAnimAnchorBoneModel, preAnimVisualTranslation);
boneAnchorWarnLogged = true;
}
return;
}
Vector3f current = getBoneModelPos(boneName);
if (current == null) {
if (!boneAnchorWarnLogged) {
log.warn("[BoneAnchor] Knochen '{}' nicht im Armature gefunden (skinningControl={})",
boneName, skinningControl != null);
boneAnchorWarnLogged = true;
}
return;
}
float scale = skinningControl != null && skinningControl.getSpatial() != null
? skinningControl.getSpatial().getWorldScale().y : 1f;
float newY = preAnimVisualTranslation.y + (preAnimAnchorBoneModel.y - current.y) * scale;
visualSinkCurrent = newY;
com.jme3.math.Vector3f t = visual.getLocalTranslation();
visual.setLocalTranslation(t.x, newY, t.z);
}
/** Durchsucht den Szenegraphen rekursiv nach dem ersten SkinningControl. */
private com.jme3.anim.SkinningControl findSkinningControl(Spatial s) {
if (s == null) {
return null;
}
com.jme3.anim.SkinningControl sc = s.getControl(com.jme3.anim.SkinningControl.class);
if (sc != null) {
return sc;
}
if (s instanceof com.jme3.scene.Node n) {
for (Spatial child : n.getChildren()) {
sc = findSkinningControl(child);
if (sc != null) {
return sc;
}
}
}
return null;
}
private void applyVisualSink() {
if (visual == null) {
return;
}
com.jme3.math.Vector3f t = visual.getLocalTranslation();
visual.setLocalTranslation(t.x, visualSinkCurrent, t.z);
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.ensureApplied(clip, visual)) {
log.info("[Anim] tryPlay('{}') → ensureApplied FAILED", clip);

View File

@@ -0,0 +1,111 @@
package de.blight.game.navigation;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import com.jme3.collision.CollisionResults;
import de.blight.common.model.WorldPoint;
import de.blight.common.path.PathNetwork;
import de.blight.common.path.PathNetworkIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
/**
* Zentrale Klasse für NPC-Navigation.
*
* <h2>Dreisegmentiges Pfad-Modell</h2>
* <pre>
* A ──[Off-Netz]──► nächster Netzknoten
* ──[A* Netz]──► letzter Netzknoten nahe B
* ──[Off-Netz]──► B
* </pre>
*
* <h2>Hindernisvermeidung</h2>
* <ul>
* <li>Netz-Segment: keine weiteren Prüfungen das Netz ist per Konstruktion
* um Hindernisse herum verlegt.</li>
* <li>Off-Netz-Segmente: Raycasting gegen {@code obstacleRoot}.
* Ist der direkte Weg blockiert, springt der Finder auf den nächsten
* freien Netzknoten zurück (Fallback: inkrementelles Steering durch
* die aufrufende Bewegungssteuerung, z. B. via {@link SteeringHelper}).</li>
* </ul>
*/
public class PathFinder {
private static final Logger log = LoggerFactory.getLogger(PathFinder.class);
private final PathNetwork network;
/**
* Optionale Szene-Geometrie, gegen die der Off-Netz-Weg auf Hindernisse
* geprüft wird. Kann null sein (dann kein Obstacle-Check).
*/
private Spatial obstacleRoot;
public PathFinder(PathNetwork network) {
this.network = network;
}
/** Lädt das Wegnetz aus der Standard-Datei. */
public static PathFinder load() throws IOException {
return new PathFinder(PathNetworkIO.load());
}
public void setObstacleRoot(Spatial root) { this.obstacleRoot = root; }
// ── Haupt-API ──────────────────────────────────────────────────────────────
/**
* Berechnet den vollständigen Pfad von {@code from} nach {@code to}.
*
* Rückgabe: flache Liste von Weltpunkten, die der NPC der Reihe nach
* anläuft. Wenn das Netz leer ist, wird [from, to] zurückgegeben.
*/
public List<WorldPoint> findPath(WorldPoint from, WorldPoint to) {
return network.findPath(from, to);
}
/**
* Wie {@link #findPath}, gibt aber die drei Segmente getrennt zurück.
* Segment {@code offNetworkStart} und {@code offNetworkEnd} können
* durch {@link SteeringHelper} mit Hindernisvermeidung traversiert werden.
*/
public PathNetwork.PathResult findPathSegmented(WorldPoint from, WorldPoint to) {
return network.findPathSegmented(from, to);
}
// ── Obstacle-Check ─────────────────────────────────────────────────────────
/**
* Prüft, ob der direkte Weg zwischen zwei Punkten durch die Szene-Geometrie
* blockiert ist.
*
* @return {@code true} wenn der Weg frei ist (oder kein obstacleRoot gesetzt).
*/
public boolean isDirectPathClear(WorldPoint a, WorldPoint b) {
if (obstacleRoot == null) return true;
Vector3f from3 = new Vector3f(a.x, a.y + 1f, a.z); // etwas über Boden
Vector3f to3 = new Vector3f(b.x, b.y + 1f, b.z);
Vector3f dir = to3.subtract(from3);
float dist = dir.length();
if (dist < 0.001f) return true;
dir.divideLocal(dist);
Ray ray = new Ray(from3, dir);
CollisionResults hits = new CollisionResults();
obstacleRoot.collideWith(ray, hits);
// Nur Treffer vor dem Ziel zählen
for (int i = 0; i < hits.size(); i++) {
if (hits.getCollision(i).getDistance() < dist) return false;
}
return true;
}
public PathNetwork getNetwork() { return network; }
}

View File

@@ -0,0 +1,121 @@
package de.blight.game.navigation;
import com.jme3.math.Vector3f;
import de.blight.common.model.WorldPoint;
import java.util.ArrayList;
import java.util.List;
/**
* Hilfsmethoden für Off-Netz-Bewegung mit einfacher Hindernisvermeidung.
*
* <h2>Prinzip (Bug-Algorithmus Lite)</h2>
* Ist der direkte Weg von {@code current} nach {@code target} frei, geht der
* NPC direkt dorthin. Ist er blockiert, weicht er seitlich aus (Tangente
* senkrecht zur Bewegungsrichtung) und versucht dann erneut.
*
* Die aufwendige Geometrie-Prüfung delegiert an {@link PathFinder#isDirectPathClear}.
* Diese Klasse liefert nur Richtungsvektoren die eigentliche Physik-/Animations-
* Steuerung übernimmt das NPC-Control.
*/
public final class SteeringHelper {
private SteeringHelper() {}
/** Maximale Anzahl von Ausweich-Versuchen pro Frame/Schritt. */
private static final int MAX_SIDESTEP_TRIES = 8;
private static final float SIDESTEP_DISTANCE = 2f;
/**
* Gibt einen Zwischen-Wegpunkt zurück, der den NPC um ein Hindernis leitet.
*
* <p>Algorithmus:
* <ol>
* <li>Direkter Weg frei? → Ziel direkt zurückgeben.</li>
* <li>Seitlich ausweichen (8 Richtungen in 45°-Schritten), erste freie
* Richtung → Zwischen-Ziel zurückgeben.</li>
* <li>Kein Ausweg gefunden → Fallback: direktes Ziel (Bewegungsystem
* darf dann selbst abbremsen / warten).</li>
* </ol>
*
* @param current Aktuelle NPC-Position
* @param target Gewünschtes Ziel
* @param finder PathFinder für Obstacle-Check
* @return Nächster anzulaufender Punkt (kann {@code target} selbst sein)
*/
public static WorldPoint nextSteeringPoint(WorldPoint current,
WorldPoint target,
PathFinder finder) {
if (finder.isDirectPathClear(current, target)) return target;
float dx = target.x - current.x;
float dz = target.z - current.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.001f) return target;
dx /= len; dz /= len;
// Senkrechter Vektor (links / rechts)
float perpX = dz;
float perpZ = -dx;
for (int i = 1; i <= MAX_SIDESTEP_TRIES; i++) {
float angle = (float) (i * Math.PI / MAX_SIDESTEP_TRIES);
// Wechselnde Seiten
int sign = (i % 2 == 0) ? 1 : -1;
float sideX = perpX * sign * (float) Math.cos(angle) * SIDESTEP_DISTANCE;
float sideZ = perpZ * sign * (float) Math.sin(angle) * SIDESTEP_DISTANCE;
WorldPoint candidate = new WorldPoint(
current.x + dx * SIDESTEP_DISTANCE + sideX,
current.y,
current.z + dz * SIDESTEP_DISTANCE + sideZ);
if (finder.isDirectPathClear(current, candidate)) return candidate;
}
return target; // Fallback
}
/**
* Verfeinert einen Off-Netz-Pfad so, dass blockierte Segmente durch
* Ausweichpunkte ersetzt werden.
*
* @param waypoints Ursprüngliche Punktliste (z. B. aus PathResult.offNetworkStart)
* @param finder Obstacle-Checker
* @return Neue Punktliste mit Ausweichpunkten wo nötig
*/
public static List<WorldPoint> resolveObstacles(List<WorldPoint> waypoints,
PathFinder finder) {
List<WorldPoint> result = new ArrayList<>();
if (waypoints.isEmpty()) return result;
result.add(waypoints.get(0));
for (int i = 0; i < waypoints.size() - 1; i++) {
WorldPoint a = waypoints.get(i);
WorldPoint b = waypoints.get(i + 1);
if (!finder.isDirectPathClear(a, b)) {
WorldPoint detour = nextSteeringPoint(a, b, finder);
result.add(detour);
}
result.add(b);
}
return result;
}
/** Berechnet die Bewegungsrichtung (normiert, im X/Z-Plane) von {@code from} nach {@code to}. */
public static Vector3f directionXZ(WorldPoint from, WorldPoint to) {
float dx = to.x - from.x;
float dz = to.z - from.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len < 0.001f) return Vector3f.ZERO;
return new Vector3f(dx / len, 0f, dz / len);
}
/** Horizontale Distanz zwischen zwei Punkten. */
public static float dist2D(WorldPoint a, WorldPoint b) {
float dx = a.x - b.x;
float dz = a.z - b.z;
return (float) Math.sqrt(dx * dx + dz * dz);
}
}

View File

@@ -40,14 +40,17 @@ import de.blight.game.state.GrassVertexRenderState;
import de.blight.game.state.LocationState;
import de.blight.game.state.RiverState;
import de.blight.game.state.TerrainChunkState;
import de.blight.game.state.VoxelChunkState;
import de.blight.game.state.SculptedMeshState;
import de.blight.game.state.WaterBodyState;
import de.blight.game.state.DayNightState;
import de.blight.game.state.WeatherState;
import de.blight.game.state.InteractionHudState;
import de.blight.game.state.InventoryState;
import de.blight.game.state.WorldInteractableState;
import de.blight.game.state.WorldItemsState;
import de.blight.game.state.StoneWorldState;
import de.blight.game.state.WorldObjectsState;
import de.blight.game.state.WorldLightState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -147,6 +150,8 @@ public class WorldScene extends BaseAppState {
BlightGame.status("Lade Welt-Objekte...");
app.getStateManager().attach(new RiverState());
app.getStateManager().attach(new WorldObjectsState());
app.getStateManager().attach(new WorldLightState(sharedFPP));
app.getStateManager().attach(new StoneWorldState());
BlightGame.status("Lade Charakter...");
character = loadOrBuildCharacter();
@@ -181,6 +186,8 @@ public class WorldScene extends BaseAppState {
app.getStateManager().attach(new LocationState(mc, character));
app.getStateManager().attach(
new WorldItemsState(keyBindings, physicsChar, mc, playerInput));
app.getStateManager().attach(
new WorldInteractableState(keyBindings, physicsChar, playerInput));
app.getStateManager().attach(new InteractionHudState());
inventoryState = new InventoryState(mc, keyBindings);
inventoryState.setEnabled(false);
@@ -238,7 +245,6 @@ public class WorldScene extends BaseAppState {
*/
public void toggleDebugNoLight() {
debugMode = (debugMode + 1) % 4;
VoxelChunkState vcs = getApplication().getStateManager().getState(VoxelChunkState.class);
switch (debugMode) {
case 0 -> {
if (terrainMaterial != null) {
@@ -246,7 +252,6 @@ public class WorldScene extends BaseAppState {
terrainMaterial.setBoolean("DebugSlot0Only", false);
terrainMaterial.clearParam("DebugDirectTex");
}
if (vcs != null) vcs.setDebugNoLight(false);
log.info("[Debug] Modus 0: normal");
}
case 1 -> {
@@ -254,8 +259,7 @@ public class WorldScene extends BaseAppState {
terrainMaterial.setBoolean("DebugNoLight", true);
terrainMaterial.setBoolean("DebugSlot0Only", false);
}
if (vcs != null) vcs.setDebugNoLight(true);
log.info("[Debug] Modus 1: kein Licht (Terrain + Voxel)");
log.info("[Debug] Modus 1: kein Licht");
}
case 2 -> {
if (terrainMaterial != null) {
@@ -263,7 +267,6 @@ public class WorldScene extends BaseAppState {
terrainMaterial.setBoolean("DebugSlot0Only", true);
terrainMaterial.clearParam("DebugDirectTex");
}
if (vcs != null) vcs.setDebugNoLight(true);
log.info("[Debug] Modus 2: nur Slot-0 (kein Blending, kein Licht)");
}
case 3 -> {
@@ -275,8 +278,7 @@ public class WorldScene extends BaseAppState {
t.setWrap(com.jme3.texture.Texture.WrapMode.Repeat);
terrainMaterial.setTexture("DebugDirectTex", t);
}
if (vcs != null) vcs.setDebugNoLight(true);
log.info("[Debug] Modus 3: direkte Texture2D '{}' (bypasses TextureArray)", debugSlot0Path);
log.info("[Debug] Modus 3: direkte Texture2D '{}'", debugSlot0Path);
}
}
}
@@ -480,9 +482,7 @@ public class WorldScene extends BaseAppState {
// sofort die Physik für die Spawn-Umgebung aufbaut.
terrainChunkState.setSpawnHint(spawnX, spawnZ);
VoxelChunkState voxelState = new VoxelChunkState(bulletAppState, loadedMapData);
terrainChunkState.addChunkListener(voxelState);
app.getStateManager().attach(voxelState);
app.getStateManager().attach(new SculptedMeshState(bulletAppState, loadedMapData));
}
// -----------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@ package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
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;
@@ -49,6 +50,24 @@ public class DayNightState extends BaseAppState implements TimeListener {
private Spatial sky;
private Geometry sunSphere;
// ── Höhlen-Abdunkelung ────────────────────────────────────────────────────
/** 0 = im Freien, 1 = vollständig in einer Höhle. */
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 ─────────────────────────────────────────────────────────
/** Game-Modus: Schatten aktiv, 5-Minuten-Tag. */
@@ -183,6 +202,39 @@ public class DayNightState extends BaseAppState implements TimeListener {
if (sunSphere != null && sunSphere.getCullHint() != Spatial.CullHint.Always) {
sunSphere.setLocalTranslation(camPos.add(sun.getDirection().negate().mult(480f)));
}
updateCaveLighting(tpf, camPos);
}
/** 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;
BulletAppState bullet = app.getStateManager().getState(BulletAppState.class);
if (bullet != null && bullet.getPhysicsSpace() != null) {
Vector3f to = camPos.add(0f, CAVE_RAY_HEIGHT, 0f);
boolean underCover = !bullet.getPhysicsSpace().rayTest(camPos, to).isEmpty();
targetCaveFactor = underCover ? 1f : 0f;
}
}
// Glatte Überblendung des Höhlen-Faktors
float prev = caveFactor;
if (caveFactor < targetCaveFactor) {
caveFactor = Math.min(targetCaveFactor, caveFactor + CAVE_FADE_SPEED * tpf);
} else if (caveFactor > targetCaveFactor) {
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)
shadowFilter.setShadowIntensity(shadowBaseIntensity * scale);
}
}
// ── Zeit-Callback ─────────────────────────────────────────────────────────
@@ -205,7 +257,8 @@ public class DayNightState extends BaseAppState implements TimeListener {
// ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) ──────────────
float dawnFactor = 1f - FastMath.clamp(elev * 5f, 0f, 1f);
ColorRGBA sunColor = SUN_DAWN.clone().interpolateLocal(SUN_DAY, 1f - dawnFactor);
sun.setColor(sunColor.multLocal(elevC * 0.85f));
sunBaseColor = sunColor.mult(elevC * 0.85f);
sun.setColor(sunBaseColor.mult(1f - caveFactor));
// ── Sonnen-Sphere ausblenden wenn unter Horizont ────────────────────
if (sunSphere != null) {
@@ -225,8 +278,9 @@ public class DayNightState extends BaseAppState implements TimeListener {
ambient.setColor(AMB_NIGHT.clone().interpolateLocal(AMB_DAY, ambFactor));
// ── Schatten ────────────────────────────────────────────────────────
shadowBaseIntensity = FastMath.clamp(elev * 0.8f, 0f, 0.5f);
if (shadowFilter != null)
shadowFilter.setShadowIntensity(FastMath.clamp(elev * 0.8f, 0f, 0.5f));
shadowFilter.setShadowIntensity(shadowBaseIntensity * (1f - caveFactor));
// ── Himmel & Hintergrundfarbe ────────────────────────────────────────
float skyFactor = FastMath.clamp((elev + 0.05f) / 0.2f, 0f, 1f);

View File

@@ -7,15 +7,11 @@ import com.jme3.asset.AssetManager;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
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.scene.control.AbstractControl;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassTuft;
import de.blight.common.GrassTuftIO;
@@ -31,9 +27,10 @@ import java.util.*;
/**
* Rendert individuell platzierte Gras-Büschel aus blight_grass.blg.
* Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME).
* GrassVisibilityControl cullt entfernte Chunks.
* Implementiert ChunkListener: Gras wird nur bei LOD 0 angezeigt.
*/
public class GrassState extends BaseAppState {
public class GrassState extends BaseAppState
implements TerrainChunkState.ChunkListener {
private static final Logger log = LoggerFactory.getLogger(GrassState.class);
@@ -44,17 +41,15 @@ public class GrassState extends BaseAppState {
private static final int BLADES_PER_TUFT = 4;
private static final float TUFT_SPREAD = 0.5f;
private static final float BLADE_WIDTH = 0.18f;
private static final float FAR_DIST = 150f;
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
private static final int INIT_PER_FRAME = 4;
private final TerrainChunkState terrainChunkState;
private Camera cam;
private Node grassNode;
@SuppressWarnings("unchecked")
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private int nextChunk = 0;
@@ -66,9 +61,9 @@ public class GrassState extends BaseAppState {
@Override
protected void initialize(Application app) {
this.cam = app.getCamera();
grassNode = new Node("gameGrass");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
terrainChunkState.addChunkListener(this);
for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
@@ -92,6 +87,7 @@ public class GrassState extends BaseAppState {
@Override
protected void cleanup(Application app) {
terrainChunkState.removeChunkListener(this);
((SimpleApplication) app).getRootNode().detachChild(grassNode);
}
@@ -201,8 +197,6 @@ public class GrassState extends BaseAppState {
if (bySlot.isEmpty()) return;
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Node node = new Node("grass_" + idx);
for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
if (entry.getValue().isEmpty()) continue;
@@ -214,10 +208,34 @@ public class GrassState extends BaseAppState {
node.attachChild(geo);
}
if (node.getChildren().isEmpty()) return;
node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
node.setCullHint(Spatial.CullHint.Always); // sichtbar erst wenn LOD0
chunkNodes[idx] = node;
grassNode.attachChild(node);
}
// ── ChunkListener: Gras nur bei LOD 0 ────────────────────────────────────
@Override
public void onChunkVisible(int cx, int cz, int lod) {
setChunkVisible(cx, cz, lod == 0);
}
@Override
public void onChunkHidden(int cx, int cz) {
setChunkVisible(cx, cz, false);
}
@Override
public void onChunkLodChanged(int cx, int cz, int oldLod, int newLod) {
setChunkVisible(cx, cz, newLod == 0);
}
private void setChunkVisible(int cx, int cz, boolean visible) {
int ci = cz * CHUNKS_PER_AXIS + cx;
if (ci < 0 || ci >= CHUNK_COUNT || chunkNodes[ci] == null) return;
chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
// ── Mesh: Kreuz-Quad mit UV ───────────────────────────────────────────────
private static Mesh buildGrassMesh(List<float[]> blades) {
@@ -256,25 +274,4 @@ public class GrassState extends BaseAppState {
return mesh;
}
// ── LOD-Control ───────────────────────────────────────────────────────────
private static final class GrassVisibilityControl extends AbstractControl {
private final Camera cam;
private final Vector3f center;
GrassVisibilityControl(Camera cam, Vector3f center) {
this.cam = cam;
this.center = center;
}
@Override
protected void controlUpdate(float tpf) {
float distSq = cam.getLocation().distanceSquared(center);
spatial.setCullHint(distSq > FAR_DIST_SQ
? Spatial.CullHint.Always
: Spatial.CullHint.Inherit);
}
@Override protected void controlRender(RenderManager rm, ViewPort vp) {}
}
}

View File

@@ -38,7 +38,7 @@ public class InteractionHudState extends BaseAppState {
this.guiNode = sapp.getGuiNode();
BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
labelText = new BitmapText(font, false);
labelText = new BitmapText(font);
labelText.setSize(font.getCharSet().getRenderedSize() * 1.2f);
labelText.setColor(new ColorRGBA(1f, 0.95f, 0.6f, 1f));
labelText.setCullHint(Spatial.CullHint.Always);

View File

@@ -434,7 +434,7 @@ public class InventoryState extends BaseAppState {
}
private BitmapText txt(String s, int size, ColorRGBA col) {
BitmapText t = new BitmapText(font, false);
BitmapText t = new BitmapText(font);
t.setSize(size); t.setColor(col); t.setText(s);
return t;
}

View File

@@ -507,7 +507,8 @@ public final class MarchingCubes {
// Vertices gleicher Position zu Gruppen zusammenfassen.
// Schlüssel: quantisierte XYZ-Koordinaten (1/2048 Voxel-Auflösung).
HashMap<Long, Integer> keyToGroup = new HashMap<>(vertCount * 2);
// Initiale Kapazität ~vertCount/3 (je 3 Triangle-Verts teilen sich 1 Unique-Position).
HashMap<Long, Integer> keyToGroup = new HashMap<>(vertCount / 3 + 16);
int[] vertGroup = new int[vertCount];
int[] groupFirst = new int[vertCount]; // ein Repräsentant je Gruppe
int groupCount = 0;
@@ -545,6 +546,21 @@ public final class MarchingCubes {
}
}
// Unterste freie Vertices einfrieren: Der Laplacian-Smooth würde die Basis des
// Gebirges nach oben ziehen und einen sichtbaren Spalt zum Terrain erzeugen.
// Vertices innerhalb der untersten 3 lokalen Einheiten (= 3 m) über der
// tiefsten freien Position bleiben fix, damit die Terrain-Anbindung erhalten bleibt.
float minFreeY = Float.MAX_VALUE;
for (int g = 0; g < groupCount; g++) {
if (!pinned[g] && gy[g] < minFreeY) minFreeY = gy[g];
}
if (minFreeY < Float.MAX_VALUE) {
float basePinY = minFreeY + 3f;
for (int g = 0; g < groupCount; g++) {
if (!pinned[g] && gy[g] <= basePinY) pinned[g] = true;
}
}
float[] ax = new float[groupCount];
float[] ay = new float[groupCount];
float[] az = new float[groupCount];

View File

@@ -0,0 +1,309 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.collision.shapes.MeshCollisionShape;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.material.Material;
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.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.VertexBuffer;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.texture.image.ColorSpace;
import de.blight.common.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
/**
* Lädt alle gebackenen Voxel-Meshes (+ eventuelle Sculpt-Overlays) beim
* Spielstart statisch in die Szene. Kein Chunk-Listener, kein dynamisches
* Nach-/Entladen das Mesh ist das finale Asset.
*/
public class SculptedMeshState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(SculptedMeshState.class);
private final BulletAppState bulletState;
private final MapData mapData;
private SimpleApplication app;
private Node sculptRoot;
private Material material;
public SculptedMeshState(BulletAppState bulletState, MapData mapData) {
this.bulletState = bulletState;
this.mapData = mapData;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
sculptRoot = new Node("sculptRoot");
app.getRootNode().attachChild(sculptRoot);
material = buildMaterial();
loadAll();
}
@Override
protected void cleanup(Application application) {
sculptRoot.removeFromParent();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
DayNightState dns = app.getStateManager().getState(DayNightState.class);
if (dns == null || dns.getSunLight() == null) return;
material.setVector3("LightDir", dns.getSunDirection().negate());
ColorRGBA sc = dns.getSunLight().getColor();
ColorRGBA ac = dns.getAmbientLight().getColor();
material.setVector3("SunColor", new Vector3f(sc.r, sc.g, sc.b));
material.setVector3("AmbientColor", new Vector3f(ac.r, ac.g, ac.b));
}
// ── Laden ────────────────────────────────────────────────────────────────
private void loadAll() {
List<int[]> chunks = SculptedMeshIO.findAllBakedChunks();
log.info("[SculptedMesh] {} gebackene Chunks gefunden.", chunks.size());
for (int[] cxyz : chunks) {
try {
loadChunk(cxyz[0], cxyz[1], cxyz[2]);
} catch (Exception e) {
log.warn("[SculptedMesh] Laden fehlgeschlagen ({},{},{}): {}",
cxyz[0], cxyz[1], cxyz[2], e.getMessage(), e);
}
}
}
private void loadChunk(int cx, int cy, int cz) throws Exception {
Path p0 = VoxelChunkIO.getBakedPath(cx, cy, cz, 0);
if (!Files.exists(p0)) return;
BinaryImporter imp = BinaryImporter.getInstance();
imp.setAssetManager(app.getAssetManager());
Mesh mesh = (Mesh) imp.load(p0.toFile());
// Vertices verschweißen gleiche Position → selber Welded-Index
FloatBuffer posBuf = mesh.getFloatBuffer(VertexBuffer.Type.Position);
int rawCount = posBuf.limit() / 3;
float[] rawPos = new float[rawCount * 3];
posBuf.rewind();
posBuf.get(rawPos);
int[] v2w = new int[rawCount];
int wCount = 0;
HashMap<Long, Integer> key2w = new HashMap<>(rawCount / 3 + 16);
for (int v = 0; v < rawCount; v++) {
long pk = posKey(rawPos, v);
Integer w = key2w.get(pk);
if (w == null) { key2w.put(pk, wCount); v2w[v] = wCount++; }
else { v2w[v] = w; }
}
float[] weldedPos = new float[wCount * 3];
for (int v = 0; v < rawCount; v++) {
int w = v2w[v];
weldedPos[w*3] = rawPos[v*3];
weldedPos[w*3+1] = rawPos[v*3+1];
weldedPos[w*3+2] = rawPos[v*3+2];
}
// Sculpt-Overlay anwenden (welded Positionen aus .blsm auf raw aufächern)
if (SculptedMeshIO.exists(cx, cy, cz)) {
try {
SculptedMesh overlay = SculptedMeshIO.load(cx, cy, cz);
if (overlay.positions.length / 3 == wCount) {
weldedPos = overlay.positions;
float[] expandedPos = new float[rawCount * 3];
for (int v = 0; v < rawCount; v++) {
int w = v2w[v];
expandedPos[v*3] = weldedPos[w*3];
expandedPos[v*3+1] = weldedPos[w*3+1];
expandedPos[v*3+2] = weldedPos[w*3+2];
}
posBuf.rewind();
posBuf.put(expandedPos);
posBuf.rewind();
mesh.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded();
} else {
log.warn("[SculptedMesh] Overlay ({},{},{}) Größe passt nicht: {} vs {}",
cx, cy, cz, overlay.positions.length / 3, wCount);
}
} catch (Exception e) {
log.warn("[SculptedMesh] Overlay ({},{},{}) fehlerhaft: {}", cx, cy, cz, e.getMessage());
}
}
// Smooth-Normalen im verschweißten Raum neu berechnen, damit vNormal.y
// zwischen Vertices interpoliert und der Flat↔Steep-Blend im Shader weich ist.
recomputeSmoothedNormals(mesh, v2w, weldedPos, rawCount, wCount);
float ox = cx * VoxelChunk.CELLS - 2048f;
float oy = cy * (float) VoxelChunk.CELLS;
float oz = cz * VoxelChunk.CELLS - 2048f;
com.jme3.bounding.BoundingBox bb = (com.jme3.bounding.BoundingBox) mesh.getBound();
log.info("[SculptedMesh] Chunk ({},{},{}) geladen: nodeOrigin=({},{},{}) meshBounds={} vertices={}",
cx, cy, cz, ox, oy, oz, bb, mesh.getVertexCount());
Geometry geo = new Geometry("sculpted_" + cx + "_" + cy + "_" + cz, mesh);
geo.setMaterial(material);
geo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
Node node = new Node();
node.setLocalTranslation(ox, oy, oz);
node.attachChild(geo);
sculptRoot.attachChild(node);
// Physics NACH Einbinden in den Szenengraphen, damit die Welt-Transform korrekt ist
MeshCollisionShape shape = new MeshCollisionShape(mesh);
RigidBodyControl rbc = new RigidBodyControl(shape, 0f);
geo.addControl(rbc);
bulletState.getPhysicsSpace().add(rbc);
}
/**
* Berechnet Smooth-Normalen im verschweißten Raum:
* Flächennormalen aller Dreiecke werden pro Welded-Vertex akkumuliert,
* normalisiert und dann auf die rohen Vertices aufgeächert.
* Das entspricht dem Verfahren im Editor und in MarchingCubes.smooth().
*/
private static void recomputeSmoothedNormals(Mesh mesh, int[] v2w, float[] weldedPos,
int rawCount, int wCount) {
float[] wn = new float[wCount * 3];
// Triangle-Soup: Vertices liegen immer in Gruppen von 3 aufeinander
for (int i = 0; i < rawCount; i += 3) {
int w0 = v2w[i], w1 = v2w[i+1], w2 = v2w[i+2];
float p0x=weldedPos[w0*3], p0y=weldedPos[w0*3+1], p0z=weldedPos[w0*3+2];
float p1x=weldedPos[w1*3], p1y=weldedPos[w1*3+1], p1z=weldedPos[w1*3+2];
float p2x=weldedPos[w2*3], p2y=weldedPos[w2*3+1], p2z=weldedPos[w2*3+2];
float e1x=p1x-p0x, e1y=p1y-p0y, e1z=p1z-p0z;
float e2x=p2x-p0x, e2y=p2y-p0y, e2z=p2z-p0z;
float nx=e1y*e2z-e1z*e2y, ny=e1z*e2x-e1x*e2z, nz=e1x*e2y-e1y*e2x;
wn[w0*3]+=nx; wn[w0*3+1]+=ny; wn[w0*3+2]+=nz;
wn[w1*3]+=nx; wn[w1*3+1]+=ny; wn[w1*3+2]+=nz;
wn[w2*3]+=nx; wn[w2*3+1]+=ny; wn[w2*3+2]+=nz;
}
for (int w = 0; w < wCount; w++) {
float len=(float)Math.sqrt(wn[w*3]*wn[w*3]+wn[w*3+1]*wn[w*3+1]+wn[w*3+2]*wn[w*3+2]);
if (len>1e-4f) { wn[w*3]/=len; wn[w*3+1]/=len; wn[w*3+2]/=len; }
else { wn[w*3+1]=1f; }
}
float[] rawNormals = new float[rawCount * 3];
for (int v = 0; v < rawCount; v++) {
int w = v2w[v];
rawNormals[v*3] = wn[w*3];
rawNormals[v*3+1] = wn[w*3+1];
rawNormals[v*3+2] = wn[w*3+2];
}
VertexBuffer nb = mesh.getBuffer(VertexBuffer.Type.Normal);
if (nb != null) {
FloatBuffer nbf = (FloatBuffer) nb.getData();
nbf.clear();
nbf.put(rawNormals);
nbf.rewind();
nb.setUpdateNeeded();
} else {
mesh.setBuffer(VertexBuffer.Type.Normal, 3, rawNormals);
}
mesh.updateBound();
}
private static long posKey(float[] pos, int vi) {
long qx = Math.round(pos[vi*3] * 2048f) & 0x3FFFFL;
long qy = Math.round(pos[vi*3+1] * 2048f) & 0x3FFFFL;
long qz = Math.round(pos[vi*3+2] * 2048f) & 0x3FFFFL;
return qx | (qy << 18) | (qz << 36);
}
// ── Material ─────────────────────────────────────────────────────────────
private Material buildMaterial() {
Material mat = new Material(app.getAssetManager(), "MatDefs/Voxel.j3md");
mat.setFloat("TexScale", 8f);
int[] slots = {
mapData != null ? mapData.voxelFlatSlot : -1,
mapData != null ? mapData.voxelSteepSlot : -1,
};
String[] colSlots = {"TexFlat", "TexSteep" };
String[] normSlots = {"NormalMapFlat", "NormalMapSteep" };
// Fallback-Farben wenn kein Slot konfiguriert: grünlich-grau (flach), felsgrau (steil)
int[][] fallbackRgb = {
{100, 130, 60},
{110, 100, 90},
};
for (int i = 0; i < 2; i++) {
String tex = resolveSlotTex(slots[i]);
String norm = resolveSlotNorm(slots[i]);
if (!tex.isEmpty()) {
try {
Texture t = app.getAssetManager().loadTexture(tex);
t.getImage().setColorSpace(ColorSpace.Linear);
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(colSlots[i], t);
} catch (Exception e) {
log.warn("Textur {} nicht ladbar: {}", tex, e.getMessage());
mat.setTexture(colSlots[i], solidColorTexture(fallbackRgb[i]));
}
} else {
mat.setTexture(colSlots[i], solidColorTexture(fallbackRgb[i]));
}
if (!norm.isEmpty()) {
try {
Texture n = app.getAssetManager().loadTexture(norm);
n.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(normSlots[i], n);
} catch (Exception e) {
mat.clearParam(normSlots[i]);
}
} else {
mat.clearParam(normSlots[i]);
}
}
return mat;
}
private Texture solidColorTexture(int[] rgb) {
ByteBuffer buf = ByteBuffer.allocate(4);
buf.put((byte) rgb[0]).put((byte) rgb[1]).put((byte) rgb[2]).put((byte) 255);
buf.flip();
Texture2D tex = new Texture2D(
new com.jme3.texture.Image(com.jme3.texture.Image.Format.RGBA8, 1, 1, buf));
tex.setWrap(Texture.WrapMode.Repeat);
return tex;
}
private String resolveSlotTex(int slot) {
if (slot < 0 || mapData == null) return "";
if (slot < 4) return s(mapData.terrainTextures[slot]);
if (slot < 8) return s(mapData.upperTextures[slot-4]);
if (slot < 12) return s(mapData.thirdTextures[slot-8]);
return "";
}
private String resolveSlotNorm(int slot) {
if (slot < 0 || mapData == null) return "";
if (slot < 4) return s(mapData.terrainNormalMaps[slot]);
if (slot < 8) return s(mapData.upperNormalMaps[slot-4]);
if (slot < 12) return s(mapData.thirdNormalMaps[slot-8]);
return "";
}
private static String s(String v) { return v != null ? v : ""; }
}

View File

@@ -0,0 +1,264 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer.Type;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedStone;
import de.blight.common.PlacedStoneIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.util.*;
public class StoneWorldState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(StoneWorldState.class);
private SimpleApplication app;
private AssetManager assets;
private BulletAppState bulletAppState;
private TerrainChunkState terrainChunkState;
private Node stoneRoot;
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
this.bulletAppState = app.getStateManager().getState(BulletAppState.class);
this.terrainChunkState = app.getStateManager().getState(TerrainChunkState.class);
stoneRoot = new Node("stoneRoot");
this.app.getRootNode().attachChild(stoneRoot);
}
@Override
protected void onEnable() {
PlacedStoneIO.StoneData data;
try {
data = PlacedStoneIO.load();
} catch (Exception e) {
log.warn("[StoneWorld] Laden fehlgeschlagen: {}", e.getMessage());
return;
}
if (data == null || data.stones().isEmpty()) {
log.info("[StoneWorld] Keine Steine vorhanden.");
return;
}
Material[] slotMat = buildMaterials(data.slotPaths());
Material defMat = buildDefaultMat();
int count = 0;
for (PlacedStone s : data.stones()) {
float worldY = terrainChunkState != null
? terrainChunkState.getHeightAt(s.x(), s.z())
: 0f;
float yCenter = worldY + s.radius() * (1f - 2f * s.sinkFraction());
Mesh mesh = buildStoneMesh(s.radius(), s.noiseSeed(), 2);
Geometry geo = new Geometry("stone", mesh);
Material mat = (s.textureSlot() >= 0 && s.textureSlot() < slotMat.length && slotMat[s.textureSlot()] != null)
? slotMat[s.textureSlot()] : defMat;
geo.setMaterial(mat);
geo.setLocalTranslation(s.x(), yCenter, s.z());
geo.rotate(0f, s.rotY() * FastMath.DEG_TO_RAD, 0f);
geo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
if (bulletAppState != null) {
try {
RigidBodyControl rbc = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(geo), 0f);
geo.addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
} catch (Exception e) {
log.warn("[StoneWorld] Physik für Stein nicht erzeugbar: {}", e.getMessage());
}
}
stoneRoot.attachChild(geo);
count++;
}
log.info("[StoneWorld] {} Steine geladen.", count);
}
@Override
protected void cleanup(Application app) {
this.app.getRootNode().detachChild(stoneRoot);
}
@Override protected void onDisable() {}
// ── Materialien ───────────────────────────────────────────────────────────
private Material buildDefaultMat() {
Material m = new Material(assets, "Common/MatDefs/Light/Lighting.j3md");
m.setBoolean("UseMaterialColors", true);
m.setColor("Diffuse", new ColorRGBA(0.55f, 0.52f, 0.48f, 1f));
m.setColor("Ambient", new ColorRGBA(0.15f, 0.14f, 0.13f, 1f));
m.setColor("Specular", ColorRGBA.Black);
return m;
}
private Material[] buildMaterials(String[] paths) {
Material[] mats = new Material[PlacedStoneIO.SLOT_COUNT];
for (int i = 0; i < PlacedStoneIO.SLOT_COUNT; i++) {
String p = (paths != null && i < paths.length && paths[i] != null) ? paths[i] : "";
if (!p.isEmpty()) {
try {
Material m = new Material(assets, "Common/MatDefs/Light/Lighting.j3md");
m.setTexture("DiffuseMap", assets.loadTexture(p));
m.setColor("Diffuse", ColorRGBA.White);
m.setColor("Ambient", new ColorRGBA(0.2f, 0.2f, 0.2f, 1f));
m.setColor("Specular", ColorRGBA.Black);
m.setBoolean("UseMaterialColors", true);
mats[i] = m;
} catch (Exception e) {
log.warn("[StoneWorld] Textur nicht ladbar: {}", p);
}
}
}
return mats;
}
// ── Icosphere-Mesh (deterministisch, identisch zum Editor) ────────────────
private static final float PHI = (1f + (float) Math.sqrt(5)) / 2f;
private static final float[][] ICO_V = normalize12(new float[][]{
{-1, PHI, 0}, { 1, PHI, 0}, {-1, -PHI, 0}, { 1, -PHI, 0},
{ 0, -1, PHI}, { 0, 1, PHI}, { 0, -1, -PHI}, { 0, 1, -PHI},
{ PHI, 0, -1}, { PHI, 0, 1}, {-PHI, 0, -1}, {-PHI, 0, 1}
});
private static final int[][] ICO_F = {
{0,11,5},{0,5,1},{0,1,7},{0,7,10},{0,10,11},
{1,5,9},{5,11,4},{11,10,2},{10,7,6},{7,1,8},
{3,9,4},{3,4,2},{3,2,6},{3,6,8},{3,8,9},
{4,9,5},{2,4,11},{6,2,10},{8,6,7},{9,8,1}
};
private static float[][] normalize12(float[][] raw) {
float[][] r = new float[raw.length][3];
for (int i = 0; i < raw.length; i++) r[i] = normalizeV(raw[i]);
return r;
}
private static float[] normalizeV(float[] v) {
float len = (float) Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
return new float[]{v[0]/len, v[1]/len, v[2]/len};
}
private static Mesh buildStoneMesh(float radius, int noiseSeed, int subdivisions) {
List<float[]> verts = new ArrayList<>(Arrays.asList(ICO_V));
List<int[]> faces = new ArrayList<>(Arrays.asList(ICO_F));
for (int s = 0; s < subdivisions; s++) {
List<float[]> nv = new ArrayList<>(verts);
List<int[]> nf = new ArrayList<>();
Map<Long, Integer> midCache = new HashMap<>();
for (int[] f : faces) {
int a = f[0], b = f[1], c = f[2];
int ab = getMid(nv, midCache, a, b);
int bc = getMid(nv, midCache, b, c);
int ca = getMid(nv, midCache, c, a);
nf.add(new int[]{a, ab, ca});
nf.add(new int[]{b, bc, ab});
nf.add(new int[]{c, ca, bc});
nf.add(new int[]{ab, bc, ca});
}
verts = nv; faces = nf;
}
Random rng = new Random(noiseSeed);
float ox = rng.nextFloat() * 50f, oy = rng.nextFloat() * 50f, oz = rng.nextFloat() * 50f;
float freq = 2.5f + (Math.abs(noiseSeed) % 3);
float amp = 0.22f;
int nv2 = verts.size();
float[] pos = new float[nv2 * 3];
float[] nor = new float[nv2 * 3];
float[] uv = new float[nv2 * 2];
for (int i = 0; i < nv2; i++) {
float[] unit = verts.get(i);
float n = smoothNoise3D(unit[0] * freq + ox, unit[1] * freq + oy, unit[2] * freq + oz);
float r = radius * (1f + n * amp);
pos[i*3] = unit[0] * r;
pos[i*3+1] = unit[1] * r;
pos[i*3+2] = unit[2] * r;
uv[i*2] = (float)(Math.atan2(unit[2], unit[0]) / (2 * Math.PI) + 0.5);
uv[i*2+1] = (float)(Math.asin(Math.max(-1, Math.min(1, unit[1]))) / Math.PI + 0.5);
}
for (int[] f : faces) {
int ai = f[0]*3, bi = f[1]*3, ci = f[2]*3;
float ax = pos[ai], ay = pos[ai+1], az = pos[ai+2];
float bx = pos[bi], by = pos[bi+1], bz = pos[bi+2];
float cx = pos[ci], cy = pos[ci+1], cz = pos[ci+2];
float nx = (by-ay)*(cz-az) - (bz-az)*(cy-ay);
float ny = (bz-az)*(cx-ax) - (bx-ax)*(cz-az);
float nz = (bx-ax)*(cy-ay) - (by-ay)*(cx-ax);
for (int vi : f) { nor[vi*3] += nx; nor[vi*3+1] += ny; nor[vi*3+2] += nz; }
}
for (int i = 0; i < nv2; i++) {
float len = (float) Math.sqrt(nor[i*3]*nor[i*3] + nor[i*3+1]*nor[i*3+1] + nor[i*3+2]*nor[i*3+2]);
if (len > 0) { nor[i*3] /= len; nor[i*3+1] /= len; nor[i*3+2] /= len; }
}
FloatBuffer pb = BufferUtils.createFloatBuffer(pos);
FloatBuffer nb = BufferUtils.createFloatBuffer(nor);
FloatBuffer ub = BufferUtils.createFloatBuffer(uv);
java.nio.IntBuffer ib = BufferUtils.createIntBuffer(faces.size() * 3);
for (int[] f : faces) ib.put(f[0]).put(f[1]).put(f[2]);
ib.flip();
Mesh mesh = new Mesh();
mesh.setBuffer(Type.Position, 3, pb);
mesh.setBuffer(Type.Normal, 3, nb);
mesh.setBuffer(Type.TexCoord, 2, ub);
mesh.setBuffer(Type.Index, 3, ib);
mesh.updateBound();
return mesh;
}
private static int getMid(List<float[]> verts, Map<Long, Integer> cache, int a, int b) {
long key = a < b ? ((long)a << 32 | b) : ((long)b << 32 | a);
return cache.computeIfAbsent(key, k -> {
float[] va = verts.get(a), vb = verts.get(b);
verts.add(normalizeV(new float[]{(va[0]+vb[0])*.5f, (va[1]+vb[1])*.5f, (va[2]+vb[2])*.5f}));
return verts.size() - 1;
});
}
private static float valueNoise3D(int x, int y, int z) {
int h = x * 1619 ^ y * 31337 ^ z * 6971 ^ (x * y * z * 1013);
h = h ^ (h >>> 13);
h = h * (h * h * 15731 + 789221) + 1376312589;
return (h & 0x7fffffff) * (1f / 2147483647f);
}
private static float smoothNoise3D(float x, float y, float z) {
int xi = (int) Math.floor(x), yi = (int) Math.floor(y), zi = (int) Math.floor(z);
float fx = x-xi, fy = y-yi, fz = z-zi;
float c000 = valueNoise3D(xi, yi, zi), c100 = valueNoise3D(xi+1, yi, zi);
float c010 = valueNoise3D(xi, yi+1, zi), c110 = valueNoise3D(xi+1, yi+1, zi);
float c001 = valueNoise3D(xi, yi, zi+1), c101 = valueNoise3D(xi+1, yi, zi+1);
float c011 = valueNoise3D(xi, yi+1, zi+1), c111 = valueNoise3D(xi+1, yi+1, zi+1);
float lo = lerp(lerp(c000,c100,fx), lerp(c010,c110,fx), fy);
float hi = lerp(lerp(c001,c101,fx), lerp(c011,c111,fx), fy);
return lerp(lo, hi, fz) * 2f - 1f;
}
private static float lerp(float a, float b, float t) { return a + (b-a)*t; }
}

View File

@@ -396,15 +396,25 @@ public class TerrainChunkState extends BaseAppState {
private void addPhysics(int ci) {
if (chunkNodes[ci] == null || chunkHeights[ci] == null) return;
HeightfieldCollisionShape shape = new HeightfieldCollisionShape(
chunkHeights[ci], new Vector3f(1f, 1f, 1f));
// jme3-jbullet HeightfieldCollisionShape-Bug: wenn alle Höhen identisch sind
// (min == max), setzt der Konstruktor max = -min statt min = -max → minHeight >
// maxHeight → invertierte AABB → Bullet findet in der Breitphase keine Überlappung
// → keine Kollision → Spieler fällt durch. Workaround: minimale Höhendifferenz
// sicherstellen, indem ein Eckwert um 0.0001 angehoben wird. Die Kollisionsfläche
// bleibt dadurch für alle praktischen Zwecke unverändert.
float[] h = chunkHeights[ci];
float min = h[0], max = h[0];
for (float v : h) { if (v < min) min = v; if (v > max) max = v; }
if (min == max) {
h = h.clone();
h[h.length - 1] += 0.0001f;
}
HeightfieldCollisionShape shape = new HeightfieldCollisionShape(h, new Vector3f(1f, 1f, 1f));
RigidBodyControl rbc = new RigidBodyControl(shape, 0f);
chunkNodes[ci].addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
// jme3-jbullet's HeightfieldCollisionShape macht die AABB symmetrisch um 0
// (min = -max), nicht um (min+max)/2. Die Höhenwerte h[i] werden direkt als
// lokale Y-Koordinaten verwendet. Der Body muss deshalb bei Y=0 liegen (Node-Y=0),
// damit Kollisionsfläche = 0 + h[i] = h[i]. Kein setPhysicsLocation nötig.
physics[ci] = rbc;
}

View File

@@ -12,7 +12,7 @@ import de.blight.common.VoxelChunk;
/**
* JME-Node für einen VoxelChunk mit 3 LOD-Geometrien.
* Wird von VoxelChunkState und VoxelEditorState verwaltet.
* Wird von VoxelEditorState verwaltet.
*
* Position im Weltraum: Translation = (cx*128-2048, cy*128, cz*128-2048).
*/
@@ -29,6 +29,7 @@ public class VoxelChunkNode extends Node {
private RigidBodyControl physics;
private BulletAppState bulletState;
private Mesh physicsMeshOverride;
public VoxelChunkNode(VoxelChunk chunk, Material material) {
super("voxel_" + chunk.cx + "_" + chunk.cy + "_" + chunk.cz);
@@ -112,15 +113,27 @@ public class VoxelChunkNode extends Node {
}
}
/** Erzeugt / aktualisiert die Physik-Kollision (LOD0-Mesh). */
/**
* Setzt ein explizites Physics-Mesh, das Vorrang vor lodGeos[0] hat.
* Für gebackene Nodes: rohes (ungesmoothtes) Mesh, das keinen Overlap
* mit dem Terrain-Heightfield erzeugt.
*/
public void setPhysicsMeshOverride(Mesh mesh) {
this.physicsMeshOverride = mesh;
}
/** Erzeugt / aktualisiert die Physik-Kollision. */
public void updatePhysics(BulletAppState bullet) {
this.bulletState = bullet;
if (physics != null) {
bullet.getPhysicsSpace().remove(physics);
removeControl(physics);
physics = null;
}
if (lodGeos[0] == null || lodGeos[0].getMesh() == null) return;
MeshCollisionShape shape = new MeshCollisionShape(lodGeos[0].getMesh());
Mesh mesh = physicsMeshOverride != null ? physicsMeshOverride
: (lodGeos[0] != null ? lodGeos[0].getMesh() : null);
if (mesh == null) return;
MeshCollisionShape shape = new MeshCollisionShape(mesh);
physics = new RigidBodyControl(shape, 0f);
addControl(physics);
bullet.getPhysicsSpace().add(physics);
@@ -135,6 +148,8 @@ public class VoxelChunkNode extends Node {
public VoxelChunk getChunk() { return chunk; }
public boolean hasPhysics() { return physics != null; }
/** Gibt true zurück wenn mindestens ein LOD ein Mesh hat. */
public boolean hasMesh() {
for (Geometry g : lodGeos) if (g != null) return true;

View File

@@ -1,265 +0,0 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bullet.BulletAppState;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.texture.Texture;
import de.blight.common.MapData;
import de.blight.common.VoxelChunk;
import de.blight.common.VoxelChunkIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Verwaltet die Voxel-Geometrie im Spiel als {@link TerrainChunkState.ChunkListener}.
*
* - Lädt VoxelChunks bei Sichtbarkeit (onChunkVisible)
* - Baut LOD-Meshes gemäß TerrainChunk-LOD
* - Aktiviert Physik-Collider bei LOD0 (PHYSICS_RANGE)
* - Nutzt Texture2DArray für 4 Voxel-Texturen (aus terrainTexturePaths)
*/
public class VoxelChunkState extends BaseAppState
implements TerrainChunkState.ChunkListener {
private static final Logger log = LoggerFactory.getLogger(VoxelChunkState.class);
private final BulletAppState bulletState;
private final MapData mapData;
private SimpleApplication app;
private AssetManager assets;
private Node voxelRoot;
private Material voxelMaterial;
// key = cx | ((long)cy << 16) | ((long)cz << 32)
private final Map<Long, VoxelChunkNode> nodes = new HashMap<>();
public VoxelChunkState(BulletAppState bulletState, MapData mapData) {
this.bulletState = bulletState;
this.mapData = mapData;
}
@Override
protected void initialize(Application application) {
this.app = (SimpleApplication) application;
this.assets = app.getAssetManager();
voxelRoot = new Node("voxelRoot");
app.getRootNode().attachChild(voxelRoot);
voxelMaterial = buildMaterial();
}
@Override
protected void cleanup(Application app) {
voxelRoot.removeFromParent();
for (VoxelChunkNode n : nodes.values()) n.removePhysics();
nodes.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setDebugNoLight(boolean enabled) {
if (voxelMaterial != null) voxelMaterial.setBoolean("DebugNoLight", enabled);
}
@Override
public void update(float tpf) {
DayNightState dns = getApplication().getStateManager().getState(DayNightState.class);
if (dns == null || voxelMaterial == null || dns.getSunLight() == null) return;
voxelMaterial.setVector3("LightDir", dns.getSunDirection().negate());
ColorRGBA sc = dns.getSunLight().getColor();
ColorRGBA ac = dns.getAmbientLight().getColor();
voxelMaterial.setVector3("SunColor", new Vector3f(sc.r, sc.g, sc.b));
voxelMaterial.setVector3("AmbientColor", new Vector3f(ac.r, ac.g, ac.b));
}
// ── ChunkListener ─────────────────────────────────────────────────────────
@Override
public void onChunkVisible(int cx, int cz, int lod) {
// Alle cy-Layers für diesen cx/cz laden
loadLayersForXZ(cx, cz, lod);
}
@Override
public void onChunkHidden(int cx, int cz) {
// Alle cy-Layers für diesen cx/cz entfernen
List<Long> toRemove = new ArrayList<>();
for (Map.Entry<Long, VoxelChunkNode> e : nodes.entrySet()) {
VoxelChunkNode n = e.getValue();
if (n.getChunk().cx == cx && n.getChunk().cz == cz) toRemove.add(e.getKey());
}
for (Long key : toRemove) removeNode(key);
}
@Override
public void onChunkLodChanged(int cx, int cz, int oldLod, int newLod) {
for (VoxelChunkNode n : nodes.values()) {
VoxelChunk c = n.getChunk();
if (c.cx != cx || c.cz != cz) continue;
n.setActiveLod(newLod);
// Physik nur bei LOD0 (nahes Terrain)
if (newLod == 0) {
n.updatePhysics(bulletState);
} else {
n.removePhysics();
}
}
}
// ── Intern ────────────────────────────────────────────────────────────────
private void loadLayersForXZ(int cx, int cz, int lod) {
for (int cy = -8; cy <= 8; cy++) {
long key = chunkKey(cx, cy, cz);
if (nodes.containsKey(key)) continue;
// Gebackene J3O-Meshes bevorzugt laden — nur wenn auch .blvc-Quelldaten existieren
// (sonst werden veraltete Meshes von gelöschten Voxeln als Geisterflächen angezeigt)
if (VoxelChunkIO.bakedExists(cx, cy, cz) && VoxelChunkIO.exists(cx, cy, cz)) {
try {
addBakedNode(key, cx, cy, cz, lod);
} catch (Exception e) {
log.warn("Gebackenen Voxel-Chunk laden fehlgeschlagen ({},{},{}): {}",
cx, cy, cz, e.getMessage());
}
continue;
}
// Fallback: .blvc laden + Marching Cubes zur Laufzeit
if (!VoxelChunkIO.exists(cx, cy, cz)) continue;
try {
VoxelChunk chunk = VoxelChunkIO.load(cx, cy, cz);
addNode(key, chunk, lod);
} catch (IOException e) {
log.warn("Voxel-Chunk laden fehlgeschlagen ({},{},{}): {}", cx, cy, cz, e.getMessage());
}
}
}
/**
* Lädt vorgebackene LOD-Meshes aus den .j3o-Dateien und hängt sie als
* VoxelChunkNode in die Szene ein (kein Marching Cubes zur Laufzeit).
*/
private void addBakedNode(long key, int cx, int cy, int cz, int lod) throws Exception {
BinaryImporter importer = BinaryImporter.getInstance();
importer.setAssetManager(assets);
VoxelChunk dummy = new VoxelChunk(cx, cy, cz);
VoxelChunkNode node = new VoxelChunkNode(dummy, voxelMaterial);
for (int l = 0; l < 3; l++) {
Path p = VoxelChunkIO.getBakedPath(cx, cy, cz, l);
if (Files.exists(p)) {
Mesh m = (Mesh) importer.load(p.toFile());
node.setLodMesh(l, m);
}
}
node.setActiveLod(lod);
if (lod == 0) node.updatePhysics(bulletState);
voxelRoot.attachChild(node);
nodes.put(key, node);
}
private void addNode(long key, VoxelChunk chunk, int lod) {
VoxelChunkNode node = new VoxelChunkNode(chunk, voxelMaterial);
for (int l = 0; l < 3; l++) node.rebuildMesh(l);
node.setActiveLod(lod);
if (lod == 0) node.updatePhysics(bulletState);
voxelRoot.attachChild(node);
nodes.put(key, node);
}
private void removeNode(long key) {
VoxelChunkNode n = nodes.remove(key);
if (n == null) return;
n.removePhysics();
n.removeFromParent();
}
/** Fügt einen extern geladenen VoxelChunk zur Szene hinzu (z.B. aus dem Editor). */
public void addOrUpdateChunk(VoxelChunk chunk, int lod) {
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
VoxelChunkNode existing = nodes.get(key);
if (existing != null) {
for (int l = 0; l < 3; l++) existing.rebuildMesh(l);
existing.setActiveLod(lod);
if (lod == 0) existing.updatePhysics(bulletState);
} else {
addNode(key, chunk, lod);
}
}
private Material buildMaterial() {
Material mat = new Material(assets, "MatDefs/Voxel.j3md");
mat.setFloat("TexScale", 8f);
int[] slotIdxs = {
mapData != null ? mapData.voxelFlatSlot : -1,
mapData != null ? mapData.voxelSteepSlot : -1,
mapData != null ? mapData.voxelCeilSlot : -1,
};
log.info("[Voxel] FlatSlot={} → '{}', SteepSlot={} → '{}', TexScale=8.0",
slotIdxs[0], resolveSlotTex(slotIdxs[0]),
slotIdxs[1], resolveSlotTex(slotIdxs[1]));
String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" };
String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" };
for (int i = 0; i < 3; i++) {
String tex = resolveSlotTex(slotIdxs[i]);
String norm = resolveSlotNorm(slotIdxs[i]);
if (!tex.isEmpty()) {
try {
Texture t = assets.loadTexture(tex);
t.getImage().setColorSpace(com.jme3.texture.image.ColorSpace.Linear);
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(colSlots[i], t);
} catch (Exception e) {
log.warn("Voxel-Textur {} nicht ladbar: {}", tex, e.getMessage());
}
}
if (!norm.isEmpty()) {
try {
Texture n = assets.loadTexture(norm);
n.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(normSlots[i], n);
} catch (Exception e) {
log.warn("Voxel-NormalMap {} nicht ladbar: {}", norm, e.getMessage());
}
}
}
return mat;
}
private String resolveSlotTex(int slot) {
if (slot < 0 || mapData == null) return "";
if (slot < 4) { String p = mapData.terrainTextures[slot]; return p != null ? p : ""; }
if (slot < 8) { String p = mapData.upperTextures[slot - 4]; return p != null ? p : ""; }
if (slot < 12) { String p = mapData.thirdTextures[slot - 8]; return p != null ? p : ""; }
return "";
}
private String resolveSlotNorm(int slot) {
if (slot < 0 || mapData == null) return "";
if (slot < 4) { String p = mapData.terrainNormalMaps[slot]; return p != null ? p : ""; }
if (slot < 8) { String p = mapData.upperNormalMaps[slot - 4]; return p != null ? p : ""; }
if (slot < 12) { String p = mapData.thirdNormalMaps[slot - 8]; return p != null ? p : ""; }
return "";
}
public static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
}

View File

@@ -0,0 +1,479 @@
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.input.InputManager;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.input.controls.MouseButtonTrigger;
import com.jme3.math.Vector3f;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.model.Bed;
import de.blight.common.model.BedIO;
import de.blight.common.model.Bench;
import de.blight.common.model.BenchIO;
import de.blight.common.model.InteractableType;
import de.blight.common.model.WorldPoint;
import de.blight.game.animation.AnimationAction;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
import de.blight.game.navigation.PathFinder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* Steuert die Interaktion des Hauptcharakters mit Betten und Bänken.
*
* <h2>Ablauf Bett</h2>
* <pre>
* Interact-Taste (E)
* → WALKING : walk zu Punkt neben dem Bett (via PathFinder)
* → LIE_ANIM : lie_down Animation (Charakter gleitet in Liegeposition)
* → RESTING : Charakter liegt; alle Eingaben gesperrt
* → GET_UP : Rechtsklick startet lie_up Animation
* → WALKING_BACK : Charakter kehrt zur Ausgangsposition zurück
* </pre>
*
* Bank läuft analog mit sit_down / sit_up und einem 0,5m-Pfeil.
*/
public class WorldInteractableState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldInteractableState.class);
private static final float INTERACT_RANGE = 6f;
private static final float REACH_DIST = 0.35f;
private static final float WALK_TIMEOUT = 12f;
// ── Abhängigkeiten ────────────────────────────────────────────────────────
private final KeyBindings keyBindings;
private final CharacterControl physicsChar;
private final PlayerInputControl playerInput;
private InputManager inputManager;
// ── Interactable-Daten aus der Karte ─────────────────────────────────────
/** Beschreibt ein platziertes Interactable-Objekt. */
private record InteractableEntry(
float worldX, float worldY, float worldZ,
InteractableType type,
String interactableId
) {}
private final List<InteractableEntry> entries = new ArrayList<>();
// ── Zustandsmaschine ──────────────────────────────────────────────────────
private enum Phase {
IDLE,
WALKING, // Annäherung an Interactable
PLAY_ANIM, // Einlege-/Sitz-Animation läuft
RESTING, // Charakter liegt/sitzt; nur Rechtsklick erlaubt
GET_UP_ANIM, // Aufsteh-Animation läuft
WALKING_BACK // Rückkehr zur Ausgangsposition
}
private Phase phase = Phase.IDLE;
private int targetIdx = -1;
private float walkTimer = 0f;
/** Ziel-Weltpunkt, zu dem der Charakter laufen soll (neben dem Objekt). */
private Vector3f approachTarget = null;
/** Position des Charakters vor der Interaktion (für Rückkehr). */
private Vector3f originPos = null;
/** Aktive Pfadliste für Annäherung / Rückkehr. */
private List<WorldPoint> currentPath = new ArrayList<>();
private int pathStep = 0;
private PathFinder pathFinder = null;
/** Sitz-/Liegeposition des aktuell angesteuerten Interactables (für Bypass-Berechnung). */
private Vector3f interactableSitPt = null;
// ── Eingabe-Mapping ───────────────────────────────────────────────────────
private static final String INTERACT_ACTION = "InteractInteractable";
private static final String GET_UP_ACTION = "GetUpFromRest";
public WorldInteractableState(KeyBindings keyBindings,
CharacterControl physicsChar,
PlayerInputControl playerInput) {
this.keyBindings = keyBindings;
this.physicsChar = physicsChar;
this.playerInput = playerInput;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.inputManager = app.getInputManager();
try { pathFinder = PathFinder.load(); }
catch (IOException e) { log.warn("[WorldInteractable] Wegnetz nicht ladbar: {}", e.getMessage()); }
try {
List<PlacedModel> models = PlacedModelIO.load();
for (PlacedModel m : models) {
if (m.interactableType() == null || m.interactableType().isBlank()) continue;
InteractableType t = InteractableType.fromString(m.interactableType());
if (t == InteractableType.BED || t == InteractableType.BENCH) {
entries.add(new InteractableEntry(m.x(), m.y(), m.z(), t, m.interactableId()));
}
}
log.info("[WorldInteractable] {} Interactables geladen.", entries.size());
} catch (IOException e) {
log.warn("[WorldInteractable] PlacedModels nicht ladbar: {}", e.getMessage());
}
}
@Override
protected void onEnable() {
inputManager.addMapping(INTERACT_ACTION, new KeyTrigger(keyBindings.interact));
inputManager.addMapping(GET_UP_ACTION, new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
inputManager.addListener(interactListener, INTERACT_ACTION);
inputManager.addListener(getUpListener, GET_UP_ACTION);
}
@Override
protected void onDisable() {
cancelInteraction();
inputManager.removeListener(interactListener);
inputManager.removeListener(getUpListener);
try { inputManager.deleteMapping(INTERACT_ACTION); } catch (Exception ignored) {}
try { inputManager.deleteMapping(GET_UP_ACTION); } catch (Exception ignored) {}
}
@Override protected void cleanup(Application app) {}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
switch (phase) {
case WALKING -> updateWalking(tpf);
case WALKING_BACK -> updateWalkingBack(tpf);
default -> {}
}
}
private void updateWalking(float tpf) {
if (playerInput.isPaused()) { cancelInteraction(); return; }
walkTimer += tpf;
if (walkTimer > WALK_TIMEOUT) {
log.info("[WorldInteractable] Annäherung abgebrochen (Timeout).");
cancelInteraction();
return;
}
if (targetIdx < 0 || targetIdx >= entries.size()) { cancelInteraction(); return; }
Vector3f pos = physicsChar.getPhysicsLocation();
Vector3f dest = approachTarget;
float dx = dest.x - pos.x;
float dz = dest.z - pos.z;
float distSq = dx * dx + dz * dz;
if (distSq <= REACH_DIST * REACH_DIST) {
startRestAnim();
} else {
// Nächsten Wegpunkt aus dem Pfad verwenden
advancePath(pos);
}
}
private void advancePath(Vector3f pos) {
if (currentPath.isEmpty()) {
// Direkt zum Ziel
float dx = approachTarget.x - pos.x;
float dz = approachTarget.z - pos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
return;
}
// Zum aktuellen Wegpunkt steuern; wenn nah genug → weiter
while (pathStep < currentPath.size()) {
WorldPoint wp = currentPath.get(pathStep);
float dx = wp.x - pos.x;
float dz = wp.z - pos.z;
float d2 = dx * dx + dz * dz;
if (d2 < 0.8f * 0.8f) { pathStep++; continue; }
float len = (float) Math.sqrt(d2);
playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
return;
}
// Pfad fertig → direkt zum Annäherungs-Ziel
float dx = approachTarget.x - pos.x;
float dz = approachTarget.z - pos.z;
float len = (float) Math.sqrt(dx * dx + dz * dz);
if (len > 0.001f) playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
}
private void updateWalkingBack(float tpf) {
if (originPos == null) { phase = Phase.IDLE; return; }
walkTimer += tpf;
if (walkTimer > WALK_TIMEOUT) {
playerInput.setAutopilotDirection(null);
phase = Phase.IDLE;
return;
}
Vector3f pos = physicsChar.getPhysicsLocation();
float dx = originPos.x - pos.x;
float dz = originPos.z - pos.z;
float distSq = dx * dx + dz * dz;
if (distSq <= REACH_DIST * REACH_DIST) {
playerInput.setAutopilotDirection(null);
phase = Phase.IDLE;
} else {
float len = (float) Math.sqrt(distSq);
playerInput.setAutopilotDirection(new Vector3f(dx / len, 0f, dz / len));
}
}
// ── Listener ──────────────────────────────────────────────────────────────
private final ActionListener interactListener = (name, isPressed, tpf) -> {
if (!isPressed || phase != Phase.IDLE) return;
int idx = findNearestInRange();
if (idx >= 0) startApproach(idx);
};
private final ActionListener getUpListener = (name, isPressed, tpf) -> {
if (!isPressed || phase != Phase.RESTING) return;
startGetUp();
};
// ── Logik ─────────────────────────────────────────────────────────────────
private int findNearestInRange() {
Vector3f pos = physicsChar.getPhysicsLocation();
int bestIdx = -1;
float bestDist = INTERACT_RANGE;
for (int i = 0; i < entries.size(); i++) {
InteractableEntry e = entries.get(i);
float dx = e.worldX() - pos.x;
float dz = e.worldZ() - pos.z;
float d = (float) Math.sqrt(dx * dx + dz * dz);
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
}
private void startApproach(int idx) {
targetIdx = idx;
walkTimer = 0f;
originPos = physicsChar.getPhysicsLocation().clone();
InteractableEntry entry = entries.get(idx);
// Kollision des Zielobjekts deaktivieren, damit der Charakter hindurchgehen kann
setTargetPhysicsEnabled(entry, false);
approachTarget = computeApproachTarget(entry);
// Pfad berechnen (PathFinder falls vorhanden)
WorldPoint from = new WorldPoint(originPos.x, originPos.y, originPos.z);
WorldPoint to = new WorldPoint(approachTarget.x, approachTarget.y, approachTarget.z);
if (pathFinder != null) {
currentPath = new ArrayList<>(pathFinder.findPath(from, to));
} else {
currentPath = new ArrayList<>(List.of(to));
}
// Bypass-Punkt einfügen wenn Sitzpunkt auf dem Weg liegt
insertBypassIfNeeded(from);
pathStep = 0;
phase = Phase.WALKING;
log.info("[WorldInteractable] Annäherung an {} [{}]", entry.type(), entry.interactableId());
}
/**
* Berechnet den Punkt, zu dem der Charakter läuft.
* Bank: direkt zum Sitzpunkt (Pfeilspitze).
* Bett: 1m in Pfeilrichtung vor dem Liegepunkt (Anfahrt von vorne).
*/
private Vector3f computeApproachTarget(InteractableEntry entry) {
if (entry.type() == InteractableType.BED) {
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
if (bed != null && bed.isLiegeSet()) {
float rotY = bed.getLiegeRotY();
interactableSitPt = new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ());
// Bett: 1m in Pfeilrichtung vor dem Liegepunkt anfahren
return new Vector3f(
bed.getLiegeX() + (float) Math.cos(rotY),
bed.getLiegeY(),
bed.getLiegeZ() + (float) Math.sin(rotY));
}
} else if (entry.type() == InteractableType.BENCH) {
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
if (bench != null && bench.isSitzSet()) {
interactableSitPt = new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
// Bank: direkt zum Sitzpunkt (Pfeilspitze) laufen
return new Vector3f(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ());
}
}
// Fallback: 1m östlich des Objekts
interactableSitPt = null;
return new Vector3f(entry.worldX() + 1f, entry.worldY(), entry.worldZ());
}
/**
* Prüft ob der direkte Weg von {@code from} zum approachTarget durch den
* Sitzpunkt führt (nur relevant wenn Annährungspunkt ≠ Sitzpunkt, d.h. Bett).
* Falls ja, wird ein Bypass-Punkt senkrecht eingefügt.
*/
private void insertBypassIfNeeded(WorldPoint from) {
if (interactableSitPt == null || approachTarget == null) return;
float sx = interactableSitPt.x, sz = interactableSitPt.z;
float tx = approachTarget.x, tz = approachTarget.z;
// Wenn Annährungsziel = Sitzpunkt, ist kein Bypass nötig
float aDx = tx - sx, aDz = tz - sz;
if (aDx * aDx + aDz * aDz < 0.1f) return;
float fx = from.x, fz = from.z;
// Projektion des Sitzpunkts auf die direkte Linie from→approachTarget
float dx = tx - fx, dz = tz - fz;
float lenSq = dx * dx + dz * dz;
if (lenSq < 0.001f) return;
float t = ((sx - fx) * dx + (sz - fz) * dz) / lenSq;
if (t < 0.05f || t > 0.95f) return;
float closestX = fx + t * dx;
float closestZ = fz + t * dz;
float distToPath = (float) Math.sqrt((sx - closestX) * (sx - closestX)
+ (sz - closestZ) * (sz - closestZ));
if (distToPath > 1.2f) return;
float faceX = sx - tx;
float faceZ = sz - tz;
float faceLen = (float) Math.sqrt(faceX * faceX + faceZ * faceZ);
if (faceLen > 0.001f) { faceX /= faceLen; faceZ /= faceLen; }
float perpX = -faceZ;
float perpZ = faceX;
float dot = (fx - sx) * perpX + (fz - sz) * perpZ;
float sign = dot >= 0f ? 1f : -1f;
WorldPoint bypass = new WorldPoint(
sx + perpX * sign * 2.5f,
from.y,
sz + perpZ * sign * 2.5f);
currentPath.add(currentPath.size() - 1, bypass);
log.info("[WorldInteractable] Bypass-Punkt eingefügt: ({}, {})", bypass.x, bypass.z);
}
private void startRestAnim() {
playerInput.setAutopilotDirection(null);
phase = Phase.PLAY_ANIM;
InteractableEntry entry = entries.get(targetIdx);
float rotY = getRestRotY(entry);
// Zuerst Rücken zur Bank drehen, dann Sitz-/Liegeanimation
Vector3f sitDir = new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY));
playerInput.requestTurn(sitDir, 0.4f, () -> startSitAnim(entry));
}
private void startSitAnim(InteractableEntry entry) {
boolean isBed = entry.type() == InteractableType.BED;
AnimationAction action = isBed ? AnimationAction.LIE_DOWN : AnimationAction.SIT_DOWN;
AnimationAction idleAction = isBed ? AnimationAction.LYING : AnimationAction.SITTING;
// duration=0 → PlayerInputControl ermittelt die echte Clip-Länge automatisch
// Sink-Wert kommt aus AnimSet-Konfiguration (Animationseditor)
playerInput.requestAnimation(action, 0f, () -> {
teleportToRestPos(entry);
playerInput.lockInPlace();
playerInput.playLockedAnimation(idleAction);
phase = Phase.RESTING;
log.info("[WorldInteractable] Ruhezustand aktiv: {}", entry.type());
});
}
private void startGetUp() {
if (targetIdx < 0 || targetIdx >= entries.size()) { phase = Phase.IDLE; return; }
InteractableEntry entry = entries.get(targetIdx);
boolean isBed = entry.type() == InteractableType.BED;
AnimationAction action = isBed ? AnimationAction.LIE_UP : AnimationAction.SIT_UP;
playerInput.unlockFromPlace();
phase = Phase.GET_UP_ANIM;
// Sink-Wert für SIT_UP/LIE_UP kommt ebenfalls aus AnimSet-Konfiguration
playerInput.requestAnimation(action, 0f, () -> {
// Kollision des Objekts nach dem Aufstehen wieder aktivieren
if (targetIdx >= 0 && targetIdx < entries.size()) {
setTargetPhysicsEnabled(entries.get(targetIdx), true);
}
phase = Phase.WALKING_BACK;
walkTimer = 0f;
log.info("[WorldInteractable] Rückkehr zur Ausgangsposition.");
});
}
private void teleportToRestPos(InteractableEntry entry) {
if (physicsChar == null) return;
if (entry.type() == InteractableType.BED) {
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
if (bed != null && bed.isLiegeSet())
physicsChar.setPhysicsLocation(new Vector3f(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ()));
} else if (entry.type() == InteractableType.BENCH) {
// X/Z aus dem Sitzpunkt, Y bleibt bei der aktuellen Physik-Position (Charakter ist
// bereits auf Bodenhöhe und durch Terrain geerdet — kein Sprung nach oben)
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
if (bench != null && bench.isSitzSet()) {
float currentY = physicsChar.getPhysicsLocation().y;
physicsChar.setPhysicsLocation(new Vector3f(bench.getSitzX(), currentY, bench.getSitzZ()));
}
}
}
private float getRestRotY(InteractableEntry entry) {
if (entry.type() == InteractableType.BED) {
Bed bed = BedIO.load(entry.interactableId()).orElse(null);
if (bed != null) return bed.getLiegeRotY();
} else if (entry.type() == InteractableType.BENCH) {
Bench bench = BenchIO.load(entry.interactableId()).orElse(null);
if (bench != null) return bench.getSitzRotY();
}
return 0f;
}
private void setTargetPhysicsEnabled(InteractableEntry entry, boolean enabled) {
WorldObjectsState wos = getApplication().getStateManager().getState(WorldObjectsState.class);
if (wos != null) wos.setInteractablePhysicsEnabled(entry.interactableId(), enabled);
}
private void cancelInteraction() {
playerInput.setAutopilotDirection(null);
if (phase == Phase.RESTING || phase == Phase.GET_UP_ANIM) {
playerInput.unlockFromPlace();
}
// Kollision bei Abbruch immer wieder aktivieren
if (targetIdx >= 0 && targetIdx < entries.size()) {
setTargetPhysicsEnabled(entries.get(targetIdx), true);
}
phase = Phase.IDLE;
targetIdx = -1;
}
}

View File

@@ -0,0 +1,154 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.light.PointLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.post.FilterPostProcessor;
import com.jme3.renderer.Camera;
import com.jme3.scene.Node;
import com.jme3.shadow.EdgeFilteringMode;
import com.jme3.shadow.PointLightShadowFilter;
import de.blight.common.LightIO;
import de.blight.common.PlacedLight;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class WorldLightState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldLightState.class);
/** Anzahl gleichzeitig aktiver Schatten-Filter. */
private static final int MAX_SHADOW_LIGHTS = 4;
/** Schattenmap-Auflösung pro Lichtquelle. */
private static final int SHADOW_MAP_SIZE = 512;
/** Sekunden zwischen Neuberechnung welche Lichter Schatten werfen. */
private static final float SHADOW_UPDATE_INTERVAL = 0.5f;
private final FilterPostProcessor fpp;
private AssetManager assetManager;
private Camera cam;
private Node rootNode;
private final List<PointLight> pointLights = new ArrayList<>();
private final List<PointLightShadowFilter> shadowFilters = new ArrayList<>();
private float shadowUpdateTimer = 0f;
/**
* @param fpp gemeinsamer FilterPostProcessor; null → keine Schatten.
*/
public WorldLightState(FilterPostProcessor fpp) {
this.fpp = fpp;
}
@Override
protected void initialize(Application app) {
assetManager = app.getAssetManager();
cam = app.getCamera();
rootNode = ((SimpleApplication) app).getRootNode();
}
@Override
protected void onEnable() {
List<PlacedLight> placed;
try {
placed = LightIO.load();
} catch (Exception e) {
log.warn("[WorldLight] Lichtquellen nicht ladbar: {}", e.getMessage());
return;
}
if (placed.isEmpty()) return;
for (PlacedLight pl : placed) {
PointLight pt = new PointLight();
pt.setColor(new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f).mult(pl.intensity()));
pt.setRadius(pl.radius());
pt.setPosition(new Vector3f(pl.x(), pl.y(), pl.z()));
rootNode.addLight(pt);
pointLights.add(pt);
}
if (fpp != null) {
int filterCount = Math.min(MAX_SHADOW_LIGHTS, pointLights.size());
for (int i = 0; i < filterCount; i++) {
PointLightShadowFilter psf =
new PointLightShadowFilter(assetManager, SHADOW_MAP_SIZE);
psf.setLight(pointLights.get(i));
psf.setEdgeFilteringMode(EdgeFilteringMode.PCF4);
psf.setShadowIntensity(0.6f);
psf.setEnabled(false); // initial deaktiviert; update() aktiviert bei Bedarf
fpp.addFilter(psf);
shadowFilters.add(psf);
}
}
log.info("[WorldLight] {} Lichtquellen geladen, {} Schatten-Filter bereit.",
pointLights.size(), shadowFilters.size());
// Sofortige erste Zuweisung
updateShadowAssignments();
}
@Override
public void update(float tpf) {
if (shadowFilters.isEmpty()) return;
shadowUpdateTimer += tpf;
if (shadowUpdateTimer >= SHADOW_UPDATE_INTERVAL) {
shadowUpdateTimer = 0f;
updateShadowAssignments();
}
}
/**
* Weist den Filter-Slots die jeweils nächsten Lichtquellen zu.
* Lichtquellen außerhalb ihres eigenen Radius deaktivieren ihren Slot.
*/
private void updateShadowAssignments() {
if (shadowFilters.isEmpty() || pointLights.isEmpty()) return;
Vector3f camPos = cam.getLocation();
// Lichter nach Distanz zur Kamera sortieren (nächste zuerst)
List<PointLight> byDist = new ArrayList<>(pointLights);
byDist.sort(Comparator.comparingDouble(
l -> l.getPosition().distanceSquared(camPos)));
for (int i = 0; i < shadowFilters.size(); i++) {
PointLightShadowFilter psf = shadowFilters.get(i);
if (i < byDist.size()) {
PointLight light = byDist.get(i);
float dist = light.getPosition().distance(camPos);
float threshold = light.getRadius(); // nur innerhalb des Leuchtradius
if (dist <= threshold) {
psf.setLight(light);
psf.setEnabled(true);
} else {
psf.setEnabled(false);
}
} else {
psf.setEnabled(false);
}
}
}
@Override
protected void onDisable() {
for (PointLightShadowFilter f : shadowFilters) {
f.setEnabled(false);
if (fpp != null) fpp.removeFilter(f);
}
shadowFilters.clear();
for (PointLight pl : pointLights) rootNode.removeLight(pl);
pointLights.clear();
}
@Override protected void cleanup(Application app) { onDisable(); }
}

View File

@@ -23,7 +23,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;
public class WorldObjectsState extends BaseAppState {
@@ -34,6 +36,9 @@ public class WorldObjectsState extends BaseAppState {
private BulletAppState bulletAppState;
private final List<Material> sceneLitMaterials = new ArrayList<>();
/** RigidBodyControl pro Interactable-ID, damit Kollision während Animationen deaktiviert werden kann. */
private final Map<String, RigidBodyControl> interactableRbcs = new HashMap<>();
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
@@ -88,6 +93,10 @@ public class WorldObjectsState extends BaseAppState {
CollisionShapeFactory.createMeshShape(s), 0f);
s.addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
// Interactable-Objekte (Bank/Bett) in Map speichern für späteren Toggle
if (m.interactableId() != null && !m.interactableId().isBlank()) {
interactableRbcs.put(m.interactableId(), rbc);
}
} catch (Exception pe) {
log.warn("[WorldObjects] Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage());
}
@@ -276,4 +285,17 @@ public class WorldObjectsState extends BaseAppState {
mat.setColor("Color", ColorRGBA.Gray);
g.setMaterial(mat);
}
/** Aktiviert oder deaktiviert die Physik-Kollision für ein Interactable-Objekt. */
public void setInteractablePhysicsEnabled(String interactableId, boolean enabled) {
RigidBodyControl rbc = interactableRbcs.get(interactableId);
if (rbc == null) {
return;
}
try {
rbc.setEnabled(enabled);
} catch (Exception e) {
// PhysicsSpace kann beim App-Shutdown bereits zerstört sein
}
}
}