Character und Dialog System weiter gebaut

This commit is contained in:
2026-07-06 21:06:27 +02:00
parent b05eb1055d
commit 8bcee4491e
40 changed files with 2006 additions and 250 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,28 @@
#Mon Jul 06 19:04:30 CEST 2026
attachedEmitters.count=0
attachedLights.count=0
castShadow=true
category=
cullDistance=120.0
footstepSurface=
interactableOffsetX=0.0
interactableOffsetY=0.5
interactableOffsetZ=0.0
interactableRotY=0.0
interactableType=NONE
lod1Distance=30.0
lod1Path=
lod2Distance=80.0
lod2Path=
name=silas
pivotOffsetY=0.0
placementOffsetY=0.0
randomScaleMax=1.0
randomScaleMin=1.0
receiveShadow=true
scaleX=1.0
scaleY=1.0
scaleZ=1.0
solid=false
tags=
uniformScale=true

View File

@@ -1,54 +1,442 @@
{
"status": "NEUTRAL",
"trader": false,
"currentOptions": [
{
"id": "5a9821c8-f0f3-49c2-a570-9b68e819b46a",
"label": "First Contact",
"requiresChapter": 0,
"requiresStatus": "NEUTRAL",
"textHero": {
"id": "Hi! Ich suche einen Weg von diesem Strand."
},
"textNpc": {
"id": "Woher kommst du?"
},
"nextOptions": [
{
"id": "f78aa61f-8532-4b64-8f0e-c90ff0e92760",
"label": "Test1",
"dialogOptions": {
"begruessung": {
"id": "begruessung",
"requiresChapter": 0,
"textHero": {
"id": "test"
"id": "dialog.silas.begruessung.textmainchar"
},
"textNpc": {
"id": "test"
"id": "dialog.silas.begruessung.textnpc"
},
"nextOptions": [
{
"id": "0114af85-986f-4923-b77e-88529b868c0f",
"label": "test2",
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"antwort_sklave": {
"id": "antwort_sklave",
"requiresChapter": 0,
"textHero": {
"id": "test"
"id": "dialog.silas.antwort_sklave.textmainchar"
},
"textNpc": {
"id": "test"
"id": "dialog.silas.antwort_sklave.textnpc"
},
"abortsQuests": [],
"enablesTrade": false
}
],
"enablesTrade": false,
"onChosenTriggers": []
},
"antwort_gedaechtnislos": {
"id": "antwort_gedaechtnislos",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.antwort_gedaechtnislos.textmainchar"
},
"textNpc": {
"id": "dialog.silas.antwort_gedaechtnislos.textnpc"
},
"abortsQuests": [],
"enablesTrade": false
}
],
"enablesTrade": false,
"onChosenTriggers": []
},
"antwort_gesandter": {
"id": "antwort_gesandter",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.antwort_gesandter.textmainchar"
},
"textNpc": {
"id": "dialog.silas.antwort_gesandter.textnpc"
},
"abortsQuests": [],
"enablesTrade": false
"enablesTrade": false,
"onChosenTriggers": []
},
"wer_bist_du": {
"id": "wer_bist_du",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.wer_bist_du.textmainchar"
},
"textNpc": {
"id": "dialog.silas.wer_bist_du.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"wer_seid_ihr": {
"id": "wer_seid_ihr",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.wer_seid_ihr.textmainchar"
},
"textNpc": {
"id": "dialog.silas.wer_seid_ihr.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"was_leuchten": {
"id": "was_leuchten",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.was_leuchten.textmainchar"
},
"textNpc": {
"id": "dialog.silas.was_leuchten.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"ausruestung": {
"id": "ausruestung",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.ausruestung.textmainchar"
},
"textNpc": {
"id": "dialog.silas.ausruestung.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"gibts_was_rum": {
"id": "gibts_was_rum",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.gibts_was_rum.textmainchar"
},
"textNpc": {
"id": "dialog.silas.gibts_was_rum.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"tipp_rum": {
"id": "tipp_rum",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.tipp_rum.textmainchar"
},
"textNpc": {
"id": "dialog.silas.tipp_rum.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"welchen_weg": {
"id": "welchen_weg",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.welchen_weg.textmainchar"
},
"textNpc": {
"id": "dialog.silas.welchen_weg.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"abschluss_phase1": {
"id": "abschluss_phase1",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.abschluss_phase1.textmainchar"
},
"textNpc": {
"id": "dialog.silas.abschluss_phase1.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"rueckkehr_beobachtet": {
"id": "rueckkehr_beobachtet",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.rueckkehr_beobachtet.textmainchar"
},
"textNpc": {
"id": "dialog.silas.rueckkehr_beobachtet.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"ich_bin_zaeh": {
"id": "ich_bin_zaeh",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.ich_bin_zaeh.textmainchar"
},
"textNpc": {
"id": "dialog.silas.ich_bin_zaeh.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"weg_ins_innere": {
"id": "weg_ins_innere",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.weg_ins_innere.textmainchar"
},
"textNpc": {
"id": "dialog.silas.weg_ins_innere.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"was_ueber_insel": {
"id": "was_ueber_insel",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.was_ueber_insel.textmainchar"
},
"textNpc": {
"id": "dialog.silas.was_ueber_insel.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"info_osten": {
"id": "info_osten",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.info_osten.textmainchar"
},
"textNpc": {
"id": "dialog.silas.info_osten.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"info_westen": {
"id": "info_westen",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.info_westen.textmainchar"
},
"textNpc": {
"id": "dialog.silas.info_westen.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"info_dorf": {
"id": "info_dorf",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.info_dorf.textmainchar"
},
"textNpc": {
"id": "dialog.silas.info_dorf.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"info_pass": {
"id": "info_pass",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.info_pass.textmainchar"
},
"textNpc": {
"id": "dialog.silas.info_pass.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"ich_weiss_genug": {
"id": "ich_weiss_genug",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.ich_weiss_genug.textmainchar"
},
"textNpc": {
"id": "dialog.silas.ich_weiss_genug.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"karte_zeichnen": {
"id": "karte_zeichnen",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.karte_zeichnen.textmainchar"
},
"textNpc": {
"id": "dialog.silas.karte_zeichnen.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"dankeschoen": {
"id": "dankeschoen",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.dankeschoen.textmainchar"
},
"textNpc": {
"id": "dialog.silas.dankeschoen.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"erstaunt_ruhe": {
"id": "erstaunt_ruhe",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.erstaunt_ruhe.textmainchar"
},
"textNpc": {
"id": "dialog.silas.erstaunt_ruhe.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
},
"abschluss_letzte_worte": {
"id": "abschluss_letzte_worte",
"requiresChapter": 0,
"textHero": {
"id": "dialog.silas.abschluss_letzte_worte.textmainchar"
},
"textNpc": {
"id": "dialog.silas.abschluss_letzte_worte.textnpc"
},
"abortsQuests": [],
"enablesTrade": false,
"onChosenTriggers": []
}
},
"dialogRoots": [
"begruessung"
],
"dialogNext": {
"begruessung": [
"antwort_sklave",
"antwort_gedaechtnislos",
"antwort_gesandter"
],
"antwort_sklave": [
"wer_bist_du",
"was_leuchten",
"ausruestung",
"gibts_was_rum",
"welchen_weg"
],
"antwort_gedaechtnislos": [
"wer_bist_du",
"was_leuchten",
"ausruestung",
"tipp_rum",
"welchen_weg"
],
"antwort_gesandter": [
"wer_bist_du",
"was_leuchten",
"ausruestung",
"tipp_rum",
"welchen_weg"
],
"wer_bist_du": [
"wer_seid_ihr"
],
"welchen_weg": [
"abschluss_phase1"
],
"abschluss_phase1": [
"rueckkehr_beobachtet"
],
"rueckkehr_beobachtet": [
"ich_bin_zaeh"
],
"ich_bin_zaeh": [
"weg_ins_innere",
"was_ueber_insel"
],
"was_ueber_insel": [
"info_osten",
"info_westen",
"info_dorf",
"info_pass",
"ich_weiss_genug"
],
"info_pass": [
"karte_zeichnen"
],
"ich_weiss_genug": [
"karte_zeichnen"
],
"karte_zeichnen": [
"dankeschoen"
],
"dankeschoen": [
"erstaunt_ruhe"
],
"erstaunt_ruhe": [
"abschluss_letzte_worte"
]
},
"dialogDisables": {
"antwort_sklave": [
"antwort_gedaechtnislos",
"antwort_gesandter"
],
"antwort_gedaechtnislos": [
"antwort_sklave",
"antwort_gesandter"
],
"antwort_gesandter": [
"antwort_sklave",
"antwort_gedaechtnislos"
],
"ich_weiss_genug": [
"info_osten",
"info_westen",
"info_dorf",
"info_pass"
]
},
"defaultMessages": {},
"routines": [],
"routines": [
{
"name": "chapter0",
"blocks": [
{
"startHour": 0,
"endHour": 24,
"activity": {
"type": "STAND",
"position": {
"x": -17.0,
"y": 1.2,
"z": -1315.6
}
}
}
]
}
],
"characterId": "silas",
"name": {
"id": "silas.name"

View File

@@ -30,5 +30,7 @@ public class SaveGame {
public Set<String> pickedUpItems = new HashSet<>();
/** IDs besiegter Gegner (für zukünftige Implementierung). */
public Set<String> defeatedEnemies = new HashSet<>();
/** NPC-ID → aktuell verfügbare Dialog-Option-IDs. */
public Map<String, List<String>> npcDialogState = new HashMap<>();
}
}

View File

@@ -33,6 +33,7 @@ public final class SaveGameIO {
if (sg.world == null) sg.world = new SaveGame.WorldSave();
if (sg.world.pickedUpItems == null) sg.world.pickedUpItems = new java.util.HashSet<>();
if (sg.world.defeatedEnemies == null) sg.world.defeatedEnemies = new java.util.HashSet<>();
if (sg.world.npcDialogState == null) sg.world.npcDialogState = new java.util.HashMap<>();
return sg;
}

View File

@@ -32,7 +32,9 @@ public final class CharacterIO {
private static final Logger log = LoggerFactory.getLogger(CharacterIO.class);
private static final String EXTENSION = ".character";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Gson GSON = de.blight.common.model.trigger.TriggerIO.registerAdapters(
new GsonBuilder().setPrettyPrinting()
).create();
private CharacterIO() {}

View File

@@ -2,8 +2,8 @@ package de.blight.common.model;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.blight.common.model.trigger.Trigger;
import lombok.Getter;
import lombok.Setter;
@@ -11,8 +11,8 @@ import lombok.Setter;
@Setter
public class DialogOption {
private String id = UUID.randomUUID().toString();
private String label = "";
private String id = "";
private TextReference label;
private int requiresChapter;
private QuestRef requiresQuestOpen;
@@ -24,8 +24,8 @@ public class DialogOption {
private TextReference textNpc;
private AudioReference audioNpc;
private List<DialogOption> nextOptions;
private List<DialogOption> disablesOptions;
private transient List<DialogOption> nextOptions;
private transient List<DialogOption> disablesOptions;
private RequiredItem requiredItem;
private RecievesItem recievesItem;
@@ -36,6 +36,8 @@ public class DialogOption {
private boolean enablesTrade;
private List<Trigger> onChosenTriggers = new ArrayList<>();
public Status getRequiredStatus() {
if (requiresStatus == null) {
return Status.ENEMY;

View File

@@ -48,6 +48,9 @@ public class MainCharacter extends GameCharacter {
if (option.getRecievesItem() != null) {
getInventar().recieveItem(option.getRecievesItem());
}
if (option.getRecievesQuest() != null) {
startQuest(option.getRecievesQuest());
}
if (option.getFulfillsQuest() != null) {
fullfillQuest(option.getFulfillsQuest());
}

View File

@@ -2,8 +2,11 @@ package de.blight.common.model;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,10 +26,24 @@ public class NPC extends GameCharacter implements Interactable {
private List<Item> items;
private List<DialogOption> currentOptions;
// ── Flache Dialog-Struktur (serialisiert) ─────────────────────────────────
/** Nur im Editor relevant: Optionen die nicht mit currentOptions verbunden sind, aber erhalten werden sollen. */
private List<DialogOption> editorOnlyOptions;
/** Alle Dialog-Optionen dieses NPCs, flach nach ID indiziert. */
private Map<String, DialogOption> dialogOptions = new LinkedHashMap<>();
/** IDs der Wurzel-Optionen (am Dialog-Start verfügbar). */
private List<String> dialogRoots = new ArrayList<>();
/** Baum: optionId → IDs der nachfolgenden Optionen. */
private Map<String, List<String>> dialogNext = new LinkedHashMap<>();
/** Baum: optionId → IDs der Optionen, die dadurch deaktiviert werden. */
private Map<String, List<String>> dialogDisables = new LinkedHashMap<>();
// ── Laufzeit-Zustand (transient, nicht serialisiert) ─────────────────────
/** Aktuell verfügbare Optionen wird per {@link #initRuntimeTree} aufgebaut. */
private transient List<DialogOption> currentOptions;
/**
* Standard-Nachrichten je Status, die angezeigt werden wenn keine Dialog-Optionen
@@ -98,12 +115,58 @@ public class NPC extends GameCharacter implements Interactable {
return null;
}
// ── Dialog-Initialisierung ────────────────────────────────────────────────
/**
* Baut den Laufzeit-Dialog-Zustand auf.
*
* @param activeOptionIds die aktuell aktiven Option-IDs (aus dem Spielstand),
* oder {@code null}/{@code empty} → {@link #dialogRoots} werden verwendet.
*/
public void initRuntimeTree(List<String> activeOptionIds) {
if (dialogOptions == null) dialogOptions = new LinkedHashMap<>();
// Transiente nextOptions / disablesOptions je DialogOption setzen
for (Map.Entry<String, List<String>> e : safe(dialogNext).entrySet()) {
DialogOption opt = dialogOptions.get(e.getKey());
if (opt == null) continue;
opt.setNextOptions(e.getValue().stream()
.map(dialogOptions::get).filter(Objects::nonNull)
.collect(Collectors.toList()));
}
for (Map.Entry<String, List<String>> e : safe(dialogDisables).entrySet()) {
DialogOption opt = dialogOptions.get(e.getKey());
if (opt == null) continue;
opt.setDisablesOptions(e.getValue().stream()
.map(dialogOptions::get).filter(Objects::nonNull)
.collect(Collectors.toList()));
}
// currentOptions aus Spielstand oder Wurzeln
List<String> ids = (activeOptionIds != null && !activeOptionIds.isEmpty())
? activeOptionIds : safe(dialogRoots);
this.currentOptions = ids.stream()
.map(dialogOptions::get).filter(Objects::nonNull)
.collect(Collectors.toList());
}
/** Gibt die aktuell aktiven Option-IDs zurück (für Spielstand-Persistenz). */
public List<String> currentOptionIds() {
if (currentOptions == null) return List.of();
return currentOptions.stream()
.map(DialogOption::getId).filter(Objects::nonNull)
.collect(Collectors.toList());
}
private static <K, V> Map<K, V> safe(Map<K, V> m) { return m != null ? m : Map.of(); }
private static <T> List<T> safe(List<T> l) { return l != null ? l : List.of(); }
// ── Dialog-Methoden ──────────────────────────────────────────────────────
public List<DialogOption> getAvailableOptions(MainCharacter character) {
return currentOptions.stream().filter(option ->
option.getRequiresChapter() < character.getChapter() &&
option.getRequiresStatus().requirementFulfilled(status) &&
option.getRequiresChapter() <= character.getChapter() &&
(option.getRequiresStatus() == null || status.requirementFulfilled(option.getRequiresStatus())) &&
(option.getRequiresQuestOpen() == null || character.getOpenQuests().contains(option.getRequiresQuestOpen())) &&
(option.getRequiredItem() == null || character.getInventar().hasItem(option.getRequiredItem())) &&
(option.getRequiresQuestComplete() == null || character.getCompletedQuests().contains(option.getRequiresQuestComplete()))).toList();

View File

@@ -0,0 +1,51 @@
package de.blight.common.model;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.SortedSet;
import java.util.TreeSet;
public final class TextKeyStore {
private static final LinkedHashSet<String> keys = new LinkedHashSet<>();
private static Path keysFile = null;
private TextKeyStore() {}
/** Muss vom Editor beim Start aufgerufen werden. Lädt bestehende Keys aus der Datei. */
public static void init(Path file) {
keysFile = file;
if (Files.exists(file)) {
try {
Files.lines(file, StandardCharsets.UTF_8)
.map(String::trim)
.filter(l -> !l.isBlank())
.forEach(keys::add);
} catch (IOException ignored) {}
}
}
/** Registriert einen Key. Im Spiel-Modus (keysFile == null) kein Schreiben. */
public static void register(String id) {
if (id == null || id.isBlank()) {
return;
}
if (keys.add(id) && keysFile != null) {
save();
}
}
/** Alphabetisch sortierte Kopie aller bekannten Keys. */
public static SortedSet<String> getKeys() {
return new TreeSet<>(keys);
}
private static void save() {
try {
Files.write(keysFile, new TreeSet<>(keys), StandardCharsets.UTF_8);
} catch (IOException ignored) {}
}
}

View File

@@ -1,3 +1,7 @@
package de.blight.common.model;
public record TextReference(String id) {}
public record TextReference(String id) {
public TextReference {
TextKeyStore.register(id);
}
}

View File

@@ -3,6 +3,8 @@ package de.blight.common.model;
/** Weltposition (x, y, z in Metern). y = 0 bedeutet "Terrain-Höhe verwenden". */
public class WorldPoint {
public float x, y, z;
/** Blickrichtung um Y-Achse in Radiant. 0 = Richtung +Z. */
public float yaw = 0f;
public WorldPoint() {}

View File

@@ -35,6 +35,11 @@ public final class TriggerIO {
private TriggerIO() {}
/** Registriert den Trigger-Typ-Adapter an einem GsonBuilder (für andere Module). */
public static GsonBuilder registerAdapters(GsonBuilder builder) {
return builder.registerTypeHierarchyAdapter(Trigger.class, new TriggerAdapter());
}
/** Serialisiert eine Trigger-Liste als kompaktes JSON (kein Zeilenumbruch). */
public static String serializeList(List<Trigger> triggers) {
if (triggers == null || triggers.isEmpty()) return "[]";

View File

@@ -172,6 +172,8 @@ public class EditorApp extends Application {
// Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView;
private de.blight.editor.ui.RoutineEditorView routineEditorView;
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
private javafx.scene.layout.VBox charNpcSection; // sichtbar nur für NPCs
@@ -286,6 +288,7 @@ public class EditorApp extends Application {
private ToggleButton grassBtn;
private ToggleButton grassVertexBtn;
private ToggleButton textureBtn;
private ToggleButton stoneBtn;
private ToggleButton objPlaceBtn;
private ToggleButton objEditBtn;
private ToggleButton lightBtn;
@@ -363,6 +366,10 @@ public class EditorApp extends Application {
Stage splash = buildSplash(splashStatus);
splash.show();
de.blight.common.model.TextKeyStore.init(
de.blight.editor.ProjectRoot.resolve(
"blight-lang", "src", "main", "resources", "lang", "text_keys.txt"));
jfxImage = new WritableImage(INITIAL_VP_W, INITIAL_VP_H);
jmeApp = JmeEditorApp.launch(input, jfxImage, INITIAL_VP_W, INITIAL_VP_H);
@@ -773,6 +780,10 @@ public class EditorApp extends Application {
boolean wasModelEditor = "modeleditor".equals(currentTool);
currentTool = "world";
topBar.getChildren().set(1, worldToolBar);
if (routineJmeSlot != null) {
routineJmeSlot.getChildren().remove(worldViewport);
routineJmeSlot = null;
}
setCenterView(worldViewport);
root.setRight(toolPanel);
if (wasObjEditor && (input.activeLayer == SharedInput.LAYER_OBJECTS
@@ -1144,6 +1155,7 @@ public class EditorApp extends Application {
locationZoneBtn = new ToggleButton("📍 Locations");
playToolBtn = new ToggleButton("🎮 Spielen");
voxelBtn = new ToggleButton("⬡ Voxel");
stoneBtn = new ToggleButton("🪨 Steine");
camOrbitBtn = new ToggleButton("⊙ Orbit");
camFreeBtn = new ToggleButton("✈ FreeFly");
baseBtn.setStyle("-fx-font-weight:bold;");
@@ -1161,6 +1173,7 @@ public class EditorApp extends Application {
locationZoneBtn.setStyle("-fx-font-weight:bold;");
playToolBtn.setStyle("-fx-font-weight:bold;");
voxelBtn.setStyle("-fx-font-weight:bold;");
stoneBtn.setStyle("-fx-font-weight:bold;");
camOrbitBtn.setStyle("-fx-font-weight:bold;");
camFreeBtn.setStyle("-fx-font-weight:bold;");
@@ -1180,6 +1193,7 @@ public class EditorApp extends Application {
locationZoneBtn.setToggleGroup(layerGroup);
playToolBtn.setToggleGroup(layerGroup);
voxelBtn.setToggleGroup(layerGroup);
stoneBtn.setToggleGroup(layerGroup);
baseBtn.setSelected(true);
baseBtn.setOnAction(e -> {
@@ -1254,6 +1268,12 @@ public class EditorApp extends Application {
root.setRight(toolPanel);
showToolParameters(toolPanel, input.activeTool);
});
stoneBtn.setOnAction(e -> {
input.activeLayer = SharedInput.LAYER_STONE;
input.activeTool = input.stoneTool;
root.setRight(toolPanel);
showToolParameters(toolPanel, input.activeTool);
});
ToggleGroup camModeGroup = new ToggleGroup();
camOrbitBtn.setToggleGroup(camModeGroup);
@@ -1279,6 +1299,7 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), soundAreaBtn, areaBtn, locationZoneBtn,
new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn,
new Separator(Orientation.VERTICAL), stoneBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), hint);
@@ -1944,8 +1965,9 @@ public class EditorApp extends Application {
inner.getChildren().add(treeFolderCB);
inner.getChildren().add(refreshFolderBtn);
inner.getChildren().add(randomTreeStatusLabel);
inner.getChildren().add(styledHint("Linksklick → zufälligen Baum platzieren"));
inner.getChildren().add(styledHint("Rechtsklick → neuer Zufallsbaum + Rotation"));
inner.getChildren().add(styledHint("Doppelklick auf Ordner links → Ordner aktivieren"));
inner.getChildren().add(styledHint("Linksklick → zufälliges Modell platzieren"));
inner.getChildren().add(styledHint("Rechtsklick → neues Zufallsmodell + Rotation"));
// ── Modell aus Asset-Baum ─────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Modell"), new Separator());
@@ -2140,11 +2162,16 @@ public class EditorApp extends Application {
} catch (Exception ignored) {}
}
// Aktuell gesetzten Pfad in die Liste aufnehmen, auch wenn er außerhalb von trees/ liegt
String cur = input.treeFolderPath;
if (cur != null && !cur.isEmpty() && !items.contains(cur)) {
items.add(cur);
}
treeFolderCB.getItems().setAll(items);
if (prev != null && items.contains(prev)) {
treeFolderCB.setValue(prev);
} else {
String cur = input.treeFolderPath;
treeFolderCB.setValue(cur != null && items.contains(cur) ? cur : "(keiner)");
}
}
@@ -3965,7 +3992,26 @@ public class EditorApp extends Application {
tree.setOnMouseClicked(e -> {
if (e.getClickCount() != 2) return;
TreeItem<String> sel = tree.getSelectionModel().getSelectedItem();
if (sel == null || isAssetFolder(sel)) return;
if (sel == null) return;
// Ordner-Doppelklick im Models-Baum → Zufalls-Ordner-Platzierung aktivieren
if (isAssetFolder(sel)) {
TreeItem<String> folderCat = getCategoryRoot(sel);
if (folderCat == modelsNode) {
Path folderPath = itemPaths.get(sel);
if (folderPath != null && java.nio.file.Files.isDirectory(folderPath)) {
String relFolder = ASSET_ROOT.relativize(folderPath).toString().replace('\\', '/');
input.treeFolderPath = relFolder;
input.pendingModelPath = null;
input.activeLayer = SharedInput.LAYER_OBJECTS;
if (objPlaceBtn != null) objPlaceBtn.setSelected(true);
root.setRight(buildObjectPlacePanel());
if (multiPlaceCB != null) multiPlaceCB.setSelected(true);
setStatus("Ordner: " + relFolder + " | Linksklick → zufälliges Modell | Rechtsklick → neu würfeln");
}
}
return;
}
TreeItem<String> cat = getCategoryRoot(sel);
Path p = itemPaths.get(sel);
@@ -4385,6 +4431,24 @@ public class EditorApp extends Application {
|| item == audioNode || item == animationsNode || item == itemsNode);
Path dir = itemPaths.get(item);
// Ordner unter Models: Zufällig-Platzieren-Eintrag
if (getCategoryRoot(item) == modelsNode && dir != null
&& java.nio.file.Files.isDirectory(dir)) {
String relFolder = ASSET_ROOT.relativize(dir).toString().replace('\\', '/');
MenuItem placeRandom = new MenuItem("🎲 Zufällig platzieren");
placeRandom.setOnAction(ev -> {
input.treeFolderPath = relFolder;
input.pendingModelPath = null;
input.activeLayer = SharedInput.LAYER_OBJECTS;
if (objPlaceBtn != null) objPlaceBtn.setSelected(true);
openAssetOverlay();
root.setRight(buildObjectPlacePanel());
if (multiPlaceCB != null) multiPlaceCB.setSelected(true);
setStatus("Ordner: " + relFolder + " | Linksklick → zufälliges Modell | Rechtsklick → neu würfeln");
});
ctx.getItems().addAll(new SeparatorMenuItem(), placeRandom);
}
if (item == animationsNode) {
MenuItem importAnim = new MenuItem("⊕ Animation importieren…");
importAnim.setOnAction(ev -> handleAnimationImport(ctx.getOwnerWindow()));
@@ -7031,6 +7095,20 @@ public class EditorApp extends Application {
new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), false, e.isShiftDown()));
objDragPrevX = e.getX(); objDragPrevY = e.getY();
objDragging = true;
} else if (e.getButton() == MouseButton.SECONDARY && !bothDown) {
input.objectClickQueue.offer(
new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), true, false));
}
return;
}
if (input.activeLayer == SharedInput.LAYER_ROUTINE_EDITOR) {
if (e.getButton() == MouseButton.PRIMARY) {
input.routinePointClickQueue.offer(
new SharedInput.RoutinePointClick((float) e.getX(), (float) e.getY()));
} else if (e.getButton() == MouseButton.SECONDARY) {
input.routineYawDragQueue.offer(
new SharedInput.RoutineYawDrag((float) e.getX(), (float) e.getY(), false));
}
return;
}
@@ -7057,7 +7135,14 @@ public class EditorApp extends Application {
return;
}
if (bothDown) {
if (input.activeLayer == SharedInput.LAYER_STONE
&& input.stoneTool.brushRadius.getValue() == 1.0) {
// Single-Stone-Modus: nur Rechtsklick, einmalig ohne Timer
if (e.getButton() == MouseButton.SECONDARY && !bothDown) {
input.stoneEditQueue.offer(
new SharedInput.StoneEdit((float) e.getX(), (float) e.getY(), -1));
}
} else if (bothDown) {
stopEditTimer();
} else if (e.getButton() == MouseButton.PRIMARY) {
if (input.activeLayer == SharedInput.LAYER_PLAY_TOOL) {
@@ -7088,6 +7173,12 @@ public class EditorApp extends Application {
return;
}
if (input.activeLayer == SharedInput.LAYER_ROUTINE_EDITOR && e.isSecondaryButtonDown()) {
input.routineYawDragQueue.offer(
new SharedInput.RoutineYawDrag((float) e.getX(), (float) e.getY(), false));
return;
}
// Play-Tool EDIT: Drag-Events an JME3 weiterleiten
if (input.activeLayer == SharedInput.LAYER_PLAY_TOOL
&& input.playToolMode == SharedInput.PlayToolMode.EDIT
@@ -7108,6 +7199,10 @@ public class EditorApp extends Application {
viewport.setOnMouseReleased(e -> {
objDragging = false;
if (!isObjectMode()) stopEditTimer();
if (input.activeLayer == SharedInput.LAYER_ROUTINE_EDITOR) {
input.routineYawDragQueue.offer(
new SharedInput.RoutineYawDrag((float) e.getX(), (float) e.getY(), true));
}
if (input.activeLayer == SharedInput.LAYER_PLAY_TOOL) {
input.playToolMouseUp = true;
}
@@ -7181,6 +7276,8 @@ public class EditorApp extends Application {
}
case SharedInput.LAYER_VOXEL ->
input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y, action));
case SharedInput.LAYER_STONE ->
input.stoneEditQueue.offer(new SharedInput.StoneEdit((float) x, (float) y, action));
case SharedInput.LAYER_MODEL_INTERACTABLE -> {
if (action > 0)
input.modelInteractableClickQueue.offer(
@@ -9536,7 +9633,29 @@ public class EditorApp extends Application {
topBar.getChildren().set(1, buildCharacterEditorToolBar());
dialogEditorView = new de.blight.editor.ui.DialogEditorView();
dialogEditorView.setDisable(true);
root.setCenter(dialogEditorView);
routineEditorView = new de.blight.editor.ui.RoutineEditorView(input, ASSET_ROOT.resolve("character"));
routineEditorView.setDisable(true);
// worldViewport in den Tagesabläufe-Tab verschieben; centerStack bekommt Platzhalter
routineJmeSlot = new StackPane();
routineJmeSlot.setMinWidth(0);
routineJmeSlot.getChildren().add(worldViewport); // JavaFX entfernt worldViewport aus centerStack
centerStack.getChildren().add(new StackPane()); // Platzhalter, damit setCenterView(set(0,...)) später klappt
javafx.scene.control.SplitPane routineSplit =
new javafx.scene.control.SplitPane(routineEditorView, routineJmeSlot);
routineSplit.setDividerPositions(0.2);
routineSplit.widthProperty().addListener((obs, ov, nv) -> {
if (nv.doubleValue() > 0) routineSplit.setDividerPositions(0.2);
});
javafx.scene.control.TabPane centerTabs = new javafx.scene.control.TabPane();
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)
);
root.setCenter(centerTabs);
root.setRight(buildCharacterEditorPanel());
}
@@ -9963,6 +10082,7 @@ public class EditorApp extends Application {
updateCharActionCombosFromSet();
if (charEditContainer != null) charEditContainer.setDisable(false);
if (dialogEditorView != null) { dialogEditorView.setDisable(false); dialogEditorView.clear(); }
if (routineEditorView != null) { routineEditorView.setDisable(false); routineEditorView.loadNpc(new de.blight.common.model.NPC()); }
if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Neuer Charakter");
}
@@ -10019,6 +10139,11 @@ public class EditorApp extends Application {
if (c instanceof de.blight.common.model.NPC npc) dialogEditorView.loadNpc(npc);
else dialogEditorView.clear();
}
if (routineEditorView != null) {
routineEditorView.setDisable(false);
if (c instanceof de.blight.common.model.NPC npc) routineEditorView.loadNpc(npc);
else routineEditorView.loadNpc(new de.blight.common.model.NPC());
}
if (charEditorStatusLabel != null) charEditorStatusLabel.setText("Geladen: " + id);
charDirty = true;
} catch (Exception e) {
@@ -10051,6 +10176,7 @@ public class EditorApp extends Application {
c.setAnimSetPath(charAnimSetCombo != null ? charAnimSetCombo.getValue() : null);
if (c instanceof de.blight.common.model.NPC npc) {
if (dialogEditorView != null) dialogEditorView.exportToNpc(npc);
if (routineEditorView != null) routineEditorView.exportToNpc(npc);
npc.setFraction(charFractionCombo != null ? charFractionCombo.getValue() : null);
if (charStatusCombo != null && charStatusCombo.getValue() != null) {
try { npc.setStatus(de.blight.common.model.Status.valueOf(charStatusCombo.getValue())); }

View File

@@ -847,6 +847,26 @@ public class SharedInput {
public volatile String routinePickedPoint = null;
public volatile boolean routinePickedChanged = false;
/**
* JFX → JME: Marker-Punkte für die aktive Routine.
* Jeder Eintrag: float[4] = {x, y, z, yaw}. null = Marker löschen.
*/
public volatile java.util.List<float[]> pendingRoutineMarkers = null;
public volatile boolean routineMarkersChanged = false;
// ── Routine-Yaw-Drag ─────────────────────────────────────────────────────
/** JFX → JME: Drag-Event zum Rotieren des aktiven Markers. released=true = Drag beendet. */
public record RoutineYawDrag(float screenX, float screenY, boolean released) {}
public final ConcurrentLinkedQueue<RoutineYawDrag> routineYawDragQueue =
new ConcurrentLinkedQueue<>();
/** JFX → JME: Weltposition (x, z) des aktiven Markers, als Drehpunkt für Yaw-Drag. null = kein aktiver Marker. */
public volatile float[] routineActiveMarkerPos = null;
/** JME → JFX: Neue Yaw-Rotation (Radiant) nach Drag. NaN = kein Ergebnis. */
public volatile float routineYawResult = Float.NaN;
public volatile boolean routineYawChanged = false;
// ── Wegnetz-Editor ────────────────────────────────────────────────────────
/** activeLayer==26 → Wegnetz bearbeiten */
public static final int LAYER_PATH_NETWORK = 26;

View File

@@ -733,11 +733,25 @@ public class EzTreeState extends BaseAppState {
try {
Path baseDir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(subPath);
Files.createDirectories(baseDir);
File out = baseDir.resolve(fileName + ".j3o").toFile();
Path outPath = baseDir.resolve(fileName + ".j3o");
// Controls vor Export entfernen (nicht serialisierbar über BinaryExporter)
while (lodRoot.getNumControls() > 0) lodRoot.removeControl(lodRoot.getControl(0));
BinaryExporter.getInstance().save(lodRoot, out);
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
BinaryExporter.getInstance().save(lodRoot, outPath.toFile());
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
fileName, "trees", "",
1f, 1f, 1f, true,
0f, 0f,
true, // solid
true, true,
1f, 1f,
"", "", 30f, 80f, 120f,
java.util.List.of(), java.util.List.of(),
de.blight.common.model.InteractableType.NONE,
0f, 0.5f, 0f, 0f, "bark");
de.blight.common.ModelMetaIO.save(meta, outPath);
log.info("[EZ-Tree] Gespeichert: {}", outPath.toAbsolutePath());
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + subPath + "/" + fileName + ".j3o";
input.refreshAssets = true;
input.refreshTreeFolders = true;

View File

@@ -9,6 +9,7 @@ import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.editor.SharedInput;
@@ -19,18 +20,24 @@ import java.util.List;
/**
* JME-AppState für den Tagesablauf-Editor.
* Verarbeitet Terrain-Klicks für die Punktauswahl und zeigt
* Waypoint-Marker in der Szene an.
* Waypoint-Marker (Kugel + Richtungspfeil) in der Szene an.
* Rechts-Drag rotiert den aktiven Marker.
*/
public class RoutineMapState extends BaseAppState {
private static final float ARROW_LEN = 1.5f;
private static final float ARROW_SHAFT_R = 0.04f;
private static final float ARROW_TIP_HALF = 0.14f;
private final SharedInput input;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<Geometry> waypointMarkers = new ArrayList<>();
private Material markerMat;
private final List<Node> markerNodes = new ArrayList<>();
private Material sphereMat;
private Material arrowMat;
public RoutineMapState(SharedInput input) {
this.input = input;
@@ -43,8 +50,11 @@ public class RoutineMapState extends BaseAppState {
assets = app.getAssetManager();
rootNode = app.getRootNode();
markerMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
markerMat.setColor("Color", new ColorRGBA(1f, 0.5f, 0f, 1f));
sphereMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
sphereMat.setColor("Color", new ColorRGBA(1f, 0.55f, 0f, 1f));
arrowMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
arrowMat.setColor("Color", new ColorRGBA(1f, 1f, 0.2f, 1f));
}
@Override protected void cleanup(Application app) { clearMarkers(); }
@@ -59,6 +69,21 @@ public class RoutineMapState extends BaseAppState {
while ((click = input.routinePointClickQueue.poll()) != null) {
handleClick(click);
}
SharedInput.RoutineYawDrag drag;
while ((drag = input.routineYawDragQueue.poll()) != null) {
handleYawDrag(drag);
}
if (input.routineMarkersChanged) {
input.routineMarkersChanged = false;
List<float[]> pts = input.pendingRoutineMarkers;
if (pts != null) {
showMarkers(pts);
} else {
clearMarkers();
}
}
}
private void handleClick(SharedInput.RoutinePointClick click) {
@@ -80,27 +105,97 @@ public class RoutineMapState extends BaseAppState {
input.routinePickedChanged = true;
}
/**
* Aktualisiert die Waypoint-Marker in der Szene.
* Wird von JavaFX-Thread via SharedInput-Koordinaten nicht direkt aufgerufen;
* stattdessen liest die View die Koordinaten und ruft diese Methode
* über Platform.runLater nicht auf die Marker werden im JME-Thread platziert.
*
* Format: Liste von "x|y|z" Strings.
*/
public void showMarkers(List<float[]> points) {
clearMarkers();
for (float[] p : points) {
Geometry g = new Geometry("routine_marker", new Sphere(8, 8, 0.6f));
g.setMaterial(markerMat);
g.setLocalTranslation(p[0], p[1] + 0.6f, p[2]);
rootNode.attachChild(g);
waypointMarkers.add(g);
private void handleYawDrag(SharedInput.RoutineYawDrag drag) {
float[] anchor = input.routineActiveMarkerPos;
if (anchor == null || terrain == null) return;
float jmeX = drag.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - drag.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());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f hit = hits.getClosestCollision().getContactPoint();
float dx = hit.x - anchor[0];
float dz = hit.z - anchor[2];
float yaw = FastMath.atan2(dx, dz);
Node nearest = findNearestMarker(anchor[0], anchor[2]);
if (nearest != null) {
nearest.setLocalRotation(new Quaternion().fromAngleAxis(yaw, Vector3f.UNIT_Y));
}
if (drag.released()) {
input.routineYawResult = yaw;
input.routineYawChanged = true;
}
}
private Node findNearestMarker(float x, float z) {
Node best = null;
float bestDist = Float.MAX_VALUE;
for (Node mn : markerNodes) {
Vector3f pos = mn.getLocalTranslation();
float dx = pos.x - x;
float dz = pos.z - z;
float d = dx * dx + dz * dz;
if (d < bestDist) {
bestDist = d;
best = mn;
}
}
return best;
}
public void showMarkers(List<float[]> points) {
clearMarkers();
for (float[] p : points) {
float x = p[0];
float y = p[1];
float z = p[2];
float yaw = p.length > 3 ? p[3] : 0f;
Node mn = buildMarkerNode(x, y, z, yaw);
rootNode.attachChild(mn);
markerNodes.add(mn);
}
}
private Node buildMarkerNode(float x, float y, float z, float yaw) {
Node mn = new Node("routine_marker");
// Sphere
Geometry sphere = new Geometry("sphere", new Sphere(8, 8, 0.4f));
sphere.setMaterial(sphereMat);
sphere.setLocalTranslation(0, 0.4f, 0);
mn.attachChild(sphere);
// Arrow shaft along +Z: Box centered at (0, 0.5, halfLen)
float halfLen = ARROW_LEN / 2f;
Geometry shaft = new Geometry("shaft", new Box(ARROW_SHAFT_R, ARROW_SHAFT_R, halfLen));
shaft.setMaterial(arrowMat);
shaft.setLocalTranslation(0, 0.5f, halfLen);
mn.attachChild(shaft);
// Arrowhead at tip
Geometry tip = new Geometry("tip", new Box(ARROW_TIP_HALF, ARROW_TIP_HALF, ARROW_TIP_HALF));
tip.setMaterial(arrowMat);
tip.setLocalTranslation(0, 0.5f, ARROW_LEN + ARROW_TIP_HALF);
mn.attachChild(tip);
mn.setLocalRotation(new Quaternion().fromAngleAxis(yaw, Vector3f.UNIT_Y));
mn.setLocalTranslation(x, y, z);
return mn;
}
public void clearMarkers() {
for (Geometry g : waypointMarkers) rootNode.detachChild(g);
waypointMarkers.clear();
for (Node mn : markerNodes) {
rootNode.detachChild(mn);
}
markerNodes.clear();
}
}

View File

@@ -68,6 +68,8 @@ public class StoneEditorState extends BaseAppState {
private final Material[] slotMat = new Material[PlacedStoneIO.SLOT_COUNT];
private Material defaultMat;
private Geometry brushIndicator;
private Geometry previewGeom;
private PlacedStone pendingStone;
private final Random random = new Random();
private boolean modified = false;
@@ -94,6 +96,9 @@ public class StoneEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator();
rootNode.attachChild(brushIndicator);
previewGeom = buildPreviewGeom();
rootNode.attachChild(previewGeom);
}
@Override
@@ -101,6 +106,7 @@ public class StoneEditorState extends BaseAppState {
saveIfModified();
rootNode.detachChild(stoneRoot);
rootNode.detachChild(brushIndicator);
rootNode.detachChild(previewGeom);
}
@Override protected void onEnable() { stoneRoot.setCullHint(Spatial.CullHint.Inherit); }
@@ -117,6 +123,7 @@ public class StoneEditorState extends BaseAppState {
}
updateBrushIndicator();
updatePreviewStone();
processEdits();
processTerrainUpdates();
rebuildDirtyChunks();
@@ -146,10 +153,80 @@ public class StoneEditorState extends BaseAppState {
Vector3f hit = raycastTerrain(jx, jy);
if (hit == null) continue;
if (isSingleStoneMode()) {
if (edit.action() < 0) { // Rechtsklick: platzierten Stein + neuen Preview
placePendingStoneAt(hit.x, hit.z);
generatePendingStone();
}
// Linksklick ignoriert in Single-Stone-Modus
} else {
if (edit.action() > 0) addStonesAt(hit.x, hit.z);
else removeStonesAt(hit.x, hit.z);
}
}
}
private boolean isSingleStoneMode() {
return input.stoneTool.brushRadius.getValue() == 1.0;
}
private void generatePendingStone() {
double minR = input.stoneTool.minSize.getValue() / 2.0;
double maxR = input.stoneTool.maxSize.getValue() / 2.0;
if (maxR < minR) { maxR = minR; }
float radius = (float)(minR + random.nextDouble() * (maxR - minR));
float rotY = random.nextFloat() * 360f;
float sinkFrac = 0.2f + random.nextFloat() * 0.3f;
String[] paths = input.stoneTool.texturePaths;
int activeSlots = 0;
for (String p : paths) {
if (p != null && !p.isEmpty()) { activeSlots++; }
}
if (activeSlots == 0) { activeSlots = 1; }
int slot = random.nextInt(activeSlots);
int seed = random.nextInt();
pendingStone = new PlacedStone(0f, 0f, radius, rotY, slot, sinkFrac, seed);
Mesh mesh = buildStoneMesh(radius, seed, 2);
previewGeom.setMesh(mesh);
previewGeom.setLocalRotation(new Quaternion().fromAngles(0f, rotY * FastMath.DEG_TO_RAD, 0f));
}
private void placePendingStoneAt(float wx, float wz) {
if (terrain == null || pendingStone == null) { return; }
float th = terrain.getHeight(new Vector2f(wx, wz));
if (!Float.isFinite(th)) { return; }
PlacedStone stone = new PlacedStone(wx, wz, pendingStone.radius(), pendingStone.rotY(),
pendingStone.textureSlot(), pendingStone.sinkFraction(), pendingStone.noiseSeed());
int ci = chunkIndex(wx, wz);
if (ci >= 0) {
chunkStones[ci].add(stone);
dirtyChunks[ci] = true;
modified = true;
}
}
private void updatePreviewStone() {
if (input.activeLayer != SharedInput.LAYER_STONE || !isSingleStoneMode()
|| input.mouseScreenX < 0) {
previewGeom.setCullHint(Spatial.CullHint.Always);
return;
}
if (pendingStone == null) { generatePendingStone(); }
float jx = input.mouseScreenX * (float) input.viewportScaleX;
float jy = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY;
Vector3f hit = raycastTerrain(jx, jy);
if (hit == null) {
previewGeom.setCullHint(Spatial.CullHint.Always);
return;
}
float yCenter = hit.y + pendingStone.radius() * (1f - 2f * pendingStone.sinkFraction());
previewGeom.setLocalTranslation(hit.x, yCenter, hit.z);
previewGeom.setCullHint(Spatial.CullHint.Inherit);
}
private void addStonesAt(float wx, float wz) {
if (terrain == null) return;
@@ -462,7 +539,8 @@ public class StoneEditorState extends BaseAppState {
// ── Brush-Indicator ───────────────────────────────────────────────────────
private void updateBrushIndicator() {
if (input.activeLayer != SharedInput.LAYER_STONE || input.mouseScreenX < 0) {
if (input.activeLayer != SharedInput.LAYER_STONE || input.mouseScreenX < 0
|| isSingleStoneMode()) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
@@ -510,6 +588,19 @@ public class StoneEditorState extends BaseAppState {
return geo;
}
private Geometry buildPreviewGeom() {
Mesh mesh = buildStoneMesh(0.5f, 0, 2);
Geometry g = new Geometry("stonePreview", mesh);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.75f, 0.82f, 0.95f, 0.5f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
g.setQueueBucket(RenderQueue.Bucket.Transparent);
g.setMaterial(mat);
g.setCullHint(Spatial.CullHint.Always);
return g;
}
// ── Raycast ───────────────────────────────────────────────────────────────
private Vector3f raycastTerrain(float sx, float sy) {

View File

@@ -4,6 +4,8 @@ import de.blight.common.model.*;
import de.blight.common.model.quests.Quest;
import de.blight.common.model.quests.QuestIO;
import de.blight.common.model.QuestRef;
import de.blight.common.model.trigger.*;
import de.blight.common.model.trigger.Trigger;
import de.blight.editor.ProjectRoot;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
@@ -33,6 +35,7 @@ public class DialogEditorView extends BorderPane {
private final Map<String, DialogOption> allOptions = new LinkedHashMap<>();
private final Set<String> rootIds = new LinkedHashSet<>();
private String selectedId = null;
private String currentNpcId = "";
// ── Top-bar ───────────────────────────────────────────────────────────────
@@ -47,15 +50,15 @@ public class DialogEditorView extends BorderPane {
// ── Detail-form fields ────────────────────────────────────────────────────
private TextField labelField;
private Label idLabel;
private TextField idField;
private Label derivedLabelKey;
private Label derivedHeroKey;
private Label derivedNpcKey;
private CheckBox rootCheck;
private Spinner<Integer> chapterSpinner;
private ComboBox<String> statusCombo;
private TextField questOpenField;
private TextField questCompleteField;
private TextField textHeroField;
private TextField textNpcField;
private TextField reqItemIdField;
private Spinner<Integer> reqItemCount;
private TextField recvItemIdField;
@@ -64,6 +67,7 @@ public class DialogEditorView extends BorderPane {
private TextField fulfillsQuestField;
private ListView<String> abortsQuestsView;
private CheckBox enablesTradeCheck;
private ListView<Trigger> triggersView;
private ListView<String> nextOptionsView;
private ListView<String> disablesOptionsView;
@@ -88,18 +92,15 @@ public class DialogEditorView extends BorderPane {
allOptions.clear();
rootIds.clear();
selectedId = null;
if (npc.getCurrentOptions() != null) {
for (DialogOption opt : npc.getCurrentOptions()) {
collectOptions(opt);
if (opt.getId() != null) rootIds.add(opt.getId());
}
}
// Restore orphaned options that were preserved separately
if (npc.getEditorOnlyOptions() != null) {
for (DialogOption opt : npc.getEditorOnlyOptions()) {
collectOptions(opt);
currentNpcId = npc.getCharacterId() != null ? npc.getCharacterId() : "";
if (npc.getDialogOptions() != null) {
allOptions.putAll(npc.getDialogOptions());
}
if (npc.getDialogRoots() != null) {
rootIds.addAll(npc.getDialogRoots());
}
// Transiente nextOptions/disablesOptions für die Editor-Formularfelder aufbauen
npc.initRuntimeTree(npc.getDialogRoots());
normalizeReferences();
refreshOptionList();
clearDetailForm();
@@ -116,33 +117,30 @@ public class DialogEditorView extends BorderPane {
public void exportToNpc(NPC npc) {
saveCurrentForm();
List<DialogOption> roots = new ArrayList<>();
for (String id : rootIds) {
DialogOption opt = allOptions.get(id);
if (opt != null) roots.add(opt);
}
npc.setCurrentOptions(roots.isEmpty() ? null : roots);
// Collect orphaned options (not reachable from any root) and preserve them
Set<String> reachable = new HashSet<>();
for (DialogOption root : roots) collectReachable(root, reachable);
List<DialogOption> orphans = new ArrayList<>();
for (Map.Entry<String, DialogOption> entry : allOptions.entrySet()) {
if (!reachable.contains(entry.getKey())) orphans.add(entry.getValue());
}
npc.setEditorOnlyOptions(orphans.isEmpty() ? null : orphans);
}
// Flache Options-Map
npc.setDialogOptions(new LinkedHashMap<>(allOptions));
npc.setDialogRoots(new ArrayList<>(rootIds));
private void collectReachable(DialogOption opt, Set<String> visited) {
if (opt == null || opt.getId() == null || visited.contains(opt.getId())) return;
visited.add(opt.getId());
if (opt.getNextOptions() != null) {
for (DialogOption next : opt.getNextOptions()) collectReachable(next, visited);
// Baum aus transient nextOptions/disablesOptions der Options-Objekte ableiten
Map<String, List<String>> dialogNext = new LinkedHashMap<>();
Map<String, List<String>> dialogDisables = new LinkedHashMap<>();
for (Map.Entry<String, DialogOption> e : allOptions.entrySet()) {
DialogOption opt = e.getValue();
if (opt.getNextOptions() != null && !opt.getNextOptions().isEmpty()) {
dialogNext.put(e.getKey(), opt.getNextOptions().stream()
.filter(o -> o != null && o.getId() != null)
.map(DialogOption::getId).toList());
}
if (opt.getDisablesOptions() != null) {
for (DialogOption dis : opt.getDisablesOptions()) collectReachable(dis, visited);
if (opt.getDisablesOptions() != null && !opt.getDisablesOptions().isEmpty()) {
dialogDisables.put(e.getKey(), opt.getDisablesOptions().stream()
.filter(o -> o != null && o.getId() != null)
.map(DialogOption::getId).toList());
}
}
npc.setDialogNext(dialogNext);
npc.setDialogDisables(dialogDisables);
}
// ── Top-bar ────────────────────────────────────────────────────────────────
@@ -186,10 +184,7 @@ public class DialogEditorView extends BorderPane {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); setStyle(""); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText((rootIds.contains(id) ? "" : " ") + lbl);
setText((rootIds.contains(id) ? "" : " ") + id);
applyStyle();
}
private void applyStyle() {
@@ -253,17 +248,34 @@ public class DialogEditorView extends BorderPane {
form.setStyle("-fx-background-color: #252535;");
// Identity
labelField = new TextField();
labelField.setPromptText("Bezeichnung (nur im Editor)");
idLabel = new Label("");
idLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #777;");
idField = new TextField();
idField.setPromptText("z. B. begruessung");
derivedLabelKey = derivedKeyLabel("");
derivedHeroKey = derivedKeyLabel("");
derivedNpcKey = derivedKeyLabel("");
idField.textProperty().addListener((obs, oldVal, newVal) -> {
String id = newVal.trim();
if (id.isBlank()) {
derivedLabelKey.setText("");
derivedHeroKey.setText("");
derivedNpcKey.setText("");
} else {
String base = "dialog." + currentNpcId + "." + id;
derivedLabelKey.setText(base + ".description");
derivedHeroKey.setText(base + ".textmainchar");
derivedNpcKey.setText(base + ".textnpc");
}
});
rootCheck = new CheckBox("Root-Option (initial im Dialog sichtbar)");
rootCheck.setStyle("-fx-text-fill: #ccc;");
form.getChildren().addAll(
sectionTitle("Option"), new Separator(),
row("Bezeichnung:", labelField),
row("ID:", idLabel),
row("ID:", idField),
row("Bezeichnung:", derivedLabelKey),
rootCheck, new Separator()
);
@@ -298,16 +310,10 @@ public class DialogEditorView extends BorderPane {
new Separator()
);
// Texte
textHeroField = new TextField();
textHeroField.setPromptText("TextReference-Schlüssel");
textNpcField = new TextField();
textNpcField.setPromptText("TextReference-Schlüssel");
form.getChildren().addAll(
sectionTitle("Texte"),
row("Text Held:", textHeroField),
row("Text NPC:", textNpcField),
row("Text Held:", derivedHeroKey),
row("Text NPC:", derivedNpcKey),
row("Audio Held:", placeholder("(wird später implementiert)")),
row("Audio NPC:", placeholder("(wird später implementiert)")),
new Separator()
@@ -371,6 +377,31 @@ public class DialogEditorView extends BorderPane {
new Separator()
);
// Trigger
triggersView = new ListView<>();
triggersView.setPrefHeight(90);
triggersView.setStyle("-fx-background-color: #1e1e2e; -fx-control-inner-background: #1e1e2e;");
triggersView.setCellFactory(lv -> new ListCell<>() {
@Override protected void updateItem(Trigger t, boolean empty) {
super.updateItem(t, empty);
setText(empty || t == null ? null : triggerLabel(t));
setStyle("-fx-text-fill: #ccc;");
}
});
Button addTriggerBtn = smallBtn("+");
Button delTriggerBtn = smallBtn("");
addTriggerBtn.setOnAction(e -> addTrigger());
delTriggerBtn.setOnAction(e -> {
Trigger sel = triggersView.getSelectionModel().getSelectedItem();
if (sel != null) triggersView.getItems().remove(sel);
});
form.getChildren().addAll(
sectionTitle("Trigger (bei Auswahl):"),
triggersView,
new HBox(4, addTriggerBtn, delTriggerBtn),
new Separator()
);
// Verbindungen: nextOptions
nextOptionsView = buildRefList();
Button addNextBtn = smallBtn("+");
@@ -405,10 +436,7 @@ public class DialogEditorView extends BorderPane {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText(lbl);
setText(id);
setStyle("-fx-text-fill: #cccccc;");
}
});
@@ -428,8 +456,8 @@ public class DialogEditorView extends BorderPane {
DialogOption opt = allOptions.get(id);
if (opt == null) { clearDetailForm(); return; }
idLabel.setText(id);
labelField.setText(opt.getLabel() != null ? opt.getLabel() : "");
idField.setText(id);
// derived keys are updated via idField listener
rootCheck.setSelected(rootIds.contains(id));
chapterSpinner.getValueFactory().setValue(opt.getRequiresChapter());
@@ -441,8 +469,7 @@ public class DialogEditorView extends BorderPane {
questCompleteField.setText(opt.getRequiresQuestComplete() != null
? safe(opt.getRequiresQuestComplete().getQuestId()) : "");
textHeroField.setText(opt.getTextHero() != null ? opt.getTextHero().id() : "");
textNpcField.setText(opt.getTextNpc() != null ? opt.getTextNpc().id() : "");
// text keys are derived from ID and shown via derivedHeroKey / derivedNpcKey labels
if (opt.getRequiredItem() != null && opt.getRequiredItem().getItem() != null) {
reqItemIdField.setText(safe(opt.getRequiredItem().getItem().getItemId()));
@@ -473,6 +500,11 @@ public class DialogEditorView extends BorderPane {
enablesTradeCheck.setSelected(opt.isEnablesTrade());
triggersView.getItems().clear();
if (opt.getOnChosenTriggers() != null) {
triggersView.getItems().setAll(opt.getOnChosenTriggers());
}
nextOptionsView.getItems().clear();
if (opt.getNextOptions() != null)
opt.getNextOptions().stream()
@@ -492,10 +524,34 @@ public class DialogEditorView extends BorderPane {
DialogOption opt = allOptions.get(id);
if (opt == null) return;
opt.setLabel(labelField.getText());
String newId = idField.getText().trim();
if (!newId.isBlank() && !newId.equals(id) && !allOptions.containsKey(newId)) {
allOptions.remove(id);
opt.setId(newId);
allOptions.put(newId, opt);
int listIdx = optionListView.getItems().indexOf(id);
if (listIdx >= 0) {
optionListView.getItems().set(listIdx, newId);
}
if (rootIds.remove(id)) {
rootIds.add(newId);
}
id = newId;
}
if (rootCheck.isSelected()) rootIds.add(id);
else rootIds.remove(id);
String currentId = opt.getId();
if (!currentId.isBlank()) {
String base = "dialog." + currentNpcId + "." + currentId;
opt.setLabel(new TextReference(base + ".description"));
opt.setTextHero(new TextReference(base + ".textmainchar"));
opt.setTextNpc(new TextReference(base + ".textnpc"));
}
if (rootCheck.isSelected()) {
rootIds.add(id);
} else {
rootIds.remove(id);
}
opt.setRequiresChapter(chapterSpinner.getValue());
@@ -510,9 +566,6 @@ public class DialogEditorView extends BorderPane {
opt.setRequiresQuestOpen(questFromField(questOpenField));
opt.setRequiresQuestComplete(questFromField(questCompleteField));
opt.setTextHero(refFromField(textHeroField));
opt.setTextNpc(refFromField(textNpcField));
opt.setRequiredItem(requiredItemFromFields(reqItemIdField, reqItemCount));
opt.setRecievesItem(recievesItemFromFields(recvItemIdField, recvItemCount));
@@ -526,6 +579,7 @@ public class DialogEditorView extends BorderPane {
opt.setAbortsQuests(aborts);
opt.setEnablesTrade(enablesTradeCheck.isSelected());
opt.setOnChosenTriggers(new ArrayList<>(triggersView.getItems()));
opt.setNextOptions(resolveRefs(nextOptionsView.getItems()));
opt.setDisablesOptions(resolveRefs(disablesOptionsView.getItems()));
@@ -538,15 +592,15 @@ public class DialogEditorView extends BorderPane {
}
private void clearDetailForm() {
if (idLabel != null) idLabel.setText("");
if (labelField != null) labelField.clear();
if (idField != null) idField.clear();
if (derivedLabelKey != null) derivedLabelKey.setText("");
if (derivedHeroKey != null) derivedHeroKey.setText("");
if (derivedNpcKey != null) derivedNpcKey.setText("");
if (rootCheck != null) rootCheck.setSelected(false);
if (chapterSpinner != null) chapterSpinner.getValueFactory().setValue(0);
if (statusCombo != null) statusCombo.setValue("— (keine)");
if (questOpenField != null) questOpenField.clear();
if (questCompleteField != null) questCompleteField.clear();
if (textHeroField != null) textHeroField.clear();
if (textNpcField != null) textNpcField.clear();
if (reqItemIdField != null) reqItemIdField.clear();
if (reqItemCount != null) reqItemCount.getValueFactory().setValue(1);
if (recvItemIdField != null) recvItemIdField.clear();
@@ -555,6 +609,7 @@ public class DialogEditorView extends BorderPane {
if (fulfillsQuestField != null) fulfillsQuestField.clear();
if (abortsQuestsView != null) abortsQuestsView.getItems().clear();
if (enablesTradeCheck != null) enablesTradeCheck.setSelected(false);
if (triggersView != null) triggersView.getItems().clear();
if (nextOptionsView != null) nextOptionsView.getItems().clear();
if (disablesOptionsView != null) disablesOptionsView.getItems().clear();
}
@@ -571,17 +626,37 @@ public class DialogEditorView extends BorderPane {
private void createOption(boolean asRoot) {
saveCurrentForm();
TextInputDialog dlg = new TextInputDialog();
dlg.setTitle("Dialog-Option erstellen");
dlg.setHeaderText("ID für die neue Dialog-Option:");
dlg.setContentText("ID (z. B. begruessung):");
Optional<String> result = dlg.showAndWait();
if (result.isEmpty()) {
return;
}
String id = result.get().trim();
if (id.isBlank()) {
new Alert(Alert.AlertType.WARNING, "ID darf nicht leer sein.", ButtonType.OK).showAndWait();
return;
}
if (allOptions.containsKey(id)) {
new Alert(Alert.AlertType.WARNING, "ID '" + id + "' bereits vorhanden.", ButtonType.OK).showAndWait();
return;
}
DialogOption opt = new DialogOption();
opt.setLabel("Neue Option");
allOptions.put(opt.getId(), opt);
opt.setId(id);
String base = "dialog." + currentNpcId + "." + id;
opt.setLabel(new TextReference(base + ".description"));
opt.setTextHero(new TextReference(base + ".textmainchar"));
opt.setTextNpc(new TextReference(base + ".textnpc"));
allOptions.put(id, opt);
if (asRoot) {
rootIds.add(opt.getId());
rootIds.add(id);
} else if (selectedId != null) {
// Auto-link to currently selected option so no orphans are created
nextOptionsView.getItems().add(opt.getId());
nextOptionsView.getItems().add(id);
}
refreshOptionList();
optionListView.getSelectionModel().select(opt.getId());
optionListView.getSelectionModel().select(id);
}
private void toggleRoot() {
@@ -616,10 +691,7 @@ public class DialogEditorView extends BorderPane {
@Override protected void updateItem(String id, boolean empty) {
super.updateItem(id, empty);
if (empty || id == null) { setText(null); return; }
DialogOption opt = allOptions.get(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
setText((rootIds.contains(id) ? "" : " ") + lbl);
setText((rootIds.contains(id) ? "" : " ") + id);
}
});
chooser.getItems().addAll(allOptions.keySet());
@@ -778,10 +850,8 @@ public class DialogEditorView extends BorderPane {
for (Map.Entry<String, double[]> entry : pos.entrySet()) {
String id = entry.getKey();
double[] p = entry.getValue();
DialogOption opt = allOptions.get(id);
boolean isRoot = rootIds.contains(id);
String lbl = (opt != null && opt.getLabel() != null && !opt.getLabel().isBlank())
? opt.getLabel() : id.substring(0, Math.min(8, id.length())) + "";
String lbl = id;
Rectangle rect = new Rectangle(p[0], p[1], nodeW, nodeH);
rect.setArcWidth(8); rect.setArcHeight(8);
@@ -899,6 +969,15 @@ public class DialogEditorView extends BorderPane {
// ── Widget helpers ────────────────────────────────────────────────────────
private static Label derivedKeyLabel(String text) {
Label l = new Label(text);
l.setStyle("-fx-text-fill: #999; -fx-font-family: monospace;"
+ " -fx-background-color: #1a1a2a; -fx-padding: 3 6 3 6;"
+ " -fx-background-radius: 3;");
l.setMaxWidth(Double.MAX_VALUE);
return l;
}
private static Label sectionTitle(String text) {
Label l = new Label(text);
l.setStyle("-fx-font-weight: bold; -fx-font-size: 12; -fx-text-fill: #88aacc;");
@@ -938,4 +1017,132 @@ public class DialogEditorView extends BorderPane {
b.setPrefWidth(28);
return b;
}
// ── Trigger-Hilfsmethoden ─────────────────────────────────────────────────
private static String triggerLabel(Trigger t) {
if (t instanceof NpcStatusTrigger n) {
return "NPC_STATUS " + n.getNpcId() + "" + n.getTargetStatus();
}
if (t instanceof ChangeRoutineTrigger r) {
return "CHANGE_ROUTINE " + r.getNpcId() + "" + r.getRoutineName();
}
if (t instanceof QuestStartTrigger q) {
return "QUEST_START " + (q.getQuest() != null ? q.getQuest().getQuestId() : "?");
}
if (t instanceof FractionStatusTrigger f) {
return "FRACTION_STATUS " + f.getFractionId() + "" + f.getTargetStatus();
}
return t.getClass().getSimpleName();
}
private void addTrigger() {
// Typ-Auswahl
List<String> types = List.of("NPC_STATUS", "CHANGE_ROUTINE", "QUEST_START", "FRACTION_STATUS");
ChoiceDialog<String> typeDlg = new ChoiceDialog<>("NPC_STATUS", types);
typeDlg.setTitle("Trigger hinzufügen");
typeDlg.setHeaderText("Trigger-Typ wählen:");
Optional<String> typeResult = typeDlg.showAndWait();
if (typeResult.isEmpty()) {
return;
}
String type = typeResult.get();
Trigger trigger = buildTriggerDialog(type);
if (trigger != null) {
triggersView.getItems().add(trigger);
}
}
private static Trigger buildTriggerDialog(String type) {
Dialog<Trigger> dlg = new Dialog<>();
dlg.setTitle("Trigger konfigurieren: " + type);
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(8);
grid.setPadding(new Insets(12));
Label chapterLbl = new Label("Min. Kapitel:");
Spinner<Integer> chapterSpinner = new Spinner<>(0, 99, 0);
chapterSpinner.setPrefWidth(70);
grid.add(chapterLbl, 0, 0);
grid.add(chapterSpinner, 1, 0);
TextField npcIdField = new TextField();
TextField routineField = new TextField();
TextField questIdField = new TextField();
TextField fractionField = new TextField();
ComboBox<String> statusBox = new ComboBox<>();
statusBox.getItems().addAll("FRIENDLY", "NEUTRAL", "ENRAGED", "ENEMY");
statusBox.setValue("FRIENDLY");
switch (type) {
case "NPC_STATUS" -> {
grid.add(new Label("NPC-ID:"), 0, 1);
grid.add(npcIdField, 1, 1);
grid.add(new Label("Ziel-Status:"), 0, 2);
grid.add(statusBox, 1, 2);
}
case "CHANGE_ROUTINE" -> {
grid.add(new Label("NPC-ID:"), 0, 1);
grid.add(npcIdField, 1, 1);
grid.add(new Label("Routine-Name:"), 0, 2);
grid.add(routineField, 1, 2);
}
case "QUEST_START" -> {
grid.add(new Label("Quest-ID:"), 0, 1);
grid.add(questIdField, 1, 1);
}
case "FRACTION_STATUS" -> {
grid.add(new Label("Fraction-ID (UUID):"), 0, 1);
grid.add(fractionField, 1, 1);
grid.add(new Label("Ziel-Status:"), 0, 2);
grid.add(statusBox, 1, 2);
}
default -> {}
}
dlg.getDialogPane().setContent(grid);
dlg.setResultConverter(btn -> {
if (btn != ButtonType.OK) { return null; }
int chapter = chapterSpinner.getValue();
return switch (type) {
case "NPC_STATUS" -> {
NpcStatusTrigger t = new NpcStatusTrigger();
t.setRequiresChapter(chapter);
t.setNpcId(npcIdField.getText().trim());
try { t.setTargetStatus(Status.valueOf(statusBox.getValue())); }
catch (IllegalArgumentException ignored) {}
yield t;
}
case "CHANGE_ROUTINE" -> {
ChangeRoutineTrigger t = new ChangeRoutineTrigger();
t.setRequiresChapter(chapter);
t.setNpcId(npcIdField.getText().trim());
t.setRoutineName(routineField.getText().trim());
yield t;
}
case "QUEST_START" -> {
QuestStartTrigger t = new QuestStartTrigger();
t.setRequiresChapter(chapter);
QuestRef ref = new QuestRef();
ref.setQuestId(questIdField.getText().trim());
t.setQuest(ref);
yield t;
}
case "FRACTION_STATUS" -> {
FractionStatusTrigger t = new FractionStatusTrigger();
t.setRequiresChapter(chapter);
try { t.setFractionId(java.util.UUID.fromString(fractionField.getText().trim())); }
catch (IllegalArgumentException ignored) {}
try { t.setTargetStatus(Status.valueOf(statusBox.getValue())); }
catch (IllegalArgumentException ignored) {}
yield t;
}
default -> null;
};
});
return dlg.showAndWait().orElse(null);
}
}

View File

@@ -2,10 +2,12 @@ package de.blight.editor.ui;
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 javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
@@ -16,17 +18,10 @@ import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
/**
* Lokalisierungs-Editor: verwaltet Sprach-Bundles (de.json, en.json …)
* und deren Schlüssel→Text-Einträge.
*
* Speicherort: {@code ASSET_ROOT/localization/<lang>.json}
*/
public class LocalizationEditorView extends BorderPane {
private final Path locDir;
// Sprach-Auswahl
private final ComboBox<String> langCombo = new ComboBox<>();
private final ObservableList<String[]> tableData = FXCollections.observableArrayList();
private final TableView<String[]> table = new TableView<>(tableData);
@@ -41,7 +36,6 @@ public class LocalizationEditorView extends BorderPane {
}
private void buildUI() {
// ── Kopfzeile ─────────────────────────────────────────────────────────
Label langLbl = new Label("Sprache:");
langLbl.setStyle("-fx-text-fill: #aaa;");
langCombo.setPrefWidth(120);
@@ -59,20 +53,22 @@ public class LocalizationEditorView extends BorderPane {
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,
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;");
// ── Tabelle ───────────────────────────────────────────────────────────
table.setEditable(true);
table.setStyle("-fx-background-color: #1a1a2a; -fx-control-inner-background: #1a1a2a;");
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
@@ -112,18 +108,21 @@ public class LocalizationEditorView extends BorderPane {
private void refreshLangList() {
String selected = langCombo.getValue();
langCombo.getItems().setAll(TextBundleIO.availableLanguages(locDir));
if (selected != null && langCombo.getItems().contains(selected))
if (selected != null && langCombo.getItems().contains(selected)) {
langCombo.setValue(selected);
else if (!langCombo.getItems().isEmpty())
} else if (!langCombo.getItems().isEmpty()) {
langCombo.setValue(langCombo.getItems().get(0));
else
} else {
tableData.clear();
}
}
private void loadLanguage(String lang) {
if (lang == null) return;
if (lang == null) {
return;
}
try {
currentBundle = TextBundleIO.load(locDir.resolve(lang + ".json"));
currentBundle = TextBundleIO.load(locDir.resolve("messages_" + lang + ".properties"));
} catch (IOException e) {
currentBundle = new TextBundle(lang);
}
@@ -139,7 +138,9 @@ public class LocalizationEditorView extends BorderPane {
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);
@@ -153,7 +154,9 @@ 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);
confirm.showAndWait().ifPresent(bt -> {
@@ -173,14 +176,60 @@ public class LocalizationEditorView extends BorderPane {
// ── Eintrags-Verwaltung ───────────────────────────────────────────────────
private void addKey() {
tableData.add(new String[]{"neuer.schluessel", ""});
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);
return;
}
Dialog<String> dlg = new Dialog<>();
dlg.setTitle("Schlüssel wählen");
dlg.setHeaderText("Noch nicht übersetzte Schlüssel:");
dlg.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
ObservableList<String> items = FXCollections.observableArrayList(available);
FilteredList<String> filtered = new FilteredList<>(items, s -> true);
ListView<String> listView = new ListView<>(filtered);
listView.setPrefHeight(300);
listView.getSelectionModel().selectFirst();
TextField filterField = new TextField();
filterField.setPromptText("Filtern…");
filterField.textProperty().addListener((obs, o, n) ->
filtered.setPredicate(s -> n.isBlank() || s.contains(n)));
dlg.getDialogPane().setContent(new VBox(6, filterField, listView));
dlg.setResultConverter(bt ->
bt == ButtonType.OK ? listView.getSelectionModel().getSelectedItem() : null);
dlg.showAndWait()
.filter(k -> k != null && !k.isBlank())
.ifPresent(this::insertKey);
}
private void insertKey(String key) {
tableData.add(new String[]{key, ""});
table.scrollTo(tableData.size() - 1);
table.getSelectionModel().select(tableData.size() - 1);
}
private void deleteKey() {
String[] sel = table.getSelectionModel().getSelectedItem();
if (sel != null) tableData.remove(sel);
if (sel != null) {
tableData.remove(sel);
}
}
private void saveCurrent() {
@@ -190,7 +239,9 @@ public class LocalizationEditorView extends BorderPane {
}
Map<String, String> entries = new LinkedHashMap<>();
for (String[] row : tableData) {
if (!row[0].isBlank()) entries.put(row[0].trim(), row[1]);
if (!row[0].isBlank()) {
entries.put(row[0].trim(), row[1]);
}
}
currentBundle.setEntries(entries);
try {

View File

@@ -56,15 +56,21 @@ public class RoutineEditorView extends VBox {
// Dynamische Felder je Aktivitätstyp
private Label pointLabel; // zeigt gepickten Punkt
private Label yawLabel; // zeigt aktuellen Yaw-Winkel
private ComboBox<String> interactableCombo; // UUID-basiert (PlacedItems)
private ComboBox<String> npcCombo; // für TALK
private ListView<String> waypointList; // für PATROL
private RadioButton rbPoint, rbInteractable; // für SIT
/** Aktueller Yaw-Winkel (Radiant) für den aktiven Standpunkt. */
private float activePointYaw = 0f;
// Geladene Hilfsdaten
private final List<PlacedItem> placedItems = new ArrayList<>();
private final List<String> npcIds = new ArrayList<>();
private boolean updatingForm = false;
// ── Polling-Timer ──────────────────────────────────────────────────────────
private final AnimationTimer pollTimer = new AnimationTimer() {
@@ -87,6 +93,16 @@ public class RoutineEditorView extends VBox {
}
}
}
if (input.routineYawChanged) {
input.routineYawChanged = false;
float yaw = input.routineYawResult;
if (!Float.isNaN(yaw)) {
activePointYaw = yaw;
updateYawLabel();
applyActivityToBlock();
updateMarkers();
}
}
}
};
@@ -143,6 +159,7 @@ public class RoutineEditorView extends VBox {
int idx = nv.intValue();
activeRoutine = (idx >= 0 && idx < routines.size()) ? routines.get(idx) : null;
refreshBlockList();
updateMarkers();
});
Button addR = new Button("+");
@@ -170,7 +187,7 @@ public class RoutineEditorView extends VBox {
blockList = new ListView<>();
blockList.setPrefHeight(110);
blockList.getSelectionModel().selectedIndexProperty().addListener((obs, ov, nv) -> {
if (activeRoutine == null) return;
if (updatingForm || activeRoutine == null) return;
int idx = nv.intValue();
activeBlock = (idx >= 0 && idx < activeRoutine.getBlocks().size())
? activeRoutine.getBlocks().get(idx) : null;
@@ -216,7 +233,7 @@ public class RoutineEditorView extends VBox {
typeCombo.getItems().addAll(
"Sitzen", "Stehen", "Reden", "Patrullieren", "Arbeiten", "Schlafen");
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> refreshDynamicFields());
typeCombo.setOnAction(e -> { if (!updatingForm) refreshDynamicFields(); });
form.getChildren().addAll(timeRow, typeCombo);
return form;
@@ -248,11 +265,13 @@ public class RoutineEditorView extends VBox {
rbPoint.setSelected(true);
pointLabel = pointLabel();
yawLabel = yawLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
interactableCombo = itemCombo();
VBox pointRow = new VBox(2, pointLabel, pickBtn);
VBox pointRow = new VBox(2, pointLabel, pickBtn, yawLabel,
styledLabel("Rechts-Drag im Viewport → Richtung drehen", false));
VBox itemRow = new VBox(2, interactableCombo);
itemRow.setManaged(false); itemRow.setVisible(false);
@@ -260,6 +279,9 @@ public class RoutineEditorView extends VBox {
boolean useItem = rbInteractable.isSelected();
pointRow.setManaged(!useItem); pointRow.setVisible(!useItem);
itemRow.setManaged(useItem); itemRow.setVisible(useItem);
if (useItem) {
input.routineActiveMarkerPos = null;
}
applyActivityToBlock();
});
@@ -271,21 +293,34 @@ public class RoutineEditorView extends VBox {
rbInteractable.setSelected(true);
selectItemCombo(interactableCombo, act.getObjectUuid());
} else if (act.getPosition() != null) {
setPointLabel(pointLabel, act.getPosition());
WorldPoint p = act.getPosition();
setPointLabel(pointLabel, p);
activePointYaw = p.yaw;
updateYawLabel();
updateActiveMarkerPos();
}
}
}
private void buildStandFields() {
pointLabel = pointLabel();
yawLabel = yawLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
dynamicBox.getChildren().addAll(styledLabel("Standpunkt:", false), pointLabel, pickBtn);
if (activeBlock.getActivity() != null && activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
dynamicBox.getChildren().addAll(
styledLabel("Standpunkt:", false), pointLabel, pickBtn,
yawLabel, styledLabel("Rechts-Drag im Viewport → Richtung drehen", false));
if (activeBlock.getActivity() != null && activeBlock.getActivity().getPosition() != null) {
WorldPoint p = activeBlock.getActivity().getPosition();
setPointLabel(pointLabel, p);
activePointYaw = p.yaw;
updateYawLabel();
updateActiveMarkerPos();
}
}
private void buildTalkFields() {
pointLabel = pointLabel();
yawLabel = yawLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
npcCombo = new ComboBox<>();
npcCombo.getItems().addAll(npcIds);
@@ -293,11 +328,18 @@ public class RoutineEditorView extends VBox {
npcCombo.setPromptText("NPC wählen…");
npcCombo.setOnAction(e -> applyActivityToBlock());
dynamicBox.getChildren().addAll(styledLabel("Gesprächspunkt:", false), pointLabel, pickBtn,
dynamicBox.getChildren().addAll(
styledLabel("Gesprächspunkt:", false), pointLabel, pickBtn,
yawLabel, styledLabel("Rechts-Drag im Viewport → Richtung drehen", false),
styledLabel("Gesprächspartner:", false), npcCombo);
if (activeBlock.getActivity() != null) {
if (activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
if (activeBlock.getActivity().getPosition() != null) {
WorldPoint p = activeBlock.getActivity().getPosition();
setPointLabel(pointLabel, p);
activePointYaw = p.yaw;
updateYawLabel();
updateActiveMarkerPos();
}
if (activeBlock.getActivity().getTalkNpcId() != null)
npcCombo.setValue(activeBlock.getActivity().getTalkNpcId());
}
@@ -315,6 +357,7 @@ public class RoutineEditorView extends VBox {
activeBlock.setActivity(RoutineActivity.patrol(new ArrayList<>()));
activeBlock.getActivity().getWaypoints().add(wp);
refreshWaypointList();
updateMarkers();
}));
delWp.setOnAction(e -> {
@@ -322,6 +365,7 @@ public class RoutineEditorView extends VBox {
List<WorldPoint> wps = activeBlock.getActivity().getWaypoints();
wps.remove(wps.size() - 1);
refreshWaypointList();
updateMarkers();
}
});
@@ -352,6 +396,7 @@ public class RoutineEditorView extends VBox {
btn.setOnAction(e -> startPick(xyz -> {
callback.accept(xyz);
applyActivityToBlock();
updateMarkers();
}));
return btn;
}
@@ -359,10 +404,9 @@ public class RoutineEditorView extends VBox {
// ── Block-Daten pflegen ────────────────────────────────────────────────────
private void applyBlockTimes() {
if (activeBlock == null) return;
if (activeBlock == null || updatingForm) return;
activeBlock.setStartHour(startSpin.getValue());
activeBlock.setEndHour(endSpin.getValue());
refreshBlockList();
refreshCoverage();
}
@@ -376,17 +420,29 @@ public class RoutineEditorView extends VBox {
activeBlock.setActivity(RoutineActivity.sitInteractable(uuid, lbl));
} else {
WorldPoint pt = parsedPoint(pointLabel);
if (pt != null) {
pt.yaw = activePointYaw;
}
activeBlock.setActivity(pt != null ? RoutineActivity.sit(pt) : new RoutineActivity());
updateActiveMarkerPos();
}
}
case "Stehen" -> {
WorldPoint pt = parsedPoint(pointLabel);
if (pt != null) {
pt.yaw = activePointYaw;
}
activeBlock.setActivity(pt != null ? RoutineActivity.stand(pt) : new RoutineActivity());
updateActiveMarkerPos();
}
case "Reden" -> {
WorldPoint pt = parsedPoint(pointLabel);
if (pt != null) {
pt.yaw = activePointYaw;
}
String talkNpc = npcCombo != null ? npcCombo.getValue() : null;
activeBlock.setActivity(RoutineActivity.talk(pt, talkNpc));
updateActiveMarkerPos();
}
case "Arbeiten" -> {
String uuid = selectedItemUuid(interactableCombo);
@@ -400,7 +456,6 @@ public class RoutineEditorView extends VBox {
}
// PATROL is managed directly in buildPatrolFields
}
refreshBlockList();
}
// ── Routine-Operationen ────────────────────────────────────────────────────
@@ -505,10 +560,19 @@ public class RoutineEditorView extends VBox {
dynamicBox.getChildren().clear();
blockStatusLabel.setText(hasBlock ? "" : (activeRoutine != null ? "— Block wählen —" : "— Ablauf wählen —"));
if (!hasBlock) return;
if (!hasBlock) {
activePointYaw = 0f;
yawLabel = null;
input.routineActiveMarkerPos = null;
return;
}
updatingForm = true;
startSpin.getValueFactory().setValue(activeBlock.getStartHour());
endSpin .getValueFactory().setValue(activeBlock.getEndHour());
typeCombo.setValue(activityTypeLabel(activeBlock.getActivity()));
updatingForm = false;
activePointYaw = 0f;
yawLabel = null;
refreshDynamicFields();
}
@@ -551,12 +615,54 @@ public class RoutineEditorView extends VBox {
npc.setRoutines(new ArrayList<>(routines));
}
private void updateMarkers() {
if (activeRoutine == null) {
input.pendingRoutineMarkers = null;
input.routineMarkersChanged = true;
return;
}
List<float[]> pts = new ArrayList<>();
for (RoutineBlock b : activeRoutine.getBlocks()) {
RoutineActivity act = b.getActivity();
if (act == null) continue;
if (act.getPosition() != null) {
WorldPoint p = act.getPosition();
pts.add(new float[]{p.x, p.y, p.z, p.yaw});
}
if (act.getWaypoints() != null) {
for (WorldPoint wp : act.getWaypoints())
pts.add(new float[]{wp.x, wp.y, wp.z, wp.yaw});
}
}
input.pendingRoutineMarkers = pts;
input.routineMarkersChanged = true;
}
private void updateActiveMarkerPos() {
if (activeBlock != null
&& activeBlock.getActivity() != null
&& activeBlock.getActivity().getPosition() != null) {
WorldPoint p = activeBlock.getActivity().getPosition();
input.routineActiveMarkerPos = new float[]{p.x, p.y, p.z};
} else {
input.routineActiveMarkerPos = null;
}
}
private void updateYawLabel() {
if (yawLabel == null) return;
int deg = (int) Math.round(Math.toDegrees(activePointYaw));
yawLabel.setText("Richtung: " + deg + "°");
yawLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #fd0;");
}
/** Muss aufgerufen werden, wenn die View geschlossen/versteckt wird. */
public void onHide() {
if (pendingPick != null) {
pendingPick = null;
input.activeLayer = savedLayer;
}
input.routineActiveMarkerPos = null;
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
@@ -574,14 +680,20 @@ public class RoutineEditorView extends VBox {
return l;
}
private Label yawLabel() {
Label l = new Label("Richtung: 0°");
l.setStyle("-fx-font-size: 11; -fx-text-fill: #fd0;");
return l;
}
private void setPointLabel(Label lbl, float[] xyz) {
lbl.setText(String.format("(%.1f, %.1f, %.1f)", xyz[0], xyz[1], xyz[2]));
lbl.setText(String.format(java.util.Locale.US, "(%.1f, %.1f, %.1f)", xyz[0], xyz[1], xyz[2]));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}
private void setPointLabel(Label lbl, WorldPoint pt) {
if (pt == null) return;
lbl.setText(String.format("(%.1f, %.1f, %.1f)", pt.x, pt.y, pt.z));
lbl.setText(String.format(java.util.Locale.US, "(%.1f, %.1f, %.1f)", pt.x, pt.y, pt.z));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}

View File

@@ -16,8 +16,10 @@ import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import de.blight.game.console.JmeConsole;
import de.blight.game.scene.WorldScene;
import de.blight.lang.TextResolver;
import javax.imageio.ImageIO;
import java.util.Locale;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
@@ -132,6 +134,7 @@ public class BlightGame extends SimpleApplication {
@Override
public void simpleInitApp() {
TextResolver.init(Locale.GERMAN);
status("Lade Tastenbelegung...");
flyCam.setEnabled(false);
inputManager.deleteMapping(INPUT_MAPPING_EXIT);

View File

@@ -22,7 +22,7 @@ public class ThirdPersonCamera {
private static final float MAX_DISTANCE = BASE_DISTANCE + 3f;
private static final float MIN_VERTICAL_ANGLE = 0.08f; // Kamera immer leicht über Schulter
private static final float MAX_VERTICAL_ANGLE = FastMath.HALF_PI - 0.1f;
private static final float TARGET_HEIGHT = 1.6f;
private static final float TARGET_HEIGHT = 1.0f;
private final Camera cam;
private final InputManager inputManager;

View File

@@ -306,8 +306,9 @@ public class WorldScene extends BaseAppState {
app.getStateManager().attach(
new WorldInteractableState(keyBindings, physicsChar, playerInput));
app.getStateManager().attach(new DialogHudState());
app.getStateManager().attach(
new WorldNpcsState(keyBindings, physicsChar, playerInput, mc));
WorldNpcsState npcsState = new WorldNpcsState(keyBindings, physicsChar, playerInput, mc);
npcsState.setThirdPersonCamera(thirdPersonCam);
app.getStateManager().attach(npcsState);
app.getStateManager().attach(new InteractionHudState());
inventoryState = new InventoryState(mc, keyBindings);
inventoryState.setEnabled(false);
@@ -472,6 +473,23 @@ public class WorldScene extends BaseAppState {
// Das Modell hat den Ursprung an den Füßen → wir brauchen einen -0.9m-Versatz im wrapper-Node.
private static final float CAPSULE_VISUAL_OFFSET_Y = -(0.5f + 0.4f); // -(halfCylHeight + radius)
/**
* Setzt Ambient=(1,1,1,1) auf allen Lighting.j3md-Materialien eines Modells,
* damit es den globalen AmbientLight korrekt empfängt (Blender-Export setzt Ambient oft auf schwarz).
*/
public static void applyFullAmbient(Spatial s) {
if (s instanceof Geometry g) {
com.jme3.material.Material m = g.getMaterial();
if (m != null && "Lighting".equals(m.getMaterialDef().getName())) {
m.setColor("Ambient", ColorRGBA.White.clone());
}
} else if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
applyFullAmbient(child);
}
}
}
/** Lädt das Hauptcharakter-Modell, falls im character/-Verzeichnis definiert; sonst Platzhalter. */
private Node loadOrBuildCharacter() {
MainCharacter mc = findMainCharacter();
@@ -480,6 +498,7 @@ public class WorldScene extends BaseAppState {
Spatial loaded = assetManager.loadModel(mc.getModelPath());
stripEmbeddedClips(loaded, mc.getModelPath());
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
applyFullAmbient(loaded);
// Auf 1.8 m skalieren Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
// bei Skinned-Meshes, die vor der ersten SkinningControl-Runde falsche Bounds liefern)

View File

@@ -257,7 +257,7 @@ public class DayNightState extends BaseAppState implements TimeListener {
// ── Sonnenfarbe: Dämmerung (orange) → Tag (warm-weiß) ──────────────
float dawnFactor = 1f - FastMath.clamp(elev * 5f, 0f, 1f);
ColorRGBA sunColor = SUN_DAWN.clone().interpolateLocal(SUN_DAY, 1f - dawnFactor);
sunBaseColor = sunColor.mult(elevC * 0.85f);
sunBaseColor = sunColor.mult(elevC * 0.68f);
sun.setColor(sunBaseColor.mult(1f - caveFactor));
// ── Sonnen-Sphere ausblenden wenn unter Horizont ────────────────────

View File

@@ -23,6 +23,9 @@ import de.blight.common.model.DialogOption;
import de.blight.common.model.MainCharacter;
import de.blight.common.model.NPC;
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 de.blight.game.config.MenuCanvas;
import de.blight.game.config.NinePatch;
import de.blight.lang.TextResolver;
@@ -97,6 +100,8 @@ public class DialogHudState extends BaseAppState {
private NPC currentNpc;
private MainCharacter mainChar;
private Runnable onClose;
private Runnable onOptionsShown;
private boolean optionsShownFired;
/** Alle Seiten des aktuellen Textes. */
private List<List<String>> textPages = new ArrayList<>();
@@ -151,12 +156,15 @@ public class DialogHudState extends BaseAppState {
*
* @param npc der angesprochene NPC
* @param mc der Hauptcharakter
* @param onOptionsShown wird einmalig aufgerufen wenn Optionen angezeigt werden (darf null sein)
* @param onCloseCallback wird aufgerufen wenn der Dialog endet
*/
public void startDialog(NPC npc, MainCharacter mc, Runnable onCloseCallback) {
public void startDialog(NPC npc, MainCharacter mc, Runnable onOptionsShown, Runnable onCloseCallback) {
this.currentNpc = npc;
this.mainChar = mc;
this.onClose = onCloseCallback;
this.onOptionsShown = onOptionsShown;
this.optionsShownFired = false;
buildPanel();
registerInput();
@@ -170,6 +178,7 @@ public class DialogHudState extends BaseAppState {
if (greetText != null && !greetText.isBlank()) {
showText(Phase.TEXT_NPC, resolveNpcName(npc), greetText, () -> {
if (available.isEmpty()) {
log.info("[DialogHud] NPC '{}' hat keine Optionen nach Begrüßung.", npc.getCharacterId());
closeDialog();
} else {
showOptions(available);
@@ -178,7 +187,7 @@ public class DialogHudState extends BaseAppState {
} else if (!available.isEmpty()) {
showOptions(available);
} else {
// Keine Nachricht, keine Optionen → sofort schließen
log.info("[DialogHud] NPC '{}' hat keine Optionen und keine Begrüßung Dialog übersprungen.", npc.getCharacterId());
closeDialog();
}
}
@@ -225,10 +234,18 @@ public class DialogHudState extends BaseAppState {
displayOptions.clear();
selectedOpt = 0;
if (!optionsShownFired && onOptionsShown != null) {
optionsShownFired = true;
onOptionsShown.run();
}
for (DialogOption opt : options) {
String label = TextResolver.get().resolveId(opt.getLabel() != null ? opt.getLabel() : "");
if (label.isBlank()) label = opt.getId() != null
String label = opt.getLabel() != null
? TextResolver.get().resolveId(opt.getLabel().id()) : "";
if (label.isBlank()) {
label = opt.getId() != null
? opt.getId().substring(0, Math.min(opt.getId().length(), 20)) : "?";
}
displayOptions.add(new DisplayOption(label, opt));
}
displayOptions.add(new DisplayOption(t(EXIT_KEY), null));
@@ -401,6 +418,30 @@ public class DialogHudState extends BaseAppState {
currentNpc.getCurrentOptions().addAll(opt.getNextOptions());
if (opt.isEnablesTrade()) currentNpc.setTrader(true);
if (mainChar != null) mainChar.handleDialogOption(opt);
// Trigger feuern
if (mainChar != null && opt.getOnChosenTriggers() != null) {
WorldNpcsState npcs = getStateManager().getState(WorldNpcsState.class);
for (Trigger t : opt.getOnChosenTriggers()) {
if (!t.isTriggarable(mainChar)) continue;
if (t instanceof NpcStatusTrigger n && npcs != null) {
npcs.applyNpcStatusTrigger(n);
} else if (t instanceof ChangeRoutineTrigger r && npcs != null) {
npcs.applyChangeRoutineTrigger(r);
} else {
t.trigger(mainChar);
}
}
}
// Dialog-Zustand im Spielstand sichern
String npcId = currentNpc.getCharacterId();
if (npcId != null) {
SaveGameState saveState = getStateManager().getState(SaveGameState.class);
if (saveState != null) {
saveState.reportDialogState(npcId, currentNpc.currentOptionIds());
}
}
}
// ── Dialog beenden ────────────────────────────────────────────────────────

View File

@@ -73,6 +73,17 @@ public class SaveGameState extends BaseAppState {
persist();
}
/** Gibt den gespeicherten Dialog-Zustand eines NPCs zurück, oder {@code null} wenn keiner vorhanden. */
public java.util.List<String> getDialogState(String npcId) {
return save.world.npcDialogState.get(npcId);
}
/** Speichert die aktuell verfügbaren Dialog-Optionen eines NPCs im Spielstand. */
public void reportDialogState(String npcId, java.util.List<String> activeOptionIds) {
save.world.npcDialogState.put(npcId, new java.util.ArrayList<>(activeOptionIds));
persist();
}
/** Meldet einen besiegten Gegner (für zukünftige Implementierung). */
public void reportEnemyDefeated(String enemyId) {
save.world.defeatedEnemies.add(enemyId);

View File

@@ -176,6 +176,7 @@ public class WorldItemsState extends BaseAppState {
}
inputManager.addMapping(INTERACT_ACTION,
new MouseButtonTrigger(MouseInput.BUTTON_LEFT),
new KeyTrigger(keyBindings.interact));
inputManager.addMapping(SECONDARY_ATTACK_ACTION,
new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));

View File

@@ -6,8 +6,12 @@ import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.input.controls.MouseButtonTrigger;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
@@ -16,9 +20,13 @@ import com.jme3.scene.shape.Box;
import de.blight.common.PlacedModel;
import de.blight.common.PlacedModelIO;
import de.blight.common.model.*;
import de.blight.common.model.trigger.ChangeRoutineTrigger;
import de.blight.common.model.trigger.NpcStatusTrigger;
import de.blight.game.animation.AnimationAction;
import de.blight.game.animation.AnimationLibrary;
import de.blight.game.config.KeyBindings;
import de.blight.game.control.PlayerInputControl;
import de.blight.game.control.ThirdPersonCamera;
import de.blight.game.state.TerrainChunkState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -56,6 +64,7 @@ public class WorldNpcsState extends BaseAppState {
private Node npcsRoot;
private DayNightState dayNight;
private TerrainChunkState terrainChunks;
private AnimationLibrary animLib;
// ── NPC-Daten ─────────────────────────────────────────────────────────────
@@ -71,7 +80,22 @@ public class WorldNpcsState extends BaseAppState {
/** Aktuell in der Welt gespawnte NPCs. */
private final List<SpawnedNpc> spawned = new ArrayList<>();
private record SpawnedNpc(NPC npc, Spatial visual, float worldX, float worldZ) {}
private static class SpawnedNpc {
final NPC npc;
final Spatial visual;
float worldX, worldZ;
AnimationAction currentAction = null;
SpawnedNpc(NPC npc, Spatial visual, float x, float z) {
this.npc = npc;
this.visual = visual;
this.worldX = x;
this.worldZ = z;
}
NPC npc() { return npc; }
Spatial visual() { return visual; }
}
// ── Periodischer Check ────────────────────────────────────────────────────
@@ -82,6 +106,27 @@ public class WorldNpcsState extends BaseAppState {
private int hoveredIdx = -1;
private boolean dialogActive = false;
private ThirdPersonCamera thirdPersonCam;
// ── NPC-Rotations-Animation ───────────────────────────────────────────────
private static final float ROT_DURATION = 0.5f;
private static final float REVERT_DURATION = 0.6f;
private static final float REVERT_DELAY_NO_OPTIONS = 3.0f;
private static final float REVERT_DELAY_AFTER_DIALOG = 0.3f;
/** NPC der gerade smooth rotiert wird. */
private SpawnedNpc rotNpc = null;
private Quaternion rotFrom = new Quaternion();
private Quaternion rotTo = new Quaternion();
private float rotElapsed = 0f;
/** NPC der nach einer Verzögerung zurück rotiert wird. */
private SpawnedNpc revertNpc = null;
private Quaternion revertFrom = new Quaternion();
private Quaternion revertTo = new Quaternion();
private float revertTimer = 0f;
private float revertDelay = 0f;
// ── Konstruktor ───────────────────────────────────────────────────────────
@@ -93,6 +138,10 @@ public class WorldNpcsState extends BaseAppState {
this.mainCharacter = mainCharacter;
}
public void setThirdPersonCamera(ThirdPersonCamera cam) {
this.thirdPersonCam = cam;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
@@ -114,13 +163,16 @@ public class WorldNpcsState extends BaseAppState {
protected void onEnable() {
dayNight = getStateManager().getState(DayNightState.class);
terrainChunks = getStateManager().getState(TerrainChunkState.class);
animLib = getStateManager().getState(AnimationLibrary.class);
loadAllNpcs();
buildInteractablePositions();
rootNode.attachChild(npcsRoot);
app.getInputManager().addMapping(INTERACT_ACTION, new KeyTrigger(keyBindings.interact));
app.getInputManager().addMapping(INTERACT_ACTION,
new MouseButtonTrigger(MouseInput.BUTTON_LEFT),
new KeyTrigger(keyBindings.interact));
app.getInputManager().addListener(interactListener, INTERACT_ACTION);
checkTimer = CHECK_INTERVAL; // sofortiger erster Check im nächsten Frame
@@ -160,6 +212,8 @@ public class WorldNpcsState extends BaseAppState {
if (!dialogActive) {
updateHover();
}
updateNpcRotation(tpf);
updateNpcRevert(tpf);
}
// ── Spawn-Logik ───────────────────────────────────────────────────────────
@@ -176,9 +230,12 @@ public class WorldNpcsState extends BaseAppState {
if (pos == null || dist > UNLOAD_RADIUS) {
s.visual().removeFromParent();
toRemove.add(s);
if (rotNpc == s) rotNpc = null;
if (revertNpc == s) revertNpc = null;
} else {
// Position aktualisieren wenn sich die Stunde geändert hat
s.visual().setLocalTranslation(pos);
playNpcAction(s, activityActionFor(s.npc(), currentHour));
}
}
spawned.removeAll(toRemove);
@@ -199,7 +256,9 @@ public class WorldNpcsState extends BaseAppState {
Spatial vis = buildVisual(npc);
vis.setLocalTranslation(pos);
npcsRoot.attachChild(vis);
spawned.add(new SpawnedNpc(npc, vis, pos.x, pos.z));
SpawnedNpc s = new SpawnedNpc(npc, vis, pos.x, pos.z);
spawned.add(s);
playNpcAction(s, activityActionFor(npc, currentHour));
log.debug("[WorldNpcs] NPC '{}' gespawnt bei ({}, {})", npc.getCharacterId(), pos.x, pos.z);
}
}
@@ -221,6 +280,7 @@ public class WorldNpcsState extends BaseAppState {
if (block == null || block.getActivity() == null) return null;
RoutineActivity act = block.getActivity();
if (act.getType() == null) return null;
return switch (act.getType()) {
case STAND, TALK -> worldPointToVec(act.getPosition());
case SIT -> {
@@ -292,11 +352,149 @@ public class WorldNpcsState extends BaseAppState {
if (dialog == null) return;
dialogActive = true;
playerInput.lockInPlace();
dialog.startDialog(entry.npc(), mainCharacter, () -> {
// Spieler und NPC drehen sich smooth zueinander
Vector3f playerPos = physicsChar.getPhysicsLocation();
Vector3f npcPos = entry.visual().getLocalTranslation().clone();
Vector3f dirToNpc = npcPos.subtract(playerPos);
dirToNpc.y = 0f;
if (dirToNpc.lengthSquared() > 0.001f) {
dirToNpc.normalizeLocal();
playerInput.lockInPlace();
playerInput.requestTurn(dirToNpc, 0.4f, null);
} else {
playerInput.lockInPlace();
}
Quaternion origRot = entry.visual().getLocalRotation().clone();
Quaternion facingRot = computeFacingQuat(npcPos, playerPos);
beginSmoothRotation(entry, origRot, facingRot);
// Kamera wird erst gesetzt wenn Optionen erscheinen
boolean[] optionsWereShown = {false};
Runnable onOptions = () -> {
optionsWereShown[0] = true;
enterDialogCamera(npcPos, playerPos);
};
dialog.startDialog(entry.npc(), mainCharacter, onOptions, () -> {
exitDialogCamera();
dialogActive = false;
playerInput.unlockFromPlace();
float delay = optionsWereShown[0] ? REVERT_DELAY_AFTER_DIALOG : REVERT_DELAY_NO_OPTIONS;
scheduleRevert(entry, facingRot, origRot, delay);
});
}
private Quaternion computeFacingQuat(Vector3f npcPos, Vector3f playerPos) {
float dx = playerPos.x - npcPos.x;
float dz = playerPos.z - npcPos.z;
float angle = FastMath.atan2(dx, dz);
return new Quaternion().fromAngleAxis(angle, Vector3f.UNIT_Y);
}
private void beginSmoothRotation(SpawnedNpc npc, Quaternion from, Quaternion to) {
if (revertNpc == npc) revertNpc = null;
rotNpc = npc;
rotFrom = from.clone();
rotTo = to.clone();
rotElapsed = 0f;
}
private void scheduleRevert(SpawnedNpc npc, Quaternion from, Quaternion to, float delay) {
revertNpc = npc;
revertFrom = from.clone();
revertTo = to.clone();
revertTimer = 0f;
revertDelay = delay;
}
private void updateNpcRotation(float tpf) {
if (rotNpc == null) return;
rotElapsed += tpf;
float p = FastMath.clamp(rotElapsed / ROT_DURATION, 0f, 1f);
Quaternion q = new Quaternion().slerp(rotFrom, rotTo, p);
rotNpc.visual().setLocalRotation(q);
if (p >= 1f) rotNpc = null;
}
private void updateNpcRevert(float tpf) {
if (revertNpc == null) return;
revertTimer += tpf;
if (revertTimer < revertDelay) return;
float p = FastMath.clamp((revertTimer - revertDelay) / REVERT_DURATION, 0f, 1f);
Quaternion q = new Quaternion().slerp(revertFrom, revertTo, p);
revertNpc.visual().setLocalRotation(q);
if (p >= 1f) revertNpc = null;
}
private void enterDialogCamera(Vector3f npcPos, Vector3f playerPos) {
// Mittelpunkt auf Hüfthöhe
Vector3f mid = npcPos.add(playerPos).multLocal(0.5f);
mid.y += 0.9f;
// Zwei mögliche Seiten (je 90° zur Blicklinie)
Vector3f axis = npcPos.subtract(playerPos).normalizeLocal();
Vector3f sideA = axis.cross(Vector3f.UNIT_Y).normalizeLocal();
Vector3f sideB = sideA.negate();
float camSide = 3.5f;
float camUp = 1.2f;
Vector3f posA = mid.add(sideA.mult(camSide)).add(0, camUp, 0);
Vector3f posB = mid.add(sideB.mult(camSide)).add(0, camUp, 0);
posA = avoidClipping(mid, posA);
posB = avoidClipping(mid, posB);
// Seite mit mehr Abstand ist weniger verdeckt
Vector3f camPos = posA.distance(mid) >= posB.distance(mid) ? posA : posB;
if (thirdPersonCam != null) {
thirdPersonCam.setPaused(true);
}
cam.setLocation(camPos);
cam.lookAt(mid, Vector3f.UNIT_Y);
}
private void exitDialogCamera() {
if (thirdPersonCam != null) {
thirdPersonCam.setPaused(false);
}
}
private Vector3f avoidClipping(Vector3f from, Vector3f to) {
Vector3f dir = to.subtract(from);
float maxDist = dir.length();
dir.normalizeLocal();
CollisionResults results = new CollisionResults();
rootNode.collideWith(new Ray(from, dir), results);
if (results.size() == 0) return to;
CollisionResult nearest = results.getClosestCollision();
if (nearest.getDistance() >= maxDist) return to;
float safeDist = Math.max(1.2f, nearest.getDistance() - 0.3f);
return from.add(dir.mult(safeDist));
}
// ── Trigger-Ausführung (aus DialogHudState) ───────────────────────────────
public void applyNpcStatusTrigger(NpcStatusTrigger t) {
allNpcs.stream()
.filter(n -> t.getNpcId().equals(n.getCharacterId()))
.findFirst()
.ifPresent(n -> {
n.setStatus(t.getTargetStatus());
log.info("[WorldNpcs] Trigger: NPC '{}' Status → {}", n.getCharacterId(), t.getTargetStatus());
});
}
public void applyChangeRoutineTrigger(ChangeRoutineTrigger t) {
allNpcs.stream()
.filter(n -> t.getNpcId().equals(n.getCharacterId()))
.findFirst()
.ifPresent(n -> {
n.setCurrentRoutine(t.getRoutineName());
log.info("[WorldNpcs] Trigger: NPC '{}' Routine → {}", n.getCharacterId(), t.getRoutineName());
});
}
@@ -317,14 +515,18 @@ public class WorldNpcsState extends BaseAppState {
private void loadAllNpcs() {
allNpcs.clear();
SaveGameState saveState = getStateManager().getState(SaveGameState.class);
try {
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
for (GameCharacter gc : CharacterIO.loadAll(charDir)) {
if (gc instanceof NPC npc) {
java.util.List<String> savedState = saveState != null
? saveState.getDialogState(npc.getCharacterId()) : null;
npc.initRuntimeTree(savedState);
allNpcs.add(npc);
}
}
log.info("[WorldNpcs] {} NPCs mit Routinen geladen.", allNpcs.size());
log.info("[WorldNpcs] {} NPCs geladen.", allNpcs.size());
} catch (Exception e) {
log.warn("[WorldNpcs] Fehler beim Laden der NPCs: {}", e.getMessage());
}
@@ -356,6 +558,37 @@ public class WorldNpcsState extends BaseAppState {
return new Vector3f(p.x, y, p.z);
}
private AnimationAction activityActionFor(NPC npc, int hour) {
NpcRoutine routine = npc.getActiveRoutine();
if (routine == null || routine.getBlocks() == null) return AnimationAction.IDLE;
for (RoutineBlock b : routine.getBlocks()) {
if (!b.covers(hour)) continue;
RoutineActivity act = b.getActivity();
if (act == null || act.getType() == null) return AnimationAction.IDLE;
return switch (act.getType()) {
case PATROL -> AnimationAction.WALK;
case SIT -> AnimationAction.SITTING;
case SLEEP -> AnimationAction.LYING;
case STAND, TALK, WORK -> AnimationAction.IDLE;
};
}
return AnimationAction.IDLE;
}
private void playNpcAction(SpawnedNpc s, AnimationAction action) {
if (action == s.currentAction || animLib == null) return;
String setName = s.npc().getAnimSetPath();
if (setName == null || setName.isBlank()) return;
String clipName = AnimationLibrary.getClipForAction(AnimationLibrary.findAssetRoot(), setName, action);
if (clipName == null && action != AnimationAction.IDLE) {
clipName = AnimationLibrary.getClipForAction(AnimationLibrary.findAssetRoot(), setName, AnimationAction.IDLE);
}
if (clipName == null) return;
if (animLib.playOn(clipName, s.visual())) {
s.currentAction = action;
}
}
private static float dist2d(Vector3f a, Vector3f b) {
float dx = a.x - b.x;
float dz = a.z - b.z;
@@ -368,6 +601,10 @@ public class WorldNpcsState extends BaseAppState {
try {
Spatial model = assets.loadModel(modelPath);
model.setName("npc_" + npc.getCharacterId());
de.blight.game.scene.WorldScene.applyFullAmbient(model);
if (animLib != null) {
animLib.applyAllTo(model);
}
return model;
} catch (Exception e) {
log.warn("[WorldNpcs] Modell '{}' nicht ladbar Platzhalter.", modelPath);

View File

@@ -79,3 +79,81 @@ key.walk=Gehen
key.interact=Interagieren
key.inventory=Inventar
key.quicksave=Schnellspeichern
# ── Silas ────────────────────────────────────────────────────────────────────
silas.name=Silas
dialog.silas.begruessung.textmainchar=Hi! Ich suche nur einen Weg weg von diesem Strand.
dialog.silas.begruessung.textnpc=Woher kommst du?
dialog.silas.antwort_sklave.textmainchar=Ich war Sklave des Protektorats unser Schiff ist an den steilen Klippen gestrandet.
dialog.silas.antwort_sklave.textnpc=Ein Sklave des Protektorats? Das Meer hat heute wohl einen Sinn für Ironie. Spült dich direkt vor meine Haustür. Immerhin bist du ehrlich… und sind wir nicht alle Sklaven des Protektorats? Ich jage ihr Wild, du hast ihre Steine geschleppt. Am Ende des Tages sitzen wir beide unter demselben Stiefel. Nur dass meiner schon etwas länger drückt.
dialog.silas.antwort_gedaechtnislos.textmainchar=Ich habe mein Gedächtnis verloren ich bin kürzlich an dem Strand dort unten aufgewacht.
dialog.silas.antwort_gedaechtnislos.textnpc=Du siehst aus wie ein Sklave des Protektorats! Das Meer hat heute wohl einen Sinn für Ironie. Spült dich direkt vor meine Haustür. Aber sind wir nicht alle Sklaven des Protektorats.
dialog.silas.antwort_gesandter.textmainchar=Ich bin Gesandter des Königs unser Schiff ist gekentert und ich bin der einzige Überlebende.
dialog.silas.antwort_gesandter.textnpc=Ein Gesandter des Königs? Du siehst eher aus wie ein Sklave des Protektorats! Das Meer hat heute wohl einen Sinn für Ironie. Spült dich direkt vor meine Haustür. Aber sind wir nicht alle Sklaven des Protektorats.
dialog.silas.wer_bist_du.textmainchar=Wer bist du?
dialog.silas.wer_bist_du.textnpc=Silas. Früher haben wir diesen Pass bewacht, damit die Guanchen und wir in Frieden tauschen konnten. Heute bin ich nur noch ein Geist, der hier oben wartet, dass die Fäule auch den letzten Rest der Welt frisst.
dialog.silas.wer_seid_ihr.textmainchar=Und wer seid „Ihr“?
dialog.silas.wer_seid_ihr.textnpc=Wer wir sind? Eine aussterbende Art, Junge. Ich bin einer der Jäger, die diesen Pass schon bewacht haben, als das Protektorat noch nichts weiter war als ein ferner Albtraum in den Köpfen gieriger Könige. Früher war das hier ein Geben und Nehmen: Wir haben das Wild reguliert, mit den Guanchen Fleisch gegen Kräuter getauscht und die Pfade sauber gehalten.\nDann kamen die Eroberer mit ihren Vermessungsgeräten und ihren Gesetzen. Wollten mir vorschreiben, wann ich einen Hirsch erlegen darf und wie viel Steuer ich für jedes Stück Fell an Puerto Valor abtreten soll. Die meisten meiner Brüder sind geflohen auf die Nachbarinseln oder zu den Piraten, nur um dem Joch zu entkommen. Aber ich? Ich bin zu alt, um vor ein paar aufgeblasenen Gockeln in Blechrüstungen wegzulaufen. Also sitze ich hier oben, lebe von dem, was der Wald noch hergibt, und sehe zu, wie die Fäule alles verschlingt, was mir mal etwas bedeutet hat.\nFür das Protektorat bin ich ein Wilderer. Für die Piraten ein Narr. Und für die Guanchen… nun, vielleicht der letzte Außenweltler, dem sie nicht sofort einen Speer in die Kehle jagen. Such dir aus, was dir am besten gefällt.
dialog.silas.was_leuchten.textmainchar=Was ist das für ein Zeug am Strand? Das Leuchten?
dialog.silas.was_leuchten.textnpc=Das ist der Tod, Junge. Die Blight. Die arroganten Gecken in Puerto Valor haben den Vulkan angebohrt und jetzt blutet die Insel Gift. Wenn du klug bist, drehst du um und läufst zurück ins Wasser.\nNicht alles auf dieser Insel will dich korrumpieren, Junge. Manche wollen dich einfach nur fressen. Ein wilder Hund hat keine Kristalle, die ihn schützen, aber er hat Hunger und Zähne wie Dolche. Behalt ihn im Auge, weich seinen Sprüngen aus und zeig ihm, wer der neue Herr im Dorf ist.
dialog.silas.ausruestung.textmainchar=Ich brauche Ausrüstung. Waffen. Und Informationen.
dialog.silas.ausruestung.textnpc=Ausrüstung? Du siehst aus, als hätte dich das Meer einmal durch den Fleischwolf gedreht. Pass auf: Geh zurück zum Strand, dort wo du angespült wurdest. Bei diesem Sturm ist sicher nicht nur Sklavenfleisch am Riff hängen geblieben. Such im Treibgut. Mit etwas Glück findest du dort mehr als nur nasse Planken.
dialog.silas.gibts_was_rum.textmainchar=Und was ist mit dir? Hast du nichts übrig?
dialog.silas.gibts_was_rum.textnpc=Ich bin kein Wohlfahrtsverband, Junge. Aber … du bist mir sympathisch, einen Schluck Insel-Rum kriegst du. Das Zeug brennt die Korruption aus der Kehle, zumindest für fünf Minuten. Trink.
dialog.silas.tipp_rum.textmainchar=Und was ist mit dir? Hast du nichts übrig?
dialog.silas.tipp_rum.textnpc=Ich bin kein Wohlfahrtsverband, Junge. Aber … einen Tipp gibt es umsonst: Insel-Rum ist das Beste, wenn du dich mal zu viel mit den wilden Tieren geprügelt hast.
dialog.silas.welchen_weg.textmainchar=Welchen Weg soll ich nehmen, um zu den Lagern zu kommen?
dialog.silas.welchen_weg.textnpc=Egal welchen Weg du nimmst geh nicht so, wie du jetzt aussiehst. In diesen Lumpen erkennt dich jeder sofort als entflohenen Sklaven. Das Protektorat steckt dich direkt wieder in Ketten, und die Piraten… nun, die füttern die Haie mit Typen, die nichts wert sind.\nHör zu: Wenn du den Hang Richtung Strand wieder ein Stück runtergehst, zweigt ein Pfad zu einem alten Fischerdorf ab. Die Leute dort sind schon lange weg, als die Fäule kam, aber in den Hütten findest du vielleicht ein paar alte Lumpen, die weniger nach 'Gefängnis' stinken. Es ist besser als nichts.
dialog.silas.abschluss_phase1.textmainchar=Alles klar. Ich mache mich auf den Weg.
dialog.silas.abschluss_phase1.textnpc=Also, hier ist der Plan: Such am Strand nach Brauchbarem, schau im Fischerdorf nach Kleidung und komm dann wieder zu mir. Wenn du dann nicht mehr aussiehst wie eine wandelnde Leiche, unterhalten wir uns darüber, wie du an den Monstern am Pass vorbeikommst. Jetzt beweg dich.
dialog.silas.rueckkehr_beobachtet.textmainchar=(Ich kehre zu Silas zurück)
dialog.silas.rueckkehr_beobachtet.textnpc=Sieh mal einer an. Das Meer hat dich ausgespückt und die Insel hat dich nicht wieder verschlungen. Ich hätte meine letzte Flasche Rum darauf verwettet, dass dich die Klippenbeßer am Hang in handliche Stücke zerlegen, noch bevor du das Dorf erreichst.
dialog.silas.ich_bin_zaeh.textmainchar=Ich bin zäher, als ich aussehe.
dialog.silas.ich_bin_zaeh.textnpc=Das scheinst du wohl zu sein. Zumindest stinkst du jetzt weniger nach Sklavenpferch. Damit kommst du vielleicht an den Wachen der Festung vorbei, ohne dass sie direkt die Musketen laden.
dialog.silas.weg_ins_innere.textmainchar=Du hast gesagt, wir unterhalten uns über den Weg ins Landesinnere, wenn ich bereit bin.
dialog.silas.weg_ins_innere.textnpc=Richtig. Hör zu, Junge: Die Pfade hier draußen sind tückisch geworden. Mein wichtigster Rat? Bleib auf den Wegen. Die Blight hat die Kreaturen in den Wäldern wahnsinnig gemacht. Abseits der Pfade lauern Dinge, für die dein kleiner Roststängel da am Gürtel nur ein Zahnstocher ist. Geh nur in die tiefe Wildnis, wenn du Rüstung am Leib und Stahl in der Hand hast, der diesen Namen auch verdient.
dialog.silas.was_ueber_insel.textmainchar=Diese Insel… sie wirkt so fremdartig. Was kannst du mir über diesen Ort sagen?
dialog.silas.was_ueber_insel.textnpc=Hör mir gut zu, Junge. Aeterna ist wie eine gespaltene Münze, und der große Vulkan in der Mitte der Monte Fuego ist der Amboss, auf dem alles geschmiedet wird. Der Westen ist ein einziges grünes Grab, erstickt von uralten Wäldern. Der Osten dagegen, hinter dem Berg, ist karg und trocken wie ein alter Knochen.
dialog.silas.info_osten.textmainchar=Wer herrscht im Osten der Insel?
dialog.silas.info_osten.textnpc=Die Protektoren. Sie haben sich im Südosten in Puerto Valor breitgemacht. Sie haben Mauern, Ordnung und das beste Equipment aber sie haben auch einen Stock im Hintern und verlangen Gehorsam. Wenn du dort ihren Wein trinken willst, den sie in den Sonnental-Weinbergen anbauen, musst du nach ihrer Pfeife tanzen.
dialog.silas.info_westen.textmainchar=Und was ist mit dem Westen?
dialog.silas.info_westen.textnpc=Dort regiert das Chaos oder die Natur. Im Südwesten liegt Shipwreck Bay, das Nest der Freibeuter. Da stellt keiner Fragen über deine Vergangenheit, solange du zupacken kannst. Aber pass auf deinen Geldbeutel auf und auf deinen Rücken. Tief im Nordwesten, im Schatten der Nebelgipfel, hausen die Guanchen in ihrer Siedlung Taganana. Du musst sie erst mal finden, aber wenn du Magie suchst, sind sie deine einzige Chance.
dialog.silas.info_dorf.textmainchar=Was hat es mit diesem Dorf hier auf sich?
dialog.silas.info_dorf.textnpc=Das hier im Norden ist San Pedro. Früher ein reiches Fischerdorf, jetzt nur noch Schutt und Gestrüpp. Ein guter Ort, um den Kampf gegen Klippenbeßer zu üben. Ein wilder Hund hat keine Kristalle, die ihn schützen, aber er hat Hunger und Zähne wie Dolche. Weich ihren Sprüngen aus, dann gehört das Fleisch dir.
dialog.silas.info_pass.textmainchar=Und der Weg durch den Pass?
dialog.silas.info_pass.textnpc=Hinter meiner Hütte geht es steil bergauf. Oben am Kamm haben sich ein paar Corrupted eingenistet. Sie sind langsam, aber wenn sie dich einkesseln, war es das. Nutze den Platz, den du hast, und schlag erst zu, wenn du eine Lücke siehst. Sobald du an ihnen vorbei bist, liegt Aeterna vor dir.
dialog.silas.ich_weiss_genug.textmainchar=Ich glaub ich weiß alles, was ich brauche…
dialog.silas.ich_weiss_genug.textnpc=Du willst also blind ins Verderben rennen? Hier, nimm das, damit deine Leiche wenigstens in der Nähe eines Pfades gefunden wird.
dialog.silas.karte_zeichnen.textmainchar=(Silas zeichnet die Karte)
dialog.silas.karte_zeichnen.textnpc=Du stellst verdammt viele Fragen für jemanden, der kaum eine Waffe halten kann. Aber Neugier hält einen hier oben am Leben.\nKomm näher. Ich zeichne dir das Ganze auf: San Pedro hier oben, Monte Fuego in der Mitte, Puerto Valor im trockenen Südosten und Shipwreck Bay im Wald des Südwestens. Der Pfad führt direkt hinauf zum Schlund… aber sag nicht, ich hätte dich nicht gewarnt. Hier… nimm die Karte.
dialog.silas.dankeschoen.textmainchar=Danke, Silas. Ich werde vorsichtig sein.
dialog.silas.dankeschoen.textnpc=Das hoffe ich. Und hey… wenn du mal wieder in der Nähe bist und noch alle Gliedmaßen hast, schau auf einen Rum vorbei. Erzähl mir, was in der Welt da draußen vor sich geht. Ich will wissen, wer sich gerade gegenseitig an die Gurgel geht.
dialog.silas.erstaunt_ruhe.textmainchar=Ich dachte, du willst hier oben deine Ruhe haben und hast die Welt da draußen längst abgeschrieben?
dialog.silas.erstaunt_ruhe.textnpc=Vielleicht wirke ich so. Und vielleicht habe ich den Glauben an die Menschen verloren… aber ich habe diese Insel noch nicht ganz aufgegeben. Und ich hab so ein Gefühl bei dir, Junge. Nenn es Instinkt oder den Rum, aber ich glaube, du wirst auf Aeterna noch für ordentlich Wirbel sorgen. Ich will nur sichergehen, dass ich in der ersten Reihe sitze, wenn der Vorhang fällt.
dialog.silas.abschluss_letzte_worte.textmainchar=(Der Held macht sich auf den Weg)
dialog.silas.abschluss_letzte_worte.textnpc=Jetzt geh schon. Bevor ich es mir anders überlege und dich doch noch für die Steuerjagd anmelde.

View File

@@ -23,6 +23,9 @@ quest.intro.title=Washed Ashore
quest.intro.description=You survived the shipwreck. Find shelter before nightfall.
quest.intro.success=You have found shelter. Rest now — tomorrow brings new dangers.
# ── NPCs ─────────────────────────────────────────────────────────────────────
silas.name=Silas
# ── Dialog ───────────────────────────────────────────────────────────────────
dialog.fisherman.greet.hero=Hello? Is someone there?
dialog.fisherman.greet.npc=Over here! Thank the gods you are alive.

View File

@@ -0,0 +1,96 @@
blutagave.description
blutagave.name
dialog.silas.0114af85-986f-4923-b77e-88529b868c0f.description
dialog.silas.0114af85-986f-4923-b77e-88529b868c0f.textmainchar
dialog.silas.0114af85-986f-4923-b77e-88529b868c0f.textnpc
dialog.silas.5a9821c8-f0f3-49c2-a570-9b68e819b46a.description
dialog.silas.5a9821c8-f0f3-49c2-a570-9b68e819b46a.textmainchar
dialog.silas.5a9821c8-f0f3-49c2-a570-9b68e819b46a.textnpc
dialog.silas.abschluss_letzte_worte.description
dialog.silas.abschluss_letzte_worte.textmainchar
dialog.silas.abschluss_letzte_worte.textnpc
dialog.silas.abschluss_phase1.description
dialog.silas.abschluss_phase1.textmainchar
dialog.silas.abschluss_phase1.textnpc
dialog.silas.antwort_gedaechtnislos.description
dialog.silas.antwort_gedaechtnislos.textmainchar
dialog.silas.antwort_gedaechtnislos.textnpc
dialog.silas.antwort_gesandter.description
dialog.silas.antwort_gesandter.textmainchar
dialog.silas.antwort_gesandter.textnpc
dialog.silas.antwort_sklave.description
dialog.silas.antwort_sklave.textmainchar
dialog.silas.antwort_sklave.textnpc
dialog.silas.ausruestung.description
dialog.silas.ausruestung.textmainchar
dialog.silas.ausruestung.textnpc
dialog.silas.begruessung.description
dialog.silas.begruessung.textmainchar
dialog.silas.begruessung.textnpc
dialog.silas.dankeschoen.description
dialog.silas.dankeschoen.textmainchar
dialog.silas.dankeschoen.textnpc
dialog.silas.erstaunt_ruhe.description
dialog.silas.erstaunt_ruhe.textmainchar
dialog.silas.erstaunt_ruhe.textnpc
dialog.silas.f78aa61f-8532-4b64-8f0e-c90ff0e92760.description
dialog.silas.f78aa61f-8532-4b64-8f0e-c90ff0e92760.textmainchar
dialog.silas.f78aa61f-8532-4b64-8f0e-c90ff0e92760.textnpc
dialog.silas.gibts_was_rum.description
dialog.silas.gibts_was_rum.textmainchar
dialog.silas.gibts_was_rum.textnpc
dialog.silas.hallosilas.description
dialog.silas.hallosilas.textmainchar
dialog.silas.hallosilas.textnpc
dialog.silas.hellosilas.description
dialog.silas.hellosilas.textmainchar
dialog.silas.hellosilas.textnpc
dialog.silas.ich_bin_zaeh.description
dialog.silas.ich_bin_zaeh.textmainchar
dialog.silas.ich_bin_zaeh.textnpc
dialog.silas.ich_weiss_genug.description
dialog.silas.ich_weiss_genug.textmainchar
dialog.silas.ich_weiss_genug.textnpc
dialog.silas.info_dorf.description
dialog.silas.info_dorf.textmainchar
dialog.silas.info_dorf.textnpc
dialog.silas.info_osten.description
dialog.silas.info_osten.textmainchar
dialog.silas.info_osten.textnpc
dialog.silas.info_pass.description
dialog.silas.info_pass.textmainchar
dialog.silas.info_pass.textnpc
dialog.silas.info_westen.description
dialog.silas.info_westen.textmainchar
dialog.silas.info_westen.textnpc
dialog.silas.karte_zeichnen.description
dialog.silas.karte_zeichnen.textmainchar
dialog.silas.karte_zeichnen.textnpc
dialog.silas.rueckkehr_beobachtet.description
dialog.silas.rueckkehr_beobachtet.textmainchar
dialog.silas.rueckkehr_beobachtet.textnpc
dialog.silas.tipp_rum.description
dialog.silas.tipp_rum.textmainchar
dialog.silas.tipp_rum.textnpc
dialog.silas.was_leuchten.description
dialog.silas.was_leuchten.textmainchar
dialog.silas.was_leuchten.textnpc
dialog.silas.was_ueber_insel.description
dialog.silas.was_ueber_insel.textmainchar
dialog.silas.was_ueber_insel.textnpc
dialog.silas.weg_ins_innere.description
dialog.silas.weg_ins_innere.textmainchar
dialog.silas.weg_ins_innere.textnpc
dialog.silas.welchen_weg.description
dialog.silas.welchen_weg.textmainchar
dialog.silas.welchen_weg.textnpc
dialog.silas.wer_bist_du.description
dialog.silas.wer_bist_du.textmainchar
dialog.silas.wer_bist_du.textnpc
dialog.silas.wer_seid_ihr.description
dialog.silas.wer_seid_ihr.textmainchar
dialog.silas.wer_seid_ihr.textnpc
erzmoss.description
erzmoss.name
hero.name
silas.name

View File

@@ -1,5 +1,5 @@
# 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/trees/pine/medium/pine_medium_20260615_221703.j3o 96.87341 6.11813 51.96483 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/trees/pine/medium/pine_medium_20260615_221707.j3o 110.91007 4.33700 50.56980 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