Weiter an der Karte gearbeitet - hier Höhlen und Tunnel
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
285
blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java
Normal file
285
blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user