Compare commits

...

13 Commits

Author SHA1 Message Date
ca4d34b806 Weltkarte: Gras-Grün abdämpfen (125,180,72 → 158,178,112)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:48:13 +02:00
3feff60e24 Weltkarte Wald: Outline-Versatz durch Koordinaten-Mismatch behoben
Root cause: Fill nutzt fsfz = (WH-wz)/WS * FD (Skala: /FD=512),
MS-Grid nutzt msW → / (fdSize-1)=511. Ergibt ~2m Versatz in Kartenmitte.

Fix: h-Feld bei SEA_MASK_SIZE mit exakt der Fill-Formel aufbauen
(* FD statt * (FD-1)), dann MS mit thr=0 auf diesem h-Feld.
Die Isokontur liegt damit pixelgenau auf der Fill-Grenze.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:46:23 +02:00
cf11a50a21 Weltkarte Wald: Outline auf echter Density-Isokontur statt Binärmaske
Vorher: Density → binäre Maske (-1/+1) → MS bei 0.5 → Boundary 75% vom
Zellrand entfernt (falscher Offset, unabhängig von echten Density-Werten).

Jetzt: h = threshold - density → MS direkt auf Density-Werten bei thr=0
→ msLerp interpoliert genau an der echten threshold-Isokontur, identisch
zur Fill-Grenze in computeForestPixels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:33:56 +02:00
f0a3c462b6 Weltkarte Wald: Outline-Fill-Mismatch durch Chaikin-Schrumpfen behoben
Chaikin corner-cutting bewegt Punkte nach innen → Outline lag kleiner als
die Fill-Fläche. Die Wald-Density-Boundary ist bereits glatt (Summe
kreisförmiger Kernels), braucht kein Smoothing.

- assembleClosedPaths(segs, chaikinIter) extrahiert aus buildSeaCoastPaths:
  chaikinIter=0 → Pfade ohne Glättung, liegen exakt auf der Fill-Grenze
- buildForestOutlinePaths nutzt assembleClosedPaths(..., 0)
- buildSeaCoastPaths delegiert weiterhin mit chaikinIter=3 (Küste braucht
  Glättung wegen der stepped Terrain-Height-Boundary)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:31:30 +02:00
b79ccaa1e0 Weltkarte Wald: Outline-Fill-Mismatch fix + Kleinstpolygon-Filter
- buildForestOutlinePaths: bilineare Interpolation statt NN → Outline liegt
  exakt auf der gleichen Schwelle wie die Füllung in computeForestPixels
- Perimeter-Filter: Pfade mit Umfang < 60m² werden verworfen (Artefakte
  aus 1-2 isolierten Bäumen)
- Threshold 2.0 → 3.0: 2 Bäume ergeben max. Density=2, liegen damit unter
  der Schwelle → kein Wald-Ring mehr bei Kleingruppen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:28:17 +02:00
28cc89dcb1 Weltkarte: Wald-Linien Z-Flip fix + korrekte Layer-Reihenfolge
- buildForestOutlinePaths: Density-Map hat Z invertiert (Nord=mz=0) gegenüber
  Marching-Squares-Koordinaten (Süd=mz=0) → gespiegelte Konturen landeten
  off-screen, kein sichtbarer Wald-Rand. Fix: dz = fdSize-1-mz beim Samplen.
- Render-Reihenfolge korrigiert: Klippen → Wald → Wasser → Areas
  (Wald war über Wasser, sollte darunter liegen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:20:43 +02:00
8d6da6b013 Weltkarte Klippen: Schraffierung nur im Kliff-Band via Winding-Korrektur
- fixWindingForClip(): äußere Pfade CCW, innere Pfade (Löcher/Berg-Inneres) CW
  → gc.clip() füllt mit Non-Zero-Rule nur das echte Kliff-Band, nicht den
  gesamten eingeschlossenen Bereich inkl. Berggipfel
- Schraffierungs-Liniendicke = overlayLineWidth() (identisch zu Konturlinie)
- Schraffierungs-Abstand = 4× Liniendicke → konstante ~25% Flächendeckung
  über alle Zoom-Stufen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 18:06:30 +02:00
972d5d6e0b Weltkarte Klippen: Schraffierung, Zoom-abhängige Linienstärke, Wald-Stil
- Kliff-Bereiche: diagonale Schraffierung (45°, ~5.5px Abstand) im gleichen
  Grauton via gc.clip() auf die Marching-Squares-Pfade
- Klippen- und Wald-Konturlinien: 1.2px bei Zoom 0-2, 2.5px bei Zoom 3,
  3.5px bei Zoom 4 (deutlich dicker zum Reinzoomen)
- Wald-Umrandung: selber Stil und Dicke wie Klippen, aber dunkelgrün (28/78/12)
- Render-Reihenfolge bleibt: Klippen → Wasser → Wald

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 17:47:19 +02:00
81c9512244 Weltkarte: Vektor-Konturen für Wald/Klippen, 8m Textur-Filter, Render-Reihenfolge
- Cliff- und Wald-Randlinien via Marching Squares + Chaikin als Canvas-Vektoren
  statt Pixel-Kanten im Raster → immer scharf wie Küstenlinie und Areas
- COARSE von 512px (4m/px) auf 256px (8m/px) erhöht + Bilinear-Interpolation
  der Flachfarben → dünne Texturen (<8m) verschwinden, keine Block-Artefakte
  bei Hires-Zoom
- Render-Reihenfolge: Klippen → Wasser → Wald (laut User-Anforderung)
- buildCliffOverlay: nur noch weiche Grau-Füllung, keine pixel-basierten Kanten
- computeForestPixels: nur noch Füllung, Randlinie kommt als Vektor-Overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 17:18:43 +02:00
ddec931ee8 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>
2026-09-03 16:32:18 +02:00
e8896c7a4d Weltkarte: Anpassungen vor schematischem Umbau
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 16:22:13 +02:00
0bf7725051 Anpassung der KArte 2026-09-03 10:44:59 +02:00
abf56f81b6 Weltkarte: tile-basiertes Rendering, Hires-Chunks, 5 Zoom-Stufen
- Durchschnittliche Kachel-Farben (terrainColorMap, 2048px) als Basis für Low-Res-Ansicht
- Bilineares Splatmap-Blending (blendTileAvgColors) statt Nearest-Neighbor
- Chunk-basierte Hires-Karte (8192px, 256 Chunks à 512px) auf Disk gecacht
- Zoom-Stufen 0–2: Overview-Bild; Stufen 3–4: Hires-Chunks per LRU-Cache
- 5 geometrisch verteilte Zoom-Stufen (Fit-All bis 0,25m/Pixel)
- Klippen-Erkennung per Steigungswinkel (1,5m Sampling-Distanz, harter 30°-Schwellwert)
- Leichter Box-Blur (Radius 2) über Terrain-Layer
- Wasserfarbe in Hires-Chunks vereinheitlicht (WATER_COLOR)
- Kein Hillshading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-03 10:39:23 +02:00
6 changed files with 890 additions and 793 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -26,5 +26,6 @@ public record WorldMapRenderModel(
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters,
int[][] mapTilePixels // 6 Kacheln (TILE_WATER..TILE_ROCK), je 16×16 ARGB; null = Fallback
int[][] mapTilePixels, // 6 Kacheln (TILE_WATER..TILE_ROCK), je 16×16 ARGB; null = Fallback
int[] terrainColorMap // COLOR_MAP_SIZE×COLOR_MAP_SIZE packed RGB; null → render() berechnet intern
) {}

View File

@@ -29,6 +29,18 @@ public final class WorldMapRenderer {
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;
/**
* Auflösung der vorberechneten Übersichts-Farbkarte (terrainColorMap, für Zoom 0-2).
* 2048 → 1 Farbwert pro Meter.
*/
public static final int COLOR_MAP_SIZE = 2048;
/**
* Auflösung der hochauflösenden Chunk-Karte (für Zoom 3-4).
* 8192 → 1 Farbwert pro 0,25m. Auf 4096 für 0,5m/px ändern.
*/
public static final int HIRES_MAP_SIZE = 8192;
/** Pixelgröße eines Chunks (quadratisch). HIRES_MAP_SIZE muss ganzzahlig teilbar sein. */
public static final int CHUNK_SIZE = 512;
public record RenderInput(
MapData mapData,
@@ -146,49 +158,37 @@ public final class WorldMapRenderer {
}
}
// 3. Tile-Rendering: tileIdx + Rohfarbe pro Pixel speichern
float cellW = WORLD_SIZE / (targetSize - 1);
int SS = MapData.SPLAT_SIZE;
int[] slotCols = model.slotColorsRGB();
int[][] tiles = model.mapTilePixels();
int[] rawRGB = new int[targetSize * targetSize];
// 3. Terrain-Farben aus vorberechneter Farbkarte (oder intern berechnen als Fallback)
float cellW = WORLD_SIZE / (targetSize - 1);
int[] slotCols = model.slotColorsRGB();
int[] colorMap = model.terrainColorMap() != null
? model.terrainColorMap()
: buildTerrainColorMap(m, slotCols, model.mapTilePixels(), model.voxelSurface(), model.waters(), model.waterfalls());
int[] rawRGB = new int[targetSize * targetSize];
int waterR = (WATER_COLOR >> 16) & 0xFF;
int waterG = (WATER_COLOR >> 8) & 0xFF;
int waterB = WATER_COLOR & 0xFF;
for (int py = 0; py < targetSize; py++) {
float wz = -WORLD_HALF + py * cellW;
for (int px = 0; px < targetSize; px++) {
float wx = -WORLD_HALF + px * cellW;
int idx = py * targetSize + px;
int tileIdx;
int tr, tg, tb;
if (isWater[idx]) {
tileIdx = TILE_WATER;
tr = waterR; tg = waterG; tb = waterB;
rawRGB[idx] = (waterR << 16) | (waterG << 8) | waterB;
} else {
int dPx = Math.max(1, (int)(0.25f / cellW));
float hR = heights[py * targetSize + Math.min(targetSize-1, px+dPx)];
float hL = heights[py * targetSize + Math.max(0, px-dPx)];
float hD = heights[Math.min(targetSize-1, py+dPx) * targetSize + px];
float hU = heights[Math.max(0, py-dPx) * targetSize + 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))));
tileIdx = (slope > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols);
if (tiles != null && tileIdx < tiles.length && tiles[tileIdx] != null) {
int tu = Math.floorMod((int)Math.floor((wx + WORLD_HALF) / TILE_WU), 16);
int tv = Math.floorMod((int)Math.floor((wz + WORLD_HALF) / TILE_WU), 16);
int argb = tiles[tileIdx][tv * 16 + tu];
tr = (argb >> 16) & 0xFF; tg = (argb >> 8) & 0xFF; tb = argb & 0xFF;
} else {
tr = TILE_FALLBACK_RGB[tileIdx][0];
tg = TILE_FALLBACK_RGB[tileIdx][1];
tb = TILE_FALLBACK_RGB[tileIdx][2];
}
// Bilinear sample aus der Farbkarte (COLOR_MAP_SIZE → targetSize)
float fx = (float) px / (targetSize - 1) * (COLOR_MAP_SIZE - 1);
float fz = (float) py / (targetSize - 1) * (COLOR_MAP_SIZE - 1);
int cx0 = (int) fx, cx1 = Math.min(cx0 + 1, COLOR_MAP_SIZE - 1);
int cz0 = (int) fz, cz1 = Math.min(cz0 + 1, COLOR_MAP_SIZE - 1);
float tx = fx - cx0, tz = fz - cz0;
int c00 = colorMap[cz0 * COLOR_MAP_SIZE + cx0];
int c10 = colorMap[cz0 * COLOR_MAP_SIZE + cx1];
int c01 = colorMap[cz1 * COLOR_MAP_SIZE + cx0];
int c11 = colorMap[cz1 * COLOR_MAP_SIZE + cx1];
rawRGB[idx] = (clamp((int)(((c00>>16)&0xFF)*(1-tx)*(1-tz)+((c10>>16)&0xFF)*tx*(1-tz)+((c01>>16)&0xFF)*(1-tx)*tz+((c11>>16)&0xFF)*tx*tz)) << 16)
| (clamp((int)(((c00>>8) &0xFF)*(1-tx)*(1-tz)+((c10>>8) &0xFF)*tx*(1-tz)+((c01>>8) &0xFF)*(1-tx)*tz+((c11>>8) &0xFF)*tx*tz)) << 8)
| clamp((int)(( c00 &0xFF)*(1-tx)*(1-tz)+( c10 &0xFF)*tx*(1-tz)+( c01 &0xFF)*(1-tx)*tz+( c11 &0xFF)*tx*tz));
}
rawRGB[idx] = (tr << 16) | (tg << 8) | tb;
}
}
@@ -223,31 +223,10 @@ public final class WorldMapRenderer {
}
}
float wFrac = wCount * subInv;
// Land-Farbe: fuer Wasser-Pixel on-the-fly berechnen, sonst aus rawRGB
int landRGB;
if (isS) {
int dPx = Math.max(1, (int)(0.25f / cellW));
float hR2 = heights[py * targetSize + Math.min(targetSize-1, px+dPx)];
float hL2 = heights[py * targetSize + Math.max(0, px-dPx)];
float hD2 = heights[Math.min(targetSize-1, py+dPx) * targetSize + px];
float hU2 = heights[Math.max(0, py-dPx) * targetSize + px];
float d2 = dPx * cellW;
double sl2 = Math.toDegrees(Math.atan(Math.sqrt(
Math.pow((hR2-hL2)/(2*d2), 2) + Math.pow((hD2-hU2)/(2*d2), 2))));
int ti2 = (sl2 > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols);
int tr2, tg2, tb2;
if (tiles != null && ti2 < tiles.length && tiles[ti2] != null) {
int tu=Math.floorMod((int)Math.floor((wx+WORLD_HALF)/TILE_WU),16);
int tv=Math.floorMod((int)Math.floor((wz+WORLD_HALF)/TILE_WU),16);
int argb = tiles[ti2][tv * 16 + tu];
tr2=(argb>>16)&0xFF; tg2=(argb>>8)&0xFF; tb2=argb&0xFF;
} else {
tr2=TILE_FALLBACK_RGB[ti2][0]; tg2=TILE_FALLBACK_RGB[ti2][1]; tb2=TILE_FALLBACK_RGB[ti2][2];
}
landRGB = (tr2<<16)|(tg2<<8)|tb2;
} else {
landRGB = rawRGB[idx];
}
// Land-Farbe aus Farbkarte (Wasser-Pixel bekommen ihren Nachbar-Landwert)
int mapX = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
int mapZ = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
int landRGB = colorMap[mapZ * COLOR_MAP_SIZE + mapX];
int lr=(landRGB>>16)&0xFF, lg=(landRGB>>8)&0xFF, lb=landRGB&0xFF;
rawRGB[idx] = (clamp((int)(waterR*wFrac + lr*(1-wFrac))) << 16)
| (clamp((int)(waterG*wFrac + lg*(1-wFrac))) << 8)
@@ -267,9 +246,7 @@ public final class WorldMapRenderer {
int x1 = Math.min(targetSize-1, bb2.x + bb2.width + 1);
int y1 = Math.min(targetSize-1, bb2.y + bb2.height + 1);
for (int py = y0; py <= y1; py++) {
float wz = -WORLD_HALF + py * cellW;
for (int px = x0; px <= x1; px++) {
float wx = -WORLD_HALF + px * cellW;
int idx = py * targetSize + px;
boolean inPoly = wp.contains(px, py) && heights[idx] < wh2;
boolean boundary2 = false;
@@ -294,31 +271,9 @@ public final class WorldMapRenderer {
}
}
float wFrac2 = wCount2 * subInv;
int landRGB2;
if (inPoly) {
// Wasser-Pixel: Land-Farbe on-the-fly
int dPx = Math.max(1, (int)(0.25f / cellW));
float hR2 = heights[py * targetSize + Math.min(targetSize-1, px+dPx)];
float hL2 = heights[py * targetSize + Math.max(0, px-dPx)];
float hD2 = heights[Math.min(targetSize-1, py+dPx) * targetSize + px];
float hU2 = heights[Math.max(0, py-dPx) * targetSize + px];
float d2 = dPx * cellW;
double sl2 = Math.toDegrees(Math.atan(Math.sqrt(
Math.pow((hR2-hL2)/(2*d2),2) + Math.pow((hD2-hU2)/(2*d2),2))));
int ti2 = (sl2 > 30.0) ? TILE_CLIFF : getTerrainTile(m, SS, wx, wz, slotCols);
int tr2, tg2, tb2;
if (tiles != null && ti2 < tiles.length && tiles[ti2] != null) {
int tu2=Math.floorMod((int)Math.floor((wx+WORLD_HALF)/TILE_WU),16);
int tv2=Math.floorMod((int)Math.floor((wz+WORLD_HALF)/TILE_WU),16);
int argb = tiles[ti2][tv2 * 16 + tu2];
tr2=(argb>>16)&0xFF; tg2=(argb>>8)&0xFF; tb2=argb&0xFF;
} else {
tr2=TILE_FALLBACK_RGB[ti2][0]; tg2=TILE_FALLBACK_RGB[ti2][1]; tb2=TILE_FALLBACK_RGB[ti2][2];
}
landRGB2 = (tr2<<16)|(tg2<<8)|tb2;
} else {
landRGB2 = rawRGB[idx];
}
int mapX2 = Math.min((int)((float)px / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
int mapZ2 = Math.min((int)((float)py / (targetSize-1) * (COLOR_MAP_SIZE-1)), COLOR_MAP_SIZE-1);
int landRGB2 = colorMap[mapZ2 * COLOR_MAP_SIZE + mapX2];
int lr2=(landRGB2>>16)&0xFF, lg2=(landRGB2>>8)&0xFF, lb2=landRGB2&0xFF;
rawRGB[idx] = (clamp((int)(waterR*wFrac2 + lr2*(1-wFrac2))) << 16)
| (clamp((int)(waterG*wFrac2 + lg2*(1-wFrac2))) << 8)
@@ -334,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;
}
@@ -531,7 +297,7 @@ public final class WorldMapRenderer {
return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), null,
input.slotColorsRGB(), null, null, null, null, null, null
input.slotColorsRGB(), null, null, null, null, null, null, null
), targetSize, opts);
}
@@ -1007,6 +773,41 @@ public final class WorldMapRenderer {
return map;
}
/** Erzeugt eine Maske: true für jeden Pixel, der innerhalb eines Wasserfall-Quads liegt.
* Nutzt Graphics2D-Rasterisierung inkl. Stroke, damit auch schmale Quads erfasst werden. */
private static boolean[] buildWaterfallMask(List<PlacedWaterfall> waterfalls, int size) {
boolean[] mask = new boolean[size * size];
if (waterfalls == null || waterfalls.isEmpty()) return mask;
// BYTE_BINARY: 1 bit/px → 512 KB bei 2048px, 8 MB bei 8192px
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_BYTE_BINARY);
Graphics2D g = img.createGraphics();
g.setColor(java.awt.Color.WHITE);
float strokeW = Math.max(4f, size / 512f); // mind. 4px Strichbreite für dünne Quads
g.setStroke(new BasicStroke(strokeW, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
for (PlacedWaterfall wf : waterfalls) {
int[] xs = {
worldToPixel(wf.ax(), size), worldToPixel(wf.bx(), size),
worldToPixel(wf.cx(), size), worldToPixel(wf.dx(), size)
};
int[] zs = {
worldToPixel(wf.az(), size), worldToPixel(wf.bz(), size),
worldToPixel(wf.cz(), size), worldToPixel(wf.dz(), size)
};
g.fillPolygon(xs, zs, 4); // Füllung
g.drawPolygon(xs, zs, 4); // Stroke-Backup für schmale Quads
}
g.dispose();
java.awt.image.Raster raster = img.getData();
int[] px = new int[1];
for (int py = 0; py < size; py++) {
for (int x = 0; x < size; x++) {
raster.getPixel(x, py, px);
if (px[0] != 0) mask[py * size + x] = true;
}
}
return mask;
}
/** Lädt die 6 Karten-Kacheln aus Textures/map/ im Asset-Verzeichnis. */
public static int[][] loadMapTiles(Path assetRoot) {
int[][] result = new int[TILE_FILE_NAMES.length][];
@@ -1243,6 +1044,341 @@ public final class WorldMapRenderer {
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
}
/** Bilineare Tile-Textur-Abfrage mit UV-Gewichten (w00/w10/w01/w11 = bilinear weights). */
private static int tileSampleBil(int[][] tiles, int tileIdx,
int u0, int u1, int v0, int v1, float w00, float w10, float w01, float w11) {
if (tiles == null || tileIdx < 0 || tileIdx >= tiles.length || tiles[tileIdx] == null) {
int[] fb = TILE_FALLBACK_RGB[Math.max(0, Math.min(TILE_FALLBACK_RGB.length-1, tileIdx))];
return (fb[0]<<16)|(fb[1]<<8)|fb[2];
}
int[] t = tiles[tileIdx];
int c00=t[v0*16+u0], c10=t[v0*16+u1], c01=t[v1*16+u0], c11=t[v1*16+u1];
int r=clamp((int)(((c00>>16)&0xFF)*w00+((c10>>16)&0xFF)*w10+((c01>>16)&0xFF)*w01+((c11>>16)&0xFF)*w11));
int g=clamp((int)(((c00>>8)&0xFF)*w00+((c10>>8)&0xFF)*w10+((c01>>8)&0xFF)*w01+((c11>>8)&0xFF)*w11));
int b=clamp((int)((c00&0xFF)*w00+(c10&0xFF)*w10+(c01&0xFF)*w01+(c11&0xFF)*w11));
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 -> (158 << 16) | (178 << 8) | 112;
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 -> (158 << 16) | (178 << 8) | 112;
};
}
/**
* 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<PlacedWater> waters,
List<PlacedWaterfall> waterfalls) {
final int SIZE = COLOR_MAP_SIZE;
final int SS = MapData.SPLAT_SIZE;
float[] heights = buildTerrainSamples(m, SIZE, voxelSurface);
float[] waterH = buildWaterHeightMap(waters, SIZE);
// Coarse-Typ-Map bei 256×256 (8m/px) – filtert Texturen <8m heraus
int[] coarseColors = buildCoarseColorMap(m, SS, slotColors, 256);
// Bilinear-Interpolation der Flachfarben auf SIZE×SIZE → keine Block-Artefakte
int[] result = new int[SIZE * SIZE];
for (int py = 0; py < SIZE; py++) {
for (int px = 0; px < SIZE; px++) {
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];
result[idx] = (wc[0] << 16) | (wc[1] << 8) | wc[2];
} else {
result[idx] = sampleCoarseBilinear(coarseColors, 256, px, py, SIZE);
}
}
}
return result;
}
/** Baut eine Coarse-Farbkarte bei gegebener Auflösung: 1 Pixel = WORLD_SIZE/coarseSize m. */
private static int[] buildCoarseColorMap(MapData m, int SS, int[] slotColors, int coarseSize) {
float cellW = WORLD_SIZE / (coarseSize - 1);
int[] colors = new int[coarseSize * coarseSize];
for (int mz = 0; mz < coarseSize; mz++) {
float wz = -WORLD_HALF + mz * cellW;
for (int mx = 0; mx < coarseSize; mx++) {
float wx = -WORLD_HALF + mx * cellW;
colors[mz * coarseSize + mx] = schematicColor(getTerrainTile(m, SS, wx, wz, slotColors));
}
}
return colors;
}
/** Bilinear-Sample einer Coarse-Farbkarte (packed RGB) auf einen Ziel-Pixel. */
private static int sampleCoarseBilinear(int[] coarseColors, int coarseSize, int px, int py, int targetSize) {
float fx = (float) px / (targetSize - 1) * (coarseSize - 1);
float fz = (float) py / (targetSize - 1) * (coarseSize - 1);
int cx0 = (int) fx, cx1 = Math.min(cx0 + 1, coarseSize - 1);
int cz0 = (int) fz, cz1 = Math.min(cz0 + 1, coarseSize - 1);
float tx = fx - cx0, tz = fz - cz0;
int c00 = coarseColors[cz0 * coarseSize + cx0];
int c10 = coarseColors[cz0 * coarseSize + cx1];
int c01 = coarseColors[cz1 * coarseSize + cx0];
int c11 = coarseColors[cz1 * coarseSize + cx1];
int r = clamp((int)(((c00>>16)&0xFF)*(1-tx)*(1-tz)+((c10>>16)&0xFF)*tx*(1-tz)+((c01>>16)&0xFF)*(1-tx)*tz+((c11>>16)&0xFF)*tx*tz));
int g = clamp((int)(((c00>>8)&0xFF)*(1-tx)*(1-tz)+((c10>>8)&0xFF)*tx*(1-tz)+((c01>>8)&0xFF)*(1-tx)*tz+((c11>>8)&0xFF)*tx*tz));
int b = clamp((int)((c00&0xFF)*(1-tx)*(1-tz)+(c10&0xFF)*tx*(1-tz)+(c01&0xFF)*(1-tx)*tz+(c11&0xFF)*tx*tz));
return (r << 16) | (g << 8) | b;
}
/**
* 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));
float[] floatMask = 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))));
floatMask[idx] = slope > 30.0 ? 1f : 0f;
}
}
float[] softMask = boxBlurFloat(floatMask, SIZE, 3);
int[] result = new int[SIZE * SIZE];
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;
}
}
return result;
}
/**
* Generiert die hochauflösende Terrain-Farbkarte (HIRES_MAP_SIZE×HIRES_MAP_SIZE) in
* CHUNK_SIZE×CHUNK_SIZE-Kacheln und speichert sie als PNG unter {@code cacheDir}.
* Bereits vorhandene Chunks werden übersprungen, wenn {@code forceRegen} false ist.
*
* @return Anzahl neu geschriebener Chunks (0 = alle aus Cache geladen)
*/
public static int buildAndSaveHiresChunks(MapData m, int[] slotColors, int[][] tilePixels,
float[] voxelSurface, List<PlacedWater> waters,
List<PlacedWaterfall> waterfalls,
java.nio.file.Path cacheDir, boolean forceRegen) {
final int SIZE = HIRES_MAP_SIZE;
final int CS = CHUNK_SIZE;
final int COLS = SIZE / CS; // Chunks pro Zeile/Spalte
final int SS = MapData.SPLAT_SIZE;
try { Files.createDirectories(cacheDir); } catch (Exception e) { log.error("Chunk-Dir nicht erstellbar", e); return 0; }
// Prüfen ob alle Chunks vorhanden sind (dann Früh-Exit)
if (!forceRegen) {
boolean allExist = true;
for (int cy = 0; cy < COLS && allExist; cy++) {
for (int cx = 0; cx < COLS && allExist; cx++) {
if (!Files.exists(chunkPath(cacheDir, cx, cy))) allExist = false;
}
}
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);
int written = 0;
// Coarse-Farbkarte bei 256×256 (8m/px) – identische Filterung wie buildTerrainColorMap
int[] coarseColors = buildCoarseColorMap(m, SS, slotColors, 256);
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;
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 gx = cx * CS + lx;
int hidx = gy * SIZE + gx;
int tr, tg, tb;
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 {
int col = sampleCoarseBilinear(coarseColors, 256, gx, gy, SIZE);
tr = (col >> 16) & 0xFF;
tg = (col >> 8) & 0xFF;
tb = col & 0xFF;
}
chunk[ly * CS + lx] = (0xFF << 24) | (tr << 16) | (tg << 8) | tb;
}
}
// Als PNG speichern
BufferedImage img = new BufferedImage(CS, CS, BufferedImage.TYPE_INT_ARGB);
img.setRGB(0, 0, CS, CS, chunk, 0, CS);
try { javax.imageio.ImageIO.write(img, "PNG", p.toFile()); written++; }
catch (Exception e) { log.error("Chunk {}/{} konnte nicht gespeichert werden", cx, cy, e); }
}
}
log.debug("[HiresChunks] {} neue Chunks geschrieben nach {}", written, cacheDir);
return written;
}
/** Lädt einen Chunk aus dem Cache. Gibt null zurück wenn nicht vorhanden. */
public static int[] loadChunk(int chunkX, int chunkY, java.nio.file.Path cacheDir) {
java.nio.file.Path p = chunkPath(cacheDir, chunkX, chunkY);
if (!Files.exists(p)) return null;
try {
BufferedImage img = javax.imageio.ImageIO.read(p.toFile());
if (img == null) return null;
return img.getRGB(0, 0, CHUNK_SIZE, CHUNK_SIZE, null, 0, CHUNK_SIZE);
} catch (Exception e) {
log.error("Chunk {}/{} konnte nicht geladen werden", chunkX, chunkY, e);
return null;
}
}
private static java.nio.file.Path chunkPath(java.nio.file.Path dir, int cx, int cy) {
return dir.resolve(String.format("chunk_%02x_%02x.png", cx, cy));
}
/** Separabler Box-Blur auf float-Array (horizontal + vertikal), Radius in Pixeln. */
private static float[] boxBlurFloat(float[] src, int size, int radius) {
float[] tmp = new float[size * size];
float[] dst = new float[size * size];
float inv = 1f / (radius * 2 + 1);
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
float sum = 0;
for (int dx = -radius; dx <= radius; dx++) {
sum += src[py * size + Math.max(0, Math.min(size-1, px+dx))];
}
tmp[py * size + px] = sum * inv;
}
}
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
float sum = 0;
for (int dz = -radius; dz <= radius; dz++) {
sum += tmp[Math.max(0, Math.min(size-1, py+dz)) * size + px];
}
dst[py * size + px] = sum * inv;
}
}
return dst;
}
/** Separabler Box-Blur (horizontal + vertikal), Radius in Pixeln. */
private static int[] boxBlur(int[] src, int size, int radius) {
int[] tmp = new int[size * size];
int[] dst = new int[size * size];
int diam = radius * 2 + 1;
// Horizontaler Pass
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
long r = 0, g = 0, b = 0;
for (int dx = -radius; dx <= radius; dx++) {
int c = src[py * size + Math.max(0, Math.min(size - 1, px + dx))];
r += (c >> 16) & 0xFF; g += (c >> 8) & 0xFF; b += c & 0xFF;
}
tmp[py * size + px] = ((int)(r/diam) << 16) | ((int)(g/diam) << 8) | (int)(b/diam);
}
}
// Vertikaler Pass
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
long r = 0, g = 0, b = 0;
for (int dz = -radius; dz <= radius; dz++) {
int c = tmp[Math.max(0, Math.min(size - 1, py + dz)) * size + px];
r += (c >> 16) & 0xFF; g += (c >> 8) & 0xFF; b += c & 0xFF;
}
dst[py * size + px] = ((int)(r/diam) << 16) | ((int)(g/diam) << 8) | (int)(b/diam);
}
}
return dst;
}
/**
* Bilineare Splatmap-Blendung der Tile-Durchschnittsfarben an Weltposition (wx, wz).
* Identische Logik wie der alte progressiver Splatmap-Blend in render(), aber mit
* vorberechneten Durchschnittsfarben statt Tile-UV-Sampling.
*/
private static int blendTileAvgColors(MapData m, int SS, float wx, float wz,
int[] tileAvg, int[] slotTypes) {
float sfx = Math.max(0f, Math.min(SS-1, (wx+WORLD_HALF)/WORLD_SIZE*(SS-1)));
float sfz = Math.max(0f, Math.min(SS-1, (WORLD_HALF-wz)/WORLD_SIZE*(SS-1)));
int sx0=(int)sfx, sx1=Math.min(sx0+1,SS-1);
int sz0=(int)sfz, sz1=Math.min(sz0+1,SS-1);
float stx=sfx-sx0, stz=sfz-sz0;
int bi00=sz0*SS+sx0, bi10=sz0*SS+sx1, bi01=sz1*SS+sx0, bi11=sz1*SS+sx1;
float wg = bilerp(m.splatG, bi00,bi10,bi01,bi11,stx,stz);
float wb = bilerp(m.splatB, bi00,bi10,bi01,bi11,stx,stz);
float wa = bilerp(m.splatA, bi00,bi10,bi01,bi11,stx,stz);
float up1 = bilerp(m.upperSplatR, bi00,bi10,bi01,bi11,stx,stz);
float up2 = bilerp(m.upperSplatG, bi00,bi10,bi01,bi11,stx,stz);
float up3 = bilerp(m.upperSplatB, bi00,bi10,bi01,bi11,stx,stz);
float up4 = bilerp(m.upperSplatA, bi00,bi10,bi01,bi11,stx,stz);
float t31 = bilerp(m.thirdSplatR, bi00,bi10,bi01,bi11,stx,stz);
float t32 = bilerp(m.thirdSplatG, bi00,bi10,bi01,bi11,stx,stz);
float t33 = bilerp(m.thirdSplatB, bi00,bi10,bi01,bi11,stx,stz);
float t34 = bilerp(m.thirdSplatA, bi00,bi10,bi01,bi11,stx,stz);
int c0 = tileAvg[slotTypes[0]];
float fr=(c0>>16)&0xFF, fg=(c0>>8)&0xFF, fb=c0&0xFF;
int col;
col=tileAvg[slotTypes[1]]; fr=fr*(1-wg) +((col>>16)&0xFF)*wg; fg=fg*(1-wg) +((col>>8)&0xFF)*wg; fb=fb*(1-wg) +(col&0xFF)*wg;
col=tileAvg[slotTypes[2]]; fr=fr*(1-wb) +((col>>16)&0xFF)*wb; fg=fg*(1-wb) +((col>>8)&0xFF)*wb; fb=fb*(1-wb) +(col&0xFF)*wb;
col=tileAvg[slotTypes[3]]; fr=fr*(1-wa) +((col>>16)&0xFF)*wa; fg=fg*(1-wa) +((col>>8)&0xFF)*wa; fb=fb*(1-wa) +(col&0xFF)*wa;
col=tileAvg[slotTypes[4]]; fr=fr*(1-up1)+((col>>16)&0xFF)*up1; fg=fg*(1-up1)+((col>>8)&0xFF)*up1; fb=fb*(1-up1)+(col&0xFF)*up1;
col=tileAvg[slotTypes[5]]; fr=fr*(1-up2)+((col>>16)&0xFF)*up2; fg=fg*(1-up2)+((col>>8)&0xFF)*up2; fb=fb*(1-up2)+(col&0xFF)*up2;
col=tileAvg[slotTypes[6]]; fr=fr*(1-up3)+((col>>16)&0xFF)*up3; fg=fg*(1-up3)+((col>>8)&0xFF)*up3; fb=fb*(1-up3)+(col&0xFF)*up3;
col=tileAvg[slotTypes[7]]; fr=fr*(1-up4)+((col>>16)&0xFF)*up4; fg=fg*(1-up4)+((col>>8)&0xFF)*up4; fb=fb*(1-up4)+(col&0xFF)*up4;
col=tileAvg[slotTypes[8]]; fr=fr*(1-t31)+((col>>16)&0xFF)*t31; fg=fg*(1-t31)+((col>>8)&0xFF)*t31; fb=fb*(1-t31)+(col&0xFF)*t31;
col=tileAvg[slotTypes[9]]; fr=fr*(1-t32)+((col>>16)&0xFF)*t32; fg=fg*(1-t32)+((col>>8)&0xFF)*t32; fb=fb*(1-t32)+(col&0xFF)*t32;
col=tileAvg[slotTypes[10]]; fr=fr*(1-t33)+((col>>16)&0xFF)*t33; fg=fg*(1-t33)+((col>>8)&0xFF)*t33; fb=fb*(1-t33)+(col&0xFF)*t33;
col=tileAvg[slotTypes[11]]; fr=fr*(1-t34)+((col>>16)&0xFF)*t34; fg=fg*(1-t34)+((col>>8)&0xFF)*t34; fb=fb*(1-t34)+(col&0xFF)*t34;
return (clamp((int)fr)<<16)|(clamp((int)fg)<<8)|clamp((int)fb);
}
private static int[] computeTileAvgColors(int[][] tiles) {
int n = TILE_FALLBACK_RGB.length;
int[] avg = new int[n];
for (int i = 0; i < n; i++) {
if (tiles != null && i < tiles.length && tiles[i] != null) {
long r = 0, g = 0, b = 0;
for (int argb : tiles[i]) { r += (argb>>16)&0xFF; g += (argb>>8)&0xFF; b += argb&0xFF; }
int len = tiles[i].length;
avg[i] = ((int)(r/len) << 16) | ((int)(g/len) << 8) | (int)(b/len);
} else {
int[] fb = TILE_FALLBACK_RGB[i];
avg[i] = (fb[0] << 16) | (fb[1] << 8) | fb[2];
}
}
return avg;
}
/**
* Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.

View File

@@ -563,8 +563,15 @@ public class EditorApp extends Application {
javafx.animation.Timeline[] pollerRef = new javafx.animation.Timeline[1];
pollerRef[0] = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(100), ev -> {
splashStatus.setText(input.loadingStatus);
if (input.jmeReady) {
boolean jmeOk = input.jmeReady;
boolean mapOk = worldMapView != null && worldMapView.isMapReady();
if (!jmeOk) {
splashStatus.setText(input.loadingStatus);
} else {
splashStatus.setText("Karte: " + (worldMapView != null
? worldMapView.getLoadingStatus() : "…"));
}
if (jmeOk && mapOk) {
pollerRef[0].stop();
stage.show();
restoreEditorPrefs();
@@ -5646,30 +5653,34 @@ public class EditorApp extends Application {
if (isSelected) mapObjectsView.refresh();
});
worldMapView = new de.blight.editor.ui.WorldMapView(() -> primaryStage);
worldMapView.setVoxelChunkSupplier(() -> {
de.blight.editor.state.VoxelEditorState ves = jmeApp == null ? null
: jmeApp.getStateManager().getState(de.blight.editor.state.VoxelEditorState.class);
return ves != null ? ves.getChunksSnapshot() : de.blight.common.VoxelChunkIO.loadAll();
});
worldMapView.setCameraInfoSupplier(() ->
new de.blight.editor.ui.WorldMapView.CameraInfo(
input.camX, input.camY, input.camZ, input.camYaw));
worldMapView.setTeleportCallback(pos -> {
input.pendingGotoX = pos[0];
input.pendingGotoY = pos[1];
input.pendingGotoZ = pos[2];
});
if (worldMapView == null) {
worldMapView = new de.blight.editor.ui.WorldMapView(() -> primaryStage);
worldMapView.setVoxelChunkSupplier(() -> {
de.blight.editor.state.VoxelEditorState ves = jmeApp == null ? null
: jmeApp.getStateManager().getState(de.blight.editor.state.VoxelEditorState.class);
return ves != null ? ves.getChunksSnapshot() : de.blight.common.VoxelChunkIO.loadAll();
});
worldMapView.setCameraInfoSupplier(() ->
new de.blight.editor.ui.WorldMapView.CameraInfo(
input.camX, input.camY, input.camZ, input.camYaw));
worldMapView.setTeleportCallback(pos -> {
input.pendingGotoX = pos[0];
input.pendingGotoY = pos[1];
input.pendingGotoZ = pos[2];
});
worldMapView.setRefreshAction(() -> {
input.saveRequested = true;
pendingAfterSave = worldMapView::loadAndRender;
setStatus("Speichere vor Weltkarten-Refresh…");
});
worldMapView.loadAndRender(); // Vorladen beim Start, nicht erst beim Tab-Öffnen
}
weltkartTab = new Tab("Weltkarte", worldMapView);
weltkartTab.setClosable(false);
weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender();
});
worldMapView.setRefreshAction(() -> {
input.saveRequested = true;
pendingAfterSave = worldMapView::loadAndRender;
setStatus("Speichere vor Weltkarten-Refresh…");
else if (isSelected) worldMapView.ensureFitToView();
});
worldMapView.setFullscreenCallbacks(
() -> {

View File

@@ -40,7 +40,9 @@ import de.blight.common.PlacedLocationZone;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.PlacedWater;
import de.blight.common.PlacedWaterfall;
import de.blight.common.WaterBodyIO;
import de.blight.common.WaterfallIO;
import de.blight.common.map.WorldMapRenderer;
import de.blight.common.map.WorldMapRenderer.RenderInput;
import de.blight.common.map.WorldMapRenderer.RenderOptions;
@@ -546,30 +548,22 @@ public class MinimapState extends BaseAppState {
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
List<PlacedWaterfall> waterfalls = WaterfallIO.load();
int[] slotColors = computeSlotColors(mapData, root);
int[][] tilePixels = WorldMapRenderer.loadMapTiles(root);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
de.blight.common.map.WorldMapRenderModel renderModel = new de.blight.common.map.WorldMapRenderModel(
mapData, areas, zones, locs, waters, models, waterfalls,
slotColors, null, null, null, null, null, tilePixels, null);
log.debug("[Minimap] Render-Modell bereit – {} Areas, {} Orte, {} Wasser ({} ms)",
areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0);
boolean needsRender = !Files.exists(png);
if (!needsRender) {
// Cache ungültig wenn Karte neuer als das PNG
try {
long pngMod = Files.getLastModifiedTime(png).toMillis();
long mapMod = Files.getLastModifiedTime(MapIO.getMapPath()).toMillis();
if (mapMod > pngMod) { needsRender = true; }
} catch (Exception ignored) { needsRender = true; }
}
if (needsRender) {
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(renderInput, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
System.currentTimeMillis() - t1, png);
} else {
log.info("[Minimap] Gecachte Weltkarte: {}", png);
}
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(renderModel, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
System.currentTimeMillis() - t1, png);
app.enqueue(() -> { onTextureReady(); return null; });
} catch (Exception e) {