diff --git a/assets/imported/models/3D-Modell eines schwarzen Wolfs.glb b/assets/imported/models/3D-Modell eines schwarzen Wolfs.glb new file mode 100644 index 0000000..630cbe7 Binary files /dev/null and b/assets/imported/models/3D-Modell eines schwarzen Wolfs.glb differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.thumb.png new file mode 100644 index 0000000..b3f16d8 Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.meta b/blight-assets/src/main/resources/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.meta new file mode 100644 index 0000000..f641c7f --- /dev/null +++ b/blight-assets/src/main/resources/Models/imported/3D-Modell eines schwarzen Wolfs.j3o.meta @@ -0,0 +1,28 @@ +#Tue Aug 25 21:32:55 CEST 2026 +attachedEmitters.count=0 +attachedLights.count=0 +castShadow=true +category= +cullDistance=120.0 +footstepSurface= +interactableOffsetX=0.0 +interactableOffsetY=0.5 +interactableOffsetZ=0.0 +interactableRotY=0.0 +interactableType=NONE +lod1Distance=30.0 +lod1Path= +lod2Distance=80.0 +lod2Path= +name=3D-Modell eines schwarzen Wolfs +pivotOffsetY=0.0 +placementOffsetY=0.0 +randomScaleMax=1.0 +randomScaleMin=1.0 +receiveShadow=true +scaleX=1.0 +scaleY=1.0 +scaleZ=1.0 +solid=true +tags= +uniformScale=true diff --git a/blight-assets/src/main/resources/Models/imported/wolf.j3o b/blight-assets/src/main/resources/Models/imported/wolf.j3o new file mode 100644 index 0000000..e4c178f Binary files /dev/null and b/blight-assets/src/main/resources/Models/imported/wolf.j3o differ diff --git a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java index 2186f8e..941b846 100644 --- a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java +++ b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java @@ -139,12 +139,17 @@ public final class VoxelChunkIO { /** * Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis. + * Für Koordinaten, bei denen die .blvc nach dem Backen gelöscht wurde, + * wird automatisch die .blvc.prebake als Fallback herangezogen. * Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert. */ public static List loadAll() { List result = new ArrayList<>(); Path dir = ChunkTerrainIO.chunksDir(); if (!Files.isDirectory(dir)) return result; + + // Aktive .blvc-Dateien einlesen + Set loaded = new java.util.HashSet<>(); try (DirectoryStream ds = Files.newDirectoryStream(dir, "voxel_*.blvc")) { for (Path p : ds) { String name = p.getFileName().toString() @@ -158,9 +163,30 @@ public final class VoxelChunkIO { : Integer.parseInt(parts[1]); int cz = Integer.parseInt(parts[2]); result.add(VoxelChunk.deserialize(Files.readAllBytes(p), cx, cy, cz)); + loaded.add(name); } catch (Exception ignored) {} } } catch (IOException ignored) {} + + // Prebake-Fallback: Koordinaten die gebacken wurden haben keine .blvc mehr + try (DirectoryStream ds = Files.newDirectoryStream(dir, "voxel_*.blvc.prebake")) { + for (Path p : ds) { + String name = p.getFileName().toString() + .replace("voxel_", "").replace(".blvc.prebake", ""); + if (loaded.contains(name)) continue; // aktive .blvc hat Vorrang + String[] parts = name.split("_"); + if (parts.length != 3) continue; + try { + int cx = Integer.parseInt(parts[0]); + int cy = parts[1].startsWith("m") + ? -Integer.parseInt(parts[1].substring(1)) + : Integer.parseInt(parts[1]); + int cz = Integer.parseInt(parts[2]); + result.add(VoxelChunk.deserialize(Files.readAllBytes(p), cx, cy, cz)); + } catch (Exception ignored) {} + } + } catch (IOException ignored) {} + return result; } } diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java index 945c0a3..b611479 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderModel.java @@ -18,9 +18,11 @@ public record WorldMapRenderModel( List locations, List waters, List models, + List waterfalls, 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[] voxelSurface, // oberster solider Voxel-Y pro UPPER_VERTS-Zelle; NaN = keine Daten float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung) List coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.) List> treeClusters diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java index 536304f..f7d5a83 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java @@ -23,6 +23,8 @@ public final class WorldMapRenderer { public static final float WORLD_HALF = 1024f; public static final float WORLD_SIZE = 2048f; + /** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */ + public static final int VOXEL_SURFACE_RES = 2048; public record RenderInput( MapData mapData, @@ -47,32 +49,46 @@ public final class WorldMapRenderer { boolean showAreas, boolean showZones, boolean showLocations, - boolean showModels + boolean showModels, + boolean showWaterfalls ) { - // Compat: alte 7-Parameter-Form → showWaterWaves = showWater + // Compat: 8-Parameter-Form (ohne showWaterfalls) → showWaterfalls = true + public RenderOptions(boolean showTerrain, boolean showSplatColors, boolean showWater, + boolean showWaterWaves, boolean showAreas, boolean showZones, + boolean showLocations, boolean showModels) { + this(showTerrain, showSplatColors, showWater, showWaterWaves, + showAreas, showZones, showLocations, showModels, true); + } + // Compat: alte 7-Parameter-Form → showWaterWaves = showWater, showWaterfalls = true public RenderOptions(boolean showTerrain, boolean showSplatColors, boolean showWater, boolean showAreas, boolean showZones, boolean showLocations, boolean showModels) { this(showTerrain, showSplatColors, showWater, showWater, - showAreas, showZones, showLocations, showModels); + showAreas, showZones, showLocations, showModels, true); } public static RenderOptions all() { - return new RenderOptions(true, true, true, true, true, true, true, true); + return new RenderOptions(true, true, true, true, true, true, true, true, true); } } public static boolean[] buildSeaMask(MapData m, int size) { + return buildSeaMask(m, size, null); + } + + public static boolean[] buildSeaMask(MapData m, int size, float[] voxelSurface) { int TV = MapData.TERRAIN_VERTS; - int UV = MapData.UPPER_VERTS; + int VS = VOXEL_SURFACE_RES; 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); - 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; } + if (voxelSurface != null) { + int vsPx = Math.min((int)((float) px / (size - 1) * (VS - 1)), VS - 1); + int vsPz = Math.min((int)((float) py / (size - 1) * (VS - 1)), VS - 1); + float vs = voxelSurface[vsPz * VS + vsPx]; + if (!Float.isNaN(vs) && vs > h) { h = vs; } + } mask[py * size + px] = h < 0f; } } @@ -100,18 +116,37 @@ public final class WorldMapRenderer { // ── 1. Heightmap auf Zielauflösung samplen ──────────────────────────── int UV = MapData.UPPER_VERTS; float[] heights = new float[targetSize * targetSize]; + // Meerserkennung basiert nur auf terrainHeight (ohne upperTop/voxelSurface): + // bakeVoxelHeights trägt Voxel-Höhen in upperTop ein, was sonst + // Voxel auf dem Meeresboden fälschlich als Landfläche klassifiziert. + boolean[] seaPixels = new boolean[targetSize * targetSize]; float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE; + int VS = VOXEL_SURFACE_RES; for (int py = 0; py < targetSize; py++) { 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]; + float ux_f = (float) px / (targetSize - 1) * (UV - 1); + float uz_f = (float) py / (targetSize - 1) * (UV - 1); + float baseH = m.terrainHeight[hz * TV + hx]; + float h = baseH; + float upper = bilerpPos(m.upperTop, ux_f, uz_f, UV); if (upper > 0f && upper > h) { h = upper; } + // Voxel-Oberfläche: direkte 1:1-Abfrage des hochauflösenden Bake-Arrays + // seaH = max(terrain, voxelSurface): Voxel über Y=0 → Land, darunter → Meer + float seaH = baseH; + if (model.voxelSurface() != null) { + int vsPx = Math.min((int)((float) px / (targetSize - 1) * (VS - 1)), VS - 1); + int vsPz = Math.min((int)((float) py / (targetSize - 1) * (VS - 1)), VS - 1); + float vs = model.voxelSurface()[vsPz * VS + vsPx]; + if (!Float.isNaN(vs)) { + h = vs; + if (vs > seaH) { seaH = vs; } + } + } heights[py * targetSize + px] = h; + seaPixels[py * targetSize + px] = (seaH < 0f); if (h < minH) minH = h; if (h > maxH) maxH = h; } @@ -188,7 +223,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, WATER_COLOR); + if (seaPixels[py * targetSize + px]) img.setRGB(px, py, WATER_COLOR); } } for (PlacedWater w : model.waters()) { @@ -227,7 +262,7 @@ public final class WorldMapRenderer { int cx = (int)(ox + wmW * 0.5f), cy = (int)oy; if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize && ox >= 0 && ox + wmW < targetSize - && heights[cy * targetSize + cx] < 0f) { + && seaPixels[cy * targetSize + cx]) { Path2D mark = new Path2D.Float(); mark.moveTo(ox, oy); mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); @@ -278,9 +313,9 @@ public final class WorldMapRenderer { boolean[] nxt = new boolean[targetSize * targetSize]; for (int py = 1; py < targetSize - 1; py++) { for (int px = 1; px < targetSize - 1; px++) { - if (heights[py * targetSize + px] >= 0f) continue; - if (heights[py * targetSize + (px-1)] >= 0f || heights[py * targetSize + (px+1)] >= 0f || - heights[(py-1) * targetSize + px] >= 0f || heights[(py+1) * targetSize + px] >= 0f) + if (!seaPixels[py * targetSize + px]) continue; + if (!seaPixels[py * targetSize + (px-1)] || !seaPixels[py * targetSize + (px+1)] || + !seaPixels[(py-1) * targetSize + px] || !seaPixels[(py+1) * targetSize + px]) cur[py * targetSize + px] = true; } } @@ -289,10 +324,10 @@ public final class WorldMapRenderer { for (int py = 1; py < targetSize - 1; py++) { for (int px = 1; px < targetSize - 1; px++) { if (!cur[py * targetSize + px]) continue; - if (heights[py * targetSize + (px-1)] < 0f) nxt[py * targetSize + (px-1)] = true; - if (heights[py * targetSize + (px+1)] < 0f) nxt[py * targetSize + (px+1)] = true; - if (heights[(py-1) * targetSize + px] < 0f) nxt[(py-1) * targetSize + px] = true; - if (heights[(py+1) * targetSize + px] < 0f) nxt[(py+1) * targetSize + px] = true; + if (seaPixels[py * targetSize + (px-1)]) nxt[py * targetSize + (px-1)] = true; + if (seaPixels[py * targetSize + (px+1)]) nxt[py * targetSize + (px+1)] = true; + if (seaPixels[(py-1) * targetSize + px]) nxt[(py-1) * targetSize + px] = true; + if (seaPixels[(py+1) * targetSize + px]) nxt[(py+1) * targetSize + px] = true; } } boolean[] tmp = cur; cur = nxt; nxt = tmp; @@ -303,6 +338,66 @@ public final class WorldMapRenderer { } } + // Wasserfälle – klassisches Kartensymbol: Kammlinie + Kaskaden-Linien + if (opts.showWaterfalls() && model.waterfalls() != null && !model.waterfalls().isEmpty()) { + float wfLineW = Math.max(1.5f, targetSize / 800f); + + for (PlacedWaterfall wf : model.waterfalls()) { + // Kamm A→B und Basis D→C in Pixel-Koordinaten + int pax = worldToPixel(wf.ax(), targetSize), paz = worldToPixel(wf.az(), targetSize); + int pbx = worldToPixel(wf.bx(), targetSize), pbz = worldToPixel(wf.bz(), targetSize); + int pdx = worldToPixel(wf.dx(), targetSize), pdz = worldToPixel(wf.dz(), targetSize); + int pcx = worldToPixel(wf.cx(), targetSize), pcz = worldToPixel(wf.cz(), targetSize); + + // Kammrichtung in Pixel-Space + float cdx = pbx - pax, cdz = pbz - paz; + float clen = (float) Math.sqrt(cdx * cdx + cdz * cdz); + if (clen < 1f) { continue; } + float ncx = cdx / clen, ncz = cdz / clen; + + // Fließrichtung: Basismitten → Kammmitte im Pixel-Space + float pmcx = (pax + pbx) * 0.5f, pmcz = (paz + pbz) * 0.5f; + float pmbx = (pdx + pcx) * 0.5f, pmbz = (pdz + pcz) * 0.5f; + float fvx = pmbx - pmcx, fvz = pmbz - pmcz; + float flen = (float) Math.sqrt(fvx * fvx + fvz * fvz); + float perpX, perpZ; + if (flen > 2f) { + // echte XZ-Fallrichtung verfügbar + perpX = fvx / flen; + perpZ = fvz / flen; + } else { + // Wasserfall senkrecht → Rechtsnormale der Kammlinie als Fallback + perpX = ncz; + perpZ = -ncx; + } + + // Pixelabstand pro Kaskaden-Stufe (mindestens 3 px, skaliert mit Bild) + float step = Math.max(3f, targetSize / 512f) * 1.8f; + + // ① Dunkler Halo unter der Kammlinie + gfx.setStroke(new BasicStroke(wfLineW * 2.5f + 1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + gfx.setColor(new Color(10, 50, 120, 160)); + gfx.drawLine(pax, paz, pbx, pbz); + + // ② Helle Kammlinie (Kante, über die das Wasser fällt) + gfx.setStroke(new BasicStroke(wfLineW * 2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + gfx.setColor(new Color(220, 240, 255, 255)); + gfx.drawLine(pax, paz, pbx, pbz); + + // ③ Kaskaden-Linien: 2 parallele Linien in Fallrichtung, jede schwächer + int[] alpha = {190, 100}; + float[] wf2 = {wfLineW * 1.3f, wfLineW * 0.8f}; + for (int ci = 0; ci < 2; ci++) { + float off = step * (ci + 1); + int ox1 = Math.round(pax + perpX * off), oz1 = Math.round(paz + perpZ * off); + int ox2 = Math.round(pbx + perpX * off), oz2 = Math.round(pbz + perpZ * off); + gfx.setStroke(new BasicStroke(wf2[ci], BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + gfx.setColor(new Color(160, 210, 255, alpha[ci])); + gfx.drawLine(ox1, oz1, ox2, oz2); + } + } + } + // Area-Polygone if (opts.showAreas()) { float dash = lineW * 7f, gap = lineW * 4f; @@ -375,8 +470,8 @@ public final class WorldMapRenderer { 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 + input.waters(), input.models(), null, + input.slotColorsRGB(), null, null, null, null, null ), targetSize, opts); } @@ -408,9 +503,11 @@ public final class WorldMapRenderer { float[] heights = null; float minH = 0f, maxH = 1f; + boolean[] seaPixels2 = null; if (opts.showTerrain() || opts.showWater()) { int UV2 = MapData.UPPER_VERTS; heights = new float[targetSize * targetSize]; + seaPixels2 = new boolean[targetSize * targetSize]; minH = Float.MAX_VALUE; maxH = -Float.MAX_VALUE; for (int py = 0; py < targetSize; py++) { @@ -421,10 +518,13 @@ public final class WorldMapRenderer { 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 baseH = m.terrainHeight[hz * TV + hx]; + float h = baseH; float upper = m.upperTop[uz * UV2 + ux]; if (upper > 0f && upper > h) { h = upper; } heights[py * targetSize + px] = h; + // upperTop enthält nach bakeVoxelHeights auch Voxel-Höhen → korrekt als Proxy nutzbar + seaPixels2[py * targetSize + px] = (Math.max(baseH, upper) < 0f); if (h < minH) { minH = h; } if (h > maxH) { maxH = h; } } @@ -507,7 +607,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, WATER_COLOR); + if (seaPixels2 != null && seaPixels2[py * targetSize + px]) img.setRGB(px, py, WATER_COLOR); } } for (PlacedWater w : input.waters()) { @@ -546,7 +646,7 @@ public final class WorldMapRenderer { int cx = (int)(ox + wmW * 0.5f), cy = (int)oy; if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize && ox >= 0 && ox + wmW < targetSize - && heights[cy * targetSize + cx] < 0f) { + && seaPixels2 != null && seaPixels2[cy * targetSize + cx]) { Path2D mark = new Path2D.Float(); mark.moveTo(ox, oy); mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); @@ -597,9 +697,9 @@ public final class WorldMapRenderer { boolean[] nxt = new boolean[targetSize * targetSize]; for (int py = 1; py < targetSize - 1; py++) { for (int px = 1; px < targetSize - 1; px++) { - if (heights[py * targetSize + px] >= 0f) continue; - if (heights[py * targetSize + (px-1)] >= 0f || heights[py * targetSize + (px+1)] >= 0f || - heights[(py-1) * targetSize + px] >= 0f || heights[(py+1) * targetSize + px] >= 0f) + if (seaPixels2 == null || !seaPixels2[py * targetSize + px]) continue; + if (!seaPixels2[py * targetSize + (px-1)] || !seaPixels2[py * targetSize + (px+1)] || + !seaPixels2[(py-1) * targetSize + px] || !seaPixels2[(py+1) * targetSize + px]) cur[py * targetSize + px] = true; } } @@ -608,10 +708,10 @@ public final class WorldMapRenderer { for (int py = 1; py < targetSize - 1; py++) { for (int px = 1; px < targetSize - 1; px++) { if (!cur[py * targetSize + px]) continue; - if (heights[py * targetSize + (px-1)] < 0f) nxt[py * targetSize + (px-1)] = true; - if (heights[py * targetSize + (px+1)] < 0f) nxt[py * targetSize + (px+1)] = true; - if (heights[(py-1) * targetSize + px] < 0f) nxt[(py-1) * targetSize + px] = true; - if (heights[(py+1) * targetSize + px] < 0f) nxt[(py+1) * targetSize + px] = true; + if (seaPixels2[py * targetSize + (px-1)]) nxt[py * targetSize + (px-1)] = true; + if (seaPixels2[py * targetSize + (px+1)]) nxt[py * targetSize + (px+1)] = true; + if (seaPixels2[(py-1) * targetSize + px]) nxt[(py-1) * targetSize + px] = true; + if (seaPixels2[(py+1) * targetSize + px]) nxt[(py+1) * targetSize + px] = true; } } boolean[] tmp = cur; cur = nxt; nxt = tmp; @@ -830,6 +930,32 @@ public final class WorldMapRenderer { } } + /** Bilinear auf float[stride²]; nur wenn alle 4 Nachbarn > 0 – sonst nearest-neighbor. */ + private static float bilerpPos(float[] arr, float fx, float fz, int stride) { + int x0 = (int) fx, z0 = (int) fz; + int x1 = Math.min(x0 + 1, stride - 1), z1 = Math.min(z0 + 1, stride - 1); + float v00 = arr[z0 * stride + x0], v10 = arr[z0 * stride + x1]; + float v01 = arr[z1 * stride + x0], v11 = arr[z1 * stride + x1]; + if (v00 <= 0f || v10 <= 0f || v01 <= 0f || v11 <= 0f) { return v00; } + float tx = fx - x0, tz = fz - z0; + return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz; + } + + /** Bilinear auf float[stride²]; nur wenn alle 4 Nachbarn nicht NaN und gleiches Vorzeichen – sonst nearest-neighbor. */ + private static float bilerpNaN(float[] arr, float fx, float fz, int stride) { + int x0 = (int) fx, z0 = (int) fz; + int x1 = Math.min(x0 + 1, stride - 1), z1 = Math.min(z0 + 1, stride - 1); + float v00 = arr[z0 * stride + x0], v10 = arr[z0 * stride + x1]; + float v01 = arr[z1 * stride + x0], v11 = arr[z1 * stride + x1]; + if (Float.isNaN(v00) || Float.isNaN(v10) || Float.isNaN(v01) || Float.isNaN(v11)) { return v00; } + // Kein Bilinear über Vorzeichen-Wechsel (Unterwasser↔Oberfläche) → nearest-neighbor + boolean allPos = v00 > 0 && v10 > 0 && v01 > 0 && v11 > 0; + boolean allNeg = v00 < 0 && v10 < 0 && v01 < 0 && v11 < 0; + if (!allPos && !allNeg) { return v00; } + float tx = fx - x0, tz = fz - z0; + return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz; + } + private static float bilerp(byte[] arr, int i00, int i10, int i01, int i11, float tx, float tz) { float v00 = (arr[i00] & 0xFF) / 255f, v10 = (arr[i10] & 0xFF) / 255f; float v01 = (arr[i01] & 0xFF) / 255f, v11 = (arr[i11] & 0xFF) / 255f; diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 361db19..b5062ac 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -81,6 +81,7 @@ public class EditorApp extends Application { private final java.util.concurrent.ConcurrentLinkedQueue consoleBuffer = new java.util.concurrent.ConcurrentLinkedQueue<>(); private VBox assetPanel; + private Tab weltkartTab; private MapObjectsView mapObjectsView; private de.blight.editor.ui.WorldMapView worldMapView; private de.blight.editor.ui.ThumbnailManagerView thumbnailManagerView; @@ -360,8 +361,8 @@ public class EditorApp extends Application { private final java.util.Deque modelImportQueue = new java.util.ArrayDeque<>(); // Asset-Overlay-Zustand - private boolean assetOverlayOpen = false; - private Button assetTabBtn; + private boolean assetOverlayOpen = false; + private Button assetTabBtn; private StackPane centerStack; // persistenter Wrapper für alle 3D-Ansichten // Toolbar-Buttons (müssen vom Status-Poller erreichbar sein) @@ -5653,21 +5654,59 @@ public class EditorApp extends Application { input.pendingGotoZ = pos[2]; }); - final Tab weltkartTab = new Tab("Weltkarte", worldMapView); + 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); + // Placeholder ersetzt worldMapView im Tab → sauberes Detach + final javafx.scene.control.Label placeholder = new javafx.scene.control.Label(); + weltkartTab.setContent(placeholder); + assetTabBtn.setVisible(false); + // Nach einem Pulse hat der TabPane-Skin den Swap verarbeitet + javafx.application.Platform.runLater(() -> { + log.debug("[FS-enter] parent after detach: {}", worldMapView.getParent()); + worldMapView.setMaxWidth(Double.MAX_VALUE); + // KEIN explizites Alignment → null = CENTER → fillWidth=true → View füllt volle Breite + StackPane.setAlignment(worldMapView, null); + worldMapView.setTranslateX(0); + try { + centerStack.getChildren().add(worldMapView); + } catch (Exception ex) { + log.error("[FS-enter] add failed", ex); + return; + } + // Nach nächstem Layout-Pass hat centerStack die View korrekt dimensioniert + javafx.application.Platform.runLater(() -> { + double w = worldMapView.getWidth(); + log.debug("[FS-enter] post-layout: w={} h={}", w, worldMapView.getHeight()); + // Karte auf volle Canvas-Größe einpassen (scale/pan), dann animieren + worldMapView.fitCanvas(); + worldMapView.setTranslateX(-w); + javafx.animation.TranslateTransition tt = new javafx.animation.TranslateTransition( + javafx.util.Duration.millis(300), worldMapView); + tt.setToX(0); + tt.setInterpolator(javafx.animation.Interpolator.EASE_OUT); + tt.play(); + }); + }); }, () -> { - // Erst aus centerStack lösen (worldViewport zurück), dann in Tab setzen - setCenterView(worldViewport); - weltkartTab.setContent(worldMapView); + // Zurück nach links schieben, dann in Tab zurücklegen + double w = worldMapView.getWidth(); + javafx.animation.TranslateTransition tt = new javafx.animation.TranslateTransition( + javafx.util.Duration.millis(220), worldMapView); + tt.setToX(-w); + tt.setInterpolator(javafx.animation.Interpolator.EASE_IN); + tt.setOnFinished(ev -> { + centerStack.getChildren().remove(worldMapView); + worldMapView.setTranslateX(0); + weltkartTab.setContent(worldMapView); + assetTabBtn.setVisible(true); + }); + tt.play(); }); TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java index 702d8ff..5f4ca55 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -82,16 +82,17 @@ public class WorldMapView extends VBox { private final StackPane canvasPane = new StackPane(canvas); // 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 CheckBox cbTerrain = layerCheck("Gelände"); + private final CheckBox cbWater = layerCheck("Wasser"); + private final CheckBox cbWaterfalls = layerCheck("Wasserfälle"); + 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 final Button fullscreenBtn = new Button(">>"); + private final Button backBtn = new Button("<<"); private boolean isFullscreen = false; /** Kamera-Zustand (Position + Blickrichtung) für die Karten-Überlagerung. */ @@ -107,6 +108,7 @@ public class WorldMapView extends VBox { private double panX = 0, panY = 0; private double scale = 1.0; + private double savedScale, savedPanX, savedPanY; private double dragStartX, dragStartY, dragStartPanX, dragStartPanY; private final Supplier stageSupplier; @@ -167,25 +169,31 @@ public class WorldMapView extends VBox { // ── Layer-Auswahl als MenuButton mit Checkboxen ─────────────────────── cbTerrain.selectedProperty().addListener((obs, o, n) -> rerenderFromModel()); cbWater.selectedProperty().addListener((obs, o, n) -> rerenderFromModel()); + cbWaterfalls.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}) { + for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbWaterfalls, cbAreas, cbZones, cbLocations, cbModels}) { CustomMenuItem item = new CustomMenuItem(cb, false); item.setHideOnClick(false); layerMenu.getItems().add(item); } // ── Buttons ─────────────────────────────────────────────────────────── - Button refreshBtn = new Button("↻ Modell aktualisieren"); + Button refreshBtn = new Button("↻"); + refreshBtn.setTooltip(new javafx.scene.control.Tooltip("Modell aktualisieren")); refreshBtn.setOnAction(e -> loadAndRender()); - Button exportBtn = new Button("Als PNG exportieren…"); + Button exportBtn = new Button("⇩"); + exportBtn.setTooltip(new javafx.scene.control.Tooltip("Als PNG exportieren…")); exportBtn.setOnAction(e -> exportPng()); + fullscreenBtn.setTooltip(new javafx.scene.control.Tooltip("Maximieren")); + backBtn.setTooltip(new javafx.scene.control.Tooltip("Minimieren")); + labelBtn.setStyle("-fx-font-size: 11;"); labelBtn.selectedProperty().addListener((obs, o, n) -> canvas.setCursor(n ? Cursor.CROSSHAIR : Cursor.DEFAULT)); @@ -293,18 +301,20 @@ public class WorldMapView extends VBox { long t0 = System.currentTimeMillis(); MapData mapData = MapIO.load(); - List areas = AreaIO.load(); - List zones = LocationZoneIO.load(); - List locs = LocationIO.load(); - List waters = WaterBodyIO.load(); - List models = PlacedModelIO.load(); - bakeVoxelHeights(mapData); + List areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List models = PlacedModelIO.load(); + List waterfalls = WaterfallIO.load(); + float[] voxelSurf = 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)", + WorldMapRenderModel model = buildRenderModel( + mapData, areas, zones, locs, waters, models, waterfalls, voxelSurf); + log.debug("[WorldMap] Modell fertig – {} Areas, {} Orte, {} Wasser, {} Wasserfälle, {} Modelle ({} ms)", model.areas().size(), model.locations().size(), - model.waters().size(), model.models().size(), + model.waters().size(), model.waterfalls().size(), model.models().size(), System.currentTimeMillis() - t0); Platform.runLater(() -> statusLbl.setText("Rendere Karte…")); @@ -740,55 +750,77 @@ public class WorldMapView extends VBox { /** * Liest alle VoxelChunks (aus dem Live-Supplier oder von Disk) und schreibt die - * höchsten soliden Voxel-Y-Werte in mapData.upperTop. + * höchsten soliden Voxel-Y-Werte in mapData.upperTop (für SeaMask/TerrainSamples) + * und gibt zusätzlich ein voxelSurface-Array zurück, das den tatsächlichen + * Oberflächen-Y pro UV-Zelle speichert — NaN wo kein Voxel, sonst der echte + * Top-Y-Wert (kann auch unter terrainHeight liegen, z. B. bei Canyons). */ - private void bakeVoxelHeights(MapData mapData) { + private float[] bakeVoxelHeights(MapData mapData) { + // voxelSurface bei voller Render-Auflösung (1 Zelle = 1 Welteinheit) backen, + // damit keine 8×8-Pixel-Blöcke entstehen. + int VS = WorldMapRenderer.VOXEL_SURFACE_RES; + float[] voxelSurface = new float[VS * VS]; + java.util.Arrays.fill(voxelSurface, Float.NaN); List chunks = voxelChunkSupplier != null ? voxelChunkSupplier.get() : VoxelChunkIO.loadAll(); - if (chunks.isEmpty()) { return; } - int UV = MapData.UPPER_VERTS; + // Nach dem Backen sind Live-Chunks geleert → Fallback auf Disk (inkl. .blvc.prebake) + boolean allEmpty = chunks.stream().allMatch(VoxelChunk::isEmpty); + if (allEmpty) { chunks = VoxelChunkIO.loadAll(); } + if (chunks.isEmpty()) { return voxelSurface; } float WH = WorldMapRenderer.WORLD_HALF; float WS = WorldMapRenderer.WORLD_SIZE; + int UV = MapData.UPPER_VERTS; for (VoxelChunk chunk : chunks) { if (chunk.isEmpty()) { continue; } for (int lz = 0; lz < VoxelChunk.SIZE; lz++) { float worldZ = VoxelChunk.toWorldZ(chunk.cz, lz); + int vsPz = Math.round((worldZ + WH) / WS * (VS - 1)); + if (vsPz < 0 || vsPz >= VS) { continue; } 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 vsPx = Math.round((worldX + WH) / WS * (VS - 1)); + if (vsPx < 0 || vsPx >= VS) { continue; } 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; } + int vsIdx = vsPz * VS + vsPx; + if (Float.isNaN(voxelSurface[vsIdx]) || topY > voxelSurface[vsIdx]) { + voxelSurface[vsIdx] = topY; + } + // upperTop auf UV-Raster aktualisieren (für SeaMask / renderRegion) + if (uz >= 0 && uz < UV && ux >= 0 && ux < UV) { + int uvIdx = uz * UV + ux; + if (topY > mapData.upperTop[uvIdx]) { mapData.upperTop[uvIdx] = topY; } + } break; } } } } } + return voxelSurface; } private WorldMapRenderModel buildRenderModel( MapData mapData, List areas, List zones, List locs, List waters, - List models) { + List models, List waterfalls, + float[] voxelSurface) { int[] slotColors = computeSlotColors(mapData); - boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE); + boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE, voxelSurface); float[] tSamp = buildTerrainSamples(mapData, SEA_MASK_SIZE); - List paths = buildSeaCoastPaths(buildCoastSegsMS(mapData, waters, SEA_MASK_SIZE)); + List paths = buildSeaCoastPaths(buildCoastSegsMS(mapData, waters, SEA_MASK_SIZE, voxelSurface)); List treeList = models.stream() .filter(WorldMapRenderer::isTree).collect(Collectors.toList()); List> tClusters = clusterTreeModels(treeList, 40f); return new WorldMapRenderModel( - mapData, areas, zones, locs, waters, models, slotColors, - mask, tSamp, paths, tClusters); + mapData, areas, zones, locs, waters, models, waterfalls, slotColors, + mask, voxelSurface, tSamp, paths, tClusters); } private static float[] buildTerrainSamples(MapData mapData, int size) { @@ -812,19 +844,27 @@ public class WorldMapView extends VBox { // 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 waters, int size) { + private static float[][] buildCoastSegsMS(MapData mapData, List waters, int size, + float[] voxelSurface) { int TV = MapData.TERRAIN_VERTS; - int UV = MapData.UPPER_VERTS; + int VS = WorldMapRenderer.VOXEL_SURFACE_RES; 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; + float base = mapData.terrainHeight[hz * TV + hx]; + // Voxel-Oberfläche hochauflösend abgreifen (falls vorhanden) + float vs = Float.NaN; + if (voxelSurface != null) { + int vsPx = Math.min((int)(mx / (double)(size-1) * (VS-1)), VS-1); + int vsPz = Math.min((int)(mz / (double)(size-1) * (VS-1)), VS-1); + vs = voxelSurface[vsPz * VS + vsPx]; + } + // Kein upperTop-Fallback: bakeVoxelHeights kontaminiert upperTop auf + // UV-Auflösung (8 Welteinheiten/Zelle) → würde Küste verfälschen. + // Übereinstimmung mit seaPixels-Logik im Renderer. + h[mz * size + mx] = Float.isNaN(vs) ? base : Math.max(base, vs); } } List segs = new ArrayList<>(); @@ -1092,11 +1132,12 @@ public class WorldMapView extends VBox { cbTerrain.isSelected(), cbTerrain.isSelected(), cbWater.isSelected(), - false, // Wellen im Editor über Canvas gezeichnet + false, // Wellen im Editor über Canvas gezeichnet false, cbZones.isSelected(), false, - cbModels.isSelected() + cbModels.isSelected(), + cbWaterfalls.isSelected() ); } @@ -1106,11 +1147,12 @@ public class WorldMapView extends VBox { cbTerrain.isSelected(), cbTerrain.isSelected(), cbWater.isSelected(), - true, // Wellen im Export-PNG gebacken + true, // Wellen im Export-PNG gebacken cbAreas.isSelected(), cbZones.isSelected(), cbLocations.isSelected(), - cbModels.isSelected() + cbModels.isSelected(), + cbWaterfalls.isSelected() ); } @@ -1122,8 +1164,22 @@ public class WorldMapView extends VBox { // ── Vollbild ────────────────────────────────────────────────────────────── + /** Passt Scale+Pan an die aktuelle Canvas-Größe an (zentriert, ganz sichtbar). */ + public void fitCanvas() { + double cw = canvasPane.getWidth(); + double ch = canvasPane.getHeight(); + if (cw <= 0 || ch <= 0 || mapFxImage == null) return; + scale = Math.min(cw / RENDER_SIZE, ch / RENDER_SIZE); + panX = (cw - RENDER_SIZE * scale) / 2.0; + panY = (ch - RENDER_SIZE * scale) / 2.0; + redraw(); + } + private void enterFullscreen() { isFullscreen = true; + savedScale = scale; + savedPanX = panX; + savedPanY = panY; fullscreenBtn.setVisible(false); backBtn.setVisible(true); if (enterFullscreenCallback != null) { @@ -1133,6 +1189,9 @@ public class WorldMapView extends VBox { private void exitFullscreen() { isFullscreen = false; + scale = savedScale; + panX = savedPanX; + panY = savedPanY; backBtn.setVisible(false); fullscreenBtn.setVisible(true); if (exitFullscreenCallback != null) { diff --git a/blight-editor/src/main/resources/logback.xml b/blight-editor/src/main/resources/logback.xml index a6fe9f4..032334f 100644 --- a/blight-editor/src/main/resources/logback.xml +++ b/blight-editor/src/main/resources/logback.xml @@ -19,6 +19,8 @@ + + diff --git a/blight-map/src/main/map/chunks/voxel_17_0_08.blvc b/blight-map/src/main/map/chunks/voxel_17_0_08.blvc new file mode 100644 index 0000000..bc33783 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_17_0_08.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod0.j3o index 8ba2efc..aa60953 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod1.j3o index 73bcc35..e08103e 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_17_0_08_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc b/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc new file mode 100644 index 0000000..b044a23 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc.prebake b/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc.prebake index 97de0ff..e00368e 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc.prebake and b/blight-map/src/main/map/chunks/voxel_17_m1_08.blvc.prebake differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod0.j3o index 01698d1..fabb26d 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod0.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod1.j3o index b0746c2..ec98daa 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod1.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod2.j3o index 021caff..4be7d9c 100644 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod2.j3o and b/blight-map/src/main/map/chunks/voxel_17_m1_08_baked_lod2.j3o differ diff --git a/doc/Lore.odt b/doc/Lore.odt new file mode 100644 index 0000000..bcc83d8 Binary files /dev/null and b/doc/Lore.odt differ