diff --git a/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte.j3o.thumb.png new file mode 100644 index 0000000..4ed0f2e Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte2.j3o.thumb.png b/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte2.j3o.thumb.png new file mode 100644 index 0000000..576c34f Binary files /dev/null and b/blight-assets/src/main/resources/.thumbnails/Models/northcoast/alte_fischerhytte2.j3o.thumb.png differ diff --git a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o index 8b9bbec..3ae69b6 100644 Binary files a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o and b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o differ diff --git a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o.meta b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o.meta new file mode 100644 index 0000000..e78a02d --- /dev/null +++ b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte.j3o.meta @@ -0,0 +1,29 @@ +#Fri Sep 04 08:16:33 CEST 2026 +attachedEmitters.count=0 +attachedLights.count=0 +castShadow=true +category= +cullDistance=120.0 +footstepSurface= +interactableOffsetX=0.0 +interactableOffsetY=0.5 +interactableOffsetZ=0.0 +interactableRotY=0.0 +interactableType=NONE +lod1Distance=30.0 +lod1Path= +lod2Distance=80.0 +lod2Path= +modelCategory=BUILDING +name=alte_fischerhytte +pivotOffsetY=0.0 +placementOffsetY=0.0 +randomScaleMax=1.0 +randomScaleMin=1.0 +receiveShadow=true +scaleX=1.0 +scaleY=1.0 +scaleZ=1.0 +solid=true +tags= +uniformScale=true diff --git a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o index 1696311..2366c00 100644 Binary files a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o and b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o differ diff --git a/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o.meta b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o.meta new file mode 100644 index 0000000..a99eda7 --- /dev/null +++ b/blight-assets/src/main/resources/Models/northcoast/alte_fischerhytte2.j3o.meta @@ -0,0 +1,29 @@ +#Fri Sep 04 10:33:43 CEST 2026 +attachedEmitters.count=0 +attachedLights.count=0 +castShadow=true +category= +cullDistance=120.0 +footstepSurface= +interactableOffsetX=0.0 +interactableOffsetY=0.5 +interactableOffsetZ=0.0 +interactableRotY=0.0 +interactableType=NONE +lod1Distance=30.0 +lod1Path= +lod2Distance=80.0 +lod2Path= +modelCategory=BUILDING +name=alte_fischerhytte2 +pivotOffsetY=0.0 +placementOffsetY=0.0 +randomScaleMax=1.0 +randomScaleMin=1.0 +receiveShadow=true +scaleX=1.0 +scaleY=1.0 +scaleZ=1.0 +solid=true +tags= +uniformScale=true diff --git a/blight-assets/src/main/resources/Textures/hud/minimap_world.png b/blight-assets/src/main/resources/Textures/hud/minimap_world.png index 1ce75ad..5cd3837 100644 Binary files a/blight-assets/src/main/resources/Textures/hud/minimap_world.png and b/blight-assets/src/main/resources/Textures/hud/minimap_world.png differ diff --git a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java index 479355f..3e4acb7 100644 --- a/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java +++ b/blight-common/src/main/java/de/blight/common/map/WorldMapRenderer.java @@ -708,6 +708,56 @@ public final class WorldMapRenderer { return s; } + /** + * Liest alle VoxelChunks von Disk und baut daraus das voxelSurface-Array + * (oberste solide Voxel-Y pro VOXEL_SURFACE_RES-Zelle; NaN = kein Voxel). + * Aktualisiert dabei mapData.upperTop auf dem UPPER_VERTS-Raster als Nebeneffekt. + */ + public static float[] bakeVoxelSurface(MapData mapData) { + int VS = VOXEL_SURFACE_RES; + float[] voxelSurface = new float[VS * VS]; + java.util.Arrays.fill(voxelSurface, Float.NaN); + List chunks; + try { + chunks = VoxelChunkIO.loadAll(); + } catch (Exception e) { + log.warn("[WorldMap] VoxelChunks nicht ladbar: {}", e.getMessage()); + return voxelSurface; + } + if (chunks.isEmpty()) { return voxelSurface; } + int UV = MapData.UPPER_VERTS; + for (VoxelChunk chunk : chunks) { + if (chunk.isEmpty()) { continue; } + for (int lz = 0; lz < VoxelChunk.SIZE; lz++) { + float worldZ = VoxelChunk.toWorldZ(chunk.cz, lz); + int vsPz = Math.round((worldZ + WORLD_HALF) / WORLD_SIZE * (VS - 1)); + if (vsPz < 0 || vsPz >= VS) { continue; } + int uz = Math.round((worldZ + WORLD_HALF) / WORLD_SIZE * (UV - 1)); + for (int lx = 0; lx < VoxelChunk.SIZE; lx++) { + float worldX = VoxelChunk.toWorldX(chunk.cx, lx); + int vsPx = Math.round((worldX + WORLD_HALF) / WORLD_SIZE * (VS - 1)); + if (vsPx < 0 || vsPx >= VS) { continue; } + int ux = Math.round((worldX + WORLD_HALF) / WORLD_SIZE * (UV - 1)); + for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) { + if (chunk.getDensity(lx, ly, lz) > 0) { + float topY = VoxelChunk.toWorldY(chunk.cy, ly); + int vsIdx = vsPz * VS + vsPx; + if (Float.isNaN(voxelSurface[vsIdx]) || topY > voxelSurface[vsIdx]) { + voxelSurface[vsIdx] = topY; + } + if (mapData != null && uz >= 0 && uz < UV && ux >= 0 && ux < UV) { + int uvIdx = uz * UV + ux; + if (topY > mapData.upperTop[uvIdx]) { mapData.upperTop[uvIdx] = topY; } + } + break; + } + } + } + } + } + return voxelSurface; + } + /** * Berechnet eine Hillshading-Karte (float pro Pixel, 0.55–1.35) aus reinen Terrain-Höhen. * Voxels werden NICHT eingerechnet, um Artefakte an Voxel-Kanten zu vermeiden. @@ -1181,6 +1231,184 @@ public final class WorldMapRenderer { * * @return Anzahl neu geschriebener Chunks (0 = alle aus Cache geladen) */ + /** + * Berechnet die visuelle Footprint-Maske für die Höhlenkarte. + * + * Zeigt alle Pixel wo Voxel-Material deutlich über dem Terrain liegt + * (Kliff-Wände + Tunnel-Decke) ODER wo ein offener Tunnel-Eingang ist + * (Boden solid, kein Dach, Void startet nahe am Boden). + * + * Kriterium A: voxelSurface > Terrain + 1.5m → Kliff-Wände + Decken + * Kriterium B∧C: hasSolid ∧ Void∧ voidStartWY ≤ 8 → offene Eingänge + * Ergebnis: A ∨ (B∧C) + */ + public static boolean[] buildCaveFootprintMask(MapData mapData, int size) { + return buildCaveMasks(mapData, size, false); + } + + /** + * Berechnet die Zugänglichkeits-Maske für Tunnel-Eingangs-Erkennung. + * + * Markiert nur zugängliche Bereiche (Hohlräume), NICHT solide Kliff-Wände. + * So findet Two-Farthest-Points die echten Tunnel-Eingänge, nicht den Kliff-Rand. + * + * Kriterium B∧(C∨A): hasSolid ∧ Void ∧ (voidStartWY≤8 ∨ voxelSurface>Terrain+1.5) + * → Eingangs-Korridore (niedrige voidStart) + Tunnel-Inneres (hohe voxelSurface), + * aber NICHT solide Wände (kein Void) und NICHT Terrain-Peaks (voxelSurface≈Terrain). + */ + public static boolean[] buildCaveTunnelMask(MapData mapData, int size) { + return buildCaveMasks(mapData, size, true); + } + + /** + * Sandwich-Erkennung als Seed: Boden + Hohlraum (≥ MIN_VOID_VOXELS) + Decke. + * Von diesen Seeds wächst die Maske per BFS durch alle Spalten mit irgendwelchem + * Voxel-Solid (Kliffwände, Tunnelboden). Abschließend 1px Dilation. + */ + private static boolean[] buildCaveMasks(MapData mapData, int size, boolean tunnelOnly) { + final int MIN_VOID_VOXELS = 3; // Mindest-Hohlraumhöhe für echten Sandwich + + boolean[] seeds = new boolean[size * size]; // Sandwich-Treffer + boolean[] solidPresent = new boolean[size * size]; // irgendwo d>0 in der Spalte + + List allChunks; + try { + allChunks = VoxelChunkIO.loadAll(); + } catch (Exception e) { + log.warn("[CaveMap] VoxelChunks nicht ladbar: {}", e.getMessage()); + return seeds; + } + if (allChunks.isEmpty()) return seeds; + + for (VoxelChunk chunk : allChunks) { + if (chunk.cy != 0) continue; + for (int lz = 0; lz < VoxelChunk.SIZE; lz++) { + float worldZ = VoxelChunk.toWorldZ(chunk.cz, lz); + int pz = Math.round((worldZ + WORLD_HALF) / WORLD_SIZE * (size - 1)); + if (pz < 0 || pz >= size) continue; + for (int lx = 0; lx < VoxelChunk.SIZE; lx++) { + float worldX = VoxelChunk.toWorldX(chunk.cx, lx); + int px = Math.round((worldX + WORLD_HALF) / WORLD_SIZE * (size - 1)); + if (px < 0 || px >= size) continue; + int idx = pz * size + px; + + // Sandwich-Scan + solid-present in einem Durchlauf + int phase = 0, voidCount = 0; + for (int ly = 0; ly < VoxelChunk.SIZE; ly++) { + byte d = chunk.getDensity(lx, ly, lz); + if (d > 0) solidPresent[idx] = true; + if (seeds[idx]) continue; // bereits gefunden, trotzdem solidPresent weiter scannen + if (phase == 0) { + if (d > 0) phase = 1; + } else if (phase == 1) { + if (d < 0) { phase = 2; voidCount = 1; } + } else { + if (d < 0) { + voidCount++; + } else if (d > 0) { + if (voidCount >= MIN_VOID_VOXELS) { seeds[idx] = true; } + phase = 1; voidCount = 0; + } + } + } + } + } + } + + // Bounding-Box der Seeds + Margin → BFS bleibt lokal, springt nicht zu fremden Voxelgebieten + int bbMinX = size, bbMaxX = -1, bbMinZ = size, bbMaxZ = -1; + for (int i = 0; i < size * size; i++) { + if (!seeds[i]) continue; + int spx = i % size, spz = i / size; + if (spx < bbMinX) bbMinX = spx; if (spx > bbMaxX) bbMaxX = spx; + if (spz < bbMinZ) bbMinZ = spz; if (spz > bbMaxZ) bbMaxZ = spz; + } + if (bbMaxX < 0) return seeds; // keine Seeds → leere Maske + final int MARGIN = 20; // 20px * 8m/px = 160m Puffer rund um den Tunnel + bbMinX = Math.max(0, bbMinX - MARGIN); bbMaxX = Math.min(size - 1, bbMaxX + MARGIN); + bbMinZ = Math.max(0, bbMinZ - MARGIN); bbMaxZ = Math.min(size - 1, bbMaxZ + MARGIN); + + // BFS von Sandwich-Seeds durch Spalten mit irgendwelchem Voxel-Solid (begrenzt auf Bounding-Box) + boolean[] mask = seeds.clone(); + boolean[] visited = seeds.clone(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + for (int i = 0; i < size * size; i++) { + if (seeds[i]) queue.add(i); + } + final int[] dx4 = {1, -1, 0, 0}; + final int[] dz4 = {0, 0, 1, -1}; + while (!queue.isEmpty()) { + int cur = queue.poll(); + int cpx = cur % size, cpz = cur / size; + for (int d = 0; d < 4; d++) { + int nx = cpx + dx4[d], nz = cpz + dz4[d]; + if (nx < bbMinX || nx > bbMaxX || nz < bbMinZ || nz > bbMaxZ) continue; + int nIdx = nz * size + nx; + if (visited[nIdx] || !solidPresent[nIdx]) continue; + visited[nIdx] = true; + mask[nIdx] = true; + queue.add(nIdx); + } + } + + // 1px Dilation: Randbereich direkt an den Kliffwänden mit einschließen + boolean[] dilated = new boolean[size * size]; + for (int pz = 0; pz < size; pz++) { + for (int px = 0; px < size; px++) { + if (!mask[pz * size + px]) continue; + for (int d = 0; d < 4; d++) { + int nx = px + dx4[d], nz = pz + dz4[d]; + if (nx < 0 || nx >= size || nz < 0 || nz >= size) continue; + dilated[nz * size + nx] = true; + } + dilated[pz * size + px] = true; + } + } + + // Kleine isolierte Reste entfernen (≥40px behalten) + final int MIN_FOOTPRINT_PX = 40; + boolean[] vis2 = new boolean[size * size]; + boolean[] cleaned = new boolean[size * size]; + for (int start = 0; start < size * size; start++) { + if (!dilated[start] || vis2[start]) continue; + java.util.ArrayDeque q2 = new java.util.ArrayDeque<>(); + q2.add(start); vis2[start] = true; + java.util.List comp = new java.util.ArrayList<>(); + while (!q2.isEmpty()) { + int cur = q2.poll(); comp.add(cur); + int cpx = cur % size, cpz = cur / size; + for (int d = 0; d < 4; d++) { + int nx = cpx + dx4[d], nz = cpz + dz4[d]; + if (nx < 0 || nx >= size || nz < 0 || nz >= size) continue; + int nIdx = nz * size + nx; + if (dilated[nIdx] && !vis2[nIdx]) { vis2[nIdx] = true; q2.add(nIdx); } + } + } + if (comp.size() >= MIN_FOOTPRINT_PX) { + for (int idx : comp) cleaned[idx] = true; + } + } + + return cleaned; + } + + /** + * Rendert eine schematische Höhlen/Tunnel-Karte aus den VoxelChunks. + * Tunnelbereiche werden als Erdfarbe dargestellt, alles andere fast schwarz. + */ + public static BufferedImage buildCaveMap(MapData mapData, int targetSize) { + boolean[] mask = buildCaveFootprintMask(mapData, targetSize); + int bgPacked = 0x1c1c1c; // fast schwarz (Gestein/Außen) + int floorPacked = 0xb68a50; // Dirt-Farbe für Tunnelboden + BufferedImage img = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_INT_RGB); + int[] pixels = ((java.awt.image.DataBufferInt) img.getRaster().getDataBuffer()).getData(); + java.util.Arrays.fill(pixels, bgPacked); + for (int i = 0; i < mask.length; i++) { + if (mask[i]) pixels[i] = floorPacked; + } + return img; + } + public static int buildAndSaveHiresChunks(MapData m, int[] slotColors, int[][] tilePixels, float[] voxelSurface, List waters, List waterfalls, 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 949e334..9a8ac03 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -178,6 +178,7 @@ public class EditorApp extends Application { private CheckBox objSolidCB; // Solid-Flag des selektierten Objekts private CheckBox objCastShadowCB; // Schatten werfen private CheckBox objReceiveShadowCB; // Schatten empfangen + private ComboBox objCatCombo; // Objekt-Kategorie private CheckBox multiPlaceCB; // Mehrfach-Platzierungs-Modus private TextField objXField, objYField, objZField; private TextField objRotXField, objRotYField, objRotZField; @@ -3561,6 +3562,7 @@ public class EditorApp extends Application { objSolidCB = null; objCastShadowCB = null; objReceiveShadowCB = null; + objCatCombo = null; objNormalMapLabel = null; objMatLabel = null; @@ -3601,6 +3603,9 @@ public class EditorApp extends Application { String animClip = parts.length >= 14 ? parts[13] : ""; boolean castShadow = parts.length >= 15 ? Boolean.parseBoolean(parts[14]) : true; boolean receiveShadow= parts.length >= 16 ? Boolean.parseBoolean(parts[15]) : true; + de.blight.common.ModelCategory modelCat = parts.length >= 19 + ? de.blight.common.ModelCategory.fromString(parts[18]) + : de.blight.common.ModelCategory.NONE; // Modell-Pfad String modelName = modelPath.contains("/") @@ -3608,6 +3613,21 @@ public class EditorApp extends Application { Label modelLbl = new Label("Modell: " + modelName); modelLbl.setStyle("-fx-text-fill: #555; -fx-font-size: 11;"); modelLbl.setWrapText(true); + objCatCombo = new ComboBox<>(); + objCatCombo.getItems().addAll(de.blight.common.ModelCategory.values()); + objCatCombo.setValue(modelCat); + objCatCombo.setConverter(new javafx.util.StringConverter<>() { + @Override public String toString(de.blight.common.ModelCategory c) { + return c == null ? "" : c.getLabel(); + } + @Override public de.blight.common.ModelCategory fromString(String s) { + return de.blight.common.ModelCategory.fromString(s); + } + }); + objCatCombo.setMaxWidth(Double.MAX_VALUE); + objCatCombo.setOnAction(ev -> enqueuePropertyChange(null, null, null)); + HBox catRow = new HBox(6, new javafx.scene.control.Label("Kategorie:"), objCatCombo); + catRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); // Textur objTexLabel = new Label(texPath.isEmpty() ? "(keine)" @@ -3735,7 +3755,7 @@ public class EditorApp extends Application { scaleRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT); objDynamicContent.getChildren().addAll( - modelLbl, + modelLbl, catRow, bold("Textur:"), texRow, bold("Normal Map:"), normRow, bold("Material:"), matRow, new Separator(), @@ -3810,10 +3830,11 @@ public class EditorApp extends Application { boolean solid = objSolidCB != null && objSolidCB.isSelected(); boolean castShadow = objCastShadowCB == null || objCastShadowCB.isSelected(); boolean receiveShadow = objReceiveShadowCB == null || objReceiveShadowCB.isSelected(); + de.blight.common.ModelCategory cat = objCatCombo != null ? objCatCombo.getValue() : null; input.objectPropertyQueue.offer( new SharedInput.ObjectPropertyChange(x, y, z, rotX, rotY, rotZ, scale, solid, castShadow, receiveShadow, - texOverride, normalOverride, matOverride)); + texOverride, normalOverride, matOverride, cat)); } catch (NumberFormatException ignored) {} } @@ -5682,6 +5703,14 @@ public class EditorApp extends Application { if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender(); else if (isSelected) worldMapView.ensureFitToView(); }); + + de.blight.editor.ui.CaveMapView caveMapView = new de.blight.editor.ui.CaveMapView(() -> primaryStage); + Tab caveMapTab = new Tab("Höhlenkarte", caveMapView); + caveMapTab.setClosable(false); + caveMapTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> { + if (isSelected && !caveMapView.isLoaded()) caveMapView.loadAndRender(); + else if (isSelected) caveMapView.ensureFitToView(); + }); worldMapView.setFullscreenCallbacks( () -> { // Placeholder ersetzt worldMapView im Tab → sauberes Detach @@ -5732,7 +5761,7 @@ public class EditorApp extends Application { tt.play(); }); - TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab); + TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab, caveMapTab); tabPane.setStyle("-fx-background-color: #e8e8e8;"); VBox.setVgrow(tabPane, Priority.ALWAYS); @@ -8759,6 +8788,35 @@ public class EditorApp extends Application { // Asset-Tree aktualisieren input.refreshAssets = true; + // Bereits platzierte Instanzen dieses Modells synchronisieren (solid + modelCategory) + try { + java.util.List placed = de.blight.common.PlacedModelIO.load(); + boolean anyChanged = false; + java.util.List updatedPlaced = new java.util.ArrayList<>(placed.size()); + for (de.blight.common.PlacedModel pm : placed) { + if (relPath.equals(pm.modelPath()) || relPath.equals(pm.meshFile())) { + updatedPlaced.add(new de.blight.common.PlacedModel( + pm.modelPath(), pm.x(), pm.y(), pm.z(), + pm.rotY(), pm.rotX(), pm.rotZ(), pm.scale(), + solid, + pm.texturePath(), pm.normalMapPath(), pm.materialPath(), + pm.meshFile(), pm.animClip(), pm.castShadow(), pm.receiveShadow(), + pm.lod1Path(), pm.lod2Path(), pm.lod1Distance(), pm.lod2Distance(), + pm.cullDistance(), pm.interactableType(), pm.interactableId(), + modelCategory != null ? modelCategory : de.blight.common.ModelCategory.NONE)); + anyChanged = true; + } else { + updatedPlaced.add(pm); + } + } + if (anyChanged) { + de.blight.common.PlacedModelIO.save(updatedPlaced); + setStatus(statusLabel.getText() + " (Instanzen aktualisiert)"); + } + } catch (Exception ex) { + log.warn("[ModelMeta] Instanz-Update fehlgeschlagen: {}", ex.getMessage()); + } + java.nio.file.Path finalJ3o = category.isEmpty() ? absolutePath : ASSET_ROOT.resolve("Models") .resolve(java.nio.file.Path.of(category.replace('/', java.io.File.separatorChar))) @@ -9111,6 +9169,17 @@ public class EditorApp extends Application { return; } + if (input.activeLayer == SharedInput.LAYER_PATH_NETWORK) { + if (e.getButton() == MouseButton.PRIMARY && !bothDown) { + input.pathNetworkClickQueue.offer( + new SharedInput.PathNetworkClick((float)e.getX(), (float)e.getY(), false)); + } else if (e.getButton() == MouseButton.SECONDARY && !bothDown) { + input.pathNetworkClickQueue.offer( + new SharedInput.PathNetworkClick((float)e.getX(), (float)e.getY(), true)); + } + return; + } + if (input.activeLayer == SharedInput.LAYER_STONE && input.stoneTool.brushRadius.getValue() == 1.0) { // Single-Stone-Modus: nur Rechtsklick, einmalig ohne Timer @@ -9835,6 +9904,8 @@ public class EditorApp extends Application { else if ("area".equals(input.zonenSelectedKind)) { input.deleteAreaRequested = true; scheduleAutoSave(); } else if ("location".equals(input.zonenSelectedKind)) { input.deleteLocationZoneRequested = true; scheduleAutoSave(); } } + else if (input.activeLayer == SharedInput.LAYER_PATH_NETWORK) + input.deletePathNodeRequested = true; else if (input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF) input.deleteVoxelCliffZoneRequested = true; else if (input.activeLayer == SharedInput.LAYER_WATER) diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index e799785..894937f 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -333,9 +333,10 @@ public class SharedInput { boolean solid, boolean castShadow, boolean receiveShadow, - String texPath, // null = nicht ändern - String normalMapPath, // null = nicht ändern - String matPath // null = nicht ändern + String texPath, // null = nicht ändern + String normalMapPath, // null = nicht ändern + String matPath, // null = nicht ändern + de.blight.common.ModelCategory modelCategory // null = nicht ändern ) {} public final ConcurrentLinkedQueue objectPropertyQueue = new ConcurrentLinkedQueue<>(); @@ -1046,6 +1047,9 @@ public class SharedInput { /** JME → JFX: Wegnetz wurde verändert, UI-Refresh nötig. */ public volatile boolean pathNetworkChanged = false; + /** JFX → JME: Entf-Taste im Wegnetz-Layer → markierten Knoten löschen. */ + public volatile boolean deletePathNodeRequested = false; + // ── Bett-Liegefläche ───────────────────────────────────────────────────── /** activeLayer==27 → Liegefläche eines Bettes platzieren (Terrain-Klick → Mittelpunkt). */ public static final int LAYER_BED_LIEGE = 27; diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java index 3a811e9..25bb40b 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelEditorState.java @@ -1330,6 +1330,14 @@ public class ModelEditorState extends BaseAppState { ModelExportUtil.stripControls(root); ModelExportUtil.bakeTransform(root, new Matrix4f()); log.info("[ModelEditor] Statisch: Scale ({},{},{}) in Vertices gebacken", sx, sy, sz); + // Scale ist in Vertices eingebacken → j3o hat jetzt localScale (1,1,1). + // Spinner + SharedInput auf 1.0 zurücksetzen, damit beim nächsten Laden + // applyScale(1,1,1) aufgerufen wird und kein doppelter Scale entsteht. + input.modelEditorScaleX = 1.0f; + input.modelEditorScaleY = 1.0f; + input.modelEditorScaleZ = 1.0f; + input.modelEditorBakedScaleDetected = true; + input.modelEditorOpenPath = currentPath; // Modell neu laden (zeigt gebackene Version) } BinaryExporter.getInstance().save(root, j3oPath.toFile()); syncToMirrors(j3oPath); diff --git a/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java index bc5a85c..eb5bd28 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/PathNetworkEditorState.java @@ -6,7 +6,12 @@ import com.jme3.app.state.BaseAppState; import com.jme3.asset.AssetManager; import com.jme3.collision.CollisionResults; import com.jme3.material.Material; -import com.jme3.math.*; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Matrix3f; +import com.jme3.math.Quaternion; +import com.jme3.math.Ray; +import com.jme3.math.Vector2f; +import com.jme3.math.Vector3f; import com.jme3.renderer.Camera; import com.jme3.scene.*; import com.jme3.scene.shape.Cylinder; @@ -118,6 +123,10 @@ public class PathNetworkEditorState extends BaseAppState { while ((click = input.pathNetworkClickQueue.poll()) != null) { handleClick(click); } + if (input.deletePathNodeRequested) { + input.deletePathNodeRequested = false; + deleteSelected(); + } } private void handleClick(SharedInput.PathNetworkClick click) { @@ -202,48 +211,33 @@ public class PathNetworkEditorState extends BaseAppState { } private Geometry buildEdgeGeometry(PathNode a, PathNode b) { - Vector3f va = toVec(a.getPosition()); - Vector3f vb = toVec(b.getPosition()); - float length = va.distance(vb); + float ax = a.getPosition().x, ay = a.getPosition().y, az = a.getPosition().z; + float bx = b.getPosition().x, by = b.getPosition().y, bz = b.getPosition().z; + float dx = bx - ax, dz = bz - az; + float length = (float) Math.sqrt(dx * dx + dz * dz); if (length < 0.001f) return null; - Vector3f mid = va.add(vb).multLocal(0.5f); - Vector3f dir = vb.subtract(va).divideLocal(length); // normalisiert + float nx = dx / length, nz = dz / length; + Vector3f mid = new Vector3f((ax + bx) * 0.5f, (ay + by) * 0.5f + EDGE_RADIUS, (az + bz) * 0.5f); Cylinder cyl = new Cylinder(4, 8, EDGE_RADIUS, length, true); Geometry g = new Geometry("edge_" + a.getUuid() + "_" + b.getUuid(), cyl); g.setMaterial(matEdge); g.setLocalTranslation(mid); - // Der JME-Zylinder liegt standardmäßig entlang seiner lokalen Y-Achse. - // Wir brauchen eine Rotation, die UNIT_Y auf `dir` dreht. - // Achse = UNIT_Y × dir, Winkel = arccos(UNIT_Y · dir) - g.setLocalRotation(rotationYToDir(dir)); + // Zylinder liegt entlang lokaler Y-Achse. + // Rotationsmatrix: Spalten = wohin lokale Achsen X, Y, Z in Weltkoordinaten zeigen. + // Lokale Y → Richtung des Weges (nx, 0, nz) im XZ-Boden. + // Lokale Z → Welt-Y (oben), damit Zylinder nicht rollt. + // Lokale X = lokale Y × lokale Z = (nz, 0, -nx). + Matrix3f rot = new Matrix3f(); + rot.setColumn(0, new Vector3f( nz, 0f, -nx)); // lokales X + rot.setColumn(1, new Vector3f( nx, 0f, nz)); // lokales Y → dir + rot.setColumn(2, new Vector3f( 0f, 1f, 0f)); // lokales Z → Welt-oben + g.setLocalRotation(new Quaternion().fromRotationMatrix(rot)); return g; } - /** - * Berechnet die Quaternion, die UNIT_Y (0,1,0) auf {@code dir} dreht. - * {@code dir} muss bereits normalisiert sein. - */ - private static Quaternion rotationYToDir(Vector3f dir) { - // Kreuzprodukt liefert die Rotationsachse; Skalarprodukt den cos(Winkel) - Vector3f axis = new Vector3f(-dir.z, 0f, dir.x); // UNIT_Y × dir = (-dz, 0, dx) - float sinA = axis.length(); // |UNIT_Y × dir| = sin(angle) - float cosA = dir.y; // UNIT_Y · dir = cos(angle) - - if (sinA < 0.0001f) { - // dir ist (anti-)parallel zu UNIT_Y - return cosA >= 0f - ? new Quaternion(0f, 0f, 0f, 1f) // identisch - : new Quaternion().fromAngleNormalAxis(FastMath.PI, Vector3f.UNIT_X); // 180° flip - } - - axis.divideLocal(sinA); // normalisieren - float angle = (float) Math.atan2(sinA, cosA); - return new Quaternion().fromAngleNormalAxis(angle, axis); - } - private void updateNodeMaterial(PathNode node) { Geometry g = (Geometry) nodeRoot.getChild("node_" + node.getUuid()); if (g != null) g.setMaterial(node == selectedNode ? matSelected : matDefault); @@ -305,10 +299,16 @@ public class PathNetworkEditorState extends BaseAppState { /** Liefert eine Kopie des aktuellen Netzes (für UI-Anzeige). */ public PathNetwork getNetwork() { return network; } - /** Löscht den gewählten Knoten (aufrufbar aus JavaFX via enqueue). */ + /** Löscht den gewählten Knoten. Nachbarn werden vollständig untereinander verbunden. */ public void deleteSelected() { if (selectedNode == null) return; + List neighbors = network.neighbors(selectedNode.getUuid()); network.removeNode(selectedNode.getUuid()); + for (int i = 0; i < neighbors.size(); i++) { + for (int j = i + 1; j < neighbors.size(); j++) { + network.addEdge(neighbors.get(i).getUuid(), neighbors.get(j).getUuid()); + } + } selectedNode = null; saveAndNotify(); rebuildVisuals(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index 98efa3b..cae8962 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -1309,7 +1309,8 @@ public class SceneObjectState extends BaseAppState { + "|" + so.getNormalMapPath() + "|" + so.getMaterialPath() + "|" + animClips.get(idx) + "|" + so.castShadow + "|" + so.receiveShadow - + "|" + so.interactableType + "|" + so.interactableId; + + "|" + so.interactableType + "|" + so.interactableId + + "|" + (so.modelCategory != null ? so.modelCategory.name() : "NONE"); } else { input.selectedObjectInfo = String.valueOf(n); } @@ -1824,6 +1825,7 @@ public class SceneObjectState extends BaseAppState { if (prop.matPath() != null) { so.setMaterialPath(prop.matPath()); appearanceChanged = true; } if (appearanceChanged) applyAppearanceToNode(node, so.getMaterialPath(), so.getTexturePath(), so.getNormalMapPath()); + if (prop.modelCategory() != null) { so.modelCategory = prop.modelCategory(); } } /** Wendet Material, Diffuse-Textur und Normal Map in einem Schritt an. */ diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java new file mode 100644 index 0000000..c318e98 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java @@ -0,0 +1,285 @@ +package de.blight.editor.ui; + +import de.blight.common.*; +import de.blight.common.map.WorldMapRenderer; +import de.blight.common.map.WorldMapRenderModel; + +import javafx.application.Platform; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.*; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.shape.ArcType; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; +import javafx.stage.Stage; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 2D-Kartenansicht des Höhlen/Tunnel-Innenbereichs. + * Hintergrund: dunkles Grau. Erdfarbe für Lochbereiche. + * Overlays: Tunneleingänge, Wege, Gebäude, Areas. + */ +public class CaveMapView extends VBox { + + private static final Logger log = LoggerFactory.getLogger(CaveMapView.class); + private static final int RENDER_SIZE = 2048; + + private final Canvas canvas = new Canvas(); + private final Label statusLbl = new Label("Höhlenkarte noch nicht geladen."); + private final ProgressBar progress = new ProgressBar(); + private final AtomicBoolean loading = new AtomicBoolean(false); + + private WritableImage mapFxImage = null; + private List cachedPathChains = new ArrayList<>(); + private List tunnelEntrances = new ArrayList<>(); + private List cachedAreas = new ArrayList<>(); + private List buildingFootprints = new ArrayList<>(); + + private static final int ZOOM_LEVELS = 5; + private static final double ZOOM_MAX_SCALE = 4.0; + + private double panX = 0, panY = 0, scale = 1.0; + private double[] zoomLevels = null; + private int zoomLevelIdx = 0; + private boolean autoLoaded = false; + + public CaveMapView(Supplier stageSupplier) { + ToolBar bar = new ToolBar(makeBtn("Aktualisieren", e -> loadAndRender())); + + progress.setMaxWidth(Double.MAX_VALUE); + progress.setVisible(false); + + StackPane canvasPane = new StackPane(canvas); + canvasPane.setStyle("-fx-background-color: #141414;"); + 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()); + + setupMouse(); + + getChildren().addAll(bar, progress, statusLbl, canvasPane); + VBox.setVgrow(canvasPane, Priority.ALWAYS); + setStyle("-fx-background-color: #1a1a1a;"); + } + + private static Button makeBtn(String label, javafx.event.EventHandler h) { + Button b = new Button(label); b.setOnAction(h); return b; + } + + private void setupMouse() { + canvas.setOnScroll(e -> { + if (zoomLevels == null || e.getDeltaY() == 0) return; + int newIdx = Math.max(0, Math.min(zoomLevels.length - 1, + zoomLevelIdx + (e.getDeltaY() > 0 ? 1 : -1))); + if (newIdx == zoomLevelIdx) return; + double old = scale; + zoomLevelIdx = newIdx; + scale = zoomLevels[zoomLevelIdx]; + panX = e.getX() - (e.getX() - panX) * scale / old; + panY = e.getY() - (e.getY() - panY) * scale / old; + redraw(); + }); + final double[] ds = new double[4]; + canvas.setOnMousePressed(e -> { ds[0]=e.getX(); ds[1]=e.getY(); ds[2]=panX; ds[3]=panY; }); + canvas.setOnMouseDragged(e -> { + panX = ds[2] + (e.getX()-ds[0]); + panY = ds[3] + (e.getY()-ds[1]); + redraw(); + }); + } + + public boolean isLoaded() { return mapFxImage != null; } + + public void ensureFitToView() { if (mapFxImage != null) fitToView(); } + + public void loadAndRender() { + if (!loading.compareAndSet(false, true)) return; + progress.setVisible(true); + progress.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + statusLbl.setText("Lade Höhlendaten…"); + + Thread t = new Thread(() -> { + try { + MapData mapData = MapIO.load(); + List areas = AreaIO.load(); + List models = PlacedModelIO.load(); + + BufferedImage bi = WorldMapRenderer.buildCaveMap(mapData, RENDER_SIZE); + List paths = WorldMapView.loadPathChains(); + List entrances = WorldMapView.computeTunnelEntrances(mapData); + List footprints = WorldMapView.computeBuildingFootprintsSimple(models); + + Platform.runLater(() -> { + mapFxImage = SwingFXUtils.toFXImage(bi, null); + cachedPathChains = paths; + tunnelEntrances = entrances; + cachedAreas = new ArrayList<>(areas); + buildingFootprints = footprints; + fitToView(); + redraw(); + progress.setVisible(false); + statusLbl.setText("Höhlenkarte bereit."); + loading.set(false); + }); + } catch (Exception ex) { + log.error("[CaveMap] Fehler: {}", ex.getMessage(), ex); + Platform.runLater(() -> { + statusLbl.setText("Fehler: " + ex.getMessage()); + progress.setVisible(false); + loading.set(false); + }); + } + }, "CaveMapRenderer"); + t.setDaemon(true); + t.start(); + } + + private void fitToView() { + double cw = canvas.getWidth(), ch = canvas.getHeight(); + if (cw <= 0 || ch <= 0 || mapFxImage == null) return; + double fit = Math.min(cw / mapFxImage.getWidth(), ch / mapFxImage.getHeight()); + zoomLevels = new double[ZOOM_LEVELS]; + zoomLevels[0] = fit; + double ratio = Math.pow(ZOOM_MAX_SCALE / fit, 1.0 / (ZOOM_LEVELS - 1)); + for (int i = 1; i < ZOOM_LEVELS; i++) zoomLevels[i] = zoomLevels[i-1] * ratio; + zoomLevelIdx = 0; + scale = fit; + panX = (cw - mapFxImage.getWidth() * scale) / 2.0; + panY = (ch - mapFxImage.getHeight() * scale) / 2.0; + } + + private void redraw() { + if (!autoLoaded && mapFxImage == null && !loading.get()) { + autoLoaded = true; + loadAndRender(); + } + GraphicsContext gc = canvas.getGraphicsContext2D(); + double w = canvas.getWidth(), h = canvas.getHeight(); + gc.setFill(Color.rgb(14, 14, 14)); + gc.fillRect(0, 0, w, h); + if (mapFxImage == null) { + gc.setFill(Color.GRAY); + gc.fillText("Höhlenkarte wird geladen…", 20, 40); + return; + } + gc.setImageSmoothing(true); + gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); + drawTunnelEntrancesOverlay(gc); + drawBuildingOverlay(gc); + drawPathsOverlay(gc); + drawAreasOverlay(gc); + } + + private double toCanvasX(float wx) { + return panX + (wx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + } + private double toCanvasZ(float wz) { + return panY + (wz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + } + + private void drawTunnelEntrancesOverlay(GraphicsContext gc) { + if (tunnelEntrances.isEmpty()) return; + gc.save(); + gc.setFill(Color.BLACK); + for (float[] te : tunnelEntrances) { + double r = Math.max(4.0, Math.min( + te[4] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale, 20.0)); + double angle = Math.toDegrees(Math.atan2(te[3], te[2])); + gc.save(); + gc.translate(toCanvasX(te[0]), toCanvasZ(te[1])); + gc.rotate(angle); + gc.fillArc(-r, -r, r*2, r*2, -90, 180, ArcType.CHORD); + gc.restore(); + } + gc.restore(); + } + + private void drawBuildingOverlay(GraphicsContext gc) { + if (buildingFootprints.isEmpty()) return; + gc.save(); + gc.setFill(Color.rgb(170, 140, 90, 0.55)); + gc.setStroke(Color.rgb(80, 60, 30, 0.8)); + gc.setLineWidth(1.0); + for (float[] fp : buildingFootprints) { + double hw = fp[2] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + double hd = fp[3] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + gc.save(); + gc.translate(toCanvasX(fp[0]), toCanvasZ(fp[1])); + gc.rotate(Math.toDegrees(fp[4])); + gc.fillRect(-hw, -hd, hw*2, hd*2); + gc.strokeRect(-hw, -hd, hw*2, hd*2); + gc.restore(); + } + gc.restore(); + } + + private void drawPathsOverlay(GraphicsContext gc) { + if (cachedPathChains.isEmpty()) return; + gc.save(); + gc.setLineCap(StrokeLineCap.ROUND); + gc.setLineJoin(StrokeLineJoin.ROUND); + gc.setLineDashes(); + gc.setStroke(Color.rgb(20, 12, 5, 0.82)); + gc.setLineWidth(5.0); + for (float[][] ch : cachedPathChains) drawChain(gc, ch); + gc.setStroke(Color.rgb(148, 105, 55, 0.92)); + gc.setLineWidth(3.0); + for (float[][] ch : cachedPathChains) drawChain(gc, ch); + gc.restore(); + } + + private void drawChain(GraphicsContext gc, float[][] chain) { + if (chain.length < 2) return; + final float R = 8f; + gc.beginPath(); + gc.moveTo(toCanvasX(chain[0][0]), toCanvasZ(chain[0][1])); + for (int i = 1; i < chain.length - 1; i++) { + float px=chain[i-1][0], pz=chain[i-1][1]; + float cx=chain[i][0], cz=chain[i][1]; + float nx=chain[i+1][0], nz=chain[i+1][1]; + float d1x=cx-px, d1z=cz-pz, d2x=nx-cx, d2z=nz-cz; + float l1=(float)Math.sqrt(d1x*d1x+d1z*d1z), l2=(float)Math.sqrt(d2x*d2x+d2z*d2z); + if (l1<0.01f||l2<0.01f) { gc.lineTo(toCanvasX(cx), toCanvasZ(cz)); continue; } + float r=Math.min(R, Math.min(l1*0.4f, l2*0.4f)); + gc.lineTo(toCanvasX(cx-(d1x/l1)*r), toCanvasZ(cz-(d1z/l1)*r)); + gc.quadraticCurveTo(toCanvasX(cx), toCanvasZ(cz), + toCanvasX(cx+(d2x/l2)*r), toCanvasZ(cz+(d2z/l2)*r)); + } + gc.lineTo(toCanvasX(chain[chain.length-1][0]), toCanvasZ(chain[chain.length-1][1])); + gc.stroke(); + } + + private void drawAreasOverlay(GraphicsContext gc) { + if (cachedAreas.isEmpty()) return; + gc.save(); + gc.setLineDashes(8.0, 4.0); + gc.setLineWidth(1.5); + gc.setStroke(Color.rgb(200, 180, 100, 0.8)); + gc.setFill(Color.rgb(200, 180, 100, 0.15)); + for (PlacedArea area : cachedAreas) { + float[] xs = area.pointsX(), zs = area.pointsZ(); + if (xs == null || xs.length < 2) continue; + gc.beginPath(); + gc.moveTo(toCanvasX(xs[0]), toCanvasZ(zs[0])); + for (int i = 1; i < xs.length; i++) gc.lineTo(toCanvasX(xs[i]), toCanvasZ(zs[i])); + gc.closePath(); + gc.fill(); + gc.stroke(); + } + gc.restore(); + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java index 4f4fa6f..525a42d 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -23,6 +23,7 @@ import javafx.scene.image.WritableImage; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; import javafx.scene.paint.Color; +import javafx.scene.shape.ArcType; import javafx.scene.shape.StrokeLineCap; import javafx.scene.shape.StrokeLineJoin; import javafx.scene.text.Font; @@ -84,6 +85,9 @@ public class WorldMapView extends VBox { private final CheckBox cbForest = layerCheck("Wald"); private final CheckBox cbCliffs = layerCheck("Klippen"); private final CheckBox cbAreas = layerCheck("Areas"); + private final CheckBox cbBuildings = layerCheck("Gebäude"); + private final CheckBox cbPaths = layerCheck("Wege"); + private final CheckBox cbTunnels = layerCheck("Tunnel"); private final ToggleButton labelBtn = new ToggleButton("Label"); private final Button fullscreenBtn = new Button(">>"); @@ -119,8 +123,16 @@ public class WorldMapView extends VBox { java.util.Collections.synchronizedSet(new java.util.HashSet<>()); private volatile boolean chunkRedrawPending = false; - private WritableImage bakedForestOverlay = null; - private List forestPaths = new ArrayList<>(); + private WritableImage bakedForestOverlay = null; + private List forestPaths = new ArrayList<>(); + + private List buildingFootprints = new ArrayList<>(); + private List cachedPathChains = new ArrayList<>(); + /** Tunneleingang-Daten: [worldX, worldZ, dirX, dirZ, radiusWorld] */ + private List tunnelEntrances = new ArrayList<>(); + private static final java.util.concurrent.ConcurrentHashMap MODEL_BOUNDS_CACHE + = new java.util.concurrent.ConcurrentHashMap<>(); + private static final float[] DEFAULT_EXTENTS = {5f, 5f}; private List cachedAreas = new ArrayList<>(); private List cachedLocs = new ArrayList<>(); @@ -196,9 +208,12 @@ public class WorldMapView extends VBox { cbForest.selectedProperty().addListener((obs, o, n) -> redraw()); cbCliffs.selectedProperty().addListener((obs, o, n) -> redraw()); cbAreas.selectedProperty().addListener((obs, o, n) -> redraw()); + cbBuildings.selectedProperty().addListener((obs, o, n) -> redraw()); + cbPaths.selectedProperty().addListener((obs, o, n) -> redraw()); + cbTunnels.selectedProperty().addListener((obs, o, n) -> redraw()); MenuButton layerMenu = new MenuButton("Layer ▾"); - for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbForest, cbCliffs, cbAreas}) { + for (CheckBox cb : new CheckBox[]{cbTerrain, cbWater, cbForest, cbCliffs, cbAreas, cbBuildings, cbPaths, cbTunnels}) { CustomMenuItem item = new CustomMenuItem(cb, false); item.setHideOnClick(false); layerMenu.getItems().add(item); @@ -363,6 +378,10 @@ public class WorldMapView extends VBox { List newCliffPaths = buildCliffPaths(model.mapData(), model.voxelSurface()); List newForestPaths = buildForestOutlinePaths(fDensity, 512, 3.0f); + List newBuildingFootprints = computeBuildingFootprints(model, findAssetRoot()); + List newPathChains = loadPathChains(); + List newTunnelEntrances = computeTunnelEntrances(model.mapData()); + // Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend) final WorldMapRenderModel chunkModel = model; Platform.runLater(() -> statusLbl.setText("Generiere Hires-Chunks…")); @@ -380,8 +399,11 @@ public class WorldMapView extends VBox { mapFxImage = SwingFXUtils.toFXImage(bi, null); bakedForestOverlay = toWritableImage(bakedFstPx, RENDER_SIZE); bakedCliffOverlay = toWritableImage(cliffPx, WorldMapRenderer.COLOR_MAP_SIZE); - cliffPaths = newCliffPaths; - forestPaths = newForestPaths; + cliffPaths = newCliffPaths; + forestPaths = newForestPaths; + buildingFootprints = newBuildingFootprints; + cachedPathChains = newPathChains; + tunnelEntrances = newTunnelEntrances; fitToView(); redraw(); progress.setVisible(false); @@ -458,11 +480,17 @@ public class WorldMapView extends VBox { } // Layer 2: Klippen (Grau-Füllung + Schraffierung + Vektor-Kontur) if (cbCliffs.isSelected()) drawCliffOverlay(gc); + // Layer 2.5: Tunneleingänge (schwarze Halbkreise) + if (cbTunnels.isSelected()) drawTunnelEntrancesOverlay(gc); // Layer 3: Wald (Füllung + Vektor-Kontur) – unter Wasser - if (cbForest.isSelected()) drawForestOverlay(gc); + if (cbForest.isSelected()) drawForestOverlay(gc); + // Layer 3.5: Gebäude (Füllung + Vektor-Kontur) – unter Wasser + if (cbBuildings.isSelected()) drawBuildingOverlay(gc); // Layer 4: Küstenlinie + Wasser (Vektor) – deckt Terrain-Features ab if (cbWater.isSelected()) drawWaterOverlay(gc); - // Layer 5: Areas (Vektoren) + // Layer 5: Wege (Vektor, braune Linie mit schwarzem Rand) + if (cbPaths.isSelected()) drawPathsOverlay(gc); + // Layer 6: Areas (Vektoren) if (cbAreas.isSelected()) drawAreasOverlay(gc); if (!isFullscreen) drawCameraIndicator(gc); } @@ -583,6 +611,79 @@ public class WorldMapView extends VBox { } } + private void drawBuildingOverlay(GraphicsContext gc) { + if (buildingFootprints.isEmpty()) return; + gc.save(); + gc.setFill(Color.rgb(220, 100, 80, 0.50)); + gc.setStroke(Color.rgb(160, 30, 30, 0.90)); + gc.setLineWidth(overlayLineWidth()); + gc.setLineDashes(); + for (float[] fp : buildingFootprints) { + double cx = toCanvasX(fp[0]); + double cz = toCanvasZ(fp[1]); + double hw = fp[2] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; + double hd = fp[3] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; + if (hw < 0.5 || hd < 0.5) continue; + gc.save(); + gc.translate(cx, cz); + gc.rotate(Math.toDegrees(-fp[4])); + gc.fillRect(-hw, -hd, hw * 2, hd * 2); + gc.strokeRect(-hw, -hd, hw * 2, hd * 2); + gc.restore(); + } + gc.restore(); + } + + private double roadLineWidth() { + return switch (zoomLevelIdx) { + case 3 -> 4.5; + case 4 -> 6.0; + default -> 3.0; + }; + } + + private void drawPathsOverlay(GraphicsContext gc) { + if (cachedPathChains.isEmpty()) return; + gc.save(); + gc.setLineCap(StrokeLineCap.ROUND); + gc.setLineJoin(StrokeLineJoin.ROUND); + gc.setLineDashes(); + gc.setStroke(Color.rgb(20, 12, 5, 0.82)); + gc.setLineWidth(roadLineWidth() + 2.0); + for (float[][] chain : cachedPathChains) drawPathChain(gc, chain); + gc.setStroke(Color.rgb(148, 105, 55, 0.92)); + gc.setLineWidth(roadLineWidth()); + for (float[][] chain : cachedPathChains) drawPathChain(gc, chain); + gc.restore(); + } + + private void drawPathChain(GraphicsContext gc, float[][] chain) { + if (chain.length < 2) return; + final float R = 8f; // Abrundungsradius in Weltmetern + gc.beginPath(); + gc.moveTo(toCanvasX(chain[0][0]), toCanvasZ(chain[0][1])); + for (int i = 1; i < chain.length - 1; i++) { + float px = chain[i-1][0], pz = chain[i-1][1]; + float cx = chain[i ][0], cz = chain[i ][1]; + float nx = chain[i+1][0], nz = chain[i+1][1]; + float d1x = cx - px, d1z = cz - pz; + float d2x = nx - cx, d2z = nz - cz; + float len1 = (float) Math.sqrt(d1x*d1x + d1z*d1z); + float len2 = (float) Math.sqrt(d2x*d2x + d2z*d2z); + if (len1 < 0.01f || len2 < 0.01f) { + gc.lineTo(toCanvasX(cx), toCanvasZ(cz)); + continue; + } + float r = Math.min(R, Math.min(len1 * 0.4f, len2 * 0.4f)); + float q1x = cx - (d1x / len1) * r, q1z = cz - (d1z / len1) * r; + float q2x = cx + (d2x / len2) * r, q2z = cz + (d2z / len2) * r; + gc.lineTo(toCanvasX(q1x), toCanvasZ(q1z)); + gc.quadraticCurveTo(toCanvasX(cx), toCanvasZ(cz), toCanvasX(q2x), toCanvasZ(q2z)); + } + gc.lineTo(toCanvasX(chain[chain.length-1][0]), toCanvasZ(chain[chain.length-1][1])); + gc.stroke(); + } + private void drawCliffOverlay(GraphicsContext gc) { double cW = canvas.getWidth(), cH = canvas.getHeight(); if (bakedCliffOverlay != null) { @@ -668,6 +769,299 @@ public class WorldMapView extends VBox { return px; } + // ── Wege-Segmente (läuft im Background-Thread) ─────────────────────────── + + /** Baut zusammenhängende Ketten aus dem Wegnetz. + * Grad-2-Knoten (Kurven) werden zu Ketteninnenpunkten. + * Grad-≠2-Knoten (Enden, Kreuzungen) bilden Kettengrenzen. */ + static List loadPathChains() { + try { + de.blight.common.path.PathNetwork net = de.blight.common.path.PathNetworkIO.load(); + List nodes = net.getNodes(); + if (nodes.isEmpty()) return new ArrayList<>(); + + java.util.Map nodeMap = new java.util.HashMap<>(); + java.util.Map> adj = new java.util.HashMap<>(); + for (de.blight.common.path.PathNode n : nodes) { + nodeMap.put(n.getUuid(), n); + adj.put(n.getUuid(), new ArrayList<>()); + } + for (de.blight.common.path.PathEdge e : net.getEdges()) { + adj.computeIfAbsent(e.getNodeUuidA(), k -> new ArrayList<>()).add(e.getNodeUuidB()); + adj.computeIfAbsent(e.getNodeUuidB(), k -> new ArrayList<>()).add(e.getNodeUuidA()); + } + + // Knoten mit Grad ≠ 2 → Kettengrenzen + java.util.Set junctions = new java.util.HashSet<>(); + for (de.blight.common.path.PathNode n : nodes) { + if (adj.get(n.getUuid()).size() != 2) junctions.add(n.getUuid()); + } + // Reine Kreise (alle Grad 2) → beliebigen Startknoten wählen + if (junctions.isEmpty()) junctions.add(nodes.get(0).getUuid()); + + java.util.Set visitedEdges = new java.util.HashSet<>(); + List chains = new ArrayList<>(); + + for (String jUuid : junctions) { + for (String nb : adj.getOrDefault(jUuid, java.util.Collections.emptyList())) { + String ek = pathEdgeKey(jUuid, nb); + if (visitedEdges.contains(ek)) continue; + + List chain = new ArrayList<>(); + de.blight.common.path.PathNode startNode = nodeMap.get(jUuid); + chain.add(new float[]{startNode.getPosition().x, startNode.getPosition().z}); + + String prev = jUuid, curr = nb; + while (curr != null) { + visitedEdges.add(pathEdgeKey(prev, curr)); + de.blight.common.path.PathNode currNode = nodeMap.get(curr); + if (currNode == null) break; + chain.add(new float[]{currNode.getPosition().x, currNode.getPosition().z}); + if (junctions.contains(curr)) break; + String next = null; + for (String n2 : adj.getOrDefault(curr, java.util.Collections.emptyList())) { + if (!n2.equals(prev)) { next = n2; break; } + } + prev = curr; + curr = next; + } + if (chain.size() >= 2) chains.add(chain.toArray(new float[0][])); + } + } + return chains; + } catch (Exception ignore) { + return new ArrayList<>(); + } + } + + private static String pathEdgeKey(String a, String b) { + return a.compareTo(b) < 0 ? a + "|" + b : b + "|" + a; + } + + // ── Gebäude-Footprints (läuft im Background-Thread) ────────────────────── + + private static volatile com.jme3.asset.AssetManager GEO_ASSET_MANAGER; + + private static synchronized com.jme3.asset.AssetManager getOrCreateAssetManager(Path assetRoot) { + if (GEO_ASSET_MANAGER == null) { + com.jme3.asset.DesktopAssetManager am = new com.jme3.asset.DesktopAssetManager(true); + am.registerLocator(assetRoot.toAbsolutePath().toString(), + com.jme3.asset.plugins.FileLocator.class); + GEO_ASSET_MANAGER = am; + } + return GEO_ASSET_MANAGER; + } + + private static boolean isBuilding(PlacedModel pm) { + return pm.modelCategory() == ModelCategory.BUILDING; + } + + private static float[] loadModelExtents(Path assetRoot, PlacedModel pm) { + String key = !pm.meshFile().isEmpty() ? pm.meshFile() : pm.modelPath(); + if (key.isEmpty() || key.startsWith("@")) return DEFAULT_EXTENTS; + return MODEL_BOUNDS_CACHE.computeIfAbsent(key, k -> { + try { + Path j3oPath = assetRoot.resolve(k.replace('/', java.io.File.separatorChar)); + if (!Files.exists(j3oPath)) return DEFAULT_EXTENTS; + com.jme3.export.binary.BinaryImporter importer = new com.jme3.export.binary.BinaryImporter(); + importer.setAssetManager(getOrCreateAssetManager(assetRoot)); + com.jme3.scene.Spatial sp = (com.jme3.scene.Spatial) importer.load(j3oPath.toFile()); + sp.updateGeometricState(); + com.jme3.bounding.BoundingVolume bv = sp.getWorldBound(); + if (bv instanceof com.jme3.bounding.BoundingBox bb) { + return new float[]{bb.getXExtent(), bb.getZExtent()}; + } else if (bv instanceof com.jme3.bounding.BoundingSphere bs) { + float r = bs.getRadius(); + return new float[]{r, r}; + } + } catch (Exception ignore) {} + return DEFAULT_EXTENTS; + }); + } + + // ── Tunneleingänge ──────────────────────────────────────────────────────── + + /** + * Ermittelt Tunneleingänge aus der Voxel-Höhlenmaske (256×256, 8m/Zelle). + * Liefert pro Eingang: [worldX, worldZ, dirX, dirZ, radiusWorld] + * wobei (dirX, dirZ) die Richtung VON Eingang IN die Höhle zeigt (Kurven-Seite des D). + * + * Pro zusammenhängender Höhlen-Komponente werden bis zu 2 Eingänge erkannt: + * durch "Two-Farthest-Points"-Splitting der Randpunkte. + * Jeder Eingang wird am äußersten Randpunkt positioniert (= direkt an der Klippe). + */ + static List computeTunnelEntrances(MapData mapData) { + final int C = MapData.UPPER_CELLS; // 256 + final int MIN_COMP = 20; // Mindestgröße einer Eingangs-Komponente + final float CELL_SIZE = WorldMapRenderer.WORLD_SIZE / C; // 8.0 + boolean[] holeMask = WorldMapRenderer.buildCaveTunnelMask(mapData, C); + boolean[] visited = new boolean[C * C]; + List result = new ArrayList<>(); + int[] dx4 = {1, -1, 0, 0}; + int[] dz4 = {0, 0, 1, -1}; + + // Globale Randpunkte aller gültigen Komponenten (>= MIN_COMP Zellen) sammeln + List allBCells = new ArrayList<>(); + List allBDirs = new ArrayList<>(); + float globalCx = 0, globalCz = 0; int globalCount = 0; + + for (int startZ = 0; startZ < C; startZ++) { + for (int startX = 0; startX < C; startX++) { + int startIdx = startZ * C + startX; + if (!holeMask[startIdx] || visited[startIdx]) continue; + + // BFS – zusammenhängende Komponente + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + queue.add(new int[]{startX, startZ}); + visited[startIdx] = true; + List component = new ArrayList<>(); + while (!queue.isEmpty()) { + int[] cell = queue.poll(); + component.add(cell); + for (int d = 0; d < 4; d++) { + int nx = cell[0] + dx4[d], nz = cell[1] + dz4[d]; + if (nx < 0 || nx >= C || nz < 0 || nz >= C) continue; + int nIdx = nz * C + nx; + if (!holeMask[nIdx] || visited[nIdx]) continue; + visited[nIdx] = true; + queue.add(new int[]{nx, nz}); + } + } + if (component.size() < MIN_COMP) continue; // Rauschwolken ignorieren + + // Randpunkte dieser Komponente zur globalen Liste hinzufügen + for (int[] cell : component) { + float bdx = 0, bdz = 0; + boolean isBound = false; + for (int d = 0; d < 4; d++) { + int nx = cell[0] + dx4[d], nz = cell[1] + dz4[d]; + boolean solid = (nx < 0 || nx >= C || nz < 0 || nz >= C) + || !holeMask[nz * C + nx]; + if (solid) { isBound = true; bdx += dx4[d]; bdz += dz4[d]; } + } + if (isBound) { allBCells.add(cell); allBDirs.add(new float[]{bdx, bdz}); } + globalCx += cell[0]; globalCz += cell[1]; globalCount++; + } + } + } + + if (allBCells.isEmpty()) return result; + globalCx /= globalCount; + globalCz /= globalCount; + + // Globale Two-Farthest-Points: findet die zwei am weitesten entfernten Eingangspole + int pIdx1 = 0, pIdx2 = 0; + float maxD2 = 0; + int[] seed = allBCells.get(0); + for (int i = 1; i < allBCells.size(); i++) { + int[] b = allBCells.get(i); + float d2 = sq(b[0]-seed[0]) + sq(b[1]-seed[1]); + if (d2 > maxD2) { maxD2 = d2; pIdx1 = i; } + } + seed = allBCells.get(pIdx1); maxD2 = 0; + for (int i = 0; i < allBCells.size(); i++) { + int[] b = allBCells.get(i); + float d2 = sq(b[0]-seed[0]) + sq(b[1]-seed[1]); + if (d2 > maxD2) { maxD2 = d2; pIdx2 = i; } + } + int[] pole1 = allBCells.get(pIdx1); + int[] pole2 = allBCells.get(pIdx2); + float poleD2 = sq(pole1[0]-pole2[0]) + sq(pole1[1]-pole2[1]); + + if (poleD2 >= 16f) { + // Zwei Tunnel-Eingänge: jeder Pol zeigt zum anderen (= in die Klippe hinein) + addTunnelEntrance(pole1, allBCells, allBDirs, poleD2, globalCx, globalCz, C, CELL_SIZE, result); + addTunnelEntrance(pole2, allBCells, allBDirs, poleD2, globalCx, globalCz, C, CELL_SIZE, result); + } else { + addTunnelEntrance(pole1, allBCells, allBDirs, poleD2 + 1f, globalCx, globalCz, C, CELL_SIZE, result); + } + return result; + } + + private static float sq(float v) { return v * v; } + + /** + * Fügt einen Tunneleingang hinzu, dessen Position am Pol liegt. + * Die Richtung (in die Höhle) wird als Vektor vom Pol zum Komponenten-Schwerpunkt berechnet. + * Zusätzlich werden nur Randpunkte in der Nähe des Pols (innerhalb halber Pol-Distanz) + * für die Richtungsakkumulation verwendet. + */ + private static void addTunnelEntrance(int[] pole, List bCells, List bDirs, + float poleD2, float compCx, float compCz, + int C, float cellSize, List result) { + float halfD2 = poleD2 * 0.25f; + float sumX = 0, sumZ = 0; int count = 0; + for (int i = 0; i < bCells.size(); i++) { + int[] b = bCells.get(i); + if (sq(b[0]-pole[0]) + sq(b[1]-pole[1]) <= halfD2) { + sumX += b[0]; sumZ += b[1]; count++; + } + } + float entrCx = count > 0 ? sumX / count : pole[0]; + float entrCz = count > 0 ? sumZ / count : pole[1]; + + float wx = (entrCx - C / 2f + 0.5f) * cellSize; + float wz = (entrCz - C / 2f + 0.5f) * cellSize; + + // Richtung: vom Eingangsbereich in die Höhle (= zum Komponenten-Schwerpunkt) + float ddx = compCx - entrCx, ddz = compCz - entrCz; + float len = (float) Math.sqrt(ddx * ddx + ddz * ddz); + float dirX = len > 0.01f ? ddx / len : 1f; + float dirZ = len > 0.01f ? ddz / len : 0f; + + float radius = 2.5f * cellSize; + result.add(new float[]{wx, wz, dirX, dirZ, radius}); + } + + private void drawTunnelEntrancesOverlay(GraphicsContext gc) { + if (tunnelEntrances.isEmpty()) return; + gc.save(); + gc.setFill(Color.BLACK); + gc.setStroke(Color.BLACK); + gc.setLineWidth(1.0); + for (float[] te : tunnelEntrances) { + double cx = toCanvasX(te[0]); + double cz = toCanvasZ(te[1]); + double dirX = te[2]; // toward solid (in map X = canvas X) + double dirZ = te[3]; // toward solid (in map Z = canvas Y) + double radius = te[4] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; + radius = Math.max(4.0, Math.min(radius, 20.0)); + + // Rotate so the D-curve points toward the solid (into the cliff) + double angleDeg = Math.toDegrees(Math.atan2(dirZ, dirX)); + + gc.save(); + gc.translate(cx, cz); + gc.rotate(angleDeg); + // Semicircle: curve on the +X side (toward solid after rotation), flat face on -X + // fillArc(x, y, w, h, startAngle, extent): startAngle=−90° (screen-down), CCW 180° → goes through east (right) + gc.fillArc(-radius, -radius, radius * 2, radius * 2, -90, 180, ArcType.CHORD); + gc.restore(); + } + gc.restore(); + } + + private static List computeBuildingFootprints(WorldMapRenderModel model, Path assetRoot) { + List result = new ArrayList<>(); + for (PlacedModel pm : model.models()) { + if (!isBuilding(pm)) continue; + float[] ext = loadModelExtents(assetRoot, pm); + result.add(new float[]{pm.x(), pm.z(), ext[0] * pm.scale(), ext[1] * pm.scale(), pm.rotY()}); + } + return result; + } + + /** Vereinfachte Variante ohne Model-Extraktion (für CaveMapView). */ + static List computeBuildingFootprintsSimple(List models) { + List result = new ArrayList<>(); + for (PlacedModel pm : models) { + if (!isBuilding(pm)) continue; + float s = pm.scale() * DEFAULT_EXTENTS[0]; + result.add(new float[]{pm.x(), pm.z(), s, s, pm.rotY()}); + } + return result; + } + private static WritableImage toWritableImage(int[] pixels, int size) { WritableImage img = new WritableImage(size, size); img.getPixelWriter().setPixels(0, 0, size, size, PixelFormat.getIntArgbInstance(), pixels, 0, size); @@ -831,26 +1225,23 @@ public class WorldMapView extends VBox { return i; } - /** - * Liest alle VoxelChunks (aus dem Live-Supplier oder von Disk) und schreibt die - * höchsten soliden Voxel-Y-Werte in mapData.upperTop (für SeaMask/TerrainSamples) - * und gibt zusätzlich ein voxelSurface-Array zurück, das den tatsächlichen - * Oberflächen-Y pro UV-Zelle speichert — NaN wo kein Voxel, sonst der echte - * Top-Y-Wert (kann auch unter terrainHeight liegen, z. B. bei Canyons). - */ private float[] bakeVoxelHeights(MapData mapData) { - // voxelSurface bei voller Render-Auflösung (1 Zelle = 1 Welteinheit) backen, - // damit keine 8×8-Pixel-Blöcke entstehen. int VS = WorldMapRenderer.VOXEL_SURFACE_RES; - float[] voxelSurface = new float[VS * VS]; - java.util.Arrays.fill(voxelSurface, Float.NaN); + // Bevorzuge Live-Chunks aus dem Editor-Speicher, falle auf Disk zurück List chunks = voxelChunkSupplier != null ? voxelChunkSupplier.get() : VoxelChunkIO.loadAll(); - // Nach dem Backen sind Live-Chunks geleert → Fallback auf Disk (inkl. .blvc.prebake) boolean allEmpty = chunks.stream().allMatch(VoxelChunk::isEmpty); if (allEmpty) { chunks = VoxelChunkIO.loadAll(); } - if (chunks.isEmpty()) { return voxelSurface; } + if (chunks.isEmpty()) { + float[] empty = new float[VS * VS]; + java.util.Arrays.fill(empty, Float.NaN); + return empty; + } + // Kern-Logik ist dieselbe wie WorldMapRenderer.bakeVoxelSurface, aber mit + // Live-Chunks statt VoxelChunkIO.loadAll(). + float[] voxelSurface = new float[VS * VS]; + java.util.Arrays.fill(voxelSurface, Float.NaN); float WH = WorldMapRenderer.WORLD_HALF; float WS = WorldMapRenderer.WORLD_SIZE; int UV = MapData.UPPER_VERTS; @@ -866,7 +1257,6 @@ public class WorldMapView extends VBox { int vsPx = Math.round((worldX + WH) / WS * (VS - 1)); if (vsPx < 0 || vsPx >= VS) { continue; } int ux = Math.round((worldX + WH) / WS * (UV - 1)); - // Oberste solide Voxel-Y in dieser Spalte suchen for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) { if (chunk.getDensity(lx, ly, lz) > 0) { float topY = VoxelChunk.toWorldY(chunk.cy, ly); @@ -874,7 +1264,6 @@ public class WorldMapView extends VBox { if (Float.isNaN(voxelSurface[vsIdx]) || topY > voxelSurface[vsIdx]) { voxelSurface[vsIdx] = topY; } - // upperTop auf UV-Raster aktualisieren (für SeaMask / renderRegion) if (uz >= 0 && uz < UV && ux >= 0 && ux < UV) { int uvIdx = uz * UV + ux; if (topY > mapData.upperTop[uvIdx]) { mapData.upperTop[uvIdx] = topY; } diff --git a/blight-game/src/main/java/de/blight/game/state/MinimapState.java b/blight-game/src/main/java/de/blight/game/state/MinimapState.java index 46a0b57..7ab60a1 100644 --- a/blight-game/src/main/java/de/blight/game/state/MinimapState.java +++ b/blight-game/src/main/java/de/blight/game/state/MinimapState.java @@ -150,6 +150,13 @@ public class MinimapState extends BaseAppState { private Geometry fogFullGeo; private Geometry playerDotFull; + // ── Höhlen-Karte ────────────────────────────────────────────────────────── + private Geometry caveGeoMini = null; // Höhlenkarte über Minimap + private Geometry caveGeoFull = null; // Höhlenkarte über Vollkarte + private float caveBlend = 0f; // 0 = Außen, 1 = Höhle + /** upperHole-Maske der geladenen Karte für Laufzeit-Erkennung; null = nicht verfügbar. */ + private byte[] upperHoleMask = null; + private float fmMapSize; private float fmMapOriginX; private float fmMapOriginY; @@ -212,8 +219,12 @@ public class MinimapState extends BaseAppState { } float px = playerNode.getWorldTranslation().x; + float py = playerNode.getWorldTranslation().y; float pz = playerNode.getWorldTranslation().z; + // Höhlen-Erkennung: upperHole-Zelle am Spieler-XZ + tickCaveBlend(px, py, pz, tpf); + // Erkundung markieren + Nebel animieren tickExploration(px, pz, tpf); fogTime += tpf; @@ -267,6 +278,22 @@ public class MinimapState extends BaseAppState { } } + private void tickCaveBlend(float px, float py, float pz, float tpf) { + if (upperHoleMask == null || caveGeoMini == null) return; + int C = MapData.UPPER_CELLS; + int cx = Math.max(0, Math.min(C-1, (int)((px + WORLD_HALF) / WORLD_SIZE * C))); + int cz = Math.max(0, Math.min(C-1, (int)((pz + WORLD_HALF) / WORLD_SIZE * C))); + boolean inHole = upperHoleMask[cz * C + cx] != 0; + float target = inHole ? 1f : 0f; + if (caveBlend != target) { + float speed = 1.5f; // blend completes in ~0.67s + caveBlend += (target - caveBlend) * Math.min(tpf * speed, 1f); + if (Math.abs(caveBlend - target) < 0.005f) caveBlend = target; + ColorRGBA col = new ColorRGBA(1f, 1f, 1f, caveBlend); + caveGeoMini.getMaterial().setColor("Color", col); + } + } + private static float distSq(float x1, float z1, float x2, float z2) { float dx = x2 - x1, dz = z2 - z1; return dx * dx + dz * dz; @@ -551,10 +578,11 @@ public class MinimapState extends BaseAppState { List waterfalls = WaterfallIO.load(); int[] slotColors = computeSlotColors(mapData, root); int[][] tilePixels = WorldMapRenderer.loadMapTiles(root); + float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData); renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors); de.blight.common.map.WorldMapRenderModel renderModel = new de.blight.common.map.WorldMapRenderModel( mapData, areas, zones, locs, waters, models, waterfalls, - slotColors, null, null, null, null, null, tilePixels, null); + slotColors, null, voxelSurface, null, null, null, tilePixels, null); log.debug("[Minimap] Render-Modell bereit – {} Areas, {} Orte, {} Wasser ({} ms)", areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0); @@ -565,6 +593,13 @@ public class MinimapState extends BaseAppState { log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}", System.currentTimeMillis() - t1, png); + BufferedImage caveBi = WorldMapRenderer.buildCaveMap(mapData, TEXTURE_SIZE); + ImageIO.write(caveBi, "PNG", dir.resolve("minimap_cave.png").toFile()); + // Verwende Voxel-basierten Tunnel-Maske für Laufzeit-Erkennung (UPPER_CELLS Auflösung) + boolean[] tunnelMask256 = WorldMapRenderer.buildCaveTunnelMask(mapData, MapData.UPPER_CELLS); + upperHoleMask = new byte[tunnelMask256.length]; + for (int i = 0; i < tunnelMask256.length; i++) upperHoleMask[i] = tunnelMask256[i] ? (byte)1 : 0; + app.enqueue(() -> { onTextureReady(); return null; }); } catch (Exception e) { log.error("[Minimap] Render-Fehler: {}", e.getMessage(), e); @@ -621,10 +656,16 @@ public class MinimapState extends BaseAppState { MINIMAP_SIZE / 2f - DOT_SIZE / 2f, MINIMAP_SIZE / 2f - DOT_SIZE / 2f, 2f); + caveGeoMini = new Geometry("minimap_cave", minimapMesh); + caveGeoMini.setMaterial(buildCaveTexMat()); + caveGeoMini.setQueueBucket(RenderQueue.Bucket.Gui); + caveGeoMini.setLocalTranslation(0f, 0f, 0.5f); // between world map and fog + minimapNode = new Node("minimap"); minimapNode.setLocalTranslation(x, y, 48f); minimapNode.attachChild(border); minimapNode.attachChild(minimapGeo); + minimapNode.attachChild(caveGeoMini); minimapNode.attachChild(fogMiniGeo); minimapNode.attachChild(playerDotMini); @@ -634,6 +675,21 @@ public class MinimapState extends BaseAppState { } } + /** Erstellt das Material für die Höhlenkarte mit Alpha-Blending (initial unsichtbar). */ + private Material buildCaveTexMat() { + Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); + mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha); + try { + Texture tex = app.getAssetManager().loadTexture("Textures/hud/minimap_cave.png"); + tex.setWrap(Texture.WrapMode.EdgeClamp); + mat.setTexture("ColorMap", tex); + } catch (Exception e) { + log.warn("[Minimap] Höhlenkarten-Textur nicht ladbar"); + } + mat.setColor("Color", new ColorRGBA(1f, 1f, 1f, 0f)); // zunächst unsichtbar + return mat; + } + // ── Vektor-Layer ────────────────────────────────────────────────────────── private void scheduleVectorRedraw() { diff --git a/blight-map/src/main/map/blight_objects.blo b/blight-map/src/main/map/blight_objects.blo index f9d4c46..259d086 100644 --- a/blight-map/src/main/map/blight_objects.blo +++ b/blight-map/src/main/map/blight_objects.blo @@ -19,3 +19,6 @@ Models/trees/pine/medium/pine_medium_20260706_190947.j3o 159.00723 2.00000 -787. Models/trees/pine/medium/pine_medium_20260706_190947.j3o 165.92183 2.00000 -797.42450 0.98495 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE Models/trees/pine/medium/pine_medium_20260706_190939.j3o 165.03651 2.00000 -808.26971 -2.37876 1.00000 -0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE Models/trees/pine/medium/pine_medium_20260706_190939.j3o 155.19229 2.00000 -802.35339 0.70993 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 TREE +Models/northcoast/alte_fischerhytte.j3o 317.92999 2.01000 -863.58002 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING +Models/northcoast/alte_fischerhytte2.j3o 312.98001 2.00000 -851.37000 -3.50811 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING +Models/northcoast/alte_fischerhytte.j3o 322.73001 2.00000 -856.09003 -1.83155 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BUILDING diff --git a/blight-map/src/main/map/blight_paths.blp b/blight-map/src/main/map/blight_paths.blp new file mode 100644 index 0000000..022f0e3 --- /dev/null +++ b/blight-map/src/main/map/blight_paths.blp @@ -0,0 +1,25 @@ +# Blight Path Network +# NODE uuid name x y z +# EDGE uuid1 uuid2 +NODE c7fe03f3-736b-4d81-accb-27dd6649e000 - 315.56274 2.00203 -857.08173 +NODE fa3be430-7714-49ed-9a81-4e4fb45ba564 - 300.86075 2.02220 -863.90460 +NODE 5618a5ba-d0a5-4897-ab4d-a4a2932edb18 - 277.15298 2.18065 -867.39679 +NODE f8f01e21-3963-4530-a5d6-bfdd0cb77233 - 266.34970 4.29437 -886.74365 +NODE d886a17b-4ad0-4437-9dbf-6c0df951742f - 252.24966 6.40171 -892.78693 +NODE caa8813b-d828-4db8-ae77-9cf369309618 - 254.10876 2.00000 -852.84991 +NODE ff5bb246-2c95-4e76-b592-5881c51dab51 - 254.28955 2.00035 -826.95752 +NODE 28a87ffd-a39b-4347-a79f-fe5ead071b59 - 291.11404 2.00004 -817.18658 +NODE 7e3c1080-d37e-4fc9-b489-c0784f34fac1 - 306.78833 2.00000 -777.88641 +NODE c29223fa-67a0-4df0-8055-91381ec8c83f - 317.70370 2.00000 -782.45502 +NODE 43677d9c-a19b-45f2-8eb3-d3368251d0b9 - 349.32700 2.00000 -774.56525 +EDGE c7fe03f3-736b-4d81-accb-27dd6649e000 fa3be430-7714-49ed-9a81-4e4fb45ba564 +EDGE fa3be430-7714-49ed-9a81-4e4fb45ba564 5618a5ba-d0a5-4897-ab4d-a4a2932edb18 +EDGE f8f01e21-3963-4530-a5d6-bfdd0cb77233 d886a17b-4ad0-4437-9dbf-6c0df951742f +EDGE caa8813b-d828-4db8-ae77-9cf369309618 ff5bb246-2c95-4e76-b592-5881c51dab51 +EDGE ff5bb246-2c95-4e76-b592-5881c51dab51 28a87ffd-a39b-4347-a79f-fe5ead071b59 +EDGE 28a87ffd-a39b-4347-a79f-fe5ead071b59 7e3c1080-d37e-4fc9-b489-c0784f34fac1 +EDGE 7e3c1080-d37e-4fc9-b489-c0784f34fac1 c29223fa-67a0-4df0-8055-91381ec8c83f +EDGE c29223fa-67a0-4df0-8055-91381ec8c83f 43677d9c-a19b-45f2-8eb3-d3368251d0b9 +EDGE 5618a5ba-d0a5-4897-ab4d-a4a2932edb18 f8f01e21-3963-4530-a5d6-bfdd0cb77233 +EDGE 5618a5ba-d0a5-4897-ab4d-a4a2932edb18 caa8813b-d828-4db8-ae77-9cf369309618 +EDGE f8f01e21-3963-4530-a5d6-bfdd0cb77233 caa8813b-d828-4db8-ae77-9cf369309618