Commit vor großem Terrain refactoring
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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 fü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 0–359) 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;
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import java.util.List;
|
||||
*/
|
||||
public class TextureTool extends EditorTool {
|
||||
|
||||
// Terrain.j3md (unlit) hat nur Tex1–Tex3; 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
|
||||
|
||||
@@ -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 : ""; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 : ""; }
|
||||
}
|
||||
}
|
||||
@@ -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 : ""; }
|
||||
}
|
||||
@@ -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 : "?"; }
|
||||
}
|
||||
Reference in New Issue
Block a user