diff --git a/blight-assets/src/main/resources/Textures/hud/map_border.png b/blight-assets/src/main/resources/Textures/hud/map_border.png new file mode 100644 index 0000000..897cb6d Binary files /dev/null and b/blight-assets/src/main/resources/Textures/hud/map_border.png differ diff --git a/blight-assets/src/main/resources/Textures/hud/minimap_cave.png b/blight-assets/src/main/resources/Textures/hud/minimap_cave.png new file mode 100644 index 0000000..14c51c2 Binary files /dev/null and b/blight-assets/src/main/resources/Textures/hud/minimap_cave.png differ diff --git a/blight-assets/src/main/resources/Textures/hud/minimap_world.png b/blight-assets/src/main/resources/Textures/hud/minimap_world.png index 5cd3837..7b954f7 100644 Binary files a/blight-assets/src/main/resources/Textures/hud/minimap_world.png and b/blight-assets/src/main/resources/Textures/hud/minimap_world.png differ diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java index badc434..da66779 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java @@ -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=0&&nz threshold) px[py * size + bx] = forestFill; + } + } + return px; + } + + /** Liefert Küstenpfade: aus Modell wenn vorhanden, sonst per Marching Squares. */ + public static List buildCoastPaths(WorldMapRenderModel model) { + if (model.coastPaths() != null) return model.coastPaths(); + float[] heights = buildTerrainSamples(model.mapData(), SEA_MASK_SIZE, model.voxelSurface()); + java.util.List 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 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 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 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 segs = new java.util.ArrayList<>(); + marchingSquares(h, SIZE, 0f, null, null, segs); + List 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 loadPathChains() { + try { + de.blight.common.path.PathNetwork net = de.blight.common.path.PathNetworkIO.load(); + List nodes = net.getNodes(); + if (nodes.isEmpty()) return java.util.Collections.emptyList(); + java.util.Map nodeMap = new java.util.HashMap<>(); + java.util.Map> 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 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 visitedEdges = new java.util.HashSet<>(); + List 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 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[] splitPathChains(List chains, float[] voxelSurface, + boolean[] caveMask) { + List surface = new java.util.ArrayList<>(); + List 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 surface, List cave) { + if (chain.length < 2) return; + java.util.List 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 filterBuildingsByCave(List footprints, boolean[] caveMask, + boolean wantCave) { + List 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 filterAreasByCave(List areas, boolean[] caveMask, + boolean wantCave) { + List 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 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 assembleClosedPaths(float[][] segs, int chaikinIter) { + if (segs == null || segs.length == 0) return java.util.Collections.emptyList(); + java.util.Map> 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 result = new java.util.ArrayList<>(); + for (int start = 0; start < segs.length; start++) { + if (used[start]) continue; + used[start] = true; + java.util.List fwd = new java.util.ArrayList<>(); + java.util.List 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 pts, float[][] segs, + java.util.Map> adj, boolean[] used) { + for (;;) { + float[] p = pts.get(pts.size() - 1); + java.util.List 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 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 fixWindingForClip(List paths) { + if (paths.size() <= 1) return paths; + List 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 coastPaths, + List cliffPaths, + List forestPaths, + List 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 allPaths = loadPathChains(); + boolean[] caveMask = buildCaveFootprintMask(model.mapData(), VOXEL_SURFACE_RES); + float[] vsForPaths = bakeVoxelSurface(model.mapData()); + List 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]=size||nx<0||nx>=size) continue; + if (!(wp.contains(nx,ny)&&heights[ny*size+nx]>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. diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index b16c189..9b409f9 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -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 diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java index 74fd78f..5b83f4f 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java @@ -291,23 +291,32 @@ public class CaveMapView extends VBox { List 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 allPaths = WorldMapView.loadPathChains(); List entrances = WorldMapView.computeTunnelEntrances(mapData); List 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[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface, caveMask); + List[] splitChains = WorldMapRenderer.splitPathChains(allPaths, voxelSurface, caveMask); List newContourPaths = WorldMapView.buildTunnelContourPaths(caveMask); List cavePaths = splitChains[1]; // Gebäude + Areas: Cave-Maske (keine Y-Info) - List caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true); - List caveAreas = WorldMapView.filterAreasByCave(new ArrayList<>(areas), caveMask, true); + List caveFootprints = WorldMapRenderer.filterBuildingsByCave(allFootprints, caveMask, true); + List 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."); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java index 24b30a4..9860914 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -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 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[] splitChains = splitPathChains(allPathChains, vsForPaths, caveMask); + List[] splitChains = WorldMapRenderer.splitPathChains(allPathChains, vsForPaths, caveMask); List newPathChains = splitChains[0]; // nur Oberfläche - List newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false); - List surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false); + List newBuildingFootprints = WorldMapRenderer.filterBuildingsByCave(allBuildingFootprints, caveMask, false); + List 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 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[] splitPathChains(List chains, float[] voxelSurface, - boolean[] caveMask) { - List surface = new ArrayList<>(); - List 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 surface, List cave) { - if (chain.length < 2) return; - List 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 filterBuildingsByCave(List footprints, boolean[] caveMask, boolean wantCave) { - List 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 filterAreasByCave(List areas, boolean[] caveMask, boolean wantCave) { - List 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); diff --git a/blight-game/src/main/java/de/blight/game/state/CompassHudState.java b/blight-game/src/main/java/de/blight/game/state/CompassHudState.java index 3e1f848..14c3856 100644 --- a/blight-game/src/main/java/de/blight/game/state/CompassHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/CompassHudState.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/state/MinimapState.java b/blight-game/src/main/java/de/blight/game/state/MinimapState.java index 7ab60a1..df4ae96 100644 --- a/blight-game/src/main/java/de/blight/game/state/MinimapState.java +++ b/blight-game/src/main/java/de/blight/game/state/MinimapState.java @@ -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 areas = AreaIO.load(); @@ -575,30 +571,44 @@ public class MinimapState extends BaseAppState { List locs = LocationIO.load(); List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); + log.debug("[Minimap] Weltdaten geladen ({} ms)", System.currentTimeMillis() - t0); + + // Render-Modell aufbauen (wird für Weltkarte + Vektor-Layer verwendet) List 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(