Commit vor Voxel Update für die Klippen

This commit is contained in:
2026-06-11 21:52:00 +02:00
parent fe5dfc19b1
commit a80269e681
143 changed files with 4340 additions and 342 deletions

View File

@@ -1,11 +1,13 @@
package de.blight.editor;
import de.blight.common.BlightHome;
import de.blight.editor.tool.ChoiceToolParameter;
import de.blight.editor.tool.EditorTool;
import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.ToolParameter;
import de.blight.editor.tree.PalmOptions;
import de.blight.editor.tree.TreeParams;
import de.blight.editor.ui.MapObjectsView;
import de.blight.editor.ui.TextureChooser;
import de.blight.eztree.Billboard;
import de.blight.eztree.TreeOptions;
@@ -62,6 +64,7 @@ public class EditorApp extends Application {
private java.nio.file.attribute.FileTime lastLivePosTime =
java.nio.file.attribute.FileTime.fromMillis(0);
private VBox assetPanel;
private MapObjectsView mapObjectsView;
private StackPane worldViewport;
private VBox topBar; // MenuBar + aktuelle Toolbar
private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons)
@@ -229,6 +232,9 @@ public class EditorApp extends Application {
private ToggleButton areaBtn;
private ToggleButton locationZoneBtn;
private ToggleButton playToolBtn;
private ToggleButton voxelBtn;
private ToggleButton camOrbitBtn;
private ToggleButton camFreeBtn;
// "Objekt"-Button in der Selektionsleiste (zum Zurückschalten bei importierten Objekten)
private ToggleButton selModeObjectBtn;
@@ -324,6 +330,16 @@ public class EditorApp extends Application {
primaryStage = stage;
input.scanSkeletalRequested = true; // Skelett-Scan beim Start anstoßen
// Alle ungefangenen Exceptions auf dem FX-Thread loggen hilft beim Finden des Zahnrad-Auslösers
Thread.currentThread().setUncaughtExceptionHandler((t, ex) ->
org.slf4j.LoggerFactory.getLogger(EditorApp.class)
.error("[FX-Thread] Ungefangene Exception", ex));
// Auch andere Threads absichern (JME3-Thread, Worker-Threads)
Thread.setDefaultUncaughtExceptionHandler((t, ex) ->
org.slf4j.LoggerFactory.getLogger(EditorApp.class)
.error("[Thread '{}'] Ungefangene Exception", t.getName(), ex));
stage.setTitle("Blight World Editor");
try (var is = getClass().getResourceAsStream("/icon_editor.png")) {
if (is != null) stage.getIcons().add(new Image(is));
@@ -932,6 +948,9 @@ public class EditorApp extends Application {
areaBtn = new ToggleButton("🗺 Bereiche");
locationZoneBtn = new ToggleButton("📍 Locations");
playToolBtn = new ToggleButton("🎮 Spielen");
voxelBtn = new ToggleButton("⬡ Voxel");
camOrbitBtn = new ToggleButton("⊙ Orbit");
camFreeBtn = new ToggleButton("✈ FreeFly");
baseBtn.setStyle("-fx-font-weight:bold;");
grassBtn.setStyle("-fx-font-weight:bold;");
grassVertexBtn.setStyle("-fx-font-weight:bold;");
@@ -946,6 +965,9 @@ public class EditorApp extends Application {
areaBtn.setStyle("-fx-font-weight:bold;");
locationZoneBtn.setStyle("-fx-font-weight:bold;");
playToolBtn.setStyle("-fx-font-weight:bold;");
voxelBtn.setStyle("-fx-font-weight:bold;");
camOrbitBtn.setStyle("-fx-font-weight:bold;");
camFreeBtn.setStyle("-fx-font-weight:bold;");
ToggleGroup layerGroup = new ToggleGroup();
baseBtn.setToggleGroup(layerGroup);
@@ -962,6 +984,7 @@ public class EditorApp extends Application {
areaBtn.setToggleGroup(layerGroup);
locationZoneBtn.setToggleGroup(layerGroup);
playToolBtn.setToggleGroup(layerGroup);
voxelBtn.setToggleGroup(layerGroup);
baseBtn.setSelected(true);
baseBtn.setOnAction(e -> {
@@ -1030,6 +1053,19 @@ public class EditorApp extends Application {
input.activeLayer = SharedInput.LAYER_PLAY_TOOL;
root.setRight(buildPlayToolPanel());
});
voxelBtn.setOnAction(e -> {
input.activeLayer = SharedInput.LAYER_VOXEL;
input.activeTool = input.voxelTool;
root.setRight(toolPanel);
showToolParameters(toolPanel, input.activeTool);
});
ToggleGroup camModeGroup = new ToggleGroup();
camOrbitBtn.setToggleGroup(camModeGroup);
camFreeBtn.setToggleGroup(camModeGroup);
camOrbitBtn.setSelected(true);
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
Label hint = new Label("WASD/QE: Kamera | Mitte-Drag / L+R-Drag: Drehen | L-Klick: hoch | R-Klick: tief");
hint.setStyle("-fx-text-fill: #555;");
@@ -1042,6 +1078,8 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), riverBtn,
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn,
new Separator(Orientation.VERTICAL), hint);
worldToolBar = toolBar;
@@ -2775,6 +2813,12 @@ public class EditorApp extends Application {
if (tool instanceof de.blight.editor.tool.TextureTool
&& param == ((de.blight.editor.tool.TextureTool) tool).textureIndex) {
panel.getChildren().addAll(nameLabel, buildTextureChoiceUI(param));
} else if (tool instanceof de.blight.editor.tool.VoxelTool vt
&& param == vt.textureSlot) {
panel.getChildren().addAll(nameLabel, buildVoxelTextureChoiceUI(param));
} else if (tool instanceof de.blight.editor.tool.VoxelTool vt2
&& param == vt2.mode) {
panel.getChildren().addAll(nameLabel, buildVoxelModeUI(param));
} else if (param.getImagePaths() != null) {
String[] paths = param.getImagePaths();
String[] labels = param.getChoices();
@@ -3045,6 +3089,132 @@ public class EditorApp extends Application {
return tile;
}
private javafx.scene.Node buildVoxelModeUI(ChoiceToolParameter param) {
String[] labels = param.getChoices();
String[] imgPaths = param.getImagePaths();
ToggleGroup tg = new ToggleGroup();
javafx.scene.layout.TilePane tile = new javafx.scene.layout.TilePane();
tile.setHgap(4);
tile.setVgap(4);
tile.setPrefColumns(6);
for (int j = 0; j < labels.length; j++) {
final int idx = j;
String path = (imgPaths != null && j < imgPaths.length) ? imgPaths[j] : null;
ToggleButton btn = new ToggleButton();
btn.setToggleGroup(tg);
btn.setPrefSize(44, 56);
btn.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
btn.setTooltip(new Tooltip(labels[j]));
VBox content = new VBox(2);
content.setAlignment(Pos.CENTER);
boolean hasImage = false;
if (path != null) {
URL resUrl = EditorApp.class.getResource("/" + path);
String imgUrl = resUrl != null ? resUrl.toString() : null;
if (imgUrl == null) {
File f = new File(path).getAbsoluteFile();
if (f.exists()) imgUrl = f.toURI().toString();
}
if (imgUrl != null) {
Image img = new Image(imgUrl, 32, 32, true, true);
if (!img.isError()) {
ImageView iv = new ImageView(img);
iv.setFitWidth(32); iv.setFitHeight(32);
content.getChildren().add(iv);
hasImage = true;
}
}
}
if (!hasImage) {
boolean isRemove = labels[j].equals("Entfernen");
Label icon = new Label(isRemove ? "" : "+");
icon.setStyle("-fx-font-size: 16; -fx-text-fill: " + (isRemove ? "#cc2222" : "#228822") + ";");
content.getChildren().add(icon);
}
Label lbl = new Label(labels[j]);
lbl.setStyle("-fx-font-size: 9; -fx-text-fill: #222;");
content.getChildren().add(lbl);
btn.setGraphic(content);
boolean initially = (j == param.getSelectedIndex());
btn.setSelected(initially);
applyModeButtonStyle(btn, initially);
btn.selectedProperty().addListener((obs, was, isNow) -> {
applyModeButtonStyle(btn, isNow);
if (isNow) param.setSelectedIndex(idx);
});
tile.getChildren().add(btn);
}
tg.selectedToggleProperty().addListener((obs, oldT, newT) -> {
if (newT == null) tg.selectToggle(oldT);
});
return tile;
}
private javafx.scene.Node buildVoxelTextureChoiceUI(ChoiceToolParameter param) {
ToggleGroup tg = new ToggleGroup();
javafx.scene.layout.TilePane tile = new javafx.scene.layout.TilePane();
tile.setHgap(4);
tile.setVgap(4);
tile.setPrefColumns(4);
for (int j = 0; j < 4; j++) {
final int idx = j;
String path = (j < input.terrainTexturePaths.length
&& input.terrainTexturePaths[j] != null
&& !input.terrainTexturePaths[j].isEmpty())
? input.terrainTexturePaths[j] : "";
ToggleButton btn = new ToggleButton();
btn.setToggleGroup(tg);
btn.setPrefSize(56, 76);
btn.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
btn.setTooltip(new Tooltip("Slot " + (j + 1) + ": " + (path.isEmpty() ? "(leer)" : path)));
VBox content = new VBox(2);
content.setAlignment(Pos.CENTER);
if (!path.isEmpty()) {
String imgUrl = resolveImageUrl(path);
if (imgUrl != null) {
Image img = new Image(imgUrl, 44, 44, true, true);
ImageView iv = new ImageView(img.isError() ? null : img);
iv.setFitWidth(44);
iv.setFitHeight(44);
content.getChildren().add(img.isError() ? emptySlotPlaceholder(44) : iv);
} else {
content.getChildren().add(emptySlotPlaceholder(44));
}
} else {
content.getChildren().add(emptySlotPlaceholder(44));
}
Label lbl = new Label("S" + (j + 1) + " " + labelFromPath(path));
lbl.setStyle("-fx-font-size: 9; -fx-text-fill: #222;");
lbl.setMaxWidth(54);
content.getChildren().add(lbl);
btn.setGraphic(content);
boolean initially = (j == param.getSelectedIndex());
btn.setSelected(initially);
applyModeButtonStyle(btn, initially);
btn.selectedProperty().addListener((obs, was, isNow) -> {
applyModeButtonStyle(btn, isNow);
if (isNow) param.setSelectedIndex(idx);
});
tile.getChildren().add(btn);
}
tg.selectedToggleProperty().addListener((obs, oldT, newT) -> {
if (newT == null) tg.selectToggle(oldT);
});
return tile;
}
private javafx.scene.layout.Region emptySlotPlaceholder(double size) {
javafx.scene.layout.Region r = new javafx.scene.layout.Region();
r.setPrefSize(size, size);
@@ -3172,10 +3342,10 @@ public class EditorApp extends Application {
// ── Linke Seite: Asset-Panel (Welteneditor) ──────────────────────────────
private VBox buildAssetPanel() {
VBox panel = new VBox(6);
panel.setPadding(new Insets(8));
panel.setPrefWidth(420);
panel.setStyle("-fx-background-color: #f0f0f0;");
// ── Tab 1: Asset-Baum ─────────────────────────────────────────────────
VBox assetContent = new VBox(6);
assetContent.setPadding(new Insets(8));
assetContent.setStyle("-fx-background-color: #f0f0f0;");
Label title = new Label("Assets");
title.setStyle("-fx-font-weight: bold; -fx-font-size: 13;");
@@ -3283,7 +3453,106 @@ public class EditorApp extends Application {
HBox.setHgrow(importBtn, Priority.ALWAYS);
bottomBar.setMaxWidth(Double.MAX_VALUE);
panel.getChildren().addAll(titleBar, tree, bottomBar);
assetContent.getChildren().addAll(titleBar, tree, bottomBar);
// ── Tab 2: Karte (platzierte Objekte) ─────────────────────────────────
mapObjectsView = new MapObjectsView(
(x, y, z, toolHint) -> {
input.pendingGotoX = x;
input.pendingGotoY = y;
input.pendingGotoZ = z;
input.pendingGotoPitch = (float) (-Math.PI / 3);
switch (toolHint) {
case "model" -> { input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT; root.setRight(buildObjectEditPanel()); }
case "item" -> { input.activeLayer = SharedInput.LAYER_ITEMS; root.setRight(buildItemPlacePanel(null)); }
case "light" -> { input.activeLayer = SharedInput.LAYER_LIGHTS; root.setRight(buildLightPanel()); }
case "emitter" -> { input.activeLayer = SharedInput.LAYER_EMITTERS; root.setRight(buildEmitterPanel()); }
case "water" -> { input.activeLayer = SharedInput.LAYER_WATER; root.setRight(buildWaterPanel()); }
case "soundarea" -> { input.activeLayer = SharedInput.LAYER_SOUND_AREAS; root.setRight(buildSoundAreaPanel()); }
case "area" -> { input.activeLayer = SharedInput.LAYER_AREAS; root.setRight(buildAreaPanel()); }
case "locationzone" -> { input.activeLayer = SharedInput.LAYER_LOCATION_ZONES; root.setRight(buildLocationZonePanel()); }
case "waterfall" -> { input.activeLayer = SharedInput.LAYER_WATERFALL; root.setRight(buildWaterfallPanel()); }
}
},
(obj, index, toolHint) -> {
switch (toolHint) {
case "model" -> {
java.util.List<de.blight.common.PlacedModel> list = new java.util.ArrayList<>(de.blight.common.PlacedModelIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.PlacedModelIO.save(list);
input.reloadPlacedModels = true;
}
case "item" -> {
de.blight.common.PlacedItem pi = (de.blight.common.PlacedItem) obj;
java.util.List<de.blight.common.PlacedItem> list = new java.util.ArrayList<>(de.blight.common.PlacedItemIO.load());
list.removeIf(it -> it.uuid().equals(pi.uuid()));
de.blight.common.PlacedItemIO.save(list);
input.reloadPlacedItems = true;
}
case "light" -> {
java.util.List<de.blight.common.PlacedLight> list = new java.util.ArrayList<>(de.blight.common.LightIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.LightIO.save(list);
input.reloadPlacedOther = true;
}
case "emitter" -> {
java.util.List<de.blight.common.PlacedEmitter> list = new java.util.ArrayList<>(de.blight.common.EmitterIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.EmitterIO.save(list);
input.reloadPlacedOther = true;
}
case "water" -> {
java.util.List<de.blight.common.PlacedWater> list = new java.util.ArrayList<>(de.blight.common.WaterBodyIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.WaterBodyIO.save(list);
input.reloadPlacedOther = true;
}
case "soundarea" -> {
java.util.List<de.blight.common.PlacedSoundArea> list = new java.util.ArrayList<>(de.blight.common.SoundAreaIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.SoundAreaIO.save(list);
input.reloadPlacedOther = true;
}
case "area" -> {
java.util.List<de.blight.common.PlacedArea> list = new java.util.ArrayList<>(de.blight.common.AreaIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.AreaIO.save(list);
input.reloadPlacedOther = true;
}
case "locationzone" -> {
java.util.List<de.blight.common.PlacedLocationZone> list = new java.util.ArrayList<>(de.blight.common.LocationZoneIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.LocationZoneIO.save(list);
input.reloadPlacedOther = true;
}
case "waterfall" -> {
java.util.List<java.util.List<de.blight.common.RiverPoint>> list = new java.util.ArrayList<>(de.blight.common.RiverIO.load());
if (index >= 0 && index < list.size()) list.remove(index);
de.blight.common.RiverIO.save(list);
input.reloadPlacedOther = true;
}
}
}
);
// ── TabPane ───────────────────────────────────────────────────────────
Tab assetsTab = new Tab("Assets", assetContent);
assetsTab.setClosable(false);
Tab karteTab = new Tab("Karte", mapObjectsView);
karteTab.setClosable(false);
karteTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected) mapObjectsView.refresh();
});
TabPane tabPane = new TabPane(assetsTab, karteTab);
tabPane.setStyle("-fx-background-color: #e8e8e8;");
VBox.setVgrow(tabPane, Priority.ALWAYS);
VBox panel = new VBox(tabPane);
panel.setPrefWidth(420);
panel.setStyle("-fx-background-color: #e8e8e8;");
VBox.setVgrow(tabPane, Priority.ALWAYS);
return panel;
}
@@ -4082,10 +4351,10 @@ public class EditorApp extends Application {
new FileChooser.ExtensionFilter("Alle unterstützten Dateien",
"*.j3o","*.obj","*.gltf","*.glb",
"*.png","*.jpg","*.jpeg","*.bmp","*.tga","*.dds",
"*.ogg","*.wav"),
"*.ogg","*.wav","*.mp3"),
new FileChooser.ExtensionFilter("Modelle", "*.j3o","*.obj","*.gltf","*.glb"),
new FileChooser.ExtensionFilter("Texturen", "*.png","*.jpg","*.jpeg","*.bmp","*.tga","*.dds"),
new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg","*.wav")
new FileChooser.ExtensionFilter("Audio (OGG, WAV, MP3)", "*.ogg","*.wav","*.mp3")
);
var files = fc.showOpenMultipleDialog(owner);
if (files == null) return;
@@ -4094,7 +4363,7 @@ public class EditorApp extends Application {
String name = file.getName().toLowerCase();
boolean isNativeModel = name.matches(".*\\.(obj|fbx|gltf|glb)");
boolean isJ3o = name.endsWith(".j3o");
boolean isAudio = name.matches(".*\\.(ogg|wav)");
boolean isAudio = name.matches(".*\\.(ogg|wav|mp3)");
boolean isModel = isNativeModel || isJ3o;
String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures";
@@ -4743,6 +5012,7 @@ public class EditorApp extends Application {
viewport.setOnScroll(e -> {
int steps = (int) Math.signum(e.getDeltaY());
if (e.isControlDown()) steps *= 10;
input.scrollAccum.addAndGet(steps);
});
@@ -4796,6 +5066,8 @@ public class EditorApp extends Application {
if (action > 0) // only left-click sets spawn
input.playToolClickQueue.offer(new SharedInput.PlayToolClick((float) x, (float) y));
}
case SharedInput.LAYER_VOXEL ->
input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y));
}
}
@@ -4839,12 +5111,24 @@ public class EditorApp extends Application {
String libPath = System.getProperty("java.library.path", "");
String projRoot = ProjectRoot.PATH.toString();
// Karte in run/session-<ts>/ kopieren dort gitignored, aber im Projekt sichtbar.
// Spielaktionen (Item-Aufheben etc.) verändern so nie die Editor-Karte.
Path srcMapDir = ProjectRoot.PATH.resolve(
Paths.get("blight-map", "src", "main", "map"));
Path sessionDir = ProjectRoot.PATH.resolve("run")
.resolve("session-" + System.currentTimeMillis());
copyDirectory(srcMapDir, sessionDir);
String sessionMapPath = sessionDir.resolve("blight_map.blm").toString();
java.util.List<String> cmd = new java.util.ArrayList<>(List.of(
javaExe,
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
"--add-opens", "java.desktop/sun.awt=ALL-UNNAMED",
"-Djava.library.path=" + libPath,
"-Dblight.project.root=" + projRoot));
"-Dblight.project.root=" + projRoot,
"-D" + de.blight.common.MapIO.PROP_SESSION_MAP + "=" + sessionMapPath,
// Vom Editor gestartet: Hauptmenü überspringen, letzten Stand fortsetzen
"-Dblight.autostart=true"));
if (!Float.isNaN(input.tempSpawnX) && !Float.isNaN(input.tempSpawnZ)) {
cmd.add("-Dblight.temp.spawn.x=" + input.tempSpawnX);
@@ -4896,6 +5180,19 @@ public class EditorApp extends Application {
}, "game-launcher").start();
}
private static void copyDirectory(Path src, Path dst) throws IOException {
try (java.util.stream.Stream<Path> walk = java.nio.file.Files.walk(src)) {
for (Path s : (Iterable<Path>) walk::iterator) {
Path d = dst.resolve(src.relativize(s));
if (java.nio.file.Files.isDirectory(s)) {
java.nio.file.Files.createDirectories(d);
} else {
java.nio.file.Files.copy(s, d, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
private void openGameConsole() {
if (gameConsoleStage == null) {
gameConsoleArea = new TextArea();
@@ -4962,8 +5259,8 @@ public class EditorApp extends Application {
p.setProperty("cam.yaw", String.valueOf(input.camYaw));
p.setProperty("cam.pitch", String.valueOf(input.camPitch));
try {
Files.createDirectories(ProjectRoot.resolve("config"));
try (java.io.Writer w = Files.newBufferedWriter(ProjectRoot.resolve("config", "editor.prefs"))) {
Files.createDirectories(BlightHome.resolve("config"));
try (java.io.Writer w = Files.newBufferedWriter(BlightHome.resolve("config", "editor.prefs"))) {
p.store(w, "Blight Editor Kamera-Einstellungen");
}
} catch (IOException e) {
@@ -5131,7 +5428,7 @@ public class EditorApp extends Application {
FileChooser fc = new FileChooser();
fc.setTitle("Sound-Datei wählen");
fc.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg", "*.wav"));
new FileChooser.ExtensionFilter("Audio (OGG, WAV, MP3)", "*.ogg", "*.wav", "*.mp3"));
Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources");
if (java.nio.file.Files.isDirectory(assetRoot))
fc.setInitialDirectory(assetRoot.toFile());
@@ -5268,7 +5565,7 @@ public class EditorApp extends Application {
FileChooser fc = new FileChooser();
fc.setTitle(trackLabels[tIdx] + " wählen");
fc.getExtensionFilters().add(
new FileChooser.ExtensionFilter("Audio (OGG, WAV)", "*.ogg", "*.wav"));
new FileChooser.ExtensionFilter("Audio (OGG, WAV, MP3)", "*.ogg", "*.wav", "*.mp3"));
Path assetRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources");
if (java.nio.file.Files.isDirectory(assetRoot))
fc.setInitialDirectory(assetRoot.toFile());
@@ -5873,17 +6170,7 @@ public class EditorApp extends Application {
animSetClipListView.getItems().addAll(animSet.getClips());
animSetClipListView.setPrefHeight(180);
// alle verfügbaren Clips aus animations/clips/ (unabhängig ob bereits im Set)
java.util.List<String> availableClips = new java.util.ArrayList<>();
Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
if (java.nio.file.Files.isDirectory(clipsDir)) {
try (var walk = java.nio.file.Files.walk(clipsDir, 1)) {
walk.filter(cp -> cp.toString().endsWith(".j3o"))
.map(cp -> cp.getFileName().toString().replaceFirst("\\.j3o$", ""))
.sorted()
.forEach(availableClips::add);
} catch (IOException ignored) {}
}
Path animRootDir = ASSET_ROOT.resolve("animations");
Button addClipBtn = new Button("+ Hinzufügen…");
Button removeClipBtn = new Button("- Entfernen");
@@ -5895,26 +6182,72 @@ public class EditorApp extends Application {
.addListener((obs, ov, nv) -> removeClipBtn.setDisable(nv == null));
addClipBtn.setOnAction(e -> {
ComboBox<String> combo = new ComboBox<>();
// alle verfügbaren Clips anzeigen, nicht nur unbenutzte
combo.getItems().addAll(availableClips);
combo.setEditable(true);
combo.setMaxWidth(Double.MAX_VALUE);
combo.getSelectionModel().selectFirst();
org.slf4j.Logger _log = org.slf4j.LoggerFactory.getLogger("AnimSetClipScan");
_log.debug("[ClipScan] animRootDir={} exists={}", animRootDir, java.nio.file.Files.isDirectory(animRootDir));
javafx.scene.control.Dialog<String> dlg = new javafx.scene.control.Dialog<>();
dlg.setTitle("Clip hinzufügen");
dlg.setHeaderText("Clip aus animations/clips/ wählen:");
dlg.getDialogPane().setContent(combo);
// Scan bei jedem Klick frisch gesamter animations/-Ordner, sets/ ausgenommen
java.util.List<String> allClips = new java.util.ArrayList<>();
if (java.nio.file.Files.isDirectory(animRootDir)) {
try (var walk = java.nio.file.Files.walk(animRootDir)) {
walk.filter(cp -> {
String name = cp.getFileName().toString();
boolean isAnim = name.endsWith(".j3o") || name.endsWith(".glb")
|| name.endsWith(".gltf") || name.endsWith(".fbx");
boolean notSets = !animRootDir.relativize(cp).startsWith("sets");
_log.debug("[ClipScan] {} | isAnim={} notSets={}", cp, isAnim, notSets);
return isAnim && notSets;
})
.map(cp -> cp.getFileName().toString().replaceFirst("\\.[^.]+$", ""))
.distinct()
.sorted()
.forEach(allClips::add);
} catch (IOException ex) {
_log.error("[ClipScan] Walk fehlgeschlagen", ex);
allClips.clear();
}
}
_log.debug("[ClipScan] allClips={}", allClips);
_log.debug("[ClipScan] bereits im Set={}", animSetClipListView.getItems());
// Nur Clips anbieten, die noch nicht im Set enthalten sind
java.util.List<String> notYetAdded = allClips.stream()
.filter(c -> !animSetClipListView.getItems().contains(c))
.collect(java.util.stream.Collectors.toList());
_log.debug("[ClipScan] notYetAdded={}", notYetAdded);
if (notYetAdded.isEmpty()) {
javafx.scene.control.Alert info = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.INFORMATION);
info.setTitle("Keine Clips verfügbar");
info.setHeaderText(null);
info.setContentText("Alle verfügbaren Clips sind bereits im Set enthalten.");
info.showAndWait();
return;
}
ListView<String> list = new ListView<>();
list.getItems().addAll(notYetAdded);
list.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
list.setPrefHeight(Math.min(notYetAdded.size() * 26 + 4, 320));
list.getSelectionModel().selectFirst();
javafx.scene.control.Dialog<java.util.List<String>> dlg = new javafx.scene.control.Dialog<>();
dlg.setTitle("Animation(en) hinzufügen");
dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:");
dlg.getDialogPane().setContent(list);
dlg.getDialogPane().setPrefWidth(360);
javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType("Hinzufügen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
dlg.setResultConverter(bt -> bt == ok ? combo.getValue() : null);
dlg.showAndWait().ifPresent(clip -> {
if (clip != null && !clip.isBlank()
&& !animSetClipListView.getItems().contains(clip)) {
animSetClipListView.getItems().add(clip);
animSetDirty = true;
dlg.setResultConverter(bt -> bt == ok
? new java.util.ArrayList<>(list.getSelectionModel().getSelectedItems())
: null);
dlg.showAndWait().ifPresent(selected -> {
for (String clip : selected) {
if (!animSetClipListView.getItems().contains(clip)) {
animSetClipListView.getItems().add(clip);
animSetDirty = true;
}
}
});
});
@@ -6026,9 +6359,17 @@ public class EditorApp extends Application {
if (animSetClipListView == null) return;
String clip = animSetClipListView.getSelectionModel().getSelectedItem();
if (clip == null) return;
if (animCurrentModelPath == null || animCurrentModelPath.isBlank()) {
if (animPreviewStatusLabel != null)
animPreviewStatusLabel.setText("Erst ein Charakter-Modell laden.");
return;
}
animSetPendingPlayClip = clip;
input.animPreviewLoadPath = "animations/clips/" + clip + ".j3o";
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade " + clip + "");
// Clip zur aktuell geladenen Figur hinzufügen (nicht als Modell laden).
// Nach Abschluss setzt AnimPreviewState animPreviewClips, das den
// animSetPendingPlayClip-Trigger auslöst und den Clip abspielt.
input.animPreviewAddAnimPath = "animations/clips/" + clip + ".j3o";
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade Clip " + clip + "");
}
private void showAddActionToSetDialog() {

View File

@@ -24,6 +24,7 @@ import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import de.blight.editor.state.VoxelEditorState;
import de.blight.game.console.JmeConsole;
import de.blight.game.state.DayNightState;
import javafx.scene.image.WritableImage;
@@ -105,6 +106,7 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new AnimPreviewState(input));
stateManager.attach(new ModelEditorState(input));
stateManager.attach(new ItemPlacementState(input));
stateManager.attach(new VoxelEditorState(input));
input.loadingStatus = "Initialisiere Konsole...";
jmeConsole = new JmeConsole(false);

View File

@@ -7,6 +7,7 @@ import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.TextureTool;
import de.blight.editor.tool.UpperHeightTool;
import de.blight.editor.tool.VoxelTool;
import de.blight.editor.tree.PalmOptions;
import de.blight.editor.tree.TreeParams;
import javafx.scene.image.WritableImage;
@@ -25,6 +26,7 @@ public class SharedInput {
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public final VoxelTool voxelTool = new VoxelTool();
public volatile EditorTool activeTool = heightTool;
// ── Initialisierungs-Status ───────────────────────────────────────────────
@@ -37,6 +39,12 @@ public class SharedInput {
// ── Upper-Layer-Sichtbarkeit ─────────────────────────────────────────────
public volatile boolean upperLayerVisible = true;
// ── Kameramodus ───────────────────────────────────────────────────────────
/** 0 = Orbit-Terrain-Kamera (Standard), 1 = FreeFly. */
public volatile int camMode = 0;
public static final int CAM_ORBIT = 0;
public static final int CAM_FREEFLY = 1;
// ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
public volatile boolean forward, backward, left, right, up, down;
@@ -277,6 +285,19 @@ public class SharedInput {
// ── Kamera-Info (JME3-Thread schreibt, JavaFX-Thread liest) ─────────────
public volatile float camX = 0f, camY = 0f, camZ = 0f;
// ── Kamera-Teleport (JavaFX schreibt, JME3-Thread liest und setzt zurück) ─
/** NaN = kein Teleport angefordert. Y=NaN → Terrain-Höhe + Offset verwenden. */
public volatile float pendingGotoX = Float.NaN;
public volatile float pendingGotoY = Float.NaN;
public volatile float pendingGotoZ = Float.NaN;
/** NaN = Kamera-Pitch unverändert lassen. */
public volatile float pendingGotoPitch = Float.NaN;
// ── Reload-Signale nach Löschung aus MapObjectsView ──────────────────────
public volatile boolean reloadPlacedModels = false;
public volatile boolean reloadPlacedItems = false;
public volatile boolean reloadPlacedOther = false; // Lichter, Emitter, Wasser, Bereiche, Zonen
/** Yaw in Grad: 0° = Süden (Z), 90° = Westen (X), ±180° = Norden (+Z). */
public volatile float camYaw = 0f;
/** Pitch in Grad: positiv = Blick nach oben, negativ = nach unten. */
@@ -602,6 +623,14 @@ public class SharedInput {
public volatile String modelEditorLod2Path = "";
public volatile boolean modelEditorLodChanged = false;
// ── Voxel-Werkzeug ────────────────────────────────────────────────────────
/** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */
public static final int LAYER_VOXEL = 16;
/** Klick/Drag im Viewport im Voxel-Modus. */
public record VoxelEdit(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<VoxelEdit> voxelEditQueue = new ConcurrentLinkedQueue<>();
// ── Item-Platzierung ──────────────────────────────────────────────────────
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */
public static final int LAYER_ITEMS = 21;

View File

@@ -82,25 +82,26 @@ public class ItemPlacementState extends BaseAppState {
@Override
protected void onEnable() {
reloadFromDisk();
rootNode.attachChild(itemRoot);
rootNode.attachChild(previewNode);
}
public void reloadFromDisk() {
items.clear();
nodes.clear();
itemRoot.detachAllChildren();
try {
items.addAll(PlacedItemIO.load());
} catch (Exception e) {
log.warn("[ItemPlacement] Laden fehlgeschlagen: {}", e.getMessage());
}
for (PlacedItem pi : items) {
Node n = buildItemNode(pi.itemId());
n.setLocalTranslation(pi.x(), pi.y() + 0.25f, pi.z());
itemRoot.attachChild(n);
nodes.add(n);
}
rootNode.attachChild(itemRoot);
rootNode.attachChild(previewNode);
}
@Override
@@ -116,6 +117,10 @@ public class ItemPlacementState extends BaseAppState {
@Override
public void update(float tpf) {
if (input.reloadPlacedItems) {
input.reloadPlacedItems = false;
reloadFromDisk();
}
updatePreview();
SharedInput.ObjectClick click;
@@ -161,7 +166,7 @@ public class ItemPlacementState extends BaseAppState {
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
PlacedItem pi = new PlacedItem(input.pendingItemId, pt.x, pt.y, pt.z);
PlacedItem pi = PlacedItem.create(input.pendingItemId, pt.x, pt.y, pt.z);
items.add(pi);
Node n = buildItemNode(pi.itemId());

View File

@@ -317,6 +317,11 @@ public class SceneObjectState extends BaseAppState {
deleteSelected();
}
if (input.reloadPlacedModels) {
input.reloadPlacedModels = false;
try { loadPlacedModels(de.blight.common.PlacedModelIO.load()); } catch (Exception ignored) {}
}
// Zusammenfassen
if (input.mergeSelectedRequested) {
input.mergeSelectedRequested = false;

View File

@@ -41,6 +41,7 @@ import de.blight.common.PlacedWater;
import de.blight.common.RiverPoint;
import de.blight.common.SoundAreaIO;
import de.blight.common.WaterBodyIO;
import de.blight.common.BlightHome;
import de.blight.common.MapData;
import de.blight.common.MapIO;
import de.blight.common.PlacedModelIO;
@@ -130,7 +131,7 @@ public class TerrainEditorState extends BaseAppState {
private Texture2D upperSplatTex;
// ── Kameraposition ────────────────────────────────────────────────────────
private static final Path EDITOR_PREFS = de.blight.editor.ProjectRoot.resolve("config", "editor.prefs");
private static final Path EDITOR_PREFS = BlightHome.resolve("config", "editor.prefs");
private static final float DEFAULT_CAM_Y = 50f;
private static final float DEFAULT_PITCH = (float) (-Math.PI / 4); // -45°
@@ -695,6 +696,22 @@ public class TerrainEditorState extends BaseAppState {
@Override
public void update(float tpf) {
float pgx = input.pendingGotoX;
if (!Float.isNaN(pgx)) {
input.pendingGotoX = Float.NaN;
float pgz = input.pendingGotoZ;
float pgy = input.pendingGotoY;
if (Float.isNaN(pgy)) {
Float h = terrain != null ? terrain.getHeight(new com.jme3.math.Vector2f(pgx, pgz)) : null;
pgy = h != null ? h + 20f : camPos.y;
}
camPos.set(pgx, pgy, pgz);
float pp = input.pendingGotoPitch;
if (!Float.isNaN(pp)) {
input.pendingGotoPitch = Float.NaN;
camPitch = FastMath.clamp(pp, -FastMath.HALF_PI + 0.05f, FastMath.HALF_PI - 0.05f);
}
}
updateCamera(tpf);
processEdits();
processTextureEdits();
@@ -729,6 +746,17 @@ public class TerrainEditorState extends BaseAppState {
terrain.setMaterial(buildTerrainMaterial());
}
if (input.reloadPlacedOther) {
input.reloadPlacedOther = false;
try { if (lightState != null) lightState.loadPlacedLights(de.blight.common.LightIO.load()); } catch (Exception ignored) {}
try { if (emitterState != null) emitterState.loadPlacedEmitters(de.blight.common.EmitterIO.load()); } catch (Exception ignored) {}
try { if (waterBodyState != null) waterBodyState.loadPlacedBodies(de.blight.common.WaterBodyIO.load()); } catch (Exception ignored) {}
try { if (soundAreaState != null) soundAreaState.loadAreas(de.blight.common.SoundAreaIO.load()); } catch (Exception ignored) {}
try { if (areaState != null) areaState.loadAreas(de.blight.common.AreaIO.load()); } catch (Exception ignored) {}
try { if (locationZoneState != null) locationZoneState.loadZones(de.blight.common.LocationZoneIO.load()); } catch (Exception ignored) {}
try { if (riverEditorState != null) riverEditorState.loadPlacedRivers(de.blight.common.RiverIO.load()); } catch (Exception ignored) {}
}
if (input.saveRequested) {
input.saveRequested = false;
performSave();
@@ -814,6 +842,9 @@ public class TerrainEditorState extends BaseAppState {
}
}
/** Gibt den TerrainQuad-Node zurück (z.B. für Voxel-Raycasts). */
public TerrainQuad getTerrainNode() { return terrain; }
/** Gibt die Terrain-Höhe (Welt-Y) an der angegebenen Welt-XZ-Position zurück. */
public float getTerrainHeightAt(float worldX, float worldZ) {
if (terrain == null) return 0f;
@@ -859,7 +890,19 @@ public class TerrainEditorState extends BaseAppState {
final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null;
final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null;
// ── Schwere Arbeit (Upsample + Datei-I/O) auf Hintergrund-Thread ─────
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
// Löschoperationen aus dem JavaFX-Thread entsteht.
try { if (models != null) PlacedModelIO.save(models); } catch (IOException e) { log.error("Modelle speichern", e); }
try { if (lights != null) LightIO.save(lights); } catch (IOException e) { log.error("Lichter speichern", e); }
try { if (emitters != null) EmitterIO.save(emitters); } catch (IOException e) { log.error("Emitter speichern", e); }
try { if (waters != null) WaterBodyIO.save(waters); } catch (IOException e) { log.error("Wasser speichern", e); }
try { if (rivers != null) de.blight.common.RiverIO.save(rivers); } catch (IOException e) { log.error("Flüsse speichern", e); }
try { if (soundAreas != null) SoundAreaIO.save(soundAreas); } catch (IOException e) { log.error("Soundbereiche speichern", e); }
try { if (areas != null) AreaIO.save(areas); } catch (IOException e) { log.error("Bereiche speichern", e); }
try { if (locationZones != null) LocationZoneIO.save(locationZones); } catch (IOException e) { log.error("Zonen speichern", e); }
// ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─
saveExecutor.submit(() -> {
try {
MapData data = new MapData();
@@ -905,14 +948,6 @@ public class TerrainEditorState extends BaseAppState {
log.error("Chunk-Export fehlgeschlagen", e);
}
}
if (models != null) PlacedModelIO.save(models);
if (lights != null) LightIO.save(lights);
if (emitters != null) EmitterIO.save(emitters);
if (waters != null) WaterBodyIO.save(waters);
if (rivers != null) de.blight.common.RiverIO.save(rivers);
if (soundAreas != null) SoundAreaIO.save(soundAreas);
if (areas != null) AreaIO.save(areas);
if (locationZones != null) LocationZoneIO.save(locationZones);
input.saveStatusMsg = "Gespeichert: " + MapIO.getMapPath();
log.info("{}", input.saveStatusMsg);
@@ -941,7 +976,8 @@ public class TerrainEditorState extends BaseAppState {
|| layer == SharedInput.LAYER_WATER
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|| layer == SharedInput.LAYER_LOCATION_ZONES
|| layer == SharedInput.LAYER_PLAY_TOOL || mx < 0) {
|| layer == SharedInput.LAYER_PLAY_TOOL
|| layer == SharedInput.LAYER_VOXEL || mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
@@ -1235,18 +1271,29 @@ public class TerrainEditorState extends BaseAppState {
float terrainDist = terrainDistBelow();
float speed = FastMath.clamp(terrainDist, 5f, CAM_SPEED) * tpf;
float hFwdX = -FastMath.sin(camYaw);
float hFwdZ = -FastMath.cos(camYaw);
if (input.forward) { camPos.x -= hFwdX * speed; camPos.z -= hFwdZ * speed; }
if (input.backward) { camPos.x += hFwdX * speed; camPos.z += hFwdZ * speed; }
if (input.camMode == SharedInput.CAM_FREEFLY) {
Vector3f fwd = cam.getDirection().clone();
Vector3f lft = cam.getLeft().clone();
if (input.forward) camPos.addLocal(fwd.mult(speed));
if (input.backward) camPos.subtractLocal(fwd.mult(speed));
if (input.left) camPos.addLocal(lft.mult(speed));
if (input.right) camPos.subtractLocal(lft.mult(speed));
if (input.up) camPos.y += speed;
if (input.down) camPos.y -= speed;
} else {
float hFwdX = -FastMath.sin(camYaw);
float hFwdZ = -FastMath.cos(camYaw);
if (input.forward) { camPos.x -= hFwdX * speed; camPos.z -= hFwdZ * speed; }
if (input.backward) { camPos.x += hFwdX * speed; camPos.z += hFwdZ * speed; }
Vector3f lft = cam.getLeft().clone().setY(0);
if (lft.lengthSquared() > 0.001f) lft.normalizeLocal();
if (input.left) camPos.addLocal(lft.mult(speed));
if (input.right) camPos.subtractLocal(lft.mult(speed));
Vector3f lft = cam.getLeft().clone().setY(0);
if (lft.lengthSquared() > 0.001f) lft.normalizeLocal();
if (input.left) camPos.addLocal(lft.mult(speed));
if (input.right) camPos.subtractLocal(lft.mult(speed));
if (input.up) orbitAroundTerrain( ORBIT_SPEED * tpf);
if (input.down) orbitAroundTerrain(-ORBIT_SPEED * tpf);
if (input.up) orbitAroundTerrain( ORBIT_SPEED * tpf);
if (input.down) orbitAroundTerrain(-ORBIT_SPEED * tpf);
}
int scroll = input.scrollAccum.getAndSet(0);
if (scroll != 0)

View File

@@ -0,0 +1,603 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Texture;
import com.jme3.util.BufferUtils;
import de.blight.common.VoxelChunk;
import de.blight.common.VoxelChunkIO;
import de.blight.editor.SharedInput;
import de.blight.editor.state.TerrainEditorState;
import de.blight.game.state.MarchingCubes;
import de.blight.game.state.VoxelChunkNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.*;
import java.util.concurrent.*;
/**
* Editor-AppState für das Voxel-Werkzeug.
*
* Verwaltet alle im Editor geladenen VoxelChunks (in-memory HashMap +
* gerenderte VoxelChunkNodes), verarbeitet Edit-Events aus der SharedInput-Queue
* und kümmert sich um Auto-Save und Hintergrund-LOD-Berechnung.
*/
public class VoxelEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(VoxelEditorState.class);
/** Maximale Edits pro Frame. */
private static final int MAX_EDITS_PER_FRAME = 4;
/** Idle-Zeit in Sekunden, nach der LOD1/2 im Hintergrund gebaut werden. */
private static final float LOD_REBUILD_IDLE_S = 0.5f;
/** Idle-Zeit in Sekunden für Auto-Save. */
private static final float AUTO_SAVE_IDLE_S = 3.0f;
// ── JME-Zustand ──────────────────────────────────────────────────────────
private SimpleApplication app;
private AssetManager assets;
private Camera cam;
/** Root-Node für alle VoxelChunkNodes. */
private Node voxelRoot;
/** Wird von TerrainEditorState gesetzt; wird als Raycast-Ziel genutzt. */
private Node terrainNode;
private TerrainQuad terrainQuad;
private final SharedInput input;
// ── Chunk-Verwaltung ─────────────────────────────────────────────────────
/** key → VoxelChunk (in-memory, ggf. dirty). */
private final Map<Long, VoxelChunk> chunks = new HashMap<>();
/** key → zugehöriger VoxelChunkNode in der Szene. */
private final Map<Long, VoxelChunkNode> nodes = new HashMap<>();
// ── Timers ───────────────────────────────────────────────────────────────
/** Sekunden seit letztem Edit. */
private float idleSinceEdit = 0f;
/** Sekunden seit letztem Save. */
private float idleSinceSave = 0f;
/** LOD-Rebuild ist für diesen Frame angefordert. */
private boolean lodRebuildPending = false;
// ── Hintergrund-Executor ─────────────────────────────────────────────────
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "VoxelEditor-BG");
t.setDaemon(true);
return t;
});
// ── Material ─────────────────────────────────────────────────────────────
private Material voxelMaterial;
// ── Brush-Indikator ───────────────────────────────────────────────────────
private Geometry brushIndicator;
// ── LOD-Rebuild-Queue ─────────────────────────────────────────────────────
/** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */
private final ConcurrentLinkedQueue<Long> lodRebuildQueue = new ConcurrentLinkedQueue<>();
/** Chunks mit fertigen LOD1/2-Meshes, die im JME-Thread übernommen werden. */
private final ConcurrentLinkedQueue<Runnable> lodResultQueue = new ConcurrentLinkedQueue<>();
// ── Konstruktor ───────────────────────────────────────────────────────────
public VoxelEditorState(SharedInput input) {
this.input = input;
}
// ── AppState-Lifecycle ────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
this.app = (SimpleApplication) application;
this.assets = app.getAssetManager();
this.cam = app.getCamera();
voxelRoot = new Node("voxelEditorRoot");
app.getRootNode().attachChild(voxelRoot);
// TerrainEditorState wurde vor uns attached und ist zu diesem Zeitpunkt initialisiert
TerrainEditorState tes = app.getStateManager().getState(TerrainEditorState.class);
if (tes != null) {
terrainNode = tes.getTerrainNode();
terrainQuad = terrainNode instanceof TerrainQuad tq ? tq : null;
}
voxelMaterial = buildMaterial();
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
// Alle vorhandenen .blvc-Dateien laden
List<VoxelChunk> loaded = VoxelChunkIO.loadAll();
for (VoxelChunk chunk : loaded) {
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
chunks.put(key, chunk);
addNodeForChunk(key, chunk);
}
log.info("VoxelEditorState: {} Chunks geladen.", loaded.size());
}
@Override
protected void cleanup(Application app) {
executor.shutdownNow();
voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
nodes.clear();
chunks.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
// LOD-Ergebnisse aus dem Hintergrund-Thread übernehmen
Runnable r;
while ((r = lodResultQueue.poll()) != null) r.run();
// Brush-Indikator immer aktualisieren (zeigen/verstecken je nach Layer)
updateBrushIndicator();
// Nur aktiv wenn LAYER_VOXEL gesetzt
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
idleSinceEdit = 0f;
idleSinceSave += tpf;
checkAutoSave();
return;
}
// Edit-Queue verarbeiten (max. MAX_EDITS_PER_FRAME)
int processed = 0;
SharedInput.VoxelEdit edit;
while (processed < MAX_EDITS_PER_FRAME
&& (edit = input.voxelEditQueue.poll()) != null) {
handleEdit(edit);
processed++;
idleSinceEdit = 0f;
idleSinceSave = 0f;
}
if (processed == 0) {
idleSinceEdit += tpf;
idleSinceSave += tpf;
}
// LOD1/2 nach Idle-Zeit im Hintergrund bauen
if (idleSinceEdit >= LOD_REBUILD_IDLE_S && !lodRebuildPending) {
lodRebuildPending = true;
scheduleLodRebuild();
}
checkAutoSave();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
/**
* Wird von TerrainEditorState gesetzt, damit VoxelEditorState Raycasts
* gegen das Terrain durchführen kann.
*/
public void setTerrainNode(Node terrain) {
this.terrainNode = terrain;
this.terrainQuad = terrain instanceof TerrainQuad tq ? tq : null;
}
/**
* Speichert alle dirty Chunks synchron.
* Kann von außen aufgerufen werden (z.B. beim globalen Speichern).
*/
public void saveAll() {
for (VoxelChunk chunk : chunks.values()) {
if (!chunk.dirty) continue;
try {
VoxelChunkIO.save(chunk);
} catch (IOException e) {
log.error("Fehler beim Speichern von VoxelChunk ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage());
}
}
}
// ── Intern: Edit verarbeiten ───────────────────────────────────────────────
private void handleEdit(SharedInput.VoxelEdit edit) {
float jmeX = edit.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - edit.screenY() * (float) input.viewportScaleY;
Vector3f worldPos = raycast(jmeX, jmeY);
if (worldPos == null) return;
applyEdit(worldPos);
}
/**
* Führt einen Raycast durch und gibt den nächstgelegenen Treffpunkt zurück,
* oder null wenn kein Treffer.
*/
private Vector3f raycast(float screenX, float screenY) {
Vector2f click2d = new Vector2f(screenX, screenY);
Vector3f origin = cam.getWorldCoordinates(click2d, 0f);
Vector3f target = cam.getWorldCoordinates(click2d, 1f);
Vector3f dir = target.subtract(origin).normalizeLocal();
Ray ray = new Ray(origin, dir);
CollisionResults results = new CollisionResults();
float bestDist = Float.MAX_VALUE;
Vector3f best = null;
// Terrain
if (terrainNode != null) {
terrainNode.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
bestDist = cr.getDistance();
best = cr.getContactPoint();
}
results.clear();
}
}
// Voxel-Geometrie
voxelRoot.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
best = cr.getContactPoint();
}
}
return best;
}
/**
* Wendet den Pinsel auf alle betroffenen Chunks an.
*
* Modi 0-3: Solid-Voxel hinzufügen (verschiedene Falloff-Kurven), danach alle Voxel
* unterhalb der Terrain-Oberfläche in der Pinselfläche entfernen.
* Modus 4 (Entfernen): Voxel löschen für Höhlen unterhalb des Terrains.
*/
private void applyEdit(Vector3f worldPos) {
float radius = (float) input.voxelTool.brushRadius.getValue();
float strength = (float) input.voxelTool.brushStrength.getValue();
int modeIdx = input.voxelTool.mode.getSelectedIndex();
int texSlot = input.voxelTool.textureSlot.getSelectedIndex();
byte matId = (byte)(texSlot & 3);
boolean remove = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE);
boolean addFlat = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_ADD);
float wx = worldPos.x, wy = worldPos.y, wz = worldPos.z;
int cxMin = VoxelChunk.worldXToCx(wx - radius);
int cxMax = VoxelChunk.worldXToCx(wx + radius);
int czMin = VoxelChunk.worldZToCz(wz - radius);
int czMax = VoxelChunk.worldZToCz(wz + radius);
// Spalten-Modi bauen nach oben bis zu strength Voxel → Y-Range entsprechend erweitern
int cyMin = VoxelChunk.worldYToCy(wy - radius);
int cyMax = (!remove && !addFlat)
? VoxelChunk.worldYToCy(wy + strength)
: VoxelChunk.worldYToCy(wy + radius);
for (int cz = czMin; cz <= czMax; cz++) {
for (int cx = cxMin; cx <= cxMax; cx++) {
for (int cy = cyMin; cy <= cyMax; cy++) {
VoxelChunkNode node = getOrCreateNode(cx, cy, cz);
VoxelChunk chunk = node.getChunk();
float lx = VoxelChunk.worldXToLocal(wx, cx);
float ly = VoxelChunk.worldYToLocal(wy, cy);
float lz = VoxelChunk.worldZToLocal(wz, cz);
if (remove) {
chunk.applyBrush(lx, ly, lz, radius, Byte.MIN_VALUE, (byte)0);
} else if (addFlat) {
byte d = (byte) Math.min(127, (int) strength);
chunk.applyBrush(lx, ly, lz, radius, d, matId);
} else {
applyAddBrush(chunk, lx, ly, lz, radius, strength, modeIdx, matId);
clearVoxelsBelowTerrain(chunk, cx, cy, cz, wx, wz, radius);
}
node.rebuildMesh(0);
node.setActiveLod(0);
long key = chunkKey(cx, cy, cz);
if (!lodRebuildQueue.contains(key)) lodRebuildQueue.add(key);
lodRebuildPending = false;
}
}
}
}
/**
* Spalten-Pinsel: Für jede XZ-Position im Pinselradius wird eine Voxel-Säule
* nach oben aufgebaut. Die Säulenhöhe ist proportional zum mode-spezifischen Falloff:
* Spike → steile Spitze in der Mitte
* Plateau → gleichmäßige flache Erhöhung
* Sinus → sanfte Kuppel
* Smooth → weiche raised-cosine Kuppel
*/
private void applyAddBrush(VoxelChunk chunk,
float lx, float ly, float lz,
float radius, float strength,
int mode, byte matId) {
int x0 = Math.max(0, (int)(lx - radius));
int x1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lx + radius));
int z0 = Math.max(0, (int)(lz - radius));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lz + radius));
float r2 = radius * radius;
byte d = (byte) Math.min(127, (int) strength);
// Basiszeile innerhalb dieses Chunks (kann negativ sein für höhere Chunks)
int baseY = (int) ly;
for (int z = z0; z <= z1; z++) {
float dz = z - lz;
for (int x = x0; x <= x1; x++) {
float dx = x - lx;
float d2 = dx*dx + dz*dz;
if (d2 > r2) continue;
float t = (float) Math.sqrt(d2) / radius; // 0=Mitte, 1=Rand
float falloff;
switch (mode) {
case de.blight.editor.tool.VoxelTool.MODE_SINUS ->
falloff = (float) Math.cos(t * Math.PI / 2);
case de.blight.editor.tool.VoxelTool.MODE_SPIKE ->
falloff = (1f - t) * (1f - t);
case de.blight.editor.tool.VoxelTool.MODE_SMOOTH ->
falloff = (float)(0.5 * (1 + Math.cos(t * Math.PI)));
default -> // PLATEAU
falloff = 1f;
}
int colHeight = (int)(strength * falloff);
if (colHeight < 1) continue;
// Säule von baseY bis baseY+colHeight, geclampt auf Chunk-Grenzen
int yBottom = Math.max(0, baseY);
int yTop = Math.min(VoxelChunk.SIZE - 1, baseY + colHeight);
for (int y = yBottom; y <= yTop; y++) {
if (chunk.getDensity(x, y, z) <= 0) {
chunk.setDensity(x, y, z, d);
chunk.setMaterial(x, y, z, matId);
}
}
}
}
}
/**
* Entfernt alle Solid-Voxel in der X/Z-Fußfläche des Pinsels, die unterhalb der
* aktuellen Terrain-Oberfläche liegen.
*/
private void clearVoxelsBelowTerrain(VoxelChunk chunk, int cx, int cy, int cz,
float brushWx, float brushWz, float radius) {
if (terrainQuad == null || chunk.isEmpty()) return;
float lxC = VoxelChunk.worldXToLocal(brushWx, cx);
float lzC = VoxelChunk.worldZToLocal(brushWz, cz);
int x0 = Math.max(0, (int)(lxC - radius));
int x1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lxC + radius));
int z0 = Math.max(0, (int)(lzC - radius));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lzC + radius));
for (int lz = z0; lz <= z1; lz++) {
float worldZ = VoxelChunk.toWorldZ(cz, lz);
for (int lx = x0; lx <= x1; lx++) {
float worldX = VoxelChunk.toWorldX(cx, lx);
Float h = terrainQuad.getHeight(new Vector2f(worldX, worldZ));
if (h == null) continue;
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
if (chunk.getDensity(lx, ly, lz) <= 0) continue;
if (VoxelChunk.toWorldY(cy, ly) <= h) {
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
}
}
}
}
}
// ── Intern: Chunk/Node-Verwaltung ─────────────────────────────────────────
/**
* Gibt den VoxelChunkNode für (cx,cy,cz) zurück.
* Legt neuen Chunk und Node an, falls noch nicht vorhanden.
*/
private VoxelChunkNode getOrCreateNode(int cx, int cy, int cz) {
long key = chunkKey(cx, cy, cz);
VoxelChunkNode node = nodes.get(key);
if (node != null) return node;
// Ggf. aus Datei laden
VoxelChunk chunk;
if (VoxelChunkIO.exists(cx, cy, cz)) {
try {
chunk = VoxelChunkIO.load(cx, cy, cz);
} catch (IOException e) {
log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}",
cx, cy, cz, e.getMessage());
chunk = new VoxelChunk(cx, cy, cz);
}
} else {
chunk = new VoxelChunk(cx, cy, cz);
}
chunks.put(key, chunk);
node = addNodeForChunk(key, chunk);
return node;
}
/** Erstellt den VoxelChunkNode und hängt ihn in den Szenen-Graph ein. */
private VoxelChunkNode addNodeForChunk(long key, VoxelChunk chunk) {
VoxelChunkNode node = new VoxelChunkNode(chunk, voxelMaterial);
// LOD0 bauen
node.rebuildMesh(0);
node.setActiveLod(0);
voxelRoot.attachChild(node);
nodes.put(key, node);
// LOD1/2 für Hintergrund vormerken
lodRebuildQueue.add(key);
return node;
}
// ── Intern: Hintergrund-LOD ───────────────────────────────────────────────
private void scheduleLodRebuild() {
executor.submit(() -> {
Long key;
while ((key = lodRebuildQueue.poll()) != null) {
VoxelChunk chunk = chunks.get(key);
if (chunk == null) continue;
// LOD1 und LOD2 berechnen (kein JME-Context nötig nur reine Berechnungen)
var mesh1 = MarchingCubes.build(chunk, 4);
var mesh2 = MarchingCubes.build(chunk, 16);
final Long fKey = key;
// Fertige Meshes an JME-Thread übergeben (kein zweites Build!)
lodResultQueue.add(() -> {
VoxelChunkNode node = nodes.get(fKey);
if (node == null) return;
if (mesh1 != null) node.setLodMesh(1, mesh1);
if (mesh2 != null) node.setLodMesh(2, mesh2);
});
}
});
}
// ── Intern: Auto-Save ─────────────────────────────────────────────────────
private void checkAutoSave() {
if (idleSinceSave < AUTO_SAVE_IDLE_S) return;
boolean anyDirty = false;
for (VoxelChunk c : chunks.values()) { if (c.dirty) { anyDirty = true; break; } }
if (!anyDirty) return;
idleSinceSave = 0f;
executor.submit(() -> {
for (VoxelChunk chunk : chunks.values()) {
if (!chunk.dirty) continue;
try {
VoxelChunkIO.save(chunk);
} catch (IOException e) {
log.error("Auto-Save VoxelChunk ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage());
}
}
});
}
// ── Intern: Material ─────────────────────────────────────────────────────
private Material buildMaterial() {
Material mat = new Material(assets, "MatDefs/Voxel.j3md");
mat.setFloat("TexScale", 4f);
String[] texPaths = input.terrainTexturePaths;
String[] slotNames = {"Tex0", "Tex1", "Tex2", "Tex3"};
for (int i = 0; i < slotNames.length; i++) {
String path = (i < texPaths.length && texPaths[i] != null && !texPaths[i].isEmpty())
? texPaths[i] : "Common/Textures/MissingTexture.png";
try {
Texture t = assets.loadTexture(path);
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(slotNames[i], t);
} catch (Exception e) {
log.warn("VoxelEditorState: Textur {} ({}) nicht ladbar: {}", slotNames[i], path, e.getMessage());
}
}
return mat;
}
// ── Intern: Brush-Indikator ───────────────────────────────────────────────
private void updateBrushIndicator() {
if (brushIndicator == null) return;
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float mx = input.mouseScreenX;
float my = input.mouseScreenY;
if (mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float jmeX = mx * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Vector3f pos = raycast(jmeX, jmeY);
if (pos != null) {
float r = (float) input.voxelTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(pos.x, pos.y + 0.3f, pos.z);
brushIndicator.setLocalScale(r, 1f, r);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
} else {
brushIndicator.setCullHint(Spatial.CullHint.Always);
}
}
private Geometry buildBrushIndicator() {
int segments = 32;
FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3);
pos.put(0f).put(0f).put(0f);
for (int i = 0; i < segments; i++) {
float a = FastMath.TWO_PI * i / segments;
pos.put(FastMath.cos(a)).put(0f).put(FastMath.sin(a));
}
IntBuffer idx = BufferUtils.createIntBuffer(segments * 3);
for (int i = 0; i < segments; i++) {
idx.put(0);
idx.put(1 + i);
idx.put(1 + (i + 1) % segments);
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("voxelBrushIndicator", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.2f, 0.6f, 1f, 0.4f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Hilfsmethode ─────────────────────────────────────────────────────────
private static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
}

View File

@@ -0,0 +1,54 @@
package de.blight.editor.tool;
import java.util.List;
/**
* Voxel-Klippen/Höhlen-Werkzeug.
*
* Modi 0-3 fügen Solid-Voxel oberhalb des Terrains hinzu (gleiche Pinselformen wie das Höhen-Tool).
* Voxel, die unterhalb der Terrain-Oberfläche landen, werden automatisch entfernt.
* Modus 4 entfernt Voxel zum Aushöhlen von Höhlen unterhalb des Terrains.
*/
public class VoxelTool extends EditorTool {
public static final int MODE_SINUS = 0;
public static final int MODE_SPIKE = 1;
public static final int MODE_PLATEAU = 2;
public static final int MODE_SMOOTH = 3;
public static final int MODE_REMOVE = 4;
/** Einfaches Hinzufügen: gleichmäßige Dichte überall im Pinsel, kein Terrain-Check. */
public static final int MODE_ADD = 5;
public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus",
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Entfernen", "Hinzufügen"},
MODE_SPIKE,
new String[]{
"img/editor/terraintool_sinus.png",
"img/editor/terraintool_spike.png",
"img/editor/terraintool_plateau.png",
"img/editor/terraintool_smooth.png",
null,
null
}
);
public final ChoiceToolParameter textureSlot = new ChoiceToolParameter(
"Textur", new String[]{"S1", "S2", "S3", "S4"}, 0
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
@Override public String getName() { return "Voxel"; }
@Override
public List<ChoiceToolParameter> getChoiceParameters() {
return List.of(mode, textureSlot);
}
@Override
public List<ToolParameter> getParameters() {
return List.of(brushRadius, brushStrength);
}
}

View File

@@ -1,8 +1,12 @@
package de.blight.editor.ui;
import de.blight.common.model.CharacterStat;
import de.blight.common.model.ConsumableEffect;
import de.blight.common.model.Item;
import de.blight.common.model.ItemCategory;
import de.blight.common.model.ItemCategoryManager;
import de.blight.common.model.ItemIO;
import de.blight.common.model.ItemSubCategory;
import de.blight.common.model.ObjectReference;
import de.blight.common.model.TextReference;
import javafx.collections.FXCollections;
@@ -17,6 +21,7 @@ import javafx.scene.paint.Color;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
@@ -33,14 +38,18 @@ public class ItemEditorView extends BorderPane {
private Item current = null;
// Form-Felder
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private TextField idField;
private ComboBox<ItemCategory> catCombo;
private ComboBox<ItemSubCategory> subCatCombo;
private TextField nameField;
private TextField descField;
private Spinner<Integer> goldSpinner;
private TextField modelRefField;
private VBox formContainer;
private Button deleteBtn;
private CheckBox consumableCheck;
private VBox consumablesSection;
private VBox effectsRows;
public ItemEditorView(Path itemDir) {
this.itemDir = itemDir;
@@ -146,8 +155,8 @@ public class ItemEditorView extends BorderPane {
case GEAR -> "#5588cc";
case CONSUMABLES -> "#55aa55";
case QUEST_ITEMS -> "#ccaa33";
case USABLES -> "#aa55cc";
case MISC -> "#778899";
case CRAFTING_ITEMS -> "#aa55cc";
case MISC_ITEMS -> "#778899";
};
}
@@ -200,6 +209,36 @@ public class ItemEditorView extends BorderPane {
}
});
subCatCombo = new ComboBox<>();
subCatCombo.setMaxWidth(Double.MAX_VALUE);
subCatCombo.setDisable(true);
subCatCombo.setPromptText("Erst Kategorie wählen");
subCatCombo.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(ItemSubCategory sub, boolean empty) {
super.updateItem(sub, empty);
setText(empty || sub == null ? null : sub.name());
}
});
subCatCombo.setButtonCell(new ListCell<>() {
@Override protected void updateItem(ItemSubCategory sub, boolean empty) {
super.updateItem(sub, empty);
setText(empty || sub == null ? null : sub.name());
}
});
// Wenn Kategorie wechselt: SubKategorie-Liste aktualisieren
catCombo.valueProperty().addListener((obs, oldCat, newCat) -> {
ItemSubCategory prevSub = subCatCombo.getValue();
subCatCombo.getItems().setAll(
newCat != null ? ItemCategoryManager.getSubCategories(newCat) : java.util.List.of());
subCatCombo.setDisable(newCat == null || subCatCombo.getItems().isEmpty());
if (prevSub != null && subCatCombo.getItems().contains(prevSub)) {
subCatCombo.setValue(prevSub);
} else {
subCatCombo.setValue(null);
}
});
nameField = new TextField();
nameField.setPromptText("TextReference-Schlüssel");
descField = new TextField();
@@ -230,6 +269,26 @@ public class ItemEditorView extends BorderPane {
HBox.setHgrow(modelRefField, Priority.ALWAYS);
modelFullRow.setAlignment(Pos.CENTER_LEFT);
// ── Consumables-Sektion ───────────────────────────────────────────────
consumableCheck = new CheckBox("Konsumierbar");
consumableCheck.setStyle("-fx-text-fill: #aaa;");
effectsRows = new VBox(4);
Button addEffectBtn = new Button("+ Effekt hinzufügen");
addEffectBtn.setStyle("-fx-background-color: #2a4a2a; -fx-text-fill: #aaffaa; -fx-cursor: hand;");
addEffectBtn.setOnAction(e -> addEffectRow(null, 0));
consumablesSection = new VBox(6,
sectionTitle("Effekte beim Benutzen"),
effectsRows,
addEffectBtn
);
consumablesSection.setVisible(false);
consumablesSection.setManaged(false);
consumableCheck.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
consumablesSection.setVisible(isSelected);
consumablesSection.setManaged(isSelected);
});
Button saveBtn = new Button("Item speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
@@ -240,6 +299,7 @@ public class ItemEditorView extends BorderPane {
new Separator(),
row("Item-ID:", idField),
row("Kategorie:", catCombo),
row("SubKategorie:", subCatCombo),
new Separator(),
sectionTitle("Texte"),
row("Name:", nameField),
@@ -249,6 +309,9 @@ public class ItemEditorView extends BorderPane {
row("Wert (Gold):", goldSpinner),
row("Modell:", modelFullRow),
new Separator(),
consumableCheck,
consumablesSection,
new Separator(),
saveBtn
);
return form;
@@ -285,31 +348,54 @@ public class ItemEditorView extends BorderPane {
private void loadFormFromItem(Item item) {
idField.setText(safe(item.getItemId()));
catCombo.setValue(item.getCategory());
catCombo.setValue(item.getCategory()); // löst Listener aus → subCatCombo befüllt
subCatCombo.setValue(item.getSubCategory());
nameField.setText(item.getName() != null ? item.getName().id() : "");
descField.setText(item.getDescription() != null ? item.getDescription().id() : "");
goldSpinner.getValueFactory().setValue(item.getWorthGold());
ObjectReference ref = item.getModelRef();
modelRefField.setText(ref != null && ref.getPath() != null ? ref.getPath() : "");
consumableCheck.setSelected(item.isConsumable());
effectsRows.getChildren().clear();
if (item.getEffects() != null) {
for (ConsumableEffect effect : item.getEffects()) {
addEffectRow(effect.getStat(), effect.getValue());
}
}
}
private void saveFormToItem(Item item) {
item.setItemId(idField.getText().trim());
item.setCategory(catCombo.getValue());
item.setSubCategory(subCatCombo.getValue());
item.setName(ref(nameField));
item.setDescription(ref(descField));
item.setWorthGold(goldSpinner.getValue());
String mr = modelRefField.getText().trim();
item.setModelRef(mr.isBlank() ? null : new ObjectReference(mr));
item.setConsumable(consumableCheck.isSelected());
List<ConsumableEffect> effects = new ArrayList<>();
for (Node node : effectsRows.getChildren()) {
if (!(node instanceof HBox row)) continue;
@SuppressWarnings("unchecked")
ComboBox<CharacterStat> sc = (ComboBox<CharacterStat>) row.getChildren().get(0);
@SuppressWarnings("unchecked")
Spinner<Integer> sp = (Spinner<Integer>) row.getChildren().get(1);
if (sc.getValue() != null) effects.add(new ConsumableEffect(sc.getValue(), sp.getValue()));
}
item.setEffects(effects.isEmpty() ? null : effects);
}
private void clearForm() {
idField.clear();
catCombo.setValue(null);
catCombo.setValue(null); // setzt subCatCombo via Listener zurück
subCatCombo.setValue(null);
nameField.clear();
descField.clear();
goldSpinner.getValueFactory().setValue(0);
modelRefField.clear();
consumableCheck.setSelected(false);
effectsRows.getChildren().clear();
}
// ── List operations ───────────────────────────────────────────────────────
@@ -317,7 +403,7 @@ public class ItemEditorView extends BorderPane {
private void createItem() {
Item item = new Item();
item.setItemId("neues_item_" + System.currentTimeMillis());
item.setCategory(ItemCategory.MISC);
item.setCategory(ItemCategory.MISC_ITEMS);
items.add(item);
listView.getSelectionModel().select(item);
}
@@ -356,6 +442,28 @@ public class ItemEditorView extends BorderPane {
selectItem(savedId);
}
// ── Effect rows ───────────────────────────────────────────────────────────
private void addEffectRow(CharacterStat stat, int value) {
ComboBox<CharacterStat> statCombo = new ComboBox<>();
statCombo.getItems().addAll(CharacterStat.values());
statCombo.setValue(stat);
statCombo.setMinWidth(210);
statCombo.setPromptText("Stat wählen");
Spinner<Integer> valSpinner = new Spinner<>(-99999, 99999, value);
valSpinner.setEditable(true);
valSpinner.setPrefWidth(100);
Button removeBtn = new Button("");
removeBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #cc4444; -fx-cursor: hand;");
HBox row = new HBox(6, statCombo, valSpinner, removeBtn);
row.setAlignment(Pos.CENTER_LEFT);
removeBtn.setOnAction(e -> effectsRows.getChildren().remove(row));
effectsRows.getChildren().add(row);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static Label sectionTitle(String text) {

View File

@@ -0,0 +1,309 @@
package de.blight.editor.ui;
import de.blight.common.*;
import de.blight.common.RiverPoint;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Übersicht aller auf der Karte platzierten Objekte.
* Doppelklick teleportiert die Kamera 5 m über das Objekt und aktiviert das passende Werkzeug.
* Entf-Taste löscht den markierten Eintrag nach Sicherheitsabfrage.
*/
public class MapObjectsView extends VBox {
@FunctionalInterface
public interface JumpAction {
void execute(float x, float y, float z, String toolHint);
}
@FunctionalInterface
public interface DeleteAction {
void execute(Object placedObj, int index, String toolHint) throws Exception;
}
private record Entry(float x, float y, float z, String toolHint, Object placedObj, int index) {}
private final JumpAction onJump;
private final DeleteAction onDelete;
private final TreeItem<String> treeRoot = new TreeItem<>("Karte");
private final TreeView<String> tree;
private final Map<TreeItem<String>, Entry> entryMap = new HashMap<>();
public MapObjectsView(JumpAction onJump, DeleteAction onDelete) {
this.onJump = onJump;
this.onDelete = onDelete;
setStyle("-fx-background-color: #f0f0f0;");
setPadding(new Insets(8));
setSpacing(6);
Label titleLbl = new Label("Platzierte Objekte");
titleLbl.setStyle("-fx-font-weight: bold; -fx-font-size: 13; -fx-text-fill: #222;");
Button refreshBtn = new Button("");
refreshBtn.setStyle("-fx-background-color: transparent; -fx-text-fill: #555; -fx-cursor: hand;");
refreshBtn.setTooltip(new Tooltip("Neu laden"));
refreshBtn.setOnAction(e -> refresh());
HBox header = new HBox(8, titleLbl, refreshBtn);
header.setAlignment(Pos.CENTER_LEFT);
treeRoot.setExpanded(true);
tree = new TreeView<>(treeRoot);
tree.setShowRoot(false);
tree.setStyle("-fx-background-color: #fafafa;");
VBox.setVgrow(tree, Priority.ALWAYS);
tree.setCellFactory(tv -> new TreeCell<>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) { setText(null); setStyle(""); return; }
setText(item);
boolean isLeaf = getTreeItem() != null && getTreeItem().isLeaf();
setStyle(isLeaf
? "-fx-text-fill: #333; -fx-padding: 1 4 1 4;"
: "-fx-text-fill: #1a4a8a; -fx-font-weight: bold; -fx-padding: 2 4 2 0;");
}
});
tree.setOnMouseClicked(e -> {
if (e.getClickCount() != 2) return;
TreeItem<String> sel = tree.getSelectionModel().getSelectedItem();
if (sel == null || !sel.isLeaf()) return;
Entry entry = entryMap.get(sel);
if (entry != null) onJump.execute(entry.x(), entry.y(), entry.z(), entry.toolHint());
});
tree.setOnKeyPressed(e -> {
if (e.getCode() != KeyCode.DELETE) return;
TreeItem<String> sel = tree.getSelectionModel().getSelectedItem();
if (sel == null || !sel.isLeaf()) return;
Entry entry = entryMap.get(sel);
if (entry == null) return;
confirmAndDelete(sel.getValue(), entry);
e.consume();
});
getChildren().addAll(header, tree);
refresh();
}
public void refresh() {
treeRoot.getChildren().clear();
entryMap.clear();
loadModels();
loadItems();
loadLights();
loadEmitters();
loadWaterBodies();
loadRivers();
loadSoundAreas();
loadAreas();
loadLocationZones();
}
// ── Löschen ───────────────────────────────────────────────────────────────
private void confirmAndDelete(String label, Entry entry) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Objekt löschen");
alert.setHeaderText("Objekt wirklich löschen?");
alert.setContentText(label);
alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.NO);
Optional<ButtonType> result = alert.showAndWait();
if (result.isEmpty() || result.get() != ButtonType.YES) return;
try {
onDelete.execute(entry.placedObj(), entry.index(), entry.toolHint());
refresh();
} catch (Exception ex) {
Alert err = new Alert(Alert.AlertType.ERROR, "Fehler: " + ex.getMessage(), ButtonType.OK);
err.showAndWait();
}
}
// ── Loader ────────────────────────────────────────────────────────────────
private void loadModels() {
try {
List<PlacedModel> list = PlacedModelIO.load();
TreeItem<String> group = group("Modelle", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedModel m = list.get(idx);
TreeItem<String> item = leaf(lastName(m.modelPath()) + " " + pos(m.x(), m.y(), m.z()));
entryMap.put(item, new Entry(m.x(), m.y() + 5f, m.z(), "model", m, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadItems() {
try {
List<PlacedItem> list = PlacedItemIO.load();
TreeItem<String> group = group("Items", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedItem i = list.get(idx);
TreeItem<String> item = leaf(i.itemId() + " " + pos(i.x(), i.y(), i.z()));
entryMap.put(item, new Entry(i.x(), i.y() + 5f, i.z(), "item", i, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadLights() {
try {
List<PlacedLight> list = LightIO.load();
TreeItem<String> group = group("Lichter", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedLight l = list.get(idx);
TreeItem<String> item = leaf("Licht " + pos(l.x(), l.y(), l.z()));
entryMap.put(item, new Entry(l.x(), l.y() + 5f, l.z(), "light", l, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadEmitters() {
try {
List<PlacedEmitter> list = EmitterIO.load();
TreeItem<String> group = group("Emitter", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedEmitter e = list.get(idx);
TreeItem<String> item = leaf(lastName(e.texturePath()) + " " + pos(e.x(), e.y(), e.z()));
entryMap.put(item, new Entry(e.x(), e.y() + 5f, e.z(), "emitter", e, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadWaterBodies() {
try {
List<PlacedWater> list = WaterBodyIO.load();
TreeItem<String> group = group("Wasser", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedWater w = list.get(idx);
float cx = centroid(w.pointsX()), cz = centroid(w.pointsZ());
TreeItem<String> item = leaf("Wasser #" + (idx + 1) + " " + posXZ(cx, cz));
entryMap.put(item, new Entry(cx, Float.NaN, cz, "water", w, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadRivers() {
try {
List<List<RiverPoint>> list = RiverIO.load();
TreeItem<String> group = group("Wasserfälle", list.size());
for (int idx = 0; idx < list.size(); idx++) {
List<RiverPoint> river = list.get(idx);
float cx = 0f, cy = 0f, cz = 0f;
for (RiverPoint pt : river) { cx += pt.x(); cy += pt.y(); cz += pt.z(); }
if (!river.isEmpty()) { cx /= river.size(); cy /= river.size(); cz /= river.size(); }
TreeItem<String> item = leaf("Wasserfall #" + (idx + 1) + " " + pos(cx, cy, cz));
entryMap.put(item, new Entry(cx, cy + 5f, cz, "waterfall", river, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadSoundAreas() {
try {
List<PlacedSoundArea> list = SoundAreaIO.load();
TreeItem<String> group = group("Soundbereiche", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedSoundArea s = list.get(idx);
float cx = centroid(s.pointsX()), cz = centroid(s.pointsZ());
String label = s.soundPath() != null && !s.soundPath().isBlank()
? lastName(s.soundPath()) : "Soundbereich #" + (idx + 1);
TreeItem<String> item = leaf(label + " " + posXZ(cx, cz));
entryMap.put(item, new Entry(cx, Float.NaN, cz, "soundarea", s, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadAreas() {
try {
List<PlacedArea> list = AreaIO.load();
TreeItem<String> group = group("Bereiche", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedArea a = list.get(idx);
float cx = centroid(a.pointsX()), cz = centroid(a.pointsZ());
String label = a.nameId() != null && !a.nameId().isBlank()
? a.nameId() : "Bereich #" + (idx + 1);
TreeItem<String> item = leaf(label + " " + posXZ(cx, cz));
entryMap.put(item, new Entry(cx, Float.NaN, cz, "area", a, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
private void loadLocationZones() {
try {
List<PlacedLocationZone> list = LocationZoneIO.load();
TreeItem<String> group = group("Zonen", list.size());
for (int idx = 0; idx < list.size(); idx++) {
PlacedLocationZone z = list.get(idx);
float cx = centroid(z.pointsX()), cz = centroid(z.pointsZ());
String label = z.nameId() != null && !z.nameId().isBlank()
? z.nameId() : "Zone #" + (idx + 1);
TreeItem<String> item = leaf(label + " " + posXZ(cx, cz));
entryMap.put(item, new Entry(cx, Float.NaN, cz, "locationzone", z, idx));
group.getChildren().add(item);
}
treeRoot.getChildren().add(group);
} catch (Exception ignored) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static TreeItem<String> group(String name, int count) {
TreeItem<String> g = new TreeItem<>(name + " (" + count + ")");
g.setExpanded(true);
return g;
}
private static TreeItem<String> leaf(String label) {
return new TreeItem<>(label);
}
private static String pos(float x, float y, float z) {
return String.format("(%.0f, %.0f, %.0f)", x, y, z);
}
private static String posXZ(float x, float z) {
return String.format("(%.0f, ?, %.0f)", x, z);
}
private static float centroid(float[] arr) {
if (arr == null || arr.length == 0) return 0f;
float sum = 0f;
for (float v : arr) sum += v;
return sum / arr.length;
}
private static String lastName(String path) {
if (path == null || path.isBlank()) return "";
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
return slash >= 0 ? path.substring(slash + 1) : path;
}
}