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

@@ -41,6 +41,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'
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,9 @@ public class FrameTransfer implements SceneProcessor {
private int[] argbBuf; // gesamtes Bild für einmaligen bulk-Write
private final AtomicBoolean jfxBusy = new AtomicBoolean(false);
private volatile Runnable onFirstFrame;
public void setOnFirstFrame(Runnable cb) { this.onFirstFrame = cb; }
public FrameTransfer(WritableImage image) {
this.pw = image.getPixelWriter();
@@ -77,6 +80,8 @@ public class FrameTransfer implements SceneProcessor {
}
}
pw.setPixels(0, 0, width, height, fmt, argbBuf, 0, width);
Runnable cb = onFirstFrame;
if (cb != null) { onFirstFrame = null; cb.run(); }
} finally {
jfxBusy.set(false);
}

View File

@@ -10,6 +10,7 @@ import com.jme3.texture.Texture2D;
import de.blight.editor.state.AnimPreviewState;
import de.blight.editor.state.EmitterState;
import de.blight.editor.state.MusicAreaState;
import de.blight.editor.state.RiverEditorState;
import de.blight.editor.state.PlayToolState;
import de.blight.editor.state.SoundAreaState;
import de.blight.editor.state.WaterBodyState;
@@ -68,8 +69,7 @@ public class JmeEditorApp extends SimpleApplication {
public void simpleInitApp() {
flyCam.setEnabled(false);
// Explizit registrieren, falls General.cfg die Klassen beim ersten Start
// noch nicht gefunden hat (jme3-plugins war zuvor nicht auf dem Classpath).
input.loadingStatus = "Registriere Asset-Loader...";
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf");
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb");
@@ -78,11 +78,12 @@ public class JmeEditorApp extends SimpleApplication {
assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class);
}
input.loadingStatus = "Initialisiere Renderer...";
currentW = vpWidth;
currentH = vpHeight;
buildFrameBuffer(vpWidth, vpHeight, initialImage);
input.loadingStatus = "Lade Editor-States...";
stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input));
@@ -93,10 +94,11 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(input));
stateManager.attach(new MusicAreaState(input));
stateManager.attach(new RiverEditorState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input));
// JME-Konsole (Editor-Modus: kein RawInputListener Eingabe via SharedInput)
input.loadingStatus = "Initialisiere Konsole...";
jmeConsole = new JmeConsole(false);
registerEditorCommands();
jmeConsole.setOnVisibilityChanged(open -> {
@@ -158,6 +160,7 @@ public class JmeEditorApp extends SimpleApplication {
viewPort.setOutputFrameBuffer(fb);
guiViewPort.setOutputFrameBuffer(fb);
frameTransfer = new FrameTransfer(image);
frameTransfer.setOnFirstFrame(() -> { input.loadingStatus = "Bereit"; input.jmeReady = true; });
guiViewPort.addProcessor(frameTransfer);
}

View File

@@ -25,6 +25,10 @@ public class SharedInput {
public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool;
// ── Initialisierungs-Status ───────────────────────────────────────────────
public volatile boolean jmeReady = false;
public volatile String loadingStatus = "Initialisiere...";
// ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ─────────────────────
public volatile int activeLayer = 0;
@@ -63,6 +67,18 @@ public class SharedInput {
public record GrassEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassEdit> grassEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Einstellungen (JavaFX → JME3) ───────────────────────────────────
/** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */
public volatile String grassTexturePath = "";
/** JFX setzt true wenn Textur geändert; JME liest + resettet. */
public volatile boolean grassSettingsChanged = false;
/** Texturpfade für Gras-Slots 1..7 (Index 0 = Slot 1). Neues Array zuweisen bei Änderung. */
public volatile String[] grassTextureSlots = new String[]{"", "", "", "", "", "", ""};
/** Aktiver Maler-Slot (0 = grassTexturePath/Slot0, 17 = grassTextureSlots[slot-1]). */
public volatile int grassActiveSlot = 0;
/** JFX setzt true wenn Slot-Texturen geändert wurden. */
public volatile boolean grassSlotsChanged = false;
// ── Textur-Edits ─────────────────────────────────────────────────────────
/** action +1 = selektierte Textur malen, -1 = auf Gras zurücksetzen. */
public record TextureEdit(float screenX, float screenY, int action) {}
@@ -123,15 +139,15 @@ public class SharedInput {
public volatile boolean treePreviewResized = false;
// ── Baum-Generator ───────────────────────────────────────────────────────
public record TreeGenRequest(TreeParams params, boolean exportAfter, String exportName) {}
public record TreeGenRequest(TreeParams params, boolean exportAfter, String treeType) {}
public final ConcurrentLinkedQueue<TreeGenRequest> treeGenQueue = new ConcurrentLinkedQueue<>();
// ── EZ-Tree-Generator ─────────────────────────────────────────────────────
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter, String exportName, String treeCategory) {}
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter) {}
public final ConcurrentLinkedQueue<EzTreeGenRequest> ezTreeGenQueue = new ConcurrentLinkedQueue<>();
// ── Palmen-Generator ──────────────────────────────────────────────────────
public record PalmGenRequest(PalmOptions options, boolean exportAfter, String exportName) {}
public record PalmGenRequest(PalmOptions options, boolean exportAfter) {}
public final ConcurrentLinkedQueue<PalmGenRequest> palmGenQueue = new ConcurrentLinkedQueue<>();
// ── Objekt-Werkzeug ──────────────────────────────────────────────────────
@@ -201,6 +217,8 @@ public class SharedInput {
float x, float y, float z,
float rotX, float rotY, float rotZ,
boolean solid,
boolean castShadow,
boolean receiveShadow,
String texPath, // null = nicht ändern
String normalMapPath, // null = nicht ändern
String matPath // null = nicht ändern
@@ -325,18 +343,38 @@ public class SharedInput {
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
public static final int LAYER_PLAY_TOOL = 12;
/** activeLayer==13 → Flüsse platzieren */
public static final int LAYER_RIVERS = 13;
// ── Fluss-Werkzeug ─────────────────────────────────────────────────────────
public record RiverClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<RiverClick> riverClickQueue = new ConcurrentLinkedQueue<>();
public volatile float riverNewWidth = 4.0f; // Breite des nächsten Punktes
public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall)
public volatile boolean undoRiverPointRequested = false;
public volatile String riverHint = null;
/** JME → JavaFX: Info des selektierten Flusses. Format: "idx|numPoints|totalLengthM" oder null. */
public volatile String selectedRiverInfo = null;
public volatile boolean riverSelectionChanged = false;
/** JavaFX → JME: Selektierten Fluss löschen. */
public volatile boolean deleteRiverRequested = false;
/** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */
public record WaterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Info der selektierten Wasseroberfläche.
* Format: "idx|x|y|z|width|depth" oder null.
* Format: "idx|seedX|seedZ|waterHeight|cellCount" oder null.
*/
public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter der selektierten Wasseroberfläche. */
/** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */
public volatile String waterHint = null;
/** JavaFX → JME: aktualisierte Wasserhöhe der selektierten Fläche. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
@@ -389,6 +427,11 @@ public class SharedInput {
public volatile float tempSpawnX = Float.NaN;
public volatile float tempSpawnZ = Float.NaN;
/** Live-Spielerposition aus dem laufenden Spiel (NaN = kein Spiel aktiv). */
public volatile float livePlayerX = Float.NaN;
public volatile float livePlayerY = Float.NaN;
public volatile float livePlayerZ = Float.NaN;
// ── Animations-Vorschau ──────────────────────────────────────────────────
public volatile float animPreviewRotY = 0f;
public volatile float animPreviewRotX = 25f;
@@ -437,8 +480,15 @@ public class SharedInput {
public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest>
animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animationen in Charakter-Modell einbetten ─────────────────────────────
public record AnimEmbedRequest(String characterModelPath, String setName) {}
public final java.util.concurrent.atomic.AtomicReference<AnimEmbedRequest>
animEmbedRequest = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */
public volatile String animOpStatus = null;
public volatile String animOpStatus = null;
/** JME3 → JavaFX: Status-Meldung für Einbett-Operationen (Character Editor). */
public volatile String animEmbedStatus = null;
// ── Modell-Konvertierung ──────────────────────────────────────────────────
/**

View File

@@ -12,7 +12,9 @@ public class SceneObject extends PlacedObject {
private float rotX; // X-Achsen-Rotation in Radiant
private float rotZ; // Z-Achsen-Rotation in Radiant
private float scale;
public boolean solid; // Charakter-Kollision
public boolean solid; // Charakter-Kollision
public boolean castShadow = true;
public boolean receiveShadow = true;
public String modelPath; // relativ zu blight-assets/src/main/resources/
public String texturePath = "";
public String normalMapPath = "";

View File

@@ -173,6 +173,9 @@ public class AnimPreviewState extends BaseAppState {
SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null);
if (setReq != null) executeAnimSetSave(setReq);
SharedInput.AnimEmbedRequest embedReq = input.animEmbedRequest.getAndSet(null);
if (embedReq != null) executeAnimEmbed(embedReq);
// Geschwindigkeit live anpassen
if (currentAction != null) {
try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {}
@@ -389,7 +392,7 @@ public class AnimPreviewState extends BaseAppState {
return false;
}
// ── Animation hinzufügen (Retargeting) ───────────────────────────────────
// ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
private void addAnimation(String animAssetPath) {
if (currentModel == null) {
@@ -422,7 +425,6 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null;
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
// Diagnose: Knochen-Namen beider Skelette ausgeben
if (srcArm != null) {
System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):");
for (var j : srcArm.getJointList()) System.err.println(" src: " + j.getName());
@@ -442,15 +444,15 @@ public class AnimPreviewState extends BaseAppState {
System.err.println("[Retarget] Mapping (" + mapping.size() + " Treffer): " + mapping);
}
// Blender-Duplikate herausfiltern: Clips deren Name mit ".NNN" endet und deren
// Basis-Name (ohne Suffix) ebenfalls in der Quelle vorkommt, werden übersprungen.
java.util.Set<String> srcNames = new java.util.HashSet<>();
for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName());
int added = 0;
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Files.createDirectories(clipsDir);
int saved = 0;
for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName();
// Prüfen ob Name dem Muster "base.NNN" entspricht (Blender-Duplikat)
if (name.matches(".*\\.\\d{3}$")) {
String base = name.substring(0, name.length() - 4);
if (srcNames.contains(base)) {
@@ -461,20 +463,20 @@ public class AnimPreviewState extends BaseAppState {
AnimClip result = retarget
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
: clip;
if (result != null) {
targetAC.addAnimClip(result);
added++;
}
if (result == null) continue;
// Direkt in die Clip-Bibliothek speichern das Modell wird nicht modifiziert
saveClipToFile(result, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(name + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden
targetAC.addAnimClip(result);
saved++;
}
// Clip-Liste neu aufbauen
List<String> clips = new ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = added + " Clip(s) hinzugefügt"
+ (retarget ? " (retargeted)" : " (direkt, kein Retargeting)");
// Modell mit neuem Clip persistieren, damit der Clip nach Editor-Neustart noch da ist
if (added > 0) saveModel();
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt)");
} catch (Exception e) {
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
}
@@ -503,7 +505,6 @@ public class AnimPreviewState extends BaseAppState {
collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = "Clip entfernt: " + clipName;
saveModel();
}
private <T extends com.jme3.scene.control.Control> T findControl(Spatial s, Class<T> type) {
@@ -755,19 +756,14 @@ public class AnimPreviewState extends BaseAppState {
AnimClip renamed = new AnimClip(req.newName());
renamed.setTracks(src.getTracks());
ac.addAnimClip(renamed);
saveModel();
// kein saveModel() Quell-Modell bleibt unverändert
// Als eigenständige .j3o nach animations/ exportieren
// Als eigenständige .j3o nach animations/clips/ exportieren
try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animExport");
AnimComposer expAC = new AnimComposer();
expAC.addAnimClip(renamed);
holder.addControl(expAC);
java.nio.file.Path outDir = ASSET_ROOT.resolve("animations");
java.nio.file.Files.createDirectories(outDir);
com.jme3.export.binary.BinaryExporter.getInstance()
.save(holder, outDir.resolve(req.newName() + ".j3o").toFile());
// Clip-Liste aktualisieren
SkinningControl sc = findControl(currentModel, SkinningControl.class);
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
saveClipToFile(renamed, sc != null ? sc.getArmature() : null,
clipsDir.resolve(req.newName() + ".j3o"));
java.util.List<String> clips = new java.util.ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips));
@@ -780,37 +776,106 @@ public class AnimPreviewState extends BaseAppState {
// ── Animations-Set speichern ──────────────────────────────────────────────
private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) {
if (currentModel == null) { input.animOpStatus = "Fehler: kein Modell geladen"; return; }
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) { input.animOpStatus = "Fehler: kein AnimComposer"; return; }
try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animSet");
AnimComposer setAC = new AnimComposer();
int added = 0;
for (String clipName : req.clips()) {
AnimClip clip = ac.getAnimClip(clipName);
if (clip != null) { setAC.addAnimClip(clip); added++; }
}
holder.addControl(setAC);
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Files.createDirectories(setDir);
java.nio.file.Path j3oPath = setDir.resolve(req.setName() + ".j3o");
com.jme3.export.binary.BinaryExporter.getInstance().save(holder, j3oPath.toFile());
// Begleitende .animset.json mit Clip-Namen und Aktions-Zuweisung
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(req.clips());
animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>());
animSet.save(setDir, req.setName());
input.animOpStatus = "Set '" + req.setName() + "' gespeichert (" + added + " Clips)";
input.animOpStatus = "Animations-Set '" + req.setName() + "' gespeichert";
} catch (Exception e) {
input.animOpStatus = "Set-Fehler: " + e.getMessage();
}
}
private void executeAnimEmbed(SharedInput.AnimEmbedRequest req) {
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
de.blight.game.animation.AnimSet set;
try {
set = de.blight.game.animation.AnimSet.load(setDir, req.setName());
} catch (Exception e) {
input.animEmbedStatus = "Fehler: Set nicht gefunden " + e.getMessage();
return;
}
if (set.getClips().isEmpty()) {
input.animEmbedStatus = "Fehler: Set '" + req.setName() + "' enthält keine Clips";
return;
}
try {
Spatial charModel = loadFresh(req.characterModelPath());
AnimComposer charAC = findControl(charModel, AnimComposer.class);
SkinningControl charSC = findControl(charModel, SkinningControl.class);
if (charAC == null) {
input.animEmbedStatus = "Fehler: Charakter-Modell hat keinen AnimComposer";
return;
}
com.jme3.anim.Armature dstArm = charSC != null ? charSC.getArmature() : null;
int embedded = 0;
for (String clipName : set.getClips()) {
if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) {
System.err.println("[AnimEmbed] Clip nicht gefunden: " + clipName);
continue;
}
try {
Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o");
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
if (clipAC == null) continue;
com.jme3.anim.Armature srcArm = clipSC != null ? clipSC.getArmature() : null;
for (AnimClip clip : clipAC.getAnimClips()) {
if (charAC.getAnimClip(clip.getName()) != null) continue;
AnimClip target;
if (srcArm != null && dstArm != null && !haveSameBoneNames(srcArm, dstArm)) {
target = de.blight.game.animation.RetargetingSystem
.retarget(clip, srcArm, dstArm);
} else {
target = clip;
}
if (target != null) { charAC.addAnimClip(target); embedded++; }
}
} catch (Exception e) {
System.err.println("[AnimEmbed] Fehler bei Clip " + clipName + ": " + e.getMessage());
}
}
// Charakter-Modell mit eingebetteten Clips speichern
java.nio.file.Path charFile = ASSET_ROOT.resolve(
req.characterModelPath().replace('/', java.io.File.separatorChar));
BinaryExporter.getInstance().save(charModel, charFile.toFile());
assets.deleteFromCache(new com.jme3.asset.ModelKey(req.characterModelPath()));
input.animEmbedStatus = embedded + " Clip(s) in '"
+ req.characterModelPath() + "' eingebettet";
} catch (Exception e) {
input.animEmbedStatus = "Embed-Fehler: " + e.getMessage();
}
}
private void saveClipToFile(AnimClip clip, com.jme3.anim.Armature armature,
java.nio.file.Path outFile) throws Exception {
Node holder = new Node("clip_" + clip.getName());
AnimComposer expAC = new AnimComposer();
expAC.addAnimClip(clip);
holder.addControl(expAC);
if (armature != null) holder.addControl(new SkinningControl(armature));
java.nio.file.Files.createDirectories(outFile.getParent());
BinaryExporter.getInstance().save(holder, outFile.toFile());
}
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
if (a.getJointCount() != b.getJointCount()) return false;
java.util.Set<String> namesA = new java.util.HashSet<>();
for (var j : a.getJointList()) namesA.add(j.getName());
for (var j : b.getJointList()) if (!namesA.contains(j.getName())) return false;
return true;
}
private static java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion>
buildMS(com.jme3.anim.Armature arm) {
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache = new java.util.HashMap<>();

View File

@@ -138,7 +138,7 @@ public class EzTreeState extends BaseAppState {
});
if (!req.exportAfter()) {
input.treeGenStatusMsg = "EZ-Tree Vorschau: '" + req.exportName() + "'";
input.treeGenStatusMsg = "EZ-Tree Vorschau: " + resolveSubPath(req.presetName());
} else {
input.treeGenStatusMsg = "EZ-Tree: generiere…";
}
@@ -356,10 +356,14 @@ public class EzTreeState extends BaseAppState {
Node treeNode = pendingTreeNode;
cleanupCapture();
String exportName = req.exportName() + "_"
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String subPath = resolveSubPath(req.presetName());
String namePart = req.presetName() != null
? req.presetName().toLowerCase().replace(" ", "_")
: subPath;
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String exportName = namePart + "_" + timestamp;
saveImpostor(pixels, "ez_impostor_" + exportName);
exportTree(treeNode, req.exportName(), req.treeCategory());
exportTree(treeNode, exportName, subPath);
pendingRequest = null;
pendingTreeNode = null;
@@ -540,21 +544,40 @@ public class EzTreeState extends BaseAppState {
}
}
private void exportTree(Node treeNode, String name, String treeCategory) {
private void exportTree(Node treeNode, String fileName, String subPath) {
try {
Path baseDir = (treeCategory != null && !treeCategory.isBlank())
? ASSET_ROOT.resolve("trees").resolve(treeCategory)
: ASSET_ROOT.resolve("Models");
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
Files.createDirectories(baseDir);
File out = baseDir.resolve(name + ".j3o").toFile();
File out = baseDir.resolve(fileName + ".j3o").toFile();
BinaryExporter.getInstance().save(treeNode, out);
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
input.refreshAssets = true;
input.refreshTreeFolders = true;
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + subPath + "/" + fileName + ".j3o";
input.refreshAssets = true;
input.refreshTreeFolders = true;
} catch (IOException e) {
log.error("[EZ-Tree] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "EZ-Tree Export-Fehler: " + e.getMessage();
}
}
private static String resolveSubPath(String presetName) {
if (presetName == null) return "unknown";
String lo = presetName.toLowerCase();
String size = lo.contains(" small") ? "/small"
: lo.contains(" medium") ? "/medium"
: lo.contains(" large") ? "/large"
: lo.contains(" 1") ? "/1"
: lo.contains(" 2") ? "/2"
: lo.contains(" 3") ? "/3"
: "";
if (lo.contains("oak")) return "oak" + size;
if (lo.contains("ash")) return "ash" + size;
if (lo.contains("aspen")) return "aspen" + size;
if (lo.contains("pine")) return "pine" + size;
if (lo.contains("bush")) return "bush" + size;
if (lo.contains("trellis")) return "trellis" + size;
return lo.replaceAll("\\s+.*", "") + size;
}
}

View File

@@ -89,17 +89,14 @@ public class PalmGeneratorState extends BaseAppState {
final Vector3f finalTarget = target;
final Node finalPalm = palm;
final PalmOptions finalOpts = req.options();
final String finalName = req.exportName();
final boolean doExport = req.exportAfter();
app.enqueue(() -> {
previewHost.setPreviewContent(finalPalm, finalDist, finalTarget);
if (doExport) exportPalm(finalPalm, finalName);
if (doExport) exportPalm(finalPalm);
});
input.treeGenStatusMsg = doExport
? "Palme: exportiere…"
: "Palme: Vorschau '" + req.exportName() + "'";
input.treeGenStatusMsg = doExport ? "Palme: exportiere…" : "Palme: Vorschau";
}
private void applyMaterials(Node palm, PalmOptions opts) {
@@ -190,16 +187,15 @@ public class PalmGeneratorState extends BaseAppState {
}
}
private void exportPalm(Node palmNode, String name) {
private void exportPalm(Node palmNode) {
try {
Path modelDir = ASSET_ROOT.resolve("trees").resolve("palm");
Path modelDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("palm");
Files.createDirectories(modelDir);
String stampedName = name + "_"
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile();
String fileName = "palm_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
File out = modelDir.resolve(fileName + ".j3o").toFile();
BinaryExporter.getInstance().save(palmNode, out);
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Palme exportiert: " + out.getName();
input.treeGenStatusMsg = "Gespeichert: Models/trees/palm/" + fileName + ".j3o";
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Palme] Export-Fehler: {}", e.getMessage());

View File

@@ -19,6 +19,8 @@ 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.GrassTuft;
import de.blight.common.GrassTuftIO;
import de.blight.common.MapData;
import de.blight.editor.SharedInput;
@@ -27,31 +29,27 @@ import java.nio.IntBuffer;
import java.util.*;
/**
* Rendert Gras auf dem Basis-Terrain.
* Rendert individuell platzierte Gras-Büschel im Editor.
*
* Datenmodell: Dichte-Map (513×513 Bytes, gleiche Auflösung wie Splatmap).
* Rendering: Pro 128×128-WE-Chunk ein gebatchtes Kreuz-Quad-Mesh.
* LOD: GrassVisibilityControl cullt Chunks jenseits FAR_DIST.
* Wind: MatDefs/Grass.j3md (Vertex-Shader mit Sinus-Wind).
* Jeder Büschel hat eine feste Weltposition (x, z), eine gebackene Höhe und einen Textur-Slot.
* Die Y-Koordinate wird beim Chunk-Rebuild live aus dem Terrain abgelesen.
*
* Chunks: 128×128-WE-Kacheln, lazy rebuild, LOD-Culling via GrassVisibilityControl.
*/
public class PlacedObjectState extends BaseAppState {
// ── Terrain-Konstanten ────────────────────────────────────────────────────
// ── Terrain ───────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048;
private static final float WORLD_SIZE = 4096f;
// ── Dichte-Map ────────────────────────────────────────────────────────────
private static final int SPLAT_SIZE = MapData.SPLAT_SIZE; // 513
private static final float SPLAT_WE_PER_PX = WORLD_SIZE / (SPLAT_SIZE - 1); // 8.0
// ── Chunks ────────────────────────────────────────────────────────────────
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;
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
// ── Gras-Generierung ──────────────────────────────────────────────────────
private static final int MAX_BLADES_PER_PIXEL = 3;
private static final float BLADE_WIDTH_FACTOR = 0.18f;
// ── Rendering ─────────────────────────────────────────────────────────────
private static final float BLADE_WIDTH = 0.18f;
private static final int BLADES_PER_TUFT = 4; // Kreuz-Quads pro Büschel
private static final float TUFT_SPREAD = 0.5f; // Streuradius (WE)
// ── LOD ───────────────────────────────────────────────────────────────────
private static final float GRASS_FAR_DIST = 400f;
@@ -64,23 +62,49 @@ public class PlacedObjectState extends BaseAppState {
private final SharedInput input;
private Camera cam;
private TerrainQuad terrain;
private AssetManager assetManager;
private Node grassNode;
private Material grassMat;
private Node grassNode;
private final Map<Integer, Material> slotMaterials = new LinkedHashMap<>();
private byte[] densityMap;
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
private final Geometry[] chunkGeos = new Geometry[CHUNK_COUNT];
@SuppressWarnings("unchecked")
private final List<GrassTuft>[] chunkTufts = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
// ── Konstruktor ───────────────────────────────────────────────────────────
public PlacedObjectState(SharedInput input, MapData loadedData) {
this.input = input;
this.densityMap = new byte[SPLAT_SIZE * SPLAT_SIZE];
if (loadedData != null && loadedData.grassDensity != null) {
System.arraycopy(loadedData.grassDensity, 0, densityMap, 0, densityMap.length);
Arrays.fill(dirtyChunks, true);
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkTufts[i] = new ArrayList<>();
try {
GrassTuftIO.GrassData data = GrassTuftIO.load();
if (data != null) {
String[] paths = data.slotPaths();
if (paths != null && paths.length > 0) {
input.grassTexturePath = paths[0] != null ? paths[0] : "";
String[] slots = new String[]{"", "", "", "", "", "", ""};
for (int i = 0; i < 7; i++) {
int si = i + 1;
slots[i] = (si < paths.length && paths[si] != null) ? paths[si] : "";
}
input.grassTextureSlots = slots;
}
for (GrassTuft t : data.tufts()) {
int ci = chunkIndex(t.x(), t.z());
if (ci >= 0) {
chunkTufts[ci].add(t);
dirtyChunks[ci] = true;
}
}
}
} catch (Exception e) {
System.err.println("[PlacedObjectState] Grasdaten nicht ladbar: " + e.getMessage());
}
if (loadedData != null) {
input.grassTool.grassHeight.setValue(loadedData.grassDefaultHeight);
}
}
@@ -88,17 +112,36 @@ public class PlacedObjectState extends BaseAppState {
this.terrain = terrain;
}
/** Gibt die aktuelle Dichte-Map zurück (für performSave). */
public byte[] getDensityMap() { return densityMap; }
// ── Getters für Save ──────────────────────────────────────────────────────
public List<GrassTuft> getAllTufts() {
List<GrassTuft> all = new ArrayList<>();
for (List<GrassTuft> list : chunkTufts) all.addAll(list);
return all;
}
public String[] getSlotPaths() {
String[] paths = new String[8];
paths[0] = input.grassTexturePath != null ? input.grassTexturePath : "";
String[] slots = input.grassTextureSlots;
for (int i = 0; i < 7 && i < slots.length; i++)
paths[i + 1] = slots[i] != null ? slots[i] : "";
return paths;
}
public float getGrassDefaultHeight() {
return (float) input.grassTool.grassHeight.getValue();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.cam = app.getCamera();
grassNode = new Node("grassNode");
this.cam = app.getCamera();
this.assetManager = app.getAssetManager();
grassNode = new Node("grassNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
grassMat = buildGrassMaterial(app.getAssetManager());
applyAllSlotMaterials();
}
@Override
@@ -111,30 +154,63 @@ public class PlacedObjectState extends BaseAppState {
@Override
public void update(float tpf) {
if (input.grassSettingsChanged || input.grassSlotsChanged) {
input.grassSettingsChanged = false;
input.grassSlotsChanged = false;
applyAllSlotMaterials();
}
processGrassEdits();
rebuildDirtyChunks();
}
// ── Material ──────────────────────────────────────────────────────────────
// ── Materialien ───────────────────────────────────────────────────────────
private Material buildGrassMaterial(AssetManager assets) {
private Material getMaterialForSlot(int slot) {
return slotMaterials.computeIfAbsent(slot, s -> buildFreshGrassMaterial());
}
private Material buildFreshGrassMaterial() {
try {
Material mat = new Material(assets, "MatDefs/Grass.j3md");
Material mat = new Material(assetManager, "MatDefs/Grass.j3md");
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
mat.setFloat("WindSpeed", 0.5f);
mat.setFloat("WindStrength", 0.12f);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
} catch (Exception e) {
System.err.println("[PlacedObjectState] Grass.j3md nicht gefunden, Fallback: " + e.getMessage());
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.22f, 0.68f, 0.12f, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
}
// ── Pinsel: Dichte-Map anpassen ───────────────────────────────────────────
private void applyAllSlotMaterials() {
if (assetManager == null) return;
applyTexToMat(getMaterialForSlot(0), input.grassTexturePath);
String[] slots = input.grassTextureSlots;
for (int i = 0; i < slots.length; i++) {
if (slots[i] != null && !slots[i].isEmpty())
applyTexToMat(getMaterialForSlot(i + 1), slots[i]);
}
}
private void applyTexToMat(Material mat, String path) {
if (path != null && !path.isEmpty()) {
try {
mat.setTexture("ColorMap", assetManager.loadTexture(path));
mat.setColor("Color", ColorRGBA.White);
} catch (Exception e) {
try { mat.clearParam("ColorMap"); } catch (Exception ignored) {}
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
}
} else {
try { mat.clearParam("ColorMap"); } catch (Exception ignored) {}
mat.setColor("Color", new ColorRGBA(0.25f, 0.70f, 0.15f, 1f));
}
}
// ── Pinsel-Interaktion ────────────────────────────────────────────────────
private void processGrassEdits() {
SharedInput.GrassEdit edit;
@@ -144,60 +220,58 @@ public class PlacedObjectState extends BaseAppState {
float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY);
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal());
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint();
float radius = (float) input.grassTool.brushRadius.getValue();
paintDensity(contact.x, contact.z, radius, edit.action());
if (edit.action() > 0) paintGrass(contact.x, contact.z, radius);
else eraseGrass(contact.x, contact.z, radius);
}
}
private void paintDensity(float cx, float cz, float radius, int action) {
int centerPX = Math.round((cx + TERRAIN_HALF) / SPLAT_WE_PER_PX);
int centerPZ = Math.round((cz + TERRAIN_HALF) / SPLAT_WE_PER_PX);
int pixR = (int) Math.ceil(radius / SPLAT_WE_PER_PX);
float strength = (float) input.grassTool.density.getValue() / 10f; // 0.15.0
for (int dz = -pixR; dz <= pixR; dz++) {
int pz = centerPZ + dz;
if (pz < 0 || pz >= SPLAT_SIZE) continue;
for (int dx = -pixR; dx <= pixR; dx++) {
int px = centerPX + dx;
if (px < 0 || px >= SPLAT_SIZE) continue;
float distWE = FastMath.sqrt(dx * dx + dz * dz) * SPLAT_WE_PER_PX;
if (distWE >= radius) continue;
float t = distWE / radius;
float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f;
int delta = (int)(strength * falloff * 40f);
int idx = pz * SPLAT_SIZE + px;
int cur = densityMap[idx] & 0xFF;
int nxt = (action > 0)
? Math.min(255, cur + delta)
: Math.max(0, cur - delta);
if (nxt != cur) {
densityMap[idx] = (byte) nxt;
markChunkDirtyAtPixel(px, pz);
}
private void paintGrass(float cx, float cz, float radius) {
int n = Math.max(1, (int) input.grassTool.density.getValue());
float baseH = (float) input.grassTool.grassHeight.getValue();
int slot = input.grassActiveSlot;
Random rng = new Random();
for (int i = 0; i < n; i++) {
float angle = rng.nextFloat() * FastMath.TWO_PI;
float dist = FastMath.sqrt(rng.nextFloat()) * radius;
float bx = cx + dist * FastMath.cos(angle);
float bz = cz + dist * FastMath.sin(angle);
if (bx < -TERRAIN_HALF || bx > TERRAIN_HALF
|| bz < -TERRAIN_HALF || bz > TERRAIN_HALF) continue;
float h = baseH * (0.7f + rng.nextFloat() * 0.6f);
int ci = chunkIndex(bx, bz);
if (ci >= 0) {
chunkTufts[ci].add(new GrassTuft(bx, bz, h, slot));
dirtyChunks[ci] = true;
}
}
}
private void eraseGrass(float cx, float cz, float radius) {
float rSq = radius * radius;
for (int[] cc : overlappingChunks(cx, cz, radius)) {
int ci = cc[0] + cc[1] * CHUNKS_PER_AXIS;
boolean changed = chunkTufts[ci].removeIf(t -> {
float dx = t.x() - cx, dz = t.z() - cz;
return dx * dx + dz * dz <= rSq;
});
if (changed) dirtyChunks[ci] = true;
}
}
// ── Höhenanpassung bei Terrain-Edit ───────────────────────────────────────
/**
* Markiert alle Chunks dirty, deren Fläche eine der übergebenen Terrain-Positionen
* enthält. Die Blatt-Y-Koordinaten werden beim nächsten Rebuild neu von
* terrain.getHeight() abgelesen.
*/
public void adjustObjectHeights(List<Vector2f> locs, List<Float> deltas) {
for (Vector2f loc : locs) {
int cx = (int)((loc.x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((loc.y + TERRAIN_HALF) / CHUNK_SIZE);
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) {
int cx = (int) Math.floor((loc.x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) Math.floor((loc.y + TERRAIN_HALF) / CHUNK_SIZE);
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS)
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
}
}
}
@@ -215,90 +289,76 @@ public class PlacedObjectState extends BaseAppState {
private void rebuildChunk(int idx) {
if (terrain == null) return;
int cx = idx % CHUNKS_PER_AXIS;
int cz = idx / CHUNKS_PER_AXIS;
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
int cx = idx % CHUNKS_PER_AXIS;
int cz = idx / CHUNKS_PER_AXIS;
float wXMin = -TERRAIN_HALF + cx * CHUNK_SIZE;
float wZMin = -TERRAIN_HALF + cz * CHUNK_SIZE;
if (chunkNodes[idx] != null) {
grassNode.detachChild(chunkNodes[idx]);
chunkNodes[idx] = null;
}
if (chunkTufts[idx].isEmpty()) return;
// Dichte-Pixel-Bereich dieses Chunks
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));
float baseH = (float) input.grassTool.grassHeight.getValue();
// Blatt-Positionen generieren
List<float[]> blades = new ArrayList<>(); // [x, y, z, height]
for (int pz = pzMin; pz <= pzMax; pz++) {
for (int px = pxMin; px <= pxMax; px++) {
int d = densityMap[pz * SPLAT_SIZE + px] & 0xFF;
if (d == 0) continue;
int count = Math.max(1, (int)(d / 255f * MAX_BLADES_PER_PIXEL));
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;
float th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue;
float h = baseH * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, h});
}
Map<Integer, List<float[]>> bySlot = new LinkedHashMap<>();
for (GrassTuft t : chunkTufts[idx]) {
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 ox = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float oz = (rng.nextFloat() - 0.5f) * TUFT_SPREAD * 2f;
float bx = t.x() + ox;
float bz = t.z() + oz;
float th = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(th)) continue;
float bh = t.height() * (0.7f + rng.nextFloat() * 0.6f);
blades.add(new float[]{bx, th, bz, bh});
}
}
// Alte Geometrie entfernen
if (chunkGeos[idx] != null) {
grassNode.detachChild(chunkGeos[idx]);
chunkGeos[idx] = null;
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Node node = new Node("grassChunk_" + idx);
for (Map.Entry<Integer, List<float[]>> entry : bySlot.entrySet()) {
if (entry.getValue().isEmpty()) continue;
Geometry geo = new Geometry("grassChunk_" + idx + "_s" + entry.getKey(),
buildGrassMesh(entry.getValue()));
geo.setMaterial(getMaterialForSlot(entry.getKey()));
node.attachChild(geo);
}
if (blades.isEmpty()) return;
Mesh mesh = buildGrassMesh(blades);
float chunkCX = wXMin + CHUNK_SIZE * 0.5f;
float chunkCZ = wZMin + CHUNK_SIZE * 0.5f;
Geometry geo = new Geometry("grassChunk_" + idx, mesh);
geo.setMaterial(grassMat);
geo.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(geo);
chunkGeos[idx] = geo;
if (node.getChildren().isEmpty()) return;
node.addControl(new GrassVisibilityControl(cam, new Vector3f(chunkCX, 0f, chunkCZ)));
grassNode.attachChild(node);
chunkNodes[idx] = node;
}
// ── Mesh: Kreuz-Quad pro Halm mit UV-Koordinaten ──────────────────────────
// ── Mesh ──────────────────────────────────────────────────────────────────
private static Mesh buildGrassMesh(List<float[]> blades) {
int n = blades.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 8 * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(n * 8 * 2);
IntBuffer idx = BufferUtils.createIntBuffer(n * 12);
int vi = 0;
for (float[] blade : blades) {
float x = blade[0], y = blade[1], z = blade[2], h = blade[3];
float w = Math.max(0.05f, h * BLADE_WIDTH_FACTOR);
// Quad A Breite entlang X-Achse
float w = Math.max(0.05f, h * BLADE_WIDTH);
pos.put(x-w).put(y ).put(z); uv.put(0).put(0);
pos.put(x+w).put(y ).put(z); uv.put(1).put(0);
pos.put(x+w).put(y+h).put(z); uv.put(1).put(1);
pos.put(x-w).put(y+h).put(z); uv.put(0).put(1);
// Quad B Breite entlang Z-Achse
pos.put(x).put(y ).put(z-w); uv.put(0).put(0);
pos.put(x).put(y ).put(z+w); uv.put(1).put(0);
pos.put(x).put(y+h).put(z+w); uv.put(1).put(1);
pos.put(x).put(y+h).put(z-w); uv.put(0).put(1);
idx.put(vi ).put(vi+1).put(vi+2);
idx.put(vi ).put(vi+2).put(vi+3);
idx.put(vi).put(vi+1).put(vi+2);
idx.put(vi).put(vi+2).put(vi+3);
idx.put(vi+4).put(vi+5).put(vi+6);
idx.put(vi+4).put(vi+6).put(vi+7);
vi += 8;
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uv);
@@ -310,14 +370,12 @@ public class PlacedObjectState extends BaseAppState {
// ── LOD-Control ───────────────────────────────────────────────────────────
private static final class GrassVisibilityControl extends AbstractControl {
private final Camera cam;
private final Camera cam;
private final Vector3f center;
GrassVisibilityControl(Camera cam, Vector3f center) {
this.cam = cam;
this.center = center;
}
@Override
protected void controlUpdate(float tpf) {
float distSq = cam.getLocation().distanceSquared(center);
@@ -325,19 +383,30 @@ public class PlacedObjectState extends BaseAppState {
? Spatial.CullHint.Always
: Spatial.CullHint.Inherit);
}
@Override protected void controlRender(RenderManager rm, ViewPort vp) {}
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void markChunkDirtyAtPixel(int px, int pz) {
float worldX = px * SPLAT_WE_PER_PX - TERRAIN_HALF;
float worldZ = pz * SPLAT_WE_PER_PX - TERRAIN_HALF;
int cx = (int)((worldX + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((worldZ + TERRAIN_HALF) / CHUNK_SIZE);
if (cx >= 0 && cx < CHUNKS_PER_AXIS && cz >= 0 && cz < CHUNKS_PER_AXIS) {
dirtyChunks[cx + cz * CHUNKS_PER_AXIS] = true;
private int chunkIndex(float wx, float wz) {
int cx = (int) Math.floor((wx + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) Math.floor((wz + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cx + cz * CHUNKS_PER_AXIS;
}
private int[][] overlappingChunks(float cx, float cz, float radius) {
int r = (int) Math.ceil(radius / CHUNK_SIZE) + 1;
int ccx = (int) Math.floor((cx + TERRAIN_HALF) / CHUNK_SIZE);
int ccz = (int) Math.floor((cz + TERRAIN_HALF) / CHUNK_SIZE);
List<int[]> result = new ArrayList<>();
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
int nx = ccx + dx, nz = ccz + dz;
if (nx >= 0 && nx < CHUNKS_PER_AXIS && nz >= 0 && nz < CHUNKS_PER_AXIS)
result.add(new int[]{nx, nz});
}
}
return result.toArray(new int[0][]);
}
}

View File

@@ -0,0 +1,493 @@
package de.blight.editor.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.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
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.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.RiverIO;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* Editor-State für das Fluss-Werkzeug.
* Erlaubt das interaktive Platzieren von Fluss-Kontrollpunkten auf dem Terrain
* und zeigt eine Live-Ribbon-Vorschau.
*/
public class RiverEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(RiverEditorState.class);
private static final float UV_SCALE = 4.0f;
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// ── Zustand ───────────────────────────────────────────────────────────────
private final List<List<RiverPoint>> rivers = new ArrayList<>();
private final List<List<Geometry>> pointGeos = new ArrayList<>();
private final List<Geometry> ribbonGeos = new ArrayList<>();
private int activeRiver = -1; // -1 = kein aktiver Fluss (wird gebaut)
private int selectedRiver = -1; // -1 = keine Selektion
public RiverEditorState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.cam = app.getCamera();
this.assets = app.getAssetManager();
this.rootNode = this.app.getRootNode();
try {
List<List<RiverPoint>> saved = RiverIO.load();
if (!saved.isEmpty()) loadPlacedRivers(saved);
} catch (Exception e) {
log.error("Flüsse nicht ladbar", e);
}
}
@Override
protected void cleanup(Application app) {
clearAll();
}
@Override
protected void onEnable() {
setCullHintAll(Spatial.CullHint.Inherit);
}
@Override
protected void onDisable() {
setCullHintAll(Spatial.CullHint.Always);
}
// ── Terrain ───────────────────────────────────────────────────────────────
private TerrainEditorState terrainEditor;
public void setTerrain(TerrainQuad t) {
this.terrain = t;
}
public void setTerrainEditor(TerrainEditorState te) {
this.terrainEditor = te;
}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_RIVERS) return;
// Undo: letzten Punkt des aktiven Flusses entfernen
if (input.undoRiverPointRequested) {
input.undoRiverPointRequested = false;
undoLastPoint();
}
// Selektierten Fluss löschen
if (input.deleteRiverRequested) {
input.deleteRiverRequested = false;
if (selectedRiver >= 0) {
removeRiver(selectedRiver);
selectedRiver = -1;
input.selectedRiverInfo = null;
input.riverSelectionChanged = true;
}
}
// Click-Queue verarbeiten
SharedInput.RiverClick click;
while ((click = input.riverClickQueue.poll()) != null) {
handleClick(click);
}
}
// ── Click-Verarbeitung ────────────────────────────────────────────────────
private void handleClick(SharedInput.RiverClick click) {
if (click.rightButton()) {
// Rechtsklick: aktiven Fluss abschließen
finalizeActiveRiver();
return;
}
if (terrain == null) return;
float jmeX = (float)(click.screenX() * input.viewportScaleX);
float jmeY = cam.getHeight() - (float)(click.screenY() * input.viewportScaleY);
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f hit = hits.getClosestCollision().getContactPoint();
// Kein aktiver Fluss: prüfen ob ein bestehender Fluss in der Nähe liegt → selektieren
if (activeRiver < 0) {
int nearby = findNearestRiver(hit, 8f);
if (nearby >= 0) {
selectRiver(nearby);
return;
}
// Klick ins Leere → Selektion aufheben
selectRiver(-1);
}
float w = input.riverNewWidth;
float speed = input.riverNewSpeed;
RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, w, speed);
addPoint(pt);
}
private void addPoint(RiverPoint pt) {
// Neuen Fluss starten wenn kein aktiver
if (activeRiver < 0 || activeRiver >= rivers.size()) {
rivers.add(new ArrayList<>());
pointGeos.add(new ArrayList<>());
ribbonGeos.add(null);
activeRiver = rivers.size() - 1;
}
List<RiverPoint> current = rivers.get(activeRiver);
if (!current.isEmpty() && terrainEditor != null) {
RiverPoint prev = current.get(current.size() - 1);
// Gefälle sicherstellen: neuer Punkt darf nicht höher als vorheriger sein
float newY = Math.min(pt.y(), prev.y() - 0.05f);
pt = new RiverPoint(pt.x(), newY, pt.z(), pt.width(), pt.uvSpeed());
// Flussbett graben
terrainEditor.carveRiverbedSegment(
prev.x(), prev.y(), prev.z(),
pt.x(), pt.y(), pt.z(),
pt.width() * 0.5f
);
}
rivers.get(activeRiver).add(pt);
// Kontrollpunkt-Geo
Geometry sphere = buildPointGeo(pt);
rootNode.attachChild(sphere);
pointGeos.get(activeRiver).add(sphere);
// Ribbon neu aufbauen
rebuildActiveRibbon();
}
private void finalizeActiveRiver() {
// Fluss mit weniger als 2 Punkten verwerfen
if (activeRiver >= 0 && activeRiver < rivers.size()) {
if (rivers.get(activeRiver).size() < 2) {
removeRiver(activeRiver);
}
}
activeRiver = -1;
}
private void undoLastPoint() {
if (activeRiver < 0 || activeRiver >= rivers.size()) return;
List<RiverPoint> pts = rivers.get(activeRiver);
List<Geometry> geos = pointGeos.get(activeRiver);
if (pts.isEmpty()) return;
pts.remove(pts.size() - 1);
Geometry last = geos.remove(geos.size() - 1);
rootNode.detachChild(last);
if (pts.isEmpty()) {
removeRiver(activeRiver);
activeRiver = -1;
} else {
rebuildActiveRibbon();
}
}
// ── Ribbon-Vorschau ───────────────────────────────────────────────────────
private void rebuildActiveRibbon() {
if (activeRiver < 0 || activeRiver >= rivers.size()) return;
// Altes Ribbon entfernen
Geometry old = ribbonGeos.get(activeRiver);
if (old != null) rootNode.detachChild(old);
List<RiverPoint> pts = rivers.get(activeRiver);
if (pts.size() < 2) {
ribbonGeos.set(activeRiver, null);
return;
}
Geometry ribbon = buildRibbon(pts);
ribbonGeos.set(activeRiver, ribbon);
rootNode.attachChild(ribbon);
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Lädt bereits gespeicherte Flüsse und baut deren Visualisierungen auf.
*/
public void loadPlacedRivers(List<List<RiverPoint>> loaded) {
clearAll();
log.info("Lade {} Fluss/Flüsse aus Datei", loaded.size());
for (List<RiverPoint> river : loaded) {
if (river == null || river.isEmpty()) continue;
int idx = rivers.size();
rivers.add(new ArrayList<>(river));
pointGeos.add(new ArrayList<>());
ribbonGeos.add(null);
for (RiverPoint pt : river) {
Geometry sphere = buildPointGeo(pt);
rootNode.attachChild(sphere);
pointGeos.get(idx).add(sphere);
}
if (river.size() >= 2) {
Geometry ribbon = buildRibbon(river);
ribbonGeos.set(idx, ribbon);
rootNode.attachChild(ribbon);
}
}
activeRiver = -1;
}
/**
* Gibt eine Kopie der aktuell platzierten Flüsse zurück.
*/
public List<List<RiverPoint>> getPlacedRivers() {
List<List<RiverPoint>> copy = new ArrayList<>();
for (List<RiverPoint> river : rivers) {
if (river != null && river.size() >= 2) {
copy.add(new ArrayList<>(river));
}
}
return copy;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void clearAll() {
for (List<Geometry> geos : pointGeos) {
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
}
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) rootNode.detachChild(ribbon);
}
rivers.clear();
pointGeos.clear();
ribbonGeos.clear();
activeRiver = -1;
}
private void removeRiver(int idx) {
if (idx < 0 || idx >= rivers.size()) return;
List<Geometry> geos = pointGeos.get(idx);
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
Geometry ribbon = ribbonGeos.get(idx);
if (ribbon != null) rootNode.detachChild(ribbon);
rivers.remove(idx);
pointGeos.remove(idx);
ribbonGeos.remove(idx);
if (activeRiver > idx) activeRiver--;
else if (activeRiver == idx) activeRiver = -1;
if (selectedRiver > idx) selectedRiver--;
else if (selectedRiver == idx) selectedRiver = -1;
}
private void selectRiver(int idx) {
if (selectedRiver == idx) return;
// Altes Highlight zurücksetzen
if (selectedRiver >= 0 && selectedRiver < ribbonGeos.size()) {
Geometry old = ribbonGeos.get(selectedRiver);
if (old != null)
old.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f));
}
selectedRiver = idx;
if (idx >= 0 && idx < rivers.size()) {
Geometry ribbon = ribbonGeos.get(idx);
if (ribbon != null)
ribbon.getMaterial().setColor("Color", new com.jme3.math.ColorRGBA(1.0f, 0.75f, 0.0f, 0.9f));
List<RiverPoint> pts = rivers.get(idx);
float len = computeLength(pts);
input.selectedRiverInfo = idx + "|" + pts.size() + "|" + String.format(java.util.Locale.ROOT, "%.1f", len);
} else {
input.selectedRiverInfo = null;
}
input.riverSelectionChanged = true;
}
private static float computeLength(List<RiverPoint> pts) {
float len = 0f;
for (int i = 1; i < pts.size(); i++) {
RiverPoint a = pts.get(i - 1), b = pts.get(i);
float dx = b.x() - a.x(), dy = b.y() - a.y(), dz = b.z() - a.z();
len += FastMath.sqrt(dx * dx + dy * dy + dz * dz);
}
return len;
}
/** Gibt den Index des Flusses zurück, dessen nächster Punkt < threshold entfernt liegt, sonst -1. */
private int findNearestRiver(Vector3f worldPos, float threshold) {
float minDist = threshold * threshold;
int best = -1;
for (int i = 0; i < rivers.size(); i++) {
List<RiverPoint> pts = rivers.get(i);
if (pts == null) continue;
for (RiverPoint p : pts) {
float dx = p.x() - worldPos.x;
float dz = p.z() - worldPos.z;
float d2 = dx * dx + dz * dz;
if (d2 < minDist) { minDist = d2; best = i; }
}
}
return best;
}
private void setCullHintAll(Spatial.CullHint hint) {
for (List<Geometry> geos : pointGeos) {
if (geos != null) for (Geometry g : geos) g.setCullHint(hint);
}
for (Geometry ribbon : ribbonGeos) {
if (ribbon != null) ribbon.setCullHint(hint);
}
}
private Geometry buildPointGeo(RiverPoint pt) {
Sphere sphere = new Sphere(8, 8, 0.4f);
Geometry geo = new Geometry("riverPoint", sphere);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
if (pt.isWaterfall()) {
mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 1f));
} else {
mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 1f));
}
geo.setMaterial(mat);
geo.setLocalTranslation(pt.x(), pt.y() + 0.4f, pt.z());
return geo;
}
/**
* Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau).
*/
Geometry buildRibbon(List<RiverPoint> pts) {
pts = RiverSpline.subdivide(pts);
int n = pts.size();
if (n < 2) return null;
int vertCount = n * 2;
FloatBuffer pos = BufferUtils.createFloatBuffer(vertCount * 3);
FloatBuffer norm = BufferUtils.createFloatBuffer(vertCount * 3);
FloatBuffer uv = BufferUtils.createFloatBuffer(vertCount * 2);
IntBuffer idx = BufferUtils.createIntBuffer((n - 1) * 2 * 3);
Vector3f UP = Vector3f.UNIT_Y;
float[] arcLen = new float[n];
arcLen[0] = 0f;
for (int i = 1; i < n; i++) {
RiverPoint a = pts.get(i - 1);
RiverPoint b = pts.get(i);
float dx = b.x() - a.x();
float dz = b.z() - a.z();
float 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);
RiverPoint 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(UP).normalizeLocal();
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y() + 0.05f, pz = pt.z();
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(0f).put(arcLen[i] / UV_SCALE);
pos.put(px + right.x * halfW).put(py).put(pz + right.z * halfW);
norm.put(0f).put(1f).put(0f);
uv.put(1f).put(arcLen[i] / UV_SCALE);
}
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();
Geometry geo = new Geometry("riverRibbon", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.1f, 0.35f, 0.85f, 0.6f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
return geo;
}
}

View File

@@ -143,7 +143,8 @@ public class SceneObjectState extends BaseAppState {
so.getRotY(), so.getRotX(), so.getRotZ(),
so.getScale(), so.solid,
so.getTexturePath(), so.getNormalMapPath(), so.getMaterialPath(),
meshFile, animClips.get(i)));
meshFile, animClips.get(i),
so.castShadow, so.receiveShadow));
}
return list;
}
@@ -165,6 +166,8 @@ public class SceneObjectState extends BaseAppState {
so.setTexturePath(pm.texturePath());
so.setNormalMapPath(pm.normalMapPath());
so.setMaterialPath(pm.materialPath());
so.castShadow = pm.castShadow();
so.receiveShadow = pm.receiveShadow();
objects.add(so);
animClips.add(pm.animClip() != null ? pm.animClip() : "");
@@ -777,7 +780,8 @@ public class SceneObjectState extends BaseAppState {
+ "|" + so.getRotX() + "|" + so.getRotY() + "|" + so.getRotZ()
+ "|" + so.getScale() + "|" + so.getTexturePath()
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
+ "|" + animClips.get(idx);
+ "|" + animClips.get(idx)
+ "|" + so.castShadow + "|" + so.receiveShadow;
} else {
input.selectedObjectInfo = String.valueOf(n);
}
@@ -1220,7 +1224,9 @@ public class SceneObjectState extends BaseAppState {
q.fromAngles(prop.rotX(), prop.rotY(), prop.rotZ());
node.setLocalRotation(q);
so.solid = prop.solid();
so.solid = prop.solid();
so.castShadow = prop.castShadow();
so.receiveShadow = prop.receiveShadow();
boolean appearanceChanged = false;
if (prop.texPath() != null) { so.setTexturePath(prop.texPath()); appearanceChanged = true; }

View File

@@ -26,6 +26,7 @@ import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import com.jme3.util.SkyFactory;
import de.blight.common.EmitterIO;
import de.blight.common.GrassTuftIO;
import de.blight.common.LightIO;
import de.blight.common.MusicAreaIO;
import de.blight.common.SoundAreaIO;
@@ -35,6 +36,8 @@ import de.blight.common.MapIO;
import de.blight.common.PlacedModelIO;
import de.blight.editor.SharedInput;
import de.blight.editor.tool.HeightTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.Reader;
@@ -52,6 +55,8 @@ import java.util.Properties;
public class TerrainEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TerrainEditorState.class);
// ── Terrain-Konstanten ────────────────────────────────────────────────────
private static final int TERRAIN_SIZE = 4096;
private static final int TOTAL_SIZE = TERRAIN_SIZE + 1; // 4097
@@ -77,6 +82,7 @@ public class TerrainEditorState extends BaseAppState {
private TerrainQuad terrain;
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
private Geometry brushIndicator;
private Geometry livePlayerMarker;
private PlacedObjectState placedObjectState;
private SceneObjectState sceneObjState;
private LightState lightState;
@@ -84,6 +90,7 @@ public class TerrainEditorState extends BaseAppState {
private WaterBodyState waterBodyState;
private SoundAreaState soundAreaState;
private MusicAreaState musicAreaState;
private RiverEditorState riverEditorState;
private MapData loadedMapData;
private Node axesGizmo;
@@ -127,9 +134,9 @@ public class TerrainEditorState extends BaseAppState {
if (MapIO.exists()) {
try {
loadedMapData = MapIO.load();
System.out.println("[TerrainEditor] Karte geladen: " + MapIO.getMapPath());
log.info("Karte geladen: {}", MapIO.getMapPath());
} catch (IOException e) {
System.err.println("[TerrainEditor] Karte nicht ladbar: " + e.getMessage());
log.error("Karte nicht ladbar", e);
}
}
loadCameraPrefs();
@@ -154,7 +161,7 @@ public class TerrainEditorState extends BaseAppState {
camYaw = (float) Math.toRadians(parsePref(p, "cam.yaw", 0f));
camPitch = (float) Math.toRadians(parsePref(p, "cam.pitch", (float) Math.toDegrees(DEFAULT_PITCH)));
} catch (IOException e) {
System.err.println("[TerrainEditor] Kamera-Prefs nicht ladbar: " + e.getMessage());
log.warn("Kamera-Prefs nicht ladbar", e);
}
}
@@ -184,10 +191,12 @@ public class TerrainEditorState extends BaseAppState {
// ── Szene aufbauen ────────────────────────────────────────────────────────
private void buildScene() {
input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
rootNode.attachChild(terrain);
input.loadingStatus = "Lade platzierte Objekte...";
placedObjectState = new PlacedObjectState(input, loadedMapData);
placedObjectState.setTerrain(terrain);
app.getStateManager().attach(placedObjectState);
@@ -199,10 +208,11 @@ public class TerrainEditorState extends BaseAppState {
var placed = PlacedModelIO.load();
if (!placed.isEmpty()) sceneObjState.loadPlacedModels(placed);
} catch (IOException e) {
System.err.println("[TerrainEditor] Objekte nicht ladbar: " + e.getMessage());
log.error("Objekte nicht ladbar", e);
}
}
input.loadingStatus = "Lade Lichter...";
lightState = app.getStateManager().getState(LightState.class);
if (lightState != null) {
lightState.setTerrain(terrain);
@@ -210,10 +220,11 @@ public class TerrainEditorState extends BaseAppState {
var lights = LightIO.load();
if (!lights.isEmpty()) lightState.loadPlacedLights(lights);
} catch (IOException e) {
System.err.println("[TerrainEditor] Lichter nicht ladbar: " + e.getMessage());
log.error("Lichter nicht ladbar", e);
}
}
input.loadingStatus = "Lade Emitter...";
emitterState = app.getStateManager().getState(EmitterState.class);
if (emitterState != null) {
emitterState.setTerrain(terrain);
@@ -221,21 +232,24 @@ public class TerrainEditorState extends BaseAppState {
var emitters = EmitterIO.load();
if (!emitters.isEmpty()) emitterState.loadPlacedEmitters(emitters);
} catch (IOException e) {
System.err.println("[TerrainEditor] Emitter nicht ladbar: " + e.getMessage());
log.error("Emitter nicht ladbar", e);
}
}
input.loadingStatus = "Lade Wasserflächen...";
waterBodyState = app.getStateManager().getState(WaterBodyState.class);
if (waterBodyState != null) {
waterBodyState.setTerrain(terrain);
waterBodyState.setHeightMap(cachedHeightMap);
try {
var waters = WaterBodyIO.load();
if (!waters.isEmpty()) waterBodyState.loadPlacedBodies(waters);
} catch (IOException e) {
System.err.println("[TerrainEditor] Wasseroberflächen nicht ladbar: " + e.getMessage());
log.error("Wasseroberflächen nicht ladbar", e);
}
}
input.loadingStatus = "Lade Sound-Bereiche...";
soundAreaState = app.getStateManager().getState(SoundAreaState.class);
if (soundAreaState != null) {
soundAreaState.setTerrain(terrain);
@@ -243,10 +257,11 @@ public class TerrainEditorState extends BaseAppState {
var soundAreas = SoundAreaIO.load();
if (!soundAreas.isEmpty()) soundAreaState.loadAreas(soundAreas);
} catch (IOException e) {
System.err.println("[TerrainEditor] Sound-Bereiche nicht ladbar: " + e.getMessage());
log.error("Sound-Bereiche nicht ladbar", e);
}
}
input.loadingStatus = "Lade Musikbereiche...";
musicAreaState = app.getStateManager().getState(MusicAreaState.class);
if (musicAreaState != null) {
musicAreaState.setTerrain(terrain);
@@ -254,10 +269,17 @@ public class TerrainEditorState extends BaseAppState {
var musicAreas = MusicAreaIO.load();
if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas);
} catch (IOException e) {
System.err.println("[TerrainEditor] Musik-Bereiche nicht ladbar: " + e.getMessage());
log.error("Musik-Bereiche nicht ladbar", e);
}
}
riverEditorState = app.getStateManager().getState(RiverEditorState.class);
if (riverEditorState != null) {
riverEditorState.setTerrain(terrain);
riverEditorState.setTerrainEditor(this);
}
input.loadingStatus = "Baue Szene...";
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
if (playToolState != null) playToolState.setTerrain(terrain);
@@ -267,6 +289,9 @@ public class TerrainEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator();
rootNode.attachChild(brushIndicator);
livePlayerMarker = buildLivePlayerMarker();
rootNode.attachChild(livePlayerMarker);
axesGizmo = buildAxesGizmo();
rootNode.attachChild(axesGizmo);
}
@@ -612,6 +637,7 @@ public class TerrainEditorState extends BaseAppState {
processTextureEdits();
updateBrushIndicator();
updateAxesGizmo();
updateLivePlayerMarker();
// Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
@@ -673,6 +699,120 @@ public class TerrainEditorState extends BaseAppState {
return h != null ? h : 0f;
}
// ── Flussbett graben ──────────────────────────────────────────────────────
/**
* Gräbt ein Flussbett zwischen zwei Wasseroberflächenpunkten A und B.
* Alle Terrain-Vertices innerhalb von halfWidth werden auf die linear
* interpolierte Höhe (A→B) minus Kanaltiefen-Offset abgesenkt.
* Am Kanalrand weicher Übergang zurück zur Original-Höhe.
*/
public void carveRiverbedSegment(float ax, float ay, float az,
float bx, float by, float bz,
float halfWidth) {
if (terrain == null || cachedHeightMap == null) return;
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx * segDx + segDz * segDz;
if (segLen2 < 0.001f) return;
// Tiefe proportional zur Breite: 0,5m bei 4m Breite, 1,0m bei 10m Breite
float width = halfWidth * 2f;
float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f));
// ── Terrain-Vertices graben ──────────────────────────────────────────
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vxMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(ax, bx) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - 1) + TERRAIN_SIZE * 0.5f));
int vzMax = Math.min(TOTAL_SIZE - 1, (int)((Math.max(az, bz) + halfWidth + 2) + TERRAIN_SIZE * 0.5f));
List<Vector2f> locs = new ArrayList<>();
List<Float> deltas = new ArrayList<>();
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx - TERRAIN_SIZE * 0.5f;
float worldZ = vz - TERRAIN_SIZE * 0.5f;
float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2;
t = FastMath.clamp(t, 0f, 1f);
float projX = ax + t * segDx, projZ = az + t * segDz;
float dist = FastMath.sqrt((worldX - projX) * (worldX - projX)
+ (worldZ - projZ) * (worldZ - projZ));
if (dist > halfWidth) continue;
float waterY = ay + t * (by - ay);
// U-Form: 60% des Kanals als flacher Boden, danach linearer Anstieg
float norm = dist / halfWidth;
float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f);
float depth = maxDepth * uShape;
float target = waterY - depth;
int idx = vz * TOTAL_SIZE + vx;
float curH = cachedHeightMap[idx];
if (curH > target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
}
}
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
// ── Textur 4 (splatA) malen: Flussbett + 25% Breite pro Seite ────────
if (splatR == null) return;
float paintHW = halfWidth * 1.5f; // +25% Breite auf jeder Seite
float minX = Math.min(ax, bx) - paintHW;
float maxX = Math.max(ax, bx) + paintHW;
float minZ = Math.min(az, bz) - paintHW;
float maxZ = Math.max(az, bz) + paintHW;
int pxMin = Math.max(0, (int)((minX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pxMax = Math.min(SPLAT_SIZE - 1, (int)((maxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
// Z-Achse ist in der Splatmap gespiegelt
int pzMin = Math.max(0, (SPLAT_SIZE - 1) - (int)((maxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pzMax = Math.min(SPLAT_SIZE - 1, (SPLAT_SIZE - 1) - (int)((minZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
boolean splatChanged = false;
for (int pz = pzMin; pz <= pzMax; pz++) {
float worldZ = (SPLAT_SIZE - 1 - pz) * SPLAT_WE_PER_PX - WORLD_HALF;
for (int px = pxMin; px <= pxMax; px++) {
float worldX = px * SPLAT_WE_PER_PX - WORLD_HALF;
float t = ((worldX - ax) * segDx + (worldZ - az) * segDz) / segLen2;
t = FastMath.clamp(t, 0f, 1f);
float projX = ax + t * segDx, projZ = az + t * segDz;
float dist = FastMath.sqrt((worldX - projX) * (worldX - projX)
+ (worldZ - projZ) * (worldZ - projZ));
if (dist > paintHW) continue;
int sidx = pz * SPLAT_SIZE + px;
splatR[sidx] = (byte) 255;
splatG[sidx] = (byte) 0;
splatB[sidx] = (byte) 0;
splatA[sidx] = (byte) 255;
int bi = sidx * 4;
splatBuf.put(bi, (byte) 255);
splatBuf.put(bi + 1, (byte) 0);
splatBuf.put(bi + 2, (byte) 0);
splatBuf.put(bi + 3, (byte) 255);
splatChanged = true;
}
}
if (splatChanged) {
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
}
// ── Speichern ─────────────────────────────────────────────────────────────
private void performSave() {
@@ -702,8 +842,13 @@ public class TerrainEditorState extends BaseAppState {
}
if (placedObjectState != null) {
System.arraycopy(placedObjectState.getDensityMap(), 0,
data.grassDensity, 0, data.grassDensity.length);
try {
GrassTuftIO.save(new GrassTuftIO.GrassData(
placedObjectState.getSlotPaths(),
placedObjectState.getAllTufts()));
} catch (IOException e) {
log.error("Gras nicht speicherbar", e);
}
}
MapIO.save(data);
@@ -719,6 +864,9 @@ public class TerrainEditorState extends BaseAppState {
if (waterBodyState != null) {
WaterBodyIO.save(waterBodyState.getPlacedBodies());
}
if (riverEditorState != null) {
de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers());
}
if (soundAreaState != null) {
SoundAreaIO.save(soundAreaState.getPlacedAreas());
}
@@ -726,10 +874,10 @@ public class TerrainEditorState extends BaseAppState {
MusicAreaIO.save(musicAreaState.getPlacedAreas());
}
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
System.out.println("[TerrainEditor] " + input.saveStatusMsg);
log.info("{}", input.saveStatusMsg);
} catch (IOException e) {
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
System.err.println("[TerrainEditor] " + input.saveStatusMsg);
log.error("Speichern fehlgeschlagen", e);
}
}
@@ -820,6 +968,7 @@ public class TerrainEditorState extends BaseAppState {
Vector3f contact = hits.getClosestCollision().getContactPoint();
int mode = input.heightTool.mode.getSelectedIndex();
boolean terrainChanged = true;
if (mode == HeightTool.MODE_SMOOTH) {
smoothHeight(contact);
} else if (mode == HeightTool.MODE_PLATEAU) {
@@ -830,6 +979,7 @@ public class TerrainEditorState extends BaseAppState {
input.heightTool.plateauHeight.setValue(h);
input.heightTool.plateauHeightChanged = true;
}
terrainChanged = false;
} else {
// Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen
flattenToPlateauHeight(contact);
@@ -838,6 +988,10 @@ public class TerrainEditorState extends BaseAppState {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode);
}
if (terrainChanged && waterBodyState != null) {
float r = (float) input.heightTool.brushRadius.getValue();
waterBodyState.invalidateNear(contact.x, contact.z, r);
}
}
if (processed > 0) terrain.updateModelBound();
}
@@ -1224,4 +1378,28 @@ public class TerrainEditorState extends BaseAppState {
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Live-Spieler-Marker ───────────────────────────────────────────────────
private Geometry buildLivePlayerMarker() {
com.jme3.scene.shape.Cylinder cyl =
new com.jme3.scene.shape.Cylinder(8, 8, 0.3f, 1.8f, true);
Geometry geo = new Geometry("livePlayerMarker", cyl);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.45f, 0f, 1f));
geo.setMaterial(mat);
// Cylinder-Achse liegt entlang Y → keine Rotation nötig
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
private void updateLivePlayerMarker() {
float x = input.livePlayerX;
if (Float.isNaN(x)) {
livePlayerMarker.setCullHint(Spatial.CullHint.Always);
} else {
livePlayerMarker.setCullHint(Spatial.CullHint.Inherit);
livePlayerMarker.setLocalTranslation(x, input.livePlayerY + 0.9f, input.livePlayerZ);
}
}
}

View File

@@ -292,14 +292,11 @@ public class TreeGeneratorState extends BaseAppState {
app.getRenderer().readFrameBuffer(captureFB, pixels);
cleanupCapture();
String baseName = pendingRequest.exportName();
String exportName = pendingRequest.exportAfter()
? baseName + "_" + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now())
: baseName;
String treeType = pendingRequest.treeType();
String timestamp = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
Texture2D impostorTex = saveImpostor(pixels, "impostor_" + exportName);
Texture2D impostorTex = saveImpostor(pixels, "impostor_" + treeType + "_" + timestamp);
// HD-Mesh im Dialog-Preview anzeigen (keine LOD-Umschaltung, kein Welt-Platzierung)
Node previewTree = makeTreeNode(pendingHdResult,
pendingBarkMat.clone(), pendingLeafMat.clone(), "prev");
previewTreeHolder.detachAllChildren();
@@ -311,10 +308,11 @@ public class TreeGeneratorState extends BaseAppState {
Math.max(bb.getYExtent(), bb.getZExtent())) * 3f;
if (pendingRequest.exportAfter()) {
float treeHeight = bb.getCenter().y + bb.getYExtent();
Node treeNode = assembleLodNode(impostorTex);
exportTree(treeNode, exportName);
exportTree(treeNode, treeType, timestamp, treeHeight);
} else {
input.treeGenStatusMsg = "Vorschau: '" + baseName + "'";
input.treeGenStatusMsg = "Vorschau: " + treeType;
}
pendingRequest = null;
@@ -328,7 +326,7 @@ public class TreeGeneratorState extends BaseAppState {
// ── LOD-Aufbau ────────────────────────────────────────────────────────────
private Node assembleLodNode(Texture2D impostorTex) {
Node root = new Node("GeneratedTree_" + pendingRequest.exportName());
Node root = new Node(pendingRequest.treeType());
root.attachChild(pendingHdNode);
root.attachChild(pendingLdNode);
@@ -557,18 +555,19 @@ public class TreeGeneratorState extends BaseAppState {
// ── .j3o-Export ───────────────────────────────────────────────────────────
private void exportTree(Node treeNode, String name) {
private void exportTree(Node treeNode, String treeType, String timestamp, float height) {
try {
Path modelDir = ASSET_ROOT.resolve("Models");
Files.createDirectories(modelDir);
File out = modelDir.resolve("GeneratedTree_" + name + ".j3o").toFile();
// Strip runtime controls before export — they lack no-arg constructors
// and cannot be deserialized by BinaryImporter.
String sizeClass = height < 6f ? "small" : height < 14f ? "medium" : "large";
String fileName = treeType + "_" + sizeClass + "_" + timestamp;
Path dir = ASSET_ROOT.resolve("Models").resolve("trees")
.resolve(treeType).resolve(sizeClass);
Files.createDirectories(dir);
File out = dir.resolve(fileName + ".j3o").toFile();
while (treeNode.getNumControls() > 0)
treeNode.removeControl(treeNode.getControl(0));
BinaryExporter.getInstance().save(treeNode, out);
log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Exportiert: " + out.getName();
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o";
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage());

View File

@@ -10,8 +10,10 @@ import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Quad;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedWater;
@@ -19,17 +21,25 @@ import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
/**
* Platziert und visualisiert Wasserflächen per Flood-Fill aus dem Gelände.
*
* Raster: 2 WE pro Pixel (WATER_GRID = 2049, STEP = 2).
* BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen.
*/
public class WaterBodyState extends BaseAppState {
private static final ColorRGBA WATER_COLOR = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.52f);
private static final ColorRGBA BORDER_COLOR = new ColorRGBA(0.30f, 0.60f, 1.00f, 0.85f);
private static final ColorRGBA BORDER_SEL = new ColorRGBA(1.00f, 1.00f, 0.00f, 1.00f);
// Flood-Fill-Raster mit 1 WE Auflösung (= volle HeightMap-Auflösung)
private static final int TOTAL_VERTS = 4097;
private static final int WATER_GRID = 4097; // (4096 / STEP) + 1
private static final int STEP = 1; // WE pro Gitterpixel
private static final int WORLD_HALF = 2048;
private static final int MAX_CELLS = 200_000;
private static final String GEO_SURFACE = "water_surface";
private static final String GEO_BORDER = "water_border";
private static final ColorRGBA COLOR_WATER = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.50f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(0.20f, 0.60f, 1.00f, 0.70f);
private final SharedInput input;
private SimpleApplication app;
@@ -37,17 +47,20 @@ public class WaterBodyState extends BaseAppState {
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private float[] heightMap;
// parallel lists
private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Set<Integer>> cellSets = new ArrayList<>();
private final List<Geometry> geos = new ArrayList<>();
private final List<float[]> bodyBounds = new ArrayList<>(); // {minX,minZ,maxX,maxZ}
private int selectedIdx = -1;
private List<PlacedWater> pendingBodies = null;
private int selectedIdx = -1;
private List<PlacedWater> pendingLoad = null;
public WaterBodyState(SharedInput input) {
this.input = input;
}
public WaterBodyState(SharedInput input) { this.input = input; }
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -63,16 +76,11 @@ public class WaterBodyState extends BaseAppState {
@Override
protected void onEnable() {
if (pendingBodies != null) {
loadPlacedBodies(pendingBodies);
pendingBodies = null;
}
if (pendingLoad != null) { loadPlacedBodies(pendingLoad); pendingLoad = null; }
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
@@ -80,14 +88,10 @@ public class WaterBodyState extends BaseAppState {
if (input.activeLayer != SharedInput.LAYER_WATER) return;
SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) {
handleClick(click);
}
while ((click = input.waterClickQueue.poll()) != null) handleClick(click);
PlacedWater pending = input.pendingWater.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight());
if (input.deleteWaterRequested) {
input.deleteWaterRequested = false;
@@ -95,57 +99,56 @@ public class WaterBodyState extends BaseAppState {
}
}
// ── Click handling ────────────────────────────────────────────────────────
// ── Click-Handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.WaterClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
int hit = pickMarker(ray);
if (hit >= 0) {
if (click.rightButton()) deselect();
else selectBody(hit);
return;
}
if (click.rightButton()) { deselect(); return; }
int hit = pickBody(ray);
if (hit >= 0) { selectBody(hit); return; }
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
addBody(new PlacedWater(pt.x, pt.y + 0.05f, pt.z, 30f, 30f));
Set<Integer> cells = floodFill(pt.x, pt.z, pt.y);
if (cells == null) {
input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle.";
return;
}
addBody(new PlacedWater(pt.x, pt.z, pt.y), cells);
selectBody(bodies.size() - 1);
}
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
private int pickBody(Ray ray) {
for (int i = 0; i < geos.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
geos.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
// ── Selektion ─────────────────────────────────────────────────────────────
private void selectBody(int idx) {
deselect();
selectedIdx = idx;
setBorderColor(idx, BORDER_SEL);
geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < bodies.size()) {
setBorderColor(selectedIdx, BORDER_COLOR);
}
if (selectedIdx >= 0 && selectedIdx < geos.size())
geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
@@ -154,120 +157,196 @@ public class WaterBodyState extends BaseAppState {
private void publishSelection(int idx) {
PlacedWater b = bodies.get(idx);
input.selectedWaterInfo = String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%.3f",
idx, b.x(), b.y(), b.z(), b.width(), b.depth());
"%d|%.3f|%.3f|%.3f|%d",
idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size());
input.waterSelectionChanged = true;
}
// ── Add / Remove ──────────────────────────────────────────────────────────
// ── Hinzufügen / Entfernen ────────────────────────────────────────────────
private void addBody(PlacedWater b) {
Node marker = buildMarker(b);
rootNode.attachChild(marker);
markers.add(marker);
bodies.add(b);
private void addBody(PlacedWater body, Set<Integer> cells) {
Geometry geo = buildWaterGeo(cells, body.waterHeight());
rootNode.attachChild(geo);
bodies.add(body);
cellSets.add(cells);
geos.add(geo);
bodyBounds.add(computeBounds(cells));
}
private void removeBody(int idx) {
rootNode.detachChild(markers.get(idx));
rootNode.detachChild(geos.get(idx));
bodies.remove(idx);
markers.remove(idx);
cellSets.remove(idx);
geos.remove(idx);
bodyBounds.remove(idx);
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
private void clearAll() {
for (Node m : markers) rootNode.detachChild(m);
for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g);
bodies.clear();
markers.clear();
cellSets.clear();
geos.clear();
bodyBounds.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, PlacedWater updated) {
rootNode.detachChild(markers.get(idx));
Node newMarker = buildMarker(updated);
setBorderColorOnNode(newMarker, BORDER_SEL);
rootNode.attachChild(newMarker);
markers.set(idx, newMarker);
bodies.set(idx, updated);
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
private Node buildMarker(PlacedWater b) {
// Water surface (semi-transparent quad)
Quad quad = new Quad(b.width(), b.depth());
Geometry surface = new Geometry(GEO_SURFACE, quad);
Material waterMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
waterMat.setColor("Color", WATER_COLOR);
waterMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
waterMat.getAdditionalRenderState().setDepthWrite(false);
surface.setMaterial(waterMat);
surface.setQueueBucket(RenderQueue.Bucket.Transparent);
surface.rotate(-FastMath.HALF_PI, 0, 0);
surface.setLocalTranslation(-b.width() * 0.5f, 0f, b.depth() * 0.5f);
// Border outline (Line mesh forming a rectangle)
Geometry border = new Geometry(GEO_BORDER, buildBorderMesh(b.width(), b.depth()));
Material borderMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
borderMat.setColor("Color", BORDER_COLOR);
borderMat.getAdditionalRenderState().setDepthTest(false);
border.setMaterial(borderMat);
Node node = new Node("water_node");
node.attachChild(surface);
node.attachChild(border);
node.setLocalTranslation(b.x(), b.y(), b.z());
return node;
}
private static Mesh buildBorderMesh(float w, float d) {
// 4 corner points at +0.02 above water surface (local coords, XZ plane)
float hw = w * 0.5f, hd = d * 0.5f, y = 0.02f;
FloatBuffer pos = BufferUtils.createFloatBuffer(4 * 3);
pos.put(-hw).put(y).put(-hd);
pos.put( hw).put(y).put(-hd);
pos.put( hw).put(y).put( hd);
pos.put(-hw).put(y).put( hd);
IntBuffer idx = BufferUtils.createIntBuffer(8); // 4 edges
idx.put(0).put(1).put(1).put(2).put(2).put(3).put(3).put(0);
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 2, idx);
mesh.updateBound();
return mesh;
}
private void setBorderColor(int idx, ColorRGBA color) {
setBorderColorOnNode(markers.get(idx), color);
}
private static void setBorderColorOnNode(Node node, ColorRGBA color) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && GEO_BORDER.equals(geo.getName())) {
geo.getMaterial().setColor("Color", color);
return;
/**
* Löscht alle Wasserflächen, deren AABB den übergebenen Pinselkreis berührt.
* Wird von TerrainEditorState nach jeder Geländeänderung aufgerufen.
*/
public void invalidateNear(float worldX, float worldZ, float brushRadius) {
for (int i = bodies.size() - 1; i >= 0; i--) {
float[] b = bodyBounds.get(i);
// Nächster Punkt auf AABB zum Kreismittelpunkt
float nearX = Math.max(b[0], Math.min(worldX, b[2]));
float nearZ = Math.max(b[1], Math.min(worldZ, b[3]));
float dx = worldX - nearX, dz = worldZ - nearZ;
if (dx * dx + dz * dz <= brushRadius * brushRadius) {
removeBody(i);
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedWater> getPlacedBodies() {
return new ArrayList<>(bodies);
private static float[] computeBounds(Set<Integer> cells) {
float minX = Float.MAX_VALUE, minZ = Float.MAX_VALUE;
float maxX = -Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
for (int cell : cells) {
float wx = (float)((cell % WATER_GRID) * STEP) - WORLD_HALF;
float wz = (float)((cell / WATER_GRID) * STEP) - WORLD_HALF;
if (wx < minX) minX = wx;
if (wz < minZ) minZ = wz;
if (wx + STEP > maxX) maxX = wx + STEP;
if (wz + STEP > maxZ) maxZ = wz + STEP;
}
return new float[]{minX, minZ, maxX, maxZ};
}
public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) {
pendingBodies = new ArrayList<>(loaded);
// ── Höhe ändern ───────────────────────────────────────────────────────────
private void applyHeightChange(int idx, float newHeight) {
PlacedWater b = bodies.get(idx);
Set<Integer> newCells = floodFill(b.seedX(), b.seedZ(), newHeight);
if (newCells == null) {
input.waterHint = "Ungültige Höhe Becken bei dieser Höhe nicht eingeschlossen.";
return;
}
rootNode.detachChild(geos.get(idx));
Geometry newGeo = buildWaterGeo(newCells, newHeight);
newGeo.getMaterial().setColor("Color", COLOR_SELECTED);
rootNode.attachChild(newGeo);
bodies.set(idx, new PlacedWater(b.seedX(), b.seedZ(), newHeight));
cellSets.set(idx, newCells);
geos.set(idx, newGeo);
publishSelection(idx);
}
// ── 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));
if (sampleHeight(seedPX, seedPZ) > waterHeight + 0.05f) 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) <= 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) {
if (heightMap != null) {
int vx = Math.min(px * STEP, TOTAL_VERTS - 1);
int vz = Math.min(pz * STEP, TOTAL_VERTS - 1);
return heightMap[vz * TOTAL_VERTS + vx];
}
if (terrain != null) {
float worldX = px * STEP - WORLD_HALF;
float worldZ = pz * STEP - WORLD_HALF;
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
return (h != null && !Float.isNaN(h)) ? h : Float.MAX_VALUE;
}
return Float.MAX_VALUE;
}
// ── Mesh-Aufbau (für Editor-Vorschau) ────────────────────────────────────
private Geometry buildWaterGeo(Set<Integer> cells, float waterHeight) {
int n = cells.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 4 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(n * 6);
int vi = 0;
float h = waterHeight + 0.05f;
for (int cell : cells) {
int pz = cell / WATER_GRID;
int px = cell % WATER_GRID;
float wx = px * STEP - WORLD_HALF;
float wz = 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);
idx.put(vi).put(vi+1).put(vi+2);
idx.put(vi).put(vi+2).put(vi+3);
vi += 4;
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("water_body", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", COLOR_WATER);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthWrite(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
return geo;
}
// ── Speichern / Laden ─────────────────────────────────────────────────────
public List<PlacedWater> getPlacedBodies() { return new ArrayList<>(bodies); }
public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) { pendingLoad = new ArrayList<>(loaded); return; }
clearAll();
for (PlacedWater b : loaded) addBody(b);
for (PlacedWater b : loaded) {
Set<Integer> cells = floodFill(b.seedX(), b.seedZ(), b.waterHeight());
if (cells != null) {
addBody(b, cells);
} else {
System.err.println("[WaterBodyState] Becken nicht rekonstruierbar: "
+ b.seedX() + "/" + b.seedZ());
}
}
}
}

View File

@@ -9,7 +9,7 @@ public class GrassTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 40.0, 1.0, 500.0);
public final ToolParameter grassHeight = new ToolParameter("Grashöhe", 1.5, 0.1, 10.0);
public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 50.0);
public final ToolParameter density = new ToolParameter("Dichte", 8.0, 1.0, 200.0);
@Override public String getName() { return "Gras"; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB