diff --git a/blight-common/src/main/java/de/blight/common/MonologueIO.java b/blight-common/src/main/java/de/blight/common/MonologueIO.java new file mode 100644 index 0000000..3907c24 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/MonologueIO.java @@ -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 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 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>(){}.getType(); + List 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<>(); + } + } +} diff --git a/blight-common/src/main/java/de/blight/common/MonologueRegistry.java b/blight-common/src/main/java/de/blight/common/MonologueRegistry.java new file mode 100644 index 0000000..01decb1 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/MonologueRegistry.java @@ -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 MAP = new HashMap<>(); + + private MonologueRegistry() {} + + public static void init(List 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(); + } +} diff --git a/blight-common/src/main/java/de/blight/common/PlacedVoxelCliffZone.java b/blight-common/src/main/java/de/blight/common/PlacedVoxelCliffZone.java new file mode 100644 index 0000000..141172e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/PlacedVoxelCliffZone.java @@ -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 +) {} diff --git a/blight-common/src/main/java/de/blight/common/VoxelCliffZoneIO.java b/blight-common/src/main/java/de/blight/common/VoxelCliffZoneIO.java new file mode 100644 index 0000000..0efb2a1 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/VoxelCliffZoneIO.java @@ -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 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 load() throws IOException { + Path p = getPath(); + if (!Files.exists(p)) return List.of(); + List 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}; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/AudioBundle.java b/blight-common/src/main/java/de/blight/common/model/AudioBundle.java new file mode 100644 index 0000000..9290c0a --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/AudioBundle.java @@ -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 entries = new LinkedHashMap<>(); + + public AudioBundle(String language) { + this.language = language; + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/AudioBundleIO.java b/blight-common/src/main/java/de/blight/common/model/AudioBundleIO.java new file mode 100644 index 0000000..f46e353 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/AudioBundleIO.java @@ -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_.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 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 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"); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/AudioReference.java b/blight-common/src/main/java/de/blight/common/model/AudioReference.java index 98a5904..f5cabfa 100644 --- a/blight-common/src/main/java/de/blight/common/model/AudioReference.java +++ b/blight-common/src/main/java/de/blight/common/model/AudioReference.java @@ -1,5 +1,3 @@ package de.blight.common.model; -public interface AudioReference { - -} +public record AudioReference(String key) {} diff --git a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java index 68b48cf..f6977a6 100644 --- a/blight-common/src/main/java/de/blight/common/model/MainCharacter.java +++ b/blight-common/src/main/java/de/blight/common/model/MainCharacter.java @@ -1,7 +1,11 @@ package de.blight.common.model; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Queue; +import java.util.Set; import de.blight.common.model.quests.Quest; import lombok.AccessLevel; @@ -36,10 +40,18 @@ public class MainCharacter extends GameCharacter { private List abortedQuests; private de.blight.common.model.abilities.Abilities abilities; - + + /** Gespielte Monolog-IDs – wird serialisiert, damit jeder Monolog nur einmal abgespielt wird. */ + private Set playedMonologueIds = new HashSet<>(); + @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private List listeners = new ArrayList(); + + /** Warteschlange für ausstehende Monologe – wird NICHT serialisiert. */ + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient Queue pendingMonologues = new ArrayDeque<>(); public void handleDialogOption(DialogOption option) { if (option.getRequiredItem() != null) { @@ -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) { listeners.remove(listener); } diff --git a/blight-common/src/main/java/de/blight/common/model/Monologue.java b/blight-common/src/main/java/de/blight/common/model/Monologue.java new file mode 100644 index 0000000..0a104b2 --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/Monologue.java @@ -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 heroSteps = new ArrayList<>(); + private QuestRef recievesQuest; + private QuestRef fulfillsQuest; + private List abortsQuests = new ArrayList<>(); + private int requiresChapter; + /** Wenn true, wird der Monolog nach dem ersten Abspielen nicht erneut gefeuert. */ + private boolean playOnce = true; +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/MonologueTrigger.java b/blight-common/src/main/java/de/blight/common/model/trigger/MonologueTrigger.java new file mode 100644 index 0000000..d479f7e --- /dev/null +++ b/blight-common/src/main/java/de/blight/common/model/trigger/MonologueTrigger.java @@ -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); + } +} diff --git a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java index 7a244be..f3f09f2 100644 --- a/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java +++ b/blight-common/src/main/java/de/blight/common/model/trigger/TriggerIO.java @@ -28,6 +28,7 @@ public final class TriggerIO { public static final String TYPE_NPC_STATUS = "NPC_STATUS"; public static final String TYPE_FRACTION_STATUS = "FRACTION_STATUS"; public static final String TYPE_CHANGE_ROUTINE = "CHANGE_ROUTINE"; + public static final String TYPE_MONOLOGUE = "MONOLOGUE"; private static final Gson GSON = new GsonBuilder() .registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter()) @@ -87,6 +88,8 @@ public final class TriggerIO { } else if (src instanceof ChangeRoutineTrigger r) { if (r.getNpcId() != null) obj.addProperty("npcId", r.getNpcId()); 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; } @@ -128,6 +131,11 @@ public final class TriggerIO { if (obj.has("routineName")) r.setRoutineName(obj.get("routineName").getAsString()); yield r; } + case TYPE_MONOLOGUE -> { + MonologueTrigger mo = new MonologueTrigger(); + if (obj.has("monologueId")) mo.setMonologueId(obj.get("monologueId").getAsString()); + yield mo; + } default -> null; }; @@ -141,6 +149,7 @@ public final class TriggerIO { if (t instanceof NpcStatusTrigger) return TYPE_NPC_STATUS; if (t instanceof FractionStatusTrigger) return TYPE_FRACTION_STATUS; if (t instanceof ChangeRoutineTrigger) return TYPE_CHANGE_ROUTINE; + if (t instanceof MonologueTrigger) return TYPE_MONOLOGUE; return "UNKNOWN"; } diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index de37786..7d05f7f 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -171,8 +171,9 @@ public class EditorApp extends Application { editableSubClips = new java.util.LinkedHashMap<>(); // Character-Editor-Zustand - private de.blight.editor.ui.DialogEditorView dialogEditorView; - private de.blight.editor.ui.RoutineEditorView routineEditorView; + private de.blight.editor.ui.DialogEditorView dialogEditorView; + private de.blight.editor.ui.RoutineEditorView routineEditorView; + private de.blight.editor.ui.MonologueEditorView monologueEditorView; private StackPane routineJmeSlot; private javafx.scene.layout.VBox charEditContainer; // deaktiviert solange kein Character geladen private javafx.scene.layout.VBox charFractionSection; // sichtbar nur für NPCs @@ -212,8 +213,16 @@ public class EditorApp extends Application { private VBox waterDynamicContent; private Label waterCurrentHeightLabel; + // Ansichts-Umschalter + private boolean wireframeActive = false; + private CheckMenuItem viewTopologyItem = null; + // Sound-Bereich-Werkzeug-Zustand private VBox soundAreaDynamicContent; + private VBox voxelCliffDynamicContent; + private Label voxelCliffStatusLabel; + private Button voxelCliffGenerateBtn; + private Button voxelCliffDeleteBtn; // Bereich-Werkzeug-Zustand private VBox areaDynamicContent; @@ -310,6 +319,7 @@ public class EditorApp extends Application { private ToggleButton locationZoneBtn; private ToggleButton playToolBtn; private ToggleButton voxelBtn; + private ToggleButton voxelCliffBtn; private ToggleButton camOrbitBtn; private ToggleButton camFreeBtn; @@ -643,6 +653,23 @@ public class EditorApp extends Application { 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) { input.spawnPickChanged = false; updateSpawnFields(input.pickedSpawnInfo); @@ -1164,14 +1191,21 @@ public class EditorApp extends Application { Menu viewMenu = new Menu("Ansicht"); MenuItem resetCam = new MenuItem("Kamera zurücksetzen"); resetCam.setOnAction(e -> input.addMouseDelta(0, 0)); - MenuItem viewTexture = new MenuItem("Textur"); - MenuItem viewWireframe = new MenuItem("Drahtgitter"); - CheckMenuItem viewTopology = new CheckMenuItem("Topologie-Overlay"); - viewTexture.setOnAction(e -> input.wireframeRequest = 2); - viewWireframe.setOnAction(e -> input.wireframeRequest = 1); - viewTopology.setOnAction(e -> input.topologyRequest = viewTopology.isSelected() ? 1 : 2); + MenuItem viewTexture = new MenuItem("Textur (Ctrl+G)"); + MenuItem viewWireframe = new MenuItem("Drahtgitter (Ctrl+G)"); + viewTopologyItem = new CheckMenuItem("Topologie-Overlay (Ctrl+T)"); + viewTexture.setOnAction(e -> { + wireframeActive = false; + 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, - new SeparatorMenuItem(), viewTopology); + new SeparatorMenuItem(), viewTopologyItem); Menu zeitMenu = new Menu("Zeit"); ToggleGroup zeitGroup = new ToggleGroup(); @@ -1202,6 +1236,7 @@ public class EditorApp extends Application { locationZoneBtn = new ToggleButton("📍 Locations"); playToolBtn = new ToggleButton("🎮 Spielen"); voxelBtn = new ToggleButton("⬡ Voxel"); + voxelCliffBtn = new ToggleButton("⛰ Klippe"); stoneBtn = new ToggleButton("🪨 Steine"); camOrbitBtn = new ToggleButton("⊙ Orbit"); camFreeBtn = new ToggleButton("✈ FreeFly"); @@ -1220,6 +1255,7 @@ public class EditorApp extends Application { locationZoneBtn.setStyle("-fx-font-weight:bold;"); playToolBtn.setStyle("-fx-font-weight:bold;"); voxelBtn.setStyle("-fx-font-weight:bold;"); + voxelCliffBtn.setStyle("-fx-font-weight:bold;"); stoneBtn.setStyle("-fx-font-weight:bold;"); camOrbitBtn.setStyle("-fx-font-weight:bold;"); camFreeBtn.setStyle("-fx-font-weight:bold;"); @@ -1240,6 +1276,7 @@ public class EditorApp extends Application { locationZoneBtn.setToggleGroup(layerGroup); playToolBtn.setToggleGroup(layerGroup); voxelBtn.setToggleGroup(layerGroup); + voxelCliffBtn.setToggleGroup(layerGroup); stoneBtn.setToggleGroup(layerGroup); baseBtn.setSelected(true); @@ -1315,6 +1352,10 @@ public class EditorApp extends Application { root.setRight(toolPanel); showToolParameters(toolPanel, input.activeTool); }); + voxelCliffBtn.setOnAction(e -> { + input.activeLayer = SharedInput.LAYER_VOXEL_CLIFF; + root.setRight(buildVoxelCliffPanel()); + }); stoneBtn.setOnAction(e -> { input.activeLayer = SharedInput.LAYER_STONE; input.activeTool = input.stoneTool; @@ -1345,7 +1386,7 @@ public class EditorApp extends Application { new Separator(Orientation.VERTICAL), riverBtn, new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn, new Separator(Orientation.VERTICAL), playToolBtn, - new Separator(Orientation.VERTICAL), voxelBtn, + new Separator(Orientation.VERTICAL), voxelBtn, voxelCliffBtn, new Separator(Orientation.VERTICAL), stoneBtn, new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn, new Separator(Orientation.VERTICAL), hint); @@ -3286,7 +3327,38 @@ public class EditorApp extends Application { poller.setCycleCount(javafx.animation.Animation.INDEFINITE); 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)); case SharedInput.LAYER_LOCATION_ZONES -> 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 -> { if (action > 0) // only left-click sets spawn 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 SHIFT -> input.shiftHeld = 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 -> { if (pressed && (input.activeLayer == SharedInput.LAYER_SOUND_AREAS || input.activeLayer == SharedInput.LAYER_AREAS || 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; } case DELETE -> { @@ -7820,6 +7902,8 @@ public class EditorApp extends Application { input.deleteAreaRequested = true; else if (input.activeLayer == SharedInput.LAYER_LOCATION_ZONES) input.deleteLocationZoneRequested = true; + else if (input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF) + input.deleteVoxelCliffZoneRequested = true; else if (input.activeLayer == SharedInput.LAYER_WATER) input.deleteWaterRequested = true; } @@ -7832,6 +7916,19 @@ public class EditorApp extends Application { if (pressed && input.activeLayer == SharedInput.LAYER_WATER) 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 F2 -> { if (pressed && "world".equals(currentTool)) Platform.runLater(() -> grassBtn.fire()); } case F5 -> { @@ -7860,6 +7957,117 @@ public class EditorApp extends Application { // ── 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() { VBox inner = new VBox(8); inner.setPadding(new Insets(10)); @@ -9871,6 +10079,7 @@ public class EditorApp extends Application { dialogEditorView.setDisable(true); routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("character")); routineEditorView.setDisable(true); + monologueEditorView = new de.blight.editor.ui.MonologueEditorView(); // worldViewport in den Tagesabläufe-Tab verschieben; centerStack bekommt Platzhalter routineJmeSlot = new StackPane(); @@ -9889,7 +10098,8 @@ public class EditorApp extends Application { centerTabs.setTabClosingPolicy(javafx.scene.control.TabPane.TabClosingPolicy.UNAVAILABLE); centerTabs.getTabs().addAll( 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.setRight(buildCharacterEditorPanel()); diff --git a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java index d5b669d..ce9715a 100644 --- a/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/JmeEditorApp.java @@ -35,6 +35,7 @@ import de.blight.editor.state.SceneObjectState; import de.blight.editor.state.TerrainEditorState; import de.blight.editor.state.TreeGeneratorState; import de.blight.editor.state.VoxelEditorState; +import de.blight.editor.state.VoxelCliffEditorState; import de.blight.editor.state.SculptedMeshEditorState; import de.blight.editor.state.ModelImportState; import de.blight.editor.state.PathNetworkEditorState; @@ -202,6 +203,7 @@ public class JmeEditorApp extends SimpleApplication { stateManager.attach(new ModelEditorState(input)); stateManager.attach(new ItemPlacementState(input)); stateManager.attach(new VoxelEditorState(input)); + stateManager.attach(new VoxelCliffEditorState(input)); stateManager.attach(new SculptedMeshEditorState(input)); stateManager.attach(new ModelImportState(input)); diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index e072b58..fa9ddb1 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -768,11 +768,22 @@ public class SharedInput { public volatile int bakeDone = 0; /** JME → JFX: Gesamtzahl der zu backenden Chunks (0 = nicht gestartet). */ 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; /** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */ 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 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. */ public volatile int voxelFlatSlot = -1; /** Terrain-Slot (0-7) für steile Wände. */ @@ -783,7 +794,7 @@ public class SharedInput { public volatile boolean voxelTexturesChanged = false; /** 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 ────────────────────────────────────────────────────── /** activeLayer==21 → Item-Pickup auf die Karte platzieren */ @@ -999,4 +1010,34 @@ public class SharedInput { public volatile boolean sculptDeleteSelected = false; /** VoxelEditorState setzt true nach Bake-Abschluss → SculptedMeshEditorState scannt neue .j3o-Dateien. */ 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 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; } diff --git a/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java b/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java index 4af616a..b512505 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/ModelImportState.java @@ -284,6 +284,7 @@ public class ModelImportState extends BaseAppState { startImpostorPass(0); } + @SuppressWarnings("deprecation") private void startImpostorPass(int pass) { Texture2D capTex = new Texture2D(ImpostorUtil.SIZE, ImpostorUtil.SIZE, Image.Format.RGBA8); impostorFB = new FrameBuffer(ImpostorUtil.SIZE, ImpostorUtil.SIZE, 1); diff --git a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java index 056450e..8cd5d13 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java @@ -207,6 +207,22 @@ public class SculptedMeshEditorState extends BaseAppState { // ── Scan / Laden ────────────────────────────────────────────────────────── private void rescanBakedChunks() { + // Geladene Meshes entfernen, deren Baked-Dateien nicht mehr existieren + Iterator> it = meshes.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry 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()) { long key = chunkKey(cxyz[0], cxyz[1], cxyz[2]); if (meshes.containsKey(key)) continue; diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 596833f..fbcef70 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -107,9 +107,10 @@ public class TerrainEditorState extends BaseAppState { private LightState lightState; private EmitterState emitterState; private WaterBodyState waterBodyState; - private SoundAreaState soundAreaState; - private AreaState areaState; - private LocationZoneState locationZoneState; + private SoundAreaState soundAreaState; + private AreaState areaState; + private LocationZoneState locationZoneState; + private VoxelCliffEditorState voxelCliffEditorState; private RiverEditorState riverEditorState; private MapData loadedMapData; 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..."; areaState = app.getStateManager().getState(AreaState.class); if (areaState != null) { @@ -993,13 +1006,15 @@ public class TerrainEditorState extends BaseAppState { log.debug("[Debug] DebugNoLight = {}", debugNoLight); } - // Wireframe-Modus setzen + // Wireframe-Modus setzen (Ctrl+G aus EditorApp) int wfReq = input.wireframeRequest; if (wfReq != 0) { input.wireframeRequest = 0; wireframeMode = (wfReq == 1); if (terrain != null) terrain.getMaterial().getAdditionalRenderState().setWireframe(wireframeMode); + // VoxelEditorState überwacht voxelWireframeEnabled und synchronisiert seine Szene-Wireframe + input.voxelWireframeEnabled = wireframeMode; } // 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 (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 (voxelCliffEditorState != null) voxelCliffEditorState.loadZones(de.blight.common.VoxelCliffZoneIO.load()); } catch (Exception ignored) {} } if (input.saveRequested) { @@ -1194,9 +1210,11 @@ public class TerrainEditorState extends BaseAppState { final List emitters = emitterState != null ? emitterState.getPlacedEmitters() : null; final List waters = waterBodyState != null ? waterBodyState.getPlacedBodies() : null; final List> rivers = riverEditorState != null ? riverEditorState.getPlacedRivers() : null; - final List soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null; - final List areas = areaState != null ? areaState.getPlacedAreas() : null; - final List locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null; + final List soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null; + final List areas = areaState != null ? areaState.getPlacedAreas() : null; + final List locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null; + final List cliffZones = + voxelCliffEditorState != null ? voxelCliffEditorState.getPlacedZones() : null; // ── Platzierte Objekte synchron speichern (kleine Textdateien) ────────── // 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 (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 (cliffZones != null) de.blight.common.VoxelCliffZoneIO.save(cliffZones); } catch (IOException e) { log.error("Klippen-Zonen speichern", e); } if (stoneEditorState != null) stoneEditorState.saveIfModified(); // ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─ diff --git a/blight-editor/src/main/java/de/blight/editor/state/VoxelCliffEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/VoxelCliffEditorState.java new file mode 100644 index 0000000..eb39697 --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/state/VoxelCliffEditorState.java @@ -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 zones = new ArrayList<>(); + private final List zoneGeos = new ArrayList<>(); + private int selectedIdx = -1; + + // laufendes Polygon + private boolean placing = false; + private final List currX = new ArrayList<>(); + private final List currZ = new ArrayList<>(); + private Geometry inProgGeo = null; + private Geometry lastPointMarker = null; + + private List 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 xs = toList(zone.pointsX()); + List 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 xs, List 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 getPlacedZones() { + return new ArrayList<>(zones); + } + + public void loadZones(List 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 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 toList(float[] arr) { + List l = new ArrayList<>(arr.length); + for (float f : arr) l.add(f); + return l; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java index cf6ab4c..6c34bd3 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java @@ -121,6 +121,13 @@ public class VoxelEditorState extends BaseAppState { 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 markedOverlays = new HashMap<>(); + // ── Basis-Terrain-Referenzebene (y = -10) ──────────────────────────────── /** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */ @@ -185,6 +192,11 @@ public class VoxelEditorState extends BaseAppState { brushIndicator = buildBrushIndicator(); 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 basePlaneNode = new Node("voxelBasePlaneNode"); basePlane = buildBasePlane(); @@ -207,9 +219,10 @@ public class VoxelEditorState extends BaseAppState { protected void cleanup(Application app) { executor.shutdownNow(); voxelRoot.removeFromParent(); - if (brushIndicator != null) brushIndicator.removeFromParent(); - if (basePlaneNode != null) basePlaneNode.removeFromParent(); - if (wireframeActive) applyWireframe(false); + if (brushIndicator != null) brushIndicator.removeFromParent(); + if (basePlaneNode != null) basePlaneNode.removeFromParent(); + if (markedChunkOverlayRoot != null) markedChunkOverlayRoot.removeFromParent(); + if (wireframeActive) applyWireframe(false); nodes.clear(); chunks.clear(); } @@ -253,20 +266,44 @@ public class VoxelEditorState extends BaseAppState { }); } + // Markierte Baked-Chunks löschen + if (input.deleteMarkedBakedRequested) { + input.deleteMarkedBakedRequested = false; + Set 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? if (input.voxelTexturesChanged) { input.voxelTexturesChanged = false; 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; if (isVoxelLayer != prevLayerWasVoxel) { prevLayerWasVoxel = isVoxelLayer; onVoxelLayerChanged(isVoxelLayer); } - // Wireframe-Toggle während LAYER_VOXEL (Button-Klick im Panel) - if (isVoxelLayer && wireframeActive != input.voxelWireframeEnabled) { + // Globaler Wireframe-Toggle (Ctrl+G): voxelWireframeEnabled wird von TerrainEditorState + // bei wireframeRequest gesetzt und gilt ebenenunabhängig für die gesamte Szene. + if (wireframeActive != input.voxelWireframeEnabled) { applyWireframe(input.voxelWireframeEnabled); } @@ -356,6 +393,131 @@ public class VoxelEditorState extends BaseAppState { 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. * 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(); 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 isColumn = !isCave; boolean lower = action < 0; @@ -689,20 +857,16 @@ public class VoxelEditorState extends BaseAppState { if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; } } if (currentTop < 0) { - if (cy == -1) { - // Basis-Ebene: Ankerpunkt bei ly=118 (Welt-Y = -10) - currentTop = 118; + // Terrain-Oberfläche als Ankerpunkt + float th = terrainH(wx, wz); + int tCy = VoxelChunk.worldYToCy(th); + if (cy == tCy) { + currentTop = Math.max(0, Math.min(VoxelChunk.SIZE - 1, + (int)(th - cy * (float) VoxelChunk.CELLS))); } else { - // Nur weiterwachsen wenn der darunterliegende Chunk bis an die - // Grenze reicht (ly ≥ SIZE-3), sonst würde ein losgelöster Klumpen entstehen - VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); - 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; + // Chunks ober- oder unterhalb des Terrain-Chunks ohne bestehende Voxel: überspringen. + // Kein Foundation-Fill – das Terrain-Mesh übernimmt die visuelle Abdeckung. + continue; } } 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 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) ───────────────────────────────── private float terrainH(float worldX, float worldZ) { @@ -782,11 +1059,11 @@ public class VoxelEditorState extends BaseAppState { /** * Berechnet Slope-Parameter im Pinselradius. - * Rückgabe: [highH, lowH, dirX, dirZ, projHigh, projLow] - * dirX/Z = Einheitsvektor vom Tiefpunkt zum Hochpunkt + * Rückgabe: [highH, lowH, dirX, dirZ, projHigh, projLow, avgH] + * dirX/Z = Einheitsvektor vom Tiefpunkt zum Hochpunkt * projHigh = Projektion des Hochpunkts auf die Achse (relativ zur Brush-Mitte) * 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) { float r2 = radius * radius; @@ -795,6 +1072,8 @@ public class VoxelEditorState extends BaseAppState { float maxH = Float.NEGATIVE_INFINITY, minH = Float.POSITIVE_INFINITY; float maxX = brushWX, maxZ = brushWZ, minX = brushWX, minZ = brushWZ; + float sum = 0f; + int count = 0; for (int xi = xMin; xi <= xMax; xi++) { float dx = xi - brushWX; @@ -804,10 +1083,12 @@ public class VoxelEditorState extends BaseAppState { float h = columnTopWorldY(xi, zi); if (h > maxH) { maxH = h; maxX = xi; maxZ = 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| 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 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. */ @@ -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--) { 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 ? VoxelChunk.toWorldY(cy, currentTopLY) - : -10f; // Keine Voxel → Basis-Niveau als Referenz + : th; float diff = targetH - currentTopWY; if (Math.abs(diff) < 0.5f) continue; @@ -888,17 +1172,16 @@ public class VoxelEditorState extends BaseAppState { int startLY; if (currentTopLY >= 0) { startLY = currentTopLY; - } else if (cy == -1) { - startLY = 118; } else { - VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz)); - 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; } + // Terrain-Oberfläche als Ankerpunkt + int tCy = VoxelChunk.worldYToCy(th); + if (cy == tCy) { + startLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1, + (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); 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. - * Ziel interpoliert zwischen tatsächlicher Projektion von Tief- und Hochpunkt — - * der höchste Punkt behält genau highH, der tiefste genau lowH. + * Smooth-Pinsel (Linksklick): bewegt alle Spalten im Pinselbereich auf die + * Durchschnittshöhe aller Spalten zu (sp[6]). + * + * 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, float brushWX, float brushWZ, float radius, float strength, float[] sp) { - if (sp == null || Float.isNaN(sp[0])) return; - final float highH = sp[0], lowH = sp[1], dirX = sp[2], dirZ = sp[3]; - final float projHigh = sp[4], projLow = sp[5]; - 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; - }); + if (sp == null || Float.isNaN(sp[6])) return; + final float targetH = sp[6]; + applyColumnToTarget(chunk, cx, cy, cz, brushWX, brushWZ, radius, strength, coord -> targetH); } /** @@ -1073,6 +1350,12 @@ public class VoxelEditorState extends BaseAppState { } 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++) { Map nextBufs = new HashMap<>(); for (VoxelChunk c : nonEmpty) { @@ -1082,17 +1365,23 @@ public class VoxelEditorState extends BaseAppState { for (int by = 0; by < blurN; by++) { for (int bz = 0; bz < blurN; bz++) { 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 dz = -1; dz <= 1; dz++) for (int dx = -1; dx <= 1; dx++) { 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) - sum += cur[c.idx(sx, sy, sz)]; + nb = cur[c.idx(sx, sy, sz)]; 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); 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, 16, nb), 2, 0.4f), }; @@ -1762,7 +2051,9 @@ public class VoxelEditorState extends BaseAppState { basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); if (chunkGrid != null) 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). */ diff --git a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java index 90bdc69..37df745 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java @@ -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. * Modus 4 (Aushöhlen): Kugel-Entfernen. * 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=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 static final int MODE_SINUS = 0; - public static final int MODE_SPIKE = 1; - public static final int MODE_PLATEAU = 2; - public static final int MODE_SMOOTH = 3; - public static final int MODE_REMOVE = 4; - public static final int MODE_RESET = 5; + public static final int MODE_SINUS = 0; + public static final int MODE_SPIKE = 1; + public static final int MODE_PLATEAU = 2; + public static final int MODE_SMOOTH = 3; + public static final int MODE_REMOVE = 4; + public static final int MODE_RESET = 5; + public static final int MODE_DELETE_BAKED = 6; public final ChoiceToolParameter mode = new ChoiceToolParameter( "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, new String[]{ "img/editor/terraintool_sinus.png", @@ -36,6 +39,7 @@ public class VoxelTool extends EditorTool { "img/editor/terraintool_plateau.png", "img/editor/terraintool_smooth.png", null, + null, null } ); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java index 9ed5a37..bed2a20 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/DialogEditorView.java @@ -584,8 +584,9 @@ public class DialogEditorView extends BorderPane { List heroList = new ArrayList<>(); for (int i = 0; i < heroStepsView.getItems().size(); i++) { + String heroKey = stepKey(base, ".textmainchar", i); 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.setTextHero(heroList.isEmpty() ? null @@ -593,8 +594,9 @@ public class DialogEditorView extends BorderPane { List npcList = new ArrayList<>(); for (int i = 0; i < npcStepsView.getItems().size(); i++) { + String npcKey = stepKey(base, ".textnpc", i); 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.setTextNpc(npcList.isEmpty() ? null @@ -708,9 +710,9 @@ public class DialogEditorView extends BorderPane { opt.setTextHero(new TextReference(base + ".textmainchar")); opt.setTextNpc(new TextReference(base + ".textnpc")); 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( - new TextReference(base + ".textnpc"), null)))); + new TextReference(base + ".textnpc"), new AudioReference(base + ".textnpc"))))); allOptions.put(id, opt); if (asRoot) { rootIds.add(id); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java index 2b813c8..21ce29e 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/LocalizationEditorView.java @@ -1,5 +1,7 @@ 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.TextBundleIO; import de.blight.common.model.TextKeyStore; @@ -13,7 +15,9 @@ import javafx.geometry.Pos; import javafx.scene.control.*; import javafx.scene.control.cell.TextFieldTableCell; import javafx.scene.layout.*; +import javafx.stage.FileChooser; +import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.util.*; @@ -22,12 +26,19 @@ public class LocalizationEditorView extends BorderPane { private final Path locDir; - private final ComboBox langCombo = new ComboBox<>(); + // Sprache + private final ComboBox langCombo = new ComboBox<>(); + + // Text-Tab private final ObservableList tableData = FXCollections.observableArrayList(); private final TableView table = new TableView<>(tableData); - private TextBundle currentBundle = null; + // Audio-Tab + private final ObservableList audioData = FXCollections.observableArrayList(); + private final TableView audioTable = new TableView<>(audioData); + private AudioBundle currentAudioBundle = null; + public LocalizationEditorView(Path locDir) { this.locDir = locDir; setStyle("-fx-background-color: #1e1e2e;"); @@ -44,53 +55,64 @@ public class LocalizationEditorView extends BorderPane { Button addLangBtn = new Button("+ Sprache"); addLangBtn.setOnAction(e -> addLanguage()); - Button delLangBtn = new Button("− Sprache"); + Button delLangBtn = new Button("- Sprache"); 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"); saveBtn.setStyle("-fx-font-weight: bold; -fx-background-color: #3a5a8a; -fx-text-fill: white;"); saveBtn.setOnAction(e -> saveCurrent()); HBox toolbar = new HBox(8, langLbl, langCombo, addLangBtn, delLangBtn, - new Separator(javafx.geometry.Orientation.VERTICAL), - addKeyBtn, delKeyBtn, sortBtn, new Separator(javafx.geometry.Orientation.VERTICAL), saveBtn); toolbar.setPadding(new Insets(8, 12, 8, 12)); toolbar.setAlignment(Pos.CENTER_LEFT); 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.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;"); @SuppressWarnings("deprecation") var policy = TableView.CONSTRAINED_RESIZE_POLICY; table.setColumnResizePolicy(policy); - TableColumn keyCol = new TableColumn<>("Schlüssel"); + TableColumn 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(); - table.refresh(); - }); + keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); table.refresh(); }); keyCol.setPrefWidth(280); TableColumn valCol = new TableColumn<>("Text"); valCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1])); valCol.setCellFactory(TextFieldTableCell.forTableColumn()); - valCol.setOnEditCommit(e -> { - e.getRowValue()[1] = e.getNewValue(); - table.refresh(); - }); + valCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); table.refresh(); }); table.getColumns().addAll(List.of(keyCol, valCol)); VBox.setVgrow(table, Priority.ALWAYS); @@ -98,14 +120,87 @@ public class LocalizationEditorView extends BorderPane { 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;"); - VBox center = new VBox(table, hint); + VBox box = new VBox(subBar, table, hint); VBox.setVgrow(table, Priority.ALWAYS); - - setTop(toolbar); - setCenter(center); + return box; } - // ── 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 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 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 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() { String selected = langCombo.getValue(); @@ -116,13 +211,12 @@ public class LocalizationEditorView extends BorderPane { langCombo.setValue(langCombo.getItems().get(0)); } else { tableData.clear(); + audioData.clear(); } } private void loadLanguage(String lang) { - if (lang == null) { - return; - } + if (lang == null) return; try { currentBundle = TextBundleIO.load(locDir.resolve("messages_" + lang + ".properties")); } catch (IOException e) { @@ -132,17 +226,19 @@ public class LocalizationEditorView extends BorderPane { currentBundle.getEntries().forEach((k, v) -> tableData.add(new String[]{k, v})); TextRegistry.clear(); 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() { TextInputDialog dlg = new TextInputDialog(); - dlg.setTitle("Sprache hinzufügen"); + dlg.setTitle("Sprache hinzufuegen"); dlg.setHeaderText("Sprach-Code (z. B. de, en, fr):"); dlg.showAndWait().ifPresent(lang -> { lang = lang.trim().toLowerCase(); - if (lang.isBlank()) { - return; - } + if (lang.isBlank()) return; TextBundle bundle = new TextBundle(lang); try { TextBundleIO.save(bundle, locDir); @@ -156,17 +252,18 @@ public class LocalizationEditorView extends BorderPane { private void deleteLanguage() { String lang = langCombo.getValue(); - if (lang == null) { - return; - } + if (lang == null) return; 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 -> { if (bt == ButtonType.YES) { try { TextBundleIO.delete(lang, locDir); + AudioBundleIO.delete(lang, locDir); currentBundle = null; + currentAudioBundle = null; tableData.clear(); + audioData.clear(); refreshLangList(); } catch (IOException e) { 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() { - if (currentBundle == null) { - return; - } + if (currentBundle == null) return; Set existing = currentBundle.getEntries().keySet(); List available = new ArrayList<>(TextKeyStore.getKeys()); available.removeAll(existing); if (available.isEmpty()) { TextInputDialog dlg = new TextInputDialog(); - dlg.setTitle("Schlüssel hinzufügen"); - dlg.setHeaderText("Alle bekannten Schlüssel bereits vorhanden.\nNeuen Schlüssel eingeben:"); - dlg.showAndWait() - .map(String::trim) - .filter(s -> !s.isBlank()) - .ifPresent(this::insertKey); + dlg.setTitle("Schluessel hinzufuegen"); + dlg.setHeaderText("Alle bekannten Schluessel bereits vorhanden.\nNeuen Schluessel eingeben:"); + dlg.showAndWait().map(String::trim).filter(s -> !s.isBlank()).ifPresent(this::insertKey); return; } Dialog dlg = new Dialog<>(); - dlg.setTitle("Schlüssel wählen"); - dlg.setHeaderText("Noch nicht übersetzte Schlüssel:"); + dlg.setTitle("Schluessel waehlen"); + dlg.setHeaderText("Noch nicht uebersetzte Schluessel:"); dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); ObservableList items = FXCollections.observableArrayList(available); @@ -208,7 +300,7 @@ public class LocalizationEditorView extends BorderPane { listView.getSelectionModel().selectFirst(); TextField filterField = new TextField(); - filterField.setPromptText("Filtern…"); + filterField.setPromptText("Filtern..."); filterField.textProperty().addListener((obs, o, n) -> filtered.setPredicate(s -> n.isBlank() || s.contains(n))); @@ -216,9 +308,7 @@ public class LocalizationEditorView extends BorderPane { dlg.setResultConverter(bt -> bt == ButtonType.OK ? listView.getSelectionModel().getSelectedItem() : null); - dlg.showAndWait() - .filter(k -> k != null && !k.isBlank()) - .ifPresent(this::insertKey); + dlg.showAndWait().filter(k -> k != null && !k.isBlank()).ifPresent(this::insertKey); } private void insertKey(String key) { @@ -229,27 +319,54 @@ public class LocalizationEditorView extends BorderPane { private void deleteKey() { String[] sel = table.getSelectionModel().getSelectedItem(); - if (sel != null) { - tableData.remove(sel); - } + if (sel != null) 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() { 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; } - Map entries = new LinkedHashMap<>(); + Map textEntries = new LinkedHashMap<>(); for (String[] row : tableData) { - if (!row[0].isBlank()) { - entries.put(row[0].trim(), row[1]); - } + if (!row[0].isBlank()) textEntries.put(row[0].trim(), row[1]); } - currentBundle.setEntries(entries); + currentBundle.setEntries(textEntries); + + Map 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 { TextBundleIO.save(currentBundle, locDir); + AudioBundleIO.save(currentAudioBundle, locDir); TextRegistry.clear(); - TextRegistry.registerAll(entries); + TextRegistry.registerAll(textEntries); } catch (IOException e) { new Alert(Alert.AlertType.ERROR, "Fehler: " + e.getMessage(), ButtonType.OK).showAndWait(); } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java b/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java new file mode 100644 index 0000000..a8fbdec --- /dev/null +++ b/blight-editor/src/main/java/de/blight/editor/ui/MonologueEditorView.java @@ -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 monologues = FXCollections.observableArrayList(); + private final ListView listView = new ListView<>(monologues); + + // Formular-Felder + private TextField idField; + private Spinner chapterSpinner; + private CheckBox playOnceCheck; + private ListView stepsView; + private TextField recvQuestField; + private TextField fulfillsQuestField; + private ListView 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 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 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 quests = QuestIO.loadAll(questDir); + + Dialog dlg = new Dialog<>(); + dlg.setTitle("Quest auswählen"); + dlg.initModality(Modality.APPLICATION_MODAL); + + ListView 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; + } +} diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java index 7ea0ecd..c12bd5e 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerDialog.java @@ -1,5 +1,7 @@ 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.Status; import de.blight.common.model.trigger.*; @@ -25,10 +27,11 @@ import java.util.UUID; */ public class TriggerDialog extends Dialog { - private static final String TYPE_QUEST = "Quest starten"; - private static final String TYPE_NPC = "NPC-Status ändern"; - private static final String TYPE_FRACTION = "Fraktions-Status ändern"; - private static final String TYPE_ROUTINE = "Routine ändern"; + private static final String TYPE_QUEST = "Quest starten"; + private static final String TYPE_NPC = "NPC-Status ändern"; + private static final String TYPE_FRACTION = "Fraktions-Status ändern"; + private static final String TYPE_ROUTINE = "Routine ändern"; + private static final String TYPE_MONOLOGUE = "Monolog starten"; // Gemeinsam private final ComboBox typeCombo = new ComboBox<>(); @@ -50,6 +53,9 @@ public class TriggerDialog extends Dialog { private TextField routineNpcIdField; private TextField routineNameField; + // Monolog + private ComboBox monologueIdCombo; + /** Öffnet den Dialog für einen neuen Trigger. */ public TriggerDialog() { this(null); @@ -61,7 +67,7 @@ public class TriggerDialog extends Dialog { initModality(Modality.APPLICATION_MODAL); 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.setOnAction(e -> rebuildDynamic(typeCombo.getValue())); @@ -102,10 +108,11 @@ public class TriggerDialog extends Dialog { dynamicArea.getChildren().clear(); if (type == null) return; switch (type) { - case TYPE_QUEST -> buildQuestFields(); - case TYPE_NPC -> buildNpcFields(); - case TYPE_FRACTION -> buildFractionFields(); - case TYPE_ROUTINE -> buildRoutineFields(); + case TYPE_QUEST -> buildQuestFields(); + case TYPE_NPC -> buildNpcFields(); + case TYPE_FRACTION -> buildFractionFields(); + case TYPE_ROUTINE -> buildRoutineFields(); + case TYPE_MONOLOGUE -> buildMonologueFields(); } } @@ -147,6 +154,23 @@ public class TriggerDialog extends Dialog { ); } + 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 ───────────────────────────────────────────────────────── private Trigger buildTrigger() { @@ -183,6 +207,14 @@ public class TriggerDialog extends Dialog { if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim()); 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; }; if (t != null) t.setRequiresChapter(chapterSpinner.getValue()); @@ -214,6 +246,10 @@ public class TriggerDialog extends Dialog { routineNpcIdField.setText(r.getNpcId()); if (routineNameField != null && r.getRoutineName() != null) routineNameField.setText(r.getRoutineName()); + } else if (t instanceof MonologueTrigger mo) { + typeCombo.setValue(TYPE_MONOLOGUE); + if (monologueIdCombo != null && mo.getMonologueId() != null) + monologueIdCombo.setValue(mo.getMonologueId()); } } diff --git a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java index 064cde6..9609131 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/TriggerListEditor.java @@ -1,6 +1,7 @@ package de.blight.editor.ui; import de.blight.common.model.trigger.*; +import de.blight.common.MonologueRegistry; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; @@ -108,6 +109,8 @@ public class TriggerListEditor extends VBox { if (t instanceof ChangeRoutineTrigger r) return "Routine ändern: " + nullSafe(r.getNpcId()) + " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter; + if (t instanceof MonologueTrigger mo) + return "Monolog: " + nullSafe(mo.getMonologueId()) + chapter; return t.getClass().getSimpleName() + chapter; } diff --git a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java index 625cce1..eaae31a 100644 --- a/blight-game/src/main/java/de/blight/game/scene/WorldScene.java +++ b/blight-game/src/main/java/de/blight/game/scene/WorldScene.java @@ -330,7 +330,10 @@ public class WorldScene extends BaseAppState { new WorldItemsState(keyBindings, physicsChar, mc, playerInput)); app.getStateManager().attach( 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); npcsState.setThirdPersonCamera(thirdPersonCam); app.getStateManager().attach(npcsState); diff --git a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java index 55881e6..c2b6587 100644 --- a/blight-game/src/main/java/de/blight/game/state/DialogHudState.java +++ b/blight-game/src/main/java/de/blight/game/state/DialogHudState.java @@ -34,10 +34,14 @@ import de.blight.common.model.TextReference; import de.blight.common.model.trigger.ChangeRoutineTrigger; import de.blight.common.model.trigger.NpcStatusTrigger; 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.scene.WorldScene; import de.blight.game.config.MenuCanvas; import de.blight.game.config.NinePatch; +import de.blight.lang.AudioResolver; import de.blight.lang.TextResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -168,9 +172,16 @@ public class DialogHudState extends BaseAppState { private BitmapText scrollUpText; private BitmapText scrollDownText; - // ── TTS ─────────────────────────────────────────────────────────────────── + // ── Audio ───────────────────────────────────────────────────────────────── 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 ───────────────────────────────────────────────────────────── @@ -207,6 +218,12 @@ public class DialogHudState extends BaseAppState { advanceText(); } } + if (phase == Phase.HIDDEN && mainCharForMonologue != null) { + Monologue pending = mainCharForMonologue.pollPendingMonologue(); + if (pending != null) { + startMonologue(pending, mainCharForMonologue); + } + } } private void updateHover() { @@ -242,6 +259,7 @@ public class DialogHudState extends BaseAppState { @Override protected void cleanup(Application app) { + stopDialogAudio(); if (tts != null) { tts.shutdown(); tts = null; @@ -284,7 +302,7 @@ public class DialogHudState extends BaseAppState { TextReference greeting = npc.getDefaultMessage(); String greetText = greeting != null ? TextResolver.get().resolve(greeting) : null; 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 { log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung – Dialog übersprungen.", npc.getCharacterId()); closeDialog(); @@ -296,6 +314,36 @@ public class DialogHudState extends BaseAppState { 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 ───────────────────────────────────────────────────────── private Runnable afterText; @@ -311,22 +359,47 @@ public class DialogHudState extends BaseAppState { playSteps(ph, speaker, steps, idx + 1, after); 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.afterText = after; this.textPages = paginate(wrapText(rawText, MAX_CHARS_LINE), MAX_TEXT_LINES); this.pageIdx = 0; 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 WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class); 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) { nameText.setText(speaker); nameText.setCullHint(Spatial.CullHint.Inherit); @@ -464,6 +537,7 @@ public class DialogHudState extends BaseAppState { if (phase == Phase.TEXT_NPC || phase == Phase.TEXT_HERO) { autoAdvanceTimer = -1f; if (tts != null) tts.stop(); + stopDialogAudio(); pageIdx = textPages.size(); if (afterText != null) { Runnable cb = afterText; afterText = null; cb.run(); } } @@ -724,10 +798,15 @@ public class DialogHudState extends BaseAppState { phase = Phase.HIDDEN; autoAdvanceTimer = -1f; 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(); if (canvasNode != null) { canvasNode.setCullHint(Spatial.CullHint.Always); @@ -754,6 +833,15 @@ public class DialogHudState extends BaseAppState { 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() { var im = app.getInputManager(); try { im.removeListener(inputListener); } catch (Exception ignored) {} diff --git a/blight-game/src/main/java/de/blight/game/state/RiverState.java b/blight-game/src/main/java/de/blight/game/state/RiverState.java index 6e05758..5afe9c7 100644 --- a/blight-game/src/main/java/de/blight/game/state/RiverState.java +++ b/blight-game/src/main/java/de/blight/game/state/RiverState.java @@ -309,7 +309,8 @@ public class RiverState extends BaseAppState { } 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.setMinFilter(Texture.MinFilter.BilinearNoMipMaps); tex.setMagFilter(Texture.MagFilter.Bilinear); diff --git a/blight-lang/src/main/java/de/blight/lang/AudioResolver.java b/blight-lang/src/main/java/de/blight/lang/AudioResolver.java new file mode 100644 index 0000000..6fb1f60 --- /dev/null +++ b/blight-lang/src/main/java/de/blight/lang/AudioResolver.java @@ -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_.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; + } +} diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 0b9fbb3..c3e2bf5 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ diff --git a/blight-map/src/main/map/blight_objects.blo b/blight-map/src/main/map/blight_objects.blo index 5f4db58..dae7eec 100644 --- a/blight-map/src/main/map/blight_objects.blo +++ b/blight-map/src/main/map/blight_objects.blo @@ -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 -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/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/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/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 diff --git a/blight-map/src/main/map/blight_voxel_cliff_zones.bvcz b/blight-map/src/main/map/blight_voxel_cliff_zones.bvcz new file mode 100644 index 0000000..2bf5bb3 --- /dev/null +++ b/blight-map/src/main/map/blight_voxel_cliff_zones.bvcz @@ -0,0 +1 @@ +# polygon minOffset maxHeight noiseScale octaves persistence edgeBlend seed diff --git a/blight-map/src/main/map/chunks/chunk_13_04.blc b/blight-map/src/main/map/chunks/chunk_13_04.blc index b488613..314a5a4 100644 Binary files a/blight-map/src/main/map/chunks/chunk_13_04.blc and b/blight-map/src/main/map/chunks/chunk_13_04.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_13_05.blc b/blight-map/src/main/map/chunks/chunk_13_05.blc index 665cd1a..1f970d0 100644 Binary files a/blight-map/src/main/map/chunks/chunk_13_05.blc and b/blight-map/src/main/map/chunks/chunk_13_05.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_13_06.blc b/blight-map/src/main/map/chunks/chunk_13_06.blc index 9c7457a..7636a43 100644 Binary files a/blight-map/src/main/map/chunks/chunk_13_06.blc and b/blight-map/src/main/map/chunks/chunk_13_06.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_14_04.blc b/blight-map/src/main/map/chunks/chunk_14_04.blc index 85b2ca0..a3915a0 100644 Binary files a/blight-map/src/main/map/chunks/chunk_14_04.blc and b/blight-map/src/main/map/chunks/chunk_14_04.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_14_05.blc b/blight-map/src/main/map/chunks/chunk_14_05.blc index 3ba157e..0d1299f 100644 Binary files a/blight-map/src/main/map/chunks/chunk_14_05.blc and b/blight-map/src/main/map/chunks/chunk_14_05.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_14_06.blc b/blight-map/src/main/map/chunks/chunk_14_06.blc index 7e63074..f33b6c2 100644 Binary files a/blight-map/src/main/map/chunks/chunk_14_06.blc and b/blight-map/src/main/map/chunks/chunk_14_06.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_15_04.blc b/blight-map/src/main/map/chunks/chunk_15_04.blc index 1e29ce1..5b9cb04 100644 Binary files a/blight-map/src/main/map/chunks/chunk_15_04.blc and b/blight-map/src/main/map/chunks/chunk_15_04.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_15_05.blc b/blight-map/src/main/map/chunks/chunk_15_05.blc index df59117..3fb3c21 100644 Binary files a/blight-map/src/main/map/chunks/chunk_15_05.blc and b/blight-map/src/main/map/chunks/chunk_15_05.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_15_06.blc b/blight-map/src/main/map/chunks/chunk_15_06.blc index c39dbfd..e7c30c9 100644 Binary files a/blight-map/src/main/map/chunks/chunk_15_06.blc and b/blight-map/src/main/map/chunks/chunk_15_06.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_16_03.blc b/blight-map/src/main/map/chunks/chunk_16_03.blc index c949002..b10aa77 100644 Binary files a/blight-map/src/main/map/chunks/chunk_16_03.blc and b/blight-map/src/main/map/chunks/chunk_16_03.blc differ diff --git a/blight-map/src/main/map/chunks/chunk_16_04.blc b/blight-map/src/main/map/chunks/chunk_16_04.blc index 96b3443..e2de685 100644 Binary files a/blight-map/src/main/map/chunks/chunk_16_04.blc and b/blight-map/src/main/map/chunks/chunk_16_04.blc differ diff --git a/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm b/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm deleted file mode 100644 index 9ffafab..0000000 Binary files a/blight-map/src/main/map/chunks/sculpt_16_0_17.blsm and /dev/null differ diff --git a/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm b/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm deleted file mode 100644 index c4a5afb..0000000 Binary files a/blight-map/src/main/map/chunks/sculpt_16_m1_17.blsm and /dev/null differ diff --git a/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm b/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm deleted file mode 100644 index e2c7fe4..0000000 Binary files a/blight-map/src/main/map/chunks/sculpt_17_0_17.blsm and /dev/null differ diff --git a/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm b/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm deleted file mode 100644 index 03f1291..0000000 Binary files a/blight-map/src/main/map/chunks/sculpt_17_m1_17.blsm and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_13_0_06.blvc b/blight-map/src/main/map/chunks/voxel_13_0_06.blvc new file mode 100644 index 0000000..69a6119 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_13_0_06.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o new file mode 100644 index 0000000..e5d3137 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o new file mode 100644 index 0000000..6b51b7f Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o new file mode 100644 index 0000000..e6579da Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_06.blvc b/blight-map/src/main/map/chunks/voxel_14_0_06.blvc new file mode 100644 index 0000000..1676381 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_06.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o new file mode 100644 index 0000000..f78bf3b Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o new file mode 100644 index 0000000..401f061 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o new file mode 100644 index 0000000..90d5dd3 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o deleted file mode 100644 index 1ade98b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o deleted file mode 100644 index a0f3ba6..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_0_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o deleted file mode 100644 index 13a1000..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o deleted file mode 100644 index d19c985..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_16_m1_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o deleted file mode 100644 index b597190..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o deleted file mode 100644 index e4d1d2f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o deleted file mode 100644 index f6bb421..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_0_17_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o deleted file mode 100644 index 4572d35..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o deleted file mode 100644 index 4def65a..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o deleted file mode 100644 index 2eaea37..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_17_m1_17_baked_lod2.j3o and /dev/null differ