Atmosphäre-Tools, EZ-Tree-Fixes, i18n, AnimSet, Baum-Export
- blight-lang: TextResolver + EN/DE Sprachpakete (TextReference i18n) - AnimSet: Clips + ActionMap in .animset.json zusammengeführt - EZ-Tree: Branch-Parameter-Fixes (length/radius/children/force nicht senden, twist Grad→Radiant, leaves.size ×5); Ordner-ComboBox mit Auto-Refresh - Logging beim Baum-Export in allen drei Generatoren (EZ-Tree, Blight, Palme) - Atmosphäre-Tools: Emitter, Licht, Wasser, Sound-/Musikbereiche, Spiel-Starten - AnimPreviewState, RetargetingSystem, AnimationLibrary (Animations-Editor) - Terrain-Transparenz-Fix, Schatten-Fix, ThirdPersonCamera-Fix - DayNightState, WeatherState, CloudsNode, JmeConsole - MapIO v6, neue blight-common Modell-Klassen (GameCharacter, NPC, Quests…) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,10 @@ import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.system.AppSettings;
|
||||
import de.blight.game.config.*;
|
||||
import org.slf4j.bridge.SLF4JBridgeHandler;
|
||||
import de.blight.game.scene.WorldScene;
|
||||
|
||||
public class BlightApp extends SimpleApplication {
|
||||
public class BlightGame extends SimpleApplication {
|
||||
|
||||
private KeyBindings keyBindings;
|
||||
private GraphicsSettings graphicsSettings;
|
||||
@@ -18,7 +19,10 @@ public class BlightApp extends SimpleApplication {
|
||||
private PauseMenu pauseMenu;
|
||||
|
||||
public static void main(String[] args) {
|
||||
BlightApp app = new BlightApp();
|
||||
SLF4JBridgeHandler.removeHandlersForRootLogger();
|
||||
SLF4JBridgeHandler.install();
|
||||
|
||||
BlightGame app = new BlightGame();
|
||||
|
||||
GraphicsSettings gs = GraphicsStore.load();
|
||||
AppSettings settings = new AppSettings(true);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Beschreibt ein Animations-Set: eine Liste von Clip-Namen sowie die
|
||||
* Zuordnung semantischer Aktionen (IDLE, WALK, …) zu Clip-Namen.
|
||||
*
|
||||
* Wird als {@code <setName>.animset.json} neben der {@code .j3o}-Datei gespeichert
|
||||
* und ersetzt sowohl die alte {@code .clips.json} als auch die {@code .animmap}-Datei.
|
||||
*/
|
||||
public class AnimSet {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
private static final String SUFFIX = ".animset.json";
|
||||
|
||||
private List<String> clips = new ArrayList<>();
|
||||
private Map<String, String> actionMap = 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; }
|
||||
|
||||
/** Speichert dieses Set als {@code <setName>.animset.json} im Verzeichnis {@code setDir}. */
|
||||
public void save(Path setDir, String setName) throws IOException {
|
||||
Files.writeString(setDir.resolve(setName + SUFFIX), GSON.toJson(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein AnimSet aus dem Verzeichnis {@code setDir}.
|
||||
* Existiert keine Datei, wird ein leeres {@code AnimSet} zurückgegeben.
|
||||
*/
|
||||
public static AnimSet load(Path setDir, String setName) throws IOException {
|
||||
Path f = setDir.resolve(setName + SUFFIX);
|
||||
if (!Files.exists(f)) return new AnimSet();
|
||||
return GSON.fromJson(Files.readString(f), AnimSet.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein AnimSet anhand des Asset-Pfades der zugehörigen {@code .j3o}-Datei.
|
||||
*
|
||||
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
|
||||
* @param j3oAssetPath relativer Pfad zur {@code .j3o}-Datei (z. B. {@code "animations/sets/foo.j3o"})
|
||||
*/
|
||||
public static AnimSet loadByJ3oPath(Path assetRoot, String j3oAssetPath) {
|
||||
Path j3o = assetRoot.resolve(j3oAssetPath.replace('/', java.io.File.separatorChar));
|
||||
String name = j3o.getFileName().toString().replaceFirst("\\.j3o$", "");
|
||||
try {
|
||||
return load(j3o.getParent(), name);
|
||||
} catch (IOException e) {
|
||||
return new AnimSet();
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt den Companion-Pfad der {@code .animset.json}-Datei neben einer {@code .j3o}-Datei zurück. */
|
||||
public static Path companionPath(Path j3oPath) {
|
||||
String name = j3oPath.getFileName().toString().replaceFirst("\\.j3o$", "");
|
||||
return j3oPath.getParent().resolve(name + SUFFIX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
/**
|
||||
* Semantische Aktionen, denen ein Animations-Clip zugewiesen werden kann.
|
||||
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
|
||||
*/
|
||||
public enum AnimationAction {
|
||||
IDLE,
|
||||
WALK,
|
||||
RUN,
|
||||
JUMP,
|
||||
DUCK;
|
||||
|
||||
/** Lesbare Bezeichnung für UI-Anzeige. */
|
||||
public String displayName() {
|
||||
return switch (this) {
|
||||
case IDLE -> "Idle";
|
||||
case WALK -> "Walk";
|
||||
case RUN -> "Run";
|
||||
case JUMP -> "Jump";
|
||||
case DUCK -> "Duck";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
import com.jme3.anim.*;
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.scene.Spatial;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Loads all .j3o animation files from the animations/ asset folder at startup.
|
||||
* Provides retargeted animation clips for any model with a SkinningControl.
|
||||
*
|
||||
* Clip keys follow the pattern "filename/clipname" (e.g. "walk/Run").
|
||||
*/
|
||||
public class AnimationLibrary extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AnimationLibrary.class);
|
||||
|
||||
// Possible base paths for the animations folder (relative to working dir)
|
||||
private static final String[] ASSET_BASES = {
|
||||
"blight-assets/src/main/resources",
|
||||
"assets",
|
||||
".",
|
||||
};
|
||||
|
||||
private AssetManager assetManager;
|
||||
|
||||
/** clip key → clip (bound to the SOURCE armature; retargeted before use) */
|
||||
private final Map<String, AnimClip> clips = new LinkedHashMap<>();
|
||||
/** clip key → armature the clip was loaded from */
|
||||
private final Map<String, Armature> armatures = new LinkedHashMap<>();
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
assetManager = ((SimpleApplication) app).getAssetManager();
|
||||
loadAll();
|
||||
}
|
||||
|
||||
@Override protected void cleanup(Application app) { clips.clear(); armatures.clear(); }
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** All loaded clip keys (filename/clipname). */
|
||||
public Collection<String> getClipKeys() {
|
||||
return Collections.unmodifiableSet(clips.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retargets the clip to {@code model}'s skeleton and registers it
|
||||
* in the model's AnimComposer (idempotent).
|
||||
*
|
||||
* @return true if the clip was applied successfully
|
||||
*/
|
||||
public boolean applyTo(String clipKey, Spatial model) {
|
||||
AnimClip src = clips.get(clipKey);
|
||||
Armature srcArm = armatures.get(clipKey);
|
||||
if (src == null) return false;
|
||||
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
|
||||
SkinningControl sc = RetargetingSystem.findSkinningControl(model);
|
||||
if (ac == null || sc == null) return false;
|
||||
|
||||
String shortName = shortName(clipKey);
|
||||
if (ac.getAnimClip(shortName) != null) return true; // already present
|
||||
|
||||
AnimClip target;
|
||||
if (srcArm != null && srcArm != sc.getArmature()) {
|
||||
// Pre-baked animations (Blender retargeting) have identical bone names →
|
||||
// copy directly without retargeting. Different skeleton → retarget.
|
||||
if (haveSameBoneNames(srcArm, sc.getArmature())) {
|
||||
target = src;
|
||||
} else {
|
||||
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
|
||||
}
|
||||
} else {
|
||||
target = src;
|
||||
}
|
||||
if (target == null) return false;
|
||||
|
||||
ac.addAnimClip(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies all loaded clips to {@code model} (only if the model has a skinned rig).
|
||||
* Useful for auto-equipping all available animations on a freshly loaded character.
|
||||
*/
|
||||
public void applyAllTo(Spatial model) {
|
||||
if (RetargetingSystem.findSkinningControl(model) == null) return;
|
||||
int applied = 0;
|
||||
for (String key : clips.keySet()) {
|
||||
if (applyTo(key, model)) applied++;
|
||||
}
|
||||
if (applied > 0)
|
||||
log.info("[AnimLib] {} Clips auf '{}' angewendet", applied, model.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the clip and immediately starts playing it.
|
||||
*
|
||||
* @return true on success
|
||||
*/
|
||||
public boolean playOn(String clipKey, Spatial model) {
|
||||
if (!applyTo(clipKey, model)) return false;
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
|
||||
if (ac == null) return false;
|
||||
ac.setCurrentAction(shortName(clipKey));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Animations-Set zugeordnet ist.
|
||||
*
|
||||
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
|
||||
* @param j3oAssetPath relativer Asset-Pfad der {@code .j3o}-Set-Datei (z. B. {@code "animations/sets/hero.j3o"})
|
||||
* @param action semantische Aktion, z. B. {@code AnimationAction.IDLE}
|
||||
* @return Clip-Name oder {@code null} wenn keine Zuweisung existiert
|
||||
*/
|
||||
public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) {
|
||||
AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath);
|
||||
return set.getActionMap().get(action.name());
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void loadAll() {
|
||||
Path animDir = findAnimDir();
|
||||
if (animDir == null) {
|
||||
log.info("[AnimLib] Kein Animations-Verzeichnis gefunden – Bibliothek leer.");
|
||||
return;
|
||||
}
|
||||
try (var walk = Files.walk(animDir)) {
|
||||
walk.filter(p -> p.toString().endsWith(".j3o"))
|
||||
.forEach(this::loadFromFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage());
|
||||
}
|
||||
log.info("[AnimLib] {} Clips geladen.", clips.size());
|
||||
}
|
||||
|
||||
private void loadFromFile(Path file) {
|
||||
Path animDir = findAnimDir();
|
||||
if (animDir == null) return;
|
||||
String relPath = animDir.relativize(file).toString().replace('\\', '/');
|
||||
String assetKey = "animations/" + relPath;
|
||||
String fileBase = relPath.replaceFirst("\\.j3o$", "");
|
||||
|
||||
try {
|
||||
Spatial loaded = assetManager.loadModel(assetKey);
|
||||
AnimComposer ac = RetargetingSystem.findAnimComposer(loaded);
|
||||
SkinningControl sc = RetargetingSystem.findSkinningControl(loaded);
|
||||
if (ac == null) {
|
||||
log.debug("[AnimLib] Kein AnimComposer in {}", assetKey);
|
||||
return;
|
||||
}
|
||||
Armature armature = sc != null ? sc.getArmature() : null;
|
||||
|
||||
for (String clipName : ac.getAnimClipsNames()) {
|
||||
String key = fileBase + "/" + clipName;
|
||||
clips.put(key, ac.getAnimClip(clipName));
|
||||
if (armature != null) armatures.put(key, armature);
|
||||
log.info("[AnimLib] Clip: {}", key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Path findAnimDir() {
|
||||
for (String base : ASSET_BASES) {
|
||||
Path p = Paths.get(base, "animations");
|
||||
if (Files.isDirectory(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean haveSameBoneNames(Armature a, Armature b) {
|
||||
Set<String> namesA = a.getJointList().stream().map(Joint::getName).collect(Collectors.toSet());
|
||||
Set<String> namesB = b.getJointList().stream().map(Joint::getName).collect(Collectors.toSet());
|
||||
return namesA.equals(namesB);
|
||||
}
|
||||
|
||||
private static String shortName(String clipKey) {
|
||||
int slash = clipKey.lastIndexOf('/');
|
||||
return slash >= 0 ? clipKey.substring(slash + 1) : clipKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Ordnet {@link AnimationAction}-Werte Clip-Namen zu.
|
||||
* Wird als JSON-Companion-Datei neben dem Modell gespeichert:
|
||||
* Models/charakter.j3o → Models/charakter.animmap
|
||||
*
|
||||
* @deprecated Ersetzt durch {@link AnimSet}, das sowohl Clip-Liste als auch
|
||||
* Aktions-Zuweisung in einer einzigen {@code .animset.json}-Datei speichert.
|
||||
*/
|
||||
@Deprecated
|
||||
public class AnimationMap {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AnimationMap.class);
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private final EnumMap<AnimationAction, String> map = new EnumMap<>(AnimationAction.class);
|
||||
|
||||
// ── API ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public void set(AnimationAction action, String clipName) {
|
||||
if (clipName == null || clipName.isBlank()) {
|
||||
map.remove(action);
|
||||
} else {
|
||||
map.put(action, clipName);
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt den zugewiesenen Clip-Namen zurück, oder {@code null} wenn nicht belegt. */
|
||||
public String get(AnimationAction action) {
|
||||
return map.get(action);
|
||||
}
|
||||
|
||||
public Map<AnimationAction, String> asMap() {
|
||||
return new EnumMap<>(map);
|
||||
}
|
||||
|
||||
// ── Persistenz ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Speichert die Map in die Companion-Datei neben {@code modelPath}
|
||||
* (Extension {@code .j3o} wird durch {@code .animmap} ersetzt).
|
||||
*/
|
||||
public void save(Path modelPath) {
|
||||
Path target = companionPath(modelPath);
|
||||
try {
|
||||
Map<String, String> raw = new HashMap<>();
|
||||
map.forEach((action, clip) -> raw.put(action.name(), clip));
|
||||
Files.writeString(target, GSON.toJson(raw), StandardCharsets.UTF_8);
|
||||
log.debug("[AnimMap] Gespeichert: {}", target);
|
||||
} catch (IOException e) {
|
||||
log.warn("[AnimMap] Speicherfehler {}: {}", target, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Companion-Datei und gibt eine {@link AnimationMap} zurück.
|
||||
* Existiert keine Datei, wird eine leere Map zurückgegeben.
|
||||
*/
|
||||
public static AnimationMap load(Path modelPath) {
|
||||
AnimationMap result = new AnimationMap();
|
||||
Path source = companionPath(modelPath);
|
||||
if (!Files.exists(source)) return result;
|
||||
try {
|
||||
String json = Files.readString(source, StandardCharsets.UTF_8);
|
||||
Type type = new TypeToken<Map<String, String>>(){}.getType();
|
||||
Map<String, String> raw = GSON.fromJson(json, type);
|
||||
if (raw != null) {
|
||||
raw.forEach((key, clip) -> {
|
||||
try {
|
||||
result.map.put(AnimationAction.valueOf(key), clip);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Unbekannte Aktion aus zukünftiger Version – ignorieren
|
||||
}
|
||||
});
|
||||
}
|
||||
log.debug("[AnimMap] Geladen: {}", source);
|
||||
} catch (IOException e) {
|
||||
log.warn("[AnimMap] Ladefehler {}: {}", source, e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Leitet den Companion-Pfad aus dem Modell-Pfad ab. */
|
||||
public static Path companionPath(Path modelPath) {
|
||||
String name = modelPath.getFileName().toString();
|
||||
String base = name.endsWith(".j3o") ? name.substring(0, name.length() - 4) : name;
|
||||
return modelPath.resolveSibling(base + ".animmap");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
import com.jme3.anim.Armature;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Normalizes bone names across different skeleton conventions (Mixamo, standard, Tripo3D).
|
||||
* Used by RetargetingSystem to map source joints to target joints.
|
||||
*/
|
||||
public final class BoneNameMapping {
|
||||
|
||||
private BoneNameMapping() {}
|
||||
|
||||
// Mixamo "mixamorig:" prefix → unprefixed standard name
|
||||
private static final Map<String, String> MIXAMO_TO_STD = new HashMap<>();
|
||||
|
||||
static {
|
||||
String[] bones = {
|
||||
"Hips", "Spine", "Spine1", "Spine2",
|
||||
"Neck", "Head", "HeadTop_End",
|
||||
"LeftEye", "RightEye",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"LeftHandThumb1", "LeftHandThumb2", "LeftHandThumb3", "LeftHandThumb4",
|
||||
"LeftHandIndex1", "LeftHandIndex2", "LeftHandIndex3", "LeftHandIndex4",
|
||||
"LeftHandMiddle1", "LeftHandMiddle2", "LeftHandMiddle3", "LeftHandMiddle4",
|
||||
"LeftHandRing1", "LeftHandRing2", "LeftHandRing3", "LeftHandRing4",
|
||||
"LeftHandPinky1", "LeftHandPinky2", "LeftHandPinky3", "LeftHandPinky4",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"RightHandThumb1", "RightHandThumb2", "RightHandThumb3", "RightHandThumb4",
|
||||
"RightHandIndex1", "RightHandIndex2", "RightHandIndex3", "RightHandIndex4",
|
||||
"RightHandMiddle1", "RightHandMiddle2", "RightHandMiddle3", "RightHandMiddle4",
|
||||
"RightHandRing1", "RightHandRing2", "RightHandRing3", "RightHandRing4",
|
||||
"RightHandPinky1", "RightHandPinky2", "RightHandPinky3", "RightHandPinky4",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase", "LeftToe_End",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase", "RightToe_End",
|
||||
};
|
||||
for (String b : bones) {
|
||||
MIXAMO_TO_STD.put("mixamorig:" + b, b);
|
||||
MIXAMO_TO_STD.put("mixamorig_" + b, b);
|
||||
}
|
||||
}
|
||||
|
||||
// Finger joints carry no useful motion in walking clips.
|
||||
private static final Set<String> FINGER_EXCLUDE = Set.of(
|
||||
"lefthandthumb1", "lefthandthumb2", "lefthandthumb3", "lefthandthumb4",
|
||||
"lefthandindex1", "lefthandindex2", "lefthandindex3", "lefthandindex4",
|
||||
"lefthandmiddle1", "lefthandmiddle2", "lefthandmiddle3", "lefthandmiddle4",
|
||||
"lefthandring1", "lefthandring2", "lefthandring3", "lefthandring4",
|
||||
"lefthandpinky1", "lefthandpinky2", "lefthandpinky3", "lefthandpinky4",
|
||||
"righthandthumb1", "righthandthumb2", "righthandthumb3", "righthandthumb4",
|
||||
"righthandindex1", "righthandindex2", "righthandindex3", "righthandindex4",
|
||||
"righthandmiddle1", "righthandmiddle2", "righthandmiddle3", "righthandmiddle4",
|
||||
"righthandring1", "righthandring2", "righthandring3", "righthandring4",
|
||||
"righthandpinky1", "righthandpinky2", "righthandpinky3", "righthandpinky4"
|
||||
);
|
||||
|
||||
// Tripo3D humanoid rig → Mixamo standard name
|
||||
private static final Map<String, String> TRIPO_TO_STD = new HashMap<>();
|
||||
static {
|
||||
// Hip ist Elternteil von Pelvis UND Waist — kein direktes Mixamo-Äquivalent.
|
||||
// Pelvis ist der direkte Elternteil der Oberschenkelknochen, wie Mixamo-Hips.
|
||||
TRIPO_TO_STD.put("Pelvis", "Hips");
|
||||
TRIPO_TO_STD.put("Waist", "Spine");
|
||||
TRIPO_TO_STD.put("Spine01", "Spine1");
|
||||
TRIPO_TO_STD.put("Spine02", "Spine2");
|
||||
// Nur NeckTwist01 → Neck; NeckTwist02 ist ein Twist-Knochen und folgt NeckTwist01.
|
||||
TRIPO_TO_STD.put("NeckTwist01", "Neck");
|
||||
TRIPO_TO_STD.put("L_Clavicle", "LeftShoulder");
|
||||
TRIPO_TO_STD.put("L_Upperarm", "LeftArm");
|
||||
TRIPO_TO_STD.put("L_Forearm", "LeftForeArm");
|
||||
TRIPO_TO_STD.put("L_Hand", "LeftHand");
|
||||
TRIPO_TO_STD.put("R_Clavicle", "RightShoulder");
|
||||
TRIPO_TO_STD.put("R_Upperarm", "RightArm");
|
||||
TRIPO_TO_STD.put("R_Forearm", "RightForeArm");
|
||||
TRIPO_TO_STD.put("R_Hand", "RightHand");
|
||||
TRIPO_TO_STD.put("L_Thigh", "LeftUpLeg");
|
||||
TRIPO_TO_STD.put("L_Calf", "LeftLeg");
|
||||
TRIPO_TO_STD.put("L_Foot", "LeftFoot");
|
||||
TRIPO_TO_STD.put("L_ToeBase", "LeftToeBase");
|
||||
TRIPO_TO_STD.put("R_Thigh", "RightUpLeg");
|
||||
TRIPO_TO_STD.put("R_Calf", "RightLeg");
|
||||
TRIPO_TO_STD.put("R_Foot", "RightFoot");
|
||||
TRIPO_TO_STD.put("R_ToeBase", "RightToeBase");
|
||||
}
|
||||
|
||||
public static Map<String, String> buildMapping(Armature source, Armature target) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
|
||||
Map<String, String> targetByNorm = new HashMap<>();
|
||||
for (var joint : target.getJointList())
|
||||
targetByNorm.put(normalize(joint.getName()), joint.getName());
|
||||
|
||||
for (var joint : source.getJointList()) {
|
||||
String sName = joint.getName();
|
||||
String norm = normalize(sName);
|
||||
|
||||
if (FINGER_EXCLUDE.contains(norm)) continue;
|
||||
|
||||
if (target.getJoint(sName) != null) {
|
||||
result.put(sName, sName);
|
||||
continue;
|
||||
}
|
||||
|
||||
String tName = targetByNorm.get(norm);
|
||||
if (tName != null) result.put(sName, tName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String normalize(String name) {
|
||||
String n = MIXAMO_TO_STD.getOrDefault(name, name);
|
||||
n = TRIPO_TO_STD.getOrDefault(n, n);
|
||||
if (n.startsWith("mixamorig:")) n = n.substring("mixamorig:".length());
|
||||
if (n.startsWith("mixamorig_")) n = n.substring("mixamorig_".length());
|
||||
return n.replace("_", "").replace(".", "").replace(" ", "").toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package de.blight.game.animation;
|
||||
|
||||
import com.jme3.anim.*;
|
||||
import com.jme3.math.FastMath;
|
||||
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;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Model-space retargeting with correct parent-chain propagation.
|
||||
*
|
||||
* For each mapped dst joint j:
|
||||
* dstActualMS[j][k] = dstBindMS[j] × inv(srcBindMS[j]) × srcAnimMS[j][k]
|
||||
*
|
||||
* For each unmapped dst joint j (needed for parent propagation):
|
||||
* dstActualMS[j][k] = dstActualMS[parent][k] × dstBindLocal[j]
|
||||
*
|
||||
* Local rotation for rendering:
|
||||
* dstLocal[j][k] = inv(dstActualMS[parent][k]) × dstActualMS[j][k]
|
||||
*
|
||||
* This correctly handles the case where a mapped parent joint is animated —
|
||||
* the child's local rotation is computed relative to the parent's ANIMATED
|
||||
* model-space, not its bind model-space.
|
||||
*/
|
||||
public final class RetargetingSystem {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RetargetingSystem.class);
|
||||
|
||||
// Manuelle Model-Space-Korrekturen pro normalisiertem Knochen-Namen (via BoneNameMapping.normalize).
|
||||
// Wird vom Bone-Editor befüllt und per retarget(clip, src, dst, corrections) übergeben.
|
||||
private static final Map<String, Quaternion> MS_CORRECTIONS = new HashMap<>();
|
||||
|
||||
private RetargetingSystem() {}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
public static AnimClip retarget(AnimClip sourceClip,
|
||||
Armature sourceArmature,
|
||||
Armature targetArmature) {
|
||||
return retarget(sourceClip, sourceArmature, targetArmature, MS_CORRECTIONS);
|
||||
}
|
||||
|
||||
public static AnimClip retarget(AnimClip sourceClip,
|
||||
Armature sourceArmature,
|
||||
Armature targetArmature,
|
||||
Map<String, Quaternion> corrections) {
|
||||
Map<String, String> nameMap = BoneNameMapping.buildMapping(sourceArmature, targetArmature);
|
||||
if (nameMap.isEmpty()) {
|
||||
log.warn("[Retarget] Keine Knochen-Übereinstimmung für '{}'", sourceClip.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Same-rig fast path: nur wenn Namen UND Bind-Posen übereinstimmen ──
|
||||
// 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());
|
||||
return redirectTracks(sourceClip, targetArmature);
|
||||
}
|
||||
|
||||
// ── Source track lookup ───────────────────────────────────────────────
|
||||
Map<String, TransformTrack> srcTrackMap = new HashMap<>();
|
||||
for (AnimTrack<?> t : sourceClip.getTracks())
|
||||
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint j)
|
||||
srcTrackMap.put(j.getName(), tt);
|
||||
|
||||
// Reference keyframe times
|
||||
float[] times = null;
|
||||
for (String name : nameMap.keySet()) {
|
||||
TransformTrack tt = srcTrackMap.get(name);
|
||||
if (tt != null) { times = tt.getTimes(); break; }
|
||||
}
|
||||
if (times == null || times.length == 0) {
|
||||
log.warn("[Retarget] Keine Keyframe-Zeiten für '{}'", sourceClip.getName());
|
||||
return null;
|
||||
}
|
||||
int numFrames = times.length;
|
||||
|
||||
// ── Bind-pose model-space for both skeletons ──────────────────────────
|
||||
Map<Joint, Quaternion> srcBindMS = buildModelSpaceBind(sourceArmature);
|
||||
Map<Joint, Quaternion> dstBindMS = buildModelSpaceBind(targetArmature);
|
||||
|
||||
// ── Source animated model-space per frame ─────────────────────────────
|
||||
Map<Joint, Quaternion[]> srcAnimMS = new HashMap<>();
|
||||
for (Joint j : sourceArmature.getJointList())
|
||||
computeModelSpaceAnim(j, srcTrackMap, numFrames, srcAnimMS);
|
||||
|
||||
// ── Arm-Diagnose: Quell-Local, Quell-ModelSpace und Bind-MS bei Frame 0 ──
|
||||
for (var e : nameMap.entrySet()) {
|
||||
String dstName = e.getValue();
|
||||
if (!dstName.equals("L_Upperarm") && !dstName.equals("R_Upperarm")
|
||||
&& !dstName.equals("L_Clavicle") && !dstName.equals("R_Clavicle")) continue;
|
||||
Joint srcJ = sourceArmature.getJoint(e.getKey());
|
||||
Joint dstJ = targetArmature.getJoint(dstName);
|
||||
if (srcJ == null || dstJ == null) continue;
|
||||
TransformTrack tt = srcTrackMap.get(e.getKey());
|
||||
Quaternion[] localRots = tt != null ? tt.getRotations() : null;
|
||||
float[] loc = localRots != null && localRots.length > 0
|
||||
? localRots[0].toAngles(null) : new float[3];
|
||||
Quaternion[] ms = srcAnimMS.get(srcJ);
|
||||
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=[{} {} {}]°",
|
||||
sourceClip.getName(), e.getKey(), dstName,
|
||||
String.format("%.1f", Math.toDegrees(loc[0])),
|
||||
String.format("%.1f", Math.toDegrees(loc[1])),
|
||||
String.format("%.1f", Math.toDegrees(loc[2])),
|
||||
String.format("%.1f", Math.toDegrees(msA[0])),
|
||||
String.format("%.1f", Math.toDegrees(msA[1])),
|
||||
String.format("%.1f", Math.toDegrees(msA[2])),
|
||||
String.format("%.1f", Math.toDegrees(sbsA[0])),
|
||||
String.format("%.1f", Math.toDegrees(sbsA[1])),
|
||||
String.format("%.1f", Math.toDegrees(sbsA[2])),
|
||||
String.format("%.1f", Math.toDegrees(dbsA[0])),
|
||||
String.format("%.1f", Math.toDegrees(dbsA[1])),
|
||||
String.format("%.1f", Math.toDegrees(dbsA[2])));
|
||||
}
|
||||
|
||||
// ── dst name → src Joint (reverse map) ───────────────────────────────
|
||||
Map<String, Joint> dstToSrc = new HashMap<>();
|
||||
for (var e : nameMap.entrySet()) {
|
||||
Joint src = sourceArmature.getJoint(e.getKey());
|
||||
if (src != null) dstToSrc.put(e.getValue(), src);
|
||||
}
|
||||
|
||||
// Manuelle Korrekturen (vom Bone-Editor übergeben) – keine automatischen Arm-Korrekturen mehr.
|
||||
// Die Standardformel dstBind × inv(srcBind) × srcAnimMS überträgt die relative Bewegung
|
||||
// korrekt, unabhängig davon ob Source und Target unterschiedliche globale Orientierungen haben.
|
||||
Map<String, Quaternion> effectiveCorrections = new HashMap<>(corrections);
|
||||
|
||||
// ── Allocate result arrays for mapped dst joints ──────────────────────
|
||||
Map<Joint, Quaternion[]> dstLocalArrays = new LinkedHashMap<>();
|
||||
for (String dstName : dstToSrc.keySet()) {
|
||||
Joint dst = targetArmature.getJoint(dstName);
|
||||
if (dst != null) dstLocalArrays.put(dst, new Quaternion[numFrames]);
|
||||
}
|
||||
|
||||
// ── Per-frame retargeting ─────────────────────────────────────────────
|
||||
for (int k = 0; k < numFrames; k++) {
|
||||
// Compute dstActualMS for all dst joints (recursive, cached per frame)
|
||||
Map<Joint, Quaternion> dstActualMS = new HashMap<>();
|
||||
for (Joint dst : targetArmature.getJointList())
|
||||
computeDstActualMS(dst, k, dstToSrc, srcAnimMS, srcBindMS, dstBindMS, dstActualMS);
|
||||
|
||||
// Welt-Raum-Korrekturen: Pre-Multiplikation auf Model-Space aller betroffenen Gelenke.
|
||||
// Dadurch drehen sich linke und rechte Seite symmetrisch (keine Spiegelproblematik).
|
||||
// Nur die Schulter-Tracks ändern sich sichtbar; Kind-Locals kürzen sich gegenseitig raus,
|
||||
// die Korrektur propagiert beim Rendern durch die Hierarchie automatisch weiter.
|
||||
for (Joint dst : targetArmature.getJointList()) {
|
||||
Quaternion corr = effectiveCorrections.get(BoneNameMapping.normalize(dst.getName()));
|
||||
if (corr != null) {
|
||||
Quaternion orig = dstActualMS.get(dst);
|
||||
if (orig != null) dstActualMS.put(dst, corr.mult(orig));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ziel-ModelSpace-Diagnose bei Frame 0 ────────────────────────────
|
||||
if (k == 0) {
|
||||
// Root-Bones: bestimmen die Blickrichtung des Charakters
|
||||
for (Joint d : targetArmature.getJointList()) {
|
||||
if (d.getParent() != null) continue; // nur Wurzel-Bones
|
||||
Quaternion ams = dstActualMS.get(d);
|
||||
if (ams == null) continue;
|
||||
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=[{} {} {}]°",
|
||||
sourceClip.getName(), d.getName(),
|
||||
String.format("%.1f", Math.toDegrees(a[0])),
|
||||
String.format("%.1f", Math.toDegrees(a[1])),
|
||||
String.format("%.1f", Math.toDegrees(a[2])),
|
||||
String.format("%.1f", Math.toDegrees(b[0])),
|
||||
String.format("%.1f", Math.toDegrees(b[1])),
|
||||
String.format("%.1f", Math.toDegrees(b[2])));
|
||||
// Auch direkte Kinder des Root loggen
|
||||
for (Joint child : d.getChildren()) {
|
||||
Quaternion cms = dstActualMS.get(child);
|
||||
Quaternion cbind = dstBindMS.get(child);
|
||||
if (cms == null) continue;
|
||||
float[] ca = cms.toAngles(null);
|
||||
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=[{} {} {}]°",
|
||||
sourceClip.getName(), child.getName(),
|
||||
String.format("%.1f", Math.toDegrees(ca[0])),
|
||||
String.format("%.1f", Math.toDegrees(ca[1])),
|
||||
String.format("%.1f", Math.toDegrees(ca[2])),
|
||||
String.format("%.1f", Math.toDegrees(cb[0])),
|
||||
String.format("%.1f", Math.toDegrees(cb[1])),
|
||||
String.format("%.1f", Math.toDegrees(cb[2])),
|
||||
String.format("%.1f", Math.toDegrees(cl_[0])),
|
||||
String.format("%.1f", Math.toDegrees(cl_[1])),
|
||||
String.format("%.1f", Math.toDegrees(cl_[2])));
|
||||
}
|
||||
}
|
||||
// Arm-Bones
|
||||
for (String dName : new String[]{"L_Clavicle","L_Upperarm","R_Clavicle","R_Upperarm"}) {
|
||||
Joint d = targetArmature.getJoint(dName);
|
||||
if (d == null) continue;
|
||||
Quaternion ams = dstActualMS.get(d);
|
||||
if (ams == null) continue;
|
||||
Quaternion pms = d.getParent() != null
|
||||
? dstActualMS.get(d.getParent()) : new Quaternion();
|
||||
Quaternion local0 = pms.inverse().mult(ams);
|
||||
float[] a = ams.toAngles(null);
|
||||
float[] l = local0.toAngles(null);
|
||||
log.warn("[DstDiag] '{}' {} | dstActualMS=[{} {} {}]° | dstLocal=[{} {} {}]°",
|
||||
sourceClip.getName(), dName,
|
||||
String.format("%.1f", Math.toDegrees(a[0])),
|
||||
String.format("%.1f", Math.toDegrees(a[1])),
|
||||
String.format("%.1f", Math.toDegrees(a[2])),
|
||||
String.format("%.1f", Math.toDegrees(l[0])),
|
||||
String.format("%.1f", Math.toDegrees(l[1])),
|
||||
String.format("%.1f", Math.toDegrees(l[2])));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert model-space → local for each mapped joint
|
||||
for (var entry : dstLocalArrays.entrySet()) {
|
||||
Joint dst = entry.getKey();
|
||||
Quaternion ms = dstActualMS.get(dst);
|
||||
Quaternion parentMS = dst.getParent() != null
|
||||
? dstActualMS.get(dst.getParent())
|
||||
: new Quaternion();
|
||||
entry.getValue()[k] = parentMS.inverse().mult(ms);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build tracks ──────────────────────────────────────────────────────
|
||||
if (dstLocalArrays.isEmpty()) {
|
||||
log.warn("[Retarget] Keine Tracks gemappt für '{}'", sourceClip.getName());
|
||||
return null;
|
||||
}
|
||||
List<AnimTrack<?>> newTracks = new ArrayList<>();
|
||||
for (var entry : dstLocalArrays.entrySet())
|
||||
newTracks.add(new TransformTrack(entry.getKey(), times, null, 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());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Compute desired dst model-space for one joint at one frame ────────────
|
||||
|
||||
private static Quaternion computeDstActualMS(
|
||||
Joint dst, int k,
|
||||
Map<String, Joint> dstToSrc,
|
||||
Map<Joint, Quaternion[]> srcAnimMS,
|
||||
Map<Joint, Quaternion> srcBindMS,
|
||||
Map<Joint, Quaternion> dstBindMS,
|
||||
Map<Joint, Quaternion> cache) {
|
||||
|
||||
Quaternion cached = cache.get(dst);
|
||||
if (cached != null) return cached;
|
||||
|
||||
Joint srcJoint = dstToSrc.get(dst.getName());
|
||||
Quaternion result;
|
||||
|
||||
if (srcJoint != null) {
|
||||
Quaternion[] srcFrames = srcAnimMS.get(srcJoint);
|
||||
Quaternion srcMS_k = srcFrames[Math.min(k, srcFrames.length - 1)];
|
||||
|
||||
// When both this bone AND its parent are mapped to the same source parent,
|
||||
// use local-space retargeting. This avoids axis-swap artifacts caused by
|
||||
// different root orientation conventions (Rx vs Ry) between skeletons:
|
||||
// the relative arm/leg motion is transferred in the parent's own frame,
|
||||
// so "arm rotates Z -90° relative to shoulder" stays exactly that.
|
||||
Joint dstParent = dst.getParent();
|
||||
Joint srcViaMap = dstParent != null ? dstToSrc.get(dstParent.getName()) : null;
|
||||
Joint srcParentDirect = srcJoint.getParent();
|
||||
|
||||
if (srcViaMap != null && srcViaMap == srcParentDirect) {
|
||||
// src_local_anim = inv(srcAnimMS_parent) × srcAnimMS
|
||||
Quaternion[] pFrames = srcAnimMS.get(srcParentDirect);
|
||||
Quaternion srcPar_k = pFrames[Math.min(k, pFrames.length - 1)];
|
||||
Quaternion srcLocal = srcPar_k.inverse().mult(srcMS_k);
|
||||
|
||||
// correction = dstBind_local × inv(srcBind_local)
|
||||
// = inv(dstBind_parent) × dstBind × inv(srcBind) × srcBind_parent
|
||||
Quaternion correction = dstBindMS.get(dstParent).inverse()
|
||||
.mult(dstBindMS.get(dst))
|
||||
.mult(srcBindMS.get(srcJoint).inverse())
|
||||
.mult(srcBindMS.get(srcParentDirect));
|
||||
|
||||
Quaternion parentActual = computeDstActualMS(dstParent, k,
|
||||
dstToSrc, srcAnimMS, srcBindMS, dstBindMS, cache);
|
||||
result = parentActual.mult(correction.mult(srcLocal));
|
||||
} else {
|
||||
// Root of mapped chain (parent unmapped): model-space formula
|
||||
result = dstBindMS.get(dst).mult(srcBindMS.get(srcJoint).inverse().mult(srcMS_k));
|
||||
}
|
||||
} else {
|
||||
// Unmapped: propagate parent's actual MS × own bind local
|
||||
Quaternion parentMS = dst.getParent() != null
|
||||
? computeDstActualMS(dst.getParent(), k, dstToSrc, srcAnimMS, srcBindMS, dstBindMS, cache)
|
||||
: new Quaternion();
|
||||
Quaternion bindLocal = dst.getInitialTransform() != null
|
||||
? dst.getInitialTransform().getRotation() : new Quaternion();
|
||||
result = parentMS.mult(bindLocal);
|
||||
}
|
||||
|
||||
cache.put(dst, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Model-space bind accumulation ─────────────────────────────────────────
|
||||
|
||||
private static Map<Joint, Quaternion> buildModelSpaceBind(Armature arm) {
|
||||
Map<Joint, Quaternion> cache = new HashMap<>();
|
||||
for (Joint j : arm.getJointList())
|
||||
buildModelSpaceBindRec(j, cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static Quaternion buildModelSpaceBindRec(Joint j, Map<Joint, Quaternion> cache) {
|
||||
Quaternion cached = cache.get(j);
|
||||
if (cached != null) return cached;
|
||||
Quaternion local = j.getInitialTransform() != null
|
||||
? j.getInitialTransform().getRotation() : new Quaternion();
|
||||
Quaternion result = j.getParent() == null
|
||||
? new Quaternion(local)
|
||||
: buildModelSpaceBindRec(j.getParent(), cache).mult(local);
|
||||
cache.put(j, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Source animated model-space accumulation ──────────────────────────────
|
||||
|
||||
private static Quaternion[] computeModelSpaceAnim(Joint j,
|
||||
Map<String, TransformTrack> tracks,
|
||||
int numFrames,
|
||||
Map<Joint, Quaternion[]> cache) {
|
||||
Quaternion[] cached = cache.get(j);
|
||||
if (cached != null) return cached;
|
||||
|
||||
Quaternion[] parentMS = j.getParent() != null
|
||||
? computeModelSpaceAnim(j.getParent(), tracks, numFrames, cache)
|
||||
: null;
|
||||
|
||||
TransformTrack tt = tracks.get(j.getName());
|
||||
Quaternion[] srcR = tt != null ? tt.getRotations() : null;
|
||||
Quaternion bind = j.getInitialTransform() != null
|
||||
? j.getInitialTransform().getRotation() : new Quaternion();
|
||||
|
||||
Quaternion[] ms = new Quaternion[numFrames];
|
||||
for (int i = 0; i < numFrames; i++) {
|
||||
Quaternion local = (srcR != null && srcR.length > 0)
|
||||
? srcR[Math.min(i, srcR.length - 1)] : bind;
|
||||
ms[i] = parentMS != null ? parentMS[i].mult(local) : new Quaternion(local);
|
||||
}
|
||||
cache.put(j, ms);
|
||||
return ms;
|
||||
}
|
||||
|
||||
// ── Same-rig detection & redirect ────────────────────────────────────────
|
||||
|
||||
/** True nur wenn alle Namen exakt selbst-gemappt UND alle Bind-Posen übereinstimmen. */
|
||||
private static boolean isSameRig(Map<String, String> nameMap, Armature src, Armature dst) {
|
||||
for (var e : nameMap.entrySet()) {
|
||||
if (!e.getKey().equals(e.getValue())) return false;
|
||||
Joint srcJ = src.getJoint(e.getKey());
|
||||
Joint dstJ = dst.getJoint(e.getValue());
|
||||
if (srcJ == null || dstJ == null) continue;
|
||||
Quaternion sb = srcJ.getInitialTransform() != null
|
||||
? srcJ.getInitialTransform().getRotation() : new Quaternion();
|
||||
Quaternion db = dstJ.getInitialTransform() != null
|
||||
? dstJ.getInitialTransform().getRotation() : new Quaternion();
|
||||
if (Math.abs(sb.dot(db)) < 0.9999f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AnimClip redirectTracks(AnimClip sourceClip, Armature targetArmature) {
|
||||
List<AnimTrack<?>> newTracks = new ArrayList<>();
|
||||
for (AnimTrack<?> t : sourceClip.getTracks()) {
|
||||
if (t instanceof TransformTrack tt && tt.getTarget() instanceof Joint srcJoint) {
|
||||
Joint dstJoint = targetArmature.getJoint(srcJoint.getName());
|
||||
if (dstJoint == null) continue;
|
||||
newTracks.add(new TransformTrack(dstJoint, tt.getTimes(),
|
||||
tt.getTranslations(), tt.getRotations(), tt.getScales()));
|
||||
}
|
||||
}
|
||||
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());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
public static AnimComposer findAnimComposer(Spatial s) {
|
||||
return findControl(s, AnimComposer.class);
|
||||
}
|
||||
|
||||
public static SkinningControl findSkinningControl(Spatial s) {
|
||||
return findControl(s, SkinningControl.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T extends Control> T findControl(Spatial s, Class<T> type) {
|
||||
T c = s.getControl(type);
|
||||
if (c != null) return c;
|
||||
if (s instanceof Node n) {
|
||||
for (Spatial child : n.getChildren()) {
|
||||
c = findControl(child, type);
|
||||
if (c != null) return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
253
blight-game/src/main/java/de/blight/game/console/JmeConsole.java
Normal file
253
blight-game/src/main/java/de/blight/game/console/JmeConsole.java
Normal file
@@ -0,0 +1,253 @@
|
||||
package de.blight.game.console;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.font.BitmapFont;
|
||||
import com.jme3.font.BitmapText;
|
||||
import com.jme3.input.KeyInput;
|
||||
import com.jme3.input.RawInputListener;
|
||||
import com.jme3.input.event.*;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.Spatial;
|
||||
import com.jme3.scene.shape.Quad;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* JME-native Konsole.
|
||||
* Game-Modus : useRawInput=true → RawInputListener fängt Tastatur direkt ab.
|
||||
* Editor-Modus: useRawInput=false → Zeichen per feedChar/feedBackspace/… zuführen.
|
||||
*
|
||||
* Toggle-Taste (Game): KEY_GRAVE (^ auf DE-Tastatur).
|
||||
*/
|
||||
public class JmeConsole extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JmeConsole.class);
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────
|
||||
private static final int HISTORY = 6;
|
||||
private static final float LINE_H = 18f;
|
||||
private static final float PAD = 6f;
|
||||
private static final float BG_H = PAD + LINE_H * (HISTORY + 1) + PAD;
|
||||
|
||||
/** Taste zum Öffnen/Schließen der Konsole im Game. */
|
||||
public static final int KEY_TOGGLE = KeyInput.KEY_GRAVE;
|
||||
|
||||
// ── Befehle ───────────────────────────────────────────────────────────────
|
||||
private final Map<String, Function<String[], String>> commands = new LinkedHashMap<>();
|
||||
private Consumer<Boolean> onVisibilityChanged;
|
||||
|
||||
// ── Zustand ───────────────────────────────────────────────────────────────
|
||||
private boolean open = false;
|
||||
private StringBuilder inputBuf = new StringBuilder();
|
||||
private final Deque<String> history = new ArrayDeque<>();
|
||||
private float blinkTimer = 0f;
|
||||
private boolean cursorOn = true;
|
||||
private final boolean useRawInput;
|
||||
|
||||
// ── JME-Objekte ───────────────────────────────────────────────────────────
|
||||
private SimpleApplication app;
|
||||
private Node guiNode;
|
||||
private Node consoleNode;
|
||||
private BitmapText inputLine;
|
||||
private BitmapText[] histLines;
|
||||
|
||||
// ── Raw-Input (Game-Modus) ────────────────────────────────────────────────
|
||||
private final RawInputListener rawListener = new RawInputListener() {
|
||||
@Override public void beginInput() {}
|
||||
@Override public void endInput() {}
|
||||
@Override public void onMouseMotionEvent(MouseMotionEvent e) {}
|
||||
@Override public void onMouseButtonEvent(MouseButtonEvent e) {}
|
||||
@Override public void onJoyAxisEvent(JoyAxisEvent e) {}
|
||||
@Override public void onJoyButtonEvent(JoyButtonEvent e) {}
|
||||
@Override public void onTouchEvent(TouchEvent e) {}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyInputEvent e) {
|
||||
if (!e.isPressed()) return;
|
||||
int key = e.getKeyCode();
|
||||
if (key == KEY_TOGGLE) { toggle(); return; }
|
||||
if (!open) return;
|
||||
char c = e.getKeyChar();
|
||||
if (key == KeyInput.KEY_RETURN) feedEnter();
|
||||
else if (key == KeyInput.KEY_BACK) feedBackspace();
|
||||
else if (key == KeyInput.KEY_ESCAPE) feedEscape();
|
||||
else if (c >= ' ') feedChar(c);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Konstruktoren ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Game-Modus: verwendet RawInputListener. */
|
||||
public JmeConsole() { this(true); }
|
||||
|
||||
/**
|
||||
* @param useRawInput true = Game (RawInputListener),
|
||||
* false = Editor (Zeichen per feed-Methoden zuführen)
|
||||
*/
|
||||
public JmeConsole(boolean useRawInput) {
|
||||
this.useRawInput = useRawInput;
|
||||
}
|
||||
|
||||
// ── Öffentliche API ───────────────────────────────────────────────────────
|
||||
|
||||
/** Registriert einen Konsolenbefehl. */
|
||||
public void registerCommand(String name, Function<String[], String> handler) {
|
||||
commands.put(name.toLowerCase(), handler);
|
||||
}
|
||||
|
||||
/** Callback: wird beim Öffnen (true) und Schließen (false) der Konsole aufgerufen. */
|
||||
public void setOnVisibilityChanged(Consumer<Boolean> cb) { onVisibilityChanged = cb; }
|
||||
|
||||
public boolean isOpen() { return open; }
|
||||
|
||||
public void toggle() { if (open) hide(); else show(); }
|
||||
|
||||
/** Baut die Konsolen-UI nach einem Viewport-Resize neu auf. */
|
||||
public void rebuild() {
|
||||
boolean wasOpen = open;
|
||||
if (consoleNode != null) {
|
||||
guiNode.detachChild(consoleNode);
|
||||
consoleNode = null;
|
||||
}
|
||||
buildUI();
|
||||
if (!wasOpen) consoleNode.setCullHint(Spatial.CullHint.Always);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
open = true;
|
||||
consoleNode.setCullHint(Spatial.CullHint.Inherit);
|
||||
if (onVisibilityChanged != null) onVisibilityChanged.accept(true);
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
open = false;
|
||||
consoleNode.setCullHint(Spatial.CullHint.Always);
|
||||
inputBuf.setLength(0);
|
||||
refreshInput();
|
||||
if (onVisibilityChanged != null) onVisibilityChanged.accept(false);
|
||||
}
|
||||
|
||||
// Feed-Methoden für Editor-Modus (SharedInput → JME-Thread)
|
||||
public void feedChar(char c) { inputBuf.append(c); refreshInput(); }
|
||||
public void feedBackspace() { if (inputBuf.length() > 0) { inputBuf.deleteCharAt(inputBuf.length()-1); refreshInput(); } }
|
||||
public void feedEnter() { String raw = inputBuf.toString().trim(); inputBuf.setLength(0); execute(raw); refreshInput(); }
|
||||
public void feedEscape() { hide(); }
|
||||
|
||||
/** Gibt eine Zeile in der Konsole aus. */
|
||||
public void print(String msg) {
|
||||
history.addFirst(msg);
|
||||
while (history.size() > HISTORY) history.pollLast();
|
||||
refreshHistory();
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.guiNode = this.app.getGuiNode();
|
||||
buildUI();
|
||||
registerBuiltin();
|
||||
if (useRawInput) app.getInputManager().addRawInputListener(rawListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application app) {
|
||||
if (useRawInput) app.getInputManager().removeRawInputListener(rawListener);
|
||||
guiNode.detachChild(consoleNode);
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (!open) return;
|
||||
blinkTimer += tpf;
|
||||
if (blinkTimer >= 0.5f) {
|
||||
blinkTimer = 0f;
|
||||
cursorOn = !cursorOn;
|
||||
refreshInput();
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private void buildUI() {
|
||||
int sw = app.getCamera().getWidth();
|
||||
float topY = app.getCamera().getHeight(); // Bildschirmoberseite
|
||||
float baseY = topY - BG_H; // Unterkante der Konsole
|
||||
|
||||
Geometry bg = new Geometry("consoleBg", new Quad(sw, BG_H));
|
||||
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", new ColorRGBA(0f, 0f, 0f, 0.82f));
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
bg.setMaterial(mat);
|
||||
bg.setQueueBucket(RenderQueue.Bucket.Gui);
|
||||
bg.setLocalTranslation(0, baseY, -1f);
|
||||
|
||||
BitmapFont font = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
|
||||
|
||||
inputLine = makeLine(font, new ColorRGBA(0.25f, 1f, 0.25f, 1f),
|
||||
PAD, baseY + PAD + LINE_H);
|
||||
|
||||
histLines = new BitmapText[HISTORY];
|
||||
for (int i = 0; i < HISTORY; i++) {
|
||||
histLines[i] = makeLine(font, ColorRGBA.White,
|
||||
PAD, baseY + PAD + LINE_H * (i + 2));
|
||||
}
|
||||
|
||||
consoleNode = new Node("jmeConsole");
|
||||
consoleNode.attachChild(bg);
|
||||
consoleNode.attachChild(inputLine);
|
||||
for (BitmapText t : histLines) consoleNode.attachChild(t);
|
||||
consoleNode.setCullHint(Spatial.CullHint.Always);
|
||||
guiNode.attachChild(consoleNode);
|
||||
|
||||
refreshInput();
|
||||
}
|
||||
|
||||
private BitmapText makeLine(BitmapFont font, ColorRGBA color, float x, float y) {
|
||||
BitmapText t = new BitmapText(font, false);
|
||||
t.setSize(LINE_H - 2f);
|
||||
t.setColor(color);
|
||||
t.setLocalTranslation(x, y, 0f);
|
||||
return t;
|
||||
}
|
||||
|
||||
private void refreshInput() {
|
||||
if (inputLine != null)
|
||||
inputLine.setText("> " + inputBuf + (cursorOn ? "_" : " "));
|
||||
}
|
||||
|
||||
private void refreshHistory() {
|
||||
String[] arr = history.toArray(new String[0]);
|
||||
for (int i = 0; i < histLines.length; i++)
|
||||
histLines[i].setText(i < arr.length ? arr[i] : "");
|
||||
}
|
||||
|
||||
private void execute(String raw) {
|
||||
if (raw.isEmpty()) return;
|
||||
print("> " + raw);
|
||||
String[] parts = raw.split("\\s+");
|
||||
Function<String[], String> h = commands.get(parts[0].toLowerCase());
|
||||
String out = (h != null) ? h.apply(parts) : "Unbekannter Befehl: " + parts[0] + " (help)";
|
||||
if (out != null && !out.isEmpty()) print(out);
|
||||
}
|
||||
|
||||
private void registerBuiltin() {
|
||||
registerCommand("help", args ->
|
||||
"Befehle: " + String.join(" | ", commands.keySet()));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.jme3.bullet.control.CharacterControl;
|
||||
import com.jme3.input.InputManager;
|
||||
import com.jme3.input.controls.ActionListener;
|
||||
import com.jme3.input.controls.KeyTrigger;
|
||||
import com.jme3.math.FastMath;
|
||||
import com.jme3.math.Quaternion;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.Camera;
|
||||
@@ -102,6 +103,9 @@ public class PlayerInputControl {
|
||||
if (visual != null) {
|
||||
Quaternion targetRot = new Quaternion();
|
||||
targetRot.lookAt(moveDir, Vector3f.UNIT_Y);
|
||||
// Modell hat +X als Vorwärtsrichtung; lookAt zeigt -Z nach vorne →
|
||||
// 90°-Y-Versatz korrigiert den Orientierungsunterschied.
|
||||
targetRot.multLocal(new Quaternion().fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_Y));
|
||||
Quaternion current = visual.getLocalRotation().clone();
|
||||
current.slerp(targetRot, ROTATE_SPEED * tpf);
|
||||
visual.setLocalRotation(current);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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.audio.AudioData;
|
||||
import com.jme3.audio.AudioNode;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.scene.Node;
|
||||
import de.blight.common.PlacedSoundArea;
|
||||
import de.blight.common.SoundAreaIO;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Distanzbasierter Ambient-Sound pro Polygon-Bereich.
|
||||
* Innerhalb des Polygons: volle Lautstärke.
|
||||
* Außerhalb: lineares Fade bis zur Reichweite (volume * CROSSFADE_SCALE Einheiten).
|
||||
*/
|
||||
public class AmbientSoundSystem extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AmbientSoundSystem.class);
|
||||
private static final float CROSSFADE_SCALE = 30f; // Einheiten Reichweite außerhalb bei volume=1.0
|
||||
private static final float FADE_DURATION = 2f; // Sekunden für vollen Lautstärke-Hub
|
||||
|
||||
private SimpleApplication app;
|
||||
private AssetManager assets;
|
||||
private Node rootNode;
|
||||
|
||||
private final List<PlacedSoundArea> data = new ArrayList<>();
|
||||
private final List<AudioNode> sounds = new ArrayList<>();
|
||||
private final List<Boolean> attached = new ArrayList<>();
|
||||
|
||||
private final Vector3f playerPos = new Vector3f();
|
||||
|
||||
@Override
|
||||
protected void initialize(Application application) {
|
||||
app = (SimpleApplication) application;
|
||||
assets = app.getAssetManager();
|
||||
rootNode = app.getRootNode();
|
||||
|
||||
try {
|
||||
for (PlacedSoundArea area : SoundAreaIO.load()) {
|
||||
if (area.soundPath().isEmpty()) continue;
|
||||
try {
|
||||
AudioNode node = new AudioNode(assets, area.soundPath(), AudioData.DataType.Stream);
|
||||
node.setLooping(true);
|
||||
node.setVolume(0f);
|
||||
node.setPositional(false);
|
||||
data.add(area);
|
||||
sounds.add(node);
|
||||
attached.add(false);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AmbientSoundSystem] Sound nicht ladbar '{}': {}", area.soundPath(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (data.isEmpty()) log.info("[AmbientSoundSystem] Keine Sound-Bereiche geladen.");
|
||||
else log.info("[AmbientSoundSystem] {} Sound-Bereiche geladen.", data.size());
|
||||
} catch (IOException e) {
|
||||
log.warn("[AmbientSoundSystem] Sound-Bereich-Datei nicht ladbar: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application application) {
|
||||
for (int i = 0; i < sounds.size(); i++) {
|
||||
if (attached.get(i)) {
|
||||
sounds.get(i).stop();
|
||||
rootNode.detachChild(sounds.get(i));
|
||||
}
|
||||
}
|
||||
data.clear();
|
||||
sounds.clear();
|
||||
attached.clear();
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public void setPlayerPosition(Vector3f pos) {
|
||||
playerPos.set(pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (data.isEmpty()) return;
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
PlacedSoundArea area = data.get(i);
|
||||
AudioNode node = sounds.get(i);
|
||||
float target = computeTarget(area);
|
||||
float cur = node.getVolume();
|
||||
boolean wasOn = attached.get(i);
|
||||
|
||||
if (target > 0f && !wasOn) {
|
||||
node.setVolume(0f);
|
||||
rootNode.attachChild(node);
|
||||
node.play();
|
||||
attached.set(i, true);
|
||||
log.info("[AmbientSoundSystem] Bereich {} hörbar → spiele: {}", i, area.soundPath());
|
||||
}
|
||||
|
||||
if (attached.get(i)) {
|
||||
float step = area.volume() * tpf / FADE_DURATION;
|
||||
float nv = target > cur
|
||||
? Math.min(cur + step, target)
|
||||
: Math.max(cur - step, target);
|
||||
node.setVolume(nv);
|
||||
|
||||
if (nv <= 0f) {
|
||||
node.stop();
|
||||
rootNode.detachChild(node);
|
||||
attached.set(i, false);
|
||||
log.info("[AmbientSoundSystem] Bereich {} unhörbar → gestoppt: {}", i, area.soundPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zielvolumen basierend auf signiertem Abstand zur Polygongrenze.
|
||||
* Innen (≥0): volle Lautstärke.
|
||||
* Außen: linear von voll (Grenze) auf null (hearRange = volume * CROSSFADE_SCALE).
|
||||
*/
|
||||
private float computeTarget(PlacedSoundArea area) {
|
||||
float signedDist = signedDistToPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
|
||||
if (signedDist >= 0f) return area.volume();
|
||||
float hearRange = CROSSFADE_SCALE * area.volume();
|
||||
if (signedDist <= -hearRange) return 0f;
|
||||
return area.volume() * (1f + signedDist / hearRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positiv = Spieler ist innen (Distanz zur nächsten Kante).
|
||||
* Negativ = Spieler ist außen (negierte Distanz zur nächsten Kante).
|
||||
*/
|
||||
private static float signedDistToPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||
float edgeDist = minDistToPolygonEdge(px, pz, xs, zs);
|
||||
return pointInPolygon(px, pz, xs, zs) ? edgeDist : -edgeDist;
|
||||
}
|
||||
|
||||
private static float minDistToPolygonEdge(float px, float pz, float[] xs, float[] zs) {
|
||||
int n = xs.length;
|
||||
float minD2 = Float.MAX_VALUE;
|
||||
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||
float d2 = pointToSegmentDist2(px, pz, xs[j], zs[j], xs[i], zs[i]);
|
||||
if (d2 < minD2) minD2 = d2;
|
||||
}
|
||||
return (float) Math.sqrt(minD2);
|
||||
}
|
||||
|
||||
private static float pointToSegmentDist2(float px, float pz,
|
||||
float ax, float az,
|
||||
float bx, float bz) {
|
||||
float dx = bx - ax, dz = bz - az;
|
||||
float lenSq = dx * dx + dz * dz;
|
||||
float t = lenSq == 0f ? 0f : Math.max(0f, Math.min(1f, ((px - ax) * dx + (pz - az) * dz) / lenSq));
|
||||
float cx = ax + t * dx - px;
|
||||
float cz = az + t * dz - pz;
|
||||
return cx * cx + cz * cz;
|
||||
}
|
||||
|
||||
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||
int n = xs.length;
|
||||
boolean inside = false;
|
||||
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||
float xi = xs[i], zi = zs[i];
|
||||
float xj = xs[j], zj = zs[j];
|
||||
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi))
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.asset.AssetManager;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.material.RenderState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.Geometry;
|
||||
import com.jme3.scene.Node;
|
||||
import com.jme3.scene.shape.Box;
|
||||
|
||||
public class CloudsNode extends Node {
|
||||
|
||||
private static final int COUNT = 14;
|
||||
private static final float Y = 800f;
|
||||
private static final float WRAP_HALF = 1800f;
|
||||
|
||||
private final Geometry[] geoms = new Geometry[COUNT];
|
||||
private final float[] offsetX = new float[COUNT];
|
||||
private final float[] offsetZ = new float[COUNT];
|
||||
private final Material mat;
|
||||
|
||||
private static final float[] W = { 350,250,420,180,300,380,220,160,480,260,310,200,440,190 };
|
||||
private static final float[] D = { 280,160,300,220,350,200,280,180,320,240,180,350,260,300 };
|
||||
private static final float[] H = { 30, 20, 25, 18, 35, 28, 22, 15, 40, 24, 20, 30, 28, 22 };
|
||||
private static final float[] IX = {-600,200,-100,500,-800,300,700,-400,0,-200,600,-700,100,-500};
|
||||
private static final float[] IZ = { 400,-300,700,-500,100,-700,-200,600,-100,800,-400,200,-600,500};
|
||||
|
||||
public CloudsNode(AssetManager assetManager) {
|
||||
super("clouds");
|
||||
|
||||
mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
|
||||
mat.setColor("Color", new ColorRGBA(0.95f, 0.95f, 0.95f, 0.45f));
|
||||
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
|
||||
mat.getAdditionalRenderState().setDepthWrite(false);
|
||||
|
||||
for (int i = 0; i < COUNT; i++) {
|
||||
Box box = new Box(W[i] * 0.5f, H[i] * 0.5f, D[i] * 0.5f);
|
||||
Geometry g = new Geometry("cloud_" + i, box);
|
||||
g.setMaterial(mat);
|
||||
g.setQueueBucket(RenderQueue.Bucket.Transparent);
|
||||
g.setShadowMode(RenderQueue.ShadowMode.Off);
|
||||
offsetX[i] = IX[i];
|
||||
offsetZ[i] = IZ[i];
|
||||
attachChild(g);
|
||||
geoms[i] = g;
|
||||
}
|
||||
}
|
||||
|
||||
public void setCloudColor(ColorRGBA color) {
|
||||
mat.setColor("Color", color);
|
||||
}
|
||||
|
||||
public void update(float tpf, Vector3f windDir, float windSpeed, Vector3f camPos) {
|
||||
float dx = windDir.x * windSpeed * tpf;
|
||||
float dz = windDir.z * windSpeed * tpf;
|
||||
for (int i = 0; i < COUNT; i++) {
|
||||
offsetX[i] += dx;
|
||||
offsetZ[i] += dz;
|
||||
if (offsetX[i] > WRAP_HALF) offsetX[i] -= WRAP_HALF * 2f;
|
||||
if (offsetX[i] < -WRAP_HALF) offsetX[i] += WRAP_HALF * 2f;
|
||||
if (offsetZ[i] > WRAP_HALF) offsetZ[i] -= WRAP_HALF * 2f;
|
||||
if (offsetZ[i] < -WRAP_HALF) offsetZ[i] += WRAP_HALF * 2f;
|
||||
geoms[i].setLocalTranslation(camPos.x + offsetX[i], Y, camPos.z + offsetZ[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.light.AmbientLight;
|
||||
import com.jme3.light.DirectionalLight;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.math.*;
|
||||
import com.jme3.renderer.queue.RenderQueue;
|
||||
import com.jme3.scene.*;
|
||||
import com.jme3.scene.shape.Sphere;
|
||||
import com.jme3.shadow.DirectionalLightShadowFilter;
|
||||
import com.jme3.shadow.EdgeFilteringMode;
|
||||
import com.jme3.util.SkyFactory;
|
||||
import de.blight.common.time.DayTime;
|
||||
import de.blight.common.time.TimeListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Tag/Nacht-Zyklus: Sonne, Ambiente, Schatten, Himmelsfarbe.
|
||||
* Wiederverwendbar im Game und Editor (withShadows=false für Editor).
|
||||
*/
|
||||
public class DayNightState extends BaseAppState implements TimeListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DayNightState.class);
|
||||
|
||||
// ── Farb-Konstanten ───────────────────────────────────────────────────────
|
||||
|
||||
private static final ColorRGBA SUN_DAY = new ColorRGBA(1.00f, 0.95f, 0.88f, 1f);
|
||||
private static final ColorRGBA SUN_DAWN = new ColorRGBA(1.00f, 0.55f, 0.20f, 1f);
|
||||
private static final ColorRGBA AMB_DAY = new ColorRGBA(0.35f, 0.38f, 0.46f, 1f);
|
||||
private static final ColorRGBA AMB_NIGHT = new ColorRGBA(0.04f, 0.04f, 0.12f, 1f);
|
||||
private static final ColorRGBA BG_DAY = new ColorRGBA(0.35f, 0.55f, 0.85f, 1f);
|
||||
private static final ColorRGBA BG_NIGHT = new ColorRGBA(0.01f, 0.01f, 0.06f, 1f);
|
||||
|
||||
// ── Konfiguration ─────────────────────────────────────────────────────────
|
||||
|
||||
private final DayTime dayTime;
|
||||
private final boolean withShadows;
|
||||
|
||||
// ── JME-Objekte ───────────────────────────────────────────────────────────
|
||||
|
||||
private SimpleApplication app;
|
||||
private Node rootNode;
|
||||
private DirectionalLight sun;
|
||||
private AmbientLight ambient;
|
||||
private DirectionalLightShadowFilter shadowFilter;
|
||||
private Spatial sky;
|
||||
private Geometry sunSphere;
|
||||
|
||||
// ── Konstruktoren ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Game-Modus: Schatten aktiv, 5-Minuten-Tag. */
|
||||
public DayNightState() {
|
||||
this(new DayTime(), true);
|
||||
}
|
||||
|
||||
/** @param withShadows false für Editor (kein Shadow-Renderer) */
|
||||
public DayNightState(boolean withShadows) {
|
||||
this(new DayTime(), withShadows);
|
||||
}
|
||||
|
||||
public DayNightState(DayTime dayTime, boolean withShadows) {
|
||||
this.dayTime = dayTime;
|
||||
this.withShadows = withShadows;
|
||||
}
|
||||
|
||||
public DayTime getDayTime() { return dayTime; }
|
||||
|
||||
public void setPaused(boolean paused) { dayTime.setPaused(paused); }
|
||||
|
||||
/** Aktuelle Sonnenrichtung (normalisiert), oder (0,-1,0) falls noch nicht initialisiert. */
|
||||
public com.jme3.math.Vector3f getSunDirection() {
|
||||
return sun != null ? sun.getDirection() : new com.jme3.math.Vector3f(0f, -1f, 0f);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void initialize(Application app) {
|
||||
this.app = (SimpleApplication) app;
|
||||
this.rootNode = this.app.getRootNode();
|
||||
|
||||
// Himmel
|
||||
try {
|
||||
sky = SkyFactory.createSky(app.getAssetManager(),
|
||||
"Textures/Sky/Bright/BrightSky.dds",
|
||||
SkyFactory.EnvMapType.CubeMap);
|
||||
rootNode.attachChild(sky);
|
||||
} catch (Exception e) {
|
||||
log.warn("Sky-Textur nicht geladen – Viewport-Farbe als Fallback");
|
||||
}
|
||||
|
||||
// Sonnen-Sphere (sichtbare Sonne am Himmel)
|
||||
Sphere sphereMesh = new Sphere(16, 16, 18f);
|
||||
sunSphere = new Geometry("sunSphere", sphereMesh);
|
||||
Material sunMat = new Material(app.getAssetManager(),
|
||||
"Common/MatDefs/Misc/Unshaded.j3md");
|
||||
sunMat.setColor("Color", new ColorRGBA(1f, 0.92f, 0.65f, 1f));
|
||||
sunSphere.setMaterial(sunMat);
|
||||
sunSphere.setQueueBucket(RenderQueue.Bucket.Sky);
|
||||
sunSphere.setShadowMode(RenderQueue.ShadowMode.Off);
|
||||
rootNode.attachChild(sunSphere);
|
||||
|
||||
// Sonne (DirectionalLight)
|
||||
sun = new DirectionalLight();
|
||||
rootNode.addLight(sun);
|
||||
|
||||
// Ambient
|
||||
ambient = new AmbientLight();
|
||||
rootNode.addLight(ambient);
|
||||
|
||||
// Schatten (nur im Game) – als Filter, damit WorldScene ihn in den FPP einhängen kann
|
||||
if (withShadows) {
|
||||
try {
|
||||
shadowFilter = new DirectionalLightShadowFilter(app.getAssetManager(), 4096, 4);
|
||||
shadowFilter.setLight(sun);
|
||||
shadowFilter.setLambda(0.75f);
|
||||
shadowFilter.setShadowZExtend(40f);
|
||||
shadowFilter.setShadowZFadeLength(8f);
|
||||
shadowFilter.setEdgeFilteringMode(EdgeFilteringMode.PCFPOISSON);
|
||||
// Nicht direkt zum Viewport hinzufügen – WorldScene hängt ihn in den FPP ein
|
||||
} catch (Exception e) {
|
||||
log.error("Shadow-Filter konnte nicht erstellt werden", e);
|
||||
}
|
||||
}
|
||||
|
||||
dayTime.addListener(this);
|
||||
onTimeChanged(dayTime.getTimeOfDay());
|
||||
}
|
||||
|
||||
/** Gibt den Shadow-Filter zurück, damit WorldScene ihn in den FPP einhängen kann. */
|
||||
public DirectionalLightShadowFilter getShadowFilter() { return shadowFilter; }
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application app) {
|
||||
dayTime.removeListener(this);
|
||||
rootNode.removeLight(sun);
|
||||
rootNode.removeLight(ambient);
|
||||
shadowFilter = null; // FPP-Cleanup liegt bei WorldScene
|
||||
if (sky != null && sky.getParent() != null) rootNode.detachChild(sky);
|
||||
if (sunSphere != null && sunSphere.getParent() != null) rootNode.detachChild(sunSphere);
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
dayTime.update(tpf);
|
||||
|
||||
// Sky/Sonnen-Sphere nach rootNode.detachAllChildren() wiederherstellen
|
||||
if (sky != null && sky.getParent() == null)
|
||||
rootNode.attachChild(sky);
|
||||
if (sunSphere != null && sunSphere.getParent() == null)
|
||||
rootNode.attachChild(sunSphere);
|
||||
|
||||
// Sonnen-Sphere immer relativ zur Kamera positionieren
|
||||
if (sunSphere != null && sunSphere.getCullHint() != Spatial.CullHint.Always) {
|
||||
Vector3f camPos = app.getCamera().getLocation();
|
||||
sunSphere.setLocalTranslation(camPos.add(sun.getDirection().negate().mult(480f)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zeit-Callback ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void onTimeChanged(float t) {
|
||||
float elev = sunElevation(t);
|
||||
float elevC = FastMath.clamp(elev, 0f, 1f);
|
||||
|
||||
// ── Sonnenrichtung ──────────────────────────────────────────────────
|
||||
float angle = t * FastMath.TWO_PI;
|
||||
// Sonne bewegt sich von Ost (X+) über Süden nach West (X-)
|
||||
Vector3f sunPos = new Vector3f(
|
||||
FastMath.sin(angle) * 0.85f,
|
||||
elev,
|
||||
-0.15f
|
||||
);
|
||||
sun.setDirection(sunPos.negate().normalizeLocal());
|
||||
|
||||
// ── 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 * 1.35f));
|
||||
|
||||
// ── Sonnen-Sphere ausblenden wenn unter Horizont ────────────────────
|
||||
if (sunSphere != null) {
|
||||
sunSphere.setCullHint(elev > -0.02f
|
||||
? Spatial.CullHint.Inherit
|
||||
: Spatial.CullHint.Always);
|
||||
// Farbe bei Dämmerung orange, bei Tag weiß-gelb
|
||||
Material m = sunSphere.getMaterial();
|
||||
if (m != null) {
|
||||
ColorRGBA sphereColor = new ColorRGBA(1f, 0.6f + elevC * 0.32f, 0.3f + elevC * 0.35f, 1f);
|
||||
m.setColor("Color", sphereColor);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ambient: Nacht → Tag ────────────────────────────────────────────
|
||||
float ambFactor = FastMath.clamp((elev + 0.2f) / 0.6f, 0f, 1f);
|
||||
ambient.setColor(AMB_NIGHT.clone().interpolateLocal(AMB_DAY, ambFactor));
|
||||
|
||||
// ── Schatten ────────────────────────────────────────────────────────
|
||||
if (shadowFilter != null)
|
||||
shadowFilter.setShadowIntensity(FastMath.clamp(elev * 0.8f, 0f, 0.5f));
|
||||
|
||||
// ── Himmel & Hintergrundfarbe ────────────────────────────────────────
|
||||
float skyFactor = FastMath.clamp((elev + 0.05f) / 0.2f, 0f, 1f);
|
||||
if (sky != null)
|
||||
sky.setCullHint(skyFactor > 0.01f
|
||||
? Spatial.CullHint.Inherit
|
||||
: Spatial.CullHint.Always);
|
||||
app.getViewPort().setBackgroundColor(
|
||||
BG_NIGHT.clone().interpolateLocal(BG_DAY, skyFactor));
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Sonnenhöhe: −1 = Mitternacht, 0 = Horizont, +1 = Mittag. */
|
||||
private static float sunElevation(float t) {
|
||||
return -FastMath.cos(t * FastMath.TWO_PI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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.effect.ParticleEmitter;
|
||||
import com.jme3.effect.ParticleMesh;
|
||||
import com.jme3.material.Material;
|
||||
import com.jme3.math.*;
|
||||
import com.jme3.scene.Node;
|
||||
import de.blight.common.EmitterIO;
|
||||
import de.blight.common.PlacedEmitter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lädt platzierte Partikel-Emitter und aktiviert/deaktiviert sie basierend
|
||||
* auf der Nähe des Spielers (proximity-based activation).
|
||||
*/
|
||||
public class EmitterSystem extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmitterSystem.class);
|
||||
private static final float CHECK_INTERVAL = 0.25f;
|
||||
|
||||
private SimpleApplication app;
|
||||
private AssetManager assets;
|
||||
private Node rootNode;
|
||||
|
||||
private final List<PlacedEmitter> data = new ArrayList<>();
|
||||
private final List<ParticleEmitter> effects = new ArrayList<>();
|
||||
private final List<Boolean> active = new ArrayList<>();
|
||||
|
||||
private Vector3f playerPos = new Vector3f();
|
||||
private float checkTimer = 0f;
|
||||
|
||||
@Override
|
||||
protected void initialize(Application application) {
|
||||
app = (SimpleApplication) application;
|
||||
assets = app.getAssetManager();
|
||||
rootNode = app.getRootNode();
|
||||
|
||||
try {
|
||||
for (PlacedEmitter pe : EmitterIO.load()) {
|
||||
ParticleEmitter effect = buildEffect(pe);
|
||||
if (effect != null) {
|
||||
data.add(pe);
|
||||
effects.add(effect);
|
||||
active.add(false);
|
||||
}
|
||||
}
|
||||
if (!data.isEmpty())
|
||||
log.info("[EmitterSystem] {} Emitter geladen.", data.size());
|
||||
} catch (IOException e) {
|
||||
log.warn("[EmitterSystem] Emitter-Datei nicht ladbar: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application application) {
|
||||
for (int i = 0; i < effects.size(); i++) {
|
||||
if (active.get(i)) rootNode.detachChild(effects.get(i));
|
||||
}
|
||||
data.clear();
|
||||
effects.clear();
|
||||
active.clear();
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public void setPlayerPosition(Vector3f pos) {
|
||||
playerPos.set(pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (data.isEmpty()) return;
|
||||
|
||||
checkTimer += tpf;
|
||||
if (checkTimer < CHECK_INTERVAL) return;
|
||||
checkTimer = 0f;
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
PlacedEmitter pe = data.get(i);
|
||||
float dx = playerPos.x - pe.x();
|
||||
float dz = playerPos.z - pe.z();
|
||||
float r = pe.activationRadius();
|
||||
boolean inRange = (dx * dx + dz * dz) <= (r * r);
|
||||
|
||||
if (inRange && !active.get(i)) {
|
||||
rootNode.attachChild(effects.get(i));
|
||||
effects.get(i).setParticlesPerSec(pe.emitRate());
|
||||
active.set(i, true);
|
||||
} else if (!inRange && active.get(i)) {
|
||||
effects.get(i).setParticlesPerSec(0);
|
||||
rootNode.detachChild(effects.get(i));
|
||||
active.set(i, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ParticleEmitter buildEffect(PlacedEmitter pe) {
|
||||
try {
|
||||
ParticleEmitter effect = new ParticleEmitter(
|
||||
"emitter_" + pe.x() + "_" + pe.z(),
|
||||
ParticleMesh.Type.Triangle, pe.maxParticles());
|
||||
Material mat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
|
||||
mat.setTexture("Texture", assets.loadTexture(pe.texturePath()));
|
||||
effect.setMaterial(mat);
|
||||
effect.setImagesX(pe.imagesX());
|
||||
effect.setImagesY(pe.imagesY());
|
||||
effect.setStartColor(new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), pe.startA()));
|
||||
effect.setEndColor( new ColorRGBA(pe.endR(), pe.endG(), pe.endB(), pe.endA()));
|
||||
effect.setStartSize(pe.startSize());
|
||||
effect.setEndSize(pe.endSize());
|
||||
effect.getParticleInfluencer()
|
||||
.setInitialVelocity(new Vector3f(pe.velX(), pe.velY(), pe.velZ()));
|
||||
effect.getParticleInfluencer().setVelocityVariation(pe.velocityVariation());
|
||||
effect.setGravity(pe.gravX(), pe.gravY(), pe.gravZ());
|
||||
effect.setLowLife(pe.lowLife());
|
||||
effect.setHighLife(pe.highLife());
|
||||
effect.setParticlesPerSec(0); // inaktiv bis Spieler in Reichweite
|
||||
effect.setLocalTranslation(pe.x(), pe.y(), pe.z());
|
||||
return effect;
|
||||
} catch (Exception e) {
|
||||
log.warn("[EmitterSystem] Emitter konnte nicht erstellt werden: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
blight-game/src/main/java/de/blight/game/state/MusicSystem.java
Normal file
180
blight-game/src/main/java/de/blight/game/state/MusicSystem.java
Normal file
@@ -0,0 +1,180 @@
|
||||
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.audio.AudioData;
|
||||
import com.jme3.audio.AudioNode;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.scene.Node;
|
||||
import de.blight.common.MusicAreaIO;
|
||||
import de.blight.common.PlacedMusicArea;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Proximity-based ambient music per polygon area.
|
||||
* Three parallel tracks (day / night / combat) play when the player is inside.
|
||||
* All three are attached + played on entry and detached on exit.
|
||||
* Which one is actually audible is controlled via volume (wiring to day/night deferred).
|
||||
*/
|
||||
public class MusicSystem extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MusicSystem.class);
|
||||
private static final float CHECK_INTERVAL = 0.25f;
|
||||
private static final float FADE_DURATION = 3f;
|
||||
private static final float MUSIC_VOLUME = 0.7f;
|
||||
|
||||
private enum FadeState { INACTIVE, FADING_IN, ACTIVE, FADING_OUT }
|
||||
|
||||
private SimpleApplication app;
|
||||
private AssetManager assets;
|
||||
private Node rootNode;
|
||||
|
||||
private final List<PlacedMusicArea> data = new ArrayList<>();
|
||||
// three nodes per area: [0]=day, [1]=night, [2]=combat; element may be null
|
||||
private final List<AudioNode[]> tracks = new ArrayList<>();
|
||||
private final List<FadeState> fadeStates = new ArrayList<>();
|
||||
|
||||
private Vector3f playerPos = new Vector3f();
|
||||
private float checkTimer = 0f;
|
||||
|
||||
@Override
|
||||
protected void initialize(Application application) {
|
||||
app = (SimpleApplication) application;
|
||||
assets = app.getAssetManager();
|
||||
rootNode = app.getRootNode();
|
||||
|
||||
try {
|
||||
for (PlacedMusicArea area : MusicAreaIO.load()) {
|
||||
AudioNode[] arr = {
|
||||
loadAmbient(area.dayTrack()),
|
||||
loadAmbient(area.nightTrack()),
|
||||
loadAmbient(area.combatTrack())
|
||||
};
|
||||
boolean hasAny = false;
|
||||
for (AudioNode n : arr) if (n != null) { hasAny = true; break; }
|
||||
if (!hasAny) continue;
|
||||
data.add(area);
|
||||
tracks.add(arr);
|
||||
fadeStates.add(FadeState.INACTIVE);
|
||||
}
|
||||
if (!data.isEmpty()) log.info("[MusicSystem] {} Musik-Bereiche geladen.", data.size());
|
||||
} catch (IOException e) {
|
||||
log.warn("[MusicSystem] Musik-Bereich-Datei nicht ladbar: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void cleanup(Application application) {
|
||||
for (int i = 0; i < tracks.size(); i++) {
|
||||
if (fadeStates.get(i) != FadeState.INACTIVE) {
|
||||
for (AudioNode n : tracks.get(i)) {
|
||||
if (n != null) { n.stop(); rootNode.detachChild(n); }
|
||||
}
|
||||
}
|
||||
}
|
||||
data.clear();
|
||||
tracks.clear();
|
||||
fadeStates.clear();
|
||||
}
|
||||
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
public void setPlayerPosition(Vector3f pos) {
|
||||
playerPos.set(pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
if (data.isEmpty()) return;
|
||||
|
||||
for (int i = 0; i < tracks.size(); i++) {
|
||||
FadeState fs = fadeStates.get(i);
|
||||
if (fs == FadeState.INACTIVE || fs == FadeState.ACTIVE) continue;
|
||||
|
||||
AudioNode[] arr = tracks.get(i);
|
||||
if (fs == FadeState.FADING_IN) {
|
||||
boolean done = true;
|
||||
for (AudioNode n : arr) {
|
||||
if (n == null) continue;
|
||||
float nv = Math.min(n.getVolume() + MUSIC_VOLUME * tpf / FADE_DURATION, MUSIC_VOLUME);
|
||||
n.setVolume(nv);
|
||||
if (nv < MUSIC_VOLUME) done = false;
|
||||
}
|
||||
if (done) fadeStates.set(i, FadeState.ACTIVE);
|
||||
} else { // FADING_OUT
|
||||
boolean done = true;
|
||||
for (AudioNode n : arr) {
|
||||
if (n == null) continue;
|
||||
float nv = Math.max(n.getVolume() - MUSIC_VOLUME * tpf / FADE_DURATION, 0f);
|
||||
n.setVolume(nv);
|
||||
if (nv > 0f) done = false;
|
||||
}
|
||||
if (done) {
|
||||
for (AudioNode n : arr) {
|
||||
if (n != null) { n.stop(); rootNode.detachChild(n); }
|
||||
}
|
||||
fadeStates.set(i, FadeState.INACTIVE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkTimer += tpf;
|
||||
if (checkTimer < CHECK_INTERVAL) return;
|
||||
checkTimer = 0f;
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
PlacedMusicArea area = data.get(i);
|
||||
boolean inside = pointInPolygon(playerPos.x, playerPos.z, area.pointsX(), area.pointsZ());
|
||||
FadeState fs = fadeStates.get(i);
|
||||
|
||||
if (inside && (fs == FadeState.INACTIVE || fs == FadeState.FADING_OUT)) {
|
||||
if (fs == FadeState.INACTIVE) {
|
||||
log.info("[MusicSystem] Bereich {} betreten → starte Tracks", i);
|
||||
for (AudioNode n : tracks.get(i)) {
|
||||
if (n != null) { n.setVolume(0f); rootNode.attachChild(n); n.play(); }
|
||||
}
|
||||
}
|
||||
fadeStates.set(i, FadeState.FADING_IN);
|
||||
} else if (!inside && (fs == FadeState.ACTIVE || fs == FadeState.FADING_IN)) {
|
||||
log.info("[MusicSystem] Bereich {} verlassen → fade out", i);
|
||||
fadeStates.set(i, FadeState.FADING_OUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AudioNode loadAmbient(String path) {
|
||||
if (path == null || path.isEmpty()) return null;
|
||||
try {
|
||||
// Use Stream for music (files are large), Buffer for short sfx
|
||||
AudioNode n = new AudioNode(assets, path, AudioData.DataType.Stream);
|
||||
n.setLooping(true);
|
||||
n.setPositional(false);
|
||||
n.setVolume(0f);
|
||||
return n;
|
||||
} catch (Exception e) {
|
||||
log.warn("[MusicSystem] Track nicht ladbar '{}': {}", path, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
|
||||
int n = xs.length;
|
||||
boolean inside = false;
|
||||
for (int i = 0, j = n - 1; i < n; j = i++) {
|
||||
float xi = xs[i], zi = zs[i];
|
||||
float xj = xs[j], zj = zs[j];
|
||||
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi)) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
188
blight-game/src/main/java/de/blight/game/state/WeatherState.java
Normal file
188
blight-game/src/main/java/de/blight/game/state/WeatherState.java
Normal file
@@ -0,0 +1,188 @@
|
||||
package de.blight.game.state;
|
||||
|
||||
import com.jme3.app.Application;
|
||||
import com.jme3.app.SimpleApplication;
|
||||
import com.jme3.app.state.BaseAppState;
|
||||
import com.jme3.math.ColorRGBA;
|
||||
import com.jme3.math.FastMath;
|
||||
import com.jme3.math.Vector3f;
|
||||
import com.jme3.post.filters.FogFilter;
|
||||
import com.jme3.water.WaterFilter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class WeatherState extends BaseAppState {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WeatherState.class);
|
||||
|
||||
public enum Weather { SUNNY, CLOUDY, OVERCAST, STORM }
|
||||
|
||||
// ── Per-weather targets (Reihenfolge: SUNNY, CLOUDY, OVERCAST, STORM) ─────
|
||||
|
||||
private static final float[] FOG_DENSITY = { 0.00f, 0.30f, 0.65f, 0.90f };
|
||||
private static final float[] FOG_DISTANCE = { 600f, 350f, 140f, 50f };
|
||||
private static final float[] WIND_SPEED = { 4f, 14f, 26f, 55f };
|
||||
private static final float[] WAVE_SPEED = { 0.5f, 1.0f, 1.5f, 3.2f };
|
||||
private static final float[] WAVE_AMP = { 0.3f, 0.5f, 0.8f, 1.8f };
|
||||
private static final float[] WAVE_SCALE = { 0.008f, 0.007f, 0.006f, 0.005f};
|
||||
private static final float[] WATER_TRANS = { 0.15f, 0.10f, 0.07f, 0.02f };
|
||||
private static final float[] FOAM_INTENSITY= { 0.0f, 0.20f, 0.45f, 0.90f };
|
||||
|
||||
private static final ColorRGBA[] FOG_COLOR = {
|
||||
new ColorRGBA(0.75f, 0.80f, 0.88f, 1f),
|
||||
new ColorRGBA(0.62f, 0.65f, 0.70f, 1f),
|
||||
new ColorRGBA(0.42f, 0.43f, 0.46f, 1f),
|
||||
new ColorRGBA(0.18f, 0.19f, 0.21f, 1f),
|
||||
};
|
||||
private static final ColorRGBA[] CLOUD_COLOR = {
|
||||
new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f),
|
||||
new ColorRGBA(0.75f, 0.75f, 0.78f, 0.72f),
|
||||
new ColorRGBA(0.35f, 0.35f, 0.37f, 0.92f),
|
||||
new ColorRGBA(0.08f, 0.08f, 0.10f, 0.98f),
|
||||
};
|
||||
private static final ColorRGBA[] WATER_COLOR = {
|
||||
new ColorRGBA(0.05f, 0.25f, 0.55f, 1f),
|
||||
new ColorRGBA(0.04f, 0.18f, 0.42f, 1f),
|
||||
new ColorRGBA(0.03f, 0.12f, 0.28f, 1f),
|
||||
new ColorRGBA(0.02f, 0.06f, 0.14f, 1f),
|
||||
};
|
||||
private static final ColorRGBA[] DEEP_WATER_COLOR = {
|
||||
new ColorRGBA(0.02f, 0.12f, 0.30f, 1f),
|
||||
new ColorRGBA(0.01f, 0.08f, 0.20f, 1f),
|
||||
new ColorRGBA(0.01f, 0.04f, 0.12f, 1f),
|
||||
new ColorRGBA(0.00f, 0.02f, 0.06f, 1f),
|
||||
};
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
private Weather active = Weather.SUNNY;
|
||||
private float changeTimer = 120f;
|
||||
|
||||
// Aktuell interpolierte Werte
|
||||
private float fogDensity = 0f;
|
||||
private float fogDistance = 600f;
|
||||
private float windSpeed = 4f;
|
||||
private float waveSpeed = 0.5f;
|
||||
private float waveAmp = 0.3f;
|
||||
private float waveScale = 0.008f;
|
||||
private float waterTrans = 0.15f;
|
||||
private float foamIntensity = 0f;
|
||||
private float windAngle = 0f;
|
||||
private float windAngleTgt = 0.4f;
|
||||
private final ColorRGBA fogColor = new ColorRGBA(0.75f, 0.80f, 0.88f, 1f);
|
||||
private final ColorRGBA cloudColor = new ColorRGBA(0.95f, 0.95f, 0.95f, 0.40f);
|
||||
private final ColorRGBA waterColor = new ColorRGBA(0.05f, 0.25f, 0.55f, 1f);
|
||||
private final ColorRGBA deepWaterColor= new ColorRGBA(0.02f, 0.12f, 0.30f, 1f);
|
||||
|
||||
// ── Externe Referenzen ────────────────────────────────────────────────────
|
||||
|
||||
private FogFilter fogFilter;
|
||||
private WaterFilter waterFilter;
|
||||
private CloudsNode cloudsNode;
|
||||
private Application app;
|
||||
|
||||
public void setFogFilter(FogFilter f) { this.fogFilter = f; }
|
||||
public void setWaterFilter(WaterFilter f) { this.waterFilter = f; }
|
||||
public void setCloudsNode(CloudsNode n) { this.cloudsNode = n; }
|
||||
|
||||
public Weather getActiveWeather() { return active; }
|
||||
public float getWindSpeed() { return windSpeed; }
|
||||
|
||||
/**
|
||||
* Setzt das Wetter sofort; Werte interpolieren sanft zum neuen Ziel.
|
||||
* Der automatische Wechsel-Timer wird zurückgesetzt.
|
||||
*/
|
||||
public void forceWeather(Weather w) {
|
||||
active = w;
|
||||
changeTimer = 90f + FastMath.nextRandomFloat() * 150f;
|
||||
windAngleTgt = FastMath.nextRandomFloat() * FastMath.TWO_PI;
|
||||
log.info("[Weather] forceWeather → {}", w);
|
||||
}
|
||||
|
||||
public Vector3f getWindDirection() {
|
||||
return new Vector3f(FastMath.sin(windAngle), 0f, FastMath.cos(windAngle));
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override protected void initialize(Application app) { this.app = app; }
|
||||
@Override protected void cleanup(Application app) {}
|
||||
@Override protected void onEnable() {}
|
||||
@Override protected void onDisable() {}
|
||||
|
||||
@Override
|
||||
public void update(float tpf) {
|
||||
changeTimer -= tpf;
|
||||
if (changeTimer <= 0f) {
|
||||
changeTimer = 90f + FastMath.nextRandomFloat() * 150f;
|
||||
pickNextWeather();
|
||||
}
|
||||
|
||||
int i = active.ordinal();
|
||||
|
||||
fogDensity = approach(fogDensity, FOG_DENSITY[i], tpf * 0.025f);
|
||||
fogDistance = approach(fogDistance, FOG_DISTANCE[i], tpf * 0.025f);
|
||||
windSpeed = approach(windSpeed, WIND_SPEED[i], tpf * 0.040f);
|
||||
waveSpeed = approach(waveSpeed, WAVE_SPEED[i], tpf * 0.030f);
|
||||
waveAmp = approach(waveAmp, WAVE_AMP[i], tpf * 0.025f);
|
||||
waveScale = approach(waveScale, WAVE_SCALE[i], tpf * 0.020f);
|
||||
waterTrans = approach(waterTrans, WATER_TRANS[i], tpf * 0.025f);
|
||||
foamIntensity = approach(foamIntensity, FOAM_INTENSITY[i], tpf * 0.020f);
|
||||
windAngle = approachAngle(windAngle, windAngleTgt, tpf * 0.012f);
|
||||
|
||||
fogColor.interpolateLocal(FOG_COLOR[i], tpf * 0.025f);
|
||||
cloudColor.interpolateLocal(CLOUD_COLOR[i], tpf * 0.025f);
|
||||
waterColor.interpolateLocal(WATER_COLOR[i], tpf * 0.020f);
|
||||
deepWaterColor.interpolateLocal(DEEP_WATER_COLOR[i], tpf * 0.020f);
|
||||
|
||||
if (fogFilter != null) {
|
||||
fogFilter.setFogDensity(fogDensity);
|
||||
fogFilter.setFogDistance(fogDistance);
|
||||
fogFilter.setFogColor(fogColor.clone());
|
||||
}
|
||||
|
||||
if (waterFilter != null) {
|
||||
waterFilter.setSpeed(waveSpeed);
|
||||
waterFilter.setMaxAmplitude(waveAmp);
|
||||
waterFilter.setWaveScale(waveScale);
|
||||
waterFilter.setWaterTransparency(waterTrans);
|
||||
waterFilter.setFoamIntensity(foamIntensity);
|
||||
waterFilter.setWaterColor(waterColor.clone());
|
||||
waterFilter.setDeepWaterColor(deepWaterColor.clone());
|
||||
waterFilter.setWindDirection(
|
||||
new com.jme3.math.Vector2f(FastMath.sin(windAngle), FastMath.cos(windAngle)));
|
||||
}
|
||||
|
||||
if (cloudsNode != null) {
|
||||
cloudsNode.setCloudColor(cloudColor.clone());
|
||||
Vector3f camPos = ((SimpleApplication) app).getCamera().getLocation();
|
||||
cloudsNode.update(tpf, getWindDirection(), windSpeed * 0.5f, camPos);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private void pickNextWeather() {
|
||||
float r = FastMath.nextRandomFloat();
|
||||
Weather next = r < 0.40f ? Weather.SUNNY
|
||||
: r < 0.70f ? Weather.CLOUDY
|
||||
: r < 0.88f ? Weather.OVERCAST
|
||||
: Weather.STORM;
|
||||
windAngleTgt = FastMath.nextRandomFloat() * FastMath.TWO_PI;
|
||||
if (next != active) {
|
||||
active = next;
|
||||
log.info("[Weather] transitioning to {}", active);
|
||||
}
|
||||
}
|
||||
|
||||
private static float approach(float cur, float tgt, float alpha) {
|
||||
return cur + (tgt - cur) * FastMath.clamp(alpha, 0f, 1f);
|
||||
}
|
||||
|
||||
private static float approachAngle(float cur, float tgt, float alpha) {
|
||||
float d = tgt - cur;
|
||||
while (d > FastMath.PI) d -= FastMath.TWO_PI;
|
||||
while (d < -FastMath.PI) d += FastMath.TWO_PI;
|
||||
return cur + d * FastMath.clamp(alpha, 0f, 1f);
|
||||
}
|
||||
}
|
||||
30
blight-game/src/main/resources/logback.xml
Normal file
30
blight-game/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<configuration>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>logs/blight-game.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>logs/blight-game.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>7</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
|
||||
<logger name="com.jme3" level="WARN"/>
|
||||
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" – bekanntes JME-Verhalten, kein Fehler -->
|
||||
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user