Voxel Klippen System weiter optimiert, Monolog System implementiert, Compiler Warnungen behoben
This commit is contained in:
@@ -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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
package de.blight.common.model;
|
||||
|
||||
public interface AudioReference {
|
||||
|
||||
}
|
||||
public record AudioReference(String key) {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ─
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
81
blight-lang/src/main/java/de/blight/lang/AudioResolver.java
Normal file
81
blight-lang/src/main/java/de/blight/lang/AudioResolver.java
Normal 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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
1
blight-map/src/main/map/blight_voxel_cliff_zones.bvcz
Normal file
1
blight-map/src/main/map/blight_voxel_cliff_zones.bvcz
Normal file
@@ -0,0 +1 @@
|
||||
# polygon minOffset maxHeight noiseScale octaves persistence edgeBlend seed
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_13_0_06.blvc
Normal file
BIN
blight-map/src/main/map/chunks/voxel_13_0_06.blvc
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_0_06.blvc
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_0_06.blvc
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o
Normal file
Binary file not shown.
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o
Normal file
BIN
blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user