Weltkarte: Kachel-Overlay, Wald-Overlay, ModelCategory-System, Selektions-Fix

- Terrain-Textur-Overlay (Splatmap-Blend, Klippen, Wald) als Dirty-Flag-Cache
- Wald wird nach Wasser gerendert, 50% Deckkraft, Schwelle 3+ Bäume
- Speichern vor Weltkarten-Refresh (save-then-load via pendingAfterSave)
- ModelCategory-System: Baum/Palme/Busch/Gebäude; Ordner-Kategorie im UI versteckt
- Selektion nur mit Auswahl-Werkzeug (kein versehentliches Selektieren im Platzier-Modus)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 22:05:38 +02:00
parent 0fc8f7f3c3
commit 2fd6a9b737
14 changed files with 937 additions and 363 deletions

View File

@@ -33,7 +33,8 @@ public record ModelMeta(
float interactableOffsetY,
float interactableOffsetZ,
float interactableRotY,
String footstepSurface
String footstepSurface,
de.blight.common.ModelCategory modelCategory
) {
/** Lichtquelle relativ zum Modell-Ursprung. */
public record AttachedLight(
@@ -53,6 +54,7 @@ public record ModelMeta(
"", "", 30f, 80f, 120f,
List.of(), List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "");
0f, 0.5f, 0f, 0f, "",
de.blight.common.ModelCategory.NONE);
}
}

View File

@@ -40,6 +40,7 @@ public final class ModelMetaIO {
p.setProperty("interactableOffsetZ", String.valueOf(m.interactableOffsetZ()));
p.setProperty("interactableRotY", String.valueOf(m.interactableRotY()));
p.setProperty("footstepSurface", m.footstepSurface() != null ? m.footstepSurface() : "");
p.setProperty("modelCategory", m.modelCategory() != null ? m.modelCategory().name() : "NONE");
// Anhänge: Lichter
List<ModelMeta.AttachedLight> lights = m.attachedLights();
@@ -140,7 +141,8 @@ public final class ModelMetaIO {
parseFloat(p, "interactableOffsetY", 0.5f),
parseFloat(p, "interactableOffsetZ", 0f),
parseFloat(p, "interactableRotY", 0f),
p.getProperty("footstepSurface", "")
p.getProperty("footstepSurface", ""),
de.blight.common.ModelCategory.fromString(p.getProperty("modelCategory", "NONE"))
);
}

View File

@@ -22,5 +22,7 @@ public record PlacedModel(
/** "CRAFTING_TABLE" / "BED" / "" für kein Interactable. */
String interactableType,
/** ID des verknüpften Interactables (CraftingTableType-Name oder Bett-UUID); "" wenn nicht gesetzt. */
String interactableId
String interactableId,
/** Kategorie für Karten-Rendering (TREE, BUILDING, BUSH, NONE). */
de.blight.common.ModelCategory modelCategory
) {}

View File

@@ -26,11 +26,11 @@ public final class PlacedModelIO {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance\tinteractableType\tinteractableId");
w.write("# modelPath\tx\ty\tz\trotY\tscale\trotX\trotZ\tsolid\ttexPath\tnmPath\tmatPath\tmeshFile\tanimClip\tcastShadow\treceiveShadow\tlod1Path\tlod2Path\tlod1Distance\tlod2Distance\tcullDistance\tinteractableType\tinteractableId\tmodelCategory");
w.newLine();
for (PlacedModel m : models) {
w.write(String.format(Locale.ROOT,
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f\t%s\t%s%n",
"%s\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%.5f\t%b\t%s\t%s\t%s\t%s\t%s\t%b\t%b\t%s\t%s\t%.5f\t%.5f\t%.5f\t%s\t%s\t%s%n",
m.modelPath(),
m.x(), m.y(), m.z(),
m.rotY(), m.scale(),
@@ -41,7 +41,8 @@ public final class PlacedModelIO {
m.castShadow(), m.receiveShadow(),
nvl(m.lod1Path()), nvl(m.lod2Path()),
m.lod1Distance(), m.lod2Distance(), m.cullDistance(),
nvl(m.interactableType()), nvl(m.interactableId())));
nvl(m.interactableType()), nvl(m.interactableId()),
m.modelCategory() != null ? m.modelCategory().name() : "NONE"));
}
}
}
@@ -79,12 +80,15 @@ public final class PlacedModelIO {
float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
String interactableType = f.length > 21 ? f[21] : "";
String interactableId = f.length > 22 ? f[22] : "";
de.blight.common.ModelCategory modelCategory =
f.length > 23 ? de.blight.common.ModelCategory.fromString(f[23])
: de.blight.common.ModelCategory.NONE;
list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow,
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance,
interactableType, interactableId));
interactableType, interactableId, modelCategory));
} catch (NumberFormatException ignored) {}
}
return list;

View File

@@ -25,5 +25,6 @@ public record WorldMapRenderModel(
float[] voxelSurface, // oberster solider Voxel-Y pro UPPER_VERTS-Zelle; NaN = keine Daten
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters
List<List<PlacedModel>> treeClusters,
int[][] mapTilePixels // 6 Kacheln (TILE_WATER..TILE_ROCK), je 16×16 ARGB; null = Fallback
) {}

View File

@@ -3,6 +3,8 @@ package de.blight.common.map;
import de.blight.common.*;
import de.blight.common.model.Location;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.geom.Path2D;
@@ -21,6 +23,8 @@ import java.util.List;
*/
public final class WorldMapRenderer {
private static final Logger log = LoggerFactory.getLogger(WorldMapRenderer.class);
public static final float WORLD_HALF = 1024f;
public static final float WORLD_SIZE = 2048f;
/** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */
@@ -102,146 +106,241 @@ public final class WorldMapRenderer {
private static final int[] DEF_SLOT_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
private static final int[] DEF_SLOT_B = { 46, 64, 77, 128, 40, 60, 50, 70 };
// Karten-Kachel-Indizes
public static final int TILE_WATER = 0, TILE_CLIFF = 1, TILE_GRASS = 2,
TILE_SAND = 3, TILE_DIRT = 4, TILE_ROCK = 5,
TILE_PAVEMENT = 6, TILE_FOREST = 7;
private static final String[] TILE_FILE_NAMES = {
"map_water", "map_cliff", "map_grass", "map_sand", "map_dirt", "map_rock", "map_pavement", "map_forest" };
public static final int[][] TILE_FALLBACK_RGB = {
{55, 120, 195}, {115, 108, 100}, {78, 138, 52},
{198, 182, 110}, {118, 82, 46}, {122, 116, 108}, {148, 135, 112}, {34, 85, 34} };
public static final float TILE_WU = 1f; // Kachelgroesse in Welteinheiten (1m → 1:1 bei scale=16)
private WorldMapRenderer() {}
/** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */
public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) {
MapData m = model.mapData();
int TV = MapData.TERRAIN_VERTS;
int SS = MapData.SPLAT_SIZE;
int[] slotR = slotChannel(model.slotColorsRGB(), 0);
int[] slotG = slotChannel(model.slotColorsRGB(), 1);
int[] slotB = slotChannel(model.slotColorsRGB(), 2);
// ── 1. Heightmap auf Zielauflösung samplen ────────────────────────────
int UV = MapData.UPPER_VERTS;
float[] heights = new float[targetSize * targetSize];
// Meerserkennung basiert nur auf terrainHeight (ohne upperTop/voxelSurface):
// bakeVoxelHeights trägt Voxel-Höhen in upperTop ein, was sonst
// Voxel auf dem Meeresboden fälschlich als Landfläche klassifiziert.
// 1. Hoehenfeld
float[] heights = buildTerrainSamples(m, targetSize, model.voxelSurface());
boolean[] seaPixels = new boolean[targetSize * targetSize];
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
for (int i = 0; i < heights.length; i++) { seaPixels[i] = heights[i] < 0f; }
int VS = VOXEL_SURFACE_RES;
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1);
float ux_f = (float) px / (targetSize - 1) * (UV - 1);
float uz_f = (float) py / (targetSize - 1) * (UV - 1);
float baseH = m.terrainHeight[hz * TV + hx];
float h = baseH;
float upper = bilerpPos(m.upperTop, ux_f, uz_f, UV);
if (upper > 0f && upper > h) { h = upper; }
// Voxel-Oberfläche: direkte 1:1-Abfrage des hochauflösenden Bake-Arrays
// seaH = max(terrain, voxelSurface): Voxel über Y=0 → Land, darunter → Meer
float seaH = baseH;
if (model.voxelSurface() != null) {
int vsPx = Math.min((int)((float) px / (targetSize - 1) * (VS - 1)), VS - 1);
int vsPz = Math.min((int)((float) py / (targetSize - 1) * (VS - 1)), VS - 1);
float vs = model.voxelSurface()[vsPz * VS + vsPx];
if (!Float.isNaN(vs)) {
h = vs;
if (vs > seaH) { seaH = vs; }
// 2. Wasser-Maske: Ozean + PlacedWater vorab berechnen
boolean[] isWater = java.util.Arrays.copyOf(seaPixels, seaPixels.length);
if (opts.showWater()) {
for (PlacedWater w : model.waters()) {
int[] wxPx = worldToPixels(w.pointsX(), targetSize);
int[] wzPx = worldToPixels(w.pointsZ(), targetSize);
Polygon waterPoly = new Polygon(wxPx, wzPx, wxPx.length);
Rectangle bb = waterPoly.getBounds();
float wh = w.waterHeight();
for (int py = Math.max(0, bb.y); py <= Math.min(targetSize-1, bb.y+bb.height); py++) {
for (int px = Math.max(0, bb.x); px <= Math.min(targetSize-1, bb.x+bb.width); px++) {
if (waterPoly.contains(px, py) && heights[py * targetSize + px] < wh)
isWater[py * targetSize + px] = true;
}
}
heights[py * targetSize + px] = h;
seaPixels[py * targetSize + px] = (seaH < 0f);
if (h < minH) minH = h;
if (h > maxH) maxH = h;
}
}
float heightRange = Math.max(0.01f, maxH - minH);
// ── 2. Pixel-Farben berechnen ─────────────────────────────────────────
BufferedImage img = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_RGB);
// 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];
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 h = heights[py * targetSize + px];
float hn = (h - minH) / heightRange;
float wx = -WORLD_HALF + px * cellW;
int idx = py * targetSize + px;
int r, g, b;
if (opts.showSplatColors()) {
int sx = Math.min((int)((float) px / (targetSize - 1) * (SS - 1)), SS - 1);
int sz = Math.min((int)((float)(targetSize - 1 - py) / (targetSize - 1) * (SS - 1)), SS - 1);
int si = sz * SS + sx;
// Base layer (Slots 1-4)
float wg = (m.splatG[si] & 0xFF) / 255f;
float wb = (m.splatB[si] & 0xFF) / 255f;
float wa = (m.splatA[si] & 0xFF) / 255f;
float fr = slotR[0], fg = slotG[0], fb = slotB[0];
fr = fr*(1-wg) + slotR[1]*wg; fg = fg*(1-wg) + slotG[1]*wg; fb = fb*(1-wg) + slotB[1]*wg;
fr = fr*(1-wb) + slotR[2]*wb; fg = fg*(1-wb) + slotG[2]*wb; fb = fb*(1-wb) + slotB[2]*wb;
fr = fr*(1-wa) + slotR[3]*wa; fg = fg*(1-wa) + slotG[3]*wa; fb = fb*(1-wa) + slotB[3]*wa;
// Upper layer (Slots 5-8)
float u1 = (m.upperSplatR[si] & 0xFF) / 255f;
float u2 = (m.upperSplatG[si] & 0xFF) / 255f;
float u3 = (m.upperSplatB[si] & 0xFF) / 255f;
float u4 = (m.upperSplatA[si] & 0xFF) / 255f;
fr = fr*(1-u1) + slotR[4]*u1; fg = fg*(1-u1) + slotG[4]*u1; fb = fb*(1-u1) + slotB[4]*u1;
fr = fr*(1-u2) + slotR[5]*u2; fg = fg*(1-u2) + slotG[5]*u2; fb = fb*(1-u2) + slotB[5]*u2;
fr = fr*(1-u3) + slotR[6]*u3; fg = fg*(1-u3) + slotG[6]*u3; fb = fb*(1-u3) + slotB[6]*u3;
fr = fr*(1-u4) + slotR[7]*u4; fg = fg*(1-u4) + slotG[7]*u4; fb = fb*(1-u4) + slotB[7]*u4;
r = clamp((int) fr); g = clamp((int) fg); b = clamp((int) fb);
int tileIdx;
int tr, tg, tb;
if (isWater[idx]) {
tileIdx = TILE_WATER;
tr = waterR; tg = waterG; tb = waterB;
} else {
int lum = clamp(30 + (int)(hn * 200));
r = g = b = lum;
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];
}
}
// Hillshading (NW-Licht)
if (opts.showTerrain() && px > 0 && px < targetSize - 1 && py > 0 && py < targetSize - 1) {
float dx = heights[py * targetSize + (px + 1)] - heights[py * targetSize + (px - 1)];
float dz = heights[(py + 1) * targetSize + px] - heights[(py - 1) * targetSize + px];
float nx = -dx * 0.25f, ny = 1f, nz = -dz * 0.25f;
float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= len; ny /= len; nz /= len;
float shade = nx * (-0.577f) + ny * 0.577f + nz * (-0.577f);
shade = 0.45f + shade * 0.85f;
shade = Math.max(0.2f, Math.min(1.8f, shade));
r = clamp((int)(r * shade));
g = clamp((int)(g * shade));
b = clamp((int)(b * shade));
}
img.setRGB(px, py, (r << 16) | (g << 8) | b);
rawRGB[idx] = (tr << 16) | (tg << 8) | tb;
}
}
// Kuwahara-Filter: lässt das Terrain wie gemalt wirken
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// 4. Kuestenlinie: Sub-Pixel-Sampling fuer pixelgenaue Wasser-Land-Grenze.
// Greift nur auf Grenz-Pixel zur Ozean-Kuestenlinie. Interior-Pixel bleiben scharf.
int SUB = 8;
float subInv = 1f / (SUB * SUB);
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;
boolean isS = seaPixels[idx];
boolean boundary = false;
for (int dy = -1; dy <= 1 && !boundary; dy++) {
for (int dx = -1; dx <= 1 && !boundary; dx++) {
if (dx == 0 && dy == 0) continue;
int ny = py + dy, nx = px + dx;
if (ny < 0 || ny >= targetSize || nx < 0 || nx >= targetSize) continue;
if (seaPixels[ny * targetSize + nx] != isS) boundary = true;
}
}
if (!boundary) continue;
// 8x8 Sub-Samples: Wasser-Anteil dieses Pixels bestimmen
int wCount = 0;
for (int sy = 0; sy < SUB; sy++) {
for (int sx = 0; sx < SUB; sx++) {
float spx = px + (sx + 0.5f) / SUB - 0.5f;
float spy = py + (sy + 0.5f) / SUB - 0.5f;
if (sampleTerrainH(m, targetSize, spx, spy, model.voxelSurface()) < 0f)
wCount++;
}
}
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];
}
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)
| clamp((int)(waterB*wFrac + lb*(1-wFrac)));
}
}
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
// 4b. PlacedWater-Kuestenlinie: Sub-Pixel-Sampling fuer definierte Wasserflaechen
if (opts.showWater()) {
for (PlacedWater w : model.waters()) {
int[] wxPx2 = worldToPixels(w.pointsX(), targetSize);
int[] wzPx2 = worldToPixels(w.pointsZ(), targetSize);
Polygon wp = new Polygon(wxPx2, wzPx2, wxPx2.length);
Rectangle bb2 = wp.getBounds();
float wh2 = w.waterHeight();
int x0 = Math.max(0, bb2.x - 1), y0 = Math.max(0, bb2.y - 1);
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;
for (int dy = -1; dy <= 1 && !boundary2; dy++) {
for (int dx = -1; dx <= 1 && !boundary2; dx++) {
if (dx == 0 && dy == 0) continue;
int ny = py+dy, nx = px+dx;
if (ny<0||ny>=targetSize||nx<0||nx>=targetSize) continue;
boolean nIn = wp.contains(nx, ny) && heights[ny*targetSize+nx] < wh2;
if (nIn != inPoly) boundary2 = true;
}
}
if (!boundary2) continue;
int wCount2 = 0;
for (int sy = 0; sy < SUB; sy++) {
for (int sx = 0; sx < SUB; sx++) {
float spx = px + (sx + 0.5f) / SUB - 0.5f;
float spy = py + (sy + 0.5f) / SUB - 0.5f;
if (wp.contains(spx, spy)
&& sampleTerrainH(m, targetSize, spx, spy, model.voxelSurface()) < wh2)
wCount2++;
}
}
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 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)
| clamp((int)(waterB*wFrac2 + lb2*(1-wFrac2)));
}
}
}
}
// 5. Ergebnis in Bild schreiben
BufferedImage img = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_RGB);
for (int i = 0; i < rawRGB.length; 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()) {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (seaPixels[py * targetSize + px]) img.setRGB(px, py, WATER_COLOR);
}
}
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();
int x0 = Math.max(0, bb.x), y0 = Math.max(0, bb.y);
int x1 = Math.min(targetSize - 1, bb.x + bb.width);
int y1 = Math.min(targetSize - 1, bb.y + bb.height);
float wh = w.waterHeight();
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, WATER_COLOR);
}
}
}
// ── Wellenmarken ──────────────────────────────────────────────────────
if (opts.showWaterWaves()) {
@@ -297,45 +396,6 @@ public final class WorldMapRenderer {
}
}
// ── Uferlinie ─────────────────────────────────────────────────────────
if (opts.showWaterWaves()) {
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Wasserflächen: Polygon-Umriss
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
gfx.drawPolygon(xs, ys, xs.length);
}
// Meer: Pixel-Küstenlinie (morphologisch aufgeweitet)
int bw = 3;
boolean[] cur = new boolean[targetSize * targetSize];
boolean[] nxt = new boolean[targetSize * targetSize];
for (int py = 1; py < targetSize - 1; py++) {
for (int px = 1; px < targetSize - 1; px++) {
if (!seaPixels[py * targetSize + px]) continue;
if (!seaPixels[py * targetSize + (px-1)] || !seaPixels[py * targetSize + (px+1)] ||
!seaPixels[(py-1) * targetSize + px] || !seaPixels[(py+1) * targetSize + px])
cur[py * targetSize + px] = true;
}
}
for (int pass = 1; pass < bw; pass++) {
System.arraycopy(cur, 0, nxt, 0, cur.length);
for (int py = 1; py < targetSize - 1; py++) {
for (int px = 1; px < targetSize - 1; px++) {
if (!cur[py * targetSize + px]) continue;
if (seaPixels[py * targetSize + (px-1)]) nxt[py * targetSize + (px-1)] = true;
if (seaPixels[py * targetSize + (px+1)]) nxt[py * targetSize + (px+1)] = true;
if (seaPixels[(py-1) * targetSize + px]) nxt[(py-1) * targetSize + px] = true;
if (seaPixels[(py+1) * targetSize + px]) nxt[(py+1) * targetSize + px] = true;
}
}
boolean[] tmp = cur; cur = nxt; nxt = tmp;
}
for (int i = 0, n = cur.length; i < n; i++) {
if (cur[i]) img.setRGB(i % targetSize, i / targetSize, 0xFF000000);
}
}
}
// Wasserfälle – klassisches Kartensymbol: Kammlinie + Kaskaden-Linien
@@ -471,7 +531,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
input.slotColorsRGB(), null, null, null, null, null, null
), targetSize, opts);
}
@@ -681,45 +741,6 @@ public final class WorldMapRenderer {
}
}
// ── Uferlinie ─────────────────────────────────────────────────────────
if (opts.showWaterWaves()) {
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Wasserflächen: Polygon-Umriss
for (PlacedWater w : input.waters()) {
int[] xs = wrp(w.pointsX(), wx0, rSize, targetSize);
int[] ys = wrp(w.pointsZ(), wz0, rSize, targetSize);
gfx.drawPolygon(xs, ys, xs.length);
}
// Meer: Pixel-Küstenlinie (morphologisch aufgeweitet)
int bw = 3;
boolean[] cur = new boolean[targetSize * targetSize];
boolean[] nxt = new boolean[targetSize * targetSize];
for (int py = 1; py < targetSize - 1; py++) {
for (int px = 1; px < targetSize - 1; px++) {
if (seaPixels2 == null || !seaPixels2[py * targetSize + px]) continue;
if (!seaPixels2[py * targetSize + (px-1)] || !seaPixels2[py * targetSize + (px+1)] ||
!seaPixels2[(py-1) * targetSize + px] || !seaPixels2[(py+1) * targetSize + px])
cur[py * targetSize + px] = true;
}
}
for (int pass = 1; pass < bw; pass++) {
System.arraycopy(cur, 0, nxt, 0, cur.length);
for (int py = 1; py < targetSize - 1; py++) {
for (int px = 1; px < targetSize - 1; px++) {
if (!cur[py * targetSize + px]) continue;
if (seaPixels2[py * targetSize + (px-1)]) nxt[py * targetSize + (px-1)] = true;
if (seaPixels2[py * targetSize + (px+1)]) nxt[py * targetSize + (px+1)] = true;
if (seaPixels2[(py-1) * targetSize + px]) nxt[(py-1) * targetSize + px] = true;
if (seaPixels2[(py+1) * targetSize + px]) nxt[(py+1) * targetSize + px] = true;
}
}
boolean[] tmp = cur; cur = nxt; nxt = tmp;
}
for (int i = 0, n = cur.length; i < n; i++) {
if (cur[i]) img.setRGB(i % targetSize, i / targetSize, 0xFF000000);
}
}
}
if (opts.showAreas()) {
@@ -885,13 +906,264 @@ public final class WorldMapRenderer {
* @param baseFallbacks Fallback-Pfade für leere Base-Slots (kann null sein)
* @return int[24] mit [R0,G0,B0, R1,G1,B1, ..., R7,G7,B7] für Slots 1-8
*/
public static int[] computeSlotColors(MapData mapData, Path assetRoot, String[] baseFallbacks) {
/**
* Bilineare Höhen-Samples aus terrainHeight (8193×8193, 0.25m/Vertex) + max(voxelSurface).
* Zentrale Methode für alle Karten-Berechnungen die Geländehöhen benötigen.
*/
public static float[] buildTerrainSamples(MapData m, int size, float[] voxelSurface) {
int TV = MapData.TERRAIN_VERTS;
int VS = VOXEL_SURFACE_RES;
float[] s = new float[size * size];
for (int mz = 0; mz < size; mz++) {
for (int mx = 0; mx < size; mx++) {
float fx = (float)mx / (size-1) * (TV-1);
float fz = (float)mz / (size-1) * (TV-1);
int x0 = Math.max(0, Math.min(TV-2, (int)fx));
int z0 = Math.max(0, Math.min(TV-2, (int)fz));
float tx = fx - x0, tz = fz - z0;
float h = m.terrainHeight[z0*TV+x0] * (1-tx)*(1-tz)
+ m.terrainHeight[z0*TV+x0+1] * tx *(1-tz)
+ m.terrainHeight[(z0+1)*TV+x0] * (1-tx)*tz
+ m.terrainHeight[(z0+1)*TV+x0+1] * tx *tz;
if (voxelSurface != null) {
int vx = Math.min((int)((float)mx/(size-1)*(VS-1)), VS-1);
int vz = Math.min((int)((float)mz/(size-1)*(VS-1)), VS-1);
for (int dz = -1; dz <= 1; dz++) {
for (int dx = -1; dx <= 1; dx++) {
float vs = voxelSurface[Math.max(0,Math.min(VS-1,vz+dz))*VS
+Math.max(0,Math.min(VS-1,vx+dx))];
if (!Float.isNaN(vs) && vs > h) { h = vs; }
}
}
}
s[mz*size+mx] = h;
}
}
return s;
}
/**
* Berechnet eine Hillshading-Karte (float pro Pixel, 0.55–1.35) aus reinen Terrain-Höhen.
* Voxels werden NICHT eingerechnet, um Artefakte an Voxel-Kanten zu vermeiden.
*/
public static float[] buildShadeMap(MapData m, int size) {
float[] th = buildTerrainSamples(m, size, null);
float[] shd = new float[size * size];
for (int mz = 0; mz < size; mz++) {
for (int mx = 0; mx < size; mx++) {
int px1 = Math.min(size-1, mx+1), px0 = Math.max(0, mx-1);
int pz1 = Math.min(size-1, mz+1), pz0 = Math.max(0, mz-1);
float dx = th[mz*size + px1] - th[mz*size + px0];
float dz = th[pz1*size + mx] - th[pz0*size + mx];
shd[mz*size + mx] = Math.max(0.55f, Math.min(1.35f, 1f - (dx * 0.3f + dz * 0.15f)));
}
}
return shd;
}
/**
* Berechnet eine Textur-Kategorie-Karte (byte pro Pixel) in der angegebenen Auflösung.
* Werte: TILE_GRASS=2, TILE_SAND=3, TILE_DIRT=4, TILE_ROCK=5.
* TILE_WATER kommt hier nicht vor — Wasser-Erkennung erfolgt über buildWaterHeightMap.
* Wird vom Canvas-Overlay genutzt, um bei jedem Zoom-Level die richtige Tile-Textur zu samplen.
*/
public static byte[] buildCategoryMap(MapData m, int size, float[] voxelSurface,
List<PlacedWater> waters, int[] slotColors) {
float cellW = WORLD_SIZE / (size - 1);
int SS = MapData.SPLAT_SIZE;
byte[] cats = new byte[size * size];
for (int mz = 0; mz < size; mz++) {
float wz = -WORLD_HALF + mz * cellW;
for (int mx = 0; mx < size; mx++) {
float wx = -WORLD_HALF + mx * cellW;
cats[mz * size + mx] = (byte) getTerrainTile(m, SS, wx, wz, slotColors);
}
}
return cats;
}
/**
* Baut eine Wasserhoehen-Karte: pro Zelle die maximale PlacedWater-Wasserhoehe,
* 0 fuer Zellen ausserhalb jeder Wasserflaeche.
* In drawWaterOverlay: Pixel ist Wasser wenn heightMap[i] < waterHeightMap[i].
*/
public static float[] buildWaterHeightMap(List<PlacedWater> waters, int size) {
float[] map = new float[size * size];
if (waters == null) return map;
for (PlacedWater w : waters) {
int[] wxPx = worldToPixels(w.pointsX(), size);
int[] wzPx = worldToPixels(w.pointsZ(), size);
Polygon poly = new Polygon(wxPx, wzPx, wxPx.length);
Rectangle bb = poly.getBounds();
float wh = w.waterHeight();
for (int py = Math.max(0, bb.y); py <= Math.min(size-1, bb.y+bb.height); py++) {
for (int px = Math.max(0, bb.x); px <= Math.min(size-1, bb.x+bb.width); px++) {
if (poly.contains(px, py)) {
map[py * size + px] = Math.max(map[py * size + px], wh);
}
}
}
}
return map;
}
/** 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][];
for (int i = 0; i < TILE_FILE_NAMES.length; i++) {
Path p = assetRoot.resolve("Textures/map/" + TILE_FILE_NAMES[i] + ".png");
int[] pixels = null;
if (Files.exists(p)) {
try {
BufferedImage img = ImageIO.read(p.toFile());
if (img != null) {
pixels = new int[256];
for (int py = 0; py < 16; py++) {
for (int px = 0; px < 16; px++) {
pixels[py * 16 + px] = img.getRGB(px % img.getWidth(), py % img.getHeight());
}
}
}
} catch (Exception ignored) {}
}
if (pixels == null) {
int[] fb = TILE_FALLBACK_RGB[i];
pixels = new int[256];
int packed = 0xFF000000 | (fb[0] << 16) | (fb[1] << 8) | fb[2];
java.util.Arrays.fill(pixels, packed);
}
result[i] = pixels;
}
return result;
}
/** Ermittelt den Tile-Index via sequentieller Splatmap-Blendung (identisch zu showSplatColors).
* slotColors: 24-Werte-Array (8 Slots × RGB) aus computeSlotColors(); null → Defaults. */
private static int getTerrainTile(MapData m, int SS, float wx, float wz, int[] slotColors) {
int sx = Math.max(0, Math.min(SS-1, (int)((wx + WORLD_HALF) / WORLD_SIZE * (SS-1))));
int sz = Math.max(0, Math.min(SS-1, SS-1 - (int)((wz + WORLD_HALF) / WORLD_SIZE * (SS-1))));
int si = sz * SS + sx;
// Base-Layer sequentiell blenden (Slot 0 → 1 → 2 → 3)
float wg = (m.splatG[si] & 0xFF) / 255f;
float wb = (m.splatB[si] & 0xFF) / 255f;
float wa = (m.splatA[si] & 0xFF) / 255f;
float fr = sc(slotColors, 0), fg = sc(slotColors, 1), fb = sc(slotColors, 2);
fr=fr*(1-wg)+sc(slotColors, 3)*wg; fg=fg*(1-wg)+sc(slotColors, 4)*wg; fb=fb*(1-wg)+sc(slotColors, 5)*wg;
fr=fr*(1-wb)+sc(slotColors, 6)*wb; fg=fg*(1-wb)+sc(slotColors, 7)*wb; fb=fb*(1-wb)+sc(slotColors, 8)*wb;
fr=fr*(1-wa)+sc(slotColors, 9)*wa; fg=fg*(1-wa)+sc(slotColors,10)*wa; fb=fb*(1-wa)+sc(slotColors,11)*wa;
// Upper-Layer
float u1=(m.upperSplatR[si]&0xFF)/255f, u2=(m.upperSplatG[si]&0xFF)/255f;
float u3=(m.upperSplatB[si]&0xFF)/255f, u4=(m.upperSplatA[si]&0xFF)/255f;
fr=fr*(1-u1)+sc(slotColors,12)*u1; fg=fg*(1-u1)+sc(slotColors,13)*u1; fb=fb*(1-u1)+sc(slotColors,14)*u1;
fr=fr*(1-u2)+sc(slotColors,15)*u2; fg=fg*(1-u2)+sc(slotColors,16)*u2; fb=fb*(1-u2)+sc(slotColors,17)*u2;
fr=fr*(1-u3)+sc(slotColors,18)*u3; fg=fg*(1-u3)+sc(slotColors,19)*u3; fb=fb*(1-u3)+sc(slotColors,20)*u3;
fr=fr*(1-u4)+sc(slotColors,21)*u4; fg=fg*(1-u4)+sc(slotColors,22)*u4; fb=fb*(1-u4)+sc(slotColors,23)*u4;
// Third-Layer
float t1=(m.thirdSplatR[si]&0xFF)/255f, t2=(m.thirdSplatG[si]&0xFF)/255f;
float t3=(m.thirdSplatB[si]&0xFF)/255f, t4=(m.thirdSplatA[si]&0xFF)/255f;
fr=fr*(1-t1)+sc(slotColors,24)*t1; fg=fg*(1-t1)+sc(slotColors,25)*t1; fb=fb*(1-t1)+sc(slotColors,26)*t1;
fr=fr*(1-t2)+sc(slotColors,27)*t2; fg=fg*(1-t2)+sc(slotColors,28)*t2; fb=fb*(1-t2)+sc(slotColors,29)*t2;
fr=fr*(1-t3)+sc(slotColors,30)*t3; fg=fg*(1-t3)+sc(slotColors,31)*t3; fb=fb*(1-t3)+sc(slotColors,32)*t3;
fr=fr*(1-t4)+sc(slotColors,33)*t4; fg=fg*(1-t4)+sc(slotColors,34)*t4; fb=fb*(1-t4)+sc(slotColors,35)*t4;
return slotToTile(Math.round(fr), Math.round(fg), Math.round(fb));
}
private static float sc(int[] cols, int i) {
if (cols != null && i < cols.length) return cols[i];
int slot = i / 3, ch = i % 3, si = Math.min(slot, DEF_SLOT_R.length - 1);
return ch == 0 ? DEF_SLOT_R[si] : ch == 1 ? DEF_SLOT_G[si] : DEF_SLOT_B[si];
}
/**
* Tile-Typen für alle 12 Slots (base 0-3, upper 4-7, third 8-11) aus Textur-Pfaden (primär) und Farb-Fallback.
*/
public static int[] computeSlotTileTypes(MapData mapData, int[] slotColors) {
int[] types = new int[12];
for (int s = 0; s < 12; s++) {
String path = null;
if (s < 4 && mapData.terrainTextures != null && s < mapData.terrainTextures.length)
path = mapData.terrainTextures[s];
else if (s < 8 && mapData.upperTextures != null && (s-4) < mapData.upperTextures.length)
path = mapData.upperTextures[s - 4];
else if (s < 12 && mapData.thirdTextures != null && (s-8) < mapData.thirdTextures.length)
path = mapData.thirdTextures[s - 8];
int byPath = path != null && !path.isEmpty() ? tileTypeByPath(path) : -1;
types[s] = byPath >= 0 ? byPath : classifySlot(slotColors, s);
}
if (log.isDebugEnabled()) {
StringBuilder dbg = new StringBuilder("SlotTileTypes: ");
for (int s = 0; s < 12; s++) {
String path = s < 4 && mapData.terrainTextures != null ? mapData.terrainTextures[s]
: s < 8 && mapData.upperTextures != null ? mapData.upperTextures[s - 4]
: s < 12 && mapData.thirdTextures != null ? mapData.thirdTextures[s - 8] : null;
dbg.append("s").append(s).append("='").append(path != null ? path : "").append("'→").append(types[s]).append(" ");
}
log.debug("{}", dbg);
}
return types;
}
/** Klassifiziert Textur-Pfad über Schlüsselwörter im Pfad. Gibt -1 zurück wenn unbekannt. */
private static int tileTypeByPath(String path) {
String p = path.toLowerCase().replace('\\', '/');
if (p.contains("dirt")) return TILE_DIRT;
if (p.contains("leaves")) return TILE_DIRT;
if (p.contains("gras")) return TILE_GRASS;
if (p.contains("vegetation")) return TILE_GRASS;
if (p.contains("foliage")) return TILE_GRASS;
if (p.contains("gravel")) return TILE_ROCK;
if (p.contains("rock")) return TILE_ROCK;
if (p.contains("stone")) return TILE_ROCK;
if (p.contains("pavement")) return TILE_PAVEMENT;
if (p.contains("sand")) return TILE_SAND;
return -1;
}
/** Gibt den Tile-Typ für Slot s per Farb-Schwellwert zurück (Fallback). */
public static int classifySlot(int[] slotColors, int s) {
return slotToTile(Math.round(sc(slotColors, s * 3)),
Math.round(sc(slotColors, s * 3 + 1)),
Math.round(sc(slotColors, s * 3 + 2)));
}
private static int slotToTile(int r, int g, int b) {
int max = Math.max(Math.max(r, g), b);
int rng = max - Math.min(Math.min(r, g), b);
if (rng < 15) return TILE_ROCK;
if (r > g + 20 && b < 115) return TILE_DIRT;
if (b > 90 && rng < 80) return TILE_SAND;
return TILE_GRASS;
}
private static float sampleTerrainH(MapData m, int targetSize, float fpx, float fpy, float[] voxelSurface) {
int TV = MapData.TERRAIN_VERTS, VS = VOXEL_SURFACE_RES;
float fx = Math.max(0, Math.min(TV - 1.001f, fpx / (targetSize - 1) * (TV - 1)));
float fz = Math.max(0, Math.min(TV - 1.001f, fpy / (targetSize - 1) * (TV - 1)));
int x0 = (int)fx, z0 = (int)fz;
int x1 = Math.min(x0 + 1, TV - 1), z1 = Math.min(z0 + 1, TV - 1);
float tx = fx - x0, tz = fz - z0;
float h = m.terrainHeight[z0*TV+x0]*(1-tx)*(1-tz)
+ m.terrainHeight[z0*TV+x1]*tx*(1-tz)
+ m.terrainHeight[z1*TV+x0]*(1-tx)*tz
+ m.terrainHeight[z1*TV+x1]*tx*tz;
if (voxelSurface != null) {
int vx = Math.min(Math.max(0, (int)(fpx/(targetSize-1)*(VS-1))), VS-1);
int vz = Math.min(Math.max(0, (int)(fpy/(targetSize-1)*(VS-1))), VS-1);
float vs = voxelSurface[vz * VS + vx];
if (!Float.isNaN(vs) && vs > h) h = vs;
}
return h;
}
public static int[] computeSlotColors(MapData mapData, Path assetRoot, String[] baseFallbacks) {
int[] result = {
71,148, 46, 115, 82, 64, 140,115, 77, 204,184,128,
90, 80, 40, 130, 90, 60, 110, 60, 50, 180,100, 70
90, 80, 40, 130, 90, 60, 110, 60, 50, 180,100, 70,
118, 82, 46, 118, 82, 46, 118, 82, 46, 118, 82, 46
};
sampleTexColors(mapData.terrainTextures, baseFallbacks, assetRoot, result, 0);
sampleTexColors(mapData.upperTextures, null, assetRoot, result, 4);
sampleTexColors(mapData.thirdTextures, null, assetRoot, result, 8);
return result;
}
@@ -963,6 +1235,11 @@ public final class WorldMapRenderer {
}
public static boolean isTree(PlacedModel m) {
de.blight.common.ModelCategory cat = m.modelCategory();
if (cat == de.blight.common.ModelCategory.TREE || cat == de.blight.common.ModelCategory.PALM)
return true;
if (cat != null && cat != de.blight.common.ModelCategory.NONE)
return false;
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
}