Ingame Karte weitergebaut

This commit is contained in:
2026-09-07 22:22:27 +02:00
parent 9fa1643264
commit 52801b5ddf
9 changed files with 1244 additions and 342 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 595 KiB

View File

@@ -1589,6 +1589,928 @@ public final class WorldMapRenderer {
return avg;
}
// ── Allgemeine Layer-Rendering (Editor + Ingame) ──────────────────────────
/** Auflösung für Marching-Squares (Küste, Klippe, Wald). */
public static final int SEA_MASK_SIZE = 1024;
/** Auflösung der Forst-Dichte-Map. */
public static final int FOREST_DENSITY_SIZE = 512;
/** Berechnet die Baum-Dichte-Map (FOREST_DENSITY_SIZE × FOREST_DENSITY_SIZE). */
public static float[] computeForestDensity(WorldMapRenderModel model) {
final int FD = FOREST_DENSITY_SIZE;
final float radius = 14f;
final int rc = Math.max(1, (int)(radius / (WORLD_SIZE / FD)));
final float[] fd = new float[FD * FD];
for (PlacedModel pm : model.models()) {
if (!isTree(pm)) continue;
int fx = Math.max(0, Math.min(FD-1, (int)((pm.x()+WORLD_HALF)/WORLD_SIZE*FD)));
int fz = Math.max(0, Math.min(FD-1, (int)((WORLD_HALF-pm.z())/WORLD_SIZE*FD)));
for (int dz=-rc; dz<=rc; dz++) {
for (int dx=-rc; dx<=rc; dx++) {
if (dx*dx+dz*dz <= rc*rc) {
int nx=fx+dx, nz=fz+dz;
if (nx>=0&&nx<FD&&nz>=0&&nz<FD) fd[nz*FD+nx]+=1f;
}
}
}
}
return fd;
}
/** Erzeugt Forst-Pixel (ARGB int[], Größe size×size) aus der Dichte-Map. */
public static int[] computeForestPixels(float[] density, float threshold, int size) {
final int FD = FOREST_DENSITY_SIZE;
final int forestFill = 0x80_4a9a28;
final int[] px = new int[size * size];
for (int py = 0; py < size; py++) {
float wz = (float) py / (size - 1) * WORLD_SIZE - WORLD_HALF;
for (int bx = 0; bx < size; bx++) {
float wx = (float) bx / (size - 1) * WORLD_SIZE - WORLD_HALF;
float fsfx = Math.max(0f, Math.min(FD - 1.001f, (wx + WORLD_HALF) / WORLD_SIZE * FD));
float fsfz = Math.max(0f, Math.min(FD - 1.001f, (WORLD_HALF - wz) / WORLD_SIZE * 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 d = (density[fsz0 * FD + fsx0] * (1 - fstx) + density[fsz0 * FD + fsx1] * fstx) * (1 - fstz)
+ (density[fsz1 * FD + fsx0] * (1 - fstx) + density[fsz1 * FD + fsx1] * fstx) * fstz;
if (d > threshold) px[py * size + bx] = forestFill;
}
}
return px;
}
/** Liefert Küstenpfade: aus Modell wenn vorhanden, sonst per Marching Squares. */
public static List<float[][]> buildCoastPaths(WorldMapRenderModel model) {
if (model.coastPaths() != null) return model.coastPaths();
float[] heights = buildTerrainSamples(model.mapData(), SEA_MASK_SIZE, model.voxelSurface());
java.util.List<float[]> segs = new java.util.ArrayList<>();
marchingSquares(heights, SEA_MASK_SIZE, 0f, null, null, segs);
for (PlacedWater w : model.waters()) {
marchingSquares(heights, SEA_MASK_SIZE, w.waterHeight(), w.pointsX(), w.pointsZ(), segs);
}
return assembleClosedPaths(segs.toArray(new float[0][]), 3);
}
/** Extrahiert Kliff-Konturen als geglättete Weltkoordinaten-Pfade. */
public static List<float[][]> buildCliffPaths(MapData mapData, float[] voxelSurface) {
final int SIZE = SEA_MASK_SIZE;
float[] heights = buildTerrainSamples(mapData, SIZE, voxelSurface);
float cellW = WORLD_SIZE / (SIZE - 1);
int dPx = Math.max(1, (int)(1.5f / cellW));
float[] mask = new float[SIZE * SIZE];
for (int py = 0; py < SIZE; py++) {
for (int px = 0; px < SIZE; px++) {
int idx = py * SIZE + px;
float hR = heights[py * SIZE + Math.min(SIZE - 1, px + dPx)];
float hL = heights[py * SIZE + Math.max(0, px - dPx)];
float hD = heights[Math.min(SIZE - 1, py + dPx) * SIZE + px];
float hU = heights[Math.max(0, py - dPx) * SIZE + px];
float dist = dPx * cellW;
double slope = Math.toDegrees(Math.atan(Math.sqrt(
Math.pow((hR - hL) / (2 * dist), 2) + Math.pow((hD - hU) / (2 * dist), 2))));
mask[idx] = slope > 30.0 ? -1f : 1f;
}
}
java.util.List<float[]> segs = new java.util.ArrayList<>();
marchingSquares(mask, SIZE, 0.5f, null, null, segs);
return fixWindingForClip(assembleClosedPaths(segs.toArray(new float[0][]), 3));
}
/** Extrahiert Wald-Konturen aus der Density-Map als Weltkoordinaten-Pfade. */
public static List<float[][]> buildForestOutlinePaths(float[] density, int fdSize, float threshold) {
final int SIZE = SEA_MASK_SIZE;
float[] h = new float[SIZE * SIZE];
for (int py = 0; py < SIZE; py++) {
float wz = (float) py / (SIZE - 1) * WORLD_SIZE - WORLD_HALF;
float fsfz = Math.max(0f, Math.min(fdSize - 1.001f, (WORLD_HALF - wz) / WORLD_SIZE * fdSize));
int fsz0 = (int) fsfz, fsz1 = Math.min(fsz0 + 1, fdSize - 1);
float fstz = fsfz - fsz0;
for (int px = 0; px < SIZE; px++) {
float wx = (float) px / (SIZE - 1) * WORLD_SIZE - WORLD_HALF;
float fsfx = Math.max(0f, Math.min(fdSize - 1.001f, (wx + WORLD_HALF) / WORLD_SIZE * fdSize));
int fsx0 = (int) fsfx, fsx1 = Math.min(fsx0 + 1, fdSize - 1);
float fstx = fsfx - fsx0;
float d = (density[fsz0 * fdSize + fsx0] * (1 - fstx) + density[fsz0 * fdSize + fsx1] * fstx) * (1 - fstz)
+ (density[fsz1 * fdSize + fsx0] * (1 - fstx) + density[fsz1 * fdSize + fsx1] * fstx) * fstz;
h[py * SIZE + px] = threshold - d;
}
}
java.util.List<float[]> segs = new java.util.ArrayList<>();
marchingSquares(h, SIZE, 0f, null, null, segs);
List<float[][]> paths = assembleClosedPaths(segs.toArray(new float[0][]), 0);
final float MIN_PERIM_SQ = 60f * 60f;
return paths.stream().filter(p -> pathPerimeterSq(p) >= MIN_PERIM_SQ)
.collect(java.util.stream.Collectors.toList());
}
/** Baut zusammenhängende Weg-Ketten aus dem PathNetwork. */
public static List<float[][]> loadPathChains() {
try {
de.blight.common.path.PathNetwork net = de.blight.common.path.PathNetworkIO.load();
List<de.blight.common.path.PathNode> nodes = net.getNodes();
if (nodes.isEmpty()) return java.util.Collections.emptyList();
java.util.Map<String, de.blight.common.path.PathNode> nodeMap = new java.util.HashMap<>();
java.util.Map<String, List<String>> adj = new java.util.HashMap<>();
for (de.blight.common.path.PathNode n : nodes) {
nodeMap.put(n.getUuid(), n);
adj.put(n.getUuid(), new java.util.ArrayList<>());
}
for (de.blight.common.path.PathEdge e : net.getEdges()) {
adj.computeIfAbsent(e.getNodeUuidA(), k -> new java.util.ArrayList<>()).add(e.getNodeUuidB());
adj.computeIfAbsent(e.getNodeUuidB(), k -> new java.util.ArrayList<>()).add(e.getNodeUuidA());
}
java.util.Set<String> junctions = new java.util.HashSet<>();
for (de.blight.common.path.PathNode n : nodes) {
if (adj.get(n.getUuid()).size() != 2) junctions.add(n.getUuid());
}
if (junctions.isEmpty()) junctions.add(nodes.get(0).getUuid());
java.util.Set<String> visitedEdges = new java.util.HashSet<>();
List<float[][]> chains = new java.util.ArrayList<>();
for (String jUuid : junctions) {
for (String nb : adj.getOrDefault(jUuid, java.util.Collections.emptyList())) {
String ek = pathEdgeKeyStr(jUuid, nb);
if (visitedEdges.contains(ek)) continue;
java.util.List<float[]> chain = new java.util.ArrayList<>();
de.blight.common.path.PathNode startNode = nodeMap.get(jUuid);
chain.add(new float[]{startNode.getPosition().x, startNode.getPosition().z});
String prev = jUuid, curr = nb;
while (curr != null) {
visitedEdges.add(pathEdgeKeyStr(prev, curr));
de.blight.common.path.PathNode currNode = nodeMap.get(curr);
if (currNode == null) break;
chain.add(new float[]{currNode.getPosition().x, currNode.getPosition().z});
if (junctions.contains(curr)) break;
String next = null;
for (String n2 : adj.getOrDefault(curr, java.util.Collections.emptyList())) {
if (!n2.equals(prev)) { next = n2; break; }
}
prev = curr; curr = next;
}
if (chain.size() >= 2) chains.add(chain.toArray(new float[0][]));
}
}
return chains;
} catch (Exception ignore) {
return java.util.Collections.emptyList();
}
}
// ── Cave-Pfad-Splitting (gemeinsam genutzt von Editor und Ingame) ─────────
public static final float PATH_UNDERGROUND_CLEARANCE = 3.0f;
/** Prüft ob eine Weltposition im Cave-Fußabdruck liegt. Erkennt Maskengröße automatisch. */
public static boolean isCavePoint(boolean[] mask, float wx, float wz) {
int SIZE = (int) Math.round(Math.sqrt(mask.length));
int px = Math.round((wx + WORLD_HALF) / WORLD_SIZE * (SIZE - 1));
int pz = Math.round((wz + WORLD_HALF) / WORLD_SIZE * (SIZE - 1));
px = Math.max(0, Math.min(SIZE - 1, px));
pz = Math.max(0, Math.min(SIZE - 1, pz));
return mask[pz * SIZE + px];
}
/**
* Teilt Pfad-Ketten an Tunnel-Grenzen auf.
* Index 0 = Oberflächensegmente, Index 1 = Tunnel-Segmente.
*/
@SuppressWarnings("unchecked")
public static List<float[][]>[] splitPathChains(List<float[][]> chains, float[] voxelSurface,
boolean[] caveMask) {
List<float[][]> surface = new java.util.ArrayList<>();
List<float[][]> cave = new java.util.ArrayList<>();
for (float[][] chain : chains) {
splitChainByVoxelSurface(chain, voxelSurface, caveMask, surface, cave);
}
return new List[]{surface, cave};
}
private static boolean isNodeUnderground(float[] node, float[] voxelSurface, boolean[] caveMask) {
if (node.length < 2) return false;
if (caveMask != null && isCavePoint(caveMask, node[0], node[1])) return true;
if (node.length < 3 || Float.isNaN(node[2])) return false;
float wy = node[2];
int px = Math.max(0, Math.min(VOXEL_SURFACE_RES - 1,
Math.round((node[0] + WORLD_HALF) / WORLD_SIZE * (VOXEL_SURFACE_RES - 1))));
int pz = Math.max(0, Math.min(VOXEL_SURFACE_RES - 1,
Math.round((node[1] + WORLD_HALF) / WORLD_SIZE * (VOXEL_SURFACE_RES - 1))));
float vs = voxelSurface[pz * VOXEL_SURFACE_RES + px];
return !Float.isNaN(vs) && vs > wy + PATH_UNDERGROUND_CLEARANCE;
}
private static float[] lerpNode(float[] a, float[] b, float t) {
float ax = a[0], az = a[1], ay = a.length > 2 ? a[2] : 0f;
float bx = b[0], bz = b[1], by = b.length > 2 ? b[2] : 0f;
return new float[]{ax + (bx - ax) * t, az + (bz - az) * t, ay + (by - ay) * t};
}
private static void splitChainByVoxelSurface(float[][] chain, float[] voxelSurface,
boolean[] caveMask,
List<float[][]> surface, List<float[][]> cave) {
if (chain.length < 2) return;
java.util.List<float[]> current = new java.util.ArrayList<>();
boolean currentCave = isNodeUnderground(chain[0], voxelSurface, caveMask);
current.add(chain[0]);
for (int i = 0; i < chain.length - 1; i++) {
float[] a = chain[i];
float[] b = chain[i + 1];
boolean bCave = isNodeUnderground(b, voxelSurface, caveMask);
if (currentCave == bCave) {
if (!currentCave) {
float dx = b[0] - a[0], dz = b[1] - a[1];
int steps = Math.max(8, (int) ((float) Math.sqrt(dx * dx + dz * dz) / 2f));
float[] sample = null;
for (int s = 1; s < steps && sample == null; s++) {
float[] p = lerpNode(a, b, s / (float) steps);
if (isNodeUnderground(p, voxelSurface, caveMask)) sample = p;
}
if (sample != null) {
float[] c1 = findPathCrossing(a, sample, voxelSurface, caveMask, false);
float[] c2 = findPathCrossing(sample, b, voxelSurface, caveMask, true);
current.add(c1);
if (current.size() >= 2) surface.add(current.toArray(new float[0][]));
current = new java.util.ArrayList<>();
current.add(c1); current.add(sample); current.add(c2);
cave.add(current.toArray(new float[0][]));
current = new java.util.ArrayList<>();
current.add(c2); current.add(b);
continue;
}
}
current.add(b);
} else {
float[] crossing = findPathCrossing(a, b, voxelSurface, caveMask, currentCave);
current.add(crossing);
if (current.size() >= 2) (currentCave ? cave : surface).add(current.toArray(new float[0][]));
current = new java.util.ArrayList<>();
current.add(crossing); current.add(b);
currentCave = bCave;
}
}
if (current.size() >= 2) (currentCave ? cave : surface).add(current.toArray(new float[0][]));
}
private static float[] findPathCrossing(float[] a, float[] b, float[] vs, boolean[] caveMask,
boolean aIsUnderground) {
float ax = a[0], az = a[1], ay = a.length > 2 ? a[2] : 0f;
float bx = b[0], bz = b[1], by = b.length > 2 ? b[2] : 0f;
for (int i = 0; i < 12; i++) {
float mx = (ax + bx) * 0.5f, mz = (az + bz) * 0.5f, my = (ay + by) * 0.5f;
float[] mid = {mx, mz, my};
if (isNodeUnderground(mid, vs, caveMask) == aIsUnderground) { ax = mx; az = mz; ay = my; }
else { bx = mx; bz = mz; by = my; }
}
return new float[]{(ax + bx) * 0.5f, (az + bz) * 0.5f, (ay + by) * 0.5f};
}
/** Filtert Gebäude-Footprints: wantCave=true → nur Untergrund, false → nur Oberfläche. */
public static List<float[]> filterBuildingsByCave(List<float[]> footprints, boolean[] caveMask,
boolean wantCave) {
List<float[]> result = new java.util.ArrayList<>();
for (float[] fp : footprints) {
if (isCavePoint(caveMask, fp[0], fp[1]) == wantCave) result.add(fp);
}
return result;
}
/** Filtert Areas anhand des Polygon-Schwerpunkts. */
public static List<PlacedArea> filterAreasByCave(List<PlacedArea> areas, boolean[] caveMask,
boolean wantCave) {
List<PlacedArea> result = new java.util.ArrayList<>();
for (PlacedArea area : areas) {
float[] xs = area.pointsX(), zs = area.pointsZ();
if (xs == null || xs.length == 0) { if (!wantCave) result.add(area); continue; }
float cx = 0, cz = 0;
for (int i = 0; i < xs.length; i++) { cx += xs[i]; cz += zs[i]; }
cx /= xs.length; cz /= zs.length;
if (isCavePoint(caveMask, cx, cz) == wantCave) result.add(area);
}
return result;
}
private static String pathEdgeKeyStr(String a, String b) {
return a.compareTo(b) < 0 ? a + "|" + b : b + "|" + a;
}
// ── Marching-Squares (identisch zu WorldMapView, hier für game-Zugriff) ───
static void marchingSquares(float[] h, int size, float thr,
float[] polyX, float[] polyZ, java.util.List<float[]> out) {
for (int mz = 0; mz < size - 1; mz++) {
for (int mx = 0; mx < size - 1; mx++) {
float hTL = h[mz*size + mx], hTR = h[mz*size + (mx+1)];
float hBL = h[(mz+1)*size + mx], hBR = h[(mz+1)*size + (mx+1)];
float wx0 = msW(mx, size), wx1 = msW(mx+1, size);
float wz0 = msW(mz, size), wz1 = msW(mz+1, size);
boolean iTL, iTR, iBL, iBR;
if (polyX != null) {
iTL = hTL < thr && msPolyContains(polyX, polyZ, wx0, wz0);
iTR = hTR < thr && msPolyContains(polyX, polyZ, wx1, wz0);
iBL = hBL < thr && msPolyContains(polyX, polyZ, wx0, wz1);
iBR = hBR < thr && msPolyContains(polyX, polyZ, wx1, wz1);
} else {
iTL = hTL < thr; iTR = hTR < thr;
iBL = hBL < thr; iBR = hBR < thr;
}
int idx = (iTL?8:0)|(iTR?4:0)|(iBR?2:0)|(iBL?1:0);
if (idx == 0 || idx == 15) continue;
float xTop = msLerp(wx0, wx1, thr, hTL, hTR);
float xBot = msLerp(wx0, wx1, thr, hBL, hBR);
float zLft = msLerp(wz0, wz1, thr, hTL, hBL);
float zRgt = msLerp(wz0, wz1, thr, hTR, hBR);
switch (idx) {
case 1: case 14: out.add(new float[]{wx0,zLft, xBot,wz1}); break;
case 2: case 13: out.add(new float[]{xBot,wz1, wx1,zRgt}); break;
case 4: case 11: out.add(new float[]{xTop,wz0, wx1,zRgt}); break;
case 8: case 7: out.add(new float[]{wx0,zLft, xTop,wz0}); break;
case 3: case 12: out.add(new float[]{wx0,zLft, wx1,zRgt}); break;
case 6: case 9: out.add(new float[]{xTop,wz0, xBot,wz1}); break;
case 5: {
float hC = (hTL+hTR+hBL+hBR)*0.25f;
if (hC >= thr) { out.add(new float[]{wx0,zLft,xTop,wz0}); out.add(new float[]{xBot,wz1,wx1,zRgt}); }
else { out.add(new float[]{wx0,zLft,xBot,wz1}); out.add(new float[]{xTop,wz0,wx1,zRgt}); }
break;
}
case 10: {
float hC = (hTL+hTR+hBL+hBR)*0.25f;
if (hC >= thr) { out.add(new float[]{xTop,wz0,wx1,zRgt}); out.add(new float[]{wx0,zLft,xBot,wz1}); }
else { out.add(new float[]{wx0,zLft,xTop,wz0}); out.add(new float[]{xBot,wz1,wx1,zRgt}); }
break;
}
}
}
}
}
private static float msLerp(float from, float to, float thr, float hA, float hB) {
float d = hB - hA;
if (Math.abs(d) < 1e-6f) return (from + to) * 0.5f;
return from + Math.max(0f, Math.min(1f, (thr - hA) / d)) * (to - from);
}
private static float msW(int idx, int size) {
return (float)(idx / (double)(size-1) * WORLD_SIZE - WORLD_HALF);
}
private static boolean msPolyContains(float[] xs, float[] zs, float px, float pz) {
boolean inside = false;
int n = xs.length;
for (int i = 0, j = n - 1; i < n; j = i++) {
if ((zs[i] > pz) != (zs[j] > pz) &&
px < (xs[j] - xs[i]) * (pz - zs[i]) / (zs[j] - zs[i]) + xs[i]) {
inside = !inside;
}
}
return inside;
}
static List<float[][]> assembleClosedPaths(float[][] segs, int chaikinIter) {
if (segs == null || segs.length == 0) return java.util.Collections.emptyList();
java.util.Map<Long, java.util.List<Integer>> adj = new java.util.HashMap<>(segs.length * 4);
for (int i = 0; i < segs.length; i++) {
adj.computeIfAbsent(coastPtKey(segs[i][0], segs[i][1]), k -> new java.util.ArrayList<>()).add(i);
adj.computeIfAbsent(coastPtKey(segs[i][2], segs[i][3]), k -> new java.util.ArrayList<>()).add(i);
}
boolean[] used = new boolean[segs.length];
List<float[][]> result = new java.util.ArrayList<>();
for (int start = 0; start < segs.length; start++) {
if (used[start]) continue;
used[start] = true;
java.util.List<float[]> fwd = new java.util.ArrayList<>();
java.util.List<float[]> bwd = new java.util.ArrayList<>();
fwd.add(new float[]{segs[start][2], segs[start][3]});
bwd.add(new float[]{segs[start][0], segs[start][1]});
coastExtend(fwd, segs, adj, used);
coastExtend(bwd, segs, adj, used);
float[][] raw = new float[bwd.size() + fwd.size()][];
for (int i = 0; i < bwd.size(); i++) raw[i] = bwd.get(bwd.size() - 1 - i);
for (int i = 0; i < fwd.size(); i++) raw[bwd.size() + i] = fwd.get(i);
float[][] path = simplifyCoastPath(raw);
result.add(chaikinIter > 0 ? chaikin(path, chaikinIter) : path);
}
return result;
}
private static void coastExtend(java.util.List<float[]> pts, float[][] segs,
java.util.Map<Long, java.util.List<Integer>> adj, boolean[] used) {
for (;;) {
float[] p = pts.get(pts.size() - 1);
java.util.List<Integer> nb = adj.get(coastPtKey(p[0], p[1]));
int next = -1;
if (nb != null) { for (int idx : nb) { if (!used[idx]) { next = idx; break; } } }
if (next < 0) break;
used[next] = true;
float[] seg = segs[next];
boolean atA = Math.abs(seg[0] - p[0]) < 0.1f && Math.abs(seg[1] - p[1]) < 0.1f;
pts.add(atA ? new float[]{seg[2], seg[3]} : new float[]{seg[0], seg[1]});
}
}
private static long coastPtKey(float x, float z) {
return ((long)Math.round(x * 4)) << 32 | (Math.round(z * 4) & 0xFFFFFFFFL);
}
private static float[][] simplifyCoastPath(float[][] path) {
int n = path.length;
int len = (n > 1 && Math.abs(path[0][0]-path[n-1][0]) < 0.01f
&& Math.abs(path[0][1]-path[n-1][1]) < 0.01f) ? n - 1 : n;
if (len <= 2) return path;
java.util.List<float[]> out = new java.util.ArrayList<>(len);
for (int i = 0; i < len; i++) {
float[] a = path[(i + len - 1) % len], b = path[i], c = path[(i + 1) % len];
float cross = (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0]);
if (Math.abs(cross) > 1e-3f) out.add(b);
}
return out.isEmpty() ? new float[][]{path[0]} : out.toArray(new float[0][]);
}
private static float[][] chaikin(float[][] path, int iterations) {
float[][] cur = path;
for (int iter = 0; iter < iterations; iter++) {
int n = cur.length;
float[][] next = new float[n * 2][];
for (int i = 0; i < n; i++) {
float[] p0 = cur[i], p1 = cur[(i + 1) % n];
next[i*2] = new float[]{ p0[0]*0.75f + p1[0]*0.25f, p0[1]*0.75f + p1[1]*0.25f };
next[i*2+1] = new float[]{ p0[0]*0.25f + p1[0]*0.75f, p0[1]*0.25f + p1[1]*0.75f };
}
cur = next;
}
return cur;
}
private static List<float[][]> fixWindingForClip(List<float[][]> paths) {
if (paths.size() <= 1) return paths;
List<float[][]> result = new java.util.ArrayList<>(paths.size());
for (float[][] path : paths) {
float[] probe = path[path.length / 2];
int cnt = 0;
for (float[][] other : paths) {
if (other != path && msPointInPolygon(probe[0], probe[1], other)) cnt++;
}
boolean isHole = (cnt % 2) == 1;
boolean isCCW = msSignedArea(path) > 0;
result.add(isCCW == !isHole ? path : msReversePath(path));
}
return result;
}
private static float msSignedArea(float[][] path) {
float a = 0f;
for (int i = 0, n = path.length; i < n; i++) {
float[] p0 = path[i], p1 = path[(i + 1) % n];
a += p0[0] * p1[1] - p1[0] * p0[1];
}
return a * 0.5f;
}
private static boolean msPointInPolygon(float px, float py, float[][] poly) {
boolean inside = false;
for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) {
float xi = poly[i][0], yi = poly[i][1], xj = poly[j][0], yj = poly[j][1];
if (((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
private static float[][] msReversePath(float[][] path) {
float[][] r = new float[path.length][];
for (int i = 0; i < path.length; i++) r[i] = path[path.length - 1 - i];
return r;
}
private static float pathPerimeterSq(float[][] path) {
float len = 0f;
for (int i = 0; i < path.length; i++) {
float[] a = path[i], b = path[(i + 1) % path.length];
float dx = b[0] - a[0], dz = b[1] - a[1];
len += (float) Math.sqrt(dx * dx + dz * dz);
}
return len * len;
}
// ── Vorberechnete Overlay-Daten (einmalig beim Laden, dann gecacht) ─────────
/** Vorberechnete Overlay-Daten (einmalig beim Laden, gecacht). */
public record MapOverlayData(
float[] forestDensity,
int[] cliffPx, // ARGB bei COLOR_MAP_SIZE×COLOR_MAP_SIZE
int[] colorMap, // RGB bei COLOR_MAP_SIZE×COLOR_MAP_SIZE (Terrain + Wasser mit AA)
List<float[][]> coastPaths,
List<float[][]> cliffPaths,
List<float[][]> forestPaths,
List<float[][]> pathChains
) {}
/** Berechnet alle Overlay-Daten einmalig aus dem Render-Modell. */
public static MapOverlayData computeOverlayData(WorldMapRenderModel model) {
float[] density = computeForestDensity(model);
int[] cm = model.terrainColorMap() != null ? model.terrainColorMap()
: buildTerrainColorMap(model.mapData(), model.slotColorsRGB(),
model.mapTilePixels(), model.voxelSurface(),
model.waters(), model.waterfalls());
bakeWaterIntoColorMap(cm, model, COLOR_MAP_SIZE);
// Cave-Pfad-Splitting: nur Oberflächen-Pfade in pathChains
List<float[][]> allPaths = loadPathChains();
boolean[] caveMask = buildCaveFootprintMask(model.mapData(), VOXEL_SURFACE_RES);
float[] vsForPaths = bakeVoxelSurface(model.mapData());
List<float[][]> surfPaths = splitPathChains(allPaths, vsForPaths, caveMask)[0];
return new MapOverlayData(
density,
buildCliffOverlay(model.mapData(), model.voxelSurface()),
cm,
buildCoastPaths(model),
buildCliffPaths(model.mapData(), model.voxelSurface()),
buildForestOutlinePaths(density, FOREST_DENSITY_SIZE, 3.0f),
surfPaths
);
}
/**
* Bäckt Wasser (Ozean + PlacedWater) mit 8×8 Sub-Pixel-Antialiasing
* an der Küstenlinie direkt in den colorMap ein – identische Logik wie in render().
*/
private static void bakeWaterIntoColorMap(int[] cm, WorldMapRenderModel model, int size) {
float[] heights = buildTerrainSamples(model.mapData(), size, model.voxelSurface());
// 1. Ozean + PlacedWater-Maske bei size×size
boolean[] isWater = new boolean[size * size];
for (int i = 0; i < heights.length; i++) isWater[i] = heights[i] < 0f;
for (PlacedWater w : model.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) && heights[py * size + px] < wh)
isWater[py * size + px] = true;
}
}
}
int waterR = (WATER_COLOR >> 16) & 0xFF;
int waterG = (WATER_COLOR >> 8) & 0xFF;
int waterB = WATER_COLOR & 0xFF;
// 2. Vollständige Wasser-Pixel direkt setzen
for (int i = 0; i < size * size; i++) {
if (isWater[i]) cm[i] = (waterR << 16) | (waterG << 8) | waterB;
}
// 3. Sub-Pixel-AA an der Ozean-Küste (8×8 Samples)
int SUB = 8;
float subInv = 1f / (SUB * SUB);
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
int idx = py * size + px;
boolean sea = isWater[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 >= size || nx < 0 || nx >= size) continue;
if (isWater[ny * size + nx] != sea) boundary = true;
}
}
if (!boundary) continue;
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(model.mapData(), size, spx, spy, model.voxelSurface()) < 0f)
wCount++;
}
}
float wFrac = wCount * subInv;
int landRGB = cm[idx];
// bei Wasser-Pixel: Nachbarland-Farbe holen
if (sea) {
for (int dy = -1; dy <= 1 && sea; dy++) {
for (int dx = -1; dx <= 1 && sea; dx++) {
int ny = py+dy, nx = px+dx;
if (ny < 0||ny>=size||nx<0||nx>=size) continue;
if (!isWater[ny*size+nx]) { landRGB = cm[ny*size+nx]; sea = false; }
}
}
}
int lr=(landRGB>>16)&0xFF, lg=(landRGB>>8)&0xFF, lb=landRGB&0xFF;
cm[idx] = (clamp((int)(waterR*wFrac + lr*(1-wFrac))) << 16)
| (clamp((int)(waterG*wFrac + lg*(1-wFrac))) << 8)
| clamp((int)(waterB*wFrac + lb*(1-wFrac)));
}
}
// 4. Sub-Pixel-AA an PlacedWater-Ufern
for (PlacedWater w : model.waters()) {
int[] wxPx = worldToPixels(w.pointsX(), size);
int[] wzPx = worldToPixels(w.pointsZ(), size);
Polygon wp = new Polygon(wxPx, wzPx, wxPx.length);
Rectangle bb = wp.getBounds();
float wh = w.waterHeight();
int x0=Math.max(0,bb.x-1), y0=Math.max(0,bb.y-1);
int x1=Math.min(size-1,bb.x+bb.width+1), y1=Math.min(size-1,bb.y+bb.height+1);
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
int idx = py * size + px;
boolean inPoly = wp.contains(px,py) && heights[idx] < wh;
boolean boundary2 = false;
for (int dy=-1; dy<=1 && !boundary2; dy++) {
for (int dx=-1; dx<=1 && !boundary2; dx++) {
int ny=py+dy, nx=px+dx;
if (ny<0||ny>=size||nx<0||nx>=size) continue;
if ((wp.contains(nx,ny)&&heights[ny*size+nx]<wh) != 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(model.mapData(),size,spx,spy,model.voxelSurface()) < wh)
wCount2++;
}
}
float wFrac2 = wCount2 * subInv;
int landRGB2 = cm[idx];
if (inPoly) {
for (int dy=-1; dy<=1; dy++) {
for (int dx=-1; dx<=1; dx++) {
int ny=py+dy, nx=px+dx;
if (ny<0||ny>=size||nx<0||nx>=size) continue;
if (!(wp.contains(nx,ny)&&heights[ny*size+nx]<wh)) { landRGB2=cm[ny*size+nx]; break; }
}
}
}
int lr2=(landRGB2>>16)&0xFF, lg2=(landRGB2>>8)&0xFF, lb2=landRGB2&0xFF;
cm[idx] = (clamp((int)(waterR*wFrac2+lr2*(1-wFrac2))) << 16)
| (clamp((int)(waterG*wFrac2+lg2*(1-wFrac2))) << 8)
| clamp((int)(waterB*wFrac2+lb2*(1-wFrac2)));
}
}
}
}
// ── Haupt-Layer-Renderer (Terrain + alle Overlay-Layer) ───────────────────
/**
* Rendert alle Layer (Terrain, Wasser, Wald-Füllung, Kliff-Füllung,
* Küstenlinie, Kliff-Schraffierung + Kontur, Wald-Kontur, Wege)
* identisch zum Editor-Canvas, aber via Java2D.
*/
/**
* Rendert den Minimap-Hintergrund (volle Welt): Terrain + Wasser + Wald-Füllung + Kliff-Füllung
* + alle Vektorlinien (Küste, Klippen, Wald, Wege) in Weltkarten-Auflösung.
*/
public static BufferedImage renderAllLayers(WorldMapRenderModel model, MapOverlayData overlay, int size) {
RenderOptions bgOpts = new RenderOptions(true, true, true, false, false, false, false, false, false);
BufferedImage bi = render(model, size, bgOpts);
Graphics2D g2 = bi.createGraphics();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
// Wald-Füllung (semi-transparent ARGB)
int[] forestPx = computeForestPixels(overlay.forestDensity(), 3.0f, size);
BufferedImage forestImg = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
forestImg.setRGB(0, 0, size, size, forestPx, 0, size);
g2.drawImage(forestImg, 0, 0, null);
// Kliff-Füllung (ARGB bei COLOR_MAP_SIZE, skaliert auf size)
BufferedImage cliffFill = new BufferedImage(COLOR_MAP_SIZE, COLOR_MAP_SIZE, BufferedImage.TYPE_INT_ARGB);
cliffFill.setRGB(0, 0, COLOR_MAP_SIZE, COLOR_MAP_SIZE, overlay.cliffPx(), 0, COLOR_MAP_SIZE);
g2.drawImage(cliffFill, 0, 0, size, size, null);
// Vektorlinien in Weltkarten-Koordinaten (Linienbreite in Textur-Pixeln)
float lw = Math.max(1f, size / 1024f);
drawVectorLayers(g2, overlay, lw,
wx -> worldToImgX((float) wx, size),
wz -> worldToImgY((float) wz, size));
g2.dispose();
return bi;
}
/** Convenience-Overload: berechnet overlay intern. */
public static BufferedImage renderAllLayers(WorldMapRenderModel model, int size) {
return renderAllLayers(model, computeOverlayData(model), size);
}
/**
* Rendert den sichtbaren Weltausschnitt vollständig: Terrain, Wasser, Wald-Füllung,
* Kliff-Füllung und alle Vektor-Linien. Linienbreiten sind konstant in Textur-Pixeln
* → immer scharf, egal bei welchem Zoom-Level.
*
* @param overlay vorberechnete Overlay-Daten
* @param size Ausgabegröße (Pixel)
* @param cx Weltmittelpunkt X
* @param cz Weltmittelpunkt Z
* @param viewRadius halber Weltausschnitt (cx ± viewRadius sichtbar)
*/
public static BufferedImage renderFullRegion(MapOverlayData overlay,
int size, float cx, float cz, float viewRadius) {
// colorMap enthält bereits Terrain + Wasser mit antialiased Küstenlinie (aus computeOverlayData)
int[] colorMap = overlay.colorMap();
int[] cliffPx = overlay.cliffPx();
float[] density = overlay.forestDensity();
// ── Terrain + Wasser: bilinear aus vorberechnetem colorMap ────────
int[] pixels = new int[size * size];
for (int py = 0; py < size; py++) {
float wz = cz - viewRadius + (float) py / (size - 1) * 2f * viewRadius;
float fz = Math.max(0f, Math.min(COLOR_MAP_SIZE - 1.001f,
(wz + WORLD_HALF) / WORLD_SIZE * (COLOR_MAP_SIZE - 1)));
int cz0 = (int) fz, cz1 = Math.min(cz0+1, COLOR_MAP_SIZE-1);
float tz = fz - cz0;
for (int px = 0; px < size; px++) {
float wx = cx - viewRadius + (float) px / (size - 1) * 2f * viewRadius;
float fx = Math.max(0f, Math.min(COLOR_MAP_SIZE - 1.001f,
(wx + WORLD_HALF) / WORLD_SIZE * (COLOR_MAP_SIZE - 1)));
int cx0 = (int) fx, cx1 = Math.min(cx0+1, COLOR_MAP_SIZE-1);
float tx = fx - cx0;
int c00 = colorMap[cz0*COLOR_MAP_SIZE+cx0], c10 = colorMap[cz0*COLOR_MAP_SIZE+cx1];
int c01 = colorMap[cz1*COLOR_MAP_SIZE+cx0], c11 = colorMap[cz1*COLOR_MAP_SIZE+cx1];
int r = clamp((int)(((c00>>16)&0xFF)*(1-tx)*(1-tz)+((c10>>16)&0xFF)*tx*(1-tz)+((c01>>16)&0xFF)*(1-tx)*tz+((c11>>16)&0xFF)*tx*tz));
int g = clamp((int)(((c00>>8)&0xFF)*(1-tx)*(1-tz)+((c10>>8)&0xFF)*tx*(1-tz)+((c01>>8)&0xFF)*(1-tx)*tz+((c11>>8)&0xFF)*tx*tz));
int b = clamp((int)((c00&0xFF)*(1-tx)*(1-tz)+(c10&0xFF)*tx*(1-tz)+(c01&0xFF)*(1-tx)*tz+(c11&0xFF)*tx*tz));
pixels[py * size + px] = (r << 16) | (g << 8) | b;
}
}
// ── Wald-Füllung + Kliff-Füllung (alpha-compositing direkt in pixels) ──
final int FD = FOREST_DENSITY_SIZE;
for (int py = 0; py < size; py++) {
float wz = cz - viewRadius + (float) py / (size - 1) * 2f * viewRadius;
float fsfz = Math.max(0f, Math.min(FD - 1.001f, (WORLD_HALF - wz) / WORLD_SIZE * FD));
int fsz0 = (int) fsfz, fsz1 = Math.min(fsz0+1, FD-1);
float fstz = fsfz - fsz0;
float cfz = Math.max(0f, Math.min(COLOR_MAP_SIZE - 1.001f,
(wz + WORLD_HALF) / WORLD_SIZE * (COLOR_MAP_SIZE - 1)));
int cfz0 = (int) cfz;
for (int px = 0; px < size; px++) {
float wx = cx - viewRadius + (float) px / (size - 1) * 2f * viewRadius;
// Wald
float fsfx = Math.max(0f, Math.min(FD - 1.001f, (wx + WORLD_HALF) / WORLD_SIZE * FD));
int fsx0 = (int) fsfx, fsx1 = Math.min(fsx0+1, FD-1);
float fstx = fsfx - fsx0;
float d = (density[fsz0*FD+fsx0]*(1-fstx)+density[fsz0*FD+fsx1]*fstx)*(1-fstz)
+ (density[fsz1*FD+fsx0]*(1-fstx)+density[fsz1*FD+fsx1]*fstx)*fstz;
if (d > 3.0f) pixels[py*size+px] = alphaBlendRGB(pixels[py*size+px], 0x80_4a9a28);
// Kliff
float cfx = Math.max(0f, Math.min(COLOR_MAP_SIZE - 1.001f,
(wx + WORLD_HALF) / WORLD_SIZE * (COLOR_MAP_SIZE - 1)));
int cliff = cliffPx[cfz0 * COLOR_MAP_SIZE + (int) cfx];
if ((cliff >>> 24) > 10) pixels[py*size+px] = alphaBlendRGB(pixels[py*size+px], cliff);
}
}
// ── Vektor-Linien ─────────────────────────────────────────────────
BufferedImage bi = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
bi.setRGB(0, 0, size, size, pixels, 0, size);
Graphics2D g2 = bi.createGraphics();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
drawVectorLayers(g2, overlay, 2.0f,
wx -> regionToImgX((float) wx, cx, viewRadius, size),
wz -> regionToImgY((float) wz, cz, viewRadius, size));
g2.dispose();
return bi;
}
private static int alphaBlendRGB(int dstRGB, int srcARGB) {
float a = ((srcARGB >>> 24) & 0xFF) / 255f;
int sr = (srcARGB >> 16) & 0xFF, sg = (srcARGB >> 8) & 0xFF, sb = srcARGB & 0xFF;
int dr = (dstRGB >> 16) & 0xFF, dg = (dstRGB >> 8) & 0xFF, db = dstRGB & 0xFF;
return (clamp((int)(dr + (sr - dr) * a)) << 16)
| (clamp((int)(dg + (sg - dg) * a)) << 8)
| clamp((int)(db + (sb - db) * a));
}
/** Zeichnet alle Vektor-Layer über g2; xFn/zFn konvertieren Weltkoord → Bildpixel. */
private static void drawVectorLayers(Graphics2D g2, MapOverlayData overlay, float lw,
java.util.function.DoubleUnaryOperator xFn,
java.util.function.DoubleUnaryOperator zFn) {
// Küstenpfade
g2.setColor(new Color(30, 80, 145, 200));
g2.setStroke(new java.awt.BasicStroke(lw * 1.2f, java.awt.BasicStroke.CAP_ROUND, java.awt.BasicStroke.JOIN_ROUND));
for (float[][] path : overlay.coastPaths()) drawMappedPath(g2, path, xFn, zFn, true);
// Kliff-Schraffierung + Konturlinie
if (!overlay.cliffPaths().isEmpty()) {
Graphics2D g2c = (Graphics2D) g2.create();
Path2D clipArea = new Path2D.Float(Path2D.WIND_NON_ZERO);
for (float[][] path : overlay.cliffPaths()) {
if (path.length < 2) continue;
clipArea.moveTo(xFn.applyAsDouble(path[0][0]), zFn.applyAsDouble(path[0][1]));
for (int i = 1; i < path.length; i++) {
clipArea.lineTo(xFn.applyAsDouble(path[i][0]), zFn.applyAsDouble(path[i][1]));
}
clipArea.closePath();
}
g2c.clip(clipArea);
g2c.setColor(new Color(120, 115, 115, 80));
g2c.setStroke(new java.awt.BasicStroke(lw));
float hatch = lw * 4f;
int imgSize = g2c.getClipBounds() != null ? g2c.getClipBounds().width : 2048;
for (float d = -imgSize; d < imgSize * 2f; d += hatch) {
g2c.drawLine((int)d, 0, (int)(d + imgSize), imgSize);
}
g2c.dispose();
g2.setColor(new Color(80, 72, 72, 200));
g2.setStroke(new java.awt.BasicStroke(lw, java.awt.BasicStroke.CAP_ROUND, java.awt.BasicStroke.JOIN_ROUND));
for (float[][] path : overlay.cliffPaths()) drawMappedPath(g2, path, xFn, zFn, true);
}
// Wald-Konturlinie
g2.setColor(new Color(28, 78, 12, 220));
g2.setStroke(new java.awt.BasicStroke(lw, java.awt.BasicStroke.CAP_ROUND, java.awt.BasicStroke.JOIN_ROUND));
for (float[][] path : overlay.forestPaths()) drawMappedPath(g2, path, xFn, zFn, true);
// Wege: dunkle Außenlinie dann helle Innenlinie
if (!overlay.pathChains().isEmpty()) {
g2.setColor(new Color(20, 12, 5, 208));
g2.setStroke(new java.awt.BasicStroke(lw * 2.5f + 1f, java.awt.BasicStroke.CAP_ROUND, java.awt.BasicStroke.JOIN_ROUND));
for (float[][] chain : overlay.pathChains()) drawMappedChain(g2, chain, xFn, zFn);
g2.setColor(new Color(148, 105, 55, 234));
g2.setStroke(new java.awt.BasicStroke(lw * 2.5f, java.awt.BasicStroke.CAP_ROUND, java.awt.BasicStroke.JOIN_ROUND));
for (float[][] chain : overlay.pathChains()) drawMappedChain(g2, chain, xFn, zFn);
}
}
// ── Koordinaten-Helfer ────────────────────────────────────────────────────
private static double worldToImgX(float wx, int size) {
return (wx + WORLD_HALF) / WORLD_SIZE * (size - 1);
}
private static double worldToImgY(float wz, int size) {
return (wz + WORLD_HALF) / WORLD_SIZE * (size - 1);
}
private static double regionToImgX(float wx, float cx, float vr, int size) {
return (wx - cx + vr) / (2f * vr) * (size - 1);
}
private static double regionToImgY(float wz, float cz, float vr, int size) {
return (wz - cz + vr) / (2f * vr) * (size - 1);
}
// ── Pfad-Zeichnen mit generischen Koordinaten-Funktionen ─────────────────
private static void drawMappedPath(Graphics2D g2, float[][] path,
java.util.function.DoubleUnaryOperator xFn,
java.util.function.DoubleUnaryOperator zFn,
boolean close) {
if (path.length < 2) return;
Path2D p = new Path2D.Float();
p.moveTo(xFn.applyAsDouble(path[0][0]), zFn.applyAsDouble(path[0][1]));
for (int i = 1; i < path.length; i++) {
p.lineTo(xFn.applyAsDouble(path[i][0]), zFn.applyAsDouble(path[i][1]));
}
if (close) p.closePath();
g2.draw(p);
}
private static void drawMappedChain(Graphics2D g2, float[][] chain,
java.util.function.DoubleUnaryOperator xFn,
java.util.function.DoubleUnaryOperator zFn) {
if (chain.length < 2) return;
final float R = 8f;
Path2D p = new Path2D.Float();
p.moveTo(xFn.applyAsDouble(chain[0][0]), zFn.applyAsDouble(chain[0][1]));
for (int i = 1; i < chain.length - 1; i++) {
float px = chain[i-1][0], pz = chain[i-1][1];
float cx = chain[i][0], cz = chain[i][1];
float nx = chain[i+1][0], nz = chain[i+1][1];
float d1x = cx-px, d1z = cz-pz, d2x = nx-cx, d2z = nz-cz;
float len1 = (float)Math.sqrt(d1x*d1x + d1z*d1z);
float len2 = (float)Math.sqrt(d2x*d2x + d2z*d2z);
if (len1 < 0.01f || len2 < 0.01f) {
p.lineTo(xFn.applyAsDouble(cx), zFn.applyAsDouble(cz));
continue;
}
float r = Math.min(R, Math.min(len1*0.4f, len2*0.4f));
float q1x = cx - (d1x/len1)*r, q1z = cz - (d1z/len1)*r;
float q2x = cx + (d2x/len2)*r, q2z = cz + (d2z/len2)*r;
p.lineTo(xFn.applyAsDouble(q1x), zFn.applyAsDouble(q1z));
p.quadTo(xFn.applyAsDouble(cx), zFn.applyAsDouble(cz),
xFn.applyAsDouble(q2x), zFn.applyAsDouble(q2z));
}
p.lineTo(xFn.applyAsDouble(chain[chain.length-1][0]), zFn.applyAsDouble(chain[chain.length-1][1]));
g2.draw(p);
}
/**
* Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.

View File

@@ -5706,7 +5706,6 @@ public class EditorApp extends Application {
if (!worldMapView.isLoaded()) {
worldMapView.loadAndRender();
} else if (caveMapView != null && caveMapView.isLoaded() && caveMapView.isViewInitialized()) {
// Sync nur wenn CaveMap korrekt initialisiert (canvas > 0, zoomLevels gesetzt)
final double s = caveMapView.getScale(), px = caveMapView.getPanX(), py = caveMapView.getPanY();
javafx.application.Platform.runLater(() -> worldMapView.setView(s, px, py));
} else {
@@ -5722,7 +5721,6 @@ public class EditorApp extends Application {
if (!caveMapView.isLoaded()) {
caveMapView.loadAndRender();
} else if (worldMapView.isMapReady() && worldMapView.isViewInitialized()) {
// Sync nur wenn WorldMap korrekt initialisiert
final double s = worldMapView.getScale(), px = worldMapView.getPanX(), py = worldMapView.getPanY();
javafx.application.Platform.runLater(() -> caveMapView.setView(s, px, py));
} else {
@@ -5770,6 +5768,7 @@ public class EditorApp extends Application {
});
tt.play();
});
caveMapView.loadAndRender(); // Vorladen beim Start, nicht erst beim Tab-Öffnen
worldMapView.setFullscreenCallbacks(
() -> {
// Placeholder ersetzt worldMapView im Tab → sauberes Detach

View File

@@ -291,23 +291,32 @@ public class CaveMapView extends VBox {
List<PlacedModel> models = PlacedModelIO.load();
BufferedImage bi = WorldMapRenderer.buildCaveMap(mapData, RENDER_SIZE);
// Für ingame-Nutzung speichern
try {
java.nio.file.Path cacheDir = java.nio.file.Paths.get(System.getProperty("user.home"), ".blight", "map_chunks");
java.nio.file.Files.createDirectories(cacheDir);
ImageIO.write(bi, "PNG", cacheDir.resolve("minimap_cave.png").toFile());
} catch (Exception ex) {
log.warn("[CaveMap] Konnte minimap_cave.png nicht speichern: {}", ex.getMessage());
}
List<float[][]> allPaths = WorldMapView.loadPathChains();
List<float[]> entrances = WorldMapView.computeTunnelEntrances(mapData);
List<float[]> allFootprints = WorldMapView.computeBuildingFootprintsSimple(models);
Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…"));
boolean[] caveMask = WorldMapView.loadCaveMask(mapData);
boolean[] caveMask = WorldMapRenderer.buildCaveFootprintMask(
mapData, WorldMapRenderer.VOXEL_SURFACE_RES);
// Pfade: Cave-Maske + Y-basierte Trennung
Platform.runLater(() -> statusLbl.setText("Berechne VoxelSurface…"));
float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData);
List<float[][]>[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface, caveMask);
List<float[][]>[] splitChains = WorldMapRenderer.splitPathChains(allPaths, voxelSurface, caveMask);
List<float[][]> newContourPaths = WorldMapView.buildTunnelContourPaths(caveMask);
List<float[][]> cavePaths = splitChains[1];
// Gebäude + Areas: Cave-Maske (keine Y-Info)
List<float[]> caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true);
List<PlacedArea> caveAreas = WorldMapView.filterAreasByCave(new ArrayList<>(areas), caveMask, true);
List<float[]> caveFootprints = WorldMapRenderer.filterBuildingsByCave(allFootprints, caveMask, true);
List<PlacedArea> caveAreas = WorldMapRenderer.filterAreasByCave(new ArrayList<>(areas), caveMask, true);
Platform.runLater(() -> {
mapFxImage = SwingFXUtils.toFXImage(bi, null);
@@ -317,7 +326,24 @@ public class CaveMapView extends VBox {
cachedAreas = caveAreas;
buildingFootprints = caveFootprints;
cachedMapData = mapData;
fitToView();
if (zoomLevels == null) {
fitToView(); // Erstmalig: Zoom initialisieren
} else {
double cw = canvas.getWidth(), ch = canvas.getHeight();
if (cw > 0 && ch > 0 && mapFxImage != null) {
double fit = Math.min(cw / mapFxImage.getWidth(), ch / mapFxImage.getHeight());
double ratio = Math.pow(ZOOM_MAX_SCALE / fit, 1.0 / (ZOOM_LEVELS - 1));
zoomLevels[0] = fit;
for (int i = 1; i < ZOOM_LEVELS; i++) zoomLevels[i] = zoomLevels[i-1] * ratio;
int best = 0;
double minDiff = Math.abs(zoomLevels[0] - scale);
for (int i = 1; i < ZOOM_LEVELS; i++) {
double d = Math.abs(zoomLevels[i] - scale);
if (d < minDiff) { minDiff = d; best = i; }
}
zoomLevelIdx = best;
}
}
redraw();
progress.setVisible(false);
statusLbl.setText("Höhlenkarte bereit.");

View File

@@ -266,6 +266,9 @@ public class WorldMapView extends VBox {
canvas.setOnMouseDragged(e -> {
panX = dragStartPanX + (e.getX() - dragStartX);
panY = dragStartPanY + (e.getY() - dragStartY);
double iw = RENDER_SIZE * scale, ih = iw;
panX = Math.max(50 - iw, Math.min(canvas.getWidth() - 50, panX));
panY = Math.max(50 - ih, Math.min(canvas.getHeight() - 50, panY));
redraw();
});
canvas.setOnMouseReleased(e -> {
@@ -290,6 +293,9 @@ public class WorldMapView extends VBox {
scale = zoomLevels[zoomLevelIdx];
panX = e.getX() - (e.getX() - panX) * scale / oldScale;
panY = e.getY() - (e.getY() - panY) * scale / oldScale;
double iw = RENDER_SIZE * scale, ih = iw;
panX = Math.max(50 - iw, Math.min(canvas.getWidth() - 50, panX));
panY = Math.max(50 - ih, Math.min(canvas.getHeight() - 50, panY));
redraw();
});
@@ -337,6 +343,10 @@ public class WorldMapView extends VBox {
scale = newScale;
panX = newPanX;
panY = newPanY;
if (zoomLevels == null && canvas.getWidth() > 0 && mapFxImage != null) {
double fitScale = Math.min(canvas.getWidth() / RENDER_SIZE, canvas.getHeight() / RENDER_SIZE);
computeZoomLevels(fitScale);
}
if (zoomLevels != null) {
int best = 0;
double minDiff = Math.abs(zoomLevels[0] - scale);
@@ -392,6 +402,12 @@ public class WorldMapView extends VBox {
BufferedImage bi = WorldMapRenderer.render(model, RENDER_SIZE, buildBackgroundOptions());
log.debug("[WorldMap] Hintergrund-PNG gerendert ({}×{} px, {} ms)",
RENDER_SIZE, RENDER_SIZE, System.currentTimeMillis() - t1);
// Für ingame-Nutzung: Hintergrund-PNG speichern
try {
javax.imageio.ImageIO.write(bi, "PNG", CHUNK_CACHE_DIR.resolve("minimap_world.png").toFile());
} catch (Exception ex) {
log.warn("[WorldMap] Konnte minimap_world.png nicht speichern: {}", ex.getMessage());
}
float[] fDensity = computeForestDensity(model);
int[] bakedFstPx = computeForestPixels(model, fDensity, 3.0f);
@@ -403,14 +419,15 @@ public class WorldMapView extends VBox {
List<float[][]> allPathChains = loadPathChains();
Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…"));
boolean[] caveMask = loadCaveMask(model.mapData());
boolean[] caveMask = WorldMapRenderer.buildCaveFootprintMask(
model.mapData(), WorldMapRenderer.VOXEL_SURFACE_RES);
// Disk-basierte voxelSurface für Pfad-Splitting: Live-Chunks decken oft nicht
// den Tunnel-Bereich ab, sodass dort NaN entstehen würde.
float[] vsForPaths = WorldMapRenderer.bakeVoxelSurface(model.mapData());
List<float[][]>[] splitChains = splitPathChains(allPathChains, vsForPaths, caveMask);
List<float[][]>[] splitChains = WorldMapRenderer.splitPathChains(allPathChains, vsForPaths, caveMask);
List<float[][]> newPathChains = splitChains[0]; // nur Oberfläche
List<float[]> newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false);
List<PlacedArea> surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false);
List<float[]> newBuildingFootprints = WorldMapRenderer.filterBuildingsByCave(allBuildingFootprints, caveMask, false);
List<PlacedArea> surfaceAreas = WorldMapRenderer.filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false);
// Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend)
final WorldMapRenderModel chunkModel = model;
@@ -433,7 +450,22 @@ public class WorldMapView extends VBox {
forestPaths = newForestPaths;
buildingFootprints = newBuildingFootprints;
cachedPathChains = newPathChains;
fitToView();
if (zoomLevels == null) {
fitToView(); // Erstmalig: Zoom initialisieren und auf Karte fitten
} else {
// Erneuter Load (Refresh): Zoom-Levels neu berechnen falls Canvas geändert,
// aber aktuelle Scale und Pan-Position beibehalten.
double fitScale = canvas.getWidth() > 0
? Math.min(canvas.getWidth() / RENDER_SIZE, canvas.getHeight() / RENDER_SIZE) : 1.0;
computeZoomLevels(fitScale);
int best = 0;
double minDiff = Math.abs(zoomLevels[0] - scale);
for (int i = 1; i < zoomLevels.length; i++) {
double d = Math.abs(zoomLevels[i] - scale);
if (d < minDiff) { minDiff = d; best = i; }
}
zoomLevelIdx = best;
}
redraw();
progress.setVisible(false);
statusLbl.setText("Bereit – " + model.areas().size() + " Areas, " + model.waters().size() + " Wasser");
@@ -1081,173 +1113,6 @@ private static List<float[]> computeBuildingFootprints(WorldMapRenderModel model
static final int CAVE_MASK_SIZE = 1024;
/** Lädt die Cave-Tunnel-Maske (true = Untergrund/Tunnel). */
static boolean[] loadCaveMask(MapData mapData) {
// buildCaveFootprintMask bei 2048px: gleiche Auflösung wie die Höhlenkarte.
// buildCaveTunnelMask bei 1024px filtert schmale Tunnel (MIN_FOOTPRINT_PX=40 ≈ 160m²).
return WorldMapRenderer.buildCaveFootprintMask(mapData, WorldMapRenderer.VOXEL_SURFACE_RES);
}
/** Prüft ob eine Weltposition im Tunnel-Fußabdruck liegt. Erkennt Maskengröße automatisch. */
static boolean isCavePoint(boolean[] mask, float wx, float wz) {
int SIZE = (int) Math.round(Math.sqrt(mask.length));
int px = Math.round((wx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SIZE - 1));
int pz = Math.round((wz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SIZE - 1));
px = Math.max(0, Math.min(SIZE - 1, px));
pz = Math.max(0, Math.min(SIZE - 1, pz));
return mask[pz * SIZE + px];
}
/**
* Teilt Pfad-Ketten an Tunnel-Grenzen auf.
* Nutzt die Cave-Chunk-Maske als räumlichen Filter (Chunk-Level-Erkennung)
* kombiniert mit Y-Koordinaten-Prüfung gegen die Voxel-Oberfläche.
* Rückgabe: Index 0 = Oberflächensegmente, Index 1 = Tunnel-Segmente.
*/
@SuppressWarnings("unchecked")
static List<float[][]>[] splitPathChains(List<float[][]> chains, float[] voxelSurface,
boolean[] caveMask) {
List<float[][]> surface = new ArrayList<>();
List<float[][]> cave = new ArrayList<>();
for (float[][] chain : chains) {
splitChainByVoxelSurface(chain, voxelSurface, caveMask, surface, cave);
}
log.warn("[PathSplit] {} Ketten → {} Oberfläche, {} Höhle",
chains.size(), surface.size(), cave.size());
if (!chains.isEmpty() && chains.get(0).length > 0) {
float[] n = chains.get(0)[0];
if (n.length >= 3 && voxelSurface != null) {
int VS = WorldMapRenderer.VOXEL_SURFACE_RES;
float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
int px = Math.max(0, Math.min(VS-1, Math.round((n[0]+WH)/WS*(VS-1))));
int pz = Math.max(0, Math.min(VS-1, Math.round((n[1]+WH)/WS*(VS-1))));
float vs = voxelSurface[pz*VS+px];
log.warn("[PathSplit] Erster Knoten: X={} Z={} Y={} voxSurf={} underground={}",
n[0], n[1], n[2], vs,
!Float.isNaN(vs) && vs > n[2] + PATH_UNDERGROUND_CLEARANCE);
}
}
return new List[]{surface, cave};
}
private static final float PATH_UNDERGROUND_CLEARANCE = 3.0f;
/**
* Prüft ob ein Knoten unter der Erde liegt.
* Primär: Cave-Footprint-Maske (2048px, erkennt auch Sculpted-Mesh-Tunnel).
* Fallback: VoxelSurface Y-Check (nur wenn VoxelChunk-Material direkt über dem Knoten liegt).
*/
private static boolean isNodeUnderground(float[] node, float[] voxelSurface, boolean[] caveMask) {
if (node.length < 2) return false;
if (caveMask != null && isCavePoint(caveMask, node[0], node[1])) return true;
if (node.length < 3 || Float.isNaN(node[2])) return false;
float wy = node[2];
int VS = WorldMapRenderer.VOXEL_SURFACE_RES;
float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE;
int px = Math.max(0, Math.min(VS - 1, Math.round((node[0] + WH) / WS * (VS - 1))));
int pz = Math.max(0, Math.min(VS - 1, Math.round((node[1] + WH) / WS * (VS - 1))));
float vs = voxelSurface[pz * VS + px];
return !Float.isNaN(vs) && vs > wy + PATH_UNDERGROUND_CLEARANCE;
}
private static float[] lerpNode(float[] a, float[] b, float t) {
float ax = a[0], az = a[1], ay = a.length > 2 ? a[2] : 0f;
float bx = b[0], bz = b[1], by = b.length > 2 ? b[2] : 0f;
return new float[]{ax + (bx - ax) * t, az + (bz - az) * t, ay + (by - ay) * t};
}
private static void splitChainByVoxelSurface(float[][] chain, float[] voxelSurface,
boolean[] caveMask,
List<float[][]> surface, List<float[][]> cave) {
if (chain.length < 2) return;
List<float[]> current = new ArrayList<>();
boolean currentCave = isNodeUnderground(chain[0], voxelSurface, caveMask);
current.add(chain[0]);
for (int i = 0; i < chain.length - 1; i++) {
float[] a = chain[i];
float[] b = chain[i + 1];
boolean bCave = isNodeUnderground(b, voxelSurface, caveMask);
if (currentCave == bCave) {
if (!currentCave) {
// Beide Oberfläche: 7-Punkte-Abtastung, um verborgene Tunnel zu erkennen
// (kein Knoten im Tunnel, aber das Segment kreuzt ein unterirdisches Gebiet).
float[] sample = null;
for (int s = 1; s <= 7 && sample == null; s++) {
float[] p = lerpNode(a, b, s / 8f);
if (isNodeUnderground(p, voxelSurface, caveMask)) { sample = p; }
}
if (sample != null) {
float[] c1 = findPathCrossing(a, sample, voxelSurface, caveMask, false);
float[] c2 = findPathCrossing(sample, b, voxelSurface, caveMask, true);
current.add(c1);
if (current.size() >= 2) surface.add(current.toArray(new float[0][]));
current = new ArrayList<>();
current.add(c1); current.add(sample); current.add(c2);
cave.add(current.toArray(new float[0][]));
current = new ArrayList<>();
current.add(c2); current.add(b);
continue;
}
}
current.add(b);
} else {
float[] crossing = findPathCrossing(a, b, voxelSurface, caveMask, currentCave);
current.add(crossing);
if (current.size() >= 2) {
(currentCave ? cave : surface).add(current.toArray(new float[0][]));
}
current = new ArrayList<>();
current.add(crossing);
current.add(b);
currentCave = bCave;
}
}
if (current.size() >= 2) {
(currentCave ? cave : surface).add(current.toArray(new float[0][]));
}
}
/** Bisektion mit Y-Interpolation: 12 Iterationen ≈ 0.05m Genauigkeit. */
private static float[] findPathCrossing(float[] a, float[] b, float[] vs, boolean[] caveMask,
boolean aIsUnderground) {
float ax = a[0], az = a[1], ay = a.length > 2 ? a[2] : 0f;
float bx = b[0], bz = b[1], by = b.length > 2 ? b[2] : 0f;
for (int i = 0; i < 12; i++) {
float mx = (ax + bx) * 0.5f, mz = (az + bz) * 0.5f, my = (ay + by) * 0.5f;
float[] mid = {mx, mz, my};
if (isNodeUnderground(mid, vs, caveMask) == aIsUnderground) { ax = mx; az = mz; ay = my; }
else { bx = mx; bz = mz; by = my; }
}
return new float[]{(ax + bx) * 0.5f, (az + bz) * 0.5f, (ay + by) * 0.5f};
}
/** Filtert Gebäude-Footprints: wantCave=true → nur Untergrund, false → nur Oberfläche. */
static List<float[]> filterBuildingsByCave(List<float[]> footprints, boolean[] caveMask, boolean wantCave) {
List<float[]> result = new ArrayList<>();
for (float[] fp : footprints) {
if (isCavePoint(caveMask, fp[0], fp[1]) == wantCave) result.add(fp);
}
return result;
}
/** Filtert Areas anhand des Polygon-Schwerpunkts. */
static List<PlacedArea> filterAreasByCave(List<PlacedArea> areas, boolean[] caveMask, boolean wantCave) {
List<PlacedArea> result = new ArrayList<>();
for (PlacedArea area : areas) {
float[] xs = area.pointsX(), zs = area.pointsZ();
if (xs == null || xs.length == 0) {
if (!wantCave) result.add(area);
continue;
}
float cx = 0, cz = 0;
for (int i = 0; i < xs.length; i++) { cx += xs[i]; cz += zs[i]; }
cx /= xs.length; cz /= zs.length;
if (isCavePoint(caveMask, cx, cz) == wantCave) result.add(area);
}
return result;
}
private static WritableImage toWritableImage(int[] pixels, int size) {
WritableImage img = new WritableImage(size, size);
img.getPixelWriter().setPixels(0, 0, size, size, PixelFormat.getIntArgbInstance(), pixels, 0, size);

View File

@@ -114,12 +114,12 @@ public class CompassHudState extends BaseAppState {
roseH = roseTex.getImage().getHeight();
} catch (Exception ignored) {}
// Kompass zentriert über der Minimap (links unten)
float cx = MinimapState.MARGIN + MinimapState.MINIMAP_SIZE / 2f;
float cy = MinimapState.MARGIN + MinimapState.MINIMAP_SIZE + GAP + roseH / 2f;
// Kompass-Mittelpunkt: 220px vom linken und unteren Bildschirmrand
float cx = 240f;
float cy = 240f;
compassNode = new Node("compass");
compassNode.setLocalTranslation(cx, cy, 0f);
compassNode.setLocalTranslation(cx, cy, 60f); // über Minimap (max z=51)
roseNode = new Node("rose");
compassNode.attachChild(roseNode);

View File

@@ -54,7 +54,6 @@ import de.blight.game.scene.WorldScene;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.nio.FloatBuffer;
import java.nio.file.Files;
@@ -73,17 +72,33 @@ public class MinimapState extends BaseAppState {
private static final float WORLD_HALF = WorldMapRenderer.WORLD_HALF;
private static final float WORLD_SIZE = WorldMapRenderer.WORLD_SIZE;
public static final float MINIMAP_SIZE = 200f;
public static final float MINIMAP_SIZE = 170f; // Karte: 170px Durchmesser im Rahmen
public static final float MINIMAP_RADIUS = MINIMAP_SIZE / 2f; // = 85px
public static final float MARGIN = 16f;
public static final float BORDER_DISPLAY = 256f; // Rahmen in natürlicher Größe
// Versatz vom Rahmen-Ursprung zur linken/unteren Kante des Kreises
private static final float MINIMAP_OFFSET = (BORDER_DISPLAY - MINIMAP_SIZE) / 2f; // = 43px
private static final float VIEW_RADIUS_DEF = 100f; // Minimap: 100 m Sichtradius (fest)
private static final float FM_VIEW_DEF = 200f; // Vollbild: Öffnungs-Radius 200 m
private static final float FM_VIEW_MIN = 50f; // Vollbild: engster Ausschnitt
private static final float FM_VIEW_MAX = WORLD_HALF; // Vollbild: ganze Welt
// 5 diskrete Zoom-Stufen (identisch zum Editor: 1×, 2×, 4×, 8×, 16× fit)
private static final float[] FM_ZOOM_RADII = { WORLD_HALF, 512f, 200f, 100f, 50f };
private static final int FM_ZOOM_DEFAULT = 2; // Index 2 = 200 m
private static final int TEXTURE_SIZE = 2048;
private static final int VEC_TEX_SIZE = 1024; // Vektor-Overlay-Textur (neu gerastert bei Zoom/Pan)
private static final float DOT_SIZE = 8f;
private static final float MARK_DIST_SQ = 4f * 4f;
private static final float AUTOSAVE_SEC = 60f;
private static final int MINIMAP_SEGS = 64;
// Vorberechnete cos/sin für die Kreissegmente (einmal beim Laden)
private static final float[] SEG_COS = new float[MINIMAP_SEGS];
private static final float[] SEG_SIN = new float[MINIMAP_SEGS];
static {
for (int i = 0; i < MINIMAP_SEGS; i++) {
double a = 2 * Math.PI * i / MINIMAP_SEGS;
SEG_COS[i] = (float) Math.cos(a);
SEG_SIN[i] = (float) Math.sin(a);
}
}
private static final String ACT_MAP_TOGGLE = "_MinimapToggle_";
private static final String ACT_MAP_ESC = "_MinimapEsc_";
@@ -97,13 +112,22 @@ public class MinimapState extends BaseAppState {
private Camera camera;
private boolean textureReady = false;
// Frisch gerenderte Karten-Images (vom Render-Thread, ausgewertet in onTextureReady im GL-Thread)
private volatile BufferedImage pendingWorldBi;
private volatile BufferedImage pendingCaveBi;
// Direkt aus BufferedImage erzeugte JME3-Texturen (kein AssetManager-Cache, immer aktuell)
private Texture2D worldMapTex;
private Texture2D caveMapTex;
// Vorberechnete Overlay-Daten (Pfade, Dichte-Maps) – einmalig beim Laden gecacht
private volatile de.blight.common.map.WorldMapRenderer.MapOverlayData overlayData;
private volatile de.blight.common.map.WorldMapRenderModel renderModel;
// ── Erkundungs-Nebel ──────────────────────────────────────────────────────
private ExploreTracker exploreTracker;
private Material fogMat; // Minimap-Nebel
private Material fogFullMat; // Vollbild-Nebel (eigene Overlay-Bounds)
private float fogTime = 0f;
private float posUpdateTimer = 0f;
// ── Blur-Filter (aktiv wenn Vollbild-Karte offen) ─────────────────────────
@@ -119,10 +143,10 @@ public class MinimapState extends BaseAppState {
private Geometry vectorGeo;
private volatile boolean vecRenderPending = false;
private volatile boolean vecLayerDirty = false;
// UV-Position des letzten abgeschlossenen Vektor-Renders (für Live-Verschiebung ohne Ghost)
private float vecRenderU = 0.5f;
private float vecRenderV = 0.5f;
private float vecRenderHU = 0.5f;
// UV-Mittelpunkt und Halbbreite des zuletzt abgeschlossenen Renders (für Live-Pan-Tracking)
private float vecRenderCenterU = 0.5f;
private float vecRenderCenterV = 0.5f;
private float vecRenderHU = 0f;
private float lastMarkX = Float.NaN;
private float lastMarkZ = Float.NaN;
private float autoSaveTimer = 0f;
@@ -154,13 +178,14 @@ public class MinimapState extends BaseAppState {
private Geometry caveGeoMini = null; // Höhlenkarte über Minimap
private Geometry caveGeoFull = null; // Höhlenkarte über Vollkarte
private float caveBlend = 0f; // 0 = Außen, 1 = Höhle
/** upperHole-Maske der geladenen Karte für Laufzeit-Erkennung; null = nicht verfügbar. */
private byte[] upperHoleMask = null;
/** Cave-Fußabdruck-Maske für Laufzeit-Erkennung (via WorldMapRenderer.isCavePoint). */
private boolean[] upperHoleMask = null;
private float fmMapSize;
private float fmMapOriginX;
private float fmMapOriginY;
private float fmViewRadius = FM_VIEW_DEF;
private int fmZoomLevelIdx = FM_ZOOM_DEFAULT;
private float fmViewRadius = FM_ZOOM_RADII[FM_ZOOM_DEFAULT];
private float fmCenterU = 0.5f;
private float fmCenterV = 0.5f;
private boolean fmDragging = false;
@@ -231,12 +256,8 @@ public class MinimapState extends BaseAppState {
if (fogMat != null) { fogMat.setFloat("Time", fogTime); }
if (fogFullMat != null) { fogFullMat.setFloat("Time", fogTime); }
posUpdateTimer += tpf;
boolean doPosUpdate = posUpdateTimer >= 1f;
if (doPosUpdate) posUpdateTimer = 0f;
if (!fullMapOpen) {
if (doPosUpdate) updateMinimapUV(px, pz);
updateMinimapUV(px, pz);
} else {
if (fmDragging) {
Vector2f cursor = app.getInputManager().getCursorPosition();
@@ -248,11 +269,11 @@ public class MinimapState extends BaseAppState {
float uvPerPixel = (2f * fmViewRadius / WORLD_SIZE) / fmMapSize;
fmCenterU -= dx * uvPerPixel;
fmCenterV -= dy * uvPerPixel;
// Nur UV des Hintergrunds verschieben – kein Re-Render während des Ziehens
updateFullMapUV();
scheduleVectorRedraw();
}
}
if (doPosUpdate) updateFullMapPlayerDot(px, pz);
updateFullMapPlayerDot(px, pz);
}
}
@@ -280,17 +301,17 @@ public class MinimapState extends BaseAppState {
private void tickCaveBlend(float px, float py, float pz, float tpf) {
if (upperHoleMask == null || caveGeoMini == null) return;
int C = MapData.UPPER_CELLS;
int cx = Math.max(0, Math.min(C-1, (int)((px + WORLD_HALF) / WORLD_SIZE * C)));
int cz = Math.max(0, Math.min(C-1, (int)((pz + WORLD_HALF) / WORLD_SIZE * C)));
boolean inHole = upperHoleMask[cz * C + cx] != 0;
boolean inHole = WorldMapRenderer.isCavePoint(upperHoleMask, px, pz);
float target = inHole ? 1f : 0f;
if (caveBlend != target) {
float speed = 1.5f; // blend completes in ~0.67s
float speed = 5f; // ~1 Sekunde Übergang (exponentiell, 99% nach 1s bei 60fps)
caveBlend += (target - caveBlend) * Math.min(tpf * speed, 1f);
if (Math.abs(caveBlend - target) < 0.005f) caveBlend = target;
ColorRGBA col = new ColorRGBA(1f, 1f, 1f, caveBlend);
caveGeoMini.getMaterial().setColor("Color", col);
if (caveGeoFull != null) {
caveGeoFull.getMaterial().setColor("Color", col);
}
}
}
@@ -319,14 +340,15 @@ public class MinimapState extends BaseAppState {
sinA = FastMath.sin(yaw);
}
// minimapMesh wird von minimapGeo UND fogMiniGeo geteilt
// Vertex-Reihenfolge: BL(-1,-1), BR(+1,-1), TR(+1,+1), TL(-1,+1)
float[] nx = { -1f, 1f, 1f, -1f };
float[] ny = { -1f, -1f, 1f, 1f };
// Kreis-Mesh: Vertex 0 = Zentrum, Vertices 1..MINIMAP_SEGS = Rand
FloatBuffer uv = (FloatBuffer) minimapMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
for (int i = 0; i < 4; i++) {
uv.put(i * 2, cu + (nx[i] * cosA + ny[i] * sinA) * hu);
uv.put(i * 2 + 1, cv + (-nx[i] * sinA + ny[i] * cosA) * hu);
uv.put(0, cu);
uv.put(1, cv);
for (int i = 0; i < MINIMAP_SEGS; i++) {
float nx = SEG_COS[i];
float ny = SEG_SIN[i];
uv.put((i + 1) * 2, cu + (nx * cosA + ny * sinA) * hu);
uv.put((i + 1) * 2 + 1, cv + (-nx * sinA + ny * cosA) * hu);
}
minimapMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
}
@@ -344,14 +366,11 @@ public class MinimapState extends BaseAppState {
fmMapSize = Math.min(sw, sh) * 0.85f;
fmMapOriginX = (sw - fmMapSize) / 2f;
fmMapOriginY = (sh - fmMapSize) / 2f;
fmViewRadius = FM_VIEW_DEF;
fmViewRadius = FM_ZOOM_RADII[fmZoomLevelIdx];
float px = playerNode.getWorldTranslation().x;
float pz = playerNode.getWorldTranslation().z;
fmCenterU = (px + WORLD_HALF) / WORLD_SIZE;
fmCenterV = 1f - (pz + WORLD_HALF) / WORLD_SIZE;
vecRenderU = fmCenterU;
vecRenderV = fmCenterV;
vecRenderHU = FM_VIEW_DEF / WORLD_SIZE;
// Blur auf dem geteilten FPP – genau wie das ESC-Menü
WorldScene ws = app.getStateManager().getState(WorldScene.class);
@@ -399,6 +418,19 @@ public class MinimapState extends BaseAppState {
vectorGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fullMapNode.attachChild(vectorGeo);
// Höhlenkarte-Overlay (teilt UV mit fullMapMesh, da gleicher Weltausschnitt)
if (caveMapTex != null) {
caveGeoFull = new Geometry("wm_cave", fullMapMesh);
Material caveFMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
caveFMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
caveFMat.setTexture("ColorMap", caveMapTex);
caveFMat.setColor("Color", new ColorRGBA(1f, 1f, 1f, caveBlend));
caveGeoFull.setMaterial(caveFMat);
caveGeoFull.setQueueBucket(RenderQueue.Bucket.Gui);
caveGeoFull.setLocalTranslation(0f, 0f, 0.4f); // über vectorGeo (z=20.3) und fullMapGeo (z=20)
fullMapNode.attachChild(caveGeoFull);
}
app.getGuiNode().attachChild(fullMapNode);
app.getInputManager().setCursorVisible(true);
@@ -466,23 +498,7 @@ public class MinimapState extends BaseAppState {
uv.put(4, fmCenterU + hu); uv.put(5, fmCenterV + hu);
uv.put(6, fmCenterU - hu); uv.put(7, fmCenterV + hu);
fullMapMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
updateVectorMeshUV();
}
private void updateVectorMeshUV() {
if (vectorMesh == null || vecRenderHU <= 0f) { return; }
float hu = fmViewRadius / WORLD_SIZE;
float rhu = vecRenderHU;
float uMin = (fmCenterU - hu - (vecRenderU - rhu)) / (2f * rhu);
float uMax = (fmCenterU + hu - (vecRenderU - rhu)) / (2f * rhu);
float vMin = (fmCenterV - hu - (vecRenderV - rhu)) / (2f * rhu);
float vMax = (fmCenterV + hu - (vecRenderV - rhu)) / (2f * rhu);
FloatBuffer uv = (FloatBuffer) vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(0, uMin); uv.put(1, vMin);
uv.put(2, uMax); uv.put(3, vMin);
uv.put(4, uMax); uv.put(5, vMax);
uv.put(6, uMin); uv.put(7, vMax);
vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
updateVectorMeshUV(); // vectorGeo trackt Pan/Zoom live
}
// ── Input ─────────────────────────────────────────────────────────────────
@@ -527,29 +543,12 @@ public class MinimapState extends BaseAppState {
private final AnalogListener analogListener = (name, value, tpf) -> {
if (!isEnabled() || !fullMapOpen) { return; }
float factor = ANA_FM_ZOOM_IN.equals(name) ? 1f / 1.15f : 1.15f;
float oldHu = fmViewRadius / WORLD_SIZE;
fmViewRadius = Math.max(FM_VIEW_MIN, Math.min(FM_VIEW_MAX, fmViewRadius * factor));
float newHu = fmViewRadius / WORLD_SIZE;
// Cursor-Anker: UV unter dem Mauszeiger bleibt an gleicher Bildschirmposition
Vector2f c = app.getInputManager().getCursorPosition();
float rx = (c.x - fmMapOriginX) / fmMapSize;
float ry = (c.y - fmMapOriginY) / fmMapSize;
if (rx >= 0f && rx <= 1f && ry >= 0f && ry <= 1f) {
fmCenterU += (rx - 0.5f) * 2f * (oldHu - newHu);
fmCenterV += (ry - 0.5f) * 2f * (oldHu - newHu);
} else {
// Zoom auf Spielerposition wenn Cursor außerhalb der Karte
float wpx = playerNode.getWorldTranslation().x;
float wpz = playerNode.getWorldTranslation().z;
float pu = (wpx + WORLD_HALF) / WORLD_SIZE;
float pv = 1f - (wpz + WORLD_HALF) / WORLD_SIZE;
float ratio = newHu / oldHu;
fmCenterU = pu + (fmCenterU - pu) * ratio;
fmCenterV = pv + (fmCenterV - pv) * ratio;
}
// Diskrete Zoom-Stufen wie im Editor (Mausrad springt eine Stufe pro Klick)
int newIdx = fmZoomLevelIdx + (ANA_FM_ZOOM_IN.equals(name) ? 1 : -1);
newIdx = Math.max(0, Math.min(FM_ZOOM_RADII.length - 1, newIdx));
if (newIdx == fmZoomLevelIdx) { return; }
fmZoomLevelIdx = newIdx;
fmViewRadius = FM_ZOOM_RADII[fmZoomLevelIdx];
updateFullMapUV();
scheduleVectorRedraw();
float px = playerNode.getWorldTranslation().x;
@@ -562,12 +561,9 @@ public class MinimapState extends BaseAppState {
private void startRenderThread() {
Thread t = new Thread(() -> {
try {
Path root = AnimationLibrary.findAssetRoot();
Path dir = root.resolve("Textures").resolve("hud");
Files.createDirectories(dir);
Path png = dir.resolve("minimap_world.png");
Path cavePng = java.nio.file.Paths.get(System.getProperty("user.home"), ".blight", "map_chunks", "minimap_cave.png");
log.debug("[Minimap] Lade Weltdaten für Render-Modell…");
log.debug("[Minimap] Lade Weltdaten…");
long t0 = System.currentTimeMillis();
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
@@ -575,30 +571,44 @@ public class MinimapState extends BaseAppState {
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
log.debug("[Minimap] Weltdaten geladen ({} ms)", System.currentTimeMillis() - t0);
// Render-Modell aufbauen (wird für Weltkarte + Vektor-Layer verwendet)
List<PlacedWaterfall> waterfalls = WaterfallIO.load();
int[] slotColors = computeSlotColors(mapData, root);
int[][] tilePixels = WorldMapRenderer.loadMapTiles(root);
int[] slotColors = computeSlotColors(mapData, AnimationLibrary.findAssetRoot());
int[][] tilePixels = WorldMapRenderer.loadMapTiles(AnimationLibrary.findAssetRoot());
float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
de.blight.common.map.WorldMapRenderModel renderModel = new de.blight.common.map.WorldMapRenderModel(
de.blight.common.map.WorldMapRenderModel rm = new de.blight.common.map.WorldMapRenderModel(
mapData, areas, zones, locs, waters, models, waterfalls,
slotColors, null, voxelSurface, null, null, null, tilePixels, null);
log.debug("[Minimap] Render-Modell bereit – {} Areas, {} Orte, {} Wasser ({} ms)",
areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0);
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
// Overlay-Daten einmalig vorberechnen (Pfade, Dichte-Maps – für alle Zoom-Level)
log.info("[Minimap] Berechne Overlay-Daten…");
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(renderModel, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
System.currentTimeMillis() - t1, png);
overlayData = WorldMapRenderer.computeOverlayData(rm);
renderModel = rm;
log.info("[Minimap] Overlay-Daten fertig ({} ms)", System.currentTimeMillis() - t1);
BufferedImage caveBi = WorldMapRenderer.buildCaveMap(mapData, TEXTURE_SIZE);
ImageIO.write(caveBi, "PNG", dir.resolve("minimap_cave.png").toFile());
// Verwende Voxel-basierten Tunnel-Maske für Laufzeit-Erkennung (UPPER_CELLS Auflösung)
boolean[] tunnelMask256 = WorldMapRenderer.buildCaveTunnelMask(mapData, MapData.UPPER_CELLS);
upperHoleMask = new byte[tunnelMask256.length];
for (int i = 0; i < tunnelMask256.length; i++) upperHoleMask[i] = tunnelMask256[i] ? (byte)1 : 0;
// Weltkarte: Echtzeit-Rendering mit allen Layern (identisch zum Editor)
log.info("[Minimap] Rendere Weltkarte mit allen Layern {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
long t2 = System.currentTimeMillis();
pendingWorldBi = WorldMapRenderer.renderAllLayers(rm, overlayData, TEXTURE_SIZE);
log.info("[Minimap] Weltkarte fertig ({} ms)", System.currentTimeMillis() - t2);
// Höhlenkarte
if (Files.exists(cavePng)) {
log.info("[Minimap] Lade Höhlenkarte aus Editor-Cache: {}", cavePng);
pendingCaveBi = javax.imageio.ImageIO.read(cavePng.toFile());
} else {
log.info("[Minimap] Kein Editor-Cache – rendere Höhlenkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
pendingCaveBi = WorldMapRenderer.buildCaveMap(mapData, TEXTURE_SIZE);
}
// Cave-Fußabdruck-Maske für Laufzeit-Erkennung (gleiche Logik wie beim Pfad-Splitting)
upperHoleMask = WorldMapRenderer.buildCaveFootprintMask(
mapData, WorldMapRenderer.VOXEL_SURFACE_RES);
app.enqueue(() -> { onTextureReady(); return null; });
} catch (Exception e) {
@@ -622,52 +632,60 @@ public class MinimapState extends BaseAppState {
}
private void onTextureReady() {
// Texturen direkt aus dem gerenderten BufferedImage erzeugen — kein AssetManager,
// kein Classpath-Cache, immer der aktuelle Render-Stand.
worldMapTex = biToTexture(pendingWorldBi);
caveMapTex = biToTexture(pendingCaveBi);
pendingWorldBi = null;
pendingCaveBi = null;
exploreTracker = new ExploreTracker();
exploreTracker.load();
float x = MARGIN;
float y = MARGIN;
// Node an der Rahmen-Ecke (MARGIN, MARGIN) – Rahmen füllt 256×256 ab dort
float x = MARGIN;
float y = MARGIN;
fogMat = buildFogMat(x, y, MINIMAP_SIZE, MINIMAP_SIZE);
// Fog-Material: Ursprung und Größe des Kreisbereichs in Bildschirm-Koordinaten
fogMat = buildFogMat(x + MINIMAP_OFFSET, y + MINIMAP_OFFSET, MINIMAP_SIZE, MINIMAP_SIZE);
minimapMesh = buildMinimapMesh(MINIMAP_SIZE, MINIMAP_SIZE);
// Kreisförmiges Mesh: Radius 85px, Mittelpunkt bei (85,85) in lokalem Raum
minimapMesh = buildCircleMesh(MINIMAP_RADIUS, MINIMAP_SEGS);
// Welt-Textur-Geo
// Alle Map-Layer teilen dasselbe Mesh; Kreis wird um MINIMAP_OFFSET verschoben
minimapGeo = new Geometry("minimap_geo", minimapMesh);
minimapGeo.setMaterial(buildTexMat());
minimapGeo.setQueueBucket(RenderQueue.Bucket.Gui);
minimapGeo.setLocalTranslation(MINIMAP_OFFSET, MINIMAP_OFFSET, 0f);
// Fog-Geo teilt dasselbe Mesh – UV-Updates gelten für beide
fogMiniGeo = new Geometry("minimap_fog", minimapMesh);
fogMiniGeo.setMaterial(fogMat);
fogMiniGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fogMiniGeo.setLocalTranslation(0f, 0f, 1f); // eine Schicht vor der Karte
// Rand-Schatten
Geometry border = makeSolidQuad("minimap_border",
MINIMAP_SIZE + 4f, MINIMAP_SIZE + 4f,
new ColorRGBA(0f, 0f, 0f, 0.6f));
border.setLocalTranslation(-2f, -2f, -1f);
// Spieler-Punkt fest in der Mitte
playerDotMini = makeSolidQuad("minimap_dot", DOT_SIZE, DOT_SIZE,
new ColorRGBA(1f, 0.85f, 0f, 1f));
playerDotMini.setLocalTranslation(
MINIMAP_SIZE / 2f - DOT_SIZE / 2f,
MINIMAP_SIZE / 2f - DOT_SIZE / 2f, 2f);
fogMiniGeo.setLocalTranslation(MINIMAP_OFFSET, MINIMAP_OFFSET, 1f);
caveGeoMini = new Geometry("minimap_cave", minimapMesh);
caveGeoMini.setMaterial(buildCaveTexMat());
caveGeoMini.setQueueBucket(RenderQueue.Bucket.Gui);
caveGeoMini.setLocalTranslation(0f, 0f, 0.5f); // between world map and fog
caveGeoMini.setLocalTranslation(MINIMAP_OFFSET, MINIMAP_OFFSET, 0.5f);
// Spieler-Punkt fest in der Mitte des Kreises
playerDotMini = makeSolidQuad("minimap_dot", DOT_SIZE, DOT_SIZE,
new ColorRGBA(1f, 0.85f, 0f, 1f));
playerDotMini.setLocalTranslation(
MINIMAP_OFFSET + MINIMAP_RADIUS - DOT_SIZE / 2f,
MINIMAP_OFFSET + MINIMAP_RADIUS - DOT_SIZE / 2f, 2f);
// PNG-Rahmen: 256×256 px, liegt über allen anderen Layern
Geometry borderGeo = makeBorderGeo(BORDER_DISPLAY);
borderGeo.setLocalTranslation(0f, 0f, 3f);
minimapNode = new Node("minimap");
minimapNode.setLocalTranslation(x, y, 48f);
minimapNode.attachChild(border);
minimapNode.attachChild(minimapGeo);
minimapNode.attachChild(caveGeoMini);
minimapNode.attachChild(fogMiniGeo);
minimapNode.attachChild(playerDotMini);
minimapNode.attachChild(borderGeo);
textureReady = true;
if (isEnabled()) {
@@ -679,12 +697,8 @@ public class MinimapState extends BaseAppState {
private Material buildCaveTexMat() {
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
try {
Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_cave.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
log.warn("[Minimap] Höhlenkarten-Textur nicht ladbar");
if (caveMapTex != null) {
mat.setTexture("ColorMap", caveMapTex);
}
mat.setColor("Color", new ColorRGBA(1f, 1f, 1f, 0f)); // zunächst unsichtbar
return mat;
@@ -693,31 +707,31 @@ public class MinimapState extends BaseAppState {
// ── Vektor-Layer ──────────────────────────────────────────────────────────
private void scheduleVectorRedraw() {
if (renderInput == null || !fullMapOpen) { return; }
if (overlayData == null || !fullMapOpen) { return; }
if (vecRenderPending) { vecLayerDirty = true; return; }
vecRenderPending = true;
vecLayerDirty = false;
float wx = fmCenterU * WORLD_SIZE - WORLD_HALF;
float wz = (1f - fmCenterV) * WORLD_SIZE - WORLD_HALF;
// Render-Zielposition jetzt einfrieren; vectorGeo bleibt sichtbar und trackt Pan via UV
float capturedCU = fmCenterU;
float capturedCV = fmCenterV;
float capturedHU = fmViewRadius / WORLD_SIZE;
float wx = capturedCU * WORLD_SIZE - WORLD_HALF;
float wz = (1f - capturedCV) * WORLD_SIZE - WORLD_HALF;
float vr = fmViewRadius;
final float capU = fmCenterU;
final float capV = fmCenterV;
final float capHU = vr / WORLD_SIZE;
Thread t = new Thread(() -> {
log.debug("[Minimap] Vektor-Layer neu rendern – Zentrum ({}/{}), Radius {}",
log.debug("[Minimap] Region rendern – Zentrum ({}/{}), Radius {}",
Math.round(wx), Math.round(wz), Math.round(vr));
long t0 = System.currentTimeMillis();
RenderOptions opts = new RenderOptions(false, false, true, true, true, true, true);
BufferedImage bi = WorldMapRenderer.renderRegion(renderInput, VEC_TEX_SIZE, opts, wx, wz, vr);
log.debug("[Minimap] Vektor-Layer fertig ({} ms)", System.currentTimeMillis() - t0);
BufferedImage bi = WorldMapRenderer.renderFullRegion(overlayData, VEC_TEX_SIZE, wx, wz, vr);
log.debug("[Minimap] Region fertig ({} ms)", System.currentTimeMillis() - t0);
app.enqueue(() -> {
updateVectorTexture(bi);
vecRenderU = capU;
vecRenderV = capV;
vecRenderHU = capHU;
updateVectorMeshUV();
vecRenderCenterU = capturedCU;
vecRenderCenterV = capturedCV;
vecRenderHU = capturedHU;
updateVectorMeshUV(); // UV auf frischen Render ausrichten
vecRenderPending = false;
if (vecLayerDirty) { scheduleVectorRedraw(); }
return null;
@@ -727,6 +741,23 @@ public class MinimapState extends BaseAppState {
t.start();
}
/** Passt die UVs des vectorMesh an, damit das alte Render bei Pan/Zoom korrekt sitzt. */
private void updateVectorMeshUV() {
if (vectorMesh == null || vecRenderHU <= 0f) { return; }
float hu = fmViewRadius / WORLD_SIZE;
float rhu = vecRenderHU;
float uMin = (fmCenterU - hu - vecRenderCenterU + rhu) / (2f * rhu);
float uMax = (fmCenterU + hu - vecRenderCenterU + rhu) / (2f * rhu);
float vMin = (fmCenterV - hu - vecRenderCenterV + rhu) / (2f * rhu);
float vMax = (fmCenterV + hu - vecRenderCenterV + rhu) / (2f * rhu);
FloatBuffer uv = (FloatBuffer) vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(0, uMin); uv.put(1, vMin);
uv.put(2, uMax); uv.put(3, vMin);
uv.put(4, uMax); uv.put(5, vMax);
uv.put(6, uMin); uv.put(7, vMax);
vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
}
private void updateVectorTexture(BufferedImage bi) {
if (vectorBuf == null || vectorImage == null) { return; }
int sz = VEC_TEX_SIZE;
@@ -749,12 +780,9 @@ public class MinimapState extends BaseAppState {
private Material buildTexMat() {
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Off);
try {
Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_world.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
log.warn("[Minimap] Weltkarten-Textur nicht ladbar");
if (worldMapTex != null) {
mat.setTexture("ColorMap", worldMapTex);
} else {
mat.setColor("Color", new ColorRGBA(0.05f, 0.1f, 0.2f, 1f));
}
return mat;
@@ -779,12 +807,8 @@ public class MinimapState extends BaseAppState {
private Material buildOverlayTexMat(float originX, float originY, float size) {
Material mat = new Material(app.getAssetManager(), "MatDefs/MapOverlay.j3md");
try {
Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_world.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
log.warn("[Minimap] Weltkarten-Textur nicht ladbar");
if (worldMapTex != null) {
mat.setTexture("ColorMap", worldMapTex);
}
mat.setVector2("OverlayOrigin", new Vector2f(originX, originY));
mat.setVector2("OverlaySize", new Vector2f(size, size));
@@ -793,19 +817,85 @@ public class MinimapState extends BaseAppState {
// ── Mesh-Helfer ───────────────────────────────────────────────────────────
private static Mesh buildMinimapMesh(float w, float h) {
/**
* Konvertiert ein BufferedImage direkt in eine JME3-Textur (Y-flip für OpenGL-Konvention).
* Umgeht den AssetManager-Cache vollständig — immer aktueller Render-Stand.
*/
private static Texture2D biToTexture(BufferedImage bi) {
int w = bi.getWidth(), h = bi.getHeight();
int[] pixels = bi.getRGB(0, 0, w, h, null, 0, w); // Bulk-Read aller Pixel auf einmal
java.nio.ByteBuffer buf = BufferUtils.createByteBuffer(w * h * 4);
for (int row = 0; row < h; row++) {
int bufRow = h - 1 - row; // Y-flip: OpenGL V=0 ist unten, Bild-Row 0 ist oben
for (int col = 0; col < w; col++) {
int argb = pixels[row * w + col];
int off = (bufRow * w + col) * 4;
buf.put(off, (byte) ((argb >> 16) & 0xFF));
buf.put(off + 1, (byte) ((argb >> 8) & 0xFF));
buf.put(off + 2, (byte) ( argb & 0xFF));
buf.put(off + 3, (byte) ((argb >> 24) & 0xFF));
}
}
Image jmeImg = new Image(Image.Format.RGBA8, w, h, buf);
Texture2D tex = new Texture2D(jmeImg);
tex.setWrap(Texture.WrapMode.EdgeClamp);
tex.setMagFilter(Texture.MagFilter.Bilinear);
tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
return tex;
}
/**
* Kreis-Mesh als Triangle-Fan: Vertex 0 = Mittelpunkt, Vertices 1..segs = Rand.
* Mittelpunkt liegt bei (radius, radius) in lokalem Koordinatenraum.
*/
private static Mesh buildCircleMesh(float radius, int segs) {
float cx = radius, cy = radius;
float[] pos = new float[(segs + 1) * 3];
float[] uvs = new float[(segs + 1) * 2];
// Mittelpunkt
pos[0] = cx; pos[1] = cy; pos[2] = 0f;
uvs[0] = 0.5f; uvs[1] = 0.5f;
// Randpunkte
for (int i = 0; i < segs; i++) {
double a = 2 * Math.PI * i / segs;
float ca = (float) Math.cos(a), sa = (float) Math.sin(a);
pos[(i + 1) * 3] = cx + radius * ca;
pos[(i + 1) * 3 + 1] = cy + radius * sa;
pos[(i + 1) * 3 + 2] = 0f;
uvs[(i + 1) * 2] = 0.5f + 0.5f * ca;
uvs[(i + 1) * 2 + 1] = 0.5f + 0.5f * sa;
}
// Triangle-Fan-Indizes
short[] idx = new short[segs * 3];
for (int i = 0; i < segs; i++) {
idx[i * 3] = 0;
idx[i * 3 + 1] = (short) (i + 1);
idx[i * 3 + 2] = (short) (i + 1 < segs ? i + 2 : 1);
}
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(
0f, 0f, 0f, w, 0f, 0f, w, h, 0f, 0f, h, 0f));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(
0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createShortBuffer(
(short) 0, (short) 1, (short) 2, (short) 0, (short) 2, (short) 3));
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(pos));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(uvs));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createShortBuffer(idx));
m.setMode(Mesh.Mode.Triangles);
m.updateBound();
return m;
}
/** Lädt map_border.png und erzeugt ein quadratisches Geo mit Alpha-Blending. */
private Geometry makeBorderGeo(float size) {
Texture2D tex = (Texture2D) app.getAssetManager().loadTexture("Textures/hud/map_border.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
tex.setMagFilter(Texture.MagFilter.Bilinear);
tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setTexture("ColorMap", tex);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
Geometry g = new Geometry("minimap_border_img", new Quad(size, size));
g.setMaterial(mat);
g.setQueueBucket(RenderQueue.Bucket.Gui);
return g;
}
private static Mesh buildQuadMesh(float x, float y, float w, float h, float z) {
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(