diff --git a/assets/imported/models/bjoernson.fbx b/assets/imported/models/bjoernson.fbx new file mode 100644 index 0000000..aacf14f Binary files /dev/null and b/assets/imported/models/bjoernson.fbx differ diff --git a/blight-assets/src/main/resources/Characters/Models/bjoernson.j3o b/blight-assets/src/main/resources/Characters/Models/bjoernson.j3o new file mode 100644 index 0000000..9784e08 Binary files /dev/null and b/blight-assets/src/main/resources/Characters/Models/bjoernson.j3o differ diff --git a/blight-assets/src/main/resources/MatDefs/FogOfWar.j3md b/blight-assets/src/main/resources/MatDefs/FogOfWar.j3md new file mode 100644 index 0000000..a48725a --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/FogOfWar.j3md @@ -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 + } + } +} diff --git a/blight-assets/src/main/resources/MatDefs/MapOverlay.j3md b/blight-assets/src/main/resources/MatDefs/MapOverlay.j3md new file mode 100644 index 0000000..93dd04b --- /dev/null +++ b/blight-assets/src/main/resources/MatDefs/MapOverlay.j3md @@ -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 + } + } +} diff --git a/blight-assets/src/main/resources/Shaders/FogOfWar.frag b/blight-assets/src/main/resources/Shaders/FogOfWar.frag new file mode 100644 index 0000000..f148c0c --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/FogOfWar.frag @@ -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); +} diff --git a/blight-assets/src/main/resources/Shaders/FogOfWar.vert b/blight-assets/src/main/resources/Shaders/FogOfWar.vert new file mode 100644 index 0000000..31182aa --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/FogOfWar.vert @@ -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); +} diff --git a/blight-assets/src/main/resources/Shaders/MapOverlay.frag b/blight-assets/src/main/resources/Shaders/MapOverlay.frag new file mode 100644 index 0000000..fcf4a58 --- /dev/null +++ b/blight-assets/src/main/resources/Shaders/MapOverlay.frag @@ -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); +} diff --git a/blight-assets/src/main/resources/Textures/hud/minimap_world.png b/blight-assets/src/main/resources/Textures/hud/minimap_world.png new file mode 100644 index 0000000..b7677cb Binary files /dev/null and b/blight-assets/src/main/resources/Textures/hud/minimap_world.png differ diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java new file mode 100644 index 0000000..4931e1a --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java @@ -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 areas, + List zones, + List locations, + List waters, + List models, + int[] slotColorsRGB // 12 Werte: [R0,G0,B0, R1,G1,B1, R2,G2,B2, R3,G3,B3], null = Defaults + ) { + public RenderInput(MapData mapData, List areas, List zones, + List locations, List waters, List 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; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 5489185..c202160 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -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); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java new file mode 100644 index 0000000..594d9e5 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -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 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 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 areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List models = PlacedModelIO.load(); + + Platform.runLater(() -> statusLbl.setText("Rendere Karte…")); + + int[] slotColors = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); + BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildOptions()); + + 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 areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List models = PlacedModelIO.load(); + int[] slotColors2 = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors2); + BufferedImage bi = WorldMapRenderer.render(input, RENDER_SIZE, buildOptions()); + 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 areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List models = PlacedModelIO.load(); + int[] slotColors3 = computeSlotColors(mapData); + RenderInput input = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors3); + BufferedImage bi = WorldMapRenderer.render(input, 4096, buildOptions()); + 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; + } +} diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index e824074..be16f44 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -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); diff --git a/blight-game/src/main/java/de/blight/game/state/CompassHudState.java b/blight-game/src/main/java/de/blight/game/state/CompassHudState.java index 625426b..3e1f848 100644 --- a/blight-game/src/main/java/de/blight/game/state/CompassHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/CompassHudState.java @@ -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; @@ -43,8 +44,8 @@ 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 SIZE = 100f; + 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); diff --git a/blight-game/src/main/java/de/blight/game/state/ExploreTracker.java b/blight-game/src/main/java/de/blight/game/state/ExploreTracker.java new file mode 100644 index 0000000..d583588 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/ExploreTracker.java @@ -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); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/GaussianBlurFilter.java b/blight-game/src/main/java/de/blight/game/state/GaussianBlurFilter.java new file mode 100644 index 0000000..b5d6cb7 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/GaussianBlurFilter.java @@ -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); + } +} diff --git a/blight-game/src/main/java/de/blight/game/state/MinimapState.java b/blight-game/src/main/java/de/blight/game/state/MinimapState.java new file mode 100644 index 0000000..1de0344 --- /dev/null +++ b/blight-game/src/main/java/de/blight/game/state/MinimapState.java @@ -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 areas = AreaIO.load(); + List zones = LocationZoneIO.load(); + List locs = LocationIO.load(); + List waters = WaterBodyIO.load(); + List 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; + } +}