Weiter an der Karte gearbeitet - hier Höhlen und Tunnel

This commit is contained in:
2026-09-06 11:41:04 +02:00
parent ca4d34b806
commit faa0a8b254
18 changed files with 1192 additions and 63 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 594 KiB

View File

@@ -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<VoxelChunk> 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<VoxelChunk> 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<Integer> 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<Integer> q2 = new java.util.ArrayDeque<>();
q2.add(start); vis2[start] = true;
java.util.List<Integer> 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<PlacedWater> waters,
List<PlacedWaterfall> waterfalls,

View File

@@ -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<de.blight.common.ModelCategory> 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<de.blight.common.PlacedModel> placed = de.blight.common.PlacedModelIO.load();
boolean anyChanged = false;
java.util.List<de.blight.common.PlacedModel> 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)

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

@@ -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<float[][]> cachedPathChains = new ArrayList<>();
private List<float[]> tunnelEntrances = new ArrayList<>();
private List<PlacedArea> cachedAreas = new ArrayList<>();
private List<float[]> 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<Stage> 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<javafx.event.ActionEvent> 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<PlacedArea> areas = AreaIO.load();
List<PlacedModel> models = PlacedModelIO.load();
BufferedImage bi = WorldMapRenderer.buildCaveMap(mapData, RENDER_SIZE);
List<float[][]> paths = WorldMapView.loadPathChains();
List<float[]> entrances = WorldMapView.computeTunnelEntrances(mapData);
List<float[]> 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();
}
}

View File

@@ -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<float[][]> forestPaths = new ArrayList<>();
private WritableImage bakedForestOverlay = null;
private List<float[][]> forestPaths = new ArrayList<>();
private List<float[]> buildingFootprints = new ArrayList<>();
private List<float[][]> cachedPathChains = new ArrayList<>();
/** Tunneleingang-Daten: [worldX, worldZ, dirX, dirZ, radiusWorld] */
private List<float[]> tunnelEntrances = new ArrayList<>();
private static final java.util.concurrent.ConcurrentHashMap<String, float[]> MODEL_BOUNDS_CACHE
= new java.util.concurrent.ConcurrentHashMap<>();
private static final float[] DEFAULT_EXTENTS = {5f, 5f};
private List<PlacedArea> cachedAreas = new ArrayList<>();
private List<Location> 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<float[][]> newCliffPaths = buildCliffPaths(model.mapData(), model.voxelSurface());
List<float[][]> newForestPaths = buildForestOutlinePaths(fDensity, 512, 3.0f);
List<float[]> newBuildingFootprints = computeBuildingFootprints(model, findAssetRoot());
List<float[][]> newPathChains = loadPathChains();
List<float[]> 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<float[][]> loadPathChains() {
try {
de.blight.common.path.PathNetwork net = de.blight.common.path.PathNetworkIO.load();
List<de.blight.common.path.PathNode> nodes = net.getNodes();
if (nodes.isEmpty()) return new ArrayList<>();
java.util.Map<String, de.blight.common.path.PathNode> nodeMap = new java.util.HashMap<>();
java.util.Map<String, List<String>> 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<String> 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<String> visitedEdges = new java.util.HashSet<>();
List<float[][]> 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<float[]> 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<float[]> 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<float[]> 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<int[]> allBCells = new ArrayList<>();
List<float[]> 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<int[]> queue = new java.util.ArrayDeque<>();
queue.add(new int[]{startX, startZ});
visited[startIdx] = true;
List<int[]> 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<int[]> bCells, List<float[]> bDirs,
float poleD2, float compCx, float compCz,
int C, float cellSize, List<float[]> 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<float[]> computeBuildingFootprints(WorldMapRenderModel model, Path assetRoot) {
List<float[]> 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<float[]> computeBuildingFootprintsSimple(List<PlacedModel> models) {
List<float[]> 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<VoxelChunk> 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; }

View File

@@ -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<PlacedWaterfall> 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() {

View File

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

View File

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