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:
@@ -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 {
|
||||
|
||||
@@ -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)");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 : ""; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user