Weiter gearbeitet

This commit is contained in:
2026-06-04 22:40:17 +02:00
parent 875c39ab27
commit d56f2ea41f
108 changed files with 4283 additions and 1122 deletions

View File

@@ -29,6 +29,7 @@ dependencies {
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'org.slf4j:jul-to-slf4j:2.0.17'
runtimeOnly 'ch.qos.logback:logback-classic:1.5.18'
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}

View File

@@ -1,96 +1,164 @@
package de.blight.game;
import com.jme3.app.SimpleApplication;
import com.jme3.input.KeyInput;
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 BlightGame extends SimpleApplication {
private KeyBindings keyBindings;
private GraphicsSettings graphicsSettings;
private WorldScene worldScene;
private ConfigScreen configScreen;
private GraphicsScreen graphicsScreen;
private PauseMenu pauseMenu;
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
BlightGame app = new BlightGame();
GraphicsSettings gs = GraphicsStore.load();
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight");
settings.setResolution(gs.width, gs.height);
settings.setFullscreen(gs.fullscreen);
settings.setVSync(gs.vsync);
settings.setSamples(gs.samples);
app.setSettings(settings);
app.setShowSettings(false);
app.start();
}
@Override
public void simpleInitApp() {
flyCam.setEnabled(false);
inputManager.deleteMapping(INPUT_MAPPING_EXIT);
keyBindings = KeyBindingStore.load();
graphicsSettings = GraphicsStore.load();
worldScene = new WorldScene(keyBindings);
stateManager.attach(worldScene);
configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings));
configScreen.setOnClose(() -> pauseMenu.setEnabled(true));
stateManager.attach(configScreen);
configScreen.setEnabled(false);
graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true));
stateManager.attach(graphicsScreen);
graphicsScreen.setEnabled(false);
pauseMenu = new PauseMenu(
() -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); },
() -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); }
);
stateManager.attach(pauseMenu);
pauseMenu.setEnabled(false);
inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE));
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
if (!isPressed) return;
if (graphicsScreen.isEnabled()) {
// GraphicsScreen wird nur über seine eigenen Buttons geschlossen
return;
}
if (configScreen.isEnabled()) {
if (configScreen.isWaiting()) {
configScreen.cancelWaiting();
} else {
configScreen.setEnabled(false);
pauseMenu.setEnabled(true);
}
return;
}
if (pauseMenu.isEnabled()) {
pauseMenu.setEnabled(false);
worldScene.setPaused(false);
return;
}
pauseMenu.setEnabled(true);
worldScene.setPaused(true);
}, "ToggleMenu");
}
@Override
public void simpleUpdate(float tpf) {}
}
package de.blight.game;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.ScreenshotAppState;
import com.jme3.input.KeyInput;
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.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import de.blight.game.scene.WorldScene;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class BlightGame extends SimpleApplication {
private static final Logger log = LoggerFactory.getLogger(BlightGame.class);
private KeyBindings keyBindings;
private GraphicsSettings graphicsSettings;
private ScreenshotAppState screenshotState;
private WorldScene worldScene;
private ConfigScreen configScreen;
private GraphicsScreen graphicsScreen;
private PauseMenu pauseMenu;
private JWindow splashWindow;
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
BlightGame app = new BlightGame();
app.splashWindow = showSplash();
GraphicsSettings gs = GraphicsStore.load();
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight");
try {
settings.setIcons(new Object[]{ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"))});
} catch (IOException | NullPointerException ignored) {}
settings.setResolution(gs.width, gs.height);
settings.setFullscreen(gs.fullscreen);
settings.setVSync(gs.vsync);
settings.setSamples(gs.samples);
app.setSettings(settings);
app.setShowSettings(false);
app.start();
}
private static JWindow showSplash() {
try {
BufferedImage img = ImageIO.read(BlightGame.class.getResourceAsStream("/logo.png"));
BufferedImage icon = ImageIO.read(BlightGame.class.getResourceAsStream("/icon.png"));
JWindow win = new JWindow();
if (icon != null) win.setIconImages(java.util.List.of(icon));
win.getContentPane().add(new JLabel(new ImageIcon(img)));
win.pack();
win.setLocationRelativeTo(null);
win.setVisible(true);
return win;
} catch (IOException | NullPointerException ignored) {
return null;
}
}
@Override
public void simpleInitApp() {
if (splashWindow != null) {
SwingUtilities.invokeLater(() -> { splashWindow.dispose(); splashWindow = null; });
}
flyCam.setEnabled(false);
inputManager.deleteMapping(INPUT_MAPPING_EXIT);
keyBindings = KeyBindingStore.load();
graphicsSettings = GraphicsStore.load();
worldScene = new WorldScene(keyBindings);
stateManager.attach(worldScene);
configScreen = new ConfigScreen(keyBindings, () -> worldScene.reloadBindings(keyBindings));
configScreen.setOnClose(() -> pauseMenu.setEnabled(true));
stateManager.attach(configScreen);
configScreen.setEnabled(false);
graphicsScreen = new GraphicsScreen(graphicsSettings, () -> pauseMenu.setEnabled(true));
stateManager.attach(graphicsScreen);
graphicsScreen.setEnabled(false);
pauseMenu = new PauseMenu(
() -> { pauseMenu.setEnabled(false); graphicsScreen.setEnabled(true); },
() -> { pauseMenu.setEnabled(false); configScreen.setEnabled(true); }
);
stateManager.attach(pauseMenu);
pauseMenu.setEnabled(false);
// ── Screenshot (Druck-Taste) ───────────────────────────────────────────
try {
Path screenshotDir = findProjectRoot().resolve("screenshots");
Files.createDirectories(screenshotDir);
screenshotState = new ScreenshotAppState(screenshotDir + File.separator, "screenshot");
stateManager.attach(screenshotState);
log.info("Screenshots werden gespeichert in: {}", screenshotDir.toAbsolutePath());
} catch (IOException e) {
log.warn("Screenshot-Verzeichnis konnte nicht angelegt werden", e);
}
inputManager.addMapping("Screenshot", new KeyTrigger(KeyInput.KEY_SYSRQ));
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
if (isPressed && screenshotState != null) screenshotState.takeScreenshot();
}, "Screenshot");
inputManager.addMapping("ToggleMenu", new KeyTrigger(KeyInput.KEY_ESCAPE));
inputManager.addListener((ActionListener) (name, isPressed, tpf) -> {
if (!isPressed) return;
if (graphicsScreen.isEnabled()) {
return;
}
if (configScreen.isEnabled()) {
if (configScreen.isWaiting()) {
configScreen.cancelWaiting();
} else {
configScreen.setEnabled(false);
pauseMenu.setEnabled(true);
}
return;
}
if (pauseMenu.isEnabled()) {
pauseMenu.setEnabled(false);
worldScene.setPaused(false);
return;
}
pauseMenu.setEnabled(true);
worldScene.setPaused(true);
}, "ToggleMenu");
}
@Override
public void simpleUpdate(float tpf) {}
private static Path findProjectRoot() {
String prop = System.getProperty("blight.project.root");
if (prop != null) return Paths.get(prop);
File dir = Paths.get(".").toAbsolutePath().normalize().toFile();
while (dir != null) {
if (new File(dir, "blight-editor").isDirectory()
&& new File(dir, "blight-game").isDirectory())
return dir.toPath();
dir = dir.getParentFile();
}
return Paths.get(".").toAbsolutePath().normalize();
}
}

View File

@@ -0,0 +1,29 @@
package de.blight.game;
import de.blight.common.MapIO;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Schreibt Live-Daten (Spielerposition) in eine Temp-Datei neben der Karte,
* damit der Editor sie live anzeigen kann.
*/
public final class LiveBroadcast {
public static final Path POS_FILE =
MapIO.getMapPath().resolveSibling("blight_live.pos");
private LiveBroadcast() {}
public static void writePosition(float x, float y, float z) {
try {
Files.writeString(POS_FILE, x + "|" + y + "|" + z);
} catch (IOException ignored) {}
}
public static void clear() {
try { Files.deleteIfExists(POS_FILE); } catch (IOException ignored) {}
}
}

View File

@@ -14,8 +14,8 @@ 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.
* Wird als {@code animations/sets/<setName>.animset.json} gespeichert.
* Die Clip-Dateien liegen als eigenständige .j3o in {@code animations/clips/}.
*/
public class AnimSet {
@@ -44,26 +44,4 @@ public class AnimSet {
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);
}
}

View File

@@ -5,20 +5,27 @@ package de.blight.game.animation;
* Im Editor festgelegt, vom Spiel zur Laufzeit abgerufen.
*/
public enum AnimationAction {
IDLE,
DEFAULT,
IDLE,
WALK,
RUN,
SPRINT,
JUMP,
RUNNING_JUMP,
DUCK;
/** Lesbare Bezeichnung für UI-Anzeige. */
public String displayName() {
return switch (this) {
case IDLE -> "Idle";
case DEFAULT -> "Default";
case IDLE -> "Idle";
case WALK -> "Walk";
case RUN -> "Run";
case JUMP -> "Jump";
case DUCK -> "Duck";
case SPRINT -> "Sprint";
case JUMP -> "Jump";
case RUNNING_JUMP -> "Running Jump";
case DUCK -> "Duck";
};
}
}

View File

@@ -14,19 +14,15 @@ 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").
* Lädt alle Clip-Dateien aus {@code animations/clips/} beim Start.
* Clip-Schlüssel entsprechen dem Dateinamen ohne Extension (= Clip-Name).
*/
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",
@@ -35,9 +31,9 @@ public class AnimationLibrary extends BaseAppState {
private AssetManager assetManager;
/** clip key → clip (bound to the SOURCE armature; retargeted before use) */
/** clip name → clip (an Quell-Armatur gebunden; wird bei Bedarf retargeted) */
private final Map<String, AnimClip> clips = new LinkedHashMap<>();
/** clip keyarmature the clip was loaded from */
/** clip nameArmatur der Quell-Datei */
private final Map<String, Armature> armatures = new LinkedHashMap<>();
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -45,6 +41,15 @@ public class AnimationLibrary extends BaseAppState {
@Override
protected void initialize(Application app) {
assetManager = ((SimpleApplication) app).getAssetManager();
Path assetRoot = findAssetRoot();
try {
assetManager.registerLocator(
assetRoot.toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
log.info("[AnimLib] Asset-Root registriert: {}", assetRoot.toAbsolutePath());
} catch (Exception e) {
log.warn("[AnimLib] Asset-Root konnte nicht registriert werden: {}", e.getMessage());
}
loadAll();
}
@@ -54,51 +59,60 @@ public class AnimationLibrary extends BaseAppState {
// ── Public API ────────────────────────────────────────────────────────────
/** All loaded clip keys (filename/clipname). */
/** Alle geladenen Clip-Namen. */
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).
* Retargeted den Clip auf das Skeleton von {@code model} und registriert
* ihn im AnimComposer des Modells (idempotent).
*
* @return true if the clip was applied successfully
* @return true wenn der Clip erfolgreich angewendet wurde
*/
public boolean applyTo(String clipKey, Spatial model) {
AnimClip src = clips.get(clipKey);
Armature srcArm = armatures.get(clipKey);
if (src == null) return false;
public boolean applyTo(String clipName, Spatial model) {
AnimClip src = clips.get(clipName);
Armature srcArm = armatures.get(clipName);
if (src == null) {
log.warn("[AnimLib] applyTo: Clip '{}' nicht in Bibliothek (verfügbar: {})", clipName, clips.keySet());
return false;
}
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
SkinningControl sc = RetargetingSystem.findSkinningControl(model);
if (ac == null || sc == null) return false;
if (ac == null) {
log.warn("[AnimLib] applyTo: Kein AnimComposer in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
if (sc == null) {
log.warn("[AnimLib] applyTo: Kein SkinningControl in '{}' für Clip '{}'", model != null ? model.getName() : "null", clipName);
return false;
}
String shortName = shortName(clipKey);
if (ac.getAnimClip(shortName) != null) return true; // already present
if (ac.getAnimClip(clipName) != null) return true; // bereits vorhanden
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());
}
// Immer retarget() aufrufen auch bei gleichen Knochen-Namen.
// retarget() nutzt intern den "redirect"-Schnellpfad für gleiche Rigs,
// erstellt aber korrekte TransformTrack-Referenzen auf die Ziel-Armatur.
// Direkte Nutzung des Quell-Clips würde Transforms auf die falschen
// (entkoppelten) Joint-Objekte der Quell-j3o anwenden.
target = RetargetingSystem.retarget(src, srcArm, sc.getArmature());
} else {
target = src;
}
if (target == null) return false;
if (target == null) {
log.warn("[AnimLib] applyTo: Retargeting für '{}' schlug fehl", clipName);
return false;
}
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
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.
*/
/** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
public void applyAllTo(Spatial model) {
if (RetargetingSystem.findSkinningControl(model) == null) return;
int applied = 0;
@@ -110,92 +124,124 @@ public class AnimationLibrary extends BaseAppState {
}
/**
* Applies the clip and immediately starts playing it.
* Stellt sicher dass der Clip auf das Modell angewendet ist und SkinningControl aktiv ist,
* startet ihn aber NICHT (das übernimmt der Aufrufer via AnimComposer.setCurrentAction).
*
* @return true on success
* @return true wenn der Clip bereit ist
*/
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));
public boolean ensureApplied(String clipName, Spatial model) {
if (!applyTo(clipName, model)) return false;
enableSkinningControls(model);
return true;
}
/**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Animations-Set zugeordnet ist.
* Wendet den Clip an und startet ihn sofort.
*
* @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
* @return true bei Erfolg
*/
public static String getClipForAction(Path assetRoot, String j3oAssetPath, AnimationAction action) {
AnimSet set = AnimSet.loadByJ3oPath(assetRoot, j3oAssetPath);
return set.getActionMap().get(action.name());
public boolean playOn(String clipName, Spatial model) {
if (!applyTo(clipName, model)) return false;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
if (ac == null) return false;
enableSkinningControls(model);
log.info("[AnimLib] setCurrentAction('{}') auf '{}'", clipName, model.getName());
com.jme3.anim.tween.action.Action action = ac.setCurrentAction(clipName);
log.info("[AnimLib] Action: {} length={}", action, action != null ? action.getLength() : "N/A");
return action != null;
}
private static void enableSkinningControls(Spatial s) {
SkinningControl sc = s.getControl(SkinningControl.class);
if (sc != null && !sc.isEnabled()) {
sc.setEnabled(true);
log.info("[AnimLib] SkinningControl aktiviert auf '{}'", s.getName());
}
if (s instanceof com.jme3.scene.Node n) {
for (Spatial child : n.getChildren()) enableSkinningControls(child);
}
}
/**
* Gibt den Clip-Namen zurück, der einer semantischen Aktion in einem Set zugeordnet ist.
*
* @param assetRoot absoluter Pfad zum Assets-Wurzelverzeichnis
* @param setName Name des Animations-Sets (ohne Pfad/Extension, z. B. {@code "human"})
* @param action semantische Aktion
* @return Clip-Name oder {@code null}
*/
public static String getClipForAction(Path assetRoot, String setName, AnimationAction action) {
Path setDir = assetRoot.resolve("animations").resolve("sets");
try {
AnimSet set = AnimSet.load(setDir, setName);
return set.getActionMap().get(action.name());
} catch (Exception e) {
return null;
}
}
// ── Loading ───────────────────────────────────────────────────────────────
private void loadAll() {
Path animDir = findAnimDir();
if (animDir == null) {
log.info("[AnimLib] Kein Animations-Verzeichnis gefunden Bibliothek leer.");
Path clipsDir = findClipsDir();
log.info("[AnimLib] Asset-Root: {}", findAssetRoot().toAbsolutePath());
if (clipsDir == null) {
log.warn("[AnimLib] Kein clips-Verzeichnis gefunden Bibliothek leer.");
return;
}
try (var walk = Files.walk(animDir)) {
log.info("[AnimLib] Scanne clips-Verzeichnis: {}", clipsDir.toAbsolutePath());
try (var walk = Files.walk(clipsDir, 1)) {
walk.filter(p -> p.toString().endsWith(".j3o"))
.forEach(this::loadFromFile);
.forEach(this::loadClipFromFile);
} catch (IOException e) {
log.warn("[AnimLib] Fehler beim Scannen: {}", e.getMessage());
}
log.info("[AnimLib] {} Clips geladen.", clips.size());
if (clips.isEmpty()) {
log.warn("[AnimLib] KEINE Clips geladen! Prüfe ob der Asset-Root korrekt ist.");
} else {
log.info("[AnimLib] {} Clips geladen: {}", clips.size(), clips.keySet());
}
}
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$", "");
private void loadClipFromFile(Path file) {
String clipName = file.getFileName().toString().replaceFirst("\\.j3o$", "");
String assetKey = "animations/clips/" + clipName + ".j3o";
try {
Spatial loaded = assetManager.loadModel(assetKey);
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);
log.warn("[AnimLib] Kein AnimComposer in {} übersprungen", 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);
for (String name : ac.getAnimClipsNames()) {
clips.put(name, ac.getAnimClip(name));
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
}
} catch (Exception e) {
log.warn("[AnimLib] Fehler beim Laden von {}: {}", assetKey, e.getMessage());
}
}
private static Path findAnimDir() {
/** Gibt den Asset-Wurzelpfad zurück (erstes Verzeichnis mit {@code animations/}-Unterordner). */
public static Path findAssetRoot() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base, "animations");
Path p = Paths.get(base);
if (Files.isDirectory(p.resolve("animations"))) return p;
}
return Paths.get(ASSET_BASES[0]);
}
private static Path findClipsDir() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base, "animations", "clips");
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;
}
}

View File

@@ -11,6 +11,7 @@ public class KeyBindings {
public int right = KeyInput.KEY_D;
public int jump = KeyInput.KEY_SPACE;
public int sprint = KeyInput.KEY_LSHIFT;
public int walk = KeyInput.KEY_LMENU;
/** Metadaten für die Config-UI: Feldname im Objekt + Anzeigename. */
public static final String[][] ENTRIES = {
@@ -20,6 +21,7 @@ public class KeyBindings {
{"right", "Rechts"},
{"jump", "Springen"},
{"sprint", "Rennen"},
{"walk", "Gehen"},
};
public int get(String fieldName) {

View File

@@ -1,117 +1,203 @@
package de.blight.game.control;
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;
import com.jme3.scene.Spatial;
import de.blight.game.config.KeyBindings;
public class PlayerInputControl {
private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f;
private static final float ROTATE_SPEED = 10f;
private static final String[] ACTION_NAMES =
{"Forward", "Backward", "Left", "Right", "Jump", "Sprint"};
private final InputManager inputManager;
private final Camera cam;
private CharacterControl physicsChar;
private Spatial visual;
private boolean forward, backward, left, right, sprint;
private boolean paused = false;
// Listener als Feld, damit er bei reload nicht doppelt registriert wird
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return;
switch (name) {
case "Forward" -> forward = isPressed;
case "Backward" -> backward = isPressed;
case "Left" -> left = isPressed;
case "Right" -> right = isPressed;
case "Sprint" -> sprint = isPressed;
case "Jump" -> { if (isPressed && physicsChar != null) physicsChar.jump(); }
}
};
public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) {
this.inputManager = inputManager;
this.cam = cam;
registerMappings(kb);
}
public void setPhysicsCharacter(CharacterControl physicsChar) {
this.physicsChar = physicsChar;
}
public void setVisual(Spatial visual) {
this.visual = visual;
}
public void setPaused(boolean paused) {
this.paused = paused;
if (paused) {
forward = backward = left = right = sprint = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
}
/** Löscht alte Mappings und registriert neue aus den übergebenen KeyBindings. */
public void reloadBindings(KeyBindings kb) {
for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
registerMappings(kb);
// Zustand zurücksetzen, damit keine Taste „hängt"
forward = backward = left = right = sprint = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
private void registerMappings(KeyBindings kb) {
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
inputManager.addMapping("Left", new KeyTrigger(kb.left));
inputManager.addMapping("Right", new KeyTrigger(kb.right));
inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
inputManager.addListener(actionListener, ACTION_NAMES);
}
public void update(float tpf) {
if (physicsChar == null || paused) return;
Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal();
Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal();
Vector3f moveDir = new Vector3f();
if (forward) moveDir.addLocal(camDir);
if (backward) moveDir.subtractLocal(camDir);
if (left) moveDir.addLocal(camLeft);
if (right) moveDir.subtractLocal(camLeft);
if (moveDir.lengthSquared() > 0.001f) {
moveDir.normalizeLocal();
float speed = sprint ? MOVE_SPEED * SPRINT_MULT : MOVE_SPEED;
physicsChar.setWalkDirection(moveDir.mult(speed));
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);
}
} else {
physicsChar.setWalkDirection(Vector3f.ZERO);
}
}
}
package de.blight.game.control;
import com.jme3.anim.AnimComposer;
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;
import com.jme3.scene.Spatial;
import de.blight.game.animation.AnimationAction;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.animation.RetargetingSystem;
import de.blight.game.config.KeyBindings;
import java.nio.file.Path;
public class PlayerInputControl {
private static final float MOVE_SPEED = 0.07f;
private static final float SPRINT_MULT = 1.5f;
private static final float WALK_MULT = 0.5f;
private static final float ROTATE_SPEED = 10f;
private static final String[] ACTION_NAMES =
{"Forward", "Backward", "Left", "Right", "Jump", "Sprint", "Walk"};
private final InputManager inputManager;
private final Camera cam;
private CharacterControl physicsChar;
private Spatial visual;
private boolean forward, backward, left, right, sprint, walk;
private boolean paused = false;
private AnimationLibrary animLib;
private String animSetName;
private Path assetRoot;
private AnimationAction currentAnim;
private boolean animCtxLogged = false;
private AnimComposer animComposer;
/** Letzter gestarteter Clip (nur für tryPlay-Deduplizierung genutzt). */
private String runningClip;
/** Frames, für die JUMP erzwungen wird (überbrückt onGround()-Lag). */
private int jumpFrames = 0;
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (paused) return;
switch (name) {
case "Forward" -> forward = isPressed;
case "Backward" -> backward = isPressed;
case "Left" -> left = isPressed;
case "Right" -> right = isPressed;
case "Sprint" -> sprint = isPressed;
case "Walk" -> walk = isPressed;
case "Jump" -> { if (isPressed && physicsChar != null) { physicsChar.jump(); jumpFrames = 12; } }
}
};
public PlayerInputControl(InputManager inputManager, Camera cam, KeyBindings kb) {
this.inputManager = inputManager;
this.cam = cam;
registerMappings(kb);
}
public void setPhysicsCharacter(CharacterControl physicsChar) {
this.physicsChar = physicsChar;
}
public void setVisual(Spatial visual) {
this.visual = visual;
}
public void setAnimationContext(AnimationLibrary animLib, String animSetName, Path assetRoot) {
this.animLib = animLib;
this.animSetName = animSetName;
this.assetRoot = assetRoot;
this.currentAnim = null;
this.runningClip = null;
this.animComposer = (visual != null) ? RetargetingSystem.findAnimComposer(visual) : null;
System.out.println("[AnimCtx] AnimComposer gefunden: " + (animComposer != null));
if (animSetName != null) {
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.IDLE);
if (clip != null && tryPlay(clip)) {
currentAnim = AnimationAction.IDLE;
}
}
}
public void setPaused(boolean paused) {
this.paused = paused;
if (paused) {
forward = backward = left = right = sprint = walk = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
}
public void reloadBindings(KeyBindings kb) {
for (String a : ACTION_NAMES) inputManager.deleteMapping(a);
registerMappings(kb);
forward = backward = left = right = sprint = walk = false;
if (physicsChar != null) physicsChar.setWalkDirection(Vector3f.ZERO);
}
private void registerMappings(KeyBindings kb) {
inputManager.addMapping("Forward", new KeyTrigger(kb.forward));
inputManager.addMapping("Backward", new KeyTrigger(kb.backward));
inputManager.addMapping("Left", new KeyTrigger(kb.left));
inputManager.addMapping("Right", new KeyTrigger(kb.right));
inputManager.addMapping("Jump", new KeyTrigger(kb.jump));
inputManager.addMapping("Sprint", new KeyTrigger(kb.sprint));
inputManager.addMapping("Walk", new KeyTrigger(kb.walk));
inputManager.addListener(actionListener, ACTION_NAMES);
}
public void update(float tpf) {
if (physicsChar == null || paused) return;
Vector3f camDir = cam.getDirection().clone().setY(0).normalizeLocal();
Vector3f camLeft = cam.getLeft().clone().setY(0).normalizeLocal();
Vector3f moveDir = new Vector3f();
if (forward) moveDir.addLocal(camDir);
if (backward) moveDir.subtractLocal(camDir);
if (left) moveDir.addLocal(camLeft);
if (right) moveDir.subtractLocal(camLeft);
boolean moving = moveDir.lengthSquared() > 0.001f;
if (moving) {
moveDir.normalizeLocal();
float speed = walk ? MOVE_SPEED * WALK_MULT
: sprint ? MOVE_SPEED * SPRINT_MULT
: MOVE_SPEED;
physicsChar.setWalkDirection(moveDir.mult(speed));
if (visual != null) {
Quaternion targetRot = new Quaternion();
targetRot.lookAt(moveDir, Vector3f.UNIT_Y);
Quaternion current = visual.getLocalRotation().clone();
current.slerp(targetRot, ROTATE_SPEED * tpf);
visual.setLocalRotation(current);
}
} else {
physicsChar.setWalkDirection(Vector3f.ZERO);
}
// Animation
if (jumpFrames > 0) jumpFrames--;
AnimationAction target;
if (jumpFrames > 0 || !physicsChar.onGround()) {
target = moving ? AnimationAction.RUNNING_JUMP : AnimationAction.JUMP;
} else if (moving) {
target = walk ? AnimationAction.WALK
: sprint ? AnimationAction.SPRINT
: AnimationAction.RUN;
} else {
target = AnimationAction.IDLE;
}
if (target != currentAnim) {
playAction(target);
currentAnim = target;
}
}
private void playAction(AnimationAction action) {
if (animLib == null || visual == null || animSetName == null) {
if (!animCtxLogged) {
animCtxLogged = true;
System.out.println("[Anim] Kein Animations-Kontext:"
+ " animLib=" + animLib + " visual=" + visual + " setName=" + animSetName);
}
return;
}
String clip = AnimationLibrary.getClipForAction(assetRoot, animSetName, action);
System.out.println("[Anim] " + action + " → clip='" + clip + "' (set=" + animSetName + ")");
if (clip != null && tryPlay(clip)) return;
if (action != AnimationAction.DEFAULT) {
String defClip = AnimationLibrary.getClipForAction(assetRoot, animSetName, AnimationAction.DEFAULT);
if (defClip != null) tryPlay(defClip);
}
}
private boolean tryPlay(String clip) {
if (animComposer == null || !animLib.ensureApplied(clip, visual)) {
System.out.println("[Anim] tryPlay('" + clip + "') → ensureApplied FAILED");
return false;
}
com.jme3.anim.tween.action.Action action = animComposer.setCurrentAction(clip);
System.out.println("[Anim] setCurrentAction('" + clip + "') → " + (action != null ? "OK" : "FAILED"));
if (action != null) {
runningClip = clip;
return true;
}
return false;
}
}

View File

@@ -17,9 +17,10 @@ import com.jme3.scene.Spatial;
public class ThirdPersonCamera {
private static final float MOUSE_SENSITIVITY = 1.8f;
private static final float MIN_DISTANCE = 3f;
private static final float MAX_DISTANCE = 20f;
private static final float MIN_VERTICAL_ANGLE = -0.3f;
private static final float BASE_DISTANCE = 5f;
private static final float MIN_DISTANCE = BASE_DISTANCE - 3f;
private static final float MAX_DISTANCE = BASE_DISTANCE + 3f;
private static final float MIN_VERTICAL_ANGLE = 0.08f; // Kamera immer leicht über Schulter
private static final float MAX_VERTICAL_ANGLE = FastMath.HALF_PI - 0.1f;
private static final float TARGET_HEIGHT = 1.6f;
@@ -30,7 +31,7 @@ public class ThirdPersonCamera {
private float yaw = 0f;
private float pitch = 0.4f;
private float distance = 10f;
private float distance = BASE_DISTANCE;
private boolean paused = false;
public ThirdPersonCamera(Camera cam, InputManager inputManager) {
@@ -65,8 +66,8 @@ public class ThirdPersonCamera {
case "MouseY" -> pitch = FastMath.clamp(pitch - value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE);
case "MouseYNeg" -> pitch = FastMath.clamp(pitch + value * MOUSE_SENSITIVITY, MIN_VERTICAL_ANGLE, MAX_VERTICAL_ANGLE);
// Zoom
case "ZoomIn" -> distance = FastMath.clamp(distance - value * 20f, MIN_DISTANCE, MAX_DISTANCE);
case "ZoomOut" -> distance = FastMath.clamp(distance + value * 20f, MIN_DISTANCE, MAX_DISTANCE);
case "ZoomIn" -> distance = FastMath.clamp(distance - value * 0.5f, MIN_DISTANCE, MAX_DISTANCE);
case "ZoomOut" -> distance = FastMath.clamp(distance + value * 0.5f, MIN_DISTANCE, MAX_DISTANCE);
}
};
inputManager.addListener(analogListener,

View File

@@ -6,7 +6,6 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.collision.shapes.CapsuleCollisionShape;
import com.jme3.bullet.collision.shapes.HeightfieldCollisionShape;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
@@ -26,27 +25,47 @@ import com.jme3.util.SkyFactory;
import java.nio.ByteBuffer;
import de.blight.common.MapData;
import de.blight.common.MapIO;
import de.blight.common.model.CharacterIO;
import de.blight.common.model.GameCharacter;
import de.blight.common.model.MainCharacter;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
import de.blight.game.control.ThirdPersonCamera;
import com.jme3.post.FilterPostProcessor;
import com.jme3.post.filters.FogFilter;
import com.jme3.water.WaterFilter;
import de.blight.game.state.GrassState;
import de.blight.game.state.RiverState;
import de.blight.game.state.WaterBodyState;
import de.blight.game.state.WeatherState;
import de.blight.game.state.WorldObjectsState;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class WorldScene extends BaseAppState {
private SimpleApplication app;
private Node rootNode;
private AssetManager assetManager;
private BulletAppState bulletAppState;
private MapData loadedMapData;
private SimpleApplication app;
private Node rootNode;
private AssetManager assetManager;
private BulletAppState bulletAppState;
private MapData loadedMapData;
private FilterPostProcessor sharedFPP;
private final KeyBindings keyBindings;
private ThirdPersonCamera thirdPersonCam;
private PlayerInputControl playerInput;
private float spawnY = 5f; // wird in buildTerrain() gesetzt
private final KeyBindings keyBindings;
private ThirdPersonCamera thirdPersonCam;
private PlayerInputControl playerInput;
private AnimationLibrary animLib;
private Node character;
private Spatial characterVisual;
private CharacterControl physicsChar;
private boolean animContextReady = false;
private float spawnX = 0f;
private float spawnY = 5f;
private float spawnZ = 0f;
public WorldScene(KeyBindings keyBindings) {
this.keyBindings = keyBindings;
@@ -74,6 +93,9 @@ public class WorldScene extends BaseAppState {
bulletAppState = new BulletAppState();
app.getStateManager().attach(bulletAppState);
animLib = new AnimationLibrary();
app.getStateManager().attach(animLib);
}
@Override
@@ -81,27 +103,27 @@ public class WorldScene extends BaseAppState {
buildLighting();
TerrainQuad terrain = buildTerrain();
if (loadedMapData != null) {
rootNode.attachChild(buildGebirge(loadedMapData));
app.getStateManager().attach(new GrassState(loadedMapData, terrain));
}
app.getStateManager().attach(new GrassState(terrain));
app.getStateManager().attach(new WaterBodyState(terrain, sharedFPP));
app.getStateManager().attach(new RiverState());
app.getStateManager().attach(new WorldObjectsState());
Node character = buildCharacter();
character = loadOrBuildCharacter();
rootNode.attachChild(character);
// Bullet-Charakter: Kapsel 0.4 Radius, 1.0 Höhe, Y-Achse (1)
CapsuleCollisionShape capsule = new CapsuleCollisionShape(0.4f, 1.0f, 1);
CharacterControl physicsChar = new CharacterControl(capsule, 0.05f);
physicsChar = new CharacterControl(capsule, 0.05f);
physicsChar.setJumpSpeed(12f);
physicsChar.setFallSpeed(35f);
physicsChar.setGravity(35f);
physicsChar.setPhysicsLocation(new Vector3f(0, spawnY, 0));
character.addControl(physicsChar);
bulletAppState.getPhysicsSpace().add(physicsChar);
physicsChar.setPhysicsLocation(new Vector3f(spawnX, spawnY, spawnZ));
playerInput = new PlayerInputControl(app.getInputManager(), app.getCamera(), keyBindings);
playerInput.setPhysicsCharacter(physicsChar);
playerInput.setVisual(character);
playerInput.setVisual(characterVisual != null ? characterVisual : character);
thirdPersonCam = new ThirdPersonCamera(app.getCamera(), app.getInputManager());
thirdPersonCam.setTarget(character);
@@ -110,15 +132,110 @@ public class WorldScene extends BaseAppState {
app.getInputManager().setCursorVisible(false);
}
private float livePosTimer = 0f;
@Override
public void update(float tpf) {
if (!animContextReady && animLib != null && animLib.isInitialized()) {
setupAnimationContext();
animContextReady = true;
}
playerInput.update(tpf);
thirdPersonCam.update(tpf);
livePosTimer += tpf;
if (livePosTimer >= 0.2f && physicsChar != null) {
livePosTimer = 0f;
com.jme3.math.Vector3f pos = physicsChar.getPhysicsLocation();
de.blight.game.LiveBroadcast.writePosition(pos.x, pos.y, pos.z);
}
}
@Override protected void cleanup(Application app) {}
@Override protected void cleanup(Application app) {
de.blight.game.LiveBroadcast.clear();
}
@Override protected void onDisable() {}
private void setupAnimationContext() {
animLib.applyAllTo(characterVisual != null ? characterVisual : character);
MainCharacter mc = findMainCharacter();
String setName = (mc != null) ? mc.getAnimSetPath() : null;
System.out.println("[AnimCtx] MainCharacter: " + (mc != null ? mc.getCharacterId() : "null")
+ " animSetPath: " + setName
+ " clipCount: " + animLib.getClipKeys().size()
+ " clips: " + animLib.getClipKeys());
// AnimSet-ActionMap ausgeben
if (setName != null) {
java.nio.file.Path setDir = AnimationLibrary.findAssetRoot().resolve("animations").resolve("sets");
try {
de.blight.game.animation.AnimSet set = de.blight.game.animation.AnimSet.load(setDir, setName);
System.out.println("[AnimCtx] AnimSet '" + setName + "' actionMap: " + set.getActionMap());
} catch (Exception e) {
System.out.println("[AnimCtx] AnimSet '" + setName + "' nicht ladbar: " + e.getMessage());
}
}
playerInput.setAnimationContext(animLib, setName, AnimationLibrary.findAssetRoot());
}
// CharacterControl setzt den Spatial auf den Kapsel-Mittelpunkt: radius=0.4, halfCyl=0.5 → 0.9m über dem Boden.
// Das Modell hat den Ursprung an den Füßen → wir brauchen einen -0.9m-Versatz im wrapper-Node.
private static final float CAPSULE_VISUAL_OFFSET_Y = -(0.5f + 0.4f); // -(halfCylHeight + radius)
/** Lädt das Hauptcharakter-Modell, falls im character/-Verzeichnis definiert; sonst Platzhalter. */
private Node loadOrBuildCharacter() {
MainCharacter mc = findMainCharacter();
if (mc != null && mc.getModelPath() != null) {
try {
Spatial loaded = assetManager.loadModel(mc.getModelPath());
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
// Auf 1.8 m skalieren Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
// bei Skinned-Meshes, die vor der ersten SkinningControl-Runde falsche Bounds liefern)
float[] yRange = vertexYRange(loaded);
float modelHeight = yRange[1] - yRange[0];
System.out.println("[WorldScene] Vertex-Y-Range: min=" + yRange[0] + " max=" + yRange[1]
+ " height=" + modelHeight);
float offsetY;
if (modelHeight > 0.1f) {
float scale = 1.8f / modelHeight;
loaded.setLocalScale(scale);
// Füße des Modells (scale * minY in loaded-Local) auf Kapsel-Unterkante legen
offsetY = -(0.9f + scale * yRange[0]);
System.out.println("[WorldScene] Charakter skaliert: " + scale
+ "x offsetY=" + offsetY);
} else {
offsetY = CAPSULE_VISUAL_OFFSET_Y;
System.out.println("[WorldScene] Kein Scale möglich (height=" + modelHeight + "), Fallback-Offset");
}
// rotationNode als Drehpunkt (CharacterControl überschreibt wrapper-Rotation jeden Frame)
Node rotNode = new Node("charRot");
loaded.setLocalTranslation(0, offsetY, 0);
rotNode.attachChild(loaded);
Node wrapper = new Node("character");
wrapper.attachChild(rotNode);
characterVisual = rotNode;
System.out.println("[WorldScene] Hauptcharakter geladen: " + mc.getModelPath());
return wrapper;
} catch (Exception e) {
System.err.println("[WorldScene] Modell nicht ladbar (" + mc.getModelPath()
+ "): " + e.getMessage() + " Fallback auf Platzhalter");
}
}
characterVisual = null;
return buildCharacter();
}
private MainCharacter findMainCharacter() {
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
for (GameCharacter c : CharacterIO.loadAll(charDir)) {
if (c instanceof MainCharacter mc) return mc;
}
return null;
}
// -----------------------------------------------------------------------
// Beleuchtung
// -----------------------------------------------------------------------
@@ -145,6 +262,42 @@ public class WorldScene extends BaseAppState {
SkyFactory.EnvMapType.CubeMap);
rootNode.attachChild(sky);
} catch (Exception ignored) {}
setupPostProcessing(sun.getDirection());
}
private void setupPostProcessing(Vector3f sunDir) {
sharedFPP = new FilterPostProcessor(assetManager);
FilterPostProcessor fpp = sharedFPP;
// Globales Wasser bei Y=0 (bedeckt die gesamte Karte unterhalb der Wasserlinie)
try {
WaterFilter waterFilter = new WaterFilter(rootNode, sunDir);
waterFilter.setWaterHeight(0f);
waterFilter.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
waterFilter.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
waterFilter.setWaterTransparency(0.15f);
waterFilter.setMaxAmplitude(0.3f);
waterFilter.setWaveScale(0.008f);
waterFilter.setSpeed(0.5f);
fpp.addFilter(waterFilter);
WeatherState weather = new WeatherState();
weather.setWaterFilter(waterFilter);
FogFilter fogFilter = new FogFilter();
fogFilter.setFogColor(new ColorRGBA(0.75f, 0.80f, 0.88f, 1f));
fogFilter.setFogDensity(0.0f);
fogFilter.setFogDistance(600f);
fpp.addFilter(fogFilter);
weather.setFogFilter(fogFilter);
app.getStateManager().attach(weather);
} catch (Exception e) {
System.err.println("[WorldScene] Post-Processing nicht verfügbar: " + e.getMessage());
}
app.getViewPort().addProcessor(fpp);
}
// -----------------------------------------------------------------------
@@ -188,13 +341,13 @@ public class WorldScene extends BaseAppState {
}
}
// Spawn über dem höchsten Punkt Basis-Terrain UND Gebirge-Oberkante
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
for (float h : heights) { if (h < minH) minH = h; if (h > maxH) maxH = h; }
float midH = (minH + maxH) * 0.5f;
float maxUpperTop = maxH;
for (float h : map.upperTop) { if (h > maxUpperTop) maxUpperTop = h; }
spawnY = maxUpperTop + 20f;
// Temp-Spawn aus Editor-Property überschreibt gespeicherten Karten-Spawn
String propX = System.getProperty("blight.temp.spawn.x");
String propZ = System.getProperty("blight.temp.spawn.z");
spawnX = propX != null ? Float.parseFloat(propX) : map.spawnX;
spawnZ = propZ != null ? Float.parseFloat(propZ) : map.spawnZ;
System.out.println("[WorldScene] SpawnXZ Quelle: " + (propX != null ? "Editor-Property" : "Karte")
+ " → X=" + spawnX + " Z=" + spawnZ);
TerrainQuad terrain = new TerrainQuad("terrain", 65, GAME_VERTS, heights);
terrain.setLocalScale(8f, 1f, 8f);
@@ -202,17 +355,22 @@ public class WorldScene extends BaseAppState {
applyTerrainMaterial(terrain, map);
rootNode.attachChild(terrain);
// jBullet subtrahiert midH intern in getVertex() → Physics-Body bei midH
// damit Kollisionsfläche und sichtbares Terrain übereinstimmen.
HeightfieldCollisionShape hcs = new HeightfieldCollisionShape(
heights, terrain.getLocalScale());
RigidBodyControl terrainPhysics = new RigidBodyControl(hcs, 0f);
// Terrain-Höhe am Spawnpunkt: lokale Koordinaten = weltXZ / scaleXZ
float terrainH = terrain.getHeight(new Vector2f(spawnX / 8f, spawnZ / 8f));
if (Float.isNaN(terrainH)) {
float maxH = -Float.MAX_VALUE;
for (float h : heights) { if (h > maxH) maxH = h; }
terrainH = maxH;
}
spawnY = terrainH + 10f;
RigidBodyControl terrainPhysics = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(terrain), 0f);
terrain.addControl(terrainPhysics);
bulletAppState.getPhysicsSpace().add(terrainPhysics);
terrainPhysics.setPhysicsLocation(new Vector3f(0f, midH, 0f));
System.out.println("[WorldScene] Karte geladen, Spawn Y=" + spawnY
+ " maxGebirgeH=" + maxUpperTop);
System.out.println("[WorldScene] Karte geladen, SpawnXYZ=("
+ spawnX + ", " + spawnY + ", " + spawnZ + ")");
return terrain;
}
@@ -389,25 +547,40 @@ public class WorldScene extends BaseAppState {
return a;
}
// Default-Texturen identisch mit Editor (TerrainEditorState.DEFAULT_TERRAIN_TEXTURES)
private static final String[] DEF_TEX = {
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/splat/dirt.jpg",
""
};
private static final ColorRGBA[] DEF_COLOR = {
new ColorRGBA(0.28f, 0.58f, 0.18f, 1f),
new ColorRGBA(0.45f, 0.32f, 0.25f, 1f),
new ColorRGBA(0.55f, 0.45f, 0.30f, 1f),
new ColorRGBA(0.80f, 0.72f, 0.50f, 1f),
};
private void applyTerrainMaterial(TerrainQuad terrain, MapData map) {
if (map != null) {
try {
Material mat = new Material(assetManager, "Common/MatDefs/Terrain/Terrain.j3md");
Material mat = new Material(assetManager, "Common/MatDefs/Terrain/TerrainLighting.j3md");
mat.setBoolean("useTriPlanarMapping", false);
mat.setFloat("Shininess", 0f);
Texture tex1 = loadTexOrFallback("Textures/Terrain/splat/grass.jpg",
new ColorRGBA(0.28f, 0.58f, 0.18f, 1f));
Texture tex2 = loadTexOrFallback("Textures/Terrain/splat/road.jpg",
new ColorRGBA(0.55f, 0.50f, 0.40f, 1f));
Texture tex3 = loadTexOrFallback("Textures/Terrain/splat/Gravel.jpg",
new ColorRGBA(0.45f, 0.35f, 0.25f, 1f));
tex1.setWrap(Texture.WrapMode.Repeat);
tex2.setWrap(Texture.WrapMode.Repeat);
tex3.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("Tex1", tex1); mat.setFloat("Tex1Scale", 512f);
mat.setTexture("Tex2", tex2); mat.setFloat("Tex2Scale", 512f);
mat.setTexture("Tex3", tex3); mat.setFloat("Tex3Scale", 512f);
String[] mapTex = map.terrainTextures;
String[] matParams = {"DiffuseMap","DiffuseMap_1","DiffuseMap_2","DiffuseMap_3"};
String[] scaleP = {"DiffuseMap_0_scale","DiffuseMap_1_scale","DiffuseMap_2_scale","DiffuseMap_3_scale"};
for (int i = 0; i < 4; i++) {
String path = (mapTex[i] != null && !mapTex[i].isEmpty()) ? mapTex[i] : DEF_TEX[i];
if (path == null || path.isEmpty()) continue;
Texture tex = loadTexOrFallback(path, DEF_COLOR[i]);
tex.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(matParams[i], tex);
mat.setFloat(scaleP[i], 512f);
}
// Ältere Maps haben splatR=0 → Gras (Tex1) wäre unsichtbar; auf 255 setzen.
// Ältere Maps haben splatR=0 → Gras (Slot 0) wäre unsichtbar; auf 255 setzen.
byte[] splatR = map.splatR;
boolean rAllZero = true;
for (byte b : splatR) { if (b != 0) { rAllZero = false; break; } }
@@ -422,14 +595,14 @@ public class WorldScene extends BaseAppState {
splatBuf.put(splatR[i]);
splatBuf.put(map.splatG[i]);
splatBuf.put(map.splatB[i]);
splatBuf.put((byte) 0);
splatBuf.put(map.splatA[i]);
}
splatBuf.flip();
Texture2D splatTex = new Texture2D(new Image(Image.Format.RGBA8, sz, sz, splatBuf));
splatTex.setWrap(Texture.WrapMode.EdgeClamp);
splatTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
splatTex.setMagFilter(Texture.MagFilter.Bilinear);
mat.setTexture("Alpha", splatTex);
mat.setTexture("AlphaMap", splatTex);
terrain.setMaterial(mat);
return;
@@ -595,6 +768,34 @@ public class WorldScene extends BaseAppState {
return character;
}
/** Gibt {minY, maxY} aller Vertex-Positionen im Teilbaum zurück. */
private static float[] vertexYRange(Spatial root) {
float[] r = {Float.MAX_VALUE, -Float.MAX_VALUE};
collectYRange(root, r);
if (r[0] == Float.MAX_VALUE) return new float[]{0f, 0f};
return r;
}
private static void collectYRange(Spatial s, float[] r) {
if (s instanceof Geometry g) {
var buf = g.getMesh().getBuffer(VertexBuffer.Type.Position);
if (buf == null) return;
FloatBuffer fb = (FloatBuffer) buf.getData();
int saved = fb.position();
fb.rewind();
while (fb.remaining() >= 3) {
fb.get(); // x überspringen
float y = fb.get(); // y
fb.get(); // z überspringen
if (y < r[0]) r[0] = y;
if (y > r[1]) r[1] = y;
}
fb.position(saved);
} else if (s instanceof Node n) {
for (Spatial c : n.getChildren()) collectYRange(c, r);
}
}
private Geometry buildLimb(Material mat, float radius, float height) {
Geometry limb = new Geometry("limb", new Cylinder(6, 12, radius, height, true));
limb.setMaterial(mat);
@@ -602,4 +803,5 @@ public class WorldScene extends BaseAppState {
limb.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
return limb;
}
}

View File

@@ -18,48 +18,44 @@ import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.MapData;
import de.blight.common.GrassTuft;
import de.blight.common.GrassTuftIO;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.*;
/**
* Rendert Gras im Spiel aus der in MapData gespeicherten Dichte-Map.
*
* Chunks werden gestreckt über mehrere Frames aufgebaut (INIT_PER_FRAME),
* um Startlags zu vermeiden. GrassVisibilityControl cullt entfernte Chunks.
* Rendert individuell platzierte Gras-Büschel aus blight_grass.blg.
* Chunks werden lazy über mehrere Frames aufgebaut (INIT_PER_FRAME).
* GrassVisibilityControl cullt entfernte Chunks.
*/
public class GrassState extends BaseAppState {
// ── Konstanten (identisch mit PlacedObjectState im Editor) ────────────────
private static final int TERRAIN_HALF = 2048;
private static final float WORLD_SIZE = 4096f;
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE;
private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1);
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE;
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS;
private static final int MAX_BLADES_PER_PX = 3;
private static final float BLADE_WIDTH = 0.18f;
private static final float DEFAULT_HEIGHT = 1.5f;
private static final float FAR_DIST = 150f; // WE (game terrain is 1:1 WE)
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
private static final int INIT_PER_FRAME = 4;
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
private static final int BLADES_PER_TUFT = 4;
private static final float TUFT_SPREAD = 0.5f;
private static final float BLADE_WIDTH = 0.18f;
private static final float FAR_DIST = 150f;
private static final float FAR_DIST_SQ = FAR_DIST * FAR_DIST;
private static final int INIT_PER_FRAME = 4;
// ── Abhängigkeiten ────────────────────────────────────────────────────────
private final MapData mapData;
private final TerrainQuad terrain;
// ── Runtime-Zustand ───────────────────────────────────────────────────────
private Camera cam;
private Node grassNode;
private Material grassMat;
private int nextChunk = 0;
private Camera cam;
private Node grassNode;
public GrassState(MapData mapData, TerrainQuad terrain) {
this.mapData = mapData;
this.terrain = terrain;
@SuppressWarnings("unchecked")
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private int nextChunk = 0;
public GrassState(TerrainQuad terrain) {
this.terrain = terrain;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -69,7 +65,25 @@ public class GrassState extends BaseAppState {
this.cam = app.getCamera();
grassNode = new Node("gameGrass");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
grassMat = buildGrassMaterial(app.getAssetManager());
for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
try {
GrassTuftIO.GrassData data = GrassTuftIO.load();
if (data != null) {
initSlotMaterials(app.getAssetManager(), data.slotPaths());
for (GrassTuft t : data.tufts()) {
int ci = chunkIndex(t.x(), t.z());
if (ci >= 0) chunkTufts[ci].add(t);
}
}
} catch (IOException e) {
System.err.println("[GrassState] Gras nicht ladbar: " + e.getMessage());
}
if (slotMaterials.isEmpty()) {
slotMaterials.put(0, buildGrassMat(app.getAssetManager(), ""));
}
}
@Override
@@ -84,20 +98,40 @@ public class GrassState extends BaseAppState {
public void update(float tpf) {
int built = 0;
while (nextChunk < CHUNK_COUNT && built < INIT_PER_FRAME) {
buildChunk(nextChunk++);
if (!chunkTufts[nextChunk].isEmpty()) buildChunk(nextChunk);
nextChunk++;
built++;
}
}
// ── Material ──────────────────────────────────────────────────────────────
private Material buildGrassMaterial(AssetManager assets) {
private void initSlotMaterials(AssetManager assets, String[] slotPaths) {
for (int i = 0; i < 8; i++) {
String p = (slotPaths != null && i < slotPaths.length) ? slotPaths[i] : "";
if (i == 0 || (p != null && !p.isEmpty())) {
slotMaterials.put(i, buildGrassMat(assets, p));
}
}
}
private Material buildGrassMat(AssetManager assets, String texPath) {
try {
Material mat = new Material(assets, "MatDefs/Grass.j3md");
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
mat.setFloat("WindSpeed", 0.5f);
mat.setFloat("WindStrength", 0.14f);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
if (texPath != null && !texPath.isEmpty()) {
try {
mat.setTexture("ColorMap", assets.loadTexture(texPath));
mat.setColor("Color", ColorRGBA.White);
} catch (Exception te) {
System.err.println("[GrassState] Gras-Textur nicht ladbar '" + texPath + "': " + te.getMessage());
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
}
} else {
mat.setColor("Color", new ColorRGBA(0.28f, 0.72f, 0.18f, 1f));
}
return mat;
} catch (Exception e) {
System.err.println("[GrassState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage());
@@ -108,6 +142,20 @@ public class GrassState extends BaseAppState {
}
}
private Material getSlotMaterial(int slot) {
Material m = slotMaterials.get(slot);
return m != null ? m : slotMaterials.get(0);
}
// ── Chunk-Index ───────────────────────────────────────────────────────────
private static int chunkIndex(float x, float z) {
int cx = (int) ((x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) ((z + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
// ── Chunk aufbauen ────────────────────────────────────────────────────────
private void buildChunk(int idx) {
@@ -116,47 +164,42 @@ public class GrassState extends BaseAppState {
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
int pxMin = Math.max(0, (int)((wXMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
int pzMin = Math.max(0, (int)((wZMin + TERRAIN_HALF) / SPLAT_WE_PER_PX));
int pxMax = Math.min(SPLAT_SIZE - 1, (int)((wXMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX));
int pzMax = Math.min(SPLAT_SIZE - 1, (int)((wZMin + CHUNK_SIZE + TERRAIN_HALF) / SPLAT_WE_PER_PX));
List<GrassTuft> tufts = chunkTufts[idx];
if (tufts.isEmpty()) return;
List<float[]> blades = new ArrayList<>();
Vector3f scale = terrain.getWorldScale();
Vector3f trans = terrain.getWorldTranslation();
for (int pz = pzMin; pz <= pzMax; pz++) {
for (int px = pxMin; px <= pxMax; px++) {
int d = mapData.grassDensity[pz * SPLAT_SIZE + px] & 0xFF;
if (d == 0) continue;
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PX));
Random rng = new Random((long) px * 100003L + pz);
float pixWorldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF;
float pixWorldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF;
for (int b = 0; b < count; b++) {
float bx = pixWorldX + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
float bz = pixWorldZ + (rng.nextFloat() - 0.5f) * SPLAT_WE_PER_PX;
// Welt→lokal→Höhe→Welt
float localX = (bx - trans.x) / scale.x;
float localZ = (bz - trans.z) / scale.z;
float th = terrain.getHeight(new Vector2f(localX, localZ));
if (Float.isNaN(th)) continue;
float worldY = trans.y + th * scale.y;
float h = DEFAULT_HEIGHT * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, worldY, bz, h});
}
Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
for (GrassTuft t : tufts) {
long seed = (long) Float.floatToRawIntBits(t.x()) * 0x9E3779B9L
^ (long) Float.floatToRawIntBits(t.z()) * 0x6C62272EL;
Random rng = new Random(seed);
List<float[]> blades = bySlot.computeIfAbsent(t.slot(), k -> new ArrayList<>());
for (int b = 0; b < BLADES_PER_TUFT; b++) {
float bx = t.x() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float bz = t.z() + (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue;
float h = t.height() * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, h});
}
}
if (blades.isEmpty()) return;
if (bySlot.isEmpty()) return;
Mesh mesh = buildGrassMesh(blades);
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Geometry geo = new Geometry("grass_" + idx, mesh);
geo.setMaterial(grassMat);
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(geo);
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Node node = new Node("grass_" + idx);
for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
if (entry.getValue().isEmpty()) continue;
Material mat = getSlotMaterial(entry.getKey());
if (mat == null) continue;
Geometry geo = new Geometry("grass_" + idx + "_s" + entry.getKey(),
buildGrassMesh(entry.getValue()));
geo.setMaterial(mat);
node.attachChild(geo);
}
if (node.getChildren().isEmpty()) return;
node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(node);
}
// ── Mesh: Kreuz-Quad mit UV ───────────────────────────────────────────────

View File

@@ -0,0 +1,314 @@
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.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.common.RiverIO;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.List;
import java.util.Random;
/**
* Rendert alle gespeicherten Flüsse als Ribbon-Meshes mit Dual-Layer Normal-Map,
* Tiefengradient, Uferschaum (Worley-Noise) und g_Time-basierter UV-Animation.
*/
public class RiverState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(RiverState.class);
private static final float UV_SCALE = 6.0f;
private Node riverNode;
private AssetManager assets;
private Texture2D foamTexture;
private final List<Material> animatedMaterials = new java.util.ArrayList<>();
private float time = 0f;
public RiverState() {}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
log.info("RiverState: initialisiere");
this.assets = app.getAssetManager();
this.foamTexture = generateFoamTexture();
riverNode = new Node("rivers");
((SimpleApplication) app).getRootNode().attachChild(riverNode);
List<List<RiverPoint>> rivers;
try {
rivers = RiverIO.load();
} catch (Exception e) {
log.error("Flüsse nicht ladbar", e);
return;
}
log.info("RiverState: {} Fluss/Flüsse in Datei", rivers.size());
if (rivers.isEmpty()) return;
int built = 0;
for (List<RiverPoint> river : rivers) {
if (river == null || river.size() < 2) continue;
try {
buildRiver(river);
built++;
} catch (Exception e) {
log.error("Fehler beim Fluss-Aufbau", e);
}
}
log.info("{}/{} Fluss/Flüsse geladen.", built, rivers.size());
}
@Override
protected void cleanup(Application app) {
riverNode.detachAllChildren();
((SimpleApplication) app).getRootNode().detachChild(riverNode);
foamTexture = null;
}
@Override
protected void onEnable() { riverNode.setCullHint(Spatial.CullHint.Inherit); }
@Override
protected void onDisable() { riverNode.setCullHint(Spatial.CullHint.Always); }
@Override
public void update(float tpf) {
if (animatedMaterials.isEmpty()) return;
time += tpf;
for (Material m : animatedMaterials) {
m.setFloat("Time", time);
}
}
// ── Fluss bauen ───────────────────────────────────────────────────────────
private void buildRiver(List<RiverPoint> pts) {
int n = pts.size();
int i = 0;
while (i < n - 1) {
boolean wf = pts.get(i).isWaterfall();
int j = i + 1;
while (j < n - 1 && pts.get(j).isWaterfall() == wf) j++;
List<RiverPoint> run = RiverSpline.subdivide(pts.subList(i, j + 1));
if (run.size() >= 2) {
buildRibbonSection(run, wf);
if (wf) buildWaterfallParticles(run.get(run.size() - 1));
}
i = j;
}
}
private void buildRibbonSection(List<RiverPoint> pts, boolean isWaterfall) {
Mesh mesh = buildRibbonMesh(pts);
if (mesh == null) return;
Material mat = buildMaterial(isWaterfall);
Geometry geo = new Geometry("river_ribbon", mesh);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
riverNode.attachChild(geo);
if (mat.getMaterialDef().getName().equals("Flowing Water")) {
animatedMaterials.add(mat);
}
}
private Mesh buildRibbonMesh(List<RiverPoint> pts) {
int n = pts.size();
if (n < 2) return null;
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3);
FloatBuffer norm = BufferUtils.createFloatBuffer(n * 2 * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(n * 2 * 2);
IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3);
// Kumulierte Bogenlänge für V-Koordinate
float[] arcLen = new float[n];
for (int i = 1; i < n; i++) {
RiverPoint a = pts.get(i - 1), b = pts.get(i);
float dx = b.x()-a.x(), dz = b.z()-a.z(), dy = b.y()-a.y();
arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz);
}
for (int i = 0; i < n; i++) {
RiverPoint pt = pts.get(i);
Vector3f tangent;
if (i == 0) {
RiverPoint next = pts.get(1);
tangent = new Vector3f(next.x()-pt.x(), next.y()-pt.y(), next.z()-pt.z());
} else if (i == n - 1) {
RiverPoint prev = pts.get(n - 2);
tangent = new Vector3f(pt.x()-prev.x(), pt.y()-prev.y(), pt.z()-prev.z());
} else {
RiverPoint prev = pts.get(i - 1), next = pts.get(i + 1);
tangent = new Vector3f(next.x()-prev.x(), next.y()-prev.y(), next.z()-prev.z());
}
if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f);
tangent.normalizeLocal();
Vector3f right = tangent.cross(Vector3f.UNIT_Y).normalizeLocal();
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y(), pz = pt.z();
float vCoord = arcLen[i] / UV_SCALE;
// Linker Rand (U=0), rechter Rand (U=1)
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(0f).put(vCoord);
pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(1f).put(vCoord);
}
for (int i = 0; i < n - 1; i++) {
int v0 = 2*i, v1 = 2*i+1, v2 = 2*i+2, v3 = 2*i+3;
idx.put(v0).put(v1).put(v3);
idx.put(v0).put(v3).put(v2);
}
pos.rewind(); norm.rewind(); uv.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Normal, 3, norm);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
private Material buildMaterial(boolean isWaterfall) {
ColorRGBA tint = isWaterfall
? new ColorRGBA(0.65f, 0.82f, 0.95f, 0.80f)
: new ColorRGBA(0.10f, 0.30f, 0.62f, 0.85f);
Material mat;
try {
mat = new Material(assets, "MatDefs/FlowingWater.j3md");
try {
Texture nm = assets.loadTexture(
"Common/MatDefs/Water/Textures/water_normalmap.png");
nm.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("NormalMap", nm);
} catch (Exception e) {
log.warn("Normal-Map nicht ladbar, wird ohne Wellenstruktur gerendert");
}
if (foamTexture != null) {
mat.setTexture("FoamMap", foamTexture);
}
mat.setColor("Tint", tint);
mat.setFloat("UVScale", UV_SCALE);
mat.setFloat("FlowSpeed", isWaterfall ? RiverPoint.WATERFALL_SPEED
: RiverPoint.RIVER_SPEED);
mat.setFloat("FoamAmount", isWaterfall ? 1.0f : 0.0f);
} catch (Exception e) {
log.warn("FlowingWater-Material nicht ladbar, Fallback auf Unshaded", e);
mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", tint);
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
mat.getAdditionalRenderState().setDepthWrite(false);
return mat;
}
// ── Worley-Noise Schaum-Textur ────────────────────────────────────────────
private Texture2D generateFoamTexture() {
int size = 256;
int nPts = 40;
Random rng = new Random(12345L);
float[] sx = new float[nPts];
float[] sy = new float[nPts];
for (int i = 0; i < nPts; i++) {
sx[i] = rng.nextFloat() * size;
sy[i] = rng.nextFloat() * size;
}
float cellR = size / (float) Math.sqrt(nPts) * 0.55f;
ByteBuffer buf = BufferUtils.createByteBuffer(size * size * 4);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
float minD = Float.MAX_VALUE;
for (int i = 0; i < nPts; i++) {
float dx = Math.abs(x - sx[i]);
float dy = Math.abs(y - sy[i]);
if (dx > size * 0.5f) dx = size - dx; // Kachelung
if (dy > size * 0.5f) dy = size - dy;
float d = (float) Math.sqrt(dx*dx + dy*dy);
if (d < minD) minD = d;
}
float v = Math.max(0f, 1f - minD / cellR);
v = v * v; // Schaum-Blasen schärfer abgrenzen
byte bv = (byte) Math.round(v * 255);
buf.put(bv).put(bv).put(bv).put((byte) 255);
}
}
buf.flip();
Texture2D tex = new Texture2D(new Image(Image.Format.RGBA8, size, size, buf));
tex.setWrap(Texture.WrapMode.Repeat);
tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
tex.setMagFilter(Texture.MagFilter.Bilinear);
return tex;
}
// ── Partikel-Emitter ──────────────────────────────────────────────────────
private void buildWaterfallParticles(RiverPoint base) {
ParticleEmitter emitter = new ParticleEmitter(
"waterfall_particles", ParticleMesh.Type.Triangle, 30);
Material pMat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
try {
pMat.setTexture("Texture", assets.loadTexture("Effects/Smoke/Smoke.png"));
} catch (Exception e) {
log.warn("Partikel-Textur nicht ladbar", e);
}
emitter.setMaterial(pMat);
emitter.setImagesX(1);
emitter.setImagesY(1);
emitter.setStartColor(new ColorRGBA(1f, 1f, 1f, 0.5f));
emitter.setEndColor(new ColorRGBA(1f, 1f, 1f, 0f));
emitter.setStartSize(1.2f);
emitter.setEndSize(2.5f);
emitter.setGravity(0f, -0.5f, 0f);
emitter.setLowLife(0.8f);
emitter.setHighLife(1.2f);
emitter.setInitialVelocity(new Vector3f(0f, 3f, 0f));
emitter.setVelocityVariation(0.6f);
emitter.setParticlesPerSec(15);
emitter.setLocalTranslation(base.x(), base.y(), base.z());
riverNode.attachChild(emitter);
}
}

View File

@@ -0,0 +1,277 @@
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.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.post.FilterPostProcessor;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import com.jme3.water.WaterFilter;
import de.blight.common.PlacedWater;
import de.blight.common.WaterBodyIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.*;
/**
* Rendert im Editor platzierte Wasserflächen per WaterFilter (visuelle Qualität).
* Ein unsichtbares Komplementär-Mesh (nur Tiefenpuffer) verhindert, dass der
* WaterFilter außerhalb der Flood-Fill-Form Wasser rendert.
*/
public class WaterBodyState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WaterBodyState.class);
private static final int WATER_GRID = 2049;
private static final int STEP = 2;
private static final int WORLD_HALF = 2048;
private static final int MAX_CELLS = 500_000;
private static final float MASK_MARGIN = 20f; // Puffer um den Becken-Radius
private final TerrainQuad terrain;
private final FilterPostProcessor fpp;
private Node waterNode;
private final List<WaterFilter> waterFilters = new ArrayList<>();
public WaterBodyState(TerrainQuad terrain, FilterPostProcessor fpp) {
this.terrain = terrain;
this.fpp = fpp;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
SimpleApplication sa = (SimpleApplication) app;
waterNode = new Node("waterBodies");
sa.getRootNode().attachChild(waterNode);
List<PlacedWater> bodies;
try {
bodies = WaterBodyIO.load();
} catch (Exception e) {
log.error("Wasserflächen nicht ladbar", e);
return;
}
if (bodies.isEmpty()) return;
Vector3f sunDir = new Vector3f(-0.5f, -1f, -0.5f).normalizeLocal();
for (PlacedWater body : bodies) {
try {
Set<Integer> cells = floodFill(body.seedX(), body.seedZ(), body.waterHeight());
if (cells == null || cells.isEmpty()) {
log.warn("Becken nicht rekonstruierbar: {}/{}", body.seedX(), body.seedZ());
continue;
}
float[] cr = computeCentroidAndRadius(cells);
float cx = cr[0], cz = cr[1];
float filterRadius = cr[2] + MASK_MARGIN;
WaterFilter wf = new WaterFilter(sa.getRootNode(), sunDir);
wf.setWaterHeight(body.waterHeight());
wf.setCenter(new Vector3f(cx, body.waterHeight(), cz));
wf.setRadius(filterRadius);
wf.setShapeType(WaterFilter.AreaShape.Circular);
wf.setWaterColor(new ColorRGBA(0.05f, 0.25f, 0.55f, 1f));
wf.setDeepWaterColor(new ColorRGBA(0.02f, 0.12f, 0.30f, 1f));
wf.setWaterTransparency(0.15f);
wf.setMaxAmplitude(0.3f);
wf.setWaveScale(0.008f);
wf.setSpeed(0.5f);
fpp.addFilter(wf);
waterFilters.add(wf);
// Tiefenpuffer-Maske: alle Zellen im Filterkreis außerhalb des Beckens
Geometry mask = buildDepthMask(cells, cx, cz, filterRadius, body.waterHeight(),
app.getAssetManager());
if (mask != null) waterNode.attachChild(mask);
log.info("Becken: cells={} h={} center=({},{}) r={}",
cells.size(), body.waterHeight(), cx, cz, filterRadius);
} catch (Exception e) {
log.error("Fehler bei Becken {}/{}", body.seedX(), body.seedZ(), e);
}
}
log.info("{}/{} Wasserfläche(n) geladen.", waterFilters.size(), bodies.size());
}
@Override
protected void cleanup(Application app) {
for (WaterFilter wf : waterFilters) fpp.removeFilter(wf);
waterFilters.clear();
if (waterNode != null)
((SimpleApplication) app).getRootNode().detachChild(waterNode);
}
@Override
protected void onEnable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
if (waterNode != null) waterNode.setCullHint(Spatial.CullHint.Always);
}
// ── Flood-Fill ────────────────────────────────────────────────────────────
private Set<Integer> floodFill(float seedWorldX, float seedWorldZ, float waterHeight) {
int seedPX = Math.round((seedWorldX + WORLD_HALF) / (float) STEP);
int seedPZ = Math.round((seedWorldZ + WORLD_HALF) / (float) STEP);
seedPX = Math.max(0, Math.min(WATER_GRID - 1, seedPX));
seedPZ = Math.max(0, Math.min(WATER_GRID - 1, seedPZ));
Map<Integer, Float> heightCache = new HashMap<>();
float seedH = sampleHeight(seedPX, seedPZ, heightCache);
if (seedH > waterHeight + 0.5f) {
log.warn("Seed-Höhe {} über waterHeight {} Becken nicht rekonstruierbar", seedH, waterHeight);
return null;
}
Set<Integer> visited = new HashSet<>();
Deque<int[]> queue = new ArrayDeque<>();
visited.add(seedPZ * WATER_GRID + seedPX);
queue.add(new int[]{seedPX, seedPZ});
final int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
while (!queue.isEmpty()) {
int[] c = queue.poll();
int px = c[0], pz = c[1];
if (px == 0 || px == WATER_GRID - 1 || pz == 0 || pz == WATER_GRID - 1)
return null;
for (int[] d : dirs) {
int nx = px + d[0], nz = pz + d[1];
int nIdx = nz * WATER_GRID + nx;
if (visited.contains(nIdx)) continue;
if (sampleHeight(nx, nz, heightCache) <= waterHeight) {
visited.add(nIdx);
if (visited.size() > MAX_CELLS) return null;
queue.add(new int[]{nx, nz});
}
}
}
return visited.isEmpty() ? null : visited;
}
private float sampleHeight(int px, int pz, Map<Integer, Float> cache) {
int key = pz * WATER_GRID + px;
Float cached = cache.get(key);
if (cached != null) return cached;
float worldX = (float)(px * STEP) - WORLD_HALF;
float worldZ = (float)(pz * STEP) - WORLD_HALF;
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
float height = (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
cache.put(key, height);
return height;
}
// ── Geometrie-Hilfsmethoden ───────────────────────────────────────────────
private static float[] computeCentroidAndRadius(Set<Integer> cells) {
double sumX = 0, sumZ = 0;
for (int cell : cells) {
sumX += (double)((cell % WATER_GRID) * STEP) - WORLD_HALF;
sumZ += (double)((cell / WATER_GRID) * STEP) - WORLD_HALF;
}
float cx = (float)(sumX / cells.size());
float cz = (float)(sumZ / cells.size());
float maxR = 0f;
for (int cell : cells) {
float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
float r = FastMath.sqrt(dx * dx + dz * dz);
if (r > maxR) maxR = r;
}
return new float[]{cx, cz, maxR};
}
/**
* Erstellt ein unsichtbares Mesh bei waterHeight+0.01 für alle Zellen im
* Filterkreis, die NICHT zum Becken gehören.
*
* Das Mesh schreibt nur in den Tiefenpuffer (ColorWrite=false, DepthWrite=true).
* Vom WaterFilter aus gesehen liegt diese "Fläche" über dem Terrain (höheres Y =
* näher zur Kamera von oben) und blockiert damit das Wasser-Rendering außerhalb
* des Beckens.
*/
private static Geometry buildDepthMask(Set<Integer> basinCells, float cx, float cz,
float radius, float waterHeight,
AssetManager assets) {
float h = waterHeight + 0.01f;
float r2 = radius * radius;
int minPX = Math.max(0, (int) Math.floor(((cx - radius) + WORLD_HALF) / STEP) - 1);
int maxPX = Math.min(WATER_GRID - 1, (int) Math.ceil (((cx + radius) + WORLD_HALF) / STEP) + 1);
int minPZ = Math.max(0, (int) Math.floor(((cz - radius) + WORLD_HALF) / STEP) - 1);
int maxPZ = Math.min(WATER_GRID - 1, (int) Math.ceil (((cz + radius) + WORLD_HALF) / STEP) + 1);
List<Integer> maskCells = new ArrayList<>();
for (int pz = minPZ; pz <= maxPZ; pz++) {
for (int px = minPX; px <= maxPX; px++) {
int cellIdx = pz * WATER_GRID + px;
if (basinCells.contains(cellIdx)) continue;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
float dx = wx - cx, dz = wz - cz;
if (dx * dx + dz * dz <= r2) maskCells.add(cellIdx);
}
}
if (maskCells.isEmpty()) return null;
int n = maskCells.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(n * 6);
int vi = 0;
for (int cell : maskCells) {
int pz = cell / WATER_GRID;
int px = cell % WATER_GRID;
float wx = (float)(px * STEP) - WORLD_HALF;
float wz = (float)(pz * STEP) - WORLD_HALF;
pos.put(wx ).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz );
pos.put(wx + STEP).put(h).put(wz + STEP);
pos.put(wx ).put(h).put(wz + STEP);
// CCW von oben → Normalen zeigen +Y
idx.put(vi).put(vi+2).put(vi+1);
idx.put(vi).put(vi+3).put(vi+2);
vi += 4;
}
pos.rewind(); idx.rewind();
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
mesh.updateCounts();
Geometry geo = new Geometry("water_mask", mesh);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Black);
mat.getAdditionalRenderState().setColorWrite(false);
mat.getAdditionalRenderState().setDepthWrite(true);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
// Transparent: renders after Opaque terrain, so terrain depth is already in buffer.
// The mask (closer to camera = higher Y) passes depth test and overwrites it,
// blocking WaterFilter from reading terrain Y < waterHeight at non-basin pixels.
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
return geo;
}
}

View File

@@ -0,0 +1,168 @@
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.asset.plugins.FileLocator;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.control.RigidBodyControl;
import com.jme3.bullet.util.CollisionShapeFactory;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.*;
import com.jme3.texture.Texture;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.game.animation.AnimationLibrary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class WorldObjectsState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(WorldObjectsState.class);
private SimpleApplication app;
private AssetManager assets;
private BulletAppState bulletAppState;
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
this.bulletAppState = app.getStateManager().getState(BulletAppState.class);
// Asset-Root registrieren, damit Modell-Pfade auflösbar sind
try {
assets.registerLocator(
AnimationLibrary.findAssetRoot().toAbsolutePath().toString(),
FileLocator.class);
} catch (Exception ignored) {}
}
@Override
protected void onEnable() {
List<PlacedModel> models;
try {
models = PlacedModelIO.load();
} catch (Exception e) {
log.warn("[WorldObjects] Fehler beim Laden der Objekte: {}", e.getMessage());
return;
}
if (models.isEmpty()) {
log.info("[WorldObjects] Keine platzierten Objekte gefunden.");
return;
}
log.info("[WorldObjects] Lade {} Objekte…", models.size());
Node root = app.getRootNode();
int loaded = 0, failed = 0;
for (PlacedModel m : models) {
try {
Spatial s = buildSpatial(m);
s.setLocalTranslation(m.x(), m.y(), m.z());
Quaternion rot = new Quaternion();
rot.fromAngles(m.rotX(), m.rotY(), m.rotZ());
s.setLocalRotation(rot);
s.setLocalScale(m.scale());
// Schatten
RenderQueue.ShadowMode shadowMode;
if (m.castShadow() && m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.CastAndReceive;
else if (m.castShadow()) shadowMode = RenderQueue.ShadowMode.Cast;
else if (m.receiveShadow()) shadowMode = RenderQueue.ShadowMode.Receive;
else shadowMode = RenderQueue.ShadowMode.Off;
s.setShadowMode(shadowMode);
// Physik-Kollision für solide Objekte
if (m.solid() && bulletAppState != null) {
try {
RigidBodyControl rbc = new RigidBodyControl(
CollisionShapeFactory.createMeshShape(s), 0f);
s.addControl(rbc);
bulletAppState.getPhysicsSpace().add(rbc);
} catch (Exception pe) {
log.warn("[WorldObjects] Physik für '{}' nicht erzeugbar: {}", m.modelPath(), pe.getMessage());
}
}
root.attachChild(s);
loaded++;
} catch (Exception e) {
log.warn("[WorldObjects] Objekt '{}' nicht ladbar: {}", m.modelPath(), e.getMessage());
failed++;
}
}
log.info("[WorldObjects] {} geladen, {} fehlgeschlagen.", loaded, failed);
}
@Override protected void cleanup(Application app) {}
@Override protected void onDisable() {}
private Spatial buildSpatial(PlacedModel m) {
// Exportiertes Mesh hat Vorrang vor modelPath
String path = (m.meshFile() != null && !m.meshFile().isBlank())
? m.meshFile() : m.modelPath();
Spatial spatial;
if (path.startsWith("@")) {
spatial = createPrimitive(path.substring(1));
applyMaterial(spatial, m);
} else {
spatial = assets.loadModel(path);
}
spatial.setName("obj_" + path);
return spatial;
}
private Spatial createPrimitive(String type) {
return switch (type) {
case "sphere" -> new Geometry("sphere", new Sphere(16, 16, 1f));
case "cylinder" -> new Geometry("cylinder", new Cylinder(2, 16, 0.5f, 2f, true));
case "plane" -> {
Geometry g = new Geometry("plane", new Quad(2f, 2f));
g.rotate(-FastMath.HALF_PI, 0, 0);
g.move(-1f, 0, 1f);
yield g;
}
default -> new Geometry("box", new Box(0.5f, 0.5f, 0.5f));
};
}
private void applyMaterial(Spatial s, PlacedModel m) {
boolean hasTex = m.texturePath() != null && !m.texturePath().isBlank();
boolean hasNmap = m.normalMapPath() != null && !m.normalMapPath().isBlank();
boolean hasMat = m.materialPath() != null && !m.materialPath().isBlank();
if (!(s instanceof Geometry g)) return;
try {
if (hasMat) {
g.setMaterial(assets.loadMaterial(m.materialPath()));
return;
}
if (hasTex || hasNmap) {
Material mat = new Material(assets, "Common/MatDefs/Light/Lighting.j3md");
mat.setBoolean("UseMaterialColors", false);
if (hasTex) {
Texture t = assets.loadTexture(m.texturePath());
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("DiffuseMap", t);
}
if (hasNmap) {
Texture n = assets.loadTexture(m.normalMapPath());
n.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("NormalMap", n);
}
g.setMaterial(mat);
return;
}
} catch (Exception e) {
log.warn("[WorldObjects] Material-Fehler: {}", e.getMessage());
}
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Gray);
g.setMaterial(mat);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB