Commit vor großem Terrain refactoring

This commit is contained in:
2026-06-08 08:42:45 +02:00
parent 7faed35287
commit 1297869dfa
119 changed files with 9784 additions and 1614 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,10 @@ import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.state.AnimPreviewState;
import de.blight.editor.state.AreaState;
import de.blight.editor.state.ModelEditorState;
import de.blight.editor.state.EmitterState;
import de.blight.editor.state.MusicAreaState;
import de.blight.editor.state.LocationZoneState;
import de.blight.editor.state.RiverEditorState;
import de.blight.editor.state.PlayToolState;
import de.blight.editor.state.SoundAreaState;
@@ -93,10 +95,12 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new EmitterState(input));
stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(input));
stateManager.attach(new MusicAreaState(input));
stateManager.attach(new AreaState(input));
stateManager.attach(new LocationZoneState(input));
stateManager.attach(new RiverEditorState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input));
stateManager.attach(new ModelEditorState(input));
input.loadingStatus = "Initialisiere Konsole...";
jmeConsole = new JmeConsole(false);

View File

@@ -2,6 +2,7 @@ package de.blight.editor;
import de.blight.editor.tool.EditorTool;
import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.GrassVertexTool;
import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.TextureTool;
@@ -21,6 +22,7 @@ public class SharedInput {
public final HeightTool heightTool = new HeightTool();
public final UpperHeightTool upperHeightTool = new UpperHeightTool();
public final GrassTool grassTool = new GrassTool();
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool;
@@ -62,11 +64,18 @@ public class SharedInput {
public record TerrainEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TerrainEdit> editQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Edits ────────────────────────────────────────────────────────────
// ── Gras (Textur) Edits ───────────────────────────────────────────────────
/** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */
public record GrassEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassEdit> grassEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras (Vertices) ───────────────────────────────────────────────────────
/** activeLayer==15 → Vertex-Gras (X-Quad-Halme) platzieren und entfernen */
public static final int LAYER_GRASS_VERTEX = 15;
public record GrassVertexEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassVertexEdit> grassVertexEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Einstellungen (JavaFX → JME3) ───────────────────────────────────
/** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */
public volatile String grassTexturePath = "";
@@ -125,6 +134,10 @@ public class SharedInput {
// 0 = keine Änderung, 1 = Drahtgitter aktivieren, 2 = Textur-Modus aktivieren
public volatile int wireframeRequest = 0;
// ── Topologie-Overlay ─────────────────────────────────────────────────────
// 0 = keine Änderung, 1 = aktivieren, 2 = deaktivieren
public volatile int topologyRequest = 0;
// ── Vorschau-Viewport (gemeinsam für Baum-Generator & EZ-Tree) ───────────
public volatile float treePreviewRotY = 0f; // Yaw-Winkel in Grad
public volatile float treePreviewRotX = 30f; // Elevation in Grad [5, 80]
@@ -339,49 +352,55 @@ public class SharedInput {
/** activeLayer==10 → Sound-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_SOUND_AREAS = 10;
// ── Musik-Bereiche ────────────────────────────────────────────────────────
/** activeLayer==11 → Musik-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_MUSIC_AREAS = 11;
// ── Bereiche (Areas) ──────────────────────────────────────────────────────
/** activeLayer==11 → Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_AREAS = 11;
// ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** 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;
/** activeLayer==13 → Wasserfälle platzieren */
public static final int LAYER_WATERFALL = 13;
// ── Fluss-Werkzeug ─────────────────────────────────────────────────────────
public record RiverClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<RiverClick> riverClickQueue = new ConcurrentLinkedQueue<>();
public volatile float riverNewWidth = 8.0f; // Breite des nächsten Punktes (Minimum 8m)
public volatile float riverNewSpeed = 0.4f; // UV-Geschwindigkeit (0.4=Fluss, 3.0=Wasserfall)
public volatile boolean undoRiverPointRequested = false;
public volatile String riverHint = null;
// ── Wasserfall-Werkzeug ────────────────────────────────────────────────────
public record WaterfallClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<WaterfallClick> waterfallClickQueue = new ConcurrentLinkedQueue<>();
public volatile float waterfallNewWidth = 8.0f;
public volatile boolean undoWaterfallPointRequested = false;
/** 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;
/** JME → JavaFX: Info des selektierten Wasserfalls. Format: "idx|numPoints|totalLengthM" oder null. */
public volatile String selectedWaterfallInfo = null;
public volatile boolean waterfallSelectionChanged = false;
/** JavaFX → JME: Selektierten Wasserfall löschen. */
public volatile boolean deleteWaterfallRequested = false;
/** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */
// ── Wasser-Werkzeug (Polygon) ─────────────────────────────────────────────
/** Klick im Viewport im Wasser-Modus: Punkt setzen 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|seedX|seedZ|waterHeight|cellCount" oder null.
* JME → JavaFX: Info der selektierten Wasserfläche.
* Format: "idx|waterHeight|pointCount" oder null.
*/
public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false;
/** JME → JavaFX: Hinweis wenn Platzierung oder Höhenänderung fehlschlägt. */
public volatile String waterHint = null;
/** JavaFX → JME: Spacetaste → Terrain-Höhe an Cursor-Position als aktuelle Wasserhöhe übernehmen. */
public volatile boolean waterSampleHeightRequested = false;
/** JME → JavaFX: Aktuelle Platzierungs-Höhe (wird per Space aktualisiert oder beim ersten Punkt gesetzt). */
public volatile float waterCurrentHeight = 0f;
/** JME → JavaFX: Höhe wurde aktualisiert → JavaFX soll UI-Label aktualisieren. */
public volatile boolean waterHeightChanged = false;
/** JavaFX → JME: aktualisierte Wasserhöhe der selektierten Fläche. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>();
/** JavaFX → JME: neue Höhe r selektierte Wasserfläche. NaN = kein Auftrag. */
public final AtomicReference<Float> pendingWaterHeight = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
/** JavaFX → JME: neue Fließrichtung (Grad 0359) für selektierte Wasserfläche. null = kein Auftrag. */
public final AtomicReference<Float> pendingWaterFlowDegrees = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasserfläche löschen. */
public volatile boolean deleteWaterRequested = false;
// ── Sound-Bereich-Werkzeug ────────────────────────────────────────────────
@@ -398,19 +417,38 @@ public class SharedInput {
/** JavaFX → JME: Selektierten Sound-Bereich löschen. */
public volatile boolean deleteSoundAreaRequested = false;
// ── Musik-Bereich-Werkzeug ───────────────────────────────────────────────
public record MusicAreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<MusicAreaClick> musicAreaClickQueue = new ConcurrentLinkedQueue<>();
// ── Bereich-Werkzeug (Area) ───────────────────────────────────────────────
public record AreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<AreaClick> areaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Musik-Bereichs. Format: "idx|dayTrack|nightTrack|combatTrack" oder null. */
public volatile String selectedMusicAreaInfo = null;
public volatile boolean musicAreaSelectionChanged = false;
/** JME → JavaFX: Info des selektierten Bereichs. Format: "idx|nameId|dayTrack|nightTrack|combatTrack" oder null. */
public volatile String selectedAreaInfo = null;
public volatile boolean areaSelectionChanged = false;
/** JME → JavaFX: letztes Polygon wurde wegen Überschneidung abgelehnt. */
public volatile boolean areaOverlapRejected = false;
/** JavaFX → JME: aktualisierte Parameter des selektierten Musik-Bereichs. */
public final AtomicReference<de.blight.common.PlacedMusicArea> pendingMusicArea = new AtomicReference<>();
/** JavaFX → JME: aktualisierte Parameter des selektierten Bereichs. */
public final AtomicReference<de.blight.common.PlacedArea> pendingArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Musik-Bereich löschen. */
public volatile boolean deleteMusicAreaRequested = false;
/** JavaFX → JME: Selektierten Bereich löschen. */
public volatile boolean deleteAreaRequested = false;
// ── Location-Zonen-Werkzeug ───────────────────────────────────────────────
/** activeLayer==14 → Location-Zonen (Polygon) platzieren und bearbeiten */
public static final int LAYER_LOCATION_ZONES = 14;
public record LocationZoneClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<LocationZoneClick> locationZoneClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info der selektierten Location-Zone. Format: "idx|nameId" oder null. */
public volatile String selectedLocationZoneInfo = null;
public volatile boolean locationZoneSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter der selektierten Location-Zone. */
public final AtomicReference<de.blight.common.PlacedLocationZone> pendingLocationZone = new AtomicReference<>();
/** JavaFX → JME: Selektierte Location-Zone löschen. */
public volatile boolean deleteLocationZoneRequested = false;
/** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */
public volatile boolean cancelZoneDrawing = false;
@@ -518,4 +556,30 @@ public class SharedInput {
}
public final ConcurrentLinkedQueue<ModelConvertRequest> modelConvertQueue =
new ConcurrentLinkedQueue<>();
// ── Modell-Editor ─────────────────────────────────────────────────────────
/** activeLayer==20 → Modell-Editor (3-D-Vorschau + Metadaten-Konfiguration) */
public static final int LAYER_MODEL_EDITOR = 20;
/** JFX → JME: Pfad des zu ladenden Modells (relativ zu blight-assets/src/main/resources/). */
public volatile String modelEditorOpenPath = null;
/** JME → JFX: Boundingbox des skalierten Modells (W=X, H=Y, D=Z in Metern). */
public volatile float modelEditorBoundsW = 0f;
public volatile float modelEditorBoundsH = 0f;
public volatile float modelEditorBoundsD = 0f;
public volatile boolean modelEditorBoundsReady = false;
/** JFX → JME: aktuelle Skalierung (Echtzeit-Vorschau). */
public volatile float modelEditorScaleX = 1f;
public volatile float modelEditorScaleY = 1f;
public volatile float modelEditorScaleZ = 1f;
public volatile boolean modelEditorScaleChanged = false;
/** JFX → JME: Pivot-Y-Versatz (Echtzeit). */
public volatile float modelEditorPivotY = 0f;
public volatile boolean modelEditorPivotChanged = false;
/** JFX → JME: Model-Editor schließen. */
public volatile boolean modelEditorCloseRequest = false;
}

View File

@@ -12,20 +12,21 @@ import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedMusicArea;
import de.blight.common.PlacedArea;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class MusicAreaState extends BaseAppState {
public class AreaState extends BaseAppState {
private static final float LINE_OFFSET_Y = 0.35f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.5f, 0.3f, 1f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.8f, 0.5f, 1f, 1f);
private static final ColorRGBA COLOR_OVERLAP = new ColorRGBA(1f, 0.2f, 0.2f, 1f);
private final SharedInput input;
private SimpleApplication app;
@@ -34,19 +35,19 @@ public class MusicAreaState extends BaseAppState {
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedMusicArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
private final List<PlacedArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedMusicArea> pendingAreas = null;
private List<PlacedArea> pendingAreas = null;
public MusicAreaState(SharedInput input) {
public AreaState(SharedInput input) {
this.input = input;
}
@@ -78,17 +79,17 @@ public class MusicAreaState extends BaseAppState {
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_MUSIC_AREAS) {
if (input.activeLayer != SharedInput.LAYER_AREAS) {
if (placing) cancelPoly();
return;
}
SharedInput.MusicAreaClick click;
while ((click = input.musicAreaClickQueue.poll()) != null) {
SharedInput.AreaClick click;
while ((click = input.areaClickQueue.poll()) != null) {
handleClick(click);
}
PlacedMusicArea pending = input.pendingMusicArea.getAndSet(null);
PlacedArea pending = input.pendingArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
@@ -98,15 +99,15 @@ public class MusicAreaState extends BaseAppState {
if (placing) cancelPoly();
}
if (input.deleteMusicAreaRequested) {
input.deleteMusicAreaRequested = false;
if (input.deleteAreaRequested) {
input.deleteAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx);
}
}
// Click handling
private void handleClick(SharedInput.MusicAreaClick click) {
private void handleClick(SharedInput.AreaClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
@@ -146,7 +147,7 @@ public class MusicAreaState extends BaseAppState {
updateInProgressGeo();
} else {
for (int i = 0; i < areas.size(); i++) {
PlacedMusicArea a = areas.get(i);
PlacedArea a = areas.get(i);
if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i);
return;
@@ -165,7 +166,7 @@ public class MusicAreaState extends BaseAppState {
private float[] snapVertex(float x, float z) {
float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST;
float bx = x, bz = z;
for (PlacedMusicArea a : areas) {
for (PlacedArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i];
@@ -186,7 +187,15 @@ public class MusicAreaState extends BaseAppState {
if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedMusicArea area = new PlacedMusicArea(xs, zs, "", "", "");
if (overlapsExistingAreas(xs, zs)) {
// Flash the in-progress polygon red briefly to indicate rejection
if (inProgGeo != null) inProgGeo.getMaterial().setColor("Color", COLOR_OVERLAP);
input.areaOverlapRejected = true;
return;
}
PlacedArea area = new PlacedArea(xs, zs, "", "", "", "");
addArea(area);
selectArea(areas.size() - 1);
cancelPoly();
@@ -204,7 +213,7 @@ public class MusicAreaState extends BaseAppState {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("music_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
inProgGeo = buildLineGeo("area_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
@@ -232,7 +241,7 @@ public class MusicAreaState extends BaseAppState {
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("music_lastpoint", mesh);
lastPointMarker = new Geometry("area_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
@@ -253,23 +262,23 @@ public class MusicAreaState extends BaseAppState {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
input.selectedAreaInfo = null;
input.areaSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedMusicArea a = areas.get(idx);
input.selectedMusicAreaInfo = idx + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack();
input.musicAreaSelectionChanged = true;
PlacedArea a = areas.get(idx);
input.selectedAreaInfo = idx + "|" + a.nameId() + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack();
input.areaSelectionChanged = true;
}
// Add / Remove / Apply
private void addArea(PlacedMusicArea area) {
private void addArea(PlacedArea area) {
areas.add(area);
List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ());
Geometry geo = buildLineGeo("music_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
Geometry geo = buildLineGeo("area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
areaGeos.add(geo);
}
@@ -279,8 +288,8 @@ public class MusicAreaState extends BaseAppState {
areas.remove(idx);
areaGeos.remove(idx);
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
input.selectedAreaInfo = null;
input.areaSelectionChanged = true;
}
private void clearAll() {
@@ -291,12 +300,12 @@ public class MusicAreaState extends BaseAppState {
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedMusicArea updated) {
private void applyProperty(int idx, PlacedArea updated) {
if (updated.pointsX().length == 0) {
PlacedMusicArea existing = areas.get(idx);
areas.set(idx, new PlacedMusicArea(
PlacedArea existing = areas.get(idx);
areas.set(idx, new PlacedArea(
existing.pointsX(), existing.pointsZ(),
updated.dayTrack(), updated.nightTrack(), updated.combatTrack()));
updated.nameId(), updated.dayTrack(), updated.nightTrack(), updated.combatTrack()));
} else {
areas.set(idx, updated);
}
@@ -327,19 +336,60 @@ public class MusicAreaState extends BaseAppState {
return geo;
}
// Overlap detection
private boolean overlapsExistingAreas(float[] xs, float[] zs) {
for (PlacedArea existing : areas) {
float[] ex = existing.pointsX();
float[] ez = existing.pointsZ();
if (polygonsIntersect(xs, zs, ex, ez)) return true;
if (xs.length > 0 && SoundAreaState.pointInPolygon(xs[0], zs[0], ex, ez)) return true;
if (ex.length > 0 && SoundAreaState.pointInPolygon(ex[0], ez[0], xs, zs)) return true;
}
return false;
}
private static boolean polygonsIntersect(float[] axs, float[] azs, float[] bxs, float[] bzs) {
int na = axs.length;
int nb = bxs.length;
for (int i = 0; i < na; i++) {
int i2 = (i + 1) % na;
for (int j = 0; j < nb; j++) {
int j2 = (j + 1) % nb;
if (segmentsIntersect(axs[i], azs[i], axs[i2], azs[i2],
bxs[j], bzs[j], bxs[j2], bzs[j2])) return true;
}
}
return false;
}
private static boolean segmentsIntersect(float ax, float ay, float bx, float by,
float cx, float cy, float dx, float dy) {
float d1 = cross(cx, cy, dx, dy, ax, ay);
float d2 = cross(cx, cy, dx, dy, bx, by);
float d3 = cross(ax, ay, bx, by, cx, cy);
float d4 = cross(ax, ay, bx, by, dx, dy);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0))
&& ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
}
private static float cross(float ax, float ay, float bx, float by, float px, float py) {
return (bx - ax) * (py - ay) - (by - ay) * (px - ax);
}
// Save / Load
public List<PlacedMusicArea> getPlacedAreas() {
public List<PlacedArea> getPlacedAreas() {
return new ArrayList<>(areas);
}
public void loadAreas(List<PlacedMusicArea> loaded) {
public void loadAreas(List<PlacedArea> loaded) {
if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedMusicArea a : loaded) addArea(a);
for (PlacedArea a : loaded) addArea(a);
}
// Helpers

View File

@@ -546,6 +546,8 @@ public class EzTreeState extends BaseAppState {
private void exportTree(Node treeNode, String fileName, String subPath) {
try {
treeNode.setLocalScale(0.33f);
treeNode.updateGeometricState();
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
Files.createDirectories(baseDir);
File out = baseDir.resolve(fileName + ".j3o").toFile();

View File

@@ -0,0 +1,392 @@
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.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
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.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.GrassVertexBlade;
import de.blight.common.GrassVertexIO;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Rendert Vertex-Gras im Editor: 3 leicht geneigte, verjüngte Halme pro Büschel,
* mit korrekten Normalen für Beleuchtung und deterministischer Rotation.
*/
public class GrassVertexState extends BaseAppState {
// ── Chunks ────────────────────────────────────────────────────────────────
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE;
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS;
private static final int MAX_REBUILDS_PER_FRAME = 3;
// ── Geometrie ─────────────────────────────────────────────────────────────
static final int BLADES_PER_TUFT = 3; // Halme pro Büschel
static final int SEGMENTS = 5; // Segmente pro Halm (5 → 6 Reihen)
static final float WIDTH_FACTOR = 0.05f; // Basis-Halbbreite = Höhe × WIDTH_FACTOR
static final float BEND_FACTOR = 0.15f; // max. Krümmungsversatz an der Spitze
static final ColorRGBA ROOT_COLOR = new ColorRGBA(0.08f, 0.34f, 0.04f, 1f);
static final ColorRGBA TIP_COLOR = new ColorRGBA(0.26f, 0.72f, 0.11f, 1f);
static final ColorRGBA DRY_ROOT_COLOR = new ColorRGBA(0.45f, 0.35f, 0.08f, 1f);
static final ColorRGBA DRY_TIP_COLOR = new ColorRGBA(0.82f, 0.74f, 0.16f, 1f);
// ── Zustand ───────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
@SuppressWarnings("unchecked")
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
public GrassVertexState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public List<GrassVertexBlade> getAllBlades() {
List<GrassVertexBlade> all = new ArrayList<>();
for (List<GrassVertexBlade> list : chunkBlades) all.addAll(list);
return all;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.assetManager = app.getAssetManager();
grassNode = new Node("grassVertexNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
material = buildMaterial();
try {
for (GrassVertexBlade b : GrassVertexIO.load()) {
int ci = chunkIndex(b.x(), b.z());
if (ci >= 0) { chunkBlades[ci].add(b); dirtyChunks[ci] = true; }
}
} catch (Exception e) {
System.err.println("[GrassVertexState] Daten nicht ladbar: " + e.getMessage());
}
}
@Override
protected void cleanup(Application app) {
((SimpleApplication) app).getRootNode().detachChild(grassNode);
}
@Override protected void onEnable() { grassNode.setCullHint(Spatial.CullHint.Inherit); }
@Override protected void onDisable() { grassNode.setCullHint(Spatial.CullHint.Always); }
@Override
public void update(float tpf) {
processBrushEdits();
rebuildDirtyChunks();
}
// ── Material ──────────────────────────────────────────────────────────────
private Material buildMaterial() {
try {
Material mat = new Material(assetManager, "MatDefs/GrassVertex.j3md");
mat.setFloat("WindSpeed", 1.0f);
mat.setFloat("WindStrength", 0.15f);
mat.setVector3("SunDir", new Vector3f(0.35f, 0.8f, 0.45f).normalizeLocal());
mat.setColor("SunColor", new ColorRGBA(0.95f, 0.90f, 0.75f, 1.0f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
} catch (Exception e) {
System.err.println("[GrassVertexState] Material nicht ladbar: " + e.getMessage());
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-Interaktion ────────────────────────────────────────────────────
private void processBrushEdits() {
SharedInput.GrassVertexEdit edit;
while ((edit = input.grassVertexEditQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = (float) (edit.screenX() * input.viewportScaleX);
float jmeY = (float) (edit.screenY() * input.viewportScaleY);
Vector3f hit = raycastTerrain(jmeX, jmeY, getApplication().getCamera());
if (hit == null) continue;
if (edit.action() > 0) addBlades(hit);
else removeBlades(hit);
}
}
private Vector3f raycastTerrain(float screenX, float screenY, com.jme3.renderer.Camera cam) {
float flippedY = cam.getHeight() - screenY;
Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f);
Vector3f direction = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f)
.subtractLocal(origin).normalizeLocal();
Ray ray = new Ray(origin, direction);
CollisionResults res = new CollisionResults();
terrain.collideWith(ray, res);
return res.size() == 0 ? null : res.getClosestCollision().getContactPoint();
}
private final Random rng = new Random();
private void addBlades(Vector3f center) {
float radius = (float) input.grassVertexTool.brushRadius.getValue();
float height = (float) input.grassVertexTool.bladeHeight.getValue();
int density = (int) input.grassVertexTool.density.getValue();
for (int i = 0; i < density; i++) {
float angle = rng.nextFloat() * (float) (Math.PI * 2);
float r = rng.nextFloat() * radius;
float bx = center.x + (float) Math.cos(angle) * r;
float bz = center.z + (float) Math.sin(angle) * r;
float by = terrain.getHeight(new Vector2f(bx, bz));
if (Float.isNaN(by)) continue;
float h = height * (0.75f + rng.nextFloat() * 0.5f);
float witherPct = (float) input.grassVertexTool.dryness.getValue() / 100f;
float bladeDryness = rng.nextFloat() < witherPct
? 0.5f + rng.nextFloat() * 0.5f : 0f;
GrassVertexBlade blade = new GrassVertexBlade(bx, by, bz, h, bladeDryness);
int ci = chunkIndex(bx, bz);
if (ci >= 0) { chunkBlades[ci].add(blade); dirtyChunks[ci] = true; }
}
}
public void adjustBladeHeights(Vector3f center, float radius) {
if (terrain == null) return;
float radSq = radius * radius;
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
List<GrassVertexBlade> list = chunkBlades[ci];
boolean changed = false;
for (int i = 0; i < list.size(); i++) {
GrassVertexBlade b = list.get(i);
float dx = b.x() - center.x, dz = b.z() - center.z;
if (dx*dx + dz*dz > radSq) continue;
float newY = terrain.getHeight(new Vector2f(b.x(), b.z()));
if (!Float.isNaN(newY)) {
list.set(i, new GrassVertexBlade(b.x(), newY, b.z(), b.height(), b.dryness()));
changed = true;
}
}
if (changed) dirtyChunks[ci] = true;
}
}
private void removeBlades(Vector3f center) {
float radSq = (float) Math.pow(input.grassVertexTool.brushRadius.getValue(), 2);
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
List<GrassVertexBlade> list = chunkBlades[ci];
int before = list.size();
list.removeIf(b -> {
float dx = b.x() - center.x, dz = b.z() - center.z;
return dx*dx + dz*dz <= radSq;
});
if (list.size() != before) dirtyChunks[ci] = true;
}
}
// ── Chunk-Verwaltung ──────────────────────────────────────────────────────
private int chunkIndex(float x, float z) {
int cx = (int) ((x + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int) ((z + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
private void rebuildDirtyChunks() {
int rebuilt = 0;
for (int ci = 0; ci < CHUNK_COUNT && rebuilt < MAX_REBUILDS_PER_FRAME; ci++) {
if (!dirtyChunks[ci]) continue;
dirtyChunks[ci] = false;
rebuilt++;
rebuildChunk(ci);
}
}
private void rebuildChunk(int ci) {
if (chunkNodes[ci] != null) {
grassNode.detachChild(chunkNodes[ci]);
chunkNodes[ci] = null;
}
List<GrassVertexBlade> blades = chunkBlades[ci];
if (blades.isEmpty()) return;
// 3 Halme × (SEGMENTS+1) Reihen × 2 Verts; 3 × SEGMENTS × 6 Indices
int bladeCount = blades.size();
int vertCount = bladeCount * BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
int indexCount = bladeCount * BLADES_PER_TUFT * SEGMENTS * 6;
float[] positions = new float[vertCount * 3];
float[] normals = new float[vertCount * 3];
float[] colors = new float[vertCount * 4];
float[] texCoords = new float[vertCount * 2];
int[] indices = new int[indexCount];
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(positions));
mesh.setBuffer(VertexBuffer.Type.Normal, 3, BufferUtils.createFloatBuffer(normals));
mesh.setBuffer(VertexBuffer.Type.Color, 4, BufferUtils.createFloatBuffer(colors));
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(texCoords));
mesh.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createIntBuffer(indices));
mesh.updateBound();
Geometry geo = new Geometry("gv_" + ci, mesh);
geo.setMaterial(material);
Node node = new Node("gvc_" + ci);
node.attachChild(geo);
chunkNodes[ci] = node;
grassNode.attachChild(node);
}
// ── Mesh-Generierung (gemeinsame Logik, auch von GrassVertexRenderState genutzt) ──
static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx,
int vi, int ii, GrassVertexBlade blade) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float baseHW = h * WIDTH_FACTOR * 0.5f;
float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
for (int b = 0; b < BLADES_PER_TUFT; b++) {
float bf = b;
float jitter = (hash(x + bf * 13.7f, z + bf * 19.3f) - 0.5f) * 0.4f;
float bladeAngle = tAngle + b * (float) (Math.PI * 2.0 / BLADES_PER_TUFT) + jitter;
float leanMag = 0.25f + hash(x + bf * 7.3f, z + bf * 3.1f) * 0.55f;
float leanDir = bladeAngle + (float) (Math.PI * 0.5) * (b % 2 == 0 ? 1f : -1f);
float offR = hash(x + bf * 2.9f, z + bf * 5.3f) * 0.28f;
float offA = hash(x + bf * 3.7f, z + bf * 1.7f) * (float) (Math.PI * 2);
float bx = x + offR * (float) Math.cos(offA);
float bz = z + offR * (float) Math.sin(offA);
float cosA = (float) Math.cos(bladeAngle);
float sinA = (float) Math.sin(bladeAngle);
float lnX = (float) (Math.sin(leanMag) * Math.cos(leanDir));
float lnY = (float) Math.cos(leanMag);
float lnZ = (float) (Math.sin(leanMag) * Math.sin(leanDir));
// Quadratischer Biegungsversatz senkrecht zur Breitenrichtung
float bendStr = (hash(x + bf * 5.1f, z + bf * 8.7f) - 0.5f) * 2f * BEND_FACTOR * h;
float bendX = -sinA * bendStr;
float bendZ = cosA * bendStr;
int bladeVi = vi + b * (SEGMENTS + 1) * 2;
int bladeIi = ii + b * SEGMENTS * 6;
for (int s = 0; s <= SEGMENTS; s++) {
float t = (float) s / SEGMENTS;
float hw = baseHW * (float) Math.pow(1.0 - t, 1.4);
// Wirbelsäulenposition: lineare Neigung + quadratische Biegung
float spX = bx + lnX * h * t + bendX * t * t;
float spY = y + lnY * h * t;
float spZ = bz + lnZ * h * t + bendZ * t * t;
// Tangente = Ableitung der Wirbelsäule
float tgX = lnX + 2f * bendX * t;
float tgY = lnY;
float tgZ = lnZ + 2f * bendZ * t;
float tgLen = (float) Math.sqrt(tgX*tgX + tgY*tgY + tgZ*tgZ);
if (tgLen > 1e-6f) { tgX /= tgLen; tgY /= tgLen; tgZ /= tgLen; }
// Normale = Breitenvektor(cosA,0,sinA) × Tangente
float nx = -sinA * tgY;
float ny = sinA * tgX - cosA * tgZ;
float nz = cosA * tgY;
float nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; }
// Normalen Richtung Weltauf kippen → weiches Overhead-Licht
float blend = 0.30f;
nx *= (1f - blend);
ny = ny * (1f - blend) + blend;
nz *= (1f - blend);
nLen = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
if (nLen > 1e-6f) { nx /= nLen; ny /= nLen; nz /= nLen; }
int svi = bladeVi + s * 2;
float dry = blade.dryness();
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry);
if (s < SEGMENTS) {
int sii = bladeIi + s * 6;
idx[sii] = svi; idx[sii+1] = svi+1; idx[sii+2] = svi+3;
idx[sii+3] = svi; idx[sii+4] = svi+3; idx[sii+5] = svi+2;
}
}
}
}
/** Deterministischer Pseudo-Zufallswert [0, 1] aus zwei float-Koordinaten. */
static float hash(float a, float b) {
int ia = Float.floatToRawIntBits(a);
int ib = Float.floatToRawIntBits(b);
int h = ia ^ (ib * 0x9e3779b9);
h ^= h >>> 16;
h *= 0x45d9f3b;
h ^= h >>> 16;
return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF;
}
private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi,
float x, float y, float z, float nx, float ny, float nz,
float wf, float dryness) {
int pi = vi * 3;
pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z;
int ni = vi * 3;
nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz;
int ci = vi * 4;
float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf;
float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf;
float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf;
float dr = DRY_ROOT_COLOR.r + (DRY_TIP_COLOR.r - DRY_ROOT_COLOR.r) * wf;
float dg = DRY_ROOT_COLOR.g + (DRY_TIP_COLOR.g - DRY_ROOT_COLOR.g) * wf;
float db = DRY_ROOT_COLOR.b + (DRY_TIP_COLOR.b - DRY_ROOT_COLOR.b) * wf;
col[ci] = gr + (dr - gr) * dryness;
col[ci+1] = gg + (dg - gg) * dryness;
col[ci+2] = gb + (db - gb) * dryness;
col[ci+3] = 1f;
int ti = vi * 2;
tex[ti] = wf; tex[ti+1] = 0f;
}
}

View File

@@ -0,0 +1,360 @@
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.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedLocationZone;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class LocationZoneState extends BaseAppState {
private static final float LINE_OFFSET_Y = 0.40f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.2f, 0.8f, 0.4f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.5f, 1f, 0.6f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedLocationZone> zones = new ArrayList<>();
private final List<Geometry> zoneGeos = new ArrayList<>();
private int selectedIdx = -1;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedLocationZone> pendingZones = null;
public LocationZoneState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingZones != null) {
loadZones(pendingZones);
pendingZones = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_LOCATION_ZONES) {
if (placing) cancelPoly();
return;
}
SharedInput.LocationZoneClick click;
while ((click = input.locationZoneClickQueue.poll()) != null) {
handleClick(click);
}
PlacedLocationZone pending = input.pendingLocationZone.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteLocationZoneRequested) {
input.deleteLocationZoneRequested = false;
if (selectedIdx >= 0) removeZone(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.LocationZoneClick 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());
if (click.rightButton()) {
if (placing) closePoly();
else deselect();
return;
}
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
for (int i = 0; i < zones.size(); i++) {
PlacedLocationZone z = zones.get(i);
if (SoundAreaState.pointInPolygon(hitX, hitZ, z.pointsX(), z.pointsZ())) {
selectZone(i);
return;
}
}
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST;
float bx = x, bz = z;
for (PlacedLocationZone zone : zones) {
for (int i = 0; i < zone.pointsX().length; i++) {
float dx = x - zone.pointsX()[i];
float dz = z - zone.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = zone.pointsX()[i]; bz = zone.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedLocationZone zone = new PlacedLocationZone(xs, zs, "");
addZone(zone);
selectZone(zones.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("loczone_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("loczone_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectZone(int idx) {
if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) {
zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
zoneGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) {
zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedLocationZoneInfo = null;
input.locationZoneSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedLocationZone z = zones.get(idx);
String triggersJson = de.blight.common.model.trigger.TriggerIO.serializeList(z.triggers());
input.selectedLocationZoneInfo = idx + "|" + z.nameId() + "|" + triggersJson;
input.locationZoneSelectionChanged = true;
}
// ── Add / Remove / Apply ──────────────────────────────────────────────────
private void addZone(PlacedLocationZone zone) {
zones.add(zone);
List<Float> xs = toList(zone.pointsX());
List<Float> zs = toList(zone.pointsZ());
Geometry geo = buildLineGeo("loczone_" + (zones.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
zoneGeos.add(geo);
}
private void removeZone(int idx) {
rootNode.detachChild(zoneGeos.get(idx));
zones.remove(idx);
zoneGeos.remove(idx);
selectedIdx = -1;
input.selectedLocationZoneInfo = null;
input.locationZoneSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : zoneGeos) rootNode.detachChild(g);
zones.clear();
zoneGeos.clear();
cancelPoly();
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedLocationZone updated) {
if (updated.pointsX().length == 0) {
PlacedLocationZone existing = zones.get(idx);
zones.set(idx, new PlacedLocationZone(
existing.pointsX(), existing.pointsZ(),
updated.nameId(),
updated.triggers() != null ? updated.triggers() : existing.triggers()));
} else {
zones.set(idx, updated);
}
publishSelection(idx);
}
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedLocationZone> getPlacedZones() {
return new ArrayList<>(zones);
}
public void loadZones(List<PlacedLocationZone> loaded) {
if (rootNode == null) {
pendingZones = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedLocationZone z : loaded) addZone(z);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -0,0 +1,353 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bounding.BoundingBox;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.shape.Box;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
/**
* Modell-Editor-Modus: zeigt ein einzelnes Modell in isolierter Vorschau
* mit Referenzgitter, Orbit-Kamera und Echtzeit-Skalierung.
*
* Aktivierung: activeLayer == LAYER_MODEL_EDITOR + modelEditorOpenPath setzen.
*/
public class ModelEditorState extends BaseAppState {
// ── Orbit-Kamera ─────────────────────────────────────────────────────────
private static final float ORBIT_SENS = 0.4f; // Grad / Pixel Maus-Delta
private static final float ZOOM_FACTOR = 0.1f;
private static final float PITCH_MIN = 2f;
private static final float PITCH_MAX = 88f;
private float orbitYaw = 30f;
private float orbitPitch = 25f;
private float orbitDist = 5f;
// ── Szene ─────────────────────────────────────────────────────────────────
private SimpleApplication app;
private Camera cam;
/** Root aller Preview-Objekte (an rootNode gehängt). */
private Node previewRoot;
/** Der geladene / skalierte Modell-Node. */
private Node modelWrapper;
/** Gitter-Geometry. */
private Geometry gridGeo;
// gespeicherter Kamerazustand aus dem Editor-Modus
private Vector3f savedCamPos;
private Quaternion savedCamRot;
private final SharedInput input;
private String currentPath = null;
// Mittelpunkt für Orbit (Bounding-Box-Zentrum des Modells)
private Vector3f orbitCenter = Vector3f.ZERO.clone();
public ModelEditorState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
}
@Override
protected void cleanup(Application application) {
exitPreview();
}
@Override
protected void onEnable() {}
@Override
protected void onDisable() {}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_MODEL_EDITOR) {
if (previewRoot != null) exitPreview();
return;
}
// Modell laden
String openPath = input.modelEditorOpenPath;
if (openPath != null) {
input.modelEditorOpenPath = null;
loadModel(openPath);
}
if (previewRoot == null) return;
// Schließen
if (input.modelEditorCloseRequest) {
input.modelEditorCloseRequest = false;
exitPreview();
input.activeLayer = 0;
return;
}
// Skalierung
if (input.modelEditorScaleChanged) {
input.modelEditorScaleChanged = false;
applyScale(input.modelEditorScaleX,
input.modelEditorScaleY,
input.modelEditorScaleZ);
}
// Pivot-Y
if (input.modelEditorPivotChanged) {
input.modelEditorPivotChanged = false;
applyPivot(input.modelEditorPivotY);
}
// Orbit-Kamera: Maus-Delta (Middle-Button zieht Kamera, Right-Button dreht Orbit)
int[] delta = input.consumeMouseDelta();
if (delta[0] != 0 || delta[1] != 0) {
orbitYaw += delta[0] * ORBIT_SENS;
orbitPitch -= delta[1] * ORBIT_SENS;
orbitPitch = FastMath.clamp(orbitPitch, PITCH_MIN, PITCH_MAX);
}
// Scroll → Zoom
int scroll = input.scrollAccum.getAndSet(0);
if (scroll != 0) {
orbitDist = Math.max(0.5f, orbitDist - scroll * orbitDist * ZOOM_FACTOR);
}
applyOrbitCamera();
previewRoot.updateLogicalState(tpf);
previewRoot.updateGeometricState();
}
// ── Modell laden ──────────────────────────────────────────────────────────
private void loadModel(String assetPath) {
if (previewRoot == null) enterPreview();
// Altes Modell entfernen
if (modelWrapper != null) {
previewRoot.detachChild(modelWrapper);
modelWrapper = null;
}
currentPath = assetPath;
modelWrapper = new Node("model_wrapper");
try {
Spatial model = app.getAssetManager().loadModel(assetPath);
stripControls(model);
modelWrapper.attachChild(model);
} catch (Exception e) {
// Fallback: roter Quader
Geometry box = new Geometry("error_box", new Box(0.5f, 0.5f, 0.5f));
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Red);
box.setMaterial(mat);
modelWrapper.attachChild(box);
}
// Skalierung aus SharedInput anwenden
applyScale(input.modelEditorScaleX, input.modelEditorScaleY, input.modelEditorScaleZ);
applyPivot(input.modelEditorPivotY);
previewRoot.attachChild(modelWrapper);
rebuildGrid();
updateBounds();
// Orbit-Distanz automatisch auf Modellgröße setzen
BoundingBox bb = getBoundingBox();
if (bb != null) {
float maxExt = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
orbitDist = maxExt * 3f;
orbitCenter = bb.getCenter().clone();
}
}
private void applyScale(float sx, float sy, float sz) {
if (modelWrapper == null) return;
if (!modelWrapper.getChildren().isEmpty()) {
Spatial child = modelWrapper.getChild(0);
child.setLocalScale(sx, sy, sz);
child.updateGeometricState();
}
updateBounds();
rebuildGrid();
}
private void applyPivot(float pivotY) {
if (modelWrapper == null) return;
if (!modelWrapper.getChildren().isEmpty()) {
Spatial child = modelWrapper.getChild(0);
// Boundingbox nach Skalierung
child.updateGeometricState();
BoundingBox bb = getBoundingBox();
if (bb != null) {
// Modell so verschieben, dass sein Boden auf Y=0 liegt, + Pivot-Versatz
float bottomY = bb.getCenter().y - bb.getYExtent();
child.setLocalTranslation(0, -bottomY + pivotY, 0);
}
}
updateBounds();
}
// ── Preview ein-/ausschalten ──────────────────────────────────────────────
private void enterPreview() {
savedCamPos = cam.getLocation().clone();
savedCamRot = cam.getRotation().clone();
app.getRootNode().setCullHint(Spatial.CullHint.Always);
previewRoot = new Node("model_editor_preview");
// Beleuchtung analog zum Animations-Editor
previewRoot.addLight(new DirectionalLight(
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
previewRoot.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.45f, 0.5f, 0.7f, 1f)));
previewRoot.addLight(new AmbientLight(new ColorRGBA(0.4f, 0.4f, 0.45f, 1f)));
// Direkt am Viewport registrieren NICHT als Kind von rootNode,
// da rootNode.CullHint=Always die komplette Child-Hierarchie ausblendet.
app.getViewPort().attachScene(previewRoot);
app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f));
orbitYaw = 30f;
orbitPitch = 25f;
}
private void exitPreview() {
if (previewRoot != null) {
app.getViewPort().detachScene(previewRoot);
previewRoot = null;
modelWrapper = null;
gridGeo = null;
}
app.getRootNode().setCullHint(Spatial.CullHint.Inherit);
if (savedCamPos != null) {
cam.setLocation(savedCamPos);
cam.setRotation(savedCamRot);
}
app.getViewPort().setBackgroundColor(ColorRGBA.Black);
currentPath = null;
}
// ── Gitter ────────────────────────────────────────────────────────────────
private void rebuildGrid() {
if (gridGeo != null) {
previewRoot.detachChild(gridGeo);
gridGeo = null;
}
BoundingBox bb = getBoundingBox();
float halfSize = 5f;
if (bb != null) {
float maxExt = Math.max(bb.getXExtent(), bb.getZExtent());
halfSize = Math.max(5f, (float) Math.ceil(maxExt * 1.5f / 5f) * 5f);
}
int majorStep = 1;
int n = (int)(halfSize * 2 / majorStep);
int lines = (n + 1) * 2; // horizontal + vertikal
FloatBuffer pos = BufferUtils.createFloatBuffer(lines * 2 * 3);
FloatBuffer col = BufferUtils.createFloatBuffer(lines * 2 * 4);
for (int i = 0; i <= n; i++) {
float coord = -halfSize + i * majorStep;
boolean isMajor5 = (Math.abs(Math.round(coord)) % 5 == 0);
boolean isMajor10 = (Math.abs(Math.round(coord)) % 10 == 0);
float bright = isMajor10 ? 0.70f : isMajor5 ? 0.45f : 0.22f;
// Linie parallel zur Z-Achse
pos.put(coord).put(0).put(-halfSize);
pos.put(coord).put(0).put( halfSize);
col.put(bright).put(bright).put(bright).put(1f);
col.put(bright).put(bright).put(bright).put(1f);
// Linie parallel zur X-Achse
pos.put(-halfSize).put(0).put(coord);
pos.put( halfSize).put(0).put(coord);
col.put(bright).put(bright).put(bright).put(1f);
col.put(bright).put(bright).put(bright).put(1f);
}
pos.flip();
col.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
mesh.updateBound();
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setBoolean("VertexColor", true);
gridGeo = new Geometry("grid", mesh);
gridGeo.setMaterial(mat);
previewRoot.attachChild(gridGeo);
}
// ── Bounds publizieren ─────────────────────────────────────────────────────
private void updateBounds() {
BoundingBox bb = getBoundingBox();
if (bb != null) {
input.modelEditorBoundsW = bb.getXExtent() * 2f;
input.modelEditorBoundsH = bb.getYExtent() * 2f;
input.modelEditorBoundsD = bb.getZExtent() * 2f;
input.modelEditorBoundsReady = true;
orbitCenter = bb.getCenter().clone();
}
}
private BoundingBox getBoundingBox() {
if (modelWrapper == null) return null;
modelWrapper.updateGeometricState();
com.jme3.bounding.BoundingVolume bv = modelWrapper.getWorldBound();
return (bv instanceof BoundingBox) ? (BoundingBox) bv : null;
}
// ── Orbit-Kamera ─────────────────────────────────────────────────────────
private void applyOrbitCamera() {
float yawRad = (float) Math.toRadians(orbitYaw);
float pitchRad = (float) Math.toRadians(orbitPitch);
float cosPitch = (float) Math.cos(pitchRad);
float x = orbitCenter.x + orbitDist * cosPitch * (float) Math.sin(yawRad);
float y = orbitCenter.y + orbitDist * (float) Math.sin(pitchRad);
float z = orbitCenter.z + orbitDist * cosPitch * (float) Math.cos(yawRad);
cam.setLocation(new Vector3f(x, y, z));
cam.lookAt(orbitCenter, Vector3f.UNIT_Y);
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static void stripControls(Spatial s) {
while (s.getNumControls() > 0) s.removeControl(s.getControl(0));
if (s instanceof Node n) n.getChildren().forEach(ModelEditorState::stripControls);
}
public String getCurrentPath() { return currentPath; }
}

View File

@@ -35,9 +35,8 @@ 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.
* Editor-State für das Wasserfall-Werkzeug.
* Erlaubt das interaktive Platzieren von Wasserfall-Kontrollpunkten auf dem Terrain.
*/
public class RiverEditorState extends BaseAppState {
@@ -52,19 +51,16 @@ public class RiverEditorState extends BaseAppState {
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
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;
private int selectedRiver = -1;
public RiverEditorState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
@@ -76,76 +72,48 @@ public class RiverEditorState extends BaseAppState {
List<List<RiverPoint>> saved = RiverIO.load();
if (!saved.isEmpty()) loadPlacedRivers(saved);
} catch (Exception e) {
log.error("Flüsse nicht ladbar", e);
log.error("Wasserfälle nicht ladbar", e);
}
}
@Override
protected void cleanup(Application app) {
clearAll();
}
protected void cleanup(Application app) { clearAll(); }
@Override
protected void onEnable() {
setCullHintAll(Spatial.CullHint.Inherit);
}
protected void onEnable() { setCullHintAll(Spatial.CullHint.Inherit); }
@Override
protected void onDisable() {
setCullHintAll(Spatial.CullHint.Always);
}
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;
// Beim ersten Setzen sofort alle geladenen Flüsse ins Terrain graben
if (te != null && !rivers.isEmpty()) {
te.reapplyAllRivers(getPlacedRivers());
}
}
// ── Update ────────────────────────────────────────────────────────────────
public void setTerrain(TerrainQuad t) { this.terrain = t; }
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_RIVERS) return;
if (input.activeLayer != SharedInput.LAYER_WATERFALL) return;
// Undo: letzten Punkt des aktiven Flusses entfernen
if (input.undoRiverPointRequested) {
input.undoRiverPointRequested = false;
if (input.undoWaterfallPointRequested) {
input.undoWaterfallPointRequested = false;
undoLastPoint();
}
// Selektierten Fluss löschen
if (input.deleteRiverRequested) {
input.deleteRiverRequested = false;
if (input.deleteWaterfallRequested) {
input.deleteWaterfallRequested = false;
if (selectedRiver >= 0) {
removeRiver(selectedRiver);
selectedRiver = -1;
input.selectedRiverInfo = null;
input.riverSelectionChanged = true;
input.selectedWaterfallInfo = null;
input.waterfallSelectionChanged = true;
}
}
// Click-Queue verarbeiten
SharedInput.RiverClick click;
while ((click = input.riverClickQueue.poll()) != null) {
SharedInput.WaterfallClick click;
while ((click = input.waterfallClickQueue.poll()) != null) {
handleClick(click);
}
}
// ── Click-Verarbeitung ────────────────────────────────────────────────────
private void handleClick(SharedInput.RiverClick click) {
private void handleClick(SharedInput.WaterfallClick click) {
if (click.rightButton()) {
// Rechtsklick: aktiven Fluss abschließen
finalizeActiveRiver();
return;
}
@@ -165,26 +133,20 @@ public class RiverEditorState extends BaseAppState {
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);
RiverPoint pt = new RiverPoint(hit.x, hit.y, hit.z, input.waterfallNewWidth, RiverPoint.WATERFALL_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<>());
@@ -192,38 +154,24 @@ public class RiverEditorState extends BaseAppState {
activeRiver = rivers.size() - 1;
}
List<RiverPoint> current = rivers.get(activeRiver);
if (!current.isEmpty()) {
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());
}
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() {
if (activeRiver >= 0 && activeRiver < rivers.size()) {
if (rivers.get(activeRiver).size() < 2) {
removeRiver(activeRiver); // löst intern reapplyAllRivers aus
removeRiver(activeRiver);
activeRiver = -1;
return;
}
}
activeRiver = -1;
// Terrain erst jetzt graben — vollständiger Spline, non-destructive
if (terrainEditor != null) {
terrainEditor.reapplyAllRivers(getPlacedRivers());
}
}
private void undoLastPoint() {
@@ -244,12 +192,9 @@ public class RiverEditorState extends BaseAppState {
}
}
// ── 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);
@@ -264,14 +209,8 @@ public class RiverEditorState extends BaseAppState {
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();
@@ -294,28 +233,19 @@ public class RiverEditorState extends BaseAppState {
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));
}
if (river != null && river.size() >= 2) copy.add(new ArrayList<>(river));
}
return copy;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void clearAll() {
for (List<Geometry> geos : pointGeos) {
for (List<Geometry> geos : pointGeos)
if (geos != null) for (Geometry g : geos) rootNode.detachChild(g);
}
for (Geometry ribbon : ribbonGeos) {
for (Geometry ribbon : ribbonGeos)
if (ribbon != null) rootNode.detachChild(ribbon);
}
rivers.clear();
pointGeos.clear();
ribbonGeos.clear();
@@ -335,32 +265,28 @@ public class RiverEditorState extends BaseAppState {
else if (activeRiver == idx) activeRiver = -1;
if (selectedRiver > idx) selectedRiver--;
else if (selectedRiver == idx) selectedRiver = -1;
// Terrain revertieren und verbleibende Flüsse neu graben
if (terrainEditor != null) {
terrainEditor.reapplyAllRivers(getPlacedRivers());
}
}
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));
old.getMaterial().setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 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));
ribbon.getMaterial().setColor("Color", new ColorRGBA(1.0f, 0.9f, 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);
input.selectedWaterfallInfo = idx + "|" + pts.size() + "|"
+ String.format(java.util.Locale.ROOT, "%.1f", len);
} else {
input.selectedRiverInfo = null;
input.selectedWaterfallInfo = null;
}
input.riverSelectionChanged = true;
input.waterfallSelectionChanged = true;
}
private static float computeLength(List<RiverPoint> pts) {
@@ -373,7 +299,6 @@ public class RiverEditorState extends BaseAppState {
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;
@@ -391,25 +316,18 @@ public class RiverEditorState extends BaseAppState {
}
private void setCullHintAll(Spatial.CullHint hint) {
for (List<Geometry> geos : pointGeos) {
for (List<Geometry> geos : pointGeos)
if (geos != null) for (Geometry g : geos) g.setCullHint(hint);
}
for (Geometry ribbon : ribbonGeos) {
for (Geometry ribbon : ribbonGeos)
if (ribbon != null) ribbon.setCullHint(hint);
}
}
private Geometry buildPointGeo(RiverPoint pt) {
// Radius = halbe Flussbreite, damit der Marker die Breite sichtbar macht
float r = Math.max(0.4f, pt.width() * 0.5f);
Sphere sphere = new Sphere(10, 10, r);
Geometry geo = new Geometry("riverPoint", sphere);
Geometry geo = new Geometry("waterfallPoint", 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, 0.7f));
} else {
mat.setColor("Color", new ColorRGBA(0.1f, 0.4f, 1.0f, 0.7f));
}
mat.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 0.7f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
@@ -418,9 +336,6 @@ public class RiverEditorState extends BaseAppState {
return geo;
}
/**
* Baut ein Ribbon-Vorschau-Mesh (Unshaded, halb-transparent blau).
*/
Geometry buildRibbon(List<RiverPoint> pts) {
pts = RiverSpline.subdivide(pts);
int n = pts.size();
@@ -437,11 +352,8 @@ public class RiverEditorState extends BaseAppState {
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();
RiverPoint a = pts.get(i - 1), b = pts.get(i);
float dx = b.x() - a.x(), dz = b.z() - a.z(), dy = b.y() - a.y();
arcLen[i] = arcLen[i - 1] + FastMath.sqrt(dx*dx + dy*dy + dz*dz);
}
@@ -455,8 +367,7 @@ public class RiverEditorState extends BaseAppState {
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);
RiverPoint prev = pts.get(i - 1), next = pts.get(i + 1);
tangent = new Vector3f(next.x() - prev.x(), next.y() - prev.y(), next.z() - prev.z());
}
if (tangent.lengthSquared() < 1e-6f) tangent.set(1f, 0f, 0f);
@@ -466,7 +377,7 @@ public class RiverEditorState extends BaseAppState {
if (right.lengthSquared() < 1e-6f) right.set(1f, 0f, 0f);
float halfW = pt.width() * 0.5f;
float px = pt.x(), py = pt.y() - 0.45f, pz = pt.z(); // 0,5m Absenkung + 0,05m Offset
float px = pt.x(), py = pt.y() - 0.45f, pz = pt.z();
pos.put(px - right.x * halfW).put(py).put(pz - right.z * halfW);
norm.put(0f).put(1f).put(0f);
@@ -478,11 +389,10 @@ public class RiverEditorState extends BaseAppState {
}
for (int i = 0; i < n - 1; i++) {
int v0 = 2 * i, v1 = 2 * i + 1, v2 = 2 * i + 2, v3 = 2 * i + 3;
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();
@@ -493,9 +403,9 @@ public class RiverEditorState extends BaseAppState {
mesh.updateBound();
mesh.updateCounts();
Geometry geo = new Geometry("riverRibbon", mesh);
Geometry geo = new Geometry("waterfallRibbon", 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.setColor("Color", new ColorRGBA(1.0f, 0.45f, 0.0f, 0.6f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);

View File

@@ -538,12 +538,43 @@ public class SceneObjectState extends BaseAppState {
// ── Objekt platzieren ────────────────────────────────────────────────────
private void placeObject(String modelPath, float wx, float wz, float wy, float rotY) {
SceneObject so = new SceneObject(modelPath, wx, wz, wy, false);
// Meta-Defaults anwenden wenn vorhanden
de.blight.common.ModelMeta meta = null;
if (!modelPath.startsWith("@")) {
Path j3o = ASSET_ROOT.resolve(modelPath);
if (j3o.toFile().exists())
meta = de.blight.common.ModelMetaIO.load(j3o);
}
float defaultScale = 1f;
float placementOffY = 0f;
boolean defaultSolid = false;
boolean defaultCast = true;
boolean defaultReceive= true;
if (meta != null) {
// Zufällige Skalierung wenn Min ≠ Max
if (meta.randomScaleMin() != meta.randomScaleMax()) {
float range = meta.randomScaleMax() - meta.randomScaleMin();
defaultScale = meta.randomScaleMin() + (float) Math.random() * range;
} else {
defaultScale = meta.scaleX(); // uniform assumed
}
placementOffY = meta.placementOffsetY();
defaultSolid = meta.solid();
defaultCast = meta.castShadow();
defaultReceive = meta.receiveShadow();
}
SceneObject so = new SceneObject(modelPath, wx, wz, wy + placementOffY, defaultSolid);
so.setRotation(0f, rotY, 0f);
so.setScale(defaultScale);
so.castShadow = defaultCast;
so.receiveShadow = defaultReceive;
objects.add(so);
animClips.add("");
Node node = loadModelNode(modelPath, wx, wy, wz);
Node node = loadModelNode(modelPath, wx, wy + placementOffY, wz);
node.setLocalScale(defaultScale);
if (rotY != 0f) {
Quaternion q = new Quaternion();
q.fromAngleAxis(rotY, Vector3f.UNIT_Y);

View File

@@ -27,8 +27,18 @@ import com.jme3.util.BufferUtils;
import com.jme3.util.SkyFactory;
import de.blight.common.EmitterIO;
import de.blight.common.GrassTuftIO;
import de.blight.common.GrassVertexIO;
import de.blight.common.LightIO;
import de.blight.common.MusicAreaIO;
import de.blight.common.AreaIO;
import de.blight.common.LocationZoneIO;
import de.blight.common.PlacedArea;
import de.blight.common.PlacedEmitter;
import de.blight.common.PlacedLight;
import de.blight.common.PlacedLocationZone;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedSoundArea;
import de.blight.common.PlacedWater;
import de.blight.common.RiverPoint;
import de.blight.common.SoundAreaIO;
import de.blight.common.WaterBodyIO;
import de.blight.common.MapData;
@@ -50,15 +60,8 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import de.blight.common.RiverPoint;
import de.blight.common.RiverSpline;
public class TerrainEditorState extends BaseAppState {
@@ -89,22 +92,29 @@ public class TerrainEditorState extends BaseAppState {
private TerrainQuad terrain;
private float[] cachedHeightMap; // Einmal geladen, danach manuell synchron gehalten
private float[] originalHeightMap; // Unveränderter Stand vor allen Fluss-Modifikationen
private byte[] originalSplatR, originalSplatG, originalSplatB, originalSplatA;
private final HashSet<Integer> modifiedVertices = new HashSet<>(); // Alle durch Flüsse veränderten Vertices
private Geometry brushIndicator;
private Geometry livePlayerMarker;
private PlacedObjectState placedObjectState;
private GrassVertexState grassVertexState;
private SceneObjectState sceneObjState;
private LightState lightState;
private EmitterState emitterState;
private WaterBodyState waterBodyState;
private SoundAreaState soundAreaState;
private MusicAreaState musicAreaState;
private AreaState areaState;
private LocationZoneState locationZoneState;
private RiverEditorState riverEditorState;
private MapData loadedMapData;
private Node axesGizmo;
private boolean wireframeMode = false;
private boolean wireframeMode = false;
private boolean topologyMode = false;
private final java.util.concurrent.ExecutorService saveExecutor =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "blight-save");
t.setDaemon(true);
return t;
});
private volatile boolean saveInProgress = false;
// ── Splatmap (Slots 1-4) ─────────────────────────────────────────────────
private byte[] splatR, splatG, splatB, splatA;
@@ -205,8 +215,7 @@ public class TerrainEditorState extends BaseAppState {
private void buildScene() {
input.loadingStatus = "Lade Terrain...";
terrain = buildTerrain();
cachedHeightMap = terrain.getHeightMap(); // einmalige 67MB-Allokation; danach nie wieder
originalHeightMap = cachedHeightMap.clone(); // Snapshot vor allen Fluss-Modifikationen
cachedHeightMap = terrain.getHeightMap();
rootNode.attachChild(terrain);
input.loadingStatus = "Lade platzierte Objekte...";
@@ -214,6 +223,11 @@ public class TerrainEditorState extends BaseAppState {
placedObjectState.setTerrain(terrain);
app.getStateManager().attach(placedObjectState);
input.loadingStatus = "Lade Vertex-Gras...";
grassVertexState = new GrassVertexState(input);
grassVertexState.setTerrain(terrain);
app.getStateManager().attach(grassVertexState);
sceneObjState = app.getStateManager().getState(SceneObjectState.class);
if (sceneObjState != null) {
sceneObjState.setTerrain(terrain);
@@ -253,7 +267,6 @@ public class TerrainEditorState extends BaseAppState {
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);
@@ -274,22 +287,33 @@ public class TerrainEditorState extends BaseAppState {
}
}
input.loadingStatus = "Lade Musikbereiche...";
musicAreaState = app.getStateManager().getState(MusicAreaState.class);
if (musicAreaState != null) {
musicAreaState.setTerrain(terrain);
input.loadingStatus = "Lade Bereiche...";
areaState = app.getStateManager().getState(AreaState.class);
if (areaState != null) {
areaState.setTerrain(terrain);
try {
var musicAreas = MusicAreaIO.load();
if (!musicAreas.isEmpty()) musicAreaState.loadAreas(musicAreas);
var areas = AreaIO.load();
if (!areas.isEmpty()) areaState.loadAreas(areas);
} catch (IOException e) {
log.error("Musik-Bereiche nicht ladbar", e);
log.error("Bereiche nicht ladbar", e);
}
}
input.loadingStatus = "Lade Location-Zonen...";
locationZoneState = app.getStateManager().getState(LocationZoneState.class);
if (locationZoneState != null) {
locationZoneState.setTerrain(terrain);
try {
var zones = LocationZoneIO.load();
if (!zones.isEmpty()) locationZoneState.loadZones(zones);
} catch (IOException e) {
log.error("Location-Zonen nicht ladbar", e);
}
}
riverEditorState = app.getStateManager().getState(RiverEditorState.class);
if (riverEditorState != null) {
riverEditorState.setTerrain(terrain);
riverEditorState.setTerrainEditor(this);
}
input.loadingStatus = "Baue Szene...";
@@ -401,28 +425,8 @@ public class TerrainEditorState extends BaseAppState {
upperSplatA = new byte[SPLAT_SIZE * SPLAT_SIZE];
}
// Snapshot vor allen Fluss-Modifikationen
// Fluss-Farbe aus dem A-Kanal rückrechnen: A-Kanal ist ausschließlich für Flüsse.
// Alte Saves können dort nicht-null-Werte haben. Diese jetzt aus dem Original entfernen,
// damit reapplyAllRivers immer von einer sauberen Basis aus malt.
originalSplatR = splatR.clone();
originalSplatG = splatG.clone();
originalSplatB = splatB.clone();
originalSplatA = splatA.clone();
for (int i = 0; i < originalSplatA.length; i++) {
int a = originalSplatA[i] & 0xFF;
if (a > 0) {
float ps = a / 255f;
float denom = 1f - ps;
// R-Kanal zurückrechnen (war gedimmt: R_painted = R_orig * (1-ps))
originalSplatR[i] = denom < 0.01f ? (byte) 255
: (byte) Math.min(255, Math.round((originalSplatR[i] & 0xFF) / denom));
// G/B wurden von alten Fluss-Saves ggf. auf Nicht-Null gesetzt → bereinigen
originalSplatG[i] = 0;
originalSplatB[i] = 0;
originalSplatA[i] = 0;
}
}
// A-Kanal aus alten Saves bereinigen (war für Fluss-Textur; nicht mehr verwendet)
Arrays.fill(splatA, (byte) 0);
splatBuf = BufferUtils.createByteBuffer(SPLAT_SIZE * SPLAT_SIZE * 4);
for (int i = 0; i < SPLAT_SIZE * SPLAT_SIZE; i++) {
@@ -694,6 +698,14 @@ public class TerrainEditorState extends BaseAppState {
terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode);
}
// Topologie-Overlay
int topoReq = input.topologyRequest;
if (topoReq != 0) {
input.topologyRequest = 0;
topologyMode = (topoReq == 1);
setTopologyOverlay(topologyMode);
}
// Terrain-Material neu aufbauen wenn Texturen (Slots 1-8) oder Normal-Maps geändert
if (input.terrainTexturesChanged || input.terrainNormalMapsChanged
|| input.upperTexturesChanged || input.upperNormalMapsChanged) {
@@ -712,6 +724,48 @@ public class TerrainEditorState extends BaseAppState {
private static final int MAX_EDITS_PER_FRAME = 2;
private Node topoOverlayNode = null;
private void setTopologyOverlay(boolean enable) {
SimpleApplication sa = (SimpleApplication) getApplication();
if (!enable) {
if (topoOverlayNode != null) {
sa.getRootNode().detachChild(topoOverlayNode);
topoOverlayNode = null;
}
return;
}
if (terrain == null) return;
if (topoOverlayNode != null) return;
Material mat = new Material(sa.getAssetManager(), "MatDefs/Topology.j3md");
mat.setFloat("Interval", 10f);
mat.setFloat("LineWidth", 0.12f);
mat.setFloat("Opacity", 0.55f);
mat.getAdditionalRenderState().setDepthTest(true);
mat.getAdditionalRenderState().setDepthWrite(false);
mat.getAdditionalRenderState().setFaceCullMode(com.jme3.material.RenderState.FaceCullMode.Off);
topoOverlayNode = new Node("topologyOverlay");
buildTopoOverlay(terrain, topoOverlayNode, mat);
sa.getRootNode().attachChild(topoOverlayNode);
}
private static void buildTopoOverlay(com.jme3.scene.Spatial spatial, Node target, Material mat) {
if (spatial instanceof Geometry geo) {
Geometry overlay = new Geometry("topo_" + geo.getName(), geo.getMesh());
overlay.setLocalTransform(geo.getWorldTransform());
overlay.setMaterial(mat);
overlay.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent);
overlay.setShadowMode(com.jme3.renderer.queue.RenderQueue.ShadowMode.Off);
target.attachChild(overlay);
} else if (spatial instanceof Node node) {
for (Spatial child : node.getChildren()) {
buildTopoOverlay(child, target, mat);
}
}
}
private void processTextureEdits() {
SharedInput.TextureEdit edit;
int processed = 0;
@@ -754,526 +808,99 @@ public class TerrainEditorState extends BaseAppState {
return h != null ? h : 0f;
}
// ── Flussbett graben ──────────────────────────────────────────────────────
// Wasser liegt 50 cm unter den Kontrollpunkten
private static final float WATER_SINK = 0.5f;
// Tiefste Stelle des Flussbetts: 1 m unter Wasseroberfläche
private static final float BED_DEPTH = 1.0f;
// Bettbreite = Flussbreite × (1 + 2×0.25) = 1.5× — je 25 % der Breite als Böschung
private static final float BED_EXTRA = 0.25f;
// Mindest-Halbbreite 4 m (= 8 m gesamt)
private static final float MIN_HALF_WIDTH = 4.0f;
/**
* Setzt das Terrain auf den Original-Zustand zurück und gräbt ALLE übergebenen
* Flüsse in einem einzigen terrain.adjustHeight()-Aufruf neu.
* Aufzurufen wenn ein Fluss fertiggestellt oder gelöscht wird.
*/
public void reapplyAllRivers(List<List<RiverPoint>> allRivers) {
if (terrain == null || cachedHeightMap == null) return;
// 1. Alle zuvor durch Flüsse veränderten Vertices auf Original zurücksetzen
if (!modifiedVertices.isEmpty()) {
List<Vector2f> resetLocs = new ArrayList<>(modifiedVertices.size());
List<Float> resetDeltas = new ArrayList<>(modifiedVertices.size());
for (int vidx : modifiedVertices) {
float orig = originalHeightMap[vidx];
float curH = cachedHeightMap[vidx];
if (curH != orig) {
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
resetLocs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
resetDeltas.add(orig - curH);
}
cachedHeightMap[vidx] = orig;
}
modifiedVertices.clear();
if (!resetLocs.isEmpty()) {
terrain.adjustHeight(resetLocs, resetDeltas);
terrain.updateModelBound();
}
}
// 2. Splatmap auf Original zurücksetzen
if (splatR != null) {
System.arraycopy(originalSplatR, 0, splatR, 0, splatR.length);
System.arraycopy(originalSplatG, 0, splatG, 0, splatG.length);
System.arraycopy(originalSplatB, 0, splatB, 0, splatB.length);
System.arraycopy(originalSplatA, 0, splatA, 0, splatA.length);
for (int i = 0; i < splatR.length; i++) {
int bi = i * 4;
splatBuf.put(bi, splatR[i]);
splatBuf.put(bi + 1, splatG[i]);
splatBuf.put(bi + 2, splatB[i]);
splatBuf.put(bi + 3, splatA[i]);
}
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
if (allRivers == null || allRivers.isEmpty()) return;
// 3. Alle Flüsse in einem Batch berechnen und anwenden
HashMap<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> bankTargets = new HashMap<>();
List<List<RiverPoint>> allSplined = new ArrayList<>(allRivers.size());
for (List<RiverPoint> river : allRivers) {
if (river == null || river.size() < 2) continue;
List<RiverPoint> splined = RiverSpline.subdivide(river);
allSplined.add(splined);
collectRiverTargets(splined, channelTargets, bankTargets);
}
applyTargets(channelTargets);
applyBankTargets(bankTargets, channelTargets.keySet());
// 4. Splatmap für alle Flüsse neu malen
if (splatR != null) {
for (List<RiverPoint> splined : allSplined) paintRiverSplat(splined);
}
}
/**
* Berechnet Tiefenziele für alle Vertices entlang eines gesplineten Flusses.
*
* Profil (gemessen von Wasseroberfläche = waterY WATER_SINK):
* dist ≤ halfWidth → flacher Kanalboden, BED_DEPTH tief
* halfWidth < dist ≤ bedHW → linearer Anstieg von BED_DEPTH → 0
* dist > bedHW → kein Eingriff
*/
private void collectRiverTargets(List<RiverPoint> splined,
HashMap<Integer, Float> channelTargets,
HashMap<Integer, Float> bankTargets) {
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), ay = pa.y(), az = pa.z();
float bx = pb.x(), by = pb.y(), bz = pb.z();
float halfWidth = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
float bedHW = halfWidth * (1f + 2f * BED_EXTRA); // 1.5 × halfWidth
float paintHW = bedHW + SPLAT_WE_PER_PX; // Texturrand (+2 m)
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float scanHW = paintHW + 1f;
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - scanHW + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + scanHW + 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float worldZ = vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float t = FastMath.clamp(
((worldX - ax)*segDx + (worldZ - az)*segDz) / segLen2, 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;
float waterY = ay + t * (by - ay);
int vidx = vz * TOTAL_SIZE + vx;
if (dist <= bedHW) {
float depth;
if (dist <= halfWidth) {
depth = BED_DEPTH;
} else {
depth = BED_DEPTH * (1f - (dist - halfWidth) / (bedHW - halfWidth));
}
float target = waterY - WATER_SINK - depth;
if (originalHeightMap != null && originalHeightMap[vidx] - target > 20f) continue;
channelTargets.merge(vidx, target, Math::min);
} else {
// Uferzone: Höhe exakt auf Wasseroberfläche setzen
bankTargets.merge(vidx, waterY - WATER_SINK, Math::min);
}
}
}
}
}
/** Wendet vorberechnete Kanalziele in einem adjustHeight()-Aufruf an. */
private void applyTargets(HashMap<Integer, Float> channelTargets) {
List<Vector2f> locs = new ArrayList<>(channelTargets.size());
List<Float> deltas = new ArrayList<>(channelTargets.size());
for (Map.Entry<Integer, Float> e : channelTargets.entrySet()) {
int vidx = e.getKey();
float target = e.getValue();
float curH = cachedHeightMap[vidx];
if (curH > target) {
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(target - curH);
cachedHeightMap[vidx] = target;
modifiedVertices.add(vidx);
}
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
}
/** Setzt Ufer-Vertices exakt auf die Wasseroberfläche (hebt und senkt). Channel-Vertices haben Vorrang. */
private void applyBankTargets(HashMap<Integer, Float> bankTargets, Set<Integer> channelVerts) {
List<Vector2f> locs = new ArrayList<>(bankTargets.size());
List<Float> deltas = new ArrayList<>(bankTargets.size());
for (Map.Entry<Integer, Float> e : bankTargets.entrySet()) {
int vidx = e.getKey();
if (channelVerts.contains(vidx)) continue;
float target = e.getValue();
float curH = cachedHeightMap[vidx];
float delta = target - curH;
if (Math.abs(delta) < 0.01f) continue;
int vx = vidx % TOTAL_SIZE, vz = vidx / TOTAL_SIZE;
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(delta);
cachedHeightMap[vidx] = target;
modifiedVertices.add(vidx);
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
terrain.updateModelBound();
}
}
/**
* Einzelnen Fluss graben (ohne Reset). Nur für isolierte Fälle —
* für Editor-Finalisierung/Löschen immer reapplyAllRivers() verwenden.
*/
public void carveRiver(List<RiverPoint> controlPts) {
if (terrain == null || cachedHeightMap == null || controlPts.size() < 2) return;
List<RiverPoint> splined = RiverSpline.subdivide(controlPts);
HashMap<Integer, Float> channelTargets = new HashMap<>();
HashMap<Integer, Float> bankTargets = new HashMap<>();
collectRiverTargets(splined, channelTargets, bankTargets);
applyTargets(channelTargets);
applyBankTargets(bankTargets, channelTargets.keySet());
if (splatR != null) paintRiverSplat(splined);
}
/**
* Malt Textur 4 (Kanal A) entlang des gesplineten Flusses.
*
* Für jeden Splatmap-Pixel im AABB des Flusses wird der minimale Abstand
* zur gesamten Pfadlinie berechnet — kein per-Segment-Clipping, daher
* folgt die Bemalung exakt dem Kurvenverlauf.
*
* Stärke maximal 70 % (0,3 × Gras bleibt immer), damit kein dunkler
* Schatten-Effekt entsteht wenn die Textur dunkler als Gras ist.
*/
private void paintRiverSplat(List<RiverPoint> splined) {
if (splined.size() < 2) return;
// Globales AABB über alle Punkte inkl. Clearing-Zone (+16 m über Bettkante)
// Die Clearing-Zone entfernt G/B-Reste älterer Algorithmen auch außerhalb der Bemalung.
final float CLEAR_EXTRA = 16f;
float globalMinX = Float.MAX_VALUE, globalMaxX = -Float.MAX_VALUE;
float globalMinZ = Float.MAX_VALUE, globalMaxZ = -Float.MAX_VALUE;
for (RiverPoint pt : splined) {
float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f);
float pad = hw * (1f + 2f * BED_EXTRA) + CLEAR_EXTRA + SPLAT_WE_PER_PX;
if (pt.x() - pad < globalMinX) globalMinX = pt.x() - pad;
if (pt.x() + pad > globalMaxX) globalMaxX = pt.x() + pad;
if (pt.z() - pad < globalMinZ) globalMinZ = pt.z() - pad;
if (pt.z() + pad > globalMaxZ) globalMaxZ = pt.z() + pad;
}
int pxMin = Math.max(0, (int)((globalMinX + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pxMax = Math.min(SPLAT_SIZE-1, (int)((globalMaxX + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
int pzMin = Math.max(0, (SPLAT_SIZE-1) - (int)((globalMaxZ + WORLD_HALF) / SPLAT_WE_PER_PX) - 1);
int pzMax = Math.min(SPLAT_SIZE-1, (SPLAT_SIZE-1) - (int)((globalMinZ + WORLD_HALF) / SPLAT_WE_PER_PX) + 1);
boolean changed = false;
for (int pz = pzMin; pz <= pzMax; pz++) {
// Pixel-Position = Vertex-Raster (kein +0,5 Offset)
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;
// Minimalen Abstand zur gesamten Pfadlinie bestimmen
float minDist = Float.MAX_VALUE;
float nearHalfW = MIN_HALF_WIDTH;
float nearBedHW = MIN_HALF_WIDTH * (1f + 2f * BED_EXTRA);
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), az = pa.z();
float segDx = pb.x() - ax, segDz = pb.z() - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f);
float projX = ax + t*segDx, projZ = az + t*segDz;
float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ));
if (d < minDist) {
minDist = d;
float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
nearHalfW = hw;
nearBedHW = hw * (1f + 2f * BED_EXTRA);
}
}
float paintHW = nearBedHW + SPLAT_WE_PER_PX;
float clearHW = nearBedHW + CLEAR_EXTRA;
if (minDist > clearHW) continue;
int sidx = pz * SPLAT_SIZE + px;
int bi = sidx * 4;
// Immer G/B in der gesamten Clearing-Zone nullen (entfernt Reste alter Algorithmen)
splatG[sidx] = 0;
splatB[sidx] = 0;
splatBuf.put(bi + 1, (byte) 0);
splatBuf.put(bi + 2, (byte) 0);
changed = true;
if (minDist > paintHW) continue; // nur räumen, nicht bemalen
float strength;
if (minDist <= nearHalfW) {
strength = 1.0f;
} else if (minDist <= nearBedHW) {
strength = 1.0f - (minDist - nearHalfW) / (nearBedHW - nearHalfW);
} else {
strength = (paintHW - minDist) / SPLAT_WE_PER_PX * 0.15f;
}
if (strength <= 0f) continue;
// Maximal 70 % River-Textur → immer 30 % Gras → kein Schatten-Effekt
float ps = Math.min(strength, 0.7f);
splatR[sidx] = (byte) Math.round((originalSplatR[sidx] & 0xFF) * (1f - ps));
splatA[sidx] = (byte) Math.round(ps * 255);
splatBuf.put(bi, splatR[sidx]);
splatBuf.put(bi + 3, splatA[sidx]);
}
}
if (changed) {
splatBuf.rewind();
splatImage.setUpdateNeeded();
}
}
/**
* 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
// + 0,5m Wasserabsenkung (Wasseroberfläche sitzt 0,5m unter Kontrollpunkten)
float width = halfWidth * 2f;
float maxDepth = Math.max(0.5f, Math.min(1.0f, 0.5f + (width - 4f) / 12f));
float WATER_SINK = 0.5f;
float BANK_WIDTH = 1.5f; // Breite der Böschungszone außerhalb des Kanals
float BANK_HEIGHT = 0.5f; // Höhe der Böschung über Wasseroberfläche
// Scan-Bereich inklusive Böschungszone erweitern
int vxMin = Math.max(0, (int)((Math.min(ax, bx) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vxMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(ax, bx) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMin = Math.max(0, (int)((Math.min(az, bz) - halfWidth - BANK_WIDTH - 1 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
int vzMax = Math.min(TOTAL_SIZE - 1,(int)((Math.max(az, bz) + halfWidth + BANK_WIDTH + 2 + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING));
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 * VERTEX_SPACING - TERRAIN_SIZE * 0.5f;
float worldZ = vz * VERTEX_SPACING - 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));
float waterY = ay + t * (by - ay);
int idx = vz * TOTAL_SIZE + vx;
float curH = cachedHeightMap[idx];
if (dist <= halfWidth) {
// ── Kanal graben (Wasseroberfläche + U-förmiges Bett) ──────
float norm = dist / halfWidth;
float uShape = 1.0f - FastMath.clamp((norm - 0.6f) / 0.4f, 0f, 1f);
float target = waterY - WATER_SINK - maxDepth * uShape;
if (curH > target) {
deltas.add(target - curH);
locs.add(new Vector2f(worldX, worldZ));
cachedHeightMap[idx] = target;
}
} else if (dist <= halfWidth + BANK_WIDTH) {
// ── Böschung aufschütten ──────────────────────────────────
// Quadratischer Abfall: volle Höhe am Kanalrand, 0 am Außenrand
float bankT = 1.0f - (dist - halfWidth) / BANK_WIDTH;
float target = waterY + BANK_HEIGHT * bankT * bankT;
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() {
try {
MapData data = new MapData();
// Post-River-Terrain speichern: Das Spiel erhält korrekt eingegrabene Flussbetten.
// originalHeightMap bleibt in-session als Rücksetz-Basis erhalten.
float[] saveHeight = (cachedHeightMap != null) ? cachedHeightMap : originalHeightMap;
if (saveHeight != null) {
if (saveHeight.length == data.terrainHeight.length) {
System.arraycopy(saveHeight, 0, data.terrainHeight, 0, saveHeight.length);
} else {
upsampleHeights(saveHeight, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
}
}
// Uferkante mit 0,25 m Präzision exakt auf Wasseroberfläche setzen
if (riverEditorState != null) {
applyHighResBankLeveling(data.terrainHeight, riverEditorState.getPlacedRivers());
}
if (splatR != null) {
// Bemalte Splatmap speichern (mit Fluss-Textur) → Spiel zeigt Fluss-Textur korrekt.
// Beim nächsten Laden strippt initSplatmap() die Fluss-Farbe für originalSplatX,
// sodass reapplyAllRivers ohne doppeltes Malen neu aufträgt.
System.arraycopy(splatR, 0, data.splatR, 0, data.splatR.length);
System.arraycopy(splatG, 0, data.splatG, 0, data.splatG.length);
System.arraycopy(splatB, 0, data.splatB, 0, data.splatB.length);
System.arraycopy(splatA, 0, data.splatA, 0, data.splatA.length);
System.arraycopy(input.terrainTexturePaths, 0,
data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
}
if (upperSplatR != null) {
System.arraycopy(upperSplatR, 0, data.upperSplatR, 0, data.upperSplatR.length);
System.arraycopy(upperSplatG, 0, data.upperSplatG, 0, data.upperSplatG.length);
System.arraycopy(upperSplatB, 0, data.upperSplatB, 0, data.upperSplatB.length);
System.arraycopy(upperSplatA, 0, data.upperSplatA, 0, data.upperSplatA.length);
System.arraycopy(input.upperTexturePaths, 0,
data.upperTextures, 0, MapData.TEXTURE_SLOTS);
}
if (placedObjectState != null) {
try {
GrassTuftIO.save(new GrassTuftIO.GrassData(
placedObjectState.getSlotPaths(),
placedObjectState.getAllTufts()));
} catch (IOException e) {
log.error("Gras nicht speicherbar", e);
}
}
MapIO.save(data);
if (sceneObjState != null) {
PlacedModelIO.save(sceneObjState.getPlacedModels());
}
if (lightState != null) {
LightIO.save(lightState.getPlacedLights());
}
if (emitterState != null) {
EmitterIO.save(emitterState.getPlacedEmitters());
}
if (waterBodyState != null) {
WaterBodyIO.save(waterBodyState.getPlacedBodies());
}
if (riverEditorState != null) {
de.blight.common.RiverIO.save(riverEditorState.getPlacedRivers());
}
if (soundAreaState != null) {
SoundAreaIO.save(soundAreaState.getPlacedAreas());
}
if (musicAreaState != null) {
MusicAreaIO.save(musicAreaState.getPlacedAreas());
}
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
log.info("{}", input.saveStatusMsg);
} catch (IOException e) {
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
log.error("Speichern fehlgeschlagen", e);
if (saveInProgress) {
log.warn("Speicherung noch aktiv, überspringe.");
return;
}
saveInProgress = true;
// ── Snapshot aller Daten auf dem JME3-Thread (schnelle Arraykopien) ──
final float[] heightSnap = cachedHeightMap != null ? cachedHeightMap.clone() : null;
final byte[] snapR = splatR != null ? splatR.clone() : null;
final byte[] snapG = splatG != null ? splatG.clone() : null;
final byte[] snapB = splatB != null ? splatB.clone() : null;
final byte[] snapA = splatA != null ? splatA.clone() : null;
final byte[] upR = upperSplatR != null ? upperSplatR.clone() : null;
final byte[] upG = upperSplatG != null ? upperSplatG.clone() : null;
final byte[] upB = upperSplatB != null ? upperSplatB.clone() : null;
final byte[] upA = upperSplatA != null ? upperSplatA.clone() : null;
final String[] texPaths = input.terrainTexturePaths.clone();
final String[] upperPaths = input.upperTexturePaths.clone();
final GrassTuftIO.GrassData grassData = placedObjectState != null
? new GrassTuftIO.GrassData(placedObjectState.getSlotPaths(), placedObjectState.getAllTufts())
: null;
final var grassVertexBlades = grassVertexState != null ? grassVertexState.getAllBlades() : null;
final List<PlacedModel> models = sceneObjState != null ? sceneObjState.getPlacedModels() : null;
final List<PlacedLight> lights = lightState != null ? lightState.getPlacedLights() : null;
final List<PlacedEmitter> emitters = emitterState != null ? emitterState.getPlacedEmitters() : null;
final List<PlacedWater> waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null;
final List<List<RiverPoint>> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null;
final List<PlacedSoundArea> soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null;
final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null;
final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null;
// ── Schwere Arbeit (Upsample + Datei-I/O) auf Hintergrund-Thread ─────
saveExecutor.submit(() -> {
try {
MapData data = new MapData();
if (heightSnap != null) {
if (heightSnap.length == data.terrainHeight.length) {
System.arraycopy(heightSnap, 0, data.terrainHeight, 0, heightSnap.length);
} else {
upsampleHeights(heightSnap, TOTAL_SIZE, data.terrainHeight, MapData.TERRAIN_VERTS);
}
}
if (snapR != null) {
System.arraycopy(snapR, 0, data.splatR, 0, data.splatR.length);
System.arraycopy(snapG, 0, data.splatG, 0, data.splatG.length);
System.arraycopy(snapB, 0, data.splatB, 0, data.splatB.length);
System.arraycopy(snapA, 0, data.splatA, 0, data.splatA.length);
System.arraycopy(texPaths, 0, data.terrainTextures, 0, MapData.TEXTURE_SLOTS);
}
if (upR != null) {
System.arraycopy(upR, 0, data.upperSplatR, 0, data.upperSplatR.length);
System.arraycopy(upG, 0, data.upperSplatG, 0, data.upperSplatG.length);
System.arraycopy(upB, 0, data.upperSplatB, 0, data.upperSplatB.length);
System.arraycopy(upA, 0, data.upperSplatA, 0, data.upperSplatA.length);
System.arraycopy(upperPaths, 0, data.upperTextures, 0, MapData.TEXTURE_SLOTS);
}
if (grassData != null) {
try { GrassTuftIO.save(grassData); }
catch (IOException e) { log.error("Gras nicht speicherbar", e); }
}
if (grassVertexBlades != null) {
try { GrassVertexIO.save(grassVertexBlades); }
catch (IOException e) { log.error("Vertex-Gras nicht speicherbar", e); }
}
MapIO.save(data);
if (models != null) PlacedModelIO.save(models);
if (lights != null) LightIO.save(lights);
if (emitters != null) EmitterIO.save(emitters);
if (waters != null) WaterBodyIO.save(waters);
if (rivers != null) de.blight.common.RiverIO.save(rivers);
if (soundAreas != null) SoundAreaIO.save(soundAreas);
if (areas != null) AreaIO.save(areas);
if (locationZones != null) LocationZoneIO.save(locationZones);
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
log.info("{}", input.saveStatusMsg);
} catch (IOException e) {
input.saveStatusMsg = "Fehler beim Speichern: " + e.getMessage();
log.error("Speichern fehlgeschlagen", e);
} catch (Throwable t) {
input.saveStatusMsg = "Speichern fehlgeschlagen: " + t;
log.error("Speichern fehlgeschlagen (unerwarteter Fehler)", t);
} finally {
saveInProgress = false;
}
});
}
// ── Brush-Indikator ───────────────────────────────────────────────────────
@@ -1287,7 +914,8 @@ public class TerrainEditorState extends BaseAppState {
if (layer == SharedInput.LAYER_OBJECTS || layer == SharedInput.LAYER_OBJECTS_EDIT
|| layer == SharedInput.LAYER_LIGHTS || layer == SharedInput.LAYER_EMITTERS
|| layer == SharedInput.LAYER_WATER
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_MUSIC_AREAS
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|| layer == SharedInput.LAYER_LOCATION_ZONES
|| layer == SharedInput.LAYER_PLAY_TOOL || mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
@@ -1318,6 +946,13 @@ public class TerrainEditorState extends BaseAppState {
contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (float) input.grassTool.brushRadius.getValue();
}
} else if (layer == SharedInput.LAYER_GRASS_VERTEX) {
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() > 0) {
contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (float) input.grassVertexTool.brushRadius.getValue();
}
}
if (contactPoint != null) {
@@ -1383,10 +1018,6 @@ 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();
}
@@ -1441,6 +1072,7 @@ public class TerrainEditorState extends BaseAppState {
syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius);
if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius);
}
}
@@ -1492,6 +1124,7 @@ public class TerrainEditorState extends BaseAppState {
syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius);
if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius);
}
/** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */
@@ -1545,6 +1178,7 @@ public class TerrainEditorState extends BaseAppState {
syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius);
if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius);
}
}
@@ -1558,6 +1192,10 @@ public class TerrainEditorState extends BaseAppState {
}
private void updateCamera(float tpf) {
if (input.activeLayer == SharedInput.LAYER_MODEL_EDITOR) {
input.consumeMouseDelta(); // konsumieren ohne zu verarbeiten
return;
}
int[] delta = input.consumeMouseDelta();
if (delta[0] != 0 || delta[1] != 0) {
camYaw += delta[0] * MOUSE_SENS;
@@ -1830,73 +1468,4 @@ public class TerrainEditorState extends BaseAppState {
}
}
/**
* Setzt Uferkanten-Vertices (bedHW < dist ≤ paintHW) in der 16385²-Heightmap
* exakt auf die Wasseroberfläche (0,25 m Präzision).
* Wird nach dem Hochskalieren in performSave() aufgerufen.
*/
private static void applyHighResBankLeveling(float[] heights, List<List<RiverPoint>> rivers) {
if (heights == null || rivers == null || rivers.isEmpty()) return;
final int HR_VERTS = MapData.TERRAIN_VERTS; // 16385
final float HR_SPACING = 4096f / (HR_VERTS - 1); // 0.25 m/Vertex
final float HR_HALF = 2048f;
for (List<RiverPoint> controlPts : rivers) {
if (controlPts == null || controlPts.size() < 2) continue;
List<RiverPoint> splined = RiverSpline.subdivide(controlPts);
if (splined.size() < 2) continue;
// AABB (inkl. paintHW-Rand)
float minX = Float.MAX_VALUE, maxX = -Float.MAX_VALUE;
float minZ = Float.MAX_VALUE, maxZ = -Float.MAX_VALUE;
for (RiverPoint pt : splined) {
float hw = Math.max(MIN_HALF_WIDTH, pt.width() * 0.5f);
float pad = hw * (1f + 2f * BED_EXTRA) + SPLAT_WE_PER_PX + 1f;
if (pt.x() - pad < minX) minX = pt.x() - pad;
if (pt.x() + pad > maxX) maxX = pt.x() + pad;
if (pt.z() - pad < minZ) minZ = pt.z() - pad;
if (pt.z() + pad > maxZ) maxZ = pt.z() + pad;
}
int vxMin = Math.max(0, (int)((minX + HR_HALF) / HR_SPACING));
int vxMax = Math.min(HR_VERTS-1, (int)((maxX + HR_HALF) / HR_SPACING) + 1);
int vzMin = Math.max(0, (int)((minZ + HR_HALF) / HR_SPACING));
int vzMax = Math.min(HR_VERTS-1, (int)((maxZ + HR_HALF) / HR_SPACING) + 1);
for (int vz = vzMin; vz <= vzMax; vz++) {
for (int vx = vxMin; vx <= vxMax; vx++) {
float worldX = vx * HR_SPACING - HR_HALF;
float worldZ = vz * HR_SPACING - HR_HALF;
float minDist = Float.MAX_VALUE;
float bestWaterY = 0f;
float bestBedHW = 0f;
float bestPaintHW = 0f;
for (int si = 1; si < splined.size(); si++) {
RiverPoint pa = splined.get(si - 1);
RiverPoint pb = splined.get(si);
float ax = pa.x(), ay = pa.y(), az = pa.z();
float bx = pb.x(), by = pb.y(), bz = pb.z();
float segDx = bx - ax, segDz = bz - az;
float segLen2 = segDx*segDx + segDz*segDz;
if (segLen2 < 0.001f) continue;
float t = FastMath.clamp(((worldX-ax)*segDx + (worldZ-az)*segDz) / segLen2, 0f, 1f);
float projX = ax + t*segDx, projZ = az + t*segDz;
float d = FastMath.sqrt((worldX-projX)*(worldX-projX) + (worldZ-projZ)*(worldZ-projZ));
if (d < minDist) {
minDist = d;
float hw = Math.max(MIN_HALF_WIDTH, (pa.width() + pb.width()) * 0.25f);
bestBedHW = hw * (1f + 2f * BED_EXTRA);
bestPaintHW = bestBedHW + SPLAT_WE_PER_PX;
bestWaterY = ay + t * (by - ay);
}
}
if (minDist > bestBedHW && minDist <= bestPaintHW) {
heights[vz * HR_VERTS + vx] = bestWaterY - WATER_SINK;
}
}
}
}
}
}

View File

@@ -24,22 +24,25 @@ import java.nio.IntBuffer;
import java.util.*;
/**
* Platziert und visualisiert Wasserflächen per Flood-Fill aus dem Gelände.
* Platziert und visualisiert Wasserflächen als frei definiertes Polygon.
*
* Raster: 2 WE pro Pixel (WATER_GRID = 2049, STEP = 2).
* BFS vom Klickpunkt; Rand erreicht → nicht eingeschlossen.
* Bedienung:
* L-Klick → Polygon-Punkt setzen (erster Punkt definiert Standard-Höhe)
* R-Klick → letzten Punkt entfernen (beim Zeichnen) / Auswahl aufheben
* Leertaste → Terrain-Höhe an Cursor-Position als Wasserhöhe übernehmen
* ESC → Zeichnen abbrechen
* Nahe am ersten Punkt klicken (≥3 Punkte) → Polygon schließen
*/
public class WaterBodyState extends BaseAppState {
// 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 float SNAP_DIST = 8f;
private static final float LINE_OFFSET = 0.1f;
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 static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.3f, 0.7f, 1.0f, 1f);
private static final ColorRGBA COLOR_OUTLINE = new ColorRGBA(0.1f, 0.5f, 0.9f, 1f);
private static final ColorRGBA COLOR_ARROW = new ColorRGBA(1f, 0.85f, 0f, 1f);
private final SharedInput input;
private SimpleApplication app;
@@ -47,22 +50,28 @@ public class WaterBodyState extends BaseAppState {
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private float[] heightMap;
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 final List<PlacedWater> bodies = new ArrayList<>();
private final List<Geometry> fillGeos = new ArrayList<>();
private final List<Geometry> outlineGeos = new ArrayList<>();
private final List<Geometry> flowArrowGeos = new ArrayList<>();
private int selectedIdx = -1;
// in-progress polygon
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private float currentWaterHeight = 0f;
private Geometry inProgGeo = null;
private Geometry lastMarker = null;
private Geometry pillarGeo = null;
private Geometry cursorPillar = null;
private int selectedIdx = -1;
private List<PlacedWater> pendingLoad = null;
public WaterBodyState(SharedInput input) { this.input = input; }
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
public void setHeightMap(float[] heightMap) { this.heightMap = heightMap; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
@@ -85,70 +94,371 @@ public class WaterBodyState extends BaseAppState {
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_WATER) return;
if (input.activeLayer != SharedInput.LAYER_WATER) {
if (placing) cancelPoly();
return;
}
SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) handleClick(click);
// Spacebar: sample terrain height at cursor
if (input.waterSampleHeightRequested) {
input.waterSampleHeightRequested = false;
float h = sampleTerrainAtCursor();
if (Float.isFinite(h)) {
currentWaterHeight = h;
input.waterCurrentHeight = h;
input.waterHeightChanged = true;
if (placing) updateInProgressGeo();
else if (selectedIdx >= 0) applyHeightChange(selectedIdx, h);
}
}
PlacedWater pending = input.pendingWater.getAndSet(null);
if (pending != null && selectedIdx >= 0) applyHeightChange(selectedIdx, pending.waterHeight());
// Live cursor pillar while placing
if (placing) {
updateCursorPillar();
} else {
removeCursorPillar();
}
// Height change for selected body
Float newH = input.pendingWaterHeight.getAndSet(null);
if (newH != null && selectedIdx >= 0) applyHeightChange(selectedIdx, newH);
// Flow direction change for selected body
Float newFlow = input.pendingWaterFlowDegrees.getAndSet(null);
if (newFlow != null && selectedIdx >= 0) applyFlowChange(selectedIdx, newFlow);
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteWaterRequested) {
input.deleteWaterRequested = false;
if (selectedIdx >= 0) removeBody(selectedIdx);
}
SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) handleClick(click);
}
// ── Click-Handling ────────────────────────────────────────────────────────
// ── Click ─────────────────────────────────────────────────────────────────
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());
if (click.rightButton()) { deselect(); return; }
int hit = pickBody(ray);
if (hit >= 0) { selectBody(hit); return; }
if (click.rightButton()) {
if (placing) {
if (!currX.isEmpty()) {
currX.remove(currX.size() - 1);
currZ.remove(currZ.size() - 1);
updateInProgressGeo();
if (currX.isEmpty()) cancelPoly();
}
} else {
deselect();
}
return;
}
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
Set<Integer> cells = floodFill(pt.x, pt.z, pt.y);
if (cells == null) {
input.waterHint = "Kein eingeschlossenes Becken an dieser Stelle.";
return;
if (placing) {
if (currX.size() >= 3) {
float dx = hitX - currX.get(0), dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
for (int i = 0; i < bodies.size(); i++) {
PlacedWater b = bodies.get(i);
if (pointInPolygon(hitX, hitZ, b.pointsX(), b.pointsZ())) {
selectBody(i);
return;
}
}
deselect();
placing = true;
currX.clear();
currZ.clear();
currentWaterHeight = pt.y;
input.waterCurrentHeight = pt.y;
input.waterHeightChanged = true;
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
addBody(new PlacedWater(pt.x, pt.z, pt.y), cells);
}
private void closePoly() {
if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedWater body = new PlacedWater(xs, zs, currentWaterHeight, 0f);
addBody(body);
selectBody(bodies.size() - 1);
cancelPoly();
}
private int pickBody(Ray ray) {
for (int i = 0; i < geos.size(); i++) {
CollisionResults res = new CollisionResults();
geos.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; }
if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; }
if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; }
}
// ── In-progress visual ────────────────────────────────────────────────────
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
inProgGeo = null;
int n = currX.size();
if (n > 0) {
inProgGeo = buildLineGeo("water_inprog", currX, currZ,
currentWaterHeight + LINE_OFFSET, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
}
return -1;
updateLastMarker();
updatePillarGeo();
}
// ── Selektion ─────────────────────────────────────────────────────────────
private void updateLastMarker() {
if (lastMarker != null) { rootNode.detachChild(lastMarker); lastMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = currentWaterHeight + LINE_OFFSET + 0.1f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x-s).put(y).put(z-s); buf.put(x+s).put(y).put(z+s);
buf.put(x-s).put(y).put(z+s); buf.put(x+s).put(y).put(z-s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastMarker = new Geometry("water_lastpoint", mesh);
lastMarker.setMaterial(mat);
rootNode.attachChild(lastMarker);
}
// ── Height-delta pillars ──────────────────────────────────────────────────
private static final ColorRGBA PILLAR_SUB = new ColorRGBA(0.2f, 0.85f, 1.0f, 1f);
private static final ColorRGBA PILLAR_DRY = new ColorRGBA(1.0f, 0.50f, 0.1f, 1f);
private void updatePillarGeo() {
if (pillarGeo != null) { rootNode.detachChild(pillarGeo); pillarGeo = null; }
int n = currX.size();
if (n == 0 || terrain == null) return;
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 2 * 3);
FloatBuffer col = BufferUtils.createFloatBuffer(n * 2 * 4);
for (int i = 0; i < n; i++) {
float x = currX.get(i), z = currZ.get(i);
Float th = terrain.getHeight(new com.jme3.math.Vector2f(x, z));
float ty = th != null ? th : currentWaterHeight;
float wy = currentWaterHeight;
ColorRGBA c = (ty < wy) ? PILLAR_SUB : PILLAR_DRY;
pos.put(x).put(ty).put(z);
pos.put(x).put(wy + LINE_OFFSET).put(z);
col.put(c.r).put(c.g).put(c.b).put(c.a);
col.put(c.r).put(c.g).put(c.b).put(c.a);
}
pos.rewind(); col.rewind();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setBoolean("VertexColor", true);
mat.getAdditionalRenderState().setLineWidth(3f);
pillarGeo = new Geometry("water_pillars", mesh);
pillarGeo.setMaterial(mat);
rootNode.attachChild(pillarGeo);
}
private void updateCursorPillar() {
if (terrain == null || input.mouseScreenX < 0) { removeCursorPillar(); return; }
float jmeX = input.mouseScreenX * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - input.mouseScreenY * (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());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) { removeCursorPillar(); return; }
Vector3f pt = hits.getClosestCollision().getContactPoint();
float ty = pt.y, wy = currentWaterHeight;
float delta = wy - ty;
ColorRGBA c = (delta > 0) ? PILLAR_SUB : PILLAR_DRY;
float lo = Math.min(ty, wy);
float hi = Math.max(ty, wy) + LINE_OFFSET;
FloatBuffer pos = BufferUtils.createFloatBuffer(2 * 3);
FloatBuffer col = BufferUtils.createFloatBuffer(2 * 4);
pos.put(pt.x).put(lo).put(pt.z);
pos.put(pt.x).put(hi).put(pt.z);
col.put(c.r).put(c.g).put(c.b).put(0.5f);
col.put(c.r).put(c.g).put(c.b).put(0.5f);
pos.rewind(); col.rewind();
if (cursorPillar == null) {
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setBoolean("VertexColor", true);
mat.getAdditionalRenderState().setLineWidth(2f);
cursorPillar = new Geometry("water_cursor_pillar", mesh);
cursorPillar.setMaterial(mat);
rootNode.attachChild(cursorPillar);
} else {
Mesh mesh = cursorPillar.getMesh();
mesh.clearBuffer(VertexBuffer.Type.Position);
mesh.clearBuffer(VertexBuffer.Type.Color);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Color, 4, col);
mesh.updateBound();
cursorPillar.getMaterial().setBoolean("VertexColor", true);
}
input.waterCurrentHeight = currentWaterHeight;
}
private void removeCursorPillar() {
if (cursorPillar != null) { rootNode.detachChild(cursorPillar); cursorPillar = null; }
}
// ── Flow arrow ────────────────────────────────────────────────────────────
private Geometry buildFlowArrowGeo(PlacedWater body, int idx) {
float[] xs = body.pointsX(), zs = body.pointsZ();
int n = xs.length;
float minX = xs[0], maxX = xs[0], minZ = zs[0], maxZ = zs[0];
for (int i = 1; i < n; i++) {
if (xs[i] < minX) minX = xs[i]; if (xs[i] > maxX) maxX = xs[i];
if (zs[i] < minZ) minZ = zs[i]; if (zs[i] > maxZ) maxZ = zs[i];
}
float diag = (float) Math.sqrt((double)(maxX-minX)*(maxX-minX) + (double)(maxZ-minZ)*(maxZ-minZ));
float spacing = Math.max(4f, Math.min(diag / 8f, 30f));
float arrowLen = spacing * 0.5f;
float hlen = arrowLen * 0.35f;
float y = body.waterHeight() + 0.3f;
double rad = Math.toRadians(body.flowDegrees());
float dx = (float) Math.sin(rad);
float dz = (float) Math.cos(rad);
// head barb offsets (pre-computed, same for every arrow)
float b1x = (float) Math.sin(Math.toRadians(body.flowDegrees() + 150)) * hlen;
float b1z = (float) Math.cos(Math.toRadians(body.flowDegrees() + 150)) * hlen;
float b2x = (float) Math.sin(Math.toRadians(body.flowDegrees() - 150)) * hlen;
float b2z = (float) Math.cos(Math.toRadians(body.flowDegrees() - 150)) * hlen;
// collect grid points inside polygon (centered in each cell)
List<float[]> pts = new ArrayList<>();
for (float gx = minX + spacing * 0.5f; gx < maxX; gx += spacing)
for (float gz = minZ + spacing * 0.5f; gz < maxZ; gz += spacing)
if (pointInPolygon(gx, gz, xs, zs))
pts.add(new float[]{gx, gz});
// fallback: polygon centroid
if (pts.isEmpty()) {
float cx = 0, cz = 0;
for (int i = 0; i < n; i++) { cx += xs[i]; cz += zs[i]; }
pts.add(new float[]{cx / n, cz / n});
}
// 3 lines × 2 verts × 3 floats per arrow
FloatBuffer pos = BufferUtils.createFloatBuffer(pts.size() * 6 * 3);
for (float[] pt : pts) {
float px = pt[0] - dx * arrowLen * 0.5f; // center arrow on grid point
float pz = pt[1] - dz * arrowLen * 0.5f;
float tipX = px + dx * arrowLen, tipZ = pz + dz * arrowLen;
pos.put(px).put(y).put(pz); pos.put(tipX).put(y).put(tipZ);
pos.put(tipX).put(y).put(tipZ); pos.put(tipX + b1x).put(y).put(tipZ + b1z);
pos.put(tipX).put(y).put(tipZ); pos.put(tipX + b2x).put(y).put(tipZ + b2z);
}
pos.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", COLOR_ARROW);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry("water_flow_" + idx, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Height sampling ───────────────────────────────────────────────────────
private float sampleTerrainAtCursor() {
if (terrain == null) return Float.NaN;
float jmeX = input.mouseScreenX * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - input.mouseScreenY * (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());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return Float.NaN;
return hits.getClosestCollision().getContactPoint().y;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectBody(int idx) {
deselect();
selectedIdx = idx;
geos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
fillGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
outlineGeos.get(idx).getMaterial().setColor("Color", new ColorRGBA(0.8f, 0.9f, 1f, 1f));
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < geos.size())
geos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
if (selectedIdx >= 0 && selectedIdx < fillGeos.size()) {
fillGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_WATER);
outlineGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_OUTLINE);
}
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
@@ -157,171 +467,111 @@ 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|%d",
idx, b.seedX(), b.seedZ(), b.waterHeight(), cellSets.get(idx).size());
"%d|%.3f|%d|%.1f", idx, b.waterHeight(), b.pointsX().length, b.flowDegrees());
input.waterSelectionChanged = true;
}
// ── Hinzufügen / Entfernen ────────────────────────────────────────────────
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addBody(PlacedWater body, Set<Integer> cells) {
Geometry geo = buildWaterGeo(cells, body.waterHeight());
rootNode.attachChild(geo);
private void addBody(PlacedWater body) {
int idx = bodies.size();
Geometry fill = buildFillGeo(body);
Geometry outline = buildLineGeo("water_outline_" + idx,
toList(body.pointsX()), toList(body.pointsZ()),
body.waterHeight() + LINE_OFFSET, COLOR_OUTLINE, Mesh.Mode.LineLoop);
Geometry arrow = buildFlowArrowGeo(body, idx);
rootNode.attachChild(fill);
rootNode.attachChild(outline);
rootNode.attachChild(arrow);
bodies.add(body);
cellSets.add(cells);
geos.add(geo);
bodyBounds.add(computeBounds(cells));
fillGeos.add(fill);
outlineGeos.add(outline);
flowArrowGeos.add(arrow);
}
private void removeBody(int idx) {
rootNode.detachChild(geos.get(idx));
rootNode.detachChild(fillGeos.get(idx));
rootNode.detachChild(outlineGeos.get(idx));
rootNode.detachChild(flowArrowGeos.get(idx));
bodies.remove(idx);
cellSets.remove(idx);
geos.remove(idx);
bodyBounds.remove(idx);
fillGeos.remove(idx);
outlineGeos.remove(idx);
flowArrowGeos.remove(idx);
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : geos) if (rootNode != null) rootNode.detachChild(g);
for (Geometry g : fillGeos) if (rootNode != null) rootNode.detachChild(g);
for (Geometry g : outlineGeos) if (rootNode != null) rootNode.detachChild(g);
for (Geometry g : flowArrowGeos) if (rootNode != null) rootNode.detachChild(g);
bodies.clear();
cellSets.clear();
geos.clear();
bodyBounds.clear();
fillGeos.clear();
outlineGeos.clear();
flowArrowGeos.clear();
cancelPoly();
selectedIdx = -1;
}
/**
* 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);
}
}
}
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};
}
// ── 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;
PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), newHeight, b.flowDegrees());
rootNode.detachChild(fillGeos.get(idx));
rootNode.detachChild(outlineGeos.get(idx));
rootNode.detachChild(flowArrowGeos.get(idx));
Geometry fill = buildFillGeo(updated);
Geometry outline = buildLineGeo("water_outline_" + idx,
toList(updated.pointsX()), toList(updated.pointsZ()),
newHeight + LINE_OFFSET, COLOR_OUTLINE, Mesh.Mode.LineLoop);
Geometry arrow = buildFlowArrowGeo(updated, idx);
boolean sel = (selectedIdx == idx);
if (sel) {
fill.getMaterial().setColor("Color", COLOR_SELECTED);
outline.getMaterial().setColor("Color", new ColorRGBA(0.8f, 0.9f, 1f, 1f));
}
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);
rootNode.attachChild(fill);
rootNode.attachChild(outline);
rootNode.attachChild(arrow);
bodies.set(idx, updated);
fillGeos.set(idx, fill);
outlineGeos.set(idx, outline);
flowArrowGeos.set(idx, arrow);
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 void applyFlowChange(int idx, float newDegrees) {
PlacedWater b = bodies.get(idx);
PlacedWater updated = new PlacedWater(b.pointsX(), b.pointsZ(), b.waterHeight(), newDegrees);
bodies.set(idx, updated);
rootNode.detachChild(flowArrowGeos.get(idx));
Geometry arrow = buildFlowArrowGeo(updated, idx);
rootNode.attachChild(arrow);
flowArrowGeos.set(idx, arrow);
publishSelection(idx);
}
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;
}
// ── Geometry builders ─────────────────────────────────────────────────────
// ── Mesh-Aufbau (für Editor-Vorschau) ────────────────────────────────────
private Geometry buildFillGeo(PlacedWater body) {
float[] xs = body.pointsX();
float[] zs = body.pointsZ();
int n = xs.length;
float h = body.waterHeight() + LINE_OFFSET * 0.5f;
int triCount = n - 2;
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 3);
IntBuffer idx = BufferUtils.createIntBuffer(triCount * 3);
for (int i = 0; i < n; i++) pos.put(xs[i]).put(h).put(zs[i]);
for (int i = 1; i <= triCount; i++) idx.put(0).put(i).put(i + 1);
pos.rewind(); idx.rewind();
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);
Geometry geo = new Geometry("water_fill", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", COLOR_WATER);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
@@ -332,21 +582,61 @@ public class WaterBodyState extends BaseAppState {
return geo;
}
// ── Speichern / Laden ─────────────────────────────────────────────────────
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
float height, ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer pos = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) pos.put(xs.get(i)).put(height).put(zs.get(i));
pos.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
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) {
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());
}
for (PlacedWater b : loaded) addBody(b);
}
// ── Point-in-polygon ──────────────────────────────────────────────────────
private static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
if ((zs[i] > pz) != (zs[j] > pz)
&& (px < (xs[j] - xs[i]) * (pz - zs[i]) / (zs[j] - zs[i]) + xs[i]))
inside = !inside;
}
return inside;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -11,7 +11,7 @@ public class GrassTool extends EditorTool {
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, 200.0);
@Override public String getName() { return "Gras"; }
@Override public String getName() { return "Gras (Textur)"; }
@Override
public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }

View File

@@ -0,0 +1,19 @@
package de.blight.editor.tool;
import java.util.List;
public class GrassVertexTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 50.0);
public final ToolParameter bladeHeight = new ToolParameter("Halmhöhe", 0.6, 0.1, 2.0);
public final ToolParameter density = new ToolParameter("Dichte", 5.0, 1.0, 100.0);
public final ToolParameter dryness = new ToolParameter("Vertrocknet %", 0.0, 0.0, 100.0);
@Override public String getName() { return "Gras (Vertices)"; }
@Override
public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }
@Override
public List<ToolParameter> getParameters() { return List.of(brushRadius, bladeHeight, density, dryness); }
}

View File

@@ -9,8 +9,8 @@ import java.util.List;
*/
public class TextureTool extends EditorTool {
// Terrain.j3md (unlit) hat nur Tex1Tex3; Slot 0=Gras(Base), 1=Fels(R), 2=Erde(G)
public static final String[] TEXTURE_NAMES = {"Gras", "Fels", "Erde"};
// Slots 1-4 = lower splatmap, Slots 5-8 = upper splatmap
public static final String[] TEXTURE_NAMES = {"S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8"};
public final ChoiceToolParameter textureIndex = new ChoiceToolParameter(
"Textur", TEXTURE_NAMES, 0

View File

@@ -0,0 +1,292 @@
package de.blight.editor.ui;
import de.blight.common.model.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
/**
* Crafting-Table-Verwaltung: zwei identische TablePanel-Instanzen nebeneinander.
* Pro CraftingTableType kann genau ein Eintrag existieren — die Liste zeigt
* immer alle 5 Typen; nicht konfigurierte Einträge erscheinen grau.
*/
public class CraftingTableEditorView extends BorderPane {
private final Map<CraftingTable.CraftingTableType, CraftingTable> shared =
new EnumMap<>(CraftingTable.CraftingTableType.class);
private final Path tableDir;
private TablePanel left;
private TablePanel right;
public CraftingTableEditorView(Path tableDir) {
this.tableDir = tableDir;
setStyle("-fx-background-color: #1e1e2e;");
reloadMap();
left = new TablePanel("Liste 1", shared, tableDir, this::onSaved);
right = new TablePanel("Liste 2", shared, tableDir, this::onSaved);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
private void reloadMap() {
shared.clear();
shared.putAll(CraftingTableIO.loadAll(tableDir));
}
private void onSaved() {
reloadMap();
left.refresh();
right.refresh();
}
// ── Single panel ──────────────────────────────────────────────────────────
static class TablePanel extends VBox {
private final Map<CraftingTable.CraftingTableType, CraftingTable> shared;
private final Path tableDir;
private final Runnable onSaved;
private final ListView<CraftingTable.CraftingTableType> listView;
private CraftingTable.CraftingTableType currentType = null;
// Form fields
private TextField nameIdField;
private TextField objectPathField;
private VBox formContainer;
private Button deleteBtn;
private Label formTypeLabel;
TablePanel(String title,
Map<CraftingTable.CraftingTableType, CraftingTable> shared,
Path tableDir, Runnable onSaved) {
this.shared = shared;
this.tableDir = tableDir;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
// ── Type list (always 5 fixed entries) ───────────────────────────
listView = new ListView<>();
listView.getItems().setAll(CraftingTable.CraftingTableType.values());
listView.setPrefHeight(160);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override
protected void updateItem(CraftingTable.CraftingTableType type, boolean empty) {
super.updateItem(type, empty);
if (empty || type == null) { setText(null); setStyle(""); return; }
boolean configured = shared.containsKey(type);
String color = typeColor(type);
String suffix = configured ? "" : "";
setText(type.name() + suffix);
String fillColor = configured ? "#dddddd" : "#666666";
setStyle("-fx-text-fill: " + fillColor + ";"
+ " -fx-border-color: transparent transparent transparent " + color + ";"
+ " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;");
setTooltip(new Tooltip(type.name() + (configured ? " konfiguriert" : " nicht konfiguriert")));
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onTypeSelected(old, nw));
deleteBtn = new Button("Konfiguration löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
VBox listSection = new VBox(listView, deleteBtn);
listSection.setPadding(new Insets(0, 0, 4, 0));
listSection.setStyle("-fx-background-color: #1a1a2a;");
VBox.setMargin(deleteBtn, new Insets(4, 8, 4, 8));
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
formTypeLabel = new Label("");
formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #ccddff;");
nameIdField = new TextField();
nameIdField.setPromptText("Text-Referenz ID (z. B. ui.crafting.alchemy_table)");
objectPathField = new TextField();
objectPathField.setPromptText("Asset-Pfad zum 3D-Objekt (z. B. Models/crafting/alchemy_table.j3o)");
Button saveBtn = new Button("Crafting Table speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentTable());
form.getChildren().addAll(
formTypeLabel,
new Separator(),
sectionTitle("Bezeichnung"),
row("Name-ID:", nameIdField),
new Separator(),
sectionTitle("3D-Objekt"),
row("Pfad:", objectPathField),
new Separator(),
saveBtn
);
return form;
}
// ── Form load / save ──────────────────────────────────────────────────
private void onTypeSelected(CraftingTable.CraftingTableType old, CraftingTable.CraftingTableType nw) {
currentType = nw;
if (nw == null) {
formContainer.setDisable(true);
deleteBtn.setDisable(true);
clearForm();
} else {
formContainer.setDisable(false);
loadFormFromType(nw);
deleteBtn.setDisable(!shared.containsKey(nw));
}
}
private void loadFormFromType(CraftingTable.CraftingTableType type) {
String color = typeColor(type);
formTypeLabel.setText(type.name());
formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13;"
+ " -fx-text-fill: " + color + ";");
CraftingTable t = shared.get(type);
if (t != null) {
nameIdField.setText(t.getName() != null ? t.getName().id() : "");
objectPathField.setText(t.getObject() != null ? safe(t.getObject().getPath()) : "");
} else {
clearFormFields();
}
}
private void saveCurrentTable() {
if (currentType == null) return;
CraftingTable t = shared.getOrDefault(currentType, new CraftingTable());
t.setType(currentType);
String nameId = nameIdField.getText().trim();
t.setName(nameId.isBlank() ? null : new TextReference(nameId));
String objPath = objectPathField.getText().trim();
t.setObject(objPath.isBlank() ? null : new ObjectReference(objPath));
try {
CraftingTableIO.save(t, tableDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
listView.getSelectionModel().select(currentType);
}
private void deleteSelected() {
if (currentType == null) return;
try {
CraftingTableIO.delete(currentType, tableDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
listView.getSelectionModel().select(currentType);
}
/** Called by the parent view when shared data has been reloaded. */
void refresh() {
listView.refresh();
if (currentType != null) {
deleteBtn.setDisable(!shared.containsKey(currentType));
if (formContainer.isDisable()) return;
loadFormFromType(currentType);
}
}
private void clearForm() {
formTypeLabel.setText("");
formTypeLabel.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #ccddff;");
clearFormFields();
}
private void clearFormFields() {
nameIdField.clear();
objectPathField.clear();
}
// ── Helpers ───────────────────────────────────────────────────────────
static String typeColor(CraftingTable.CraftingTableType type) {
if (type == null) return "#666666";
return switch (type) {
case AlchemyTable -> "#44bb88";
case EnchantmentTable -> "#aa55ee";
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
};
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(60);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static String safe(String s) { return s != null ? s : ""; }
}
}

View File

@@ -0,0 +1,831 @@
package de.blight.editor.ui;
import de.blight.common.model.*;
import de.blight.common.model.quests.Quest;
import de.blight.common.model.QuestRef;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.stage.Modality;
import java.util.*;
/**
* Vollbild-Ansicht für Dialog-Option-Editor im CharacterEditor.
* Zeigt alle DialogOptions eines NPC in einer Listen- oder Graph-Ansicht.
*/
public class DialogEditorView extends BorderPane {
// ── State ─────────────────────────────────────────────────────────────────
private final Map<String, DialogOption> allOptions = new LinkedHashMap<>();
private final Set<String> rootIds = new LinkedHashSet<>();
private String selectedId = null;
// ── Top-bar ───────────────────────────────────────────────────────────────
private ToggleButton listBtn;
private ToggleButton graphBtn;
// ── List-mode ─────────────────────────────────────────────────────────────
private SplitPane splitPane;
private ListView<String> optionListView;
private ScrollPane detailScroll;
// ── Detail-form fields ────────────────────────────────────────────────────
private TextField labelField;
private Label idLabel;
private CheckBox rootCheck;
private Spinner<Integer> chapterSpinner;
private ComboBox<String> statusCombo;
private TextField questOpenField;
private TextField questCompleteField;
private TextField textHeroField;
private TextField textNpcField;
private TextField reqItemIdField;
private Spinner<Integer> reqItemCount;
private TextField recvItemIdField;
private Spinner<Integer> recvItemCount;
private TextField recvQuestField;
private TextField fulfillsQuestField;
private ListView<String> abortsQuestsView;
private CheckBox enablesTradeCheck;
private ListView<String> nextOptionsView;
private ListView<String> disablesOptionsView;
// ── Graph-mode ────────────────────────────────────────────────────────────
private Pane graphCanvas;
private ScrollPane graphScroll;
// ── Construction ──────────────────────────────────────────────────────────
public DialogEditorView() {
setStyle("-fx-background-color: #252535;");
setTop(buildTopBar());
splitPane = buildListPane();
setCenter(splitPane);
}
// ── Public API ────────────────────────────────────────────────────────────
public void loadNpc(NPC npc) {
saveCurrentForm();
allOptions.clear();
rootIds.clear();
selectedId = null;
if (npc.getCurrentOptions() != null) {
for (DialogOption opt : npc.getCurrentOptions()) {
collectOptions(opt);
if (opt.getId() != null) rootIds.add(opt.getId());
}
normalizeReferences();
}
refreshOptionList();
clearDetailForm();
}
public void clear() {
saveCurrentForm();
allOptions.clear();
rootIds.clear();
selectedId = null;
refreshOptionList();
clearDetailForm();
}
public void exportToNpc(NPC npc) {
saveCurrentForm();
List<DialogOption> roots = new ArrayList<>();
for (String id : rootIds) {
DialogOption opt = allOptions.get(id);
if (opt != null) roots.add(opt);
}
npc.setCurrentOptions(roots.isEmpty() ? null : roots);
}
// ── Top-bar ────────────────────────────────────────────────────────────────
private HBox buildTopBar() {
listBtn = new ToggleButton("Liste");
graphBtn = new ToggleButton("Graph");
ToggleGroup tg = new ToggleGroup();
listBtn.setToggleGroup(tg);
graphBtn.setToggleGroup(tg);
listBtn.setSelected(true);
listBtn.setOnAction(e -> { if (listBtn.isSelected()) switchToListMode(); });
graphBtn.setOnAction(e -> { if (graphBtn.isSelected()) switchToGraphMode(); });
Button newRootBtn = new Button("+ Root-Option");
newRootBtn.setStyle("-fx-background-color: #3a7a3a; -fx-text-fill: white;");
newRootBtn.setOnAction(e -> createOption(true));
Button newBtn = new Button("+ Option");
newBtn.setOnAction(e -> createOption(false));
HBox bar = new HBox(8, listBtn, graphBtn,
new Separator(Orientation.VERTICAL), newRootBtn, newBtn);
bar.setPadding(new Insets(8));
bar.setAlignment(Pos.CENTER_LEFT);
bar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #555;"
+ " -fx-border-width: 0 0 1 0;");
return bar;
}
// ── List mode ─────────────────────────────────────────────────────────────
private SplitPane buildListPane() {
// ── Left: option list ─────────────────────────────────────────────────
optionListView = new ListView<>();
optionListView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); setStyle(""); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText((rootIds.contains(id) ? "" : " ") + lbl);
setStyle("-fx-text-fill: " + (rootIds.contains(id) ? "#ffdd88" : "#cccccc") + ";");
}
});
optionListView.getSelectionModel().selectedItemProperty().addListener(
(obs, oldId, newId) -> onOptionSelected(oldId, newId));
VBox.setVgrow(optionListView, Priority.ALWAYS);
Button rootToggleBtn = new Button("★ Root togglen");
rootToggleBtn.setMaxWidth(Double.MAX_VALUE);
rootToggleBtn.setOnAction(e -> toggleRoot());
Button deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #7a2a2a; -fx-text-fill: white;");
deleteBtn.setOnAction(e -> deleteSelected());
VBox leftBox = new VBox(6, optionListView, rootToggleBtn, deleteBtn);
leftBox.setPadding(new Insets(8));
leftBox.setStyle("-fx-background-color: #1e1e2e;");
leftBox.setPrefWidth(230);
// ── Right: detail form ────────────────────────────────────────────────
detailScroll = new ScrollPane(buildDetailForm());
detailScroll.setFitToWidth(true);
detailScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
SplitPane sp = new SplitPane(leftBox, detailScroll);
sp.setDividerPositions(0.27);
sp.setStyle("-fx-background-color: #252535;");
return sp;
}
private void switchToListMode() {
setCenter(splitPane);
}
// ── Graph mode ────────────────────────────────────────────────────────────
private void switchToGraphMode() {
saveCurrentForm();
graphCanvas = new Pane();
graphCanvas.setStyle("-fx-background-color: #1a1a2a;");
rebuildGraph();
graphScroll = new ScrollPane(graphCanvas);
graphScroll.setStyle("-fx-background-color: #1a1a2a; -fx-background: #1a1a2a;");
setCenter(graphScroll);
}
// ── Detail form ───────────────────────────────────────────────────────────
private VBox buildDetailForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(12));
form.setStyle("-fx-background-color: #252535;");
// Identity
labelField = new TextField();
labelField.setPromptText("Bezeichnung (nur im Editor)");
idLabel = new Label("");
idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #777;");
rootCheck = new CheckBox("Root-Option (initial im Dialog sichtbar)");
rootCheck.setStyle("-fx-text-fill: #ccc;");
form.getChildren().addAll(
sectionTitle("Option"), new Separator(),
row("Bezeichnung:", labelField),
row("ID:", idLabel),
rootCheck, new Separator()
);
// Voraussetzungen
chapterSpinner = new Spinner<>(0, 99, 0);
chapterSpinner.setEditable(true);
chapterSpinner.setPrefWidth(80);
statusCombo = new ComboBox<>();
statusCombo.getItems().addAll("— (keine)", "FRIENDLY", "NEUTRAL", "ENRAGED", "ENEMY");
statusCombo.setValue("— (keine)");
statusCombo.setMaxWidth(Double.MAX_VALUE);
questOpenField = new TextField();
questOpenField.setPromptText("Quest-ID");
questCompleteField = new TextField();
questCompleteField.setPromptText("Quest-ID");
form.getChildren().addAll(
sectionTitle("Voraussetzungen"),
row("Kapitel ≥:", chapterSpinner),
row("Status ≥:", statusCombo),
row("Quest offen:", questOpenField),
row("Quest abgeschl.:", questCompleteField),
new Separator()
);
// Texte
textHeroField = new TextField();
textHeroField.setPromptText("TextReference-Schlüssel");
textNpcField = new TextField();
textNpcField.setPromptText("TextReference-Schlüssel");
form.getChildren().addAll(
sectionTitle("Texte"),
row("Text Held:", textHeroField),
row("Text NPC:", textNpcField),
row("Audio Held:", placeholder("(wird später implementiert)")),
row("Audio NPC:", placeholder("(wird später implementiert)")),
new Separator()
);
// Items
reqItemIdField = new TextField();
reqItemIdField.setPromptText("Item-ID");
reqItemCount = new Spinner<>(1, 999, 1);
reqItemCount.setPrefWidth(70);
recvItemIdField = new TextField();
recvItemIdField.setPromptText("Item-ID");
recvItemCount = new Spinner<>(1, 999, 1);
recvItemCount.setPrefWidth(70);
form.getChildren().addAll(
sectionTitle("Items"),
itemRow("Benötigt:", reqItemIdField, reqItemCount),
itemRow("Erhält:", recvItemIdField, recvItemCount),
new Separator()
);
// Quests
recvQuestField = new TextField();
recvQuestField.setPromptText("Quest-ID");
fulfillsQuestField = new TextField();
fulfillsQuestField.setPromptText("Quest-ID");
abortsQuestsView = new ListView<>();
abortsQuestsView.setPrefHeight(80);
abortsQuestsView.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
Button addAbortsBtn = smallBtn("+");
Button delAbortsBtn = smallBtn("");
addAbortsBtn.setOnAction(e -> promptQuestId(abortsQuestsView));
delAbortsBtn.setOnAction(e -> removeSelected(abortsQuestsView));
form.getChildren().addAll(
sectionTitle("Quests"),
row("Erhält Quest:", recvQuestField),
row("Erfüllt Quest:", fulfillsQuestField),
sectionTitle("Bricht Quests ab:"),
abortsQuestsView,
new HBox(4, addAbortsBtn, delAbortsBtn),
new Separator()
);
// Effekte
enablesTradeCheck = new CheckBox("Ermöglicht Handel");
enablesTradeCheck.setStyle("-fx-text-fill: #ccc;");
form.getChildren().addAll(
sectionTitle("Effekte"),
enablesTradeCheck,
new Separator()
);
// Verbindungen: nextOptions
nextOptionsView = buildRefList();
Button addNextBtn = smallBtn("+");
Button delNextBtn = smallBtn("");
addNextBtn.setOnAction(e -> pickOptionRef(nextOptionsView));
delNextBtn.setOnAction(e -> removeSelected(nextOptionsView));
// Verbindungen: disablesOptions
disablesOptionsView = buildRefList();
Button addDisBtn = smallBtn("+");
Button delDisBtn = smallBtn("");
addDisBtn.setOnAction(e -> pickOptionRef(disablesOptionsView));
delDisBtn.setOnAction(e -> removeSelected(disablesOptionsView));
form.getChildren().addAll(
sectionTitle("Nächste Optionen (nextOptions):"),
nextOptionsView,
new HBox(4, addNextBtn, delNextBtn),
sectionTitle("Deaktiviert Optionen (disablesOptions):"),
disablesOptionsView,
new HBox(4, addDisBtn, delDisBtn)
);
return form;
}
private ListView<String> buildRefList() {
ListView<String> lv = new ListView<>();
lv.setPrefHeight(90);
lv.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
lv.setCellFactory(v -> new ListCell<>() {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText(lbl);
setStyle("-fx-text-fill: #cccccc;");
}
});
return lv;
}
// ── Form load / save ──────────────────────────────────────────────────────
private void onOptionSelected(String oldId, String newId) {
if (oldId != null) saveFormToOption(oldId);
selectedId = newId;
if (newId != null) loadFormFromOption(newId);
else clearDetailForm();
}
private void loadFormFromOption(String id) {
DialogOption opt = allOptions.get(id);
if (opt == null) { clearDetailForm(); return; }
idLabel.setText(id);
labelField.setText(opt.getLabel() != null ? opt.getLabel() : "");
rootCheck.setSelected(rootIds.contains(id));
chapterSpinner.getValueFactory().setValue(opt.getRequiresChapter());
Status s = opt.getRequiresStatus();
statusCombo.setValue(s == null ? "— (keine)" : s.name());
questOpenField.setText(opt.getRequiresQuestOpen() != null
? safe(opt.getRequiresQuestOpen().getQuestId()) : "");
questCompleteField.setText(opt.getRequiresQuestComplete() != null
? safe(opt.getRequiresQuestComplete().getQuestId()) : "");
textHeroField.setText(opt.getTextHero() != null ? opt.getTextHero().id() : "");
textNpcField.setText(opt.getTextNpc() != null ? opt.getTextNpc().id() : "");
if (opt.getRequiredItem() != null && opt.getRequiredItem().getItem() != null) {
reqItemIdField.setText(safe(opt.getRequiredItem().getItem().getItemId()));
reqItemCount.getValueFactory().setValue(opt.getRequiredItem().getCount());
} else {
reqItemIdField.clear();
reqItemCount.getValueFactory().setValue(1);
}
if (opt.getRecievesItem() != null && opt.getRecievesItem().getItem() != null) {
recvItemIdField.setText(safe(opt.getRecievesItem().getItem().getItemId()));
recvItemCount.getValueFactory().setValue(opt.getRecievesItem().getCount());
} else {
recvItemIdField.clear();
recvItemCount.getValueFactory().setValue(1);
}
recvQuestField.setText(opt.getRecievesQuest() != null
? safe(opt.getRecievesQuest().getQuestId()) : "");
fulfillsQuestField.setText(opt.getFulfillsQuest() != null
? safe(opt.getFulfillsQuest().getQuestId()) : "");
abortsQuestsView.getItems().clear();
if (opt.getAbortsQuests() != null)
opt.getAbortsQuests().stream()
.filter(q -> q != null && q.getQuestId() != null)
.map(Quest::getQuestId)
.forEach(abortsQuestsView.getItems()::add);
enablesTradeCheck.setSelected(opt.isEnablesTrade());
nextOptionsView.getItems().clear();
if (opt.getNextOptions() != null)
opt.getNextOptions().stream()
.filter(o -> o != null && o.getId() != null)
.map(DialogOption::getId)
.forEach(nextOptionsView.getItems()::add);
disablesOptionsView.getItems().clear();
if (opt.getDisablesOptions() != null)
opt.getDisablesOptions().stream()
.filter(o -> o != null && o.getId() != null)
.map(DialogOption::getId)
.forEach(disablesOptionsView.getItems()::add);
}
private void saveFormToOption(String id) {
DialogOption opt = allOptions.get(id);
if (opt == null) return;
opt.setLabel(labelField.getText());
if (rootCheck.isSelected()) rootIds.add(id);
else rootIds.remove(id);
opt.setRequiresChapter(chapterSpinner.getValue());
String sv = statusCombo.getValue();
if (sv == null || sv.startsWith("")) {
opt.setRequiresStatus(null);
} else {
try { opt.setRequiresStatus(Status.valueOf(sv)); }
catch (IllegalArgumentException ex) { opt.setRequiresStatus(null); }
}
opt.setRequiresQuestOpen(questFromField(questOpenField));
opt.setRequiresQuestComplete(questFromField(questCompleteField));
opt.setTextHero(refFromField(textHeroField));
opt.setTextNpc(refFromField(textNpcField));
opt.setRequiredItem(requiredItemFromFields(reqItemIdField, reqItemCount));
opt.setRecievesItem(recievesItemFromFields(recvItemIdField, recvItemCount));
opt.setRecievesQuest(questFromField(recvQuestField));
opt.setFulfillsQuest(questFromField(fulfillsQuestField));
List<QuestRef> aborts = new ArrayList<>();
for (String qId : abortsQuestsView.getItems()) {
QuestRef q = new QuestRef(); q.setQuestId(qId); aborts.add(q);
}
opt.setAbortsQuests(aborts);
opt.setEnablesTrade(enablesTradeCheck.isSelected());
opt.setNextOptions(resolveRefs(nextOptionsView.getItems()));
opt.setDisablesOptions(resolveRefs(disablesOptionsView.getItems()));
optionListView.refresh();
}
private void saveCurrentForm() {
if (selectedId != null) saveFormToOption(selectedId);
}
private void clearDetailForm() {
if (idLabel != null) idLabel.setText("");
if (labelField != null) labelField.clear();
if (rootCheck != null) rootCheck.setSelected(false);
if (chapterSpinner != null) chapterSpinner.getValueFactory().setValue(0);
if (statusCombo != null) statusCombo.setValue("— (keine)");
if (questOpenField != null) questOpenField.clear();
if (questCompleteField != null) questCompleteField.clear();
if (textHeroField != null) textHeroField.clear();
if (textNpcField != null) textNpcField.clear();
if (reqItemIdField != null) reqItemIdField.clear();
if (reqItemCount != null) reqItemCount.getValueFactory().setValue(1);
if (recvItemIdField != null) recvItemIdField.clear();
if (recvItemCount != null) recvItemCount.getValueFactory().setValue(1);
if (recvQuestField != null) recvQuestField.clear();
if (fulfillsQuestField != null) fulfillsQuestField.clear();
if (abortsQuestsView != null) abortsQuestsView.getItems().clear();
if (enablesTradeCheck != null) enablesTradeCheck.setSelected(false);
if (nextOptionsView != null) nextOptionsView.getItems().clear();
if (disablesOptionsView != null) disablesOptionsView.getItems().clear();
}
// ── List management ───────────────────────────────────────────────────────
private void refreshOptionList() {
if (optionListView == null) return;
String prev = selectedId;
optionListView.getItems().setAll(allOptions.keySet());
if (prev != null && allOptions.containsKey(prev))
optionListView.getSelectionModel().select(prev);
}
private void createOption(boolean asRoot) {
saveCurrentForm();
DialogOption opt = new DialogOption();
opt.setLabel("Neue Option");
allOptions.put(opt.getId(), opt);
if (asRoot) rootIds.add(opt.getId());
refreshOptionList();
optionListView.getSelectionModel().select(opt.getId());
}
private void toggleRoot() {
String id = optionListView.getSelectionModel().getSelectedItem();
if (id == null) return;
if (rootIds.contains(id)) rootIds.remove(id); else rootIds.add(id);
optionListView.refresh();
if (id.equals(selectedId)) rootCheck.setSelected(rootIds.contains(id));
}
private void deleteSelected() {
String id = optionListView.getSelectionModel().getSelectedItem();
if (id == null) return;
allOptions.remove(id);
rootIds.remove(id);
for (DialogOption opt : allOptions.values()) {
if (opt.getNextOptions() != null) opt.getNextOptions().removeIf(o -> id.equals(o.getId()));
if (opt.getDisablesOptions() != null) opt.getDisablesOptions().removeIf(o -> id.equals(o.getId()));
}
selectedId = null;
refreshOptionList();
clearDetailForm();
}
private void pickOptionRef(ListView<String> target) {
Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Option auswählen");
dlg.initModality(Modality.APPLICATION_MODAL);
ListView<String> chooser = new ListView<>();
chooser.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText((rootIds.contains(id) ? "" : " ") + lbl);
}
});
chooser.getItems().addAll(allOptions.keySet());
chooser.setPrefSize(320, 280);
dlg.getDialogPane().setContent(chooser);
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) dlg.getDialogPane().lookupButton(ButtonType.OK);
okBtn.setDisable(true);
chooser.getSelectionModel().selectedItemProperty()
.addListener((obs, o, n) -> okBtn.setDisable(n == null));
chooser.setOnMouseClicked(e -> {
if (e.getClickCount() == 2 && !chooser.getSelectionModel().isEmpty()) okBtn.fire();
});
dlg.setResultConverter(bt -> bt == ButtonType.OK
? chooser.getSelectionModel().getSelectedItem() : null);
dlg.showAndWait().ifPresent(id -> {
if (!target.getItems().contains(id)) target.getItems().add(id);
});
}
private void promptQuestId(ListView<String> target) {
TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Quest-ID");
dlg.setHeaderText("Quest-ID eingeben:");
dlg.initModality(Modality.APPLICATION_MODAL);
dlg.showAndWait().ifPresent(id -> {
if (!id.isBlank() && !target.getItems().contains(id)) target.getItems().add(id);
});
}
private static void removeSelected(ListView<String> list) {
String sel = list.getSelectionModel().getSelectedItem();
if (sel != null) list.getItems().remove(sel);
}
// ── Graph ─────────────────────────────────────────────────────────────────
private void rebuildGraph() {
graphCanvas.getChildren().clear();
if (allOptions.isEmpty()) {
graphCanvas.setPrefSize(600, 200);
Text hint = new Text("Keine Dialog-Optionen vorhanden");
hint.setFill(Color.web("#666"));
hint.setLayoutX(200); hint.setLayoutY(100);
graphCanvas.getChildren().add(hint);
return;
}
double nodeW = 160, nodeH = 44, hGap = 24, vGap = 70;
// BFS layer assignment starting from root options
Map<String, Integer> layer = new LinkedHashMap<>();
Queue<String> queue = new ArrayDeque<>(rootIds);
for (String id : rootIds) layer.put(id, 0);
while (!queue.isEmpty()) {
String id = queue.poll();
DialogOption opt = allOptions.get(id);
if (opt == null || opt.getNextOptions() == null) continue;
int lyr = layer.get(id);
for (DialogOption next : opt.getNextOptions()) {
if (next != null && !layer.containsKey(next.getId())) {
layer.put(next.getId(), lyr + 1);
queue.add(next.getId());
}
}
}
int maxLayer = layer.values().stream().mapToInt(v -> v).max().orElse(0);
for (String id : allOptions.keySet())
if (!layer.containsKey(id)) layer.put(id, maxLayer + 1);
// Group by layer
Map<Integer, List<String>> groups = new TreeMap<>();
layer.forEach((id, lyr) -> groups.computeIfAbsent(lyr, k -> new ArrayList<>()).add(id));
// Assign x/y positions
Map<String, double[]> pos = new HashMap<>();
groups.forEach((lyr, ids) -> {
double totalW = ids.size() * nodeW + (ids.size() - 1) * hGap;
double startX = Math.max(20, (900 - totalW) / 2.0);
double y = 24 + lyr * (nodeH + vGap);
for (int i = 0; i < ids.size(); i++)
pos.put(ids.get(i), new double[]{ startX + i * (nodeW + hGap), y });
});
// Draw edges first (behind nodes)
for (String id : allOptions.keySet()) {
DialogOption opt = allOptions.get(id);
double[] from = pos.get(id);
if (from == null) continue;
double fx = from[0] + nodeW / 2, fy = from[1] + nodeH;
if (opt.getNextOptions() != null) {
for (DialogOption next : opt.getNextOptions()) {
double[] to = pos.get(next.getId());
if (to != null) drawArrow(fx, fy, to[0] + nodeW / 2, to[1], "#5588cc", false);
}
}
if (opt.getDisablesOptions() != null) {
for (DialogOption dis : opt.getDisablesOptions()) {
double[] to = pos.get(dis.getId());
if (to != null) drawArrow(fx, fy, to[0] + nodeW / 2, to[1], "#cc4444", true);
}
}
}
// Draw nodes
for (Map.Entry<String, double[]> entry : pos.entrySet()) {
String id = entry.getKey();
double[] p = entry.getValue();
DialogOption opt = allOptions.get(id);
boolean isRoot = rootIds.contains(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
Rectangle rect = new Rectangle(p[0], p[1], nodeW, nodeH);
rect.setArcWidth(8); rect.setArcHeight(8);
rect.setFill(Color.web(isRoot ? "#253a5a" : "#2a3040"));
rect.setStroke(Color.web(isRoot ? "#5599ee" : "#445566"));
rect.setStrokeWidth(isRoot ? 2 : 1);
Text txt = new Text(truncate(lbl, 21));
txt.setFill(Color.web(isRoot ? "#ddeeff" : "#aabbcc"));
txt.setLayoutX(p[0] + 8);
txt.setLayoutY(p[1] + nodeH / 2.0 + 5);
graphCanvas.getChildren().addAll(rect, txt);
}
double maxX = pos.values().stream().mapToDouble(p -> p[0] + nodeW + 30).max().orElse(400);
double maxY = pos.values().stream().mapToDouble(p -> p[1] + nodeH + 30).max().orElse(200);
graphCanvas.setPrefSize(Math.max(900, maxX), Math.max(300, maxY));
}
private void drawArrow(double x1, double y1, double x2, double y2,
String colorHex, boolean dashed) {
Line line = new Line(x1, y1, x2, y2);
line.setStroke(Color.web(colorHex));
line.setStrokeWidth(1.5);
if (dashed) line.getStrokeDashArray().addAll(6.0, 4.0);
double angle = Math.atan2(y2 - y1, x2 - x1);
double as = 9;
Polygon head = new Polygon(
x2, y2,
x2 - as * Math.cos(angle - 0.45), y2 - as * Math.sin(angle - 0.45),
x2 - as * Math.cos(angle + 0.45), y2 - as * Math.sin(angle + 0.45)
);
head.setFill(Color.web(colorHex));
graphCanvas.getChildren().addAll(line, head);
}
// ── Data helpers ──────────────────────────────────────────────────────────
private void collectOptions(DialogOption opt) {
if (opt == null || allOptions.containsKey(opt.getId())) return;
allOptions.put(opt.getId(), opt);
if (opt.getNextOptions() != null)
for (DialogOption next : opt.getNextOptions()) collectOptions(next);
if (opt.getDisablesOptions() != null)
for (DialogOption dis : opt.getDisablesOptions()) collectOptions(dis);
}
private void normalizeReferences() {
for (DialogOption opt : allOptions.values()) {
if (opt.getNextOptions() != null) {
List<DialogOption> norm = new ArrayList<>();
for (DialogOption o : opt.getNextOptions()) {
DialogOption c = allOptions.get(o.getId());
if (c != null) norm.add(c);
}
opt.setNextOptions(norm);
}
if (opt.getDisablesOptions() != null) {
List<DialogOption> norm = new ArrayList<>();
for (DialogOption o : opt.getDisablesOptions()) {
DialogOption c = allOptions.get(o.getId());
if (c != null) norm.add(c);
}
opt.setDisablesOptions(norm);
}
}
}
private List<DialogOption> resolveRefs(List<String> ids) {
List<DialogOption> result = new ArrayList<>();
for (String id : ids) {
DialogOption opt = allOptions.get(id);
if (opt != null) result.add(opt);
}
return result.isEmpty() ? null : result;
}
// ── Form value helpers ────────────────────────────────────────────────────
private static QuestRef questFromField(TextField f) {
String s = f.getText().trim();
if (s.isBlank()) return null;
QuestRef q = new QuestRef(); q.setQuestId(s); return q;
}
private static TextReference refFromField(TextField f) {
String s = f.getText().trim();
return s.isBlank() ? null : new TextReference(s);
}
private static RequiredItem requiredItemFromFields(TextField idField, Spinner<Integer> count) {
String s = idField.getText().trim();
if (s.isBlank()) return null;
RequiredItem ri = new RequiredItem();
Item item = new Item(); item.setItemId(s);
ri.setItem(item); ri.setCount(count.getValue()); return ri;
}
private static RecievesItem recievesItemFromFields(TextField idField, Spinner<Integer> count) {
String s = idField.getText().trim();
if (s.isBlank()) return null;
RecievesItem rv = new RecievesItem();
Item item = new Item(); item.setItemId(s);
rv.setItem(item); rv.setCount(count.getValue()); return rv;
}
private static String safe(String s) { return s != null ? s : ""; }
private static String truncate(String s, int max) {
if (s == null || s.length() <= max) return s != null ? s : "";
return s.substring(0, max - 1) + "";
}
// ── Widget helpers ────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(150);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static HBox itemRow(String labelText, TextField idField, Spinner<Integer> count) {
Label lbl = new Label(labelText);
lbl.setMinWidth(65);
lbl.setStyle("-fx-text-fill: #aaa;");
Label cntLbl = new Label("Anz:");
cntLbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(idField, Priority.ALWAYS);
HBox box = new HBox(8, lbl, idField, cntLbl, count);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static Label placeholder(String text) {
Label l = new Label(text);
l.setStyle("-fx-text-fill: #555; -fx-font-style: italic;");
return l;
}
private static Button smallBtn(String text) {
Button b = new Button(text);
b.setPrefWidth(28);
return b;
}
}

View File

@@ -0,0 +1,302 @@
package de.blight.editor.ui;
import de.blight.common.model.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.UUID;
/**
* Fraktions-Verwaltung: zwei identische FractionPanel-Instanzen nebeneinander.
* Sortiert nach Name-ID, dann nach UUID.
*/
public class FractionEditorView extends BorderPane {
private final ObservableList<Fraction> sharedFractions = FXCollections.observableArrayList();
private final Path fractionDir;
public FractionEditorView(Path fractionDir) {
this.fractionDir = fractionDir;
setStyle("-fx-background-color: #1e1e2e;");
reload();
FractionPanel left = new FractionPanel("Liste 1", sharedFractions, fractionDir, this::reload);
FractionPanel right = new FractionPanel("Liste 2", sharedFractions, fractionDir, this::reload);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
public void reload() {
sharedFractions.setAll(FractionIO.loadAll(fractionDir));
}
// ── Single panel ──────────────────────────────────────────────────────────
static class FractionPanel extends VBox {
private final ObservableList<Fraction> fractions;
private final Path fractionDir;
private final Runnable onSaved;
private final SortedList<Fraction> sortedFractions;
private final ListView<Fraction> listView;
private Fraction current = null;
// Form fields
private Label idLabel;
private TextField nameField;
private TextField maleMemberField;
private TextField femaleMemberField;
private TextField rank1Field;
private TextField rank2Field;
private TextField rank3Field;
private VBox formContainer;
private Button deleteBtn;
FractionPanel(String title, ObservableList<Fraction> fractions, Path fractionDir, Runnable onSaved) {
this.fractions = fractions;
this.fractionDir = fractionDir;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
// ── Fraction list ─────────────────────────────────────────────────
sortedFractions = new SortedList<>(fractions, FractionIO.SORT_ORDER);
listView = new ListView<>(sortedFractions);
listView.setPrefHeight(180);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Fraction f, boolean empty) {
super.updateItem(f, empty);
if (empty || f == null) { setText(null); setStyle(""); return; }
String name = f.getName() != null ? f.getName().id() : "";
String uuid = f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "" : "?";
setText(name);
setTooltip(new Tooltip("ID: " + (f.getFractionId() != null ? f.getFractionId() : "?")));
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent #6699cc;"
+ " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;");
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onFractionSelected(old, nw));
Button newBtn = new Button("Neue Fraktion");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createFraction());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox listSection = new VBox(listView, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
idLabel = new Label("");
idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-font-family: monospace;");
nameField = field("z. B. faction.guards");
maleMemberField = field("z. B. faction.guards.member.male");
femaleMemberField = field("z. B. faction.guards.member.female");
rank1Field = field("z. B. faction.guards.rank1");
rank2Field = field("z. B. faction.guards.rank2");
rank3Field = field("z. B. faction.guards.rank3");
Button saveBtn = new Button("Fraktion speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentFraction());
form.getChildren().addAll(
sectionTitle("Kennung"),
new Separator(),
row("UUID:", idLabel),
sectionTitle("Text-Referenzen"),
new Separator(),
row("Name:", nameField),
row("Mitglied (m):", maleMemberField),
row("Mitglied (w):", femaleMemberField),
sectionTitle("Ränge"),
new Separator(),
row("Rang 1:", rank1Field),
row("Rang 2:", rank2Field),
row("Rang 3:", rank3Field),
new Separator(),
saveBtn
);
return form;
}
// ── Form load / save ──────────────────────────────────────────────────
private void onFractionSelected(Fraction old, Fraction nw) {
if (old != null) saveFormToFraction(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromFraction(nw);
} else {
formContainer.setDisable(true);
clearForm();
}
}
private void loadFormFromFraction(Fraction f) {
idLabel.setText(f.getFractionId() != null ? f.getFractionId().toString() : "");
nameField.setText(textId(f.getName()));
maleMemberField.setText(textId(f.getMaleMemberName()));
femaleMemberField.setText(textId(f.getFemaleMemberName()));
rank1Field.setText(textId(f.getRank1Name()));
rank2Field.setText(textId(f.getRank2Name()));
rank3Field.setText(textId(f.getRank3Name()));
}
private void saveFormToFraction(Fraction f) {
f.setName(ref(nameField.getText()));
f.setMaleMemberName(ref(maleMemberField.getText()));
f.setFemaleMemberName(ref(femaleMemberField.getText()));
f.setRank1Name(ref(rank1Field.getText()));
f.setRank2Name(ref(rank2Field.getText()));
f.setRank3Name(ref(rank3Field.getText()));
}
private void clearForm() {
idLabel.setText("");
nameField.clear();
maleMemberField.clear();
femaleMemberField.clear();
rank1Field.clear();
rank2Field.clear();
rank3Field.clear();
}
// ── List operations ───────────────────────────────────────────────────
private void createFraction() {
Fraction f = new Fraction();
f.setFractionId(UUID.randomUUID());
fractions.add(f);
listView.getSelectionModel().select(f);
}
private void deleteSelected() {
Fraction sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
UUID id = sel.getFractionId();
fractions.remove(sel);
try { FractionIO.delete(id, fractionDir); } catch (IOException ignored) {}
current = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrentFraction() {
if (current == null) return;
saveFormToFraction(current);
if (current.getFractionId() == null) {
new Alert(Alert.AlertType.ERROR,
"Fraktion hat keine UUID bitte neu erstellen.", ButtonType.OK).showAndWait();
return;
}
try {
FractionIO.save(current, fractionDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
final UUID fid = current.getFractionId();
fractions.stream()
.filter(f -> fid.equals(f.getFractionId()))
.findFirst()
.ifPresent(listView.getSelectionModel()::select);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static TextReference ref(String text) {
String t = text == null ? "" : text.trim();
return t.isBlank() ? null : new TextReference(t);
}
private static String textId(TextReference r) { return r != null ? r.id() : ""; }
private static TextField field(String prompt) {
TextField tf = new TextField();
tf.setPromptText(prompt);
return tf;
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(100);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
}
}

View File

@@ -0,0 +1,367 @@
package de.blight.editor.ui;
import de.blight.common.model.Item;
import de.blight.common.model.ItemCategory;
import de.blight.common.model.ItemIO;
import de.blight.common.model.ObjectReference;
import de.blight.common.model.TextReference;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
/**
* Item-Verwaltung: zwei identische ItemPanel-Instanzen nebeneinander.
* Liste sortiert nach Kategorie, dann nach Name.
*/
public class ItemEditorView extends BorderPane {
private final ObservableList<Item> sharedItems = FXCollections.observableArrayList();
private final Path itemDir;
public ItemEditorView(Path itemDir) {
this.itemDir = itemDir;
setStyle("-fx-background-color: #1e1e2e;");
reload();
ItemPanel left = new ItemPanel("Liste 1", sharedItems, itemDir, this::reload);
ItemPanel right = new ItemPanel("Liste 2", sharedItems, itemDir, this::reload);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
public void reload() {
List<Item> loaded = ItemIO.loadAll(itemDir);
sharedItems.setAll(loaded);
}
// ── Category colors ───────────────────────────────────────────────────────
static String categoryColor(ItemCategory cat) {
if (cat == null) return "#666666";
return switch (cat) {
case WEAPON -> "#cc5555";
case GEAR -> "#5588cc";
case CONSUMABLES -> "#55aa55";
case QUEST_ITEMS -> "#ccaa33";
case USABLES -> "#aa55cc";
case MISC -> "#778899";
};
}
// ── Single panel ──────────────────────────────────────────────────────────
static class ItemPanel extends VBox {
private final ObservableList<Item> items;
private final Path itemDir;
private final Runnable onSaved;
private final SortedList<Item> sortedItems;
private final ListView<Item> listView;
private Item current = null;
// Form fields
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private TextField nameField;
private TextField descField;
private Spinner<Integer> goldSpinner;
private TextField modelRefField;
// Form container
private VBox formContainer;
private Button deleteBtn;
ItemPanel(String title, ObservableList<Item> items, Path itemDir, Runnable onSaved) {
this.items = items;
this.itemDir = itemDir;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
setSpacing(0);
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
// ── Item list (sorted) ─────────────────────────────────────────────
sortedItems = new SortedList<>(items, ItemIO.SORT_ORDER);
listView = new ListView<>(sortedItems);
listView.setPrefHeight(200);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Item item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) { setText(null); setStyle(""); return; }
String catName = item.getCategory() != null ? item.getCategory().name() : "";
String name = item.getName() != null ? item.getName().id()
: (item.getItemId() != null ? item.getItemId() : "");
setText(name);
String color = categoryColor(item.getCategory());
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent " + color + ";"
+ " -fx-border-width: 0 0 0 3;"
+ " -fx-padding: 3 6 3 8;");
setTooltip(new Tooltip("[" + catName + "] " + item.getItemId()));
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onItemSelected(old, nw));
// Category legend
HBox legend = buildLegend();
legend.setPadding(new Insets(4, 8, 4, 8));
legend.setStyle("-fx-background-color: #1a1a2a;");
Button newBtn = new Button("Neues Item");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createItem());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox listSection = new VBox(listView, legend, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Legend ────────────────────────────────────────────────────────────
private HBox buildLegend() {
HBox box = new HBox(10);
box.setAlignment(Pos.CENTER_LEFT);
for (ItemCategory cat : ItemCategory.values()) {
Label dot = new Label("");
dot.setStyle("-fx-text-fill: " + categoryColor(cat) + "; -fx-font-size: 10;");
Label lbl = new Label(cat.name());
lbl.setStyle("-fx-text-fill: #888; -fx-font-size: 10;");
box.getChildren().addAll(dot, lbl);
}
return box;
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
idField = new TextField();
idField.setPromptText("eindeutige ID");
catCombo = new ComboBox<>();
catCombo.getItems().addAll(ItemCategory.values());
catCombo.setMaxWidth(Double.MAX_VALUE);
catCombo.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
catCombo.setButtonCell(new ListCell<>() {
@Override protected void updateItem(ItemCategory cat, boolean empty) {
super.updateItem(cat, empty);
if (empty || cat == null) { setText(null); return; }
setText(cat.name());
setStyle("-fx-text-fill: " + categoryColor(cat) + ";");
}
});
nameField = new TextField();
nameField.setPromptText("TextReference-Schlüssel");
descField = new TextField();
descField.setPromptText("TextReference-Schlüssel");
goldSpinner = new Spinner<>(0, 999999, 0);
goldSpinner.setEditable(true);
goldSpinner.setMaxWidth(Double.MAX_VALUE);
modelRefField = new TextField();
modelRefField.setPromptText("Modell-Pfad (z.B. Models/Items/sword.j3o)");
Button saveBtn = new Button("Item speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentItem());
form.getChildren().addAll(
sectionTitle("Item"),
new Separator(),
row("Item-ID:", idField),
row("Kategorie:", catCombo),
new Separator(),
sectionTitle("Texte"),
row("Name:", nameField),
row("Beschreibung:", descField),
new Separator(),
sectionTitle("Werte"),
row("Wert (Gold):", goldSpinner),
row("Modell:", modelRefField),
new Separator(),
saveBtn
);
return form;
}
// ── Form load / save ──────────────────────────────────────────────────
private void onItemSelected(Item old, Item nw) {
if (old != null) saveFormToItem(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromItem(nw);
} else {
formContainer.setDisable(true);
clearForm();
}
}
private void loadFormFromItem(Item item) {
idField.setText(safe(item.getItemId()));
catCombo.setValue(item.getCategory());
nameField.setText(item.getName() != null ? item.getName().id() : "");
descField.setText(item.getDescription() != null ? item.getDescription().id() : "");
goldSpinner.getValueFactory().setValue(item.getWorthGold());
ObjectReference ref = item.getModelRef();
modelRefField.setText(ref != null && ref.getPath() != null ? ref.getPath() : "");
}
private void saveFormToItem(Item item) {
item.setItemId(idField.getText().trim());
item.setCategory(catCombo.getValue());
item.setName(ref(nameField));
item.setDescription(ref(descField));
item.setWorthGold(goldSpinner.getValue());
String mr = modelRefField.getText().trim();
item.setModelRef(mr.isBlank() ? null : new ObjectReference(mr));
}
private void clearForm() {
idField.clear();
catCombo.setValue(null);
nameField.clear();
descField.clear();
goldSpinner.getValueFactory().setValue(0);
modelRefField.clear();
}
// ── List operations ───────────────────────────────────────────────────
private void createItem() {
Item item = new Item();
item.setItemId("neues_item_" + System.currentTimeMillis());
item.setCategory(ItemCategory.MISC);
items.add(item);
// Select the new item in the sorted view
listView.getSelectionModel().select(item);
}
private void deleteSelected() {
Item sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String iId = sel.getItemId();
items.remove(sel);
if (iId != null && !iId.isBlank()) {
try { ItemIO.delete(iId, itemDir); }
catch (IOException e) { /* ignore */ }
}
current = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrentItem() {
if (current == null) return;
saveFormToItem(current);
if (current.getItemId() == null || current.getItemId().isBlank()) {
new Alert(Alert.AlertType.ERROR, "Item-ID darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
try {
ItemIO.save(current, itemDir);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
// Re-select after reload (list re-sorts)
String savedId = current.getItemId();
items.stream()
.filter(i -> savedId.equals(i.getItemId()))
.findFirst()
.ifPresent(listView.getSelectionModel()::select);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(120);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static String safe(String s) { return s != null ? s : ""; }
private static TextReference ref(TextField f) {
String s = f.getText().trim();
return s.isBlank() ? null : new TextReference(s);
}
}
}

View File

@@ -0,0 +1,204 @@
package de.blight.editor.ui;
import de.blight.common.model.TextBundle;
import de.blight.common.model.TextBundleIO;
import de.blight.common.model.TextRegistry;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
/**
* Lokalisierungs-Editor: verwaltet Sprach-Bundles (de.json, en.json …)
* und deren Schlüssel→Text-Einträge.
*
* Speicherort: {@code ASSET_ROOT/localization/<lang>.json}
*/
public class LocalizationEditorView extends BorderPane {
private final Path locDir;
// Sprach-Auswahl
private final ComboBox<String> langCombo = new ComboBox<>();
private final ObservableList<String[]> tableData = FXCollections.observableArrayList();
private final TableView<String[]> table = new TableView<>(tableData);
private TextBundle currentBundle = null;
public LocalizationEditorView(Path locDir) {
this.locDir = locDir;
setStyle("-fx-background-color: #1e1e2e;");
buildUI();
refreshLangList();
}
private void buildUI() {
// ── Kopfzeile ─────────────────────────────────────────────────────────
Label langLbl = new Label("Sprache:");
langLbl.setStyle("-fx-text-fill: #aaa;");
langCombo.setPrefWidth(120);
langCombo.setOnAction(e -> loadLanguage(langCombo.getValue()));
Button addLangBtn = new Button("+ Sprache");
addLangBtn.setOnAction(e -> addLanguage());
Button delLangBtn = new Button(" Sprache");
delLangBtn.setOnAction(e -> deleteLanguage());
Button addKeyBtn = new Button("+ Schlüssel");
addKeyBtn.setOnAction(e -> addKey());
Button delKeyBtn = new Button(" Entfernen");
delKeyBtn.setOnAction(e -> deleteKey());
Button saveBtn = new Button("Speichern");
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrent());
HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn,
new Separator(javafx.geometry.Orientation.VERTICAL),
addKeyBtn, delKeyBtn,
new Separator(javafx.geometry.Orientation.VERTICAL),
saveBtn);
toolbar.setPadding(new Insets(8, 12, 8, 12));
toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
// ── Tabelle ───────────────────────────────────────────────────────────
table.setEditable(true);
table.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
TableColumn<String[], String> keyCol = new TableColumn<>("Schlüssel");
keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0]));
keyCol.setCellFactory(TextFieldTableCell.forTableColumn());
keyCol.setOnEditCommit(e -> {
e.getRowValue()[0] = e.getNewValue().trim();
table.refresh();
});
keyCol.setPrefWidth(280);
TableColumn<String[], String> valCol = new TableColumn<>("Text");
valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1]));
valCol.setCellFactory(TextFieldTableCell.forTableColumn());
valCol.setOnEditCommit(e -> {
e.getRowValue()[1] = e.getNewValue();
table.refresh();
});
table.getColumns().addAll(keyCol, valCol);
VBox.setVgrow(table, Priority.ALWAYS);
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");
hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;");
VBox center = new VBox(table, hint);
VBox.setVgrow(table, Priority.ALWAYS);
setTop(toolbar);
setCenter(center);
}
// ── Sprach-Verwaltung ─────────────────────────────────────────────────────
private void refreshLangList() {
String selected = langCombo.getValue();
langCombo.getItems().setAll(TextBundleIO.availableLanguages(locDir));
if (selected != null && langCombo.getItems().contains(selected))
langCombo.setValue(selected);
else if (!langCombo.getItems().isEmpty())
langCombo.setValue(langCombo.getItems().get(0));
else
tableData.clear();
}
private void loadLanguage(String lang) {
if (lang == null) return;
try {
currentBundle = TextBundleIO.load(locDir.resolve(lang + ".json"));
} catch (IOException e) {
currentBundle = new TextBundle(lang);
}
tableData.clear();
currentBundle.getEntries().forEach((k, v) -> tableData.add(new String[]{k, v}));
TextRegistry.clear();
TextRegistry.registerAll(currentBundle.getEntries());
}
private void addLanguage() {
TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Sprache hinzufügen");
dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):");
dlg.showAndWait().ifPresent(lang -> {
lang = lang.trim().toLowerCase();
if (lang.isBlank()) return;
TextBundle bundle = new TextBundle(lang);
try {
TextBundleIO.save(bundle, locDir);
refreshLangList();
langCombo.setValue(lang);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
}
});
}
private void deleteLanguage() {
String lang = langCombo.getValue();
if (lang == null) return;
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION,
"Sprache '" + lang + "' wirklich löschen?", ButtonType.YES, ButtonType.NO);
confirm.showAndWait().ifPresent(bt -> {
if (bt == ButtonType.YES) {
try {
TextBundleIO.delete(lang, locDir);
currentBundle = null;
tableData.clear();
refreshLangList();
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
}
}
});
}
// ── Eintrags-Verwaltung ───────────────────────────────────────────────────
private void addKey() {
tableData.add(new String[]{"neuer.schluessel", ""});
table.scrollTo(tableData.size() - 1);
table.getSelectionModel().select(tableData.size() - 1);
}
private void deleteKey() {
String[] sel = table.getSelectionModel().getSelectedItem();
if (sel != null) tableData.remove(sel);
}
private void saveCurrent() {
if (currentBundle == null) {
new Alert(Alert.AlertType.WARNING, "Keine Sprache ausgewählt.", ButtonType.OK).showAndWait();
return;
}
Map<String, String> entries = new LinkedHashMap<>();
for (String[] row : tableData) {
if (!row[0].isBlank()) entries.put(row[0].trim(), row[1]);
}
currentBundle.setEntries(entries);
try {
TextBundleIO.save(currentBundle, locDir);
TextRegistry.clear();
TextRegistry.registerAll(entries);
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
}
}
}

View File

@@ -0,0 +1,251 @@
package de.blight.editor.ui;
import de.blight.common.LocationIO;
import de.blight.common.model.Location;
import de.blight.common.model.TextReference;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.io.IOException;
import java.util.List;
/**
* Locations-Verwaltung: zwei identische LocationPanel-Instanzen nebeneinander.
* Alle Locations werden gemeinsam in einer Datei gespeichert (LocationIO).
*/
public class LocationEditorView extends BorderPane {
private final ObservableList<Location> sharedLocations = FXCollections.observableArrayList();
public LocationEditorView() {
setStyle("-fx-background-color: #1e1e2e;");
reload();
LocationPanel left = new LocationPanel("Liste 1", sharedLocations, this::saveAll);
LocationPanel right = new LocationPanel("Liste 2", sharedLocations, this::saveAll);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
private void reload() {
try { sharedLocations.setAll(LocationIO.load()); }
catch (IOException e) { sharedLocations.clear(); }
}
private void saveAll() {
try { LocationIO.save(sharedLocations); }
catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
}
reload();
}
// ── Single panel ──────────────────────────────────────────────────────────
static class LocationPanel extends VBox {
private final ObservableList<Location> locations;
private final Runnable onSaved;
private final ListView<Location> listView;
private Location current = null;
// Form fields
private TextField nameIdField;
private TextField centerXField;
private TextField centerZField;
private TextField radiusField;
private TriggerListEditor triggerEditor;
private VBox formContainer;
private Button deleteBtn;
LocationPanel(String title, ObservableList<Location> locations, Runnable onSaved) {
this.locations = locations;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
listView = new ListView<>(locations);
listView.setPrefHeight(180);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Location loc, boolean empty) {
super.updateItem(loc, empty);
if (empty || loc == null) { setText(null); setStyle(""); return; }
setText(loc.getId().isBlank() ? "" : loc.getId());
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent #66aacc;"
+ " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;");
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onSelected(old, nw));
Button newBtn = new Button("Neue Location");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createLocation());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox listSection = new VBox(listView, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
nameIdField = field("z. B. location.village");
centerXField = field("X-Koordinate");
centerZField = field("Z-Koordinate");
radiusField = field("Radius in Meter");
// TriggerEditor — placeholder; rebuilt when item selected
triggerEditor = new TriggerListEditor(List.of(), () -> {});
Button saveBtn = new Button("Location speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrent());
form.getChildren().addAll(
sectionTitle("Kennung & Position"),
new Separator(),
row("Name-ID:", nameIdField),
row("Mitte X:", centerXField),
row("Mitte Z:", centerZField),
row("Radius:", radiusField),
sectionTitle("Trigger"),
new Separator(),
triggerEditor,
new Separator(),
saveBtn
);
return form;
}
private void onSelected(Location old, Location nw) {
if (old != null) saveFormToLocation(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) { formContainer.setDisable(false); loadForm(nw); }
else { formContainer.setDisable(true); clearForm(); }
}
private void loadForm(Location loc) {
nameIdField.setText(loc.getId());
centerXField.setText(String.valueOf(loc.getCenterX()));
centerZField.setText(String.valueOf(loc.getCenterZ()));
radiusField.setText(String.valueOf(loc.getRadius()));
// Rebuild trigger editor
int idx = formContainer.getChildren().indexOf(triggerEditor);
triggerEditor = new TriggerListEditor(
loc.getTriggers() != null ? loc.getTriggers() : List.of(), () -> {});
if (idx >= 0) formContainer.getChildren().set(idx, triggerEditor);
}
private void saveFormToLocation(Location loc) {
String nameId = nameIdField.getText().trim();
loc.setName(nameId.isBlank() ? null : new TextReference(nameId));
loc.setCenterX(parseFloat(centerXField.getText()));
loc.setCenterZ(parseFloat(centerZField.getText()));
loc.setRadius(parseFloat(radiusField.getText()));
loc.setTriggers(triggerEditor.getTriggers());
}
private void clearForm() {
nameIdField.clear(); centerXField.clear(); centerZField.clear(); radiusField.clear();
}
private void createLocation() {
Location loc = new Location();
loc.setName(new TextReference("location.neu_" + System.currentTimeMillis()));
locations.add(loc);
listView.getSelectionModel().select(loc);
}
private void deleteSelected() {
if (current == null) return;
locations.remove(current);
current = null; clearForm(); formContainer.setDisable(true); deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrent() {
if (current == null) return;
saveFormToLocation(current);
if (current.getId().isBlank()) {
new Alert(Alert.AlertType.ERROR, "Name-ID darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
onSaved.run();
listView.refresh();
}
private static float parseFloat(String s) {
try { return Float.parseFloat(s.trim().replace(',', '.')); }
catch (NumberFormatException ignored) { return 0f; }
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(80);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static TextField field(String prompt) {
TextField tf = new TextField(); tf.setPromptText(prompt); return tf;
}
}
}

View File

@@ -0,0 +1,487 @@
package de.blight.editor.ui;
import de.blight.common.model.Interactable;
import de.blight.common.model.InteractableRef;
import de.blight.common.model.Item;
import de.blight.common.model.NPC;
import de.blight.common.model.Location;
import de.blight.common.model.TextReference;
import de.blight.common.model.quests.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
/**
* Quest-Verwaltung: zwei identische QuestPanel-Instanzen nebeneinander.
* Beide Panels teilen die gleiche ObservableList und speichern in dasselbe Verzeichnis.
*/
public class QuestEditorView extends BorderPane {
private final ObservableList<Quest> sharedQuests = FXCollections.observableArrayList();
private final Path questDir;
public QuestEditorView(Path questDir) {
this.questDir = questDir;
setStyle("-fx-background-color: #1e1e2e;");
reload();
QuestPanel left = new QuestPanel("Liste 1", sharedQuests, questDir, this::reload);
QuestPanel right = new QuestPanel("Liste 2", sharedQuests, questDir, this::reload);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
public void reload() {
List<Quest> loaded = QuestIO.loadAll(questDir);
sharedQuests.setAll(loaded);
}
// ── Single panel ──────────────────────────────────────────────────────────
static class QuestPanel extends VBox {
private final ObservableList<Quest> quests;
private final Path questDir;
private final Runnable onSaved;
private final ListView<Quest> listView;
private Quest current = null;
// Common fields
private TextField idField;
private Spinner<Integer> xpSpinner;
private TextField textField;
private TextField descField;
private TextField successField;
// Type selection
private ComboBox<String> typeCombo;
// Dynamic area
private VBox dynamicArea;
// Type-specific fields (lazily filled)
private TextField f1, f2, f3;
private Spinner<Integer> countSpinner;
// Form container (disabled when nothing loaded)
private VBox formContainer;
private Button saveBtn;
private Button deleteBtn;
QuestPanel(String title, ObservableList<Quest> quests, Path questDir, Runnable onSaved) {
this.quests = quests;
this.questDir = questDir;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
setSpacing(0);
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
// ── Quest list ────────────────────────────────────────────────────
listView = new ListView<>(quests);
listView.setPrefHeight(160);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Quest q, boolean empty) {
super.updateItem(q, empty);
if (empty || q == null) { setText(null); setStyle(""); return; }
String id = q.getQuestId() != null ? q.getQuestId() : "";
String type = QuestIO.typeOf(q);
setText("[" + type + "] " + id);
setStyle("-fx-text-fill: #cccccc;");
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onQuestSelected(old, nw));
Button newBtn = new Button("Neue Quest");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createQuest());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox listSection = new VBox(listView, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
// Common fields
idField = new TextField();
idField.setPromptText("eindeutige ID");
xpSpinner = new Spinner<>(0, 99999, 0);
xpSpinner.setEditable(true);
xpSpinner.setMaxWidth(Double.MAX_VALUE);
textField = new TextField();
textField.setPromptText("TextReference-Schlüssel");
descField = new TextField();
descField.setPromptText("TextReference-Schlüssel");
successField = new TextField();
successField.setPromptText("TextReference-Schlüssel");
form.getChildren().addAll(
sectionTitle("Quest"),
new Separator(),
row("Quest-ID:", idField),
row("XP:", xpSpinner),
new Separator(),
sectionTitle("Texte"),
row("Text:", textField),
row("Beschreibung:", descField),
row("Erfolgstext:", successField),
new Separator()
);
// Type selection
typeCombo = new ComboBox<>();
typeCombo.getItems().addAll("BringQuest", "FollowQuest", "InteractQuest", "ItemQuest", "TalkQuest");
typeCombo.setPromptText("Typ auswählen…");
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> rebuildDynamicArea(typeCombo.getValue()));
dynamicArea = new VBox(6);
form.getChildren().addAll(
sectionTitle("Typ"),
typeCombo,
dynamicArea,
new Separator()
);
// Save button
saveBtn = new Button("Quest speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentQuest());
form.getChildren().add(saveBtn);
return form;
}
private void rebuildDynamicArea(String type) {
dynamicArea.getChildren().clear();
f1 = null; f2 = null; f3 = null; countSpinner = null;
if (type == null) return;
switch (type) {
case "BringQuest" -> {
f1 = tf("NPC-ID (bringen)");
f2 = tf("Location-ID (Ziel)");
dynamicArea.getChildren().addAll(
sectionTitle("BringQuest"),
row("NPC:", f1),
row("Ziel-Location:", f2)
);
}
case "FollowQuest" -> {
f1 = tf("NPC-ID (folgen)");
f2 = tf("Location-ID (Ziel)");
dynamicArea.getChildren().addAll(
sectionTitle("FollowQuest"),
row("NPC:", f1),
row("Ziel-Location:", f2)
);
}
case "InteractQuest" -> {
f1 = tf("Interactable-ID");
dynamicArea.getChildren().addAll(
sectionTitle("InteractQuest"),
row("Interactable:", f1)
);
}
case "ItemQuest" -> {
f1 = tf("Item-ID");
countSpinner = new Spinner<>(1, 9999, 1);
countSpinner.setEditable(true);
countSpinner.setMaxWidth(Double.MAX_VALUE);
dynamicArea.getChildren().addAll(
sectionTitle("ItemQuest"),
row("Item:", f1),
row("Anzahl:", countSpinner)
);
}
case "TalkQuest" -> {
f1 = tf("NPC-ID");
dynamicArea.getChildren().addAll(
sectionTitle("TalkQuest"),
row("NPC:", f1)
);
}
}
}
// ── Form load / save ──────────────────────────────────────────────────
private void onQuestSelected(Quest old, Quest nw) {
if (old != null) saveFormToQuest(old);
current = nw;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromQuest(nw);
} else {
formContainer.setDisable(true);
clearForm();
}
}
private void loadFormFromQuest(Quest q) {
idField.setText(safe(q.getQuestId()));
xpSpinner.getValueFactory().setValue(q.getXp());
textField.setText(q.getText() != null ? q.getText().id() : "");
descField.setText(q.getDescription() != null ? q.getDescription().id() : "");
successField.setText(q.getSuccessText() != null ? q.getSuccessText().id() : "");
String type = QuestIO.typeOf(q);
typeCombo.setValue(switch (type) {
case "BRING" -> "BringQuest";
case "FOLLOW" -> "FollowQuest";
case "INTERACT" -> "InteractQuest";
case "ITEM" -> "ItemQuest";
case "TALK" -> "TalkQuest";
default -> null;
});
rebuildDynamicArea(typeCombo.getValue());
// Fill type-specific fields
switch (q) {
case BringQuest bq -> {
if (f1 != null) f1.setText(bq.getBring() != null ? safe(bq.getBring().getCharacterId()) : "");
if (f2 != null) f2.setText(bq.getBringTo() != null ? safe(bq.getBringTo().getId()) : "");
}
case FollowQuest fq -> {
if (f1 != null) f1.setText(fq.getFollow() != null ? safe(fq.getFollow().getCharacterId()) : "");
if (f2 != null) f2.setText(fq.getFollowTo() != null ? safe(fq.getFollowTo().getId()) : "");
}
case InteractQuest iq -> {
if (f1 != null && iq.getInteractWith() instanceof InteractableRef ir)
f1.setText(safe(ir.getId()));
}
case ItemQuest iq -> {
if (f1 != null) f1.setText(iq.getItem() != null ? safe(iq.getItem().getItemId()) : "");
if (countSpinner != null) countSpinner.getValueFactory().setValue(iq.getCount());
}
case TalkQuest tq -> {
if (f1 != null) f1.setText(tq.getTalkTo() != null ? safe(tq.getTalkTo().getCharacterId()) : "");
}
default -> {}
}
}
private void saveFormToQuest(Quest q) {
q.setQuestId(idField.getText().trim());
q.setXp(xpSpinner.getValue());
q.setText(ref(textField));
q.setDescription(ref(descField));
q.setSuccessText(ref(successField));
// Type-specific fields written when actually saving (buildQuestFromForm)
}
private Quest buildQuestFromForm() {
String type = typeCombo.getValue();
if (type == null) return null;
Quest q = switch (type) {
case "BringQuest" -> {
BringQuest bq = new BringQuest();
if (f1 != null && !f1.getText().isBlank()) {
NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); bq.setBring(n);
}
if (f2 != null && !f2.getText().isBlank()) {
Location l = new Location(); l.setName(new de.blight.common.model.TextReference(f2.getText().trim())); bq.setBringTo(l);
}
yield bq;
}
case "FollowQuest" -> {
FollowQuest fq = new FollowQuest();
if (f1 != null && !f1.getText().isBlank()) {
NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); fq.setFollow(n);
}
if (f2 != null && !f2.getText().isBlank()) {
Location l = new Location(); l.setName(new de.blight.common.model.TextReference(f2.getText().trim())); fq.setFollowTo(l);
}
yield fq;
}
case "InteractQuest" -> {
InteractQuest iq = new InteractQuest();
if (f1 != null && !f1.getText().isBlank()) {
InteractableRef ir = new InteractableRef(); ir.setId(f1.getText().trim());
iq.setInteractWith(ir);
}
yield iq;
}
case "ItemQuest" -> {
ItemQuest iq = new ItemQuest();
if (f1 != null && !f1.getText().isBlank()) {
Item item = new Item(); item.setItemId(f1.getText().trim()); iq.setItem(item);
}
iq.setCount(countSpinner != null ? countSpinner.getValue() : 1);
yield iq;
}
case "TalkQuest" -> {
TalkQuest tq = new TalkQuest();
if (f1 != null && !f1.getText().isBlank()) {
NPC n = new NPC(); n.setCharacterId(f1.getText().trim()); tq.setTalkTo(n);
}
yield tq;
}
default -> new TalkQuest();
};
q.setQuestId(idField.getText().trim());
q.setXp(xpSpinner.getValue());
q.setText(ref(textField));
q.setDescription(ref(descField));
q.setSuccessText(ref(successField));
return q;
}
private void clearForm() {
idField.clear();
xpSpinner.getValueFactory().setValue(0);
textField.clear();
descField.clear();
successField.clear();
typeCombo.setValue(null);
dynamicArea.getChildren().clear();
}
// ── List operations ───────────────────────────────────────────────────
private void createQuest() {
TalkQuest q = new TalkQuest();
q.setQuestId("neue_quest_" + System.currentTimeMillis());
quests.add(q);
listView.getSelectionModel().select(q);
}
private void deleteSelected() {
Quest sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String qId = sel.getQuestId();
quests.remove(sel);
if (qId != null && !qId.isBlank()) {
try { QuestIO.delete(qId, questDir); }
catch (IOException e) { /* ignore */ }
}
current = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrentQuest() {
Quest built = buildQuestFromForm();
if (built == null) {
showError("Bitte einen Typ wählen.");
return;
}
if (built.getQuestId() == null || built.getQuestId().isBlank()) {
showError("Quest-ID darf nicht leer sein.");
return;
}
// Replace or add in shared list
int idx = quests.indexOf(current);
if (idx >= 0) quests.set(idx, built);
else quests.add(built);
current = built;
listView.getSelectionModel().select(built);
try {
QuestIO.save(built, questDir);
} catch (IOException e) {
showError("Fehler beim Speichern: " + e.getMessage());
return;
}
onSaved.run();
}
// ── Helpers ───────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(130);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static TextField tf(String prompt) {
TextField f = new TextField();
f.setPromptText(prompt);
return f;
}
private static String safe(String s) { return s != null ? s : ""; }
private static TextReference ref(TextField f) {
String s = f.getText().trim();
return s.isBlank() ? null : new TextReference(s);
}
private void showError(String msg) {
Alert a = new Alert(Alert.AlertType.ERROR, msg, ButtonType.OK);
a.showAndWait();
}
}
}

View File

@@ -0,0 +1,506 @@
package de.blight.editor.ui;
import de.blight.common.model.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
/**
* Rezept-Verwaltung: zwei identische RecipePanel-Instanzen nebeneinander.
* Sortiert nach CraftingTableType, dann nach erstelltem Item-ID.
*/
public class RecipeEditorView extends BorderPane {
private final ObservableList<Recipe> sharedRecipes = FXCollections.observableArrayList();
private final Path recipeDir;
public RecipeEditorView(Path recipeDir) {
this.recipeDir = recipeDir;
setStyle("-fx-background-color: #1e1e2e;");
reload();
RecipePanel left = new RecipePanel("Liste 1", sharedRecipes, recipeDir, this::reload);
RecipePanel right = new RecipePanel("Liste 2", sharedRecipes, recipeDir, this::reload);
HBox panels = new HBox(1, left, right);
HBox.setHgrow(left, Priority.ALWAYS);
HBox.setHgrow(right, Priority.ALWAYS);
setCenter(panels);
}
public void reload() {
sharedRecipes.setAll(RecipeIO.loadAll(recipeDir));
}
// ── Single panel ──────────────────────────────────────────────────────────
static class RecipePanel extends VBox {
private static final String NO_TABLE = "— kein Tisch —";
private final ObservableList<Recipe> recipes;
private final Path recipeDir;
private final Runnable onSaved;
private final SortedList<Recipe> sortedRecipes;
private final ListView<Recipe> listView;
private Recipe current = null;
private String oldFileId = null; // for rename-on-save detection
// Form fields
private TextField createsField;
private ListView<String> componentsList; // "itemId × count"
private ComboBox<String> tableCombo;
// Level-Anforderungs-Zeilen (jeweils Label + Spinner)
private HBox alchemyRow;
private HBox enchantingRow;
private HBox smitheryRow;
private HBox engineeringRow;
private Spinner<Integer> alchemySpinner;
private Spinner<Integer> enchantingSpinner;
private Spinner<Integer> smitherySpinner;
private Spinner<Integer> engineeringSpinner;
private VBox formContainer;
private Button deleteBtn;
RecipePanel(String title, ObservableList<Recipe> recipes, Path recipeDir, Runnable onSaved) {
this.recipes = recipes;
this.recipeDir = recipeDir;
this.onSaved = onSaved;
setStyle("-fx-background-color: #252535; -fx-border-color: #3a3a4a; -fx-border-width: 0 1 0 0;");
// ── Header ────────────────────────────────────────────────────────
Label titleLbl = new Label(title);
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #aaccee;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #888; -fx-cursor: hand;");
refreshBtn.setOnAction(e -> onSaved.run());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setPadding(new Insets(8, 10, 8, 10));
header.setAlignment(Pos.CENTER_LEFT);
header.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444;"
+ " -fx-border-width: 0 0 1 0;");
// ── Recipe list ───────────────────────────────────────────────────
sortedRecipes = new SortedList<>(recipes, RecipeIO.SORT_ORDER);
listView = new ListView<>(sortedRecipes);
listView.setPrefHeight(180);
listView.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Recipe r, boolean empty) {
super.updateItem(r, empty);
if (empty || r == null) { setText(null); setStyle(""); return; }
String creates = r.getCreates() != null ? safe(r.getCreates().getItemId()) : "";
String table = r.getTable() != null && r.getTable().getType() != null
? r.getTable().getType().name() : "Handwerk";
setText(creates);
setTooltip(new Tooltip("[" + table + "] erstellt: " + creates));
String color = tableColor(r.getTable());
setStyle("-fx-text-fill: #dddddd;"
+ " -fx-border-color: transparent transparent transparent " + color + ";"
+ " -fx-border-width: 0 0 0 3; -fx-padding: 3 6 3 8;");
}
});
listView.getSelectionModel().selectedItemProperty()
.addListener((obs, old, nw) -> onRecipeSelected(old, nw));
Button newBtn = new Button("Neues Rezept");
newBtn.setMaxWidth(Double.MAX_VALUE);
newBtn.setStyle("-fx-background-color: #3a6a3a; -fx-text-fill: white;");
newBtn.setOnAction(e -> createRecipe());
deleteBtn = new Button("Löschen");
deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteBtn.setStyle("-fx-background-color: #6a2a2a; -fx-text-fill: white;");
deleteBtn.setDisable(true);
deleteBtn.setOnAction(e -> deleteSelected());
HBox listButtons = new HBox(6, newBtn, deleteBtn);
listButtons.setPadding(new Insets(6, 8, 6, 8));
HBox.setHgrow(newBtn, Priority.ALWAYS);
HBox.setHgrow(deleteBtn, Priority.ALWAYS);
VBox listSection = new VBox(listView, listButtons);
listSection.setStyle("-fx-background-color: #1a1a2a;");
// ── Form ──────────────────────────────────────────────────────────
formContainer = buildForm();
formContainer.setDisable(true);
ScrollPane formScroll = new ScrollPane(formContainer);
formScroll.setFitToWidth(true);
formScroll.setStyle("-fx-background-color: #252535; -fx-background: #252535;");
VBox.setVgrow(formScroll, Priority.ALWAYS);
getChildren().addAll(header, listSection, new Separator(), formScroll);
}
// ── Form construction ─────────────────────────────────────────────────
private VBox buildForm() {
VBox form = new VBox(6);
form.setPadding(new Insets(10));
form.setStyle("-fx-background-color: #252535;");
// Erstellt
createsField = new TextField();
createsField.setPromptText("Item-ID des erstellten Items");
// Zutaten
componentsList = new ListView<>();
componentsList.setPrefHeight(110);
componentsList.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
componentsList.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(String s, boolean empty) {
super.updateItem(s, empty);
setText(empty || s == null ? null : s);
setStyle(empty ? "" : "-fx-text-fill: #cccccc;");
}
});
Button addCompBtn = smallBtn("+");
Button delCompBtn = smallBtn("");
addCompBtn.setOnAction(e -> addComponent());
delCompBtn.setOnAction(e -> {
String sel = componentsList.getSelectionModel().getSelectedItem();
if (sel != null) componentsList.getItems().remove(sel);
});
HBox compButtons = new HBox(4, addCompBtn, delCompBtn);
form.getChildren().addAll(
sectionTitle("Ergebnis"),
new Separator(),
row("Erstellt:", createsField),
sectionTitle("Zutaten"),
componentsList,
compButtons,
new Separator()
);
// Crafting Table
tableCombo = new ComboBox<>();
tableCombo.getItems().add(NO_TABLE);
for (CraftingTable.CraftingTableType t : CraftingTable.CraftingTableType.values())
tableCombo.getItems().add(t.name());
tableCombo.setValue(NO_TABLE);
tableCombo.setMaxWidth(Double.MAX_VALUE);
tableCombo.setOnAction(e -> updateRequirementRows(tableCombo.getValue()));
// Level-Anforderungen (werden je nach Tischtyp aktiviert)
alchemySpinner = lvlSpinner();
enchantingSpinner = lvlSpinner();
smitherySpinner = lvlSpinner();
engineeringSpinner = lvlSpinner();
alchemyRow = requirementRow("Lvl Alchemie:", alchemySpinner);
enchantingRow = requirementRow("Lvl Verzauberung:", enchantingSpinner);
smitheryRow = requirementRow("Lvl Schmieden:", smitherySpinner);
engineeringRow = requirementRow("Lvl Engineering:", engineeringSpinner);
form.getChildren().addAll(
sectionTitle("Crafting Table"),
tableCombo,
alchemyRow,
enchantingRow,
smitheryRow,
engineeringRow
);
// Anfangs alle Anforderungen deaktiviert
updateRequirementRows(NO_TABLE);
form.getChildren().add(new Separator());
Button saveBtn = new Button("Rezept speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrentRecipe());
form.getChildren().add(saveBtn);
return form;
}
private void updateRequirementRows(String tableValue) {
boolean noTable = tableValue == null || tableValue.equals(NO_TABLE);
// Alle ausblenden wenn kein Tisch
setRowVisible(alchemyRow, false);
setRowVisible(enchantingRow, false);
setRowVisible(smitheryRow, false);
setRowVisible(engineeringRow, false);
if (noTable) return;
switch (tableValue) {
case "AlchemyTable" -> setRowVisible(alchemyRow, true);
case "EnchantmentTable" -> setRowVisible(enchantingRow, true);
case "Smithy",
"Goldsmiths" -> setRowVisible(smitheryRow, true);
case "Workshop" -> setRowVisible(engineeringRow, true);
}
}
private static void setRowVisible(HBox row, boolean visible) {
row.setVisible(visible);
row.setManaged(visible);
}
// ── Form load / save ──────────────────────────────────────────────────
private void onRecipeSelected(Recipe old, Recipe nw) {
if (old != null) saveFormToRecipe(old);
current = nw;
oldFileId = nw != null ? RecipeIO.fileId(nw) : null;
deleteBtn.setDisable(nw == null);
if (nw != null) {
formContainer.setDisable(false);
loadFormFromRecipe(nw);
} else {
formContainer.setDisable(true);
clearForm();
}
}
private void loadFormFromRecipe(Recipe r) {
createsField.setText(r.getCreates() != null ? safe(r.getCreates().getItemId()) : "");
componentsList.getItems().clear();
if (r.getComponents() != null) {
r.getComponents().forEach((item, count) ->
componentsList.getItems().add(item.getItemId() + " × " + count));
}
String tableVal = NO_TABLE;
if (r.getTable() != null && r.getTable().getType() != null)
tableVal = r.getTable().getType().name();
tableCombo.setValue(tableVal);
updateRequirementRows(tableVal);
alchemySpinner.getValueFactory().setValue(
r.getRequiresLvlAlchemy() != null ? r.getRequiresLvlAlchemy() : 1);
enchantingSpinner.getValueFactory().setValue(
r.getRequiresLvlEnchanting() != null ? r.getRequiresLvlEnchanting() : 1);
smitherySpinner.getValueFactory().setValue(
r.getRequiresLvlSmithery() != null ? r.getRequiresLvlSmithery() : 1);
engineeringSpinner.getValueFactory().setValue(
r.getRequiresLvlEngineering() != null ? r.getRequiresLvlEngineering() : 1);
}
private void saveFormToRecipe(Recipe r) {
// creates
String cId = createsField.getText().trim();
if (!cId.isBlank()) {
Item creates = r.getCreates() != null ? r.getCreates() : new Item();
creates.setItemId(cId);
r.setCreates(creates);
} else {
r.setCreates(null);
}
// components
Map<Item, Integer> comps = new LinkedHashMap<>();
for (String entry : componentsList.getItems()) {
int sep = entry.lastIndexOf(" × ");
if (sep < 0) continue;
String itemId = entry.substring(0, sep).trim();
int count = 1;
try { count = Integer.parseInt(entry.substring(sep + 3).trim()); }
catch (NumberFormatException ignored) {}
Item item = new Item(); item.setItemId(itemId);
comps.put(item, count);
}
r.setComponents(comps.isEmpty() ? null : comps);
// table
String tv = tableCombo.getValue();
if (tv == null || tv.equals(NO_TABLE)) {
r.setTable(null);
r.setRequiresLvlAlchemy(null);
r.setRequiresLvlEngineering(null);
r.setRequiresLvlSmithery(null);
r.setRequiresLvlEnchanting(null);
} else {
CraftingTable table = r.getTable() != null ? r.getTable() : new CraftingTable();
table.setType(CraftingTable.CraftingTableType.valueOf(tv));
r.setTable(table);
// Nur das relevante Level-Feld setzen, alle anderen null
r.setRequiresLvlAlchemy(null);
r.setRequiresLvlEngineering(null);
r.setRequiresLvlSmithery(null);
r.setRequiresLvlEnchanting(null);
switch (tv) {
case "AlchemyTable" -> r.setRequiresLvlAlchemy(alchemySpinner.getValue());
case "EnchantmentTable" -> r.setRequiresLvlEnchanting(enchantingSpinner.getValue());
case "Smithy",
"Goldsmiths" -> r.setRequiresLvlSmithery(smitherySpinner.getValue());
case "Workshop" -> r.setRequiresLvlEngineering(engineeringSpinner.getValue());
}
}
}
private void clearForm() {
createsField.clear();
componentsList.getItems().clear();
tableCombo.setValue(NO_TABLE);
updateRequirementRows(NO_TABLE);
alchemySpinner.getValueFactory().setValue(1);
enchantingSpinner.getValueFactory().setValue(1);
smitherySpinner.getValueFactory().setValue(1);
engineeringSpinner.getValueFactory().setValue(1);
}
// ── List operations ───────────────────────────────────────────────────
private void createRecipe() {
Recipe r = new Recipe();
Item creates = new Item();
creates.setItemId("neues_rezept_" + System.currentTimeMillis());
r.setCreates(creates);
recipes.add(r);
listView.getSelectionModel().select(r);
}
private void deleteSelected() {
Recipe sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
String fid = RecipeIO.fileId(sel);
recipes.remove(sel);
try { RecipeIO.delete(fid, recipeDir); } catch (IOException ignored) {}
current = null;
oldFileId = null;
clearForm();
formContainer.setDisable(true);
deleteBtn.setDisable(true);
onSaved.run();
}
private void saveCurrentRecipe() {
if (current == null) return;
saveFormToRecipe(current);
String newFileId = RecipeIO.fileId(current);
if (newFileId.startsWith("unbenanntes")) {
new Alert(Alert.AlertType.ERROR,
"Item-ID des erstellten Items darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
try {
// Datei umbenennen: alten Eintrag löschen wenn ID sich geändert hat
if (oldFileId != null && !oldFileId.equals(newFileId))
RecipeIO.delete(oldFileId, recipeDir);
RecipeIO.save(current, recipeDir);
oldFileId = newFileId;
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
return;
}
onSaved.run();
// Re-select nach Reload
final String fid = newFileId;
recipes.stream()
.filter(r -> fid.equals(RecipeIO.fileId(r)))
.findFirst()
.ifPresent(listView.getSelectionModel()::select);
}
private void addComponent() {
Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Zutat hinzufügen");
dlg.initModality(Modality.APPLICATION_MODAL);
TextField itemIdField = new TextField();
itemIdField.setPromptText("Item-ID");
Spinner<Integer> countSpin = new Spinner<>(1, 9999, 1);
countSpin.setEditable(true);
GridPane grid = new GridPane();
grid.setHgap(10); grid.setVgap(8);
grid.setPadding(new Insets(12));
grid.add(new Label("Item-ID:"), 0, 0); grid.add(itemIdField, 1, 0);
grid.add(new Label("Anzahl:"), 0, 1); grid.add(countSpin, 1, 1);
GridPane.setHgrow(itemIdField, Priority.ALWAYS);
dlg.getDialogPane().setContent(grid);
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) dlg.getDialogPane().lookupButton(ButtonType.OK);
okBtn.setDisable(true);
itemIdField.textProperty().addListener((obs, o, n) -> okBtn.setDisable(n.isBlank()));
dlg.setResultConverter(bt -> bt == ButtonType.OK
? itemIdField.getText().trim() + " × " + countSpin.getValue() : null);
dlg.showAndWait().ifPresent(entry -> {
if (!componentsList.getItems().contains(entry))
componentsList.getItems().add(entry);
});
}
// ── Helpers ───────────────────────────────────────────────────────────
private static String tableColor(CraftingTable t) {
if (t == null || t.getType() == null) return "#666666";
return switch (t.getType()) {
case AlchemyTable -> "#44bb88";
case EnchantmentTable -> "#aa55ee";
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
};
}
private static HBox requirementRow(String labelText, Spinner<Integer> spinner) {
Label lbl = new Label(labelText);
lbl.setMinWidth(140);
lbl.setStyle("-fx-text-fill: #aaa;");
spinner.setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(spinner, Priority.ALWAYS);
HBox row = new HBox(8, lbl, spinner);
row.setAlignment(Pos.CENTER_LEFT);
row.setPadding(new Insets(2, 0, 2, 0));
return row;
}
private static Spinner<Integer> lvlSpinner() {
Spinner<Integer> s = new Spinner<>(1, 100, 1);
s.setEditable(true);
return s;
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(80);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static Button smallBtn(String text) {
Button b = new Button(text);
b.setPrefWidth(28);
return b;
}
private static String safe(String s) { return s != null ? s : ""; }
}
}

View File

@@ -0,0 +1,224 @@
package de.blight.editor.ui;
import de.blight.common.model.QuestRef;
import de.blight.common.model.Status;
import de.blight.common.model.trigger.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.util.UUID;
/**
* Modal-Dialog zum Anlegen oder Bearbeiten eines {@link Trigger}.
*
* <p>Ablauf:
* <ol>
* <li>Typ-ComboBox wählen</li>
* <li>Typ-spezifische Felder füllen</li>
* <li>Kapitel-Anforderung (optional)</li>
* </ol>
* Rückgabewert: der fertig gebaute {@link Trigger} oder {@code null} bei Abbruch.
*/
public class TriggerDialog extends Dialog<Trigger> {
private static final String TYPE_QUEST = "Quest starten";
private static final String TYPE_NPC = "NPC-Status ändern";
private static final String TYPE_FRACTION = "Fraktions-Status ändern";
// Gemeinsam
private final ComboBox<String> typeCombo = new ComboBox<>();
private final Spinner<Integer> chapterSpinner = new Spinner<>(0, 99, 0);
private final VBox dynamicArea = new VBox(6);
// Quest
private TextField questIdField;
// NPC-Status
private TextField npcIdField;
private ComboBox<Status> npcStatusCombo;
// Fraktions-Status
private TextField fractionIdField;
private ComboBox<Status> fractionStatusCombo;
/** Öffnet den Dialog für einen neuen Trigger. */
public TriggerDialog() {
this(null);
}
/** Öffnet den Dialog im Bearbeitungsmodus mit einem vorhandenen Trigger. */
public TriggerDialog(Trigger existing) {
setTitle(existing == null ? "Trigger hinzufügen" : "Trigger bearbeiten");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION);
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
chapterSpinner.setEditable(true);
chapterSpinner.setPrefWidth(80);
VBox content = new VBox(10);
content.setPadding(new Insets(16));
content.setPrefWidth(400);
content.getChildren().addAll(
row("Trigger-Typ:", typeCombo),
row("Kapitel (mind.):", chapterSpinner),
new Separator(),
dynamicArea
);
getDialogPane().setContent(content);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setDisable(true);
// Validierung: OK nur wenn Typ gewählt und Pflichtfelder gefüllt
typeCombo.valueProperty().addListener((obs, o, n) -> okBtn.setDisable(n == null));
// Ergebnis-Konverter
setResultConverter(bt -> bt == ButtonType.OK ? buildTrigger() : null);
// Vorhandenen Trigger laden
if (existing != null) preload(existing);
else typeCombo.setValue(TYPE_QUEST);
}
// ── Typ-spezifische Felder ────────────────────────────────────────────────
private void rebuildDynamic(String type) {
dynamicArea.getChildren().clear();
if (type == null) return;
switch (type) {
case TYPE_QUEST -> buildQuestFields();
case TYPE_NPC -> buildNpcFields();
case TYPE_FRACTION -> buildFractionFields();
}
}
private void buildQuestFields() {
questIdField = field("Quest-ID (z. B. q_main_001)");
dynamicArea.getChildren().addAll(
sectionTitle("Quest"),
row("Quest-ID:", questIdField)
);
}
private void buildNpcFields() {
npcIdField = field("Character-ID des NPCs");
npcStatusCombo = statusCombo();
dynamicArea.getChildren().addAll(
sectionTitle("NPC-Status"),
row("NPC-ID:", npcIdField),
row("Neuer Status:", npcStatusCombo)
);
}
private void buildFractionFields() {
fractionIdField = field("UUID der Fraktion");
fractionStatusCombo = statusCombo();
dynamicArea.getChildren().addAll(
sectionTitle("Fraktions-Status"),
row("Fraktions-UUID:", fractionIdField),
row("Neuer Status:", fractionStatusCombo)
);
}
// ── Trigger bauen ─────────────────────────────────────────────────────────
private Trigger buildTrigger() {
String type = typeCombo.getValue();
if (type == null) return null;
Trigger t = switch (type) {
case TYPE_QUEST -> {
QuestStartTrigger q = new QuestStartTrigger();
if (questIdField != null && !questIdField.getText().isBlank()) {
QuestRef ref = new QuestRef();
ref.setQuestId(questIdField.getText().trim());
q.setQuest(ref);
}
yield q;
}
case TYPE_NPC -> {
NpcStatusTrigger n = new NpcStatusTrigger();
if (npcIdField != null) n.setNpcId(npcIdField.getText().trim());
if (npcStatusCombo != null) n.setTargetStatus(npcStatusCombo.getValue());
yield n;
}
case TYPE_FRACTION -> {
FractionStatusTrigger f = new FractionStatusTrigger();
if (fractionIdField != null && !fractionIdField.getText().isBlank()) {
try { f.setFractionId(UUID.fromString(fractionIdField.getText().trim())); }
catch (IllegalArgumentException ignored) {}
}
if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue());
yield f;
}
default -> null;
};
if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
return t;
}
// ── Vorhandenen Trigger vorfüllen ─────────────────────────────────────────
private void preload(Trigger t) {
chapterSpinner.getValueFactory().setValue(t.getRequiresChapter());
if (t instanceof QuestStartTrigger q) {
typeCombo.setValue(TYPE_QUEST);
if (questIdField != null && q.getQuest() != null)
questIdField.setText(nullSafe(q.getQuest().getQuestId()));
} else if (t instanceof NpcStatusTrigger n) {
typeCombo.setValue(TYPE_NPC);
if (npcIdField != null) npcIdField.setText(nullSafe(n.getNpcId()));
if (npcStatusCombo != null && n.getTargetStatus() != null)
npcStatusCombo.setValue(n.getTargetStatus());
} else if (t instanceof FractionStatusTrigger f) {
typeCombo.setValue(TYPE_FRACTION);
if (fractionIdField != null && f.getFractionId() != null)
fractionIdField.setText(f.getFractionId().toString());
if (fractionStatusCombo != null && f.getTargetStatus() != null)
fractionStatusCombo.setValue(f.getTargetStatus());
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ComboBox<Status> statusCombo() {
ComboBox<Status> cb = new ComboBox<>();
cb.getItems().addAll(Status.values());
cb.setValue(Status.NEUTRAL);
cb.setMaxWidth(Double.MAX_VALUE);
return cb;
}
private static TextField field(String prompt) {
TextField tf = new TextField();
tf.setPromptText(prompt);
return tf;
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12;");
return l;
}
private static HBox row(String labelText, Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(120);
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static String nullSafe(String s) { return s != null ? s : ""; }
}

View File

@@ -0,0 +1,116 @@
package de.blight.editor.ui;
import de.blight.common.model.trigger.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import java.util.ArrayList;
import java.util.List;
/**
* Wiederverwendbare Komponente zur Verwaltung einer {@link Trigger}-Liste.
*
* <p>Enthält:
* <ul>
* <li>ListView mit Kurzanzeige je Trigger</li>
* <li>Schaltflächen: Hinzufügen, Bearbeiten, Entfernen</li>
* </ul>
* {@code onChange} wird nach jeder Listenänderung aufgerufen.
*/
public class TriggerListEditor extends VBox {
private final ObservableList<Trigger> triggers;
private final Runnable onChange;
private final ListView<Trigger> listView;
public TriggerListEditor(List<Trigger> initial, Runnable onChange) {
this.triggers = FXCollections.observableArrayList(
initial != null ? initial : List.of());
this.onChange = onChange;
setSpacing(4);
listView = new ListView<>(triggers);
listView.setPrefHeight(100);
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Trigger t, boolean empty) {
super.updateItem(t, empty);
setText(empty || t == null ? null : describe(t));
setStyle(empty ? "" : "-fx-font-size: 11;");
}
});
Button addBtn = new Button("+ Hinzufügen");
Button editBtn = new Button("✎ Bearbeiten");
Button delBtn = new Button(" Entfernen");
editBtn.setDisable(true);
delBtn.setDisable(true);
listView.getSelectionModel().selectedItemProperty().addListener((obs, o, nw) -> {
boolean sel = nw != null;
editBtn.setDisable(!sel);
delBtn.setDisable(!sel);
});
addBtn.setOnAction(e -> {
new TriggerDialog().showAndWait().ifPresent(t -> {
triggers.add(t);
onChange.run();
});
});
editBtn.setOnAction(e -> {
Trigger sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
new TriggerDialog(sel).showAndWait().ifPresent(updated -> {
int idx = triggers.indexOf(sel);
if (idx >= 0) triggers.set(idx, updated);
onChange.run();
});
});
delBtn.setOnAction(e -> {
Trigger sel = listView.getSelectionModel().getSelectedItem();
if (sel != null) { triggers.remove(sel); onChange.run(); }
});
HBox buttons = new HBox(4, addBtn, editBtn, delBtn);
buttons.setPadding(new Insets(2, 0, 0, 0));
getChildren().addAll(listView, buttons);
}
/** Gibt die aktuelle Trigger-Liste zurück (Kopie). */
public List<Trigger> getTriggers() {
return new ArrayList<>(triggers);
}
// ── Kurzanzeige ───────────────────────────────────────────────────────────
static String describe(Trigger t) {
if (t == null) return "";
String chapter = t.getRequiresChapter() > 0
? " [Kap. " + t.getRequiresChapter() + "]" : "";
if (t instanceof QuestStartTrigger q)
return "Quest starten: "
+ (q.getQuest() != null ? q.getQuest().getQuestId() : "?") + chapter;
if (t instanceof NpcStatusTrigger n)
return "NPC-Status: " + nullSafe(n.getNpcId())
+ "" + statusName(n.getTargetStatus()) + chapter;
if (t instanceof FractionStatusTrigger f)
return "Fraktion-Status: "
+ (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "" : "?")
+ "" + statusName(f.getTargetStatus()) + chapter;
return t.getClass().getSimpleName() + chapter;
}
private static String statusName(de.blight.common.model.Status s) {
return s != null ? s.name() : "?";
}
private static String nullSafe(String s) { return s != null && !s.isBlank() ? s : "?"; }
}