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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ public final class TriggerIO {
public static final String TYPE_NPC_STATUS = "NPC_STATUS";
public static final String TYPE_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";
}

View File

@@ -173,6 +173,7 @@ public class EditorApp extends Application {
// Character-Editor-Zustand
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());

View File

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

View File

@@ -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<Long> markedDeleteBakedKeys =
java.util.concurrent.ConcurrentHashMap.newKeySet();
/** JFX → JME: alle markierten Chunks löschen. */
public volatile boolean deleteMarkedBakedRequested = false;
/** JFX → JME: Markierung ohne Löschen leeren. */
public volatile boolean clearMarkedBakedRequested = false;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
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<VoxelCliffClick> voxelCliffClickQueue = new ConcurrentLinkedQueue<>();
/** JFX → JME: Voxel für die ausgewählte Klippen-Zone generieren. */
public volatile boolean generateCliffVoxelsRequested = false;
/** JFX → JME: Ausgewählte Klippen-Zone löschen. */
public volatile boolean deleteVoxelCliffZoneRequested = false;
/** JME → JFX: Status-Meldung nach Generierung. */
public volatile String cliffGenStatusMsg = null;
/** JME → JFX: Index der ausgewählten Klippen-Zone (-1 = keine). */
public volatile int selectedCliffZoneIdx = -1;
/** JME → JFX: Selektion hat sich geändert → Panel aktualisieren. */
public volatile boolean cliffZoneSelectionChanged = false;
/** JME → JFX: Generierung abgeschlossen → zu LAYER_VOXEL wechseln. */
public volatile boolean cliffGenComplete = false;
/** Maximale Kliff-Höhe über Terrain in Welt-Einheiten (2D-fBm-Skalierung). */
public volatile float cliffMaxHeight = 3.0f;
/** 2D-Noise-Frequenz für die Höhenverteilung (kleiner = breiter, größer = lokaler). */
public volatile float cliffNoiseScale = 0.15f;
/** Rand-Überblendung: Zone läuft über diese Distanz zum Rand hin aus. */
public volatile float cliffEdgeBlend = 5.0f;
/** Oberflächen-Rauigkeit in Welt-Einheiten (wie stark 3D-Noise die Iso-Fläche verschiebt). */
public volatile float cliffRoughnessAmp = 4.0f;
/** 3D-Noise-Frequenz für die Kliff-Oberfläche (höher = feiner Detail). */
public volatile float cliffRoughnessScale = 0.12f;
}

View File

@@ -284,6 +284,7 @@ public class ModelImportState extends BaseAppState {
startImpostorPass(0);
}
@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);

View File

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

View File

@@ -110,6 +110,7 @@ public class TerrainEditorState extends BaseAppState {
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) {
@@ -1197,6 +1213,8 @@ public class TerrainEditorState extends BaseAppState {
final List<PlacedSoundArea> soundAreas = soundAreaState != null ? soundAreaState.getPlacedAreas() : null;
final List<PlacedArea> areas = areaState != null ? areaState.getPlacedAreas() : null;
final List<PlacedLocationZone> locationZones = locationZoneState != null ? locationZoneState.getPlacedZones() : null;
final List<de.blight.common.PlacedVoxelCliffZone> cliffZones =
voxelCliffEditorState != null ? voxelCliffEditorState.getPlacedZones() : null;
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
// 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 ─

View File

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

View File

@@ -121,6 +121,13 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator;
// ── Overlay für markierte Baked-Chunks ────────────────────────────────────
/** Root-Node für rote Chunk-Markierungen im MODE_DELETE_BAKED. */
private Node markedChunkOverlayRoot;
/** key → zugehörige Overlay-Geometrie. */
private final Map<Long, Geometry> markedOverlays = new HashMap<>();
// ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** 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();
@@ -209,6 +221,7 @@ public class VoxelEditorState extends BaseAppState {
voxelRoot.removeFromParent();
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<Long> toDelete = new HashSet<>(input.markedDeleteBakedKeys);
input.markedDeleteBakedKeys.clear();
clearMarkedOverlays();
executor.submit(() -> {
try {
deleteMarkedBaked(toDelete);
} catch (Throwable e) {
log.error("Baked-Löschen fehlgeschlagen: {}", e.getMessage(), e);
input.bakeStatusMsg = "FEHLER: " + e.getMessage();
}
});
}
// Markierung leeren
if (input.clearMarkedBakedRequested) {
input.clearMarkedBakedRequested = false;
input.markedDeleteBakedKeys.clear();
clearMarkedOverlays();
}
// Voxel-Texturen aktualisiert?
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<Long> keys) {
if (keys.isEmpty()) {
input.bakeStatusMsg = "Keine Chunks markiert.";
return;
}
int deleted = 0;
for (long key : keys) {
int cx = (int)(key & 0xFFFF); if (cx >= 0x8000) cx -= 0x10000;
int cy = (int)((key >> 16) & 0xFFFF); if (cy >= 0x8000) cy -= 0x10000;
int cz = (int)((key >> 32) & 0xFFFF); if (cz >= 0x8000) cz -= 0x10000;
for (int lod = 0; lod < 3; lod++) {
try {
Files.deleteIfExists(VoxelChunkIO.getBakedPath(cx, cy, cz, lod));
} catch (IOException e) {
log.warn("Baked LOD{} löschen fehlgeschlagen ({},{},{}): {}",
lod, cx, cy, cz, e.getMessage());
}
}
if (de.blight.common.SculptedMeshIO.exists(cx, cy, cz)) {
try { de.blight.common.SculptedMeshIO.delete(cx, cy, cz); }
catch (Exception e) { log.warn("Sculpt-Overlay löschen fehlgeschlagen: {}", e.getMessage()); }
}
deleted++;
}
input.sculptRescanNeeded = true;
input.bakeStatusMsg = "Gelöscht: " + deleted + " Chunk" + (deleted != 1 ? "s" : "") + ".";
log.info("Markierte Baked-Chunks gelöscht: {}.", deleted);
}
// ── Terrain-Höhe (schneller O(1)-Zugriff) ─────────────────────────────────
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]
* 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<Long, float[]> 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). */

View File

@@ -8,6 +8,8 @@ import java.util.List;
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten für Felstürme, Plateaus.
* 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.
@@ -25,10 +27,11 @@ public class VoxelTool extends EditorTool {
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
}
);

View File

@@ -584,8 +584,9 @@ public class DialogEditorView extends BorderPane {
List<de.blight.common.model.DialogStep> 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<de.blight.common.model.DialogStep> 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);

View File

@@ -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;
// Sprache
private final ComboBox<String> langCombo = new ComboBox<>();
// Text-Tab
private final ObservableList<String[]> tableData = FXCollections.observableArrayList();
private final TableView<String[]> table = new TableView<>(tableData);
private TextBundle currentBundle = null;
// Audio-Tab
private final ObservableList<String[]> audioData = FXCollections.observableArrayList();
private final TableView<String[]> audioTable = new TableView<>(audioData);
private AudioBundle currentAudioBundle = null;
public LocalizationEditorView(Path locDir) {
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<String[], String> keyCol = new TableColumn<>("Schlüssel");
TableColumn<String[], String> keyCol = new TableColumn<>("Schluessel");
keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0]));
keyCol.setCellFactory(TextFieldTableCell.forTableColumn());
keyCol.setOnEditCommit(e -> {
e.getRowValue()[0] = e.getNewValue().trim();
table.refresh();
});
keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); table.refresh(); });
keyCol.setPrefWidth(280);
TableColumn<String[], String> 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<String[], String> keyCol = new TableColumn<>("Schluessel");
keyCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[0]));
keyCol.setCellFactory(TextFieldTableCell.forTableColumn());
keyCol.setOnEditCommit(e -> { e.getRowValue()[0] = e.getNewValue().trim(); audioTable.refresh(); });
keyCol.setPrefWidth(220);
TableColumn<String[], String> pathCol = new TableColumn<>("Datei");
pathCol.setCellValueFactory(cd -> new SimpleStringProperty(cd.getValue()[1]));
pathCol.setCellFactory(TextFieldTableCell.forTableColumn());
pathCol.setOnEditCommit(e -> { e.getRowValue()[1] = e.getNewValue(); audioTable.refresh(); });
TableColumn<String[], Void> browseCol = new TableColumn<>("");
browseCol.setPrefWidth(36);
browseCol.setMinWidth(36);
browseCol.setMaxWidth(36);
browseCol.setCellFactory(tc -> new TableCell<>() {
private final Button btn = new Button("...");
{
btn.setStyle("-fx-padding: 2 4 2 4;");
btn.setOnAction(ev -> {
int idx = getIndex();
if (idx < 0 || idx >= audioData.size()) return;
FileChooser fc = new FileChooser();
fc.setTitle("Audio-Datei waehlen");
fc.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("Audio", "*.ogg", "*.wav", "*.mp3"),
new FileChooser.ExtensionFilter("Alle Dateien", "*.*")
);
File file = fc.showOpenDialog(getScene().getWindow());
if (file != null) {
audioData.get(idx)[1] = file.getAbsolutePath();
audioTable.refresh();
}
});
}
@Override
protected void updateItem(Void v, boolean empty) {
super.updateItem(v, empty);
setGraphic(empty ? null : btn);
}
});
audioTable.getColumns().addAll(List.of(keyCol, pathCol, browseCol));
VBox.setVgrow(audioTable, Priority.ALWAYS);
Label hint = new Label("Schluessel muss dem AudioReference-Key im Dialog entsprechen.");
hint.setStyle("-fx-font-size: 10; -fx-text-fill: #666; -fx-padding: 2 12 4 12;");
VBox box = new VBox(subBar, audioTable, hint);
VBox.setVgrow(audioTable, Priority.ALWAYS);
return box;
}
// Sprach-Verwaltung
private void refreshLangList() {
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<String> existing = currentBundle.getEntries().keySet();
List<String> 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<String> 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<String> 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<String, String> entries = new LinkedHashMap<>();
Map<String, String> 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(textEntries);
Map<String, String> audioEntries = new LinkedHashMap<>();
for (String[] row : audioData) {
if (!row[0].isBlank()) audioEntries.put(row[0].trim(), row[1]);
}
currentBundle.setEntries(entries);
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();
}

View File

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

View File

@@ -1,5 +1,7 @@
package de.blight.editor.ui;
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.*;
@@ -29,6 +31,7 @@ public class TriggerDialog extends Dialog<Trigger> {
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<String> typeCombo = new ComboBox<>();
@@ -50,6 +53,9 @@ public class TriggerDialog extends Dialog<Trigger> {
private TextField routineNpcIdField;
private TextField routineNameField;
// Monolog
private ComboBox<String> monologueIdCombo;
/** Öffnet den Dialog für einen neuen Trigger. */
public TriggerDialog() {
this(null);
@@ -61,7 +67,7 @@ public class TriggerDialog extends Dialog<Trigger> {
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()));
@@ -106,6 +112,7 @@ public class TriggerDialog extends Dialog<Trigger> {
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<Trigger> {
);
}
private void buildMonologueFields() {
monologueIdCombo = new ComboBox<>();
monologueIdCombo.setEditable(true);
monologueIdCombo.setMaxWidth(Double.MAX_VALUE);
monologueIdCombo.setPromptText("Monolog-ID eingeben oder wählen...");
try {
de.blight.common.MonologueIO.load().stream()
.map(Monologue::getId)
.filter(id -> !id.isBlank())
.forEach(monologueIdCombo.getItems()::add);
} catch (Exception ignored) {}
dynamicArea.getChildren().addAll(
sectionTitle("Monolog starten"),
row("Monolog-ID:", monologueIdCombo)
);
}
// ── Trigger bauen ─────────────────────────────────────────────────────────
private Trigger buildTrigger() {
@@ -183,6 +207,14 @@ public class TriggerDialog extends Dialog<Trigger> {
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<Trigger> {
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());
}
}

View File

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

View File

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

View File

@@ -34,10 +34,14 @@ import de.blight.common.model.TextReference;
import de.blight.common.model.trigger.ChangeRoutineTrigger;
import de.blight.common.model.trigger.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);
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;
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) {}

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
# modelPath x y z rotY scale rotX rotZ solid texPath nmPath matPath meshFile animClip castShadow receiveShadow lod1Path lod2Path lod1Distance lod2Distance cullDistance interactableType interactableId
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

View File

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

Binary file not shown.

Binary file not shown.