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 interactableOffsetY,
float interactableOffsetZ, float interactableOffsetZ,
float interactableRotY, float interactableRotY,
String footstepSurface String footstepSurface,
de.blight.common.ModelCategory modelCategory
) { ) {
/** Lichtquelle relativ zum Modell-Ursprung. */ /** Lichtquelle relativ zum Modell-Ursprung. */
public record AttachedLight( public record AttachedLight(
@@ -53,6 +54,7 @@ public record ModelMeta(
"", "", 30f, 80f, 120f, "", "", 30f, 80f, 120f,
List.of(), List.of(), List.of(), List.of(),
de.blight.common.model.InteractableType.NONE, 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("interactableOffsetZ", String.valueOf(m.interactableOffsetZ()));
p.setProperty("interactableRotY", String.valueOf(m.interactableRotY())); p.setProperty("interactableRotY", String.valueOf(m.interactableRotY()));
p.setProperty("footstepSurface", m.footstepSurface() != null ? m.footstepSurface() : ""); p.setProperty("footstepSurface", m.footstepSurface() != null ? m.footstepSurface() : "");
p.setProperty("modelCategory", m.modelCategory() != null ? m.modelCategory().name() : "NONE");
// Anhänge: Lichter // Anhänge: Lichter
List<ModelMeta.AttachedLight> lights = m.attachedLights(); List<ModelMeta.AttachedLight> lights = m.attachedLights();
@@ -140,7 +141,8 @@ public final class ModelMetaIO {
parseFloat(p, "interactableOffsetY", 0.5f), parseFloat(p, "interactableOffsetY", 0.5f),
parseFloat(p, "interactableOffsetZ", 0f), parseFloat(p, "interactableOffsetZ", 0f),
parseFloat(p, "interactableRotY", 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. */ /** "CRAFTING_TABLE" / "BED" / "" für kein Interactable. */
String interactableType, String interactableType,
/** ID des verknüpften Interactables (CraftingTableType-Name oder Bett-UUID); "" wenn nicht gesetzt. */ /** 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(); Path p = getPath();
Files.createDirectories(p.getParent()); Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) { 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(); w.newLine();
for (PlacedModel m : models) { for (PlacedModel m : models) {
w.write(String.format(Locale.ROOT, 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.modelPath(),
m.x(), m.y(), m.z(), m.x(), m.y(), m.z(),
m.rotY(), m.scale(), m.rotY(), m.scale(),
@@ -41,7 +41,8 @@ public final class PlacedModelIO {
m.castShadow(), m.receiveShadow(), m.castShadow(), m.receiveShadow(),
nvl(m.lod1Path()), nvl(m.lod2Path()), nvl(m.lod1Path()), nvl(m.lod2Path()),
m.lod1Distance(), m.lod2Distance(), m.cullDistance(), 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; float cullDistance = f.length > 20 ? parseFloat(f[20], 120f) : 120f;
String interactableType = f.length > 21 ? f[21] : ""; String interactableType = f.length > 21 ? f[21] : "";
String interactableId = f.length > 22 ? f[22] : ""; 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, list.add(new PlacedModel(modelPath, x, y, z,
rotY, rotX, rotZ, scale, solid, rotY, rotX, rotZ, scale, solid,
texPath, nmPath, matPath, meshFile, animClip, texPath, nmPath, matPath, meshFile, animClip,
castShadow, receiveShadow, castShadow, receiveShadow,
lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance, lod1Path, lod2Path, lod1Distance, lod2Distance, cullDistance,
interactableType, interactableId)); interactableType, interactableId, modelCategory));
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
} }
return list; 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[] 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) float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.) 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.*;
import de.blight.common.model.Location; import de.blight.common.model.Location;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.*; import java.awt.*;
import java.awt.geom.Path2D; import java.awt.geom.Path2D;
@@ -21,6 +23,8 @@ import java.util.List;
*/ */
public final class WorldMapRenderer { 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_HALF = 1024f;
public static final float WORLD_SIZE = 2048f; public static final float WORLD_SIZE = 2048f;
/** Auflösung des voxelSurface-Bake-Arrays (1 Zelle pro Welteinheit). */ /** 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_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
private static final int[] DEF_SLOT_B = { 46, 64, 77, 128, 40, 60, 50, 70 }; 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() {} private WorldMapRenderer() {}
/** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */ /** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */
public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) { public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) {
MapData m = model.mapData(); 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 ──────────────────────────── // 1. Hoehenfeld
int UV = MapData.UPPER_VERTS; float[] heights = buildTerrainSamples(m, targetSize, model.voxelSurface());
float[] heights = new float[targetSize * targetSize];
// Meerserkennung basiert nur auf terrainHeight (ohne upperTop/voxelSurface):
// bakeVoxelHeights trägt Voxel-Höhen in upperTop ein, was sonst
// Voxel auf dem Meeresboden fälschlich als Landfläche klassifiziert.
boolean[] seaPixels = new boolean[targetSize * targetSize]; 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; // 2. Wasser-Maske: Ozean + PlacedWater vorab berechnen
for (int py = 0; py < targetSize; py++) { boolean[] isWater = java.util.Arrays.copyOf(seaPixels, seaPixels.length);
for (int px = 0; px < targetSize; px++) { if (opts.showWater()) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1); for (PlacedWater w : model.waters()) {
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1); int[] wxPx = worldToPixels(w.pointsX(), targetSize);
float ux_f = (float) px / (targetSize - 1) * (UV - 1); int[] wzPx = worldToPixels(w.pointsZ(), targetSize);
float uz_f = (float) py / (targetSize - 1) * (UV - 1); Polygon waterPoly = new Polygon(wxPx, wzPx, wxPx.length);
float baseH = m.terrainHeight[hz * TV + hx]; Rectangle bb = waterPoly.getBounds();
float h = baseH; float wh = w.waterHeight();
float upper = bilerpPos(m.upperTop, ux_f, uz_f, UV); for (int py = Math.max(0, bb.y); py <= Math.min(targetSize-1, bb.y+bb.height); py++) {
if (upper > 0f && upper > h) { h = upper; } for (int px = Math.max(0, bb.x); px <= Math.min(targetSize-1, bb.x+bb.width); px++) {
// Voxel-Oberfläche: direkte 1:1-Abfrage des hochauflösenden Bake-Arrays if (waterPoly.contains(px, py) && heights[py * targetSize + px] < wh)
// seaH = max(terrain, voxelSurface): Voxel über Y=0 → Land, darunter → Meer isWater[py * targetSize + px] = true;
float seaH = baseH;
if (model.voxelSurface() != null) {
int vsPx = Math.min((int)((float) px / (targetSize - 1) * (VS - 1)), VS - 1);
int vsPz = Math.min((int)((float) py / (targetSize - 1) * (VS - 1)), VS - 1);
float vs = model.voxelSurface()[vsPz * VS + vsPx];
if (!Float.isNaN(vs)) {
h = vs;
if (vs > seaH) { seaH = vs; }
} }
} }
heights[py * targetSize + px] = h;
seaPixels[py * targetSize + px] = (seaH < 0f);
if (h < minH) minH = h;
if (h > maxH) maxH = h;
} }
} }
float heightRange = Math.max(0.01f, maxH - minH); // 3. Tile-Rendering: tileIdx + Rohfarbe pro Pixel speichern
float cellW = WORLD_SIZE / (targetSize - 1);
// ── 2. Pixel-Farben berechnen ───────────────────────────────────────── int SS = MapData.SPLAT_SIZE;
BufferedImage img = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_RGB); 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++) { for (int py = 0; py < targetSize; py++) {
float wz = -WORLD_HALF + py * cellW;
for (int px = 0; px < targetSize; px++) { for (int px = 0; px < targetSize; px++) {
float h = heights[py * targetSize + px]; float wx = -WORLD_HALF + px * cellW;
float hn = (h - minH) / heightRange; int idx = py * targetSize + px;
int r, g, b; int tileIdx;
int tr, tg, tb;
if (opts.showSplatColors()) { if (isWater[idx]) {
int sx = Math.min((int)((float) px / (targetSize - 1) * (SS - 1)), SS - 1); tileIdx = TILE_WATER;
int sz = Math.min((int)((float)(targetSize - 1 - py) / (targetSize - 1) * (SS - 1)), SS - 1); tr = waterR; tg = waterG; tb = waterB;
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);
} else { } else {
int lum = clamp(30 + (int)(hn * 200)); int dPx = Math.max(1, (int)(0.25f / cellW));
r = g = b = lum; 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];
}
} }
rawRGB[idx] = (tr << 16) | (tg << 8) | tb;
// 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);
} }
} }
// Kuwahara-Filter: lässt das Terrain wie gemalt wirken // 4. Kuestenlinie: Sub-Pixel-Sampling fuer pixelgenaue Wasser-Land-Grenze.
if (opts.showSplatColors()) { applyKuwahara(img, 3); } // 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(); Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
float lineW = Math.max(1.5f, targetSize / 400f); float lineW = Math.max(1.5f, targetSize / 400f);
// Wasser // Wasser
if (opts.showWater()) { 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 ────────────────────────────────────────────────────── // ── Wellenmarken ──────────────────────────────────────────────────────
if (opts.showWaterWaves()) { 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 // Wasserfälle – klassisches Kartensymbol: Kammlinie + Kaskaden-Linien
@@ -471,7 +531,7 @@ public final class WorldMapRenderer {
return render(new WorldMapRenderModel( return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(), input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), null, input.waters(), input.models(), null,
input.slotColorsRGB(), null, null, null, null, null input.slotColorsRGB(), null, null, null, null, null, null
), targetSize, opts); ), 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()) { if (opts.showAreas()) {
@@ -885,13 +906,264 @@ public final class WorldMapRenderer {
* @param baseFallbacks Fallback-Pfade für leere Base-Slots (kann null sein) * @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 * @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 = { int[] result = {
71,148, 46, 115, 82, 64, 140,115, 77, 204,184,128, 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.terrainTextures, baseFallbacks, assetRoot, result, 0);
sampleTexColors(mapData.upperTextures, null, assetRoot, result, 4); sampleTexColors(mapData.upperTextures, null, assetRoot, result, 4);
sampleTexColors(mapData.thirdTextures, null, assetRoot, result, 8);
return result; return result;
} }
@@ -963,6 +1235,11 @@ public final class WorldMapRenderer {
} }
public static boolean isTree(PlacedModel m) { 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/"); return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
} }

View File

@@ -71,7 +71,8 @@ public class EditorApp extends Application {
private ImageView viewport; private ImageView viewport;
private Label statusLabel; private Label statusLabel;
private Label camCoordsLabel; private Label camCoordsLabel;
private boolean launchGameAfterSave = false; private boolean launchGameAfterSave = false;
private Runnable pendingAfterSave = null;
private Button gamePlayBtn; // Spielen-Button im Seitenpanel private Button gamePlayBtn; // Spielen-Button im Seitenpanel
private CheckBox debugConsoleCB; // Debug-Konsole bei Spielstart anzeigen private CheckBox debugConsoleCB; // Debug-Konsole bei Spielstart anzeigen
private VBox toolPanel; private VBox toolPanel;
@@ -345,6 +346,7 @@ public class EditorApp extends Application {
private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>(); private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>();
private javafx.scene.layout.VBox modelEditorLightBox = null; private javafx.scene.layout.VBox modelEditorLightBox = null;
private javafx.scene.layout.VBox modelEditorEmitterBox = null; private javafx.scene.layout.VBox modelEditorEmitterBox = null;
private ComboBox<de.blight.common.ModelCategory> modelEditorCategoryCB = null;
private ComboBox<de.blight.common.model.InteractableType> modelEditorInteractableCB = null; private ComboBox<de.blight.common.model.InteractableType> modelEditorInteractableCB = null;
private Spinner<Double> modelEditorInteractableXSpin = null; private Spinner<Double> modelEditorInteractableXSpin = null;
private Spinner<Double> modelEditorInteractableYSpin = null; private Spinner<Double> modelEditorInteractableYSpin = null;
@@ -587,6 +589,11 @@ public class EditorApp extends Application {
launchGameAfterSave = false; launchGameAfterSave = false;
startGameProcess(); startGameProcess();
} }
if (pendingAfterSave != null) {
Runnable r = pendingAfterSave;
pendingAfterSave = null;
r.run();
}
} }
String treeMsg = input.treeGenStatusMsg; String treeMsg = input.treeGenStatusMsg;
@@ -5659,6 +5666,11 @@ public class EditorApp extends Application {
weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> { weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender(); if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender();
}); });
worldMapView.setRefreshAction(() -> {
input.saveRequested = true;
pendingAfterSave = worldMapView::loadAndRender;
setStatus("Speichere vor Weltkarten-Refresh…");
});
worldMapView.setFullscreenCallbacks( worldMapView.setFullscreenCallbacks(
() -> { () -> {
// Placeholder ersetzt worldMapView im Tab → sauberes Detach // Placeholder ersetzt worldMapView im Tab → sauberes Detach
@@ -7906,6 +7918,18 @@ public class EditorApp extends Application {
// Initiale Gizmos pushen // Initiale Gizmos pushen
pushAttachmentsToJme(); pushAttachmentsToJme();
// ── Modell-Kategorie ──────────────────────────────────────────────────
Label catTitle = new Label("Kategorie:");
catTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;");
modelEditorCategoryCB = new ComboBox<>();
modelEditorCategoryCB.getItems().addAll(de.blight.common.ModelCategory.values());
modelEditorCategoryCB.setValue(meta.modelCategory() != null ? meta.modelCategory() : de.blight.common.ModelCategory.NONE);
modelEditorCategoryCB.setMaxWidth(Double.MAX_VALUE);
modelEditorCategoryCB.setConverter(new javafx.util.StringConverter<>() {
@Override public String toString(de.blight.common.ModelCategory c) { return c == null ? "" : c.getLabel(); }
@Override public de.blight.common.ModelCategory fromString(String s) { return de.blight.common.ModelCategory.fromString(s); }
});
// ── Interaktivität ──────────────────────────────────────────────────── // ── Interaktivität ────────────────────────────────────────────────────
Label interactTitle = new Label("Interaktivität:"); Label interactTitle = new Label("Interaktivität:");
interactTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;"); interactTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;");
@@ -8119,7 +8143,10 @@ public class EditorApp extends Application {
input.modelInteractableRotY, input.modelInteractableRotY,
modelEditorFootstepSurfaceCB != null modelEditorFootstepSurfaceCB != null
? modelEditorFootstepSurfaceCB.getValue() ? modelEditorFootstepSurfaceCB.getValue()
: "")); : "",
modelEditorCategoryCB != null
? modelEditorCategoryCB.getValue()
: de.blight.common.ModelCategory.NONE));
placeBtn.setOnAction(e -> { placeBtn.setOnAction(e -> {
input.modelEditorCloseRequest = true; input.modelEditorCloseRequest = true;
@@ -8140,7 +8167,7 @@ public class EditorApp extends Application {
title, title,
new Separator(), new Separator(),
nameLabel, nameTF, nameLabel, nameTF,
catLabel, catCB,
tagsLabel, tagsTF, tagsLabel, tagsTF,
new Separator(), new Separator(),
scaleTitle, modelEditorUniformCB, scaleGrid, scaleTitle, modelEditorUniformCB, scaleGrid,
@@ -8160,6 +8187,8 @@ public class EditorApp extends Application {
lightSectionLbl, modelEditorLightBox, addLightBtn, lightSectionLbl, modelEditorLightBox, addLightBtn,
emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn, emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn,
new Separator(), new Separator(),
catTitle, modelEditorCategoryCB,
new Separator(),
interactTitle, modelEditorInteractableCB, restPointBox, interactTitle, modelEditorInteractableCB, restPointBox,
new Separator(), new Separator(),
footstepTitle, modelEditorFootstepSurfaceCB, footstepTitle, modelEditorFootstepSurfaceCB,
@@ -8656,7 +8685,8 @@ public class EditorApp extends Application {
de.blight.common.model.InteractableType interactableType, de.blight.common.model.InteractableType interactableType,
float interactableOffsetX, float interactableOffsetY, float interactableOffsetX, float interactableOffsetY,
float interactableOffsetZ, float interactableRotY, float interactableOffsetZ, float interactableRotY,
String footstepSurface) { String footstepSurface,
de.blight.common.ModelCategory modelCategory) {
// Scale wird in j3o eingebrannt → Meta bekommt immer 1.0 (kein doppelter Scale beim Laden) // Scale wird in j3o eingebrannt → Meta bekommt immer 1.0 (kein doppelter Scale beim Laden)
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta( de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
name, category, tags, 1f, 1f, 1f, uniform, name, category, tags, 1f, 1f, 1f, uniform,
@@ -8665,7 +8695,8 @@ public class EditorApp extends Application {
lights, emitters, lights, emitters,
interactableType != null ? interactableType : de.blight.common.model.InteractableType.NONE, interactableType != null ? interactableType : de.blight.common.model.InteractableType.NONE,
interactableOffsetX, interactableOffsetY, interactableOffsetZ, interactableRotY, interactableOffsetX, interactableOffsetY, interactableOffsetZ, interactableRotY,
footstepSurface != null ? footstepSurface : ""); footstepSurface != null ? footstepSurface : "",
modelCategory != null ? modelCategory : de.blight.common.ModelCategory.NONE);
if (absolutePath == null || !absolutePath.toFile().exists()) { if (absolutePath == null || !absolutePath.toFile().exists()) {
setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert"); setStatus("Fehler: Modell-Datei nicht gefunden – Meta nicht gespeichert");

View File

@@ -26,6 +26,7 @@ public class SceneObject extends PlacedObject {
public float cullDistance = 120f; public float cullDistance = 120f;
public String interactableType = ""; public String interactableType = "";
public String interactableId = ""; public String interactableId = "";
public de.blight.common.ModelCategory modelCategory = de.blight.common.ModelCategory.NONE;
public SceneObject(String modelPath, float worldX, float worldZ, float groundY, public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
boolean solid) { boolean solid) {

View File

@@ -723,6 +723,12 @@ public class EzTreeState extends BaseAppState {
BinaryExporter.getInstance().save(lodRoot, outPath.toFile()); BinaryExporter.getInstance().save(lodRoot, outPath.toFile());
if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, outPath, ASSET_ROOT); if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, outPath, ASSET_ROOT);
String sp = subPath.toLowerCase();
de.blight.common.ModelCategory autoCategory =
sp.contains("palm") ? de.blight.common.ModelCategory.PALM
: sp.contains("bush") ? de.blight.common.ModelCategory.BUSH
: sp.contains("trellis") ? de.blight.common.ModelCategory.BUSH
: de.blight.common.ModelCategory.TREE;
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta( de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
fileName, "trees", "", fileName, "trees", "",
1f, 1f, 1f, true, 1f, 1f, 1f, true,
@@ -733,7 +739,7 @@ public class EzTreeState extends BaseAppState {
"", "", 30f, 80f, 120f, "", "", 30f, 80f, 120f,
java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of(),
de.blight.common.model.InteractableType.NONE, de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "bark"); 0f, 0.5f, 0f, 0f, "bark", autoCategory);
de.blight.common.ModelMetaIO.save(meta, outPath); de.blight.common.ModelMetaIO.save(meta, outPath);
log.info("[EZ-Tree] Gespeichert: {}", outPath.toAbsolutePath()); log.info("[EZ-Tree] Gespeichert: {}", outPath.toAbsolutePath());

View File

@@ -428,7 +428,7 @@ public class ModelImportState extends BaseAppState {
def.interactableType(), def.interactableType(),
def.interactableOffsetX(), def.interactableOffsetY(), def.interactableOffsetX(), def.interactableOffsetY(),
def.interactableOffsetZ(), def.interactableRotY(), def.interactableOffsetZ(), def.interactableRotY(),
def.footstepSurface()); def.footstepSurface(), def.modelCategory());
ModelMetaIO.save(meta, outFile.toPath()); ModelMetaIO.save(meta, outFile.toPath());
log.info("[ModelImport] Meta erstellt: solid={}", solid); log.info("[ModelImport] Meta erstellt: solid={}", solid);
} catch (IOException e) { } catch (IOException e) {

View File

@@ -193,7 +193,7 @@ public class SceneObjectState extends BaseAppState {
so.castShadow, so.receiveShadow, so.castShadow, so.receiveShadow,
so.lod1Path, so.lod2Path, so.lod1Path, so.lod2Path,
so.lod1Distance, so.lod2Distance, so.cullDistance, so.lod1Distance, so.lod2Distance, so.cullDistance,
so.interactableType, so.interactableId)); so.interactableType, so.interactableId, so.modelCategory));
} }
return list; return list;
} }
@@ -228,6 +228,7 @@ public class SceneObjectState extends BaseAppState {
so.cullDistance = pm.cullDistance(); so.cullDistance = pm.cullDistance();
so.interactableType = pm.interactableType() != null ? pm.interactableType() : ""; so.interactableType = pm.interactableType() != null ? pm.interactableType() : "";
so.interactableId = pm.interactableId() != null ? pm.interactableId() : ""; so.interactableId = pm.interactableId() != null ? pm.interactableId() : "";
so.modelCategory = pm.modelCategory() != null ? pm.modelCategory() : de.blight.common.ModelCategory.NONE;
objects.add(so); objects.add(so);
animClips.add(pm.animClip() != null ? pm.animClip() : ""); animClips.add(pm.animClip() != null ? pm.animClip() : "");
@@ -685,8 +686,9 @@ public class SceneObjectState extends BaseAppState {
activeRing = -1; activeRing = -1;
// 2. Objekt-Treffer? (Highlight-Geometrien ignorieren) // 2. Objekt-Treffer? (Highlight-Geometrien ignorieren)
// Im Platzier-Modus keine Selektion — Klick soll immer platzieren
CollisionResults objHits = new CollisionResults(); CollisionResults objHits = new CollisionResults();
objectRoot.collideWith(ray, objHits); if (input.pendingModelPath == null) objectRoot.collideWith(ray, objHits);
if (objHits.size() > 0) { if (objHits.size() > 0) {
CollisionResult closest = null; CollisionResult closest = null;
for (int ci = 0; ci < objHits.size(); ci++) { for (int ci = 0; ci < objHits.size(); ci++) {
@@ -826,9 +828,10 @@ public class SceneObjectState extends BaseAppState {
if (meta != null) { if (meta != null) {
so.lod1Path = meta.lod1Path() != null ? meta.lod1Path() : ""; so.lod1Path = meta.lod1Path() != null ? meta.lod1Path() : "";
so.lod2Path = meta.lod2Path() != null ? meta.lod2Path() : ""; so.lod2Path = meta.lod2Path() != null ? meta.lod2Path() : "";
so.lod1Distance = meta.lod1Distance(); so.lod1Distance = meta.lod1Distance();
so.lod2Distance = meta.lod2Distance(); so.lod2Distance = meta.lod2Distance();
so.cullDistance = meta.cullDistance(); so.cullDistance = meta.cullDistance();
so.modelCategory = meta.modelCategory() != null ? meta.modelCategory() : de.blight.common.ModelCategory.NONE;
if (meta.interactableType() != null if (meta.interactableType() != null
&& meta.interactableType() != de.blight.common.model.InteractableType.NONE) { && meta.interactableType() != de.blight.common.model.InteractableType.NONE) {
so.interactableType = meta.interactableType().name(); so.interactableType = meta.interactableType().name();

View File

@@ -484,6 +484,15 @@ public class TreeGeneratorState extends BaseAppState {
} catch (Exception te) { log.warn("[Blight-Baum] Thumbnail-Fehler: {}", te.getMessage()); } } catch (Exception te) { log.warn("[Blight-Baum] Thumbnail-Fehler: {}", te.getMessage()); }
BinaryExporter.getInstance().save(treeNode, out); BinaryExporter.getInstance().save(treeNode, out);
if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT); if (thumb != null) ThumbnailRenderer.saveSidecar(thumb, out.toPath(), ASSET_ROOT);
de.blight.common.ModelCategory autoCategory =
"bush".equals(treeType) ? de.blight.common.ModelCategory.BUSH : de.blight.common.ModelCategory.TREE;
de.blight.common.ModelMeta autoMeta = new de.blight.common.ModelMeta(
fileName, "trees", "", 1f, 1f, 1f, true, 0f, 0f,
true, true, true, 1f, 1f, "", "", 30f, 80f, 120f,
java.util.List.of(), java.util.List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "bark", autoCategory);
de.blight.common.ModelMetaIO.save(autoMeta, out.toPath());
log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath()); log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o"; input.treeGenStatusMsg = "Gespeichert: Models/trees/" + treeType + "/" + sizeClass + "/" + fileName + ".j3o";
input.refreshAssets = true; input.refreshAssets = true;

View File

@@ -18,6 +18,7 @@ import javafx.scene.Cursor;
import javafx.scene.canvas.Canvas; import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext; import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.PixelFormat;
import javafx.scene.image.WritableImage; import javafx.scene.image.WritableImage;
import javafx.scene.input.MouseButton; import javafx.scene.input.MouseButton;
import javafx.scene.layout.*; import javafx.scene.layout.*;
@@ -84,6 +85,7 @@ public class WorldMapView extends VBox {
// Layer-Checkboxen (im MenuButton gebündelt) // Layer-Checkboxen (im MenuButton gebündelt)
private final CheckBox cbTerrain = layerCheck("Gelände"); private final CheckBox cbTerrain = layerCheck("Gelände");
private final CheckBox cbWater = layerCheck("Wasser"); private final CheckBox cbWater = layerCheck("Wasser");
private final CheckBox cbCoastline = layerCheck("Küstenlinie");
private final CheckBox cbWaterfalls = layerCheck("Wasserfälle"); private final CheckBox cbWaterfalls = layerCheck("Wasserfälle");
private final CheckBox cbAreas = layerCheck("Areas"); private final CheckBox cbAreas = layerCheck("Areas");
private final CheckBox cbZones = layerCheck("Zonen"); private final CheckBox cbZones = layerCheck("Zonen");
@@ -101,6 +103,35 @@ 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 static final int CAT_SIZE = 2048;
private byte[] categoryMap = null;
private float[] heightMap = null; // terrain+voxel, CAT_SIZE×CAT_SIZE (für Klippen)
private float[] heightMapTerrain = null; // terrain-only, CAT_SIZE×CAT_SIZE (für Wasser-Füllung)
private WritableImage tileOverlayImg = null;
private int[] tileOverlayPixels = null;
private int tileOverlayW = 0;
private int tileOverlayH = 0;
private WritableImage forestOverlayImg = null;
private int[] forestOverlayPixels = null;
private int forestOverlayW = 0;
private int forestOverlayH = 0;
// Gecachte Vorab-Berechnungen — nur bei Modell-Wechsel neu berechnen
private WorldMapRenderModel tileOverlayModel = null;
private float[] tileOverlayForestD = null;
private int[] tileOverlaySlots = null;
// Dirty-Flag: Overlay-Pixel neu rendern wenn Pan/Zoom oder Modell geändert
private double tileOverlayCachedScale = Double.NaN;
private double tileOverlayCachedPanX = Double.NaN;
private double tileOverlayCachedPanY = Double.NaN;
// Wasser-Fill-Overlay (Layer über Terrain-Textur, unter Küstenlinie)
private WritableImage waterFillImg = null;
private int[] waterFillPixels = null;
private int waterFillW = 0;
private int waterFillH = 0;
// 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<>();
private WorldMapRenderModel currentModel = null; private WorldMapRenderModel currentModel = null;
@@ -117,6 +148,7 @@ public class WorldMapView extends VBox {
private Consumer<float[]> teleportCallback = null; // [worldX, worldY, worldZ] private Consumer<float[]> teleportCallback = null; // [worldX, worldY, worldZ]
private Runnable enterFullscreenCallback = null; private Runnable enterFullscreenCallback = null;
private Runnable exitFullscreenCallback = null; private Runnable exitFullscreenCallback = null;
private Runnable refreshAction = null;
private final AtomicBoolean loading = new AtomicBoolean(false); private final AtomicBoolean loading = new AtomicBoolean(false);
/** Verknüpft den Live-Voxel-Chunk-Zustand des Editors mit der Kartenansicht. */ /** Verknüpft den Live-Voxel-Chunk-Zustand des Editors mit der Kartenansicht. */
@@ -129,6 +161,8 @@ public class WorldMapView extends VBox {
public void setTeleportCallback(Consumer<float[]> cb) { teleportCallback = cb; } public void setTeleportCallback(Consumer<float[]> cb) { teleportCallback = cb; }
/** Callbacks für Vollbild-Modus ein/aus (typisch: Eltern-Layout anpassen). */ /** Callbacks für Vollbild-Modus ein/aus (typisch: Eltern-Layout anpassen). */
public void setRefreshAction(Runnable r) { this.refreshAction = r; }
public void setFullscreenCallbacks(Runnable enter, Runnable exit) { public void setFullscreenCallbacks(Runnable enter, Runnable exit) {
enterFullscreenCallback = enter; enterFullscreenCallback = enter;
exitFullscreenCallback = exit; exitFullscreenCallback = exit;
@@ -174,9 +208,10 @@ public class WorldMapView extends VBox {
cbModels.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()); 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, cbWaterfalls, cbAreas, cbZones, cbLocations, cbModels}) { for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbCoastline, cbWaterfalls, cbAreas, cbZones, cbLocations, cbModels}) {
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);
@@ -185,7 +220,7 @@ public class WorldMapView extends VBox {
// ── Buttons ─────────────────────────────────────────────────────────── // ── Buttons ───────────────────────────────────────────────────────────
Button refreshBtn = new Button("↻"); Button refreshBtn = new Button("↻");
refreshBtn.setTooltip(new javafx.scene.control.Tooltip("Modell aktualisieren")); refreshBtn.setTooltip(new javafx.scene.control.Tooltip("Modell aktualisieren"));
refreshBtn.setOnAction(e -> loadAndRender()); refreshBtn.setOnAction(e -> { if (refreshAction != null) refreshAction.run(); else loadAndRender(); });
Button exportBtn = new Button("⇩"); Button exportBtn = new Button("⇩");
exportBtn.setTooltip(new javafx.scene.control.Tooltip("Als PNG exportieren…")); exportBtn.setTooltip(new javafx.scene.control.Tooltip("Als PNG exportieren…"));
@@ -320,7 +355,14 @@ 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());
log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)", float[] hMap = WorldMapRenderer.buildTerrainSamples(
model.mapData(), CAT_SIZE, model.voxelSurface());
float[] hMapT = WorldMapRenderer.buildTerrainSamples(
model.mapData(), CAT_SIZE, null);
byte[] catMap = WorldMapRenderer.buildCategoryMap(
model.mapData(), CAT_SIZE, model.voxelSurface(), model.waters(), model.slotColorsRGB());
float[] whMap = WorldMapRenderer.buildWaterHeightMap(model.waters(), CAT_SIZE);
log.debug("[WorldMap] Hintergrund-PNG + Kategorie-Karte gerendert ({}×{} px, {} ms)",
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1); RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1);
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -329,6 +371,10 @@ 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;
fitToView(); fitToView();
redraw(); redraw();
progress.setVisible(false); progress.setVisible(false);
@@ -364,10 +410,21 @@ public class WorldMapView extends VBox {
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();
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions()); BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
float[] hMap = WorldMapRenderer.buildTerrainSamples(
model.mapData(), CAT_SIZE, model.voxelSurface());
float[] hMapT = WorldMapRenderer.buildTerrainSamples(
model.mapData(), CAT_SIZE, null);
byte[] catMap = WorldMapRenderer.buildCategoryMap(
model.mapData(), CAT_SIZE, model.voxelSurface(), model.waters(), model.slotColorsRGB());
float[] whMap = WorldMapRenderer.buildWaterHeightMap(model.waters(), CAT_SIZE);
log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0); log.debug("[WorldMap] PNG-Rerender fertig ({} ms)", System.currentTimeMillis() - t0);
Platform.runLater(() -> { Platform.runLater(() -> {
mapBuffered = bi; mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null); mapFxImage = SwingFXUtils.toFXImage(bi, null);
heightMap = hMap;
heightMapTerrain = hMapT;
categoryMap = catMap;
waterHeightMap = whMap;
redraw(); redraw();
progress.setVisible(false); progress.setVisible(false);
statusLbl.setText("Bereit"); statusLbl.setText("Bereit");
@@ -397,15 +454,246 @@ public class WorldMapView extends VBox {
return; return;
} }
gc.setImageSmoothing(false);
gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale);
gc.setImageSmoothing(true);
// Layer 1: Terrain-Textur (überall, opak)
drawTileOverlay(gc);
// Layer 2: Wasser darüber (glatte Küste via seaCoastPaths)
if (cbWater.isSelected()) drawWaterOverlay(gc); if (cbWater.isSelected()) drawWaterOverlay(gc);
// Layer 3: Wald-Overlay (nach Wasser, damit Bäume nicht im Wasser erscheinen)
drawForestOverlay(gc);
if (cbAreas.isSelected()) drawAreasOverlay(gc); if (cbAreas.isSelected()) drawAreasOverlay(gc);
if (cbLocations.isSelected()) drawLocationsOverlay(gc); if (cbLocations.isSelected()) drawLocationsOverlay(gc);
if (cbModels.isSelected()) drawTreeOverlay(gc);
if (!isFullscreen) drawCameraIndicator(gc); if (!isFullscreen) drawCameraIndicator(gc);
} }
// ── Canvas Tile-Overlay (zoom-unabhängige Kacheln) ────────────────────────
private static int tileSample(int[][] tilePx, int cat,
int u0, int u1, int v0, int v1,
float w00, float w10, float w01, float w11) {
if (cat >= 0 && cat < tilePx.length && tilePx[cat] != null) {
int[] tp = tilePx[cat];
int p00=tp[v0*16+u0], p10=tp[v0*16+u1], p01=tp[v1*16+u0], p11=tp[v1*16+u1];
int r=(int)((p00>>16&0xFF)*w00+(p10>>16&0xFF)*w10+(p01>>16&0xFF)*w01+(p11>>16&0xFF)*w11);
int g=(int)((p00>> 8&0xFF)*w00+(p10>> 8&0xFF)*w10+(p01>> 8&0xFF)*w01+(p11>> 8&0xFF)*w11);
int b=(int)((p00 &0xFF)*w00+(p10 &0xFF)*w10+(p01 &0xFF)*w01+(p11 &0xFF)*w11);
return (r<<16)|(g<<8)|b;
}
int c = Math.max(0, Math.min(WorldMapRenderer.TILE_FALLBACK_RGB.length-1, cat));
int[] fb = WorldMapRenderer.TILE_FALLBACK_RGB[c];
return (fb[0]<<16)|(fb[1]<<8)|fb[2];
}
private static float spBilerp(byte[] arr, int i00, int i10, int i01, int i11, float tx, float tz) {
float v00=(arr[i00]&0xFF)/255f, v10=(arr[i10]&0xFF)/255f;
float v01=(arr[i01]&0xFF)/255f, v11=(arr[i11]&0xFF)/255f;
return (v00*(1-tx)+v10*tx)*(1-tz)+(v01*(1-tx)+v11*tx)*tz;
}
private void drawTileOverlay(GraphicsContext gc) {
if (heightMap == null || currentModel == null) return;
de.blight.common.MapData md = currentModel.mapData();
if (md == null) return;
int[][] tilePx = currentModel.mapTilePixels();
if (tilePx == null) return;
int cW = (int) canvas.getWidth();
int cH = (int) canvas.getHeight();
if (cW <= 0 || cH <= 0) return;
final float WH = WorldMapRenderer.WORLD_HALF;
final float WS = WorldMapRenderer.WORLD_SIZE;
final float TW = WorldMapRenderer.TILE_WU;
final float scaleF = (float) scale;
final float panXf = (float) panX;
final float panYf = (float) panY;
final float imgW = (float)(RENDER_SIZE - 1);
final int CS = CAT_SIZE;
final float[] hm = heightMap;
final float CS1 = CS - 1f;
final float CLIFF_LO = 0.4663f, CLIFF_RNG = 0.8391f - 0.4663f;
// ── Modell-abhängige Vorab-Berechnungen cachen ────────────────────
if (tileOverlayModel != currentModel) {
tileOverlayModel = currentModel;
tileOverlaySlots = WorldMapRenderer.computeSlotTileTypes(md, currentModel.slotColorsRGB());
final int FD0 = 512;
final float forestRadius0 = 14f;
final int forestRCells0 = Math.max(1, (int)(forestRadius0 / (WS / FD0)));
final float[] fd0 = new float[FD0 * FD0];
for (de.blight.common.PlacedModel pm : currentModel.models()) {
if (!WorldMapRenderer.isTree(pm)) continue;
int fx = Math.max(0, Math.min(FD0-1, (int)((pm.x() + WH) / WS * FD0)));
int fz = Math.max(0, Math.min(FD0-1, (int)((WH - pm.z()) / WS * FD0)));
for (int dz = -forestRCells0; dz <= forestRCells0; dz++) {
for (int dx = -forestRCells0; dx <= forestRCells0; dx++) {
if (dx*dx + dz*dz <= forestRCells0*forestRCells0) {
int nx = fx+dx, nz = fz+dz;
if (nx >= 0 && nx < FD0 && nz >= 0 && nz < FD0) fd0[nz*FD0+nx] += 1f;
}
}
}
}
tileOverlayForestD = fd0;
tileOverlayCachedScale = Double.NaN;
}
// ── Pixel-Buffer nur neu berechnen wenn Pan/Zoom oder Canvas-Größe geändert ──
boolean needsRedraw = tileOverlayPixels == null || tileOverlayPixels.length != cW * cH
|| tileOverlayW != cW || tileOverlayH != cH
|| tileOverlayCachedScale != scale
|| tileOverlayCachedPanX != panX
|| tileOverlayCachedPanY != panY;
if (!needsRedraw) {
gc.drawImage(tileOverlayImg, 0, 0);
return;
}
if (tileOverlayPixels == null || tileOverlayPixels.length != cW * cH) {
tileOverlayPixels = new int[cW * cH];
}
if (forestOverlayPixels == null || forestOverlayPixels.length != cW * cH) {
forestOverlayPixels = new int[cW * cH];
}
if (tileOverlayImg == null || tileOverlayW != cW || tileOverlayH != cH) {
tileOverlayImg = new WritableImage(cW, cH);
tileOverlayW = cW;
tileOverlayH = cH;
}
if (forestOverlayImg == null || forestOverlayW != cW || forestOverlayH != cH) {
forestOverlayImg = new WritableImage(cW, cH);
forestOverlayW = cW;
forestOverlayH = cH;
}
tileOverlayCachedScale = scale;
tileOverlayCachedPanX = panX;
tileOverlayCachedPanY = panY;
final int[] slotTiles = tileOverlaySlots;
final float[] forestDensity = tileOverlayForestD;
final int FD = 512;
final byte[] spG = md.splatG, spB = md.splatB, spA = md.splatA;
final byte[] upR = md.upperSplatR, upG = md.upperSplatG, upB = md.upperSplatB, upA = md.upperSplatA;
final byte[] t3R = md.thirdSplatR, t3G = md.thirdSplatG, t3B = md.thirdSplatB, t3A = md.thirdSplatA;
final int SS = de.blight.common.MapData.SPLAT_SIZE;
int[] tileArgbs = new int[8];
for (int cy = 0; cy < cH; cy++) {
float wz = (cy - panYf) / scaleF / imgW * WS - WH;
for (int cx = 0; cx < cW; cx++) {
float wx = (cx - panXf) / scaleF / imgW * WS - WH;
if (wx < -WH || wx >= WH || wz < -WH || wz >= WH) {
tileOverlayPixels[cy * cW + cx] = 0x00000000;
continue;
}
// ── Steigung aus heightMap → Cliff-Blend
float fmx = (wx + WH) / WS * CS1;
float fmz = (wz + WH) / WS * CS1;
int cmx = Math.max(0, Math.min(CS-1, (int)fmx));
int cmz = Math.max(0, Math.min(CS-1, (int)fmz));
int cmxR = Math.min(CS-1, cmx+2), cmxL = Math.max(0, cmx-2);
int cmzD = Math.min(CS-1, cmz+2), cmzU = Math.max(0, cmz-2);
float hC = hm[cmz*CS+cmx];
float hR = hm[cmz*CS+cmxR]; if (hR < 0f) hR = hC;
float hL = hm[cmz*CS+cmxL]; if (hL < 0f) hL = hC;
float hD = hm[cmzD*CS+cmx]; if (hD < 0f) hD = hC;
float hU = hm[cmzU*CS+cmx]; if (hU < 0f) hU = hC;
float dx = (hR - hL) / (cmxR - cmxL), dz = (hD - hU) / (cmzD - cmzU);
float gradMag = (float)Math.sqrt((double)(dx*dx + dz*dz));
float t = Math.max(0f, Math.min(1f, (gradMag - CLIFF_LO) / CLIFF_RNG));
t = t * t * (3f - 2f * t);
// ── Tile-UV bilinear
float rawU = (wx + WH) / TW, rawV = (wz + WH) / TW;
float uf16 = (rawU - (float)Math.floor(rawU)) * 16f;
float vf16 = (rawV - (float)Math.floor(rawV)) * 16f;
int u0 = Math.floorMod((int)uf16,16), u1 = Math.floorMod(u0+1,16);
int v0 = Math.floorMod((int)vf16,16), v1 = Math.floorMod(v0+1,16);
float ux = uf16-(float)Math.floor(uf16), vxf = vf16-(float)Math.floor(vf16);
float w00=(1-ux)*(1-vxf), w10=ux*(1-vxf), w01=(1-ux)*vxf, w11=ux*vxf;
// ── Splatmap bilinear (Y-flip)
float sfx = Math.max(0f, Math.min(SS-1, (wx+WH)/WS*(SS-1)));
float sfz = Math.max(0f, Math.min(SS-1, (WH-wz)/WS*(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 = spBilerp(spG,bi00,bi10,bi01,bi11,stx,stz);
float wb = spBilerp(spB,bi00,bi10,bi01,bi11,stx,stz);
float wa = spBilerp(spA,bi00,bi10,bi01,bi11,stx,stz);
float up1 = spBilerp(upR,bi00,bi10,bi01,bi11,stx,stz);
float up2 = spBilerp(upG,bi00,bi10,bi01,bi11,stx,stz);
float up3 = spBilerp(upB,bi00,bi10,bi01,bi11,stx,stz);
float up4 = spBilerp(upA,bi00,bi10,bi01,bi11,stx,stz);
float t31 = spBilerp(t3R,bi00,bi10,bi01,bi11,stx,stz);
float t32 = spBilerp(t3G,bi00,bi10,bi01,bi11,stx,stz);
float t33 = spBilerp(t3B,bi00,bi10,bi01,bi11,stx,stz);
float t34 = spBilerp(t3A,bi00,bi10,bi01,bi11,stx,stz);
// ── Alle Tile-Farben sampeln
for (int ti = 0; ti < 8; ti++) tileArgbs[ti] = tileSample(tilePx,ti,u0,u1,v0,v1,w00,w10,w01,w11);
// ── Splatmap-Blend
int col = tileArgbs[slotTiles[0]];
int cr=(col>>16)&0xFF, cgv=(col>>8)&0xFF, cbv=col&0xFF;
col=tileArgbs[slotTiles[1]]; cr=(int)(cr*(1-wg) +((col>>16)&0xFF)*wg ); cgv=(int)(cgv*(1-wg) +((col>>8)&0xFF)*wg ); cbv=(int)(cbv*(1-wg) +(col&0xFF)*wg );
col=tileArgbs[slotTiles[2]]; cr=(int)(cr*(1-wb) +((col>>16)&0xFF)*wb ); cgv=(int)(cgv*(1-wb) +((col>>8)&0xFF)*wb ); cbv=(int)(cbv*(1-wb) +(col&0xFF)*wb );
col=tileArgbs[slotTiles[3]]; cr=(int)(cr*(1-wa) +((col>>16)&0xFF)*wa ); cgv=(int)(cgv*(1-wa) +((col>>8)&0xFF)*wa ); cbv=(int)(cbv*(1-wa) +(col&0xFF)*wa );
col=tileArgbs[slotTiles[4]]; cr=(int)(cr*(1-up1)+((col>>16)&0xFF)*up1); cgv=(int)(cgv*(1-up1)+((col>>8)&0xFF)*up1); cbv=(int)(cbv*(1-up1)+(col&0xFF)*up1);
col=tileArgbs[slotTiles[5]]; cr=(int)(cr*(1-up2)+((col>>16)&0xFF)*up2); cgv=(int)(cgv*(1-up2)+((col>>8)&0xFF)*up2); cbv=(int)(cbv*(1-up2)+(col&0xFF)*up2);
col=tileArgbs[slotTiles[6]]; cr=(int)(cr*(1-up3)+((col>>16)&0xFF)*up3); cgv=(int)(cgv*(1-up3)+((col>>8)&0xFF)*up3); cbv=(int)(cbv*(1-up3)+(col&0xFF)*up3);
col=tileArgbs[slotTiles[7]]; cr=(int)(cr*(1-up4)+((col>>16)&0xFF)*up4); cgv=(int)(cgv*(1-up4)+((col>>8)&0xFF)*up4); cbv=(int)(cbv*(1-up4)+(col&0xFF)*up4);
col=tileArgbs[slotTiles[8]]; cr=(int)(cr*(1-t31)+((col>>16)&0xFF)*t31); cgv=(int)(cgv*(1-t31)+((col>>8)&0xFF)*t31); cbv=(int)(cbv*(1-t31)+(col&0xFF)*t31);
col=tileArgbs[slotTiles[9]]; cr=(int)(cr*(1-t32)+((col>>16)&0xFF)*t32); cgv=(int)(cgv*(1-t32)+((col>>8)&0xFF)*t32); cbv=(int)(cbv*(1-t32)+(col&0xFF)*t32);
col=tileArgbs[slotTiles[10]];cr=(int)(cr*(1-t33)+((col>>16)&0xFF)*t33); cgv=(int)(cgv*(1-t33)+((col>>8)&0xFF)*t33); cbv=(int)(cbv*(1-t33)+(col&0xFF)*t33);
col=tileArgbs[slotTiles[11]];cr=(int)(cr*(1-t34)+((col>>16)&0xFF)*t34); cgv=(int)(cgv*(1-t34)+((col>>8)&0xFF)*t34); cbv=(int)(cbv*(1-t34)+(col&0xFF)*t34);
// ── Wald-Dichte (bilinear) → separater Buffer, nach Wasser gezeichnet
float fsfx = Math.max(0f, Math.min(FD-1.001f, (wx + WH) / WS * FD));
float fsfz = Math.max(0f, Math.min(FD-1.001f, (WH - wz) / WS * FD));
int fsx0=(int)fsfx, fsx1=Math.min(fsx0+1,FD-1);
int fsz0=(int)fsfz, fsz1=Math.min(fsz0+1,FD-1);
float fstx=fsfx-fsx0, fstz=fsfz-fsz0;
float fd = (forestDensity[fsz0*FD+fsx0]*(1-fstx)+forestDensity[fsz0*FD+fsx1]*fstx)*(1-fstz)
+ (forestDensity[fsz1*FD+fsx0]*(1-fstx)+forestDensity[fsz1*FD+fsx1]*fstx)*fstz;
// mind. 3 Bäume im Überlappungsbereich (fd > 2.0 bedeutet >2 Bäume)
forestOverlayPixels[cy * cW + cx] = (fd > 2.0f)
? (0x80000000 | (tileArgbs[WorldMapRenderer.TILE_FOREST] & 0x00FFFFFF))
: 0x00000000;
int argb = (cr<<16)|(cgv<<8)|cbv;
// ── Cliff-Overlay
if (t > 0f) {
int cArgb = tileArgbs[WorldMapRenderer.TILE_CLIFF];
int ccr=(cArgb>>16)&0xFF, ccg=(cArgb>>8)&0xFF, ccb=cArgb&0xFF;
argb = ((int)(cr+(ccr-cr)*t)<<16)|((int)(cgv+(ccg-cgv)*t)<<8)|(int)(cbv+(ccb-cbv)*t);
}
tileOverlayPixels[cy * cW + cx] = 0xFF000000 | (argb & 0x00FFFFFF);
}
}
tileOverlayImg.getPixelWriter().setPixels(
0, 0, cW, cH, PixelFormat.getIntArgbInstance(), tileOverlayPixels, 0, cW);
forestOverlayImg.getPixelWriter().setPixels(
0, 0, cW, cH, PixelFormat.getIntArgbInstance(), forestOverlayPixels, 0, cW);
gc.drawImage(tileOverlayImg, 0, 0);
}
private void drawForestOverlay(GraphicsContext gc) {
if (forestOverlayImg == null) return;
gc.drawImage(forestOverlayImg, 0, 0);
}
// ── Canvas-Vektor-Overlays ──────────────────────────────────────────────── // ── Canvas-Vektor-Overlays ────────────────────────────────────────────────
private void drawAreasOverlay(GraphicsContext gc) { private void drawAreasOverlay(GraphicsContext gc) {
@@ -480,135 +768,96 @@ public class WorldMapView extends VBox {
} }
private void drawWaterOverlay(GraphicsContext gc) { private void drawWaterOverlay(GraphicsContext gc) {
if (currentModel == null) return; if (currentModel == null || heightMap == null || waterHeightMap == null) return;
boolean[] seaMask = currentModel.seaMask();
float[] terrainSamples = currentModel.terrainSamples(); int cW = (int) canvas.getWidth(), cH = (int) canvas.getHeight();
if (cW <= 0 || cH <= 0) return;
// ── Wasser-Füllung: blauer Pixel-Layer über dem Terrain-Overlay ──────────
if (waterFillPixels == null || waterFillPixels.length != cW * cH) {
waterFillPixels = new int[cW * cH];
}
if (waterFillImg == null || waterFillW != cW || waterFillH != cH) {
waterFillImg = new WritableImage(cW, cH);
waterFillW = cW;
waterFillH = cH;
}
final float WH = WorldMapRenderer.WORLD_HALF;
final float WS = WorldMapRenderer.WORLD_SIZE;
final float scaleF = (float) scale;
final float panXf = (float) panX;
final float panYf = (float) panY;
final float imgW = (float)(RENDER_SIZE - 1);
final int CS = CAT_SIZE;
final float[] hm = heightMap; // terrain+voxel
final float[] whm = waterHeightMap; // PlacedWater-Höhe pro Zelle (0 = kein PlacedWater)
final float CS1 = CS - 1f;
final int WR = 0xAD, WG = 0xD8, WB = 0xE6; // WATER_COLOR = #ADD8E6
for (int cy = 0; cy < cH; cy++) {
float wz = (cy - panYf) / scaleF / imgW * WS - WH;
for (int cx = 0; cx < cW; cx++) {
float wx = (cx - panXf) / scaleF / imgW * WS - WH;
if (wx < -WH || wx >= WH || wz < -WH || wz >= WH) {
waterFillPixels[cy * cW + cx] = 0;
continue;
}
float fmx = (wx + WH) / WS * CS1;
float fmz = (wz + WH) / WS * CS1;
int cmx = Math.max(0, Math.min(CS-1, (int) fmx));
int cmz = Math.max(0, Math.min(CS-1, (int) fmz));
int cmx1 = Math.min(CS-1, cmx+1), cmz1 = Math.min(CS-1, cmz+1);
float tx = fmx - cmx, tz = fmz - cmz;
// Bilinear heightMap (terrain+voxel)
float hPx = hm[cmz *CS+cmx ] * (1-tx)*(1-tz)
+ hm[cmz *CS+cmx1] * tx *(1-tz)
+ hm[cmz1*CS+cmx ] * (1-tx)*tz
+ hm[cmz1*CS+cmx1] * tx *tz;
// Bilinear waterHeightMap → PlacedWater-Schwelle (0 = nur Ozean)
float wH = whm[cmz *CS+cmx ] * (1-tx)*(1-tz)
+ whm[cmz *CS+cmx1] * tx *(1-tz)
+ whm[cmz1*CS+cmx ] * (1-tx)*tz
+ whm[cmz1*CS+cmx1] * tx *tz;
// delta < 0 → unter Wasseroberfläche; Übergangsband ±0.5 WU
float delta = hPx - wH;
int alpha;
if (delta <= -0.5f) {
alpha = 255;
} else if (delta < 0.5f) {
alpha = Math.max(0, Math.min(255, (int)((0.5f - delta) * 255f)));
} else {
alpha = 0;
}
waterFillPixels[cy * cW + cx] = (alpha << 24) | (WR << 16) | (WG << 8) | WB;
}
}
waterFillImg.getPixelWriter().setPixels(
0, 0, cW, cH, PixelFormat.getIntArgbInstance(), waterFillPixels, 0, cW);
gc.drawImage(waterFillImg, 0, 0);
// ── Küstenlinie – geglättete Pfade über der Wasser-Füllung ─────────────
List<float[][]> seaCoastPaths = currentModel.coastPaths(); List<float[][]> seaCoastPaths = currentModel.coastPaths();
if (cbCoastline.isSelected() && seaCoastPaths != null && !seaCoastPaths.isEmpty()) {
// ── Seewellen (Terrain < 0) ────────────────────────────────────────────── double canvasW = canvas.getWidth(), canvasH = canvas.getHeight();
if (seaMask != null && cbWater.isSelected()) {
// Wellengröße in Weltkoordinaten (skaliert mit Zoom, damit Bildschirmgröße = WAVE_W)
double pxPerWorld = (RENDER_SIZE - 1) * scale / WorldMapRenderer.WORLD_SIZE;
double wWave = WAVE_W / pxPerWorld;
double wSpX = wWave * 1.8, wSpY = wWave * 1.05, wAmp = WAVE_W * 0.2;
// Sichtbarer Weltbereich
double cW = canvas.getWidth(), cH = canvas.getHeight();
double wxMin = toWorldX(0), wxMax = toWorldX(cW);
double wzMin = toWorldZ(0), wzMax = toWorldZ(cH);
wxMin = Math.max(-WorldMapRenderer.WORLD_HALF, wxMin);
wzMin = Math.max(-WorldMapRenderer.WORLD_HALF, wzMin);
wxMax = Math.min(WorldMapRenderer.WORLD_HALF, wxMax);
wzMax = Math.min(WorldMapRenderer.WORLD_HALF, wzMax);
gc.setStroke(Color.BLACK);
gc.setLineWidth(0.8);
gc.setLineDashes(null);
// Gitter-Startzeile: auf Weltgitter einrasten → Wellen bleiben beim Panning stabil
long rowBase = (long)Math.floor((wzMin - wSpY * 0.5) / wSpY);
for (long rowIdx = rowBase; rowIdx * wSpY + wSpY * 0.5 < wzMax; rowIdx++) {
double wz = rowIdx * wSpY + wSpY * 0.5;
double rowOff = (rowIdx & 1L) == 1L ? wSpX * 0.5 : 0;
long colBase = (long)Math.floor((wxMin - rowOff) / wSpX);
for (long colIdx = colBase; colIdx * wSpX + rowOff < wxMax; colIdx++) {
double wx = colIdx * wSpX + rowOff;
double jx = Math.abs(Math.sin(rowIdx * 73.1 + colIdx * 157.3)) * wSpX * 0.25;
double jy = Math.sin(rowIdx * 211.7 + colIdx * 89.5) * wSpY * 0.2;
double owx = wx + jx, owz = wz + jy;
// Seekartenmaske prüfen (Mitte und Ende der Welle)
int mxS = (int)((owx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1));
int mzS = (int)((owz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1));
int mxE = (int)((owx + wWave + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1));
if (mxS < 0 || mxS >= SEA_MASK_SIZE || mzS < 0 || mzS >= SEA_MASK_SIZE) continue;
if (!seaMask[mzS * SEA_MASK_SIZE + mxS]) continue;
if (mxE < 0 || mxE >= SEA_MASK_SIZE || !seaMask[mzS * SEA_MASK_SIZE + mxE]) continue;
// Canvas-Koordinaten; Breite = WAVE_W px, Amplitude = wAmp px
double ox = toCanvasX((float)owx), oy = toCanvasZ((float)owz);
double ex = ox + WAVE_W;
gc.beginPath();
gc.moveTo(ox, oy);
gc.bezierCurveTo(ox + WAVE_W*0.3, oy - wAmp, ox + WAVE_W*0.7, oy + wAmp, ex, oy);
gc.stroke();
}
}
}
List<PlacedWater> waters = currentModel.waters();
if (waters.isEmpty()) return;
final double spX = WAVE_W * 1.8, spY = WAVE_W * 1.05, wmA = WAVE_W * 0.2;
for (PlacedWater w : waters) {
float[] wx = w.pointsX(), wz = w.pointsZ();
int n = wx.length;
if (n < 3) continue;
float wh = w.waterHeight();
double[] cx = new double[n], cz = new double[n];
for (int i = 0; i < n; i++) { cx[i] = toCanvasX(wx[i]); cz[i] = toCanvasZ(wz[i]); }
double minX = cx[0], maxX = cx[0], minZ = cz[0], maxZ = cz[0];
for (int i = 1; i < n; i++) {
if (cx[i] < minX) minX = cx[i]; if (cx[i] > maxX) maxX = cx[i];
if (cz[i] < minZ) minZ = cz[i]; if (cz[i] > maxZ) maxZ = cz[i];
}
// Wellenmarken: nur wenn Fläche groß genug für mindestens eine Welle
if ((maxX - minX) > WAVE_W * 1.5 && (maxZ - minZ) > spY) {
gc.save();
gc.beginPath();
gc.moveTo(cx[0], cz[0]);
for (int i = 1; i < n; i++) { gc.lineTo(cx[i], cz[i]); }
gc.closePath();
gc.clip();
gc.setStroke(Color.BLACK);
gc.setLineWidth(0.8);
gc.setLineDashes(null);
int row = 0;
for (double wy = minZ + spY * 0.5; wy < maxZ; wy += spY, row++) {
double rowOff = (row & 1) == 1 ? spX * 0.5 : 0;
int col = 0;
for (double wxx = minX + rowOff; wxx + WAVE_W <= maxX; wxx += spX, col++) {
double jx = Math.abs(Math.sin(row * 73.1 + col * 157.3)) * spX * 0.25;
double jy = Math.sin(row * 211.7 + col * 89.5) * spY * 0.2;
double ox = wxx + jx, oy = wy + jy;
if (ox < minX || ox + WAVE_W > maxX || oy < minZ || oy > maxZ) { continue; }
// Höhenprüfung: Terrain muss unter waterHeight liegen
if (terrainSamples != null) {
float owx = toWorldX(ox), owz = toWorldZ(oy);
int mxS = Math.max(0, Math.min(SEA_MASK_SIZE-1, (int)((owx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE-1))));
int mzS = Math.max(0, Math.min(SEA_MASK_SIZE-1, (int)((owz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE-1))));
if (terrainSamples[mzS * SEA_MASK_SIZE + mxS] >= wh) { continue; }
}
gc.beginPath();
gc.moveTo(ox, oy);
gc.bezierCurveTo(ox + WAVE_W*0.3, oy - wmA, ox + WAVE_W*0.7, oy + wmA, ox + WAVE_W, oy);
gc.stroke();
}
}
gc.restore();
}
}
// ── Seeküstenlinie – verbundene Pfade, runde Joins, weicher Halo ────────────
if (seaCoastPaths != null && !seaCoastPaths.isEmpty()) {
double cW = canvas.getWidth(), cH = 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, cW, cH, 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, cW, cH, 4); } for (float[][] path : seaCoastPaths) { drawCoastPath(gc, path, canvasW, canvasH, 4); }
} }
} }
@@ -811,35 +1060,17 @@ public class WorldMapView extends VBox {
List<Location> locs, List<PlacedWater> waters, List<Location> locs, List<PlacedWater> waters,
List<PlacedModel> models, List<PlacedWaterfall> waterfalls, List<PlacedModel> models, List<PlacedWaterfall> waterfalls,
float[] voxelSurface) { float[] voxelSurface) {
int[] slotColors = computeSlotColors(mapData); int[] slotColors = computeSlotColors(mapData);
boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE, voxelSurface); int[][] mapTiles = WorldMapRenderer.loadMapTiles(findAssetRoot());
float[] tSamp = buildTerrainSamples(mapData, SEA_MASK_SIZE); boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE, voxelSurface);
float[] tSamp = WorldMapRenderer.buildTerrainSamples(mapData, SEA_MASK_SIZE, voxelSurface);
List<float[][]> paths = buildSeaCoastPaths(buildCoastSegsMS(mapData, waters, SEA_MASK_SIZE, voxelSurface)); List<float[][]> paths = buildSeaCoastPaths(buildCoastSegsMS(mapData, waters, SEA_MASK_SIZE, voxelSurface));
List<PlacedModel> treeList = models.stream() List<PlacedModel> treeList = models.stream()
.filter(WorldMapRenderer::isTree).collect(Collectors.toList()); .filter(WorldMapRenderer::isTree).collect(Collectors.toList());
List<List<PlacedModel>> tClusters = clusterTreeModels(treeList, 40f); List<List<PlacedModel>> tClusters = clusterTreeModels(treeList, 40f);
return new WorldMapRenderModel( return new WorldMapRenderModel(
mapData, areas, zones, locs, waters, models, waterfalls, slotColors, mapData, areas, zones, locs, waters, models, waterfalls, slotColors,
mask, voxelSurface, tSamp, paths, tClusters); mask, voxelSurface, tSamp, paths, tClusters, mapTiles);
}
private static float[] buildTerrainSamples(MapData mapData, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
float[] s = new float[size * size];
for (int mz = 0; mz < size; mz++) {
for (int mx = 0; mx < size; mx++) {
int hx = Math.min((int)(mx / (double)(size-1) * (TV-1)), TV-1);
int hz = Math.min((int)(mz / (double)(size-1) * (TV-1)), TV-1);
int ux = Math.min((int)(mx / (double)(size-1) * (UV-1)), UV-1);
int uz = Math.min((int)(mz / (double)(size-1) * (UV-1)), UV-1);
float h = mapData.terrainHeight[hz * TV + hx];
float upper = mapData.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
s[mz * size + mx] = h;
}
}
return s;
} }
// Erzeugt interpolierte Marching-Squares-Segmente für Meer + Wasserflächen. // Erzeugt interpolierte Marching-Squares-Segmente für Meer + Wasserflächen.
@@ -1147,7 +1378,7 @@ public class WorldMapView extends VBox {
cbTerrain.isSelected(), cbTerrain.isSelected(),
cbTerrain.isSelected(), cbTerrain.isSelected(),
cbWater.isSelected(), cbWater.isSelected(),
true, // Wellen im Export-PNG gebacken false,
cbAreas.isSelected(), cbAreas.isSelected(),
cbZones.isSelected(), cbZones.isSelected(),
cbLocations.isSelected(), cbLocations.isSelected(),

View File

@@ -101,6 +101,7 @@ public class MinimapState extends BaseAppState {
private Material fogMat; // Minimap-Nebel private Material fogMat; // Minimap-Nebel
private Material fogFullMat; // Vollbild-Nebel (eigene Overlay-Bounds) private Material fogFullMat; // Vollbild-Nebel (eigene Overlay-Bounds)
private float fogTime = 0f; private float fogTime = 0f;
private float posUpdateTimer = 0f;
// ── Blur-Filter (aktiv wenn Vollbild-Karte offen) ───────────────────────── // ── Blur-Filter (aktiv wenn Vollbild-Karte offen) ─────────────────────────
@@ -217,8 +218,12 @@ public class MinimapState extends BaseAppState {
if (fogMat != null) { fogMat.setFloat("Time", fogTime); } if (fogMat != null) { fogMat.setFloat("Time", fogTime); }
if (fogFullMat != null) { fogFullMat.setFloat("Time", fogTime); } if (fogFullMat != null) { fogFullMat.setFloat("Time", fogTime); }
posUpdateTimer += tpf;
boolean doPosUpdate = posUpdateTimer >= 1f;
if (doPosUpdate) posUpdateTimer = 0f;
if (!fullMapOpen) { if (!fullMapOpen) {
updateMinimapUV(px, pz); if (doPosUpdate) updateMinimapUV(px, pz);
} else { } else {
if (fmDragging) { if (fmDragging) {
Vector2f cursor = app.getInputManager().getCursorPosition(); Vector2f cursor = app.getInputManager().getCursorPosition();
@@ -234,7 +239,7 @@ public class MinimapState extends BaseAppState {
scheduleVectorRedraw(); scheduleVectorRedraw();
} }
} }
updateFullMapPlayerDot(px, pz); if (doPosUpdate) updateFullMapPlayerDot(px, pz);
} }
} }