Minimap und ingame Karte - umsetzung begonnen

This commit is contained in:
2026-08-23 22:13:32 +02:00
parent 46b504e050
commit 9238378bc8
16 changed files with 2119 additions and 8 deletions

Binary file not shown.

View File

@@ -0,0 +1,24 @@
MaterialDef FogOfWar {
MaterialParameters {
Texture2D ExploreMap // Alpha=0 erkundet, Alpha=1 Nebel
Float Time : 0.0 // Sekunden seit Spielstart (für Animation)
Vector2 OverlayOrigin // Overlay-Position in Bildschirm-Pixeln (bottom-left)
Vector2 OverlaySize // Overlay-Größe in Pixeln
Float FadePx : 5.0 // Breite des Weich-Übergangs
}
Technique {
VertexShader GLSL150: Shaders/FogOfWar.vert
FragmentShader GLSL150: Shaders/FogOfWar.frag
WorldParameters {
WorldViewProjectionMatrix
}
RenderState {
Blend Alpha
DepthWrite Off
}
}
}

View File

@@ -0,0 +1,23 @@
MaterialDef MapOverlay {
MaterialParameters {
Texture2D ColorMap
Vector2 OverlayOrigin // Overlay-Position in Bildschirm-Pixeln (bottom-left)
Vector2 OverlaySize // Overlay-Größe in Pixeln
Float FadePx : 5.0 // Breite des Weich-Übergangs in Pixeln
}
Technique {
VertexShader GLSL150: Shaders/FogOfWar.vert
FragmentShader GLSL150: Shaders/MapOverlay.frag
WorldParameters {
WorldViewProjectionMatrix
}
RenderState {
Blend Alpha
DepthWrite Off
}
}
}

View File

@@ -0,0 +1,98 @@
uniform sampler2D m_ExploreMap;
uniform float m_Time;
uniform vec2 m_OverlayOrigin;
uniform vec2 m_OverlaySize;
uniform float m_FadePx;
in vec2 texCoord;
out vec4 outFragColor;
// ── Procedural Value Noise ────────────────────────────────────────────────────
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0, 1.0)), f.x),
f.y);
}
// FBM: 3 Oktaven
float fbm(vec2 p) {
float v = 0.0;
v += vnoise(p) * 0.500;
v += vnoise(p * 2.1 + vec2(1.7, 9.2)) * 0.250;
v += vnoise(p * 4.3 + vec2(8.3, 2.8)) * 0.125;
return v / 0.875;
}
// ── Hauptprogramm ─────────────────────────────────────────────────────────────
void main() {
// Weichgezeichnete Erkundungsmaske (3×3-Kernel, 8-Texel-Radius = ~32 m)
float ts = 8.0 / 512.0;
float density =
texture(m_ExploreMap, texCoord + vec2(-ts,-ts)).a +
texture(m_ExploreMap, texCoord + vec2(0.0,-ts)).a +
texture(m_ExploreMap, texCoord + vec2( ts,-ts)).a +
texture(m_ExploreMap, texCoord + vec2(-ts, 0.0)).a +
texture(m_ExploreMap, texCoord ).a +
texture(m_ExploreMap, texCoord + vec2( ts, 0.0)).a +
texture(m_ExploreMap, texCoord + vec2(-ts, ts)).a +
texture(m_ExploreMap, texCoord + vec2(0.0, ts)).a +
texture(m_ExploreMap, texCoord + vec2( ts, ts)).a;
density /= 9.0;
// Vollständig erkundet → diesen Fragment überspringen
if (density <= 0.0) { discard; }
// ── Animierter Nebel mit Domain-Warping ───────────────────────────────────
float t = m_Time * 0.18;
vec2 uv = texCoord;
// Erste Warp-Schicht: langsam driftende Wirbel
vec2 q = vec2(
fbm(uv * 2.5 + vec2(t * 0.40, t * 0.15)),
fbm(uv * 2.5 + vec2(t * 0.15, t * 0.30) + vec2(5.2, 1.3))
);
// Zweite Warp-Schicht: gibt Tiefe und Komplexität
vec2 r = vec2(
fbm(uv * 2.0 + q * 0.9 + vec2(1.7, 9.2) + vec2(t * 0.10, 0.0)),
fbm(uv * 2.0 + q * 0.9 + vec2(8.3, 2.8) + vec2(0.0, t * 0.08))
);
// Finaler Noise-Wert [0..1]
float f = fbm(uv * 3.0 + r * 1.2 + vec2(t * 0.05, t * 0.04));
// ── Farbe + Alpha ─────────────────────────────────────────────────────────
// Dunkles Blaugrau etwas heller an den "Schwaden"
vec3 fogColor = mix(
vec3(0.04, 0.05, 0.09), // tiefer Schatten
vec3(0.11, 0.13, 0.20), // heller Dunst
f
);
// Weich-Übergang am Overlay-Rand (Screen-Space, zoom-unabhängig)
float distL = gl_FragCoord.x - m_OverlayOrigin.x;
float distR = (m_OverlayOrigin.x + m_OverlaySize.x) - gl_FragCoord.x;
float distB = gl_FragCoord.y - m_OverlayOrigin.y;
float distT = (m_OverlayOrigin.y + m_OverlaySize.y) - gl_FragCoord.y;
float edgeFade = clamp(min(min(distL, distR), min(distB, distT)) / m_FadePx, 0.0, 1.0);
// Alpha: voll opak im unentdeckten Innern, weicher Rand durch Kernel + Edge-Fade
float alpha = density * (0.88 + f * 0.12) * edgeFade;
outFragColor = vec4(fogColor, alpha);
}

View File

@@ -0,0 +1,11 @@
uniform mat4 g_WorldViewProjectionMatrix;
in vec3 inPosition;
in vec2 inTexCoord;
out vec2 texCoord;
void main() {
texCoord = inTexCoord;
gl_Position = g_WorldViewProjectionMatrix * vec4(inPosition, 1.0);
}

View File

@@ -0,0 +1,20 @@
uniform sampler2D m_ColorMap;
uniform vec2 m_OverlayOrigin;
uniform vec2 m_OverlaySize;
uniform float m_FadePx;
in vec2 texCoord;
out vec4 outFragColor;
void main() {
vec4 col = texture(m_ColorMap, texCoord);
// Abstand des Fragments vom nächsten Overlay-Rand in Screen-Pixeln
float distL = gl_FragCoord.x - m_OverlayOrigin.x;
float distR = (m_OverlayOrigin.x + m_OverlaySize.x) - gl_FragCoord.x;
float distB = gl_FragCoord.y - m_OverlayOrigin.y;
float distT = (m_OverlayOrigin.y + m_OverlaySize.y) - gl_FragCoord.y;
float edgeFade = clamp(min(min(distL, distR), min(distB, distT)) / m_FadePx, 0.0, 1.0);
outFragColor = vec4(col.rgb, col.a * edgeFade);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,609 @@
package de.blight.common.map;
import de.blight.common.*;
import de.blight.common.model.Location;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Rendert eine 2D-Draufsicht der Spielwelt als {@link BufferedImage}.
*
* Koordinaten-Konvention:
* pixelX=0 / pixelY=0 → worldX=-1024 / worldZ=-1024
* pixelX=size-1 → worldX=+1024
* pixelY=size-1 → worldZ=+1024
*/
public final class WorldMapRenderer {
public static final float WORLD_HALF = 1024f;
public static final float WORLD_SIZE = 2048f;
public record RenderInput(
MapData mapData,
List<PlacedArea> areas,
List<PlacedLocationZone> zones,
List<Location> locations,
List<PlacedWater> waters,
List<PlacedModel> models,
int[] slotColorsRGB // 12 Werte: [R0,G0,B0, R1,G1,B1, R2,G2,B2, R3,G3,B3], null = Defaults
) {
public RenderInput(MapData mapData, List<PlacedArea> areas, List<PlacedLocationZone> zones,
List<Location> locations, List<PlacedWater> waters, List<PlacedModel> models) {
this(mapData, areas, zones, locations, waters, models, null);
}
}
public record RenderOptions(
boolean showTerrain,
boolean showSplatColors,
boolean showWater,
boolean showAreas,
boolean showZones,
boolean showLocations,
boolean showModels
) {
public static RenderOptions all() {
return new RenderOptions(true, true, true, true, true, true, true);
}
}
// Default-Slot-Farben für Slots 1-8 (Base-Layer 1-4 + Upper-Layer 5-8)
private static final int[] DEF_SLOT_R = { 71, 115, 140, 204, 90, 130, 110, 180 };
private static final int[] DEF_SLOT_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
private static final int[] DEF_SLOT_B = { 46, 64, 77, 128, 40, 60, 50, 70 };
private WorldMapRenderer() {}
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
MapData m = input.mapData();
int TV = MapData.TERRAIN_VERTS;
int SS = MapData.SPLAT_SIZE;
int[] slotR = slotChannel(input, 0);
int[] slotG = slotChannel(input, 1);
int[] slotB = slotChannel(input, 2);
// ── 1. Heightmap auf Zielauflösung samplen ────────────────────────────
float[] heights = new float[targetSize * targetSize];
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1);
float h = m.terrainHeight[hz * TV + hx];
heights[py * targetSize + px] = h;
if (h < minH) minH = h;
if (h > maxH) maxH = h;
}
}
float heightRange = Math.max(0.01f, maxH - minH);
// ── 2. Pixel-Farben berechnen ─────────────────────────────────────────
BufferedImage img = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_RGB);
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
float h = heights[py * targetSize + px];
float hn = (h - minH) / heightRange;
int r, g, b;
if (opts.showSplatColors()) {
int sx = Math.min((int)((float) px / (targetSize - 1) * (SS - 1)), SS - 1);
int sz = Math.min((int)((float)(targetSize - 1 - py) / (targetSize - 1) * (SS - 1)), SS - 1);
int si = sz * SS + sx;
// Base layer (Slots 1-4)
float wg = (m.splatG[si] & 0xFF) / 255f;
float wb = (m.splatB[si] & 0xFF) / 255f;
float wa = (m.splatA[si] & 0xFF) / 255f;
float fr = slotR[0], fg = slotG[0], fb = slotB[0];
fr = fr*(1-wg) + slotR[1]*wg; fg = fg*(1-wg) + slotG[1]*wg; fb = fb*(1-wg) + slotB[1]*wg;
fr = fr*(1-wb) + slotR[2]*wb; fg = fg*(1-wb) + slotG[2]*wb; fb = fb*(1-wb) + slotB[2]*wb;
fr = fr*(1-wa) + slotR[3]*wa; fg = fg*(1-wa) + slotG[3]*wa; fb = fb*(1-wa) + slotB[3]*wa;
// Upper layer (Slots 5-8)
float u1 = (m.upperSplatR[si] & 0xFF) / 255f;
float u2 = (m.upperSplatG[si] & 0xFF) / 255f;
float u3 = (m.upperSplatB[si] & 0xFF) / 255f;
float u4 = (m.upperSplatA[si] & 0xFF) / 255f;
fr = fr*(1-u1) + slotR[4]*u1; fg = fg*(1-u1) + slotG[4]*u1; fb = fb*(1-u1) + slotB[4]*u1;
fr = fr*(1-u2) + slotR[5]*u2; fg = fg*(1-u2) + slotG[5]*u2; fb = fb*(1-u2) + slotB[5]*u2;
fr = fr*(1-u3) + slotR[6]*u3; fg = fg*(1-u3) + slotG[6]*u3; fb = fb*(1-u3) + slotB[6]*u3;
fr = fr*(1-u4) + slotR[7]*u4; fg = fg*(1-u4) + slotG[7]*u4; fb = fb*(1-u4) + slotB[7]*u4;
r = clamp((int) fr); g = clamp((int) fg); b = clamp((int) fb);
} else {
int lum = clamp(30 + (int)(hn * 200));
r = g = b = lum;
}
// Hillshading (NW-Licht)
if (opts.showTerrain() && px > 0 && px < targetSize - 1 && py > 0 && py < targetSize - 1) {
float dx = heights[py * targetSize + (px + 1)] - heights[py * targetSize + (px - 1)];
float dz = heights[(py + 1) * targetSize + px] - heights[(py - 1) * targetSize + px];
float nx = -dx * 0.25f, ny = 1f, nz = -dz * 0.25f;
float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
nx /= len; ny /= len; nz /= len;
float shade = nx * (-0.577f) + ny * 0.577f + nz * (-0.577f);
shade = 0.45f + shade * 0.85f;
shade = Math.max(0.2f, Math.min(1.8f, shade));
r = clamp((int)(r * shade));
g = clamp((int)(g * shade));
b = clamp((int)(b * shade));
}
img.setRGB(px, py, (r << 16) | (g << 8) | b);
}
}
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
float lineW = Math.max(1.5f, targetSize / 400f);
// Wasser
if (opts.showWater()) {
final int WATER_RGB = (255 << 24) | (35 << 16) | (100 << 8) | 190;
// Meer: Terrain unter Meeresspiegel
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);
}
}
}
// 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);
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);
}
}
}
}
}
// Area-Polygone
if (opts.showAreas()) {
gfx.setStroke(new BasicStroke(lineW));
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);
}
}
// Location-Zonen
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
for (PlacedLocationZone z : input.zones()) {
int[] xs = worldToPixels(z.pointsX(), targetSize);
int[] ys = worldToPixels(z.pointsZ(), targetSize);
gfx.setColor(new Color(240, 190, 40, 70));
gfx.fillPolygon(xs, ys, xs.length);
gfx.setColor(new Color(200, 150, 20));
gfx.drawPolygon(xs, ys, xs.length);
}
}
// Modell-Punkte
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
int mx = worldToPixel(model.x(), targetSize);
int mz = worldToPixel(model.z(), targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
}
}
// Locations (Kreise + Labels)
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);
}
}
}
// 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;
}
/**
* Rendert einen rechteckigen Weltausschnitt als {@link BufferedImage}.
* Koordinatenursprung und Skalierung passen sich dem Ausschnitt an,
* sodass die Ausgabe immer {@code targetSize × targetSize} Pixel scharf gezeichnet wird.
*
* Wenn {@code opts.showTerrain() == false}, ist der Hintergrund transparent (ARGB).
* Das ist nützlich für eine Vektor-Overlay-Textur über einem separaten Terrain-Layer.
*
* @param worldCenterX Weltkoordinate X des Mittelpunkts
* @param worldCenterZ Weltkoordinate Z des Mittelpunkts
* @param halfExtent Halbausdehnung in Weltmetern (sichtbarer Radius)
*/
public static BufferedImage renderRegion(RenderInput input, int targetSize, RenderOptions opts,
float worldCenterX, float worldCenterZ, float halfExtent) {
halfExtent = Math.max(1f, halfExtent);
float wx0 = worldCenterX - halfExtent;
float wz0 = worldCenterZ - halfExtent;
float rSize = halfExtent * 2f;
int imageType = opts.showTerrain() ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
BufferedImage img = new BufferedImage(targetSize, targetSize, imageType);
// ── Höhen samplen (für Terrain-Render und Wasser-Clipping) ──────────
MapData m = input.mapData();
int TV = MapData.TERRAIN_VERTS;
float[] heights = null;
float minH = 0f, maxH = 1f;
if (opts.showTerrain() || opts.showWater()) {
heights = new float[targetSize * targetSize];
minH = Float.MAX_VALUE;
maxH = -Float.MAX_VALUE;
for (int py = 0; py < targetSize; py++) {
float wz = wz0 + (float) py / (targetSize - 1) * rSize;
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
for (int px = 0; px < targetSize; px++) {
float wx = wx0 + (float) px / (targetSize - 1) * rSize;
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
float h = m.terrainHeight[hz * TV + hx];
heights[py * targetSize + px] = h;
if (h < minH) { minH = h; }
if (h > maxH) { maxH = h; }
}
}
}
// ── Terrain (optional) ────────────────────────────────────────────────
if (opts.showTerrain()) {
float heightRange = Math.max(0.01f, maxH - minH);
int[] sR = slotChannel(input, 0);
int[] sG = slotChannel(input, 1);
int[] sB = slotChannel(input, 2);
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
float h = heights[py * targetSize + px];
float hn = (h - minH) / heightRange;
int r, g, b;
if (opts.showSplatColors()) {
float wx = wx0 + (float) px / (targetSize - 1) * rSize;
float wz = wz0 + (float) py / (targetSize - 1) * rSize;
int SS2 = MapData.SPLAT_SIZE;
float sfx = Math.max(0f, Math.min(SS2 - 1, (wx + WORLD_HALF) / WORLD_SIZE * (SS2 - 1)));
float sfz = Math.max(0f, Math.min(SS2 - 1, (WORLD_HALF - wz) / WORLD_SIZE * (SS2 - 1)));
int sx0 = (int) sfx; int sx1 = Math.min(sx0 + 1, SS2 - 1);
int sz0 = (int) sfz; int sz1 = Math.min(sz0 + 1, SS2 - 1);
float tx = sfx - sx0, tz = sfz - sz0;
int i00 = sz0 * SS2 + sx0, i10 = sz0 * SS2 + sx1;
int i01 = sz1 * SS2 + sx0, i11 = sz1 * SS2 + sx1;
// Base layer (Slots 1-4) bilinear interpolated weights
float wg = bilerp(m.splatG, i00, i10, i01, i11, tx, tz);
float wb = bilerp(m.splatB, i00, i10, i01, i11, tx, tz);
float wa = bilerp(m.splatA, i00, i10, i01, i11, tx, tz);
float fr = sR[0], fg = sG[0], fb = sB[0];
fr = fr*(1-wg) + sR[1]*wg; fg = fg*(1-wg) + sG[1]*wg; fb = fb*(1-wg) + sB[1]*wg;
fr = fr*(1-wb) + sR[2]*wb; fg = fg*(1-wb) + sG[2]*wb; fb = fb*(1-wb) + sB[2]*wb;
fr = fr*(1-wa) + sR[3]*wa; fg = fg*(1-wa) + sG[3]*wa; fb = fb*(1-wa) + sB[3]*wa;
// Upper layer (Slots 5-8) bilinear interpolated weights
float u1 = bilerp(m.upperSplatR, i00, i10, i01, i11, tx, tz);
float u2 = bilerp(m.upperSplatG, i00, i10, i01, i11, tx, tz);
float u3 = bilerp(m.upperSplatB, i00, i10, i01, i11, tx, tz);
float u4 = bilerp(m.upperSplatA, i00, i10, i01, i11, tx, tz);
fr = fr*(1-u1) + sR[4]*u1; fg = fg*(1-u1) + sG[4]*u1; fb = fb*(1-u1) + sB[4]*u1;
fr = fr*(1-u2) + sR[5]*u2; fg = fg*(1-u2) + sG[5]*u2; fb = fb*(1-u2) + sB[5]*u2;
fr = fr*(1-u3) + sR[6]*u3; fg = fg*(1-u3) + sG[6]*u3; fb = fb*(1-u3) + sB[6]*u3;
fr = fr*(1-u4) + sR[7]*u4; fg = fg*(1-u4) + sG[7]*u4; fb = fb*(1-u4) + sB[7]*u4;
r = clamp((int) fr); g = clamp((int) fg); b = clamp((int) fb);
} else {
int lum = clamp(30 + (int) (hn * 200));
r = g = b = lum;
}
if (px > 0 && px < targetSize - 1 && py > 0 && py < targetSize - 1) {
float dx = heights[py * targetSize + (px + 1)] - heights[py * targetSize + (px - 1)];
float dz = heights[(py + 1) * targetSize + px] - heights[(py - 1) * targetSize + px];
float nx = -dx * 0.25f, ny = 1f, nz = -dz * 0.25f;
float len = (float) Math.sqrt(nx*nx + ny*ny + nz*nz);
nx /= len; ny /= len; nz /= len;
float shade = Math.max(0.2f, Math.min(1.8f,
0.45f + (nx * (-0.577f) + ny * 0.577f + nz * (-0.577f)) * 0.85f));
r = clamp((int) (r * shade));
g = clamp((int) (g * shade));
b = clamp((int) (b * shade));
}
img.setRGB(px, py, (r << 16) | (g << 8) | b);
}
}
}
// ── Vektor-Overlays ───────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
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
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);
}
}
}
// 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);
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 (opts.showAreas()) {
gfx.setStroke(new BasicStroke(lineW));
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);
}
}
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
for (PlacedLocationZone z : input.zones()) {
int[] xs = wrp(z.pointsX(), wx0, rSize, targetSize);
int[] ys = wrp(z.pointsZ(), wz0, rSize, targetSize);
gfx.setColor(new Color(240, 190, 40, 70));
gfx.fillPolygon(xs, ys, xs.length);
gfx.setColor(new Color(200, 150, 20));
gfx.drawPolygon(xs, ys, xs.length);
}
}
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
int mx = wrp1(model.x(), wx0, rSize, targetSize);
int mz = wrp1(model.z(), wz0, rSize, targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
}
}
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);
}
}
}
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;
}
// ── Koordinaten-Hilfsmethoden ─────────────────────────────────────────────
public static int worldToPixel(float worldCoord, int targetSize) {
int px = (int)((worldCoord + WORLD_HALF) / WORLD_SIZE * (targetSize - 1));
return Math.max(0, Math.min(targetSize - 1, px));
}
public static float pixelToWorld(double pixel, double canvasSize, double panOffset, double scale) {
return (float)((pixel - panOffset) / scale / canvasSize * WORLD_SIZE - WORLD_HALF);
}
private static int[] worldToPixels(float[] coords, int targetSize) {
int[] px = new int[coords.length];
for (int i = 0; i < coords.length; i++) {
px[i] = worldToPixel(coords[i], targetSize);
}
return px;
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static int clamp(int v) {
return Math.max(0, Math.min(255, v));
}
// Gibt den R-, G- oder B-Kanal (channel=0/1/2) aller 8 Splatmap-Slots zurück.
// slotColorsRGB: 24 Werte (8 Slots × 3), 12 Werte (4 Slots, Upper-Layer = Defaults) oder null.
private static int[] slotChannel(RenderInput input, int channel) {
int[] rgb = input.slotColorsRGB();
int[] def = channel == 0 ? DEF_SLOT_R : (channel == 1 ? DEF_SLOT_G : DEF_SLOT_B);
if (rgb == null || rgb.length < 12) { return def; }
int[] out = new int[8];
for (int s = 0; s < 8; s++) {
out[s] = (rgb.length >= (s + 1) * 3) ? rgb[s * 3 + channel] : def[s];
}
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;
}
// 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));
}
private static int[] wrp(float[] coords, float regionOrigin, float regionSize, int targetSize) {
int[] px = new int[coords.length];
for (int i = 0; i < coords.length; i++) {
px[i] = wrp1(coords[i], regionOrigin, regionSize, targetSize);
}
return px;
}
private static int iclamp(int v, int lo, int hi) {
return Math.max(lo, Math.min(hi, v));
}
/**
* Berechnet die Durchschnittsfarben aller 8 Textur-Slots (Base 1-4 + Upper 5-8)
* durch Pixel-Sampling der Texturdateien.
*
* @param mapData Kartendaten (terrainTextures / upperTextures)
* @param assetRoot Wurzelverzeichnis der Assets (enthält Textures/…)
* @param baseFallbacks Fallback-Pfade für leere Base-Slots (kann null sein)
* @return int[24] mit [R0,G0,B0, R1,G1,B1, ..., R7,G7,B7] für Slots 1-8
*/
public static int[] computeSlotColors(MapData mapData, Path assetRoot, String[] baseFallbacks) {
int[] result = {
71,148, 46, 115, 82, 64, 140,115, 77, 204,184,128,
90, 80, 40, 130, 90, 60, 110, 60, 50, 180,100, 70
};
sampleTexColors(mapData.terrainTextures, baseFallbacks, assetRoot, result, 0);
sampleTexColors(mapData.upperTextures, null, assetRoot, result, 4);
return result;
}
private static void sampleTexColors(String[] textures, String[] fallbacks,
Path assetRoot, int[] result, int slotOffset) {
if (textures == null) { return; }
for (int slot = 0; slot < 4 && slot < textures.length; slot++) {
String rel = textures[slot];
if ((rel == null || rel.isEmpty()) && fallbacks != null && slot < fallbacks.length) {
rel = fallbacks[slot];
}
if (rel == null || rel.isEmpty()) { continue; }
Path texFile = assetRoot.resolve(rel);
if (!Files.exists(texFile)) { continue; }
try {
BufferedImage img = ImageIO.read(texFile.toFile());
if (img == null) { continue; }
long sumR = 0, sumG = 0, sumB = 0, count = 0;
int stride = Math.max(1, img.getWidth() / 32);
for (int y = 0; y < img.getHeight(); y += stride) {
for (int x = 0; x < img.getWidth(); x += stride) {
int rgb = img.getRGB(x, y);
sumR += (rgb >> 16) & 0xFF;
sumG += (rgb >> 8) & 0xFF;
sumB += rgb & 0xFF;
count++;
}
}
if (count > 0) {
int i = (slotOffset + slot) * 3;
result[i] = (int)(sumR / count);
result[i + 1] = (int)(sumG / count);
result[i + 2] = (int)(sumB / count);
}
} catch (Exception ignored) {}
}
}
private static float bilerp(byte[] arr, int i00, int i10, int i01, int i11, float tx, float tz) {
float v00 = (arr[i00] & 0xFF) / 255f, v10 = (arr[i10] & 0xFF) / 255f;
float v01 = (arr[i01] & 0xFF) / 255f, v11 = (arr[i11] & 0xFF) / 255f;
return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz;
}
}

View File

@@ -82,6 +82,7 @@ public class EditorApp extends Application {
new java.util.concurrent.ConcurrentLinkedQueue<>();
private VBox assetPanel;
private MapObjectsView mapObjectsView;
private de.blight.editor.ui.WorldMapView worldMapView;
private de.blight.editor.ui.ThumbnailManagerView thumbnailManagerView;
private StackPane worldViewport;
private javafx.scene.canvas.Canvas compassCanvas;
@@ -5637,7 +5638,14 @@ public class EditorApp extends Application {
if (isSelected) mapObjectsView.refresh();
});
TabPane tabPane = new TabPane(assetsTab, karteTab);
worldMapView = new de.blight.editor.ui.WorldMapView(() -> primaryStage);
Tab weltkartTab = new Tab("Weltkarte", worldMapView);
weltkartTab.setClosable(false);
weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender();
});
TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab);
tabPane.setStyle("-fx-background-color: #e8e8e8;");
VBox.setVgrow(tabPane, Priority.ALWAYS);

View File

@@ -0,0 +1,342 @@
package de.blight.editor.ui;
import de.blight.common.*;
import de.blight.common.model.Location;
import de.blight.common.map.WorldMapRenderer;
import de.blight.common.map.WorldMapRenderer.RenderInput;
import de.blight.common.map.WorldMapRenderer.RenderOptions;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
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.stage.FileChooser;
import javafx.stage.Stage;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
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.
*/
public class WorldMapView extends VBox {
private static final int RENDER_SIZE = 2048;
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);
private final StackPane canvasPane = new StackPane(canvas);
private final ToggleButton layerTerrain = layerBtn("Gelände");
private final ToggleButton layerWater = layerBtn("Wasser");
private final ToggleButton layerAreas = layerBtn("Areas");
private final ToggleButton layerZones = layerBtn("Zonen");
private final ToggleButton layerLocations = layerBtn("Orte");
private final ToggleButton layerModels = layerBtn("Modelle");
private WritableImage mapFxImage;
private BufferedImage mapBuffered;
private double panX = 0, panY = 0;
private double scale = 1.0;
private double dragStartX, dragStartY, dragStartPanX, dragStartPanY;
private final Supplier<Stage> stageSupplier;
private final AtomicBoolean loading = new AtomicBoolean(false);
private static final String[] ASSET_BASES = {
"blight-assets/src/main/resources",
"../blight-assets/src/main/resources",
"assets",
".",
};
private static final String[] TERRAIN_TEX_DEFAULTS = {
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/splat/dirt.jpg",
"",
};
public WorldMapView(Supplier<Stage> stageSupplier) {
this.stageSupplier = stageSupplier;
buildUi();
}
private static Path findAssetRoot() {
for (String base : ASSET_BASES) {
Path p = Paths.get(base);
if (Files.isDirectory(p.resolve("Textures"))) return p;
}
return Paths.get(ASSET_BASES[0]);
}
private static int[] computeSlotColors(MapData mapData) {
return WorldMapRenderer.computeSlotColors(mapData, findAssetRoot(), TERRAIN_TEX_DEFAULTS);
}
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());
ToolBar toolbar = new ToolBar(
new Label("Layer:"),
layerTerrain, layerWater, layerAreas, layerZones, layerLocations, layerModels,
new Separator(),
refreshBtn,
exportBtn
);
// ── Canvas ────────────────────────────────────────────────────────────
canvasPane.setStyle("-fx-background-color: #1a1a2a;");
VBox.setVgrow(canvasPane, Priority.ALWAYS);
canvas.widthProperty().bind(canvasPane.widthProperty());
canvas.heightProperty().bind(canvasPane.heightProperty());
canvas.widthProperty().addListener(obs -> redraw());
canvas.heightProperty().addListener(obs -> redraw());
// Layer-Toggle → 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
canvas.setOnMousePressed(e -> {
dragStartX = e.getX();
dragStartY = e.getY();
dragStartPanX = panX;
dragStartPanY = panY;
});
canvas.setOnMouseDragged(e -> {
panX = dragStartPanX + (e.getX() - dragStartX);
panY = dragStartPanY + (e.getY() - dragStartY);
redraw();
});
// Zoom (Mausrad, Ziel = Mausposition)
canvas.setOnScroll(e -> {
if (e.getDeltaY() == 0) return;
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;
panY = e.getY() - (e.getY() - panY) * scale / oldScale;
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);
statusLbl.setText(String.format("X: %.1f Z: %.1f", wx, wz));
});
// ── Statuszeile ───────────────────────────────────────────────────────
progress.setPrefWidth(160);
progress.setVisible(false);
HBox statusBar = new HBox(8, statusLbl, progress);
statusBar.setAlignment(Pos.CENTER_LEFT);
statusBar.setPadding(new Insets(4, 8, 4, 8));
statusBar.setStyle("-fx-background-color: #2a2a3a;");
statusBar.getStyleClass().add("status-bar");
getChildren().addAll(toolbar, canvasPane, statusBar);
setStyle("-fx-background-color: #1a1a2a;");
}
// ── Ö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);
statusLbl.setText("Lade Welt-Daten…");
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();
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());
Platform.runLater(() -> {
mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null);
fitToView();
redraw();
progress.setVisible(false);
statusLbl.setText("Bereit " + areas.size() + " Areas, " +
locs.size() + " Orte, " + waters.size() + " Wasser");
loading.set(false);
});
} catch (Exception ex) {
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Fehler: " + ex.getMessage());
loading.set(false);
});
}
}, "WorldMapRenderer");
t.setDaemon(true);
t.start();
}
// ── Interne Methoden ──────────────────────────────────────────────────────
private void rerender() {
if (mapBuffered == null) { loadAndRender(); return; }
if (loading.getAndSet(true)) return;
progress.setVisible(true);
statusLbl.setText("Rendere…");
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());
Platform.runLater(() -> {
mapBuffered = bi;
mapFxImage = SwingFXUtils.toFXImage(bi, null);
redraw();
progress.setVisible(false);
statusLbl.setText("Bereit");
loading.set(false);
});
} catch (Exception ex) {
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Fehler: " + ex.getMessage());
loading.set(false);
});
}
}, "WorldMapRerender");
t.setDaemon(true);
t.start();
}
private void redraw() {
GraphicsContext gc = canvas.getGraphicsContext2D();
double w = canvas.getWidth(), h = canvas.getHeight();
gc.setFill(javafx.scene.paint.Color.rgb(20, 20, 35));
gc.fillRect(0, 0, w, h);
if (mapFxImage == null) {
gc.setFill(Color.GRAY);
gc.fillText("Karte noch nicht geladen 'Aktualisieren' klicken", 20, 40);
return;
}
double imgW = mapFxImage.getWidth() * scale;
double imgH = mapFxImage.getHeight() * scale;
gc.drawImage(mapFxImage, panX, panY, imgW, imgH);
}
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);
panX = (canvas.getWidth() - mapFxImage.getWidth() * scale) / 2;
panY = (canvas.getHeight() - mapFxImage.getHeight() * scale) / 2;
}
private void exportPng() {
FileChooser fc = new FileChooser();
fc.setTitle("Karte als PNG exportieren");
fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG-Bild", "*.png"));
fc.setInitialFileName("weltkarte.png");
File file = fc.showSaveDialog(stageSupplier.get());
if (file == null) return;
statusLbl.setText("Exportiere 4096×4096 PNG…");
progress.setVisible(true);
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();
int[] slotColors3 = computeSlotColors(mapData);
RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors3);
BufferedImage bi = WorldMapRenderer.render(input, 4096, buildOptions());
ImageIO.write(bi, "PNG", file);
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Exportiert: " + file.getName());
});
} catch (IOException ex) {
Platform.runLater(() -> {
progress.setVisible(false);
statusLbl.setText("Export-Fehler: " + ex.getMessage());
});
}
}, "MapPngExport");
t.setDaemon(true);
t.start();
}
private RenderOptions buildOptions() {
return new RenderOptions(
layerTerrain.isSelected(),
layerTerrain.isSelected(),
layerWater.isSelected(),
layerAreas.isSelected(),
layerZones.isSelected(),
layerLocations.isSelected(),
layerModels.isSelected()
);
}
private static ToggleButton layerBtn(String label) {
ToggleButton btn = new ToggleButton(label);
btn.setSelected(true);
return btn;
}
}

View File

@@ -371,6 +371,7 @@ public class WorldScene extends BaseAppState {
app.getStateManager().attach(new de.blight.game.state.HudState(mc));
app.getStateManager().attach(new de.blight.game.state.HotbarState(mc));
app.getStateManager().attach(new de.blight.game.state.CompassHudState());
app.getStateManager().attach(new de.blight.game.state.MinimapState(character));
de.blight.game.state.CharacterState charState = new de.blight.game.state.CharacterState(mc);
charState.setEnabled(false);
app.getStateManager().attach(charState);

View File

@@ -18,6 +18,7 @@ import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import com.jme3.texture.Texture;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.state.MinimapState;
import de.blight.game.config.MenuScreen;
import de.blight.game.config.OverlayState;
import org.slf4j.Logger;
@@ -44,7 +45,7 @@ public class CompassHudState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(CompassHudState.class);
private static final float SIZE = 100f;
private static final float MARGIN = 16f;
private static final float GAP = 8f; // Abstand zwischen Minimap und Kompass
private SimpleApplication app;
private Camera cam;
@@ -89,7 +90,7 @@ public class CompassHudState extends BaseAppState {
public void update(float tpf) {
if (compassNode == null) { return; }
boolean menuOpen = OverlayState.isAnyOpen() || MenuScreen.isAnyOpen();
boolean menuOpen = OverlayState.isAnyOpen() || MenuScreen.isAnyOpen() || MinimapState.isFullMapOpen();
if (menuOpen != lastMenuOpen) {
lastMenuOpen = menuOpen;
compassNode.setCullHint(menuOpen ? Spatial.CullHint.Always : Spatial.CullHint.Inherit);
@@ -98,7 +99,7 @@ public class CompassHudState extends BaseAppState {
Vector3f dir = cam.getDirection();
float yaw = FastMath.atan2(dir.x, -dir.z);
roseRot.fromAngleAxis(-yaw, Vector3f.UNIT_Z);
roseRot.fromAngleAxis(yaw, Vector3f.UNIT_Z);
roseNode.setLocalRotation(roseRot);
}
@@ -113,9 +114,9 @@ public class CompassHudState extends BaseAppState {
roseH = roseTex.getImage().getHeight();
} catch (Exception ignored) {}
// compassNode-Mittelpunkt so setzen, dass die Rose mit MARGIN Abstand zur Ecke endet
float cx = MARGIN + roseW / 2f;
float cy = MARGIN + roseH / 2f;
// Kompass zentriert über der Minimap (links unten)
float cx = MinimapState.MARGIN + MinimapState.MINIMAP_SIZE / 2f;
float cy = MinimapState.MARGIN + MinimapState.MINIMAP_SIZE + GAP + roseH / 2f;
compassNode = new Node("compass");
compassNode.setLocalTranslation(cx, cy, 0f);

View File

@@ -0,0 +1,180 @@
package de.blight.game.state;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import com.jme3.texture.Texture.MagFilter;
import com.jme3.texture.Texture.MinFilter;
import com.jme3.texture.Texture.WrapMode;
import com.jme3.util.BufferUtils;
import de.blight.common.BlightHome;
import de.blight.common.map.WorldMapRenderer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* Verfolgt welche Bereiche der Spielwelt der Spieler bereits erkundet hat.
*
* 512×512 Raster über 2048×2048 m = 4 m/Zelle.
* Erkundungsradius: 50 m = ~13 Zellen.
*
* Persistenz: ~/.blight/saves/explored.bin (flaches byte[]-Dump, 0=unbekannt 1=erkundet).
* Nebelmaske: RGBA8-ByteBuffer für JME3-Texture2D (Alpha=0 erkundet, Alpha=192 unbekannt).
*/
public final class ExploreTracker {
private static final Logger log = LoggerFactory.getLogger(ExploreTracker.class);
static final int GRID = 512;
static final float CELL = WorldMapRenderer.WORLD_SIZE / GRID; // 4 m pro Zelle
static final float EXPLORE_R = 50f; // Erkundungsradius in Metern
static final byte FOG_ALPHA = (byte) 0xFF; // 255 = vollständig opak
private static final Path SAVE_PATH = BlightHome.resolve("saves", "explored.bin");
private final byte[] cells = new byte[GRID * GRID]; // 0=unbekannt, 1=erkundet
private boolean dirty = false;
// JME3-Nebel-Textur
private final ByteBuffer fogBuffer = BufferUtils.createByteBuffer(GRID * GRID * 4);
private final Image fogImage = new Image(Image.Format.RGBA8, GRID, GRID, fogBuffer);
private final Texture2D fogTex;
public ExploreTracker() {
fogTex = new Texture2D(fogImage);
fogTex.setWrap(WrapMode.EdgeClamp);
fogTex.setMagFilter(MagFilter.Bilinear);
fogTex.setMinFilter(MinFilter.BilinearNoMipMaps);
// Gesamte Karte zunächst auf undurchsichtig setzen
fillFogOpaque();
}
// ── Erkundung ─────────────────────────────────────────────────────────────
/**
* Markiert alle Zellen im Radius {@value #EXPLORE_R} m um die Weltposition als erkundet.
* @return true wenn sich mindestens eine Zelle geändert hat
*/
public boolean markCircle(float worldX, float worldZ) {
int cx = worldToCell(worldX);
int cz = worldToCell(worldZ);
int cr = (int) Math.ceil(EXPLORE_R / CELL);
boolean changed = false;
for (int dz = -cr; dz <= cr; dz++) {
for (int dx = -cr; dx <= cr; dx++) {
if (dx * dx + dz * dz > cr * cr) { continue; }
int gx = cx + dx;
int gz = cz + dz;
if (gx < 0 || gx >= GRID || gz < 0 || gz >= GRID) { continue; }
int idx = gz * GRID + gx;
if (cells[idx] == 0) {
cells[idx] = 1;
changed = true;
writeFogCell(gx, gz, true);
}
}
}
if (changed) {
dirty = true;
fogImage.setUpdateNeeded();
}
return changed;
}
// ── Textur ────────────────────────────────────────────────────────────────
public Texture2D getFogTexture() { return fogTex; }
// ── Persistenz ────────────────────────────────────────────────────────────
public void load() {
if (!Files.exists(SAVE_PATH)) { return; }
try {
byte[] data = Files.readAllBytes(SAVE_PATH);
if (data.length == cells.length) {
System.arraycopy(data, 0, cells, 0, cells.length);
rebuildFogBuffer();
log.info("[Explore] Erkundungsdaten geladen: {}", SAVE_PATH);
} else {
log.warn("[Explore] Ungültige Erkundungsdatei ({}B, erwartet {}B) ignoriert",
data.length, cells.length);
}
} catch (IOException e) {
log.warn("[Explore] Laden fehlgeschlagen: {}", e.getMessage());
}
}
public void save() {
if (!dirty) { return; }
try {
Files.createDirectories(SAVE_PATH.getParent());
Path tmp = SAVE_PATH.resolveSibling("explored.tmp");
Files.write(tmp, cells);
Files.move(tmp, SAVE_PATH, StandardCopyOption.REPLACE_EXISTING);
dirty = false;
log.debug("[Explore] Erkundungsdaten gespeichert");
} catch (IOException e) {
log.warn("[Explore] Speichern fehlgeschlagen: {}", e.getMessage());
}
}
/** Wie viele Zellen bereits erkundet wurden (für Diagnostik). */
public int exploredCount() {
int n = 0;
for (byte c : cells) { if (c != 0) n++; }
return n;
}
// ── Interne Helfer ────────────────────────────────────────────────────────
private int worldToCell(float worldCoord) {
return Math.max(0, Math.min(GRID - 1,
(int) ((worldCoord + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * GRID)));
}
/**
* Füllt den fogBuffer vollständig anhand des cells[]-Arrays.
* Kostenintensiver Rebuild nur beim Laden nötig.
*/
private void rebuildFogBuffer() {
for (int gz = 0; gz < GRID; gz++) {
for (int gx = 0; gx < GRID; gx++) {
writeFogCell(gx, gz, cells[gz * GRID + gx] != 0);
}
}
fogImage.setUpdateNeeded();
}
/** Setzt den gesamten Buffer auf undurchsichtig (Startzustand). */
private void fillFogOpaque() {
for (int i = 0; i < GRID * GRID; i++) {
int off = i * 4;
fogBuffer.put(off, (byte) 0);
fogBuffer.put(off + 1, (byte) 0);
fogBuffer.put(off + 2, (byte) 0);
fogBuffer.put(off + 3, FOG_ALPHA);
}
fogImage.setUpdateNeeded();
}
/**
* Schreibt einen einzelnen Fog-Pixel in den Buffer.
* Y-Achse wird gespiegelt damit die Textur mit der Welt-Map-Textur übereinstimmt
* (JME3 AWTLoader spiegelt PNGs; direkter ByteBuffer wird nicht gespiegelt → manuell).
*/
private void writeFogCell(int gx, int gz, boolean explored) {
int bufRow = GRID - 1 - gz; // Spiegeln: gz=0 (Norden) → letzte Buffer-Zeile (V=1)
int off = (bufRow * GRID + gx) * 4;
fogBuffer.put(off, (byte) 0);
fogBuffer.put(off + 1, (byte) 0);
fogBuffer.put(off + 2, (byte) 0);
fogBuffer.put(off + 3, explored ? (byte) 0 : FOG_ALPHA);
}
}

View File

@@ -0,0 +1,28 @@
package de.blight.game.state;
import com.jme3.asset.AssetManager;
import com.jme3.material.Material;
import com.jme3.post.Filter;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
public final class GaussianBlurFilter extends Filter {
private final float strength;
public GaussianBlurFilter(float strength) {
super("GaussianBlur");
this.strength = strength;
}
@Override
protected Material getMaterial() {
return material;
}
@Override
protected void initFilter(AssetManager manager, RenderManager rm, ViewPort vp, int w, int h) {
material = new Material(manager, "MatDefs/GaussianBlur.j3md");
material.setFloat("BlurScale", strength);
}
}

View File

@@ -0,0 +1,766 @@
package de.blight.game.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.input.KeyInput;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.AnalogListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.input.controls.MouseAxisTrigger;
import com.jme3.input.controls.MouseButtonTrigger;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.FastMath;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.texture.Image;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector2f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.shape.Quad;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.util.BufferUtils;
import de.blight.common.AreaIO;
import de.blight.common.LocationIO;
import de.blight.common.LocationZoneIO;
import de.blight.common.MapData;
import de.blight.common.MapIO;
import de.blight.common.PlacedArea;
import de.blight.common.PlacedLocationZone;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.PlacedWater;
import de.blight.common.WaterBodyIO;
import de.blight.common.map.WorldMapRenderer;
import de.blight.common.map.WorldMapRenderer.RenderInput;
import de.blight.common.map.WorldMapRenderer.RenderOptions;
import de.blight.common.model.Location;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.MenuScreen;
import de.blight.game.config.OverlayState;
import de.blight.game.scene.WorldScene;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.nio.FloatBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Ingame-Minimap (permanent, unten rechts) und Weltkarte-Overlay (M-Taste).
*
* Nebelmaske: Nur bereits erkundete Gebiete (Radius 50 m um den Spieler) sind sichtbar.
* Der Erkundungsfortschritt wird in ~/.blight/saves/explored.bin gespeichert.
*/
public class MinimapState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(MinimapState.class);
private static final float WORLD_HALF = WorldMapRenderer.WORLD_HALF;
private static final float WORLD_SIZE = WorldMapRenderer.WORLD_SIZE;
public static final float MINIMAP_SIZE = 200f;
public static final float MARGIN = 16f;
private static final float VIEW_RADIUS_DEF = 100f; // Minimap: 100 m Sichtradius (fest)
private static final float FM_VIEW_DEF = 200f; // Vollbild: Öffnungs-Radius 200 m
private static final float FM_VIEW_MIN = 50f; // Vollbild: engster Ausschnitt
private static final float FM_VIEW_MAX = WORLD_HALF; // Vollbild: ganze Welt
private static final int TEXTURE_SIZE = 2048;
private static final int VEC_TEX_SIZE = 1024; // Vektor-Overlay-Textur (neu gerastert bei Zoom/Pan)
private static final float DOT_SIZE = 8f;
private static final float MARK_DIST_SQ = 4f * 4f;
private static final float AUTOSAVE_SEC = 60f;
private static final String ACT_MAP_TOGGLE = "_MinimapToggle_";
private static final String ACT_MAP_ESC = "_MinimapEsc_";
private static final String ACT_FM_DRAG = "_MinimapDrag_";
private static final String ANA_FM_ZOOM_IN = "_FMZoomIn_";
private static final String ANA_FM_ZOOM_OUT = "_FMZoomOut_";
private Node playerNode;
private SimpleApplication app;
private Camera camera;
private boolean textureReady = false;
// ── Erkundungs-Nebel ──────────────────────────────────────────────────────
private ExploreTracker exploreTracker;
private Material fogMat; // Minimap-Nebel
private Material fogFullMat; // Vollbild-Nebel (eigene Overlay-Bounds)
private float fogTime = 0f;
// ── Blur-Filter (aktiv wenn Vollbild-Karte offen) ─────────────────────────
private de.blight.game.post.GaussianBlurFilter blurFilter;
// ── Vektor-Overlay (scharf bei jedem Zoom-Level) ──────────────────────────
private WorldMapRenderer.RenderInput renderInput;
private java.nio.ByteBuffer vectorBuf;
private Image vectorImage;
private Texture2D vectorTex;
private Mesh vectorMesh;
private Geometry vectorGeo;
private volatile boolean vecRenderPending = false;
private volatile boolean vecLayerDirty = false;
// UV-Position des letzten abgeschlossenen Vektor-Renders (für Live-Verschiebung ohne Ghost)
private float vecRenderU = 0.5f;
private float vecRenderV = 0.5f;
private float vecRenderHU = 0.5f;
private float lastMarkX = Float.NaN;
private float lastMarkZ = Float.NaN;
private float autoSaveTimer = 0f;
// Dynamischer Sichtradius der Minimap ([ / ] zum Zoomen)
private float viewRadius = VIEW_RADIUS_DEF;
// ── Minimap (immer sichtbar) ──────────────────────────────────────────────
private Node minimapNode;
private Mesh minimapMesh;
private Geometry minimapGeo;
private Geometry fogMiniGeo;
private Geometry playerDotMini;
// ── Vollbild-Overlay (M-Taste) ────────────────────────────────────────────
private static volatile boolean fullMapOpenGlobal = false;
public static boolean isFullMapOpen() { return fullMapOpenGlobal; }
private boolean fullMapOpen = false;
private Node fullMapNode;
private Mesh fullMapMesh;
private Geometry fullMapGeo;
private Geometry fogFullGeo;
private Geometry playerDotFull;
private float fmMapSize;
private float fmMapOriginX;
private float fmMapOriginY;
private float fmViewRadius = FM_VIEW_DEF;
private float fmCenterU = 0.5f;
private float fmCenterV = 0.5f;
private boolean fmDragging = false;
private float fmDragPrevX, fmDragPrevY;
// ── Konstruktor ───────────────────────────────────────────────────────────
public MinimapState(Node playerNode) {
this.playerNode = playerNode;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
camera = app.getCamera();
registerInput();
startRenderThread();
}
@Override
protected void cleanup(Application application) {
removeInput();
if (exploreTracker != null) {
exploreTracker.save();
}
}
@Override
protected void onEnable() {
if (textureReady && minimapNode != null) {
app.getGuiNode().attachChild(minimapNode);
}
}
@Override
protected void onDisable() {
closeFullMap();
if (minimapNode != null) {
app.getGuiNode().detachChild(minimapNode);
}
}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (!textureReady) { return; }
boolean anyOverlay = OverlayState.isAnyOpen() || MenuScreen.isAnyOpen();
if (minimapNode != null) {
minimapNode.setCullHint(anyOverlay || fullMapOpen
? Spatial.CullHint.Always : Spatial.CullHint.Inherit);
}
float px = playerNode.getWorldTranslation().x;
float pz = playerNode.getWorldTranslation().z;
// Erkundung markieren + Nebel animieren
tickExploration(px, pz, tpf);
fogTime += tpf;
if (fogMat != null) { fogMat.setFloat("Time", fogTime); }
if (fogFullMat != null) { fogFullMat.setFloat("Time", fogTime); }
if (!fullMapOpen) {
updateMinimapUV(px, pz);
} else {
if (fmDragging) {
Vector2f cursor = app.getInputManager().getCursorPosition();
float dx = cursor.x - fmDragPrevX;
float dy = cursor.y - fmDragPrevY;
fmDragPrevX = cursor.x;
fmDragPrevY = cursor.y;
if (dx != 0f || dy != 0f) {
float uvPerPixel = (2f * fmViewRadius / WORLD_SIZE) / fmMapSize;
fmCenterU -= dx * uvPerPixel;
fmCenterV -= dy * uvPerPixel;
updateFullMapUV();
scheduleVectorRedraw();
}
}
updateFullMapPlayerDot(px, pz);
}
}
// ── Erkundung ─────────────────────────────────────────────────────────────
private void tickExploration(float px, float pz, float tpf) {
if (exploreTracker == null) { return; }
// Nur markieren wenn der Spieler sich weit genug bewegt hat
boolean moved = Float.isNaN(lastMarkX)
|| distSq(px, pz, lastMarkX, lastMarkZ) >= MARK_DIST_SQ;
if (moved) {
exploreTracker.markCircle(px, pz);
lastMarkX = px;
lastMarkZ = pz;
}
// Auto-Save
autoSaveTimer += tpf;
if (autoSaveTimer >= AUTOSAVE_SEC) {
autoSaveTimer = 0f;
exploreTracker.save();
}
}
private static float distSq(float x1, float z1, float x2, float z2) {
float dx = x2 - x1, dz = z2 - z1;
return dx * dx + dz * dz;
}
// ── Minimap UV ────────────────────────────────────────────────────────────
private void updateMinimapUV(float playerX, float playerZ) {
if (minimapMesh == null) { return; }
float cu = (playerX + WORLD_HALF) / WORLD_SIZE;
float cv = 1f - (playerZ + WORLD_HALF) / WORLD_SIZE;
float hu = viewRadius / WORLD_SIZE;
cu = Math.max(hu, Math.min(1f - hu, cu));
cv = Math.max(hu, Math.min(1f - hu, cv));
// Kamera-Yaw → Karte dreht sich so dass Blickrichtung immer oben ist
float cosA = 1f, sinA = 0f;
if (camera != null) {
Vector3f dir = camera.getDirection();
float yaw = FastMath.atan2(dir.x, -dir.z);
cosA = FastMath.cos(yaw);
sinA = FastMath.sin(yaw);
}
// minimapMesh wird von minimapGeo UND fogMiniGeo geteilt
// Vertex-Reihenfolge: BL(-1,-1), BR(+1,-1), TR(+1,+1), TL(-1,+1)
float[] nx = { -1f, 1f, 1f, -1f };
float[] ny = { -1f, -1f, 1f, 1f };
FloatBuffer uv = (FloatBuffer) minimapMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
for (int i = 0; i < 4; i++) {
uv.put(i * 2, cu + (nx[i] * cosA + ny[i] * sinA) * hu);
uv.put(i * 2 + 1, cv + (-nx[i] * sinA + ny[i] * cosA) * hu);
}
minimapMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
}
// ── Vollbild-Overlay ──────────────────────────────────────────────────────
private void openFullMap() {
if (fullMapNode != null || !textureReady) { return; }
fullMapOpen = true;
fullMapOpenGlobal = true;
float sw = app.getCamera().getWidth();
float sh = app.getCamera().getHeight();
fmMapSize = Math.min(sw, sh) * 0.85f;
fmMapOriginX = (sw - fmMapSize) / 2f;
fmMapOriginY = (sh - fmMapSize) / 2f;
fmViewRadius = FM_VIEW_DEF;
float px = playerNode.getWorldTranslation().x;
float pz = playerNode.getWorldTranslation().z;
fmCenterU = (px + WORLD_HALF) / WORLD_SIZE;
fmCenterV = 1f - (pz + WORLD_HALF) / WORLD_SIZE;
vecRenderU = fmCenterU;
vecRenderV = fmCenterV;
vecRenderHU = FM_VIEW_DEF / WORLD_SIZE;
// Blur auf dem geteilten FPP genau wie das ESC-Menü
WorldScene ws = app.getStateManager().getState(WorldScene.class);
if (ws != null && ws.getSharedFPP() != null) {
blurFilter = new de.blight.game.post.GaussianBlurFilter(5f);
ws.getSharedFPP().addFilter(blurFilter);
}
fullMapNode = new Node("worldmap_overlay");
fullMapMesh = buildQuadMesh(fmMapOriginX, fmMapOriginY, fmMapSize, fmMapSize, 20f);
updateFullMapUV();
// Karten-Textur mit screen-space Edge-Fade
fullMapGeo = new Geometry("wm_geo", fullMapMesh);
fullMapGeo.setMaterial(buildOverlayTexMat(fmMapOriginX, fmMapOriginY, fmMapSize));
fullMapGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fullMapNode.attachChild(fullMapGeo);
// Fog-Overlay: eigene Material-Instanz mit Overlay-Bounds für Edge-Fade
if (exploreTracker != null) {
fogFullMat = buildFogMat(fmMapOriginX, fmMapOriginY, fmMapSize, fmMapSize);
fogFullGeo = new Geometry("wm_fog", fullMapMesh);
fogFullGeo.setMaterial(fogFullMat);
fogFullGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fogFullGeo.setLocalTranslation(0f, 0f, 0.5f);
fullMapNode.attachChild(fogFullGeo);
}
playerDotFull = makeSolidQuad("wm_dot", DOT_SIZE, DOT_SIZE,
new ColorRGBA(1f, 0.85f, 0f, 1f));
playerDotFull.setLocalTranslation(0f, 0f, 21f);
fullMapNode.attachChild(playerDotFull);
// Vektor-Overlay: ARGB ByteBuffer-Textur, die bei Zoom/Pan neu gerastert wird
vectorBuf = BufferUtils.createByteBuffer(VEC_TEX_SIZE * VEC_TEX_SIZE * 4);
vectorImage = new Image(Image.Format.RGBA8, VEC_TEX_SIZE, VEC_TEX_SIZE, vectorBuf);
vectorTex = new Texture2D(vectorImage);
vectorTex.setWrap(Texture.WrapMode.EdgeClamp);
vectorTex.setMagFilter(Texture.MagFilter.Bilinear);
vectorTex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
vectorMesh = buildQuadMesh(fmMapOriginX, fmMapOriginY, fmMapSize, fmMapSize, 20.3f);
vectorGeo = new Geometry("wm_vec", vectorMesh);
vectorGeo.setMaterial(buildVectorMat(fmMapOriginX, fmMapOriginY, fmMapSize));
vectorGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fullMapNode.attachChild(vectorGeo);
app.getGuiNode().attachChild(fullMapNode);
app.getInputManager().setCursorVisible(true);
WorldScene ws2 = app.getStateManager().getState(WorldScene.class);
if (ws2 != null) { ws2.setPaused(true); }
updateFullMapPlayerDot(px, pz);
scheduleVectorRedraw();
}
private void closeFullMap() {
if (!fullMapOpen || fullMapNode == null) { return; }
fullMapOpen = false;
fullMapOpenGlobal = false;
app.getGuiNode().detachChild(fullMapNode);
fullMapNode = null;
fullMapGeo = null;
fogFullGeo = null;
fogFullMat = null;
fullMapMesh = null;
playerDotFull = null;
vectorGeo = null;
vectorMesh = null;
vectorTex = null;
vectorImage = null;
vectorBuf = null;
vecRenderPending = false;
vecLayerDirty = false;
if (blurFilter != null) {
WorldScene ws = app.getStateManager().getState(WorldScene.class);
if (ws != null && ws.getSharedFPP() != null) {
ws.getSharedFPP().removeFilter(blurFilter);
}
blurFilter = null;
}
app.getInputManager().setCursorVisible(false);
WorldScene ws = app.getStateManager().getState(WorldScene.class);
if (ws != null) { ws.setPaused(false); }
}
private void updateFullMapPlayerDot(float px, float pz) {
if (playerDotFull == null) { return; }
float hu = fmViewRadius / WORLD_SIZE;
float pu = (px + WORLD_HALF) / WORLD_SIZE;
float pv = 1f - (pz + WORLD_HALF) / WORLD_SIZE;
float relU = (pu - (fmCenterU - hu)) / (2f * hu);
float relV = (pv - (fmCenterV - hu)) / (2f * hu);
float screenX = fmMapOriginX + relU * fmMapSize - DOT_SIZE / 2f;
float screenY = fmMapOriginY + relV * fmMapSize - DOT_SIZE / 2f;
playerDotFull.setLocalTranslation(screenX, screenY, 21f);
}
private void updateFullMapUV() {
if (fullMapMesh == null) { return; }
float hu = fmViewRadius / WORLD_SIZE;
fmCenterU = Math.max(hu, Math.min(1f - hu, fmCenterU));
fmCenterV = Math.max(hu, Math.min(1f - hu, fmCenterV));
FloatBuffer uv = (FloatBuffer) fullMapMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(0, fmCenterU - hu); uv.put(1, fmCenterV - hu);
uv.put(2, fmCenterU + hu); uv.put(3, fmCenterV - hu);
uv.put(4, fmCenterU + hu); uv.put(5, fmCenterV + hu);
uv.put(6, fmCenterU - hu); uv.put(7, fmCenterV + hu);
fullMapMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
updateVectorMeshUV();
}
private void updateVectorMeshUV() {
if (vectorMesh == null || vecRenderHU <= 0f) { return; }
float hu = fmViewRadius / WORLD_SIZE;
float rhu = vecRenderHU;
float uMin = (fmCenterU - hu - (vecRenderU - rhu)) / (2f * rhu);
float uMax = (fmCenterU + hu - (vecRenderU - rhu)) / (2f * rhu);
float vMin = (fmCenterV - hu - (vecRenderV - rhu)) / (2f * rhu);
float vMax = (fmCenterV + hu - (vecRenderV - rhu)) / (2f * rhu);
FloatBuffer uv = (FloatBuffer) vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).getData();
uv.put(0, uMin); uv.put(1, vMin);
uv.put(2, uMax); uv.put(3, vMin);
uv.put(4, uMax); uv.put(5, vMax);
uv.put(6, uMin); uv.put(7, vMax);
vectorMesh.getBuffer(VertexBuffer.Type.TexCoord).setUpdateNeeded();
}
// ── Input ─────────────────────────────────────────────────────────────────
private void registerInput() {
app.getInputManager().addMapping(ACT_MAP_TOGGLE, new KeyTrigger(KeyInput.KEY_M));
app.getInputManager().addMapping(ACT_MAP_ESC, new KeyTrigger(KeyInput.KEY_ESCAPE));
app.getInputManager().addMapping(ACT_FM_DRAG, new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
app.getInputManager().addMapping(ANA_FM_ZOOM_IN, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, false));
app.getInputManager().addMapping(ANA_FM_ZOOM_OUT, new MouseAxisTrigger(MouseInput.AXIS_WHEEL, true));
app.getInputManager().addListener(actionListener, ACT_MAP_TOGGLE, ACT_MAP_ESC, ACT_FM_DRAG);
app.getInputManager().addListener(analogListener, ANA_FM_ZOOM_IN, ANA_FM_ZOOM_OUT);
}
private void removeInput() {
app.getInputManager().removeListener(actionListener);
app.getInputManager().removeListener(analogListener);
for (String m : new String[]{
ACT_MAP_TOGGLE, ACT_MAP_ESC, ACT_FM_DRAG,
ANA_FM_ZOOM_IN, ANA_FM_ZOOM_OUT }) {
if (app.getInputManager().hasMapping(m)) { app.getInputManager().deleteMapping(m); }
}
}
private final ActionListener actionListener = (name, isPressed, tpf) -> {
if (!isEnabled()) { return; }
if (ACT_MAP_TOGGLE.equals(name) && isPressed) {
if (fullMapOpen) { closeFullMap(); } else { openFullMap(); }
} else if (ACT_MAP_ESC.equals(name) && isPressed && fullMapOpen) {
closeFullMap();
} else if (ACT_FM_DRAG.equals(name) && fullMapOpen) {
fmDragging = isPressed;
if (isPressed) {
Vector2f c = app.getInputManager().getCursorPosition();
fmDragPrevX = c.x;
fmDragPrevY = c.y;
} else {
scheduleVectorRedraw();
}
}
};
private final AnalogListener analogListener = (name, value, tpf) -> {
if (!isEnabled() || !fullMapOpen) { return; }
float factor = ANA_FM_ZOOM_IN.equals(name) ? 1f / 1.15f : 1.15f;
float oldHu = fmViewRadius / WORLD_SIZE;
fmViewRadius = Math.max(FM_VIEW_MIN, Math.min(FM_VIEW_MAX, fmViewRadius * factor));
float newHu = fmViewRadius / WORLD_SIZE;
// Cursor-Anker: UV unter dem Mauszeiger bleibt an gleicher Bildschirmposition
Vector2f c = app.getInputManager().getCursorPosition();
float rx = (c.x - fmMapOriginX) / fmMapSize;
float ry = (c.y - fmMapOriginY) / fmMapSize;
if (rx >= 0f && rx <= 1f && ry >= 0f && ry <= 1f) {
fmCenterU += (rx - 0.5f) * 2f * (oldHu - newHu);
fmCenterV += (ry - 0.5f) * 2f * (oldHu - newHu);
} else {
// Zoom auf Spielerposition wenn Cursor außerhalb der Karte
float wpx = playerNode.getWorldTranslation().x;
float wpz = playerNode.getWorldTranslation().z;
float pu = (wpx + WORLD_HALF) / WORLD_SIZE;
float pv = 1f - (wpz + WORLD_HALF) / WORLD_SIZE;
float ratio = newHu / oldHu;
fmCenterU = pu + (fmCenterU - pu) * ratio;
fmCenterV = pv + (fmCenterV - pv) * ratio;
}
updateFullMapUV();
scheduleVectorRedraw();
float px = playerNode.getWorldTranslation().x;
float pz = playerNode.getWorldTranslation().z;
updateFullMapPlayerDot(px, pz);
};
// ── Welt-Renderer (Hintergrund-Thread) ────────────────────────────────────
private void startRenderThread() {
Thread t = new Thread(() -> {
try {
Path root = AnimationLibrary.findAssetRoot();
Path dir = root.resolve("Textures").resolve("hud");
Files.createDirectories(dir);
Path png = dir.resolve("minimap_world.png");
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[] slotColors = computeSlotColors(mapData, root);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
boolean needsRender = !Files.exists(png);
if (!needsRender) {
// Cache ungültig wenn Karte neuer als das PNG
try {
long pngMod = Files.getLastModifiedTime(png).toMillis();
long mapMod = Files.getLastModifiedTime(MapIO.getMapPath()).toMillis();
if (mapMod > pngMod) { needsRender = true; }
} catch (Exception ignored) { needsRender = true; }
}
if (needsRender) {
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
BufferedImage bi = WorldMapRenderer.render(renderInput, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.info("[Minimap] Weltkarte gespeichert: {}", png);
} else {
log.info("[Minimap] Gecachte Weltkarte: {}", png);
}
app.enqueue(() -> { onTextureReady(); return null; });
} catch (Exception e) {
log.error("[Minimap] Render-Fehler: {}", e.getMessage(), e);
}
}, "MinimapRenderer");
t.setDaemon(true);
t.start();
}
// Default-Texturen aus TerrainEditorState (werden genutzt wenn mapData.terrainTextures leer ist)
private static final String[] TERRAIN_TEX_DEFAULTS = {
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/splat/dirt.jpg",
""
};
private static int[] computeSlotColors(MapData mapData, Path assetRoot) {
return WorldMapRenderer.computeSlotColors(mapData, assetRoot, TERRAIN_TEX_DEFAULTS);
}
private void onTextureReady() {
exploreTracker = new ExploreTracker();
exploreTracker.load();
float x = MARGIN;
float y = MARGIN;
fogMat = buildFogMat(x, y, MINIMAP_SIZE, MINIMAP_SIZE);
minimapMesh = buildMinimapMesh(MINIMAP_SIZE, MINIMAP_SIZE);
// Welt-Textur-Geo
minimapGeo = new Geometry("minimap_geo", minimapMesh);
minimapGeo.setMaterial(buildTexMat());
minimapGeo.setQueueBucket(RenderQueue.Bucket.Gui);
// Fog-Geo teilt dasselbe Mesh UV-Updates gelten für beide
fogMiniGeo = new Geometry("minimap_fog", minimapMesh);
fogMiniGeo.setMaterial(fogMat);
fogMiniGeo.setQueueBucket(RenderQueue.Bucket.Gui);
fogMiniGeo.setLocalTranslation(0f, 0f, 1f); // eine Schicht vor der Karte
// Rand-Schatten
Geometry border = makeSolidQuad("minimap_border",
MINIMAP_SIZE + 4f, MINIMAP_SIZE + 4f,
new ColorRGBA(0f, 0f, 0f, 0.6f));
border.setLocalTranslation(-2f, -2f, -1f);
// Spieler-Punkt fest in der Mitte
playerDotMini = makeSolidQuad("minimap_dot", DOT_SIZE, DOT_SIZE,
new ColorRGBA(1f, 0.85f, 0f, 1f));
playerDotMini.setLocalTranslation(
MINIMAP_SIZE / 2f - DOT_SIZE / 2f,
MINIMAP_SIZE / 2f - DOT_SIZE / 2f, 2f);
minimapNode = new Node("minimap");
minimapNode.setLocalTranslation(x, y, 48f);
minimapNode.attachChild(border);
minimapNode.attachChild(minimapGeo);
minimapNode.attachChild(fogMiniGeo);
minimapNode.attachChild(playerDotMini);
textureReady = true;
if (isEnabled()) {
app.getGuiNode().attachChild(minimapNode);
}
}
// ── Vektor-Layer ──────────────────────────────────────────────────────────
private void scheduleVectorRedraw() {
if (renderInput == null || !fullMapOpen) { return; }
if (vecRenderPending) { vecLayerDirty = true; return; }
vecRenderPending = true;
vecLayerDirty = false;
float wx = fmCenterU * WORLD_SIZE - WORLD_HALF;
float wz = (1f - fmCenterV) * WORLD_SIZE - WORLD_HALF;
float vr = fmViewRadius;
final float capU = fmCenterU;
final float capV = fmCenterV;
final float capHU = vr / WORLD_SIZE;
Thread t = new Thread(() -> {
RenderOptions opts = new RenderOptions(false, false, true, true, true, true, true);
BufferedImage bi = WorldMapRenderer.renderRegion(renderInput, VEC_TEX_SIZE, opts, wx, wz, vr);
app.enqueue(() -> {
updateVectorTexture(bi);
vecRenderU = capU;
vecRenderV = capV;
vecRenderHU = capHU;
updateVectorMeshUV();
vecRenderPending = false;
if (vecLayerDirty) { scheduleVectorRedraw(); }
return null;
});
}, "VecLayerRenderer");
t.setDaemon(true);
t.start();
}
private void updateVectorTexture(BufferedImage bi) {
if (vectorBuf == null || vectorImage == null) { return; }
int sz = VEC_TEX_SIZE;
for (int py = 0; py < sz; py++) {
int bufRow = sz - 1 - py; // Y-flip: ByteBuffer-V=0 ist unten (Süden)
for (int px = 0; px < sz; px++) {
int argb = bi.getRGB(px, py);
int off = (bufRow * sz + px) * 4;
vectorBuf.put(off, (byte) ((argb >> 16) & 0xFF));
vectorBuf.put(off + 1, (byte) ((argb >> 8) & 0xFF));
vectorBuf.put(off + 2, (byte) ( argb & 0xFF));
vectorBuf.put(off + 3, (byte) ((argb >> 24) & 0xFF));
}
}
vectorImage.setUpdateNeeded();
}
// ── Material-Helfer ───────────────────────────────────────────────────────
private Material buildTexMat() {
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Off);
try {
Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_world.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
log.warn("[Minimap] Weltkarten-Textur nicht ladbar");
mat.setColor("Color", new ColorRGBA(0.05f, 0.1f, 0.2f, 1f));
}
return mat;
}
private Material buildVectorMat(float originX, float originY, float size) {
Material mat = new Material(app.getAssetManager(), "MatDefs/MapOverlay.j3md");
mat.setTexture("ColorMap", vectorTex);
mat.setVector2("OverlayOrigin", new Vector2f(originX, originY));
mat.setVector2("OverlaySize", new Vector2f(size, size));
return mat;
}
private Material buildFogMat(float originX, float originY, float sizeW, float sizeH) {
Material mat = new Material(app.getAssetManager(), "MatDefs/FogOfWar.j3md");
mat.setTexture("ExploreMap", exploreTracker.getFogTexture());
mat.setFloat("Time", 0f);
mat.setVector2("OverlayOrigin", new Vector2f(originX, originY));
mat.setVector2("OverlaySize", new Vector2f(sizeW, sizeH));
return mat;
}
private Material buildOverlayTexMat(float originX, float originY, float size) {
Material mat = new Material(app.getAssetManager(), "MatDefs/MapOverlay.j3md");
try {
Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_world.png");
tex.setWrap(Texture.WrapMode.EdgeClamp);
mat.setTexture("ColorMap", tex);
} catch (Exception e) {
log.warn("[Minimap] Weltkarten-Textur nicht ladbar");
}
mat.setVector2("OverlayOrigin", new Vector2f(originX, originY));
mat.setVector2("OverlaySize", new Vector2f(size, size));
return mat;
}
// ── Mesh-Helfer ───────────────────────────────────────────────────────────
private static Mesh buildMinimapMesh(float w, float h) {
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(
0f, 0f, 0f, w, 0f, 0f, w, h, 0f, 0f, h, 0f));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(
0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createShortBuffer(
(short) 0, (short) 1, (short) 2, (short) 0, (short) 2, (short) 3));
m.setMode(Mesh.Mode.Triangles);
m.updateBound();
return m;
}
private static Mesh buildQuadMesh(float x, float y, float w, float h, float z) {
Mesh m = new Mesh();
m.setBuffer(VertexBuffer.Type.Position, 3, BufferUtils.createFloatBuffer(
x, y, z, x + w, y, z, x + w, y + h, z, x, y + h, z));
m.setBuffer(VertexBuffer.Type.TexCoord, 2, BufferUtils.createFloatBuffer(
0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f));
m.setBuffer(VertexBuffer.Type.Index, 3, BufferUtils.createShortBuffer(
(short) 0, (short) 1, (short) 2, (short) 0, (short) 2, (short) 3));
m.setMode(Mesh.Mode.Triangles);
m.updateBound();
return m;
}
private Geometry makeSolidQuad(String name, float w, float h, ColorRGBA color) {
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
Geometry g = new Geometry(name, new Quad(w, h));
g.setMaterial(mat);
g.setQueueBucket(RenderQueue.Bucket.Gui);
return g;
}
}