diff --git a/blight-common/src/main/java/de/blight/common/AreaIO.java b/blight-common/src/main/java/de/blight/common/AreaIO.java index 2249d95..653cb33 100644 --- a/blight-common/src/main/java/de/blight/common/AreaIO.java +++ b/blight-common/src/main/java/de/blight/common/AreaIO.java @@ -6,6 +6,7 @@ import de.blight.common.model.trigger.TriggerIO; import java.io.*; import java.nio.file.*; import java.util.*; +import java.util.Locale; public final class AreaIO { @@ -19,7 +20,7 @@ public final class AreaIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# polygon\tareaId\ttriggersJson"); + w.write("# polygon\tareaId\ttriggersJson\tlabelX\tlabelZ"); w.newLine(); for (PlacedArea a : areas) { w.write(SoundAreaIO.encodePolygon(a.pointsX(), a.pointsZ())); @@ -27,6 +28,10 @@ public final class AreaIO { w.write(a.areaId()); w.write('\t'); w.write(TriggerIO.serializeList(a.triggers())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", a.labelX())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", a.labelZ())); w.newLine(); } } @@ -46,7 +51,9 @@ public final class AreaIO { if (pts[0].length < 3) continue; String areaId = f.length > 1 ? f[1].strip() : ""; List triggers = f.length > 2 ? TriggerIO.deserializeList(f[2]) : new ArrayList<>(); - list.add(new PlacedArea(pts[0], pts[1], areaId, triggers)); + float labelX = f.length > 3 ? Float.parseFloat(f[3].strip()) : Float.NaN; + float labelZ = f.length > 4 ? Float.parseFloat(f[4].strip()) : Float.NaN; + list.add(new PlacedArea(pts[0], pts[1], areaId, triggers, labelX, labelZ)); } catch (Exception ignored) {} } return list; diff --git a/blight-common/src/main/java/de/blight/common/LocationIO.java b/blight-common/src/main/java/de/blight/common/LocationIO.java index 3295e5d..da16545 100644 --- a/blight-common/src/main/java/de/blight/common/LocationIO.java +++ b/blight-common/src/main/java/de/blight/common/LocationIO.java @@ -26,7 +26,7 @@ public final class LocationIO { Path p = getPath(); Files.createDirectories(p.getParent()); try (BufferedWriter w = Files.newBufferedWriter(p)) { - w.write("# nameId\tcenterX\tcenterZ\tradius\ttriggersJson"); + w.write("# nameId\tcenterX\tcenterZ\tradius\ttriggersJson\tshowOnMap\tlabelX\tlabelZ"); w.newLine(); for (Location loc : locations) { w.write(loc.getId()); @@ -38,6 +38,12 @@ public final class LocationIO { w.write(String.format(Locale.ROOT, "%.3f", loc.getRadius())); w.write('\t'); w.write(TriggerIO.serializeList(loc.getTriggers())); + w.write('\t'); + w.write(Boolean.toString(loc.isShowOnMap())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", loc.getLabelX())); + w.write('\t'); + w.write(String.format(Locale.ROOT, "%.3f", loc.getLabelZ())); w.newLine(); } } @@ -62,6 +68,9 @@ public final class LocationIO { List triggers = TriggerIO.deserializeList(f[4].strip()); loc.setTriggers(triggers); } + loc.setShowOnMap(f.length > 5 ? Boolean.parseBoolean(f[5].strip()) : true); + loc.setLabelX(f.length > 6 ? Float.parseFloat(f[6].strip()) : Float.NaN); + loc.setLabelZ(f.length > 7 ? Float.parseFloat(f[7].strip()) : Float.NaN); list.add(loc); } catch (NumberFormatException ignored) {} } diff --git a/blight-common/src/main/java/de/blight/common/PlacedArea.java b/blight-common/src/main/java/de/blight/common/PlacedArea.java index c7c7cca..0ecb157 100644 --- a/blight-common/src/main/java/de/blight/common/PlacedArea.java +++ b/blight-common/src/main/java/de/blight/common/PlacedArea.java @@ -8,9 +8,14 @@ public record PlacedArea( float[] pointsX, float[] pointsZ, String areaId, - List triggers + List triggers, + float labelX, + float labelZ ) { public PlacedArea(float[] pointsX, float[] pointsZ, String areaId) { - this(pointsX, pointsZ, areaId, List.of()); + this(pointsX, pointsZ, areaId, List.of(), Float.NaN, Float.NaN); + } + public PlacedArea(float[] pointsX, float[] pointsZ, String areaId, List triggers) { + this(pointsX, pointsZ, areaId, triggers, Float.NaN, Float.NaN); } } 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 4931e1a..022f922 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 @@ -5,6 +5,7 @@ import de.blight.common.model.Location; import javax.imageio.ImageIO; import java.awt.*; +import java.awt.geom.Path2D; import java.awt.image.BufferedImage; import java.nio.file.Files; import java.nio.file.Path; @@ -42,14 +43,34 @@ public final class WorldMapRenderer { boolean showTerrain, boolean showSplatColors, boolean showWater, + boolean showWaterWaves, boolean showAreas, boolean showZones, boolean showLocations, boolean showModels ) { - public static RenderOptions all() { - return new RenderOptions(true, true, true, true, true, true, true); + // Compat: alte 7-Parameter-Form → showWaterWaves = showWater + public RenderOptions(boolean showTerrain, boolean showSplatColors, boolean showWater, + boolean showAreas, boolean showZones, boolean showLocations, boolean showModels) { + this(showTerrain, showSplatColors, showWater, showWater, + showAreas, showZones, showLocations, showModels); } + public static RenderOptions all() { + return new RenderOptions(true, true, true, true, true, true, true, true); + } + } + + public static boolean[] buildSeaMask(MapData m, int size) { + int TV = MapData.TERRAIN_VERTS; + boolean[] mask = new boolean[size * size]; + for (int py = 0; py < size; py++) { + for (int px = 0; px < size; px++) { + int hx = Math.min((int)((float) px / (size - 1) * (TV - 1)), TV - 1); + int hz = Math.min((int)((float) py / (size - 1) * (TV - 1)), TV - 1); + mask[py * size + px] = m.terrainHeight[hz * TV + hx] < 0f; + } + } + return mask; } // Default-Slot-Farben für Slots 1-8 (Base-Layer 1-4 + Upper-Layer 5-8) @@ -147,47 +168,146 @@ public final class WorldMapRenderer { // Wasser if (opts.showWater()) { - final int WATER_RGB = (255 << 24) | (35 << 16) | (100 << 8) | 190; - // Meer: Terrain unter Meeresspiegel + // ── Weißfüllung ─────────────────────────────────────────────────────── for (int py = 0; py < targetSize; py++) { for (int px = 0; px < targetSize; px++) { - if (heights[py * targetSize + px] < 0f) { - img.setRGB(px, py, WATER_RGB); - } + if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF); } } - // Wasserflächen: nur dort sichtbar, wo Terrain unter Wasseroberfläche liegt for (PlacedWater w : input.waters()) { int[] xs = worldToPixels(w.pointsX(), targetSize); int[] ys = worldToPixels(w.pointsZ(), targetSize); Polygon poly = new Polygon(xs, ys, xs.length); - Rectangle bb = poly.getBounds(); - int x0 = Math.max(0, bb.x); - int y0 = Math.max(0, bb.y); + Rectangle bb = poly.getBounds(); + int x0 = Math.max(0, bb.x), y0 = Math.max(0, bb.y); int x1 = Math.min(targetSize - 1, bb.x + bb.width); int y1 = Math.min(targetSize - 1, bb.y + bb.height); float wh = w.waterHeight(); for (int py = y0; py <= y1; py++) { for (int px = x0; px <= x1; px++) { - if (poly.contains(px, py) && heights[py * targetSize + px] < wh) { - img.setRGB(px, py, WATER_RGB); + if (poly.contains(px, py) && heights[py * targetSize + px] < wh) + img.setRGB(px, py, 0xFFFFFFFF); + } + } + } + + // ── Wellenmarken ────────────────────────────────────────────────────── + if (opts.showWaterWaves()) { + float wmW = Math.max(20f, targetSize / 70f); + float wmA = wmW * 0.2f, spX = wmW * 1.8f, spY = wmW * 1.05f; + gfx.setColor(Color.BLACK); + gfx.setStroke(new BasicStroke(Math.max(0.7f, lineW * 0.4f), + BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + // Meer: versetzt + gejittert + int seaRow = 0; + for (float wy = spY * 0.5f; wy < targetSize; wy += spY, seaRow++) { + float rowOff = (seaRow & 1) == 1 ? spX * 0.5f : 0f; + int seaCol = 0; + for (float wx = rowOff; wx + wmW <= targetSize; wx += spX, seaCol++) { + float jx = (float)(Math.abs(Math.sin(seaRow * 73.1 + seaCol * 157.3)) * spX * 0.25); + float jy = (float)(Math.sin(seaRow * 211.7 + seaCol * 89.5) * spY * 0.2); + float ox = wx + jx, oy = wy + jy; + int cx = (int)(ox + wmW * 0.5f), cy = (int)oy; + if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize + && ox >= 0 && ox + wmW < targetSize + && heights[cy * targetSize + cx] < 0f) { + Path2D mark = new Path2D.Float(); + mark.moveTo(ox, oy); + mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); + gfx.draw(mark); } } } + // Wasserflächen: nur wenn groß genug + for (PlacedWater w : input.waters()) { + int[] xs = worldToPixels(w.pointsX(), targetSize); + int[] ys = worldToPixels(w.pointsZ(), targetSize); + Polygon poly = new Polygon(xs, ys, xs.length); + Rectangle bb = poly.getBounds(); + if (bb.width <= wmW * 1.5f || bb.height <= spY) continue; + gfx.setClip(poly); + int wRow = 0; + for (float wy = bb.y + spY * 0.5f; wy < bb.y + bb.height; wy += spY, wRow++) { + float rowOff = (wRow & 1) == 1 ? spX * 0.5f : 0f; + int wCol = 0; + for (float wx = bb.x + rowOff; wx + wmW <= bb.x + bb.width; wx += spX, wCol++) { + float jx = (float)(Math.abs(Math.sin(wRow * 73.1 + wCol * 157.3)) * spX * 0.25); + float jy = (float)(Math.sin(wRow * 211.7 + wCol * 89.5) * spY * 0.2); + float ox = wx + jx, oy = wy + jy; + if (ox < bb.x || ox + wmW > bb.x + bb.width) continue; + Path2D mark = new Path2D.Float(); + mark.moveTo(ox, oy); + mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); + gfx.draw(mark); + } + } + gfx.setClip(null); + } + } + + // ── Uferlinie ───────────────────────────────────────────────────────── + if (opts.showWaterWaves()) { + gfx.setColor(Color.BLACK); + gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + // Wasserflächen: Polygon-Umriss + for (PlacedWater w : input.waters()) { + int[] xs = worldToPixels(w.pointsX(), targetSize); + int[] ys = worldToPixels(w.pointsZ(), targetSize); + gfx.drawPolygon(xs, ys, xs.length); + } + // Meer: Pixel-Küstenlinie (morphologisch aufgeweitet) + int bw = 3; + boolean[] cur = new boolean[targetSize * targetSize]; + boolean[] nxt = new boolean[targetSize * targetSize]; + for (int py = 1; py < targetSize - 1; py++) { + for (int px = 1; px < targetSize - 1; px++) { + if (heights[py * targetSize + px] >= 0f) continue; + if (heights[py * targetSize + (px-1)] >= 0f || heights[py * targetSize + (px+1)] >= 0f || + heights[(py-1) * targetSize + px] >= 0f || heights[(py+1) * targetSize + px] >= 0f) + cur[py * targetSize + px] = true; + } + } + for (int pass = 1; pass < bw; pass++) { + System.arraycopy(cur, 0, nxt, 0, cur.length); + for (int py = 1; py < targetSize - 1; py++) { + for (int px = 1; px < targetSize - 1; px++) { + if (!cur[py * targetSize + px]) continue; + if (heights[py * targetSize + (px-1)] < 0f) nxt[py * targetSize + (px-1)] = true; + if (heights[py * targetSize + (px+1)] < 0f) nxt[py * targetSize + (px+1)] = true; + if (heights[(py-1) * targetSize + px] < 0f) nxt[(py-1) * targetSize + px] = true; + if (heights[(py+1) * targetSize + px] < 0f) nxt[(py+1) * targetSize + px] = true; + } + } + boolean[] tmp = cur; cur = nxt; nxt = tmp; + } + for (int i = 0, n = cur.length; i < n; i++) { + if (cur[i]) img.setRGB(i % targetSize, i / targetSize, 0xFF000000); + } } } // Area-Polygone if (opts.showAreas()) { - gfx.setStroke(new BasicStroke(lineW)); + float dash = lineW * 7f, gap = lineW * 4f; + BasicStroke dashed = new BasicStroke(lineW * 1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, + 10f, new float[]{dash, gap}, 0f); + int aFontSize = Math.max(8, targetSize / 160); + gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize)); for (PlacedArea a : input.areas()) { - Color c = hashColor(a.areaId()); int[] xs = worldToPixels(a.pointsX(), targetSize); int[] ys = worldToPixels(a.pointsZ(), targetSize); - gfx.setColor(new Color(c.getRed(), c.getGreen(), c.getBlue(), 55)); - gfx.fillPolygon(xs, ys, xs.length); - gfx.setColor(c); - gfx.drawPolygon(xs, ys, xs.length); + gfx.setStroke(dashed); + gfx.setColor(Color.BLACK); + gfx.draw(toPath(xs, ys)); + String lbl = friendlyAreaName(a.areaId()); + if (lbl != null) { + float[] cen = centroid(a.pointsX(), a.pointsZ()); + float lx = Float.isNaN(a.labelX()) ? cen[0] : a.labelX(); + float lz = Float.isNaN(a.labelZ()) ? cen[1] : a.labelZ(); + int plx = worldToPixel(lx, targetSize); + int plz = worldToPixel(lz, targetSize); + drawLabel(gfx, lbl, plx, plz); + } } } @@ -215,45 +335,21 @@ public final class WorldMapRenderer { } } - // Locations (Kreise + Labels) + // Locations (nur Label) if (opts.showLocations()) { - gfx.setStroke(new BasicStroke(lineW)); int fontSize = Math.max(8, targetSize / 140); gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize)); for (Location loc : input.locations()) { - int lx = worldToPixel(loc.getCenterX(), targetSize); - int lz = worldToPixel(loc.getCenterZ(), targetSize); - int lr = Math.max(4, (int)(loc.getRadius() / WORLD_SIZE * targetSize)); - - gfx.setColor(new Color(255, 255, 200, 50)); - gfx.fillOval(lx - lr, lz - lr, lr * 2, lr * 2); - gfx.setColor(new Color(255, 220, 60)); - gfx.drawOval(lx - lr, lz - lr, lr * 2, lr * 2); - - if (targetSize >= 512 && loc.getId() != null && !loc.getId().isEmpty()) { - String label = friendlyLocationName(loc.getId()); - FontMetrics fm = gfx.getFontMetrics(); - int tw = fm.stringWidth(label); - gfx.setColor(new Color(0, 0, 0, 160)); - gfx.drawString(label, lx - tw / 2 + 1, lz - lr - 3); - gfx.setColor(Color.WHITE); - gfx.drawString(label, lx - tw / 2, lz - lr - 4); - } + if (!loc.isShowOnMap()) continue; + if (loc.getId() == null || loc.getId().isEmpty()) continue; + float wx = Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX(); + float wz = Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ(); + int lx = worldToPixel(wx, targetSize); + int lz = worldToPixel(wz, targetSize); + drawLabel(gfx, friendlyLocationName(loc.getId()), lx, lz); } } - // Spawnpunkt - int spx = worldToPixel(m.spawnX, targetSize); - int spz = worldToPixel(m.spawnZ, targetSize); - int cr = Math.max(5, targetSize / 180); - gfx.setStroke(new BasicStroke(Math.max(2f, targetSize / 300f))); - gfx.setColor(new Color(0, 0, 0, 120)); - gfx.drawLine(spx - cr + 1, spz + 1, spx + cr + 1, spz + 1); - gfx.drawLine(spx + 1, spz - cr + 1, spx + 1, spz + cr + 1); - gfx.setColor(new Color(60, 230, 90)); - gfx.drawLine(spx - cr, spz, spx + cr, spz); - gfx.drawLine(spx, spz - cr, spx, spz + cr); - gfx.dispose(); return img; } @@ -375,46 +471,145 @@ public final class WorldMapRenderer { float lineW = Math.max(1.5f, targetSize / 400f); if (opts.showWater()) { - final int WATER_RGB = (255 << 24) | (35 << 16) | (100 << 8) | 190; - // Meer: Terrain unter Meeresspiegel + // ── Weißfüllung ─────────────────────────────────────────────────────── for (int py = 0; py < targetSize; py++) { for (int px = 0; px < targetSize; px++) { - if (heights[py * targetSize + px] < 0f) { - img.setRGB(px, py, WATER_RGB); - } + if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF); } } - // Wasserflächen: nur dort sichtbar, wo Terrain unter Wasseroberfläche liegt for (PlacedWater w : input.waters()) { int[] xs = wrp(w.pointsX(), wx0, rSize, targetSize); int[] ys = wrp(w.pointsZ(), wz0, rSize, targetSize); Polygon poly = new Polygon(xs, ys, xs.length); - Rectangle bb = poly.getBounds(); - int x0 = Math.max(0, bb.x); - int y0 = Math.max(0, bb.y); + Rectangle bb = poly.getBounds(); + int x0 = Math.max(0, bb.x), y0 = Math.max(0, bb.y); int x1 = Math.min(targetSize - 1, bb.x + bb.width); int y1 = Math.min(targetSize - 1, bb.y + bb.height); float wh = w.waterHeight(); for (int py = y0; py <= y1; py++) { for (int px = x0; px <= x1; px++) { - if (poly.contains(px, py) && heights[py * targetSize + px] < wh) { - img.setRGB(px, py, WATER_RGB); + if (poly.contains(px, py) && heights[py * targetSize + px] < wh) + img.setRGB(px, py, 0xFFFFFFFF); + } + } + } + + // ── Wellenmarken ────────────────────────────────────────────────────── + if (opts.showWaterWaves()) { + float wmW = Math.max(20f, targetSize / 70f); + float wmA = wmW * 0.2f, spX = wmW * 1.8f, spY = wmW * 1.05f; + gfx.setColor(Color.BLACK); + gfx.setStroke(new BasicStroke(Math.max(0.7f, lineW * 0.4f), + BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + // Meer: versetzt + gejittert + int seaRow = 0; + for (float wy = spY * 0.5f; wy < targetSize; wy += spY, seaRow++) { + float rowOff = (seaRow & 1) == 1 ? spX * 0.5f : 0f; + int seaCol = 0; + for (float wx = rowOff; wx + wmW <= targetSize; wx += spX, seaCol++) { + float jx = (float)(Math.abs(Math.sin(seaRow * 73.1 + seaCol * 157.3)) * spX * 0.25); + float jy = (float)(Math.sin(seaRow * 211.7 + seaCol * 89.5) * spY * 0.2); + float ox = wx + jx, oy = wy + jy; + int cx = (int)(ox + wmW * 0.5f), cy = (int)oy; + if (cx >= 0 && cx < targetSize && cy >= 0 && cy < targetSize + && ox >= 0 && ox + wmW < targetSize + && heights[cy * targetSize + cx] < 0f) { + Path2D mark = new Path2D.Float(); + mark.moveTo(ox, oy); + mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); + gfx.draw(mark); } } } + // Wasserflächen: nur wenn groß genug + for (PlacedWater w : input.waters()) { + int[] xs = wrp(w.pointsX(), wx0, rSize, targetSize); + int[] ys = wrp(w.pointsZ(), wz0, rSize, targetSize); + Polygon poly = new Polygon(xs, ys, xs.length); + Rectangle bb = poly.getBounds(); + if (bb.width <= wmW * 1.5f || bb.height <= spY) continue; + gfx.setClip(poly); + int wRow = 0; + for (float wy = bb.y + spY * 0.5f; wy < bb.y + bb.height; wy += spY, wRow++) { + float rowOff = (wRow & 1) == 1 ? spX * 0.5f : 0f; + int wCol = 0; + for (float wx = bb.x + rowOff; wx + wmW <= bb.x + bb.width; wx += spX, wCol++) { + float jx = (float)(Math.abs(Math.sin(wRow * 73.1 + wCol * 157.3)) * spX * 0.25); + float jy = (float)(Math.sin(wRow * 211.7 + wCol * 89.5) * spY * 0.2); + float ox = wx + jx, oy = wy + jy; + if (ox < bb.x || ox + wmW > bb.x + bb.width) continue; + Path2D mark = new Path2D.Float(); + mark.moveTo(ox, oy); + mark.curveTo(ox + wmW*0.3f, oy - wmA, ox + wmW*0.7f, oy + wmA, ox + wmW, oy); + gfx.draw(mark); + } + } + gfx.setClip(null); + } + } + + // ── Uferlinie ───────────────────────────────────────────────────────── + if (opts.showWaterWaves()) { + gfx.setColor(Color.BLACK); + gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + // Wasserflächen: Polygon-Umriss + for (PlacedWater w : input.waters()) { + int[] xs = wrp(w.pointsX(), wx0, rSize, targetSize); + int[] ys = wrp(w.pointsZ(), wz0, rSize, targetSize); + gfx.drawPolygon(xs, ys, xs.length); + } + // Meer: Pixel-Küstenlinie (morphologisch aufgeweitet) + int bw = 3; + boolean[] cur = new boolean[targetSize * targetSize]; + boolean[] nxt = new boolean[targetSize * targetSize]; + for (int py = 1; py < targetSize - 1; py++) { + for (int px = 1; px < targetSize - 1; px++) { + if (heights[py * targetSize + px] >= 0f) continue; + if (heights[py * targetSize + (px-1)] >= 0f || heights[py * targetSize + (px+1)] >= 0f || + heights[(py-1) * targetSize + px] >= 0f || heights[(py+1) * targetSize + px] >= 0f) + cur[py * targetSize + px] = true; + } + } + for (int pass = 1; pass < bw; pass++) { + System.arraycopy(cur, 0, nxt, 0, cur.length); + for (int py = 1; py < targetSize - 1; py++) { + for (int px = 1; px < targetSize - 1; px++) { + if (!cur[py * targetSize + px]) continue; + if (heights[py * targetSize + (px-1)] < 0f) nxt[py * targetSize + (px-1)] = true; + if (heights[py * targetSize + (px+1)] < 0f) nxt[py * targetSize + (px+1)] = true; + if (heights[(py-1) * targetSize + px] < 0f) nxt[(py-1) * targetSize + px] = true; + if (heights[(py+1) * targetSize + px] < 0f) nxt[(py+1) * targetSize + px] = true; + } + } + boolean[] tmp = cur; cur = nxt; nxt = tmp; + } + for (int i = 0, n = cur.length; i < n; i++) { + if (cur[i]) img.setRGB(i % targetSize, i / targetSize, 0xFF000000); + } } } if (opts.showAreas()) { - gfx.setStroke(new BasicStroke(lineW)); + float dash = lineW * 7f, gap = lineW * 4f; + BasicStroke dashed = new BasicStroke(lineW * 1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, + 10f, new float[]{dash, gap}, 0f); + int aFontSize = Math.max(8, targetSize / 160); + gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize)); for (PlacedArea a : input.areas()) { - Color c = hashColor(a.areaId()); int[] xs = wrp(a.pointsX(), wx0, rSize, targetSize); int[] ys = wrp(a.pointsZ(), wz0, rSize, targetSize); - gfx.setColor(new Color(c.getRed(), c.getGreen(), c.getBlue(), 55)); - gfx.fillPolygon(xs, ys, xs.length); - gfx.setColor(c); - gfx.drawPolygon(xs, ys, xs.length); + gfx.setStroke(dashed); + gfx.setColor(Color.BLACK); + gfx.draw(toPath(xs, ys)); + String lbl = friendlyAreaName(a.areaId()); + if (lbl != null) { + float[] cen = centroid(a.pointsX(), a.pointsZ()); + float lx = Float.isNaN(a.labelX()) ? cen[0] : a.labelX(); + float lz = Float.isNaN(a.labelZ()) ? cen[1] : a.labelZ(); + int plx = wrp1(lx, wx0, rSize, targetSize); + int plz = wrp1(lz, wz0, rSize, targetSize); + drawLabel(gfx, lbl, plx, plz); + } } } @@ -441,40 +636,19 @@ public final class WorldMapRenderer { } if (opts.showLocations()) { - gfx.setStroke(new BasicStroke(lineW)); int fontSize = Math.max(8, targetSize / 140); gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize)); for (Location loc : input.locations()) { - int lx = wrp1(loc.getCenterX(), wx0, rSize, targetSize); - int lz = wrp1(loc.getCenterZ(), wz0, rSize, targetSize); - int lr = Math.max(4, (int) (loc.getRadius() / rSize * targetSize)); - gfx.setColor(new Color(255, 255, 200, 50)); - gfx.fillOval(lx - lr, lz - lr, lr * 2, lr * 2); - gfx.setColor(new Color(255, 220, 60)); - gfx.drawOval(lx - lr, lz - lr, lr * 2, lr * 2); - if (loc.getId() != null && !loc.getId().isEmpty()) { - String label = friendlyLocationName(loc.getId()); - FontMetrics fm = gfx.getFontMetrics(); - int tw = fm.stringWidth(label); - gfx.setColor(new Color(0, 0, 0, 160)); - gfx.drawString(label, lx - tw / 2 + 1, lz - lr - 3); - gfx.setColor(Color.WHITE); - gfx.drawString(label, lx - tw / 2, lz - lr - 4); - } + if (!loc.isShowOnMap()) continue; + if (loc.getId() == null || loc.getId().isEmpty()) continue; + float wx = Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX(); + float wz = Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ(); + int lx = wrp1(wx, wx0, rSize, targetSize); + int lz = wrp1(wz, wz0, rSize, targetSize); + drawLabel(gfx, friendlyLocationName(loc.getId()), lx, lz); } } - int spx = wrp1(m.spawnX, wx0, rSize, targetSize); - int spz = wrp1(m.spawnZ, wz0, rSize, targetSize); - int cr = Math.max(5, targetSize / 180); - gfx.setStroke(new BasicStroke(Math.max(2f, targetSize / 300f))); - gfx.setColor(new Color(0, 0, 0, 120)); - gfx.drawLine(spx - cr + 1, spz + 1, spx + cr + 1, spz + 1); - gfx.drawLine(spx + 1, spz - cr + 1, spx + 1, spz + cr + 1); - gfx.setColor(new Color(60, 230, 90)); - gfx.drawLine(spx - cr, spz, spx + cr, spz); - gfx.drawLine(spx, spz - cr, spx, spz + cr); - gfx.dispose(); return img; } @@ -517,19 +691,41 @@ public final class WorldMapRenderer { return out; } -private static Color hashColor(String key) { - int hash = key == null ? 0 : key.hashCode(); - int r = 100 + ((hash & 0xFF0000) >> 16) % 120; - int g = 100 + ((hash & 0x00FF00) >> 8) % 120; - int b = 100 + ((hash & 0x0000FF)) % 120; - return new Color(r, g, b); - } - private static String friendlyLocationName(String id) { String s = id.replace("location.", "").replace(".name", ""); return s.isEmpty() ? id : s; } + private static String friendlyAreaName(String id) { + if (id == null || id.isBlank()) return null; + String s = id.replace("area.", ""); + if (s.endsWith(".name")) s = s.substring(0, s.length() - 5); + return s.isBlank() ? null : s; + } + + private static float[] centroid(float[] xs, float[] zs) { + float cx = 0f, cz = 0f; + for (int i = 0; i < xs.length; i++) { cx += xs[i]; cz += zs[i]; } + return new float[]{cx / xs.length, cz / xs.length}; + } + + private static Path2D toPath(int[] xs, int[] ys) { + Path2D p = new Path2D.Float(); + p.moveTo(xs[0], ys[0]); + for (int i = 1; i < xs.length; i++) p.lineTo(xs[i], ys[i]); + p.closePath(); + return p; + } + + private static void drawLabel(Graphics2D g, String label, int x, int y) { + FontMetrics fm = g.getFontMetrics(); + int tw = fm.stringWidth(label); + g.setColor(new Color(0, 0, 0, 180)); + g.drawString(label, x - tw / 2 + 1, y + 1); + g.setColor(Color.WHITE); + g.drawString(label, x - tw / 2, y); + } + // Konvertiert eine Weltkoordinate in einen Pixel-Index innerhalb einer Region. private static int wrp1(float worldCoord, float regionOrigin, float regionSize, int targetSize) { return (int) ((worldCoord - regionOrigin) / regionSize * (targetSize - 1)); diff --git a/blight-common/src/main/java/de/blight/common/model/Location.java b/blight-common/src/main/java/de/blight/common/model/Location.java index 2a4696a..9e05e0b 100644 --- a/blight-common/src/main/java/de/blight/common/model/Location.java +++ b/blight-common/src/main/java/de/blight/common/model/Location.java @@ -15,7 +15,10 @@ public class Location { private float centerX; private float centerZ; private float radius; - private List triggers = new ArrayList<>(); + private List triggers = new ArrayList<>(); + private boolean showOnMap = true; + private float labelX = Float.NaN; + private float labelZ = Float.NaN; /** Leitet die ID aus dem TextReference-Schlüssel ab – eindeutiger Bezeichner der Location. */ public String getId() { return name != null ? name.id() : ""; } diff --git a/blight-editor/src/main/java/de/blight/editor/state/AreaState.java b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java index cbf8bbc..92e0017 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/AreaState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/AreaState.java @@ -357,7 +357,8 @@ public class AreaState extends BaseAppState { PlacedArea existing = areas.get(idx); areas.set(idx, new PlacedArea(existing.pointsX(), existing.pointsZ(), updated.areaId(), - updated.triggers() != null ? updated.triggers() : existing.triggers())); + updated.triggers() != null ? updated.triggers() : existing.triggers(), + existing.labelX(), existing.labelZ())); } else { areas.set(idx, updated); } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java index d101102..df84130 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocationEditorView.java @@ -31,6 +31,7 @@ public class LocationEditorView extends BorderPane { private TextField idField; private Label nameKeyLabel; + private CheckBox showOnMapCheck; private TriggerListEditor triggerEditor; private VBox formContainer; @@ -83,6 +84,11 @@ public class LocationEditorView extends BorderPane { if (!is) { savePause.stop(); persistCurrent(); } }); + showOnMapCheck = new CheckBox("Name auf Karte anzeigen"); + showOnMapCheck.setSelected(true); + showOnMapCheck.setStyle("-fx-text-fill: #ccc;"); + showOnMapCheck.selectedProperty().addListener((obs, o, n) -> scheduleSave()); + triggerEditor = new TriggerListEditor(List.of(), () -> {}); form.getChildren().addAll( @@ -90,6 +96,7 @@ public class LocationEditorView extends BorderPane { new Separator(), row("ID:", idField), nameKeyLabel, + showOnMapCheck, sectionTitle("Trigger"), new Separator(), triggerEditor @@ -112,6 +119,7 @@ public class LocationEditorView extends BorderPane { String shortId = fullId.startsWith(PREFIX) ? fullId.substring(PREFIX.length()) : fullId; idField.setText(shortId); nameKeyLabel.setText("Name-Key: " + (shortId.isBlank() ? "—" : PREFIX + shortId + ".name")); + showOnMapCheck.setSelected(loc.isShowOnMap()); int idx = formContainer.getChildren().indexOf(triggerEditor); triggerEditor = new TriggerListEditor( @@ -124,11 +132,13 @@ public class LocationEditorView extends BorderPane { String fullId = shortId.isBlank() ? "" : PREFIX + shortId; loc.setName(fullId.isBlank() ? null : new TextReference(fullId)); loc.setTriggers(triggerEditor.getTriggers()); + loc.setShowOnMap(showOnMapCheck.isSelected()); } private void clearForm() { idField.clear(); nameKeyLabel.setText("Name-Key: —"); + showOnMapCheck.setSelected(true); } // ── List operations ─────────────────────────────────────────────────────── 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 594d9e5..cbeef17 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 @@ -10,12 +10,18 @@ import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Cursor; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.*; import javafx.scene.image.WritableImage; import javafx.scene.layout.*; import javafx.scene.paint.Color; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; import javafx.stage.FileChooser; import javafx.stage.Stage; @@ -26,18 +32,32 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; /** * Interaktive 2D-Weltkarte im Editor-Tab. - * Zoom via Mausrad, Pan via Linksklick-Drag. + * + * Architektur: Terrain/Wasser/Zonen/Modelle als vorgerechnetes PNG (Hintergrund). + * Areas und Locations werden als Vektor-Overlay direkt auf den Canvas gezeichnet, + * sodass Liniendicke und Schriftgröße beim Zoomen konstant bleiben. */ public class WorldMapView extends VBox { private static final int RENDER_SIZE = 2048; + // Konstante Bildschirmgrößen für das Canvas-Overlay + private static final double AREA_LINE_W = 1.5; + private static final double AREA_DASH = 8.0; + private static final double AREA_GAP = 5.0; + private static final double AREA_CORNER_R = 12.0; + private static final double WAVE_W = 20.0; // konstante Wellengröße in Bildschirmpixeln + private static final int SEA_MASK_SIZE = 1024; + private static final Font AREA_FONT = Font.font("SansSerif", FontWeight.BOLD, 11); + private static final Font LOC_FONT = Font.font("SansSerif", FontWeight.BOLD, 12); + private final Canvas canvas = new Canvas(100, 100); private final Label statusLbl = new Label("Karte noch nicht geladen"); private final ProgressBar progress = new ProgressBar(-1); @@ -49,10 +69,17 @@ public class WorldMapView extends VBox { private final ToggleButton layerZones = layerBtn("Zonen"); private final ToggleButton layerLocations = layerBtn("Orte"); private final ToggleButton layerModels = layerBtn("Modelle"); + private final ToggleButton labelBtn = new ToggleButton("Label"); private WritableImage mapFxImage; private BufferedImage mapBuffered; + private List cachedAreas = new ArrayList<>(); + private List cachedLocs = new ArrayList<>(); + private List cachedWaters = new ArrayList<>(); + private boolean[] seaMask = null; + private float[][] seaCoastSegs = null; // [wx1,wz1,wx2,wz2] in Weltkoordinaten + private double panX = 0, panY = 0; private double scale = 1.0; private double dragStartX, dragStartY, dragStartPanX, dragStartPanY; @@ -92,22 +119,27 @@ public class WorldMapView extends VBox { } private void buildUi() { - // ── Layer-Toolbar ────────────────────────────────────────────────────── Button refreshBtn = new Button("Aktualisieren"); refreshBtn.setOnAction(e -> loadAndRender()); Button exportBtn = new Button("Als PNG exportieren…"); exportBtn.setOnAction(e -> exportPng()); + labelBtn.setStyle("-fx-font-size: 11;"); + labelBtn.selectedProperty().addListener((obs, o, n) -> + canvas.setCursor(n ? Cursor.CROSSHAIR : Cursor.DEFAULT)); + ToolBar toolbar = new ToolBar( new Label("Layer:"), layerTerrain, layerWater, layerAreas, layerZones, layerLocations, layerModels, new Separator(), + new Label("Bearbeiten:"), + labelBtn, + new Separator(), refreshBtn, exportBtn ); - // ── Canvas ──────────────────────────────────────────────────────────── canvasPane.setStyle("-fx-background-color: #1a1a2a;"); VBox.setVgrow(canvasPane, Priority.ALWAYS); @@ -116,15 +148,16 @@ public class WorldMapView extends VBox { canvas.widthProperty().addListener(obs -> redraw()); canvas.heightProperty().addListener(obs -> redraw()); - // Layer-Toggle → neu rendern + // Terrain/Wasser/Zonen/Modelle → PNG neu rendern layerTerrain.setOnAction(e -> rerender()); layerWater.setOnAction(e -> rerender()); - layerAreas.setOnAction(e -> rerender()); layerZones.setOnAction(e -> rerender()); - layerLocations.setOnAction(e -> rerender()); layerModels.setOnAction(e -> rerender()); - // Pan + // Areas und Locations → nur Canvas-Overlay neu zeichnen (kein PNG-Rerender) + layerAreas.setOnAction(e -> redraw()); + layerLocations.setOnAction(e -> redraw()); + canvas.setOnMousePressed(e -> { dragStartX = e.getX(); dragStartY = e.getY(); @@ -136,11 +169,16 @@ public class WorldMapView extends VBox { panY = dragStartPanY + (e.getY() - dragStartY); redraw(); }); + canvas.setOnMouseReleased(e -> { + if (!labelBtn.isSelected()) return; + if (Math.abs(e.getX() - dragStartX) < 5 && Math.abs(e.getY() - dragStartY) < 5) { + handleLabelPlacement(e.getX(), e.getY()); + } + }); - // Zoom (Mausrad, Ziel = Mausposition) canvas.setOnScroll(e -> { if (e.getDeltaY() == 0) return; - double factor = e.getDeltaY() > 0 ? 1.15 : 1.0 / 1.15; + double factor = e.getDeltaY() > 0 ? 1.15 : 1.0 / 1.15; double oldScale = scale; scale = Math.max(0.1, Math.min(30.0, scale * factor)); panX = e.getX() - (e.getX() - panX) * scale / oldScale; @@ -148,15 +186,13 @@ public class WorldMapView extends VBox { redraw(); }); - // Tooltip mit Weltkoordinaten canvas.setOnMouseMoved(e -> { if (mapFxImage == null) return; - float wx = WorldMapRenderer.pixelToWorld(e.getX(), canvas.getWidth(), panX, scale); - float wz = WorldMapRenderer.pixelToWorld(e.getY(), canvas.getHeight(), panY, scale); + double wx = (e.getX() - panX) / scale / RENDER_SIZE * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF; + double wz = (e.getY() - panY) / scale / RENDER_SIZE * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF; statusLbl.setText(String.format("X: %.1f Z: %.1f", wx, wz)); }); - // ── Statuszeile ─────────────────────────────────────────────────────── progress.setPrefWidth(160); progress.setVisible(false); HBox statusBar = new HBox(8, statusLbl, progress); @@ -171,10 +207,8 @@ public class WorldMapView extends VBox { // ── Öffentliche API ─────────────────────────────────────────────────────── - /** True wenn die Karte bereits geladen wurde. */ public boolean isLoaded() { return mapFxImage != null || loading.get(); } - /** Lädt Welt-Daten und rendert die Karte (im Hintergrund). */ public void loadAndRender() { if (loading.getAndSet(true)) return; progress.setVisible(true); @@ -183,19 +217,26 @@ public class WorldMapView extends VBox { Thread t = new Thread(() -> { try { MapData mapData = MapIO.load(); - List areas = AreaIO.load(); - List zones = LocationZoneIO.load(); - List locs = LocationIO.load(); - List waters = WaterBodyIO.load(); - List models = PlacedModelIO.load(); + List areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List models = PlacedModelIO.load(); Platform.runLater(() -> statusLbl.setText("Rendere Karte…")); - int[] slotColors = computeSlotColors(mapData); - RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); - BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildOptions()); + int[] slotColors = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); + BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildBackgroundOptions()); + boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE); + float[][] segs = buildSeaCoastSegs(mask, SEA_MASK_SIZE); Platform.runLater(() -> { + cachedAreas = new ArrayList<>(areas); + cachedLocs = new ArrayList<>(locs); + cachedWaters = new ArrayList<>(waters); + seaMask = mask; + seaCoastSegs = segs; mapBuffered = bi; mapFxImage = SwingFXUtils.toFXImage(bi, null); fitToView(); @@ -227,17 +268,21 @@ public class WorldMapView extends VBox { Thread t = new Thread(() -> { try { - // Bereits geladene Daten nochmal laden, damit Layer-Wechsel korrekt MapData mapData = MapIO.load(); List areas = AreaIO.load(); List zones = LocationZoneIO.load(); List locs = LocationIO.load(); List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); - int[] slotColors2 = computeSlotColors(mapData); - RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors2); - BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildOptions()); + int[] slotColors = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); + BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildBackgroundOptions()); + boolean[] mask = WorldMapRenderer.buildSeaMask(mapData, SEA_MASK_SIZE); Platform.runLater(() -> { + cachedAreas = new ArrayList<>(areas); + cachedLocs = new ArrayList<>(locs); + cachedWaters = new ArrayList<>(waters); + seaMask = mask; mapBuffered = bi; mapFxImage = SwingFXUtils.toFXImage(bi, null); redraw(); @@ -261,7 +306,7 @@ public class WorldMapView extends VBox { GraphicsContext gc = canvas.getGraphicsContext2D(); double w = canvas.getWidth(), h = canvas.getHeight(); - gc.setFill(javafx.scene.paint.Color.rgb(20, 20, 35)); + gc.setFill(Color.rgb(20, 20, 35)); gc.fillRect(0, 0, w, h); if (mapFxImage == null) { @@ -270,20 +315,364 @@ public class WorldMapView extends VBox { return; } - double imgW = mapFxImage.getWidth() * scale; - double imgH = mapFxImage.getHeight() * scale; - gc.drawImage(mapFxImage, panX, panY, imgW, imgH); + gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); + + if (layerWater.isSelected()) drawWaterOverlay(gc); + if (layerAreas.isSelected()) drawAreasOverlay(gc); + if (layerLocations.isSelected()) drawLocationsOverlay(gc); } + // ── Canvas-Vektor-Overlays ──────────────────────────────────────────────── + + private void drawAreasOverlay(GraphicsContext gc) { + if (cachedAreas.isEmpty()) return; + gc.save(); + gc.setStroke(Color.BLACK); + gc.setLineWidth(AREA_LINE_W); + gc.setLineDashes(AREA_DASH, AREA_GAP); + gc.setLineDashOffset(0); + gc.setLineCap(StrokeLineCap.ROUND); + gc.setLineJoin(StrokeLineJoin.ROUND); + gc.setFont(AREA_FONT); + + for (PlacedArea a : cachedAreas) { + float[] wx = a.pointsX(), wz = a.pointsZ(); + int n = wx.length; + if (n < 2) continue; + + // Weltkoordinaten → Canvas-Pixel + double[] cx = new double[n], cz = new double[n]; + for (int i = 0; i < n; i++) { cx[i] = toCanvasX(wx[i]); cz[i] = toCanvasZ(wz[i]); } + + // Approach- und Departure-Punkte für abgerundete Ecken (quadratische Beziers) + double[] apx = new double[n], apz = new double[n]; + double[] dpx = new double[n], dpz = new double[n]; + for (int i = 0; i < n; i++) { + int prev = (i + n - 1) % n, next = (i + 1) % n; + double dx1 = cx[i] - cx[prev], dz1 = cz[i] - cz[prev]; + double dx2 = cx[next] - cx[i], dz2 = cz[next] - cz[i]; + double l1 = Math.sqrt(dx1*dx1 + dz1*dz1), l2 = Math.sqrt(dx2*dx2 + dz2*dz2); + double r = Math.min(AREA_CORNER_R, Math.min(l1, l2) * 0.4); + apx[i] = l1 > 0 ? cx[i] - dx1/l1*r : cx[i]; + apz[i] = l1 > 0 ? cz[i] - dz1/l1*r : cz[i]; + dpx[i] = l2 > 0 ? cx[i] + dx2/l2*r : cx[i]; + dpz[i] = l2 > 0 ? cz[i] + dz2/l2*r : cz[i]; + } + + gc.beginPath(); + gc.moveTo(apx[0], apz[0]); + for (int i = 0; i < n; i++) { + gc.quadraticCurveTo(cx[i], cz[i], dpx[i], dpz[i]); + int next = (i + 1) % n; + gc.lineTo(apx[next], apz[next]); + } + gc.closePath(); + gc.stroke(); + + String lbl = areaLabel(a.areaId()); + if (lbl != null) { + float[] cen = centroid(wx, wz); + double lx = toCanvasX(Float.isNaN(a.labelX()) ? cen[0] : a.labelX()); + double lz = toCanvasZ(Float.isNaN(a.labelZ()) ? cen[1] : a.labelZ()); + drawCanvasLabel(gc, lbl, lx, lz); + } + } + gc.restore(); + } + + private void drawLocationsOverlay(GraphicsContext gc) { + if (cachedLocs.isEmpty()) return; + gc.save(); + gc.setFont(LOC_FONT); + + for (Location loc : cachedLocs) { + if (!loc.isShowOnMap()) continue; + if (loc.getId() == null || loc.getId().isEmpty()) continue; + double lx = toCanvasX(Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX()); + double lz = toCanvasZ(Float.isNaN(loc.getLabelZ()) ? loc.getCenterZ() : loc.getLabelZ()); + drawCanvasLabel(gc, locLabel(loc.getId()), lx, lz); + } + gc.restore(); + } + + private void drawWaterOverlay(GraphicsContext gc) { + // ── Seewellen (Terrain < 0) ────────────────────────────────────────────── + if (seaMask != null && layerWater.isSelected()) { + // Wellengröße in Weltkoordinaten (skaliert mit Zoom, damit Bildschirmgröße = WAVE_W) + double pxPerWorld = (RENDER_SIZE - 1) * scale / WorldMapRenderer.WORLD_SIZE; + double wWave = WAVE_W / pxPerWorld; + double wSpX = wWave * 1.8, wSpY = wWave * 1.05, wAmp = WAVE_W * 0.2; + + // Sichtbarer Weltbereich + double cW = canvas.getWidth(), cH = canvas.getHeight(); + double wxMin = toWorldX(0), wxMax = toWorldX(cW); + double wzMin = toWorldZ(0), wzMax = toWorldZ(cH); + wxMin = Math.max(-WorldMapRenderer.WORLD_HALF, wxMin); + wzMin = Math.max(-WorldMapRenderer.WORLD_HALF, wzMin); + wxMax = Math.min(WorldMapRenderer.WORLD_HALF, wxMax); + wzMax = Math.min(WorldMapRenderer.WORLD_HALF, wzMax); + + gc.setStroke(Color.BLACK); + gc.setLineWidth(0.8); + gc.setLineDashes(null); + + // Gitter-Startzeile: auf Weltgitter einrasten → Wellen bleiben beim Panning stabil + long rowBase = (long)Math.floor((wzMin - wSpY * 0.5) / wSpY); + for (long rowIdx = rowBase; rowIdx * wSpY + wSpY * 0.5 < wzMax; rowIdx++) { + double wz = rowIdx * wSpY + wSpY * 0.5; + double rowOff = (rowIdx & 1L) == 1L ? wSpX * 0.5 : 0; + long colBase = (long)Math.floor((wxMin - rowOff) / wSpX); + for (long colIdx = colBase; colIdx * wSpX + rowOff < wxMax; colIdx++) { + double wx = colIdx * wSpX + rowOff; + double jx = Math.abs(Math.sin(rowIdx * 73.1 + colIdx * 157.3)) * wSpX * 0.25; + double jy = Math.sin(rowIdx * 211.7 + colIdx * 89.5) * wSpY * 0.2; + double owx = wx + jx, owz = wz + jy; + // Seekartenmaske prüfen (Mitte und Ende der Welle) + int mxS = (int)((owx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1)); + int mzS = (int)((owz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1)); + int mxE = (int)((owx + wWave + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (SEA_MASK_SIZE - 1)); + if (mxS < 0 || mxS >= SEA_MASK_SIZE || mzS < 0 || mzS >= SEA_MASK_SIZE) continue; + if (!seaMask[mzS * SEA_MASK_SIZE + mxS]) continue; + if (mxE < 0 || mxE >= SEA_MASK_SIZE || !seaMask[mzS * SEA_MASK_SIZE + mxE]) continue; + // Canvas-Koordinaten; Breite = WAVE_W px, Amplitude = wAmp px + double ox = toCanvasX((float)owx), oy = toCanvasZ((float)owz); + double ex = ox + WAVE_W; + gc.beginPath(); + gc.moveTo(ox, oy); + gc.bezierCurveTo(ox + WAVE_W*0.3, oy - wAmp, ox + WAVE_W*0.7, oy + wAmp, ex, oy); + gc.stroke(); + } + } + } + + if (cachedWaters.isEmpty()) return; + + final double spX = WAVE_W * 1.8, spY = WAVE_W * 1.05, wmA = WAVE_W * 0.2; + + for (PlacedWater w : cachedWaters) { + float[] wx = w.pointsX(), wz = w.pointsZ(); + int n = wx.length; + if (n < 3) continue; + + double[] cx = new double[n], cz = new double[n]; + for (int i = 0; i < n; i++) { cx[i] = toCanvasX(wx[i]); cz[i] = toCanvasZ(wz[i]); } + + double minX = cx[0], maxX = cx[0], minZ = cz[0], maxZ = cz[0]; + for (int i = 1; i < n; i++) { + if (cx[i] < minX) minX = cx[i]; if (cx[i] > maxX) maxX = cx[i]; + if (cz[i] < minZ) minZ = cz[i]; if (cz[i] > maxZ) maxZ = cz[i]; + } + + // Wellenmarken: nur wenn Fläche groß genug für mindestens eine Welle + if ((maxX - minX) > WAVE_W * 1.5 && (maxZ - minZ) > spY) { + gc.save(); + gc.beginPath(); + gc.moveTo(cx[0], cz[0]); + for (int i = 1; i < n; i++) gc.lineTo(cx[i], cz[i]); + gc.closePath(); + gc.clip(); + + gc.setStroke(Color.BLACK); + gc.setLineWidth(0.8); + gc.setLineDashes(null); + + int row = 0; + for (double wy = minZ + spY * 0.5; wy < maxZ; wy += spY, row++) { + double rowOff = (row & 1) == 1 ? spX * 0.5 : 0; + int col = 0; + for (double wxx = minX + rowOff; wxx + WAVE_W <= maxX; wxx += spX, col++) { + // deterministisches Jitter pro Zelle + double jx = Math.abs(Math.sin(row * 73.1 + col * 157.3)) * spX * 0.25; + double jy = Math.sin(row * 211.7 + col * 89.5) * spY * 0.2; + double ox = wxx + jx, oy = wy + jy; + if (ox < minX || ox + WAVE_W > maxX || oy < minZ || oy > maxZ) continue; + gc.beginPath(); + gc.moveTo(ox, oy); + gc.bezierCurveTo(ox + WAVE_W*0.3, oy - wmA, ox + WAVE_W*0.7, oy + wmA, ox + WAVE_W, oy); + gc.stroke(); + } + } + gc.restore(); + } + + // Schwarzer Umriss (konstant 3px) + gc.setStroke(Color.BLACK); + gc.setLineWidth(3.0); + gc.setLineDashes(null); + gc.beginPath(); + gc.moveTo(cx[0], cz[0]); + for (int i = 1; i < n; i++) gc.lineTo(cx[i], cz[i]); + gc.closePath(); + gc.stroke(); + } + + // ── Seeküstenlinie (konstant 3px, weicher Halo gegen Treppeneffekt) ──────── + if (seaCoastSegs != null && seaCoastSegs.length > 0) { + double cW = canvas.getWidth(), cH = canvas.getHeight(); + gc.setLineDashes(null); + gc.setLineCap(StrokeLineCap.ROUND); + + // Halo-Pass: breiter, halbtransparent + gc.setStroke(Color.color(0, 0, 0, 0.2)); + gc.setLineWidth(7.0); + for (float[] seg : seaCoastSegs) { + double sx1 = toCanvasX(seg[0]), sy1 = toCanvasZ(seg[1]); + double sx2 = toCanvasX(seg[2]), sy2 = toCanvasZ(seg[3]); + if (Math.max(sx1, sx2) < -8 || Math.min(sx1, sx2) > cW + 8) continue; + if (Math.max(sy1, sy2) < -8 || Math.min(sy1, sy2) > cH + 8) continue; + gc.strokeLine(sx1, sy1, sx2, sy2); + } + + // Kern-Pass: 3px solid + gc.setStroke(Color.BLACK); + gc.setLineWidth(3.0); + for (float[] seg : seaCoastSegs) { + double sx1 = toCanvasX(seg[0]), sy1 = toCanvasZ(seg[1]); + double sx2 = toCanvasX(seg[2]), sy2 = toCanvasZ(seg[3]); + if (Math.max(sx1, sx2) < -3 || Math.min(sx1, sx2) > cW + 3) continue; + if (Math.max(sy1, sy2) < -3 || Math.min(sy1, sy2) > cH + 3) continue; + gc.strokeLine(sx1, sy1, sx2, sy2); + } + } + } + + private void drawCanvasLabel(GraphicsContext gc, String text, double x, double y) { + Text measure = new Text(text); + measure.setFont(gc.getFont()); + double tw = measure.getLayoutBounds().getWidth(); + gc.setFill(Color.rgb(0, 0, 0, 0.7)); + gc.fillText(text, x - tw / 2 + 1, y + 1); + gc.setFill(Color.WHITE); + gc.fillText(text, x - tw / 2, y); + } + + // ── Koordinaten-Umrechnung ──────────────────────────────────────────────── + + private double toCanvasX(float worldX) { + return panX + (worldX + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; + } + + private double toCanvasZ(float worldZ) { + return panY + (worldZ + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; + } + + private float toWorldX(double canvasX) { + return (float)((canvasX - panX) / scale / (RENDER_SIZE - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + } + + private float toWorldZ(double canvasZ) { + return (float)((canvasZ - panY) / scale / (RENDER_SIZE - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + } + + // ── Label-Platzierung ───────────────────────────────────────────────────── + + private void handleLabelPlacement(double canvasX, double canvasY) { + float wx = toWorldX(canvasX); + float wz = toWorldZ(canvasY); + + // Locations: Treffer innerhalb 2× Radius + for (Location loc : cachedLocs) { + float dx = wx - loc.getCenterX(), dz = wz - loc.getCenterZ(), r = loc.getRadius(); + if (dx * dx + dz * dz <= r * r * 4) { + loc.setLabelX(wx); + loc.setLabelZ(wz); + try { LocationIO.save(cachedLocs); } catch (IOException e) { statusLbl.setText("Fehler: " + e.getMessage()); return; } + redraw(); + statusLbl.setText(String.format("Label %s → (%.0f, %.0f)", loc.getId(), wx, wz)); + return; + } + } + + // Areas: kleinste enthaltende Area + PlacedArea best = null; + int bestIdx = -1; + double bestSize = Double.MAX_VALUE; + for (int i = 0; i < cachedAreas.size(); i++) { + PlacedArea a = cachedAreas.get(i); + if (polyContains(a.pointsX(), a.pointsZ(), wx, wz)) { + double sz = boundingBoxArea(a.pointsX(), a.pointsZ()); + if (sz < bestSize) { bestSize = sz; best = a; bestIdx = i; } + } + } + if (best != null) { + cachedAreas.set(bestIdx, new PlacedArea(best.pointsX(), best.pointsZ(), + best.areaId(), best.triggers(), wx, wz)); + try { AreaIO.save(cachedAreas); } catch (IOException e) { statusLbl.setText("Fehler: " + e.getMessage()); return; } + redraw(); + statusLbl.setText(String.format("Label %s → (%.0f, %.0f)", best.areaId(), wx, wz)); + } + } + + private static float[][] buildSeaCoastSegs(boolean[] mask, int size) { + List segs = new ArrayList<>(); + for (int mz = 0; mz < size - 1; mz++) { + for (int mx = 0; mx < size - 1; mx++) { + boolean c = mask[mz * size + mx]; + boolean r = mask[mz * size + (mx + 1)]; + boolean d = mask[(mz + 1) * size + mx]; + float wx0 = (float)(mx / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + float wx1 = (float)((mx + 1) / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + float wz0 = (float)(mz / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + float wz1 = (float)((mz + 1) / (double)(size - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + float wzM = (wz0 + wz1) * 0.5f; + float wxM = (wx0 + wx1) * 0.5f; + // Horizontale Kante zwischen (mz,mx) und (mz+1,mx) + if (c != d) segs.add(new float[]{wx0, wzM, wx1, wzM}); + // Vertikale Kante zwischen (mz,mx) und (mz,mx+1) + if (c != r) segs.add(new float[]{wxM, wz0, wxM, wz1}); + } + } + return segs.toArray(new float[0][]); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + private void fitToView() { if (canvas.getWidth() <= 0 || mapFxImage == null) return; - double sw = canvas.getWidth() / mapFxImage.getWidth(); - double sh = canvas.getHeight() / mapFxImage.getHeight(); - scale = Math.min(sw, sh); + scale = Math.min(canvas.getWidth() / mapFxImage.getWidth(), canvas.getHeight() / mapFxImage.getHeight()); panX = (canvas.getWidth() - mapFxImage.getWidth() * scale) / 2; panY = (canvas.getHeight() - mapFxImage.getHeight() * scale) / 2; } + private static boolean polyContains(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; + } + + private static double boundingBoxArea(float[] xs, float[] zs) { + float mnX = xs[0], mxX = xs[0], mnZ = zs[0], mxZ = zs[0]; + for (int i = 1; i < xs.length; i++) { + if (xs[i] < mnX) mnX = xs[i]; if (xs[i] > mxX) mxX = xs[i]; + if (zs[i] < mnZ) mnZ = zs[i]; if (zs[i] > mxZ) mxZ = zs[i]; + } + return (double)(mxX - mnX) * (mxZ - mnZ); + } + + private static float[] centroid(float[] xs, float[] zs) { + float cx = 0, cz = 0; + for (int i = 0; i < xs.length; i++) { cx += xs[i]; cz += zs[i]; } + return new float[]{cx / xs.length, cz / zs.length}; + } + + private static String areaLabel(String id) { + if (id == null || id.isBlank()) return null; + String s = id.replace("area.", ""); + if (s.endsWith(".name")) s = s.substring(0, s.length() - 5); + return s.isBlank() ? null : s; + } + + private static String locLabel(String id) { + String s = id.replace("location.", "").replace(".name", ""); + return s.isEmpty() ? id : s; + } + private void exportPng() { FileChooser fc = new FileChooser(); fc.setTitle("Karte als PNG exportieren"); @@ -303,9 +692,9 @@ public class WorldMapView extends VBox { List locs = LocationIO.load(); List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); - int[] slotColors3 = computeSlotColors(mapData); - RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors3); - BufferedImage bi = WorldMapRenderer.render(input, 4096, buildOptions()); + int[] slotColors = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); + BufferedImage bi = WorldMapRenderer.render(input, 4096, buildExportOptions()); ImageIO.write(bi, "PNG", file); Platform.runLater(() -> { progress.setVisible(false); @@ -322,11 +711,27 @@ public class WorldMapView extends VBox { t.start(); } - private RenderOptions buildOptions() { + /** Hintergrund-PNG: ohne Areas und Locations (die werden live auf Canvas gezeichnet). */ + private RenderOptions buildBackgroundOptions() { return new RenderOptions( layerTerrain.isSelected(), layerTerrain.isSelected(), layerWater.isSelected(), + false, // Wellen im Editor über Canvas gezeichnet + false, + layerZones.isSelected(), + false, + layerModels.isSelected() + ); + } + + /** Export-PNG: alle Layer gebacken. */ + private RenderOptions buildExportOptions() { + return new RenderOptions( + layerTerrain.isSelected(), + layerTerrain.isSelected(), + layerWater.isSelected(), + true, // Wellen im Export-PNG gebacken layerAreas.isSelected(), layerZones.isSelected(), layerLocations.isSelected(),