Weltkarte: Render-Modell-Trennung, UI-Umbau und Kamera-Indikator

- WorldMapRenderModel: neues Record als Zwischenschicht zwischen I/O und Rendering
- WorldMapRenderer: render() akzeptiert Modell direkt; rückwärtskompatible Wrapper-Signatur für MinimapState
- WorldMapView: Layer-Auswahl als MenuButton mit Checkboxen; Vollbild-Modus (centerStack-Swap); Kamera-Indikator mit FOV-Kegel (120ms-Timer, unabhängig vom Karten-Rendering); Linksklick teleportiert JME3-Kamera unter Beibehaltung der Terrain-Höhendifferenz
- EditorApp: CameraInfoSupplier, TeleportCallback und FullscreenCallbacks verdrahtet
- MinimapState, Logback: Debug-Logging für Modell-Build und Render-Zyklen
- Map-Daten: Zwischenstand Weltdaten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:24:35 +02:00
parent 68ec75c41a
commit 36c6a9b7d8
23 changed files with 806 additions and 189 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,27 @@
package de.blight.common.map;
import de.blight.common.*;
import de.blight.common.model.Location;
import java.util.List;
/**
* Fertig vorberechnetes Karten-Modell: enthält alle Rohdaten + die im Editor
* vorcompilierten Overlay-Informationen (SeaMask, Küstenpfade, Baum-Cluster).
* WorldMapRenderer.render() arbeitet nur noch gegen dieses Modell.
*/
public record WorldMapRenderModel(
// Terrain-Daten + Polygon-Objekte (werden vom Renderer für das PNG genutzt)
MapData mapData,
List<PlacedArea> areas,
List<PlacedLocationZone> zones,
List<Location> locations,
List<PlacedWater> waters,
List<PlacedModel> models,
int[] slotColorsRGB,
// Vorberechnete Overlay-Daten (werden vom Canvas genutzt, nicht vom PNG-Renderer)
boolean[] seaMask, // Wasser-Pixel-Maske bei SEA_MASK_SIZE-Auflösung
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters
) {}

View File

@@ -62,17 +62,25 @@ public final class WorldMapRenderer {
public static boolean[] buildSeaMask(MapData m, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
boolean[] mask = new boolean[size * size];
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
int hx = Math.min((int)((float) px / (size - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (size - 1) * (TV - 1)), TV - 1);
mask[py * size + px] = m.terrainHeight[hz * TV + hx] < 0f;
int ux = Math.min((int)((float) px / (size - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (size - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
mask[py * size + px] = h < 0f;
}
}
return mask;
}
private static final int WATER_COLOR = 0xFF_ADD8E6;
// Default-Slot-Farben für Slots 1-8 (Base-Layer 1-4 + Upper-Layer 5-8)
private static final int[] DEF_SLOT_R = { 71, 115, 140, 204, 90, 130, 110, 180 };
private static final int[] DEF_SLOT_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
@@ -80,15 +88,17 @@ public final class WorldMapRenderer {
private WorldMapRenderer() {}
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
MapData m = input.mapData();
/** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */
public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) {
MapData m = model.mapData();
int TV = MapData.TERRAIN_VERTS;
int SS = MapData.SPLAT_SIZE;
int[] slotR = slotChannel(input, 0);
int[] slotG = slotChannel(input, 1);
int[] slotB = slotChannel(input, 2);
int[] slotR = slotChannel(model.slotColorsRGB(), 0);
int[] slotG = slotChannel(model.slotColorsRGB(), 1);
int[] slotB = slotChannel(model.slotColorsRGB(), 2);
// ── 1. Heightmap auf Zielauflösung samplen ────────────────────────────
int UV = MapData.UPPER_VERTS;
float[] heights = new float[targetSize * targetSize];
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
@@ -96,7 +106,11 @@ public final class WorldMapRenderer {
for (int px = 0; px < targetSize; px++) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1);
int ux = Math.min((int)((float) px / (targetSize - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (targetSize - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) minH = h;
if (h > maxH) maxH = h;
@@ -161,6 +175,9 @@ public final class WorldMapRenderer {
}
}
// Kuwahara-Filter: lässt das Terrain wie gemalt wirken
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -171,10 +188,10 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -186,7 +203,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -219,7 +236,7 @@ public final class WorldMapRenderer {
}
}
// Wasserflächen: nur wenn groß genug
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -250,7 +267,7 @@ public final class WorldMapRenderer {
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Wasserflächen: Polygon-Umriss
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
gfx.drawPolygon(xs, ys, xs.length);
@@ -293,7 +310,7 @@ public final class WorldMapRenderer {
10f, new float[]{dash, gap}, 0f);
int aFontSize = Math.max(8, targetSize / 160);
gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize));
for (PlacedArea a : input.areas()) {
for (PlacedArea a : model.areas()) {
int[] xs = worldToPixels(a.pointsX(), targetSize);
int[] ys = worldToPixels(a.pointsZ(), targetSize);
gfx.setStroke(dashed);
@@ -314,7 +331,7 @@ public final class WorldMapRenderer {
// Location-Zonen
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
for (PlacedLocationZone z : input.zones()) {
for (PlacedLocationZone z : model.zones()) {
int[] xs = worldToPixels(z.pointsX(), targetSize);
int[] ys = worldToPixels(z.pointsZ(), targetSize);
gfx.setColor(new Color(240, 190, 40, 70));
@@ -324,13 +341,13 @@ public final class WorldMapRenderer {
}
}
// Modell-Punkte
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
int mx = worldToPixel(model.x(), targetSize);
int mz = worldToPixel(model.z(), targetSize);
for (PlacedModel pm : model.models()) {
if (isTree(pm)) { continue; }
int mx = worldToPixel(pm.x(), targetSize);
int mz = worldToPixel(pm.z(), targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
}
}
@@ -339,7 +356,7 @@ public final class WorldMapRenderer {
if (opts.showLocations()) {
int fontSize = Math.max(8, targetSize / 140);
gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize));
for (Location loc : input.locations()) {
for (Location loc : model.locations()) {
if (!loc.isShowOnMap()) continue;
if (loc.getId() == null || loc.getId().isEmpty()) continue;
float wx = Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX();
@@ -354,6 +371,15 @@ public final class WorldMapRenderer {
return img;
}
/** Rückwärts-kompatibel: baut ein minimales Modell (ohne Overlay-Daten) und delegiert. */
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), input.slotColorsRGB(),
null, null, null, null
), targetSize, opts);
}
/**
* Rendert einen rechteckigen Weltausschnitt als {@link BufferedImage}.
* Koordinatenursprung und Skalierung passen sich dem Ausschnitt an,
@@ -383,16 +409,21 @@ public final class WorldMapRenderer {
float minH = 0f, maxH = 1f;
if (opts.showTerrain() || opts.showWater()) {
int UV2 = MapData.UPPER_VERTS;
heights = new float[targetSize * targetSize];
minH = Float.MAX_VALUE;
maxH = -Float.MAX_VALUE;
for (int py = 0; py < targetSize; py++) {
float wz = wz0 + (float) py / (targetSize - 1) * rSize;
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int uz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
for (int px = 0; px < targetSize; px++) {
float wx = wx0 + (float) px / (targetSize - 1) * rSize;
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int ux = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV2 + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) { minH = h; }
if (h > maxH) { maxH = h; }
@@ -403,9 +434,9 @@ public final class WorldMapRenderer {
// ── Terrain (optional) ────────────────────────────────────────────────
if (opts.showTerrain()) {
float heightRange = Math.max(0.01f, maxH - minH);
int[] sR = slotChannel(input, 0);
int[] sG = slotChannel(input, 1);
int[] sB = slotChannel(input, 2);
int[] sR = slotChannel(input.slotColorsRGB(), 0);
int[] sG = slotChannel(input.slotColorsRGB(), 1);
int[] sB = slotChannel(input.slotColorsRGB(), 2);
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
@@ -465,6 +496,8 @@ public final class WorldMapRenderer {
}
}
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── Vektor-Overlays ───────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -474,7 +507,7 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
@@ -489,7 +522,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -629,6 +662,7 @@ public final class WorldMapRenderer {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
if (isTree(model)) { continue; }
int mx = wrp1(model.x(), wx0, rSize, targetSize);
int mz = wrp1(model.z(), wz0, rSize, targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
@@ -680,13 +714,12 @@ public final class WorldMapRenderer {
// Gibt den R-, G- oder B-Kanal (channel=0/1/2) aller 8 Splatmap-Slots zurück.
// slotColorsRGB: 24 Werte (8 Slots × 3), 12 Werte (4 Slots, Upper-Layer = Defaults) oder null.
private static int[] slotChannel(RenderInput input, int channel) {
int[] rgb = input.slotColorsRGB();
private static int[] slotChannel(int[] slotColorsRGB, int channel) {
int[] def = channel == 0 ? DEF_SLOT_R : (channel == 1 ? DEF_SLOT_G : DEF_SLOT_B);
if (rgb == null || rgb.length < 12) { return def; }
if (slotColorsRGB == null || slotColorsRGB.length < 12) { return def; }
int[] out = new int[8];
for (int s = 0; s < 8; s++) {
out[s] = (rgb.length >= (s + 1) * 3) ? rgb[s * 3 + channel] : def[s];
out[s] = (slotColorsRGB.length >= (s + 1) * 3) ? slotColorsRGB[s * 3 + channel] : def[s];
}
return out;
}
@@ -802,4 +835,53 @@ public final class WorldMapRenderer {
float v01 = (arr[i01] & 0xFF) / 255f, v11 = (arr[i11] & 0xFF) / 255f;
return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz;
}
public static boolean isTree(PlacedModel m) {
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
}
/**
* Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.
* Radius r=3 → Fenster 7×7, 4 Quadranten je 4×4.
*/
private static void applyKuwahara(BufferedImage img, int r) {
int w = img.getWidth(), h = img.getHeight();
int[] src = img.getRGB(0, 0, w, h, null, 0, w);
int[] dst = new int[src.length];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
float bestVar = Float.MAX_VALUE;
int bestPacked = src[y * w + x];
// 4 Quadranten: [xOff0..xOff1] × [yOff0..yOff1]
int[][] quads = {{-r,-r,0,0},{0,-r,r,0},{-r,0,0,r},{0,0,r,r}};
for (int[] q : quads) {
int x0 = Math.max(0, x+q[0]), y0 = Math.max(0, y+q[1]);
int x1 = Math.min(w-1, x+q[2]), y1 = Math.min(h-1, y+q[3]);
float sumR=0,sumG=0,sumB=0, sum2R=0,sum2G=0,sum2B=0;
int cnt = 0;
for (int qy = y0; qy <= y1; qy++) {
for (int qx = x0; qx <= x1; qx++) {
int p = src[qy * w + qx];
float pr = (p>>16)&0xFF, pg = (p>>8)&0xFF, pb = p&0xFF;
sumR+=pr; sumG+=pg; sumB+=pb;
sum2R+=pr*pr; sum2G+=pg*pg; sum2B+=pb*pb;
cnt++;
}
}
float inv = 1f / cnt;
float mR=sumR*inv, mG=sumG*inv, mB=sumB*inv;
float var = (sum2R*inv - mR*mR) + (sum2G*inv - mG*mG) + (sum2B*inv - mB*mB);
if (var < bestVar) {
bestVar = var;
bestPacked = (clamp((int)mR)<<16) | (clamp((int)mG)<<8) | clamp((int)mB);
}
}
dst[y * w + x] = bestPacked | 0xFF000000;
}
}
img.setRGB(0, 0, w, h, dst, 0, w);
}
}

View File

@@ -5639,11 +5639,36 @@ public class EditorApp extends Application {
});
worldMapView = new de.blight.editor.ui.WorldMapView(() -> primaryStage);
Tab weltkartTab = new Tab("Weltkarte", worldMapView);
worldMapView.setVoxelChunkSupplier(() -> {
de.blight.editor.state.VoxelEditorState ves = jmeApp == null ? null
: jmeApp.getStateManager().getState(de.blight.editor.state.VoxelEditorState.class);
return ves != null ? ves.getChunksSnapshot() : de.blight.common.VoxelChunkIO.loadAll();
});
worldMapView.setCameraInfoSupplier(() ->
new de.blight.editor.ui.WorldMapView.CameraInfo(
input.camX, input.camY, input.camZ, input.camYaw));
worldMapView.setTeleportCallback(pos -> {
input.pendingGotoX = pos[0];
input.pendingGotoY = pos[1];
input.pendingGotoZ = pos[2];
});
final Tab weltkartTab = new Tab("Weltkarte", worldMapView);
weltkartTab.setClosable(false);
weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender();
});
worldMapView.setFullscreenCallbacks(
() -> {
// Erst aus Tab lösen, dann in centerStack einsetzen
weltkartTab.setContent(new javafx.scene.control.Label(""));
setCenterView(worldMapView);
},
() -> {
// Erst aus centerStack lösen (worldViewport zurück), dann in Tab setzen
setCenterView(worldViewport);
weltkartTab.setContent(worldMapView);
});
TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab);
tabPane.setStyle("-fx-background-color: #e8e8e8;");

View File

@@ -3,11 +3,15 @@ package de.blight.editor.ui;
import de.blight.common.*;
import de.blight.common.model.Location;
import de.blight.common.map.WorldMapRenderer;
import de.blight.common.map.WorldMapRenderer.RenderInput;
import de.blight.common.map.WorldMapRenderer.RenderOptions;
import de.blight.common.map.WorldMapRenderModel;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.util.Duration;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
@@ -15,6 +19,7 @@ import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.*;
import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseButton;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.StrokeLineCap;
@@ -32,10 +37,21 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javafx.geometry.VPos;
import javafx.scene.text.TextAlignment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* Interaktive 2D-Weltkarte im Editor-Tab.
@@ -46,6 +62,8 @@ import java.util.function.Supplier;
*/
public class WorldMapView extends VBox {
private static final Logger log = LoggerFactory.getLogger(WorldMapView.class);
private static final int RENDER_SIZE = 2048;
// Konstante Bildschirmgrößen für das Canvas-Overlay
@@ -63,30 +81,57 @@ public class WorldMapView extends VBox {
private final ProgressBar progress = new ProgressBar(-1);
private final StackPane canvasPane = new StackPane(canvas);
private final ToggleButton layerTerrain = layerBtn("Gelände");
private final ToggleButton layerWater = layerBtn("Wasser");
private final ToggleButton layerAreas = layerBtn("Areas");
private final ToggleButton layerZones = layerBtn("Zonen");
private final ToggleButton layerLocations = layerBtn("Orte");
private final ToggleButton layerModels = layerBtn("Modelle");
// Layer-Checkboxen (im MenuButton gebündelt)
private final CheckBox cbTerrain = layerCheck("Gelände");
private final CheckBox cbWater = layerCheck("Wasser");
private final CheckBox cbAreas = layerCheck("Areas");
private final CheckBox cbZones = layerCheck("Zonen");
private final CheckBox cbLocations = layerCheck("Orte");
private final CheckBox cbModels = layerCheck("Modelle");
private final ToggleButton labelBtn = new ToggleButton("Label");
private final Button fullscreenBtn = new Button("⤢ Vollbild");
private final Button backBtn = new Button("< Zurück");
private boolean isFullscreen = false;
/** Kamera-Zustand (Position + Blickrichtung) für die Karten-Überlagerung. */
public record CameraInfo(float x, float y, float z, float yawDeg) {}
private WritableImage mapFxImage;
private BufferedImage mapBuffered;
private List<PlacedArea> cachedAreas = new ArrayList<>();
private List<Location> cachedLocs = new ArrayList<>();
private List<PlacedWater> cachedWaters = new ArrayList<>();
private boolean[] seaMask = null;
private float[][] seaCoastSegs = null; // [wx1,wz1,wx2,wz2] in Weltkoordinaten
private WorldMapRenderModel currentModel = null;
private boolean autoLoaded = false;
private double panX = 0, panY = 0;
private double scale = 1.0;
private double dragStartX, dragStartY, dragStartPanX, dragStartPanY;
private final Supplier<Stage> stageSupplier;
private Supplier<List<VoxelChunk>> voxelChunkSupplier = null;
private Supplier<CameraInfo> cameraInfoSupplier = null;
private Consumer<float[]> teleportCallback = null; // [worldX, worldY, worldZ]
private Runnable enterFullscreenCallback = null;
private Runnable exitFullscreenCallback = null;
private final AtomicBoolean loading = new AtomicBoolean(false);
/** Verknüpft den Live-Voxel-Chunk-Zustand des Editors mit der Kartenansicht. */
public void setVoxelChunkSupplier(Supplier<List<VoxelChunk>> s) { this.voxelChunkSupplier = s; }
/** Liefert aktuelle Kamera-Position und Blickrichtung für die Kartenanzeige. */
public void setCameraInfoSupplier(Supplier<CameraInfo> s) { cameraInfoSupplier = s; }
/** Callback für Kamera-Teleport per Linksklick; erhält [worldX, worldY, worldZ]. */
public void setTeleportCallback(Consumer<float[]> cb) { teleportCallback = cb; }
/** Callbacks für Vollbild-Modus ein/aus (typisch: Eltern-Layout anpassen). */
public void setFullscreenCallbacks(Runnable enter, Runnable exit) {
enterFullscreenCallback = enter;
exitFullscreenCallback = exit;
}
private static final String[] ASSET_BASES = {
"blight-assets/src/main/resources",
"../blight-assets/src/main/resources",
@@ -119,7 +164,23 @@ public class WorldMapView extends VBox {
}
private void buildUi() {
Button refreshBtn = new Button("Aktualisieren");
// ── Layer-Auswahl als MenuButton mit Checkboxen ───────────────────────
cbTerrain.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
cbWater.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
cbZones.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
cbModels.selectedProperty().addListener((obs, o, n) -> redraw());
cbAreas.selectedProperty().addListener((obs, o, n) -> redraw());
cbLocations.selectedProperty().addListener((obs, o, n) -> redraw());
MenuButton layerMenu = new MenuButton("Layer ▾");
for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbAreas, cbZones, cbLocations, cbModels}) {
CustomMenuItem item = new CustomMenuItem(cb, false);
item.setHideOnClick(false);
layerMenu.getItems().add(item);
}
// ── Buttons ───────────────────────────────────────────────────────────
Button refreshBtn = new Button("↻ Modell aktualisieren");
refreshBtn.setOnAction(e -> loadAndRender());
Button exportBtn = new Button("Als PNG exportieren…");
@@ -129,15 +190,21 @@ public class WorldMapView extends VBox {
labelBtn.selectedProperty().addListener((obs, o, n) ->
canvas.setCursor(n ? Cursor.CROSSHAIR : Cursor.DEFAULT));
fullscreenBtn.setOnAction(e -> enterFullscreen());
backBtn.setOnAction(e -> exitFullscreen());
backBtn.setVisible(false);
ToolBar toolbar = new ToolBar(
new Label("Layer:"),
layerTerrain, layerWater, layerAreas, layerZones, layerLocations, layerModels,
layerMenu,
new Separator(),
new Label("Bearbeiten:"),
labelBtn,
new Separator(),
refreshBtn,
exportBtn
exportBtn,
new Separator(),
fullscreenBtn,
backBtn
);
canvasPane.setStyle("-fx-background-color: #1a1a2a;");
@@ -148,16 +215,6 @@ public class WorldMapView extends VBox {
canvas.widthProperty().addListener(obs -> redraw());
canvas.heightProperty().addListener(obs -> redraw());
// Terrain/Wasser/Zonen/Modelle → PNG neu rendern
layerTerrain.setOnAction(e -> rerender());
layerWater.setOnAction(e -> rerender());
layerZones.setOnAction(e -> rerender());
layerModels.setOnAction(e -> rerender());
// Areas und Locations → nur Canvas-Overlay neu zeichnen (kein PNG-Rerender)
layerAreas.setOnAction(e -> redraw());
layerLocations.setOnAction(e -> redraw());
canvas.setOnMousePressed(e -> {
dragStartX = e.getX();
dragStartY = e.getY();
@@ -170,9 +227,14 @@ public class WorldMapView extends VBox {
redraw();
});
canvas.setOnMouseReleased(e -> {
if (!labelBtn.isSelected()) return;
if (Math.abs(e.getX() - dragStartX) < 5 && Math.abs(e.getY() - dragStartY) < 5) {
boolean wasDrag = Math.abs(e.getX() - dragStartX) >= 5
|| Math.abs(e.getY() - dragStartY) >= 5;
if (!wasDrag) {
if (labelBtn.isSelected()) {
handleLabelPlacement(e.getX(), e.getY());
} else if (e.getButton() == MouseButton.PRIMARY) {
handleMapClick(e.getX(), e.getY());
}
}
});
@@ -203,12 +265,23 @@ public class WorldMapView extends VBox {
getChildren().addAll(toolbar, canvasPane, statusBar);
setStyle("-fx-background-color: #1a1a2a;");
// Kamera-Indikator: regelmäßig neu zeichnen (unabhängig vom Karten-Rendering)
Timeline cameraRefresh = new Timeline(
new KeyFrame(Duration.millis(120), e -> {
if (cameraInfoSupplier != null && mapFxImage != null && !isFullscreen) {
redraw();
}
}));
cameraRefresh.setCycleCount(Animation.INDEFINITE);
cameraRefresh.play();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
public boolean isLoaded() { return mapFxImage != null || loading.get(); }
/** Lädt alle Weltdaten neu und baut das Render-Modell (teuer: I/O + Berechnungen). */
public void loadAndRender() {
if (loading.getAndSet(true)) return;
progress.setVisible(true);
@@ -216,73 +289,73 @@ public class WorldMapView extends VBox {
Thread t = new Thread(() -> {
try {
log.debug("[WorldMap] Modell-Build gestartet (I/O + Vorberechnungen)");
long t0 = System.currentTimeMillis();
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
bakeVoxelHeights(mapData);
Platform.runLater(() -> statusLbl.setText("Berechne Modell…"));
WorldMapRenderModel model = buildRenderModel(mapData, areas, zones, locs, waters, models);
log.debug("[WorldMap] Modell fertig {} Areas, {} Orte, {} Wasser, {} Modelle ({} ms)",
model.areas().size(), model.locations().size(),
model.waters().size(), model.models().size(),
System.currentTimeMillis() - t0);
Platform.runLater(() -> statusLbl.setText("Rendere Karte…"));
int[] slotColors = computeSlotColors(mapData);
RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildBackgroundOptions());
boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE);
float[][] segs = buildSeaCoastSegs(mask, SEA_MASK_SIZE);
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)",
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1);
Platform.runLater(() -> {
cachedAreas = new ArrayList<>(areas);
cachedLocs = new ArrayList<>(locs);
cachedWaters = new ArrayList<>(waters);
seaMask = mask;
seaCoastSegs = segs;
currentModel = model;
cachedAreas = new ArrayList<>(model.areas());
cachedLocs = new ArrayList<>(model.locations());
mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null);
fitToView();
redraw();
progress.setVisible(false);
statusLbl.setText("Bereit " + areas.size() + " Areas, " +
locs.size() + " Orte, " + waters.size() + " Wasser");
statusLbl.setText("Bereit " + model.areas().size() + " Areas, " +
model.locations().size() + " Orte, " + model.waters().size() + " Wasser");
loading.set(false);
log.debug("[WorldMap] Gesamt-Ladezeit: {} ms", System.currentTimeMillis() - t0);
});
} catch (Exception ex) {
log.error("[WorldMap] Fehler beim Modell-Build", ex);
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Fehler: " + ex.getMessage());
loading.set(false);
});
}
}, "WorldMapRenderer");
}, "WorldMapModelBuilder");
t.setDaemon(true);
t.start();
}
// ── Interne Methoden ──────────────────────────────────────────────────────
private void rerender() {
if (mapBuffered == null) { loadAndRender(); return; }
/** Rendert das PNG aus dem gecachten Modell neu (kein I/O, kein Modell-Rebuild). */
private void rerenderFromModel() {
if (currentModel == null) { loadAndRender(); return; }
if (loading.getAndSet(true)) return;
progress.setVisible(true);
statusLbl.setText("Rendere…");
WorldMapRenderModel model = currentModel;
Thread t = new Thread(() -> {
try {
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
int[] slotColors = computeSlotColors(mapData);
RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildBackgroundOptions());
boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE);
log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE);
long t0 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0);
Platform.runLater(() -> {
cachedAreas = new ArrayList<>(areas);
cachedLocs = new ArrayList<>(locs);
cachedWaters = new ArrayList<>(waters);
seaMask = mask;
mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null);
redraw();
@@ -290,19 +363,18 @@ public class WorldMapView extends VBox {
statusLbl.setText("Bereit");
loading.set(false);
});
} catch (Exception ex) {
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Fehler: " + ex.getMessage());
loading.set(false);
});
}
}, "WorldMapRerender");
t.setDaemon(true);
t.start();
}
private void redraw() {
// Beim ersten Anzeigen der Weltkarte automatisch Modell laden
if (!autoLoaded && currentModel == null && !loading.get()) {
autoLoaded = true;
loadAndRender();
}
GraphicsContext gc = canvas.getGraphicsContext2D();
double w = canvas.getWidth(), h = canvas.getHeight();
@@ -311,15 +383,17 @@ public class WorldMapView extends VBox {
if (mapFxImage == null) {
gc.setFill(Color.GRAY);
gc.fillText("Karte noch nicht geladen 'Aktualisieren' klicken", 20, 40);
gc.fillText("Karte wird geladen…", 20, 40);
return;
}
gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale);
if (layerWater.isSelected()) drawWaterOverlay(gc);
if (layerAreas.isSelected()) drawAreasOverlay(gc);
if (layerLocations.isSelected()) drawLocationsOverlay(gc);
if (cbWater.isSelected()) drawWaterOverlay(gc);
if (cbAreas.isSelected()) drawAreasOverlay(gc);
if (cbLocations.isSelected()) drawLocationsOverlay(gc);
if (cbModels.isSelected()) drawTreeOverlay(gc);
if (!isFullscreen) drawCameraIndicator(gc);
}
// ── Canvas-Vektor-Overlays ────────────────────────────────────────────────
@@ -396,8 +470,13 @@ public class WorldMapView extends VBox {
}
private void drawWaterOverlay(GraphicsContext gc) {
if (currentModel == null) return;
boolean[] seaMask = currentModel.seaMask();
float[] terrainSamples = currentModel.terrainSamples();
List<float[][]> seaCoastPaths = currentModel.coastPaths();
// ── Seewellen (Terrain < 0) ──────────────────────────────────────────────
if (seaMask != null && layerWater.isSelected()) {
if (seaMask != null && cbWater.isSelected()) {
// Wellengröße in Weltkoordinaten (skaliert mit Zoom, damit Bildschirmgröße = WAVE_W)
double pxPerWorld = (RENDER_SIZE - 1) * scale / WorldMapRenderer.WORLD_SIZE;
double wWave = WAVE_W / pxPerWorld;
@@ -445,15 +524,18 @@ public class WorldMapView extends VBox {
}
}
if (cachedWaters.isEmpty()) return;
List<PlacedWater> waters = currentModel.waters();
if (waters.isEmpty()) return;
final double spX = WAVE_W * 1.8, spY = WAVE_W * 1.05, wmA = WAVE_W * 0.2;
for (PlacedWater w : cachedWaters) {
for (PlacedWater w : waters) {
float[] wx = w.pointsX(), wz = w.pointsZ();
int n = wx.length;
if (n < 3) continue;
float wh = w.waterHeight();
double[] cx = new double[n], cz = new double[n];
for (int i = 0; i < n; i++) { cx[i] = toCanvasX(wx[i]); cz[i] = toCanvasZ(wz[i]); }
@@ -468,7 +550,7 @@ public class WorldMapView extends VBox {
gc.save();
gc.beginPath();
gc.moveTo(cx[0], cz[0]);
for (int i = 1; i < n; i++) gc.lineTo(cx[i], cz[i]);
for (int i = 1; i < n; i++) { gc.lineTo(cx[i], cz[i]); }
gc.closePath();
gc.clip();
@@ -481,11 +563,17 @@ public class WorldMapView extends VBox {
double rowOff = (row & 1) == 1 ? spX * 0.5 : 0;
int col = 0;
for (double wxx = minX + rowOff; wxx + WAVE_W <= maxX; wxx += spX, col++) {
// deterministisches Jitter pro Zelle
double jx = Math.abs(Math.sin(row * 73.1 + col * 157.3)) * spX * 0.25;
double jy = Math.sin(row * 211.7 + col * 89.5) * spY * 0.2;
double ox = wxx + jx, oy = wy + jy;
if (ox < minX || ox + WAVE_W > maxX || oy < minZ || oy > maxZ) continue;
if (ox < minX || ox + WAVE_W > maxX || oy < minZ || oy > maxZ) { continue; }
// Höhenprüfung: Terrain muss unter waterHeight liegen
if (terrainSamples != null) {
float owx = toWorldX(ox), owz = toWorldZ(oy);
int mxS = Math.max(0, Math.min(SEA_MASK_SIZE-1, (int)((owx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE-1))));
int mzS = Math.max(0, Math.min(SEA_MASK_SIZE-1, (int)((owz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE-1))));
if (terrainSamples[mzS * SEA_MASK_SIZE + mxS] >= wh) { continue; }
}
gc.beginPath();
gc.moveTo(ox, oy);
gc.bezierCurveTo(ox + WAVE_W*0.3, oy - wmA, ox + WAVE_W*0.7, oy + wmA, ox + WAVE_W, oy);
@@ -495,44 +583,22 @@ public class WorldMapView extends VBox {
gc.restore();
}
// Schwarzer Umriss (konstant 3px)
gc.setStroke(Color.BLACK);
gc.setLineWidth(3.0);
gc.setLineDashes(null);
gc.beginPath();
gc.moveTo(cx[0], cz[0]);
for (int i = 1; i < n; i++) gc.lineTo(cx[i], cz[i]);
gc.closePath();
gc.stroke();
}
// ── Seeküstenlinie (konstant 3px, weicher Halo gegen Treppeneffekt) ────────
if (seaCoastSegs != null && seaCoastSegs.length > 0) {
// ── Seeküstenlinie verbundene Pfade, runde Joins, weicher Halo ────────────
if (seaCoastPaths != null && !seaCoastPaths.isEmpty()) {
double cW = canvas.getWidth(), cH = canvas.getHeight();
gc.setLineDashes(null);
gc.setLineCap(StrokeLineCap.ROUND);
gc.setLineJoin(StrokeLineJoin.ROUND);
// Halo-Pass: breiter, halbtransparent
gc.setStroke(Color.color(0, 0, 0, 0.2));
gc.setStroke(Color.color(0, 0, 0, 0.22));
gc.setLineWidth(7.0);
for (float[] seg : seaCoastSegs) {
double sx1 = toCanvasX(seg[0]), sy1 = toCanvasZ(seg[1]);
double sx2 = toCanvasX(seg[2]), sy2 = toCanvasZ(seg[3]);
if (Math.max(sx1, sx2) < -8 || Math.min(sx1, sx2) > cW + 8) continue;
if (Math.max(sy1, sy2) < -8 || Math.min(sy1, sy2) > cH + 8) continue;
gc.strokeLine(sx1, sy1, sx2, sy2);
}
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, cW, cH, 8); }
// Kern-Pass: 3px solid
gc.setStroke(Color.BLACK);
gc.setLineWidth(3.0);
for (float[] seg : seaCoastSegs) {
double sx1 = toCanvasX(seg[0]), sy1 = toCanvasZ(seg[1]);
double sx2 = toCanvasX(seg[2]), sy2 = toCanvasZ(seg[3]);
if (Math.max(sx1, sx2) < -3 || Math.min(sx1, sx2) > cW + 3) continue;
if (Math.max(sy1, sy2) < -3 || Math.min(sy1, sy2) > cH + 3) continue;
gc.strokeLine(sx1, sy1, sx2, sy2);
}
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, cW, cH, 4); }
}
}
@@ -603,28 +669,336 @@ public class WorldMapView extends VBox {
}
}
private static float[][] buildSeaCoastSegs(boolean[] mask, int size) {
List<float[]> segs = new ArrayList<>();
for (int mz = 0; mz < size - 1; mz++) {
for (int mx = 0; mx < size - 1; mx++) {
boolean c = mask[mz * size + mx];
boolean r = mask[mz * size + (mx + 1)];
boolean d = mask[(mz + 1) * size + mx];
float wx0 = (float)(mx / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF);
float wx1 = (float)((mx + 1) / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF);
float wz0 = (float)(mz / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF);
float wz1 = (float)((mz + 1) / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF);
float wzM = (wz0 + wz1) * 0.5f;
float wxM = (wx0 + wx1) * 0.5f;
// Horizontale Kante zwischen (mz,mx) und (mz+1,mx)
if (c != d) segs.add(new float[]{wx0, wzM, wx1, wzM});
// Vertikale Kante zwischen (mz,mx) und (mz,mx+1)
if (c != r) segs.add(new float[]{wxM, wz0, wxM, wz1});
// Zoom-Schwelle: darüber Einzelbaum-Symbole, darunter Wald-Cluster-Symbol
private static final double TREE_INDIVIDUAL_SCALE = 3.0;
// Farbe der Baum-/Waldsymbole
private static final Color TREE_COLOR = Color.rgb(25, 90, 25);
private void drawTreeOverlay(GraphicsContext gc) {
if (currentModel == null) { return; }
List<List<PlacedModel>> treeClusters = currentModel.treeClusters();
if (treeClusters == null || treeClusters.isEmpty()) { return; }
double cW = canvas.getWidth(), cH = canvas.getHeight();
gc.save();
gc.setFill(TREE_COLOR);
gc.setTextAlign(TextAlignment.CENTER);
gc.setTextBaseline(VPos.CENTER);
if (scale >= TREE_INDIVIDUAL_SCALE) {
// Einzelne Bäume ein Symbol pro Baum
gc.setFont(Font.font("SansSerif", 11));
for (List<PlacedModel> cluster : treeClusters) {
for (PlacedModel t : cluster) {
double cx = toCanvasX(t.x()), cz = toCanvasZ(t.z());
if (cx < -15 || cx > cW + 15 || cz < -15 || cz > cH + 15) { continue; }
gc.fillText("", cx, cz);
}
}
} else {
// Wald-Modus ein Symbol pro Cluster (am Schwerpunkt)
gc.setFont(Font.font("SansSerif", FontWeight.BOLD, 14));
for (List<PlacedModel> cluster : treeClusters) {
double sumX = 0, sumZ = 0;
for (PlacedModel t : cluster) { sumX += t.x(); sumZ += t.z(); }
double cx = toCanvasX((float)(sumX / cluster.size()));
double cz = toCanvasZ((float)(sumZ / cluster.size()));
if (cx < -20 || cx > cW + 20 || cz < -20 || cz > cH + 20) { continue; }
gc.fillText("", cx, cz);
}
}
gc.restore();
}
private static List<List<PlacedModel>> clusterTreeModels(List<PlacedModel> trees, float radius) {
int n = trees.size();
int[] parent = new int[n];
for (int i = 0; i < n; i++) { parent[i] = i; }
float r2 = radius * radius;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
float dx = trees.get(i).x() - trees.get(j).x();
float dz = trees.get(i).z() - trees.get(j).z();
if (dx*dx + dz*dz <= r2) {
int pi = ufFind(parent, i), pj = ufFind(parent, j);
if (pi != pj) { parent[pi] = pj; }
}
}
}
Map<Integer, List<PlacedModel>> groups = new HashMap<>();
for (int i = 0; i < n; i++) {
groups.computeIfAbsent(ufFind(parent, i), k -> new ArrayList<>()).add(trees.get(i));
}
return new ArrayList<>(groups.values());
}
private static int ufFind(int[] parent, int i) {
while (parent[i] != i) { parent[i] = parent[parent[i]]; i = parent[i]; }
return i;
}
/**
* Liest alle VoxelChunks (aus dem Live-Supplier oder von Disk) und schreibt die
* höchsten soliden Voxel-Y-Werte in mapData.upperTop.
*/
private void bakeVoxelHeights(MapData mapData) {
List<VoxelChunk> chunks = voxelChunkSupplier != null
? voxelChunkSupplier.get()
: VoxelChunkIO.loadAll();
if (chunks.isEmpty()) { return; }
int UV = MapData.UPPER_VERTS;
float WH = WorldMapRenderer.WORLD_HALF;
float WS = WorldMapRenderer.WORLD_SIZE;
for (VoxelChunk chunk : chunks) {
if (chunk.isEmpty()) { continue; }
for (int lz = 0; lz < VoxelChunk.SIZE; lz++) {
float worldZ = VoxelChunk.toWorldZ(chunk.cz, lz);
int uz = Math.round((worldZ + WH) / WS * (UV - 1));
if (uz < 0 || uz >= UV) { continue; }
for (int lx = 0; lx < VoxelChunk.SIZE; lx++) {
float worldX = VoxelChunk.toWorldX(chunk.cx, lx);
int ux = Math.round((worldX + WH) / WS * (UV - 1));
if (ux < 0 || ux >= UV) { continue; }
// Oberste solide Voxel-Y in dieser Spalte suchen
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) {
float topY = VoxelChunk.toWorldY(chunk.cy, ly);
int idx = uz * UV + ux;
if (topY > mapData.upperTop[idx]) { mapData.upperTop[idx] = topY; }
break;
}
}
}
}
}
}
private WorldMapRenderModel buildRenderModel(
MapData mapData,
List<PlacedArea> areas, List<PlacedLocationZone> zones,
List<Location> locs, List<PlacedWater> waters,
List<PlacedModel> models) {
int[] slotColors = computeSlotColors(mapData);
boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE);
float[] tSamp = buildTerrainSamples(mapData, SEA_MASK_SIZE);
List<float[][]> paths = buildSeaCoastPaths(buildCoastSegsMS(mapData, waters, SEA_MASK_SIZE));
List<PlacedModel> treeList = models.stream()
.filter(WorldMapRenderer::isTree).collect(Collectors.toList());
List<List<PlacedModel>> tClusters = clusterTreeModels(treeList, 40f);
return new WorldMapRenderModel(
mapData, areas, zones, locs, waters, models, slotColors,
mask, tSamp, paths, tClusters);
}
private static float[] buildTerrainSamples(MapData mapData, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
float[] s = new float[size * size];
for (int mz = 0; mz < size; mz++) {
for (int mx = 0; mx < size; mx++) {
int hx = Math.min((int)(mx / (double)(size-1) * (TV-1)), TV-1);
int hz = Math.min((int)(mz / (double)(size-1) * (TV-1)), TV-1);
int ux = Math.min((int)(mx / (double)(size-1) * (UV-1)), UV-1);
int uz = Math.min((int)(mz / (double)(size-1) * (UV-1)), UV-1);
float h = mapData.terrainHeight[hz * TV + hx];
float upper = mapData.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
s[mz * size + mx] = h;
}
}
return s;
}
// Erzeugt interpolierte Marching-Squares-Segmente für Meer + Wasserflächen.
// Endpunkte liegen am echten Höhen-Nulldurchgang → keine Treppenstufen auf Diagonalen.
private static float[][] buildCoastSegsMS(MapData mapData, List<PlacedWater> waters, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
float[] h = new float[size * size];
for (int mz = 0; mz < size; mz++) {
for (int mx = 0; mx < size; mx++) {
int hx = Math.min((int)(mx / (double)(size-1) * (TV-1)), TV-1);
int hz = Math.min((int)(mz / (double)(size-1) * (TV-1)), TV-1);
int ux = Math.min((int)(mx / (double)(size-1) * (UV-1)), UV-1);
int uz = Math.min((int)(mz / (double)(size-1) * (UV-1)), UV-1);
float base = mapData.terrainHeight[hz * TV + hx];
float upper = mapData.upperTop[uz * UV + ux];
h[mz * size + mx] = (upper > 0f && upper > base) ? upper : base;
}
}
List<float[]> segs = new ArrayList<>();
marchingSquares(h, size, 0f, null, null, segs);
for (PlacedWater w : waters) {
marchingSquares(h, size, w.waterHeight(), w.pointsX(), w.pointsZ(), segs);
}
return segs.toArray(new float[0][]);
}
private static void marchingSquares(float[] h, int size, float thr,
float[] polyX, float[] polyZ, List<float[]> out) {
for (int mz = 0; mz < size - 1; mz++) {
for (int mx = 0; mx < size - 1; mx++) {
float hTL = h[mz*size + mx], hTR = h[mz*size + (mx+1)];
float hBL = h[(mz+1)*size + mx], hBR = h[(mz+1)*size + (mx+1)];
float wx0 = msW(mx, size), wx1 = msW(mx+1, size);
float wz0 = msW(mz, size), wz1 = msW(mz+1, size);
boolean iTL, iTR, iBL, iBR;
if (polyX != null) {
iTL = hTL < thr && polyContains(polyX, polyZ, wx0, wz0);
iTR = hTR < thr && polyContains(polyX, polyZ, wx1, wz0);
iBL = hBL < thr && polyContains(polyX, polyZ, wx0, wz1);
iBR = hBR < thr && polyContains(polyX, polyZ, wx1, wz1);
} else {
iTL = hTL < thr; iTR = hTR < thr;
iBL = hBL < thr; iBR = hBR < thr;
}
int idx = (iTL?8:0)|(iTR?4:0)|(iBR?2:0)|(iBL?1:0);
if (idx == 0 || idx == 15) { continue; }
// Interpolierte Schnittpunkte auf den vier Zellkanten
float xTop = msLerp(wx0, wx1, thr, hTL, hTR); // obere Kante
float xBot = msLerp(wx0, wx1, thr, hBL, hBR); // untere Kante
float zLft = msLerp(wz0, wz1, thr, hTL, hBL); // linke Kante
float zRgt = msLerp(wz0, wz1, thr, hTR, hBR); // rechte Kante
switch (idx) {
case 1: case 14: out.add(new float[]{wx0,zLft, xBot,wz1}); break;
case 2: case 13: out.add(new float[]{xBot,wz1, wx1,zRgt}); break;
case 4: case 11: out.add(new float[]{xTop,wz0, wx1,zRgt}); break;
case 8: case 7: out.add(new float[]{wx0,zLft, xTop,wz0}); break;
case 3: case 12: out.add(new float[]{wx0,zLft, wx1,zRgt}); break;
case 6: case 9: out.add(new float[]{xTop,wz0, xBot,wz1}); break;
case 5: {
float hC = (hTL+hTR+hBL+hBR)*0.25f;
if (hC >= thr) { out.add(new float[]{wx0,zLft,xTop,wz0}); out.add(new float[]{xBot,wz1,wx1,zRgt}); }
else { out.add(new float[]{wx0,zLft,xBot,wz1}); out.add(new float[]{xTop,wz0,wx1,zRgt}); }
break;
}
case 10: {
float hC = (hTL+hTR+hBL+hBR)*0.25f;
if (hC >= thr) { out.add(new float[]{xTop,wz0,wx1,zRgt}); out.add(new float[]{wx0,zLft,xBot,wz1}); }
else { out.add(new float[]{wx0,zLft,xTop,wz0}); out.add(new float[]{xBot,wz1,wx1,zRgt}); }
break;
}
}
}
}
}
private static float msLerp(float from, float to, float thr, float hA, float hB) {
float d = hB - hA;
if (Math.abs(d) < 1e-6f) { return (from + to) * 0.5f; }
return from + Math.max(0f, Math.min(1f, (thr - hA) / d)) * (to - from);
}
private static float msW(int idx, int size) {
return (float)(idx / (double)(size-1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF);
}
// Verbindet Rohsegmente zu geschlossenen Küstenpfaden.
private static List<float[][]> buildSeaCoastPaths(float[][] segs) {
if (segs == null || segs.length == 0) { return Collections.emptyList(); }
Map<Long, List<Integer>> adj = new HashMap<>(segs.length * 4);
for (int i = 0; i < segs.length; i++) {
adj.computeIfAbsent(coastPtKey(segs[i][0], segs[i][1]), k -> new ArrayList<>()).add(i);
adj.computeIfAbsent(coastPtKey(segs[i][2], segs[i][3]), k -> new ArrayList<>()).add(i);
}
boolean[] used = new boolean[segs.length];
List<float[][]> result = new ArrayList<>();
for (int start = 0; start < segs.length; start++) {
if (used[start]) { continue; }
used[start] = true;
List<float[]> fwd = new ArrayList<>();
List<float[]> bwd = new ArrayList<>();
fwd.add(new float[]{segs[start][2], segs[start][3]});
bwd.add(new float[]{segs[start][0], segs[start][1]});
coastExtend(fwd, segs, adj, used);
coastExtend(bwd, segs, adj, used);
float[][] raw = new float[bwd.size() + fwd.size()][];
for (int i = 0; i < bwd.size(); i++) { raw[i] = bwd.get(bwd.size() - 1 - i); }
for (int i = 0; i < fwd.size(); i++) { raw[bwd.size() + i] = fwd.get(i); }
result.add(chaikin(simplifyCoastPath(raw), 3));
}
return result;
}
// Entfernt kollineare Zwischenpunkte (lange H/V-Läufe → ein Segment).
private static float[][] simplifyCoastPath(float[][] path) {
int n = path.length;
// Letzten Punkt entfernen wenn er gleich dem ersten ist (geschlossene Schleife)
int len = (n > 1 && Math.abs(path[0][0]-path[n-1][0]) < 0.01f
&& Math.abs(path[0][1]-path[n-1][1]) < 0.01f) ? n - 1 : n;
if (len <= 2) { return path; }
List<float[]> out = new ArrayList<>(len);
for (int i = 0; i < len; i++) {
float[] a = path[(i + len - 1) % len], b = path[i], c = path[(i + 1) % len];
float cross = (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0]);
if (Math.abs(cross) > 1e-3f) { out.add(b); }
}
return out.isEmpty() ? new float[][]{path[0]} : out.toArray(new float[0][]);
}
// Chaikin-Eckenschnitt: glättet Treppenstufen zu Kurven.
private static float[][] chaikin(float[][] path, int iterations) {
float[][] cur = path;
for (int iter = 0; iter < iterations; iter++) {
int n = cur.length;
float[][] next = new float[n * 2][];
for (int i = 0; i < n; i++) {
float[] p0 = cur[i], p1 = cur[(i + 1) % n];
next[i*2] = new float[]{ p0[0]*0.75f + p1[0]*0.25f, p0[1]*0.75f + p1[1]*0.25f };
next[i*2+1] = new float[]{ p0[0]*0.25f + p1[0]*0.75f, p0[1]*0.25f + p1[1]*0.75f };
}
cur = next;
}
return cur;
}
private static void coastExtend(List<float[]> pts, float[][] segs, Map<Long, List<Integer>> adj, boolean[] used) {
for (;;) {
float[] p = pts.get(pts.size() - 1);
List<Integer> nb = adj.get(coastPtKey(p[0], p[1]));
int next = -1;
if (nb != null) { for (int idx : nb) { if (!used[idx]) { next = idx; break; } } }
if (next < 0) { break; }
used[next] = true;
float[] seg = segs[next];
boolean atA = Math.abs(seg[0] - p[0]) < 0.1f && Math.abs(seg[1] - p[1]) < 0.1f;
pts.add(atA ? new float[]{seg[2], seg[3]} : new float[]{seg[0], seg[1]});
}
}
private static long coastPtKey(float x, float z) {
return ((long)Math.round(x * 4)) << 32 | (Math.round(z * 4) & 0xFFFFFFFFL);
}
// Zeichnet einen Küstenpfad als einzelnen geschlossenen Canvas-Pfad.
// margin: Viewport-Puffer in Pixeln für Culling.
private void drawCoastPath(GraphicsContext gc, float[][] path, double cW, double cH, double margin) {
if (path.length < 2) { return; }
double bx0 = Double.MAX_VALUE, bx1 = -Double.MAX_VALUE;
double by0 = Double.MAX_VALUE, by1 = -Double.MAX_VALUE;
for (float[] pt : path) {
double px = toCanvasX(pt[0]), py = toCanvasZ(pt[1]);
if (px < bx0) bx0 = px; if (px > bx1) bx1 = px;
if (py < by0) by0 = py; if (py > by1) by1 = py;
}
if (bx1 < -margin || bx0 > cW + margin || by1 < -margin || by0 > cH + margin) { return; }
gc.beginPath();
gc.moveTo(toCanvasX(path[0][0]), toCanvasZ(path[0][1]));
for (int i = 1; i < path.length; i++) { gc.lineTo(toCanvasX(path[i][0]), toCanvasZ(path[i][1])); }
gc.closePath();
gc.stroke();
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void fitToView() {
@@ -681,21 +1055,22 @@ public class WorldMapView extends VBox {
File file = fc.showSaveDialog(stageSupplier.get());
if (file == null) return;
if (currentModel == null) {
statusLbl.setText("Kein Modell geladen bitte erst 'Modell aktualisieren'");
return;
}
statusLbl.setText("Exportiere 4096×4096 PNG…");
progress.setVisible(true);
WorldMapRenderModel model = currentModel;
Thread t = new Thread(() -> {
try {
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
int[] slotColors = computeSlotColors(mapData);
RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
BufferedImage bi = WorldMapRenderer.render(input, 4096, buildExportOptions());
log.debug("[WorldMap] Export-PNG gestartet (4096×4096, Ziel: {})", file.getName());
long t0 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(model, 4096, buildExportOptions());
ImageIO.write(bi, "PNG", file);
log.debug("[WorldMap] Export-PNG fertig ({} ms)", System.currentTimeMillis() - t0);
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Exportiert: " + file.getName());
@@ -714,34 +1089,116 @@ public class WorldMapView extends VBox {
/** Hintergrund-PNG: ohne Areas und Locations (die werden live auf Canvas gezeichnet). */
private RenderOptions buildBackgroundOptions() {
return new RenderOptions(
layerTerrain.isSelected(),
layerTerrain.isSelected(),
layerWater.isSelected(),
cbTerrain.isSelected(),
cbTerrain.isSelected(),
cbWater.isSelected(),
false, // Wellen im Editor über Canvas gezeichnet
false,
layerZones.isSelected(),
cbZones.isSelected(),
false,
layerModels.isSelected()
cbModels.isSelected()
);
}
/** Export-PNG: alle Layer gebacken. */
private RenderOptions buildExportOptions() {
return new RenderOptions(
layerTerrain.isSelected(),
layerTerrain.isSelected(),
layerWater.isSelected(),
cbTerrain.isSelected(),
cbTerrain.isSelected(),
cbWater.isSelected(),
true, // Wellen im Export-PNG gebacken
layerAreas.isSelected(),
layerZones.isSelected(),
layerLocations.isSelected(),
layerModels.isSelected()
cbAreas.isSelected(),
cbZones.isSelected(),
cbLocations.isSelected(),
cbModels.isSelected()
);
}
private static ToggleButton layerBtn(String label) {
ToggleButton btn = new ToggleButton(label);
btn.setSelected(true);
return btn;
private static CheckBox layerCheck(String label) {
CheckBox cb = new CheckBox(label);
cb.setSelected(true);
return cb;
}
// ── Vollbild ──────────────────────────────────────────────────────────────
private void enterFullscreen() {
isFullscreen = true;
fullscreenBtn.setVisible(false);
backBtn.setVisible(true);
if (enterFullscreenCallback != null) {
enterFullscreenCallback.run();
}
}
private void exitFullscreen() {
isFullscreen = false;
backBtn.setVisible(false);
fullscreenBtn.setVisible(true);
if (exitFullscreenCallback != null) {
exitFullscreenCallback.run();
}
}
// ── Kamera-Indikator ──────────────────────────────────────────────────────
private void drawCameraIndicator(GraphicsContext gc) {
if (cameraInfoSupplier == null || mapFxImage == null) return;
CameraInfo ci = cameraInfoSupplier.get();
double cx = toCanvasX(ci.x());
double cz = toCanvasZ(ci.z());
// Yaw 0° = Blick in -Z. Canvas-Winkel: angle = -(yaw + 90°) in Rad
double angle = -(Math.toRadians(ci.yawDeg()) + Math.PI / 2.0);
double coneLen = 55.0;
double halfFov = Math.toRadians(22.5); // 45° FOV / 2
double lx = cx + coneLen * Math.cos(angle - halfFov);
double lz = cz + coneLen * Math.sin(angle - halfFov);
double rx = cx + coneLen * Math.cos(angle + halfFov);
double rz = cz + coneLen * Math.sin(angle + halfFov);
gc.save();
gc.setLineDashes(null);
// Kegel-Fläche
gc.setFill(Color.rgb(255, 220, 0, 0.25));
gc.fillPolygon(new double[]{cx, lx, rx}, new double[]{cz, lz, rz}, 3);
// Kegel-Rand
gc.setStroke(Color.rgb(255, 220, 0, 0.85));
gc.setLineWidth(1.5);
gc.strokePolygon(new double[]{cx, lx, rx}, new double[]{cz, lz, rz}, 3);
// Positions-Punkt
gc.setFill(Color.rgb(255, 220, 0));
gc.fillOval(cx - 4.5, cz - 4.5, 9, 9);
gc.setStroke(Color.BLACK);
gc.setLineWidth(1.0);
gc.strokeOval(cx - 4.5, cz - 4.5, 9, 9);
gc.restore();
}
// ── Kamera-Teleport ───────────────────────────────────────────────────────
private void handleMapClick(double canvasX, double canvasY) {
if (teleportCallback == null) return;
float wx = toWorldX(canvasX);
float wz = toWorldZ(canvasY);
float dstH = getTerrainH(wx, wz);
float targetY = dstH + 20f;
if (cameraInfoSupplier != null) {
CameraInfo ci = cameraInfoSupplier.get();
float srcH = getTerrainH(ci.x(), ci.z());
targetY = dstH + (ci.y() - srcH);
}
teleportCallback.accept(new float[]{wx, targetY, wz});
}
private float getTerrainH(float worldX, float worldZ) {
if (currentModel == null) return 0f;
float[] samples = currentModel.terrainSamples();
if (samples == null) return 0f;
int mx = Math.max(0, Math.min(SEA_MASK_SIZE - 1,
(int)((worldX + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1))));
int mz = Math.max(0, Math.min(SEA_MASK_SIZE - 1,
(int)((worldZ + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1))));
return samples[mz * SEA_MASK_SIZE + mx];
}
}

View File

@@ -17,6 +17,9 @@
</encoder>
</appender>
<!-- Karten-Rendering: Modell-Build und PNG-Render auf DEBUG -->
<logger name="de.blight.editor.ui.WorldMapView" level="DEBUG"/>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" bekanntes JME-Verhalten, kein Fehler -->

View File

@@ -533,6 +533,8 @@ public class MinimapState extends BaseAppState {
Files.createDirectories(dir);
Path png = dir.resolve("minimap_world.png");
log.debug("[Minimap] Lade Weltdaten für Render-Modell…");
long t0 = System.currentTimeMillis();
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
@@ -541,6 +543,8 @@ public class MinimapState extends BaseAppState {
List<PlacedModel> models = PlacedModelIO.load();
int[] slotColors = computeSlotColors(mapData, root);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
log.debug("[Minimap] Render-Modell bereit {} Areas, {} Orte, {} Wasser ({} ms)",
areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0);
boolean needsRender = !Files.exists(png);
if (!needsRender) {
@@ -553,9 +557,11 @@ public class MinimapState extends BaseAppState {
}
if (needsRender) {
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(renderInput, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.info("[Minimap] Weltkarte gespeichert: {}", png);
log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
System.currentTimeMillis() - t1, png);
} else {
log.info("[Minimap] Gecachte Weltkarte: {}", png);
}
@@ -645,8 +651,12 @@ public class MinimapState extends BaseAppState {
final float capHU = vr / WORLD_SIZE;
Thread t = new Thread(() -> {
log.debug("[Minimap] Vektor-Layer neu rendern Zentrum ({}/{}), Radius {}",
Math.round(wx), Math.round(wz), Math.round(vr));
long t0 = System.currentTimeMillis();
RenderOptions opts = new RenderOptions(false, false, true, true, true, true, true);
BufferedImage bi = WorldMapRenderer.renderRegion(renderInput, VEC_TEX_SIZE, opts, wx, wz, vr);
log.debug("[Minimap] Vektor-Layer fertig ({} ms)", System.currentTimeMillis() - t0);
app.enqueue(() -> {
updateVectorTexture(bi);
vecRenderU = capU;

View File

@@ -19,6 +19,8 @@
<!-- LOD-Slot-Wechsel auf DEBUG aktivieren -->
<logger name="de.blight.game.state.ModelLodControl" level="DEBUG"/>
<!-- Minimap: Modell-Build und Render-Zyklen auf DEBUG -->
<logger name="de.blight.game.state.MinimapState" level="DEBUG"/>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>

View File

@@ -0,0 +1,9 @@
{
"id": "b26f42df-7897-4840-9e7f-5f35329aa0a6",
"benchType": "Simple",
"sitzX": 0.30349,
"sitzY": 2.5,
"sitzZ": -5.00573,
"sitzRotY": 1.5707964,
"sitzSet": true
}

View File

@@ -11,3 +11,5 @@ Models/trees/palm/palm_20260816_213341.j3o 270.73062 3.06689 -913.67090 -1.45828
Models/imported/bank1.j3o 236.63928 -6.31074 -888.17450 -3.22597 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH 9ac9943d-0e12-4d5c-8323-0e2b92eebdec
Models/trees/willow/willow_20260823_101850.j3o 152.40488 11.49070 -888.86847 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/trees/willow/willow_20260823_101856.j3o 170.68971 11.48961 -873.92236 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/imported/wolf.j3o -1.25617 2.00000 -1.86424 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/imported/bank1.j3o 0.30349 2.00000 -5.00573 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH b26f42df-7897-4840-9e7f-5f35329aa0a6