Weltkarte: schematische 5-Layer-Optik
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 <noreply@anthropic.com>
This commit is contained in:
@@ -289,195 +289,6 @@ public final class WorldMapRenderer {
|
|||||||
img.setRGB(i % targetSize, i / targetSize, rawRGB[i]);
|
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;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,62 +1059,131 @@ public final class WorldMapRenderer {
|
|||||||
return (r<<16)|(g<<8)|b;
|
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
|
* Baut die schematische Terrain-Farbkarte: Tile-Typ bei 512×512 (4m/px) bestimmen,
|
||||||
* ermitteln, Durchschnittsfarbe der Kachel lesen, Hillshading anwenden.
|
* dann per Nearest-Neighbor auf COLOR_MAP_SIZE hochskalieren und Flachfarbe zuweisen.
|
||||||
* Das Ergebnis wird im WorldMapRenderModel gespeichert und von render() direkt
|
* Dünne Texturen (<4m) werden durch das Coarse-Sampling automatisch gefiltert.
|
||||||
* verwendet — kein Splatmap-Blend zur Renderzeit mehr.
|
|
||||||
*/
|
*/
|
||||||
public static int[] buildTerrainColorMap(MapData m, int[] slotColors, int[][] tilePixels,
|
public static int[] buildTerrainColorMap(MapData m, int[] slotColors, int[][] tilePixels,
|
||||||
float[] voxelSurface, List<PlacedWater> waters,
|
float[] voxelSurface, List<PlacedWater> waters,
|
||||||
List<PlacedWaterfall> waterfalls) {
|
List<PlacedWaterfall> waterfalls) {
|
||||||
final int SIZE = COLOR_MAP_SIZE;
|
final int SIZE = COLOR_MAP_SIZE;
|
||||||
final int SS = MapData.SPLAT_SIZE;
|
final int COARSE = 512;
|
||||||
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
|
final int SS = MapData.SPLAT_SIZE;
|
||||||
float[] waterH = buildWaterHeightMap(waters, SIZE);
|
|
||||||
boolean[] wfMask = buildWaterfallMask(waterfalls, SIZE);
|
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
|
||||||
int[] tileAvg = computeTileAvgColors(tilePixels);
|
float[] waterH = buildWaterHeightMap(waters, SIZE);
|
||||||
int[] slotTypes = computeSlotTileTypes(m, slotColors);
|
float cellWC = WORLD_SIZE / (COARSE - 1);
|
||||||
float cellW = WORLD_SIZE / (SIZE - 1);
|
|
||||||
int[] terrainRGB = new int[SIZE * SIZE];
|
// Coarse-Typ-Map bei 512×512 (4m/px) – filtert dünne Texturen heraus
|
||||||
float[] cliffMask = new float[SIZE * SIZE];
|
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++) {
|
for (int py = 0; py < SIZE; py++) {
|
||||||
float wz = -WORLD_HALF + py * cellW;
|
|
||||||
for (int px = 0; px < SIZE; px++) {
|
for (int px = 0; px < SIZE; px++) {
|
||||||
float wx = -WORLD_HALF + px * cellW;
|
int idx = py * SIZE + px;
|
||||||
int idx = py * SIZE + px;
|
float h = heights[idx];
|
||||||
float h = heights[idx];
|
if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h)) {
|
||||||
if (h < 0f || (waterH[idx] > 0f && waterH[idx] > h) || wfMask[idx]) {
|
|
||||||
int[] wc = TILE_FALLBACK_RGB[TILE_WATER];
|
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 {
|
} else {
|
||||||
int dPx = Math.max(1, (int)(1.5f / cellW));
|
int cx = Math.min(COARSE - 1, (int)((float) px / (SIZE - 1) * (COARSE - 1)));
|
||||||
float hR = heights[py * SIZE + Math.min(SIZE-1, px+dPx)];
|
int cz = Math.min(COARSE - 1, (int)((float) py / (SIZE - 1) * (COARSE - 1)));
|
||||||
float hL = heights[py * SIZE + Math.max(0, px-dPx)];
|
result[idx] = schematicColor(coarseTypes[cz * COARSE + cx] & 0xFF);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cliff-Maske weichzeichnen → weiche Klippen-Kanten ohne Änderung der Schwellwert-Logik
|
return result;
|
||||||
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];
|
* Berechnet das schematische Cliff-Overlay (ARGB, COLOR_MAP_SIZE×COLOR_MAP_SIZE):
|
||||||
for (int i = 0; i < SIZE * SIZE; i++) {
|
* graue Füllung wo Steigung > 30°, dunklere Linie an der oberen Kante (Kamm),
|
||||||
float t = softMask[i];
|
* mittlere Linie an der unteren Kante (Fuß).
|
||||||
int tc = terrainRGB[i];
|
*/
|
||||||
int r = (tc>>16)&0xFF, g = (tc>>8)&0xFF, b = tc&0xFF;
|
public static int[] buildCliffOverlay(MapData m, float[] voxelSurface) {
|
||||||
result[i] = (clamp((int)(r + (cr-r)*t)) << 16)
|
final int SIZE = COLOR_MAP_SIZE;
|
||||||
| (clamp((int)(g + (cg-g)*t)) << 8)
|
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
|
||||||
| clamp((int)(b + (cb-b)*t));
|
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; }
|
if (allExist) { log.debug("[HiresChunks] Alle {} Chunks im Cache – übersprungen.", COLS*COLS); return 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
|
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
|
||||||
float[] waterH = buildWaterHeightMap(waters, SIZE);
|
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 written = 0;
|
int written = 0;
|
||||||
|
|
||||||
final int CLIFF_BLUR_R = 4; // Blur-Radius für weiche Klippen-Kanten
|
// Coarse-Typ-Map (4m/px) – identische Filterung wie buildTerrainColorMap
|
||||||
final int EXT = CLIFF_BLUR_R;
|
final int COARSE = 512;
|
||||||
final int EXTW = CS + 2 * EXT; // erweitertes Fenster inkl. Rand für nahtlosen Blur
|
float cellWC = WORLD_SIZE / (COARSE - 1);
|
||||||
int dPxHires = Math.max(1, (int)(1.5f / cellW));
|
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 cy = 0; cy < COLS; cy++) {
|
||||||
for (int cx = 0; cx < COLS; cx++) {
|
for (int cx = 0; cx < COLS; cx++) {
|
||||||
java.nio.file.Path p = chunkPath(cacheDir, cx, cy);
|
java.nio.file.Path p = chunkPath(cacheDir, cx, cy);
|
||||||
if (!forceRegen && Files.exists(p)) continue;
|
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];
|
int[] chunk = new int[CS * CS];
|
||||||
for (int ly = 0; ly < CS; ly++) {
|
for (int ly = 0; ly < CS; ly++) {
|
||||||
|
int gy = cy * CS + ly;
|
||||||
for (int lx = 0; lx < CS; lx++) {
|
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;
|
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;
|
tr = (WATER_COLOR >> 16) & 0xFF;
|
||||||
tg = (WATER_COLOR >> 8) & 0xFF;
|
tg = (WATER_COLOR >> 8) & 0xFF;
|
||||||
tb = WATER_COLOR & 0xFF;
|
tb = WATER_COLOR & 0xFF;
|
||||||
} else {
|
} else {
|
||||||
float t = softMaskExt[extIdx];
|
int ctX = Math.min(COARSE - 1, (int)((float) gx / (SIZE - 1) * (COARSE - 1)));
|
||||||
int tc = terrainColExt[extIdx];
|
int ctZ = Math.min(COARSE - 1, (int)((float) gy / (SIZE - 1) * (COARSE - 1)));
|
||||||
int r2 = (tc>>16)&0xFF, g2 = (tc>>8)&0xFF, b2 = tc&0xFF;
|
int col = schematicColor(coarseTypes[ctZ * COARSE + ctX] & 0xFF);
|
||||||
tr = clamp((int)(r2 + (cr-r2)*t));
|
tr = (col >> 16) & 0xFF;
|
||||||
tg = clamp((int)(g2 + (cg-g2)*t));
|
tg = (col >> 8) & 0xFF;
|
||||||
tb = clamp((int)(b2 + (cb-b2)*t));
|
tb = col & 0xFF;
|
||||||
}
|
}
|
||||||
chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb;
|
chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import javafx.geometry.VPos;
|
|
||||||
import javafx.scene.text.TextAlignment;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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 double WAVE_W = 20.0; // konstante Wellengröße in Bildschirmpixeln
|
||||||
private static final int SEA_MASK_SIZE = 1024;
|
private static final int SEA_MASK_SIZE = 1024;
|
||||||
private static final Font AREA_FONT = Font.font("SansSerif", FontWeight.BOLD, 11);
|
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 Canvas canvas = new Canvas(100, 100);
|
||||||
private final Label statusLbl = new Label("Karte noch nicht geladen");
|
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);
|
private final StackPane canvasPane = new StackPane(canvas);
|
||||||
|
|
||||||
// Layer-Checkboxen (im MenuButton gebündelt)
|
// Layer-Checkboxen (im MenuButton gebündelt)
|
||||||
private final CheckBox cbTerrain = layerCheck("Gelände");
|
private final CheckBox cbTerrain = layerCheck("Basis");
|
||||||
private final CheckBox cbWater = layerCheck("Wasser");
|
private final CheckBox cbWater = layerCheck("Wasser/Küste");
|
||||||
private final CheckBox cbCoastline = layerCheck("Küstenlinie");
|
private final CheckBox cbForest = layerCheck("Wald");
|
||||||
private final CheckBox cbWaterfalls = layerCheck("Wasserfälle");
|
private final CheckBox cbCliffs = layerCheck("Klippen");
|
||||||
private final CheckBox cbAreas = layerCheck("Areas");
|
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 ToggleButton labelBtn = new ToggleButton("Label");
|
||||||
|
|
||||||
private final Button fullscreenBtn = new Button(">>");
|
private final Button fullscreenBtn = new Button(">>");
|
||||||
@@ -103,11 +96,7 @@ public class WorldMapView extends VBox {
|
|||||||
private WritableImage mapFxImage;
|
private WritableImage mapFxImage;
|
||||||
private BufferedImage mapBuffered;
|
private BufferedImage mapBuffered;
|
||||||
|
|
||||||
// Canvas-level Tile-Overlay: zoom-unabhängige Kacheldarstellung
|
private WritableImage bakedCliffOverlay = null;
|
||||||
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 static final int ZOOM_LEVELS = 5;
|
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 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
|
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 volatile boolean chunkRedrawPending = false;
|
||||||
|
|
||||||
private WritableImage bakedForestOverlay = null;
|
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<PlacedArea> cachedAreas = new ArrayList<>();
|
private List<PlacedArea> cachedAreas = new ArrayList<>();
|
||||||
private List<Location> cachedLocs = new ArrayList<>();
|
private List<Location> cachedLocs = new ArrayList<>();
|
||||||
@@ -205,15 +191,12 @@ public class WorldMapView extends VBox {
|
|||||||
// ── Layer-Auswahl als MenuButton mit Checkboxen ───────────────────────
|
// ── Layer-Auswahl als MenuButton mit Checkboxen ───────────────────────
|
||||||
cbTerrain.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
cbTerrain.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
||||||
cbWater.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
cbWater.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
||||||
cbWaterfalls.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
cbForest.selectedProperty().addListener((obs, o, n) -> redraw());
|
||||||
cbZones.selectedProperty().addListener((obs, o, n) -> rerenderFromModel());
|
cbCliffs.selectedProperty().addListener((obs, o, n) -> redraw());
|
||||||
cbModels.selectedProperty().addListener((obs, o, n) -> redraw());
|
|
||||||
cbAreas.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 ▾");
|
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);
|
CustomMenuItem item = new CustomMenuItem(cb, false);
|
||||||
item.setHideOnClick(false);
|
item.setHideOnClick(false);
|
||||||
layerMenu.getItems().add(item);
|
layerMenu.getItems().add(item);
|
||||||
@@ -369,18 +352,12 @@ public class WorldMapView extends VBox {
|
|||||||
Platform.runLater(() -> statusLbl.setText("Rendere Karte…"));
|
Platform.runLater(() -> statusLbl.setText("Rendere Karte…"));
|
||||||
long t1 = System.currentTimeMillis();
|
long t1 = System.currentTimeMillis();
|
||||||
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
|
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
|
||||||
float[] hMap = WorldMapRenderer.buildTerrainSamples(
|
log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)",
|
||||||
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)",
|
|
||||||
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1);
|
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1);
|
||||||
|
|
||||||
float[] fDensity = computeForestDensity(model);
|
float[] fDensity = computeForestDensity(model);
|
||||||
int[] bakedFstPx = computeForestPixels(model, fDensity, 2.0f);
|
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)
|
// Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend)
|
||||||
final WorldMapRenderModel chunkModel = model;
|
final WorldMapRenderModel chunkModel = model;
|
||||||
@@ -397,16 +374,12 @@ public class WorldMapView extends VBox {
|
|||||||
cachedLocs = new ArrayList<>(model.locations());
|
cachedLocs = new ArrayList<>(model.locations());
|
||||||
mapBuffered = bi;
|
mapBuffered = bi;
|
||||||
mapFxImage = SwingFXUtils.toFXImage(bi, null);
|
mapFxImage = SwingFXUtils.toFXImage(bi, null);
|
||||||
heightMap = hMap;
|
|
||||||
heightMapTerrain = hMapT;
|
|
||||||
categoryMap = catMap;
|
|
||||||
waterHeightMap = whMap;
|
|
||||||
bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE);
|
bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE);
|
||||||
|
bakedCliffOverlay = toWritableImage(cliffPx, WorldMapRenderer.COLOR_MAP_SIZE);
|
||||||
fitToView();
|
fitToView();
|
||||||
redraw();
|
redraw();
|
||||||
progress.setVisible(false);
|
progress.setVisible(false);
|
||||||
statusLbl.setText("Bereit – " + model.areas().size() + " Areas, " +
|
statusLbl.setText("Bereit – " + model.areas().size() + " Areas, " + model.waters().size() + " Wasser");
|
||||||
model.locations().size() + " Orte, " + model.waters().size() + " Wasser");
|
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
log.debug("[WorldMap] Gesamt-Ladezeit: {} ms", System.currentTimeMillis() - t0);
|
log.debug("[WorldMap] Gesamt-Ladezeit: {} ms", System.currentTimeMillis() - t0);
|
||||||
});
|
});
|
||||||
@@ -436,8 +409,7 @@ public class WorldMapView extends VBox {
|
|||||||
Thread t = new Thread(() -> {
|
Thread t = new Thread(() -> {
|
||||||
log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE);
|
log.debug("[WorldMap] PNG-Rerender aus gecachtem Modell gestartet ({}×{})", RENDER_SIZE, RENDER_SIZE);
|
||||||
long t0 = System.currentTimeMillis();
|
long t0 = System.currentTimeMillis();
|
||||||
// Nur das PNG neu rendern — heightMap/catMap/waterHeightMap kommen aus Stage 1
|
// Nur das PNG neu rendern — Overlays (Forest, Cliff) bleiben aus dem letzten Modell-Build
|
||||||
// und sind bereits in den Instanz-Feldern vorhanden
|
|
||||||
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
|
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
|
||||||
log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0);
|
log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0);
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
@@ -478,13 +450,15 @@ public class WorldMapView extends VBox {
|
|||||||
} else {
|
} else {
|
||||||
gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale);
|
gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale);
|
||||||
}
|
}
|
||||||
// Layer 1: Wasser (glatte Küste via seaCoastPaths)
|
// Layer 2: Küstenlinie (Vektor)
|
||||||
if (cbWater.isSelected()) drawWaterOverlay(gc);
|
if (cbWater.isSelected()) drawWaterOverlay(gc);
|
||||||
// Layer 2: Wald-Overlay (nach Wasser, damit Bäume nicht im Wasser erscheinen)
|
// Layer 3: Wald (Füllung + Rand)
|
||||||
drawForestOverlay(gc);
|
if (cbForest.isSelected()) drawForestOverlay(gc);
|
||||||
if (cbAreas.isSelected()) drawAreasOverlay(gc);
|
// Layer 4: Klippen (Grau-Füllung + Kantenlinien)
|
||||||
if (cbLocations.isSelected()) drawLocationsOverlay(gc);
|
if (cbCliffs.isSelected()) drawCliffOverlay(gc);
|
||||||
if (!isFullscreen) drawCameraIndicator(gc);
|
// Layer 5: Areas (Vektoren)
|
||||||
|
if (cbAreas.isSelected()) drawAreasOverlay(gc);
|
||||||
|
if (!isFullscreen) drawCameraIndicator(gc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawHiresChunks(GraphicsContext 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);
|
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) ─────────────────────────────
|
// ── Wald-Baking (läuft im Background-Thread) ─────────────────────────────
|
||||||
|
|
||||||
private static float[] computeForestDensity(WorldMapRenderModel model) {
|
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) {
|
private static int[] computeForestPixels(WorldMapRenderModel model, float[] density, float threshold) {
|
||||||
final int SIZE = RENDER_SIZE, FD = 512;
|
final int SIZE = RENDER_SIZE, FD = 512;
|
||||||
final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
|
final float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
|
||||||
final int[][] tilePx = model.mapTilePixels();
|
final int forestFill = 0x80_4a9a28; // 50% alpha grün
|
||||||
final int forestArgb = (tilePx != null && WorldMapRenderer.TILE_FOREST < tilePx.length
|
final int forestOutline = 0xCC_2d6316; // 80% alpha dunkelgrün
|
||||||
&& 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[] px = new int[SIZE * SIZE];
|
final int[] px = new int[SIZE * SIZE];
|
||||||
|
boolean[] isFill = new boolean[SIZE * SIZE];
|
||||||
for (int py = 0; py < SIZE; py++) {
|
for (int py = 0; py < SIZE; py++) {
|
||||||
float wz = (float) py / (SIZE - 1) * WS - WH;
|
float wz = (float) py / (SIZE - 1) * WS - WH;
|
||||||
for (int bx = 0; bx < SIZE; bx++) {
|
for (int bx = 0; bx < SIZE; bx++) {
|
||||||
@@ -628,7 +605,22 @@ public class WorldMapView extends VBox {
|
|||||||
float fstx=fsfx-fsx0, fstz=fsfz-fsz0;
|
float fstx=fsfx-fsx0, fstz=fsfz-fsz0;
|
||||||
float fd=(density[fsz0*FD+fsx0]*(1-fstx)+density[fsz0*FD+fsx1]*fstx)*(1-fstz)
|
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;
|
+(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;
|
return px;
|
||||||
@@ -698,42 +690,22 @@ public class WorldMapView extends VBox {
|
|||||||
gc.restore();
|
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) {
|
private void drawWaterOverlay(GraphicsContext gc) {
|
||||||
if (currentModel == null) return;
|
if (currentModel == null) return;
|
||||||
// Wasser-Füllung kommt aus mapFxImage (bereits korrekt gerendert).
|
|
||||||
// Hier nur noch Küstenlinie und Wellen-Vektoren.
|
|
||||||
|
|
||||||
// ── Küstenlinie ───────────────────────────────────────────────────────────
|
|
||||||
List<float[][]> seaCoastPaths = currentModel.coastPaths();
|
List<float[][]> seaCoastPaths = currentModel.coastPaths();
|
||||||
if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) {
|
if (seaCoastPaths == null || seaCoastPaths.isEmpty()) { return; }
|
||||||
double canvasW = canvas.getWidth(), canvasH = canvas.getHeight();
|
double canvasW = canvas.getWidth(), canvasH = canvas.getHeight();
|
||||||
gc.setLineDashes(null);
|
gc.setLineDashes(null);
|
||||||
gc.setLineCap(StrokeLineCap.ROUND);
|
gc.setLineCap(StrokeLineCap.ROUND);
|
||||||
gc.setLineJoin(StrokeLineJoin.ROUND);
|
gc.setLineJoin(StrokeLineJoin.ROUND);
|
||||||
|
|
||||||
gc.setStroke(Color.color(0, 0, 0, 0.22));
|
gc.setStroke(Color.color(0, 0, 0, 0.22));
|
||||||
gc.setLineWidth(7.0);
|
gc.setLineWidth(7.0);
|
||||||
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 8); }
|
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 8); }
|
||||||
|
|
||||||
gc.setStroke(Color.BLACK);
|
gc.setStroke(Color.BLACK);
|
||||||
gc.setLineWidth(3.0);
|
gc.setLineWidth(3.0);
|
||||||
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 4); }
|
for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 4); }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawCanvasLabel(GraphicsContext gc, String text, double x, double y) {
|
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 wx = toWorldX(canvasX);
|
||||||
float wz = toWorldZ(canvasY);
|
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
|
// Areas: kleinste enthaltende Area
|
||||||
PlacedArea best = null;
|
PlacedArea best = null;
|
||||||
int bestIdx = -1;
|
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<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) {
|
private static List<List<PlacedModel>> clusterTreeModels(List<PlacedModel> trees, float radius) {
|
||||||
int n = trees.size();
|
int n = trees.size();
|
||||||
int[] parent = new int[n];
|
int[] parent = new int[n];
|
||||||
@@ -1201,11 +1118,6 @@ public class WorldMapView extends VBox {
|
|||||||
return s.isBlank() ? null : s;
|
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() {
|
private void exportPng() {
|
||||||
FileChooser fc = new FileChooser();
|
FileChooser fc = new FileChooser();
|
||||||
fc.setTitle("Karte als PNG exportieren");
|
fc.setTitle("Karte als PNG exportieren");
|
||||||
@@ -1245,34 +1157,14 @@ public class WorldMapView extends VBox {
|
|||||||
t.start();
|
t.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hintergrund-PNG: ohne Areas und Locations (die werden live auf Canvas gezeichnet). */
|
|
||||||
private RenderOptions buildBackgroundOptions() {
|
private RenderOptions buildBackgroundOptions() {
|
||||||
return new RenderOptions(
|
return new RenderOptions(cbTerrain.isSelected(), cbTerrain.isSelected(),
|
||||||
cbTerrain.isSelected(),
|
cbWater.isSelected(), false, false, false, false, false, false);
|
||||||
cbTerrain.isSelected(),
|
|
||||||
cbWater.isSelected(),
|
|
||||||
false, // Wellen im Editor über Canvas gezeichnet
|
|
||||||
false,
|
|
||||||
cbZones.isSelected(),
|
|
||||||
false,
|
|
||||||
cbModels.isSelected(),
|
|
||||||
cbWaterfalls.isSelected()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export-PNG: alle Layer gebacken. */
|
|
||||||
private RenderOptions buildExportOptions() {
|
private RenderOptions buildExportOptions() {
|
||||||
return new RenderOptions(
|
return new RenderOptions(cbTerrain.isSelected(), cbTerrain.isSelected(),
|
||||||
cbTerrain.isSelected(),
|
cbWater.isSelected(), false, cbAreas.isSelected(), false, false, false, false);
|
||||||
cbTerrain.isSelected(),
|
|
||||||
cbWater.isSelected(),
|
|
||||||
false,
|
|
||||||
cbAreas.isSelected(),
|
|
||||||
cbZones.isSelected(),
|
|
||||||
cbLocations.isSelected(),
|
|
||||||
cbModels.isSelected(),
|
|
||||||
cbWaterfalls.isSelected()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CheckBox layerCheck(String label) {
|
private static CheckBox layerCheck(String label) {
|
||||||
|
|||||||
Reference in New Issue
Block a user