Tool und Oberflächen Konsolidierung angefangen

This commit is contained in:
2026-08-14 15:41:27 +02:00
parent 400a736804
commit 90599c9c7b
78 changed files with 2400 additions and 311 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,13 @@ public class SharedInput {
public record GrassVertexEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<GrassVertexEdit> grassVertexEditQueue = new ConcurrentLinkedQueue<>();
/** Globale Grasfarbe frisches Gras (Spitze; Wurzel wird intern abgeleitet). Defaults: TIP_COLOR */
public volatile float grassFreshR = 0.26f, grassFreshG = 0.72f, grassFreshB = 0.11f;
/** Globale Grasfarbe vertrocknet (Spitze). Defaults: DRY_TIP_COLOR */
public volatile float grassDryR = 0.82f, grassDryG = 0.74f, grassDryB = 0.16f;
/** true → GrassVertexState soll alle Chunks neu aufbauen */
public volatile boolean grassColorsChanged = false;
// ── Farn-Generator ────────────────────────────────────────────────────────
public record FernGenRequest(de.blight.editor.tree.FernOptions options, boolean exportAfter) {}
public final ConcurrentLinkedQueue<FernGenRequest> fernGenQueue = new ConcurrentLinkedQueue<>();
@@ -276,6 +283,8 @@ public class SharedInput {
public volatile boolean objectSelectionChanged = false;
/** Wird von JME3 gesetzt, wenn ein Objekt gerade neu platziert wurde (nicht nur selektiert). */
public volatile boolean objectJustPlaced = false;
/** Wird von JME3 gesetzt, wenn ein Objekt im Platzierungsmodus angeklickt wurde → JFX wechselt Panel zu Edit. */
public volatile boolean objectClickedWhileInPlaceMode = false;
/** JavaFX → JME3: Modell-Pfad für nächste Platzierung (relativ zu blight-assets/src/main/resources/). */
public volatile String pendingModelPath = null;
@@ -1087,6 +1096,10 @@ public class SharedInput {
/** 3D-Noise-Frequenz für die Kliff-Oberfläche (höher = feiner Detail). */
public volatile float cliffRoughnessScale = 0.12f;
// ── Universelles Auswahl-Werkzeug ─────────────────────────────────────────
/** activeLayer==33 → Auswahl-Modus: kein Terrain-Editing, Klick selektiert Objekte */
public static final int LAYER_AUSWAHL = 33;
// ── Voxel-Textur-Malen ────────────────────────────────────────────────────
/** Parallel-Queue zu textureEditQueue wird von submitEdit(layer=4) mitbefüllt,
* damit VoxelEditorState unabhängig vom Terrain seine Splatmap beschreiben kann. */

View File

@@ -91,6 +91,10 @@ public class GrassVertexState extends BaseAppState {
private final Node[] chunkNodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
// Effektive Grasfarben werden aus SharedInput übernommen, Wurzel wird abgeleitet
private float freshTipR = TIP_COLOR.r, freshTipG = TIP_COLOR.g, freshTipB = TIP_COLOR.b;
private float dryTipR = DRY_TIP_COLOR.r, dryTipG = DRY_TIP_COLOR.g, dryTipB = DRY_TIP_COLOR.b;
public GrassVertexState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkBlades[i] = new ArrayList<>();
@@ -168,6 +172,12 @@ public class GrassVertexState extends BaseAppState {
@Override
public void update(float tpf) {
if (input.grassColorsChanged) {
input.grassColorsChanged = false;
freshTipR = input.grassFreshR; freshTipG = input.grassFreshG; freshTipB = input.grassFreshB;
dryTipR = input.grassDryR; dryTipG = input.grassDryG; dryTipB = input.grassDryB;
for (int ci = 0; ci < CHUNK_COUNT; ci++) dirtyChunks[ci] = !chunkBlades[ci].isEmpty();
}
processBrushEdits();
rebuildDirtyChunks();
updateChunkVisibility();
@@ -399,9 +409,15 @@ public class GrassVertexState extends BaseAppState {
float[] texCoords = new float[vertCount * 2];
int[] indices = new int[indexCount];
// Wurzelfarbe = Spitze * fester Faktor (entspricht den ursprünglichen statischen Verhältnissen)
float frR = freshTipR * 0.308f, frG = freshTipG * 0.472f, frB = freshTipB * 0.364f;
float drR = dryTipR * 0.549f, drG = dryTipG * 0.473f, drB = dryTipB * 0.500f;
int vi = 0, ii = 0;
for (GrassVertexBlade blade : blades) {
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade);
buildTuft(positions, normals, colors, texCoords, indices, vi, ii, blade,
frR, frG, frB, freshTipR, freshTipG, freshTipB,
drR, drG, drB, dryTipR, dryTipG, dryTipB);
vi += BLADES_PER_TUFT * (SEGMENTS + 1) * 2;
ii += BLADES_PER_TUFT * SEGMENTS * 6;
}
@@ -560,7 +576,11 @@ public class GrassVertexState extends BaseAppState {
// ── Mesh-Generierung (gemeinsame Logik, auch von GrassVertexRenderState genutzt) ──
static void buildTuft(float[] pos, float[] nrm, float[] col, float[] tex, int[] idx,
int vi, int ii, GrassVertexBlade blade) {
int vi, int ii, GrassVertexBlade blade,
float frshRootR, float frshRootG, float frshRootB,
float frshTipR, float frshTipG, float frshTipB,
float dryRootR, float dryRootG, float dryRootB,
float dryTipR, float dryTipG, float dryTipB) {
float x = blade.x(), y = blade.y(), z = blade.z(), h = blade.height();
float baseHW = h * WIDTH_FACTOR * 0.5f;
float tAngle = (float) (((x * 127.1f + z * 311.7f) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2));
@@ -626,8 +646,12 @@ public class GrassVertexState extends BaseAppState {
int svi = bladeVi + s * 2;
float dry = blade.dryness();
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry);
setV(pos, nrm, col, tex, svi, spX - cosA * hw, spY, spZ - sinA * hw, nx, ny, nz, t, dry,
frshRootR, frshRootG, frshRootB, frshTipR, frshTipG, frshTipB,
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB);
setV(pos, nrm, col, tex, svi+1, spX + cosA * hw, spY, spZ + sinA * hw, nx, ny, nz, t, dry,
frshRootR, frshRootG, frshRootB, frshTipR, frshTipG, frshTipB,
dryRootR, dryRootG, dryRootB, dryTipR, dryTipG, dryTipB);
if (s < SEGMENTS) {
int sii = bladeIi + s * 6;
@@ -651,7 +675,11 @@ public class GrassVertexState extends BaseAppState {
private static void setV(float[] pos, float[] nrm, float[] col, float[] tex, int vi,
float x, float y, float z, float nx, float ny, float nz,
float wf, float dryness) {
float wf, float dryness,
float frshRootR, float frshRootG, float frshRootB,
float frshTipR, float frshTipG, float frshTipB,
float dryRootR, float dryRootG, float dryRootB,
float dryTipR, float dryTipG, float dryTipB) {
int pi = vi * 3;
pos[pi] = x; pos[pi+1] = y; pos[pi+2] = z;
@@ -659,16 +687,15 @@ public class GrassVertexState extends BaseAppState {
nrm[ni] = nx; nrm[ni+1] = ny; nrm[ni+2] = nz;
int ci = vi * 4;
float gr = ROOT_COLOR.r + (TIP_COLOR.r - ROOT_COLOR.r) * wf;
float gg = ROOT_COLOR.g + (TIP_COLOR.g - ROOT_COLOR.g) * wf;
float gb = ROOT_COLOR.b + (TIP_COLOR.b - ROOT_COLOR.b) * wf;
float dr = DRY_ROOT_COLOR.r + (DRY_TIP_COLOR.r - DRY_ROOT_COLOR.r) * wf;
float dg = DRY_ROOT_COLOR.g + (DRY_TIP_COLOR.g - DRY_ROOT_COLOR.g) * wf;
float db = DRY_ROOT_COLOR.b + (DRY_TIP_COLOR.b - DRY_ROOT_COLOR.b) * wf;
float gr = frshRootR + (frshTipR - frshRootR) * wf;
float gg = frshRootG + (frshTipG - frshRootG) * wf;
float gb = frshRootB + (frshTipB - frshRootB) * wf;
float dr = dryRootR + (dryTipR - dryRootR) * wf;
float dg = dryRootG + (dryTipG - dryRootG) * wf;
float db = dryRootB + (dryTipB - dryRootB) * wf;
float vr = VERY_DRY_ROOT_COLOR.r + (VERY_DRY_TIP_COLOR.r - VERY_DRY_ROOT_COLOR.r) * wf;
float vg = VERY_DRY_ROOT_COLOR.g + (VERY_DRY_TIP_COLOR.g - VERY_DRY_ROOT_COLOR.g) * wf;
float vb = VERY_DRY_ROOT_COLOR.b + (VERY_DRY_TIP_COLOR.b - VERY_DRY_ROOT_COLOR.b) * wf;
// Zwei-Segment-Gradient: 0→0.5 = grün→goldgelb, 0.5→1.0 = goldgelb→dunkelbraun
float fr, fg, fb;
if (dryness <= 0.5f) {
float t = dryness * 2f;

View File

@@ -381,10 +381,13 @@ public class PlayToolState extends BaseAppState {
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
Vector3f pt = ves.clickAt(ray);
if (pt != null && ves.terrainTypeAt(pt.x, pt.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) {
return null;
if (pt != null) {
if (ves.terrainTypeAt(pt.x, pt.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) {
return null;
}
return pt;
}
return pt;
// ves hat kein Terrain-Geometry — Fallback auf Base-Terrain
}
if (terrain == null) return null;
CollisionResults hits = new CollisionResults();

View File

@@ -395,7 +395,8 @@ public class SceneObjectState extends BaseAppState {
handleBenchSitzLayer();
boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT;
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT
|| input.activeLayer == SharedInput.LAYER_AUSWAHL;
if (!isObjectLayer) return;
// Animation-Clip-Zuweisung von JavaFX
@@ -680,11 +681,23 @@ public class SceneObjectState extends BaseAppState {
input.objectSelectionMode = SharedInput.SEL_MODE_OBJECT;
if (input.objectSelectionMode != SharedInput.SEL_MODE_OBJECT)
applySubSelection(closest);
if (input.activeLayer == SharedInput.LAYER_OBJECTS
|| input.activeLayer == SharedInput.LAYER_AUSWAHL) {
input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT;
input.objectClickedWhileInPlaceMode = true;
}
return;
}
}
// 3. Terrain-Treffer Platzieren nur im Platzieren-Modus
// 3. Terrain-Treffer im Edit-Modus: zurück zu Place-Modus, deselektieren
if (input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT) {
input.activeLayer = SharedInput.LAYER_OBJECTS;
deselectAll();
return;
}
// Auswahl-Modus: kein Objekt angeklickt → nur deselektieren, Layer bleibt
if (input.activeLayer == SharedInput.LAYER_AUSWAHL) { deselectAll(); return; }
if (input.activeLayer != SharedInput.LAYER_OBJECTS) { deselectAll(); return; }
String modelPath = input.pendingModelPath;

View File

@@ -378,6 +378,9 @@ public class TerrainEditorState extends BaseAppState {
}
input.loadingStatus = "Baue Szene...";
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
if (ves != null) ves.setTerrainNode(terrain);
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
if (playToolState != null) playToolState.setTerrain(terrain);
@@ -1394,6 +1397,7 @@ public class TerrainEditorState extends BaseAppState {
|| layer == SharedInput.LAYER_VOXEL || layer == SharedInput.LAYER_STONE
|| layer == SharedInput.LAYER_BED_LIEGE
|| layer == SharedInput.LAYER_BENCH_SITZ
|| layer == SharedInput.LAYER_AUSWAHL
|| mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;

View File

@@ -6,7 +6,7 @@ public class GrassVertexTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 50.0);
public final ToolParameter bladeHeight = new ToolParameter("Halmhöhe", 0.6, 0.1, 2.0);
public final ToolParameter density = new ToolParameter("Dichte", 5.0, 1.0, 100.0);
public final ToolParameter density = new ToolParameter("Dichte", 50.0, 1.0, 100.0);
public final ToolParameter dryness = new ToolParameter("Vertrocknet %", 0.0, 0.0, 100.0);
/** 1.0 = exakt gleiche Höhe, 0.0 = ±25 % Zufallsvariation */
public final ToolParameter uniformity = new ToolParameter("Gleichmäßigkeit", 1.0, 0.0, 1.0);

View File

@@ -1,5 +1,6 @@
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;
@@ -21,7 +22,10 @@ import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.stage.Modality;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.*;
/**
@@ -41,6 +45,8 @@ public class DialogEditorView extends BorderPane {
private ToggleButton listBtn;
private ToggleButton graphBtn;
private Button skriptBtn;
private Button ttsBtn;
// ── List-mode ─────────────────────────────────────────────────────────────
@@ -105,6 +111,7 @@ public class DialogEditorView extends BorderPane {
normalizeReferences();
refreshOptionList();
clearDetailForm();
setCharacterLoaded(true);
}
public void clear() {
@@ -112,8 +119,15 @@ public class DialogEditorView extends BorderPane {
allOptions.clear();
rootIds.clear();
selectedId = null;
currentNpcId = "";
refreshOptionList();
clearDetailForm();
setCharacterLoaded(false);
}
public void setCharacterLoaded(boolean loaded) {
if (skriptBtn != null) skriptBtn.setDisable(!loaded);
if (ttsBtn != null) ttsBtn.setDisable(!loaded);
}
public void exportToNpc(NPC npc) {
@@ -162,8 +176,21 @@ public class DialogEditorView extends BorderPane {
Button newBtn = new Button("+ Option");
newBtn.setOnAction(e -> createOption(false));
skriptBtn = new Button("Skript");
skriptBtn.setStyle("-fx-background-color: #3a4a6a; -fx-text-fill: white;");
skriptBtn.setDisable(true);
skriptBtn.setOnAction(e -> { exportNpcScript(); exportHeroScript(); });
ttsBtn = new Button("TTS");
ttsBtn.setStyle("-fx-background-color: #5a3a2a; -fx-text-fill: white;");
ttsBtn.setDisable(true);
ttsBtn.setOnAction(e -> new TtsGeneratorDialog(currentNpcId,
new LinkedHashMap<>(allOptions)).showAndWait());
HBox bar = new HBox(8, listBtn, graphBtn,
new Separator(Orientation.VERTICAL), newRootBtn, newBtn);
new Separator(Orientation.VERTICAL), newRootBtn, newBtn,
new Separator(Orientation.VERTICAL), skriptBtn,
new Separator(Orientation.VERTICAL), ttsBtn);
bar.setPadding(new Insets(8));
bar.setAlignment(Pos.CENTER_LEFT);
bar.setStyle("-fx-background-color: #1a1a2a; -fx-border-color: #555;"
@@ -1249,6 +1276,149 @@ public class DialogEditorView extends BorderPane {
return idx == 0 ? base + suffix : base + suffix + "." + idx;
}
// ── Skript-Export ──────────────────────────────────────────────────────────
private void exportNpcScript() {
saveCurrentForm();
if (currentNpcId.isBlank() || allOptions.isEmpty()) {
Dialogs.alert(Alert.AlertType.WARNING,
"Kein NPC geladen oder keine Dialog-Optionen vorhanden.", ButtonType.OK).showAndWait();
return;
}
Map<String, String> texts = loadTexts();
StringBuilder sb = new StringBuilder();
sb.append("=== NPC-Skript: ").append(currentNpcId).append(" ===\n");
sb.append("Exportiert: ").append(LocalDate.now()).append("\n\n");
for (Map.Entry<String, DialogOption> entry : allOptions.entrySet()) {
DialogOption opt = entry.getValue();
String optId = entry.getKey();
String base = "dialog." + currentNpcId + "." + optId;
List<String> npcKeys = resolveStepKeys(opt.getNpcSteps(), opt.getTextNpc(), base, ".textnpc");
if (npcKeys.isEmpty()) continue;
sb.append("--- ").append(optId);
if (rootIds.contains(optId)) sb.append(" [ROOT]");
sb.append(" ---\n");
appendStepLines(sb, npcKeys, "NPC", texts);
sb.append("\n");
}
writeScript(ProjectRoot.resolve("dialog_script_npc_" + currentNpcId + ".txt"), sb.toString());
}
private void exportHeroScript() {
Map<String, String> texts = loadTexts();
StringBuilder sb = new StringBuilder();
sb.append("=== Held-Skript ===\n");
sb.append("Exportiert: ").append(LocalDate.now()).append("\n\n");
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "character");
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
if (!(gc instanceof NPC npc)) continue;
if (npc.getDialogOptions() == null || npc.getDialogOptions().isEmpty()) continue;
boolean npcHeaderWritten = false;
for (Map.Entry<String, DialogOption> entry : npc.getDialogOptions().entrySet()) {
String optId = entry.getKey();
DialogOption opt = entry.getValue();
String base = "dialog." + npc.getCharacterId() + "." + optId;
List<String> heroKeys = resolveStepKeys(opt.getHeroSteps(), opt.getTextHero(), base, ".textmainchar");
if (heroKeys.isEmpty()) continue;
if (!npcHeaderWritten) {
sb.append("=== NPC: ").append(npc.getCharacterId()).append(" ===\n\n");
npcHeaderWritten = true;
}
sb.append("--- ").append(optId).append(" ---\n");
appendStepLines(sb, heroKeys, "HELD", texts);
sb.append("\n");
}
}
List<Monologue> monologues = MonologueIO.load();
if (!monologues.isEmpty()) {
sb.append("=== Monologe ===\n\n");
for (Monologue m : monologues) {
if (m.getHeroSteps() == null || m.getHeroSteps().isEmpty()) continue;
sb.append("--- ").append(m.getId()).append(" ---\n");
List<String> keys = m.getHeroSteps().stream()
.filter(s -> s.getText() != null && !s.getText().id().isBlank())
.map(s -> s.getText().id())
.toList();
appendStepLines(sb, keys, "HELD", texts);
sb.append("\n");
}
}
writeScript(ProjectRoot.resolve("dialog_script_held.txt"), sb.toString());
}
private Map<String, String> loadTexts() {
try {
Path msgFile = ProjectRoot.resolve(
"blight-lang", "src", "main", "resources", "lang", "messages_de.properties");
if (Files.exists(msgFile)) return new LinkedHashMap<>(TextBundleIO.load(msgFile).getEntries());
} catch (Exception ignored) {}
return new LinkedHashMap<>();
}
private static void appendStepLines(StringBuilder sb, List<String> keys,
String label, Map<String, String> texts) {
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
sb.append(keys.size() > 1 ? " " + label + " " + (i + 1) + ":\n" : " " + label + ":\n");
sb.append(" Key: ").append(key).append("\n");
sb.append(" Text: \"").append(texts.getOrDefault(key, "(nicht übersetzt)")).append("\"\n");
sb.append(" Audio: ").append(audioPathForKey(key)).append("\n");
}
}
private void writeScript(Path out, String content) {
try {
Files.writeString(out, content);
Dialogs.alert(Alert.AlertType.INFORMATION,
"Skript exportiert nach:\n" + out, ButtonType.OK).showAndWait();
} catch (IOException ex) {
Dialogs.alert(Alert.AlertType.ERROR,
"Fehler beim Schreiben: " + ex.getMessage(), ButtonType.OK).showAndWait();
}
}
/** Liefert die Step-Keys einer Option: erst aus heroSteps/npcSteps, dann textHero/textNpc, dann Ableitungsfall. */
static List<String> resolveStepKeys(List<DialogStep> steps, TextReference legacy,
String base, String suffix) {
if (steps != null && !steps.isEmpty()) {
List<String> keys = new ArrayList<>();
for (DialogStep s : steps) {
if (s.getText() != null && !s.getText().id().isBlank()) keys.add(s.getText().id());
}
if (!keys.isEmpty()) return keys;
}
if (legacy != null && !legacy.id().isBlank()) return List.of(legacy.id());
return List.of(base + suffix);
}
private static String audioPathForKey(String key) {
if (key == null) return "";
if (key.startsWith("dialog.")) {
String without = key.substring("dialog.".length());
int dot = without.indexOf('.');
if (dot < 0) return "";
return "audio/dialog/" + without.substring(0, dot) + "/"
+ without.substring(dot + 1).replace('.', '_') + ".ogg";
}
if (key.startsWith("monologue.")) {
String without = key.substring("monologue.".length());
int dot = without.indexOf('.');
if (dot < 0) return "audio/monologue/" + without + ".ogg";
return "audio/monologue/" + without.substring(0, dot) + "_"
+ without.substring(dot + 1).replace('.', '_') + ".ogg";
}
return "";
}
private void refreshStepKeys(String base) {
if (heroStepsView == null || npcStepsView == null) {
return;

View File

@@ -6,6 +6,7 @@ import de.blight.common.model.TextBundle;
import de.blight.common.model.TextBundleIO;
import de.blight.common.model.TextKeyStore;
import de.blight.common.model.TextRegistry;
import de.blight.editor.ProjectRoot;
import javafx.animation.PauseTransition;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
@@ -139,7 +140,11 @@ public class LocalizationEditorView extends BorderPane {
Button sortBtn = new Button("A->Z");
sortBtn.setOnAction(e -> audioData.sort(Comparator.comparing(r -> r[0])));
HBox subBar = new HBox(6, addKeyBtn, delKeyBtn, sortBtn);
Button fillDialogBtn = new Button("Dialog-Schluessel befuellen");
fillDialogBtn.setStyle("-fx-background-color: #3a4a6a; -fx-text-fill: white;");
fillDialogBtn.setOnAction(e -> autoFillDialogKeys());
HBox subBar = new HBox(6, addKeyBtn, delKeyBtn, sortBtn, fillDialogBtn);
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;");
@@ -177,9 +182,20 @@ public class LocalizationEditorView extends BorderPane {
new FileChooser.ExtensionFilter("Audio", "*.ogg", "*.wav", "*.mp3"),
new FileChooser.ExtensionFilter("Alle Dateien", "*.*")
);
Path audioRoot = ProjectRoot.resolve(
"blight-assets", "src", "main", "resources");
File initialDir = audioRoot.resolve("audio").resolve("dialog").toFile();
if (initialDir.isDirectory()) fc.setInitialDirectory(initialDir);
File file = fc.showOpenDialog(getScene().getWindow());
if (file != null) {
audioData.get(idx)[1] = file.getAbsolutePath();
String path;
try {
path = audioRoot.relativize(file.toPath())
.toString().replace('\\', '/');
} catch (IllegalArgumentException e) {
path = file.getAbsolutePath();
}
audioData.get(idx)[1] = path;
audioTable.refresh();
scheduleSave();
}
@@ -349,6 +365,38 @@ public class LocalizationEditorView extends BorderPane {
if (sel != null) { audioData.remove(sel); scheduleSave(); }
}
private void autoFillDialogKeys() {
Set<String> existing = new HashSet<>();
for (String[] row : audioData) existing.add(row[0]);
List<String> added = new ArrayList<>();
for (String key : TextKeyStore.getKeys()) {
if (!key.startsWith("dialog.")) continue;
if (existing.contains(key)) continue;
audioData.add(new String[]{key, keyToAudioPath(key)});
existing.add(key);
added.add(key);
}
if (!added.isEmpty()) {
audioData.sort(Comparator.comparing(r -> r[0]));
scheduleSave();
}
Dialogs.alert(Alert.AlertType.INFORMATION,
added.isEmpty() ? "Alle Dialog-Schluessel bereits vorhanden."
: added.size() + " Schluessel hinzugefuegt.",
ButtonType.OK).showAndWait();
}
private static String keyToAudioPath(String key) {
if (key == null || !key.startsWith("dialog.")) return "";
String without = key.substring("dialog.".length());
int dot = without.indexOf('.');
if (dot < 0) return "";
String npcId = without.substring(0, dot);
String rest = without.substring(dot + 1).replace('.', '_');
return "audio/dialog/" + npcId + "/" + rest + ".ogg";
}
// Auto-save
private void scheduleSave() {

View File

@@ -0,0 +1,424 @@
package de.blight.editor.ui;
import de.blight.common.BlightHome;
import de.blight.common.MonologueIO;
import de.blight.common.model.*;
import de.blight.editor.ProjectRoot;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.DirectoryChooser;
import javafx.stage.Modality;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
/**
* Generiert TTS-Platzhalter-Audio via Piper für alle Dialog- oder Monolog-Zeilen.
*
* <ul>
* <li>NPC-Modus: NPC-Zeilen des aktuell geladenen NPCs</li>
* <li>Held-Modus: alle Held-Zeilen aus allen NPCs + Monologe</li>
* </ul>
*
* Ausgabe: OGG (wenn ffmpeg verfügbar) oder WAV als Fallback.
* Pfade werden automatisch in {@code audio_de.properties} eingetragen.
*/
public class TtsGeneratorDialog extends Dialog<Void> {
private static final Path PREFS_FILE = BlightHome.resolve("config", "editor.prefs");
private final String npcId;
private final Map<String, DialogOption> npcOptions;
private TextField piperField;
private TextField modelsDirField;
private ComboBox<String> modelCombo; // zeigt Dateinamen, Wert = voller Pfad
private CheckBox overwriteCheck;
private TextArea logArea;
private Button generateBtn;
private ProgressBar progressBar;
public TtsGeneratorDialog(String npcId, Map<String, DialogOption> npcOptions) {
this.npcId = npcId != null ? npcId : "";
this.npcOptions = npcOptions != null ? new LinkedHashMap<>(npcOptions) : Map.of();
setTitle("TTS-Platzhalter generieren (Piper)");
initModality(Modality.APPLICATION_MODAL);
initOwner(Dialogs.primaryWindow());
setResizable(true);
buildUI();
loadPrefs();
getDialogPane().getButtonTypes().add(ButtonType.CLOSE);
setResultConverter(bt -> null);
}
// ── UI ────────────────────────────────────────────────────────────────────
private void buildUI() {
// Piper binary
piperField = new TextField();
piperField.setPromptText("/pfad/zur/piper-binary oder piper");
// Models directory + rescan
modelsDirField = new TextField();
modelsDirField.setPromptText("/pfad/zu/models/");
Button dirBrowseBtn = new Button("...");
dirBrowseBtn.setOnAction(e -> browseModelsDir());
Button rescanBtn = new Button("");
rescanBtn.setTooltip(new Tooltip("Modelle neu einlesen"));
rescanBtn.setOnAction(e -> refreshModels());
HBox dirRow = new HBox(4, modelsDirField, dirBrowseBtn, rescanBtn);
HBox.setHgrow(modelsDirField, Priority.ALWAYS);
// Model chooser
modelCombo = new ComboBox<>();
modelCombo.setMaxWidth(Double.MAX_VALUE);
modelCombo.setPromptText("Modell wählen...");
overwriteCheck = new CheckBox("Vorhandene Dateien überschreiben");
progressBar = new ProgressBar(0);
progressBar.setMaxWidth(Double.MAX_VALUE);
logArea = new TextArea();
logArea.setEditable(false);
logArea.setPrefHeight(200);
logArea.setStyle("-fx-control-inner-background: #1a1a2a; -fx-text-fill: #ccc;"
+ " -fx-font-family: monospace; -fx-font-size: 11;");
generateBtn = new Button("Generieren");
generateBtn.setStyle("-fx-background-color: #3a7a3a; -fx-text-fill: white;");
generateBtn.setMaxWidth(Double.MAX_VALUE);
generateBtn.setOnAction(e -> startGeneration());
VBox content = new VBox(8);
content.setPadding(new Insets(16));
content.setPrefWidth(580);
content.getChildren().addAll(
row("Piper-Binary:", piperField),
row("Modell-Verzeichnis:", dirRow),
row("Stimme:", modelCombo),
new Separator(),
overwriteCheck,
new Separator(),
generateBtn,
progressBar,
logArea
);
getDialogPane().setContent(content);
piperField.focusedProperty().addListener((obs, o, n) -> { if (!n) savePrefs(); });
modelsDirField.focusedProperty().addListener((obs, o, n) -> { if (!n) { refreshModels(); savePrefs(); } });
}
private void browseModelsDir() {
DirectoryChooser dc = new DirectoryChooser();
dc.setTitle("Piper-Modell-Verzeichnis wählen");
String cur = modelsDirField.getText().trim();
if (!cur.isBlank()) {
File dir = new File(cur);
if (dir.isDirectory()) dc.setInitialDirectory(dir);
}
File chosen = dc.showDialog(getDialogPane().getScene().getWindow());
if (chosen != null) {
modelsDirField.setText(chosen.getAbsolutePath());
refreshModels();
savePrefs();
}
}
/** Scannt das Modell-Verzeichnis nach .onnx-Dateien und befüllt die ComboBox. */
private void refreshModels() {
String dirStr = modelsDirField.getText().trim();
if (dirStr.isBlank()) return;
Path dir = Path.of(dirStr);
if (!Files.isDirectory(dir)) return;
String prevSelection = modelCombo.getValue();
modelCombo.getItems().clear();
try (Stream<Path> s = Files.list(dir)) {
s.filter(p -> p.getFileName().toString().endsWith(".onnx")
&& !p.getFileName().toString().endsWith(".onnx.1"))
.sorted()
.map(Path::toAbsolutePath)
.map(Path::toString)
.forEach(modelCombo.getItems()::add);
} catch (IOException ignored) {}
// Zeige nur Dateinamen, interner Wert bleibt voller Pfad
modelCombo.setButtonCell(new ModelCell());
modelCombo.setCellFactory(lv -> new ModelCell());
if (prevSelection != null && modelCombo.getItems().contains(prevSelection)) {
modelCombo.setValue(prevSelection);
} else if (!modelCombo.getItems().isEmpty()) {
modelCombo.setValue(modelCombo.getItems().get(0));
}
}
private static class ModelCell extends ListCell<String> {
@Override
protected void updateItem(String fullPath, boolean empty) {
super.updateItem(fullPath, empty);
if (empty || fullPath == null) { setText(null); return; }
setText(Path.of(fullPath).getFileName().toString()
.replaceAll("\\.onnx$", ""));
}
}
// ── Generierung ───────────────────────────────────────────────────────────
private void startGeneration() {
String piperCmd = piperField.getText().trim();
String modelPath = modelCombo.getValue();
boolean heroMode = this.npcId.isBlank();
boolean overwrite = overwriteCheck.isSelected();
if (piperCmd.isBlank()) { appendLog("FEHLER: Piper-Binary nicht angegeben."); return; }
if (modelPath == null || modelPath.isBlank()) { appendLog("FEHLER: Kein Modell gewählt."); return; }
if (!new File(modelPath).exists()) { appendLog("FEHLER: Modell nicht gefunden:\n " + modelPath); return; }
generateBtn.setDisable(true);
logArea.clear();
progressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS);
final String piperFinal = piperCmd, modelFinal = modelPath;
Task<Void> task = new Task<>() {
@Override protected Void call() {
try {
doGenerate(piperFinal, modelFinal, heroMode, overwrite);
} catch (Exception e) {
Platform.runLater(() -> {
appendLog("FEHLER: " + e.getMessage());
progressBar.setProgress(0);
generateBtn.setDisable(false);
});
}
return null;
}
};
new Thread(task, "tts-generator").start();
}
private void doGenerate(String piperCmd, String modelPath,
boolean heroMode, boolean overwrite) throws IOException {
Map<String, String> texts = loadTexts();
boolean hasFfmpeg = checkFfmpeg();
Platform.runLater(() -> appendLog(hasFfmpeg
? "[i] ffmpeg verfügbar — Ausgabe als OGG."
: "[i] ffmpeg nicht gefunden — Ausgabe als WAV.\n"));
List<String> keys = heroMode ? collectHeroKeys() : collectNpcKeys();
Platform.runLater(() -> appendLog(keys.size() + " Schlüssel zu verarbeiten.\n"));
Path assetsRoot = ProjectRoot.resolve("blight-assets", "src", "main", "resources");
Path langDir = ProjectRoot.resolve("blight-lang", "src", "main", "resources", "lang");
AudioBundle bundle = AudioBundleIO.loadOrEmpty("de", langDir);
Map<String, String> audioEntries = new LinkedHashMap<>(bundle.getEntries());
int total = keys.size(), done = 0;
for (String key : keys) {
String text = texts.getOrDefault(key, "").trim();
if (text.isBlank()) {
Platform.runLater(() -> appendLog("SKIP (kein Text): " + key));
done++;
final int d = done;
Platform.runLater(() -> progressBar.setProgress((double) d / total));
continue;
}
String basePath = audioPathForKey(key);
if (basePath.isBlank()) { done++; continue; }
String relPath = hasFfmpeg ? basePath : basePath.replaceAll("\\.ogg$", ".wav");
Path outPath = assetsRoot.resolve(relPath);
if (!overwrite && Files.exists(outPath)) {
Platform.runLater(() -> appendLog("SKIP (vorhanden): " + relPath));
done++;
final int d = done;
Platform.runLater(() -> progressBar.setProgress((double) d / total));
continue;
}
Files.createDirectories(outPath.getParent());
boolean ok = hasFfmpeg
? generateOgg(piperCmd, modelPath, text, outPath)
: generateWav(piperCmd, modelPath, text, outPath);
if (ok) {
audioEntries.put(key, relPath);
Platform.runLater(() -> appendLog("OK: " + relPath));
} else {
Platform.runLater(() -> appendLog("FEHLER: " + relPath));
}
done++;
final int d = done;
Platform.runLater(() -> progressBar.setProgress((double) d / total));
}
bundle.setEntries(audioEntries);
AudioBundleIO.save(bundle, langDir);
Platform.runLater(() -> {
progressBar.setProgress(1.0);
appendLog("\n--- Fertig! audio_de.properties aktualisiert. ---");
generateBtn.setDisable(false);
});
}
// ── Schlüssel sammeln ─────────────────────────────────────────────────────
private List<String> collectNpcKeys() {
List<String> keys = new ArrayList<>();
for (Map.Entry<String, DialogOption> e : npcOptions.entrySet()) {
String base = "dialog." + npcId + "." + e.getKey();
keys.addAll(DialogEditorView.resolveStepKeys(
e.getValue().getNpcSteps(), e.getValue().getTextNpc(), base, ".textnpc"));
}
return keys;
}
private List<String> collectHeroKeys() {
List<String> keys = new ArrayList<>();
Path charDir = ProjectRoot.resolve("blight-assets", "src", "main", "resources", "character");
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
if (!(gc instanceof NPC npc) || npc.getDialogOptions() == null) continue;
for (Map.Entry<String, DialogOption> e : npc.getDialogOptions().entrySet()) {
String base = "dialog." + npc.getCharacterId() + "." + e.getKey();
keys.addAll(DialogEditorView.resolveStepKeys(
e.getValue().getHeroSteps(), e.getValue().getTextHero(), base, ".textmainchar"));
}
}
for (Monologue m : MonologueIO.load()) {
if (m.getHeroSteps() == null) continue;
m.getHeroSteps().stream()
.filter(s -> s.getText() != null && !s.getText().id().isBlank())
.map(s -> s.getText().id())
.forEach(keys::add);
}
return keys;
}
// ── Piper / ffmpeg ────────────────────────────────────────────────────────
private boolean generateWav(String piperCmd, String modelPath, String text, Path out) {
try {
ProcessBuilder pb = new ProcessBuilder(
piperCmd, "--model", modelPath, "--output_file", out.toString(), "--quiet");
pb.redirectErrorStream(true);
Process p = pb.start();
try (OutputStream os = p.getOutputStream()) {
os.write((text + "\n").getBytes(StandardCharsets.UTF_8));
}
return p.waitFor() == 0;
} catch (Exception e) { return false; }
}
private boolean generateOgg(String piperCmd, String modelPath, String text, Path out) {
Path tmp = out.resolveSibling(out.getFileName() + ".tmp.wav");
try {
if (!generateWav(piperCmd, modelPath, text, tmp)) return false;
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", tmp.toString(),
"-ar", "22050", "-ac", "1", "-q:a", "4",
out.toString(), "-y", "-loglevel", "error");
pb.redirectErrorStream(true);
return pb.start().waitFor() == 0;
} catch (Exception e) { return false; }
finally { try { Files.deleteIfExists(tmp); } catch (Exception ignored) {} }
}
private boolean checkFfmpeg() {
try {
return new ProcessBuilder("ffmpeg", "-version")
.redirectErrorStream(true).start().waitFor() == 0;
} catch (Exception e) { return false; }
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static Map<String, String> loadTexts() {
try {
Path f = ProjectRoot.resolve(
"blight-lang", "src", "main", "resources", "lang", "messages_de.properties");
if (Files.exists(f)) return new LinkedHashMap<>(TextBundleIO.load(f).getEntries());
} catch (Exception ignored) {}
return Map.of();
}
private static String audioPathForKey(String key) {
if (key == null) return "";
if (key.startsWith("dialog.")) {
String w = key.substring("dialog.".length());
int dot = w.indexOf('.');
if (dot < 0) return "";
return "audio/dialog/" + w.substring(0, dot) + "/"
+ w.substring(dot + 1).replace('.', '_') + ".ogg";
}
if (key.startsWith("monologue.")) {
String w = key.substring("monologue.".length());
int dot = w.indexOf('.');
if (dot < 0) return "audio/monologue/" + w + ".ogg";
return "audio/monologue/" + w.substring(0, dot) + "_"
+ w.substring(dot + 1).replace('.', '_') + ".ogg";
}
return "";
}
private void appendLog(String msg) { logArea.appendText(msg + "\n"); }
private void loadPrefs() {
try {
if (!Files.exists(PREFS_FILE)) return;
Properties p = new Properties();
try (InputStream is = Files.newInputStream(PREFS_FILE)) { p.load(is); }
piperField.setText(p.getProperty("piper.path", "piper"));
modelsDirField.setText(p.getProperty("piper.models.dir", ""));
refreshModels();
String savedModel = p.getProperty("piper.model", "");
if (!savedModel.isBlank() && modelCombo.getItems().contains(savedModel)) {
modelCombo.setValue(savedModel);
}
} catch (Exception ignored) {}
}
private void savePrefs() {
try {
Properties p = new Properties();
p.setProperty("piper.path", piperField.getText().trim());
p.setProperty("piper.models.dir", modelsDirField.getText().trim());
String sel = modelCombo.getValue();
if (sel != null) p.setProperty("piper.model", sel);
try (OutputStream os = Files.newOutputStream(PREFS_FILE)) {
p.store(os, "Piper TTS Settings");
}
} catch (Exception ignored) {}
}
private static HBox row(String labelText, javafx.scene.Node ctrl) {
Label lbl = new Label(labelText);
lbl.setMinWidth(140);
HBox.setHgrow(ctrl, Priority.ALWAYS);
HBox box = new HBox(8, lbl, ctrl);
box.setAlignment(Pos.CENTER_LEFT);
return box;
}
private static Label bold(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold;");
return l;
}
}