Voxel Klippen System weiter optimiert, Monolog System implementiert, Compiler Warnungen behoben

This commit is contained in:
2026-07-23 20:29:51 +02:00
parent 9ebde59d40
commit 5a7d9897e4
65 changed files with 2365 additions and 179 deletions

View File

@@ -0,0 +1,50 @@
package de.blight.common;
import com.google.gson.*;
import de.blight.common.model.Monologue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Lädt und speichert Monolog-Definitionen als {@code blight_monologues.json}. */
public final class MonologueIO {
private static final Logger log = LoggerFactory.getLogger(MonologueIO.class);
private static final Gson GSON = de.blight.common.model.trigger.TriggerIO.registerAdapters(
new GsonBuilder().setPrettyPrinting()
).create();
private MonologueIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_monologues.json");
}
public static void save(List<Monologue> monologues) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
Files.writeString(p, GSON.toJson(monologues), StandardCharsets.UTF_8);
log.debug("[MonologueIO] {} Monolog(e) gespeichert.", monologues.size());
}
public static List<Monologue> load() {
Path p = getPath();
if (!Files.exists(p)) return new ArrayList<>();
try {
String json = Files.readString(p, StandardCharsets.UTF_8);
Type listType = new com.google.gson.reflect.TypeToken<List<Monologue>>(){}.getType();
List<Monologue> result = GSON.fromJson(json, listType);
return result != null ? result : new ArrayList<>();
} catch (Exception e) {
log.warn("[MonologueIO] Fehler beim Laden: {}", e.getMessage());
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,32 @@
package de.blight.common;
import de.blight.common.model.Monologue;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Laufzeit-Nachschlagetabelle für Monologe (ID → Monologue). Wird beim Spielstart befüllt. */
public final class MonologueRegistry {
private static final Map<String, Monologue> MAP = new HashMap<>();
private MonologueRegistry() {}
public static void init(List<Monologue> monologues) {
MAP.clear();
for (Monologue m : monologues) {
if (m.getId() != null && !m.getId().isBlank()) {
MAP.put(m.getId(), m);
}
}
}
public static Monologue get(String id) {
return id != null ? MAP.get(id) : null;
}
public static boolean isEmpty() {
return MAP.isEmpty();
}
}

View File

@@ -0,0 +1,13 @@
package de.blight.common;
public record PlacedVoxelCliffZone(
float[] pointsX,
float[] pointsZ,
float minOffset,
float maxHeight,
float noiseScale,
int octaves,
float persistence,
float edgeBlend,
int seed
) {}

View File

@@ -0,0 +1,77 @@
package de.blight.common;
import java.io.*;
import java.nio.file.*;
import java.util.*;
public final class VoxelCliffZoneIO {
private VoxelCliffZoneIO() {}
public static Path getPath() {
return MapIO.getMapPath().resolveSibling("blight_voxel_cliff_zones.bvcz");
}
public static void save(List<PlacedVoxelCliffZone> zones) throws IOException {
Path p = getPath();
Files.createDirectories(p.getParent());
try (BufferedWriter w = Files.newBufferedWriter(p)) {
w.write("# polygon\tminOffset\tmaxHeight\tnoiseScale\toctaves\tpersistence\tedgeBlend\tseed");
w.newLine();
for (PlacedVoxelCliffZone z : zones) {
w.write(encodePolygon(z.pointsX(), z.pointsZ()));
w.write('\t');
w.write(String.format(Locale.ROOT, "%.4f\t%.4f\t%.6f\t%d\t%.4f\t%.4f\t%d%n",
z.minOffset(), z.maxHeight(), z.noiseScale(),
z.octaves(), z.persistence(), z.edgeBlend(), z.seed()));
}
}
}
public static List<PlacedVoxelCliffZone> load() throws IOException {
Path p = getPath();
if (!Files.exists(p)) return List.of();
List<PlacedVoxelCliffZone> list = new ArrayList<>();
for (String line : Files.readAllLines(p)) {
line = line.strip();
if (line.isEmpty() || line.startsWith("#")) continue;
String[] f = line.split("\t", -1);
if (f.length < 8) continue;
try {
float[][] pts = decodePolygon(f[0]);
if (pts[0].length < 3) continue;
list.add(new PlacedVoxelCliffZone(
pts[0], pts[1],
Float.parseFloat(f[1]),
Float.parseFloat(f[2]),
Float.parseFloat(f[3]),
Integer.parseInt(f[4]),
Float.parseFloat(f[5]),
Float.parseFloat(f[6]),
Integer.parseInt(f[7])));
} catch (Exception ignored) {}
}
return list;
}
private static String encodePolygon(float[] xs, float[] zs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < xs.length; i++) {
if (i > 0) sb.append(';');
sb.append(String.format(Locale.ROOT, "%.3f,%.3f", xs[i], zs[i]));
}
return sb.toString();
}
private static float[][] decodePolygon(String encoded) {
String[] pts = encoded.split(";", -1);
float[] xs = new float[pts.length];
float[] zs = new float[pts.length];
for (int i = 0; i < pts.length; i++) {
String[] xz = pts[i].split(",", -1);
xs[i] = Float.parseFloat(xz[0]);
zs[i] = Float.parseFloat(xz[1]);
}
return new float[][]{xs, zs};
}
}

View File

@@ -0,0 +1,20 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.LinkedHashMap;
import java.util.Map;
/** Ein Audio-Paket: Sprach-Code + Schlüssel→Dateipfad-Map. */
@Getter
@Setter
public class AudioBundle {
private String language;
private Map<String, String> entries = new LinkedHashMap<>();
public AudioBundle(String language) {
this.language = language;
}
}

View File

@@ -0,0 +1,87 @@
package de.blight.common.model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/** Lädt und speichert {@link AudioBundle}-Instanzen als Properties-Dateien.
* Dateiformat: {@code audio_<lang>.properties} im lang/-Verzeichnis. */
public final class AudioBundleIO {
private static final Logger log = LoggerFactory.getLogger(AudioBundleIO.class);
private static final String PREFIX = "audio_";
private static final String EXTENSION = ".properties";
private AudioBundleIO() {}
public static void save(AudioBundle bundle, Path dir) throws IOException {
Files.createDirectories(dir);
Path file = dir.resolve(PREFIX + bundle.getLanguage() + EXTENSION);
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
for (Map.Entry<String, String> e : bundle.getEntries().entrySet()) {
w.write(escapeKey(e.getKey()) + "=" + escapeValue(e.getValue()));
w.newLine();
}
}
log.debug("[AudioBundleIO] Gespeichert: {}", file);
}
public static AudioBundle load(Path file) throws IOException {
String name = file.getFileName().toString();
String lang = name.replace(PREFIX, "").replace(EXTENSION, "");
Properties props = new Properties();
try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
props.load(r);
}
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
try (BufferedReader r = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#") || line.startsWith("!")) continue;
int eq = line.indexOf('=');
int colon = line.indexOf(':');
int sep = (eq >= 0 && (colon < 0 || eq <= colon)) ? eq : colon;
if (sep < 0) continue;
String key = line.substring(0, sep).trim();
ordered.put(key, props.getProperty(key, ""));
}
}
AudioBundle bundle = new AudioBundle(lang);
bundle.setEntries(ordered);
return bundle;
}
public static AudioBundle loadOrEmpty(String lang, Path dir) {
Path file = dir.resolve(PREFIX + lang + EXTENSION);
if (!Files.exists(file)) {
return new AudioBundle(lang);
}
try {
return load(file);
} catch (IOException e) {
log.warn("[AudioBundleIO] Fehler beim Laden: {}", e.getMessage());
return new AudioBundle(lang);
}
}
public static void delete(String language, Path dir) throws IOException {
Files.deleteIfExists(dir.resolve(PREFIX + language + EXTENSION));
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static String escapeKey(String key) {
return key.replace(" ", "\\ ");
}
private static String escapeValue(String val) {
return val.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r");
}
}

View File

@@ -1,5 +1,3 @@
package de.blight.common.model; package de.blight.common.model;
public interface AudioReference { public record AudioReference(String key) {}
}

View File

@@ -1,7 +1,11 @@
package de.blight.common.model; package de.blight.common.model;
import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Queue;
import java.util.Set;
import de.blight.common.model.quests.Quest; import de.blight.common.model.quests.Quest;
import lombok.AccessLevel; import lombok.AccessLevel;
@@ -37,10 +41,18 @@ public class MainCharacter extends GameCharacter {
private de.blight.common.model.abilities.Abilities abilities; private de.blight.common.model.abilities.Abilities abilities;
/** Gespielte Monolog-IDs wird serialisiert, damit jeder Monolog nur einmal abgespielt wird. */
private Set<String> playedMonologueIds = new HashSet<>();
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private List<CharacterListener> listeners = new ArrayList<CharacterListener>(); private List<CharacterListener> listeners = new ArrayList<CharacterListener>();
/** Warteschlange für ausstehende Monologe wird NICHT serialisiert. */
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private transient Queue<Monologue> pendingMonologues = new ArrayDeque<>();
public void handleDialogOption(DialogOption option) { public void handleDialogOption(DialogOption option) {
if (option.getRequiredItem() != null) { if (option.getRequiredItem() != null) {
getInventar().remove(option.getRequiredItem()); getInventar().remove(option.getRequiredItem());
@@ -122,6 +134,25 @@ public class MainCharacter extends GameCharacter {
} }
} }
/** Stellt einen Monolog in die Warteschlange (wird vom JME-Render-Thread geleert). */
public void queueMonologue(Monologue m) {
if (pendingMonologues == null) pendingMonologues = new ArrayDeque<>();
pendingMonologues.offer(m);
}
/** Gibt den nächsten ausstehenden Monolog zurück oder null. */
public Monologue pollPendingMonologue() {
if (pendingMonologues == null) return null;
return pendingMonologues.poll();
}
/** Wendet die Quest-Folgen eines Monologs an. */
public void handleMonologue(Monologue m) {
if (m.getRecievesQuest() != null) startQuest(m.getRecievesQuest());
if (m.getFulfillsQuest() != null) fullfillQuest(m.getFulfillsQuest());
if (m.getAbortsQuests() != null) m.getAbortsQuests().forEach(this::abortQuest);
}
public void removeListener(CharacterListener listener) { public void removeListener(CharacterListener listener) {
listeners.remove(listener); listeners.remove(listener);
} }

View File

@@ -0,0 +1,22 @@
package de.blight.common.model;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/** Selbstgespräch des Hauptcharakters: eine Sequenz von Held-Textschritten mit optionalen Quest-Folgen. */
@Getter
@Setter
public class Monologue {
private String id = "";
private List<DialogStep> heroSteps = new ArrayList<>();
private QuestRef recievesQuest;
private QuestRef fulfillsQuest;
private List<QuestRef> abortsQuests = new ArrayList<>();
private int requiresChapter;
/** Wenn true, wird der Monolog nach dem ersten Abspielen nicht erneut gefeuert. */
private boolean playOnce = true;
}

View File

@@ -0,0 +1,33 @@
package de.blight.common.model.trigger;
import de.blight.common.MonologueRegistry;
import de.blight.common.model.MainCharacter;
import de.blight.common.model.Monologue;
import lombok.Getter;
import lombok.Setter;
/** Startet einen Monolog des Hauptcharakters, wenn dieser eine Zone betritt. */
@Getter
@Setter
public class MonologueTrigger extends Trigger {
private String monologueId;
@Override
public boolean isTriggarableDelegate(MainCharacter character) {
Monologue m = MonologueRegistry.get(monologueId);
if (m == null) return false;
if (m.isPlayOnce() && character.getPlayedMonologueIds().contains(monologueId)) return false;
return true;
}
@Override
public void trigger(MainCharacter character) {
Monologue m = MonologueRegistry.get(monologueId);
if (m == null) return;
if (m.isPlayOnce()) {
character.getPlayedMonologueIds().add(monologueId);
}
character.queueMonologue(m);
}
}

View File

@@ -28,6 +28,7 @@ public final class TriggerIO {
public static final String TYPE_NPC_STATUS = "NPC_STATUS"; public static final String TYPE_NPC_STATUS = "NPC_STATUS";
public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS"; public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS";
public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE"; public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE";
public static final String TYPE_MONOLOGUE = "MONOLOGUE";
private static final Gson GSON = new GsonBuilder() private static final Gson GSON = new GsonBuilder()
.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter()) .registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter())
@@ -87,6 +88,8 @@ public final class TriggerIO {
} else if (src instanceof ChangeRoutineTrigger r) { } else if (src instanceof ChangeRoutineTrigger r) {
if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId()); if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId());
if (r.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName()); if (r.getRoutineName() != null) obj.addProperty("routineName", r.getRoutineName());
} else if (src instanceof MonologueTrigger mo) {
if (mo.getMonologueId() != null) obj.addProperty("monologueId", mo.getMonologueId());
} }
return obj; return obj;
} }
@@ -128,6 +131,11 @@ public final class TriggerIO {
if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString()); if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString());
yield r; yield r;
} }
case TYPE_MONOLOGUE -> {
MonologueTrigger mo = new MonologueTrigger();
if (obj.has("monologueId")) mo.setMonologueId(obj.get("monologueId").getAsString());
yield mo;
}
default -> null; default -> null;
}; };
@@ -141,6 +149,7 @@ public final class TriggerIO {
if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS; if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS;
if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS; if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS;
if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE; if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE;
if (t instanceof MonologueTrigger) return TYPE_MONOLOGUE;
return "UNKNOWN"; return "UNKNOWN";
} }

View File

@@ -171,8 +171,9 @@ public class EditorApp extends Application {
editableSubClips = new java.util.LinkedHashMap<>(); editableSubClips = new java.util.LinkedHashMap<>();
// Character-Editor-Zustand // Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView; private de.blight.editor.ui.DialogEditorView dialogEditorView;
private de.blight.editor.ui.RoutineEditorView routineEditorView; private de.blight.editor.ui.RoutineEditorView routineEditorView;
private de.blight.editor.ui.MonologueEditorView monologueEditorView;
private StackPane routineJmeSlot; private StackPane routineJmeSlot;
private javafx.scene.layout.VBox charEditContainer; // deaktiviert solange kein Character geladen private javafx.scene.layout.VBox charEditContainer; // deaktiviert solange kein Character geladen
private javafx.scene.layout.VBox charFractionSection; // sichtbar nur für NPCs private javafx.scene.layout.VBox charFractionSection; // sichtbar nur für NPCs
@@ -212,8 +213,16 @@ public class EditorApp extends Application {
private VBox waterDynamicContent; private VBox waterDynamicContent;
private Label waterCurrentHeightLabel; private Label waterCurrentHeightLabel;
// Ansichts-Umschalter
private boolean wireframeActive = false;
private CheckMenuItem viewTopologyItem = null;
// Sound-Bereich-Werkzeug-Zustand // Sound-Bereich-Werkzeug-Zustand
private VBox soundAreaDynamicContent; private VBox soundAreaDynamicContent;
private VBox voxelCliffDynamicContent;
private Label voxelCliffStatusLabel;
private Button voxelCliffGenerateBtn;
private Button voxelCliffDeleteBtn;
// Bereich-Werkzeug-Zustand // Bereich-Werkzeug-Zustand
private VBox areaDynamicContent; private VBox areaDynamicContent;
@@ -310,6 +319,7 @@ public class EditorApp extends Application {
private ToggleButton locationZoneBtn; private ToggleButton locationZoneBtn;
private ToggleButton playToolBtn; private ToggleButton playToolBtn;
private ToggleButton voxelBtn; private ToggleButton voxelBtn;
private ToggleButton voxelCliffBtn;
private ToggleButton camOrbitBtn; private ToggleButton camOrbitBtn;
private ToggleButton camFreeBtn; private ToggleButton camFreeBtn;
@@ -643,6 +653,23 @@ public class EditorApp extends Application {
updateLocationZonePanel(input.selectedLocationZoneInfo); updateLocationZonePanel(input.selectedLocationZoneInfo);
} }
if (input.cliffZoneSelectionChanged) {
input.cliffZoneSelectionChanged = false;
updateVoxelCliffPanel(input.selectedCliffZoneIdx >= 0);
}
String cliffMsg = input.cliffGenStatusMsg;
if (cliffMsg != null) {
input.cliffGenStatusMsg = null;
updateVoxelCliffStatus(cliffMsg);
}
if (input.cliffGenComplete) {
input.cliffGenComplete = false;
// Zu Voxel-Tool wechseln, damit der User direkt backen kann
Platform.runLater(() -> { if (voxelBtn != null) voxelBtn.fire(); });
}
if (input.spawnPickChanged) { if (input.spawnPickChanged) {
input.spawnPickChanged = false; input.spawnPickChanged = false;
updateSpawnFields(input.pickedSpawnInfo); updateSpawnFields(input.pickedSpawnInfo);
@@ -1164,14 +1191,21 @@ public class EditorApp extends Application {
Menu viewMenu = new Menu("Ansicht"); Menu viewMenu = new Menu("Ansicht");
MenuItem resetCam = new MenuItem("Kamera zurücksetzen"); MenuItem resetCam = new MenuItem("Kamera zurücksetzen");
resetCam.setOnAction(e -> input.addMouseDelta(0, 0)); resetCam.setOnAction(e -> input.addMouseDelta(0, 0));
MenuItem viewTexture = new MenuItem("Textur"); MenuItem viewTexture = new MenuItem("Textur (Ctrl+G)");
MenuItem viewWireframe = new MenuItem("Drahtgitter"); MenuItem viewWireframe = new MenuItem("Drahtgitter (Ctrl+G)");
CheckMenuItem viewTopology = new CheckMenuItem("Topologie-Overlay"); viewTopologyItem = new CheckMenuItem("Topologie-Overlay (Ctrl+T)");
viewTexture.setOnAction(e -> input.wireframeRequest = 2); viewTexture.setOnAction(e -> {
viewWireframe.setOnAction(e -> input.wireframeRequest = 1); wireframeActive = false;
viewTopology.setOnAction(e -> input.topologyRequest = viewTopology.isSelected() ? 1 : 2); input.wireframeRequest = 2;
});
viewWireframe.setOnAction(e -> {
wireframeActive = true;
input.wireframeRequest = 1;
});
viewTopologyItem.setOnAction(e ->
input.topologyRequest = viewTopologyItem.isSelected() ? 1 : 2);
viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe, viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe,
new SeparatorMenuItem(), viewTopology); new SeparatorMenuItem(), viewTopologyItem);
Menu zeitMenu = new Menu("Zeit"); Menu zeitMenu = new Menu("Zeit");
ToggleGroup zeitGroup = new ToggleGroup(); ToggleGroup zeitGroup = new ToggleGroup();
@@ -1202,6 +1236,7 @@ public class EditorApp extends Application {
locationZoneBtn = new ToggleButton("📍 Locations"); locationZoneBtn = new ToggleButton("📍 Locations");
playToolBtn = new ToggleButton("🎮 Spielen"); playToolBtn = new ToggleButton("🎮 Spielen");
voxelBtn = new ToggleButton("⬡ Voxel"); voxelBtn = new ToggleButton("⬡ Voxel");
voxelCliffBtn = new ToggleButton("⛰ Klippe");
stoneBtn = new ToggleButton("🪨 Steine"); stoneBtn = new ToggleButton("🪨 Steine");
camOrbitBtn = new ToggleButton("⊙ Orbit"); camOrbitBtn = new ToggleButton("⊙ Orbit");
camFreeBtn = new ToggleButton("✈ FreeFly"); camFreeBtn = new ToggleButton("✈ FreeFly");
@@ -1220,6 +1255,7 @@ public class EditorApp extends Application {
locationZoneBtn.setStyle("-fx-font-weight:bold;"); locationZoneBtn.setStyle("-fx-font-weight:bold;");
playToolBtn.setStyle("-fx-font-weight:bold;"); playToolBtn.setStyle("-fx-font-weight:bold;");
voxelBtn.setStyle("-fx-font-weight:bold;"); voxelBtn.setStyle("-fx-font-weight:bold;");
voxelCliffBtn.setStyle("-fx-font-weight:bold;");
stoneBtn.setStyle("-fx-font-weight:bold;"); stoneBtn.setStyle("-fx-font-weight:bold;");
camOrbitBtn.setStyle("-fx-font-weight:bold;"); camOrbitBtn.setStyle("-fx-font-weight:bold;");
camFreeBtn.setStyle("-fx-font-weight:bold;"); camFreeBtn.setStyle("-fx-font-weight:bold;");
@@ -1240,6 +1276,7 @@ public class EditorApp extends Application {
locationZoneBtn.setToggleGroup(layerGroup); locationZoneBtn.setToggleGroup(layerGroup);
playToolBtn.setToggleGroup(layerGroup); playToolBtn.setToggleGroup(layerGroup);
voxelBtn.setToggleGroup(layerGroup); voxelBtn.setToggleGroup(layerGroup);
voxelCliffBtn.setToggleGroup(layerGroup);
stoneBtn.setToggleGroup(layerGroup); stoneBtn.setToggleGroup(layerGroup);
baseBtn.setSelected(true); baseBtn.setSelected(true);
@@ -1315,6 +1352,10 @@ public class EditorApp extends Application {
root.setRight(toolPanel); root.setRight(toolPanel);
showToolParameters(toolPanel, input.activeTool); showToolParameters(toolPanel, input.activeTool);
}); });
voxelCliffBtn.setOnAction(e -> {
input.activeLayer = SharedInput.LAYER_VOXEL_CLIFF;
root.setRight(buildVoxelCliffPanel());
});
stoneBtn.setOnAction(e -> { stoneBtn.setOnAction(e -> {
input.activeLayer = SharedInput.LAYER_STONE; input.activeLayer = SharedInput.LAYER_STONE;
input.activeTool = input.stoneTool; input.activeTool = input.stoneTool;
@@ -1345,7 +1386,7 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), riverBtn, new Separator(Orientation.VERTICAL), riverBtn,
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn, new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
new Separator(Orientation.VERTICAL), playToolBtn, new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn, new Separator(Orientation.VERTICAL), voxelBtn, voxelCliffBtn,
new Separator(Orientation.VERTICAL), stoneBtn, new Separator(Orientation.VERTICAL), stoneBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn, new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), hint); new Separator(Orientation.VERTICAL), hint);
@@ -3286,7 +3327,38 @@ public class EditorApp extends Application {
poller.setCycleCount(javafx.animation.Animation.INDEFINITE); poller.setCycleCount(javafx.animation.Animation.INDEFINITE);
poller.play(); poller.play();
panel.getChildren().addAll(new Separator(), bakeBtn, bakeBar, bakeBarLabel, bakeStatus); // ── Markierte Baked-Chunks löschen (Modus 6) ─────────────────────
Label markCountLabel = new Label("0 Chunks markiert");
markCountLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;");
Button deleteMarkedBtn = new Button("Markierte Chunks löschen");
deleteMarkedBtn.setMaxWidth(Double.MAX_VALUE);
deleteMarkedBtn.setStyle("-fx-background-color: #ba4a4a; -fx-text-fill: white;");
deleteMarkedBtn.setDisable(true);
deleteMarkedBtn.setOnAction(e -> {
input.deleteMarkedBakedRequested = true;
deleteMarkedBtn.setDisable(true);
});
Button clearMarkBtn = new Button("Markierung leeren");
clearMarkBtn.setMaxWidth(Double.MAX_VALUE);
clearMarkBtn.setDisable(true);
clearMarkBtn.setOnAction(e -> input.clearMarkedBakedRequested = true);
// Poller: Anzahl markierter Chunks + Bake-Status aktualisieren
javafx.animation.Timeline markPoller = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> {
int n = input.markedDeleteBakedKeys.size();
markCountLabel.setText(n + " Chunk" + (n != 1 ? "s" : "") + " markiert");
deleteMarkedBtn.setDisable(n == 0);
clearMarkBtn.setDisable(n == 0);
})
);
markPoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
markPoller.play();
panel.getChildren().addAll(new Separator(), bakeBtn, bakeBar, bakeBarLabel, bakeStatus,
new Separator(), markCountLabel, deleteMarkedBtn, clearMarkBtn);
} }
} }
@@ -7485,6 +7557,8 @@ public class EditorApp extends Application {
input.areaClickQueue.offer(new SharedInput.AreaClick((float) x, (float) y, action < 0)); input.areaClickQueue.offer(new SharedInput.AreaClick((float) x, (float) y, action < 0));
case SharedInput.LAYER_LOCATION_ZONES -> case SharedInput.LAYER_LOCATION_ZONES ->
input.locationZoneClickQueue.offer(new SharedInput.LocationZoneClick((float) x, (float) y, action < 0)); input.locationZoneClickQueue.offer(new SharedInput.LocationZoneClick((float) x, (float) y, action < 0));
case SharedInput.LAYER_VOXEL_CLIFF ->
input.voxelCliffClickQueue.offer(new SharedInput.VoxelCliffClick((float) x, (float) y, action < 0));
case SharedInput.LAYER_PLAY_TOOL -> { case SharedInput.LAYER_PLAY_TOOL -> {
if (action > 0) // only left-click sets spawn if (action > 0) // only left-click sets spawn
input.playToolClickQueue.offer(new SharedInput.PlayToolClick((float) x, (float) y)); input.playToolClickQueue.offer(new SharedInput.PlayToolClick((float) x, (float) y));
@@ -7802,11 +7876,19 @@ public class EditorApp extends Application {
case E -> input.down = pressed; case E -> input.down = pressed;
case SHIFT -> input.shiftHeld = pressed; case SHIFT -> input.shiftHeld = pressed;
case CONTROL -> input.ctrlHeld = pressed; case CONTROL -> input.ctrlHeld = pressed;
case ENTER -> {
if (pressed && input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF) {
input.generateCliffVoxelsRequested = true;
if (voxelCliffGenerateBtn != null) voxelCliffGenerateBtn.setDisable(true);
if (voxelCliffStatusLabel != null) voxelCliffStatusLabel.setText("Generiere…");
}
}
case ESCAPE -> { case ESCAPE -> {
if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS
|| input.activeLayer == SharedInput.LAYER_AREAS || input.activeLayer == SharedInput.LAYER_AREAS
|| input.activeLayer == SharedInput.LAYER_LOCATION_ZONES || input.activeLayer == SharedInput.LAYER_LOCATION_ZONES
|| input.activeLayer == SharedInput.LAYER_WATER)) || input.activeLayer == SharedInput.LAYER_WATER
|| input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF))
input.cancelZoneDrawing = true; input.cancelZoneDrawing = true;
} }
case DELETE -> { case DELETE -> {
@@ -7820,6 +7902,8 @@ public class EditorApp extends Application {
input.deleteAreaRequested = true; input.deleteAreaRequested = true;
else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES) else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES)
input.deleteLocationZoneRequested = true; input.deleteLocationZoneRequested = true;
else if (input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF)
input.deleteVoxelCliffZoneRequested = true;
else if (input.activeLayer == SharedInput.LAYER_WATER) else if (input.activeLayer == SharedInput.LAYER_WATER)
input.deleteWaterRequested = true; input.deleteWaterRequested = true;
} }
@@ -7832,6 +7916,19 @@ public class EditorApp extends Application {
if (pressed && input.activeLayer == SharedInput.LAYER_WATER) if (pressed && input.activeLayer == SharedInput.LAYER_WATER)
input.waterSampleHeightRequested = true; input.waterSampleHeightRequested = true;
} }
case G -> {
if (pressed && input.ctrlHeld) {
wireframeActive = !wireframeActive;
input.wireframeRequest = wireframeActive ? 1 : 2;
}
}
case T -> {
if (pressed && input.ctrlHeld && viewTopologyItem != null) {
boolean sel = !viewTopologyItem.isSelected();
viewTopologyItem.setSelected(sel);
input.topologyRequest = sel ? 1 : 2;
}
}
case F1 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> baseBtn.fire()); } case F1 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> baseBtn.fire()); }
case F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); } case F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); }
case F5 -> { case F5 -> {
@@ -7860,6 +7957,117 @@ public class EditorApp extends Application {
// ── Sound-Bereich-Panel ─────────────────────────────────────────────────── // ── Sound-Bereich-Panel ───────────────────────────────────────────────────
// ── Voxel-Klippen-Zone-Panel ──────────────────────────────────────────────
private VBox buildVoxelCliffPanel() {
VBox inner = new VBox(8);
inner.setPadding(new Insets(10));
inner.getChildren().addAll(
sectionTitle("Voxel-Klippen-Zonen"),
styledHint("L-Klick → Punkt setzen / Startpunkt: Polygon schließen"),
styledHint("R-Klick → Letzten Punkt rückgängig"),
styledHint("ESC → Abbrechen / Zone löschen"),
styledHint("Enter → Generieren + Voxel-Tool öffnen"),
styledHint("Entf → Zone löschen"),
new Separator(),
sectionTitle("Höhen-Parameter"));
Slider maxHeightSlider = new Slider(0, 30, input.cliffMaxHeight);
maxHeightSlider.valueProperty().addListener((o, ov, nv) ->
input.cliffMaxHeight = nv.floatValue());
inner.getChildren().addAll(new Label("Max. Höhe über Terrain"), withField(maxHeightSlider, "%.1f"));
Slider noiseScaleSlider = new Slider(0.01, 0.5, input.cliffNoiseScale);
noiseScaleSlider.valueProperty().addListener((o, ov, nv) ->
input.cliffNoiseScale = nv.floatValue());
inner.getChildren().addAll(new Label("Höhen-Noise-Frequenz"), withField(noiseScaleSlider, "%.3f"));
Slider edgeBlendSlider = new Slider(0, 30, input.cliffEdgeBlend);
edgeBlendSlider.valueProperty().addListener((o, ov, nv) ->
input.cliffEdgeBlend = nv.floatValue());
inner.getChildren().addAll(new Label("Rand-Überblendung"), withField(edgeBlendSlider, "%.1f"));
inner.getChildren().addAll(
new Separator(),
sectionTitle("Oberflächen-Rauigkeit"));
Slider roughAmpSlider = new Slider(0, 20, input.cliffRoughnessAmp);
roughAmpSlider.valueProperty().addListener((o, ov, nv) ->
input.cliffRoughnessAmp = nv.floatValue());
inner.getChildren().addAll(new Label("Oberflächen-Amplitude"), withField(roughAmpSlider, "%.1f"));
Slider roughFreqSlider = new Slider(0.01, 0.5, input.cliffRoughnessScale);
roughFreqSlider.valueProperty().addListener((o, ov, nv) ->
input.cliffRoughnessScale = nv.floatValue());
inner.getChildren().addAll(new Label("Oberflächen-Frequenz"), withField(roughFreqSlider, "%.3f"));
inner.getChildren().addAll(
new Separator(),
sectionTitle("Gewählte Zone"),
new Separator());
voxelCliffDynamicContent = new VBox(6);
inner.getChildren().add(voxelCliffDynamicContent);
voxelCliffStatusLabel = new Label("");
voxelCliffStatusLabel.setStyle("-fx-text-fill: #555; -fx-font-size: 11;");
voxelCliffStatusLabel.setWrapText(true);
inner.getChildren().add(voxelCliffStatusLabel);
// Sofort korrekten Zustand anzeigen (Zone evtl. schon vor Panel-Bau geladen)
updateVoxelCliffPanel(input.selectedCliffZoneIdx >= 0);
ScrollPane scroll = new ScrollPane(inner);
scroll.setFitToWidth(true);
scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;");
VBox panel = new VBox(scroll);
VBox.setVgrow(scroll, Priority.ALWAYS);
panel.setPrefWidth(270);
panel.setStyle("-fx-background-color: #f0f0f0; -fx-border-color: #ccc; -fx-border-width: 0 0 0 1;");
return panel;
}
private void updateVoxelCliffPanel(boolean hasSelection) {
if (voxelCliffDynamicContent == null) return;
voxelCliffDynamicContent.getChildren().clear();
if (!hasSelection) {
Label noSel = new Label("Keine Zone ausgewählt");
noSel.setStyle("-fx-text-fill: #888;");
voxelCliffDynamicContent.getChildren().add(noSel);
if (voxelCliffGenerateBtn != null) voxelCliffGenerateBtn.setDisable(true);
if (voxelCliffDeleteBtn != null) voxelCliffDeleteBtn.setDisable(true);
return;
}
voxelCliffGenerateBtn = new Button("⛰ Voxel generieren");
voxelCliffGenerateBtn.setMaxWidth(Double.MAX_VALUE);
voxelCliffGenerateBtn.setStyle("-fx-background-color: #4a7eba; -fx-text-fill: white;");
voxelCliffGenerateBtn.setOnAction(e -> {
input.generateCliffVoxelsRequested = true;
voxelCliffGenerateBtn.setDisable(true);
if (voxelCliffStatusLabel != null) voxelCliffStatusLabel.setText("Generiere…");
});
voxelCliffDeleteBtn = new Button("🗑 Zone löschen");
voxelCliffDeleteBtn.setMaxWidth(Double.MAX_VALUE);
voxelCliffDeleteBtn.setStyle("-fx-text-fill: #c0392b;");
voxelCliffDeleteBtn.setOnAction(e -> input.deleteVoxelCliffZoneRequested = true);
Label hint = new Label("Hinweis: Generierung legt Voxel über das Terrain. Bestehende Voxel\nim Bereich werden nicht gelöscht (Reset-Brush verwenden).");
hint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
hint.setWrapText(true);
voxelCliffDynamicContent.getChildren().addAll(
voxelCliffGenerateBtn, voxelCliffDeleteBtn, new Separator(), hint);
}
private void updateVoxelCliffStatus(String msg) {
if (voxelCliffStatusLabel != null) voxelCliffStatusLabel.setText(msg);
if (voxelCliffGenerateBtn != null) voxelCliffGenerateBtn.setDisable(false);
}
private VBox buildSoundAreaPanel() { private VBox buildSoundAreaPanel() {
VBox inner = new VBox(8); VBox inner = new VBox(8);
inner.setPadding(new Insets(10)); inner.setPadding(new Insets(10));
@@ -9871,6 +10079,7 @@ public class EditorApp extends Application {
dialogEditorView.setDisable(true); dialogEditorView.setDisable(true);
routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("character")); routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("character"));
routineEditorView.setDisable(true); routineEditorView.setDisable(true);
monologueEditorView = new de.blight.editor.ui.MonologueEditorView();
// worldViewport in den Tagesabläufe-Tab verschieben; centerStack bekommt Platzhalter // worldViewport in den Tagesabläufe-Tab verschieben; centerStack bekommt Platzhalter
routineJmeSlot = new StackPane(); routineJmeSlot = new StackPane();
@@ -9889,7 +10098,8 @@ public class EditorApp extends Application {
centerTabs.setTabClosingPolicy(javafx.scene.control.TabPane.TabClosingPolicy.UNAVAILABLE); centerTabs.setTabClosingPolicy(javafx.scene.control.TabPane.TabClosingPolicy.UNAVAILABLE);
centerTabs.getTabs().addAll( centerTabs.getTabs().addAll(
new javafx.scene.control.Tab("Dialog", dialogEditorView), new javafx.scene.control.Tab("Dialog", dialogEditorView),
new javafx.scene.control.Tab("Tagesabläufe", routineSplit) new javafx.scene.control.Tab("Tagesabläufe", routineSplit),
new javafx.scene.control.Tab("Monologe", monologueEditorView)
); );
root.setCenter(centerTabs); root.setCenter(centerTabs);
root.setRight(buildCharacterEditorPanel()); root.setRight(buildCharacterEditorPanel());

View File

@@ -35,6 +35,7 @@ import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState; import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState; import de.blight.editor.state.TreeGeneratorState;
import de.blight.editor.state.VoxelEditorState; import de.blight.editor.state.VoxelEditorState;
import de.blight.editor.state.VoxelCliffEditorState;
import de.blight.editor.state.SculptedMeshEditorState; import de.blight.editor.state.SculptedMeshEditorState;
import de.blight.editor.state.ModelImportState; import de.blight.editor.state.ModelImportState;
import de.blight.editor.state.PathNetworkEditorState; import de.blight.editor.state.PathNetworkEditorState;
@@ -202,6 +203,7 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new ModelEditorState(input)); stateManager.attach(new ModelEditorState(input));
stateManager.attach(new ItemPlacementState(input)); stateManager.attach(new ItemPlacementState(input));
stateManager.attach(new VoxelEditorState(input)); stateManager.attach(new VoxelEditorState(input));
stateManager.attach(new VoxelCliffEditorState(input));
stateManager.attach(new SculptedMeshEditorState(input)); stateManager.attach(new SculptedMeshEditorState(input));
stateManager.attach(new ModelImportState(input)); stateManager.attach(new ModelImportState(input));

View File

@@ -768,11 +768,22 @@ public class SharedInput {
public volatile int bakeDone = 0; public volatile int bakeDone = 0;
/** JME → JFX: Gesamtzahl der zu backenden Chunks (0 = nicht gestartet). */ /** JME → JFX: Gesamtzahl der zu backenden Chunks (0 = nicht gestartet). */
public volatile int bakeTotal = 0; public volatile int bakeTotal = 0;
/** JME → JFX: Status-Meldung nach Abschluss des Backens. */ /** JME → JFX: Status-Meldung nach Abschluss des Backens oder Löschens. */
public volatile String bakeStatusMsg = null; public volatile String bakeStatusMsg = null;
/** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */ /** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */
public volatile int blurIterDone = 0; public volatile int blurIterDone = 0;
/**
* Chunks, die per Brush zum Löschen markiert wurden (chunkKey-kodiert).
* Thread-sicher; JME schreibt, JFX liest (nur size() für Anzeige).
*/
public final java.util.Set<Long> markedDeleteBakedKeys =
java.util.concurrent.ConcurrentHashMap.newKeySet();
/** JFX → JME: alle markierten Chunks löschen. */
public volatile boolean deleteMarkedBakedRequested = false;
/** JFX → JME: Markierung ohne Löschen leeren. */
public volatile boolean clearMarkedBakedRequested = false;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */ /** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
public volatile int voxelFlatSlot = -1; public volatile int voxelFlatSlot = -1;
/** Terrain-Slot (0-7) für steile Wände. */ /** Terrain-Slot (0-7) für steile Wände. */
@@ -783,7 +794,7 @@ public class SharedInput {
public volatile boolean voxelTexturesChanged = false; public volatile boolean voxelTexturesChanged = false;
/** Wenn true, werden beim Aktivieren des Voxel-Layers alle anderen Objekte als Wireframe gerendert. */ /** Wenn true, werden beim Aktivieren des Voxel-Layers alle anderen Objekte als Wireframe gerendert. */
public volatile boolean voxelWireframeEnabled = true; public volatile boolean voxelWireframeEnabled = false;
// ── Item-Platzierung ────────────────────────────────────────────────────── // ── Item-Platzierung ──────────────────────────────────────────────────────
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */ /** activeLayer==21 → Item-Pickup auf die Karte platzieren */
@@ -999,4 +1010,34 @@ public class SharedInput {
public volatile boolean sculptDeleteSelected = false; public volatile boolean sculptDeleteSelected = false;
/** VoxelEditorState setzt true nach Bake-Abschluss → SculptedMeshEditorState scannt neue .j3o-Dateien. */ /** VoxelEditorState setzt true nach Bake-Abschluss → SculptedMeshEditorState scannt neue .j3o-Dateien. */
public volatile boolean sculptRescanNeeded = false; public volatile boolean sculptRescanNeeded = false;
// ── Voxel-Klippen-Zonen ───────────────────────────────────────────────────
/** activeLayer==31 → Voxel-Klippen-Zonen (Polygon) platzieren und Voxel generieren */
public static final int LAYER_VOXEL_CLIFF = 31;
public record VoxelCliffClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<VoxelCliffClick> voxelCliffClickQueue = new ConcurrentLinkedQueue<>();
/** JFX → JME: Voxel für die ausgewählte Klippen-Zone generieren. */
public volatile boolean generateCliffVoxelsRequested = false;
/** JFX → JME: Ausgewählte Klippen-Zone löschen. */
public volatile boolean deleteVoxelCliffZoneRequested = false;
/** JME → JFX: Status-Meldung nach Generierung. */
public volatile String cliffGenStatusMsg = null;
/** JME → JFX: Index der ausgewählten Klippen-Zone (-1 = keine). */
public volatile int selectedCliffZoneIdx = -1;
/** JME → JFX: Selektion hat sich geändert → Panel aktualisieren. */
public volatile boolean cliffZoneSelectionChanged = false;
/** JME → JFX: Generierung abgeschlossen → zu LAYER_VOXEL wechseln. */
public volatile boolean cliffGenComplete = false;
/** Maximale Kliff-Höhe über Terrain in Welt-Einheiten (2D-fBm-Skalierung). */
public volatile float cliffMaxHeight = 3.0f;
/** 2D-Noise-Frequenz für die Höhenverteilung (kleiner = breiter, größer = lokaler). */
public volatile float cliffNoiseScale = 0.15f;
/** Rand-Überblendung: Zone läuft über diese Distanz zum Rand hin aus. */
public volatile float cliffEdgeBlend = 5.0f;
/** Oberflächen-Rauigkeit in Welt-Einheiten (wie stark 3D-Noise die Iso-Fläche verschiebt). */
public volatile float cliffRoughnessAmp = 4.0f;
/** 3D-Noise-Frequenz für die Kliff-Oberfläche (höher = feiner Detail). */
public volatile float cliffRoughnessScale = 0.12f;
} }

View File

@@ -284,6 +284,7 @@ public class ModelImportState extends BaseAppState {
startImpostorPass(0); startImpostorPass(0);
} }
@SuppressWarnings("deprecation")
private void startImpostorPass(int pass) { private void startImpostorPass(int pass) {
Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8); Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8);
impostorFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1); impostorFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1);

View File

@@ -207,6 +207,22 @@ public class SculptedMeshEditorState extends BaseAppState {
// ── Scan / Laden ────────────────────────────────────────────────────────── // ── Scan / Laden ──────────────────────────────────────────────────────────
private void rescanBakedChunks() { private void rescanBakedChunks() {
// Geladene Meshes entfernen, deren Baked-Dateien nicht mehr existieren
Iterator<Map.Entry<Long, EditableMesh>> it = meshes.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Long, EditableMesh> e = it.next();
EditableMesh em = e.getValue();
if (!VoxelChunkIO.bakedExists(em.cx, em.cy, em.cz)) {
em.node.removeFromParent();
if (selectedKey == e.getKey()) {
selectedKey = -1L;
input.selectedSculptKey = -1L;
input.selectedSculptLabel = null;
}
it.remove();
}
}
// Neue Chunks laden
for (int[] cxyz : SculptedMeshIO.findAllBakedChunks()) { for (int[] cxyz : SculptedMeshIO.findAllBakedChunks()) {
long key = chunkKey(cxyz[0], cxyz[1], cxyz[2]); long key = chunkKey(cxyz[0], cxyz[1], cxyz[2]);
if (meshes.containsKey(key)) continue; if (meshes.containsKey(key)) continue;

View File

@@ -107,9 +107,10 @@ public class TerrainEditorState extends BaseAppState {
private LightState lightState; private LightState lightState;
private EmitterState emitterState; private EmitterState emitterState;
private WaterBodyState waterBodyState; private WaterBodyState waterBodyState;
private SoundAreaState soundAreaState; private SoundAreaState soundAreaState;
private AreaState areaState; private AreaState areaState;
private LocationZoneState locationZoneState; private LocationZoneState locationZoneState;
private VoxelCliffEditorState voxelCliffEditorState;
private RiverEditorState riverEditorState; private RiverEditorState riverEditorState;
private MapData loadedMapData; private MapData loadedMapData;
private Node axesGizmo; private Node axesGizmo;
@@ -329,6 +330,18 @@ public class TerrainEditorState extends BaseAppState {
} }
} }
input.loadingStatus = "Lade Voxel-Klippen-Zonen...";
voxelCliffEditorState = app.getStateManager().getState(VoxelCliffEditorState.class);
if (voxelCliffEditorState != null) {
voxelCliffEditorState.setTerrain(terrain);
try {
var cliffZones = de.blight.common.VoxelCliffZoneIO.load();
if (!cliffZones.isEmpty()) voxelCliffEditorState.loadZones(cliffZones);
} catch (IOException e) {
log.error("Voxel-Klippen-Zonen nicht ladbar", e);
}
}
input.loadingStatus = "Lade Bereiche..."; input.loadingStatus = "Lade Bereiche...";
areaState = app.getStateManager().getState(AreaState.class); areaState = app.getStateManager().getState(AreaState.class);
if (areaState != null) { if (areaState != null) {
@@ -993,13 +1006,15 @@ public class TerrainEditorState extends BaseAppState {
log.debug("[Debug] DebugNoLight = {}", debugNoLight); log.debug("[Debug] DebugNoLight = {}", debugNoLight);
} }
// Wireframe-Modus setzen // Wireframe-Modus setzen (Ctrl+G aus EditorApp)
int wfReq = input.wireframeRequest; int wfReq = input.wireframeRequest;
if (wfReq != 0) { if (wfReq != 0) {
input.wireframeRequest = 0; input.wireframeRequest = 0;
wireframeMode = (wfReq == 1); wireframeMode = (wfReq == 1);
if (terrain != null) if (terrain != null)
terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode); terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode);
// VoxelEditorState überwacht voxelWireframeEnabled und synchronisiert seine Szene-Wireframe
input.voxelWireframeEnabled = wireframeMode;
} }
// Topologie-Overlay // Topologie-Overlay
@@ -1037,6 +1052,7 @@ public class TerrainEditorState extends BaseAppState {
try { if (areaState != null) areaState.loadAreas(de.blight.common.AreaIO.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 (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) {} try { if (riverEditorState != null) riverEditorState.loadPlacedRivers(de.blight.common.RiverIO.load()); } catch (Exception ignored) {}
try { if (voxelCliffEditorState != null) voxelCliffEditorState.loadZones(de.blight.common.VoxelCliffZoneIO.load()); } catch (Exception ignored) {}
} }
if (input.saveRequested) { if (input.saveRequested) {
@@ -1194,9 +1210,11 @@ public class TerrainEditorState extends BaseAppState {
final List<PlacedEmitter> emitters = emitterState != null ? emitterState.getPlacedEmitters() : null; final List<PlacedEmitter> emitters = emitterState != null ? emitterState.getPlacedEmitters() : null;
final List<PlacedWater> waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null; final List<PlacedWater> waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null;
final List<List<RiverPoint>> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null; final List<List<RiverPoint>> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null;
final List<PlacedSoundArea> soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null; final List<PlacedSoundArea> soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null;
final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null; final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null;
final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null; final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null;
final List<de.blight.common.PlacedVoxelCliffZone> cliffZones =
voxelCliffEditorState != null ? voxelCliffEditorState.getPlacedZones() : null;
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ────────── // ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen // Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
@@ -1210,6 +1228,7 @@ public class TerrainEditorState extends BaseAppState {
try { if (soundAreas != null) SoundAreaIO.save(soundAreas); } catch (IOException e) { log.error("Soundbereiche 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 (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); } try { if (locationZones != null) LocationZoneIO.save(locationZones); } catch (IOException e) { log.error("Zonen speichern", e); }
try { if (cliffZones != null) de.blight.common.VoxelCliffZoneIO.save(cliffZones); } catch (IOException e) { log.error("Klippen-Zonen speichern", e); }
if (stoneEditorState != null) stoneEditorState.saveIfModified(); if (stoneEditorState != null) stoneEditorState.saveIfModified();
// ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─ // ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─

View File

@@ -0,0 +1,542 @@
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.CollisionResults;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedVoxelCliffZone;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class VoxelCliffEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(VoxelCliffEditorState.class);
private static final float SNAP_DIST = 8f;
private static final float LINE_OFFSET_Y = 0.4f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(1.0f, 0.6f, 0.1f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 1f, 0.1f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(1f, 0.85f, 0.3f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private TerrainEditorState terrainEditorState;
private final List<PlacedVoxelCliffZone> zones = new ArrayList<>();
private final List<Geometry> zoneGeos = new ArrayList<>();
private int selectedIdx = -1;
// laufendes Polygon
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedVoxelCliffZone> pendingZones = null;
public VoxelCliffEditorState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
terrainEditorState = app.getStateManager().getState(TerrainEditorState.class);
}
@Override
protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingZones != null) {
loadZones(pendingZones);
pendingZones = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_VOXEL_CLIFF) {
if (placing) cancelPoly();
return;
}
SharedInput.VoxelCliffClick click;
while ((click = input.voxelCliffClickQueue.poll()) != null) {
handleClick(click);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) {
cancelPoly();
} else if (selectedIdx >= 0) {
// ESC mit ausgewählter, aber nicht generierter Zone → Zone löschen
removeZone(selectedIdx);
}
}
if (input.deleteVoxelCliffZoneRequested) {
input.deleteVoxelCliffZoneRequested = false;
if (selectedIdx >= 0) {
removeZone(selectedIdx);
}
}
if (input.generateCliffVoxelsRequested) {
input.generateCliffVoxelsRequested = false;
if (selectedIdx >= 0 && selectedIdx < zones.size()) {
generateZoneVoxels(zones.get(selectedIdx));
}
}
}
// ── Click-Behandlung ───────────────────────────────────────────────────────
private void handleClick(SharedInput.VoxelCliffClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
if (click.rightButton()) {
if (placing) {
if (!currX.isEmpty()) {
currX.remove(currX.size() - 1);
currZ.remove(currZ.size() - 1);
}
if (currX.isEmpty()) {
cancelPoly();
} else {
updateInProgressGeo();
}
} else {
deselect();
}
return;
}
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
// Snap-Close VOR Vertex-Snap prüfen: Klick nahe Startpunkt → Polygon schließen
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST) {
closePoly();
return;
}
}
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
for (int i = 0; i < zones.size(); i++) {
PlacedVoxelCliffZone z = zones.get(i);
if (SoundAreaState.pointInPolygon(hitX, hitZ, z.pointsX(), z.pointsZ())) {
selectZone(i);
return;
}
}
// Nur eine Zone gleichzeitig erlaubt — neue Zone erst nach Löschen/Generieren
if (!zones.isEmpty()) return;
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SNAP_DIST * SNAP_DIST;
float bx = x, bz = z;
for (PlacedVoxelCliffZone zone : zones) {
for (int i = 0; i < zone.pointsX().length; i++) {
float dx = x - zone.pointsX()[i];
float dz = z - zone.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = zone.pointsX()[i]; bz = zone.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) {
cancelPoly();
return;
}
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedVoxelCliffZone zone = new PlacedVoxelCliffZone(
xs, zs, 1f, 60f, 0.025f, 4, 0.5f, 15f, 42);
addZone(zone);
selectZone(zones.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
// ── In-Progress-Visualisierung ────────────────────────────────────────────
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
if (currX.isEmpty()) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("cliff_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s);
buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s);
buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.3f, 0.1f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("cliff_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selektion ─────────────────────────────────────────────────────────────
private void selectZone(int idx) {
if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) {
zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
zoneGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
input.selectedCliffZoneIdx = idx;
input.cliffZoneSelectionChanged = true;
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < zoneGeos.size()) {
zoneGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedCliffZoneIdx = -1;
input.cliffZoneSelectionChanged = true;
}
// ── Hinzufügen / Entfernen ────────────────────────────────────────────────
private void addZone(PlacedVoxelCliffZone zone) {
zones.add(zone);
List<Float> xs = toList(zone.pointsX());
List<Float> zs = toList(zone.pointsZ());
Geometry geo = buildLineGeo("cliff_zone_" + (zones.size() - 1),
xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
zoneGeos.add(geo);
}
private void removeZone(int idx) {
rootNode.detachChild(zoneGeos.get(idx));
zones.remove(idx);
zoneGeos.remove(idx);
selectedIdx = -1;
input.selectedCliffZoneIdx = -1;
input.cliffZoneSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : zoneGeos) rootNode.detachChild(g);
zones.clear();
zoneGeos.clear();
cancelPoly();
selectedIdx = -1;
}
// ── Linien-Geometrie ──────────────────────────────────────────────────────
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2.5f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Voxel-Generation ─────────────────────────────────────────────────────
private void generateZoneVoxels(PlacedVoxelCliffZone zone) {
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
if (ves == null) {
input.cliffGenStatusMsg = "Fehler: VoxelEditorState nicht verfügbar.";
return;
}
float[] xs = zone.pointsX();
float[] zs = zone.pointsZ();
float minX = xs[0], maxX = xs[0], minZ = zs[0], maxZ = zs[0];
for (int i = 1; i < xs.length; i++) {
if (xs[i] < minX) minX = xs[i];
if (xs[i] > maxX) maxX = xs[i];
if (zs[i] < minZ) minZ = zs[i];
if (zs[i] > maxZ) maxZ = zs[i];
}
// Alle Höhen- und Rauigkeits-Parameter kommen aus SharedInput (live aus dem Panel)
float maxHeight = input.cliffMaxHeight;
float noiseScale = input.cliffNoiseScale;
float edgeBlend = input.cliffEdgeBlend;
float roughAmp = input.cliffRoughnessAmp;
float roughScale = input.cliffRoughnessScale;
int count = 0;
for (int xi = (int) Math.floor(minX); xi <= (int) Math.ceil(maxX); xi++) {
for (int zi = (int) Math.floor(minZ); zi <= (int) Math.ceil(maxZ); zi++) {
float wx = xi, wz = zi;
if (!SoundAreaState.pointInPolygon(wx, wz, xs, zs)) continue;
float th = terrainH(wx, wz);
float[] near = nearestEdgePoint(wx, wz, xs, zs);
float edgeDist = near[0];
float boundaryTh = terrainH(near[1], near[2]);
float baseH = Math.max(boundaryTh, th);
// Organische Übergangslinie: niederfrequenter Noise verschiebt die
// effektive Kantendistanz → keine geometrisch geraden Linien im Tal
float lineNoise = fBm(wx * 0.02f, wz * 0.02f, 3, 0.5f, zone.seed() + 54321);
float effectiveDist = edgeDist + lineNoise * edgeBlend * 0.35f;
float edgeFactor = smoothstep(0f, edgeBlend, effectiveDist);
float noise2d = fBm(wx * noiseScale, wz * noiseScale,
zone.octaves(), zone.persistence(), zone.seed());
float fromY = th - 14f;
float topY = baseH + noise2d * maxHeight * edgeFactor;
// Rauigkeit zur Kante hin ausblenden → Talbereich bleibt ruhiger
float effRough = roughAmp * edgeFactor;
if (topY <= fromY) continue;
ves.fillColumnRange3D(wx, wz, fromY, topY,
effRough, roughScale,
zone.octaves(), zone.persistence(), zone.seed() + 7919);
count++;
}
}
log.info("Voxel-Kliff generiert: {} Spalten in Zone {}.", count, selectedIdx);
// Zone nach der Generierung entfernen (Outline verschwindet, Tool wechselt zu Voxel)
int genIdx = selectedIdx;
removeZone(genIdx);
input.cliffGenStatusMsg = "Generiert: " + count + " Spalten.";
input.cliffGenComplete = true; // → EditorApp wechselt zu LAYER_VOXEL
}
private float terrainH(float worldX, float worldZ) {
if (terrainEditorState != null) {
return terrainEditorState.getTerrainHeightFast(worldX, worldZ);
}
if (terrain != null) {
Float h = terrain.getHeight(new Vector2f(worldX, worldZ));
return h != null ? h : 0f;
}
return 0f;
}
/**
* Least-Squares-Ebene durch die Terrain-Höhen der Polygon-Eckpunkte.
* Gibt [a, b, c] zurück, sodass planeH(x,z) = a*x + b*z + c.
*/
// ── Noise (fBm + Value Noise, deterministisch) ────────────────────────────
private static float fBm(float x, float z, int octaves, float persistence, int seed) {
float value = 0f, amp = 1f, freq = 1f, maxAmp = 0f;
for (int i = 0; i < octaves; i++) {
value += amp * valueNoise(x * freq, z * freq, seed + i * 1337);
maxAmp += amp;
amp *= persistence;
freq *= 2f;
}
return maxAmp > 0f ? value / maxAmp : 0f;
}
private static float valueNoise(float x, float z, int seed) {
int xi = (int) Math.floor(x), zi = (int) Math.floor(z);
float xf = x - xi, zf = z - zi;
float ux = xf * xf * (3f - 2f * xf);
float uz = zf * zf * (3f - 2f * zf);
float v00 = hash2i(xi, zi, seed);
float v10 = hash2i(xi + 1, zi, seed);
float v01 = hash2i(xi, zi + 1, seed);
float v11 = hash2i(xi + 1, zi + 1, seed);
return lerp(lerp(v00, v10, ux), lerp(v01, v11, ux), uz);
}
private static float hash2i(int x, int z, int seed) {
int h = seed ^ (x * 374761393) ^ (z * 668265263);
h = (h ^ (h >>> 13)) * 1274126177;
h ^= h >>> 16;
return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF;
}
private static float lerp(float a, float b, float t) { return a + (b - a) * t; }
private static float smoothstep(float edge0, float edge1, float x) {
if (edge1 <= edge0) return 1f;
float t = Math.max(0f, Math.min(1f, (x - edge0) / (edge1 - edge0)));
return t * t * (3f - 2f * t);
}
private static float distToPolygonEdge(float px, float pz, float[] xs, float[] zs) {
return nearestEdgePoint(px, pz, xs, zs)[0];
}
/** Gibt [dist, nearX, nearZ] zurück — Distanz und Koordinaten des nächsten Randpunkts. */
private static float[] nearestEdgePoint(float px, float pz, float[] xs, float[] zs) {
float minD2 = Float.MAX_VALUE;
float nearX = px, nearZ = pz;
int n = xs.length;
for (int i = 0; i < n; i++) {
int j = (i + 1) % n;
float ax = xs[i], az = zs[i];
float bx = xs[j], bz = zs[j];
float dx = bx - ax, dz = bz - az;
float lenSq = dx * dx + dz * dz;
float cx, cz;
if (lenSq < 1e-6f) {
cx = ax; cz = az;
} else {
float t = Math.max(0f, Math.min(1f, ((px - ax) * dx + (pz - az) * dz) / lenSq));
cx = ax + t * dx; cz = az + t * dz;
}
float ex = px - cx, ez = pz - cz;
float d2 = ex * ex + ez * ez;
if (d2 < minD2) { minD2 = d2; nearX = cx; nearZ = cz; }
}
return new float[]{(float) Math.sqrt(minD2), nearX, nearZ};
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedVoxelCliffZone> getPlacedZones() {
return new ArrayList<>(zones);
}
public void loadZones(List<PlacedVoxelCliffZone> loaded) {
if (rootNode == null) {
pendingZones = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedVoxelCliffZone z : loaded) {
addZone(z);
}
if (!zones.isEmpty()) {
selectZone(0);
}
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -121,6 +121,13 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator; private Geometry brushIndicator;
// ── Overlay für markierte Baked-Chunks ────────────────────────────────────
/** Root-Node für rote Chunk-Markierungen im MODE_DELETE_BAKED. */
private Node markedChunkOverlayRoot;
/** key → zugehörige Overlay-Geometrie. */
private final Map<Long, Geometry> markedOverlays = new HashMap<>();
// ── Basis-Terrain-Referenzebene (y = -10) ──────────────────────────────── // ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */ /** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */
@@ -185,6 +192,11 @@ public class VoxelEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator(); brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator); app.getRootNode().attachChild(brushIndicator);
// Overlay-Root für markierte Baked-Chunks
markedChunkOverlayRoot = new Node("markedBakedChunks");
markedChunkOverlayRoot.setCullHint(Spatial.CullHint.Always);
app.getRootNode().attachChild(markedChunkOverlayRoot);
// Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter // Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter
basePlaneNode = new Node("voxelBasePlaneNode"); basePlaneNode = new Node("voxelBasePlaneNode");
basePlane = buildBasePlane(); basePlane = buildBasePlane();
@@ -207,9 +219,10 @@ public class VoxelEditorState extends BaseAppState {
protected void cleanup(Application app) { protected void cleanup(Application app) {
executor.shutdownNow(); executor.shutdownNow();
voxelRoot.removeFromParent(); voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent(); if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent(); if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (wireframeActive) applyWireframe(false); if (markedChunkOverlayRoot != null) markedChunkOverlayRoot.removeFromParent();
if (wireframeActive) applyWireframe(false);
nodes.clear(); nodes.clear();
chunks.clear(); chunks.clear();
} }
@@ -253,20 +266,44 @@ public class VoxelEditorState extends BaseAppState {
}); });
} }
// Markierte Baked-Chunks löschen
if (input.deleteMarkedBakedRequested) {
input.deleteMarkedBakedRequested = false;
Set<Long> toDelete = new HashSet<>(input.markedDeleteBakedKeys);
input.markedDeleteBakedKeys.clear();
clearMarkedOverlays();
executor.submit(() -> {
try {
deleteMarkedBaked(toDelete);
} catch (Throwable e) {
log.error("Baked-Löschen fehlgeschlagen: {}", e.getMessage(), e);
input.bakeStatusMsg = "FEHLER: " + e.getMessage();
}
});
}
// Markierung leeren
if (input.clearMarkedBakedRequested) {
input.clearMarkedBakedRequested = false;
input.markedDeleteBakedKeys.clear();
clearMarkedOverlays();
}
// Voxel-Texturen aktualisiert? // Voxel-Texturen aktualisiert?
if (input.voxelTexturesChanged) { if (input.voxelTexturesChanged) {
input.voxelTexturesChanged = false; input.voxelTexturesChanged = false;
applyTextures(voxelMaterial); applyTextures(voxelMaterial);
} }
// Layer-Wechsel erkennen → Referenzebene und Wireframe steuern // Layer-Wechsel erkennen → Referenzebene steuern (kein automatischer Wireframe-Wechsel)
boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL; boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL;
if (isVoxelLayer != prevLayerWasVoxel) { if (isVoxelLayer != prevLayerWasVoxel) {
prevLayerWasVoxel = isVoxelLayer; prevLayerWasVoxel = isVoxelLayer;
onVoxelLayerChanged(isVoxelLayer); onVoxelLayerChanged(isVoxelLayer);
} }
// Wireframe-Toggle während LAYER_VOXEL (Button-Klick im Panel) // Globaler Wireframe-Toggle (Ctrl+G): voxelWireframeEnabled wird von TerrainEditorState
if (isVoxelLayer && wireframeActive != input.voxelWireframeEnabled) { // bei wireframeRequest gesetzt und gilt ebenenunabhängig für die gesamte Szene.
if (wireframeActive != input.voxelWireframeEnabled) {
applyWireframe(input.voxelWireframeEnabled); applyWireframe(input.voxelWireframeEnabled);
} }
@@ -356,6 +393,131 @@ public class VoxelEditorState extends BaseAppState {
this.terrainQuad = terrain instanceof TerrainQuad tq ? tq : null; this.terrainQuad = terrain instanceof TerrainQuad tq ? tq : null;
} }
/**
* Füllt eine Voxel-Spalte von fromWorldY bis toWorldY mit Solid-Material.
* Muss im JME-Thread aufgerufen werden (von VoxelCliffEditorState).
*/
public void fillColumnRange(float worldX, float worldZ, float fromWorldY, float toWorldY) {
if (fromWorldY > toWorldY) return;
int cx = VoxelChunk.worldXToCx(worldX);
int cz = VoxelChunk.worldZToCz(worldZ);
int lx = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldXToLocal(worldX, cx)));
int lzl = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldZToLocal(worldZ, cz)));
int cyMin = VoxelChunk.worldYToCy(fromWorldY);
int cyMax = VoxelChunk.worldYToCy(toWorldY);
for (int cy = cyMin; cy <= cyMax; cy++) {
VoxelChunk chunk = getOrCreateChunk(cx, cy, cz);
int startLY = (cy == cyMin)
? Math.max(0, (int)(fromWorldY - cy * (float) VoxelChunk.CELLS))
: 0;
int endLY = (cy == cyMax)
? Math.min(VoxelChunk.SIZE - 1, (int)(toWorldY - cy * (float) VoxelChunk.CELLS))
: VoxelChunk.SIZE - 1;
for (int ly = startLY; ly <= endLY; ly++) {
chunk.setDensity(lx, ly, lzl, (byte) 127);
}
chunk.dirty = true;
long key = chunkKey(cx, cy, cz);
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
addNodeForChunk(key, chunk);
}
dirtyChunksThisFrame.add(key);
}
}
/**
* Füllt eine Voxel-Spalte mit einem 3D-Dichte-Rauschfeld für Kliff-Oberflächen.
* Anders als fillColumnRange (binär solid/air) erzeugt diese Methode einen Dichtegradienten
* mit 3D-fBm-Verschiebung, der den 7-Pass-Gaussian-Blur beim Backen überlebt.
* Muss im JME-Thread aufgerufen werden.
*/
public void fillColumnRange3D(float worldX, float worldZ,
float fromWorldY, float topWorldY,
float roughAmp, float roughFreq,
int octaves, float persistence, int seed) {
if (fromWorldY > topWorldY) return;
int cx = VoxelChunk.worldXToCx(worldX);
int cz = VoxelChunk.worldZToCz(worldZ);
int lx = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldXToLocal(worldX, cx)));
int lzl = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldZToLocal(worldZ, cz)));
// Gradient: 127 Dichte-Einheiten über roughAmp Welt-Einheiten.
// Damit geht die Dichte von 127 (weit unterhalb) über 0 (Oberfläche) zu -128 (weit oberhalb).
float gradient = roughAmp > 0.5f ? 127f / roughAmp : 254f;
// Bereich: von Boden bis topWorldY + roughAmp (Noise kann die Fläche nach oben verschieben)
float extendedTop = topWorldY + roughAmp;
int cyMin = VoxelChunk.worldYToCy(fromWorldY);
int cyMax = VoxelChunk.worldYToCy(extendedTop);
for (int cy = cyMin; cy <= cyMax; cy++) {
VoxelChunk chunk = getOrCreateChunk(cx, cy, cz);
float chunkYBase = cy * (float) VoxelChunk.CELLS;
int startLY = (cy == cyMin)
? Math.max(0, (int)(fromWorldY - chunkYBase))
: 0;
int endLY = (cy == cyMax)
? Math.min(VoxelChunk.SIZE - 1, (int)(extendedTop - chunkYBase))
: VoxelChunk.SIZE - 1;
for (int ly = startLY; ly <= endLY; ly++) {
float worldY = chunkYBase + ly;
byte density;
if (worldY <= fromWorldY) {
// Unterhalb des Terrain-Ankers: immer solid Iso-Fläche darf nicht unter das Terrain tauchen
density = (byte) 127;
} else {
// n3d ∈ [-1, 1]: 3D-fBm-Rauschen verschiebt die Iso-Fläche lokal
float n3d = noise3DfBm(worldX * roughFreq, worldY * roughFreq, worldZ * roughFreq,
octaves, persistence, seed);
float d = (topWorldY - worldY) * gradient + n3d * roughAmp * gradient;
density = (byte) Math.max(-128, Math.min(127, (int) d));
}
chunk.setDensity(lx, ly, lzl, density);
}
chunk.dirty = true;
long key = chunkKey(cx, cy, cz);
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
addNodeForChunk(key, chunk);
}
dirtyChunksThisFrame.add(key);
}
}
private static float noise3DfBm(float x, float y, float z, int octaves, float persistence, int seed) {
float value = 0f, amp = 1f, freq = 1f, maxAmp = 0f;
for (int i = 0; i < octaves; i++) {
value += amp * noise3DValue(x * freq, y * freq, z * freq, seed + i * 1337);
maxAmp += amp;
amp *= persistence;
freq *= 2f;
}
return maxAmp > 0f ? (value / maxAmp) * 2f - 1f : 0f;
}
private static float noise3DValue(float x, float y, float z, int seed) {
int xi = (int) Math.floor(x), yi = (int) Math.floor(y), zi = (int) Math.floor(z);
float xf = x - xi, yf = y - yi, zf = z - zi;
float ux = xf * xf * (3f - 2f * xf);
float uy = yf * yf * (3f - 2f * yf);
float uz = zf * zf * (3f - 2f * zf);
float x0y0 = nlerp(hash3n(xi, yi, zi, seed), hash3n(xi+1, yi, zi, seed), ux);
float x0y1 = nlerp(hash3n(xi, yi+1, zi, seed), hash3n(xi+1, yi+1, zi, seed), ux);
float x1y0 = nlerp(hash3n(xi, yi, zi+1, seed), hash3n(xi+1, yi, zi+1, seed), ux);
float x1y1 = nlerp(hash3n(xi, yi+1, zi+1, seed), hash3n(xi+1, yi+1, zi+1, seed), ux);
return nlerp(nlerp(x0y0, x0y1, uy), nlerp(x1y0, x1y1, uy), uz);
}
private static float nlerp(float a, float b, float t) { return a + (b - a) * t; }
private static float hash3n(int x, int y, int z, int seed) {
int h = seed ^ (x * 374761393) ^ (y * 1013904223) ^ (z * 668265263);
h = (h ^ (h >>> 13)) * 1274126177;
h ^= h >>> 16;
return (h & 0x7FFFFFFF) / (float) 0x7FFFFFFF;
}
/** /**
* Speichert alle dirty Chunks synchron. * Speichert alle dirty Chunks synchron.
* Kann von außen aufgerufen werden (z.B. beim globalen Speichern). * Kann von außen aufgerufen werden (z.B. beim globalen Speichern).
@@ -464,6 +626,12 @@ public class VoxelEditorState extends BaseAppState {
int modeIdx = input.voxelTool.mode.getSelectedIndex(); int modeIdx = input.voxelTool.mode.getSelectedIndex();
boolean isHorizontal = input.voxelTool.horizontal; boolean isHorizontal = input.voxelTool.horizontal;
// Baked-Chunk Markierungs-Modus: keine Voxel-Bearbeitung, nur Selektion
if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_DELETE_BAKED) {
toggleBakedChunkMark(hit.pos.x, hit.pos.z, radius, action >= 0);
return;
}
boolean isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE); boolean isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE);
boolean isColumn = !isCave; boolean isColumn = !isCave;
boolean lower = action < 0; boolean lower = action < 0;
@@ -689,20 +857,16 @@ public class VoxelEditorState extends BaseAppState {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; } if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
} }
if (currentTop < 0) { if (currentTop < 0) {
if (cy == -1) { // Terrain-Oberfläche als Ankerpunkt
// Basis-Ebene: Ankerpunkt bei ly=118 (Welt-Y = -10) float th = terrainH(wx, wz);
currentTop = 118; int tCy = VoxelChunk.worldYToCy(th);
if (cy == tCy) {
currentTop = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int)(th - cy * (float) VoxelChunk.CELLS)));
} else { } else {
// Nur weiterwachsen wenn der darunterliegende Chunk bis an die // Chunks ober- oder unterhalb des Terrain-Chunks ohne bestehende Voxel: überspringen.
// Grenze reicht (ly ≥ SIZE-3), sonst würde ein losgelöster Klumpen entstehen // Kein Foundation-Fill das Terrain-Mesh übernimmt die visuelle Abdeckung.
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); continue;
if (below == null) continue;
boolean belowAtBoundary = false;
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) {
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; }
}
if (!belowAtBoundary) continue;
currentTop = 0;
} }
} }
int newTop = Math.min(VoxelChunk.SIZE - 1, currentTop + colStep); int newTop = Math.min(VoxelChunk.SIZE - 1, currentTop + colStep);
@@ -767,6 +931,119 @@ public class VoxelEditorState extends BaseAppState {
} }
} }
// ── Baked-Chunk Markierung ────────────────────────────────────────────────
/**
* Markiert (mark=true) oder hebt die Markierung (mark=false) aller gebackenen
* Chunks auf, deren XZ-Ausdehnung den Pinselkreis schneidet.
* Fügt/entfernt gleichzeitig das rote Overlay-Quad im JME-Szenen-Graph.
*/
private void toggleBakedChunkMark(float brushWX, float brushWZ, float radius, boolean mark) {
float r2 = radius * radius;
int cxMin = VoxelChunk.worldXToCx(brushWX - radius);
int cxMax = VoxelChunk.worldXToCx(brushWX + radius);
int czMin = VoxelChunk.worldZToCz(brushWZ - radius);
int czMax = VoxelChunk.worldZToCz(brushWZ + radius);
for (int cx = cxMin; cx <= cxMax; cx++) {
for (int cz = czMin; cz <= czMax; cz++) {
// Nächster Punkt des Chunk-AABB zum Brush-Mittelpunkt
float chunkX0 = cx * VoxelChunk.CELLS - 2048f;
float chunkZ0 = cz * VoxelChunk.CELLS - 2048f;
float nearX = Math.max(chunkX0, Math.min(brushWX, chunkX0 + VoxelChunk.CELLS));
float nearZ = Math.max(chunkZ0, Math.min(brushWZ, chunkZ0 + VoxelChunk.CELLS));
float dx = brushWX - nearX, dz = brushWZ - nearZ;
if (dx*dx + dz*dz > r2) continue;
// Alle cy-Ebenen prüfen, die ein gebackenes LOD0 haben
for (int cy = -2; cy <= 10; cy++) {
if (!VoxelChunkIO.bakedExists(cx, cy, cz)) continue;
long key = chunkKey(cx, cy, cz);
if (mark) {
if (input.markedDeleteBakedKeys.add(key)) {
addMarkedOverlay(key, cx, cz);
}
} else {
if (input.markedDeleteBakedKeys.remove(key)) {
removeMarkedOverlay(key);
}
}
}
}
}
}
private void addMarkedOverlay(long key, int cx, int cz) {
if (markedOverlays.containsKey(key)) return;
float x0 = cx * VoxelChunk.CELLS - 2048f;
float z0 = cz * VoxelChunk.CELLS - 2048f;
com.jme3.scene.shape.Quad q = new com.jme3.scene.shape.Quad(VoxelChunk.CELLS, VoxelChunk.CELLS);
Geometry geo = new Geometry("markedBaked_" + key, q);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 0.45f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
// Quad liegt im XY-Raum; -90° um X rotieren → horizontale XZ-Ebene
Quaternion rot = new Quaternion();
rot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
geo.setLocalRotation(rot);
// Nach Rotation reicht der Quad von (x0, -9, z0) bis (x0+CELLS, -9, z0+CELLS)
geo.setLocalTranslation(x0, -9f, z0 + VoxelChunk.CELLS);
markedChunkOverlayRoot.attachChild(geo);
markedOverlays.put(key, geo);
}
private void removeMarkedOverlay(long key) {
Geometry geo = markedOverlays.remove(key);
if (geo != null) geo.removeFromParent();
}
private void clearMarkedOverlays() {
for (Geometry geo : markedOverlays.values()) geo.removeFromParent();
markedOverlays.clear();
}
/**
* Löscht die per Brush markierten gebackenen Chunks (alle LODs + Sculpt-Overlays).
* Läuft im Hintergrund-Thread.
*/
private void deleteMarkedBaked(Set<Long> keys) {
if (keys.isEmpty()) {
input.bakeStatusMsg = "Keine Chunks markiert.";
return;
}
int deleted = 0;
for (long key : keys) {
int cx = (int)(key & 0xFFFF); if (cx >= 0x8000) cx -= 0x10000;
int cy = (int)((key >> 16) & 0xFFFF); if (cy >= 0x8000) cy -= 0x10000;
int cz = (int)((key >> 32) & 0xFFFF); if (cz >= 0x8000) cz -= 0x10000;
for (int lod = 0; lod < 3; lod++) {
try {
Files.deleteIfExists(VoxelChunkIO.getBakedPath(cx, cy, cz, lod));
} catch (IOException e) {
log.warn("Baked LOD{} löschen fehlgeschlagen ({},{},{}): {}",
lod, cx, cy, cz, e.getMessage());
}
}
if (de.blight.common.SculptedMeshIO.exists(cx, cy, cz)) {
try { de.blight.common.SculptedMeshIO.delete(cx, cy, cz); }
catch (Exception e) { log.warn("Sculpt-Overlay löschen fehlgeschlagen: {}", e.getMessage()); }
}
deleted++;
}
input.sculptRescanNeeded = true;
input.bakeStatusMsg = "Gelöscht: " + deleted + " Chunk" + (deleted != 1 ? "s" : "") + ".";
log.info("Markierte Baked-Chunks gelöscht: {}.", deleted);
}
// ── Terrain-Höhe (schneller O(1)-Zugriff) ───────────────────────────────── // ── Terrain-Höhe (schneller O(1)-Zugriff) ─────────────────────────────────
private float terrainH(float worldX, float worldZ) { private float terrainH(float worldX, float worldZ) {
@@ -782,11 +1059,11 @@ public class VoxelEditorState extends BaseAppState {
/** /**
* Berechnet Slope-Parameter im Pinselradius. * Berechnet Slope-Parameter im Pinselradius.
* Rückgabe: [highH, lowH, dirX, dirZ, projHigh, projLow] * Rückgabe: [highH, lowH, dirX, dirZ, projHigh, projLow, avgH]
* dirX/Z = Einheitsvektor vom Tiefpunkt zum Hochpunkt * dirX/Z = Einheitsvektor vom Tiefpunkt zum Hochpunkt
* projHigh = Projektion des Hochpunkts auf die Achse (relativ zur Brush-Mitte) * projHigh = Projektion des Hochpunkts auf die Achse (relativ zur Brush-Mitte)
* projLow = Projektion des Tiefpunkts auf die Achse (immer ≤ projHigh) * projLow = Projektion des Tiefpunkts auf die Achse (immer ≤ projHigh)
* projHigh - projLow = |maxPos - minPos|, immer ≥ 0. * avgH = Durchschnittshöhe aller Spalten im Pinselbereich (für Smooth-Modus)
*/ */
private float[] computeSlopeParams(float brushWX, float brushWZ, float radius) { private float[] computeSlopeParams(float brushWX, float brushWZ, float radius) {
float r2 = radius * radius; float r2 = radius * radius;
@@ -795,6 +1072,8 @@ public class VoxelEditorState extends BaseAppState {
float maxH = Float.NEGATIVE_INFINITY, minH = Float.POSITIVE_INFINITY; float maxH = Float.NEGATIVE_INFINITY, minH = Float.POSITIVE_INFINITY;
float maxX = brushWX, maxZ = brushWZ, minX = brushWX, minZ = brushWZ; float maxX = brushWX, maxZ = brushWZ, minX = brushWX, minZ = brushWZ;
float sum = 0f;
int count = 0;
for (int xi = xMin; xi <= xMax; xi++) { for (int xi = xMin; xi <= xMax; xi++) {
float dx = xi - brushWX; float dx = xi - brushWX;
@@ -804,10 +1083,12 @@ public class VoxelEditorState extends BaseAppState {
float h = columnTopWorldY(xi, zi); float h = columnTopWorldY(xi, zi);
if (h > maxH) { maxH = h; maxX = xi; maxZ = zi; } if (h > maxH) { maxH = h; maxX = xi; maxZ = zi; }
if (h < minH) { minH = h; minX = xi; minZ = zi; } if (h < minH) { minH = h; minX = xi; minZ = zi; }
sum += h;
count++;
} }
} }
if (maxH == Float.NEGATIVE_INFINITY) return new float[]{ Float.NaN, Float.NaN, 0, 0, 0, 0 }; if (maxH == Float.NEGATIVE_INFINITY) return new float[]{ Float.NaN, Float.NaN, 0, 0, 0, 0, Float.NaN };
// Richtung vom tiefsten zum höchsten Punkt — garantiert projHigh - projLow = |maxPos - minPos| // Richtung vom tiefsten zum höchsten Punkt — garantiert projHigh - projLow = |maxPos - minPos|
float ddx = maxX - minX, ddz = maxZ - minZ; float ddx = maxX - minX, ddz = maxZ - minZ;
@@ -816,7 +1097,8 @@ public class VoxelEditorState extends BaseAppState {
float projHigh = (maxX - brushWX) * ddx + (maxZ - brushWZ) * ddz; float projHigh = (maxX - brushWX) * ddx + (maxZ - brushWZ) * ddz;
float projLow = (minX - brushWX) * ddx + (minZ - brushWZ) * ddz; float projLow = (minX - brushWX) * ddx + (minZ - brushWZ) * ddz;
return new float[]{ maxH, minH, ddx, ddz, projHigh, projLow }; float avgH = sum / count;
return new float[]{ maxH, minH, ddx, ddz, projHigh, projLow, avgH };
} }
/** Welt-Y des höchsten Solid-Voxels an (worldX, worldZ), oder Terrain-Höhe wenn keine Voxel. */ /** Welt-Y des höchsten Solid-Voxels an (worldX, worldZ), oder Terrain-Höhe wenn keine Voxel. */
@@ -834,7 +1116,7 @@ public class VoxelEditorState extends BaseAppState {
} }
} }
} }
return -10f; // Kein Voxel vorhanden → Basis-Niveau return terrainH(worldX, worldZ); // Kein Voxel → Terrain-Oberfläche als Basis
} }
/** /**
@@ -876,9 +1158,11 @@ public class VoxelEditorState extends BaseAppState {
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) { for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTopLY = ly; break; } if (chunk.getDensity(lx, ly, lz) > 0) { currentTopLY = ly; break; }
} }
// Terrain-Höhe als Fallback wenn noch keine Voxel in dieser Spalte
float th = currentTopLY < 0 ? terrainH(wx, wz) : Float.NaN;
float currentTopWY = currentTopLY >= 0 float currentTopWY = currentTopLY >= 0
? VoxelChunk.toWorldY(cy, currentTopLY) ? VoxelChunk.toWorldY(cy, currentTopLY)
: -10f; // Keine Voxel → Basis-Niveau als Referenz : th;
float diff = targetH - currentTopWY; float diff = targetH - currentTopWY;
if (Math.abs(diff) < 0.5f) continue; if (Math.abs(diff) < 0.5f) continue;
@@ -888,17 +1172,16 @@ public class VoxelEditorState extends BaseAppState {
int startLY; int startLY;
if (currentTopLY >= 0) { if (currentTopLY >= 0) {
startLY = currentTopLY; startLY = currentTopLY;
} else if (cy == -1) {
startLY = 118;
} else { } else {
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); // Terrain-Oberfläche als Ankerpunkt
if (below == null) continue; int tCy = VoxelChunk.worldYToCy(th);
boolean belowAtBoundary = false; if (cy == tCy) {
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) { startLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; } (int)(th - cy * (float) VoxelChunk.CELLS)));
} else {
// Chunk ohne bestehende Voxel und nicht der Terrain-Chunk: überspringen.
continue;
} }
if (!belowAtBoundary) continue;
startLY = 0;
} }
int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step); int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step);
for (int ly = startLY; ly <= newTop; ly++) { for (int ly = startLY; ly <= newTop; ly++) {
@@ -916,25 +1199,19 @@ public class VoxelEditorState extends BaseAppState {
} }
/** /**
* Smooth-Pinsel (Linksklick): gerichteter Slope von lowH nach highH. * Smooth-Pinsel (Linksklick): bewegt alle Spalten im Pinselbereich auf die
* Ziel interpoliert zwischen tatsächlicher Projektion von Tief- und Hochpunkt — * Durchschnittshöhe aller Spalten zu (sp[6]).
* der höchste Punkt behält genau highH, der tiefste genau lowH. *
* Spikes (Spalten über dem Durchschnitt) werden abgebaut, tiefe Stellen
* leicht angehoben. Dadurch entsteht eine gleichmäßig geebnte Fläche,
* ohne dass neues Material an Stellen aufgebaut wird, die bereits flach sind.
*/ */
private void applySmoothColumn(VoxelChunk chunk, int cx, int cy, int cz, private void applySmoothColumn(VoxelChunk chunk, int cx, int cy, int cz,
float brushWX, float brushWZ, float brushWX, float brushWZ,
float radius, float strength, float[] sp) { float radius, float strength, float[] sp) {
if (sp == null || Float.isNaN(sp[0])) return; if (sp == null || Float.isNaN(sp[6])) return;
final float highH = sp[0], lowH = sp[1], dirX = sp[2], dirZ = sp[3]; final float targetH = sp[6];
final float projHigh = sp[4], projLow = sp[5]; applyColumnToTarget(chunk, cx, cy, cz, brushWX, brushWZ, radius, strength, coord -> targetH);
final float projRange = projHigh - projLow; // = |maxPos minPos| ≥ 0
if (projRange < 0.5f) return;
applyColumnToTarget(chunk, cx, cy, cz, brushWX, brushWZ, radius, strength, coord -> {
float proj = coord[0] * dirX + coord[1] * dirZ;
float t = (proj - projLow) / projRange;
t = Math.max(0f, Math.min(1f, t));
return lowH + (highH - lowH) * t;
});
} }
/** /**
@@ -1073,6 +1350,12 @@ public class VoxelEditorState extends BaseAppState {
} }
input.blurIterDone = 0; input.blurIterDone = 0;
// Bilateraler Filter: Nachbarn mit großem Dichtesprung (Klippen, Felskanten)
// bekommen fast Null-Gewicht → scharfe Übergänge bleiben erhalten.
// Flache Bereiche (geringe Differenz) werden voll geglättet wie bisher.
// sigma_r = 30 → diff=20: w≈0.80 (flach, volles Blur);
// diff=80: w≈0.04 (Klippe, kaum Blur).
final float BILATERAL_INV_2S2 = 1f / (2f * 30f * 30f);
for (int iter = 0; iter < 7; iter++) { for (int iter = 0; iter < 7; iter++) {
Map<Long, float[]> nextBufs = new HashMap<>(); Map<Long, float[]> nextBufs = new HashMap<>();
for (VoxelChunk c : nonEmpty) { for (VoxelChunk c : nonEmpty) {
@@ -1082,17 +1365,23 @@ public class VoxelEditorState extends BaseAppState {
for (int by = 0; by < blurN; by++) { for (int by = 0; by < blurN; by++) {
for (int bz = 0; bz < blurN; bz++) { for (int bz = 0; bz < blurN; bz++) {
for (int bx = 0; bx < blurN; bx++) { for (int bx = 0; bx < blurN; bx++) {
float sum = 0f; float center = cur[c.idx(bx, by, bz)];
float wSum = 0f, vSum = 0f;
for (int dy = -1; dy <= 1; dy++) for (int dy = -1; dy <= 1; dy++)
for (int dz = -1; dz <= 1; dz++) for (int dz = -1; dz <= 1; dz++)
for (int dx = -1; dx <= 1; dx++) { for (int dx = -1; dx <= 1; dx++) {
int sx = bx+dx, sy = by+dy, sz = bz+dz; int sx = bx+dx, sy = by+dy, sz = bz+dz;
float nb;
if (sx >= 0 && sx < blurN && sy >= 0 && sy < blurN && sz >= 0 && sz < blurN) if (sx >= 0 && sx < blurN && sy >= 0 && sy < blurN && sz >= 0 && sz < blurN)
sum += cur[c.idx(sx, sy, sz)]; nb = cur[c.idx(sx, sy, sz)];
else else
sum += getBlurBuf(curBufs, allOriginal, c, sx, sy, sz); nb = getBlurBuf(curBufs, allOriginal, c, sx, sy, sz);
float diff = nb - center;
float w = (float) Math.exp(-diff * diff * BILATERAL_INV_2S2);
vSum += w * nb;
wSum += w;
} }
next[c.idx(bx, by, bz)] = sum / 27f; next[c.idx(bx, by, bz)] = vSum / wSum;
} }
} }
} }
@@ -1251,7 +1540,7 @@ public class VoxelEditorState extends BaseAppState {
VoxelChunk[] nb = getNeighbors(original.cx, original.cy, original.cz, blurredMap); VoxelChunk[] nb = getNeighbors(original.cx, original.cy, original.cz, blurredMap);
Mesh[] meshes = { Mesh[] meshes = {
MarchingCubes.smooth(MarchingCubes.build(blurred, 1, nb), 4, 0.4f), MarchingCubes.smooth(MarchingCubes.build(blurred, 1, nb), 1, 0.3f),
MarchingCubes.smooth(MarchingCubes.build(blurred, 4, nb), 3, 0.4f), MarchingCubes.smooth(MarchingCubes.build(blurred, 4, nb), 3, 0.4f),
MarchingCubes.smooth(MarchingCubes.build(blurred, 16, nb), 2, 0.4f), MarchingCubes.smooth(MarchingCubes.build(blurred, 16, nb), 2, 0.4f),
}; };
@@ -1762,7 +2051,9 @@ public class VoxelEditorState extends BaseAppState {
basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
if (chunkGrid != null) if (chunkGrid != null)
chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
applyWireframe(entered && input.voxelWireframeEnabled); if (markedChunkOverlayRoot != null)
markedChunkOverlayRoot.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
// Wireframe-Zustand beim Layer-Wechsel NICHT ändern — wird global via Ctrl+G gesteuert.
} }
/** Zeigt/verbirgt die Voxel-Chunk-Nodes (wird vom SculptedMeshEditorState gesteuert). */ /** Zeigt/verbirgt die Voxel-Chunk-Nodes (wird vom SculptedMeshEditorState gesteuert). */

View File

@@ -8,6 +8,8 @@ import java.util.List;
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten für Felstürme, Plateaus. * Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten für Felstürme, Plateaus.
* Modus 4 (Aushöhlen): Kugel-Entfernen. * Modus 4 (Aushöhlen): Kugel-Entfernen.
* Modus 5 (Zurücksetzen): Setzt Voxel im Bereich auf das Basis-Niveau y=-10 zurück. * Modus 5 (Zurücksetzen): Setzt Voxel im Bereich auf das Basis-Niveau y=-10 zurück.
* Modus 6 (Baked löschen): Linksklick markiert gebackene Chunks im Pinselbereich (rotes
* Overlay), Rechtsklick hebt Markierung auf. Ein Panel-Button löscht alle markierten Chunks.
* *
* horizontal=false (Vertikal): Säulen entlang Y-Achse. * horizontal=false (Vertikal): Säulen entlang Y-Achse.
* horizontal=true (Horizontal): Brush entlang der Flächennormale; bei flacher Fläche kein Effekt. * horizontal=true (Horizontal): Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
@@ -19,16 +21,17 @@ import java.util.List;
*/ */
public class VoxelTool extends EditorTool { public class VoxelTool extends EditorTool {
public static final int MODE_SINUS = 0; public static final int MODE_SINUS = 0;
public static final int MODE_SPIKE = 1; public static final int MODE_SPIKE = 1;
public static final int MODE_PLATEAU = 2; public static final int MODE_PLATEAU = 2;
public static final int MODE_SMOOTH = 3; public static final int MODE_SMOOTH = 3;
public static final int MODE_REMOVE = 4; public static final int MODE_REMOVE = 4;
public static final int MODE_RESET = 5; public static final int MODE_RESET = 5;
public static final int MODE_DELETE_BAKED = 6;
public final ChoiceToolParameter mode = new ChoiceToolParameter( public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus", "Modus",
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen"}, new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen", "Baked löschen"},
MODE_SINUS, MODE_SINUS,
new String[]{ new String[]{
"img/editor/terraintool_sinus.png", "img/editor/terraintool_sinus.png",
@@ -36,6 +39,7 @@ public class VoxelTool extends EditorTool {
"img/editor/terraintool_plateau.png", "img/editor/terraintool_plateau.png",
"img/editor/terraintool_smooth.png", "img/editor/terraintool_smooth.png",
null, null,
null,
null null
} }
); );

View File

@@ -584,8 +584,9 @@ public class DialogEditorView extends BorderPane {
List<de.blight.common.model.DialogStep> heroList = new ArrayList<>(); List<de.blight.common.model.DialogStep> heroList = new ArrayList<>();
for (int i = 0; i < heroStepsView.getItems().size(); i++) { for (int i = 0; i < heroStepsView.getItems().size(); i++) {
String heroKey = stepKey(base, ".textmainchar", i);
heroList.add(new de.blight.common.model.DialogStep( heroList.add(new de.blight.common.model.DialogStep(
new TextReference(stepKey(base, ".textmainchar", i)), null)); new TextReference(heroKey), new AudioReference(heroKey)));
} }
opt.setHeroSteps(heroList); opt.setHeroSteps(heroList);
opt.setTextHero(heroList.isEmpty() ? null opt.setTextHero(heroList.isEmpty() ? null
@@ -593,8 +594,9 @@ public class DialogEditorView extends BorderPane {
List<de.blight.common.model.DialogStep> npcList = new ArrayList<>(); List<de.blight.common.model.DialogStep> npcList = new ArrayList<>();
for (int i = 0; i < npcStepsView.getItems().size(); i++) { for (int i = 0; i < npcStepsView.getItems().size(); i++) {
String npcKey = stepKey(base, ".textnpc", i);
npcList.add(new de.blight.common.model.DialogStep( npcList.add(new de.blight.common.model.DialogStep(
new TextReference(stepKey(base, ".textnpc", i)), null)); new TextReference(npcKey), new AudioReference(npcKey)));
} }
opt.setNpcSteps(npcList); opt.setNpcSteps(npcList);
opt.setTextNpc(npcList.isEmpty() ? null opt.setTextNpc(npcList.isEmpty() ? null
@@ -708,9 +710,9 @@ public class DialogEditorView extends BorderPane {
opt.setTextHero(new TextReference(base + ".textmainchar")); opt.setTextHero(new TextReference(base + ".textmainchar"));
opt.setTextNpc(new TextReference(base + ".textnpc")); opt.setTextNpc(new TextReference(base + ".textnpc"));
opt.setHeroSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep( opt.setHeroSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep(
new TextReference(base + ".textmainchar"), null)))); new TextReference(base + ".textmainchar"), new AudioReference(base + ".textmainchar")))));
opt.setNpcSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep( opt.setNpcSteps(new ArrayList<>(List.of(new de.blight.common.model.DialogStep(
new TextReference(base + ".textnpc"), null)))); new TextReference(base + ".textnpc"), new AudioReference(base + ".textnpc")))));
allOptions.put(id, opt); allOptions.put(id, opt);
if (asRoot) { if (asRoot) {
rootIds.add(id); rootIds.add(id);

View File

@@ -1,5 +1,7 @@
package de.blight.editor.ui; package de.blight.editor.ui;
import de.blight.common.model.AudioBundle;
import de.blight.common.model.AudioBundleIO;
import de.blight.common.model.TextBundle; import de.blight.common.model.TextBundle;
import de.blight.common.model.TextBundleIO; import de.blight.common.model.TextBundleIO;
import de.blight.common.model.TextKeyStore; import de.blight.common.model.TextKeyStore;
@@ -13,7 +15,9 @@ import javafx.geometry.Pos;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTableCell; import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.*; import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.*; import java.util.*;
@@ -22,12 +26,19 @@ public class LocalizationEditorView extends BorderPane {
private final Path locDir; private final Path locDir;
private final ComboBox<String> langCombo = new ComboBox<>(); // Sprache
private final ComboBox<String> langCombo = new ComboBox<>();
// Text-Tab
private final ObservableList<String[]> tableData = FXCollections.observableArrayList(); private final ObservableList<String[]> tableData = FXCollections.observableArrayList();
private final TableView<String[]> table = new TableView<>(tableData); private final TableView<String[]> table = new TableView<>(tableData);
private TextBundle currentBundle = null; private TextBundle currentBundle = null;
// Audio-Tab
private final ObservableList<String[]> audioData = FXCollections.observableArrayList();
private final TableView<String[]> audioTable = new TableView<>(audioData);
private AudioBundle currentAudioBundle = null;
public LocalizationEditorView(Path locDir) { public LocalizationEditorView(Path locDir) {
this.locDir = locDir; this.locDir = locDir;
setStyle("-fx-background-color: #1e1e2e;"); setStyle("-fx-background-color: #1e1e2e;");
@@ -44,53 +55,64 @@ public class LocalizationEditorView extends BorderPane {
Button addLangBtn = new Button("+ Sprache"); Button addLangBtn = new Button("+ Sprache");
addLangBtn.setOnAction(e -> addLanguage()); addLangBtn.setOnAction(e -> addLanguage());
Button delLangBtn = new Button(" Sprache"); Button delLangBtn = new Button("- Sprache");
delLangBtn.setOnAction(e -> deleteLanguage()); delLangBtn.setOnAction(e -> deleteLanguage());
Button addKeyBtn = new Button("+ Schlüssel");
addKeyBtn.setOnAction(e -> addKey());
Button delKeyBtn = new Button(" Entfernen");
delKeyBtn.setOnAction(e -> deleteKey());
Button sortBtn = new Button("A→Z");
sortBtn.setOnAction(e -> tableData.sort(Comparator.comparing(r -> r[0])));
Button saveBtn = new Button("Speichern"); Button saveBtn = new Button("Speichern");
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveCurrent()); saveBtn.setOnAction(e -> saveCurrent());
HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn, HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn,
new Separator(javafx.geometry.Orientation.VERTICAL),
addKeyBtn, delKeyBtn, sortBtn,
new Separator(javafx.geometry.Orientation.VERTICAL), new Separator(javafx.geometry.Orientation.VERTICAL),
saveBtn); saveBtn);
toolbar.setPadding(new Insets(8, 12, 8, 12)); toolbar.setPadding(new Insets(8, 12, 8, 12));
toolbar.setAlignment(Pos.CENTER_LEFT); toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;"); toolbar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
Tab textTab = new Tab("Text", buildTextTabContent());
Tab audioTab = new Tab("Audio", buildAudioTabContent());
textTab.setClosable(false);
audioTab.setClosable(false);
TabPane tabs = new TabPane(textTab, audioTab);
tabs.setStyle("-fx-background-color: #1e1e2e;");
setTop(toolbar);
setCenter(tabs);
}
// Text-Tab
private VBox buildTextTabContent() {
Button addKeyBtn = new Button("+ Schluessel");
addKeyBtn.setOnAction(e -> addKey());
Button delKeyBtn = new Button("- Entfernen");
delKeyBtn.setOnAction(e -> deleteKey());
Button sortBtn = new Button("A->Z");
sortBtn.setOnAction(e -> tableData.sort(Comparator.comparing(r -> r[0])));
HBox subBar = new HBox(6, addKeyBtn, delKeyBtn, sortBtn);
subBar.setPadding(new Insets(6, 12, 6, 12));
subBar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
table.setEditable(true); table.setEditable(true);
table.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); table.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
var policy = TableView.CONSTRAINED_RESIZE_POLICY; var policy = TableView.CONSTRAINED_RESIZE_POLICY;
table.setColumnResizePolicy(policy); table.setColumnResizePolicy(policy);
TableColumn<String[], String> keyCol = new TableColumn<>("Schlüssel"); TableColumn<String[], String> keyCol = new TableColumn<>("Schluessel");
keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0])); keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0]));
keyCol.setCellFactory(TextFieldTableCell.forTableColumn()); keyCol.setCellFactory(TextFieldTableCell.forTableColumn());
keyCol.setOnEditCommit(e -> { keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); table.refresh(); });
e.getRowValue()[0] = e.getNewValue().trim();
table.refresh();
});
keyCol.setPrefWidth(280); keyCol.setPrefWidth(280);
TableColumn<String[], String> valCol = new TableColumn<>("Text"); TableColumn<String[], String> valCol = new TableColumn<>("Text");
valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1])); valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1]));
valCol.setCellFactory(TextFieldTableCell.forTableColumn()); valCol.setCellFactory(TextFieldTableCell.forTableColumn());
valCol.setOnEditCommit(e -> { valCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); table.refresh(); });
e.getRowValue()[1] = e.getNewValue();
table.refresh();
});
table.getColumns().addAll(List.of(keyCol, valCol)); table.getColumns().addAll(List.of(keyCol, valCol));
VBox.setVgrow(table, Priority.ALWAYS); VBox.setVgrow(table, Priority.ALWAYS);
@@ -98,14 +120,87 @@ public class LocalizationEditorView extends BorderPane {
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags."); Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");
hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;"); hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;");
VBox center = new VBox(table, hint); VBox box = new VBox(subBar, table, hint);
VBox.setVgrow(table, Priority.ALWAYS); VBox.setVgrow(table, Priority.ALWAYS);
return box;
setTop(toolbar);
setCenter(center);
} }
// ── Sprach-Verwaltung ───────────────────────────────────────────────────── // Audio-Tab
private VBox buildAudioTabContent() {
Button addKeyBtn = new Button("+ Schluessel");
addKeyBtn.setOnAction(e -> addAudioKey());
Button delKeyBtn = new Button("- Entfernen");
delKeyBtn.setOnAction(e -> deleteAudioKey());
Button sortBtn = new Button("A->Z");
sortBtn.setOnAction(e -> audioData.sort(Comparator.comparing(r -> r[0])));
HBox subBar = new HBox(6, addKeyBtn, delKeyBtn, sortBtn);
subBar.setPadding(new Insets(6, 12, 6, 12));
subBar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #444; -fx-border-width: 0 0 1 0;");
audioTable.setEditable(true);
audioTable.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
@SuppressWarnings("deprecation")
var policy2 = TableView.CONSTRAINED_RESIZE_POLICY;
audioTable.setColumnResizePolicy(policy2);
TableColumn<String[], String> keyCol = new TableColumn<>("Schluessel");
keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0]));
keyCol.setCellFactory(TextFieldTableCell.forTableColumn());
keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); audioTable.refresh(); });
keyCol.setPrefWidth(220);
TableColumn<String[], String> pathCol = new TableColumn<>("Datei");
pathCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1]));
pathCol.setCellFactory(TextFieldTableCell.forTableColumn());
pathCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); audioTable.refresh(); });
TableColumn<String[], Void> browseCol = new TableColumn<>("");
browseCol.setPrefWidth(36);
browseCol.setMinWidth(36);
browseCol.setMaxWidth(36);
browseCol.setCellFactory(tc -> new TableCell<>() {
private final Button btn = new Button("...");
{
btn.setStyle("-fx-padding: 2 4 2 4;");
btn.setOnAction(ev -> {
int idx = getIndex();
if (idx < 0 || idx >= audioData.size()) return;
FileChooser fc = new FileChooser();
fc.setTitle("Audio-Datei waehlen");
fc.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("Audio", "*.ogg", "*.wav", "*.mp3"),
new FileChooser.ExtensionFilter("Alle Dateien", "*.*")
);
File file = fc.showOpenDialog(getScene().getWindow());
if (file != null) {
audioData.get(idx)[1] = file.getAbsolutePath();
audioTable.refresh();
}
});
}
@Override
protected void updateItem(Void v, boolean empty) {
super.updateItem(v, empty);
setGraphic(empty ? null : btn);
}
});
audioTable.getColumns().addAll(List.of(keyCol, pathCol, browseCol));
VBox.setVgrow(audioTable, Priority.ALWAYS);
Label hint = new Label("Schluessel muss dem AudioReference-Key im Dialog entsprechen.");
hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;");
VBox box = new VBox(subBar, audioTable, hint);
VBox.setVgrow(audioTable, Priority.ALWAYS);
return box;
}
// Sprach-Verwaltung
private void refreshLangList() { private void refreshLangList() {
String selected = langCombo.getValue(); String selected = langCombo.getValue();
@@ -116,13 +211,12 @@ public class LocalizationEditorView extends BorderPane {
langCombo.setValue(langCombo.getItems().get(0)); langCombo.setValue(langCombo.getItems().get(0));
} else { } else {
tableData.clear(); tableData.clear();
audioData.clear();
} }
} }
private void loadLanguage(String lang) { private void loadLanguage(String lang) {
if (lang == null) { if (lang == null) return;
return;
}
try { try {
currentBundle = TextBundleIO.load(locDir.resolve("messages_" + lang + ".properties")); currentBundle = TextBundleIO.load(locDir.resolve("messages_" + lang + ".properties"));
} catch (IOException e) { } catch (IOException e) {
@@ -132,17 +226,19 @@ public class LocalizationEditorView extends BorderPane {
currentBundle.getEntries().forEach((k, v) -> tableData.add(new String[]{k, v})); currentBundle.getEntries().forEach((k, v) -> tableData.add(new String[]{k, v}));
TextRegistry.clear(); TextRegistry.clear();
TextRegistry.registerAll(currentBundle.getEntries()); TextRegistry.registerAll(currentBundle.getEntries());
currentAudioBundle = AudioBundleIO.loadOrEmpty(lang, locDir);
audioData.clear();
currentAudioBundle.getEntries().forEach((k, v) -> audioData.add(new String[]{k, v}));
} }
private void addLanguage() { private void addLanguage() {
TextInputDialog dlg = new TextInputDialog(); TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Sprache hinzufügen"); dlg.setTitle("Sprache hinzufuegen");
dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):"); dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):");
dlg.showAndWait().ifPresent(lang -> { dlg.showAndWait().ifPresent(lang -> {
lang = lang.trim().toLowerCase(); lang = lang.trim().toLowerCase();
if (lang.isBlank()) { if (lang.isBlank()) return;
return;
}
TextBundle bundle = new TextBundle(lang); TextBundle bundle = new TextBundle(lang);
try { try {
TextBundleIO.save(bundle, locDir); TextBundleIO.save(bundle, locDir);
@@ -156,17 +252,18 @@ public class LocalizationEditorView extends BorderPane {
private void deleteLanguage() { private void deleteLanguage() {
String lang = langCombo.getValue(); String lang = langCombo.getValue();
if (lang == null) { if (lang == null) return;
return;
}
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION, Alert confirm = new Alert(Alert.AlertType.CONFIRMATION,
"Sprache '" + lang + "' wirklich löschen?", ButtonType.YES, ButtonType.NO); "Sprache '" + lang + "' wirklich loeschen?", ButtonType.YES, ButtonType.NO);
confirm.showAndWait().ifPresent(bt -> { confirm.showAndWait().ifPresent(bt -> {
if (bt == ButtonType.YES) { if (bt == ButtonType.YES) {
try { try {
TextBundleIO.delete(lang, locDir); TextBundleIO.delete(lang, locDir);
AudioBundleIO.delete(lang, locDir);
currentBundle = null; currentBundle = null;
currentAudioBundle = null;
tableData.clear(); tableData.clear();
audioData.clear();
refreshLangList(); refreshLangList();
} catch (IOException e) { } catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
@@ -175,30 +272,25 @@ public class LocalizationEditorView extends BorderPane {
}); });
} }
// ── Eintrags-Verwaltung ─────────────────────────────────────────────────── // Text-Eintrags-Verwaltung
private void addKey() { private void addKey() {
if (currentBundle == null) { if (currentBundle == null) return;
return;
}
Set<String> existing = currentBundle.getEntries().keySet(); Set<String> existing = currentBundle.getEntries().keySet();
List<String> available = new ArrayList<>(TextKeyStore.getKeys()); List<String> available = new ArrayList<>(TextKeyStore.getKeys());
available.removeAll(existing); available.removeAll(existing);
if (available.isEmpty()) { if (available.isEmpty()) {
TextInputDialog dlg = new TextInputDialog(); TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Schlüssel hinzufügen"); dlg.setTitle("Schluessel hinzufuegen");
dlg.setHeaderText("Alle bekannten Schlüssel bereits vorhanden.\nNeuen Schlüssel eingeben:"); dlg.setHeaderText("Alle bekannten Schluessel bereits vorhanden.\nNeuen Schluessel eingeben:");
dlg.showAndWait() dlg.showAndWait().map(String::trim).filter(s -> !s.isBlank()).ifPresent(this::insertKey);
.map(String::trim)
.filter(s -> !s.isBlank())
.ifPresent(this::insertKey);
return; return;
} }
Dialog<String> dlg = new Dialog<>(); Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Schlüssel wählen"); dlg.setTitle("Schluessel waehlen");
dlg.setHeaderText("Noch nicht übersetzte Schlüssel:"); dlg.setHeaderText("Noch nicht uebersetzte Schluessel:");
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ObservableList<String> items = FXCollections.observableArrayList(available); ObservableList<String> items = FXCollections.observableArrayList(available);
@@ -208,7 +300,7 @@ public class LocalizationEditorView extends BorderPane {
listView.getSelectionModel().selectFirst(); listView.getSelectionModel().selectFirst();
TextField filterField = new TextField(); TextField filterField = new TextField();
filterField.setPromptText("Filtern"); filterField.setPromptText("Filtern...");
filterField.textProperty().addListener((obs, o, n) -> filterField.textProperty().addListener((obs, o, n) ->
filtered.setPredicate(s -> n.isBlank() || s.contains(n))); filtered.setPredicate(s -> n.isBlank() || s.contains(n)));
@@ -216,9 +308,7 @@ public class LocalizationEditorView extends BorderPane {
dlg.setResultConverter(bt -> dlg.setResultConverter(bt ->
bt == ButtonType.OK ? listView.getSelectionModel().getSelectedItem() : null); bt == ButtonType.OK ? listView.getSelectionModel().getSelectedItem() : null);
dlg.showAndWait() dlg.showAndWait().filter(k -> k != null && !k.isBlank()).ifPresent(this::insertKey);
.filter(k -> k != null && !k.isBlank())
.ifPresent(this::insertKey);
} }
private void insertKey(String key) { private void insertKey(String key) {
@@ -229,27 +319,54 @@ public class LocalizationEditorView extends BorderPane {
private void deleteKey() { private void deleteKey() {
String[] sel = table.getSelectionModel().getSelectedItem(); String[] sel = table.getSelectionModel().getSelectedItem();
if (sel != null) { if (sel != null) tableData.remove(sel);
tableData.remove(sel);
}
} }
// Audio-Eintrags-Verwaltung
private void addAudioKey() {
TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Audio-Schluessel hinzufuegen");
dlg.setHeaderText("Schluessel eingeben (muss dem AudioReference-Key im Dialog entsprechen):");
dlg.showAndWait().map(String::trim).filter(s -> !s.isBlank()).ifPresent(key -> {
audioData.add(new String[]{key, ""});
audioTable.scrollTo(audioData.size() - 1);
audioTable.getSelectionModel().select(audioData.size() - 1);
});
}
private void deleteAudioKey() {
String[] sel = audioTable.getSelectionModel().getSelectedItem();
if (sel != null) audioData.remove(sel);
}
// Speichern
private void saveCurrent() { private void saveCurrent() {
if (currentBundle == null) { if (currentBundle == null) {
new Alert(Alert.AlertType.WARNING, "Keine Sprache ausgewählt.", ButtonType.OK).showAndWait(); new Alert(Alert.AlertType.WARNING, "Keine Sprache ausgewaehlt.", ButtonType.OK).showAndWait();
return; return;
} }
Map<String, String> entries = new LinkedHashMap<>(); Map<String, String> textEntries = new LinkedHashMap<>();
for (String[] row : tableData) { for (String[] row : tableData) {
if (!row[0].isBlank()) { if (!row[0].isBlank()) textEntries.put(row[0].trim(), row[1]);
entries.put(row[0].trim(), row[1]);
}
} }
currentBundle.setEntries(entries); currentBundle.setEntries(textEntries);
Map<String, String> audioEntries = new LinkedHashMap<>();
for (String[] row : audioData) {
if (!row[0].isBlank()) audioEntries.put(row[0].trim(), row[1]);
}
if (currentAudioBundle == null) {
currentAudioBundle = new AudioBundle(currentBundle.getLanguage());
}
currentAudioBundle.setEntries(audioEntries);
try { try {
TextBundleIO.save(currentBundle, locDir); TextBundleIO.save(currentBundle, locDir);
AudioBundleIO.save(currentAudioBundle, locDir);
TextRegistry.clear(); TextRegistry.clear();
TextRegistry.registerAll(entries); TextRegistry.registerAll(textEntries);
} catch (IOException e) { } catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
} }

View File

@@ -0,0 +1,357 @@
package de.blight.editor.ui;
import de.blight.common.MonologueIO;
import de.blight.common.model.*;
import de.blight.common.model.quests.Quest;
import de.blight.common.model.quests.QuestIO;
import de.blight.editor.ProjectRoot;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Editor für Monologe des Hauptcharakters (Selbstgespräche). */
public class MonologueEditorView extends SplitPane {
private final ObservableList<Monologue> monologues = FXCollections.observableArrayList();
private final ListView<Monologue> listView = new ListView<>(monologues);
// Formular-Felder
private TextField idField;
private Spinner<Integer> chapterSpinner;
private CheckBox playOnceCheck;
private ListView<String> stepsView;
private TextField recvQuestField;
private TextField fulfillsQuestField;
private ListView<String> abortsQuestsView;
private Monologue current = null;
private boolean loading = false;
public MonologueEditorView() {
setDividerPositions(0.25);
getItems().addAll(buildList(), buildForm());
loadAll();
}
// ── Liste ─────────────────────────────────────────────────────────────────
private VBox buildList() {
listView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Monologue m, boolean empty) {
super.updateItem(m, empty);
setText(empty || m == null ? null : (m.getId().isBlank() ? "(kein Name)" : m.getId()));
}
});
listView.getSelectionModel().selectedItemProperty().addListener((obs, o, n) -> {
if (!loading) loadMonologue(n);
});
Button addBtn = new Button("+ Neu");
addBtn.setOnAction(e -> createMonologue());
Button delBtn = new Button("- Löschen");
delBtn.setOnAction(e -> deleteMonologue());
Button saveBtn = new Button("Speichern");
saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;");
saveBtn.setOnAction(e -> saveAll());
HBox buttons = new HBox(6, addBtn, delBtn, saveBtn);
buttons.setPadding(new Insets(6));
VBox box = new VBox(buttons, listView);
VBox.setVgrow(listView, Priority.ALWAYS);
box.setStyle("-fx-background-color: #1a1a2a;");
box.setMinWidth(160);
return box;
}
// ── Formular ──────────────────────────────────────────────────────────────
private ScrollPane buildForm() {
idField = new TextField();
idField.setPromptText("monologue.my_intro");
idField.textProperty().addListener((obs, o, n) -> {
if (current != null && !loading) {
current.setId(n.trim());
listView.refresh();
}
});
chapterSpinner = new Spinner<>(0, 99, 0);
chapterSpinner.setEditable(true);
chapterSpinner.setPrefWidth(80);
playOnceCheck = new CheckBox("Nur einmal abspielen");
playOnceCheck.setSelected(true);
// Schritte
stepsView = new ListView<>();
stepsView.setPrefHeight(120);
stepsView.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
Button addStepBtn = new Button("+ Schritt");
addStepBtn.setOnAction(e -> {
if (current == null) return;
String base = "monologue." + (current.getId().isBlank() ? "id" : current.getId());
stepsView.getItems().add(base + ".text_" + stepsView.getItems().size());
});
Button delStepBtn = new Button("- Entfernen");
delStepBtn.setOnAction(e -> {
String sel = stepsView.getSelectionModel().getSelectedItem();
if (sel != null) stepsView.getItems().remove(sel);
});
stepsView.setEditable(true);
stepsView.setCellFactory(javafx.scene.control.cell.TextFieldListCell.forListView());
HBox stepBtns = new HBox(6, addStepBtn, delStepBtn);
// Quest-Folgen
recvQuestField = questField("Quest erhalten (ID)");
fulfillsQuestField = questField("Quest erfüllen (ID)");
abortsQuestsView = new ListView<>();
abortsQuestsView.setPrefHeight(80);
abortsQuestsView.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
Button addAbortBtn = new Button("+ Quest abbrechen");
addAbortBtn.setOnAction(e -> {
String id = showQuestPicker();
if (id != null && !abortsQuestsView.getItems().contains(id)) abortsQuestsView.getItems().add(id);
});
Button delAbortBtn = new Button("- Entfernen");
delAbortBtn.setOnAction(e -> {
String sel = abortsQuestsView.getSelectionModel().getSelectedItem();
if (sel != null) abortsQuestsView.getItems().remove(sel);
});
HBox abortBtns = new HBox(6, addAbortBtn, delAbortBtn);
Label stepsLbl = sectionLabel("Schritte (Textschlüssel)");
Label questLbl = sectionLabel("Quest-Folgen");
VBox form = new VBox(10);
form.setPadding(new Insets(12));
form.setStyle("-fx-background-color: #1e1e2e;");
form.getChildren().addAll(
row("Monolog-ID:", idField),
row("Kapitel (mind.):", chapterSpinner),
playOnceCheck,
new Separator(),
stepsLbl, stepsView, stepBtns,
new Separator(),
questLbl,
row("Erhält Quest:", recvQuestField),
row("Erfüllt Quest:", fulfillsQuestField),
new Label("Bricht Quests ab:"),
abortsQuestsView, abortBtns
);
ScrollPane scroll = new ScrollPane(form);
scroll.setFitToWidth(true);
scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scroll.setStyle("-fx-background-color: transparent; -fx-background: transparent;");
return scroll;
}
// ── Daten laden/speichern ─────────────────────────────────────────────────
private void loadAll() {
loading = true;
monologues.setAll(MonologueIO.load());
loading = false;
if (!monologues.isEmpty()) {
listView.getSelectionModel().select(0);
loadMonologue(monologues.get(0));
}
}
private void loadMonologue(Monologue m) {
current = m;
loading = true;
try {
if (m == null) {
clearForm();
return;
}
idField.setText(m.getId());
chapterSpinner.getValueFactory().setValue(m.getRequiresChapter());
playOnceCheck.setSelected(m.isPlayOnce());
stepsView.getItems().clear();
for (DialogStep step : m.getHeroSteps()) {
String key = step.getText() != null ? step.getText().id() : "";
stepsView.getItems().add(key);
}
recvQuestField.setText(m.getRecievesQuest() != null ? m.getRecievesQuest().getQuestId() : "");
fulfillsQuestField.setText(m.getFulfillsQuest() != null ? m.getFulfillsQuest().getQuestId() : "");
abortsQuestsView.getItems().clear();
if (m.getAbortsQuests() != null) {
m.getAbortsQuests().stream()
.map(QuestRef::getQuestId)
.filter(id -> id != null && !id.isBlank())
.forEach(abortsQuestsView.getItems()::add);
}
} finally {
loading = false;
}
}
private void applyFormToMonologue() {
if (current == null || loading) return;
current.setId(idField.getText().trim());
current.setRequiresChapter(chapterSpinner.getValue());
current.setPlayOnce(playOnceCheck.isSelected());
List<DialogStep> steps = new ArrayList<>();
for (String key : stepsView.getItems()) {
if (!key.isBlank()) {
steps.add(new DialogStep(new TextReference(key), new AudioReference(key)));
}
}
current.setHeroSteps(steps);
current.setRecievesQuest(questRefFromText(recvQuestField.getText()));
current.setFulfillsQuest(questRefFromText(fulfillsQuestField.getText()));
List<QuestRef> aborts = new ArrayList<>();
for (String id : abortsQuestsView.getItems()) {
QuestRef q = new QuestRef();
q.setQuestId(id);
aborts.add(q);
}
current.setAbortsQuests(aborts);
}
private void saveAll() {
applyFormToMonologue();
try {
MonologueIO.save(new ArrayList<>(monologues));
} catch (IOException e) {
new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait();
}
}
private void createMonologue() {
applyFormToMonologue();
Monologue m = new Monologue();
m.setId("monologue.new_" + monologues.size());
monologues.add(m);
listView.getSelectionModel().select(m);
loadMonologue(m);
}
private void deleteMonologue() {
Monologue sel = listView.getSelectionModel().getSelectedItem();
if (sel == null) return;
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION,
"Monolog '" + sel.getId() + "' wirklich löschen?", ButtonType.YES, ButtonType.NO);
confirm.showAndWait().ifPresent(bt -> {
if (bt == ButtonType.YES) {
monologues.remove(sel);
current = null;
clearForm();
if (!monologues.isEmpty()) listView.getSelectionModel().select(0);
}
});
}
private void clearForm() {
idField.clear();
chapterSpinner.getValueFactory().setValue(0);
playOnceCheck.setSelected(true);
stepsView.getItems().clear();
recvQuestField.clear();
fulfillsQuestField.clear();
abortsQuestsView.getItems().clear();
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private TextField questField(String prompt) {
TextField tf = new TextField();
tf.setPromptText(prompt);
tf.setEditable(false);
tf.setCursor(Cursor.HAND);
tf.setOnMouseClicked(e -> {
String id = showQuestPicker();
if (id != null) tf.setText(id);
});
return tf;
}
private String showQuestPicker() {
Path questDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources").resolve("quests");
List<Quest> quests = QuestIO.loadAll(questDir);
Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Quest auswählen");
dlg.initModality(Modality.APPLICATION_MODAL);
ListView<Quest> chooser = new ListView<>();
chooser.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Quest q, boolean empty) {
super.updateItem(q, empty);
if (empty || q == null) { setText(null); return; }
String id = q.getQuestId() != null ? q.getQuestId() : "";
String name = q.getText() != null ? q.getText().id() : "";
setText(name.isBlank() ? id : id + "" + name);
setStyle("-fx-text-fill: #cccccc;");
}
});
chooser.getItems().setAll(quests);
chooser.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
chooser.setPrefSize(380, 280);
dlg.getDialogPane().setContent(quests.isEmpty()
? new Label("Keine Quests gefunden.") : chooser);
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
dlg.getDialogPane().setStyle("-fx-background-color: #252535;");
Button okBtn = (Button) dlg.getDialogPane().lookupButton(ButtonType.OK);
okBtn.setDisable(true);
chooser.getSelectionModel().selectedItemProperty()
.addListener((obs, o, n) -> okBtn.setDisable(n == null));
chooser.setOnMouseClicked(e -> {
if (e.getClickCount() == 2 && !chooser.getSelectionModel().isEmpty()) okBtn.fire();
});
dlg.setResultConverter(bt -> {
if (bt != ButtonType.OK) return null;
Quest sel = chooser.getSelectionModel().getSelectedItem();
return sel != null ? sel.getQuestId() : null;
});
return dlg.showAndWait().orElse(null);
}
private static QuestRef questRefFromText(String text) {
if (text == null || text.isBlank()) return null;
QuestRef q = new QuestRef();
q.setQuestId(text.trim());
return q;
}
private static Label sectionLabel(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-text-fill: #aaa; -fx-font-size: 11;");
return l;
}
private static HBox row(String labelText, javafx.scene.Node control) {
Label lbl = new Label(labelText);
lbl.setMinWidth(130);
lbl.setStyle("-fx-text-fill: #aaa;");
HBox.setHgrow(control, Priority.ALWAYS);
HBox box = new HBox(8, lbl, control);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
}

View File

@@ -1,5 +1,7 @@
package de.blight.editor.ui; package de.blight.editor.ui;
import de.blight.common.MonologueIO;
import de.blight.common.model.Monologue;
import de.blight.common.model.QuestRef; import de.blight.common.model.QuestRef;
import de.blight.common.model.Status; import de.blight.common.model.Status;
import de.blight.common.model.trigger.*; import de.blight.common.model.trigger.*;
@@ -25,10 +27,11 @@ import java.util.UUID;
*/ */
public class TriggerDialog extends Dialog<Trigger> { public class TriggerDialog extends Dialog<Trigger> {
private static final String TYPE_QUEST = "Quest starten"; private static final String TYPE_QUEST = "Quest starten";
private static final String TYPE_NPC = "NPC-Status ändern"; private static final String TYPE_NPC = "NPC-Status ändern";
private static final String TYPE_FRACTION = "Fraktions-Status ändern"; private static final String TYPE_FRACTION = "Fraktions-Status ändern";
private static final String TYPE_ROUTINE = "Routine ändern"; private static final String TYPE_ROUTINE = "Routine ändern";
private static final String TYPE_MONOLOGUE = "Monolog starten";
// Gemeinsam // Gemeinsam
private final ComboBox<String> typeCombo = new ComboBox<>(); private final ComboBox<String> typeCombo = new ComboBox<>();
@@ -50,6 +53,9 @@ public class TriggerDialog extends Dialog<Trigger> {
private TextField routineNpcIdField; private TextField routineNpcIdField;
private TextField routineNameField; private TextField routineNameField;
// Monolog
private ComboBox<String> monologueIdCombo;
/** Öffnet den Dialog für einen neuen Trigger. */ /** Öffnet den Dialog für einen neuen Trigger. */
public TriggerDialog() { public TriggerDialog() {
this(null); this(null);
@@ -61,7 +67,7 @@ public class TriggerDialog extends Dialog<Trigger> {
initModality(Modality.APPLICATION_MODAL); initModality(Modality.APPLICATION_MODAL);
setResizable(true); setResizable(true);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE); typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE, TYPE_MONOLOGUE);
typeCombo.setMaxWidth(Double.MAX_VALUE); typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
@@ -102,10 +108,11 @@ public class TriggerDialog extends Dialog<Trigger> {
dynamicArea.getChildren().clear(); dynamicArea.getChildren().clear();
if (type == null) return; if (type == null) return;
switch (type) { switch (type) {
case TYPE_QUEST -> buildQuestFields(); case TYPE_QUEST -> buildQuestFields();
case TYPE_NPC -> buildNpcFields(); case TYPE_NPC -> buildNpcFields();
case TYPE_FRACTION -> buildFractionFields(); case TYPE_FRACTION -> buildFractionFields();
case TYPE_ROUTINE -> buildRoutineFields(); case TYPE_ROUTINE -> buildRoutineFields();
case TYPE_MONOLOGUE -> buildMonologueFields();
} }
} }
@@ -147,6 +154,23 @@ public class TriggerDialog extends Dialog<Trigger> {
); );
} }
private void buildMonologueFields() {
monologueIdCombo = new ComboBox<>();
monologueIdCombo.setEditable(true);
monologueIdCombo.setMaxWidth(Double.MAX_VALUE);
monologueIdCombo.setPromptText("Monolog-ID eingeben oder wählen...");
try {
de.blight.common.MonologueIO.load().stream()
.map(Monologue::getId)
.filter(id -> !id.isBlank())
.forEach(monologueIdCombo.getItems()::add);
} catch (Exception ignored) {}
dynamicArea.getChildren().addAll(
sectionTitle("Monolog starten"),
row("Monolog-ID:", monologueIdCombo)
);
}
// ── Trigger bauen ───────────────────────────────────────────────────────── // ── Trigger bauen ─────────────────────────────────────────────────────────
private Trigger buildTrigger() { private Trigger buildTrigger() {
@@ -183,6 +207,14 @@ public class TriggerDialog extends Dialog<Trigger> {
if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim()); if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim());
yield r; yield r;
} }
case TYPE_MONOLOGUE -> {
MonologueTrigger mo = new MonologueTrigger();
if (monologueIdCombo != null) {
String id = monologueIdCombo.getValue();
if (id != null) mo.setMonologueId(id.trim());
}
yield mo;
}
default -> null; default -> null;
}; };
if (t != null) t.setRequiresChapter(chapterSpinner.getValue()); if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
@@ -214,6 +246,10 @@ public class TriggerDialog extends Dialog<Trigger> {
routineNpcIdField.setText(r.getNpcId()); routineNpcIdField.setText(r.getNpcId());
if (routineNameField != null && r.getRoutineName() != null) if (routineNameField != null && r.getRoutineName() != null)
routineNameField.setText(r.getRoutineName()); routineNameField.setText(r.getRoutineName());
} else if (t instanceof MonologueTrigger mo) {
typeCombo.setValue(TYPE_MONOLOGUE);
if (monologueIdCombo != null && mo.getMonologueId() != null)
monologueIdCombo.setValue(mo.getMonologueId());
} }
} }

View File

@@ -1,6 +1,7 @@
package de.blight.editor.ui; package de.blight.editor.ui;
import de.blight.common.model.trigger.*; import de.blight.common.model.trigger.*;
import de.blight.common.MonologueRegistry;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.geometry.Insets; import javafx.geometry.Insets;
@@ -108,6 +109,8 @@ public class TriggerListEditor extends VBox {
if (t instanceof ChangeRoutineTrigger r) if (t instanceof ChangeRoutineTrigger r)
return "Routine ändern: " + nullSafe(r.getNpcId()) return "Routine ändern: " + nullSafe(r.getNpcId())
+ " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter; + " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter;
if (t instanceof MonologueTrigger mo)
return "Monolog: " + nullSafe(mo.getMonologueId()) + chapter;
return t.getClass().getSimpleName() + chapter; return t.getClass().getSimpleName() + chapter;
} }

View File

@@ -330,7 +330,10 @@ public class WorldScene extends BaseAppState {
new WorldItemsState(keyBindings, physicsChar, mc, playerInput)); new WorldItemsState(keyBindings, physicsChar, mc, playerInput));
app.getStateManager().attach( app.getStateManager().attach(
new WorldInteractableState(keyBindings, physicsChar, playerInput)); new WorldInteractableState(keyBindings, physicsChar, playerInput));
app.getStateManager().attach(new DialogHudState()); de.blight.common.MonologueRegistry.init(de.blight.common.MonologueIO.load());
de.blight.game.state.DialogHudState dialogHud = new de.blight.game.state.DialogHudState();
dialogHud.setMainCharacter(mc);
app.getStateManager().attach(dialogHud);
WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc); WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc);
npcsState.setThirdPersonCamera(thirdPersonCam); npcsState.setThirdPersonCamera(thirdPersonCam);
app.getStateManager().attach(npcsState); app.getStateManager().attach(npcsState);

View File

@@ -34,10 +34,14 @@ import de.blight.common.model.TextReference;
import de.blight.common.model.trigger.ChangeRoutineTrigger; import de.blight.common.model.trigger.ChangeRoutineTrigger;
import de.blight.common.model.trigger.NpcStatusTrigger; import de.blight.common.model.trigger.NpcStatusTrigger;
import de.blight.common.model.trigger.Trigger; import de.blight.common.model.trigger.Trigger;
import com.jme3.audio.AudioData;
import com.jme3.audio.AudioNode;
import de.blight.common.model.Monologue;
import de.blight.game.audio.DialogTtsService; import de.blight.game.audio.DialogTtsService;
import de.blight.game.scene.WorldScene; import de.blight.game.scene.WorldScene;
import de.blight.game.config.MenuCanvas; import de.blight.game.config.MenuCanvas;
import de.blight.game.config.NinePatch; import de.blight.game.config.NinePatch;
import de.blight.lang.AudioResolver;
import de.blight.lang.TextResolver; import de.blight.lang.TextResolver;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -168,9 +172,16 @@ public class DialogHudState extends BaseAppState {
private BitmapText scrollUpText; private BitmapText scrollUpText;
private BitmapText scrollDownText; private BitmapText scrollDownText;
// ── TTS ─────────────────────────────────────────────────────────────────── // ── Audio ─────────────────────────────────────────────────────────────────
private DialogTtsService tts; private DialogTtsService tts;
private AudioNode dialogAudioNode;
// ── Monologue polling ────────────────────────────────────────────────────
private MainCharacter mainCharForMonologue;
/** Gesetzt während eines Monologs: kein HUD-Hide, kein Cursor, freie Bewegung. */
private boolean monologueMode = false;
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -207,6 +218,12 @@ public class DialogHudState extends BaseAppState {
advanceText(); advanceText();
} }
} }
if (phase == Phase.HIDDEN && mainCharForMonologue != null) {
Monologue pending = mainCharForMonologue.pollPendingMonologue();
if (pending != null) {
startMonologue(pending, mainCharForMonologue);
}
}
} }
private void updateHover() { private void updateHover() {
@@ -242,6 +259,7 @@ public class DialogHudState extends BaseAppState {
@Override @Override
protected void cleanup(Application app) { protected void cleanup(Application app) {
stopDialogAudio();
if (tts != null) { if (tts != null) {
tts.shutdown(); tts.shutdown();
tts = null; tts = null;
@@ -284,7 +302,7 @@ public class DialogHudState extends BaseAppState {
TextReference greeting = npc.getDefaultMessage(); TextReference greeting = npc.getDefaultMessage();
String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null; String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null;
if (greetText != null && !greetText.isBlank()) { if (greetText != null && !greetText.isBlank()) {
showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, this::closeDialog); showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, null, this::closeDialog);
} else { } else {
log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung Dialog übersprungen.", npc.getCharacterId()); log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung Dialog übersprungen.", npc.getCharacterId());
closeDialog(); closeDialog();
@@ -296,6 +314,36 @@ public class DialogHudState extends BaseAppState {
return phase != Phase.HIDDEN; return phase != Phase.HIDDEN;
} }
/** Registriert den Hauptcharakter für Monolog-Polling in update(). */
public void setMainCharacter(MainCharacter mc) {
this.mainCharForMonologue = mc;
}
/**
* Startet ein Selbstgespräch des Hauptcharakters (kein NPC beteiligt).
* HUD bleibt sichtbar, Cursor bleibt verborgen, Bewegung ist weiterhin möglich.
* Audio wird abgespielt (Datei wenn vorhanden, sonst TTS).
* Quest-Folgen werden nach dem letzten Schritt angewendet.
*/
public void startMonologue(Monologue monologue, MainCharacter mc) {
this.currentNpc = null;
this.mainChar = mc;
this.onClose = null;
this.onOptionsShown = null;
this.optionsShownFired = false;
this.monologueMode = true;
// HUD bleibt sichtbar, Cursor bleibt verborgen
canvasNode.setCullHint(Spatial.CullHint.Inherit);
registerMonologueInput();
String speaker = TextResolver.get().resolveId("dialog.speaker.player");
playSteps(Phase.TEXT_HERO, speaker, monologue.getHeroSteps(), 0, () -> {
mc.handleMonologue(monologue);
closeDialog();
});
}
// ── Text-Anzeige ───────────────────────────────────────────────────────── // ── Text-Anzeige ─────────────────────────────────────────────────────────
private Runnable afterText; private Runnable afterText;
@@ -311,22 +359,47 @@ public class DialogHudState extends BaseAppState {
playSteps(ph, speaker, steps, idx + 1, after); playSteps(ph, speaker, steps, idx + 1, after);
return; return;
} }
showText(ph, speaker, text, () -> playSteps(ph, speaker, steps, idx + 1, after)); String audioPath = step.getAudio() != null ? AudioResolver.get().resolve(step.getAudio()) : null;
showText(ph, speaker, text, audioPath, () -> playSteps(ph, speaker, steps, idx + 1, after));
} }
private void showText(Phase textPhase, String speaker, String rawText, Runnable after) { private void showText(Phase textPhase, String speaker, String rawText, String audioPath, Runnable after) {
this.phase = textPhase; this.phase = textPhase;
this.afterText = after; this.afterText = after;
this.textPages = paginate(wrapText(rawText, MAX_CHARS_LINE), MAX_TEXT_LINES); this.textPages = paginate(wrapText(rawText, MAX_CHARS_LINE), MAX_TEXT_LINES);
this.pageIdx = 0; this.pageIdx = 0;
renderCurrentTextPage(speaker); renderCurrentTextPage(speaker);
if (tts != null) tts.speak(rawText);
stopDialogAudio();
if (audioPath != null && !audioPath.isBlank()) {
try {
dialogAudioNode = new AudioNode(app.getAssetManager(), audioPath, AudioData.DataType.Buffer);
dialogAudioNode.setPositional(false);
dialogAudioNode.setLooping(false);
app.getRootNode().attachChild(dialogAudioNode);
dialogAudioNode.play();
} catch (Exception e) {
log.warn("[DialogHud] Audio konnte nicht abgespielt werden: {}", audioPath);
dialogAudioNode = null;
if (tts != null) tts.speak(rawText);
}
} else {
if (tts != null) tts.speak(rawText);
}
// Shot-Reverse-Shot: NPC spricht → über Schulter des Spielers; Spieler spricht → über NPC-Schulter // Shot-Reverse-Shot: NPC spricht → über Schulter des Spielers; Spieler spricht → über NPC-Schulter
WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class); WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class);
if (npcs != null) npcs.switchDialogCamera(textPhase == Phase.TEXT_NPC); if (npcs != null) npcs.switchDialogCamera(textPhase == Phase.TEXT_NPC);
} }
private void stopDialogAudio() {
if (dialogAudioNode != null) {
dialogAudioNode.stop();
dialogAudioNode.removeFromParent();
dialogAudioNode = null;
}
}
private void renderCurrentTextPage(String speaker) { private void renderCurrentTextPage(String speaker) {
nameText.setText(speaker); nameText.setText(speaker);
nameText.setCullHint(Spatial.CullHint.Inherit); nameText.setCullHint(Spatial.CullHint.Inherit);
@@ -464,6 +537,7 @@ public class DialogHudState extends BaseAppState {
if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) { if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) {
autoAdvanceTimer = -1f; autoAdvanceTimer = -1f;
if (tts != null) tts.stop(); if (tts != null) tts.stop();
stopDialogAudio();
pageIdx = textPages.size(); pageIdx = textPages.size();
if (afterText != null) { Runnable cb = afterText; afterText = null; cb.run(); } if (afterText != null) { Runnable cb = afterText; afterText = null; cb.run(); }
} }
@@ -724,10 +798,15 @@ public class DialogHudState extends BaseAppState {
phase = Phase.HIDDEN; phase = Phase.HIDDEN;
autoAdvanceTimer = -1f; autoAdvanceTimer = -1f;
if (tts != null) tts.stop(); if (tts != null) tts.stop();
setHudsVisible(true); stopDialogAudio();
if (!monologueMode) {
setHudsVisible(true);
WorldScene ws = getStateManager().getState(WorldScene.class);
if (ws != null) ws.clearDof();
}
monologueMode = false;
WorldScene ws = getStateManager().getState(WorldScene.class);
if (ws != null) ws.clearDof();
unregisterInput(); unregisterInput();
if (canvasNode != null) { if (canvasNode != null) {
canvasNode.setCullHint(Spatial.CullHint.Always); canvasNode.setCullHint(Spatial.CullHint.Always);
@@ -754,6 +833,15 @@ public class DialogHudState extends BaseAppState {
im.setCursorVisible(true); im.setCursorVisible(true);
} }
/** Monolog-Variante: nur Enter zum Weiterschalten, kein Cursor, keine LMB-Übernahme. */
private void registerMonologueInput() {
var im = app.getInputManager();
im.addMapping(ACT_CONFIRM, new KeyTrigger(KeyInput.KEY_RETURN),
new KeyTrigger(KeyInput.KEY_NUMPADENTER));
im.addListener(inputListener, ACT_CONFIRM);
// Cursor und Maus-Listener bleiben unberührt Spieler behält volle Bewegung
}
private void unregisterInput() { private void unregisterInput() {
var im = app.getInputManager(); var im = app.getInputManager();
try { im.removeListener(inputListener); } catch (Exception ignored) {} try { im.removeListener(inputListener); } catch (Exception ignored) {}

View File

@@ -309,7 +309,8 @@ public class RiverState extends BaseAppState {
} }
buf.flip(); buf.flip();
Texture2D tex = new Texture2D(new Image(Image.Format.RGBA8, size, size, buf)); Texture2D tex = new Texture2D(new Image(Image.Format.RGBA8, size, size, buf, null,
com.jme3.texture.image.ColorSpace.Linear));
tex.setWrap(Texture.WrapMode.Repeat); tex.setWrap(Texture.WrapMode.Repeat);
tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); tex.setMinFilter(Texture.MinFilter.BilinearNoMipMaps);
tex.setMagFilter(Texture.MagFilter.Bilinear); tex.setMagFilter(Texture.MagFilter.Bilinear);

View File

@@ -0,0 +1,81 @@
package de.blight.lang;
import de.blight.common.model.AudioReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
/** Löst AudioReference-Keys zur Laufzeit in Dateipfade auf.
* Liest {@code lang/audio_<lang>.properties} vom Classpath.
* Gibt null zurück wenn kein Eintrag gefunden wird. */
public class AudioResolver {
private static final Logger LOG = LoggerFactory.getLogger(AudioResolver.class);
private static final String BASE_NAME = "lang/audio";
private static AudioResolver instance;
private final ResourceBundle bundle;
private final ResourceBundle fallback;
private AudioResolver(Locale locale) {
ResourceBundle fb;
try {
fb = ResourceBundle.getBundle(BASE_NAME, Locale.ENGLISH);
} catch (MissingResourceException e) {
fb = null;
}
this.fallback = fb;
if (locale.equals(Locale.ENGLISH)) {
this.bundle = fb;
} else {
ResourceBundle loaded;
try {
loaded = ResourceBundle.getBundle(BASE_NAME, locale);
} catch (MissingResourceException e) {
loaded = fb;
}
this.bundle = loaded;
}
}
public static void init(Locale locale) {
instance = new AudioResolver(locale);
}
public static AudioResolver get() {
if (instance == null) {
instance = new AudioResolver(Locale.ENGLISH);
}
return instance;
}
/** Gibt den Dateipfad für den Key zurück, oder null wenn nicht gesetzt. */
public String resolve(AudioReference ref) {
if (ref == null || ref.key() == null || ref.key().isBlank()) {
return null;
}
return resolveKey(ref.key());
}
public String resolveKey(String key) {
if (key == null || key.isBlank()) return null;
if (bundle != null) {
try {
String val = bundle.getString(key);
if (!val.isBlank()) return val;
} catch (MissingResourceException ignored) {}
}
if (fallback != null && fallback != bundle) {
try {
String val = fallback.getString(key);
if (!val.isBlank()) return val;
} catch (MissingResourceException ignored) {}
}
return null;
}
}

View File

@@ -1,7 +1,6 @@
# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId # modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId
Models/imported/wooden+cabin+3d+model.j3o 105.61634 4.85395 58.69108 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/imported/bank1.j3o 106.96240 2.89958 70.01086 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 BENCH 5be97ffb-e413-42e5-9815-9d14d1e3b93f Models/imported/bank1.j3o 106.96240 2.89958 70.01086 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 BENCH 5be97ffb-e413-42e5-9815-9d14d1e3b93f
Models/trees/pine/medium/pine_medium_20260706_190947.j3o -6.42357 1.22971 -1318.06396 5.19824 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 Models/trees/pine/medium/pine_medium_20260706_190947.j3o -6.42357 1.22971 -1318.06396 5.19824 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/trees/pine/medium/pine_medium_20260706_190953.j3o -17.84356 1.24567 -1316.45410 0.26793 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 Models/trees/pine/medium/pine_medium_20260706_190953.j3o -17.84356 1.24567 -1316.45410 0.26793 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/plants/misc/heliconia+plant+3d+model.j3o -7.48228 1.22979 -1320.31714 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/plants/misc/kaktusfeige.j3o -6.18754 1.22995 -1320.34875 0.00000 2.50000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 Models/plants/misc/kaktusfeige.j3o -6.18754 1.22995 -1320.34875 0.00000 2.50000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000
Models/imported/alter_steg.j3o -8.42317 -0.11015 -1359.15662 3.14159 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000

View File

@@ -0,0 +1 @@
# polygon minOffset maxHeight noiseScale octaves persistence edgeBlend seed

Binary file not shown.

Binary file not shown.