From ddec931ee81f978e5beaa9f03a3f827e1b8a9db9 Mon Sep 17 00:00:00 2001 From: Mario Date: Thu, 3 Sep 2026 16:32:18 +0200 Subject: [PATCH] Weltkarte: schematische 5-Layer-Optik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1: Flachfarben aus Splatmap (Coarse 512px filtert dünne Texturen <4m heraus, kein Hillshading) Layer 2: Wasser-Füllung (PNG) + Küstenlinie (Canvas-Vektor), eine Checkbox Layer 3: Wald – 50% alpha grün + dunkelgrüne Randlinie (vorgebacken) Layer 4: Klippen – graue Füllung + Kammlinie + Fußlinie (vorgebacken, buildCliffOverlay) Layer 5: Areas – gestrichelte Vektorpolygone (Canvas) Entfernt: Wellenmarken, Wasserfall-Symbole, Zonen, Modell-Punkte, Orte-Labels, Baum-Symbole Co-Authored-By: Claude Sonnet 4.6 --- .../blight/common/map/WorldMapRenderer.java | 421 ++++++------------ .../de/blight/editor/ui/WorldMapView.java | 232 +++------- 2 files changed, 198 insertions(+), 455 deletions(-) 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 b462be8..a8ec90c 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 @@ -289,195 +289,6 @@ public final class WorldMapRenderer { img.setRGB(i % targetSize, i / targetSize, rawRGB[i]); } - // ── 3. Vektor-Overlays ──────────────────────────────────────────────── - Graphics2D gfx = img.createGraphics(); - gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - float lineW = Math.max(1.5f, targetSize / 400f); - - // Wasser - if (opts.showWater()) { - - // ── Wellenmarken ────────────────────────────────────────────────────── - if (opts.showWaterWaves()) { - float wmW = Math.max(20f, targetSize / 70f); - float wmA = wmW * 0.2f, spX = wmW * 1.8f, spY = wmW * 1.05f; - gfx.setColor(Color.BLACK); - gfx.setStroke(new BasicStroke(Math.max(0.7f, lineW * 0.4f), - BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); - // Meer: versetzt + gejittert - int seaRow = 0; - for (float wy = spY * 0.5f; wy < targetSize; wy += spY, seaRow++) { - float rowOff = (seaRow & 1) == 1 ? spX * 0.5f : 0f; - int seaCol = 0; - for (float wx = rowOff; wx + wmW <= targetSize; wx += spX, seaCol++) { - float jx = (float)(Math.abs(Math.sin(seaRow * 73.1 + seaCol * 157.3)) * spX * 0.25); - float jy = (float)(Math.sin(seaRow * 211.7 + seaCol * 89.5) * spY * 0.2); - float ox = wx + jx, oy = wy + jy; - int cx = (int)(ox + wmW * 0.5f), cy = (int)oy; - if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize - && ox >= 0 && ox + wmW < targetSize - && 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); - gfx.draw(mark); - } - } - } - // Wasserflächen: nur wenn groß genug - 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); - Rectangle bb = poly.getBounds(); - if (bb.width <= wmW * 1.5f || bb.height <= spY) continue; - gfx.setClip(poly); - int wRow = 0; - for (float wy = bb.y + spY * 0.5f; wy < bb.y + bb.height; wy += spY, wRow++) { - float rowOff = (wRow & 1) == 1 ? spX * 0.5f : 0f; - int wCol = 0; - for (float wx = bb.x + rowOff; wx + wmW <= bb.x + bb.width; wx += spX, wCol++) { - float jx = (float)(Math.abs(Math.sin(wRow * 73.1 + wCol * 157.3)) * spX * 0.25); - float jy = (float)(Math.sin(wRow * 211.7 + wCol * 89.5) * spY * 0.2); - float ox = wx + jx, oy = wy + jy; - if (ox < bb.x || ox + wmW > bb.x + bb.width) continue; - 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); - gfx.draw(mark); - } - } - gfx.setClip(null); - } - } - - } - - // 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; - BasicStroke dashed = new BasicStroke(lineW * 1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, - 10f, new float[]{dash, gap}, 0f); - int aFontSize = Math.max(8, targetSize / 160); - gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize)); - for (PlacedArea a : model.areas()) { - int[] xs = worldToPixels(a.pointsX(), targetSize); - int[] ys = worldToPixels(a.pointsZ(), targetSize); - gfx.setStroke(dashed); - gfx.setColor(Color.BLACK); - gfx.draw(toPath(xs, ys)); - String lbl = friendlyAreaName(a.areaId()); - if (lbl != null) { - float[] cen = centroid(a.pointsX(), a.pointsZ()); - float lx = Float.isNaN(a.labelX()) ? cen[0] : a.labelX(); - float lz = Float.isNaN(a.labelZ()) ? cen[1] : a.labelZ(); - int plx = worldToPixel(lx, targetSize); - int plz = worldToPixel(lz, targetSize); - drawLabel(gfx, lbl, plx, plz); - } - } - } - - // Location-Zonen - if (opts.showZones()) { - gfx.setStroke(new BasicStroke(lineW)); - 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)); - gfx.fillPolygon(xs, ys, xs.length); - gfx.setColor(new Color(200, 150, 20)); - gfx.drawPolygon(xs, ys, xs.length); - } - } - - if (opts.showModels()) { - int dotR = Math.max(1, targetSize / 600); - gfx.setColor(new Color(160, 80, 20, 200)); - 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); - } - } - - // Locations (nur Label) - if (opts.showLocations()) { - int fontSize = Math.max(8, targetSize / 140); - gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize)); - 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(); - float wz = Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ(); - int lx = worldToPixel(wx, targetSize); - int lz = worldToPixel(wz, targetSize); - drawLabel(gfx, friendlyLocationName(loc.getId()), lx, lz); - } - } - - gfx.dispose(); return img; } @@ -1248,62 +1059,131 @@ public final class WorldMapRenderer { return (r<<16)|(g<<8)|b; } + /** Schematische Flachfarbe pro Tile-Typ (kein Hillshading, keine Klippen-Blend). */ + private static int schematicColor(int tileType) { + return switch (tileType) { + case TILE_GRASS -> (125 << 16) | (180 << 8) | 72; + case TILE_DIRT -> (182 << 16) | (138 << 8) | 80; + case TILE_SAND -> (212 << 16) | (198 << 8) | 128; + case TILE_ROCK -> (162 << 16) | (155 << 8) | 148; + case TILE_PAVEMENT -> (155 << 16) | (145 << 8) | 128; + default -> (125 << 16) | (180 << 8) | 72; + }; + } + /** - * Baut die Terrain-Farbkarte vor: für jeden Pixel den dominanten Splatmap-Slot - * ermitteln, Durchschnittsfarbe der Kachel lesen, Hillshading anwenden. - * Das Ergebnis wird im WorldMapRenderModel gespeichert und von render() direkt - * verwendet — kein Splatmap-Blend zur Renderzeit mehr. + * Baut die schematische Terrain-Farbkarte: Tile-Typ bei 512×512 (4m/px) bestimmen, + * dann per Nearest-Neighbor auf COLOR_MAP_SIZE hochskalieren und Flachfarbe zuweisen. + * Dünne Texturen (<4m) werden durch das Coarse-Sampling automatisch gefiltert. */ public static int[] buildTerrainColorMap(MapData m, int[] slotColors, int[][] tilePixels, float[] voxelSurface, List waters, List waterfalls) { - final int SIZE = COLOR_MAP_SIZE; - final int SS = MapData.SPLAT_SIZE; - float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); - float[] waterH = buildWaterHeightMap(waters, SIZE); - boolean[] wfMask = buildWaterfallMask(waterfalls, SIZE); - int[] tileAvg = computeTileAvgColors(tilePixels); - int[] slotTypes = computeSlotTileTypes(m, slotColors); - float cellW = WORLD_SIZE / (SIZE - 1); - int[] terrainRGB = new int[SIZE * SIZE]; - float[] cliffMask = new float[SIZE * SIZE]; + final int SIZE = COLOR_MAP_SIZE; + final int COARSE = 512; + final int SS = MapData.SPLAT_SIZE; + + float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); + float[] waterH = buildWaterHeightMap(waters, SIZE); + float cellWC = WORLD_SIZE / (COARSE - 1); + + // Coarse-Typ-Map bei 512×512 (4m/px) – filtert dünne Texturen heraus + byte[] coarseTypes = new byte[COARSE * COARSE]; + for (int mz = 0; mz < COARSE; mz++) { + float wz = -WORLD_HALF + mz * cellWC; + for (int mx = 0; mx < COARSE; mx++) { + float wx = -WORLD_HALF + mx * cellWC; + coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors); + } + } + + // Hochskalieren (NN) auf SIZE×SIZE und Flachfarbe zuweisen + int[] result = new int[SIZE * SIZE]; for (int py = 0; py < SIZE; py++) { - float wz = -WORLD_HALF + py * cellW; for (int px = 0; px < SIZE; px++) { - float wx = -WORLD_HALF + px * cellW; - int idx = py * SIZE + px; - float h = heights[idx]; - if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h) || wfMask[idx]) { + int idx = py * SIZE + px; + float h = heights[idx]; + if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h)) { int[] wc = TILE_FALLBACK_RGB[TILE_WATER]; - terrainRGB[idx] = (wc[0]<<16)|(wc[1]<<8)|wc[2]; + result[idx] = (wc[0] << 16) | (wc[1] << 8) | wc[2]; } else { - int dPx = Math.max(1, (int)(1.5f / cellW)); - float hR = heights[py * SIZE + Math.min(SIZE-1, px+dPx)]; - float hL = heights[py * SIZE + Math.max(0, px-dPx)]; - float hD = heights[Math.min(SIZE-1, py+dPx) * SIZE + px]; - float hU = heights[Math.max(0, py-dPx) * SIZE + px]; - float dist = dPx * cellW; - double slope = Math.toDegrees(Math.atan(Math.sqrt( - Math.pow((hR-hL)/(2*dist),2) + Math.pow((hD-hU)/(2*dist),2)))); - cliffMask[idx] = (slope > 30.0) ? 1f : 0f; - terrainRGB[idx] = blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes); + int cx = Math.min(COARSE - 1, (int)((float) px / (SIZE - 1) * (COARSE - 1))); + int cz = Math.min(COARSE - 1, (int)((float) py / (SIZE - 1) * (COARSE - 1))); + result[idx] = schematicColor(coarseTypes[cz * COARSE + cx] & 0xFF); } } } - // Cliff-Maske weichzeichnen → weiche Klippen-Kanten ohne Änderung der Schwellwert-Logik - float[] softMask = boxBlurFloat(cliffMask, SIZE, 3); - int cliffRgb = tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)]; - int cr = (cliffRgb>>16)&0xFF, cg = (cliffRgb>>8)&0xFF, cb = cliffRgb&0xFF; - int[] result = new int[SIZE * SIZE]; - for (int i = 0; i < SIZE * SIZE; i++) { - float t = softMask[i]; - int tc = terrainRGB[i]; - int r = (tc>>16)&0xFF, g = (tc>>8)&0xFF, b = tc&0xFF; - result[i] = (clamp((int)(r + (cr-r)*t)) << 16) - | (clamp((int)(g + (cg-g)*t)) << 8) - | clamp((int)(b + (cb-b)*t)); + return result; + } + + /** + * Berechnet das schematische Cliff-Overlay (ARGB, COLOR_MAP_SIZE×COLOR_MAP_SIZE): + * graue Füllung wo Steigung > 30°, dunklere Linie an der oberen Kante (Kamm), + * mittlere Linie an der unteren Kante (Fuß). + */ + public static int[] buildCliffOverlay(MapData m, float[] voxelSurface) { + final int SIZE = COLOR_MAP_SIZE; + float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); + float cellW = WORLD_SIZE / (SIZE - 1); + int dPx = Math.max(1, (int)(1.5f / cellW)); + + boolean[] cliffMask = new boolean[SIZE * SIZE]; + float[] gradX = new float[SIZE * SIZE]; + float[] gradZ = new float[SIZE * SIZE]; + for (int py = 0; py < SIZE; py++) { + for (int px = 0; px < SIZE; px++) { + int idx = py * SIZE + px; + float hR = heights[py * SIZE + Math.min(SIZE - 1, px + dPx)]; + float hL = heights[py * SIZE + Math.max(0, px - dPx)]; + float hD = heights[Math.min(SIZE - 1, py + dPx) * SIZE + px]; + float hU = heights[Math.max(0, py - dPx) * SIZE + px]; + float dist = dPx * cellW; + double slope = Math.toDegrees(Math.atan(Math.sqrt( + Math.pow((hR - hL) / (2 * dist), 2) + Math.pow((hD - hU) / (2 * dist), 2)))); + cliffMask[idx] = slope > 30.0; + gradX[idx] = (hR - hL) / (2 * dist); + gradZ[idx] = (hD - hU) / (2 * dist); + } } - return boxBlur(result, SIZE, 2); + + float[] floatMask = new float[SIZE * SIZE]; + for (int i = 0; i < SIZE * SIZE; i++) { floatMask[i] = cliffMask[i] ? 1f : 0f; } + float[] softMask = boxBlurFloat(floatMask, SIZE, 3); + + int[] result = new int[SIZE * SIZE]; + // Füllung: weiches Grau, max 33% alpha + for (int i = 0; i < SIZE * SIZE; i++) { + if (softMask[i] > 0.05f) { + int alpha = clamp((int)(softMask[i] * 85)); + result[i] = (alpha << 24) | 0x999090; + } + } + // Kantenerkennung: obere Kante (Kamm) dunkler, untere Kante (Fuß) heller + for (int py = 1; py < SIZE - 1; py++) { + for (int px = 1; px < SIZE - 1; px++) { + int idx = py * SIZE + px; + if (!cliffMask[idx]) { continue; } + float gx = gradX[idx], gz = gradZ[idx]; + float glen = (float) Math.sqrt(gx * gx + gz * gz); + if (glen < 1e-6f) { continue; } + float upX = -gx / glen, upZ = -gz / glen; // bergauf + float dnX = gx / glen, dnZ = gz / glen; // bergab + // Obere Kante: bergauf-Nachbar ist kein Cliff + int uhx = px + Math.round(upX * 2), uhz = py + Math.round(upZ * 2); + if (uhx >= 0 && uhx < SIZE && uhz >= 0 && uhz < SIZE + && !cliffMask[uhz * SIZE + uhx]) { + result[idx] = (0xCC << 24) | 0x504848; + continue; + } + // Untere Kante: bergab-Nachbar ist kein Cliff + int dhx = px + Math.round(dnX), dhz = py + Math.round(dnZ); + if (dhx >= 0 && dhx < SIZE && dhz >= 0 && dhz < SIZE + && !cliffMask[dhz * SIZE + dhx]) { + result[idx] = (0x88 << 24) | 0x686060; + } + } + } + return result; } /** @@ -1335,75 +1215,46 @@ public final class WorldMapRenderer { if (allExist) { log.debug("[HiresChunks] Alle {} Chunks im Cache – übersprungen.", COLS*COLS); return 0; } } - float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); - float[] waterH = buildWaterHeightMap(waters, SIZE); - boolean[] wfMask = buildWaterfallMask(waterfalls, SIZE); - int[] tileAvg = computeTileAvgColors(tilePixels); - int[] slotTypes = computeSlotTileTypes(m, slotColors); - float cellW = WORLD_SIZE / (SIZE - 1); + float[] heights = buildTerrainSamples(m, SIZE, voxelSurface); + float[] waterH = buildWaterHeightMap(waters, SIZE); int written = 0; - final int CLIFF_BLUR_R = 4; // Blur-Radius für weiche Klippen-Kanten - final int EXT = CLIFF_BLUR_R; - final int EXTW = CS + 2 * EXT; // erweitertes Fenster inkl. Rand für nahtlosen Blur - int dPxHires = Math.max(1, (int)(1.5f / cellW)); + // Coarse-Typ-Map (4m/px) – identische Filterung wie buildTerrainColorMap + final int COARSE = 512; + float cellWC = WORLD_SIZE / (COARSE - 1); + byte[] coarseTypes = new byte[COARSE * COARSE]; + for (int mz = 0; mz < COARSE; mz++) { + float wz = -WORLD_HALF + mz * cellWC; + for (int mx = 0; mx < COARSE; mx++) { + float wx = -WORLD_HALF + mx * cellWC; + coarseTypes[mz * COARSE + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors); + } + } for (int cy = 0; cy < COLS; cy++) { for (int cx = 0; cx < COLS; cx++) { java.nio.file.Path p = chunkPath(cacheDir, cx, cy); if (!forceRegen && Files.exists(p)) continue; - // Erweiterte Cliff-Maske + Terrain-Farben (mit Rand über Chunk-Grenze) - float[] cliffMaskExt = new float[EXTW * EXTW]; - boolean[] isWaterExt = new boolean[EXTW * EXTW]; - int[] terrainColExt = new int[EXTW * EXTW]; - for (int ely = 0; ely < EXTW; ely++) { - int gy = cy * CS + ely - EXT; - int gyC = Math.max(0, Math.min(SIZE-1, gy)); - float wz = -WORLD_HALF + gyC * cellW; - for (int elx = 0; elx < EXTW; elx++) { - int gx = cx * CS + elx - EXT; - int gxC = Math.max(0, Math.min(SIZE-1, gx)); - float wx = -WORLD_HALF + gxC * cellW; - int eidx = ely * EXTW + elx; - int hidx = gyC * SIZE + gxC; - float h = heights[hidx]; - if (h < 0f || (waterH[hidx] > 0f && waterH[hidx] > h) || wfMask[hidx]) { - isWaterExt[eidx] = true; - } else { - float hR = heights[gyC * SIZE + Math.min(SIZE-1, gxC+dPxHires)]; - float hL = heights[gyC * SIZE + Math.max(0, gxC-dPxHires)]; - float hD = heights[Math.min(SIZE-1, gyC+dPxHires) * SIZE + gxC]; - float hU = heights[Math.max(0, gyC-dPxHires) * SIZE + gxC]; - float dist = dPxHires * cellW; - double slope = Math.toDegrees(Math.atan(Math.sqrt( - Math.pow((hR-hL)/(2*dist),2) + Math.pow((hD-hU)/(2*dist),2)))); - cliffMaskExt[eidx] = (slope > 30.0) ? 1f : 0f; - terrainColExt[eidx] = blendTileAvgColors(m, SS, wx, wz, tileAvg, slotTypes); - } - } - } - float[] softMaskExt = boxBlurFloat(cliffMaskExt, EXTW, CLIFF_BLUR_R); - int cliffRgb = tileAvg[Math.min(TILE_CLIFF, tileAvg.length-1)]; - int cr = (cliffRgb>>16)&0xFF, cg = (cliffRgb>>8)&0xFF, cb = cliffRgb&0xFF; - - // Chunk-Pixel aus dem inneren Bereich der erweiterten Maske zusammensetzen int[] chunk = new int[CS * CS]; for (int ly = 0; ly < CS; ly++) { + int gy = cy * CS + ly; for (int lx = 0; lx < CS; lx++) { - int extIdx = (ly + EXT) * EXTW + (lx + EXT); + int gx = cx * CS + lx; + int hidx = gy * SIZE + gx; int tr, tg, tb; - if (isWaterExt[extIdx]) { + float h = heights[hidx]; + if (h < 0f || (waterH[hidx] > 0f && waterH[hidx] > h)) { tr = (WATER_COLOR >> 16) & 0xFF; tg = (WATER_COLOR >> 8) & 0xFF; tb = WATER_COLOR & 0xFF; } else { - float t = softMaskExt[extIdx]; - int tc = terrainColExt[extIdx]; - int r2 = (tc>>16)&0xFF, g2 = (tc>>8)&0xFF, b2 = tc&0xFF; - tr = clamp((int)(r2 + (cr-r2)*t)); - tg = clamp((int)(g2 + (cg-g2)*t)); - tb = clamp((int)(b2 + (cb-b2)*t)); + int ctX = Math.min(COARSE - 1, (int)((float) gx / (SIZE - 1) * (COARSE - 1))); + int ctZ = Math.min(COARSE - 1, (int)((float) gy / (SIZE - 1) * (COARSE - 1))); + int col = schematicColor(coarseTypes[ctZ * COARSE + ctX] & 0xFF); + tr = (col >> 16) & 0xFF; + tg = (col >> 8) & 0xFF; + tb = col & 0xFF; } chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb; } 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 5f50fb4..0f05662 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 @@ -38,9 +38,6 @@ 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; @@ -75,7 +72,6 @@ public class WorldMapView extends VBox { private static final double WAVE_W = 20.0; // konstante Wellengröße in Bildschirmpixeln private static final int SEA_MASK_SIZE = 1024; private static final Font AREA_FONT = Font.font("SansSerif", FontWeight.BOLD, 11); - private static final Font LOC_FONT = Font.font("SansSerif", FontWeight.BOLD, 12); private final Canvas canvas = new Canvas(100, 100); private final Label statusLbl = new Label("Karte noch nicht geladen"); @@ -83,14 +79,11 @@ 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 cbCoastline = layerCheck("Küstenlinie"); - private final CheckBox cbWaterfalls = layerCheck("Wasserfälle"); + private final CheckBox cbTerrain = layerCheck("Basis"); + private final CheckBox cbWater = layerCheck("Wasser/Küste"); + private final CheckBox cbForest = layerCheck("Wald"); + private final CheckBox cbCliffs = layerCheck("Klippen"); 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(">>"); @@ -103,11 +96,7 @@ public class WorldMapView extends VBox { private WritableImage mapFxImage; private BufferedImage mapBuffered; - // Canvas-level Tile-Overlay: zoom-unabhängige Kacheldarstellung - private static final int CAT_SIZE = 2048; - private byte[] categoryMap = null; - private float[] heightMap = null; // terrain+voxel, CAT_SIZE×CAT_SIZE (für Klippen) - private float[] heightMapTerrain = null; // terrain-only, CAT_SIZE×CAT_SIZE (für Wasser-Füllung) + private WritableImage bakedCliffOverlay = null; private static final int ZOOM_LEVELS = 5; private static final double ZOOM_MAX_SCALE = 4.0; // 1 Canvas-Pixel = 0,25 WU = 0,25m private static final int ZOOM_HIRES_CUTOFF = 3; // ab diesem Index Chunk-Rendering @@ -130,9 +119,6 @@ public class WorldMapView extends VBox { private volatile boolean chunkRedrawPending = false; private WritableImage bakedForestOverlay = null; - // Wasser-Fill-Overlay entfernt: ocean + PlacedWater sind bereits in mapFxImage korrekt gerendert - // PlacedWater-Wasserhoehe pro Zelle (0 = kein PlacedWater); fuer glatte Uebergaenge - private float[] waterHeightMap = null; private List cachedAreas = new ArrayList<>(); private List cachedLocs = new ArrayList<>(); @@ -205,15 +191,12 @@ 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()); + cbForest.selectedProperty().addListener((obs, o, n) -> redraw()); + cbCliffs.selectedProperty().addListener((obs, o, n) -> redraw()); cbAreas.selectedProperty().addListener((obs, o, n) -> redraw()); - cbLocations.selectedProperty().addListener((obs, o, n) -> redraw()); - cbCoastline.selectedProperty().addListener((obs, o, n) -> redraw()); MenuButton layerMenu = new MenuButton("Layer ▾"); - for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbCoastline, cbWaterfalls, cbAreas, cbZones, cbLocations, cbModels}) { + for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbForest, cbCliffs, cbAreas}) { CustomMenuItem item = new CustomMenuItem(cb, false); item.setHideOnClick(false); layerMenu.getItems().add(item); @@ -369,18 +352,12 @@ public class WorldMapView extends VBox { Platform.runLater(() -> statusLbl.setText("Rendere Karte…")); long t1 = System.currentTimeMillis(); BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions()); - float[] hMap = WorldMapRenderer.buildTerrainSamples( - model.mapData(), CAT_SIZE, model.voxelSurface()); - float[] hMapT = WorldMapRenderer.buildTerrainSamples( - model.mapData(), CAT_SIZE, null); - byte[] catMap = WorldMapRenderer.buildCategoryMap( - model.mapData(), CAT_SIZE, model.voxelSurface(), model.waters(), model.slotColorsRGB()); - float[] whMap = WorldMapRenderer.buildWaterHeightMap(model.waters(), CAT_SIZE); - log.debug("[WorldMap] Hintergrund-PNG + Kategorie-Karte gerendert ({}×{} px, {} ms)", + log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)", RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1); float[] fDensity = computeForestDensity(model); int[] bakedFstPx = computeForestPixels(model, fDensity, 2.0f); + int[] cliffPx = WorldMapRenderer.buildCliffOverlay(model.mapData(), model.voxelSurface()); // Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend) final WorldMapRenderModel chunkModel = model; @@ -397,16 +374,12 @@ public class WorldMapView extends VBox { cachedLocs = new ArrayList<>(model.locations()); mapBuffered = bi; mapFxImage = SwingFXUtils.toFXImage(bi, null); - heightMap = hMap; - heightMapTerrain = hMapT; - categoryMap = catMap; - waterHeightMap = whMap; bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE); + bakedCliffOverlay = toWritableImage(cliffPx, WorldMapRenderer.COLOR_MAP_SIZE); fitToView(); redraw(); progress.setVisible(false); - statusLbl.setText("Bereit – " + model.areas().size() + " Areas, " + - model.locations().size() + " Orte, " + model.waters().size() + " Wasser"); + statusLbl.setText("Bereit – " + model.areas().size() + " Areas, " + model.waters().size() + " Wasser"); loading.set(false); log.debug("[WorldMap] Gesamt-Ladezeit: {} ms", System.currentTimeMillis() - t0); }); @@ -436,8 +409,7 @@ public class WorldMapView extends VBox { Thread t = new Thread(() -> { log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE); long t0 = System.currentTimeMillis(); - // Nur das PNG neu rendern — heightMap/catMap/waterHeightMap kommen aus Stage 1 - // und sind bereits in den Instanz-Feldern vorhanden + // Nur das PNG neu rendern — Overlays (Forest, Cliff) bleiben aus dem letzten Modell-Build BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions()); log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0); Platform.runLater(() -> { @@ -478,13 +450,15 @@ public class WorldMapView extends VBox { } else { gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); } - // Layer 1: Wasser (glatte Küste via seaCoastPaths) - if (cbWater.isSelected()) drawWaterOverlay(gc); - // Layer 2: Wald-Overlay (nach Wasser, damit Bäume nicht im Wasser erscheinen) - drawForestOverlay(gc); - if (cbAreas.isSelected()) drawAreasOverlay(gc); - if (cbLocations.isSelected()) drawLocationsOverlay(gc); - if (!isFullscreen) drawCameraIndicator(gc); + // Layer 2: Küstenlinie (Vektor) + if (cbWater.isSelected()) drawWaterOverlay(gc); + // Layer 3: Wald (Füllung + Rand) + if (cbForest.isSelected()) drawForestOverlay(gc); + // Layer 4: Klippen (Grau-Füllung + Kantenlinien) + if (cbCliffs.isSelected()) drawCliffOverlay(gc); + // Layer 5: Areas (Vektoren) + if (cbAreas.isSelected()) drawAreasOverlay(gc); + if (!isFullscreen) drawCameraIndicator(gc); } private void drawHiresChunks(GraphicsContext gc) { @@ -583,6 +557,12 @@ public class WorldMapView extends VBox { gc.drawImage(bakedForestOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); } + private void drawCliffOverlay(GraphicsContext gc) { + if (bakedCliffOverlay == null) return; + int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight(); + gc.drawImage(bakedCliffOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); + } + // ── Wald-Baking (läuft im Background-Thread) ───────────────────────────── private static float[] computeForestDensity(WorldMapRenderModel model) { @@ -610,13 +590,10 @@ public class WorldMapView extends VBox { private static int[] computeForestPixels(WorldMapRenderModel model, float[] density, float threshold) { final int SIZE = RENDER_SIZE, FD = 512; final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE; - final int[][] tilePx = model.mapTilePixels(); - final int forestArgb = (tilePx != null && WorldMapRenderer.TILE_FOREST < tilePx.length - && tilePx[WorldMapRenderer.TILE_FOREST] != null) - ? tileSample(tilePx, WorldMapRenderer.TILE_FOREST, 0, 0, 0, 0, 1f, 0f, 0f, 0f) - : 0x3D7A28; - final int forestColor = 0x80000000 | (forestArgb & 0x00FFFFFF); + final int forestFill = 0x80_4a9a28; // 50% alpha grün + final int forestOutline = 0xCC_2d6316; // 80% alpha dunkelgrün final int[] px = new int[SIZE * SIZE]; + boolean[] isFill = new boolean[SIZE * SIZE]; for (int py = 0; py < SIZE; py++) { float wz = (float) py / (SIZE - 1) * WS - WH; for (int bx = 0; bx < SIZE; bx++) { @@ -628,7 +605,22 @@ public class WorldMapView extends VBox { float fstx=fsfx-fsx0, fstz=fsfz-fsz0; float fd=(density[fsz0*FD+fsx0]*(1-fstx)+density[fsz0*FD+fsx1]*fstx)*(1-fstz) +(density[fsz1*FD+fsx0]*(1-fstx)+density[fsz1*FD+fsx1]*fstx)*fstz; - px[py*SIZE+bx] = (fd > threshold) ? forestColor : 0x00000000; + if (fd > threshold) { + px[py * SIZE + bx] = forestFill; + isFill[py * SIZE + bx] = true; + } + } + } + // Randlinie: Füll-Pixel mit mindestens einem Nicht-Füll-Nachbarn + for (int py = 1; py < SIZE - 1; py++) { + for (int bx = 1; bx < SIZE - 1; bx++) { + int idx = py * SIZE + bx; + if (!isFill[idx]) { continue; } + boolean edge = !isFill[idx - 1] || !isFill[idx + 1] + || !isFill[idx - SIZE] || !isFill[idx + SIZE] + || !isFill[idx - SIZE - 1] || !isFill[idx - SIZE + 1] + || !isFill[idx + SIZE - 1] || !isFill[idx + SIZE + 1]; + if (edge) { px[idx] = forestOutline; } } } return px; @@ -698,42 +690,22 @@ public class WorldMapView extends VBox { gc.restore(); } - private void drawLocationsOverlay(GraphicsContext gc) { - if (cachedLocs.isEmpty()) return; - gc.save(); - gc.setFont(LOC_FONT); - - for (Location loc : cachedLocs) { - if (!loc.isShowOnMap()) continue; - if (loc.getId() == null || loc.getId().isEmpty()) continue; - double lx = toCanvasX(Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX()); - double lz = toCanvasZ(Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ()); - drawCanvasLabel(gc, locLabel(loc.getId()), lx, lz); - } - gc.restore(); - } - private void drawWaterOverlay(GraphicsContext gc) { if (currentModel == null) return; - // Wasser-Füllung kommt aus mapFxImage (bereits korrekt gerendert). - // Hier nur noch Küstenlinie und Wellen-Vektoren. - - // ── Küstenlinie ─────────────────────────────────────────────────────────── List seaCoastPaths = currentModel.coastPaths(); - if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) { - double canvasW = canvas.getWidth(), canvasH = canvas.getHeight(); - gc.setLineDashes(null); - gc.setLineCap(StrokeLineCap.ROUND); - gc.setLineJoin(StrokeLineJoin.ROUND); + if (seaCoastPaths == null || seaCoastPaths.isEmpty()) { return; } + double canvasW = canvas.getWidth(), canvasH = canvas.getHeight(); + gc.setLineDashes(null); + gc.setLineCap(StrokeLineCap.ROUND); + gc.setLineJoin(StrokeLineJoin.ROUND); - gc.setStroke(Color.color(0, 0, 0, 0.22)); - gc.setLineWidth(7.0); - for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 8); } + gc.setStroke(Color.color(0, 0, 0, 0.22)); + gc.setLineWidth(7.0); + for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 8); } - gc.setStroke(Color.BLACK); - gc.setLineWidth(3.0); - for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 4); } - } + gc.setStroke(Color.BLACK); + gc.setLineWidth(3.0); + for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 4); } } private void drawCanvasLabel(GraphicsContext gc, String text, double x, double y) { @@ -770,19 +742,6 @@ public class WorldMapView extends VBox { float wx = toWorldX(canvasX); float wz = toWorldZ(canvasY); - // Locations: Treffer innerhalb 2× Radius - for (Location loc : cachedLocs) { - float dx = wx - loc.getCenterX(), dz = wz - loc.getCenterZ(), r = loc.getRadius(); - if (dx * dx + dz * dz <= r * r * 4) { - loc.setLabelX(wx); - loc.setLabelZ(wz); - try { LocationIO.save(cachedLocs); } catch (IOException e) { statusLbl.setText("Fehler: " + e.getMessage()); return; } - redraw(); - statusLbl.setText(String.format("Label %s → (%.0f, %.0f)", loc.getId(), wx, wz)); - return; - } - } - // Areas: kleinste enthaltende Area PlacedArea best = null; int bestIdx = -1; @@ -803,48 +762,6 @@ public class WorldMapView extends VBox { } } - // 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> 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 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 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> clusterTreeModels(List trees, float radius) { int n = trees.size(); int[] parent = new int[n]; @@ -1201,11 +1118,6 @@ public class WorldMapView extends VBox { return s.isBlank() ? null : s; } - private static String locLabel(String id) { - String s = id.replace("location.", "").replace(".name", ""); - return s.isEmpty() ? id : s; - } - private void exportPng() { FileChooser fc = new FileChooser(); fc.setTitle("Karte als PNG exportieren"); @@ -1245,34 +1157,14 @@ public class WorldMapView extends VBox { t.start(); } - /** Hintergrund-PNG: ohne Areas und Locations (die werden live auf Canvas gezeichnet). */ private RenderOptions buildBackgroundOptions() { - return new RenderOptions( - cbTerrain.isSelected(), - cbTerrain.isSelected(), - cbWater.isSelected(), - false, // Wellen im Editor über Canvas gezeichnet - false, - cbZones.isSelected(), - false, - cbModels.isSelected(), - cbWaterfalls.isSelected() - ); + return new RenderOptions(cbTerrain.isSelected(), cbTerrain.isSelected(), + cbWater.isSelected(), false, false, false, false, false, false); } - /** Export-PNG: alle Layer gebacken. */ private RenderOptions buildExportOptions() { - return new RenderOptions( - cbTerrain.isSelected(), - cbTerrain.isSelected(), - cbWater.isSelected(), - false, - cbAreas.isSelected(), - cbZones.isSelected(), - cbLocations.isSelected(), - cbModels.isSelected(), - cbWaterfalls.isSelected() - ); + return new RenderOptions(cbTerrain.isSelected(), cbTerrain.isSelected(), + cbWater.isSelected(), false, cbAreas.isSelected(), false, false, false, false); } private static CheckBox layerCheck(String label) {