Zwischenstand Minimap

This commit is contained in:
2026-08-24 20:39:22 +02:00
parent 9238378bc8
commit f8dbbb09c7
8 changed files with 785 additions and 149 deletions

View File

@@ -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<Trigger> 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;

View File

@@ -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<Trigger> 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) {}
}

View File

@@ -8,9 +8,14 @@ public record PlacedArea(
float[] pointsX,
float[] pointsZ,
String areaId,
List<Trigger> triggers
List<Trigger> 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<Trigger> triggers) {
this(pointsX, pointsZ, areaId, triggers, Float.NaN, Float.NaN);
}
}

View File

@@ -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));

View File

@@ -15,7 +15,10 @@ public class Location {
private float centerX;
private float centerZ;
private float radius;
private List<Trigger> triggers = new ArrayList<>();
private List<Trigger> 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() : ""; }

View File

@@ -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);
}

View File

@@ -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 ───────────────────────────────────────────────────────

View File

@@ -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<PlacedArea> cachedAreas = new ArrayList<>();
private List<Location> cachedLocs = new ArrayList<>();
private List<PlacedWater> 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<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> models = PlacedModelIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> 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<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
List<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> 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<float[]> 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<Location> locs = LocationIO.load();
List<PlacedWater> waters = WaterBodyIO.load();
List<PlacedModel> 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(),